This commit is contained in:
Your Name
2026-08-22 10:46:13 +08:00
parent c06d293424
commit 43e5411b6a
39 changed files with 1607 additions and 300 deletions
@@ -187,11 +187,11 @@
<div>
<h2>获客客户消息统计</h2>
<p>统计当前权限范围内由获客助手链接添加的客户,以及客户向承接成员发送消息的情况。</p>
</div>
<div class="customer-heading-actions">
<span v-if="customerStats.meta.last_synced_at" class="update-time">最近同步 {{ customerStats.meta.last_synced_at }}</span>
<el-button type="primary" :icon="Refresh" :loading="syncingCustomers" @click="syncCustomers">同步客户</el-button>
</div>
</div>
<div class="customer-heading-actions">
<span v-if="customerStats.meta.last_synced_at" class="update-time">最近同步 {{ customerStats.meta.last_synced_at }}</span>
<el-button type="primary" :icon="Refresh" :loading="syncingCustomers" @click="syncCustomers">同步客户</el-button>
</div>
</div>
<section class="customer-metric-grid" aria-label="获客客户消息汇总">
@@ -207,11 +207,11 @@
<span class="metric-icon is-green"><el-icon><DataAnalysis /></el-icon></span>
<div><small>消息客户率</small><strong>{{ formatCustomerRate(customerStats.summary.message_customer_rate) }}</strong><p>已发消息客户占获客客户比例</p></div>
</article>
<article class="customer-metric-card">
<span class="metric-icon is-orange"><el-icon><Connection /></el-icon></span>
<div><small>累计接收消息数</small><strong>{{ formatNumber(customerStats.summary.received_message_count) }}</strong><p>已精确统计 {{ formatNumber(customerStats.summary.message_count_known_count) }} 位客户</p></div>
</article>
</section>
<article class="customer-metric-card">
<span class="metric-icon is-orange"><el-icon><Connection /></el-icon></span>
<div><small>累计接收消息数</small><strong>{{ formatNumber(customerStats.summary.received_message_count) }}</strong><p>已精确统计 {{ formatNumber(customerStats.summary.message_count_known_count) }} 位客户</p></div>
</article>
</section>
<el-alert
v-if="!overview.config.callback_ready"
@@ -220,18 +220,18 @@
show-icon
:closable="false"
title="消息次数回调尚未配置完成"
description="请在企业微信获客助手的 API 接入场景中配置本页“应用配置”展示的消息回调地址,并在服务器配置 contact_callback_token 与 contact_callback_aes_key。未完成前只能同步客户及聊天状态,无法得到精确接收消息次数。"
/>
<el-alert
class="chatkey-alert"
type="info"
show-icon
:closable="false"
title="消息统计依赖企业微信 ChatKey"
description="ChatKey 仅在事件产生后的 30 分钟内有效。服务端收到企业微信回调后会立即拉取会话详情;手动同步用于刷新客户清单,无法补取已过期的会话消息。"
description="复用企业微信自建应用现有的“API 接收消息”地址即可,无需新增第二个回调;请确保该地址为 QywxExternalContactCallback/notify,且服务器 Token、AESKey 与企微后台一致。未完成前无法得到精确接收消息次数。"
/>
<el-alert
class="chatkey-alert"
type="info"
show-icon
:closable="false"
title="获客助手通过事件与 ChatKey 返回收消息次数"
description="本功能复用现有回调并按事件类型处理 customer_acquisition,不会占用第二个回调。客户首次发消息会推送 customer_start_chat;累计第 3、5、10 次时推送 message_from_customer,服务端需在 30 分钟内用 ChatKey 查询成员、客户、获客链接和累计收消息次数。"
/>
<div class="customer-filter-bar">
<el-form :model="customerFilters" inline label-position="top">
<el-form-item label="来源链接">
@@ -244,13 +244,13 @@
<el-option v-for="option in customerMemberOptions" :key="option.userid || String(option.id)" :label="memberFilterLabel(option)" :value="option.userid || String(option.id)" />
</el-select>
</el-form-item>
<el-form-item label="聊天状态">
<el-form-item label="聊天状态">
<el-select v-model="customerFilters.chat_status" clearable placeholder="全部状态">
<el-option label="已发过消息" value="messaged" />
<el-option label="未发过消息" value="silent" />
<el-option label="状态未知" value="unknown" />
</el-select>
</el-form-item>
</el-select>
</el-form-item>
<el-form-item class="filter-actions">
<el-button type="primary" :icon="Search" :loading="customerStatsLoading" @click="applyCustomerFilters">查询</el-button>
<el-button @click="resetCustomerFilters">重置</el-button>
@@ -269,23 +269,26 @@
<div class="member-cell"><strong>{{ customerLinkName(row) }}</strong><small>{{ customerLinkId(row) }}</small></div>
</template>
</el-table-column>
<el-table-column label="承接成员 / 部门" min-width="175">
<template #default="{ row }">
<div class="member-cell"><strong>{{ row.member_name || row.owner_name || '未匹配成员' }}</strong><small>{{ row.department_name || row.dept_name || '未分部门' }}</small></div>
</template>
</el-table-column>
<el-table-column label="是否发过消息" width="125" align="center">
<el-table-column label="承接成员 / 部门" min-width="175">
<template #default="{ row }">
<div class="member-cell">
<strong>{{ row.member_name || row.owner_name || '未匹配成员' }}</strong>
<small>{{ row.department_name || row.dept_name || '未分部门' }} · {{ row.userid || '未绑定企微账号' }}</small>
</div>
</template>
</el-table-column>
<el-table-column label="客户开口" width="110" align="center">
<template #default="{ row }">
<span class="message-state" :class="`is-${customerChatState(row)}`">{{ customerChatStateLabel(row) }}</span>
</template>
</el-table-column>
<el-table-column label="接收消息数" width="120" align="right">
<template #default="{ row }"><strong class="message-count" :class="{ muted: !customerMessageCountKnown(row) }">{{ customerMessageCountLabel(row) }}</strong></template>
</el-table-column>
<el-table-column label="累计消息" width="100" align="right">
<template #default="{ row }"><strong class="message-count" :class="{ muted: !customerMessageCountKnown(row) }">{{ customerMessageCountLabel(row) }}</strong></template>
</el-table-column>
<el-table-column label="最近同步时间" min-width="165">
<template #default="{ row }">{{ formatCustomerTime(row.last_synced_at || row.last_sync_time || row.synced_at || row.sync_time) }}</template>
</el-table-column>
<el-table-column label="最近消息时间" min-width="165">
<el-table-column label="最近收消息事件" min-width="165">
<template #default="{ row }"><span :class="{ muted: !customerLastMessageTime(row) }">{{ formatCustomerTime(customerLastMessageTime(row)) }}</span></template>
</el-table-column>
<template #empty><el-empty :image-size="72" description="当前筛选范围暂无获客客户数据" /></template>
@@ -429,7 +432,7 @@ import {
wecomPromotionSyncRemoteLinks,
wecomPromotionToggleLink
} from '@/api/first_visit'
import type { WecomPromotionCustomerChatStatus } from '@/api/first_visit'
import type { WecomPromotionCustomerChatStatus } from '@/api/first_visit'
import WecomFloatingWidgetBuilder from './components/WecomFloatingWidgetBuilder.vue'
@@ -458,19 +461,19 @@ const linkForm = reactive({ id: 0, pool_id: 0, name: '', group_name: '默认分
const customerStatsLoading = ref(false)
const customerStatsLoaded = ref(false)
const syncingCustomers = ref(false)
const customerFilters = reactive<{
link_id: number | string
userid: string
chat_status: WecomPromotionCustomerChatStatus
}>({ link_id: '', userid: '', chat_status: '' })
const customerPager = reactive({ page_no: 1, page_size: 20, total: 0 })
const customerStats = reactive({
summary: { customer_count: 0, messaged_customer_count: 0, message_customer_rate: 0, received_message_count: 0, message_count_known_count: 0 },
const customerFilters = reactive<{
link_id: number | string
userid: string
chat_status: WecomPromotionCustomerChatStatus
}>({ link_id: '', userid: '', chat_status: '' })
const customerPager = reactive({ page_no: 1, page_size: 20, total: 0 })
const customerStats = reactive({
summary: { customer_count: 0, messaged_customer_count: 0, message_customer_rate: 0, received_message_count: 0, message_count_known_count: 0 },
rows: [] as any[],
link_options: [] as Array<{ id: number | string; name: string }>,
member_options: [] as Array<{ id: number; userid?: string; name: string; department_name?: string; dept_name?: string }>,
meta: { last_synced_at: '' }
})
meta: { last_synced_at: '' }
})
const selectedPool = computed(() => overview.pools.find((item: any) => Number(item.id) === selectedPoolId.value))
const selectedLinks = computed(() => overview.links.filter((item: any) => Number(item.pool_id) === selectedPoolId.value))
@@ -630,10 +633,10 @@ async function loadCustomerStats() {
page_no: customerPager.page_no,
page_size: customerPager.page_size,
promotion_link_id: customerFilters.link_id ? Number(customerFilters.link_id) : undefined,
userid: customerFilters.userid || undefined,
chat_status: customerFilters.chat_status === ''
? undefined
: (customerFilters.chat_status === 'messaged' ? 1 : (customerFilters.chat_status === 'unknown' ? 2 : 0))
userid: customerFilters.userid || undefined,
chat_status: customerFilters.chat_status === ''
? undefined
: (customerFilters.chat_status === 'messaged' ? 1 : (customerFilters.chat_status === 'unknown' ? 2 : 0))
})
const summary = result?.summary || result?.stats || {}
const customerCount = numberFrom(summary, ['customer_count', 'total_customers', 'customer_total', 'total'])
@@ -643,9 +646,9 @@ async function loadCustomerStats() {
Object.assign(customerStats.summary, {
customer_count: customerCount,
messaged_customer_count: messagedCount,
message_customer_rate: hasRate ? numberFrom(summary, rateKeys) : (customerCount ? messagedCount / customerCount * 100 : 0),
received_message_count: numberFrom(summary, ['received_message_count', 'recv_msg_cnt', 'recv_msg_count', 'total_recv_msg_cnt', 'message_count']),
message_count_known_count: numberFrom(summary, ['message_count_known_count', 'exact_message_customer_count'])
message_customer_rate: hasRate ? numberFrom(summary, rateKeys) : (customerCount ? messagedCount / customerCount * 100 : 0),
received_message_count: numberFrom(summary, ['received_message_count', 'recv_msg_cnt', 'recv_msg_count', 'total_recv_msg_cnt', 'message_count']),
message_count_known_count: numberFrom(summary, ['message_count_known_count', 'exact_message_customer_count'])
})
customerStats.rows = Array.isArray(result?.lists)
? result.lists
@@ -661,9 +664,9 @@ async function loadCustomerStats() {
return Number(current || 0) > Number(latest || 0) ? current : latest
}, '')
const syncedAt = result?.meta?.last_synced_at || result?.last_synced_at || latestSyncTime || customerStats.meta.last_synced_at || ''
Object.assign(customerStats.meta, result?.meta || {}, {
last_synced_at: syncedAt ? formatCustomerTime(syncedAt) : ''
})
Object.assign(customerStats.meta, result?.meta || {}, {
last_synced_at: syncedAt ? formatCustomerTime(syncedAt) : ''
})
customerStatsLoaded.value = true
} catch (error: any) {
ElMessage.error(error?.message || '获客客户统计加载失败')
@@ -677,8 +680,8 @@ function applyCustomerFilters() {
loadCustomerStats()
}
function resetCustomerFilters() {
Object.assign(customerFilters, { link_id: '', userid: '', chat_status: '' })
function resetCustomerFilters() {
Object.assign(customerFilters, { link_id: '', userid: '', chat_status: '' })
customerPager.page_no = 1
loadCustomerStats()
}
@@ -821,10 +824,11 @@ function remoteMemberLabel(row: any) {
return row.is_official ? '未返回成员范围' : '手工配置'
}
function memberFilterLabel(member: any) {
const department = member.department_name || member.dept_name || ''
return department ? `${member.name} · ${department}` : member.name
}
function memberFilterLabel(member: any) {
const department = member.department_name || member.dept_name || ''
const userid = member.userid ? ` · ${member.userid}` : ''
return department ? `${member.name} · ${department}${userid}` : `${member.name}${userid}`
}
function customerIdentifier(row: any) {
const masked = row.external_userid_masked || row.customer_id_masked || row.customer_masked || row.customer_name_masked
@@ -879,16 +883,16 @@ function customerChatStateLabel(row: any) {
return state === 'messaged' ? '已发消息' : (state === 'unknown' ? '状态未知' : '未发消息')
}
function customerMessageCountLabel(row: any) {
if (customerMessageCountKnown(row)) return formatNumber(customerMessageCount(row))
const state = customerChatState(row)
if (state === 'silent') return '0'
return state === 'unknown' ? '未知' : '待回调'
}
function customerLastMessageTime(row: any) {
return row.last_message_at || row.last_chat_time || row.last_message_time || row.last_msg_time || row.event_time || ''
}
function customerMessageCountLabel(row: any) {
if (customerMessageCountKnown(row)) return formatNumber(customerMessageCount(row))
const state = customerChatState(row)
if (state === 'silent') return '0'
return state === 'unknown' ? '未知' : '待回调'
}
function customerLastMessageTime(row: any) {
return row.last_message_at || row.last_chat_time || row.last_message_time || row.last_msg_time || row.event_time || ''
}
function formatCustomerTime(value: unknown) {
if (value === undefined || value === null || value === '' || value === 0 || value === '0') return '-'
@@ -998,7 +1002,7 @@ h1, h2, h3, p { margin: 0; }
.chatkey-alert { margin-top: 14px; }.chatkey-alert :deep(.el-alert__title) { font-size: 12px; }.chatkey-alert :deep(.el-alert__description) { line-height: 1.6; }
.customer-filter-bar { margin-top: 14px; padding: 12px 14px 0; border: 1px solid var(--line); border-radius: 9px; background: #f8fafb; }
.customer-filter-bar :deep(.el-form-item) { margin-right: 12px; margin-bottom: 12px; }.customer-filter-bar :deep(.el-form-item__label) { height: 22px; padding: 0; color: #67768a; font-size: 11px; line-height: 22px; }.customer-filter-bar .el-select { width: 220px; }.customer-filter-bar .filter-actions { align-self: flex-end; margin-right: 0; }
.customer-table { margin-top: 14px; --el-table-header-bg-color: #f7f9fb; }.customer-table :deep(th.el-table__cell) { color: #67768a; font-weight: 500; }.customer-identifier { color: #53657a; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 11px; }.message-state { display: inline-flex; align-items: center; min-height: 24px; padding: 0 9px; border-radius: 12px; font-size: 10px; }.message-state.is-messaged { color: #16895f; background: #eaf8ef; }.message-state.is-silent { color: #788696; background: #eef2f5; }.message-state.is-unknown { color: #a66a1f; background: #fff3df; }.message-count { color: #253348; font-variant-numeric: tabular-nums; }
.customer-table { margin-top: 14px; --el-table-header-bg-color: #f7f9fb; }.customer-table :deep(th.el-table__cell) { color: #67768a; font-weight: 500; }.customer-identifier { color: #53657a; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 11px; }.message-state { display: inline-flex; align-items: center; min-height: 24px; padding: 0 9px; border-radius: 12px; font-size: 10px; }.message-state.is-messaged { color: #16895f; background: #eaf8ef; }.message-state.is-silent { color: #788696; background: #eef2f5; }.message-state.is-unknown { color: #a66a1f; background: #fff3df; }.message-count { color: #253348; font-variant-numeric: tabular-nums; }
.customer-pagination { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-top: 14px; color: #8491a2; font-size: 11px; }
.configuration-panel { margin-bottom: 16px; padding: 16px; border: 1px solid; border-radius: 10px; }.configuration-panel.is-ready { border-color: #cce8e3; background: #f5fbfa; }.configuration-panel.is-pending { border-color: #f0d8b6; background: #fffaf2; }
.configuration-state { display: flex; align-items: center; gap: 12px; }.configuration-state > span { display: grid; width: 36px; height: 36px; place-items: center; border-radius: 9px; color: #fff; background: var(--teal); font-size: 18px; }.is-pending .configuration-state > span { background: #e69a43; }.configuration-state strong { font-size: 14px; }.configuration-state p { margin-top: 4px; color: #718094; font-size: 11px; }