diff --git a/yunkes/src/api/index.ts b/yunkes/src/api/index.ts index e78a3de..906fd26 100644 --- a/yunkes/src/api/index.ts +++ b/yunkes/src/api/index.ts @@ -17,6 +17,7 @@ import type { PhoneCall, Sms, Staff, + StaffChatStats, SyncRun, SyncState, SyncStatus, @@ -215,6 +216,28 @@ export function getSms(query: SmsQuery = {}): Promise> { ) } +// --------------------------------------------------------------------------- +// Staff chat stats (微信员工 5 数字 + 客户明细,全本地 message 聚合) +// --------------------------------------------------------------------------- + +export interface StaffChatStatsQuery { + owner_account_id: string + /** ms-epoch inclusive 窗口(默认近 3 天,由后端兜底)。 */ + start?: number + end?: number +} + +export function getStaffChatStats(query: StaffChatStatsQuery): Promise { + return getJson( + '/staff-chat-stats', + clean({ + owner_account_id: query.owner_account_id, + start: query.start, + end: query.end, + }), + ) +} + // --------------------------------------------------------------------------- // Sync monitor // --------------------------------------------------------------------------- @@ -279,6 +302,7 @@ export type { PhoneCall, Sms, Staff, + StaffChatStats, SyncRun, SyncState, SyncStatus, diff --git a/yunkes/src/api/types.ts b/yunkes/src/api/types.ts index e76c648..026fd32 100644 --- a/yunkes/src/api/types.ts +++ b/yunkes/src/api/types.ts @@ -246,3 +246,34 @@ export interface Message { */ raw_json?: Record | null } + +/** 微信员工沟通统计:单个客户聚合行(镜像 backend StaffChatCustomerOut)。 */ +export interface StaffChatCustomer { + talker_id: string + friend_name: string + send_count: number + receive_count: number + interaction_days: number + /** 1 = 员工最后发,0 = 客户最后发,null = 最后一条方向未知(不计入两率) */ + last_is_send: number | null + /** ms-epoch,最后一条时间 */ + last_time: number | null +} + +/** 5 个汇总数字(镜像 backend StaffChatStatsSummary)。 */ +export interface StaffChatStatsSummary { + chat_count: number + effective_count: number + follow_count: number + last_reply_count: number + no_reply_count: number +} + +/** GET /api/staff-chat-stats 响应(镜像 backend StaffChatStatsOut)。 */ +export interface StaffChatStats { + owner_account_id: string + start: number + end: number + summary: StaffChatStatsSummary + customers: StaffChatCustomer[] +} diff --git a/yunkes/src/views/StaffList.vue b/yunkes/src/views/StaffList.vue index ee51155..9ae6e5f 100644 --- a/yunkes/src/views/StaffList.vue +++ b/yunkes/src/views/StaffList.vue @@ -2,8 +2,8 @@ import { computed, onMounted, reactive, ref } from 'vue' import { useRouter } from 'vue-router' import { ElMessage } from 'element-plus' -import { getAccounts, getCustomers, getMessages } from '@/api' -import type { Account, Channel, Customer } from '@/api/types' +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' @@ -64,6 +64,56 @@ const channelStore = useChannelStore() const drawerOpen = ref(false) const drawerAccount = ref(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>({}) + +async function loadWechatStats(rows: Account[]): Promise { + 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) // --------------------------------------------------------------------------- @@ -271,31 +321,31 @@ function openTalkedModal(row: Account) { detailOpen.value = true } -/** 微信员工「沟通数」3 数字 → 明细 modal(数据源:该员工客户列表,度量精确区分见 plan 数据源说明)。 */ -function openWechatCommModal(row: Account, title: string, withMsgCols: boolean): void { - detailTitle.value = `${title} · ${row.user_name ?? row.account_id}` - // 客户微信列可点 → 跳该客户聊天记录(ChatTimeline,name:'chat' 路由同 CustomerList.viewChat)。 - // modal 列数据是 Customer,goChat 守卫 owner/friend 存在;缺失则静默 no-op(与 onClick?: 一致)。 - const goChat = (r: Customer) => { - if (!r.owner_account_id || !r.friend_id) return - router.push({ - name: 'chat', - params: { - channel: r.channel, - ownerId: r.owner_account_id, - talkerId: r.friend_id, - }, - }) +type ChatMetric = 'chat' | 'effective' | 'follow' | 'last_reply' | 'no_reply' +const METRIC_TITLES: Record = { + 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' }, + ) } - detailCols.value = withMsgCols - ? [ - { prop: 'friend_nickname', label: '客户微信', minWidth: 160, onClick: goChat }, - { prop: 'send_num', label: '发送消息数', width: 110, align: 'center' }, - { prop: 'receive_num', label: '接收消息数', width: 110, align: 'center' }, - ] - : [{ prop: 'friend_nickname', label: '客户微信', minWidth: 160, onClick: goChat }] - detailFetcher.value = async (page: number, page_size: number) => - getCustomers({ channel: 'wechat', owner_account_id: row.account_id, page, page_size }) + 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 } @@ -316,6 +366,10 @@ async function loadTab(ch: TabKey): Promise { 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}`) @@ -397,6 +451,17 @@ onMounted(() => { 云客 /cms/wechat/wechatlist (员工微信列表). ============================================================ --> +
+ 沟通统计时段 + + 数字为本地消息表实时聚合(口径近似云客) +
{ @@ -538,7 +603,7 @@ onMounted(() => { @@ -977,4 +1042,21 @@ onMounted(() => { .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; +}