更新
This commit is contained in:
@@ -190,6 +190,10 @@ export function wecomPromotionSavePool(params: Record<string, unknown>) {
|
||||
return request.post({ url: '/firstvisit.wecomPromotion/savePool', params })
|
||||
}
|
||||
|
||||
export function wecomPromotionSaveWidget(params: Record<string, unknown>) {
|
||||
return request.post({ url: '/firstvisit.wecomPromotion/saveWidget', params })
|
||||
}
|
||||
|
||||
export function wecomPromotionDeletePool(params: { id: number }) {
|
||||
return request.post({ url: '/firstvisit.wecomPromotion/deletePool', params })
|
||||
}
|
||||
|
||||
@@ -318,15 +318,22 @@ const targetChartOption = computed(() => ({
|
||||
]
|
||||
}))
|
||||
|
||||
let latestDashboardRequestId = 0
|
||||
|
||||
async function loadDashboard() {
|
||||
const requestId = ++latestDashboardRequestId
|
||||
loading.value = true
|
||||
try {
|
||||
const result: any = await firstVisitConversionOverview(query)
|
||||
const result: any = await firstVisitConversionOverview({ ...query })
|
||||
if (requestId !== latestDashboardRequestId) return
|
||||
Object.assign(dashboard, emptyDashboard(), result || {})
|
||||
// 服务端会规范化失效渠道;同步真实生效值,避免筛选框与数据口径不一致。
|
||||
query.media_channel_code = dashboard.meta.selected_media_channel_code || ''
|
||||
} catch (error: any) {
|
||||
if (requestId !== latestDashboardRequestId) return
|
||||
ElMessage.error(error?.message || '综合数据加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
if (requestId === latestDashboardRequestId) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1222
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,147 @@
|
||||
export const WECOM_WIDGET_TEMPLATE_IDS = [
|
||||
'bubble',
|
||||
'pill',
|
||||
'card',
|
||||
'message',
|
||||
'edge',
|
||||
'bar'
|
||||
] as const
|
||||
|
||||
export type WecomWidgetTemplateId = (typeof WECOM_WIDGET_TEMPLATE_IDS)[number]
|
||||
export type WecomWidgetPosition = 'bottom-right' | 'bottom-left'
|
||||
|
||||
export interface WecomWidgetConfig {
|
||||
v: 1
|
||||
enabled: boolean
|
||||
template: WecomWidgetTemplateId
|
||||
position: WecomWidgetPosition
|
||||
title: string
|
||||
subtitle: string
|
||||
button_text: string
|
||||
primary_color: string
|
||||
bottom_offset: number
|
||||
show_mobile: boolean
|
||||
}
|
||||
|
||||
export interface WecomWidgetTemplateOption {
|
||||
id: WecomWidgetTemplateId
|
||||
name: string
|
||||
description: string
|
||||
scene: string
|
||||
}
|
||||
|
||||
export const DEFAULT_WECOM_WIDGET_CONFIG: Readonly<WecomWidgetConfig> = Object.freeze({
|
||||
v: 1,
|
||||
enabled: false,
|
||||
template: 'bubble',
|
||||
position: 'bottom-right',
|
||||
title: '专属顾问在线',
|
||||
subtitle: '点击添加企业微信,获取一对一服务',
|
||||
button_text: '立即咨询',
|
||||
primary_color: '#139A8C',
|
||||
bottom_offset: 28,
|
||||
show_mobile: true
|
||||
})
|
||||
|
||||
export const WECOM_WIDGET_TEMPLATES: ReadonlyArray<WecomWidgetTemplateOption> = Object.freeze([
|
||||
{
|
||||
id: 'bubble',
|
||||
name: '轻巧气泡',
|
||||
description: '圆形入口,占用空间最少',
|
||||
scene: '内容型页面'
|
||||
},
|
||||
{
|
||||
id: 'pill',
|
||||
name: '行动胶囊',
|
||||
description: '图标与按钮文案同时露出',
|
||||
scene: '营销落地页'
|
||||
},
|
||||
{
|
||||
id: 'card',
|
||||
name: '顾问名片',
|
||||
description: '完整呈现标题、说明与行动按钮',
|
||||
scene: '高意向咨询'
|
||||
},
|
||||
{
|
||||
id: 'message',
|
||||
name: '消息提醒',
|
||||
description: '模拟新消息,视觉提醒更明确',
|
||||
scene: '活动推广页'
|
||||
},
|
||||
{
|
||||
id: 'edge',
|
||||
name: '贴边咨询',
|
||||
description: '沿浏览器边缘停靠,干扰更低',
|
||||
scene: '工具与内容页'
|
||||
},
|
||||
{
|
||||
id: 'bar',
|
||||
name: '底部咨询条',
|
||||
description: '宽幅行动区,移动端更醒目',
|
||||
scene: '移动端页面'
|
||||
}
|
||||
])
|
||||
|
||||
const TEMPLATE_SET = new Set<string>(WECOM_WIDGET_TEMPLATE_IDS)
|
||||
const POSITION_SET = new Set<string>(['bottom-right', 'bottom-left'])
|
||||
const HEX_COLOR_PATTERN = /^#[0-9A-F]{6}$/i
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
if (typeof value !== 'string' || !value.trim()) return {}
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(value)
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
||||
? parsed as Record<string, unknown>
|
||||
: {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeBoolean(value: unknown, fallback: boolean): boolean {
|
||||
if (value === true || value === 1 || value === '1' || value === 'true') return true
|
||||
if (value === false || value === 0 || value === '0' || value === 'false') return false
|
||||
return fallback
|
||||
}
|
||||
|
||||
function normalizeText(value: unknown, fallback: string, maxLength: number): string {
|
||||
if (value === undefined || value === null) return fallback
|
||||
return Array.from(String(value).trim()).slice(0, maxLength).join('')
|
||||
}
|
||||
|
||||
export function normalizeWecomWidgetConfig(value: unknown): WecomWidgetConfig {
|
||||
const source = asRecord(value)
|
||||
const template = String(source.template || '')
|
||||
const position = String(source.position || '')
|
||||
const color = String(source.primary_color || '').trim().toUpperCase()
|
||||
const rawOffset = Number(source.bottom_offset)
|
||||
const bottomOffset = Number.isFinite(rawOffset)
|
||||
? Math.min(160, Math.max(16, Math.round(rawOffset)))
|
||||
: DEFAULT_WECOM_WIDGET_CONFIG.bottom_offset
|
||||
|
||||
return {
|
||||
v: 1,
|
||||
enabled: normalizeBoolean(source.enabled, DEFAULT_WECOM_WIDGET_CONFIG.enabled),
|
||||
template: TEMPLATE_SET.has(template)
|
||||
? template as WecomWidgetTemplateId
|
||||
: DEFAULT_WECOM_WIDGET_CONFIG.template,
|
||||
position: POSITION_SET.has(position)
|
||||
? position as WecomWidgetPosition
|
||||
: DEFAULT_WECOM_WIDGET_CONFIG.position,
|
||||
title: normalizeText(source.title, DEFAULT_WECOM_WIDGET_CONFIG.title, 24),
|
||||
subtitle: normalizeText(source.subtitle, DEFAULT_WECOM_WIDGET_CONFIG.subtitle, 48),
|
||||
button_text: normalizeText(source.button_text, DEFAULT_WECOM_WIDGET_CONFIG.button_text, 12),
|
||||
primary_color: HEX_COLOR_PATTERN.test(color)
|
||||
? color
|
||||
: DEFAULT_WECOM_WIDGET_CONFIG.primary_color,
|
||||
bottom_offset: bottomOffset,
|
||||
show_mobile: normalizeBoolean(source.show_mobile, DEFAULT_WECOM_WIDGET_CONFIG.show_mobile)
|
||||
}
|
||||
}
|
||||
|
||||
export function cloneDefaultWecomWidgetConfig(): WecomWidgetConfig {
|
||||
return { ...DEFAULT_WECOM_WIDGET_CONFIG }
|
||||
}
|
||||
@@ -323,39 +323,20 @@
|
||||
<div v-else class="tab-content install-tab">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<h2>安装 JS 到推广落地页</h2>
|
||||
<p>基础脚本只负责捕获点击并请求服务端分流,不携带授权凭证,也不在浏览器中计算随机规则。</p>
|
||||
<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></head></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>
|
||||
<WecomFloatingWidgetBuilder
|
||||
v-if="selectedInstallPool"
|
||||
:key="Number(selectedInstallPool.id)"
|
||||
:pool="selectedInstallPool"
|
||||
@saved="handleWidgetSaved"
|
||||
/>
|
||||
<el-empty v-else description="请先创建分流方案,系统会自动生成 JS 安装代码" />
|
||||
</div>
|
||||
</section>
|
||||
@@ -412,8 +393,8 @@ 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
|
||||
DocumentCopy, Edit, Key, Link, Lock, Mouse, OfficeBuilding, Plus,
|
||||
Promotion, Refresh, Search, SetUp, User, Warning
|
||||
} from '@element-plus/icons-vue'
|
||||
import {
|
||||
wecomPromotionCheckApiPermission,
|
||||
@@ -431,10 +412,12 @@ import {
|
||||
} from '@/api/first_visit'
|
||||
import type { WecomPromotionCustomerChatStatus } from '@/api/first_visit'
|
||||
|
||||
import WecomFloatingWidgetBuilder from './components/WecomFloatingWidgetBuilder.vue'
|
||||
|
||||
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: '' },
|
||||
config: { mode: 'internal', configured: false, ready: false, missing: [] as string[], corp_id_masked: '', agent_id: '', secret_configured: false, callback_ready: false, callback_url: '', 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'
|
||||
})
|
||||
@@ -930,8 +913,8 @@ async function copyText(value: string, label: string) {
|
||||
ElMessage.success(`${label}已复制`)
|
||||
}
|
||||
|
||||
function openTestLink() {
|
||||
if (selectedInstallPool.value?.go_url) window.open(selectedInstallPool.value.go_url, '_blank', 'noopener,noreferrer')
|
||||
async function handleWidgetSaved() {
|
||||
await loadOverview()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -998,10 +981,8 @@ h1, h2, h3, p { margin: 0; }
|
||||
.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; }
|
||||
.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%; }
|
||||
@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; } }
|
||||
@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; } }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user