更新
This commit is contained in:
@@ -126,7 +126,7 @@ export function myPatientCancelAppointment(params: { id: number }) {
|
||||
}
|
||||
|
||||
export interface FirstVisitConversionParams {
|
||||
time_type: 'today' | 'week' | 'month' | 'quarter' | 'year'
|
||||
time_type: 'today' | 'yesterday' | 'week' | 'month' | 'quarter' | 'year'
|
||||
dept_id?: number
|
||||
assistant_id?: number
|
||||
}
|
||||
@@ -173,14 +173,6 @@ export function wecomPromotionOverview() {
|
||||
return request.get({ url: '/firstvisit.wecomPromotion/overview' })
|
||||
}
|
||||
|
||||
export function wecomPromotionAuthorizationUrl() {
|
||||
return request.post({ url: '/firstvisit.wecomPromotion/authorizationUrl' })
|
||||
}
|
||||
|
||||
export function wecomPromotionVerifyAccount(params: { id: number }) {
|
||||
return request.post({ url: '/firstvisit.wecomPromotion/verifyAccount', params })
|
||||
}
|
||||
|
||||
export function wecomPromotionSavePool(params: Record<string, unknown>) {
|
||||
return request.post({ url: '/firstvisit.wecomPromotion/savePool', params })
|
||||
}
|
||||
@@ -193,6 +185,22 @@ export function wecomPromotionSaveLink(params: Record<string, unknown>) {
|
||||
return request.post({ url: '/firstvisit.wecomPromotion/saveLink', params })
|
||||
}
|
||||
|
||||
export function wecomPromotionCheckApiPermission() {
|
||||
return request.post({ url: '/firstvisit.wecomPromotion/checkApiPermission' })
|
||||
}
|
||||
|
||||
export function wecomPromotionSyncRemoteLinks(params: { pool_id: number }) {
|
||||
return request.post({ url: '/firstvisit.wecomPromotion/syncRemoteLinks', params, timeout: 120000 })
|
||||
}
|
||||
|
||||
export function wecomPromotionRemoteLinkDetail(params: { id: number }) {
|
||||
return request.get({ url: '/firstvisit.wecomPromotion/remoteLinkDetail', params })
|
||||
}
|
||||
|
||||
export function wecomPromotionDeleteRemoteLink(params: { id: number }) {
|
||||
return request.post({ url: '/firstvisit.wecomPromotion/deleteRemoteLink', params })
|
||||
}
|
||||
|
||||
export function wecomPromotionToggleLink(params: { id: number; status: number }) {
|
||||
return request.post({ url: '/firstvisit.wecomPromotion/toggleLink', params })
|
||||
}
|
||||
@@ -200,3 +208,61 @@ export function wecomPromotionToggleLink(params: { id: number; status: number })
|
||||
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 {
|
||||
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
|
||||
customer_id_masked?: string
|
||||
customer_name_masked?: string
|
||||
link_id?: number | string
|
||||
link_name?: string
|
||||
member_id?: number
|
||||
member_name?: string
|
||||
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
|
||||
}
|
||||
|
||||
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 }
|
||||
}
|
||||
|
||||
/** 获客客户消息统计:服务端继续按当前角色和部门数据范围收窄。 */
|
||||
export function wecomPromotionCustomerStats(params: WecomPromotionCustomerStatsParams) {
|
||||
return request.get(
|
||||
{ url: '/firstvisit.wecomPromotion/customerStatistics', params, timeout: 120000 },
|
||||
{ ignoreCancelToken: true }
|
||||
)
|
||||
}
|
||||
|
||||
export function wecomPromotionSyncCustomers(params: { promotion_link_id?: number } = {}) {
|
||||
return request.post({ url: '/firstvisit.wecomPromotion/syncCustomers', params, timeout: 120000 })
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<h2>部门订单量占比</h2>
|
||||
<p>当前范围内审核通过的接诊诊单</p>
|
||||
<p>双审通过,并排除取消、拒收及退款</p>
|
||||
</div>
|
||||
<span>单位:单</span>
|
||||
</div>
|
||||
@@ -85,7 +85,7 @@
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<h2>部门金额占比</h2>
|
||||
<p>与诊单金额指标保持同一审核口径</p>
|
||||
<p>仅统计未取消、未拒收且未退款的有效金额</p>
|
||||
</div>
|
||||
<span>单位:元</span>
|
||||
</div>
|
||||
@@ -231,6 +231,7 @@ const query = reactive<FirstVisitConversionParams>({ time_type: 'today', dept_id
|
||||
const deptTreeProps = { value: 'id', label: 'name', children: 'children' }
|
||||
const timeOptions = [
|
||||
{ label: '今日', value: 'today' },
|
||||
{ label: '昨天', value: 'yesterday' },
|
||||
{ label: '本周', value: 'week' },
|
||||
{ label: '本月', value: 'month' },
|
||||
{ label: '本季度', value: 'quarter' },
|
||||
@@ -240,8 +241,8 @@ const metricCards: Array<{ key: string; label: string; type: MetricType; hint: s
|
||||
{ key: 'add_fans_count', label: '加粉数', type: 'count', hint: '企微新增客户' },
|
||||
{ key: 'total_open_count', label: '开口数', type: 'count', hint: '来源于个人业绩录入' },
|
||||
{ key: 'interview_count', label: '面诊', type: 'count', hint: '已完成挂号' },
|
||||
{ key: 'completed_order_count', label: '接诊诊单', type: 'count', hint: '处方与支付审核通过' },
|
||||
{ key: 'completed_order_amount', label: '诊单金额', type: 'money', hint: '与接诊诊单同口径' },
|
||||
{ key: 'completed_order_count', label: '接诊诊单', type: 'count', hint: '双审通过,排除取消、拒收及退款' },
|
||||
{ key: 'completed_order_amount', label: '诊单金额', type: 'money', hint: '仅统计有效诊单金额' },
|
||||
{ key: 'avg_unit_price', label: '平均客单价', type: 'money', hint: '诊单金额 / 接诊诊单' },
|
||||
{ key: 'account_cost', label: '现金成本', type: 'money', hint: '当前范围内实际投放成本' },
|
||||
{ key: 'roi', label: 'ROI', type: 'ratio', hint: '诊单金额 / 投放成本' }
|
||||
|
||||
@@ -52,9 +52,9 @@
|
||||
<small>笔</small>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<span>订单金额</span>
|
||||
<span>有效订单金额</span>
|
||||
<strong>¥{{ money(summary.amount) }}</strong>
|
||||
<small>当前筛选</small>
|
||||
<small>排除取消、拒收及退款</small>
|
||||
</div>
|
||||
<div class="metric-card metric-warning">
|
||||
<span>待审核</span>
|
||||
@@ -98,8 +98,11 @@
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="金额" width="108" align="right">
|
||||
<template #default="{ row }"><strong class="amount">¥{{ money(row.amount) }}</strong></template>
|
||||
<el-table-column label="有效金额" width="118" align="right">
|
||||
<template #default="{ row }">
|
||||
<strong v-if="row.amount_included" class="amount">¥{{ money(row.effective_amount) }}</strong>
|
||||
<span v-else class="amount-excluded">{{ row.amount_exclusion_text || '不计入' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="处方审核" width="104" align="center">
|
||||
<template #default="{ row }">
|
||||
@@ -437,6 +440,11 @@ onMounted(getLists)
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.amount-excluded {
|
||||
color: #9aa4b2;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.order-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -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="loading" @click="loadOverview">刷新</el-button>
|
||||
<el-button :icon="Refresh" :loading="loading || customerStatsLoading" @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="680px" destroy-on-close>
|
||||
<el-dialog v-model="linkDialogVisible" :title="linkForm.id ? '编辑获客助手链接' : '创建企业微信官方获客链接'" width="720px" 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' | 'authorization' | 'install'
|
||||
type TabName = 'links' | 'customer-stats' | 'configuration' | '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: 1px solid var(--line); background: #fff; }
|
||||
.page-header, .metric-card, .customer-metric-card, .workspace-card { border: 1px solid var(--line); background: #fff; }
|
||||
.page-header { display: flex; align-items: center; justify-content: space-between; gap: 20px; min-height: 78px; padding: 14px 18px; border-radius: 13px; }
|
||||
.heading-copy, .heading-actions, .pool-title-row, .toolbar-actions { display: flex; align-items: center; }
|
||||
.heading-copy { gap: 12px; }
|
||||
@@ -576,14 +983,25 @@ h1, h2, h3, p { margin: 0; }
|
||||
.pool-title-row { gap: 8px; }.pool-title-row h3 { font-size: 15px; }.pool-toolbar p { margin-top: 6px; color: #8b97a6; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 10px; }.toolbar-actions { flex-wrap: wrap; justify-content: flex-end; gap: 7px; }.toolbar-actions .el-button + .el-button { margin-left: 0; }
|
||||
.status-tag, .owner-tag, .availability { display: inline-flex; align-items: center; min-height: 22px; padding: 0 8px; border-radius: 11px; font-size: 10px; }.status-tag.is-online, .availability.is-ok { color: #16895f; background: #eaf8ef; }.status-tag.is-offline, .availability.is-muted { color: #788696; background: #eef2f5; }.owner-tag { color: #50728c; 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: #67768a; font-weight: 500; }.member-cell strong, .member-cell small { display: block; }.member-cell small { margin-top: 3px; color: #8d98a7; font-size: 10px; }.muted { color: #9aa4b0; }
|
||||
.remote-id { display: block; overflow: hidden; color: #68778b; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }.legacy-tag { display: inline-flex; padding: 3px 7px; border-radius: 5px; color: #9a6b22; background: #fff5df; font-size: 10px; }.danger-command { color: #d94b4b; }
|
||||
.customer-heading-actions { display: flex; align-items: center; gap: 12px; }
|
||||
.customer-metric-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; }
|
||||
.customer-metric-card { display: flex; align-items: center; gap: 12px; min-width: 0; min-height: 94px; padding: 14px; border-radius: 10px; }
|
||||
.customer-metric-card > div { min-width: 0; }.customer-metric-card small { color: #78869a; font-size: 11px; }.customer-metric-card strong { display: block; margin: 3px 0; font-size: 23px; font-variant-numeric: tabular-nums; line-height: 1.15; }.customer-metric-card p { overflow: hidden; color: #98a2af; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.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-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; }
|
||||
.missing-fields { margin-top: 12px; color: #9c651f; font-size: 11px; }.missing-fields code { margin-left: 6px; padding: 3px 6px; border-radius: 4px; background: rgba(224,153,63,.11); }
|
||||
.callback-list { margin: 14px 0 0; border-top: 1px solid rgba(124,150,157,.16); }.callback-list > div { display: grid; grid-template-columns: 120px 1fr 50px; align-items: center; min-height: 40px; border-bottom: 1px solid rgba(124,150,157,.12); font-size: 11px; }.callback-list dt { color: #657488; }.callback-list dd { overflow: hidden; margin: 0; color: #27374c; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; text-overflow: ellipsis; white-space: nowrap; }.callback-list button { border: 0; color: #148f83; background: transparent; cursor: pointer; }
|
||||
.configuration-actions { display: flex; align-items: center; gap: 14px; margin-top: 16px; }
|
||||
.install-pool-select { width: 220px; }.install-grid { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 14px; }.code-card { min-width: 0; padding: 16px; border: 1px solid var(--line); border-radius: 10px; background: #fbfcfd; }.code-heading { display: flex; align-items: center; gap: 10px; }.code-heading > span { display: grid; width: 30px; height: 30px; flex: 0 0 30px; place-items: center; border-radius: 8px; color: #fff; background: var(--teal); font-weight: 700; }.code-heading strong { font-size: 13px; }.code-heading p { margin-top: 3px; color: #7a889a; font-size: 10px; }.code-card pre { min-height: 92px; margin: 14px 0; padding: 13px; overflow: auto; border-radius: 7px; color: #cbe9e6; background: #172a36; white-space: pre-wrap; word-break: break-all; }.code-card pre code { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 11px; line-height: 1.7; }
|
||||
.rule-panel { display: grid; grid-template-columns: repeat(3, minmax(0,1fr)); gap: 10px; margin-top: 14px; }.rule-panel > div { display: flex; align-items: flex-start; gap: 9px; min-height: 68px; padding: 12px; border-radius: 9px; color: #69788b; background: #f3f7f8; font-size: 11px; line-height: 1.65; }.rule-panel .el-icon { margin-top: 2px; color: var(--teal); font-size: 17px; }.rule-panel strong { display: block; color: #26364a; }
|
||||
.test-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-top: 14px; padding: 14px 16px; border: 1px dashed #b8d9d4; border-radius: 9px; background: #f7fcfb; }.test-row strong { font-size: 13px; }.test-row p { margin-top: 4px; color: #7c8999; font-size: 10px; }
|
||||
.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 14px; }.form-tip { display: block; margin-top: 5px; color: #8b97a6; font-size: 10px; }.link-form .el-select, .link-form .el-input-number { width: 100%; }
|
||||
@media (max-width: 1100px) { .heading-actions { flex-wrap: wrap; justify-content: flex-end; }.metric-grid { grid-template-columns: repeat(2, minmax(0,1fr)); }.pool-layout { grid-template-columns: 210px minmax(0,1fr); }.pool-toolbar { align-items: flex-start; flex-direction: column; }.rule-panel { grid-template-columns: 1fr; } }
|
||||
@media (max-width: 760px) { .promotion-page { padding: 10px; }.page-header, .section-heading { align-items: flex-start; flex-direction: column; }.update-time { display: none; }.metric-grid, .install-grid, .form-grid { grid-template-columns: 1fr; }.tab-nav { overflow-x: auto; }.tab-nav button { min-width: 112px; }.tab-content { padding: 14px; }.pool-layout { grid-template-columns: 1fr; }.pool-sidebar { display: flex; overflow-x: auto; border-right: 0; border-bottom: 1px solid var(--line); }.pool-item { min-width: 190px; }.callback-list > div { grid-template-columns: 1fr 48px; padding: 8px 0; }.callback-list dt { grid-column: 1 / -1; }.install-pool-select { width: 100%; } }
|
||||
@media (max-width: 1100px) { .heading-actions { flex-wrap: wrap; justify-content: flex-end; }.metric-grid, .customer-metric-grid { grid-template-columns: repeat(2, minmax(0,1fr)); }.pool-layout { grid-template-columns: 210px minmax(0,1fr); }.pool-toolbar { align-items: flex-start; flex-direction: column; }.rule-panel { grid-template-columns: 1fr; } }
|
||||
@media (max-width: 760px) { .promotion-page { padding: 10px; }.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: 1fr; }.tab-nav { overflow-x: auto; }.tab-nav button { min-width: 112px; }.tab-content { padding: 14px; }.pool-layout { grid-template-columns: 1fr; }.pool-sidebar { display: flex; overflow-x: auto; border-right: 0; border-bottom: 1px solid var(--line); }.pool-item { min-width: 190px; }.callback-list > div { grid-template-columns: 1fr 48px; padding: 8px 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>
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user