Files
zyt/admin/src/views/first_visit/wecom_promotion/index.vue
T
2026-08-06 10:57:35 +08:00

1008 lines
67 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="promotion-page" v-loading="loading" element-loading-text="正在加载推广配置">
<header class="page-header">
<div class="heading-copy">
<span class="heading-icon"><el-icon><Promotion /></el-icon></span>
<div>
<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 || 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.configured_apps }}</strong><p>直接读取服务器环境配置</p></div>
</article>
<article class="metric-card">
<span class="metric-icon is-blue"><el-icon><SetUp /></el-icon></span>
<div><small>分流方案</small><strong>{{ overview.summary.pool_count }}</strong><p>每个方案生成独立 JS</p></div>
</article>
<article class="metric-card">
<span class="metric-icon is-green"><el-icon><Link /></el-icon></span>
<div><small>在线链接</small><strong>{{ overview.summary.online_links }}</strong><p>停用或超限自动排除</p></div>
</article>
<article class="metric-card">
<span class="metric-icon is-orange"><el-icon><Mouse /></el-icon></span>
<div><small>今日分流点击</small><strong>{{ overview.summary.today_clicks }}</strong><p>只记录脱敏访问数据</p></div>
</article>
</section>
<section class="workspace-card">
<nav class="tab-nav" aria-label="企业微信获客助手功能">
<button :class="{ active: activeTab === 'links' }" @click="activeTab = 'links'">
<el-icon><Connection /></el-icon>获客助手链接
</button>
<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 安装
</button>
</nav>
<div v-if="activeTab === 'links'" class="tab-content links-tab">
<div class="section-heading">
<div>
<h2>获客助手链接分流</h2>
<p>访客点击时由服务端从当前可用的获客助手链接中按权重随机选择,页面端不会暴露完整链接池。</p>
</div>
<el-button type="primary" :icon="Plus" @click="openPoolDialog()">新建分流方案</el-button>
</div>
<div v-if="overview.pools.length" class="pool-layout">
<aside class="pool-sidebar">
<button
v-for="pool in overview.pools"
:key="pool.id"
class="pool-item"
:class="{ active: Number(pool.id) === selectedPoolId }"
@click="selectedPoolId = Number(pool.id)"
>
<span class="pool-status" :class="Number(pool.status) === 1 ? 'online' : 'offline'" />
<span><strong>{{ pool.name }}</strong><small>{{ linkCount(pool.id) }} 条链接 · {{ formatNumber(pool.click_count) }} 次点击</small></span>
<el-icon><ArrowRight /></el-icon>
</button>
</aside>
<div class="pool-main">
<div v-if="selectedPool" class="pool-toolbar">
<div>
<div class="pool-title-row">
<h3>{{ selectedPool.name }}</h3>
<span class="status-tag" :class="Number(selectedPool.status) === 1 ? 'is-online' : 'is-offline'">
{{ Number(selectedPool.status) === 1 ? '运行中' : '已停用' }}
</span>
<span class="owner-tag">{{ selectedPool.dept_name || '未分部门' }} · {{ selectedPool.owner_name || '系统' }}</span>
</div>
<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>
</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="企业微信链接 ID" min-width="180">
<template #default="{ row }">
<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" />
<el-table-column label="今日 / 上限" min-width="120" align="center">
<template #default="{ row }">{{ todayCount(row) }} / {{ Number(row.daily_limit) ? row.daily_limit : '不限' }}</template>
</el-table-column>
<el-table-column label="累计点击" prop="click_count" min-width="105" align="center" />
<el-table-column label="当前可用性" min-width="130">
<template #default="{ row }">
<span class="availability" :class="eligibility(row).className">{{ eligibility(row).label }}</span>
</template>
</el-table-column>
<el-table-column label="上线" width="90" align="center">
<template #default="{ row }">
<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="245" fixed="right">
<template #default="{ row }">
<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>
</el-table>
</div>
</div>
<el-empty v-else description="先创建一个分流方案,再添加企业微信获客助手链接">
<el-button type="primary" :icon="Plus" @click="openPoolDialog()">创建第一个方案</el-button>
</el-empty>
</div>
<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>直接读取服务器现有的 work_wechat 配置,密钥不会返回浏览器,也不需要第三方应用授权。</p>
</div>
</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">已识别 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>企业 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>
</div>
<div v-else class="tab-content install-tab">
<div class="section-heading">
<div>
<h2>安装 JS 到推广落地页</h2>
<p>基础脚本只负责捕获点击并请求服务端分流,不携带授权凭证,也不在浏览器中计算随机规则。</p>
</div>
<el-select v-model="selectedInstallPoolId" placeholder="选择分流方案" class="install-pool-select">
<el-option v-for="pool in overview.pools" :key="pool.id" :label="pool.name" :value="Number(pool.id)" />
</el-select>
</div>
<template v-if="selectedInstallPool">
<div class="install-grid">
<article class="code-card">
<div class="code-heading"><span>1</span><div><strong>安装基础脚本</strong><p>放在页面 <code>&lt;/head&gt;</code> 标签之前,只需安装一次。</p></div></div>
<pre><code>{{ selectedInstallPool.install_code }}</code></pre>
<el-button type="primary" plain :icon="DocumentCopy" @click="copyText(selectedInstallPool.install_code, '基础脚本')">复制代码</el-button>
</article>
<article class="code-card">
<div class="code-heading"><span>2</span><div><strong>标记点击元素</strong><p>按钮、图片或文字链接都可以使用同一个数据属性。</p></div></div>
<pre><code>{{ selectedInstallPool.trigger_code }}</code></pre>
<el-button type="primary" plain :icon="DocumentCopy" @click="copyText(selectedInstallPool.trigger_code, '点击元素代码')">复制代码</el-button>
</article>
</div>
<div class="rule-panel">
<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>
<div class="test-row">
<div><strong>分流测试</strong><p>每次打开都会执行与线上相同的筛选和随机规则,并计入点击数据。</p></div>
<el-button type="primary" :icon="TopRight" @click="openTestLink">打开测试链接</el-button>
</div>
</template>
<el-empty v-else description="请先创建分流方案,系统会自动生成 JS 安装代码" />
</div>
</section>
<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-input v-model="poolForm.fallback_url" placeholder="可选;没有可用成员时跳转" />
<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="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="分组"><el-input v-model="linkForm.group_name" maxlength="60" placeholder="默认分组" /></el-form-item>
</div>
<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="分流权重" required><el-input-number v-model="linkForm.weight" :min="1" :max="100" controls-position="right" /></el-form-item>
<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>
</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>
</el-form-item>
<el-form-item label="备注"><el-input v-model="linkForm.remark" maxlength="255" show-word-limit /></el-form-item>
</el-form>
<template #footer><el-button @click="linkDialogVisible = false">取消</el-button><el-button type="primary" :loading="savingLink" @click="saveLink">保存链接</el-button></template>
</el-dialog>
</div>
</template>
<script setup lang="ts" name="firstVisitWecomPromotionPage">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
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 {
wecomPromotionCheckApiPermission,
wecomPromotionCustomerStats,
wecomPromotionDeleteLink,
wecomPromotionDeleteRemoteLink,
wecomPromotionDeletePool,
wecomPromotionOverview,
wecomPromotionRemoteLinkDetail,
wecomPromotionSaveLink,
wecomPromotionSavePool,
wecomPromotionSyncCustomers,
wecomPromotionSyncRemoteLinks,
wecomPromotionToggleLink
} from '@/api/first_visit'
import type { WecomPromotionCustomerChatStatus } from '@/api/first_visit'
type TabName = 'links' | 'customer-stats' | 'configuration' | 'install'
const emptyOverview = () => ({
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 overview = reactive(emptyOverview())
const loading = ref(false)
const activeTab = ref<TabName>('links')
const selectedPoolId = ref<number>()
const selectedInstallPoolId = ref<number>()
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, 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 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))
if (!selectedPoolId.value || !ids.includes(selectedPoolId.value)) selectedPoolId.value = ids[0]
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 {
const result: any = await wecomPromotionOverview()
Object.assign(overview, emptyOverview(), result || {})
} catch (error: any) {
ElMessage.error(error?.message || '企业微信推广配置加载失败')
} finally {
loading.value = false
}
}
function linkCount(poolId: number) {
return overview.links.filter((item: any) => Number(item.pool_id) === Number(poolId)).length
}
function openPoolDialog(pool?: any) {
Object.assign(poolForm, pool ? { id: Number(pool.id), name: pool.name, fallback_url: pool.fallback_url || '', status: Number(pool.status) } : { id: 0, name: '', fallback_url: '', status: 1 })
poolDialogVisible.value = true
}
async function savePool() {
if (!poolForm.name.trim()) return ElMessage.warning('请输入分流方案名称')
savingPool.value = true
try {
const result: any = await wecomPromotionSavePool({ ...poolForm })
poolDialogVisible.value = false
await loadOverview()
if (result?.id) selectedPoolId.value = Number(result.id)
ElMessage.success('分流方案已保存')
} catch (error: any) {
ElMessage.error(error?.message || '分流方案保存失败')
} finally {
savingPool.value = false
}
}
async function removePool(pool: any) {
await ElMessageBox.confirm(`删除“${pool.name}”后,该方案下的获客助手链接也会停用。确认继续?`, '删除分流方案', { type: 'warning' })
try {
await wecomPromotionDeletePool({ id: Number(pool.id) })
ElMessage.success('分流方案已删除')
await loadOverview()
} catch (error: any) {
ElMessage.error(error?.message || '删除失败')
}
}
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),
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), 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.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,
active_start: linkForm.active_range?.[0] || '',
active_end: linkForm.active_range?.[1] || ''
})
linkDialogVisible.value = false
await loadOverview()
ElMessage.success(linkForm.legacy ? '本地分流规则已保存' : '企业微信官方获客链接已保存')
} catch (error: any) {
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 })
row.status = value ? 1 : 0
ElMessage.success(value ? '链接已上线' : '链接已下线')
await loadOverview()
} catch (error: any) {
ElMessage.error(error?.message || '状态更新失败')
await loadOverview()
}
}
async function removeLink(row: any) {
await ElMessageBox.confirm(`仅从本地分流池移除“${row.name}”,不会删除企业微信后台的官方链接。确认继续?`, '从本地移除', { type: 'warning' })
try {
await wecomPromotionDeleteLink({ id: Number(row.id) })
ElMessage.success('已从本地分流池移除')
await loadOverview()
} catch (error: any) {
ElMessage.error(error?.message || '删除失败')
}
}
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.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' }
return { label: '可参与分流', className: 'is-ok' }
}
function todayCount(row: any) {
const today = new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date())
return row.today_date === today ? Number(row.today_count || 0) : 0
}
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 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) {
const date = new Date(Number(value || 0) * 1000)
const pad = (number: number) => String(number).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
}
async function copyText(value: string, label: string) {
if (!value) return ElMessage.warning(`${label}暂无内容`)
try {
await navigator.clipboard.writeText(value)
} catch {
const textarea = document.createElement('textarea')
textarea.value = value
textarea.style.position = 'fixed'
textarea.style.opacity = '0'
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
textarea.remove()
}
ElMessage.success(`${label}已复制`)
}
function openTestLink() {
if (selectedInstallPool.value?.go_url) window.open(selectedInstallPool.value.go_url, '_blank', 'noopener,noreferrer')
}
onMounted(async () => {
await loadOverview()
})
</script>
<style scoped lang="scss">
.promotion-page {
--ink: #17243a;
--muted: #778598;
--line: #e1e7ed;
--canvas: #f4f6f8;
--teal: #139a8c;
min-height: 100%;
padding: 18px;
color: var(--ink);
background: var(--canvas);
}
h1, h2, h3, p { margin: 0; }
.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; }
.heading-icon { display: grid; width: 42px; height: 42px; place-items: center; border-radius: 11px; color: #fff; background: var(--teal); font-size: 21px; box-shadow: 0 8px 20px rgba(19,154,140,.18); }
.heading-copy h1 { font-size: 20px; }
.heading-copy p { margin-top: 4px; color: var(--muted); font-size: 12px; }
.heading-actions { gap: 10px; color: var(--muted); font-size: 12px; }
.scope-chip { display: inline-flex; align-items: center; gap: 5px; padding: 6px 9px; border: 1px solid #cce8e3; border-radius: 7px; color: #117f75; background: #f2faf8; }
.metric-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; margin-top: 14px; }
.metric-card { display: flex; align-items: center; gap: 13px; min-height: 104px; padding: 16px; border-radius: 11px; }
.metric-icon { display: grid; width: 42px; height: 42px; flex: 0 0 42px; place-items: center; border-radius: 11px; font-size: 20px; }
.metric-icon.is-teal { color: #0e8b7e; background: #e8f7f4; }.metric-icon.is-blue { color: #3976e6; background: #edf3ff; }.metric-icon.is-green { color: #3b9c67; background: #edf8f1; }.metric-icon.is-orange { color: #d87a2d; background: #fff2e7; }
.metric-card small { color: #78869a; font-size: 12px; }.metric-card strong { display: block; margin: 3px 0; font-size: 25px; line-height: 1.15; }.metric-card p { color: #98a2af; font-size: 10px; }
.workspace-card { margin-top: 14px; border-radius: 13px; overflow: hidden; }
.tab-nav { display: flex; gap: 4px; padding: 0 18px; border-bottom: 1px solid var(--line); background: #fbfcfd; }
.tab-nav button { display: flex; align-items: center; gap: 7px; min-width: 132px; height: 54px; justify-content: center; border: 0; border-bottom: 3px solid transparent; color: #5e6d80; background: transparent; cursor: pointer; font-size: 14px; }
.tab-nav button.active { border-bottom-color: var(--teal); color: #0d8277; font-weight: 700; }
.tab-content { min-height: 460px; padding: 20px; }
.section-heading { display: flex; align-items: center; justify-content: space-between; gap: 20px; margin-bottom: 16px; }
.section-heading h2 { font-size: 17px; }.section-heading p { margin-top: 5px; color: var(--muted); font-size: 12px; }
.pool-layout { display: grid; grid-template-columns: 252px minmax(0, 1fr); min-height: 430px; border: 1px solid var(--line); border-radius: 11px; overflow: hidden; }
.pool-sidebar { padding: 8px; border-right: 1px solid var(--line); background: #f7f9fa; }
.pool-item { display: grid; grid-template-columns: 9px 1fr 16px; align-items: center; gap: 9px; width: 100%; min-height: 62px; margin-bottom: 5px; padding: 10px; border: 1px solid transparent; border-radius: 8px; text-align: left; background: transparent; cursor: pointer; }
.pool-item:hover { background: #fff; }.pool-item.active { border-color: #bfe1dc; background: #fff; box-shadow: 0 5px 16px rgba(25,69,70,.05); }
.pool-item strong, .pool-item small { display: block; }.pool-item strong { overflow: hidden; color: #253348; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }.pool-item small { margin-top: 4px; color: #8c98a7; font-size: 10px; }
.pool-status { width: 8px; height: 8px; border-radius: 50%; }.pool-status.online { background: #18a277; }.pool-status.offline { background: #aab3bf; }.pool-item > .el-icon { color: #9ca7b4; }
.pool-main { min-width: 0; }.pool-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; min-height: 78px; padding: 12px 15px; border-bottom: 1px solid var(--line); }
.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, .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>