This commit is contained in:
Your Name
2026-08-25 14:53:17 +08:00
parent af603a4e9a
commit 381fe65367
344 changed files with 617 additions and 398 deletions
@@ -351,19 +351,35 @@
<el-form label-position="top">
<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
<el-tree-select
v-model="poolForm.member_admin_ids"
:data="memberDepartmentTree"
:props="memberTreeProps"
:default-expanded-keys="memberTreeDefaultExpandedKeys"
:filter-node-method="filterMemberTreeNode"
:disabled="savingPool"
node-key="value"
multiple
filterable
clearable
show-checkbox
collapse-tags
collapse-tags-tooltip
placeholder="选择后由企业微信生成一个多人分流链接"
:max-collapse-tags="2"
:multiple-limit="500"
:render-after-expand="false"
placeholder="按部门展开,或搜索姓名 / 部门 / 企微 userid"
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">保存后会为每名医助生成一条成员规则,官方链接地址始终只有一个。</span>
<template #default="{ data }">
<span class="member-tree-option" :class="`is-${data.kind}`">
<span class="member-tree-option__label">{{ data.label }}</span>
<span v-if="data.kind === 'department'" class="member-tree-option__count">{{ data.member_count }} 人</span>
<span v-else class="member-tree-option__detail">{{ data.detail }}</span>
</span>
</template>
</el-tree-select>
<span class="form-tip">已选 {{ poolForm.member_admin_ids.length }} 名;保存后会为每名医助生成一条成员规则,官方链接地址始终只有一个。</span>
</el-form-item>
<el-form-item label="添加客户时跳过验证">
<el-switch v-model="poolForm.skip_verify" :disabled="savingPool" :active-value="1" :inactive-value="0" active-text="跳过验证" inactive-text="需要验证" />
@@ -421,13 +437,44 @@ 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, 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'
})
type TabName = 'links' | 'customer-stats' | 'configuration' | 'install'
interface PromotionDepartmentOption {
id: number | string
name: string
children?: PromotionDepartmentOption[]
}
interface PromotionMemberOption {
id: number
name: string
userid: string
display_dept_id?: number
dept_ids: number[]
dept_names: string[]
}
interface PromotionMemberTreeNode {
value: number | string
label: string
kind: 'department' | 'member'
disabled?: boolean
member_count?: number
detail?: string
search_text: string
children?: PromotionMemberTreeNode[]
}
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, 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 PromotionMemberOption[],
department_options: [] as PromotionDepartmentOption[],
customer_acquisition_link_example: 'https://work.weixin.qq.com/ca/xxxxxxxx'
})
const overview = reactive(emptyOverview())
const loading = ref(false)
@@ -462,8 +509,11 @@ const customerStats = reactive({
const selectedPool = computed(() => overview.pools.find((item: any) => Number(item.id) === selectedPoolId.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
const selectedInstallPool = computed(() => overview.pools.find((item: any) => Number(item.id) === selectedInstallPoolId.value))
const memberTreeProps = { value: 'value', label: 'label', children: 'children', disabled: 'disabled' }
const memberDepartmentTree = computed(() => buildMemberDepartmentTree(overview.department_options, overview.member_options))
const memberTreeDefaultExpandedKeys = computed(() => memberDepartmentTree.value.map((node) => node.value))
const customerLinkOptions = computed(() => customerStats.link_options.length
? customerStats.link_options
: overview.links
.filter((item: any) => item.id)
@@ -739,16 +789,122 @@ function eligibility(row: any) {
return { label: '可参与分流', className: 'is-ok' }
}
function todayCount(row: any) {
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 buildMemberDepartmentTree(
departmentOptions: PromotionDepartmentOption[],
memberOptions: PromotionMemberOption[]
): PromotionMemberTreeNode[] {
const availableDeptIds = new Set<number>()
const collectDeptIds = (departments: PromotionDepartmentOption[]) => {
departments.forEach((department) => {
const departmentId = Number(department.id)
if (departmentId > 0) availableDeptIds.add(departmentId)
if (Array.isArray(department.children)) collectDeptIds(department.children)
})
}
collectDeptIds(Array.isArray(departmentOptions) ? departmentOptions : [])
const members: PromotionMemberOption[] = []
const seenMemberIds = new Set<number>()
for (const rawMember of Array.isArray(memberOptions) ? memberOptions : []) {
const memberId = Number(rawMember.id)
if (memberId <= 0 || seenMemberIds.has(memberId)) continue
seenMemberIds.add(memberId)
members.push({
id: memberId,
name: String(rawMember.name || rawMember.userid || `成员 ${memberId}`),
userid: String(rawMember.userid || ''),
display_dept_id: Number(rawMember.display_dept_id || 0),
dept_ids: Array.isArray(rawMember.dept_ids)
? Array.from(new Set(rawMember.dept_ids.map(Number).filter((id) => id > 0)))
: [],
dept_names: Array.isArray(rawMember.dept_names)
? rawMember.dept_names.map(String).filter(Boolean)
: []
})
}
const membersByDept = new Map<number, PromotionMemberOption[]>()
members.forEach((member) => {
const explicitDeptId = Number(member.display_dept_id || 0)
const displayDeptId = availableDeptIds.has(explicitDeptId)
? explicitDeptId
: (member.dept_ids.find((id) => availableDeptIds.has(id)) || 0)
if (!membersByDept.has(displayDeptId)) membersByDept.set(displayDeptId, [])
membersByDept.get(displayDeptId)?.push(member)
})
const placedMemberIds = new Set<number>()
const toMemberNode = (member: PromotionMemberOption): PromotionMemberTreeNode => {
const departments = member.dept_names.length ? member.dept_names.join(' / ') : '未分部门'
const detail = member.userid ? `${departments} · ${member.userid}` : departments
return {
value: member.id,
label: member.name,
kind: 'member',
detail,
search_text: `${member.name} ${member.userid} ${member.dept_names.join(' ')}`.toLocaleLowerCase()
}
}
const byMemberName = (left: PromotionMemberOption, right: PromotionMemberOption) => left.name.localeCompare(right.name, 'zh-CN')
const buildDepartments = (departments: PromotionDepartmentOption[], parentPath: string[]): PromotionMemberTreeNode[] => {
const result: PromotionMemberTreeNode[] = []
for (const department of Array.isArray(departments) ? departments : []) {
const departmentId = Number(department.id)
if (departmentId <= 0) continue
const departmentName = String(department.name || `部门 ${departmentId}`)
const path = [...parentPath, departmentName]
const departmentChildren = buildDepartments(
Array.isArray(department.children) ? department.children : [],
path
)
const directMembers = [...(membersByDept.get(departmentId) || [])].sort(byMemberName)
directMembers.forEach((member) => placedMemberIds.add(member.id))
const memberChildren = directMembers.map(toMemberNode)
const children = [...departmentChildren, ...memberChildren]
if (!children.length) continue
const memberCount = children.reduce(
(count, child) => count + (child.kind === 'member' ? 1 : Number(child.member_count || 0)),
0
)
result.push({
value: `dept:${departmentId}`,
label: departmentName,
kind: 'department',
disabled: true,
member_count: memberCount,
search_text: `${path.join(' ')} ${children.map((child) => child.search_text).join(' ')}`.toLocaleLowerCase(),
children
})
}
return result
}
const tree = buildDepartments(Array.isArray(departmentOptions) ? departmentOptions : [], [])
const unassigned = members.filter((member) => !placedMemberIds.has(member.id)).sort(byMemberName)
if (unassigned.length) {
const children = unassigned.map(toMemberNode)
tree.push({
value: 'dept:unassigned',
label: '未分部门',
kind: 'department',
disabled: true,
member_count: children.length,
search_text: `未分部门 ${children.map((child) => child.search_text).join(' ')}`.toLocaleLowerCase(),
children
})
}
return tree
}
function filterMemberTreeNode(keyword: string, data: any) {
const query = String(keyword || '').trim().toLocaleLowerCase()
return query === '' || data.search_text.includes(query)
}
function memberSummary(pool: any) {
@@ -946,8 +1102,13 @@ h1, h2, h3, p { margin: 0; }
.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; }
.configuration-actions { display: flex; align-items: center; gap: 14px; margin-top: 16px; }
.install-pool-select { width: 220px; }
.member-tree-option { display: inline-flex; min-width: 0; flex: 1; align-items: center; gap: 10px; }
.member-tree-option__label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.member-tree-option.is-department .member-tree-option__label { color: #344054; font-weight: 600; }
.member-tree-option__count { flex: 0 0 auto; color: #98a2b3; font-size: 11px; }
.member-tree-option__detail { overflow: hidden; color: #8b97a6; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
.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, .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; } }