feat(staff): StaffList 微信 5 数字改读本地 summary + 日期选择器(Phase A 接线)
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user