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
+27 -27
View File
@@ -230,27 +230,27 @@ export function wecomPromotionDeleteLink(params: { id: number }) {
return request.post({ url: '/firstvisit.wecomPromotion/deleteLink', params })
}
export type WecomPromotionCustomerChatStatus = '' | 'messaged' | 'silent' | 'unknown'
export interface WecomPromotionCustomerStatsParams {
page_no: number
page_size: number
promotion_link_id?: number
userid?: string
chat_status?: 0 | 1 | 2
}
export interface WecomPromotionCustomerStatsSummary {
export type WecomPromotionCustomerChatStatus = '' | 'messaged' | 'silent' | 'unknown'
export interface WecomPromotionCustomerStatsParams {
page_no: number
page_size: number
promotion_link_id?: number
userid?: string
chat_status?: 0 | 1 | 2
}
export interface WecomPromotionCustomerStatsSummary {
customer_count: number
messaged_customer_count: number
message_customer_rate: number
received_message_count: number
message_count_known_count: number
}
export interface WecomPromotionCustomerStatRow {
id?: number
external_userid_masked?: string
received_message_count: number
message_count_known_count: number
}
export interface WecomPromotionCustomerStatRow {
id?: number
userid?: string
external_userid_masked?: string
customer_id_masked?: string
customer_name_masked?: string
link_id?: number | string
@@ -260,21 +260,21 @@ export interface WecomPromotionCustomerStatRow {
department_name?: string
dept_name?: string
has_messaged?: boolean | number
chat_status?: 0 | 1 | 2
message_count_known?: boolean | number
received_message_count?: number
last_synced_at?: string
last_message_at?: string
}
chat_status?: 0 | 1 | 2
message_count_known?: boolean | number
received_message_count?: number
last_synced_at?: string
last_message_at?: string
}
export interface WecomPromotionCustomerStatsResult {
summary?: Partial<WecomPromotionCustomerStatsSummary>
lists?: WecomPromotionCustomerStatRow[]
total?: number
link_options?: Array<{ id: number | string; name: string }>
member_options?: Array<{ id: number; name: string; department_name?: string; dept_name?: string }>
meta?: { last_synced_at?: string }
}
member_options?: Array<{ id: number; userid?: string; name: string; department_name?: string; dept_name?: string }>
meta?: { last_synced_at?: string }
}
/** 获客客户消息统计:服务端继续按当前角色和部门数据范围收窄。 */
export function wecomPromotionCustomerStats(params: WecomPromotionCustomerStatsParams) {
+6 -1
View File
@@ -1,10 +1,13 @@
import request from '@/utils/request'
export type DesktopPackageType = 'archive' | 'inno_setup'
export type DesktopPackage = {
url: string
sha256: string
size: number
filename: string
type: DesktopPackageType
}
export type DesktopWorkstationConfig = {
@@ -22,7 +25,9 @@ export type DesktopWorkstationConfig = {
}
export function getDesktopWorkstationConfig() {
return request.get({ url: '/setting.desktop_workstation/getConfig' }) as Promise<DesktopWorkstationConfig>
return request.get({
url: '/setting.desktop_workstation/getConfig'
}) as Promise<DesktopWorkstationConfig>
}
export function setDesktopWorkstationConfig(params: DesktopWorkstationConfig) {
@@ -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; }
@@ -14,8 +14,9 @@
<div class="leading-6">
桌面端启动后会检测本页发布的版本请上传或填写与
<code>一键打包</code>
产物一致的 ZIP并填入打包目录中的 SHA-256安装包通常超过
200MB优先传到对象存储 / CDN 后粘贴地址
产物一致的安装包并填入打包目录中的 SHA-256Windows 推荐使用
Setup.exe用户点击立即更新后会自动安装并重启macOS 继续使用 ZIP
安装包通常超过 200MB优先传到对象存储 / CDN 后粘贴地址
</div>
</el-alert>
<div class="text-xl font-medium mb-[20px]">升级策略</div>
@@ -92,11 +93,32 @@
class="!border-none mt-4"
>
<div class="text-xl font-medium mb-[20px]">{{ item.label }}</div>
<el-form-item label="安装包类型">
<el-select v-model="formData.packages[item.key].type" class="w-80">
<el-option label="便携压缩包(ZIP" value="archive" />
<el-option
v-if="item.key === 'windows_x64'"
label="Windows 安装程序(Inno Setup EXE"
value="inno_setup"
/>
</el-select>
<div class="form-tips ml-3">
{{
formData.packages[item.key].type === 'inno_setup'
? '推荐:退出客户端后静默安装,必要时显示 Windows 权限确认。'
: '兼容旧版客户端的 ZIP 覆盖更新。'
}}
</div>
</el-form-item>
<el-form-item label="安装包地址">
<div class="w-[640px]">
<el-input
v-model.trim="formData.packages[item.key].url"
placeholder="https://...zip 或站内 uploads/file/... 路径"
:placeholder="
formData.packages[item.key].type === 'inno_setup'
? 'https://.../DoctorWorkstation-Setup-....exe'
: 'https://...zip 或站内 uploads/file/... 路径'
"
/>
</div>
</el-form-item>
@@ -111,11 +133,11 @@
@success="(response: any) => handleUploadSuccess(item.key, response)"
@change="(file: any) => handleUploadFile(item.key, file)"
>
<el-button type="primary" plain>选择 ZIP 并上传</el-button>
<el-button type="primary" plain>选择安装包并上传</el-button>
</upload>
<div class="form-tips">
仅建议上传较小的包大文件请先传到对象存储再把地址和 SHA-256
填到上方客户端只会安装 ZIP
填到上方Windows 自动安装程序必须使用 HTTPS 地址并开启证书校验
</div>
</div>
</el-form-item>
@@ -131,7 +153,11 @@
<div class="w-[480px]">
<el-input
v-model.trim="formData.packages[item.key].filename"
placeholder="DoctorWorkstation-Windows-x64-0.2.0.zip"
:placeholder="
formData.packages[item.key].type === 'inno_setup'
? 'DoctorWorkstation-Setup-Windows-x64-0.2.0.exe'
: 'DoctorWorkstation-Windows-x64-0.2.0.zip'
"
/>
</div>
</el-form-item>
@@ -161,18 +187,20 @@ import type { FormInstance } from 'element-plus'
import {
getDesktopWorkstationConfig,
setDesktopWorkstationConfig,
type DesktopPackage
type DesktopPackage,
type DesktopPackageType
} from '@/api/setting/desktop_workstation'
import Upload from '@/components/upload/index.vue'
import feedback from '@/utils/feedback'
type PlatformKey = 'windows_x64' | 'macos_arm64' | 'macos_x64'
const emptyPackage = (): DesktopPackage => ({
const emptyPackage = (type: DesktopPackageType = 'archive'): DesktopPackage => ({
url: '',
sha256: '',
size: 0,
filename: ''
filename: '',
type
})
const formRef = shallowRef<FormInstance>()
@@ -184,7 +212,7 @@ const formData = reactive({
title: '',
notes: '',
packages: {
windows_x64: emptyPackage(),
windows_x64: emptyPackage('inno_setup'),
macos_arm64: emptyPackage(),
macos_x64: emptyPackage()
}
@@ -242,6 +270,10 @@ const assignPackage = (key: PlatformKey, row: Partial<DesktopPackage> | undefine
current.sha256 = String(row?.sha256 || '')
current.size = Number(row?.size || 0)
current.filename = String(row?.filename || '')
current.type =
row?.type === 'inno_setup' || (key === 'windows_x64' && !row?.url && !row?.filename)
? 'inno_setup'
: 'archive'
}
const getData = async () => {
@@ -270,6 +302,11 @@ const handleUploadFile = async (key: PlatformKey, file: any) => {
if (!raw) return
formData.packages[key].filename = raw.name || formData.packages[key].filename
formData.packages[key].size = Number(raw.size || 0)
if (key === 'windows_x64' && raw.name?.toLowerCase().endsWith('.exe')) {
formData.packages[key].type = 'inno_setup'
} else if (raw.name?.toLowerCase().endsWith('.zip')) {
formData.packages[key].type = 'archive'
}
try {
formData.packages[key].sha256 = await sha256File(raw)
} catch (error) {