feat(staff): StaffList 微信 5 数字改读本地 summary + 日期选择器(Phase A 接线)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
浅念
2026-08-07 18:42:19 +08:00
parent 628659b849
commit 876767cd68
3 changed files with 168 additions and 31 deletions
+24
View File
@@ -17,6 +17,7 @@ import type {
PhoneCall, PhoneCall,
Sms, Sms,
Staff, Staff,
StaffChatStats,
SyncRun, SyncRun,
SyncState, SyncState,
SyncStatus, SyncStatus,
@@ -215,6 +216,28 @@ export function getSms(query: SmsQuery = {}): Promise<Page<Sms>> {
) )
} }
// ---------------------------------------------------------------------------
// 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<StaffChatStats> {
return getJson<StaffChatStats>(
'/staff-chat-stats',
clean({
owner_account_id: query.owner_account_id,
start: query.start,
end: query.end,
}),
)
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Sync monitor // Sync monitor
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -279,6 +302,7 @@ export type {
PhoneCall, PhoneCall,
Sms, Sms,
Staff, Staff,
StaffChatStats,
SyncRun, SyncRun,
SyncState, SyncState,
SyncStatus, SyncStatus,
+31
View File
@@ -246,3 +246,34 @@ export interface Message {
*/ */
raw_json?: Record<string, unknown> | null raw_json?: Record<string, unknown> | 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[]
}
+113 -31
View File
@@ -2,8 +2,8 @@
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { getAccounts, getCustomers, getMessages } from '@/api' import { getAccounts, getCustomers, getMessages, getStaffChatStats } from '@/api'
import type { Account, Channel, Customer } from '@/api/types' import type { Account, Channel, StaffChatStats } from '@/api/types'
import YkStaffDetailModal from '@/components/YkStaffDetailModal.vue' import YkStaffDetailModal from '@/components/YkStaffDetailModal.vue'
import { useChannelStore } from '@/stores/channel' import { useChannelStore } from '@/stores/channel'
import { channelLabel, formatTime } from '@/utils/format' import { channelLabel, formatTime } from '@/utils/format'
@@ -64,6 +64,56 @@ const channelStore = useChannelStore()
const drawerOpen = ref(false) const drawerOpen = ref(false)
const drawerAccount = ref<Account | null>(null) 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) // Cell helpers (structured-first, raw_json fallback via accountFields.readField)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -271,31 +321,31 @@ function openTalkedModal(row: Account) {
detailOpen.value = true detailOpen.value = true
} }
/** 微信员工「沟通数」3 数字 → 明细 modal(数据源:该员工客户列表,度量精确区分见 plan 数据源说明)。 */ type ChatMetric = 'chat' | 'effective' | 'follow' | 'last_reply' | 'no_reply'
function openWechatCommModal(row: Account, title: string, withMsgCols: boolean): void { const METRIC_TITLES: Record<ChatMetric, string> = {
detailTitle.value = `${title} · ${row.user_name ?? row.account_id}` chat: '沟通人数(已去重)',
// 客户微信列可点 → 跳该客户聊天记录(ChatTimeline,name:'chat' 路由同 CustomerList.viewChat)。 effective: '有效沟通数(已去重)',
// modal 列数据是 Customer,goChat 守卫 owner/friend 存在;缺失则静默 no-op(与 onClick?: 一致)。 follow: '跟进客户数(口径:窗口内≥2个沟通日,近似)',
const goChat = (r: Customer) => { last_reply: '员工最后回复数',
if (!r.owner_account_id || !r.friend_id) return no_reply: '未回复客户数',
router.push({
name: 'chat',
params: {
channel: r.channel,
ownerId: r.owner_account_id,
talkerId: r.friend_id,
},
})
} }
detailCols.value = withMsgCols // 对齐云客 spec §6:所有 modal 显示「客户微信」;仅「有效沟通数」modal 额外显示发送/接收数。
? [ // 序号需改 YkStaffDetailModal(spec §1 不改组件,略);员工信息/员工微信由 modal 标题承载,行内冗余故略。
{ prop: 'friend_nickname', label: '客户微信', minWidth: 160, onClick: goChat }, function commModalColsFor(metric: ChatMetric): any[] {
{ prop: 'send_num', label: '发送消息数', width: 110, align: 'center' }, const cols: any[] = [{ prop: 'friend_name', label: '客户微信', minWidth: 180 }]
{ prop: 'receive_num', label: '接收消息数', width: 110, align: 'center' }, if (metric === 'effective') {
] cols.push(
: [{ prop: 'friend_nickname', label: '客户微信', minWidth: 160, onClick: goChat }] { prop: 'send_count', label: '发送消息数', width: 110, align: 'center' },
detailFetcher.value = async (page: number, page_size: number) => { prop: 'receive_count', label: '接收消息数', width: 110, align: 'center' },
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 detailOpen.value = true
} }
@@ -316,6 +366,10 @@ async function loadTab(ch: TabKey): Promise<void> {
st.items = res.items st.items = res.items
st.total = res.total st.total = res.total
st.loaded = true st.loaded = true
if (ch === 'wechat') {
// 5 数字改读本地 summary:员工列表到位后并行拉每行 stats。
void loadWechatStats(st.items)
}
} catch (e) { } catch (e) {
st.errorMsg = (e as Error).message st.errorMsg = (e as Error).message
ElMessage.error(`员工列表加载失败:${(e as Error).message}`) ElMessage.error(`员工列表加载失败:${(e as Error).message}`)
@@ -397,6 +451,17 @@ onMounted(() => {
云客 /cms/wechat/wechatlist (员工微信列表). 云客 /cms/wechat/wechatlist (员工微信列表).
============================================================ --> ============================================================ -->
<el-tab-pane label="微信员工" name="wechat"> <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 <el-alert
v-if="tabState.wechat.errorMsg" v-if="tabState.wechat.errorMsg"
:title="tabState.wechat.errorMsg" :title="tabState.wechat.errorMsg"
@@ -520,15 +585,15 @@ onMounted(() => {
<template #default="{ row }"> <template #default="{ row }">
<div class="yk-pair"> <div class="yk-pair">
<span class="yk-pair__k">沟通人数</span> <span class="yk-pair__k">沟通人数</span>
<el-button link type="primary" size="small" class="yk-pair__v" @click.stop="openWechatCommModal(row, '沟通人数(已去重)', false)">{{ cellNum(row, 'chatCount') }}</el-button> <el-button link type="primary" size="small" class="yk-pair__v" @click.stop="openWechatCommModal(row, 'chat')">{{ statNum(row, 'chat_count') }}</el-button>
</div> </div>
<div class="yk-pair"> <div class="yk-pair">
<span class="yk-pair__k">有效沟通数</span> <span class="yk-pair__k">有效沟通数</span>
<el-button link type="primary" size="small" class="yk-pair__v" @click.stop="openWechatCommModal(row, '有效沟通数(已去重)', true)">{{ cellNum(row, 'contactCount') }}</el-button> <el-button link type="primary" size="small" class="yk-pair__v" @click.stop="openWechatCommModal(row, 'effective')">{{ statNum(row, 'effective_count') }}</el-button>
</div> </div>
<div class="yk-pair"> <div class="yk-pair">
<span class="yk-pair__k">跟进客户数</span> <span class="yk-pair__k">跟进客户数</span>
<el-button link type="primary" size="small" class="yk-pair__v" @click.stop="openWechatCommModal(row, '跟进客户数', false)">{{ cellNum(row, 'followCount') }}</el-button> <el-button link type="primary" size="small" class="yk-pair__v" @click.stop="openWechatCommModal(row, 'follow')">{{ statNum(row, 'follow_count') }}</el-button>
</div> </div>
</template> </template>
</el-table-column> </el-table-column>
@@ -538,7 +603,7 @@ onMounted(() => {
<template #default="{ row }"> <template #default="{ row }">
<div class="yk-pair"> <div class="yk-pair">
<span class="yk-pair__k">员工最后回复数</span> <span class="yk-pair__k">员工最后回复数</span>
<el-button link type="primary" size="small" class="yk-pair__v" @click.stop="openWechatCommModal(row, '员工最后回复数', false)">{{ cellNum(row, 'saleSendLastNum') }}</el-button> <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>
<div class="yk-pair"> <div class="yk-pair">
<span class="yk-pair__k">员工最后回复率</span> <span class="yk-pair__k">员工最后回复率</span>
@@ -546,7 +611,7 @@ onMounted(() => {
</div> </div>
<div class="yk-pair"> <div class="yk-pair">
<span class="yk-pair__k">未回复客户数</span> <span class="yk-pair__k">未回复客户数</span>
<el-button link type="primary" size="small" class="yk-pair__v" @click.stop="openWechatCommModal(row, '未回复客户数', false)">{{ cellNum(row, 'customerSendLastNum') }}</el-button> <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> </div>
</template> </template>
</el-table-column> </el-table-column>
@@ -977,4 +1042,21 @@ onMounted(() => {
.yk-drawer__empty { .yk-drawer__empty {
color: #c0c4cc; 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> </style>