@@ -4,21 +4,21 @@
< div class = "heading-copy" >
< span class = "heading-icon" > < el-icon > < Promotion / > < / el-icon > < / span >
< div >
< h1 > 企业微信推广 助手 < / h1 >
< p > 授权 企业微信后 , 将多个推广链接按权重 、 时段和每日上限安全分流 < / p >
< h1 > 企业微信获客 助手 < / h1 >
< p > 对接 企业微信获客助手官方 API , 按角色与部门管理获客成员 , 并将链接安全分流到推广页面 < / p >
< / div >
< / div >
< div class = "heading-actions" >
< span class = "scope-chip" > < el-icon > < Lock / > < / el-icon > { { overview . meta . scope _label || '当前数据范围' } } < / span >
< span v-if = "overview.meta.generated_at" class="update-time" > 更新于 {{ overview.meta.generated_at }} < / span >
< el -button :icon = "Refresh" :loading = "l oading" @click ="loadOverview " > 刷新 < / el -button >
< el -button :icon = "Refresh" : loading = "loading || customerStatsL oading" @click ="refreshCurrentTab " > 刷新 < / el -button >
< / div >
< / header >
< section class = "metric-grid" >
< article class = "metric-card" >
< span class = "metric-icon is-teal" > < el-icon > < OfficeBuilding / > < / el-icon > < / span >
< div > < small > 已授权企业 < / small > < strong > { { overview . summary . authorized _accounts } } < / strong > < p > 凭证仅在服务端加密保存 < / p > < / div >
< div > < small > 内部应用 < / small > < strong > { { overview . summary . configured _apps } } < / strong > < p > 直接读取服务器环境配置 < / p > < / div >
< / article >
< article class = "metric-card" >
< span class = "metric-icon is-blue" > < el-icon > < SetUp / > < / el-icon > < / span >
@@ -35,12 +35,15 @@
< / section >
< section class = "workspace-card" >
< nav class = "tab-nav" aria -label = " 企业微信推广 助手功能 " >
< nav class = "tab-nav" aria -label = " 企业微信获客 助手功能 " >
< button : class = "{ active: activeTab === 'links' }" @click ="activeTab = 'links'" >
< el -icon > < Connection / > < / el-icon > 推广 链接
< el -icon > < Connection / > < / el-icon > 获客助手 链接
< / button >
< button : class = "{ active: activeTab === 'authorization ' }" @click ="activeTab = 'authorization '" >
< el -icon > < Key / > < / el-icon > 企业授权
< button : class = "{ active: activeTab === 'customer-stats ' }" @click ="activeTab = 'customer-stats '" >
< el -icon > < DataAnalysis / > < / el-icon > 获客客户统计
< / button >
< button : class = "{ active: activeTab === 'configuration' }" @click ="activeTab = 'configuration'" >
< el -icon > < Key / > < / el-icon > 应用配置
< / button >
< button : class = "{ active: activeTab === 'install' }" @click ="activeTab = 'install'" >
< el -icon > < DocumentCopy / > < / el-icon > JS 安装
@@ -50,8 +53,8 @@
< div v-if = "activeTab === 'links'" class="tab-content links-tab" >
< div class = "section-heading" >
< div >
< h2 > 推广 链接分流< / h2 >
< p > 访客点击时由服务端从当前可用链接中按权重随机选择 , 页面端不会暴露完整链接池 。 < / p >
< h2 > 获客助手 链接分流< / h2 >
< p > 访客点击时由服务端从当前可用的获客助手 链接中按权重随机选择 , 页面端不会暴露完整链接池 。 < / p >
< / div >
< el-button type = "primary" :icon = "Plus" @click ="openPoolDialog()" > 新建分流方案 < / el -button >
< / div >
@@ -84,23 +87,40 @@
< p > 公开键 : { { selectedPool . public _key } } < / p >
< / div >
< div class = "toolbar-actions" >
< el-button :icon = "CircleCheck" :loading = "checkingApi" @click ="checkApiPermission" > 验证 API < / el -button >
< el-button :icon = "Refresh" :loading = "syncingRemote" @click ="syncRemoteLinks" > 同步企业微信 < / el -button >
< el-button :icon = "DocumentCopy" @click ="copyText(selectedPool.install_code, 'JS 安装代码')" > 复制 JS < / el -button >
< el-button :icon = "Edit" @click ="openPoolDialog(selectedPool)" > 编辑方案 < / el -button >
< el-button type = "danger" plain :icon = "Delete" @click ="removePool(selectedPool)" > 删除 < / el -button >
< el-button type = "primary" :icon = "Plus" @click ="openLinkDialog()" > 添加推广 链接< / el -button >
< el-button type = "primary" :icon = "Plus" @click ="openLinkDialog()" > 创建官方获客 链接< / el -button >
< / div >
< / div >
< el-alert
v-if = "selectedLegacyCount > 0 && selectedOfficialCount === 0"
class = "legacy-sync-alert"
type = "warning"
show -icon
:closable = "false"
: title = "`当前方案的 ${selectedLegacyCount} 条链接都是历史手工链接,企业微信同步不会返回它们`"
description = "官方接口只返回当前应用通过 API 创建的获客链接。现有链接仍可参与本地分流;如需同步 link_id、客户和官方统计,请点击“创建官方获客链接”。"
/ >
< el-table :data = "selectedLinks" class = "link-table" stripe >
< el-table-column label = "推广成员 / 分组" min -width = " 180 " fixed = "left" >
< template # default = "{ row }" >
< div class = "member-cell" > < strong > { { row . name } } < / strong > < small > { { row . group _name || '默认分组' } } < / small > < / div >
< / template >
< / el-table-column >
< el-table-column label = "授权 企业" min -width = " 150 " >
< el-table-column label = "企业微信链接 ID " min -width = " 180 " >
< template # default = "{ row }" >
< span v-if = "row.account_id" > {{ row.corp_name | | ' 授权已失效 ' }} < / span >
< span v-else class = "muted " > 未关联 < / span >
< span v-if = "row.remote_link_id" class="remote-id" :title="row.remote_link_id" > {{ row.remote_link_id }} < / span >
< span v-else class = "legacy-tag " > 历史手工链接 < / span >
< / template >
< / el-table-column >
< el-table-column label = "可用成员" min -width = " 150 " >
< template # default = "{ row }" >
< span > { { remoteMemberLabel ( row ) } } < / span >
< / template >
< / el-table-column >
< el-table-column label = "权重" prop = "weight" width = "84" align = "center" / >
@@ -115,77 +135,189 @@
< / el-table-column >
< el-table-column label = "上线" width = "90" align = "center" >
< template # default = "{ row }" >
< el-switch : model -value = " Number ( row.status ) = = = 1 " @change ="(value) => handleToggle(row, value)" / >
< el-switch : model -value = " Number ( row.status ) = = = 1 " : disabled = "Number(row.remote_status) === 2" @change ="(value) => handleToggle(row, value)" / >
< / template >
< / el-table-column >
< el-table-column label = "操作" min -width = " 132 " fixed = "right" >
< el-table-column label = "操作" min -width = " 245 " fixed = "right" >
< template # default = "{ row }" >
< el-button type = "primary" link @click ="openLinkDialog (row)"> 编辑 < / el -button >
< el-button type = "danger" link @click ="removeLink (row)"> 删除 < / el -button >
< el-button v-if = "row.is_official && Number(row.remote_status) !== 2" type="primary" link :loading="refreshingLinkId === Number(row.id)" @click="refreshRemoteDetail (row)" > 刷新详情 < / el -button >
< el-button v-if = "!row.is_official || Number(row.remote_status) !== 2" type="primary" link @click="openLinkDialog (row)" > 编辑 < / el -button >
< el-dropdown v-if = "row.is_official" trigger="click" @command="(command) => handleLinkCommand(command, row)" >
< el -button type = "primary" link > 更多 < el-icon class = "el-icon--right" > < ArrowDown / > < / el-icon > < / el-button >
< template # dropdown >
< el-dropdown-menu >
< el-dropdown-item command = "local" > 从本地移除 < / el-dropdown-item >
< el-dropdown-item v-if = "Number(row.remote_status) !== 2" command="remote" divided class="danger-command" > 永久删除企业微信链接 < / el -dropdown -item >
< / el-dropdown-menu >
< / template >
< / el-dropdown >
< el-button v-else type = "danger" link @click ="removeLink(row)" > 从本地移除 < / el -button >
< / template >
< / el-table-column >
< template # empty > < el-empty :image-size = "72" description = "当前方案还没有推广 链接" / > < / template >
< template # empty > < el-empty :image-size = "72" description = "当前方案还没有获客助手 链接" / > < / template >
< / el-table >
< / div >
< / div >
< el-empty v-else description = "先创建一个分流方案,再添加企业微信推广 链接" >
< el-empty v-else description = "先创建一个分流方案,再添加企业微信获客助手 链接" >
< el-button type = "primary" :icon = "Plus" @click ="openPoolDialog()" > 创建第一个方案 < / el -button >
< / el-empty >
< / div >
< div v-else-if = "activeTab === 'authorization '" class="tab-content authorization -tab" >
< div v-else-if = "activeTab === 'customer-stats '" class="tab-content customer-stats -tab" >
< div class = "section-heading customer-stats-heading" >
< 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 >
< section class = "customer-metric-grid" aria -label = " 获客客户消息汇总 " >
< article class = "customer-metric-card" >
< span class = "metric-icon is-teal" > < el-icon > < User / > < / el-icon > < / span >
< div > < small > 获客客户数 < / small > < strong > { { formatNumber ( customerStats . summary . customer _count ) } } < / strong > < p > 当前筛选范围内的去重客户 < / p > < / div >
< / article >
< article class = "customer-metric-card" >
< span class = "metric-icon is-blue" > < el-icon > < ChatDotRound / > < / el-icon > < / span >
< div > < small > 已发消息客户数 < / small > < strong > { { formatNumber ( customerStats . summary . messaged _customer _count ) } } < / strong > < p > 至少发送过一条消息 < / p > < / div >
< / article >
< article class = "customer-metric-card" >
< 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 >
< el-alert
v-if = "!overview.config.callback_ready"
class = "chatkey-alert"
type = "warning"
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 分钟内有效。服务端收到企业微信回调后会立即拉取会话详情;手动同步用于刷新客户清单,无法补取已过期的会话消息。"
/ >
< div class = "customer-filter-bar" >
< el-form :model = "customerFilters" inline label -position = " top " >
< el-form-item label = "来源链接" >
< el-select v-model = "customerFilters.link_id" clearable filterable placeholder="全部链接" >
< el -option v-for = "option in customerLinkOptions" :key="String(option.id)" :label="option.name" :value="option.id" / >
< / el-select >
< / el-form-item >
< el-form-item label = "承接成员" >
< el-select v-model = "customerFilters.userid" clearable filterable placeholder="全部成员" >
< 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-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-form-item class = "filter-actions" >
< el-button type = "primary" :icon = "Search" :loading = "customerStatsLoading" @click ="applyCustomerFilters" > 查询 < / el -button >
< el-button @click ="resetCustomerFilters" > 重置 < / el -button >
< / el-form-item >
< / el-form >
< / div >
< el-table v-loading = "customerStatsLoading" :data="customerStats.rows" class="customer-table" stripe >
< el-table-column label = "客户标识(已脱敏)" min -width = " 190 " fixed = "left" >
< template # default = "{ row }" >
< span class = "customer-identifier" :title = "customerIdentifier(row)" > { { customerIdentifier ( row ) } } < / span >
< / template >
< / el-table-column >
< el-table-column label = "来源链接" min -width = " 190 " >
< template # default = "{ row }" >
< 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" >
< 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 = "最近同步时间" 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 " >
< 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 >
< / el-table >
< div class = "customer-pagination" >
< span > 共 { { formatNumber ( customerPager . total ) } } 位客户 < / span >
< el-pagination
v -model :current-page = "customerPager.page_no"
v -model :page-size = "customerPager.page_size"
background
layout = "sizes, prev, pager, next"
: page -sizes = " [ 10 , 20 , 50 , 100 ] "
:total = "customerPager.total"
@ current -change = " loadCustomerStats "
@ size -change = " handleCustomerPageSizeChange "
/ >
< / div >
< / div >
< div v-else-if = "activeTab === 'configuration'" class="tab-content authorization-tab" >
< div class = "section-heading" >
< div >
< h2 > 企业微信第三方应用授权 < / h2 >
< p > 永久授权码 、 suite _ticket 等敏感凭证全部加密存放 , 前端接口只返回企业名称和脱敏 CorpID 。 < / p >
< h2 > 企业微信内部应用配置 < / h2 >
< p > 直接读取服务器现有的 work _wechat 配置 , 密钥不会返回浏览器 , 也不需要第三方应用授权 。 < / p >
< / div >
< el-button
v-if = "overview.meta.can_authorize"
type = "primary"
:icon = "CirclePlus"
:loading = "authorizing"
:disabled = "!overview.config.ready"
@click ="startAuthorization"
> 授权新企业 < / el-button >
< / div >
< div class = "configuration-panel" : class = "overview.config.ready ? 'is-ready' : 'is-pending'" >
< div class = "configuration-state" >
< span > < el-icon > < component : is = "overview.config.ready ? CircleCheck : Warning" / > < / el-icon > < / span >
< div >
< strong > { { overview . config . ready ? '授权通道 已就绪' : '授权通道等待配置 ' } } < / strong >
< p v-if = "overview.config.ready" > 已收到企业微信 suite_ticket , 可以发起第三方应用安装授权 。 < / p >
< p v-else-if = "overview.config.configured" > 配置已保存 , 尚未收到 suite_ticket , 请核对应用指令回调地址 。 < / p >
< p v-else > 请由运维按部署文档补齐服务商参数 ; 页面不会展示任何密钥值 。 < / p >
< strong > { { overview . config . ready ? '内部应用 已就绪' : '内部应用配置不完整 ' } } < / strong >
< p v-if = "overview.config.ready" > 已识别 CorpID 与应用 Secret ; 点击 “ 验证 API ” 可同时检查可信 IP 、 获客助手开通状态和应用权限 。 < / p >
< p v-else > 请补齐服务器 .env 的 [ work_wechat ] 配置 ; 页面不会展示 Secret 内容 。 < / p >
< / div >
< / div >
< div v-if = "overview.config.missing?.length" class="missing-fields" >
缺少 : < code v-for = "item in overview.config.missing" :key="item" > {{ item }} < / code >
< / div >
< dl class = "callback-list" >
< div > < dt > 应用指令回调 < / dt > < dd > { { overview . config . provider _callback _url || '— ' } } < / dd > < button @click ="copyText(overview.config.provider_callback_url, '应用指令回调')" > 复制 < / button > < / div >
< div > < dt > 授权完成回调 < / dt > < dd > { { overview . config . auth _callback _url || '— ' } } < / dd > < button @click ="copyText(overview.config.auth_callback_url, '授权完成回调')" > 复制 < / button > < / div >
< div > < dt > 企业 CorpID < / dt > < dd > { { overview . config . corp _id _masked || '未配置 ' } } < / dd > < span > { { overview . config . corp _id _masked ? '已识别' : '缺失' } } < / span > < / div >
< div > < dt > 应用 AgentID < / dt > < dd > { { overview . config . agent _id || '未配置 ' } } < / dd > < span > { { overview . config . agent _id ? '已识别' : '缺失' } } < / span > < / div >
< div > < dt > 应用 Secret < / dt > < dd > 仅保存在服务器环境变量中 < / dd > < span > { { overview . config . secret _configured ? '已配置' : '缺失' } } < / span > < / div >
< div > < dt > 消息回调地址 < / dt > < dd > { { overview . config . callback _url || '未生成' } } < / dd > < span > { { overview . config . callback _ready ? '已配置密钥' : '待配置 Token/AESKey' } } < / span > < / div >
< / dl >
< div class = "configuration-actions" >
< el-button type = "primary" :loading = "checkingApi" @click ="checkApiPermission" > 验证获客助手 API < / el -button >
< el-link v-if = "overview.config.official_doc" :href="overview.config.official_doc" target="_blank" type="primary" > 查看企业微信官方文档 < / el -link >
< / div >
< / div >
< el -table :data = "overview.accounts" class = "account-table" stripe >
< el-table-column prop = "corp_name" label = "企业微信名称" min -width = " 180 " fixed = "left" >
< template # default = "{ row }" > < strong > { { row . corp _name } } < / strong > < / template >
< / el-table-column >
< el-table-column prop = "corp_id_masked" label = "CorpID(脱敏)" min -width = " 190 " / >
< el-table-column prop = "agent_id" label = "应用 AgentID" min -width = " 130 " >
< template # default = "{ row }" > { { row . agent _id || '—' } } < / template >
< / el-table-column >
< el-table-column label = "授权状态" min -width = " 110 " >
< template # default = "{ row }" > < span class = "availability" : class = "Number(row.auth_status) === 1 ? 'is-ok' : 'is-error'" > { { Number ( row . auth _status ) === 1 ? '有效' : '已取消' } } < / span > < / template >
< / el-table-column >
< el-table-column label = "授权时间" min -width = " 170 " > < template # default = "{ row }" > { { formatTime ( row . authorized _at ) } } < / template > < / el-table-column >
< el-table-column label = "最近校验" min -width = " 170 " > < template # default = "{ row }" > { { formatTime ( row . last _refresh _at ) } } < / template > < / el-table-column >
< el-table-column v-if = "overview.meta.can_authorize" label="操作" width="110" fixed="right" >
< template # default = "{ row }" > < el-button type = "primary" link : loading = "verifyingId === Number(row.id)" @click ="verifyAccount(row)" > 验证凭证 < / el -button > < / template >
< / el-table-column >
< template # empty > < el-empty :image-size = "72" description = "尚未授权企业微信" / > < / template >
< / el-table >
< / div >
< div v-else class = "tab-content install-tab" >
@@ -214,7 +346,7 @@
< / div >
< div class = "rule-panel" >
< div > < el-icon > < Select / > < / el-icon > < span > < strong > 可用性筛选 < / strong > 自动排除停用 、 授权失效 、 超出时间段和达到每日上限的链接 。 < / span > < / div >
< div > < el-icon > < Select / > < / el-icon > < span > < strong > 可用性筛选 < / strong > 自动排除停用 、 超出时间段和达到每日上限的链接 。 < / span > < / div >
< div > < el-icon > < Opportunity / > < / el-icon > < span > < strong > 权重随机 < / strong > 权重越高 , 被选中的概率越大 ; 没有可用链接时才使用兜底链接 。 < / span > < / div >
< div > < el-icon > < View / > < / el-icon > < span > < strong > 隐私与安全 < / strong > 前端看不到完整链接池 ; 访问 IP 只保存带服务端密钥的不可逆哈希 。 < / span > < / div >
< / div >
@@ -231,37 +363,39 @@
< el-dialog v-model = "poolDialogVisible" :title="poolForm.id ? '编辑分流方案' : '新建分流方案'" width="560px" destroy -on -close >
< el-form label -position = " top " >
< el-form-item label = "方案名称" required > < el-input v-model = "poolForm.name" maxlength="60" show-word-limit placeholder="例如:官网咨询分流" / > < / el-form-item >
< el-form-item label = "兜底企业微信 链接" >
< el-form-item label = "兜底获客助手 链接" >
< el-input v-model = "poolForm.fallback_url" placeholder="可选;没有可用成员时跳转" / >
< span class = "form-tip" > 仅允许配置页列出的企业微信 HTTPS 域名 。 < / span >
< span class = "form-tip" > 仅接受企业微信获客助手生成的 https : //work.weixin.qq.com/ca/... 链接。</span>
< / el-form-item >
< el-form-item label = "运行状态" > < el-switch v-model = "poolForm.status" :active-value="1" :inactive-value="0" active-text="运行" inactive-text="停用" / > < / el-form-item >
< / el-form >
< template # footer > < el-button @click ="poolDialogVisible = false" > 取消 < / el -button > < el-button type = "primary" :loading = "savingPool" @click ="savePool" > 保存方案 < / el -button > < / template >
< / el-dialog >
< el-dialog v-model = "linkDialogVisible" :title="linkForm.id ? '编辑推广 链接' : '添加推广 链接'" width="68 0px" destroy -on -close >
< el-dialog v-model = "linkDialogVisible" :title="linkForm.id ? '编辑获客助手 链接' : '创建企业微信官方获客 链接'" width="72 0px" destroy -on -close >
< el-form label -position = " top " class = "link-form" >
< div class = "form-grid" >
< el-form-item label = "推广成员 名称" required > < el-input v-model = "linkForm.name" maxlength="80" placeholder="用于内部 识别" / > < / el-form-item >
< el-form-item label = "获客链接 名称" required > < el-input v-model = "linkForm.name" maxlength="80" placeholder="用于企业微信和后台 识别" / > < / el-form-item >
< el-form-item label = "分组" > < el-input v-model = "linkForm.group_name" maxlength="60" placeholder="默认分组" / > < / el-form-item >
< / div >
< el-form-item label = "企业微信推广链接" required >
< el-input v-model = "linkForm.wecom_url" type="textarea" :rows="2" placeholder="粘贴 企业微信成员联系我、客户群或其他允许的推广链接" / >
< span class = "form-tip" > 允许域名 : { { overview . allowed _link _hosts . join ( '、' ) || '请先配置允许域名' } } < / span >
< el-form-item v-if = "!linkForm.legacy" label="获客成员" required >
< el-select v-model = "linkForm.member_admin_ids" multiple filterable collapse-tags collapse-tags-tooltip placeholder="仅显示当前角色和部门可管理、且已绑定 企业微信的 成员" style="width: 100%" >
< el -option v-for = "member in overview.member_options" :key="member.id" :label="memberOptionLabel(member)" :value="Number(member.id)" / >
< / el-select >
< span class = "form-tip" > 官方链接按这里的企业微信 userid 建立可见范围 ; 越权成员不会出现在列表中 。 < / span >
< / el-form-item >
< el-form-item v-else label = "企业微信获客助手链接" required >
< el-input v-model = "linkForm.wecom_url" type="textarea" :rows="2" :placeholder="overview.customer_acquisition_link_example" / >
< span class = "form-tip" > 这是历史手工链接 , 只更新本地分流规则 , 不会调用企业微信创建或修改接口 。 < / span >
< / el-form-item >
< div class = "form-grid" >
< el-form-item label = "关联授权企业" >
< el-select v-model = "linkForm.account_id" clearable placeholder="可选;用于授权失效联动下线" >
< el -option v-for = "account in activeAccounts" :key="account.id" :label="account.corp_name" :value="Number(account.id)" / >
< / el-select >
< / el-form-item >
< el-form-item label = "分流权重" required > < el-input-number v-model = "linkForm.weight" :min="1" :max="100" controls-position="right" / > < / el-form-item >
< / div >
< div class = "form-grid" >
< el-form-item label = "每日点击上限" > < el-input-number v-model = "linkForm.daily_limit" :min="0" :max="1000000" controls-position="right" /><span class="form-tip" > 0 表示不限 < / span > < / el -form -item >
< el-form-item label = "上线状态" > < el-switch v-model = "linkForm.status" :active-value="1" :inactive-value="0" active-text="上线" inactive-text="下线" / > < / el-form-item >
< / div >
< el-form-item v-if = "!linkForm.legacy" label="添加客户时跳过验证" >
< el -switch v-model = "linkForm.skip_verify" :active-value="1" :inactive-value="0" active-text="跳过验证" inactive-text="需要验证" / >
< / el-form-item >
< el-form-item label = "上线状态" > < el-switch v-model = "linkForm.status" :active-value="1" :inactive-value="0" active-text="上线" inactive-text="下线" / > < / el-form-item >
< el-form-item label = "生效时间段" >
< el-date-picker v-model = "linkForm.active_range" type="datetimerange" value-format="YYYY-MM-DD HH:mm:ss" start-placeholder="开始时间" end-placeholder="结束时间" range-separator="至" / >
< span class = "form-tip" > 不选择表示长期有效 < / span >
@@ -275,52 +409,85 @@
< script setup lang = "ts" name = "firstVisitWecomPromotionPage" >
import { computed , onMounted , reactive , ref , watch } from 'vue'
import { useRoute , useRouter } from 'vue-router'
import { ElMessage , ElMessageBox } from 'element-plus'
import {
ArrowRight , CircleCheck , CirclePlus , Connection , Delete , DocumentCopy , Edit , Key , Link ,
Lock , Mouse , OfficeBuilding , Opportunity , Plus , Promotion , Refresh , Select , SetUp ,
TopRight , View , Warning
ArrowDown , ArrowRight , ChatDotRound , CircleCheck , Connection , DataAnalysis , Delete ,
DocumentCopy , Edit , Key , Link , Lock , Mouse , OfficeBuilding , Opportunity , Plus ,
Promotion , Refresh , Search , Select , SetUp , TopRight , User , View , Warning
} from '@element-plus/icons-vue'
import {
wecomPromotionAuthorizationUrl ,
wecomPromotionCheckApiPermission ,
wecomPromotionCustomerStats ,
wecomPromotionDeleteLink ,
wecomPromotionDeleteRemoteLink ,
wecomPromotionDeletePool ,
wecomPromotionOverview ,
wecomPromotionRemoteLinkDetail ,
wecomPromotionSaveLink ,
wecomPromotionSavePool ,
wecomPromotionToggleLink ,
wecomPromotionVerifyAccount
wecomPromotionSyncCustomers ,
wecomPromotionSyncRemoteLinks ,
wecomPromotionToggleLink
} from '@/api/first_visit'
import type { WecomPromotionCustomerChatStatus } from '@/api/first_visit'
type TabName = 'links' | 'authoriz ation' | 'install'
type TabName = 'links' | 'customer-stats' | 'configur ation' | 'install'
const emptyOverview = ( ) => ( {
meta : { scope _label : '' , can _authorize : false , generated _at : '' } ,
config : { enabled : false , configured : false , ready : false , missing : [ ] as string [ ] , suite _id _masked : '' , ticket _received _at : 0 , provider _callback _url : '' , auth _callback _url : '' } ,
summary : { authorized _accounts : 0 , pool _count : 0 , online _links : 0 , today _clicks : 0 } ,
accounts : [ ] as any [ ] , pools : [ ] as any [ ] , links : [ ] as any [ ] , allowed _link _hosts : [ ] as string [ ]
meta : { scope _label : '' , generated _at : '' } ,
config : { mode : 'internal' , configured : false , ready : false , missing : [ ] as string [ ] , corp _id _masked : '' , agent _id : '' , secret _configured : false , official _doc : '' } ,
summary : { configured _apps : 0 , pool _count : 0 , online _links : 0 , today _clicks : 0 } ,
pools : [ ] as any [ ] , links : [ ] as any [ ] , member _options : [ ] as any [ ] , customer _acquisition _link _example : 'https://work.weixin.qq.com/ca/xxxxxxxx'
} )
const route = useRoute ( )
const router = useRouter ( )
const overview = reactive ( emptyOverview ( ) )
const loading = ref ( false )
const activeTab = ref < TabName > ( 'links' )
const selectedPoolId = ref < number > ( )
const selectedInstallPoolId = ref < number > ( )
const authorizing = ref ( false )
const verifyingId = ref ( 0 )
const poolDialogVisible = ref ( false )
const linkDialogVisible = ref ( false )
const savingPool = ref ( false )
const savingLink = ref ( false )
const checkingApi = ref ( false )
const syncingRemote = ref ( false )
const refreshingLinkId = ref ( 0 )
const poolForm = reactive ( { id : 0 , name : '' , fallback _url : '' , status : 1 } )
const linkForm = reactive ( { id : 0 , pool _id : 0 , account _id : undefined as number | undefined , name : '' , group _name : '默认分组' , wecom _url : '' , weight : 1 , status : 1 , daily _limit : 0 , active _range : [ ] as string [ ] , remark : '' } )
const linkForm = reactive ( { id : 0 , pool _id : 0 , name : '' , group _name : '默认分组' , wecom _url : '' , member _admin _ids : [ ] as number [ ] , skip _verify : 0 , legacy : false , weight : 1 , status : 1 , daily _limit : 0 , active _range : [ ] as string [ ] , remark : '' } )
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 } ,
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 : '' }
} )
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 ) )
const activeAccounts = computed ( ( ) => overview . accounts . filter ( ( item : any ) => Number ( item . auth _status ) === 1 ) )
const selectedOfficialCount = computed ( ( ) => selectedLinks . value . filter ( ( item : any ) => Boolean ( item . remote _link _id ) ) . length )
const selectedLegacyCount = computed ( ( ) => selectedLinks . value . length - selectedOfficialCount . value )
const selectedInstallPool = computed ( ( ) => overview . pools . find ( ( item : any ) => Number ( item . id ) === selectedInstallPoolId . value ) )
const customerLinkOptions = computed ( ( ) => customerStats . link _options . length
? customerStats . link _options
: overview . links
. filter ( ( item : any ) => item . id )
. map ( ( item : any ) => ( { id : Number ( item . id ) , name : item . name || item . remote _link _id || ` 链接 ${ item . id } ` } ) ) )
const customerMemberOptions = computed ( ( ) => customerStats . member _options . length
? customerStats . member _options
: overview . member _options . map ( ( item : any ) => ( {
id : Number ( item . id ) ,
userid : String ( item . userid || '' ) ,
name : item . name ,
department _name : Array . isArray ( item . dept _names ) ? item . dept _names . join ( ' / ' ) : ( item . department _name || item . dept _name || '' )
} ) ) )
watch ( ( ) => overview . pools . map ( ( item : any ) => Number ( item . id ) ) . join ( ',' ) , ( ) => {
const ids = overview . pools . map ( ( item : any ) => Number ( item . id ) )
@@ -328,6 +495,10 @@ watch(() => overview.pools.map((item: any) => Number(item.id)).join(','), () =>
if ( ! selectedInstallPoolId . value || ! ids . includes ( selectedInstallPoolId . value ) ) selectedInstallPoolId . value = ids [ 0 ]
} , { immediate : true } )
watch ( activeTab , ( tab ) => {
if ( tab === 'customer-stats' && ! customerStatsLoaded . value ) loadCustomerStats ( )
} )
async function loadOverview ( ) {
loading . value = true
try {
@@ -366,7 +537,7 @@ async function savePool() {
}
async function removePool ( pool : any ) {
await ElMessageBox . confirm ( ` 删除“ ${ pool . name } ”后,该方案下的推广 链接也会停用。确认继续? ` , '删除分流方案' , { type : 'warning' } )
await ElMessageBox . confirm ( ` 删除“ ${ pool . name } ”后,该方案下的获客助手 链接也会停用。确认继续? ` , '删除分流方案' , { type : 'warning' } )
try {
await wecomPromotionDeletePool ( { id : Number ( pool . id ) } )
ElMessage . success ( '分流方案已删除' )
@@ -380,39 +551,216 @@ function openLinkDialog(row?: any) {
if ( ! selectedPool . value ) return
const range = row && Number ( row . active _start ) > 0 && Number ( row . active _end ) > 0
? [ formatPickerTime ( row . active _start ) , formatPickerTime ( row . active _end ) ] : [ ]
const selectedUserIds = new Set < string > ( ( row ? . range _userids || [ ] ) . map ( ( item : unknown ) => String ( item ) ) )
const selectedMemberIds = overview . member _options
. filter ( ( member : any ) => selectedUserIds . has ( String ( member . userid ) ) )
. map ( ( member : any ) => Number ( member . id ) )
Object . assign ( linkForm , row ? {
id : Number ( row . id ) , pool _id : Number ( row . pool _id ) , account _id : Number ( row . account _id ) || undefined ,
id : Number ( row . id ) , pool _id : Number ( row . pool _id ) ,
name : row . name , group _name : row . group _name || '默认分组' , wecom _url : row . wecom _url ,
member _admin _ids : selectedMemberIds , skip _verify : Number ( row . skip _verify ) === 1 ? 1 : 0 , legacy : ! row . is _official ,
weight : Number ( row . weight ) || 1 , status : Number ( row . status ) , daily _limit : Number ( row . daily _limit ) || 0 ,
active _range : range , remark : row . remark || ''
} : {
id : 0 , pool _id : Number ( selectedPool . value . id ) , account _id : undefined , name : '' , group _name : '默认分组' ,
wecom _url : '' , weight : 1 , status : 1 , daily _limit : 0 , active _range : [ ] , remark : ''
id : 0 , pool _id : Number ( selectedPool . value . id ) , name : '' , group _name : '默认分组' ,
wecom _url : '' , member _admin _ids : [ ] , skip _verify : 0 , legacy : false ,
weight : 1 , status : 1 , daily _limit : 0 , active _range : [ ] , remark : ''
} )
linkDialogVisible . value = true
}
async function saveLink ( ) {
if ( ! linkForm . name . trim ( ) ) return ElMessage . warning ( '请输入推广成员 名称' )
if ( ! linkForm . wecom _url . trim ( ) ) return ElMessage . warning ( '请粘贴 企业微信推广 链接' )
if ( ! linkForm . name . trim ( ) ) return ElMessage . warning ( '请输入获客链接 名称' )
if ( linkForm . legacy && ! linkForm . wecom _url . trim ( ) ) return ElMessage . warning ( '请填写 企业微信获客助手 链接' )
if ( ! linkForm . legacy && ! linkForm . member _admin _ids . length ) return ElMessage . warning ( '请至少选择一名获客成员' )
savingLink . value = true
try {
await wecomPromotionSaveLink ( {
... linkForm ,
account _id : linkForm . account _id || 0 ,
active _start : linkForm . active _range ? . [ 0 ] || '' ,
active _end : linkForm . active _range ? . [ 1 ] || ''
} )
linkDialogVisible . value = false
await loadOverview ( )
ElMessage . success ( '推广 链接已保存')
ElMessage . success ( linkForm . legacy ? '本地分流规则已保存' : '企业微信官方获客 链接已保存')
} catch ( error : any ) {
ElMessage . error ( error ? . message || '推广 链接保存失败' )
ElMessage . error ( error ? . message || '获客助手 链接保存失败' )
} finally {
savingLink . value = false
}
}
async function checkApiPermission ( ) {
checkingApi . value = true
try {
const result : any = await wecomPromotionCheckApiPermission ( )
if ( result ? . has _link ) {
ElMessage . success ( result ? . message || 'API 验证通过:应用凭证、可信 IP 与获客助手权限均可用' )
} else {
ElMessage . warning ( result ? . message || 'API 权限可用,但当前应用还没有创建官方获客链接' )
}
} catch ( error : any ) {
ElMessage . error ( error ? . message || '获客助手 API 验证失败' )
} finally {
checkingApi . value = false
}
}
async function refreshCurrentTab ( ) {
await loadOverview ( )
if ( activeTab . value === 'customer-stats' ) await loadCustomerStats ( )
}
function numberFrom ( source : Record < string , any > , keys : string [ ] ) {
for ( const key of keys ) {
if ( source ? . [ key ] !== undefined && source ? . [ key ] !== null && source ? . [ key ] !== '' ) {
const value = Number ( source [ key ] )
return Number . isFinite ( value ) ? value : 0
}
}
return 0
}
async function loadCustomerStats ( ) {
customerStatsLoading . value = true
try {
const result : any = await wecomPromotionCustomerStats ( {
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 ) )
} )
const summary = result ? . summary || result ? . stats || { }
const customerCount = numberFrom ( summary , [ 'customer_count' , 'total_customers' , 'customer_total' , 'total' ] )
const messagedCount = numberFrom ( summary , [ 'messaged_customer_count' , 'started_chat_count' , 'chat_customer_count' , 'message_customer_count' , 'chatting_customers' ] )
const rateKeys = [ 'message_customer_rate' , 'chat_customer_rate' , 'messaged_rate' , 'chat_rate' ]
const hasRate = rateKeys . some ( ( key ) => summary ? . [ key ] !== undefined && summary ? . [ key ] !== null && summary ? . [ key ] !== '' )
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' ] )
} )
customerStats . rows = Array . isArray ( result ? . lists )
? result . lists
: ( Array . isArray ( result ? . list ) ? result . list : ( Array . isArray ( result ? . rows ) ? result . rows : [ ] ) )
customerPager . total = Number ( result ? . total ? ? result ? . count ? ? result ? . page ? . total ? ? customerStats . rows . length ) || 0
const filters = result ? . filters || { }
const linkOptions = result ? . link _options || filters ? . link _options || filters ? . links
const memberOptions = result ? . member _options || filters ? . member _options || filters ? . members
if ( Array . isArray ( linkOptions ) ) customerStats . link _options = linkOptions
if ( Array . isArray ( memberOptions ) ) customerStats . member _options = memberOptions
const latestSyncTime = customerStats . rows . reduce ( ( latest : unknown , row : any ) => {
const current = row . last _synced _at || row . last _sync _time || row . synced _at || row . sync _time || ''
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 ) : ''
} )
customerStatsLoaded . value = true
} catch ( error : any ) {
ElMessage . error ( error ? . message || '获客客户统计加载失败' )
} finally {
customerStatsLoading . value = false
}
}
function applyCustomerFilters ( ) {
customerPager . page _no = 1
loadCustomerStats ( )
}
function resetCustomerFilters ( ) {
Object . assign ( customerFilters , { link _id : '' , userid : '' , chat _status : '' } )
customerPager . page _no = 1
loadCustomerStats ( )
}
function handleCustomerPageSizeChange ( ) {
customerPager . page _no = 1
loadCustomerStats ( )
}
async function syncCustomers ( ) {
syncingCustomers . value = true
try {
const result : any = await wecomPromotionSyncCustomers ( {
promotion _link _id : customerFilters . link _id ? Number ( customerFilters . link _id ) : undefined
} )
const scanned = Number ( result ? . scanned ? ? result ? . customer _scanned ? ? result ? . customers ? . scanned ? ? 0 )
const created = Number ( result ? . created ? ? result ? . customer _created ? ? result ? . customers ? . created ? ? 0 )
const updated = Number ( result ? . updated ? ? result ? . customer _updated ? ? result ? . customers ? . updated ? ? 0 )
ElMessage . success ( result ? . message || ` 客户同步完成:扫描 ${ formatNumber ( scanned ) } 位,新增 ${ formatNumber ( created ) } 位,更新 ${ formatNumber ( updated ) } 位 ` )
customerPager . page _no = 1
await Promise . all ( [ loadOverview ( ) , loadCustomerStats ( ) ] )
} catch ( error : any ) {
ElMessage . error ( error ? . message || '获客客户同步失败' )
} finally {
syncingCustomers . value = false
}
}
async function syncRemoteLinks ( ) {
if ( ! selectedPool . value ) return ElMessage . warning ( '请先选择分流方案' )
syncingRemote . value = true
try {
const result : any = await wecomPromotionSyncRemoteLinks ( { pool _id : Number ( selectedPool . value . id ) } )
const message = ` 扫描 ${ Number ( result ? . scanned || 0 ) } 条,新建 ${ Number ( result ? . created || 0 ) } 条,更新 ${ Number ( result ? . updated || 0 ) } 条,按权限跳过 ${ Number ( result ? . skipped || 0 ) } 条,失败 ${ Number ( result ? . failed || 0 ) } 条 `
if ( Number ( result ? . scanned || 0 ) === 0 ) {
await ElMessageBox . alert (
` ${ result ? . empty _reason || '当前应用没有可同步的官方获客链接' } \ n \ n ${ result ? . suggestion || '' } ` ,
'没有可同步的官方链接' ,
{ type : 'warning' , confirmButtonText : '知道了' }
)
} else {
result ? . failed ? ElMessage . warning ( message ) : ElMessage . success ( message )
}
await loadOverview ( )
} catch ( error : any ) {
ElMessage . error ( error ? . message || '同步企业微信获客链接失败' )
} finally {
syncingRemote . value = false
}
}
async function refreshRemoteDetail ( row : any ) {
refreshingLinkId . value = Number ( row . id )
try {
await wecomPromotionRemoteLinkDetail ( { id : Number ( row . id ) } )
ElMessage . success ( '企业微信官方详情已刷新' )
await loadOverview ( )
} catch ( error : any ) {
ElMessage . error ( error ? . message || '刷新官方详情失败' )
} finally {
refreshingLinkId . value = 0
}
}
async function handleLinkCommand ( command : string , row : any ) {
if ( command === 'local' ) {
await removeLink ( row )
return
}
if ( command !== 'remote' ) return
await ElMessageBox . confirm (
` 将永久删除企业微信后台中的“ ${ row . name } ”,现有推广页面会立即失效,且无法恢复。确认继续? ` ,
'永久删除企业微信获客链接' ,
{ type : 'error' , confirmButtonText : '永久删除' , cancelButtonText : '取消' }
)
try {
await wecomPromotionDeleteRemoteLink ( { id : Number ( row . id ) } )
ElMessage . success ( '企业微信获客链接已永久删除,本地记录已保留并停止分流' )
await loadOverview ( )
} catch ( error : any ) {
ElMessage . error ( error ? . message || '永久删除失败' )
}
}
async function handleToggle ( row : any , value : unknown ) {
try {
await wecomPromotionToggleLink ( { id : Number ( row . id ) , status : value ? 1 : 0 } )
@@ -426,45 +774,21 @@ async function handleToggle(row: any, value: unknown) {
}
async function removeLink ( row : any ) {
await ElMessageBox . confirm ( ` 确认删除推广链接 “${ row . name } ”? ` , '删除推广链接 ' , { type : 'warning' } )
await ElMessageBox . confirm ( ` 仅从本地分流池移除 “${ row . name } ”,不会删除企业微信后台的官方链接。确认继续 ? ` , '从本地移除 ' , { type : 'warning' } )
try {
await wecomPromotionDeleteLink ( { id : Number ( row . id ) } )
ElMessage . success ( '推广链接已删 除' )
ElMessage . success ( '已从本地分流池移 除' )
await loadOverview ( )
} catch ( error : any ) {
ElMessage . error ( error ? . message || '删除失败' )
}
}
async function startAuthorization ( ) {
authorizing . value = true
try {
const result : any = await wecomPromotionAuthorizationUrl ( )
if ( ! result ? . url ) throw new Error ( '未获取到企业微信授权地址' )
window . location . assign ( result . url )
} catch ( error : any ) {
ElMessage . error ( error ? . message || '授权地址生成失败' )
authorizing . value = false
}
}
async function verifyAccount ( row : any ) {
verifyingId . value = Number ( row . id )
try {
await wecomPromotionVerifyAccount ( { id : Number ( row . id ) } )
ElMessage . success ( '授权凭证有效' )
await loadOverview ( )
} catch ( error : any ) {
ElMessage . error ( error ? . message || '凭证验证失败' )
} finally {
verifyingId . value = 0
}
}
function eligibility ( row : any ) {
const now = Math . floor ( Date . now ( ) / 1000 )
if ( Number ( row . remote _status ) === 2 ) return { label : '企业微信已删除' , className : 'is-error' }
if ( row . valid _customer _acquisition _link === false ) return { label : '非获客助手链接' , className : 'is-error' }
if ( Number ( row . status ) !== 1 ) return { label : '已下线' , className : 'is-muted' }
if ( Number ( row . account _id ) > 0 && Number ( row . auth _status ) !== 1 ) return { label : '授权失效' , className : 'is-error' }
if ( Number ( row . active _start ) > 0 && Number ( row . active _start ) > now ) return { label : '尚未生效' , className : 'is-waiting' }
if ( Number ( row . active _end ) > 0 && Number ( row . active _end ) < now ) return { label : '已过期' , className : 'is-error' }
if ( Number ( row . daily _limit ) > 0 && todayCount ( row ) >= Number ( row . daily _limit ) ) return { label : '今日已达上限' , className : 'is-waiting' }
@@ -476,14 +800,111 @@ function todayCount(row: any) {
return row . today _date === today ? Number ( row . today _count || 0 ) : 0
}
function formatNumber ( value : unknown ) {
return Number ( value || 0 ) . toLocaleString ( 'zh-CN' )
function memberOptionLabel ( member : any ) {
const departments = Array . isArray ( member . dept _names ) && member . dept _names . length
? member . dept _names . join ( ' / ' )
: '未分部门'
return ` ${ member . name } · ${ departments } · ${ member . userid } `
}
function formatTime ( value : unknown ) {
const timestamp = Number ( value || 0 )
if ( ! timestamp ) return '—'
return new Date ( timestamp * 1000 ) . toLocaleString ( 'zh-CN' , { hour12 : false } )
function remoteMemberLabel ( row : any ) {
const userIds = Array . isArray ( row . range _userids ) ? row . range _userids : [ ]
const names = userIds . map ( ( userid : unknown ) => {
const member = overview . member _options . find ( ( item : any ) => String ( item . userid ) === String ( userid ) )
return member ? . name || String ( userid )
} )
if ( names . length ) return names . length > 2 ? ` ${ names . slice ( 0 , 2 ) . join ( '、' ) } 等 ${ names . length } 人 ` : names . join ( '、' )
const departments = Array . isArray ( row . range _department _ids ) ? row . range _department _ids : [ ]
if ( departments . length ) return ` 企微部门 ${ departments . join ( '、' ) } `
return row . is _official ? '未返回成员范围' : '手工配置'
}
function memberFilterLabel ( member : any ) {
const department = member . department _name || member . dept _name || ''
return department ? ` ${ member . name } · ${ department } ` : member . name
}
function customerIdentifier ( row : any ) {
const masked = row . external _userid _masked || row . customer _id _masked || row . customer _masked || row . customer _name _masked
if ( masked ) return String ( masked )
return maskIdentifier ( row . external _userid || row . customer _id || row . id )
}
function maskIdentifier ( value : unknown ) {
const text = String ( value || '' ) . trim ( )
if ( ! text ) return '-'
if ( text . includes ( '*' ) ) return text
if ( text . length <= 4 ) return ` ${ text . slice ( 0 , 1 ) } *** `
if ( text . length <= 8 ) return ` ${ text . slice ( 0 , 2 ) } *** ${ text . slice ( - 1 ) } `
return ` ${ text . slice ( 0 , 4 ) } **** ${ text . slice ( - 4 ) } `
}
function customerLinkName ( row : any ) {
return row . link _name || row . source _link _name || row . promotion _link _name || '未匹配来源链接'
}
function customerLinkId ( row : any ) {
const linkId = row . link _id || row . remote _link _id || row . source _link _id
return linkId ? ` 链接 ID: ${ linkId } ` : '暂无链接 ID'
}
function customerMessageCount ( row : any ) {
return numberFrom ( row , [ 'received_message_count' , 'recv_msg_cnt' , 'message_count' , 'received_count' ] )
}
function customerMessageCountKnown ( row : any ) {
const explicit = row . message _count _known ? ? row . message _count _exact ? ? row . count _known
if ( explicit !== undefined && explicit !== null ) {
return explicit === true || explicit === 1 || explicit === '1' || explicit === 'true'
}
return customerMessageCount ( row ) > 0
}
function customerChatState ( row : any ) : 'messaged' | 'silent' | 'unknown' {
const status = Number ( row . chat _status )
if ( status === 2 ) return 'unknown'
const explicit = row . has _messaged ? ? row . has _message ? ? row . messaged
if ( explicit !== undefined && explicit !== null ) {
return explicit === true || explicit === 1 || explicit === '1' || explicit === 'true' || explicit === 'messaged'
? 'messaged'
: 'silent'
}
return status === 1 || customerMessageCount ( row ) > 0 ? 'messaged' : 'silent'
}
function customerChatStateLabel ( row : any ) {
const state = customerChatState ( row )
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 formatCustomerTime ( value : unknown ) {
if ( value === undefined || value === null || value === '' || value === 0 || value === '0' ) return '-'
if ( typeof value === 'number' || /^\d+$/ . test ( String ( value ) ) ) {
const timestamp = Number ( value )
const date = new Date ( timestamp > 100000000000 ? timestamp : timestamp * 1000 )
if ( Number . isNaN ( date . getTime ( ) ) ) return '-'
return date . toLocaleString ( 'zh-CN' , { hour12 : false } ) . replaceAll ( '/' , '-' )
}
return String ( value ) . replace ( 'T' , ' ' ) . replace ( /Z$/ , '' ) . slice ( 0 , 19 )
}
function formatCustomerRate ( value : unknown ) {
return ` ${ Number ( value || 0 ) . toFixed ( 1 ) } % `
}
function formatNumber ( value : unknown ) {
return Number ( value || 0 ) . toLocaleString ( 'zh-CN' )
}
function formatPickerTime ( value : unknown ) {
@@ -513,22 +934,8 @@ function openTestLink() {
if ( selectedInstallPool . value ? . go _url ) window . open ( selectedInstallPool . value . go _url , '_blank' , 'noopener,noreferrer' )
}
async function handleAuthorizationResult ( ) {
const status = String ( route . query . wecom _auth || '' )
if ( ! status ) return
activeTab . value = 'authorization'
if ( status === 'success' ) ElMessage . success ( '企业微信授权成功,凭证已安全保存' )
else ElMessage . error ( String ( route . query . message || '企业微信授权失败,请重新发起' ) )
const query = { ... route . query }
delete query . wecom _auth
delete query . account _id
delete query . message
await router . replace ( { query } )
}
onMounted ( async ( ) => {
await loadOverview ( )
await handleAuthorizationResult ( )
} )
< / script >
@@ -545,7 +952,7 @@ onMounted(async () => {
background : var ( -- canvas ) ;
}
h1 , h2 , h3 , p { margin : 0 ; }
. page - header , . metric - card , . workspace - card { border : 1 px solid var ( -- line ) ; background : # fff ; }
. page - header , . metric - card , . customer - metric - card , . workspace - card { border : 1 px solid var ( -- line ) ; background : # fff ; }
. page - header { display : flex ; align - items : center ; justify - content : space - between ; gap : 20 px ; min - height : 78 px ; padding : 14 px 18 px ; border - radius : 13 px ; }
. heading - copy , . heading - actions , . pool - title - row , . toolbar - actions { display : flex ; align - items : center ; }
. heading - copy { gap : 12 px ; }
@@ -576,14 +983,25 @@ h1, h2, h3, p { margin: 0; }
. pool - title - row { gap : 8 px ; } . pool - title - row h3 { font - size : 15 px ; } . pool - toolbar p { margin - top : 6 px ; color : # 8 b97a6 ; font - family : ui - monospace , SFMono - Regular , Consolas , monospace ; font - size : 10 px ; } . toolbar - actions { flex - wrap : wrap ; justify - content : flex - end ; gap : 7 px ; } . toolbar - actions . el - button + . el - button { margin - left : 0 ; }
. status - tag , . owner - tag , . availability { display : inline - flex ; align - items : center ; min - height : 22 px ; padding : 0 8 px ; border - radius : 11 px ; font - size : 10 px ; } . status - tag . is - online , . availability . is - ok { color : # 16895 f ; background : # eaf8ef ; } . status - tag . is - offline , . availability . is - muted { color : # 788696 ; background : # eef2f5 ; } . owner - tag { color : # 50728 c ; background : # edf4f8 ; } . availability . is - error { color : # d94b4b ; background : # ffeded ; } . availability . is - waiting { color : # b86c1f ; background : # fff1dd ; }
. link - table , . account - table { -- el - table - header - bg - color : # f7f9fb ; } . link - table : deep ( th . el - table _ _cell ) , . account - table : deep ( th . el - table _ _cell ) { color : # 67768 a ; font - weight : 500 ; } . member - cell strong , . member - cell small { display : block ; } . member - cell small { margin - top : 3 px ; color : # 8 d98a7 ; font - size : 10 px ; } . muted { color : # 9 aa4b0 ; }
. remote - id { display : block ; overflow : hidden ; color : # 68778 b ; font - family : ui - monospace , SFMono - Regular , Consolas , monospace ; font - size : 11 px ; text - overflow : ellipsis ; white - space : nowrap ; } . legacy - tag { display : inline - flex ; padding : 3 px 7 px ; border - radius : 5 px ; color : # 9 a6b22 ; background : # fff5df ; font - size : 10 px ; } . danger - command { color : # d94b4b ; }
. customer - heading - actions { display : flex ; align - items : center ; gap : 12 px ; }
. customer - metric - grid { display : grid ; grid - template - columns : repeat ( 4 , minmax ( 0 , 1 fr ) ) ; gap : 12 px ; }
. customer - metric - card { display : flex ; align - items : center ; gap : 12 px ; min - width : 0 ; min - height : 94 px ; padding : 14 px ; border - radius : 10 px ; }
. customer - metric - card > div { min - width : 0 ; } . customer - metric - card small { color : # 78869 a ; font - size : 11 px ; } . customer - metric - card strong { display : block ; margin : 3 px 0 ; font - size : 23 px ; font - variant - numeric : tabular - nums ; line - height : 1.15 ; } . customer - metric - card p { overflow : hidden ; color : # 98 a2af ; font - size : 10 px ; text - overflow : ellipsis ; white - space : nowrap ; }
. chatkey - alert { margin - top : 14 px ; } . chatkey - alert : deep ( . el - alert _ _title ) { font - size : 12 px ; } . chatkey - alert : deep ( . el - alert _ _description ) { line - height : 1.6 ; }
. customer - filter - bar { margin - top : 14 px ; padding : 12 px 14 px 0 ; border : 1 px solid var ( -- line ) ; border - radius : 9 px ; background : # f8fafb ; }
. customer - filter - bar : deep ( . el - form - item ) { margin - right : 12 px ; margin - bottom : 12 px ; } . customer - filter - bar : deep ( . el - form - item _ _label ) { height : 22 px ; padding : 0 ; color : # 67768 a ; font - size : 11 px ; line - height : 22 px ; } . customer - filter - bar . el - select { width : 220 px ; } . customer - filter - bar . filter - actions { align - self : flex - end ; margin - right : 0 ; }
. customer - table { margin - top : 14 px ; -- el - table - header - bg - color : # f7f9fb ; } . customer - table : deep ( th . el - table _ _cell ) { color : # 67768 a ; font - weight : 500 ; } . customer - identifier { color : # 53657 a ; font - family : ui - monospace , SFMono - Regular , Consolas , monospace ; font - size : 11 px ; } . message - state { display : inline - flex ; align - items : center ; min - height : 24 px ; padding : 0 9 px ; border - radius : 12 px ; font - size : 10 px ; } . message - state . is - messaged { color : # 16895 f ; 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 : 16 px ; margin - top : 14 px ; color : # 8491 a2 ; font - size : 11 px ; }
. configuration - panel { margin - bottom : 16 px ; padding : 16 px ; border : 1 px solid ; border - radius : 10 px ; } . 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 : 12 px ; } . configuration - state > span { display : grid ; width : 36 px ; height : 36 px ; place - items : center ; border - radius : 9 px ; color : # fff ; background : var ( -- teal ) ; font - size : 18 px ; } . is - pending . configuration - state > span { background : # e69a43 ; } . configuration - state strong { font - size : 14 px ; } . configuration - state p { margin - top : 4 px ; color : # 718094 ; font - size : 11 px ; }
. missing - fields { margin - top : 12 px ; color : # 9 c651f ; font - size : 11 px ; } . missing - fields code { margin - left : 6 px ; padding : 3 px 6 px ; border - radius : 4 px ; background : rgba ( 224 , 153 , 63 , .11 ) ; }
. callback - list { margin : 14 px 0 0 ; border - top : 1 px solid rgba ( 124 , 150 , 157 , .16 ) ; } . callback - list > div { display : grid ; grid - template - columns : 120 px 1 fr 50 px ; align - items : center ; min - height : 40 px ; border - bottom : 1 px solid rgba ( 124 , 150 , 157 , .12 ) ; font - size : 11 px ; } . callback - list dt { color : # 657488 ; } . callback - list dd { overflow : hidden ; margin : 0 ; color : # 27374 c ; font - family : ui - monospace , SFMono - Regular , Consolas , monospace ; text - overflow : ellipsis ; white - space : nowrap ; } . callback - list button { border : 0 ; color : # 148 f83 ; background : transparent ; cursor : pointer ; }
. configuration - actions { display : flex ; align - items : center ; gap : 14 px ; margin - top : 16 px ; }
. install - pool - select { width : 220 px ; } . install - grid { display : grid ; grid - template - columns : repeat ( 2 , minmax ( 0 , 1 fr ) ) ; gap : 14 px ; } . code - card { min - width : 0 ; padding : 16 px ; border : 1 px solid var ( -- line ) ; border - radius : 10 px ; background : # fbfcfd ; } . code - heading { display : flex ; align - items : center ; gap : 10 px ; } . code - heading > span { display : grid ; width : 30 px ; height : 30 px ; flex : 0 0 30 px ; place - items : center ; border - radius : 8 px ; color : # fff ; background : var ( -- teal ) ; font - weight : 700 ; } . code - heading strong { font - size : 13 px ; } . code - heading p { margin - top : 3 px ; color : # 7 a889a ; font - size : 10 px ; } . code - card pre { min - height : 92 px ; margin : 14 px 0 ; padding : 13 px ; overflow : auto ; border - radius : 7 px ; color : # cbe9e6 ; background : # 172 a36 ; white - space : pre - wrap ; word - break : break - all ; } . code - card pre code { font - family : ui - monospace , SFMono - Regular , Consolas , monospace ; font - size : 11 px ; line - height : 1.7 ; }
. rule - panel { display : grid ; grid - template - columns : repeat ( 3 , minmax ( 0 , 1 fr ) ) ; gap : 10 px ; margin - top : 14 px ; } . rule - panel > div { display : flex ; align - items : flex - start ; gap : 9 px ; min - height : 68 px ; padding : 12 px ; border - radius : 9 px ; color : # 69788 b ; background : # f3f7f8 ; font - size : 11 px ; line - height : 1.65 ; } . rule - panel . el - icon { margin - top : 2 px ; color : var ( -- teal ) ; font - size : 17 px ; } . rule - panel strong { display : block ; color : # 26364 a ; }
. test - row { display : flex ; align - items : center ; justify - content : space - between ; gap : 16 px ; margin - top : 14 px ; padding : 14 px 16 px ; border : 1 px dashed # b8d9d4 ; border - radius : 9 px ; background : # f7fcfb ; } . test - row strong { font - size : 13 px ; } . test - row p { margin - top : 4 px ; color : # 7 c8999 ; font - size : 10 px ; }
. form - grid { display : grid ; grid - template - columns : repeat ( 2 , minmax ( 0 , 1 fr ) ) ; gap : 14 px ; } . form - tip { display : block ; margin - top : 5 px ; color : # 8 b97a6 ; font - size : 10 px ; } . link - form . el - select , . link - form . el - input - number { width : 100 % ; }
@ media ( max - width : 1100 px ) { . heading - actions { flex - wrap : wrap ; justify - content : flex - end ; } . metric - grid { grid - template - columns : repeat ( 2 , minmax ( 0 , 1 fr ) ) ; } . pool - layout { grid - template - columns : 210 px minmax ( 0 , 1 fr ) ; } . pool - toolbar { align - items : flex - start ; flex - direction : column ; } . rule - panel { grid - template - columns : 1 fr ; } }
@ media ( max - width : 760 px ) { . promotion - page { padding : 10 px ; } . page - header , . section - heading { align - items : flex - start ; flex - direction : column ; } . update - time { display : none ; } . metric - grid , . install - grid , . form - grid { grid - template - columns : 1 fr ; } . tab - nav { overflow - x : auto ; } . tab - nav button { min - width : 112 px ; } . tab - content { padding : 14 px ; } . pool - layout { grid - template - columns : 1 fr ; } . pool - sidebar { display : flex ; overflow - x : auto ; border - right : 0 ; border - bottom : 1 px solid var ( -- line ) ; } . pool - item { min - width : 190 px ; } . callback - list > div { grid - template - columns : 1 fr 48 px ; padding : 8 px 0 ; } . callback - list dt { grid - column : 1 / - 1 ; } . install - pool - select { width : 100 % ; } }
@ media ( max - width : 1100 px ) { . heading - actions { flex - wrap : wrap ; justify - content : flex - end ; } . metric - grid , . customer - metric - grid { grid - template - columns : repeat ( 2 , minmax ( 0 , 1 fr ) ) ; } . pool - layout { grid - template - columns : 210 px minmax ( 0 , 1 fr ) ; } . pool - toolbar { align - items : flex - start ; flex - direction : column ; } . rule - panel { grid - template - columns : 1 fr ; } }
@ media ( max - width : 760 px ) { . promotion - page { padding : 10 px ; } . page - header , . section - heading { align - items : flex - start ; flex - direction : column ; } . update - time { display : none ; } . metric - grid , . customer - metric - grid , . install - grid , . form - grid { grid - template - columns : 1 fr ; } . tab - nav { overflow - x : auto ; } . tab - nav button { min - width : 112 px ; } . tab - content { padding : 14 px ; } . pool - layout { grid - template - columns : 1 fr ; } . pool - sidebar { display : flex ; overflow - x : auto ; border - right : 0 ; border - bottom : 1 px solid var ( -- line ) ; } . pool - item { min - width : 190 px ; } . callback - list > div { grid - template - columns : 1 fr 48 px ; padding : 8 px 0 ; } . callback - list dt { grid - column : 1 / - 1 ; } . install - pool - select { width : 100 % ; } . customer - heading - actions { width : 100 % ; justify - content : flex - end ; } . customer - filter - bar : deep ( . el - form - item ) { width : 100 % ; margin - right : 0 ; } . customer - filter - bar : deep ( . el - form - item _ _content ) , . customer - filter - bar . el - select { width : 100 % ; } . customer - filter - bar . filter - actions : deep ( . el - form - item _ _content ) { justify - content : flex - end ; } . customer - pagination { align - items : flex - start ; flex - direction : column ; } . customer - pagination : deep ( . el - pagination ) { max - width : 100 % ; flex - wrap : wrap ; justify - content : flex - start ; } }
< / style >