Files
yunke/yunkes/src/views/StaffList.vue
T
浅念 2e526cd056 feat(staff-friends): 员工客户列表独立页——对齐云客员工详情,好友数/微信群数下钻
员工列表点「好友数」原先跳全局 /customers?owner_account_id=x,布局与全局
列表无异且计数/分页绑全局声明口径(同日 dfa4fdd 修的显示 bug),与云客
「员工名下客户列表」体验割裂。新页 /staff-friends/:ownerId/:tab:

- 微信客户/微信群两 tab(对齐云客截图);标题=员工显示名(入口直带,
  直链经 /api/accounts 解析),返回箭头回员工列表
- 搜索(昵称/微信号/备注名/手机号,服务端 q)+ 查询/导出 CSV
- 发送/接收条数=Customer.send_num/receive_num 累计值(CustomerOut 补
  两列,同步自云客 friend 记录,与截图同源)
- 勾选批量「导出聊天记录」:新端点 GET /api/export/chat-html-zip,复用
  chat-html 渲染器每会话一个 HTML 打 zip(文件名=备注>昵称>friend_id,
  非法字符清洗;单会话 5000 行截断告警;一次上限 50 会话防误选打爆)
- 行内:查看聊天记录→staff-chat;导出HTML聊天记录(时间范围弹窗)
- WA 行保持原入口(该页暂仅微信)

后端 605 测试绿(zip 新 4 例);本地浏览器端到端验证:过滤 9 条/勾选
激活/群 tab/返回箭头;zip 产物 application/zip 双会话双气泡。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-08 16:14:44 +08:00

