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; } }
+59 -35
View File
@@ -1775,9 +1775,31 @@ const appointmentCellClasses = (row: any) => {
}
}
/** 仅已预约(1)可进视频/小程序码 */
const isAppointmentActiveForVideo = (row: any) =>
row.has_appointment && Number(row.appointment_status) === 1
/**
* 找到当前可用的已预约挂号。
* 同一诊单当天可能先完成一条挂号、随后又新增一条预约,此时行级 appointment_* 仍可能指向旧记录。
*/
const activeAppointment = (row: any) =>
appointmentRows(row).find((apt: any) => Number(apt?.status) === 1) ?? null
/** 二维码必须使用已预约挂号对应的医生和时间,不能继续沿用行级旧挂号字段。 */
const activeAppointmentRow = (row: any) => {
const apt = activeAppointment(row)
if (!apt) return null
return {
...row,
has_appointment: 1,
appointment_id: apt.id,
appointment_status: apt.status,
appointment_doctor_id: apt.doctor_id,
appointment_doctor_name: apt.doctor_name,
appointment_time_text: apt.time_text
}
}
/** 任一挂号记录处于已预约(1)即可进视频/小程序码。 */
const isAppointmentActiveForVideo = (row: any) => !!activeAppointment(row)
/** 已预约、已过号可取消(后端同步限制),针对行上主字段 */
const canCancelAppointmentRow = (row: any) => {
@@ -1962,17 +1984,18 @@ const submitFillIdCard = async () => {
}
// 生成视频二维码(跳转登录页)- 仅已预约(1)可生成
const handleVideoQRCode = async (row: any) => {
if (!isAppointmentActiveForVideo(row)) {
feedback.msgWarning('仅「已预约」状态可生成视频二维码')
return
}
if (!row.patient_id) {
feedback.msgWarning('患者信息不完整')
return
}
lastQRCodeType.value = 'video'
currentQRCodePatient.value = row
const handleVideoQRCode = async (row: any) => {
const qrcodeRow = activeAppointmentRow(row)
if (!qrcodeRow) {
feedback.msgWarning('仅「已预约」状态可生成视频二维码')
return
}
if (!qrcodeRow.patient_id) {
feedback.msgWarning('患者信息不完整')
return
}
lastQRCodeType.value = 'video'
currentQRCodePatient.value = qrcodeRow
qrcodeDialogVisible.value = true
qrcodeLoading.value = true
qrcodeUrl.value = ''
@@ -1983,11 +2006,11 @@ const handleVideoQRCode = async (row: any) => {
feedback.msgError('小程序未配置,请先配置小程序信息')
qrcodeDialogVisible.value = false
return
}
const result = await generateMiniProgramQrcode({
doctor_id: row.appointment_doctor_id,
diagnosis_id: row.appointment_doctor_id,
patient_id: row.patient_id,
}
const result = await generateMiniProgramQrcode({
doctor_id: qrcodeRow.appointment_doctor_id,
diagnosis_id: qrcodeRow.appointment_doctor_id,
patient_id: qrcodeRow.patient_id,
share_user_id: userStore.userInfo?.id || '',
mini_program_path: 'pages/login/login'
})
@@ -2004,17 +2027,18 @@ const handleVideoQRCode = async (row: any) => {
}
// 生成确认诊单二维码
const handleMiniProgramQRCode = async (row: any) => {
if (!isAppointmentActiveForVideo(row)) {
feedback.msgWarning('仅「已预约」状态可使用诊单二维码')
return
}
if (!row.patient_id) {
feedback.msgWarning('患者信息不完整')
return
}
lastQRCodeType.value = 'confirm'
currentQRCodePatient.value = row
const handleMiniProgramQRCode = async (row: any) => {
const qrcodeRow = activeAppointmentRow(row)
if (!qrcodeRow) {
feedback.msgWarning('仅「已预约」状态可使用诊单二维码')
return
}
if (!qrcodeRow.patient_id) {
feedback.msgWarning('患者信息不完整')
return
}
lastQRCodeType.value = 'confirm'
currentQRCodePatient.value = qrcodeRow
qrcodeDialogVisible.value = true
qrcodeLoading.value = true
qrcodeUrl.value = ''
@@ -2033,11 +2057,11 @@ const handleMiniProgramQRCode = async (row: any) => {
// 获取当前登录用户信息
const currentUser = userStore.userInfo
// 调用生成二维码接口
const result = await generateMiniProgramQrcode({
diagnosis_id: row.id,
doctor_id: row.appointment_doctor_id,
patient_id: row.patient_id,
// 调用生成二维码接口
const result = await generateMiniProgramQrcode({
diagnosis_id: qrcodeRow.id,
doctor_id: qrcodeRow.appointment_doctor_id,
patient_id: qrcodeRow.patient_id,
share_user_id: currentUser?.id || ''
})
+48 -26
View File
@@ -946,8 +946,28 @@ const appointmentRowClass = (row: any) => {
}
}
const isAppointmentActiveForVideo = (row: any) =>
row.has_appointment && Number(row.appointment_status) === 1
/**
* 同一诊单可能同时有已完成的旧挂号和已预约的新挂号,二维码应使用明细里的有效预约。
*/
const activeAppointment = (row: any) =>
appointmentRows(row).find((apt: any) => Number(apt?.status) === 1) ?? null
const activeAppointmentRow = (row: any) => {
const apt = activeAppointment(row)
if (!apt) return null
return {
...row,
has_appointment: 1,
appointment_id: apt.id,
appointment_status: apt.status,
appointment_doctor_id: apt.doctor_id,
appointment_doctor_name: apt.doctor_name,
appointment_time_text: apt.time_text
}
}
const isAppointmentActiveForVideo = (row: any) => !!activeAppointment(row)
const canCancelAppointmentRow = (row: any) => {
const s = Number(row.appointment_status)
@@ -1241,13 +1261,14 @@ const qrcodeAppointmentTimeText = computed(() => {
return p.appointment_time_text || '—'
})
const handleVideoQRCode = async (row: any) => {
if (!isAppointmentActiveForVideo(row)) {
feedback.msgWarning('仅「已预约」状态可生成视频二维码'); return
}
if (!row.patient_id) { feedback.msgWarning('患者信息不完整'); return }
lastQRCodeType.value = 'video'
currentQRCodePatient.value = row
const handleVideoQRCode = async (row: any) => {
const qrcodeRow = activeAppointmentRow(row)
if (!qrcodeRow) {
feedback.msgWarning('仅「已预约」状态可生成视频二维码'); return
}
if (!qrcodeRow.patient_id) { feedback.msgWarning('患者信息不完整'); return }
lastQRCodeType.value = 'video'
currentQRCodePatient.value = qrcodeRow
qrcodeDialogVisible.value = true
qrcodeLoading.value = true
qrcodeUrl.value = ''
@@ -1258,11 +1279,11 @@ const handleVideoQRCode = async (row: any) => {
feedback.msgError('小程序未配置')
qrcodeDialogVisible.value = false
return
}
const result = await generateMiniProgramQrcode({
doctor_id: row.appointment_doctor_id,
diagnosis_id: row.appointment_doctor_id,
patient_id: row.patient_id,
}
const result = await generateMiniProgramQrcode({
doctor_id: qrcodeRow.appointment_doctor_id,
diagnosis_id: qrcodeRow.appointment_doctor_id,
patient_id: qrcodeRow.patient_id,
share_user_id: userStore.userInfo?.id || '',
mini_program_path: 'pages/login/login'
})
@@ -1275,13 +1296,14 @@ const handleVideoQRCode = async (row: any) => {
}
}
const handleMiniProgramQRCode = async (row: any) => {
if (!isAppointmentActiveForVideo(row)) {
feedback.msgWarning('仅「已预约」状态可使用诊单二维码'); return
}
if (!row.patient_id) { feedback.msgWarning('患者信息不完整'); return }
lastQRCodeType.value = 'confirm'
currentQRCodePatient.value = row
const handleMiniProgramQRCode = async (row: any) => {
const qrcodeRow = activeAppointmentRow(row)
if (!qrcodeRow) {
feedback.msgWarning('仅「已预约」状态可使用诊单二维码'); return
}
if (!qrcodeRow.patient_id) { feedback.msgWarning('患者信息不完整'); return }
lastQRCodeType.value = 'confirm'
currentQRCodePatient.value = qrcodeRow
qrcodeDialogVisible.value = true
qrcodeLoading.value = true
qrcodeUrl.value = ''
@@ -1292,11 +1314,11 @@ const handleMiniProgramQRCode = async (row: any) => {
feedback.msgError('小程序未配置')
qrcodeDialogVisible.value = false
return
}
const result = await generateMiniProgramQrcode({
diagnosis_id: row.id,
doctor_id: row.appointment_doctor_id,
patient_id: row.patient_id,
}
const result = await generateMiniProgramQrcode({
diagnosis_id: qrcodeRow.id,
doctor_id: qrcodeRow.appointment_doctor_id,
patient_id: qrcodeRow.patient_id,
share_user_id: userStore.userInfo?.id || ''
})
if (result?.qrcode_url) qrcodeUrl.value = result.qrcode_url