Files
yunke/yunkes/src/views/StaffList.vue
T
浅念 62dc663294 feat: employee page with channel tabs (WeChat/WhatsApp account lists)
Replace the sparse staff-record table on /staff with a channel-tabbed
employee account view mirroring the 云客 backend's 员工微信列表 +
WhatsApp 员工列表. Each tab loads /api/accounts?channel=<channel> with
its own pagination state; rows open the existing account all-fields
drawer (reused from AccountList via accountFields.ts).

WeChat tab columns: 头像/微信昵称/微信号/手机号/员工姓名/好友数/群数/
发送/接收/数据库大小/在职/在线状态/最后更新. WhatsApp tab columns:
头像(el-avatar initials)/昵称/员工姓名/手机/销售手机/好友数/群数/
联系人数/新增好友/在职/最后更新. Reads raw_json via the structured-first
readField helper; tolerates boolean and 1/0 forms of isOnJob/onlineStatus.

Also fix the avatar field key in accountFields.ts (headImageUrl, with
headImgUrl as a legacy alias) so the WeChat drawer avatar renders — the
upstream wxUser record uses headImageUrl.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-05 14:13:21 +08:00

622 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { getAccounts } from '@/api'
import type { Account, Channel } from '@/api/types'
import { channelLabel, formatTime } from '@/utils/format'
import { buildAccountSections, formatBytes, readField } from '@/utils/accountFields'
/**
* 员工 list — channel-tabbed account view mirroring the 云客 backend's
* 员工微信列表 (WeChat staff accounts) and WhatsApp 员工列表. The earlier sparse
* staff-record table is replaced by the underlying messaging accounts, which
* carry the verbatim upstream record on `raw_json` (nickName/alias/userName/
* sendNum/receiveNum/dbSize/onlineStatus/lastUpdate/talkerCount/...).
*
* Each tab loads `/api/accounts?channel=<channel>` independently with its own
* pagination state. Row click opens the all-fields drawer reused from
* AccountList.vue (see accountFields.ts).
*/
type TabKey = Channel
interface TabState {
items: Account[]
total: number
page: number
pageSize: number
loading: boolean
errorMsg: string | null
/** Has this tab been loaded at least once? Tabs lazy-load on first activation. */
loaded: boolean
}
function freshTabState(): TabState {
return {
items: [],
total: 0,
page: 1,
pageSize: 20,
loading: false,
errorMsg: null,
loaded: false,
}
}
const activeTab = ref<TabKey>('wechat')
const tabState = reactive<Record<TabKey, TabState>>({
wechat: freshTabState(),
whatsapp: freshTabState(),
})
// All-fields drawer state (shared across tabs).
const drawerOpen = ref(false)
const drawerAccount = ref<Account | null>(null)
// ---------------------------------------------------------------------------
// Cell helpers (structured-first, raw_json fallback via accountFields.readField)
// ---------------------------------------------------------------------------
function cell(row: Account, camelKey: string): unknown {
return readField(row, camelKey)
}
function cellText(row: Account, camelKey: string): string {
const v = cell(row, camelKey)
return v === null || v === undefined || v === '' ? '--' : String(v)
}
function cellNumber(row: Account, camelKey: string): number | undefined {
const v = cell(row, camelKey)
return typeof v === 'number' && Number.isFinite(v) ? v : undefined
}
function cellTime(row: Account, camelKey: string): number | undefined {
return cellNumber(row, camelKey)
}
function imageCell(row: Account, camelKey: string): string | undefined {
const v = cell(row, camelKey)
return typeof v === 'string' && v !== '' ? v : undefined
}
/** First character of userName/nickName/account_id for avatar fallbacks. */
function initials(row: Account): string {
const name =
cell(row, 'userName') ?? cell(row, 'nickName') ?? row.account_id ?? '?'
const s = String(name).trim()
return s ? s.slice(0, 1).toUpperCase() : '?'
}
function avatarUrl(row: Account): string | undefined {
// WeChat records key the avatar as `headImageUrl`; tolerate the legacy
// `headImg` / `headImgUrl` shapes too so the column degrades gracefully.
return (
imageCell(row, 'headImageUrl') ??
imageCell(row, 'headImgUrl') ??
imageCell(row, 'headImg')
)
}
function dbSizeText(row: Account): string {
const v = cellNumber(row, 'dbSize')
return v === undefined ? '--' : formatBytes(v)
}
// 在职: structured column is boolean; raw_json may carry 1/0. Handle both so
// the tag is correct regardless of which source populated the row.
function onJobTagType(v: unknown): 'success' | 'info' | '' {
if (v === true || v === 1 || v === '1') return 'success'
if (v === false || v === 0 || v === '0') return 'info'
return ''
}
function onJobLabel(v: unknown): string {
if (v === true || v === 1 || v === '1') return '在职'
if (v === false || v === 0 || v === '0') return '离职'
return '--'
}
// 在线状态: upstream sends "online"/"offline" (sometimes 1/0). Render as a tag
// when recognized; pass through other strings so info isn't hidden.
function onlineTagType(v: unknown): 'success' | 'info' | '' {
if (v === 'online' || v === 1 || v === '1') return 'success'
if (v === 'offline' || v === 0 || v === '0') return 'info'
return ''
}
function onlineLabel(v: unknown): string {
if (v === 'online' || v === 1 || v === '1') return '在线'
if (v === 'offline' || v === 0 || v === '0') return '离线'
if (v === null || v === undefined || v === '') return '--'
return String(v)
}
function channelTagType(c: Channel): 'success' | 'warning' {
return c === 'wechat' ? 'success' : 'warning'
}
// ---------------------------------------------------------------------------
// Per-tab loading
// ---------------------------------------------------------------------------
async function loadTab(ch: TabKey): Promise<void> {
const st = tabState[ch]
st.loading = true
st.errorMsg = null
try {
const res = await getAccounts({
channel: ch,
page: st.page,
page_size: st.pageSize,
})
st.items = res.items
st.total = res.total
st.loaded = true
} catch (e) {
st.errorMsg = (e as Error).message
ElMessage.error(`员工列表加载失败:${(e as Error).message}`)
} finally {
st.loading = false
}
}
function handlePageChange(ch: TabKey, p: number): void {
tabState[ch].page = p
loadTab(ch)
}
function handlePageSizeChange(ch: TabKey, s: number): void {
tabState[ch].pageSize = s
tabState[ch].page = 1
loadTab(ch)
}
// Per-tab wrappers so template bindings carry proper types (vue-tsc strict
// mode does not infer inline-arrow params in @event handlers).
function onWechatPageChange(p: number): void {
handlePageChange('wechat', p)
}
function onWechatPageSizeChange(s: number): void {
handlePageSizeChange('wechat', s)
}
function onWhatsappPageChange(p: number): void {
handlePageChange('whatsapp', p)
}
function onWhatsappPageSizeChange(s: number): void {
handlePageSizeChange('whatsapp', s)
}
function onTabChange(name: string | number): void {
const ch = String(name) as TabKey
if (!tabState[ch].loaded) loadTab(ch)
}
// ---------------------------------------------------------------------------
// Drawer
// ---------------------------------------------------------------------------
function openDrawer(row: Account): void {
drawerAccount.value = row
drawerOpen.value = true
}
const drawerSections = computed(() =>
drawerAccount.value ? buildAccountSections(drawerAccount.value) : [],
)
const drawerAvatarSrc = computed<string | undefined>(() => {
const a = drawerAccount.value
return a ? avatarUrl(a) : undefined
})
onMounted(() => {
// The visible tab on first paint is 微信员工 — load it. The other tab
// lazy-loads on activation.
loadTab('wechat')
})
</script>
<template>
<div class="yk-staff">
<div class="yk-staff__header">
<div>
<h2 style="margin: 0 0 4px; font-size: 16px">员工列表</h2>
<span class="yk-staff__sub">
按渠道展示员工账号(微信号 / WhatsApp 号),点击行查看全部字段。
</span>
</div>
</div>
<el-tabs v-model="activeTab" class="yk-staff__tabs" @tab-change="onTabChange">
<!-- ============================================================
微信员工 (WeChat staff accounts)
============================================================ -->
<el-tab-pane label="微信员工" name="wechat">
<el-alert
v-if="tabState.wechat.errorMsg"
:title="tabState.wechat.errorMsg"
type="error"
show-icon
:closable="false"
style="margin-bottom: 12px"
/>
<el-table
v-loading="tabState.wechat.loading"
:data="tabState.wechat.items"
stripe
border
row-key="account_id"
style="width: 100%"
:row-class-name="() => 'yk-clickable-row'"
empty-text="暂无微信员工账号"
@row-click="openDrawer"
>
<el-table-column label="头像" width="64" align="center">
<template #default="{ row }">
<template v-if="avatarUrl(row)">
<el-image
:src="avatarUrl(row)"
fit="cover"
class="yk-avatar-round"
>
<template #error>
<div class="yk-avatar-fallback">{{ initials(row) }}</div>
</template>
</el-image>
</template>
<el-avatar v-else :size="36" shape="circle">
{{ initials(row) }}
</el-avatar>
</template>
</el-table-column>
<el-table-column label="微信昵称" min-width="120">
<template #default="{ row }">{{ cellText(row, 'nickName') }}</template>
</el-table-column>
<el-table-column label="微信号" min-width="120">
<template #default="{ row }">{{ cellText(row, 'alias') }}</template>
</el-table-column>
<el-table-column label="手机号" min-width="120">
<template #default="{ row }">{{ cellText(row, 'phone') }}</template>
</el-table-column>
<el-table-column label="员工姓名" min-width="110">
<template #default="{ row }">{{ cellText(row, 'userName') }}</template>
</el-table-column>
<el-table-column label="好友数" width="90" align="right">
<template #default="{ row }">
{{ cellNumber(row, 'contactsNum') ?? 0 }}
</template>
</el-table-column>
<el-table-column label="群数" width="80" align="right">
<template #default="{ row }">
{{ cellNumber(row, 'groupNum') ?? 0 }}
</template>
</el-table-column>
<el-table-column label="发送" width="90" align="right">
<template #default="{ row }">{{ cellText(row, 'sendNum') }}</template>
</el-table-column>
<el-table-column label="接收" width="90" align="right">
<template #default="{ row }">{{ cellText(row, 'receiveNum') }}</template>
</el-table-column>
<el-table-column label="数据库大小" width="110" align="right">
<template #default="{ row }">{{ dbSizeText(row) }}</template>
</el-table-column>
<el-table-column label="在职" width="90" align="center">
<template #default="{ row }">
<el-tag
:type="onJobTagType(cell(row, 'isOnJob'))"
size="small"
effect="plain"
>
{{ onJobLabel(cell(row, 'isOnJob')) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="在线状态" width="100" align="center">
<template #default="{ row }">
<el-tag
v-if="cell(row, 'onlineStatus') !== undefined && cell(row, 'onlineStatus') !== null && cell(row, 'onlineStatus') !== ''"
:type="onlineTagType(cell(row, 'onlineStatus'))"
size="small"
effect="plain"
>
{{ onlineLabel(cell(row, 'onlineStatus')) }}
</el-tag>
<span v-else style="color: #c0c4cc">--</span>
</template>
</el-table-column>
<el-table-column label="最后更新" width="160">
<template #default="{ row }">
{{ formatTime(cellTime(row, 'lastUpdate')) }}
</template>
</el-table-column>
</el-table>
<div class="yk-staff__pager">
<el-pagination
:current-page="tabState.wechat.page"
:page-size="tabState.wechat.pageSize"
:page-sizes="[20, 50, 100]"
:total="tabState.wechat.total"
layout="total, sizes, prev, pager, next, jumper"
background
@current-change="onWechatPageChange"
@size-change="onWechatPageSizeChange"
/>
</div>
</el-tab-pane>
<!-- ============================================================
WhatsApp员工 (WhatsApp staff accounts)
============================================================ -->
<el-tab-pane label="WhatsApp员工" name="whatsapp">
<el-alert
v-if="tabState.whatsapp.errorMsg"
:title="tabState.whatsapp.errorMsg"
type="error"
show-icon
:closable="false"
style="margin-bottom: 12px"
/>
<el-table
v-loading="tabState.whatsapp.loading"
:data="tabState.whatsapp.items"
stripe
border
row-key="account_id"
style="width: 100%"
:row-class-name="() => 'yk-clickable-row'"
empty-text="暂无 WhatsApp 员工账号"
@row-click="openDrawer"
>
<el-table-column label="头像" width="64" align="center">
<template #default="{ row }">
<el-avatar :size="36" shape="circle">
{{ initials(row) }}
</el-avatar>
</template>
</el-table-column>
<el-table-column label="昵称" min-width="120">
<template #default="{ row }">{{ cellText(row, 'nickName') }}</template>
</el-table-column>
<el-table-column label="员工姓名" min-width="110">
<template #default="{ row }">{{ cellText(row, 'userName') }}</template>
</el-table-column>
<el-table-column label="手机" min-width="120">
<template #default="{ row }">{{ cellText(row, 'phone') }}</template>
</el-table-column>
<el-table-column label="销售手机" min-width="120">
<template #default="{ row }">{{ cellText(row, 'salePhone') }}</template>
</el-table-column>
<el-table-column label="好友数" width="90" align="right">
<template #default="{ row }">
{{ cellNumber(row, 'contactsNum') ?? 0 }}
</template>
</el-table-column>
<el-table-column label="群数" width="80" align="right">
<template #default="{ row }">
{{ cellNumber(row, 'groupNum') ?? 0 }}
</template>
</el-table-column>
<el-table-column label="联系人数" width="100" align="right">
<template #default="{ row }">{{ cellText(row, 'talkerCount') }}</template>
</el-table-column>
<el-table-column label="新增好友" width="100" align="right">
<template #default="{ row }">{{ cellText(row, 'friendAddNum') }}</template>
</el-table-column>
<el-table-column label="在职" width="90" align="center">
<template #default="{ row }">
<el-tag
:type="onJobTagType(cell(row, 'isOnJob'))"
size="small"
effect="plain"
>
{{ onJobLabel(cell(row, 'isOnJob')) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="最后更新" width="160">
<template #default="{ row }">
{{ formatTime(cellTime(row, 'lastUpdate')) }}
</template>
</el-table-column>
</el-table>
<div class="yk-staff__pager">
<el-pagination
:current-page="tabState.whatsapp.page"
:page-size="tabState.whatsapp.pageSize"
:page-sizes="[20, 50, 100]"
:total="tabState.whatsapp.total"
layout="total, sizes, prev, pager, next, jumper"
background
@current-change="onWhatsappPageChange"
@size-change="onWhatsappPageSizeChange"
/>
</div>
</el-tab-pane>
</el-tabs>
<p class="yk-staff__hint">点击任意一行查看员工账号全部字段。</p>
<!-- All-fields drawer (reused from AccountList.vue / accountFields.ts) -->
<el-drawer
v-model="drawerOpen"
:title="drawerAccount ? `员工详情 · ${drawerAccount.user_name ?? drawerAccount.nickname ?? drawerAccount.account_id}` : '员工详情'"
direction="rtl"
size="560px"
>
<template v-if="drawerAccount">
<div class="yk-drawer__avatar">
<el-avatar :size="56" :src="drawerAvatarSrc" shape="square">
{{ initials(drawerAccount) }}
</el-avatar>
<div class="yk-drawer__avatar-meta">
<div class="yk-drawer__avatar-name">
{{ drawerAccount.user_name ?? drawerAccount.nickname ?? drawerAccount.account_id }}
</div>
<div class="yk-drawer__avatar-sub">
<el-tag
:type="channelTagType(drawerAccount.channel)"
size="small"
effect="plain"
>
{{ channelLabel(drawerAccount.channel) }}
</el-tag>
<el-tag
:type="onJobTagType(cell(drawerAccount, 'isOnJob'))"
size="small"
effect="plain"
style="margin-left: 6px"
>
{{ onJobLabel(cell(drawerAccount, 'isOnJob')) }}
</el-tag>
</div>
</div>
</div>
<div v-for="section in drawerSections" :key="section.title" class="yk-drawer__section">
<el-divider content-position="left">{{ section.title }}</el-divider>
<dl class="yk-drawer__kv">
<div v-for="f in section.fields" :key="f.key" class="yk-drawer__kv-item">
<dt>{{ f.label }}</dt>
<dd v-if="f.render === 'image'" class="yk-drawer__img">
<el-image
:src="String(f.value)"
:preview-src-list="[String(f.value)]"
fit="cover"
hide-on-click-modal
/>
</dd>
<dd v-else-if="f.render === 'empty'" class="yk-drawer__empty">—</dd>
<dd v-else>{{ f.text }}</dd>
</div>
</dl>
</div>
</template>
</el-drawer>
</div>
</template>
<style scoped>
.yk-staff__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.yk-staff__sub {
color: #909399;
font-size: 13px;
}
.yk-staff__tabs {
margin-top: 4px;
}
.yk-staff__pager {
margin-top: 12px;
display: flex;
justify-content: flex-end;
}
.yk-staff__hint {
margin: 8px 2px 0;
font-size: 12px;
color: #c0c4cc;
}
/* Round 36px avatar — el-image renders an <img> inside; size + round it. */
.yk-avatar-round {
width: 36px;
height: 36px;
border-radius: 50%;
overflow: hidden;
display: inline-flex;
align-items: center;
justify-content: center;
background: #f0f2f5;
}
.yk-avatar-round :deep(img) {
width: 36px;
height: 36px;
object-fit: cover;
}
.yk-avatar-fallback {
width: 36px;
height: 36px;
line-height: 36px;
text-align: center;
color: #909399;
font-size: 14px;
background: #f0f2f5;
}
.yk-drawer__avatar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 4px;
}
.yk-drawer__avatar-meta {
min-width: 0;
}
.yk-drawer__avatar-name {
font-size: 16px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.yk-drawer__avatar-sub {
font-size: 12px;
color: #909399;
margin-top: 4px;
display: flex;
align-items: center;
}
.yk-drawer__kv {
margin: 0 0 8px;
}
.yk-drawer__kv-item {
display: grid;
grid-template-columns: 110px 1fr;
gap: 8px 12px;
padding: 3px 0;
}
.yk-drawer__kv dt {
color: #909399;
font-size: 13px;
word-break: break-all;
}
.yk-drawer__kv dd {
margin: 0;
font-size: 13px;
color: #303133;
word-break: break-all;
min-width: 0;
}
.yk-drawer__img :deep(.el-image) {
width: 64px;
height: 64px;
border-radius: 4px;
border: 1px solid var(--el-border-color-light, #e4e7ed);
}
.yk-drawer__empty {
color: #c0c4cc;
}
</style>