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,
Sms,
Staff,
StaffChatStats,
SyncRun,
SyncState,
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
// ---------------------------------------------------------------------------
@@ -279,6 +302,7 @@ export type {
PhoneCall,
Sms,
Staff,
StaffChatStats,
SyncRun,
SyncState,
SyncStatus,
+31
View File
@@ -246,3 +246,34 @@ export interface Message {
*/
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 { 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<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)
// ---------------------------------------------------------------------------
@@ -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<ChatMetric, string> = {
chat: '沟通人数(已去重)',
effective: '有效沟通数(已去重)',
follow: '跟进客户数(口径:窗口内≥2个沟通日,近似)',
last_reply: '员工最后回复数',
no_reply: '未回复客户数',
}
// 对齐云客 spec §6:所有 modal 显示「客户微信」;仅「有效沟通数」modal 额外显示发送/接收数。
// 序号需改 YkStaffDetailModal(spec §1 不改组件,略);员工信息/员工微信由 modal 标题承载,行内冗余故略。
function commModalColsFor(metric: ChatMetric): any[] {
const cols: any[] = [{ prop: 'friend_name', label: '客户微信', minWidth: 180 }]
if (metric === 'effective') {
cols.push(
{ prop: 'send_count', label: '发送消息数', width: 110, align: 'center' },
{ prop: 'receive_count', label: '接收消息数', width: 110, align: 'center' },
)
}
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<void> {
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 (员工微信列表).
============================================================ -->
<el-tab-pane label="微信员工" name="wechat">
<div class="yk-staff__stats-bar">
<span class="yk-staff__stats-label">沟通统计时段</span>
<el-date-picker
v-model="dateRange"
type="daterange"
size="small"
:clearable="false"
@change="onStatsDateChange"
/>
<span class="yk-staff__stats-hint">数字为本地消息表实时聚合(口径近似云客)</span>
</div>
<el-alert
v-if="tabState.wechat.errorMsg"
:title="tabState.wechat.errorMsg"
@@ -520,15 +585,15 @@ onMounted(() => {
<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, '沟通人数(已去重)', 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 class="yk-pair">
<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 class="yk-pair">
<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>
</template>
</el-table-column>
@@ -538,7 +603,7 @@ onMounted(() => {
<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, '员工最后回复数', 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 class="yk-pair">
<span class="yk-pair__k">员工最后回复率</span>
@@ -546,7 +611,7 @@ onMounted(() => {
</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, '未回复客户数', 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>
</template>
</el-table-column>
@@ -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;
}
</style>