1275 lines
44 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, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { Download } from '@element-plus/icons-vue'
import { getAccounts, getCustomers, getStaffChatStats } from '@/api'
import type { Account, Channel, Page, StaffChatCustomer, StaffChatStats } from '@/api/types'
import YkStaffDetailModal from '@/components/YkStaffDetailModal.vue'
import { useChannelStore } from '@/stores/channel'
import { channelLabel, formatTime } from '@/utils/format'
import { exportListToCsv } from '@/utils/csvExport'
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 (built via 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: 10,
loading: false,
errorMsg: null,
loaded: false,
}
}
const route = useRoute()
const router = useRouter()
const channelStore = useChannelStore()
const activeTab = ref<TabKey>(
(route.query.channel === 'whatsapp' ? 'whatsapp' : 'wechat') as TabKey,
)
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)
// ---------------------------------------------------------------------------
// 微信员工沟通统计(全本地 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() - 29)
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>>({})
// 单调递增 token:防止快速切换日期窗时旧 in-flight fetch(捕获旧窗)覆盖新结果。
let statsLoadToken = 0
async function loadWechatStats(rows: Account[]): Promise<void> {
const myToken = ++statsLoadToken
const ids = rows.map((r) => r.account_id).filter(Boolean)
// 先清空当前批次缓存 → cell 显示 '--'(加载中),并防止旧窗 in-flight 写回覆盖新结果
for (const id of ids) staffStats.value[id] = undefined
await Promise.all(
ids.map(async (id) => {
try {
const stats = await getStaffChatStats({
owner_account_id: id,
start: statsStart.value,
end: statsEnd.value,
})
if (myToken === statsLoadToken) staffStats.value[id] = stats
} catch {
if (myToken === statsLoadToken) 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 行(覆盖其缓存条目)。
void 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 {
// 微信行 → 员工客户列表独立页(2026-09-08 对齐云客员工详情,账号维度
// 下钻:两 tab/搜索/批量导出);WA 行保持全局客户列表(该页暂仅微信)。
if (row.channel === 'wechat') {
router.push({
name: 'staff-friends',
params: { ownerId: row.account_id, tab: 'customers' },
query: { name: row.nickname || row.user_name || '' },
})
return
}
channelStore.setChannel(row.channel)
router.push({
path: '/customers',
query: { owner_account_id: row.account_id, channel: row.channel },
})
}
function viewGroups(row: Account): void {
// 同上:微信群数 → 员工客户列表的群 tab;WA 保持原路径。
if (row.channel === 'wechat') {
router.push({
name: 'staff-friends',
params: { ownerId: row.account_id, tab: 'groups' },
query: { name: row.nickname || row.user_name || '' },
})
return
}
channelStore.setChannel(row.channel)
if (row.channel === 'whatsapp') {
// WA:对齐云客,群列表在客户列表页的子 tab(2026-08-24)
router.push({
path: '/customers',
query: { owner_account_id: row.account_id, channel: 'whatsapp', tab: 'groups' },
})
} else {
router.push({
path: '/groups',
query: { owner_account_id: row.account_id },
})
}
}
async function viewChat(row: Account) {
// 账号级聊天窗口(左侧会话列表 + 右消息流);对齐云客 badge→聊天窗口。
// 微信 → 新员工聊天窗口(五视图);WA 保持旧 ChatTimeline(新窗口仅微信)。
if (row.channel === 'wechat') {
await router.push({ name: 'staff-chat', params: { ownerId: row.account_id, tab: 'single' } })
} else {
await router.push({ name: 'chat', params: { channel: row.channel, ownerId: row.account_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 detailSummary = ref('')
const fmtTime = (_r: any, _c: any, v: any): string =>
typeof v === 'number' && v > 0 ? formatTime(v) : v ? String(v) : '--'
/**
* WA 员工 modal 列(对齐云客 WA 真机,6 列):
* 序号 / 员工信息 / 员工WhatsApp / 客户WhatsApp / 添加时间 / 最后沟通时间.
* 序号/员工信息/员工WhatsApp/客户WhatsApp 为注入字段(同 Phase B 微信 modal 模式),
* 由 fetcher 在每条 Customer 上合成;add_time/last_chat_time 为 Customer 本身字段。
*/
function waModalCols(_row: Account): any[] {
return [
{ prop: '_serial', label: '序号', width: 70, align: 'center' },
{ prop: 'staff_info', label: '员工信息', minWidth: 140 },
{ prop: 'staff_wx', label: '员工WhatsApp', minWidth: 140 },
{ prop: 'friend_wx', label: '客户WhatsApp', minWidth: 160 },
{ prop: 'add_time', label: '添加时间', width: 150, formatter: fmtTime },
{ prop: 'last_chat_time', label: '最后沟通时间', width: 150, formatter: fmtTime },
]
}
/** 为 WA modal 一页 Customer 合成注入字段(序号跨页连续 + 员工/客户显示字段)。 */
function injectWaModalFields(rows: any[], page: number, pageSize: number, row: Account): any[] {
const startIdx = (page - 1) * pageSize
const staffInfo = joinFields(cellText(row, 'userName'), cellText(row, 'phone'))
// WA 员工可能无 alias,用 nickName+phone,兜底 account_id。
const staffWx = joinFields(cellText(row, 'nickName'), cellText(row, 'phone'), row.account_id)
return rows.map((c, i) => ({
...c,
_serial: startIdx + i + 1,
staff_info: staffInfo,
staff_wx: staffWx,
friend_wx: joinFields(c.friend_nickname, c.friend_id),
}))
}
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 = waModalCols(row)
detailFetcher.value = async (p, s) => {
const res = await getCustomers({
owner_account_id: row.account_id,
channel: row.channel,
page: p,
page_size: s,
})
return { ...res, items: injectWaModalFields(res.items, p, s, row) }
}
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 = waModalCols(row)
detailFetcher.value = async (p, s) => {
const res = await getCustomers({
owner_account_id: row.account_id,
channel: row.channel,
last_chat_since: since,
page: p,
page_size: s,
})
return { ...res, items: injectWaModalFields(res.items, p, s, row) }
}
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: '未回复客户数',
}
/** 拼接非空字段(过滤 '--'),全缺则 '--'。用于 modal 里员工信息/员工微信 单行显示。 */
function joinFields(...vals: (string | undefined | null)[]): string {
const parts = vals.map((v) => (v == null ? '' : String(v).trim())).filter((v) => v && v !== '--')
return parts.length ? parts.join(' ') : '--'
}
/** modal 客户微信 → 该员工×客户聊天(全时段,不带统计日期窗——2026-08-27
* 用户定调所有聊天记录不做时间限制,要看某时段在聊天窗里自选日期)。缺失
* owner/talker 静默 no-op。微信 → 新员工聊天窗口 single tab(不带 msgid 定位)。 */
function openCustomerChat(row: Account, customer: StaffChatCustomer): void {
if (!row.account_id || !customer.talker_id) return
router.push({
name: 'staff-chat',
params: { ownerId: row.account_id, tab: 'single', talkerId: customer.talker_id },
})
}
// 对齐云客(2026-08-08 真机):序号 / 客户微信(可点→聊天) / [仅有效沟通:发送·接收] / 员工信息 / 员工微信。
// 序号/员工信息/员工微信 靠 fetcher 注入字段实现,不改 YkStaffDetailModal(其 cols 仍 prop 驱动)。
function commModalColsFor(metric: ChatMetric, row: Account): any[] {
const cols: any[] = [
{ prop: '_serial', label: '序号', width: 70, align: 'center' },
{
prop: 'customer_wx',
label: '客户微信',
minWidth: 200,
type: 'person',
onClick: (c: any) => openCustomerChat(row, c),
},
]
if (metric === 'effective') {
cols.push(
{ prop: 'send_count', label: '发送消息数', width: 110, align: 'center' },
{ prop: 'receive_count', label: '接收消息数', width: 110, align: 'center' },
)
}
cols.push(
{ prop: 'staff_info', label: '员工信息', minWidth: 140 },
{ prop: 'staff_wx', label: '员工微信', minWidth: 200, type: 'person' },
)
return cols
}
/** 按 metric 过滤客户明细(口径见 spec §4;last_is_send 为 null 时既非最后回复也非未回复)。 */
function metricFilter(c: StaffChatCustomer, m: ChatMetric): boolean {
switch (m) {
case 'chat':
return true
case 'effective':
return c.send_count >= 1 && c.receive_count >= 1
case 'follow':
return c.interaction_days >= 2
case 'last_reply':
return c.last_is_send === 1
case 'no_reply':
return c.last_is_send === 0
}
}
/** 微信员工 5 数字 → 明细 modal(数据源:本地 staff-chat-stats,按 metric 过滤 + 合成分页)。 */
function openWechatCommModal(row: Account, metric: ChatMetric): void {
detailTitle.value = `${METRIC_TITLES[metric]} · ${row.user_name ?? row.account_id}`
detailCols.value = commModalColsFor(metric, row)
detailFetcher.value = async (page: number, page_size: number): Promise<Page<any>> => {
// 复用已缓存的 stats;点击早于列表加载完成则按需补拉。
let stats = staffStats.value[row.account_id]
if (!stats) {
const start = statsStart.value
const end = statsEnd.value
try {
stats = await getStaffChatStats({ owner_account_id: row.account_id, start, end })
// 仅当日期窗未变时回写缓存:避免旧窗 on-demand 结果覆盖日期切换后的新数据
// (loadWechatStats 的 token 守卫负责批量重查)。
if (statsStart.value === start && statsEnd.value === end) {
staffStats.value[row.account_id] = stats
}
} catch {
return { items: [], total: 0, page, page_size }
}
}
const filtered = stats.customers.filter((c) => metricFilter(c, metric))
const startIdx = (page - 1) * page_size
const staffWx = joinFields(cellText(row, 'nickName'), row.account_id)
const staffInfo = joinFields(cellText(row, 'userName'), cellText(row, 'phone'))
const items = filtered.slice(startIdx, startIdx + page_size).map((c, i) => ({
...c,
_serial: startIdx + i + 1,
customer_wx: c.friend_name,
customer_wx_name: c.friend_name,
customer_wx_sub: c.talker_id,
customer_wx_real: c.real_name || '',
customer_wx_avatar: c.head_image_url || '',
staff_info: staffInfo,
staff_wx: staffWx,
staff_wx_name: cellText(row, 'nickName'),
staff_wx_sub: row.account_id,
staff_wx_real: cellText(row, 'userName') || '',
staff_wx_avatar: cellText(row, 'headImg') || (row.raw_json as any)?.headImg || '',
}))
// 汇总说明(对齐云客)
const metricTitle = METRIC_TITLES[metric]
const statCount = stats.summary?.[metric === 'chat' ? 'chat_count' : metric === 'effective' ? 'effective_count' : metric === 'follow' ? 'follow_count' : metric === 'last_reply' ? 'last_reply_count' : 'no_reply_count'] ?? 0
detailSummary.value = `${metricTitle}总${statCount}人次,其中重复客户${Math.max(0, statCount - filtered.length)}人次 剩余${filtered.length}条记录:`
return { items, total: filtered.length, page, page_size }
}
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)
}
// 侧栏菜单在同一页面内切换 ?channel= 时组件复用(不重挂载)→ watch 同步渠道并加载。
watch(
() => route.query.channel,
(q) => {
const next: TabKey = q === 'whatsapp' ? 'whatsapp' : 'wechat'
if (next !== activeTab.value) {
activeTab.value = next
if (!tabState[next].loaded) loadTab(next)
}
},
)
// ---------------------------------------------------------------------------
// Header 导出:当前 tab 渠道全量员工账号 CSV
// ---------------------------------------------------------------------------
const exporting = ref(false)
async function exportStaff(): Promise<void> {
const channel = activeTab.value // 'wechat' | 'whatsapp'
if (tabState[channel].total === 0) {
ElMessage.warning('当前列表为空,无数据可导出')
return
}
exporting.value = true
try {
const result = await exportListToCsv({
fetchPage: (page, pageSize) => getAccounts({ channel, page, page_size: pageSize }),
columns: [
{ key: 'user_name', label: '员工姓名' },
{ key: 'nickname', label: '微信昵称' },
{ key: 'account_id', label: '微信号' },
{ key: 'phone', label: '手机' },
{ key: 'contacts_num', label: '好友数' },
{ key: 'group_num', label: '群数' },
{ key: 'channel', label: '渠道', format: (r) => channelLabel(r.channel) },
],
filename: `${channel === 'wechat' ? '微信员工' : 'WhatsApp员工'}列表`,
})
ElMessage.success(`已导出 ${result.rows} 条`)
} catch (e) {
ElMessage.error(`导出失败:${(e as Error).message}`)
} finally {
exporting.value = false
}
}
// ---------------------------------------------------------------------------
// Drawer
// ---------------------------------------------------------------------------
function openDrawer(row: Account): void {
drawerAccount.value = row
drawerOpen.value = true
}
/**
* 员工姓名 click → standalone 员工详情 page (mirrors 云客 userDetailNew).
* The 员工姓名 cell is a small, deliberate click target, distinct from the
* row-click quick-preview drawer. Prefer the structured `user_id`; fall back
* to `account_id` for accounts that carry no staff binding (so the page can
* still resolve by the account identifier).
*/
function goToDetail(row: Account): void {
router.push({
name: 'staff-detail',
params: { userId: row.user_id ?? row.account_id },
})
}
const drawerSections = computed(() =>
drawerAccount.value ? buildAccountSections(drawerAccount.value) : [],
)
const drawerAvatarSrc = computed<string | undefined>(() => {
const a = drawerAccount.value
return a ? avatarUrl(a) : undefined
})
onMounted(() => {
// Load the channel requested via ?channel= (sidebar preset).
loadTab(activeTab.value)
})
</script>
<template>
<div class="yk-staff">
<div class="yk-staff__header">
<div>
<h2 style="margin: 0 0 4px; font-size: 16px">{{ activeTab === 'wechat' ? '微信' : 'WhatsApp' }}员工列表</h2>
<span class="yk-staff__sub">
按渠道展示员工账号(微信号 / WhatsApp 号),列布局对齐云客后台,点击行查看全部字段。
</span>
</div>
<div class="yk-staff__filters">
<el-button size="small" :loading="exporting" @click="exportStaff">
<el-icon><Download /></el-icon> 导出
</el-button>
</div>
</div>
<!-- ============================================================
微信员工 (WeChat staff accounts) — 6 compound columns mirroring
云客 /cms/wechat/wechatlist (员工微信列表).
============================================================ -->
<template v-if="activeTab === '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="goToDetail(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="[10, 20, 50, 100]"
:total="tabState.wechat.total"
layout="total, sizes, prev, pager, next, jumper"
background
@current-change="onWechatPageChange"
@size-change="onWechatPageSizeChange"
/>
</div>
</template>
<!-- ============================================================
WhatsApp员工 (WhatsApp staff accounts) — 10 columns mirroring
云客 /cms/wechat/waBusiness/userlist (员工列表), ending in an
操作 column with 好友列表 / 群聊.
============================================================ -->
<template v-if="activeTab === '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="[10, 20, 50, 100]"
:total="tabState.whatsapp.total"
layout="total, sizes, prev, pager, next, jumper"
background
@current-change="onWhatsappPageChange"
@size-change="onWhatsappPageSizeChange"
/>
</div>
</template>
<YkStaffDetailModal
:summary-text="detailSummary"
v-model="detailOpen"
:title="detailTitle"
:cols="detailCols"
:fetcher="detailFetcher"
/>
<p class="yk-staff__hint">点击任意一行查看员工账号全部字段。</p>
<!-- All-fields drawer (built via 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__filters {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.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;
}
/* 可点按钮(link primary)不受灰文本覆盖,保持玉绿示意(2026-08-22) */
.yk-pair__v.el-button,
.yk-staffinfo__v.el-button {
color: var(--el-color-primary);
}
/* 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>