Files
yunke/yunkes/src/views/StaffList.vue
T

1063 lines
35 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 { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { getAccounts, getCustomers, getMessages, getStaffChatStats } from '@/api'
import type { Account, Channel, StaffChatStats } from '@/api/types'
import YkStaffDetailModal from '@/components/YkStaffDetailModal.vue'
import { useChannelStore } from '@/stores/channel'
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 员工列表.
*
* Column layout matches 云客 verbatim (captured via Playwright against
* crm2.yunkecn.com): the WeChat tab uses 云客's 6 compound columns
* (员工信息 / 数据更新时间·最新消息时间 / 好友数 / 消息数 / 沟通数 / 及时回复),
* the WhatsApp tab uses 云客's 10 columns ending in an 操作 column with
* 好友列表 / 群聊 actions. Each compound cell stacks label+value pairs.
*
* Fields are read structured-first, raw_json fallback via accountFields.
* readField. Time fields upstream are strings ("YYYY-MM-DD HH:mm:ss"), so
* cellTimeText renders strings directly and only falls back to formatTime
* for numeric epochs.
*
* Row click opens the all-fields drawer reused from AccountList.vue.
*/
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(),
})
const router = useRouter()
const channelStore = useChannelStore()
// All-fields drawer state (shared across tabs).
const drawerOpen = ref(false)
const drawerAccount = ref<Account | null>(null)
// ---------------------------------------------------------------------------
// 微信员工沟通统计(全本地 message 聚合)——仅 wechat tab。
// 日期窗默认近 3 天;每行(按 account_id=wxid)缓存 stats 供 5 数字 + modal 复用。
// WA tab 与「员工最后回复率」保持原 raw_json 数据源不动。
// ---------------------------------------------------------------------------
function defaultDateRange(): [Date, Date] {
const end = new Date()
end.setHours(23, 59, 59, 999)
const start = new Date(end)
start.setDate(start.getDate() - 2)
start.setHours(0, 0, 0, 0)
return [start, end]
}
const dateRange = ref<[Date, Date]>(defaultDateRange())
const statsStart = computed(() => dateRange.value[0].getTime())
const statsEnd = computed(() => dateRange.value[1].getTime())
/** account_id → stats 缓存(wechat 行)。undefined 表示尚未加载,cell 显示 '--'。 */
const staffStats = ref<Record<string, StaffChatStats | undefined>>({})
async function loadWechatStats(rows: Account[]): Promise<void> {
const ids = rows.map((r) => r.account_id).filter(Boolean)
await Promise.all(
ids.map(async (id) => {
try {
staffStats.value[id] = await getStaffChatStats({
owner_account_id: id,
start: statsStart.value,
end: statsEnd.value,
})
} catch {
staffStats.value[id] = undefined // 失败留空,cell 显示 '--'
}
}),
)
}
/** 5 数字 cell:读本地 summary;未加载显示 '--'。 */
function statNum(
row: Account,
key: 'chat_count' | 'effective_count' | 'follow_count' | 'last_reply_count' | 'no_reply_count',
): string {
const s = staffStats.value[row.account_id]?.summary
return s ? String(s[key]) : '--'
}
function onStatsDateChange(): void {
// 改日期 → 重查当前可见 wechat 行(覆盖其缓存条目)。
loadWechatStats(tabState.wechat.items)
}
// ---------------------------------------------------------------------------
// 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
}
/** Numeric value with a fallback (defaults to 0) — for stat cells. */
function cellNum(row: Account, camelKey: string, fallback = 0): number {
return cellNumber(row, camelKey) ?? fallback
}
/**
* Time cell: upstream sends strings ("YYYY-MM-DD HH:mm[:ss]"); legacy/numeric
* epochs are formatted via formatTime. Returns '--' only when truly empty.
*/
function cellTimeText(row: Account, camelKey: string): string {
const v = cell(row, camelKey)
if (typeof v === 'number' && Number.isFinite(v)) return formatTime(v)
if (typeof v === 'string' && v.trim()) return v.trim()
return '--'
}
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 '--'
}
/**
* 主/副号 tag for the WeChat 员工信息 cell. 云客 marks the primary
* dual-open account with 主 and the secondary with 副. Mapped from the
* upstream flags: isOpenDuo===1 → 主 (this account hosts a dual-open),
* isDuo===1 → 副 (this account is the secondary instance).
*
* Split into text/type helpers because Vue's template expression parser
* rejects TS non-null assertions (duoTag()!.x); two plain functions
* sidestep that cleanly.
*/
function duoTagText(row: Account): string {
if (cellNumber(row, 'isDuo') === 1) return '副'
if (cellNumber(row, 'isOpenDuo') === 1) return '主'
return ''
}
function duoTagType(row: Account): 'success' | 'warning' {
return cellNumber(row, 'isDuo') === 1 ? 'warning' : 'success'
}
function channelTagType(c: Channel): 'success' | 'warning' {
return c === 'wechat' ? 'success' : 'warning'
}
/** WhatsApp row序号: accounts for current page/page-size. */
function waSerial(index: number): number {
const st = tabState.whatsapp
return (st.page - 1) * st.pageSize + index + 1
}
// ---------------------------------------------------------------------------
// Action buttons (操作 column) — jump to the corresponding list scoped to the
// row's account, presetting the global channel filter to the row's channel.
// owner_account_id is the staff's account_id (wxId for WeChat, uid for WA),
// which both CustomerList and GroupList already filter on server-side.
// ---------------------------------------------------------------------------
function viewCustomers(row: Account): void {
channelStore.setChannel(row.channel)
router.push({
path: '/customers',
query: { owner_account_id: row.account_id },
})
}
function viewGroups(row: Account): void {
channelStore.setChannel(row.channel)
router.push({
path: '/groups',
query: { owner_account_id: row.account_id },
})
}
async function viewChat(row: Account) {
// 取该账号最近一条会话,跳 ChatTimeline(降级:云客是会话列表,我们跳最近一条)
const res = await getMessages({
channel: row.channel,
owner_account_id: row.account_id,
page: 1,
page_size: 1,
})
const m = res.items[0]
if (!m) {
ElMessage.info('该账号暂无聊天记录')
return
}
const target = m.room_id ?? m.talker_id
if (!target) {
ElMessage.info('最近一条消息无会话标识')
return
}
await router.push(
m.room_id
? `/group/${row.channel}/${row.account_id}/${m.room_id}`
: `/chat/${row.channel}/${row.account_id}/${m.talker_id}`,
)
}
// ---------------------------------------------------------------------------
// Detail modal (stat-number click → 明细) — reused for 添加人数 / 沟通人数.
// fetcher is set by open*Modal; the modal triggers it on open.
// ---------------------------------------------------------------------------
const detailOpen = ref(false)
const detailTitle = ref('')
// fetcher is set by open*Modal; the modal invokes it on open.
const detailFetcher = ref<(p: number, s: number) => Promise<any>>(
async () => ({ items: [], total: 0 }),
)
const detailCols = ref<any[]>([])
const fmtTime = (_r: any, _c: any, v: any): string =>
typeof v === 'number' && v > 0 ? formatTime(v) : v ? String(v) : '--'
const baseCustomerCols = [
{ prop: 'friend_nickname', label: '客户', minWidth: 140 },
{ prop: 'phone', label: '客户号码', width: 130 },
{ prop: 'last_chat_time', label: '最后沟通', width: 150, formatter: fmtTime },
{ prop: 'add_time', label: '添加时间', width: 150, formatter: fmtTime },
]
function openAddedModal(row: Account) {
// ⚠️ WA customer has no add_time (Task 2 confirmed: WA raw_json lacks
// addTime; createTime is owner-staff). Degrade: do NOT set add_time_since
// (would be empty); show all customers of this account, sorted by last_chat_time.
// 用 owner_account_id(=account_id,始终存在)而非 user_id(可能 null 导致全表泄漏)。
// 标题如实写"客户列表"——非"添加人数",因 WA 无法按 add_time 精确筛新增。
detailTitle.value = `客户列表 · ${row.user_name ?? row.account_id}`
detailCols.value = baseCustomerCols
detailFetcher.value = (p, s) =>
getCustomers({ owner_account_id: row.account_id, channel: row.channel, page: p, page_size: s })
detailOpen.value = true
}
function openTalkedModal(row: Account) {
// 沟通人数: chatted within the last 7 days (last_chat_time exists on both
// channels). 用 owner_account_id(始终存在)而非 user_id(可能 null)。
const since = Date.now() - 7 * 86400000
detailTitle.value = `沟通人数 · ${row.user_name ?? row.account_id}`
detailCols.value = baseCustomerCols
detailFetcher.value = (p, s) =>
getCustomers({
owner_account_id: row.account_id,
channel: row.channel,
last_chat_since: since,
page: p,
page_size: s,
})
detailOpen.value = true
}
type ChatMetric = 'chat' | 'effective' | 'follow' | 'last_reply' | 'no_reply'
const METRIC_TITLES: Record<ChatMetric, string> = {
chat: '沟通人数(已去重)',
effective: '有效沟通数(已去重)',
follow: '跟进客户数(口径:窗口内≥2个沟通日,近似)',
last_reply: '员工最后回复数',
no_reply: '未回复客户数',
}
// 对齐云客 spec §6:所有 modal 显示「客户微信」;仅「有效沟通数」modal 额外显示发送/接收数。
// 序号需改 YkStaffDetailModal(spec §1 不改组件,略);员工信息/员工微信由 modal 标题承载,行内冗余故略。
function commModalColsFor(metric: ChatMetric): any[] {
const cols: any[] = [{ prop: 'friend_name', label: '客户微信', minWidth: 180 }]
if (metric === 'effective') {
cols.push(
{ prop: 'send_count', label: '发送消息数', width: 110, align: 'center' },
{ prop: 'receive_count', label: '接收消息数', width: 110, align: 'center' },
)
}
return cols
}
function openWechatCommModal(row: Account, metric: ChatMetric): void {
// Task 3 接真实数据 + metric 过滤 + 合成分页。此处先立标题与列骨架。
detailTitle.value = `${METRIC_TITLES[metric]} · ${row.user_name ?? row.account_id}`
detailCols.value = commModalColsFor(metric)
detailFetcher.value = async () => ({ items: [], total: 0, page: 1, page_size: 10 })
detailOpen.value = true
}
// ---------------------------------------------------------------------------
// 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
if (ch === 'wechat') {
// 5 数字改读本地 summary:员工列表到位后并行拉每行 stats。
void loadWechatStats(st.items)
}
} 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) — 6 compound columns mirroring
云客 /cms/wechat/wechatlist (员工微信列表).
============================================================ -->
<el-tab-pane label="微信员工" name="wechat">
<div class="yk-staff__stats-bar">
<span class="yk-staff__stats-label">沟通统计时段</span>
<el-date-picker
v-model="dateRange"
type="daterange"
size="small"
:clearable="false"
@change="onStatsDateChange"
/>
<span class="yk-staff__stats-hint">数字为本地消息表实时聚合(口径近似云客)</span>
</div>
<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"
border
row-key="account_id"
style="width: 100%"
:row-class-name="() => 'yk-clickable-row'"
empty-text="暂无微信员工账号"
@row-click="openDrawer"
>
<!-- 员工信息 -->
<el-table-column label="员工信息" min-width="300">
<template #default="{ row }">
<div class="yk-staffinfo">
<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="40" shape="circle">
{{ initials(row) }}
</el-avatar>
<div class="yk-staffinfo__meta">
<div class="yk-staffinfo__line">
<el-tag
v-if="duoTagText(row)"
:type="duoTagType(row)"
size="small"
effect="dark"
class="yk-staffinfo__duo"
style="cursor:pointer"
@click.stop="viewChat(row)"
>{{ duoTagText(row) }}</el-tag>
<span class="yk-staffinfo__nick yk-link" :title="cellText(row, 'nickName')" @click.stop="viewChat(row)">
{{ cellText(row, 'nickName') }}
</span>
</div>
<div class="yk-staffinfo__pair">
<span class="yk-staffinfo__k">员工姓名</span>
<el-button link type="primary" size="small" class="yk-staffinfo__v" @click.stop="openDrawer(row)">{{ cellText(row, 'userName') }}</el-button>
</div>
<div class="yk-staffinfo__pair">
<span class="yk-staffinfo__k">微信手机号</span>
<span class="yk-staffinfo__v">{{ cellText(row, 'phone') }}</span>
</div>
<div class="yk-staffinfo__pair">
<span class="yk-staffinfo__k">微信号</span>
<span class="yk-staffinfo__v">{{ cellText(row, 'alias') }}</span>
</div>
</div>
</div>
</template>
</el-table-column>
<!-- 数据更新时间 / 最新消息时间 -->
<el-table-column label="数据更新时间/最新消息时间" width="220">
<template #default="{ row }">
<div class="yk-pair">
<span class="yk-pair__k">数据更新</span>
<span class="yk-pair__v">{{ cellTimeText(row, 'uploadTime') }}</span>
</div>
<div class="yk-pair">
<span class="yk-pair__k">最新消息</span>
<span class="yk-pair__v">{{ cellTimeText(row, 'lastUpdate') }}</span>
</div>
<div class="yk-pair">
<span class="yk-pair__k">数据库大小</span>
<span class="yk-pair__v">{{ dbSizeText(row) }}</span>
</div>
</template>
</el-table-column>
<!-- 好友数 -->
<el-table-column label="好友数" width="130">
<template #default="{ row }">
<div class="yk-pair">
<span class="yk-pair__k">好友数</span>
<el-button link type="primary" size="small" class="yk-pair__v" @click.stop="viewCustomers(row)">{{ cellNum(row, 'contactsNum') }}</el-button>
</div>
<div class="yk-pair">
<span class="yk-pair__k">微信群数</span>
<el-button link type="primary" size="small" class="yk-pair__v" @click.stop="viewGroups(row)">{{ cellNum(row, 'groupNum') }}</el-button>
</div>
</template>
</el-table-column>
<!-- 消息数 -->
<el-table-column label="消息数" width="150">
<template #default="{ row }">
<div class="yk-pair">
<span class="yk-pair__k">发送消息数</span>
<span class="yk-pair__v">{{ cellNum(row, 'sendNum') }}</span>
</div>
<div class="yk-pair">
<span class="yk-pair__k">接收消息数</span>
<span class="yk-pair__v">{{ cellNum(row, 'receiveNum') }}</span>
</div>
<div class="yk-pair">
<span class="yk-pair__k">撤回消息数</span>
<span class="yk-pair__v">{{ cellNum(row, 'withdrawMsgCount') }}</span>
</div>
</template>
</el-table-column>
<!-- 沟通数 -->
<el-table-column label="沟通数" width="170">
<template #default="{ row }">
<div class="yk-pair">
<span class="yk-pair__k">沟通人数</span>
<el-button link type="primary" size="small" class="yk-pair__v" @click.stop="openWechatCommModal(row, 'chat')">{{ statNum(row, 'chat_count') }}</el-button>
</div>
<div class="yk-pair">
<span class="yk-pair__k">有效沟通数</span>
<el-button link type="primary" size="small" class="yk-pair__v" @click.stop="openWechatCommModal(row, 'effective')">{{ statNum(row, 'effective_count') }}</el-button>
</div>
<div class="yk-pair">
<span class="yk-pair__k">跟进客户数</span>
<el-button link type="primary" size="small" class="yk-pair__v" @click.stop="openWechatCommModal(row, 'follow')">{{ statNum(row, 'follow_count') }}</el-button>
</div>
</template>
</el-table-column>
<!-- 及时回复 -->
<el-table-column label="及时回复" width="200">
<template #default="{ row }">
<div class="yk-pair">
<span class="yk-pair__k">员工最后回复数</span>
<el-button link type="primary" size="small" class="yk-pair__v" @click.stop="openWechatCommModal(row, 'last_reply')">{{ statNum(row, 'last_reply_count') }}</el-button>
</div>
<div class="yk-pair">
<span class="yk-pair__k">员工最后回复率</span>
<span class="yk-pair__v">{{ cellText(row, 'saleSendLastNumRate') }}</span>
</div>
<div class="yk-pair">
<span class="yk-pair__k">未回复客户数</span>
<el-button link type="primary" size="small" class="yk-pair__v" @click.stop="openWechatCommModal(row, 'no_reply')">{{ statNum(row, 'no_reply_count') }}</el-button>
</div>
</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) — 10 columns mirroring
云客 /cms/wechat/waBusiness/userlist (员工列表), ending in an
操作 column with 好友列表 / 群聊.
============================================================ -->
<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"
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="70" align="center">
<template #default="{ $index }">{{ waSerial($index) }}</template>
</el-table-column>
<!-- 员工信息 -->
<el-table-column label="员工信息" min-width="150">
<template #default="{ row }">
<div class="yk-staffinfo__nick" :title="cellText(row, 'userName')">
{{ cellText(row, 'userName') }}
</div>
<div class="yk-staffinfo__muted">{{ cellText(row, 'salePhone') }}</div>
</template>
</el-table-column>
<!-- 员工WhatsApp -->
<el-table-column label="员工WhatsApp" min-width="220">
<template #default="{ row }">
<div class="yk-staffinfo">
<el-avatar :size="36" shape="circle">{{ initials(row) }}</el-avatar>
<div class="yk-staffinfo__meta">
<span
class="yk-staffinfo__nick yk-link"
:title="cellText(row, 'nickName')"
@click.stop="viewChat(row)"
>
{{ cellText(row, 'nickName') }}
</span>
<div class="yk-staffinfo__muted">{{ cellText(row, 'phone') }}</div>
</div>
</div>
</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="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click.stop="openAddedModal(row)">
{{ cellNum(row, 'friendAddNum') }}
</el-button>
</template>
</el-table-column>
<!-- 沟通人数 -->
<el-table-column label="沟通人数" width="100" align="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click.stop="openTalkedModal(row)">
{{ cellNum(row, 'talkerCount') }}
</el-button>
</template>
</el-table-column>
<!-- 好友数 -->
<el-table-column label="好友数" width="90" align="right">
<template #default="{ row }">{{ cellNum(row, 'contactsNum') }}</template>
</el-table-column>
<!-- 群数 -->
<el-table-column label="群数" width="80" align="right">
<template #default="{ row }">{{ cellNum(row, 'groupNum') }}</template>
</el-table-column>
<!-- 最新消息时间 -->
<el-table-column label="最新消息时间" width="170">
<template #default="{ row }">
{{ cellTimeText(row, 'lastUpdate') }}
</template>
</el-table-column>
<!-- 操作 -->
<el-table-column label="操作" width="150" fixed="right" align="center">
<template #default="{ row }">
<el-button link type="primary" size="small" @click.stop="viewCustomers(row)">
好友列表
</el-button>
<el-button link type="primary" size="small" @click.stop="viewGroups(row)">
群聊
</el-button>
</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>
<YkStaffDetailModal
v-model="detailOpen"
:title="detailTitle"
:cols="detailCols"
:fetcher="detailFetcher"
/>
<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;
}
/* ---- 员工信息 compound cell (avatar + labelled lines) ---- */
.yk-staffinfo {
display: flex;
align-items: center;
gap: 10px;
}
.yk-staffinfo__meta {
min-width: 0;
flex: 1;
}
.yk-staffinfo__line {
display: flex;
align-items: center;
gap: 6px;
}
.yk-staffinfo__duo {
flex-shrink: 0;
}
.yk-staffinfo__nick {
display: block;
font-weight: 600;
font-size: 13px;
color: #303133;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
.yk-staffinfo__muted {
font-size: 12px;
color: #909399;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.yk-link {
color: var(--el-color-primary);
cursor: pointer;
}
.yk-link:hover {
opacity: 0.8;
}
.yk-staffinfo__pair {
font-size: 12px;
line-height: 1.7;
display: flex;
gap: 4px;
}
.yk-staffinfo__k {
color: #909399;
flex-shrink: 0;
}
.yk-staffinfo__v {
color: #606266;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ---- label/value pair stack (KPI compound cells) ---- */
.yk-pair {
display: flex;
gap: 6px;
line-height: 1.8;
font-size: 13px;
white-space: nowrap;
}
.yk-pair__k {
color: #909399;
flex-shrink: 0;
}
.yk-pair__v {
color: #303133;
}
/* Round 40px avatar — el-image renders an <img> inside; size + round it. */
.yk-avatar-round {
width: 40px;
height: 40px;
border-radius: 50%;
overflow: hidden;
display: inline-flex;
align-items: center;
justify-content: center;
background: #f0f2f5;
flex-shrink: 0;
}
.yk-avatar-round :deep(img) {
width: 40px;
height: 40px;
object-fit: cover;
}
.yk-avatar-fallback {
width: 40px;
height: 40px;
line-height: 40px;
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;
}
.yk-staff__stats-bar {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
.yk-staff__stats-label {
font-size: 13px;
color: #606266;
}
.yk-staff__stats-hint {
font-size: 12px;
color: #c0c4cc;
}
</style>