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

1275 lines
44 KiB
Vue
Raw Normal View History

<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>