This commit is contained in:
Your Name
2026-08-25 09:34:39 +08:00
parent 562fe0ea0e
commit 1f3e580cf8
5 changed files with 312 additions and 368 deletions
+9 -1
View File
@@ -191,7 +191,7 @@ export function wecomPromotionOverview() {
}
export function wecomPromotionSavePool(params: Record<string, unknown>) {
return request.post({ url: '/firstvisit.wecomPromotion/savePool', params })
return request.post({ url: '/firstvisit.wecomPromotion/savePool', params, timeout: 120000 })
}
export function wecomPromotionSaveWidget(params: Record<string, unknown>) {
@@ -206,6 +206,14 @@ export function wecomPromotionSaveLink(params: Record<string, unknown>) {
return request.post({ url: '/firstvisit.wecomPromotion/saveLink', params })
}
export function wecomPromotionSaveMember(params: Record<string, unknown>) {
return request.post({ url: '/firstvisit.wecomPromotion/saveMember', params, timeout: 120000 })
}
export function wecomPromotionToggleMember(params: { id: number; status: number }) {
return request.post({ url: '/firstvisit.wecomPromotion/toggleMember', params, timeout: 120000 })
}
export function wecomPromotionCheckApiPermission() {
return request.post({ url: '/firstvisit.wecomPromotion/checkApiPermission' })
}
@@ -239,7 +239,7 @@
<div class="install-tip">
<el-icon><CircleCheck /></el-icon>
<span><strong>无需暴露链接</strong>浮窗点击后仍由服务端执行可用性筛选权重随机和访问统计</span>
<span><strong>一个方案一个官方链接</strong>浮窗会直接打开企业微信链接多名医助由企业微信执行均衡分流</span>
</div>
</section>
</section>
@@ -274,6 +274,8 @@ interface PromotionPool {
name?: string
public_key?: string
script_url?: string
main_url?: string
compat_go_url?: string
go_url?: string
install_code?: string
trigger_code?: string
@@ -437,11 +439,12 @@ async function copySnippet(value: string | undefined, label: string) {
}
function openTestLink() {
if (!props.pool.go_url) {
const targetUrl = props.pool.main_url || props.pool.go_url || props.pool.compat_go_url
if (!targetUrl) {
ElMessage.warning('当前方案暂无测试链接')
return
}
window.open(props.pool.go_url, '_blank', 'noopener,noreferrer')
window.open(targetUrl, '_blank', 'noopener,noreferrer')
}
</script>
@@ -5,7 +5,7 @@
<span class="heading-icon"><el-icon><Promotion /></el-icon></span>
<div>
<h1>企业微信获客助手</h1>
<p>对接企业微信获客助手官方 API按角色与部门管理获客成员并将链接安全分流到推广页面</p>
<p>一个方案保留一条企业微信官方直链按回调结果自动调度医助权重与数量</p>
</div>
</div>
<div class="heading-actions">
@@ -22,15 +22,15 @@
</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>
<div><small>分流方案</small><strong>{{ overview.summary.pool_count }}</strong><p>每个方案对应一个官方链接</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>
<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>
<div><small>今日实际获客</small><strong>{{ overview.summary.today_clicks }}</strong><p>按企业微信回调确认承接成员</p></div>
</article>
</section>
@@ -53,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>
@@ -69,7 +69,7 @@
@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>
<span><strong>{{ pool.name }}</strong><small>{{ memberSummary(pool) }} · {{ pool.main_url ? '官方链接已生成' : '待生成链接' }}</small></span>
<el-icon><ArrowRight /></el-icon>
</button>
</aside>
@@ -88,65 +88,53 @@
</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" :disabled="!selectedPool.main_url" @click="copyText(selectedPool.main_url, '官方获客链接')">复制链接</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"
v-if="selectedPool.migration_state === 'needs_resolution'"
class="legacy-sync-alert"
type="warning"
show-icon
:closable="false"
:title="`当前方案的 ${selectedLegacyCount} 条链接都是历史手工链接,企业微信同步不会返回它们`"
description="官方接口只返回当前应用通过 API 创建的获客链接。现有链接仍可参与本地分流;如需同步 link_id、客户和官方统计,请点击“创建官方获客链接”。"
title="检测到旧版方案存在多个官方链接"
description="当前显示最新链接;保存方案后,其余旧链接只会在本地下线,企业微信端与历史客户归因仍会保留。"
/>
<el-table :data="selectedLinks" class="link-table" stripe>
<el-table-column label="推广成员 / 分组" min-width="180" fixed="left">
<el-alert
class="legacy-sync-alert"
type="info"
show-icon
:closable="false"
title="回调驱动分流"
description="企业微信确认实际承接成员后,系统累计今日数量,并在符合启用状态、有效期和上限的成员中按权重随机抽取下一位;随机结果可能连续命中同一人,并发访问及企微同步存在短暂延迟。"
/>
<el-table :data="selectedMemberRules" class="link-table" stripe>
<el-table-column label="推广成员" min-width="210" 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="280">
<template #default="{ row }">
<div class="link-id-cell">
<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>
<a
v-if="row.wecom_url"
class="wecom-url"
:href="row.wecom_url"
:title="row.wecom_url"
target="_blank"
rel="noopener noreferrer"
@click.stop
>{{ row.wecom_url }}</a>
<el-button
v-if="row.wecom_url"
type="primary"
link
class="copy-url-btn"
@click.stop="copyText(row.wecom_url, '企业微信链接')"
>复制链接</el-button>
<span v-else class="muted">暂无链接地址</span>
<div class="member-cell">
<strong>{{ row.name || row.userid }}</strong>
<small>{{ memberRuleDetail(row) }}</small>
</div>
</template>
</el-table-column>
<el-table-column label="可用成员" min-width="150">
<el-table-column label="调度" width="105" align="center">
<template #default="{ row }">
<span>{{ remoteMemberLabel(row) }}</span>
<span v-if="row.is_current" class="status-tag is-online">{{ Number(row.sync_status) === 0 ? '当前命中' : '待切换' }}</span>
<span v-else-if="row.is_applied" class="status-tag is-offline">切换中</span>
<span v-else class="muted">候选</span>
</template>
</el-table-column>
<el-table-column label="权重" prop="weight" width="84" align="center" />
<el-table-column label="今日 / 上限" min-width="120" align="center">
<el-table-column label="权重" prop="weight" width="85" align="center" />
<el-table-column label="今日 / 上限" width="125" 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="累计获客" prop="total_count" 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>
@@ -154,30 +142,19 @@
</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)" />
<el-switch :model-value="Number(row.enabled) === 1" :loading="togglingMemberId === Number(row.id)" @change="(value) => handleMemberToggle(row, value)" />
</template>
</el-table-column>
<el-table-column label="操作" min-width="245" fixed="right">
<el-table-column label="操作" min-width="100" 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>
<el-button type="primary" link @click="openMemberDialog(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>
@@ -220,7 +197,7 @@
show-icon
:closable="false"
title="消息次数回调尚未配置完成"
description="复用企业微信自建应用现有的“API 接收消息”地址即可,无需新增第二个回调;请确保该地址为 QywxExternalContactCallback/notify,且服务器 Token、AESKey 与企微后台一致。未完成前无法得到精确接收消息次数。"
description="复用企业微信自建应用现有的“API 接收消息”地址即可,无需新增第二个回调;请确保该地址为 /api/qywx/external-contact/notify,且服务器 Token、AESKey 与企微后台一致。未完成前无法自动累计成员获客数量和切换下一位。"
/>
<el-alert
@@ -363,49 +340,67 @@
</div>
</section>
<el-dialog v-model="poolDialogVisible" :title="poolForm.id ? '编辑分流方案' : '新建分流方案'" width="560px" destroy-on-close>
<el-dialog
v-model="poolDialogVisible"
:title="poolForm.id ? '编辑分流方案' : '新建分流方案'"
width="680px"
destroy-on-close
:close-on-click-modal="false"
:close-on-press-escape="!savingPool"
>
<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-form-item label="方案名称" required><el-input v-model="poolForm.name" :disabled="savingPool" maxlength="60" show-word-limit placeholder="例如:官网咨询分流" /></el-form-item>
<el-form-item label="获客医助" required>
<el-select
v-model="poolForm.member_admin_ids"
:disabled="savingPool"
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>
<span class="form-tip">保存后会为每名医助生成一条成员规则官方链接地址始终只有一个</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 label="添加客户时跳过验证">
<el-switch v-model="poolForm.skip_verify" :disabled="savingPool" :active-value="1" :inactive-value="0" active-text="跳过验证" inactive-text="需要验证" />
</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 label="兜底获客助手链接">
<el-input v-model="poolForm.fallback_url" :disabled="savingPool" placeholder="可选;仅供旧版兼容跳转使用" />
<span class="form-tip">新生成的官方直链不经过本站跳转此项仅兼容旧安装代码</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>
<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-item label="运行状态"><el-switch v-model="poolForm.status" :disabled="savingPool" :active-value="1" :inactive-value="0" active-text="运行" inactive-text="停用" /></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>
<template #footer><el-button :disabled="savingPool" @click="poolDialogVisible = false">取消</el-button><el-button type="primary" :loading="savingPool" @click="savePool">{{ poolForm.id ? '保存方案' : '保存并生成链接' }}</el-button></template>
</el-dialog>
<el-dialog v-model="memberDialogVisible" title="编辑医助分流规则" width="560px" destroy-on-close :close-on-click-modal="false">
<el-form label-position="top">
<el-form-item label="获客医助">
<el-input :model-value="memberForm.name ? `${memberForm.name} · ${memberForm.userid}` : memberForm.userid" readonly />
</el-form-item>
<div class="rule-form-grid">
<el-form-item label="权重" required>
<el-input-number v-model="memberForm.weight" :min="1" :max="100" :disabled="savingMember" style="width: 100%" />
<span class="form-tip">权重越高在后续回调调度中被选中的频率越高</span>
</el-form-item>
<el-form-item label="每日获客上限">
<el-input-number v-model="memberForm.daily_limit" :min="0" :max="1000000" :disabled="savingMember" style="width: 100%" />
<span class="form-tip">0 表示不限数量按实际获客回调累计</span>
</el-form-item>
</div>
<el-form-item label="有效时间">
<el-date-picker v-model="memberForm.active_range" type="datetimerange" range-separator="至" start-placeholder="开始时间" end-placeholder="结束时间" value-format="YYYY-MM-DD HH:mm:ss" :disabled="savingMember" style="width: 100%" />
</el-form-item>
<el-form-item label="上线状态">
<el-switch v-model="memberForm.status" :active-value="1" :inactive-value="0" active-text="启用" inactive-text="禁用" :disabled="savingMember" />
</el-form-item>
<el-form-item label="备注"><el-input v-model="memberForm.remark" type="textarea" :rows="2" maxlength="255" show-word-limit :disabled="savingMember" /></el-form-item>
</el-form>
<template #footer><el-button :disabled="savingMember" @click="memberDialogVisible = false">取消</el-button><el-button type="primary" :loading="savingMember" @click="saveMemberRule">保存规则</el-button></template>
</el-dialog>
</div>
</template>
@@ -414,23 +409,19 @@
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
ArrowDown, ArrowRight, ChatDotRound, CircleCheck, Connection, DataAnalysis, Delete,
ArrowRight, ChatDotRound, CircleCheck, Connection, DataAnalysis, Delete,
DocumentCopy, Edit, Key, Link, Lock, Mouse, OfficeBuilding, Plus,
Promotion, Refresh, Search, SetUp, User, Warning
} from '@element-plus/icons-vue'
import {
wecomPromotionCheckApiPermission,
wecomPromotionCustomerStats,
wecomPromotionDeleteLink,
wecomPromotionDeleteRemoteLink,
wecomPromotionDeletePool,
wecomPromotionOverview,
wecomPromotionRemoteLinkDetail,
wecomPromotionSaveLink,
wecomPromotionSaveMember,
wecomPromotionSavePool,
wecomPromotionSyncCustomers,
wecomPromotionSyncRemoteLinks,
wecomPromotionToggleLink
wecomPromotionToggleMember
} from '@/api/first_visit'
import type { WecomPromotionCustomerChatStatus } from '@/api/first_visit'
@@ -450,14 +441,13 @@ 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 memberDialogVisible = ref(false)
const savingMember = ref(false)
const togglingMemberId = ref(0)
const poolForm = reactive({ id: 0, name: '', fallback_url: '', status: 1, member_admin_ids: [] as number[], skip_verify: 0, main_url: '' })
const memberForm = reactive({ id: 0, name: '', userid: '', weight: 1, daily_limit: 0, status: 1, active_range: [] as string[], remark: '' })
const customerStatsLoading = ref(false)
const customerStatsLoaded = ref(false)
const syncingCustomers = ref(false)
@@ -476,9 +466,7 @@ const customerStats = reactive({
})
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 selectedMemberRules = computed(() => Array.isArray(selectedPool.value?.member_rules) ? selectedPool.value.member_rules : [])
const selectedInstallPool = computed(() => overview.pools.find((item: any) => Number(item.id) === selectedInstallPoolId.value))
const customerLinkOptions = computed(() => customerStats.link_options.length
? customerStats.link_options
@@ -516,24 +504,37 @@ async function loadOverview() {
}
}
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 })
Object.assign(poolForm, pool ? {
id: Number(pool.id),
name: pool.name,
fallback_url: pool.fallback_url || '',
status: Number(pool.status),
member_admin_ids: Array.isArray(pool.member_admin_ids) ? pool.member_admin_ids.map(Number) : [],
skip_verify: Number(pool.skip_verify) === 1 ? 1 : 0,
main_url: pool.main_url || ''
} : {
id: 0,
name: '',
fallback_url: '',
status: 1,
member_admin_ids: [],
skip_verify: 0,
main_url: ''
})
poolDialogVisible.value = true
}
async function savePool() {
if (!poolForm.name.trim()) return ElMessage.warning('请输入分流方案名称')
if (!poolForm.member_admin_ids.length) 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('分流方案已保存')
ElMessage.success(poolForm.id ? '分流方案已保存' : '分流方案和官方获客链接已创建')
} catch (error: any) {
ElMessage.error(error?.message || '分流方案保存失败')
} finally {
@@ -542,7 +543,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('分流方案已删除')
@@ -552,46 +553,56 @@ async function removePool(pool: any) {
}
}
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: ''
function openMemberDialog(row: any) {
const range = Number(row.active_start) > 0 && Number(row.active_end) > 0
? [formatPickerTime(row.active_start), formatPickerTime(row.active_end)]
: []
Object.assign(memberForm, {
id: Number(row.id),
name: row.name || '',
userid: row.userid || '',
weight: Number(row.weight) || 1,
daily_limit: Number(row.daily_limit) || 0,
status: Number(row.enabled) === 1 ? 1 : 0,
active_range: range,
remark: row.remark || ''
})
linkDialogVisible.value = true
memberDialogVisible.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
async function saveMemberRule() {
savingMember.value = true
try {
await wecomPromotionSaveLink({
...linkForm,
active_start: linkForm.active_range?.[0] || '',
active_end: linkForm.active_range?.[1] || ''
const result: any = await wecomPromotionSaveMember({
...memberForm,
active_start: memberForm.active_range?.[0] || '',
active_end: memberForm.active_range?.[1] || ''
})
linkDialogVisible.value = false
memberDialogVisible.value = false
await loadOverview()
ElMessage.success(linkForm.legacy ? '本地分流规则已保存' : '企业微信官方获客链接已保存')
result?.sync_error
? ElMessage.warning('规则已保存,企业微信成员范围将在后台自动重试同步')
: ElMessage.success('医助分流规则已保存')
} catch (error: any) {
ElMessage.error(error?.message || '获客助手链接保存失败')
ElMessage.error(error?.message || '医助分流规则保存失败')
} finally {
savingLink.value = false
savingMember.value = false
}
}
async function handleMemberToggle(row: any, value: unknown) {
togglingMemberId.value = Number(row.id)
try {
const result: any = await wecomPromotionToggleMember({ id: Number(row.id), status: value ? 1 : 0 })
await loadOverview()
result?.sync_error
? ElMessage.warning('成员状态已保存,企业微信成员范围将在后台自动重试同步')
: ElMessage.success(value ? '医助已启用' : '医助已禁用并退出后续调度')
} catch (error: any) {
ElMessage.error(error?.message || '成员状态更新失败')
await loadOverview()
} finally {
togglingMemberId.value = 0
}
}
@@ -710,93 +721,15 @@ async function syncCustomers() {
}
}
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.enabled) !== 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' }
const isSyncTarget = Boolean(row.is_current || row.is_applied)
if (isSyncTarget && Number(row.sync_status) === 3) return { label: '企微同步重试中', className: 'is-waiting' }
if (isSyncTarget && Number(row.sync_status) === 4) return { label: '暂无可用调度', className: 'is-error' }
return { label: '可参与分流', className: 'is-ok' }
}
@@ -812,16 +745,14 @@ function memberOptionLabel(member: any) {
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 memberSummary(pool: any) {
const ids = Array.isArray(pool?.member_admin_ids) ? pool.member_admin_ids : []
return ids.length ? `${ids.length} 名医助` : '未选择医助'
}
function memberRuleDetail(row: any) {
const departments = Array.isArray(row.dept_names) && row.dept_names.length ? row.dept_names.join(' / ') : '未分部门'
return `${departments} · ${row.userid || '未绑定 userid'}`
}
function memberFilterLabel(member: any) {
@@ -930,8 +861,9 @@ async function copyText(value: string, label: string) {
textarea.style.opacity = '0'
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
const copied = document.execCommand('copy')
textarea.remove()
if (!copied) throw new Error('copy command failed')
}
ElMessage.success(`${label}已复制`)
}
@@ -1010,7 +942,7 @@ h1, h2, h3, p { margin: 0; }
.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; }
.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%; }
.form-grid, .rule-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; } }
@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, .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; } }
@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, .form-grid, .rule-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
+1
View File
@@ -47,6 +47,7 @@ export default defineConfig(({ mode }) => {
return {
base: '/admin/',
server: {
port: 5555,
host: '0.0.0.0',
hmr: true,
open: true,