Compare commits

...
29 Commits
Author SHA1 Message Date
Your Name 56dbd7f115 新增 2026-08-08 15:46:32 +08:00
Your Name d10f213573 更新 2026-08-08 15:42:45 +08:00
Your Name a968945057 更新 2026-08-07 15:25:09 +08:00
Your Name 971a627288 更新 2026-08-07 15:13:36 +08:00
Your Name c37d5abac4 更新 2026-08-07 15:08:55 +08:00
Your Name ebc463864b 更新 2026-08-07 15:07:17 +08:00
Your Name ab140ab05e Merge branch 'yz-0803' 2026-08-07 15:02:36 +08:00
Your Name 894f52e875 更新 2026-08-07 15:02:20 +08:00
Your Name a133d5d85d 更新 2026-08-07 14:57:33 +08:00
Your Name 2c238d6599 Merge branch 'yz-0803' 2026-08-07 14:54:48 +08:00
Your Name 6ce58bcd85 更新 2026-08-07 14:54:05 +08:00
Your Name 1ca87f8d72 新增 2026-08-07 14:38:25 +08:00
Your Name 5bf6ea01f6 Merge branch 'yz-0803' 2026-08-07 14:33:50 +08:00
Your Name 58a7197d3e 新增功能 2026-08-07 14:32:35 +08:00
Your Name 90be4bf60b 更新 2026-08-07 09:24:50 +08:00
Your Name 1c7b22a72f Merge branch 'yz-0803' 2026-08-07 09:17:18 +08:00
Your Name 16d301f302 更新 2026-08-07 09:16:15 +08:00
Your Name 00ce6d6875 更新 2026-08-06 16:36:37 +08:00
Your Name bdd8e69ec8 Merge branch 'yz-0803' 2026-08-06 16:30:05 +08:00
Your Name 8bbd6f7885 更新 2026-08-06 16:29:08 +08:00
Your Name 245c52075d 更新 2026-08-06 14:52:40 +08:00
Your Name c836084fd3 Merge branch 'yz-0803'
GENG
2026-08-06 14:49:29 +08:00
Your Name a010483bdc 更新 2026-08-06 14:48:14 +08:00
Your Name e20374e5d8 更新 2026-08-06 14:13:54 +08:00
Your Name 037e5ba450 更新 2026-08-06 14:11:52 +08:00
Your Name d5b0ab4709 更新 2026-08-06 12:03:58 +08:00
Your Name 079e50006d 更新 2026-08-06 10:57:35 +08:00
Your Name 2c0b9c5afa 更新 2026-08-05 14:37:29 +08:00
Your Name dd28bba354 更新 2026-08-05 14:14:55 +08:00
443 changed files with 7382 additions and 1285 deletions
+93 -10
View File
@@ -4,7 +4,7 @@ export interface MyPatientListParams {
page_no: number
page_size: number
keyword?: string
status_filter?: '' | 'unconfirmed' | 'booked' | 'completed' | 'missed'
status_filter?: '' | 'unbooked' | 'pending_interview' | 'completed' | 'missed'
start_date?: string
end_date?: string
}
@@ -125,10 +125,23 @@ export function myPatientCancelAppointment(params: { id: number }) {
return request.post({ url: '/firstvisit.myPatient/cancelAppointment', params })
}
export function myPatientAssistants() {
return request.get({ url: '/firstvisit.myPatient/assistants' })
}
export function myPatientAssign(params: { id: number; assistant_id: number; is_inherit?: 0 | 1 }) {
return request.post({ url: '/firstvisit.myPatient/assign', params })
}
export function myPatientFillIdCard(params: { id: number; id_card: string }) {
return request.post({ url: '/firstvisit.myPatient/fillIdCard', params })
}
export interface FirstVisitConversionParams {
time_type: 'today' | 'week' | 'month' | 'quarter' | 'year'
time_type: 'today' | 'yesterday' | 'week' | 'month' | 'quarter' | 'year'
dept_id?: number
assistant_id?: number
media_channel_code?: string
}
/** 一诊综合数据转化:服务端按当前角色 DataScope 与所选部门/员工取交集。 */
@@ -173,18 +186,14 @@ export function wecomPromotionOverview() {
return request.get({ url: '/firstvisit.wecomPromotion/overview' })
}
export function wecomPromotionAuthorizationUrl() {
return request.post({ url: '/firstvisit.wecomPromotion/authorizationUrl' })
}
export function wecomPromotionVerifyAccount(params: { id: number }) {
return request.post({ url: '/firstvisit.wecomPromotion/verifyAccount', params })
}
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 })
}
@@ -193,6 +202,22 @@ export function wecomPromotionSaveLink(params: Record<string, unknown>) {
return request.post({ url: '/firstvisit.wecomPromotion/saveLink', params })
}
export function wecomPromotionCheckApiPermission() {
return request.post({ url: '/firstvisit.wecomPromotion/checkApiPermission' })
}
export function wecomPromotionSyncRemoteLinks(params: { pool_id: number }) {
return request.post({ url: '/firstvisit.wecomPromotion/syncRemoteLinks', params, timeout: 120000 })
}
export function wecomPromotionRemoteLinkDetail(params: { id: number }) {
return request.get({ url: '/firstvisit.wecomPromotion/remoteLinkDetail', params })
}
export function wecomPromotionDeleteRemoteLink(params: { id: number }) {
return request.post({ url: '/firstvisit.wecomPromotion/deleteRemoteLink', params })
}
export function wecomPromotionToggleLink(params: { id: number; status: number }) {
return request.post({ url: '/firstvisit.wecomPromotion/toggleLink', params })
}
@@ -200,3 +225,61 @@ export function wecomPromotionToggleLink(params: { id: number; status: number })
export function wecomPromotionDeleteLink(params: { id: number }) {
return request.post({ url: '/firstvisit.wecomPromotion/deleteLink', params })
}
export type WecomPromotionCustomerChatStatus = '' | 'messaged' | 'silent' | 'unknown'
export interface WecomPromotionCustomerStatsParams {
page_no: number
page_size: number
promotion_link_id?: number
userid?: string
chat_status?: 0 | 1 | 2
}
export interface WecomPromotionCustomerStatsSummary {
customer_count: number
messaged_customer_count: number
message_customer_rate: number
received_message_count: number
message_count_known_count: number
}
export interface WecomPromotionCustomerStatRow {
id?: number
external_userid_masked?: string
customer_id_masked?: string
customer_name_masked?: string
link_id?: number | string
link_name?: string
member_id?: number
member_name?: string
department_name?: string
dept_name?: string
has_messaged?: boolean | number
chat_status?: 0 | 1 | 2
message_count_known?: boolean | number
received_message_count?: number
last_synced_at?: string
last_message_at?: string
}
export interface WecomPromotionCustomerStatsResult {
summary?: Partial<WecomPromotionCustomerStatsSummary>
lists?: WecomPromotionCustomerStatRow[]
total?: number
link_options?: Array<{ id: number | string; name: string }>
member_options?: Array<{ id: number; name: string; department_name?: string; dept_name?: string }>
meta?: { last_synced_at?: string }
}
/** 获客客户消息统计:服务端继续按当前角色和部门数据范围收窄。 */
export function wecomPromotionCustomerStats(params: WecomPromotionCustomerStatsParams) {
return request.get(
{ url: '/firstvisit.wecomPromotion/customerStatistics', params, timeout: 120000 },
{ ignoreCancelToken: true }
)
}
export function wecomPromotionSyncCustomers(params: { promotion_link_id?: number } = {}) {
return request.post({ url: '/firstvisit.wecomPromotion/syncCustomers', params, timeout: 120000 })
}
+2 -2
View File
@@ -1,9 +1,9 @@
import request from '@/utils/request'
/** 角色数据驾驶舱:服务端统一按当前管理员的数据范围聚合。 */
export function performanceDashboardOverview() {
export function performanceDashboardOverview(params?: { ranking_dept_id?: number; _t?: number }) {
return request.get(
{ url: '/stats.performanceDashboard/overview', timeout: 120000 },
{ url: '/stats.performanceDashboard/overview', params, timeout: 120000 },
{ ignoreCancelToken: true }
)
}
@@ -51,6 +51,24 @@
@change="handleDeptChange"
/>
</div>
<div class="filter-item">
<span class="filter-label">渠道</span>
<el-select
v-model="query.media_channel_code"
clearable
filterable
placeholder="全部渠道"
class="channel-select"
@change="loadDashboard"
>
<el-option
v-for="item in dashboard.filters.media_channels"
:key="item.code"
:label="item.name"
:value="item.code"
/>
</el-select>
</div>
<span class="range-text">{{ dashboard.meta.start_date }} {{ dashboard.meta.end_date }}</span>
</section>
@@ -67,7 +85,7 @@
<div class="panel-heading">
<div>
<h2>部门订单量占比</h2>
<p>当前范围内审核通过的接诊诊单</p>
<p>按订单创建人归属排除取消拒收及退款</p>
</div>
<span>单位</span>
</div>
@@ -85,7 +103,7 @@
<div class="panel-heading">
<div>
<h2>部门金额占比</h2>
<p>与诊单金额指标保持同一审核口径</p>
<p>仅统计未取消未拒收且未退款的有效金额</p>
</div>
<span>单位</span>
</div>
@@ -104,7 +122,7 @@
<div class="panel-heading panel-heading--table">
<div>
<h2>明细数据列表</h2>
<p>部门层级汇总父级包含其下级数据</p>
<p>展开部门可查看人员明细挂号=已支付且实收低于 10 元的订单预约=有效预约记录开口率=开口/加粉挂号率=挂号/加粉面诊率=面诊/预约接诊率=接诊诊单/加粉</p>
</div>
<span>{{ dashboard.rows.length }} 个顶层节点</span>
</div>
@@ -115,23 +133,43 @@
default-expand-all
class="detail-table"
>
<el-table-column prop="name" label="部门" min-width="230" fixed="left">
<el-table-column prop="name" label="部门 / 人员" min-width="250" fixed="left">
<template #default="{ row }">
<strong :class="{ 'is-parent': Array.isArray(row.children) && row.children.length }">
<strong :class="{ 'is-parent': Array.isArray(row.children) && row.children.length, 'is-member': row.type === 'member' }">
{{ row.name }}
</strong>
<el-tag v-if="row.type === 'member' && row.role" size="small" type="info" effect="plain" class="role-tag">
{{ row.role }}{{ row.is_leader ? ' · 组长' : '' }}
</el-tag>
<el-tag v-else-if="row.type === 'unbound'" size="small" type="warning" effect="plain" class="role-tag">
未绑定账号
</el-tag>
</template>
</el-table-column>
<el-table-column prop="add_fans_count" label="加粉" min-width="88" align="right" />
<el-table-column prop="total_open_count" label="开口" min-width="88" align="right" />
<el-table-column prop="paid_appointment_count" label="挂号" min-width="88" align="right" />
<el-table-column prop="appointment_total_count" label="预约" min-width="88" align="right" />
<el-table-column prop="interview_count" label="面诊" min-width="88" align="right" />
<el-table-column prop="completed_order_count" label="接诊诊单" min-width="104" align="right" />
<el-table-column label="诊金额" min-width="120" align="right">
<el-table-column label="诊金额" min-width="120" align="right">
<template #default="{ row }">{{ formatMoney(row.completed_order_amount) }}</template>
</el-table-column>
<el-table-column label="接诊率" min-width="96" align="right">
<el-table-column label="开口率" min-width="96" align="right">
<template #default="{ row }">{{ formatPercent(row.total_open_rate) }}</template>
</el-table-column>
<el-table-column label="挂号率" min-width="96" align="right">
<template #default="{ row }">{{ formatPercent(row.paid_appointment_rate) }}</template>
</el-table-column>
<el-table-column label="面诊率" min-width="96" align="right">
<template #default="{ row }">{{ formatPercent(row.interview_rate) }}</template>
</el-table-column>
<el-table-column label="面诊接诊率" min-width="116" align="right">
<template #default="{ row }">{{ formatPercent(row.interview_receive_rate) }}</template>
</el-table-column>
<el-table-column label="接诊率" min-width="96" align="right">
<template #default="{ row }">{{ formatPercent(row.receive_rate) }}</template>
</el-table-column>
<el-table-column label="ROI" min-width="86" align="right">
<template #default="{ row }">{{ formatRatio(row.roi) }}</template>
</el-table-column>
@@ -212,9 +250,14 @@ type MetricType = 'count' | 'money' | 'ratio'
const emptyDashboard = () => ({
meta: {
time_type: 'today', time_label: '今日', start_date: '', end_date: '', generated_at: '',
scope_value: 4, scope_label: '', selected_dept_name: '', selected_assistant_name: '', open_count_source: ''
scope_value: 4, scope_label: '', selected_dept_name: '', selected_assistant_name: '',
selected_media_channel_code: '', selected_media_channel_name: '', open_count_source: ''
},
filters: {
departments: [] as any[],
assistants: [] as Array<{ id: number; name: string }>,
media_channels: [] as Array<{ code: string; name: string }>
},
filters: { departments: [] as any[], assistants: [] as Array<{ id: number; name: string }> },
summary: {} as Record<string, any>,
rankings: { orders: [] as any[], amounts: [] as any[] },
rows: [] as any[],
@@ -227,10 +270,16 @@ const emptyDashboard = () => ({
const dashboard = reactive(emptyDashboard())
const loading = ref(false)
const query = reactive<FirstVisitConversionParams>({ time_type: 'today', dept_id: undefined, assistant_id: undefined })
const query = reactive<FirstVisitConversionParams>({
time_type: 'today',
dept_id: undefined,
assistant_id: undefined,
media_channel_code: ''
})
const deptTreeProps = { value: 'id', label: 'name', children: 'children' }
const timeOptions = [
{ label: '今日', value: 'today' },
{ label: '昨天', value: 'yesterday' },
{ label: '本周', value: 'week' },
{ label: '本月', value: 'month' },
{ label: '本季度', value: 'quarter' },
@@ -239,9 +288,9 @@ const timeOptions = [
const metricCards: Array<{ key: string; label: string; type: MetricType; hint: string }> = [
{ key: 'add_fans_count', label: '加粉数', type: 'count', hint: '企微新增客户' },
{ key: 'total_open_count', label: '开口数', type: 'count', hint: '来源于个人业绩录入' },
{ key: 'interview_count', label: '面诊', type: 'count', hint: '已完成挂号' },
{ key: 'completed_order_count', label: '接诊诊单', type: 'count', hint: '处方与支付审核通过' },
{ key: 'completed_order_amount', label: '诊单金额', type: 'money', hint: '与接诊诊单同口径' },
{ key: 'interview_count', label: '面诊', type: 'count', hint: '已完成预约' },
{ key: 'completed_order_count', label: '接诊诊单', type: 'count', hint: '业务订单,按创建人归属并过滤无效单' },
{ key: 'completed_order_amount', label: '诊单金额', type: 'money', hint: '排除取消、拒收、退款及已退款订单' },
{ key: 'avg_unit_price', label: '平均客单价', type: 'money', hint: '诊单金额 / 接诊诊单' },
{ key: 'account_cost', label: '现金成本', type: 'money', hint: '当前范围内实际投放成本' },
{ key: 'roi', label: 'ROI', type: 'ratio', hint: '诊单金额 / 投放成本' }
@@ -251,6 +300,7 @@ const scopeDescription = computed(() => {
const parts = [`${dashboard.meta.time_label || '当前区间'}数据`, dashboard.meta.scope_label || '当前权限范围']
if (dashboard.meta.selected_dept_name) parts.push(dashboard.meta.selected_dept_name)
if (dashboard.meta.selected_assistant_name) parts.push(dashboard.meta.selected_assistant_name)
if (dashboard.meta.selected_media_channel_name) parts.push(`渠道:${dashboard.meta.selected_media_channel_name}`)
return parts.join(' · ')
})
const maxOrderValue = computed(() => Math.max(0, ...dashboard.rankings.orders.map(item => Number(item.value || 0))))
@@ -268,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
}
}
@@ -387,6 +444,7 @@ onMounted(loadDashboard)
.range-text { margin-left: auto; }
.employee-select { width: 190px; }
.dept-select { width: 220px; }
.channel-select { width: 180px; }
.metric-grid {
display: grid;
@@ -434,6 +492,8 @@ onMounted(loadDashboard)
:deep(td.el-table__cell) { color: #273347; font-size: 12px; }
:deep(.el-table__row--level-0 > td.el-table__cell) { background: #edf7f5; font-weight: 650; }
strong.is-parent { color: #172033; font-weight: 700; }
strong.is-member { color: #314158; font-weight: 600; }
.role-tag { margin-left: 7px; vertical-align: middle; }
}
.target-panel { padding-bottom: 18px; }
@@ -468,7 +528,7 @@ onMounted(loadDashboard)
.page-heading, .heading-meta { align-items: flex-start; flex-direction: column; }
.metric-grid, .ranking-grid { grid-template-columns: 1fr; }
.filter-item, .filter-item--time { width: 100%; align-items: flex-start; flex-direction: column; }
.employee-select, .dept-select { width: 100%; }
.employee-select, .dept-select, .channel-select { width: 100%; }
.bar-row { grid-template-columns: 90px minmax(70px, 1fr) 82px; }
.target-summary { grid-template-columns: 1fr; }
}
@@ -5,7 +5,7 @@
<span class="heading-mark"><el-icon><DataLine /></el-icon></span>
<div>
<h1>医生看板</h1>
<p>从挂号面诊到接诊成交统一观察医生经营表现</p>
<p>从挂号预约面诊到接诊成交统一观察医生经营表现</p>
</div>
</div>
<div class="heading-actions">
@@ -70,8 +70,8 @@
<div class="metric-icon"><el-icon><Calendar /></el-icon></div>
<div>
<span>总挂号</span>
<strong>{{ formatNumber(dashboard.summary.appointment_total) }}</strong>
<small>完成 {{ formatNumber(dashboard.summary.interview_count) }} · 完成率 {{ formatPercent(dashboard.summary.appointment_completion_rate) }}</small>
<strong>{{ formatNumber(dashboard.summary.registration_total) }}</strong>
<small>已支付且实收金额大于 0低于 10 元的订单</small>
</div>
</article>
<article class="metric-card metric-card--green">
@@ -98,12 +98,20 @@
<small>客单价 {{ nullableMoney(dashboard.summary.avg_order_amount) }}</small>
</div>
</article>
<article class="metric-card metric-card--orange">
<div class="metric-icon"><el-icon><DataLine /></el-icon></div>
<div>
<span>总接诊率</span>
<strong>{{ formatPercent(dashboard.summary.receive_conversion_rate) }}</strong>
<small>总接诊 {{ formatNumber(dashboard.summary.order_count) }} ÷ 总面诊 {{ formatNumber(dashboard.summary.interview_count) }}</small>
</div>
</article>
<article class="metric-card metric-card--cyan">
<div class="metric-icon"><el-icon><CircleCheck /></el-icon></div>
<div>
<span>挂号完成率</span>
<span>预约完成率</span>
<strong>{{ formatPercent(dashboard.summary.appointment_completion_rate) }}</strong>
<small>{{ dashboard.meta.time_label }} · {{ dashboard.meta.doctor_count }} 位有数据医生</small>
<small>总预约 {{ formatNumber(dashboard.summary.appointment_total) }} · {{ dashboard.meta.doctor_count }} 位有数据医生</small>
</div>
</article>
</section>
@@ -143,28 +151,7 @@
</article>
</section>
<section class="two-column-grid analytics-grid">
<article class="panel funnel-panel">
<div class="panel-heading">
<div><h2>经营转化漏斗</h2><p>挂号 面诊 接诊 成交</p></div>
<span>人数 / 单数</span>
</div>
<div class="funnel-wrap">
<div
v-for="(stage, index) in dashboard.funnel"
:key="stage.key"
class="funnel-stage"
:class="`stage-${index + 1}`"
:style="{ width: `${100 - index * 15}%` }"
>
<span>{{ stage.label }}</span><strong>{{ formatNumber(stage.value) }}</strong>
</div>
</div>
<div class="funnel-insight">
最大流失发生在 <strong>{{ funnelLoss.label }}</strong>流失 {{ formatNumber(funnelLoss.value) }}
</div>
</article>
<section class="analytics-grid">
<article class="panel trend-panel">
<div class="panel-heading">
<div><h2> 30 天成交金额趋势</h2><p>{{ dashboard.trend.start_date }} {{ dashboard.trend.end_date }}</p></div>
@@ -225,7 +212,7 @@
</span>
</template>
</el-table-column>
<el-table-column prop="appointment_total" label="挂号" min-width="90" sortable />
<el-table-column prop="appointment_total" label="预约" min-width="90" sortable />
<el-table-column prop="interview_count" label="面诊" min-width="90" sortable />
<el-table-column prop="order_count" label="接诊" min-width="90" sortable />
<el-table-column prop="receive_conversion_rate" label="接诊率" min-width="110" sortable>
@@ -261,7 +248,7 @@
<footer class="data-note">
<el-icon><InfoFilled /></el-icon>
<span>{{ dashboard.meta.appointment_rule }}{{ dashboard.meta.performance_rule }}</span>
<span>{{ dashboard.meta.registration_rule }}{{ dashboard.meta.appointment_rule }}{{ dashboard.meta.performance_rule }}</span>
</footer>
</div>
</template>
@@ -292,19 +279,18 @@ const emptyDashboard = () => ({
meta: {
time_type: 'month', time_label: '本月', start_date: '', end_date: '', generated_at: '',
scope_value: 4, scope_label: '', scope_kind: '', selected_dept_name: '', selected_doctor_name: '',
doctor_count: 0, appointment_rule: '', performance_rule: ''
doctor_count: 0, registration_rule: '', appointment_rule: '', performance_rule: ''
},
filters: {
departments: [] as any[], doctors: [] as Array<{ id: number; name: string; disable: number }>,
can_filter_department: true
},
summary: {
appointment_total: 0, interview_count: 0, order_count: 0, deal_amount: 0,
registration_total: 0, appointment_total: 0, interview_count: 0, order_count: 0, deal_amount: 0,
avg_order_amount: null as number | null, appointment_completion_rate: null as number | null,
receive_conversion_rate: null as number | null, missed_count: 0, cancelled_count: 0
},
rankings: { amounts: [] as any[], conversion: [] as any[] },
funnel: [] as Array<{ key: string; label: string; value: number }>,
trend: { start_date: '', end_date: '', dates: [] as string[], labels: [] as string[], amounts: [] as number[] },
alerts: [] as any[],
alert_threshold: 15,
@@ -339,19 +325,6 @@ const visibleDetailRows = computed(() => {
if (query.doctor_id || showZeroRows.value) return dashboard.rows
return businessRows.value
})
const funnelLoss = computed(() => {
const stages = dashboard.funnel
let result = { label: '暂无可比阶段', value: 0 }
let maxLoss = -1
for (let index = 0; index < stages.length - 1; index++) {
const loss = Math.max(0, Number(stages[index].value) - Number(stages[index + 1].value))
if (loss > maxLoss) {
maxLoss = loss
result = { label: `${stages[index].label}${stages[index + 1].label}`, value: loss }
}
}
return result
})
const trendChartOption = computed(() => ({
animationDuration: 450,
grid: { left: 20, right: 20, top: 18, bottom: 18, containLabel: true },
@@ -480,7 +453,7 @@ function exportRows() {
ElMessage.warning('当前范围暂无可导出的医生数据')
return
}
const headers = ['医生', '状态', '挂号', '面诊', '接诊', '接诊率', '成交金额', '过号', '取消']
const headers = ['医生', '状态', '预约', '面诊', '接诊', '接诊率', '成交金额', '过号', '取消']
const lines = dashboard.rows.map((row: any) => [
row.doctor_name, row.status === 'disabled' ? '停用' : '活跃', row.appointment_total,
row.interview_count, row.order_count, formatPercent(row.receive_conversion_rate), row.deal_amount,
@@ -580,7 +553,7 @@ h2 { font-size: 15px; line-height: 1.4; }
.metric-grid {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 14px;
margin-top: 14px;
}
@@ -605,6 +578,7 @@ h2 { font-size: 15px; line-height: 1.4; }
.metric-card--green .metric-icon { color: #37a465; background: #eef8f1; }
.metric-card--indigo .metric-icon { color: #556dde; background: #eef0fd; }
.metric-card--blue .metric-icon { color: #3976e6; background: #edf3ff; }
.metric-card--orange .metric-icon { color: #d77a2c; background: #fff4e9; }
.metric-card--cyan .metric-icon { color: #138da1; background: #eaf7f8; }
.metric-card span { display: block; color: #748296; font-size: 12px; }
.metric-card strong { display: block; margin: 4px 0 3px; font-size: 24px; line-height: 1.1; }
@@ -637,25 +611,8 @@ h2 { font-size: 15px; line-height: 1.4; }
.bar-track .is-blue { background: var(--blue); }
.bar-track .is-teal { background: var(--teal); }
.analytics-grid { display: grid; grid-template-columns: minmax(0, 1fr); }
.analytics-grid .panel { min-height: 300px; }
.funnel-wrap { display: flex; align-items: center; flex-direction: column; gap: 5px; padding: 12px 48px 7px; }
.funnel-stage {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
height: 38px;
clip-path: polygon(4% 0, 96% 0, 88% 100%, 12% 100%);
color: #fff;
font-size: 12px;
}
.funnel-stage strong { font-size: 14px; }
.stage-1 { background: #258bd4; }
.stage-2 { background: #20a290; }
.stage-3 { background: #ed913f; }
.stage-4 { background: #45a96d; }
.funnel-insight { margin: 8px 16px 16px; padding: 8px 10px; border-radius: 7px; color: #617085; background: #f4f7f8; font-size: 11px; }
.funnel-insight strong { color: #d4772c; }
.trend-chart { width: 100%; height: 238px; }
.alert-panel { border-color: #dfe8e7; }
@@ -12,37 +12,71 @@
</div>
<div class="filter-panel">
<el-input
v-model="formData.keyword"
class="keyword-input"
clearable
:prefix-icon="Search"
placeholder="订单号 / 患者 / 手机号 / 处方ID / 诊单ID"
@keyup.enter="handleSearch"
@clear="handleSearch"
/>
<el-select v-model="formData.prescription_audit_status" clearable placeholder="处方审核" @change="handleSearch">
<el-option v-for="item in auditOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
<el-select v-model="formData.payment_slip_audit_status" clearable placeholder="支付单审核" @change="handleSearch">
<el-option v-for="item in auditOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
<el-select v-model="formData.fulfillment_status" clearable placeholder="履约状态" @change="handleSearch">
<el-option v-for="item in fulfillmentOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
<el-date-picker
v-model="dateRange"
class="date-range"
type="daterange"
range-separator=""
start-placeholder="创建开始"
end-placeholder="创建结束"
value-format="YYYY-MM-DD"
clearable
@change="handleDateChange"
/>
<el-button type="primary" :icon="Search" @click="handleSearch">查询</el-button>
<el-button @click="resetFilters">重置</el-button>
<div class="filter-heading">
<div>
<h3>订单检索</h3>
<p>审核与履约状态直接展示点击后立即筛选</p>
</div>
<el-button link :disabled="!hasActiveFilters" @click="resetFilters">清空全部条件</el-button>
</div>
<div class="status-filter-list">
<div class="status-filter-row">
<span class="filter-label">处方审核</span>
<el-radio-group v-model="formData.prescription_audit_status" @change="handleSearch">
<el-radio-button v-for="item in auditFilterOptions" :key="String(item.value)" :value="item.value">
{{ item.label }}
</el-radio-button>
</el-radio-group>
</div>
<div class="status-filter-row">
<span class="filter-label">支付单审核</span>
<el-radio-group v-model="formData.payment_slip_audit_status" @change="handleSearch">
<el-radio-button v-for="item in auditFilterOptions" :key="String(item.value)" :value="item.value">
{{ item.label }}
</el-radio-button>
</el-radio-group>
</div>
<div class="status-filter-row status-filter-row--fulfillment">
<span class="filter-label">履约状态</span>
<el-radio-group v-model="formData.fulfillment_status" @change="handleSearch">
<el-radio-button v-for="item in fulfillmentFilterOptions" :key="String(item.value)" :value="item.value">
{{ item.label }}
</el-radio-button>
</el-radio-group>
</div>
</div>
<div class="search-filter-row">
<label class="filter-field filter-field--keyword">
<span>关键词</span>
<el-input
v-model="formData.keyword"
clearable
:prefix-icon="Search"
placeholder="订单号 / 患者 / 手机号 / 处方ID / 诊单ID"
@keyup.enter="handleSearch"
@clear="handleSearch"
/>
</label>
<label class="filter-field filter-field--date">
<span>创建时间</span>
<el-date-picker
v-model="dateRange"
type="daterange"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
clearable
@change="handleDateChange"
/>
</label>
<div class="filter-actions">
<el-button type="primary" :icon="Search" @click="handleSearch">查询</el-button>
<el-button @click="resetFilters">重置</el-button>
</div>
</div>
</div>
<div class="metric-grid">
@@ -52,9 +86,9 @@
<small></small>
</div>
<div class="metric-card">
<span>订单金额</span>
<span>有效订单金额</span>
<strong>¥{{ money(summary.amount) }}</strong>
<small>当前筛选</small>
<small>排除取消拒收及退款</small>
</div>
<div class="metric-card metric-warning">
<span>待审核</span>
@@ -66,6 +100,26 @@
<strong>{{ summary.completed }}</strong>
<small></small>
</div>
<button
class="metric-card metric-card--interactive metric-rejected"
:class="{ 'is-active': Number(formData.fulfillment_status) === 9 }"
type="button"
@click="toggleRejectedFilter"
>
<span>拒收订单</span>
<strong>{{ summary.rejected }}</strong>
<small> · 点击查看明细</small>
</button>
<button
class="metric-card metric-card--interactive metric-rejected-rate"
:class="{ 'is-active': Number(formData.fulfillment_status) === 9 }"
type="button"
@click="toggleRejectedFilter"
>
<span>拒收率</span>
<strong>{{ percent(summary.rejectionRate) }}</strong>
<small>拒收订单 ÷ 同检索范围订单</small>
</button>
</div>
<el-table
@@ -98,8 +152,11 @@
</div>
</template>
</el-table-column>
<el-table-column label="金额" width="108" align="right">
<template #default="{ row }"><strong class="amount">¥{{ money(row.amount) }}</strong></template>
<el-table-column label="有效金额" width="118" align="right">
<template #default="{ row }">
<strong v-if="row.amount_included" class="amount">¥{{ money(row.effective_amount) }}</strong>
<span v-else class="amount-excluded">{{ row.amount_exclusion_text || '不计入' }}</span>
</template>
</el-table-column>
<el-table-column label="处方审核" width="104" align="center">
<template #default="{ row }">
@@ -200,12 +257,14 @@ const formData = reactive({
end_date: ''
})
const auditOptions = [
const auditFilterOptions: Array<{ label: string; value: SelectValue }> = [
{ label: '全部', value: '' },
{ label: '待审核', value: 0 },
{ label: '已通过', value: 1 },
{ label: '已驳回', value: 2 }
]
const fulfillmentOptions = [
const fulfillmentFilterOptions: Array<{ label: string; value: SelectValue }> = [
{ label: '全部', value: '' },
{ label: '待双审通过', value: 1 },
{ label: '待发货', value: 2 },
{ label: '已完成', value: 3 },
@@ -231,9 +290,19 @@ const summary = computed(() => ({
orders: Number(pager.extend?.summary?.orders || 0),
amount: Number(pager.extend?.summary?.amount || 0),
pending: Number(pager.extend?.summary?.pending || 0),
completed: Number(pager.extend?.summary?.completed || 0)
completed: Number(pager.extend?.summary?.completed || 0),
rejected: Number(pager.extend?.summary?.rejected || 0),
rejectionRate: Number(pager.extend?.summary?.rejection_rate || 0)
}))
const scopeLabel = computed(() => pager.extend?.scope?.label || '按权限加载')
const hasActiveFilters = computed(() => Boolean(
formData.keyword.trim()
|| formData.prescription_audit_status !== ''
|| formData.payment_slip_audit_status !== ''
|| formData.fulfillment_status !== ''
|| formData.start_date
|| formData.end_date
))
function handleSearch() {
resetPage()
@@ -260,10 +329,20 @@ function resetFilters() {
resetPage()
}
function toggleRejectedFilter() {
formData.fulfillment_status = Number(formData.fulfillment_status) === 9 ? '' : 9
resetPage()
}
function money(value: number | string) {
return Number(value || 0).toFixed(2)
}
function percent(value: number | string) {
const number = Number(value || 0)
return `${Number.isInteger(number) ? number.toFixed(0) : number.toFixed(2)}%`
}
function auditTagType(status: number): 'success' | 'warning' | 'danger' | 'info' {
if (Number(status) === 1) return 'success'
if (Number(status) === 2) return 'danger'
@@ -332,31 +411,121 @@ onMounted(getLists)
}
.filter-panel {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
padding: 14px;
padding: 16px;
border: 1px solid #e3e8ef;
border-radius: 10px;
background: #fbfcfd;
}
:deep(.el-select) {
width: 132px;
.filter-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding-bottom: 12px;
border-bottom: 1px solid #e8edf2;
h3 {
margin: 0;
color: #273244;
font-size: 14px;
font-weight: 600;
}
p {
margin: 4px 0 0;
color: #8a95a6;
font-size: 12px;
}
}
.keyword-input {
width: min(340px, 100%);
.status-filter-list {
display: grid;
gap: 10px;
padding: 13px 0;
border-bottom: 1px solid #e8edf2;
}
.date-range {
width: 250px;
.status-filter-row {
display: flex;
align-items: flex-start;
gap: 12px;
:deep(.el-radio-group) {
display: flex;
flex: 1;
flex-wrap: wrap;
gap: 6px;
}
:deep(.el-radio-button__inner) {
min-width: 72px;
padding: 7px 12px;
color: #5f6b7d;
border: 1px solid #dfe5ec;
border-radius: 6px;
box-shadow: none;
background: #fff;
transition: color 0.18s ease, border-color 0.18s ease, background 0.18s ease;
}
:deep(.el-radio-button:first-child .el-radio-button__inner),
:deep(.el-radio-button:last-child .el-radio-button__inner) {
border-radius: 6px;
}
:deep(.el-radio-button.is-active .el-radio-button__inner) {
color: #fff;
border-color: #0f9185;
background: #0f9185;
box-shadow: none;
}
}
.filter-label {
flex: 0 0 76px;
padding-top: 7px;
color: #475467;
font-size: 12px;
font-weight: 600;
}
.search-filter-row {
display: grid;
grid-template-columns: minmax(280px, 1fr) minmax(300px, 360px) auto;
align-items: end;
gap: 12px;
padding-top: 13px;
}
.filter-field {
display: grid;
gap: 6px;
min-width: 0;
> span {
color: #667085;
font-size: 12px;
font-weight: 500;
}
:deep(.el-date-editor) {
width: 100%;
}
}
.filter-actions {
display: flex;
align-items: center;
:deep(.el-button + .el-button) {
margin-left: 8px;
}
}
.metric-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 10px;
margin: 14px 0;
}
@@ -364,6 +533,8 @@ onMounted(getLists)
.metric-card {
min-height: 82px;
padding: 14px 16px;
text-align: left;
font: inherit;
border: 1px solid #e3e8ef;
border-radius: 10px;
background: #fff;
@@ -383,6 +554,39 @@ onMounted(getLists)
}
}
.metric-card--interactive {
color: inherit;
cursor: pointer;
transition: transform 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease;
&:hover,
&:focus-visible {
border-color: #e5a9a3;
box-shadow: 0 7px 18px rgba(132, 45, 40, 0.08);
transform: translateY(-1px);
outline: none;
}
&:active {
transform: translateY(0);
}
&.is-active {
border-color: #d9685f;
box-shadow: 0 0 0 2px rgba(217, 104, 95, 0.11);
}
}
.metric-rejected,
.metric-rejected-rate {
border-color: #f0cbc7;
background: #fff9f8;
strong {
color: #c64f47;
}
}
.metric-warning {
border-color: #f3d8aa;
background: #fffcf5;
@@ -437,6 +641,11 @@ onMounted(getLists)
font-variant-numeric: tabular-nums;
}
.amount-excluded {
color: #9aa4b2;
font-size: 11px;
}
.order-actions {
display: flex;
align-items: center;
@@ -458,10 +667,24 @@ onMounted(getLists)
padding-top: 16px;
}
@media (max-width: 1400px) {
.metric-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 1080px) {
.metric-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.search-filter-row {
grid-template-columns: 1fr 1fr;
}
.filter-actions {
grid-column: 1 / -1;
}
}
@media (max-width: 760px) {
@@ -474,10 +697,24 @@ onMounted(getLists)
grid-template-columns: 1fr;
}
.filter-panel > *,
.filter-panel :deep(.el-select),
.date-range {
width: 100%;
.status-filter-row,
.search-filter-row {
display: flex;
align-items: stretch;
flex-direction: column;
}
.filter-label {
padding-top: 0;
}
.filter-actions {
display: grid;
grid-template-columns: 1fr 1fr;
:deep(.el-button) {
width: 100%;
}
}
}
</style>
@@ -72,7 +72,7 @@
<div class="summary-grid">
<button class="summary-card summary-today" type="button" @click="selectDateType('today')">
<span class="summary-icon"><el-icon><Calendar /></el-icon></span>
<span class="summary-copy"><small>今日挂号</small><strong>{{ summary.today }}</strong><em></em></span>
<span class="summary-copy"><small>今日预约</small><strong>{{ summary.today }}</strong><em></em></span>
<span class="summary-date">{{ summaryDates.today || '—' }}</span>
</button>
<button class="summary-card summary-tomorrow" type="button" @click="selectDateType('tomorrow')">
@@ -146,12 +146,36 @@
<el-table-column label="诊单日期" width="118">
<template #default="{ row }">{{ row.diagnosis_date_text || '—' }}</template>
</el-table-column>
<el-table-column label="操作" min-width="205" fixed="right">
<el-table-column label="操作" min-width="430" fixed="right">
<template #default="{ row }">
<div class="row-actions">
<el-button v-if="canEditDiagnosis" type="primary" link @click="openDiagnosis(row)">诊单</el-button>
<el-button v-else-if="canReadDiagnosis" type="primary" link @click="openReadonlyDiagnosis(row)">查看</el-button>
<el-button v-if="canBookAppointment" type="primary" link @click="openAppointment(row)">预约</el-button>
<el-button
v-if="canAssignPatient"
type="warning"
link
@click="openAssignDialog(row)"
>
{{ Number(row.assistant_id) > 0 ? '重新指派' : '指派' }}
</el-button>
<el-button
v-if="canFillIdCard && !Number(row.has_id_card)"
type="warning"
link
@click="openFillIdCardDialog(row)"
>
补全身份证
</el-button>
<el-button
v-if="canShowDiagnosisQRCode(row)"
type="primary"
link
@click="openDiagnosisQRCode(row)"
>
诊单二维码
</el-button>
<el-button
v-if="canBookAppointment && canCancelAppointment(row)"
type="danger"
@@ -187,6 +211,111 @@
<edit-popup ref="editRef" @success="refreshPage" />
<appointment-popup ref="appointmentRef" api-scene="my_patient" @success="refreshPage" />
<el-dialog
v-model="assignDialogVisible"
title="指派医助"
width="500px"
:close-on-click-modal="false"
>
<el-form :model="assignForm" label-width="88px">
<el-form-item label="患者">
<span class="dialog-patient-name">{{ currentActionPatient?.patient_name || '—' }}</span>
</el-form-item>
<el-form-item label="当前助理">
<span>{{ currentActionPatient?.assistant_name || '未分配' }}</span>
</el-form-item>
<el-form-item label="选择医助" required>
<el-select
v-model="assignForm.assistant_id"
class="dialog-full-width"
filterable
clearable
:loading="assistantOptionsLoading"
placeholder="请选择当前范围内的医助"
>
<el-option
v-for="item in assistantOptions"
:key="item.id"
:label="assistantOptionLabel(item)"
:value="Number(item.id)"
/>
</el-select>
</el-form-item>
<el-form-item label="继承">
<div class="inherit-field">
<el-checkbox v-model="assignForm.is_inherit">作为继承指派</el-checkbox>
<small>用于区分继承关系指派操作仍会完整记录</small>
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="assignDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="assignLoading" @click="submitAssign">确定指派</el-button>
</template>
</el-dialog>
<el-dialog
v-model="fillIdCardDialogVisible"
title="补全身份证号"
width="430px"
:close-on-click-modal="false"
@closed="resetFillIdCardForm"
>
<el-form
ref="fillIdCardFormRef"
:model="fillIdCardForm"
:rules="fillIdCardRules"
label-width="88px"
>
<el-form-item label="患者">
<span class="dialog-patient-name">{{ fillIdCardForm.patient_name || '—' }}</span>
</el-form-item>
<el-form-item label="身份证号" prop="id_card">
<el-input
v-model="fillIdCardForm.id_card"
maxlength="18"
clearable
show-word-limit
placeholder="请输入15或18位身份证号"
@keyup.enter="submitFillIdCard"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="fillIdCardDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="fillIdCardLoading" @click="submitFillIdCard">
提交并更新年龄
</el-button>
</template>
</el-dialog>
<el-dialog
v-model="qrcodeDialogVisible"
title="诊单二维码"
width="400px"
:close-on-click-modal="false"
>
<div class="qrcode-body">
<template v-if="qrcodeLoading">
<el-icon class="is-loading" :size="40"><Loading /></el-icon>
<span>二维码生成中...</span>
</template>
<template v-else-if="qrcodeUrl">
<img :src="qrcodeUrl" alt="诊单二维码" class="qrcode-image" />
<div class="qrcode-meta">
<div>患者{{ currentQRCodePatient?.patient_name || '—' }}</div>
<div>挂号时间{{ currentQRCodePatient?.appointment_time_text || '—' }}</div>
<div>请使用企业微信扫描二维码然后转发给患者</div>
</div>
</template>
<span v-else class="qrcode-error">生成失败请重试</span>
</div>
<template #footer>
<el-button @click="qrcodeDialogVisible = false">关闭</el-button>
<el-button v-if="!qrcodeLoading" type="primary" @click="regenerateDiagnosisQRCode">重新生成</el-button>
</template>
</el-dialog>
</div>
</template>
@@ -194,21 +323,31 @@
import { computed, defineAsyncComponent, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import dayjs from 'dayjs'
import { Calendar, Clock, Lock, Refresh, Search } from '@element-plus/icons-vue'
import { Calendar, Clock, Loading, Lock, Refresh, Search } from '@element-plus/icons-vue'
import { usePaging } from '@/hooks/usePaging'
import { hasPermission } from '@/utils/perm'
import feedback from '@/utils/feedback'
import { myPatientCancelAppointment, myPatientLists } from '@/api/first_visit'
import {
myPatientAssign,
myPatientAssistants,
myPatientCancelAppointment,
myPatientFillIdCard,
myPatientLists
} from '@/api/first_visit'
import { generateMiniProgramQrcode } from '@/api/tcm'
import { getWeappConfig } from '@/api/channel/weapp'
import useUserStore from '@/stores/modules/user'
const EditPopup = defineAsyncComponent(() => import('@/views/tcm/diagnosis/edit.vue'))
const AppointmentPopup = defineAsyncComponent(() => import('@/views/tcm/diagnosis/appointment.vue'))
const OrderPanel = defineAsyncComponent(() => import('./components/OrderPanel.vue'))
const ProgressPanel = defineAsyncComponent(() => import('./components/ProgressPanel.vue'))
type StatusFilter = '' | 'unconfirmed' | 'booked' | 'completed' | 'missed'
type StatusFilter = '' | 'unbooked' | 'pending_interview' | 'completed' | 'missed'
type DateType = 'all' | 'today' | 'tomorrow' | 'day_after' | 'last7' | 'last30' | 'custom'
const router = useRouter()
const userStore = useUserStore()
const activeWorkspace = ref('patients')
const activeDateType = ref<DateType>('all')
const customDateRange = ref<string[]>([])
@@ -216,6 +355,27 @@ const editRef = ref<any>()
const appointmentRef = ref<any>()
const orderPanelRef = ref<any>()
const progressPanelRef = ref<any>()
const qrcodeDialogVisible = ref(false)
const qrcodeLoading = ref(false)
const qrcodeUrl = ref('')
const currentQRCodePatient = ref<any>(null)
const assignDialogVisible = ref(false)
const assignLoading = ref(false)
const assistantOptionsLoading = ref(false)
const assistantOptions = ref<any[]>([])
const currentActionPatient = ref<any>(null)
const assignForm = reactive({
assistant_id: null as number | null,
is_inherit: false
})
const fillIdCardDialogVisible = ref(false)
const fillIdCardLoading = ref(false)
const fillIdCardFormRef = ref<any>()
const fillIdCardForm = reactive({
id: 0,
patient_name: '',
id_card: ''
})
const formData = reactive({
keyword: '',
@@ -226,8 +386,8 @@ const formData = reactive({
const statusOptions: Array<{ label: string; value: StatusFilter }> = [
{ label: '全部', value: '' },
{ label: '未确认', value: 'unconfirmed' },
{ label: '已挂号', value: 'booked' },
{ label: '未预约', value: 'unbooked' },
{ label: '待面诊', value: 'pending_interview' },
{ label: '已完成', value: 'completed' },
{ label: '已过号', value: 'missed' }
]
@@ -258,6 +418,8 @@ const scopeLabel = computed(() => pager.extend?.scope?.label || '按权限加载
const canEditDiagnosis = computed(() => hasPermission(['tcm.diagnosis/edit']))
const canReadDiagnosis = computed(() => hasPermission(['tcm.diagnosis/readonlyDetail']))
const canBookAppointment = computed(() => hasPermission(['tcm.diagnosis/guahao']))
const canAssignPatient = computed(() => hasPermission(['tcm.diagnosis/assign']))
const canFillIdCard = computed(() => hasPermission(['tcm.diagnosis/edit']))
const workspaceLoading = computed(() => {
if (activeWorkspace.value === 'orders') return Boolean(orderPanelRef.value?.loading)
if (activeWorkspace.value === 'progress') return Boolean(progressPanelRef.value?.loading)
@@ -356,6 +518,166 @@ function openAppointment(row: any) {
})
}
function assistantOptionLabel(item: any) {
const name = String(item?.name || item?.account || `医助${item?.id || ''}`)
const departments = String(item?.dept_names || '').trim()
return departments ? `${name} · ${departments}` : name
}
async function loadAssistantOptions() {
assistantOptionsLoading.value = true
try {
const result = await myPatientAssistants()
assistantOptions.value = Array.isArray(result) ? result : []
} catch (error: any) {
assistantOptions.value = []
feedback.msgError(error?.msg || '医助列表加载失败')
} finally {
assistantOptionsLoading.value = false
}
}
async function openAssignDialog(row: any) {
currentActionPatient.value = row
assignForm.assistant_id = Number(row.assistant_id) > 0 ? Number(row.assistant_id) : null
assignForm.is_inherit = false
assignDialogVisible.value = true
if (assistantOptions.value.length === 0) {
await loadAssistantOptions()
}
}
async function submitAssign() {
const diagnosisId = Number(currentActionPatient.value?.diagnosis_id || currentActionPatient.value?.id)
if (diagnosisId <= 0) {
feedback.msgWarning('患者诊单信息不完整')
return
}
if (!assignForm.assistant_id) {
feedback.msgWarning('请选择医助')
return
}
assignLoading.value = true
try {
await myPatientAssign({
id: diagnosisId,
assistant_id: Number(assignForm.assistant_id),
is_inherit: assignForm.is_inherit ? 1 : 0
})
feedback.msgSuccess('指派成功')
assignDialogVisible.value = false
await getLists()
} catch (error: any) {
feedback.msgError(error?.msg || '指派失败')
} finally {
assignLoading.value = false
}
}
const validateIdCard = (_rule: any, value: string, callback: (error?: Error) => void) => {
const idCard = String(value || '').trim()
if (!idCard) {
callback(new Error('请输入身份证号'))
return
}
const valid18 = /^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/.test(idCard)
const valid15 = /^[1-9]\d{5}\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}$/.test(idCard)
callback(valid18 || valid15 ? undefined : new Error('请输入15或18位有效身份证号'))
}
const fillIdCardRules = {
id_card: [{ required: true, validator: validateIdCard, trigger: 'blur' }]
}
function openFillIdCardDialog(row: any) {
fillIdCardForm.id = Number(row.diagnosis_id || row.id)
fillIdCardForm.patient_name = String(row.patient_name || '')
fillIdCardForm.id_card = ''
fillIdCardDialogVisible.value = true
}
function resetFillIdCardForm() {
fillIdCardForm.id = 0
fillIdCardForm.patient_name = ''
fillIdCardForm.id_card = ''
fillIdCardFormRef.value?.clearValidate?.()
}
async function submitFillIdCard() {
if (fillIdCardLoading.value) return
try {
await fillIdCardFormRef.value?.validate?.()
} catch {
return
}
fillIdCardLoading.value = true
try {
await myPatientFillIdCard({
id: fillIdCardForm.id,
id_card: fillIdCardForm.id_card.trim()
})
feedback.msgSuccess('补全成功,年龄已自动更新')
fillIdCardDialogVisible.value = false
await getLists()
} catch (error: any) {
feedback.msgError(error?.msg || '补全身份证失败')
} finally {
fillIdCardLoading.value = false
}
}
function canShowDiagnosisQRCode(row: any) {
return canBookAppointment.value
&& Number(row.appointment_id) > 0
&& Number(row.appointment_status) === 1
&& Number(row.appointment_doctor_id) > 0
}
async function openDiagnosisQRCode(row: any) {
if (!canShowDiagnosisQRCode(row)) {
feedback.msgWarning('仅已挂号患者可以生成诊单二维码')
return
}
const diagnosisId = Number(row.diagnosis_id || row.id)
const patientId = Number(row.source_patient_id || row.diagnosis_id || row.id)
if (diagnosisId <= 0 || patientId <= 0) {
feedback.msgWarning('患者诊单信息不完整')
return
}
currentQRCodePatient.value = row
qrcodeDialogVisible.value = true
qrcodeLoading.value = true
qrcodeUrl.value = ''
try {
const config = await getWeappConfig()
if (!config?.app_id) {
throw new Error('小程序未配置,请先配置小程序信息')
}
const result = await generateMiniProgramQrcode({
diagnosis_id: diagnosisId,
doctor_id: Number(row.appointment_doctor_id),
patient_id: patientId,
share_user_id: userStore.userInfo?.id || ''
})
if (!result?.qrcode_url) {
throw new Error('二维码生成失败')
}
qrcodeUrl.value = result.qrcode_url
} catch (error: any) {
feedback.msgError(error?.msg || error?.message || '生成二维码失败,请重试')
} finally {
qrcodeLoading.value = false
}
}
function regenerateDiagnosisQRCode() {
if (currentQRCodePatient.value) {
openDiagnosisQRCode(currentQRCodePatient.value)
}
}
function canCancelAppointment(row: any) {
return Number(row.appointment_id) > 0 && [1, 4].includes(Number(row.appointment_status))
}
@@ -402,6 +724,36 @@ onMounted(() => {
color: #172033;
}
.qrcode-body {
display: flex;
min-height: 320px;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 16px;
color: #7b8798;
}
.qrcode-image {
width: 256px;
height: 256px;
border: 1px solid #e1e7ed;
border-radius: 10px;
object-fit: contain;
}
.qrcode-meta {
display: grid;
gap: 6px;
color: #697587;
text-align: center;
font-size: 12px;
}
.qrcode-error {
color: var(--el-color-danger);
}
.page-heading {
display: flex;
align-items: center;
@@ -726,6 +1078,25 @@ onMounted(() => {
}
}
.dialog-full-width {
width: 100%;
}
.dialog-patient-name {
color: #1f2937;
font-weight: 600;
}
.inherit-field {
display: grid;
gap: 2px;
small {
color: #98a2b3;
line-height: 1.5;
}
}
.pagination-wrap {
display: flex;
justify-content: flex-end;
@@ -2,14 +2,14 @@
<div
class="registration-stats"
v-loading="loading"
element-loading-text="正在汇总权限范围内的挂号数据"
element-loading-text="正在汇总权限范围内的挂号与预约数据"
>
<header class="page-heading">
<div class="heading-copy">
<span class="heading-mark"><el-icon><Histogram /></el-icon></span>
<div>
<h1>挂号统计</h1>
<p>挂号诊单与目标数据按当前角色和部门权限实时汇总</p>
<p>挂号预约诊单与目标数据按当前角色和部门权限实时汇总</p>
</div>
</div>
<div class="heading-actions">
@@ -66,16 +66,26 @@
<section class="metric-grid" aria-label="挂号统计核心指标">
<article class="metric-card metric-card--teal">
<div class="metric-icon"><el-icon><Calendar /></el-icon></div>
<div class="metric-icon"><el-icon><Wallet /></el-icon></div>
<div>
<span>{{ dashboard.meta.time_label || '今日' }}总挂号</span>
<strong>{{ formatNumber(dashboard.summary.registration_count) }}</strong>
<small :class="compareClass(dashboard.summary.registration_compare_rate)">
{{ compareText(dashboard.summary.registration_compare_rate) }}
</small>
</div>
</article>
<article class="metric-card metric-card--blue">
<div class="metric-icon"><el-icon><Calendar /></el-icon></div>
<div>
<span>{{ dashboard.meta.time_label || '今日' }}总预约</span>
<strong>{{ formatNumber(dashboard.summary.appointment_count) }}</strong>
<small :class="compareClass(dashboard.summary.appointment_compare_rate)">
{{ compareText(dashboard.summary.appointment_compare_rate) }}
</small>
</div>
</article>
<article class="metric-card metric-card--blue">
<article class="metric-card metric-card--violet">
<div class="metric-icon"><el-icon><DocumentChecked /></el-icon></div>
<div>
<span>{{ dashboard.meta.time_label || '今日' }}总诊单</span>
@@ -96,8 +106,8 @@
<section class="panel employee-panel">
<div class="panel-heading">
<div>
<h2>本组员工挂号统计</h2>
<p>部门汇总可展开查看员工明日后日预约始终使用对应自然日</p>
<h2>本组员工挂号与预约统计</h2>
<p>挂号按已支付且实收低于 10 元的支付订单统计预约按预约记录统计</p>
</div>
<span class="panel-badge">{{ dashboard.meta.time_label || '当前范围' }}</span>
</div>
@@ -118,14 +128,22 @@
</div>
</template>
</el-table-column>
<el-table-column prop="appointment_count" :label="`${dashboard.meta.time_label || '当前'}挂号`" min-width="118" align="right" sortable />
<el-table-column prop="registration_count" :label="`${dashboard.meta.time_label || '当前'}挂号`" min-width="118" align="right" sortable />
<el-table-column prop="appointment_count" :label="`${dashboard.meta.time_label || '当前'}预约`" min-width="118" align="right" sortable />
<el-table-column prop="tomorrow_count" label="明日预约" min-width="105" align="right" sortable />
<el-table-column prop="day_after_count" label="后日预约" min-width="105" align="right" sortable />
<el-table-column prop="order_count" label="诊单" min-width="86" align="right" sortable />
<el-table-column label="业绩" min-width="128" align="right" sortable :sort-method="sortAmount">
<template #default="{ row }">{{ formatMoney(row.order_amount) }}</template>
</el-table-column>
<el-table-column label="较上期" min-width="105" align="right">
<el-table-column label="挂号较上期" min-width="112" align="right">
<template #default="{ row }">
<span :class="['rate-text', compareClass(row.registration_compare_rate)]">
{{ compactCompare(row.registration_compare_rate) }}
</span>
</template>
</el-table-column>
<el-table-column label="预约较上期" min-width="112" align="right">
<template #default="{ row }">
<span :class="['rate-text', compareClass(row.appointment_compare_rate)]">
{{ compactCompare(row.appointment_compare_rate) }}
@@ -190,9 +208,24 @@
</article>
<article class="panel ranking-panel">
<div class="panel-heading">
<div><h2>{{ dashboard.meta.time_label || '今日' }}挂号 TOP</h2><p>有效挂号数量排序</p></div>
<div><h2>{{ dashboard.meta.time_label || '今日' }}挂号 TOP</h2><p>低于 10 元的已支付订单笔数排序</p></div>
<span class="panel-badge">挂号</span>
</div>
<div v-if="dashboard.rankings.registrations.length" class="ranking-list ranking-list--blue">
<div v-for="(item, index) in dashboard.rankings.registrations" :key="`registration-${item.admin_id}`" class="ranking-row">
<b :class="{ 'is-top': index < 3 }">{{ index + 1 }}</b>
<span>{{ item.name }}<small>已支付小额订单</small></span>
<div class="rank-track"><i :style="{ width: rankWidth(item.value, maxRegistrations) }" /></div>
<strong>{{ formatNumber(item.value) }} </strong>
</div>
</div>
<el-empty v-else :image-size="52" description="暂无挂号数据" />
</article>
<article class="panel ranking-panel">
<div class="panel-heading">
<div><h2>{{ dashboard.meta.time_label || '今日' }}预约 TOP</h2><p>按有效预约记录数量排序</p></div>
<span class="panel-badge">预约</span>
</div>
<div v-if="dashboard.rankings.appointments.length" class="ranking-list ranking-list--blue">
<div v-for="(item, index) in dashboard.rankings.appointments" :key="`appointment-${item.admin_id}`" class="ranking-row">
<b :class="{ 'is-top': index < 3 }">{{ index + 1 }}</b>
@@ -201,7 +234,7 @@
<strong>{{ formatNumber(item.value) }} </strong>
</div>
</div>
<el-empty v-else :image-size="52" description="暂无挂号数据" />
<el-empty v-else :image-size="52" description="暂无预约数据" />
</article>
</section>
@@ -215,13 +248,21 @@
<template #default="{ row }"><strong>{{ row.name }}</strong></template>
</el-table-column>
<el-table-column prop="member_count" label="人数" min-width="90" align="right" sortable />
<el-table-column prop="appointment_count" :label="`${dashboard.meta.time_label || '当前'}挂号`" min-width="118" align="right" sortable />
<el-table-column prop="registration_count" :label="`${dashboard.meta.time_label || '当前'}挂号`" min-width="118" align="right" sortable />
<el-table-column prop="appointment_count" :label="`${dashboard.meta.time_label || '当前'}预约`" min-width="118" align="right" sortable />
<el-table-column prop="tomorrow_count" label="明日预约" min-width="105" align="right" sortable />
<el-table-column prop="order_count" label="诊单" min-width="90" align="right" sortable />
<el-table-column label="业绩" min-width="130" align="right">
<template #default="{ row }">{{ formatMoney(row.order_amount) }}</template>
</el-table-column>
<el-table-column label="挂号环比" min-width="110" align="right">
<template #default="{ row }">
<span :class="['rate-text', compareClass(row.registration_compare_rate)]">
{{ compactCompare(row.registration_compare_rate) }}
</span>
</template>
</el-table-column>
<el-table-column label="预约环比" min-width="110" align="right">
<template #default="{ row }">
<span :class="['rate-text', compareClass(row.appointment_compare_rate)]">
{{ compactCompare(row.appointment_compare_rate) }}
@@ -234,7 +275,7 @@
<footer class="data-note">
<el-icon><InfoFilled /></el-icon>
<span>{{ dashboard.meta.appointment_rule }}{{ dashboard.meta.performance_rule }}</span>
<span>{{ dashboard.meta.registration_rule }}{{ dashboard.meta.appointment_rule }}{{ dashboard.meta.performance_rule }}</span>
</footer>
</div>
</template>
@@ -261,15 +302,16 @@ const emptyDashboard = () => ({
meta: {
time_type: 'today', time_label: '今日', start_date: '', end_date: '', generated_at: '',
scope_value: 4, scope_label: '', selected_dept_name: '', selected_assistant_name: '',
member_count: 0, appointment_rule: '', performance_rule: ''
member_count: 0, registration_rule: '', appointment_rule: '', performance_rule: ''
},
filters: { departments: [] as any[], assistants: [] as Array<{ id: number; name: string }> },
summary: {
registration_count: 0, registration_compare_count: 0, registration_compare_rate: null as number | null,
appointment_count: 0, appointment_compare_count: 0, appointment_compare_rate: null as number | null,
order_count: 0, order_amount: 0, range_label: '今日'
},
employee_rows: [] as any[],
rankings: { performance: [] as any[], appointments: [] as any[] },
rankings: { performance: [] as any[], registrations: [] as any[], appointments: [] as any[] },
departments: [] as any[],
target: {
year: new Date().getFullYear(), target_amount: 0, actual_amount: 0,
@@ -289,6 +331,7 @@ const timeOptions = [
const deptTreeProps = { label: 'name', value: 'id', children: 'children' }
const maxPerformance = computed(() => Math.max(0, ...dashboard.rankings.performance.map((item: any) => Number(item.value) || 0)))
const maxRegistrations = computed(() => Math.max(0, ...dashboard.rankings.registrations.map((item: any) => Number(item.value) || 0)))
const maxAppointments = computed(() => Math.max(0, ...dashboard.rankings.appointments.map((item: any) => Number(item.value) || 0)))
const targetChartOption = computed(() => ({
animationDuration: 450,
@@ -490,7 +533,7 @@ h2 { font-size: 15px; line-height: 1.4; }
.metric-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 14px;
margin-top: 14px;
}
@@ -513,6 +556,7 @@ h2 { font-size: 15px; line-height: 1.4; }
}
.metric-card--teal .metric-icon { color: #107f75; background: #e9f7f5; }
.metric-card--blue .metric-icon { color: #416bd7; background: #edf1ff; }
.metric-card--violet .metric-icon { color: #7257c7; background: #f2efff; }
.metric-card--amber .metric-icon { color: #bc7428; background: #fff4e7; }
.metric-card span { display: block; color: #748296; font-size: 13px; }
.metric-card strong { display: block; margin: 4px 0 2px; font-size: 27px; line-height: 1.15; }
@@ -565,10 +609,10 @@ h2 { font-size: 15px; line-height: 1.4; }
.chart-legend .actual { background: var(--teal); }
.chart-legend .target { background: repeating-linear-gradient(90deg, var(--blue) 0 5px, transparent 5px 8px); }
.ranking-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
.ranking-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; }
.ranking-panel { min-height: 286px; }
.ranking-list { padding: 2px 16px 16px; }
.ranking-row { display: grid; grid-template-columns: 30px minmax(110px, .8fr) minmax(100px, 1fr) 105px; align-items: center; gap: 10px; min-height: 43px; border-top: 1px solid #eff2f5; }
.ranking-row { display: grid; grid-template-columns: 30px minmax(86px, .8fr) minmax(64px, 1fr) auto; align-items: center; gap: 10px; min-height: 43px; border-top: 1px solid #eff2f5; }
.ranking-row > b { display: grid; width: 22px; height: 22px; place-items: center; border-radius: 7px; color: #8491a1; background: #f1f4f6; font-size: 11px; }
.ranking-row > b.is-top { color: #fff; background: var(--teal); }
.ranking-list--blue .ranking-row > b.is-top { background: var(--blue); }
@@ -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 }
}
File diff suppressed because it is too large Load Diff
@@ -7,7 +7,7 @@
</div>
<div>
<div class="dashboard-title-row">
<h1>数据驾驶舱</h1>
<h1>我的首页</h1>
<span class="scope-badge">
<el-icon><Lock /></el-icon>
{{ dashboard.scope.label || '数据范围' }}
@@ -66,6 +66,22 @@
</div>
</article>
<article class="metric-card">
<div class="metric-label">今日新增业绩</div>
<div class="metric-value metric-value--money">
{{ formatMoney(dashboard.performance.today_amount) }}
</div>
<div class="metric-foot" :class="comparisonClass(dashboard.performance.today_compare_rate)">
<template v-if="dashboard.performance.today_compare_rate !== null">
<el-icon v-if="dashboard.performance.today_compare_rate >= 0"><CaretTop /></el-icon>
<el-icon v-else><CaretBottom /></el-icon>
{{ dashboard.performance.today_compare_label }}
{{ formatSignedPercent(dashboard.performance.today_compare_rate) }}
</template>
<template v-else>昨日暂无可比数据</template>
</div>
</article>
<article class="metric-card">
<div class="metric-label">昨日新增业绩</div>
<div class="metric-value metric-value--money">
@@ -137,7 +153,23 @@
<h2>{{ dashboard.rankings.appointments.title }}</h2>
<p>{{ appointmentRankingSubtitle }}</p>
</div>
<span class="panel-meta">实时</span>
<div class="panel-header-actions">
<el-tree-select
v-if="dashboard.rankings.appointments.kind !== 'doctor'"
v-model="rankingDeptId"
:data="dashboard.filters.ranking_departments"
:props="departmentTreeProps"
node-key="id"
check-strictly
clearable
filterable
default-expand-all
placeholder="全部可见部门"
class="ranking-dept-select"
@change="handleRankingDeptChange"
/>
<span class="panel-meta">实时</span>
</div>
</div>
<div v-if="dashboard.rankings.appointments.items.length" class="ranking-list">
<div
@@ -149,7 +181,7 @@
<div class="ranking-main">
<div class="ranking-copy">
<strong>{{ item.name || '未命名成员' }}</strong>
<span>{{ formatNumber(item.count) }} 挂号</span>
<span>{{ formatNumber(item.count) }} 挂号</span>
</div>
<div class="ranking-meter" aria-hidden="true">
<span :style="{ width: rankingWidth(item.count, appointmentRankingMax) }" />
@@ -282,7 +314,7 @@
</template>
<script setup lang="ts" name="performanceDashboardPage">
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { computed, onActivated, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import {
CaretBottom,
CaretTop,
@@ -296,7 +328,7 @@ import vCharts from 'vue-echarts'
import { performanceDashboardOverview } from '@/api/stats'
type TrendKey = 'appointments' | 'leads' | 'orders'
type TrendKey = 'registrations' | 'appointments' | 'leads' | 'orders'
interface RankingItem {
id: number
@@ -336,6 +368,9 @@ const createInitialDashboard = () => ({
month_amount: 0,
month_compare_rate: null as number | null,
month_compare_label: '',
today_amount: 0,
today_compare_rate: null as number | null,
today_compare_label: '',
yesterday_amount: 0,
yesterday_compare_rate: null as number | null,
yesterday_compare_label: '',
@@ -344,14 +379,17 @@ const createInitialDashboard = () => ({
today: {
add_fans_count: 0,
appointment_total_count: 0,
low_amount_payment_count: 0,
interview_count: 0,
completed_order_count: 0,
completed_order_amount: 0,
paid_appointment_count: 0,
paid_appointment_rate: 0,
interview_receive_rate: 0,
comparisons: {
add_fans_count: emptyComparison(),
appointment_total_count: emptyComparison(),
low_amount_payment_count: emptyComparison(),
interview_count: emptyComparison(),
completed_order_count: emptyComparison(),
completed_order_amount: emptyComparison(),
@@ -372,9 +410,14 @@ const createInitialDashboard = () => ({
items: [] as RankingItem[],
},
},
filters: {
ranking_departments: [] as any[],
ranking_dept_id: 0,
},
trend: {
date_range: [] as string[],
dates: [] as string[],
registrations: [] as number[],
appointments: [] as number[],
leads: [] as number[],
orders: [] as number[],
@@ -400,12 +443,15 @@ const dashboard = reactive(createInitialDashboard())
const loading = ref(false)
const loaded = ref(false)
const errorMessage = ref('')
const activeTrend = ref<TrendKey>('appointments')
const activeTrend = ref<TrendKey>('registrations')
const rankingDeptId = ref<number | undefined>()
const now = ref(new Date())
let clockTimer: ReturnType<typeof setInterval> | undefined
const departmentTreeProps = { value: 'id', label: 'name', children: 'children' }
const trendOptions = [
{ label: '挂号', value: 'appointments' },
{ label: '挂号', value: 'registrations' },
{ label: '预约', value: 'appointments' },
{ label: '进线', value: 'leads' },
{ label: '诊单', value: 'orders' },
]
@@ -428,11 +474,18 @@ const todayMetrics = computed(() => [
},
{
key: 'appointments',
label: '今日挂号',
label: '今日预约',
value: formatNumber(dashboard.today.appointment_total_count),
hint: '状态为已预约、已完成或改期,排除已取消',
comparisons: [dashboard.today.comparisons.appointment_total_count],
},
{
key: 'lowAmountPayments',
label: '今日挂号',
value: `${formatNumber(dashboard.today.low_amount_payment_count)} `,
hint: '已支付且实收金额大于 0、低于 10 元的订单',
comparisons: [dashboard.today.comparisons.low_amount_payment_count],
},
{
key: 'orders',
label: '今日接诊 / 诊单金额',
@@ -445,14 +498,14 @@ const todayMetrics = computed(() => [
},
{
key: 'appointmentRate',
label: '付费挂号率',
label: '挂号率',
value: formatPercent(dashboard.today.paid_appointment_rate),
hint: '付费挂号数 / 加粉数',
hint: `挂号 ${formatNumber(dashboard.today.paid_appointment_count)} / 加粉数`,
comparisons: [dashboard.today.comparisons.paid_appointment_rate],
},
{
key: 'receiveRate',
label: '面诊接诊率',
label: '接诊率',
value: formatPercent(dashboard.today.interview_receive_rate),
hint: '诊单数 / 面诊数',
comparisons: [dashboard.today.comparisons.interview_receive_rate],
@@ -461,7 +514,7 @@ const todayMetrics = computed(() => [
key: 'interviews',
label: '今日面诊',
value: formatNumber(dashboard.today.interview_count),
hint: '状态为已完成的挂号',
hint: '状态为已完成的预约',
comparisons: [dashboard.today.comparisons.interview_count],
},
])
@@ -481,8 +534,8 @@ const currentDate = computed(() => new Intl.DateTimeFormat('zh-CN', {
}).format(now.value))
const appointmentRankingSubtitle = computed(() => {
const actor = dashboard.rankings.appointments.kind === 'doctor' ? '医生' : '医助'
return `${dashboard.rankings.appointments.scope_label}${actor}有效挂号数(不含已取消)`
const actor = dashboard.rankings.appointments.kind === 'doctor' ? '医生' : '成员'
return `${dashboard.rankings.appointments.scope_label}${actor}已支付且实收低于 10 元的订单数`
})
const appointmentRankingMax = computed(() => Math.max(
@@ -497,7 +550,8 @@ const performanceRankingMax = computed(() => Math.max(
const trendChartOption = computed(() => {
const config: Record<TrendKey, { label: string; data: number[] }> = {
appointments: { label: '挂号', data: dashboard.trend.appointments },
registrations: { label: '挂号', data: dashboard.trend.registrations },
appointments: { label: '预约', data: dashboard.trend.appointments },
leads: { label: '进线', data: dashboard.trend.leads },
orders: { label: '诊单', data: dashboard.trend.orders },
}
@@ -601,8 +655,13 @@ const loadDashboard = async () => {
loading.value = true
errorMessage.value = ''
try {
const res: any = await performanceDashboardOverview()
const res: any = await performanceDashboardOverview({
ranking_dept_id: rankingDeptId.value,
// 驾驶舱是实时数据,避免浏览器或反向代理复用旧的 GET 响应。
_t: Date.now(),
})
Object.assign(dashboard, createInitialDashboard(), res || {})
rankingDeptId.value = Number(dashboard.filters.ranking_dept_id || 0) || undefined
loaded.value = true
} catch (error: any) {
errorMessage.value = error?.msg || error?.message || '驾驶舱数据加载失败,请稍后重试'
@@ -611,6 +670,10 @@ const loadDashboard = async () => {
}
}
const handleRankingDeptChange = () => {
loadDashboard()
}
onMounted(() => {
loadDashboard()
clockTimer = setInterval(() => {
@@ -618,6 +681,11 @@ onMounted(() => {
}, 1000)
})
// 后台标签页使用 KeepAlive;从其它菜单返回驾驶舱时必须重新取实时数据。
onActivated(() => {
if (loaded.value) loadDashboard()
})
onBeforeUnmount(() => {
if (clockTimer) clearInterval(clockTimer)
})
@@ -750,12 +818,12 @@ onBeforeUnmount(() => {
}
.performance-strip {
grid-template-columns: repeat(4, minmax(0, 1fr));
grid-template-columns: repeat(5, minmax(0, 1fr));
margin-bottom: 12px;
}
.operating-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-template-columns: repeat(4, minmax(0, 1fr));
margin-bottom: 14px;
}
@@ -901,6 +969,17 @@ onBeforeUnmount(() => {
}
}
.panel-header-actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.ranking-dept-select {
width: 180px;
}
.panel-meta {
padding: 4px 7px;
color: var(--dash-text-muted);
@@ -1068,6 +1147,10 @@ onBeforeUnmount(() => {
.dashboard-grid--bottom {
grid-template-columns: 1fr;
}
.operating-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 900px) {
@@ -1112,6 +1195,15 @@ onBeforeUnmount(() => {
flex-direction: column;
}
.panel-header-actions {
align-items: flex-end;
flex-direction: column;
}
.ranking-dept-select {
width: 150px;
}
.ranking-copy span {
display: none;
}
File diff suppressed because one or more lines are too long
@@ -11,8 +11,10 @@ use app\adminapi\lists\firstvisit\MyPatientProgressLists;
use app\adminapi\logic\auth\AuthLogic;
use app\adminapi\logic\doctor\AppointmentLogic;
use app\adminapi\logic\firstvisit\MyPatientLogic;
use app\adminapi\logic\tcm\DiagnosisLogic;
use app\adminapi\logic\tcm\PrescriptionOrderLogic;
use app\adminapi\validate\doctor\AppointmentValidate;
use app\adminapi\validate\tcm\DiagnosisValidate;
use app\adminapi\validate\tcm\PrescriptionOrderValidate;
use app\common\model\doctor\Appointment;
use app\common\model\tcm\PrescriptionOrder;
@@ -52,6 +54,74 @@ class MyPatientController extends BaseAdminController
return $this->dataLists(new MyPatientProgressLists());
}
/** 当前账号数据范围内可被指派的医助。 */
public function assistants()
{
if (!$this->hasPagePermission() || !$this->hasOriginalPermission('tcm.diagnosis/assign')) {
return $this->fail('权限不足,无法获取医助列表');
}
return $this->data(DiagnosisLogic::getAssistants($this->adminId, $this->adminInfo));
}
/** 从“我的患者”指派医助,先校验患者行级数据范围和目标医助范围。 */
public function assign()
{
if (!$this->hasPagePermission() || !$this->hasOriginalPermission('tcm.diagnosis/assign')) {
return $this->fail('权限不足,无法指派患者');
}
$params = $this->request->post();
$diagnosisId = (int) ($params['id'] ?? 0);
$assistantId = (int) ($params['assistant_id'] ?? 0);
if (!MyPatientLogic::canAccessDiagnosis($diagnosisId, $this->adminId, $this->adminInfo)) {
return $this->fail('患者不存在或无权操作');
}
if ($assistantId <= 0 || !$this->canAssignToAssistant($assistantId)) {
return $this->fail('所选医助不在当前可指派范围内');
}
$result = DiagnosisLogic::assign([
'id' => $diagnosisId,
'assistant_id' => $assistantId,
'is_inherit' => (int) ($params['is_inherit'] ?? 0) === 1 ? 1 : 0,
]);
if ($result === false) {
return $this->fail(DiagnosisLogic::getError());
}
return $this->success('指派成功');
}
/** 从“我的患者”补全身份证,复用诊单身份证校验和年龄计算。 */
public function fillIdCard()
{
if (!$this->hasPagePermission() || !$this->hasOriginalPermission('tcm.diagnosis/edit')) {
return $this->fail('权限不足,无法补全身份证');
}
$params = (new DiagnosisValidate())->post()->goCheck('fillIdCard');
$diagnosisId = (int) ($params['id'] ?? 0);
if (!MyPatientLogic::canAccessDiagnosis($diagnosisId, $this->adminId, $this->adminInfo)) {
return $this->fail('患者不存在或无权操作');
}
$duplicate = DiagnosisLogic::checkIdCard([
'id' => $diagnosisId,
'id_card' => trim((string) ($params['id_card'] ?? '')),
]);
if (!empty($duplicate['exists'])) {
return $this->fail((string) ($duplicate['message'] ?? '该身份证号已存在'));
}
$result = DiagnosisLogic::fillIdCard($params);
if ($result === false) {
return $this->fail(DiagnosisLogic::getError());
}
return $this->success('补全成功,年龄已自动更新');
}
/** 当前患者范围内的订单详情;仍要求原订单详情权限。 */
public function orderDetail()
{
@@ -384,6 +454,17 @@ class MyPatientController extends BaseAdminController
return $order;
}
private function canAssignToAssistant(int $assistantId): bool
{
foreach (DiagnosisLogic::getAssistants($this->adminId, $this->adminInfo) as $assistant) {
if ((int) ($assistant['id'] ?? 0) === $assistantId) {
return true;
}
}
return false;
}
/** @param array<string,mixed> $params @param array<int,string> $keys */
private function onlyParams(array $params, array $keys): array
{
@@ -6,6 +6,7 @@ namespace app\adminapi\controller\firstvisit;
use app\adminapi\controller\BaseAdminController;
use app\adminapi\logic\auth\AuthLogic;
use app\adminapi\logic\firstvisit\WecomAcquisitionCustomerLogic;
use app\adminapi\logic\firstvisit\WecomPromotionLogic;
class WecomPromotionController extends BaseAdminController
@@ -15,7 +16,7 @@ class WecomPromotionController extends BaseAdminController
public function overview()
{
if (!$this->hasPagePermission()) {
return $this->fail('权限不足,无法访问企业微信推广助手');
return $this->fail('权限不足,无法访问企业微信获客助手');
}
return $this->run(fn () => $this->data(WecomPromotionLogic::overview(
@@ -25,33 +26,6 @@ class WecomPromotionController extends BaseAdminController
)));
}
public function authorizationUrl()
{
if (!$this->hasPagePermission()) {
return $this->fail('权限不足');
}
return $this->run(fn () => $this->data(WecomPromotionLogic::authorizationUrl(
$this->adminId,
$this->adminInfo,
$this->request->domain()
)));
}
public function verifyAccount()
{
if (!$this->hasPagePermission()) {
return $this->fail('权限不足');
}
$id = (int) $this->request->post('id', 0);
return $this->run(fn () => $this->success('凭证验证成功', WecomPromotionLogic::verifyAccount(
$id,
$this->adminId,
$this->adminInfo
)));
}
public function savePool()
{
if (!$this->hasPagePermission()) {
@@ -65,6 +39,19 @@ class WecomPromotionController extends BaseAdminController
)));
}
public function saveWidget()
{
if (!$this->hasPagePermission()) {
return $this->fail('权限不足');
}
return $this->run(fn () => $this->success('浮窗配置已保存', WecomPromotionLogic::saveWidget(
$this->request->post(),
$this->adminId,
$this->adminInfo
)));
}
public function deletePool()
{
if (!$this->hasPagePermission()) {
@@ -85,13 +72,90 @@ class WecomPromotionController extends BaseAdminController
return $this->fail('权限不足');
}
return $this->run(fn () => $this->success('推广链接已保存', WecomPromotionLogic::saveLink(
return $this->run(fn () => $this->success('获客助手链接已保存', WecomPromotionLogic::saveLink(
$this->request->post(),
$this->adminId,
$this->adminInfo
)));
}
public function checkApiPermission()
{
if (!$this->hasPagePermission()) {
return $this->fail('权限不足');
}
return $this->run(fn () => $this->success('获客助手 API 权限验证通过', WecomPromotionLogic::checkApiPermission()));
}
public function syncRemoteLinks()
{
if (!$this->hasPagePermission()) {
return $this->fail('权限不足');
}
$poolId = (int) $this->request->post('pool_id', 0);
return $this->run(fn () => $this->success('企业微信获客链接同步完成', WecomPromotionLogic::syncRemoteLinks(
$poolId,
$this->adminId,
$this->adminInfo
)));
}
public function remoteLinkDetail()
{
if (!$this->hasPagePermission()) {
return $this->fail('权限不足');
}
$id = (int) $this->request->get('id', 0);
return $this->run(fn () => $this->data(WecomPromotionLogic::remoteLinkDetail(
$id,
$this->adminId,
$this->adminInfo
)));
}
public function deleteRemoteLink()
{
if (!$this->hasPagePermission()) {
return $this->fail('权限不足');
}
$id = (int) $this->request->post('id', 0);
return $this->run(function () use ($id) {
WecomPromotionLogic::deleteRemoteLink($id, $this->adminId, $this->adminInfo);
return $this->success('企业微信获客链接已永久删除,本地审计记录已保留');
});
}
public function syncCustomers()
{
if (!$this->hasPagePermission()) {
return $this->fail('权限不足');
}
return $this->run(fn () => $this->success('获客客户同步完成', WecomAcquisitionCustomerLogic::sync(
$this->request->post(),
$this->adminId,
$this->adminInfo
)));
}
public function customerStatistics()
{
if (!$this->hasPagePermission()) {
return $this->fail('权限不足');
}
return $this->run(fn () => $this->data(WecomAcquisitionCustomerLogic::statistics(
$this->request->get(),
$this->adminId,
$this->adminInfo
)));
}
public function toggleLink()
{
if (!$this->hasPagePermission()) {
@@ -117,7 +181,7 @@ class WecomPromotionController extends BaseAdminController
return $this->run(function () use ($id) {
WecomPromotionLogic::deleteLink($id, $this->adminId, $this->adminInfo);
return $this->success('推广链接已删除');
return $this->success('获客助手链接已删除');
});
}
@@ -18,6 +18,17 @@ class PerformanceDashboardController extends BaseAdminController
{
@set_time_limit(120);
return $this->data(PerformanceDashboardLogic::overview($this->adminId, $this->adminInfo));
$response = $this->data(PerformanceDashboardLogic::overview(
$this->adminId,
$this->adminInfo,
$this->request->get()
));
// 驾驶舱包含分钟级实时数据,禁止浏览器和中间代理缓存旧统计结果。
return $response->header([
'Cache-Control' => 'no-store, no-cache, must-revalidate, max-age=0',
'Pragma' => 'no-cache',
'Expires' => '0',
]);
}
}
@@ -37,7 +37,7 @@ class MyPatientLists extends BaseAdminDataLists implements ListsSearchInterface,
$rows = $query
->field([
'd.id', 'd.patient_id', 'd.patient_name', 'd.phone', 'd.gender', 'd.age',
'd.id', 'd.patient_id', 'd.patient_name', 'd.phone', 'd.id_card', 'd.gender', 'd.age',
'd.diagnosis_date', 'd.diagnosis_type', 'd.syndrome_type', 'd.assistant_id',
'd.assign_read_at', 'd.create_time',
])
@@ -89,18 +89,17 @@ class MyPatientLists extends BaseAdminDataLists implements ListsSearchInterface,
$this->applyKeyword($query);
$statusFilter = $applyStatusFilter ? trim((string) ($this->params['status_filter'] ?? '')) : '';
if ($statusFilter === 'unconfirmed') {
$viewTable = (new DiagnosisViewRecord())->getTable();
$appointmentTable = (new Appointment())->getTable();
if ($statusFilter === 'unbooked') {
$query->whereNotExists(
"SELECT 1 FROM {$viewTable} confirm_row"
. ' WHERE confirm_row.diagnosis_id = d.id'
. ' AND confirm_row.is_confirmed = 1'
. ' AND confirm_row.delete_time IS NULL'
"SELECT 1 FROM {$appointmentTable} unbooked_apt"
. ' WHERE unbooked_apt.patient_id = d.id'
. ' AND unbooked_apt.status IN (' . implode(',', self::EFFECTIVE_APPOINTMENT_STATUSES) . ')'
);
}
$appointmentStatuses = self::EFFECTIVE_APPOINTMENT_STATUSES;
if ($statusFilter === 'booked') {
if (in_array($statusFilter, ['pending_interview', 'booked'], true)) {
$appointmentStatuses = [1];
} elseif ($statusFilter === 'completed') {
$appointmentStatuses = [3];
@@ -108,14 +107,17 @@ class MyPatientLists extends BaseAdminDataLists implements ListsSearchInterface,
$appointmentStatuses = [4];
}
$needsAppointmentFilter = in_array($statusFilter, ['booked', 'completed', 'missed'], true);
$needsAppointmentFilter = in_array(
$statusFilter,
['pending_interview', 'booked', 'completed', 'missed'],
true
);
[$startDate, $endDate] = $applyDateFilter ? $this->dateRange() : ['', ''];
if ($startDate !== '' || $endDate !== '') {
$needsAppointmentFilter = true;
}
if ($needsAppointmentFilter) {
$appointmentTable = (new Appointment())->getTable();
$conditions = [
'filter_apt.patient_id = d.id',
'filter_apt.status IN (' . implode(',', $appointmentStatuses) . ')',
@@ -245,6 +247,7 @@ class MyPatientLists extends BaseAdminDataLists implements ListsSearchInterface,
$today = date('Y-m-d');
$statusFilter = trim((string) ($this->params['status_filter'] ?? ''));
$preferredStatuses = [
'pending_interview' => [1],
'booked' => [1],
'completed' => [3],
'missed' => [4],
@@ -269,6 +272,8 @@ class MyPatientLists extends BaseAdminDataLists implements ListsSearchInterface,
$row['source_patient_id'] = (int) ($row['patient_id'] ?? 0);
$row['phone_masked'] = $this->maskPhone((string) ($row['phone'] ?? ''));
unset($row['phone']);
$row['has_id_card'] = trim((string) ($row['id_card'] ?? '')) !== '' ? 1 : 0;
unset($row['id_card']);
$row['gender_desc'] = (int) ($row['gender'] ?? 0) === 1 ? '男' : '女';
$row['diagnosis_date_text'] = $this->formatDiagnosisDate($row['diagnosis_date'] ?? '');
$row['assistant_name'] = (string) ($adminNames[$assistantId] ?? '未分配');
@@ -362,6 +367,6 @@ class MyPatientLists extends BaseAdminDataLists implements ListsSearchInterface,
private function appointmentStatusText(int $status): string
{
return [1 => '已挂号', 3 => '已完成', 4 => '已过号'][$status] ?? '未挂号';
return [1 => '待面诊', 3 => '已完成', 4 => '已过号'][$status] ?? '未预约';
}
}
@@ -6,6 +6,7 @@ namespace app\adminapi\lists\firstvisit;
use app\adminapi\lists\BaseAdminDataLists;
use app\adminapi\logic\firstvisit\MyPatientLogic;
use app\adminapi\logic\stats\YejiStatsLogic;
use app\adminapi\logic\tcm\PrescriptionOrderLogic;
use app\common\lists\ListsExtendInterface;
use app\common\lists\ListsSearchInterface;
@@ -61,11 +62,22 @@ class MyPatientOrderLists extends BaseAdminDataLists implements ListsSearchInter
{
$query = $this->buildQuery();
$pendingQuery = clone $query;
$effectiveAmountQuery = clone $query;
YejiStatsLogic::applyPrescriptionOrderEffectiveAmountQuery($effectiveAmountQuery, 'po');
// 拒收指标保留关键词、审核和日期条件,但不受当前履约状态按钮影响,
// 避免点击“拒收订单”后分母被收窄为拒收状态而固定显示 100%。
$rejectionScopeQuery = $this->buildQuery(true);
$rejectionScopeOrderCount = (int) (clone $rejectionScopeQuery)->count('po.id');
$rejectedCount = (int) (clone $rejectionScopeQuery)
->where('po.fulfillment_status', 9)
->count('po.id');
$orderCount = (int) (clone $query)->count('po.id');
return [
'summary' => [
'orders' => (int) (clone $query)->count('po.id'),
'amount' => round((float) (clone $query)->sum('po.amount'), 2),
'orders' => $orderCount,
'amount' => round((float) $effectiveAmountQuery->sum('po.amount'), 2),
'pending' => (int) $pendingQuery
->where(function ($q) {
$q->where('po.prescription_audit_status', 0)
@@ -73,12 +85,16 @@ class MyPatientOrderLists extends BaseAdminDataLists implements ListsSearchInter
})
->count('po.id'),
'completed' => (int) (clone $query)->whereIn('po.fulfillment_status', [3, 6])->count('po.id'),
'rejected' => $rejectedCount,
'rejection_rate' => $rejectionScopeOrderCount > 0
? round($rejectedCount / $rejectionScopeOrderCount * 100, 2)
: 0.0,
],
'scope' => MyPatientLogic::scopeMeta($this->adminId, $this->adminInfo),
];
}
private function buildQuery(): Query
private function buildQuery(bool $ignoreFulfillmentStatus = false): Query
{
$query = PrescriptionOrder::alias('po')
->join('tcm_diagnosis d', 'po.diagnosis_id = d.id')
@@ -88,7 +104,7 @@ class MyPatientOrderLists extends BaseAdminDataLists implements ListsSearchInter
MyPatientLogic::applyScope($query, $this->adminId, $this->adminInfo);
$this->applyKeyword($query);
$this->applyStatusFilters($query);
$this->applyStatusFilters($query, $ignoreFulfillmentStatus);
$this->applyDateFilter($query);
return $query;
@@ -119,9 +135,12 @@ class MyPatientOrderLists extends BaseAdminDataLists implements ListsSearchInter
});
}
private function applyStatusFilters(Query $query): void
private function applyStatusFilters(Query $query, bool $ignoreFulfillmentStatus = false): void
{
foreach (['prescription_audit_status', 'payment_slip_audit_status', 'fulfillment_status'] as $field) {
if ($ignoreFulfillmentStatus && $field === 'fulfillment_status') {
continue;
}
$raw = $this->params[$field] ?? '';
if ($raw === '' || $raw === null) {
continue;
@@ -294,6 +313,18 @@ class MyPatientOrderLists extends BaseAdminDataLists implements ListsSearchInter
$row['prescription_audit_text'] = $this->auditStatusText((int) ($row['prescription_audit_status'] ?? 0));
$row['payment_slip_audit_text'] = $this->auditStatusText((int) ($row['payment_slip_audit_status'] ?? 0));
$row['fulfillment_text'] = $this->fulfillmentStatusText((int) ($row['fulfillment_status'] ?? 0));
$fulfillmentStatus = (int) ($row['fulfillment_status'] ?? 0);
$refundAmount = round((float) ($row['refund_amount'] ?? 0), 2);
$amountIncluded = !in_array(
$fulfillmentStatus,
YejiStatsLogic::PRESCRIPTION_ORDER_FULFILLMENT_EXCLUDED_FROM_PERFORMANCE,
true
) && $refundAmount <= 0;
$row['amount_included'] = $amountIncluded;
$row['effective_amount'] = $amountIncluded ? round((float) ($row['amount'] ?? 0), 2) : 0.0;
$row['amount_exclusion_text'] = $amountIncluded
? ''
: ($refundAmount > 0 || $fulfillmentStatus === 10 ? '退款不计入' : $row['fulfillment_text'] . '不计入');
}
unset($row);
@@ -6,10 +6,12 @@ namespace app\adminapi\logic\firstvisit;
use app\adminapi\logic\dept\DeptLogic;
use app\adminapi\logic\stats\ConversionLogic;
use app\adminapi\logic\stats\YejiStatsLogic;
use app\common\model\auth\Admin;
use app\common\model\auth\AdminDept;
use app\common\model\stats\PersonalYeji;
use app\common\service\DataScope\DataScopeService;
use app\common\service\qywx\MediaChannelService;
use think\facade\Db;
/**
@@ -31,6 +33,11 @@ class FirstVisitConversionLogic
$scopeValue = DataScopeService::getEffectiveScope($adminInfo);
$selectedDeptId = max(0, (int) ($params['dept_id'] ?? 0));
$selectedAssistantId = max(0, (int) ($params['assistant_id'] ?? 0));
$requestedMediaChannelCode = trim((string)($params['media_channel_code'] ?? ''));
$selectedMediaChannel = $requestedMediaChannelCode !== ''
? MediaChannelService::getChannelByCode($requestedMediaChannelCode)
: null;
$selectedMediaChannelCode = $selectedMediaChannel !== null ? $requestedMediaChannelCode : '';
$deptSelectionValid = $selectedDeptId <= 0
|| $allowedDeptSet === null
@@ -72,14 +79,19 @@ class FirstVisitConversionLogic
'time_type' => 'custom',
'start_date' => $startDate,
'end_date' => $endDate,
'include_filters' => 0,
'include_members' => 0,
'include_filters' => 1,
'include_members' => 1,
'exclude_cancelled_appointments' => 1,
'order_metric_mode' => 'performance',
'page_no' => 1,
'page_size' => 100,
];
if ($selectedDeptId > 0 && $deptSelectionValid) {
$conversionParams['dept_id'] = $selectedDeptId;
}
if ($selectedMediaChannelCode !== '') {
$conversionParams['media_channel_code'] = $selectedMediaChannelCode;
}
$conversion = ConversionLogic::overview(
$conversionParams,
@@ -96,16 +108,26 @@ class FirstVisitConversionLogic
$rowDeptIdSet = [];
self::collectRowDeptIds($rows, $rowDeptIdSet);
$openDirect = self::loadOpenCountByDept(
$openCounts = self::loadOpenCounts(
$startDate,
$endDate,
$effectiveAdminIds,
array_fill_keys(array_keys($rowDeptIdSet), true)
array_fill_keys(array_keys($rowDeptIdSet), true),
self::personalYejiMediaSources($selectedMediaChannelCode, $selectedMediaChannel)
);
self::applyOpenCounts($rows, $openDirect);
$openDirect = $openCounts['dept'];
self::applyOpenCounts($rows, $openDirect, $openCounts['admin']);
$summary = is_array($conversion['summary'] ?? null) ? $conversion['summary'] : [];
$summary['total_open_count'] = array_sum($openDirect);
$summary['total_open_rate'] = self::percent(
(int) $summary['total_open_count'],
(int) ($summary['add_fans_count'] ?? 0)
);
$summary['open_appointment_rate'] = self::percent(
(int) ($summary['paid_appointment_count'] ?? 0),
(int) $summary['total_open_count']
);
$summary['open_receive_rate'] = self::percent(
(int) ($summary['completed_order_count'] ?? 0),
(int) $summary['total_open_count']
@@ -124,6 +146,12 @@ class FirstVisitConversionLogic
$selectedAssistantName = $selectedAssistantId > 0
? (string) (Admin::where('id', $selectedAssistantId)->whereNull('delete_time')->value('name') ?? '')
: '';
$selectedMediaChannelName = $selectedMediaChannelCode !== ''
? (string) ($selectedMediaChannel['channel_name'] ?? $selectedMediaChannelCode)
: '';
$conversionFilters = is_array($conversion['extend']['filters'] ?? null)
? $conversion['extend']['filters']
: [];
return [
'meta' => [
@@ -136,11 +164,21 @@ class FirstVisitConversionLogic
'scope_label' => DataScopeService::scopeLabel($scopeValue),
'selected_dept_name' => $selectedDeptName,
'selected_assistant_name' => $selectedAssistantName,
'open_count_source' => '个人业绩录入',
'selected_media_channel_code' => $selectedMediaChannelCode,
'selected_media_channel_name' => $selectedMediaChannelName,
'open_count_source' => $selectedMediaChannelCode === ''
? '个人业绩录入'
: '个人业绩录入(按渠道名称匹配)',
'appointment_rule' => '按预约日期统计,归属优先挂号医助、再回退诊单医助;仅含已预约、已完成和已过号',
'registration_rule' => '按支付时间统计已支付且实收金额大于 0、低于 10 元的订单,每笔计 1 个挂号并按订单创建人归属',
'performance_rule' => '按业务订单创建时间和创建人统计,排除取消、拒收、退款及已发生退款的订单',
],
'filters' => [
'departments' => DeptLogic::getAllDataScoped($adminId, $adminInfo),
'assistants' => self::assistantOptions($baseVisibleAdminIds, $selectedDeptIds, $selectedDeptId),
'media_channels' => is_array($conversionFilters['media_channels'] ?? null)
? $conversionFilters['media_channels']
: [],
],
'summary' => $summary,
'rankings' => [
@@ -156,10 +194,15 @@ class FirstVisitConversionLogic
private static function resolveTimeRange(string $timeType): array
{
$today = date('Y-m-d');
$timeType = in_array($timeType, ['today', 'week', 'month', 'quarter', 'year'], true)
$timeType = in_array($timeType, ['today', 'yesterday', 'week', 'month', 'quarter', 'year'], true)
? $timeType
: 'today';
if ($timeType === 'yesterday') {
$yesterday = date('Y-m-d', strtotime('-1 day'));
return [$yesterday, $yesterday, $timeType, '昨天'];
}
if ($timeType === 'week') {
return [date('Y-m-d', strtotime('monday this week')), $today, $timeType, '本周'];
}
@@ -252,6 +295,10 @@ class FirstVisitConversionLogic
if (!is_array($row)) {
continue;
}
if (in_array((string) ($row['type'] ?? ''), ['member', 'unbound'], true)) {
$out[] = $row;
continue;
}
$children = self::filterDeptRows(is_array($row['children'] ?? null) ? $row['children'] : [], $allowedSet);
$id = (int) ($row['id'] ?? 0);
if (isset($allowedSet[$id])) {
@@ -282,23 +329,37 @@ class FirstVisitConversionLogic
}
}
/** @param int[]|null $effectiveAdminIds @param array<int,true> $rowDeptSet @return array<int,int> */
private static function loadOpenCountByDept(string $startDate, string $endDate, ?array $effectiveAdminIds, array $rowDeptSet): array
/**
* @param int[]|null $effectiveAdminIds
* @param array<int,true> $rowDeptSet
* @param string[]|null $mediaSources null=全部渠道;空数组=所选渠道没有可匹配的手工来源
* @return array{dept:array<int,int>,admin:array<int,int>}
*/
private static function loadOpenCounts(
string $startDate,
string $endDate,
?array $effectiveAdminIds,
array $rowDeptSet,
?array $mediaSources = null
): array
{
if ($effectiveAdminIds === [] || $rowDeptSet === []) {
return [];
if ($effectiveAdminIds === [] || $rowDeptSet === [] || $mediaSources === []) {
return ['dept' => [], 'admin' => []];
}
$query = PersonalYeji::whereBetween('yeji_date', [$startDate, $endDate]);
if ($effectiveAdminIds !== null) {
$query->whereIn('creator_id', $effectiveAdminIds);
}
if ($mediaSources !== null) {
$query->whereIn('media_source', $mediaSources);
}
$rows = $query
->fieldRaw('creator_id, SUM(total_open_count) AS open_count')
->group('creator_id')
->select()
->toArray();
if ($rows === []) {
return [];
return ['dept' => [], 'admin' => []];
}
$creatorIds = self::normalizeIds(array_column($rows, 'creator_id'));
@@ -312,8 +373,54 @@ class FirstVisitConversionLogic
foreach ($deptRows as $deptRow) {
$adminDeptMap[(int) $deptRow['admin_id']][] = (int) $deptRow['dept_id'];
}
$deptMetaRows = Db::name('dept')
->whereNull('delete_time')
->field('id, pid, sort')
->select()
->toArray();
$deptMeta = [];
foreach ($deptMetaRows as $deptMetaRow) {
$deptId = (int) ($deptMetaRow['id'] ?? 0);
if ($deptId > 0) {
$deptMeta[$deptId] = [
'pid' => (int) ($deptMetaRow['pid'] ?? 0),
'sort' => (int) ($deptMetaRow['sort'] ?? 0),
];
}
}
$depthCache = [];
$depthOf = static function (int $deptId) use (&$depthOf, &$depthCache, $deptMeta): int {
if ($deptId <= 0 || !isset($deptMeta[$deptId])) {
return 0;
}
if (isset($depthCache[$deptId])) {
return $depthCache[$deptId];
}
$parentId = (int) ($deptMeta[$deptId]['pid'] ?? 0);
if ($parentId <= 0 || $parentId === $deptId || !isset($deptMeta[$parentId])) {
return $depthCache[$deptId] = 0;
}
return $depthCache[$deptId] = $depthOf($parentId) + 1;
};
foreach ($adminDeptMap as &$deptIds) {
usort($deptIds, static function (int $left, int $right) use ($depthOf, $deptMeta): int {
$depthCompare = $depthOf($right) <=> $depthOf($left);
if ($depthCompare !== 0) {
return $depthCompare;
}
$sortCompare = (int) ($deptMeta[$right]['sort'] ?? 0) <=> (int) ($deptMeta[$left]['sort'] ?? 0);
if ($sortCompare !== 0) {
return $sortCompare;
}
return $left <=> $right;
});
}
unset($deptIds);
$direct = [];
$adminDirect = [];
foreach ($rows as $row) {
$adminId = (int) ($row['creator_id'] ?? 0);
$targetDeptId = 0;
@@ -327,41 +434,117 @@ class FirstVisitConversionLogic
$targetDeptId = -2;
}
if ($targetDeptId !== 0) {
$direct[$targetDeptId] = ($direct[$targetDeptId] ?? 0) + (int) ($row['open_count'] ?? 0);
$openCount = (int) ($row['open_count'] ?? 0);
$direct[$targetDeptId] = ($direct[$targetDeptId] ?? 0) + $openCount;
$adminDirect[$adminId] = ($adminDirect[$adminId] ?? 0) + $openCount;
}
}
return $direct;
return ['dept' => $direct, 'admin' => $adminDirect];
}
/** @param array<int,array<string,mixed>> $rows @param array<int,int> $direct */
private static function applyOpenCounts(array &$rows, array $direct): int
/**
* @param array<int,array<string,mixed>> $rows
* @param array<int,int> $deptDirect
* @param array<int,int> $adminDirect
*/
private static function applyOpenCounts(array &$rows, array $deptDirect, array $adminDirect): int
{
$sum = 0;
foreach ($rows as &$row) {
$rowType = (string) ($row['type'] ?? '');
if (in_array($rowType, ['member', 'unbound'], true)) {
$count = $rowType === 'member'
? (int) ($adminDirect[(int) ($row['admin_id'] ?? 0)] ?? 0)
: 0;
$row['total_open_count'] = $count;
$row['total_open_rate'] = self::percent($count, (int) ($row['add_fans_count'] ?? 0));
$row['open_appointment_rate'] = self::percent(
(int) ($row['paid_appointment_count'] ?? 0),
$count
);
$row['open_receive_rate'] = self::percent(
(int) ($row['completed_order_count'] ?? 0),
$count
);
continue;
}
$children = is_array($row['children'] ?? null) ? $row['children'] : [];
$childTotal = self::applyOpenCounts($children, $direct);
$childTotal = self::applyOpenCounts($children, $deptDirect, $adminDirect);
if ($children !== []) {
$row['children'] = $children;
}
$count = (int) ($direct[(int) ($row['id'] ?? 0)] ?? 0) + $childTotal;
$directCount = (int) ($deptDirect[(int) ($row['id'] ?? 0)] ?? 0);
$count = $directCount + $childTotal;
$row['total_open_count'] = $count;
$row['total_open_rate'] = self::percent($count, (int) ($row['add_fans_count'] ?? 0));
$row['open_appointment_rate'] = self::percent(
(int) ($row['paid_appointment_count'] ?? 0),
$count
);
$row['open_receive_rate'] = self::percent((int) ($row['completed_order_count'] ?? 0), $count);
$sum += (int) ($direct[(int) ($row['id'] ?? 0)] ?? 0) + $childTotal;
$sum += $directCount + $childTotal;
}
unset($row);
return $sum;
}
/**
* 手工开口按 personal_yeji.media_source 保存;渠道筛选时仅匹配该渠道自身的稳定标识和名称。
* 不使用 source_group_name,避免同组多个渠道的开口数被重复计入每个渠道。
*
* @param array<string,mixed>|null $channel
* @return string[]|null
*/
private static function personalYejiMediaSources(string $channelCode, ?array $channel): ?array
{
if ($channelCode === '') {
return null;
}
if ($channel === null) {
return [];
}
return array_values(array_unique(array_filter(array_map(
static fn ($value): string => trim((string) $value),
[
$channelCode,
$channel['channel_name'] ?? '',
$channel['source_tag_name'] ?? '',
]
), static fn (string $value): bool => $value !== '')));
}
/** @param array<int,array<string,mixed>> $rows @return array<int,array<string,mixed>> */
private static function rankingRows(array $rows): array
{
if (count($rows) === 1 && is_array($rows[0]['children'] ?? null) && $rows[0]['children'] !== []) {
return $rows[0]['children'];
// lists 里可能同时存在“未绑定/未分配部门”等虚拟根节点。它们会让顶层节点数量
// 大于 1,导致原逻辑无法展开唯一的真实组织根节点,图表最终只显示医院汇总行。
$visibleRows = array_values(array_filter($rows, static function (array $row): bool {
return (int) ($row['id'] ?? 0) > 0 && !((bool) ($row['_virtual_bucket'] ?? false));
}));
// 每个可见顶层分支只展示同一层级:有权限看到下级时展示直属子部门;没有可见
// 下级时保留当前部门。这样既能按角色/DataScope 展示子部门,也不会把父子汇总
// 同时放进占比图造成重复计算。
$chartRows = [];
foreach ($visibleRows as $row) {
$children = array_values(array_filter(
is_array($row['children'] ?? null) ? $row['children'] : [],
static fn (array $child): bool => (int) ($child['id'] ?? 0) > 0
&& !((bool) ($child['_virtual_bucket'] ?? false))
));
if ($children !== []) {
foreach ($children as $child) {
$chartRows[] = $child;
}
continue;
}
$chartRows[] = $row;
}
return $rows;
return $chartRows;
}
/** @param array<int,array<string,mixed>> $rows @return array<int,array<string,mixed>> */
@@ -443,16 +626,14 @@ class FirstVisitConversionLogic
if ($effectiveAdminIds !== []) {
$actualQuery = Db::name('tcm_prescription_order')
->alias('po')
->leftJoin('tcm_prescription rx', 'rx.id = po.prescription_id')
->whereNull('po.delete_time')
->where('po.prescription_audit_status', 1)
->where('po.payment_slip_audit_status', 1)
->where('po.create_time', 'between', [
strtotime($year . '-01-01 00:00:00'),
strtotime($year . '-12-31 23:59:59'),
]);
YejiStatsLogic::applyPrescriptionOrderEffectiveAmountQuery($actualQuery, 'po');
if ($effectiveAdminIds !== null) {
$actualQuery->whereIn('rx.assistant_id', $effectiveAdminIds);
$actualQuery->whereIn('po.creator_id', $effectiveAdminIds);
}
$actualRows = $actualQuery
->fieldRaw("DATE_FORMAT(FROM_UNIXTIME(po.create_time), '%m') AS month_no, SUM(po.amount) AS actual_amount")
@@ -14,7 +14,7 @@ use think\facade\Db;
/**
* 一诊「医生看板」。
*
* 医生是最终展示维度;部门权限通过实际经手医助下推到挂号、诊单与业绩:
* 医生是最终展示维度;部门权限通过实际经手医助下推到预约、诊单与业绩:
* - 医生 SELF:只看本人医生数据,不限制经手医助;
* - 医助 SELF:只看本人经手患者关联的医生数据;
* - 组长/经理:只看数据范围内医助经手患者关联的医生数据;
@@ -79,7 +79,15 @@ class FirstVisitDoctorDashboardLogic
$doctorDeptNames,
$doctorStatus
);
$summary = self::buildSummary($rows);
// 支付单没有医生字段,当前数据中的低额支付单也未关联患者;挂号只能按创建人及权限范围汇总,
// 不能为了医生排行而将医助创建的支付单虚构分摊给某位医生。
$registrationCreatorIds = $doctorSelf ? [$adminId] : $assistantIds;
$registrationTotal = self::loadRegistrationTotal(
$range['start'],
$range['end'],
$registrationCreatorIds
);
$summary = self::buildSummary($rows, $registrationTotal);
$trend = self::buildAmountTrend($doctorIds, $assistantIds);
$selectedDeptName = $selectedDeptId > 0
@@ -108,8 +116,9 @@ class FirstVisitDoctorDashboardLogic
'selected_dept_name' => $selectedDeptName,
'selected_doctor_name' => $selectedDoctorName,
'doctor_count' => count($rows),
'appointment_rule' => '总挂号包含已预约、已取消、已完成和已过号;面诊取状态为已完成的挂号',
'performance_rule' => '诊单按订单创建时间统计,排除履约已取消、拒收和退款,金额归属处方开方医生',
'registration_rule' => '总挂号按支付时间统计已支付且实收金额大于 0、低于 10 元的订单,每笔计 1 个,并按订单创建人及当前权限范围归属',
'appointment_rule' => '总预约包含已预约、已取消、已完成和已过号;面诊取状态为已完成的预约',
'performance_rule' => '诊单按订单创建时间统计,排除已取消、拒收、全额退款及部分退款,金额归属处方开方医生',
],
'filters' => [
'departments' => $doctorSelf ? [] : DeptLogic::getAllDataScoped($adminId, $adminInfo),
@@ -122,7 +131,8 @@ class FirstVisitDoctorDashboardLogic
'conversion' => self::ranking($rows, 'receive_conversion_rate', 8),
],
'funnel' => [
['key' => 'appointment', 'label' => '挂号', 'value' => (int) $summary['appointment_total']],
['key' => 'registration', 'label' => '挂号', 'value' => (int) $summary['registration_total']],
['key' => 'appointment', 'label' => '预约', 'value' => (int) $summary['appointment_total']],
['key' => 'interview', 'label' => '面诊', 'value' => (int) $summary['interview_count']],
['key' => 'receive', 'label' => '接诊', 'value' => (int) $summary['order_count']],
['key' => 'deal', 'label' => '成交', 'value' => (int) $summary['order_count']],
@@ -327,7 +337,7 @@ class FirstVisitDoctorDashboardLogic
}
/** @param array<int,array<string,mixed>> $rows @return array<string,mixed> */
private static function buildSummary(array $rows): array
private static function buildSummary(array $rows, int $registrationTotal): array
{
$appointmentTotal = 0;
$interviewCount = 0;
@@ -345,6 +355,7 @@ class FirstVisitDoctorDashboardLogic
}
return [
'registration_total' => $registrationTotal,
'appointment_total' => $appointmentTotal,
'interview_count' => $interviewCount,
'order_count' => $orderCount,
@@ -361,6 +372,40 @@ class FirstVisitDoctorDashboardLogic
];
}
/**
* 新挂号口径:支付时间位于筛选区间、状态为已支付、0 < 实收金额 < 10 元。
* null 表示全部创建人,空数组表示当前权限范围没有可统计创建人。
*
* @param int[]|null $creatorIds
*/
private static function loadRegistrationTotal(
string $startDate,
string $endDate,
?array $creatorIds
): int {
if ($creatorIds === []) {
return 0;
}
$query = Db::name('order')
->whereNull('delete_time')
->where('status', 2)
->where('amount', '>', 0)
->where('amount', '<', 10)
// payment_time 是 DATETIME NULLMySQL 8 严格模式下不能与空字符串比较。
->whereNotNull('payment_time')
->whereBetweenTime(
'payment_time',
$startDate . ' 00:00:00',
$endDate . ' 23:59:59'
);
if ($creatorIds !== null) {
$query->whereIn('creator_id', $creatorIds);
}
return (int) $query->count();
}
/** @param array<int,array<string,mixed>> $rows @return array<int,array<string,mixed>> */
private static function ranking(array $rows, string $field, int $limit): array
{
@@ -396,7 +441,7 @@ class FirstVisitDoctorDashboardLogic
strtotime($startDate . ' 00:00:00'),
strtotime($endDate . ' 23:59:59'),
]);
YejiStatsLogic::applyPrescriptionOrderNotCancelledForPerformanceQuery($query, 'o');
YejiStatsLogic::applyPrescriptionOrderEffectiveAmountQuery($query, 'o');
if ($assistantIds !== null) {
$query->whereIn('o.creator_id', $assistantIds);
}
@@ -14,7 +14,9 @@ use think\facade\Db;
* 一诊「挂号统计」。
*
* 统计口径:
* - 挂号:doctor_appointment.appointment_date,状态 1/3/4,排除已取消 2
* - 挂号:order.payment_time,已支付且 0 < amount < 10,每笔支付订单计 1 个
* 按支付订单 creator_id 归属员工。
* - 预约:doctor_appointment.appointment_date,状态 1/3/4,排除已取消 2;
* 归属优先挂号医助 assistant_id,再回退诊单医助 assistant_id。
* - 诊单:tcm_prescription_order.create_time,归属订单 creator_id,排除履约 4/9/10。
* - 所有部门和员工筛选都只能收窄 DataScope,不允许 HTTP 参数扩大当前账号范围。
@@ -62,6 +64,11 @@ class FirstVisitRegistrationStatsLogic
$range['day_after_tomorrow'],
$assistantIds
);
$registrationDaily = self::loadRegistrationDaily(
min($range['compare_start'], $range['start']),
$range['end'],
$assistantIds
);
$orderDaily = self::loadOrderDaily(
min($range['compare_start'], $range['start']),
$range['end'],
@@ -73,6 +80,7 @@ class FirstVisitRegistrationStatsLogic
$assistantIds,
$assignment,
$appointmentDaily,
$registrationDaily,
$orderDaily,
$range
);
@@ -112,6 +120,7 @@ class FirstVisitRegistrationStatsLogic
'selected_dept_name' => $selectedDeptName,
'selected_assistant_name' => $selectedAssistantName,
'member_count' => count($assistantIds),
'registration_rule' => '支付时间在统计区间,状态为已支付且实收金额低于 10 元(大于 0 元),每笔支付订单计 1 个挂号',
'appointment_rule' => '预约日期在统计区间,状态为已预约、已完成或已过号,排除已取消',
'performance_rule' => '按订单创建时间和创建人统计,排除已取消、拒收和退款',
],
@@ -123,6 +132,7 @@ class FirstVisitRegistrationStatsLogic
'employee_rows' => $groups,
'rankings' => [
'performance' => self::rankMembers($members, 'order_amount', 10),
'registrations' => self::rankMembers($members, 'registration_count', 10),
'appointments' => self::rankMembers($members, 'appointment_count', 10),
],
'departments' => self::departmentSummaryRows($groups),
@@ -305,6 +315,39 @@ class FirstVisitRegistrationStatsLogic
return $out;
}
/** @param int[] $assistantIds @return array<int,array<string,array{count:int}>> */
private static function loadRegistrationDaily(string $startDate, string $endDate, array $assistantIds): array
{
if ($assistantIds === []) {
return [];
}
$rows = Db::name('order')->alias('o')
->whereNull('o.delete_time')
->where('o.status', 2)
->where('o.amount', '>', 0)
->where('o.amount', '<', 10)
->whereBetweenTime(
'o.payment_time',
$startDate . ' 00:00:00',
$endDate . ' 23:59:59'
)
->whereIn('o.creator_id', $assistantIds)
->fieldRaw('o.creator_id AS assistant_id, DATE(o.payment_time) AS date_label, COUNT(*) AS item_count')
->group(['o.creator_id', 'date_label'])
->select()
->toArray();
$out = [];
foreach ($rows as $row) {
$aid = (int) ($row['assistant_id'] ?? 0);
$date = (string) ($row['date_label'] ?? '');
if ($aid > 0 && $date !== '') {
$out[$aid][$date] = ['count' => (int) ($row['item_count'] ?? 0)];
}
}
return $out;
}
/** @param int[] $assistantIds @return array<int,array<string,array{count:int,amount:float}>> */
private static function loadOrderDaily(string $startDate, string $endDate, array $assistantIds): array
{
@@ -345,6 +388,7 @@ class FirstVisitRegistrationStatsLogic
array $assistantIds,
array $assignment,
array $appointmentDaily,
array $registrationDaily,
array $orderDaily,
array $range
): array {
@@ -356,6 +400,8 @@ class FirstVisitRegistrationStatsLogic
foreach ($assistantIds as $aid) {
$appointmentCount = self::sumDaily($appointmentDaily[$aid] ?? [], $range['start'], $range['end'], 'count');
$compareAppointmentCount = self::sumDaily($appointmentDaily[$aid] ?? [], $range['compare_start'], $range['compare_end'], 'count');
$registrationCount = self::sumDaily($registrationDaily[$aid] ?? [], $range['start'], $range['end'], 'count');
$compareRegistrationCount = self::sumDaily($registrationDaily[$aid] ?? [], $range['compare_start'], $range['compare_end'], 'count');
$orderCount = self::sumDaily($orderDaily[$aid] ?? [], $range['start'], $range['end'], 'count');
$orderAmount = self::sumDaily($orderDaily[$aid] ?? [], $range['start'], $range['end'], 'amount');
$rows[] = [
@@ -364,6 +410,9 @@ class FirstVisitRegistrationStatsLogic
'dept_id' => (int) ($assignment[$aid] ?? 0),
'name' => (string) ($assistantIndex[$aid] ?? '未命名员工'),
'row_type' => 'employee',
'registration_count' => (int) $registrationCount,
'compare_registration_count' => (int) $compareRegistrationCount,
'registration_compare_rate' => self::relativeChange($registrationCount, $compareRegistrationCount),
'appointment_count' => (int) $appointmentCount,
'compare_appointment_count' => (int) $compareAppointmentCount,
'appointment_compare_rate' => self::relativeChange($appointmentCount, $compareAppointmentCount),
@@ -374,7 +423,7 @@ class FirstVisitRegistrationStatsLogic
'status' => 'normal',
];
}
usort($rows, static fn (array $a, array $b): int => ($b['appointment_count'] <=> $a['appointment_count']) ?: ($b['order_amount'] <=> $a['order_amount']));
usort($rows, static fn (array $a, array $b): int => ($b['registration_count'] <=> $a['registration_count']) ?: ($b['appointment_count'] <=> $a['appointment_count']) ?: ($b['order_amount'] <=> $a['order_amount']));
return $rows;
}
@@ -393,6 +442,8 @@ class FirstVisitRegistrationStatsLogic
'name' => $key > 0 ? (string) ($deptIndex[$key]['name'] ?? '未命名部门') : '未分配部门',
'row_type' => 'department',
'member_count' => 0,
'registration_count' => 0,
'compare_registration_count' => 0,
'appointment_count' => 0,
'compare_appointment_count' => 0,
'tomorrow_count' => 0,
@@ -405,13 +456,17 @@ class FirstVisitRegistrationStatsLogic
}
$groups[$key]['children'][] = $member;
$groups[$key]['member_count']++;
foreach (['appointment_count', 'compare_appointment_count', 'tomorrow_count', 'day_after_count', 'order_count'] as $field) {
foreach (['registration_count', 'compare_registration_count', 'appointment_count', 'compare_appointment_count', 'tomorrow_count', 'day_after_count', 'order_count'] as $field) {
$groups[$key][$field] += (int) ($member[$field] ?? 0);
}
$groups[$key]['order_amount'] += (float) ($member['order_amount'] ?? 0);
}
foreach ($groups as &$group) {
$group['order_amount'] = round((float) $group['order_amount'], 2);
$group['registration_compare_rate'] = self::relativeChange(
(float) $group['registration_count'],
(float) $group['compare_registration_count']
);
$group['appointment_compare_rate'] = self::relativeChange(
(float) $group['appointment_count'],
(float) $group['compare_appointment_count']
@@ -432,11 +487,15 @@ class FirstVisitRegistrationStatsLogic
/** @param array<int,array<string,mixed>> $members @return array<string,mixed> */
private static function buildSummary(array $members, array $range): array
{
$registrationCount = 0;
$compareRegistrationCount = 0;
$appointmentCount = 0;
$compareAppointmentCount = 0;
$orderCount = 0;
$orderAmount = 0.0;
foreach ($members as $member) {
$registrationCount += (int) ($member['registration_count'] ?? 0);
$compareRegistrationCount += (int) ($member['compare_registration_count'] ?? 0);
$appointmentCount += (int) ($member['appointment_count'] ?? 0);
$compareAppointmentCount += (int) ($member['compare_appointment_count'] ?? 0);
$orderCount += (int) ($member['order_count'] ?? 0);
@@ -444,6 +503,9 @@ class FirstVisitRegistrationStatsLogic
}
return [
'registration_count' => $registrationCount,
'registration_compare_count' => $compareRegistrationCount,
'registration_compare_rate' => self::relativeChange($registrationCount, $compareRegistrationCount),
'appointment_count' => $appointmentCount,
'appointment_compare_count' => $compareAppointmentCount,
'appointment_compare_rate' => self::relativeChange($appointmentCount, $compareAppointmentCount),
@@ -460,13 +522,18 @@ class FirstVisitRegistrationStatsLogic
usort($rows, static fn (array $a, array $b): int => (($b[$field] ?? 0) <=> ($a[$field] ?? 0)) ?: strcmp((string) $a['name'], (string) $b['name']));
$out = [];
foreach (array_slice($rows, 0, $limit) as $row) {
$countField = match ($field) {
'order_amount' => 'order_count',
'registration_count' => 'registration_count',
default => 'appointment_count',
};
$out[] = [
'admin_id' => (int) ($row['admin_id'] ?? 0),
'name' => (string) ($row['name'] ?? ''),
'value' => $field === 'order_amount'
? round((float) ($row[$field] ?? 0), 2)
: (int) ($row[$field] ?? 0),
'count' => $field === 'order_amount' ? (int) ($row['order_count'] ?? 0) : (int) ($row['appointment_count'] ?? 0),
'count' => (int) ($row[$countField] ?? 0),
];
}
@@ -0,0 +1,156 @@
<?php
declare(strict_types=1);
namespace app\adminapi\logic\firstvisit;
use app\common\service\DataScope\DataScopeService;
use app\common\service\qywx\QywxCustomerAcquisitionCustomerService;
use RuntimeException;
use think\facade\Db;
/** 获客客户同步与数据权限统计。 */
class WecomAcquisitionCustomerLogic
{
/** @return array<string,mixed> */
public static function sync(array $params, int $adminId, array $adminInfo): array
{
$localLinkId = max(0, (int) ($params['promotion_link_id'] ?? $params['id'] ?? 0));
$query = Db::name('qywx_promotion_link')->alias('l')
->whereNull('l.delete_time')
->where('l.remote_link_id', '<>', '')
->where('l.remote_status', 1);
self::applyScope($query, 'l', DataScopeService::getVisibleAdminIds($adminId, $adminInfo));
if ($localLinkId > 0) {
$query->where('l.id', $localLinkId);
}
$links = $query->field('l.id,l.remote_link_id')->order('l.id', 'asc')->limit(200)->select()->toArray();
if ($localLinkId > 0 && $links === []) {
throw new RuntimeException('获客链接不存在、已失效,或超出当前权限范围');
}
if ($links === []) {
throw new RuntimeException('当前数据范围内没有可同步的有效官方获客链接;已删除和历史手工链接不会参与客户同步,请先创建官方获客链接');
}
$service = new QywxCustomerAcquisitionCustomerService();
$result = ['links' => count($links), 'scanned' => 0, 'created' => 0, 'updated' => 0, 'failed' => 0, 'errors' => []];
foreach ($links as $link) {
try {
$one = $service->syncLink((string) $link['remote_link_id']);
$result['scanned'] += $one['scanned'];
$result['created'] += $one['created'];
$result['updated'] += $one['updated'];
} catch (\Throwable $e) {
$result['failed']++;
if (count($result['errors']) < 10) {
$result['errors'][] = (string) $link['remote_link_id'] . '' . $e->getMessage();
}
}
}
return $result;
}
/** @return array<string,mixed> */
public static function statistics(array $params, int $adminId, array $adminInfo): array
{
$page = max(1, (int) ($params['page_no'] ?? $params['page'] ?? 1));
$pageSize = min(100, max(1, (int) ($params['page_size'] ?? 20)));
$visibleIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
$base = self::customerQuery($params, $visibleIds);
$total = (int) (clone $base)->count();
$rows = $base
->field('c.id,c.promotion_link_id,c.link_id,c.external_userid,c.userid,c.owner_admin_id,c.dept_id,c.state,c.chat_status,c.recv_msg_cnt,c.message_count_known,c.first_acquired_time,c.last_chat_time,c.last_sync_time,c.create_time,c.update_time,a.name as owner_name,d.name as dept_name,l.name as link_name,p.name as pool_name')
->order('c.last_chat_time', 'desc')->order('c.id', 'desc')
->page($page, $pageSize)->select()->toArray();
foreach ($rows as &$row) {
$row['external_userid_masked'] = self::maskIdentifier((string) ($row['external_userid'] ?? ''));
unset($row['external_userid']);
$row['has_messaged'] = (int) ($row['chat_status'] ?? 0) === 1;
$row['message_count_known'] = (int) ($row['message_count_known'] ?? 0);
$row['received_message_count'] = (int) ($row['recv_msg_cnt'] ?? 0);
}
unset($row);
$summaryQuery = self::customerQuery($params, $visibleIds);
$summaryRow = $summaryQuery->fieldRaw(
'COUNT(*) AS customer_count, '
. 'COALESCE(SUM(CASE WHEN c.message_count_known = 1 THEN c.recv_msg_cnt ELSE 0 END),0) AS recv_msg_cnt, '
. 'SUM(CASE WHEN c.chat_status = 1 THEN 1 ELSE 0 END) AS started_chat_count, '
. 'SUM(CASE WHEN c.message_count_known = 1 THEN 1 ELSE 0 END) AS message_count_known_count'
)->find() ?: [];
return [
'meta' => [
'scope_label' => DataScopeService::scopeLabel(DataScopeService::getEffectiveScope($adminInfo)),
'generated_at' => date('Y-m-d H:i:s'),
],
'summary' => [
'customer_count' => (int) ($summaryRow['customer_count'] ?? 0),
'started_chat_count' => (int) ($summaryRow['started_chat_count'] ?? 0),
'recv_msg_cnt' => (int) ($summaryRow['recv_msg_cnt'] ?? 0),
'received_message_count' => (int) ($summaryRow['recv_msg_cnt'] ?? 0),
'message_count_known_count' => (int) ($summaryRow['message_count_known_count'] ?? 0),
],
'lists' => $rows,
'count' => $total,
'page_no' => $page,
'page_size' => $pageSize,
];
}
private static function customerQuery(array $params, ?array $visibleIds)
{
$query = Db::name('qywx_customer_acquisition_customer')->alias('c')
->leftJoin('admin a', 'a.id = c.owner_admin_id AND a.delete_time IS NULL')
->leftJoin('dept d', 'd.id = c.dept_id')
->leftJoin('qywx_promotion_link l', 'l.id = c.promotion_link_id')
->leftJoin('qywx_promotion_pool p', 'p.id = l.pool_id');
self::applyScope($query, 'c', $visibleIds);
$localLinkId = max(0, (int) ($params['promotion_link_id'] ?? 0));
if ($localLinkId > 0) {
$query->where('c.promotion_link_id', $localLinkId);
}
$userId = trim((string) ($params['userid'] ?? ''));
if ($userId !== '') {
$query->where('c.userid', $userId);
}
if (isset($params['chat_status']) && $params['chat_status'] !== '') {
$query->where('c.chat_status', max(0, (int) $params['chat_status']));
}
$keyword = trim((string) ($params['keyword'] ?? ''));
if ($keyword !== '') {
$query->whereLike('c.external_userid|c.userid|a.name|l.name', '%' . $keyword . '%');
}
return $query;
}
private static function applyScope($query, string $alias, ?array $visibleIds): void
{
if ($visibleIds === null) {
return;
}
if ($visibleIds === []) {
$query->whereRaw('1 = 0');
return;
}
$query->whereIn($alias . '.owner_admin_id', array_values(array_unique(array_map('intval', $visibleIds))));
}
private static function maskIdentifier(string $value): string
{
$value = trim($value);
$length = mb_strlen($value);
if ($length <= 0) {
return '-';
}
if ($length <= 4) {
return mb_substr($value, 0, 1) . '***';
}
if ($length <= 8) {
return mb_substr($value, 0, 2) . '***' . mb_substr($value, -1);
}
return mb_substr($value, 0, 4) . '****' . mb_substr($value, -4);
}
}
@@ -5,38 +5,25 @@ declare(strict_types=1);
namespace app\adminapi\logic\firstvisit;
use app\common\service\DataScope\DataScopeService;
use app\common\service\qywx\QywxPromotionOpenWorkService;
use app\common\service\qywx\QywxCustomerAcquisitionApiService;
use app\common\service\qywx\QywxCustomerAcquisitionLinkService;
use app\common\service\qywx\QywxPromotionWidgetService;
use RuntimeException;
use think\facade\Db;
/** 一诊 / 企业微信推广助手管理逻辑。 */
/** 一诊 / 企业微信获客助手管理逻辑。 */
class WecomPromotionLogic
{
public static function overview(int $adminId, array $adminInfo, string $domain): array
{
$visibleIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
$accountsQuery = Db::name('qywx_promotion_account')->alias('a')
->leftJoin('admin u', 'u.id = a.owner_admin_id')
->leftJoin('dept d', 'd.id = a.dept_id')
->whereNull('a.delete_time');
$accounts = $accountsQuery
->field('a.id,a.corp_id,a.corp_name,a.agent_id,a.auth_status,a.owner_admin_id,a.dept_id,a.authorized_at,a.last_refresh_at,a.create_time,u.name as owner_name,d.name as dept_name')
->order('a.auth_status', 'desc')
->order('a.id', 'desc')
->select()->toArray();
foreach ($accounts as &$account) {
$account['corp_id_masked'] = self::mask((string) ($account['corp_id'] ?? ''));
unset($account['corp_id']);
}
unset($account);
$poolsQuery = Db::name('qywx_promotion_pool')->alias('p')
->leftJoin('admin u', 'u.id = p.owner_admin_id')
->leftJoin('dept d', 'd.id = p.dept_id')
->whereNull('p.delete_time');
self::applyOwnerScope($poolsQuery, 'p', $visibleIds);
$pools = $poolsQuery
->field('p.id,p.name,p.public_key,p.status,p.fallback_url,p.click_count,p.owner_admin_id,p.dept_id,p.create_time,p.update_time,u.name as owner_name,d.name as dept_name')
->field('p.id,p.name,p.public_key,p.status,p.fallback_url,p.widget_config_json,p.click_count,p.owner_admin_id,p.dept_id,p.create_time,p.update_time,u.name as owner_name,d.name as dept_name')
->order('p.id', 'desc')
->select()->toArray();
@@ -44,86 +31,72 @@ class WecomPromotionLogic
$links = [];
if ($poolIds !== []) {
$links = Db::name('qywx_promotion_link')->alias('l')
->leftJoin('qywx_promotion_account a', 'a.id = l.account_id AND a.delete_time IS NULL')
->whereNull('l.delete_time')
->whereIn('l.pool_id', $poolIds)
->field('l.id,l.pool_id,l.account_id,l.name,l.group_name,l.wecom_url,l.weight,l.status,l.daily_limit,l.today_count,l.today_date,l.active_start,l.active_end,l.click_count,l.last_click_time,l.remark,l.create_time,l.update_time,a.corp_name,a.auth_status')
->field('l.id,l.pool_id,l.name,l.group_name,l.wecom_url,l.remote_link_id,l.remote_status,l.remote_create_time,l.range_user_json,l.range_department_json,l.skip_verify,l.priority_option_json,l.last_sync_time,l.sync_error,l.weight,l.status,l.daily_limit,l.today_count,l.today_date,l.active_start,l.active_end,l.click_count,l.last_click_time,l.remark,l.create_time,l.update_time')
->order('l.status', 'desc')
->order('l.weight', 'desc')
->order('l.id', 'desc')
->select()->toArray();
}
$domain = rtrim($domain, '/');
$domain = self::publicDomain($domain);
foreach ($pools as &$pool) {
$pool['widget_config'] = QywxPromotionWidgetService::decode($pool['widget_config_json'] ?? null);
unset($pool['widget_config_json']);
$key = (string) $pool['public_key'];
$scriptUrl = $domain . '/api/qywx-promotion/js/' . $key;
$goUrl = $domain . '/api/qywx-promotion/go/' . $key;
$pool['script_url'] = $scriptUrl;
$pool['go_url'] = $goUrl;
$pool['install_code'] = '<script src="' . $scriptUrl . '" defer></script>';
$pool['trigger_code'] = '<a href="#" data-wecom-promotion="' . $key . '">添加企业微信</a>';
$pool['install_code'] = '<script src="'
. htmlspecialchars($scriptUrl, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')
. '" defer></script>';
$pool['trigger_code'] = '<a href="'
. htmlspecialchars($goUrl, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')
. '" data-wecom-promotion="' . $key . '">添加企业微信</a>';
}
unset($pool);
$today = date('Y-m-d');
$todayClicks = 0;
$onlineLinks = 0;
foreach ($links as $link) {
if ((int) ($link['status'] ?? 0) === 1) {
foreach ($links as &$link) {
$link['range_userids'] = self::decodeStringList($link['range_user_json'] ?? null);
$link['range_department_ids'] = self::decodeStringList($link['range_department_json'] ?? null);
$link['priority_option'] = self::decodeObject($link['priority_option_json'] ?? null);
$link['is_official'] = trim((string) ($link['remote_link_id'] ?? '')) !== '';
$link['valid_customer_acquisition_link'] = QywxCustomerAcquisitionLinkService::isAllowed((string) ($link['wecom_url'] ?? ''));
if ((int) ($link['status'] ?? 0) === 1 && $link['valid_customer_acquisition_link']) {
$onlineLinks++;
}
if ((string) ($link['today_date'] ?? '') === $today) {
$todayClicks += (int) ($link['today_count'] ?? 0);
}
}
unset($link);
$config = QywxPromotionOpenWorkService::configurationStatus();
$config['provider_callback_url'] = $domain . '/api/qywx-promotion/provider/callback';
$config['auth_callback_url'] = QywxPromotionOpenWorkService::configuredRedirectUri(
$domain . '/api/qywx-promotion/auth/callback'
);
$config = self::internalApplicationStatus($domain);
return [
'meta' => [
'scope_label' => DataScopeService::scopeLabel(DataScopeService::getEffectiveScope($adminInfo)),
'can_authorize' => self::canAuthorize($adminId, $adminInfo),
'generated_at' => date('Y-m-d H:i:s'),
],
'config' => $config,
'summary' => [
'authorized_accounts' => count(array_filter($accounts, static fn (array $row): bool => (int) ($row['auth_status'] ?? 0) === 1)),
'configured_apps' => $config['ready'] ? 1 : 0,
'pool_count' => count($pools),
'online_links' => $onlineLinks,
'today_clicks' => $todayClicks,
],
'accounts' => $accounts,
'pools' => $pools,
'links' => $links,
'allowed_link_hosts' => array_values((array) config('qywx_promotion.allowed_link_hosts', [])),
'member_options' => self::memberOptions($adminId, $adminInfo),
'customer_acquisition_link_example' => QywxCustomerAcquisitionLinkService::example(),
];
}
public static function authorizationUrl(int $adminId, array $adminInfo, string $domain): array
{
if (!self::canAuthorize($adminId, $adminInfo)) {
throw new RuntimeException('只有系统管理员可以发起企业微信应用授权');
}
$redirectUri = rtrim($domain, '/') . '/api/qywx-promotion/auth/callback';
return ['url' => QywxPromotionOpenWorkService::authorizationUrl($adminId, $redirectUri)];
}
public static function verifyAccount(int $id, int $adminId, array $adminInfo): array
{
if (!self::canAuthorize($adminId, $adminInfo)) {
throw new RuntimeException('只有系统管理员可以验证企业微信授权凭证');
}
self::assertAuthorizedAccount($id, false);
return QywxPromotionOpenWorkService::verifyAccount($id);
}
public static function savePool(array $params, int $adminId, array $adminInfo): array
{
$id = max(0, (int) ($params['id'] ?? 0));
@@ -132,8 +105,8 @@ class WecomPromotionLogic
throw new RuntimeException('请输入 1-60 个字符的分流方案名称');
}
$fallback = trim((string) ($params['fallback_url'] ?? ''));
if (!QywxPromotionOpenWorkService::isAllowedPromotionUrl($fallback, true)) {
throw new RuntimeException('兜底链接必须是已允许的 HTTPS 企业微信链接');
if (!QywxCustomerAcquisitionLinkService::isAllowed($fallback, true)) {
throw new RuntimeException('兜底链接必须是企业微信获客助手生成的 HTTPS 链接');
}
$now = time();
$data = [
@@ -159,6 +132,21 @@ class WecomPromotionLogic
return ['id' => $id];
}
public static function saveWidget(array $params, int $adminId, array $adminInfo): array
{
$id = max(0, (int) ($params['pool_id'] ?? $params['id'] ?? 0));
self::assertScopedRow('qywx_promotion_pool', $id, $adminId, $adminInfo);
$input = $params['widget_config'] ?? $params;
$config = QywxPromotionWidgetService::fromInput($input);
Db::name('qywx_promotion_pool')->where('id', $id)->update([
'widget_config_json' => QywxPromotionWidgetService::encode($config),
'update_time' => time(),
]);
return ['id' => $id, 'widget_config' => $config];
}
public static function deletePool(int $id, int $adminId, array $adminInfo): void
{
self::assertScopedRow('qywx_promotion_pool', $id, $adminId, $adminInfo);
@@ -174,17 +162,10 @@ class WecomPromotionLogic
$id = max(0, (int) ($params['id'] ?? 0));
$poolId = max(0, (int) ($params['pool_id'] ?? 0));
$pool = self::assertScopedRow('qywx_promotion_pool', $poolId, $adminId, $adminInfo);
$existing = $id > 0 ? self::assertScopedRow('qywx_promotion_link', $id, $adminId, $adminInfo) : null;
$name = trim((string) ($params['name'] ?? ''));
if ($name === '' || mb_strlen($name) > 80) {
throw new RuntimeException('请输入 1-80 个字符的推广链接名称');
}
$url = trim((string) ($params['wecom_url'] ?? ''));
if (!QywxPromotionOpenWorkService::isAllowedPromotionUrl($url)) {
throw new RuntimeException('推广链接必须是已允许的 HTTPS 企业微信链接');
}
$accountId = max(0, (int) ($params['account_id'] ?? 0));
if ($accountId > 0) {
self::assertAuthorizedAccount($accountId, true);
throw new RuntimeException('请输入 1-80 个字符的获客链接名称');
}
$startAt = self::parseTime($params['active_start'] ?? null);
$endAt = self::parseTime($params['active_end'] ?? null);
@@ -194,10 +175,9 @@ class WecomPromotionLogic
$now = time();
$data = [
'pool_id' => $poolId,
'account_id' => $accountId,
'account_id' => 0,
'name' => $name,
'group_name' => mb_substr(trim((string) ($params['group_name'] ?? '默认分组')), 0, 60),
'wecom_url' => $url,
'weight' => min(100, max(1, (int) ($params['weight'] ?? 1))),
'status' => (int) ($params['status'] ?? 1) === 1 ? 1 : 0,
'daily_limit' => min(1000000, max(0, (int) ($params['daily_limit'] ?? 0))),
@@ -206,8 +186,43 @@ class WecomPromotionLogic
'remark' => mb_substr(trim((string) ($params['remark'] ?? '')), 0, 255),
'update_time' => $now,
];
if ($id > 0) {
self::assertScopedRow('qywx_promotion_link', $id, $adminId, $adminInfo);
// 历史手工链接只维护本地分流规则,不会在企业微信端创建重复链接。
if ($existing !== null && trim((string) ($existing['remote_link_id'] ?? '')) === '') {
$url = trim((string) ($params['wecom_url'] ?? $existing['wecom_url'] ?? ''));
if (!QywxCustomerAcquisitionLinkService::isAllowed($url)) {
throw new RuntimeException('历史链接必须是 https://work.weixin.qq.com/ca/... 格式');
}
$data['wecom_url'] = $url;
Db::name('qywx_promotion_link')->where('id', $id)->update($data);
return ['id' => $id, 'mode' => 'legacy'];
}
$userIds = self::resolveMemberUserIds((array) ($params['member_admin_ids'] ?? []), $adminId, $adminInfo);
$skipVerify = (int) ($params['skip_verify'] ?? 0) === 1 ? 1 : 0;
$payload = [
'link_name' => $name,
'range' => ['user_list' => $userIds],
'skip_verify' => $skipVerify === 1,
];
$api = new QywxCustomerAcquisitionApiService();
if ($existing !== null) {
$remoteLinkId = trim((string) ($existing['remote_link_id'] ?? ''));
$payload['link_id'] = $remoteLinkId;
$api->updateLink($payload);
} else {
$created = $api->createLink($payload);
$remoteLinkId = self::extractRemoteLinkId($created);
if ($remoteLinkId === '') {
throw new RuntimeException('企业微信已创建链接,但接口未返回 link_id,请先执行“同步企业微信”确认结果');
}
}
$remote = self::normaliseRemoteLink($api->getLink($remoteLinkId), $remoteLinkId);
$data += self::remoteColumns($remote, $now);
if ($existing !== null) {
Db::name('qywx_promotion_link')->where('id', $id)->update($data);
} else {
$data += [
@@ -219,15 +234,142 @@ class WecomPromotionLogic
'last_click_time' => 0,
'create_time' => $now,
];
$id = (int) Db::name('qywx_promotion_link')->insertGetId($data);
try {
$id = (int) Db::name('qywx_promotion_link')->insertGetId($data);
} catch (\Throwable $e) {
try {
$api->deleteLink($remoteLinkId);
} catch (\Throwable) {
// 远端补偿失败时保留原始异常,管理员可通过“同步企业微信”找回链接。
}
throw $e;
}
}
return ['id' => $id];
return ['id' => $id, 'remote_link_id' => $remoteLinkId, 'mode' => 'official'];
}
/** 验证 CorpID、应用 Secret、可信 IP 与获客助手接口权限。 */
public static function checkApiPermission(): array
{
return (new QywxCustomerAcquisitionApiService())->checkPermission();
}
/**
* 将企业微信端获客链接同步进指定分流方案。
* 非全量权限账号仅导入 range.user_list 与其可见成员有交集的链接,未知部门映射时严格隐藏。
*/
public static function syncRemoteLinks(int $poolId, int $adminId, array $adminInfo): array
{
$pool = self::assertScopedRow('qywx_promotion_pool', $poolId, $adminId, $adminInfo);
$legacyCount = (int) Db::name('qywx_promotion_link')
->where('pool_id', $poolId)
->whereNull('delete_time')
->whereRaw("(remote_link_id IS NULL OR remote_link_id = '')")
->count();
$visibleAdminIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
$visibleUserIds = null;
if ($visibleAdminIds !== null) {
$visibleUserIds = array_fill_keys(array_column(self::memberOptions($adminId, $adminInfo), 'userid'), true);
}
$api = new QywxCustomerAcquisitionApiService();
$cursor = '';
$seen = 0;
$created = 0;
$updated = 0;
$skipped = 0;
$failed = 0;
$errors = [];
do {
$page = $api->listLinks($cursor, 100);
foreach ($page['link_id_list'] as $remoteLinkId) {
if ($seen >= 500) {
break 2;
}
$seen++;
try {
$remote = self::normaliseRemoteLink($api->getLink($remoteLinkId), $remoteLinkId);
if (!self::canSeeRemoteLink($remote, $visibleUserIds)) {
$skipped++;
continue;
}
$result = self::upsertRemoteLink($remote, $pool, $adminId, $adminInfo);
$result === 'created' ? $created++ : $updated++;
} catch (\Throwable $e) {
$failed++;
if (count($errors) < 5) {
$errors[] = $remoteLinkId . '' . $e->getMessage();
}
}
}
$cursor = (string) ($page['next_cursor'] ?? '');
} while ($cursor !== '');
return [
'scanned' => $seen,
'created' => $created,
'updated' => $updated,
'skipped' => $skipped,
'failed' => $failed,
'legacy_count' => $legacyCount,
'empty_reason' => $seen === 0
? '当前获客助手可调用应用没有通过 API 创建的官方获客链接;历史手工链接及其他应用创建的链接不会出现在该应用的同步列表中。'
: '',
'suggestion' => $seen === 0
? '请点击“创建官方获客链接”通过当前应用创建。历史手工链接仍可参与本地分流,但无法同步官方 link_id 和官方获客数据。'
: '',
'truncated' => $cursor !== '',
'errors' => $errors,
];
}
/** 获取并刷新单条企业微信官方详情。 */
public static function remoteLinkDetail(int $id, int $adminId, array $adminInfo): array
{
$row = self::assertScopedRow('qywx_promotion_link', $id, $adminId, $adminInfo);
$remoteLinkId = trim((string) ($row['remote_link_id'] ?? ''));
if ($remoteLinkId === '') {
throw new RuntimeException('这是历史手工链接,没有企业微信 link_id');
}
$api = new QywxCustomerAcquisitionApiService();
$remote = self::normaliseRemoteLink($api->getLink($remoteLinkId), $remoteLinkId);
$visibleUserIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo) === null
? null
: array_fill_keys(array_column(self::memberOptions($adminId, $adminInfo), 'userid'), true);
if (!self::canSeeRemoteLink($remote, $visibleUserIds)) {
throw new RuntimeException('该获客链接已不在当前角色或部门的数据范围内');
}
Db::name('qywx_promotion_link')->where('id', $id)->update(self::remoteColumns($remote, time()));
return self::remotePublicPayload($remote);
}
/** 永久删除企业微信端获客链接,本地保留审计记录并停止分流。 */
public static function deleteRemoteLink(int $id, int $adminId, array $adminInfo): void
{
$row = self::assertScopedRow('qywx_promotion_link', $id, $adminId, $adminInfo);
$remoteLinkId = trim((string) ($row['remote_link_id'] ?? ''));
if ($remoteLinkId === '') {
throw new RuntimeException('历史手工链接只能从本地移除');
}
(new QywxCustomerAcquisitionApiService())->deleteLink($remoteLinkId);
Db::name('qywx_promotion_link')->where('id', $id)->update([
'status' => 0,
'remote_status' => 2,
'last_sync_time' => time(),
'sync_error' => '',
'update_time' => time(),
]);
}
public static function toggleLink(int $id, int $status, int $adminId, array $adminInfo): void
{
self::assertScopedRow('qywx_promotion_link', $id, $adminId, $adminInfo);
$row = self::assertScopedRow('qywx_promotion_link', $id, $adminId, $adminInfo);
if ($status === 1 && (int) ($row['remote_status'] ?? 0) === 2) {
throw new RuntimeException('企业微信端已永久删除该链接,不能重新上线');
}
Db::name('qywx_promotion_link')->where('id', $id)->update([
'status' => $status === 1 ? 1 : 0,
'update_time' => time(),
@@ -243,6 +385,251 @@ class WecomPromotionLogic
]);
}
/** @return list<array{id:int,name:string,userid:string,dept_ids:list<int>,dept_names:list<string>}> */
private static function memberOptions(int $adminId, array $adminInfo): array
{
$visibleIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
if ($visibleIds === []) {
return [];
}
$query = Db::name('admin')->alias('a')
->whereNull('a.delete_time')
->where('a.work_wechat_userid', '<>', '');
if ($visibleIds !== null) {
$query->whereIn('a.id', $visibleIds);
}
$admins = $query->field('a.id,a.name,a.work_wechat_userid')->order('a.id', 'asc')->select()->toArray();
if ($admins === []) {
return [];
}
$adminIds = array_map('intval', array_column($admins, 'id'));
$deptRows = Db::name('admin_dept')->alias('ad')
->leftJoin('dept d', 'd.id = ad.dept_id')
->whereIn('ad.admin_id', $adminIds)
->field('ad.admin_id,ad.dept_id,d.name as dept_name')
->order('ad.dept_id', 'asc')->select()->toArray();
$departments = [];
foreach ($deptRows as $row) {
$aid = (int) ($row['admin_id'] ?? 0);
$departments[$aid]['ids'][] = (int) ($row['dept_id'] ?? 0);
if (trim((string) ($row['dept_name'] ?? '')) !== '') {
$departments[$aid]['names'][] = (string) $row['dept_name'];
}
}
$result = [];
$seenUserIds = [];
foreach ($admins as $admin) {
$userId = trim((string) ($admin['work_wechat_userid'] ?? ''));
if ($userId === '' || isset($seenUserIds[$userId])) {
continue;
}
$seenUserIds[$userId] = true;
$aid = (int) $admin['id'];
$result[] = [
'id' => $aid,
'name' => (string) ($admin['name'] ?? $userId),
'userid' => $userId,
'dept_ids' => array_values(array_unique(array_filter($departments[$aid]['ids'] ?? []))),
'dept_names' => array_values(array_unique($departments[$aid]['names'] ?? [])),
];
}
return $result;
}
/** @return list<string> */
private static function resolveMemberUserIds(array $adminIds, int $adminId, array $adminInfo): array
{
$requested = array_values(array_unique(array_filter(array_map('intval', $adminIds))));
if ($requested === []) {
throw new RuntimeException('请至少选择一名当前角色或部门范围内的获客成员');
}
$available = [];
foreach (self::memberOptions($adminId, $adminInfo) as $member) {
$available[$member['id']] = $member['userid'];
}
$userIds = [];
foreach ($requested as $requestedId) {
if (!isset($available[$requestedId])) {
throw new RuntimeException('选择的获客成员超出当前角色或部门的数据范围,或尚未绑定企业微信 userid');
}
$userIds[] = $available[$requestedId];
}
if (count($userIds) > 500) {
throw new RuntimeException('单个获客链接最多配置 500 名成员');
}
return array_values(array_unique($userIds));
}
/** @return array<string,mixed> */
private static function normaliseRemoteLink(array $response, string $fallbackId = ''): array
{
$link = isset($response['link']) && is_array($response['link']) ? $response['link'] : $response;
$linkId = trim((string) ($link['link_id'] ?? $response['link_id'] ?? $fallbackId));
$url = trim((string) ($link['url'] ?? $link['link_url'] ?? $response['url'] ?? ''));
if ($linkId === '') {
throw new RuntimeException('企业微信获客链接详情缺少 link_id');
}
if (!QywxCustomerAcquisitionLinkService::isAllowed($url)) {
throw new RuntimeException('企业微信获客链接详情未返回有效的 https://work.weixin.qq.com/ca/... 地址');
}
$range = isset($link['range']) && is_array($link['range']) ? $link['range'] : [];
return [
'link_id' => $linkId,
'link_name' => trim((string) ($link['link_name'] ?? $link['name'] ?? $linkId)),
'url' => $url,
'create_time' => max(0, (int) ($link['create_time'] ?? 0)),
'range_userids' => self::normaliseScalarList($range['user_list'] ?? []),
'range_department_ids' => self::normaliseScalarList($range['department_list'] ?? []),
'skip_verify' => !empty($link['skip_verify']),
'priority_option' => isset($link['priority_option']) && is_array($link['priority_option']) ? $link['priority_option'] : [],
'snapshot' => $link,
];
}
/** @return array<string,mixed> */
private static function remoteColumns(array $remote, int $now): array
{
return [
'name' => mb_substr((string) ($remote['link_name'] ?? ''), 0, 80),
'wecom_url' => (string) ($remote['url'] ?? ''),
'remote_link_id' => (string) ($remote['link_id'] ?? ''),
'remote_status' => 1,
'remote_create_time' => (int) ($remote['create_time'] ?? 0),
'range_user_json' => self::encodeJson($remote['range_userids'] ?? []),
'range_department_json' => self::encodeJson($remote['range_department_ids'] ?? []),
'skip_verify' => !empty($remote['skip_verify']) ? 1 : 0,
'priority_option_json' => self::encodeJson($remote['priority_option'] ?? []),
'remote_snapshot' => self::encodeJson($remote['snapshot'] ?? []),
'last_sync_time' => $now,
'sync_error' => '',
'update_time' => $now,
];
}
private static function upsertRemoteLink(array $remote, array $pool, int $adminId, array $adminInfo): string
{
$remoteLinkId = (string) $remote['link_id'];
$now = time();
$existing = Db::name('qywx_promotion_link')->where('remote_link_id', $remoteLinkId)->find();
$remoteData = self::remoteColumns($remote, $now);
if ($existing) {
$visibleIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
if ($visibleIds !== null && !in_array((int) ($existing['owner_admin_id'] ?? 0), $visibleIds, true)) {
throw new RuntimeException('该链接已归属其他数据范围');
}
$remoteData['delete_time'] = null;
Db::name('qywx_promotion_link')->where('id', (int) $existing['id'])->update($remoteData);
return 'updated';
}
Db::name('qywx_promotion_link')->insert($remoteData + [
'pool_id' => (int) $pool['id'],
'account_id' => 0,
'group_name' => '企业微信同步',
'weight' => 1,
'status' => 1,
'daily_limit' => 0,
'today_count' => 0,
'today_date' => null,
'active_start' => 0,
'active_end' => 0,
'click_count' => 0,
'last_click_time' => 0,
'owner_admin_id' => (int) ($pool['owner_admin_id'] ?? 0) ?: $adminId,
'dept_id' => (int) ($pool['dept_id'] ?? 0) ?: self::primaryDeptId($adminId),
'remark' => '',
'create_time' => $now,
'delete_time' => null,
]);
return 'created';
}
private static function canSeeRemoteLink(array $remote, ?array $visibleUserIds): bool
{
if ($visibleUserIds === null) {
return true;
}
foreach ((array) ($remote['range_userids'] ?? []) as $userId) {
if (isset($visibleUserIds[(string) $userId])) {
return true;
}
}
return false;
}
/** @return array<string,mixed> */
private static function remotePublicPayload(array $remote): array
{
return [
'link_id' => (string) ($remote['link_id'] ?? ''),
'link_name' => (string) ($remote['link_name'] ?? ''),
'url' => (string) ($remote['url'] ?? ''),
'create_time' => (int) ($remote['create_time'] ?? 0),
'range_userids' => (array) ($remote['range_userids'] ?? []),
'range_department_ids' => (array) ($remote['range_department_ids'] ?? []),
'skip_verify' => !empty($remote['skip_verify']),
'priority_option' => (array) ($remote['priority_option'] ?? []),
];
}
private static function extractRemoteLinkId(array $response): string
{
if (isset($response['link']) && is_array($response['link'])) {
return trim((string) ($response['link']['link_id'] ?? ''));
}
return trim((string) ($response['link_id'] ?? ''));
}
/** @return list<string> */
private static function normaliseScalarList(mixed $value): array
{
if (!is_array($value)) {
return [];
}
return array_values(array_unique(array_filter(array_map(
static fn (mixed $item): string => trim((string) $item),
$value
), static fn (string $item): bool => $item !== '')));
}
/** @return list<string> */
private static function decodeStringList(mixed $value): array
{
if (!is_string($value) || $value === '') {
return [];
}
$decoded = json_decode($value, true);
return self::normaliseScalarList(is_array($decoded) ? $decoded : []);
}
/** @return array<string,mixed> */
private static function decodeObject(mixed $value): array
{
if (!is_string($value) || $value === '') {
return [];
}
$decoded = json_decode($value, true);
return is_array($decoded) ? $decoded : [];
}
private static function encodeJson(mixed $value): string
{
$encoded = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
return $encoded === false ? '[]' : $encoded;
}
private static function assertScopedRow(string $table, int $id, int $adminId, array $adminInfo): array
{
if ($id <= 0) {
@@ -264,23 +651,6 @@ class WecomPromotionLogic
return $row;
}
private static function assertAuthorizedAccount(int $id, bool $requireActive): array
{
if ($id <= 0) {
throw new RuntimeException('授权企业不存在');
}
$query = Db::name('qywx_promotion_account')->where('id', $id)->whereNull('delete_time');
if ($requireActive) {
$query->where('auth_status', 1);
}
$row = $query->find();
if (!$row) {
throw new RuntimeException($requireActive ? '授权企业无效或已取消授权' : '授权企业不存在');
}
return $row;
}
private static function applyOwnerScope($query, string $alias, ?array $visibleIds): void
{
if ($visibleIds === null) {
@@ -293,19 +663,6 @@ class WecomPromotionLogic
$query->whereIn($alias . '.owner_admin_id', $visibleIds);
}
private static function canAuthorize(int $adminId, array $adminInfo): bool
{
if ((int) ($adminInfo['root'] ?? 0) === 1) {
return true;
}
return Db::name('admin_role')->alias('ar')
->join('system_role r', 'r.id = ar.role_id AND r.delete_time IS NULL')
->where('ar.admin_id', $adminId)
->where('r.name', '管理员')
->count() > 0;
}
private static function primaryDeptId(int $adminId): int
{
return (int) (Db::name('admin_dept')->where('admin_id', $adminId)->order('dept_id', 'asc')->value('dept_id') ?? 0);
@@ -333,4 +690,58 @@ class WecomPromotionLogic
return substr($value, 0, 4) . str_repeat('*', max(4, $length - 8)) . substr($value, -4);
}
private static function publicDomain(string $requestDomain): string
{
$configuredDomain = trim((string) config('app.app_host', ''));
foreach ([$configuredDomain, trim($requestDomain)] as $candidate) {
if ($candidate === '') {
continue;
}
$parts = parse_url($candidate);
if (!is_array($parts)) {
continue;
}
$scheme = strtolower((string) ($parts['scheme'] ?? ''));
$host = (string) ($parts['host'] ?? '');
if (!in_array($scheme, ['http', 'https'], true) || $host === '') {
continue;
}
$port = isset($parts['port']) ? ':' . (int) $parts['port'] : '';
return $scheme . '://' . $host . $port;
}
throw new RuntimeException('未配置有效的应用访问域名');
}
/**
* 内部应用直接复用项目现有 work_wechat 配置,不经过第三方服务商授权。
*
* @return array<string, mixed>
*/
private static function internalApplicationStatus(string $domain): array
{
$corpId = trim((string) config('qywx_customer_acquisition.corp_id', ''));
$agentId = trim((string) env('WECHAT_WORK_AGENT_ID', ''));
if ($agentId === '') {
$agentId = trim((string) env('work_wechat.agent_id', ''));
}
$apiStatus = QywxCustomerAcquisitionApiService::configurationStatus();
$callbackTokenConfigured = trim((string) config('pay.wechat_work.contact_callback_token', '')) !== '';
$callbackAesConfigured = trim((string) config('pay.wechat_work.contact_callback_aes_key', '')) !== '';
return [
'mode' => 'internal',
'configured' => $apiStatus['configured'],
'ready' => $apiStatus['configured'],
'missing' => $apiStatus['missing'],
'corp_id_masked' => self::mask($corpId),
'agent_id' => $agentId,
'secret_configured' => trim((string) config('qywx_customer_acquisition.secret', '')) !== '',
'callback_ready' => $callbackTokenConfigured && $callbackAesConfigured,
'callback_url' => rtrim($domain, '/') . '/api/qywx/external-contact/notify',
'official_doc' => 'https://developer.work.weixin.qq.com/document/path/97297',
];
}
}
@@ -18,6 +18,14 @@ class ConversionLogic
private const VIRTUAL_DEPT_UNBOUND_ADMIN_ID = -1;
private const VIRTUAL_DEPT_UNASSIGNED_ID = -2;
/**
* Per-overview raw aggregate cache. It is reset at the beginning of every
* overview call so long-running workers never reuse stale business data.
*
* @var array<string, array<int, array<string, mixed>>>
*/
private static array $requestRowsCache = [];
/**
* @param array $params
* @param int $adminId 当前操作 admin(来自 BaseAdminController
@@ -39,12 +47,19 @@ class ConversionLogic
?array $trustedCostAllocationAdminIdsOverride = null
): array
{
self::$requestRowsCache = [];
$includeFilters = (int)($params['include_filters'] ?? 0) === 1;
// 仅供需要“有效挂号”口径的内部看板调用;默认保持转换统计历史口径不变。
$excludeCancelledAppointments = (int)($params['exclude_cancelled_appointments'] ?? 0) === 1;
// 一诊综合转化复用处方订单页的业绩口径;其它调用方继续保留历史“双审完成单”口径。
$usePerformanceOrderMetrics = strtolower(trim((string)($params['order_metric_mode'] ?? ''))) === 'performance';
$dimension = self::normalizeDimension((string)($params['dimension'] ?? 'dept'));
$mediaChannelCode = MediaChannelService::normalizeStatsCode((string) ($params['media_channel_code'] ?? ''));
$mediaChannel = $mediaChannelCode !== '' ? MediaChannelService::getChannelByCode($mediaChannelCode) : null;
$requestedMediaChannelCode = trim((string)($params['media_channel_code'] ?? ''));
$mediaChannel = $requestedMediaChannelCode !== ''
? MediaChannelService::getChannelByCode($requestedMediaChannelCode)
: null;
$mediaChannelCode = $mediaChannel !== null ? $requestedMediaChannelCode : '';
$filterEmptyEntities = $mediaChannel !== null;
[$startTimestamp, $endTimestamp, $startDate, $endDate] = self::resolveTimeRange($params);
$pageNo = max(1, (int)($params['page_no'] ?? 1));
@@ -154,17 +169,33 @@ class ConversionLogic
$endDate,
$mediaChannel,
$visibleAdminIds,
$excludeCancelledAppointments
$excludeCancelledAppointments,
$usePerformanceOrderMetrics
);
self::hydrateOrderAndAmountStats(
$entities,
$dimension,
$entityIds,
$adminToDeptIds,
$startTimestamp,
$endTimestamp,
$mediaChannel,
$visibleAdminIds,
$usePerformanceOrderMetrics
);
self::hydrateOrderAndAmountStats($entities, $dimension, $entityIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel, $visibleAdminIds);
// 数据隔离:可见部门 = 可见 admin 所属部门并集;用于 account_cost 与下游 cost 分摊。
$visibleDeptIds = self::resolveVisibleDeptIds($visibleAdminIds);
[$globalAccountCost, $accountCostDeptIds] = self::hydrateAccountCostStats($entities, $startDate, $endDate, $mediaChannelCode, $visibleDeptIds);
$supportsDeptBinding = AccountCost::supportsDeptBinding();
$restrictAccountCostByDept = $supportsDeptBinding;
$restrictStatsByDept = $supportsDeptBinding && $mediaChannelCode !== '';
$scopeDeptIds = $restrictStatsByDept
$channelBoundDeptIds = $supportsDeptBinding && $mediaChannelCode !== ''
? self::loadChannelBoundDeptIds($mediaChannelCode)
: [];
// 渠道尚未维护投放成本时,不能把真实的加粉、挂号和订单一并过滤为空。
// 已维护绑定关系的渠道继续按绑定部门收窄;成本本身仍只在实际成本部门内分摊。
$restrictStatsByDept = $channelBoundDeptIds !== [];
$scopeDeptIds = $restrictStatsByDept
? $channelBoundDeptIds
: $accountCostDeptIds;
$eligibleDeptIds = $restrictAccountCostByDept ? self::expandDeptIdsWithDescendants($scopeDeptIds) : $scopeDeptIds;
@@ -239,7 +270,9 @@ class ConversionLogic
$adminToDeptIds,
$validDeptIds,
$globalAccountCost,
$visibleAdminIds
$visibleAdminIds,
$excludeCancelledAppointments,
$usePerformanceOrderMetrics
);
$pagedRows = self::attachDeptMembers($pagedRows, $memberRowsByDeptId);
}
@@ -713,8 +746,8 @@ class ConversionLogic
/**
* @return array<int, int[]>
*
* 返回 admin_id => [dept_id, ...],每个 admin 的 dept_id 列表按 dept_id 升序排列
* zyt_admin_dept 联合主键 admin_id+dept_id,无独立 id 列)
* 返回 admin_id => [dept_id, ...]。跨部门时优先最深层、再按部门排序,
* 与一诊挂号统计和业绩看板的人员归属规则保持一致
* 当 admin 跨部门时,下游 mapEntityIds 只取列表中第一个落在当前 entityIds 内的部门,
* 避免同一笔加粉/挂号/接诊被多次累加到不同部门。
*/
@@ -722,10 +755,38 @@ class ConversionLogic
{
$rows = Db::name('admin_dept')
->field('admin_id, dept_id')
->order('admin_id', 'asc')
->order('dept_id', 'asc')
->select()
->toArray();
$deptRows = Db::name('dept')
->whereNull('delete_time')
->field('id, pid, sort')
->select()
->toArray();
$deptById = [];
foreach ($deptRows as $deptRow) {
$deptId = (int)($deptRow['id'] ?? 0);
if ($deptId > 0) {
$deptById[$deptId] = [
'pid' => (int)($deptRow['pid'] ?? 0),
'sort' => (int)($deptRow['sort'] ?? 0),
];
}
}
$depthCache = [];
$depthOf = static function (int $deptId) use (&$depthOf, &$depthCache, $deptById): int {
if ($deptId <= 0 || !isset($deptById[$deptId])) {
return 0;
}
if (isset($depthCache[$deptId])) {
return $depthCache[$deptId];
}
$parentId = (int)($deptById[$deptId]['pid'] ?? 0);
if ($parentId <= 0 || $parentId === $deptId || !isset($deptById[$parentId])) {
return $depthCache[$deptId] = 0;
}
return $depthCache[$deptId] = $depthOf($parentId) + 1;
};
$map = [];
foreach ($rows as $row) {
$adminId = (int)($row['admin_id'] ?? 0);
@@ -736,6 +797,21 @@ class ConversionLogic
$map[$adminId] ??= [];
$map[$adminId][] = $deptId;
}
foreach ($map as &$deptIds) {
usort($deptIds, static function (int $left, int $right) use ($depthOf, $deptById): int {
$depthCompare = $depthOf($right) <=> $depthOf($left);
if ($depthCompare !== 0) {
return $depthCompare;
}
$sortCompare = (int)($deptById[$right]['sort'] ?? 0) <=> (int)($deptById[$left]['sort'] ?? 0);
if ($sortCompare !== 0) {
return $sortCompare;
}
return $left <=> $right;
});
}
unset($deptIds);
return $map;
}
@@ -851,29 +927,16 @@ class ConversionLogic
?array $mediaChannel,
?array $visibleAdminIds = null
): void {
$query = Db::name('qywx_external_contact_event')
->alias('e')
->leftJoin('admin a', 'a.work_wechat_userid = e.user_id AND a.delete_time IS NULL')
->where('e.change_type', 'add_external_contact')
->where('e.event_time', 'between', [$startTimestamp, $endTimestamp])
->fieldRaw('a.id AS admin_id, COUNT(*) AS add_fans_count')
->group('a.id');
if ($visibleAdminIds !== null) {
$query->whereIn('a.id', $visibleAdminIds);
} elseif ($dimension !== 'dept' && $entityIds !== []) {
$query->whereIn('a.id', $entityIds);
$queryAdminIds = $visibleAdminIds;
if ($queryAdminIds === null && $dimension !== 'dept') {
$queryAdminIds = $entityIds;
}
if ($mediaChannel !== null) {
$query->leftJoin('qywx_external_contact q', 'q.external_userid = e.external_userid');
MediaChannelService::applyFollowUsersChannelFilter($query, 'q.follow_users', $mediaChannel);
}
$rows = $query->select()->toArray();
$rows = self::loadFanRows($startTimestamp, $endTimestamp, $mediaChannel, $queryAdminIds);
$adminByUserId = self::loadActiveAdminByWorkWechatUserIds(array_column($rows, 'user_id'));
foreach ($rows as $row) {
$adminId = (int)($row['admin_id'] ?? 0);
$userId = (string)($row['user_id'] ?? '');
$adminId = (int)($adminByUserId[$userId]['id'] ?? 0);
$addFansCount = (int)($row['add_fans_count'] ?? 0);
if ($dimension === 'dept' && $adminId <= 0) {
@@ -910,6 +973,123 @@ class ConversionLogic
}
}
/**
* Aggregate add-contact events by WeCom user once, then project that raw
* snapshot to departments, members and virtual buckets in PHP.
*
* @param array<string, mixed>|null $mediaChannel
* @param int[]|null $adminIds null means all active/unbound WeCom users
* @return array<int, array{user_id: string, add_fans_count: int|string}>
*/
private static function loadFanRows(
int $startTimestamp,
int $endTimestamp,
?array $mediaChannel,
?array $adminIds = null
): array {
if ($adminIds !== null) {
$adminIds = array_values(array_unique(array_filter(
array_map('intval', $adminIds),
static fn (int $id): bool => $id > 0
)));
sort($adminIds);
if ($adminIds === []) {
return [];
}
}
$baseKey = self::requestRowsCacheKey('fans', [
$startTimestamp,
$endTimestamp,
self::mediaChannelCacheKey($mediaChannel),
]);
$allKey = $baseKey . ':all';
$cacheKey = $adminIds === null
? $allKey
: $baseKey . ':admins:' . implode(',', $adminIds);
if (isset(self::$requestRowsCache[$cacheKey])) {
return self::$requestRowsCache[$cacheKey];
}
$workWechatUserIds = null;
if ($adminIds !== null) {
$workWechatUserIds = Db::name('admin')
->whereIn('id', $adminIds)
->whereNull('delete_time')
->where('work_wechat_userid', '<>', '')
->column('work_wechat_userid');
$workWechatUserIds = array_values(array_unique(array_filter(array_map('strval', $workWechatUserIds))));
if ($workWechatUserIds === []) {
self::$requestRowsCache[$cacheKey] = [];
return [];
}
if (isset(self::$requestRowsCache[$allKey])) {
$allowed = array_fill_keys($workWechatUserIds, true);
self::$requestRowsCache[$cacheKey] = array_values(array_filter(
self::$requestRowsCache[$allKey],
static fn (array $row): bool => isset($allowed[(string)($row['user_id'] ?? '')])
));
return self::$requestRowsCache[$cacheKey];
}
}
$query = Db::name('qywx_external_contact_event')
->alias('e')
->where('e.change_type', 'add_external_contact')
->where('e.event_time', 'between', [$startTimestamp, $endTimestamp])
->fieldRaw('e.user_id, COUNT(*) AS add_fans_count')
->group('e.user_id');
if ($workWechatUserIds !== null) {
$query->whereIn('e.user_id', $workWechatUserIds);
}
if ($mediaChannel !== null) {
MediaChannelService::applyExternalUserChannelFilter($query, 'e.external_userid', $mediaChannel);
}
self::$requestRowsCache[$cacheKey] = $query->select()->toArray();
return self::$requestRowsCache[$cacheKey];
}
/**
* @param string[] $userIds
* @return array<string, array{id: int|string, name: string}>
*/
private static function loadActiveAdminByWorkWechatUserIds(array $userIds): array
{
$userIds = array_values(array_unique(array_filter(array_map('strval', $userIds))));
sort($userIds);
if ($userIds === []) {
return [];
}
$cacheKey = self::requestRowsCacheKey('active-admin-by-wecom-user', $userIds);
if (!isset(self::$requestRowsCache[$cacheKey])) {
self::$requestRowsCache[$cacheKey] = Db::name('admin')
->whereIn('work_wechat_userid', $userIds)
->whereNull('delete_time')
->field('id, name, work_wechat_userid')
->select()
->toArray();
}
$result = [];
foreach (self::$requestRowsCache[$cacheKey] as $row) {
$userId = (string)($row['work_wechat_userid'] ?? '');
if ($userId !== '' && !isset($result[$userId])) {
$result[$userId] = [
'id' => (int)($row['id'] ?? 0),
'name' => (string)($row['name'] ?? ''),
];
}
}
return $result;
}
/**
* @param array<int, array<string, mixed>> $entities
* @param int[] $entityIds
@@ -928,46 +1108,59 @@ class ConversionLogic
string $endDate,
?array $mediaChannel,
?array $visibleAdminIds = null,
bool $excludeCancelledAppointments = false
bool $excludeCancelledAppointments = false,
bool $useRegistrationMetric = false
): void {
$sourceExpr = $dimension === 'doctor' ? 'a.doctor_id' : 'u.assistant_id';
$query = Db::name('doctor_appointment')
->alias('a')
->leftJoin('tcm_diagnosis u', 'a.patient_id = u.id')
->where('a.appointment_date', '>=', $startDate)
->where('a.appointment_date', '<=', $endDate)
->fieldRaw("{$sourceExpr} AS source_admin_id, a.patient_id AS diagnosis_id, COUNT(*) AS appointment_count, SUM(CASE WHEN a.status = 3 THEN 1 ELSE 0 END) AS interview_count")
->group("{$sourceExpr}, a.patient_id");
$sourceType = $dimension === 'doctor' ? 'doctor' : 'assistant';
$rows = self::cachedRequestRows('appointments', [
$sourceType,
$startDate,
$endDate,
self::mediaChannelCacheKey($mediaChannel),
$excludeCancelledAppointments,
], static function () use (
$sourceType,
$startDate,
$endDate,
$mediaChannel,
$excludeCancelledAppointments
): array {
$sourceExpr = $sourceType === 'doctor'
? 'a.doctor_id'
: 'COALESCE(NULLIF(a.assistant_id, 0), NULLIF(u.assistant_id, 0))';
$query = Db::name('doctor_appointment')
->alias('a')
->leftJoin('tcm_diagnosis u', 'a.patient_id = u.id')
->where('a.appointment_date', '>=', $startDate)
->where('a.appointment_date', '<=', $endDate)
->where('a.patient_id', '>', 0)
->fieldRaw("{$sourceExpr} AS source_admin_id, COUNT(*) AS appointment_count, SUM(CASE WHEN a.status = 3 THEN 1 ELSE 0 END) AS interview_count")
->group($sourceExpr);
if ($excludeCancelledAppointments) {
$query->where('a.status', '<>', 2);
}
if ($excludeCancelledAppointments) {
$query->whereIn('a.status', [1, 3, 4]);
}
$query->where(static function (Query $subQuery): void {
$subQuery->whereNull('u.id')
->whereOr(static function (Query $orQuery): void {
$orQuery->whereNull('u.delete_time');
});
$query->where(static function (Query $subQuery): void {
$subQuery->whereNull('u.id')
->whereOr(static function (Query $orQuery): void {
$orQuery->whereNull('u.delete_time');
});
});
if ($mediaChannel !== null) {
$legacyChannelValues = MediaChannelService::getLegacyAppointmentChannelValues($mediaChannel);
if ($legacyChannelValues !== []) {
self::applyAppointmentChannelFilter($query, $legacyChannelValues);
} else {
MediaChannelService::applyExternalUserChannelFilter($query, 'u.external_userid', $mediaChannel);
}
}
return $query->select()->toArray();
});
if ($mediaChannel !== null) {
$legacyChannelValues = MediaChannelService::getLegacyAppointmentChannelValues($mediaChannel);
if ($legacyChannelValues !== []) {
$query->whereIn('a.channels', $legacyChannelValues);
} else {
$query->leftJoin('qywx_external_contact q', 'q.external_userid = u.external_userid');
MediaChannelService::applyFollowUsersChannelFilter($query, 'q.follow_users', $mediaChannel);
}
}
$rows = $query->select()->toArray();
foreach ($rows as $row) {
$diagnosisId = (int)($row['diagnosis_id'] ?? 0);
if ($diagnosisId <= 0) {
continue;
}
$sourceAdminId = (int)($row['source_admin_id'] ?? 0);
$mappedEntityIds = self::mapEntityIds($dimension, $sourceAdminId, $entityIds, $adminToDeptIds, $visibleAdminIds);
@@ -983,7 +1176,17 @@ class ConversionLogic
}
}
self::hydratePaidAppointmentStats($entities, $dimension, $entityIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel, $visibleAdminIds);
self::hydratePaidAppointmentStats(
$entities,
$dimension,
$entityIds,
$adminToDeptIds,
$startTimestamp,
$endTimestamp,
$mediaChannel,
$visibleAdminIds,
$useRegistrationMetric
);
foreach ($entities as &$entity) {
$appointmentTotalCount = (int)($entity['appointment_total_count'] ?? 0);
@@ -1008,28 +1211,46 @@ class ConversionLogic
int $startTimestamp,
int $endTimestamp,
?array $mediaChannel,
?array $visibleAdminIds = null
?array $visibleAdminIds = null,
bool $useRegistrationMetric = false
): void {
$startDateTime = date('Y-m-d H:i:s', $startTimestamp);
$endDateTime = date('Y-m-d H:i:s', $endTimestamp);
$query = Db::name('order')
->alias('o')
->whereNull('o.delete_time')
->where('o.status', 2)
->where('o.order_type', 1)
->where('o.amount', 5)
->whereNotNull('o.payment_time')
->where('o.payment_time', '<>', '')
->whereBetweenTime('o.payment_time', $startDateTime, $endDateTime)
->fieldRaw('o.creator_id AS source_admin_id, COUNT(*) AS paid_appointment_count')
->group('o.creator_id');
$rows = self::cachedRequestRows('paid-appointments', [
$startDateTime,
$endDateTime,
self::mediaChannelCacheKey($mediaChannel),
$useRegistrationMetric,
], static function () use (
$startDateTime,
$endDateTime,
$mediaChannel,
$useRegistrationMetric
): array {
$query = Db::name('order')
->alias('o')
->whereNull('o.delete_time')
->where('o.status', 2)
// payment_time 是 DATETIME NULLMySQL 8 严格模式下不能与空字符串比较。
->whereNotNull('o.payment_time')
->whereBetweenTime('o.payment_time', $startDateTime, $endDateTime)
->fieldRaw('o.creator_id AS source_admin_id, COUNT(*) AS paid_appointment_count')
->group('o.creator_id');
if ($mediaChannel !== null) {
$query->leftJoin('qywx_external_contact q', 'q.external_userid = o.payer_external_userid');
MediaChannelService::applyFollowUsersChannelFilter($query, 'q.follow_users', $mediaChannel);
}
if ($useRegistrationMetric) {
// 一诊页面的新“挂号”:已支付且 0 < 实收金额 < 10 元,每笔支付订单计 1 个。
$query->where('o.amount', '>', 0)->where('o.amount', '<', 10);
} else {
// 保留旧统计页面的历史“付费挂号”字段,避免本次一诊改造改变其它模块口径。
$query->where('o.order_type', 1)->where('o.amount', 5);
}
$rows = $query->select()->toArray();
if ($mediaChannel !== null) {
MediaChannelService::applyExternalUserChannelFilter($query, 'o.payer_external_userid', $mediaChannel);
}
return $query->select()->toArray();
});
foreach ($rows as $row) {
$sourceAdminId = (int)($row['source_admin_id'] ?? 0);
$mappedEntityIds = self::mapEntityIds($dimension, $sourceAdminId, $entityIds, $adminToDeptIds, $visibleAdminIds);
@@ -1044,6 +1265,54 @@ class ConversionLogic
}
}
/**
* 挂号渠道兼容:新表写 channel_sourcevarchar),旧表写 channelsint),
* 过渡库可能两列同时存在。不能因为运行库采用其中一种结构而漏统或报错。
*
* @param int[] $channelValues
*/
private static function applyAppointmentChannelFilter(Query $query, array $channelValues): void
{
$channelValues = array_values(array_unique(array_filter(
array_map('intval', $channelValues),
static fn (int $value): bool => $value > 0
)));
if ($channelValues === []) {
$query->whereRaw('0 = 1');
return;
}
try {
$fields = Db::name('doctor_appointment')->getTableFields();
} catch (\Throwable) {
$fields = [];
}
$fields = is_array($fields) ? $fields : [];
$hasChannelSource = in_array('channel_source', $fields, true);
$hasChannels = in_array('channels', $fields, true);
if (!$hasChannelSource && !$hasChannels) {
$query->whereRaw('0 = 1');
return;
}
$placeholders = implode(',', array_fill(0, count($channelValues), '?'));
$parts = [];
$bindings = [];
if ($hasChannelSource) {
$parts[] = "a.channel_source IN ({$placeholders})";
array_push($bindings, ...array_map('strval', $channelValues));
}
if ($hasChannels) {
$parts[] = "a.channels IN ({$placeholders})";
array_push($bindings, ...$channelValues);
}
$query->whereRaw('(' . implode(' OR ', $parts) . ')', $bindings);
}
/**
* @param array<int, array<string, mixed>> $entities
* @param int[] $entityIds
@@ -1059,8 +1328,73 @@ class ConversionLogic
int $startTimestamp,
int $endTimestamp,
?array $mediaChannel,
?array $visibleAdminIds = null
?array $visibleAdminIds = null,
bool $usePerformanceOrderMetrics = false
): void {
if ($usePerformanceOrderMetrics) {
$isDoctorDimension = $dimension === 'doctor';
$rows = self::cachedRequestRows('performance-orders', [
$isDoctorDimension ? 'doctor' : 'assistant',
$startTimestamp,
$endTimestamp,
self::mediaChannelCacheKey($mediaChannel),
], static function () use (
$isDoctorDimension,
$startTimestamp,
$endTimestamp,
$mediaChannel
): array {
$sourceExpr = $isDoctorDimension ? 'rx.creator_id' : 'po.creator_id';
$query = Db::name('tcm_prescription_order')
->alias('po')
->whereNull('po.delete_time')
->where('po.create_time', 'between', [$startTimestamp, $endTimestamp]);
if ($isDoctorDimension) {
$query->leftJoin('tcm_prescription rx', 'rx.id = po.prescription_id AND rx.delete_time IS NULL');
}
YejiStatsLogic::applyPrescriptionOrderEffectiveAmountQuery($query, 'po');
$query
->fieldRaw("{$sourceExpr} AS source_admin_id, COUNT(*) AS order_count, SUM(po.amount) AS total_amount")
->group($sourceExpr);
if ($mediaChannel !== null) {
$query->leftJoin('order o', 'o.id = po.linked_pay_order_id');
MediaChannelService::applyExternalUserChannelFilter($query, 'o.payer_external_userid', $mediaChannel);
}
return $query->select()->toArray();
});
foreach ($rows as $row) {
$sourceAdminId = (int)($row['source_admin_id'] ?? 0);
$mappedEntityIds = self::mapEntityIds(
$dimension,
$sourceAdminId,
$entityIds,
$adminToDeptIds,
$visibleAdminIds
);
if ($mappedEntityIds === []) {
continue;
}
$orderCount = (int)($row['order_count'] ?? 0);
$amount = round((float)($row['total_amount'] ?? 0), 2);
foreach ($mappedEntityIds as $entityId) {
$entities[$entityId]['completed_order_count'] += $orderCount;
$entities[$entityId]['completed_order_amount'] = round(
(float)$entities[$entityId]['completed_order_amount'] + $amount,
2
);
$entities[$entityId]['business_order_amount'] = round(
(float)$entities[$entityId]['business_order_amount'] + $amount,
2
);
}
}
return;
}
$completedSourceExpr = $dimension === 'doctor' ? 'rx.creator_id' : 'rx.assistant_id';
$completedQuery = Db::name('tcm_prescription_order')
->alias('po')
@@ -1068,15 +1402,15 @@ class ConversionLogic
->leftJoin('tcm_diagnosis dg', 'dg.id = po.diagnosis_id')
->whereNull('po.delete_time')
->where('po.payment_slip_audit_status', 1)
->where('po.create_time', 'between', [$startTimestamp, $endTimestamp])
->where('po.create_time', 'between', [$startTimestamp, $endTimestamp]);
YejiStatsLogic::applyPrescriptionOrderEffectiveAmountQuery($completedQuery, 'po');
$completedQuery
->fieldRaw("{$completedSourceExpr} AS source_admin_id, SUM(CASE WHEN po.prescription_audit_status = 1 THEN 1 ELSE 0 END) AS order_count, SUM(CASE WHEN po.prescription_audit_status = 1 THEN po.amount ELSE 0 END) AS total_amount")
->group($completedSourceExpr);
if ($mediaChannel !== null) {
$completedQuery
->leftJoin('order o', 'o.id = po.linked_pay_order_id')
->leftJoin('qywx_external_contact q', 'q.external_userid = o.payer_external_userid');
MediaChannelService::applyFollowUsersChannelFilter($completedQuery, 'q.follow_users', $mediaChannel);
$completedQuery->leftJoin('order o', 'o.id = po.linked_pay_order_id');
MediaChannelService::applyExternalUserChannelFilter($completedQuery, 'o.payer_external_userid', $mediaChannel);
}
$completedRows = $completedQuery->select()->toArray();
@@ -1104,16 +1438,14 @@ class ConversionLogic
->leftJoin('tcm_diagnosis dg', 'dg.id = po.diagnosis_id')
->whereNull('po.delete_time')
->where('po.create_time', 'between', [$startTimestamp, $endTimestamp]);
YejiStatsLogic::applyPrescriptionOrderNotCancelledForPerformanceQuery($businessQuery, 'po');
YejiStatsLogic::applyPrescriptionOrderEffectiveAmountQuery($businessQuery, 'po');
$businessQuery
->fieldRaw("{$businessSourceExpr} AS source_admin_id, SUM(po.amount) AS total_amount")
->group($businessSourceExpr);
if ($mediaChannel !== null) {
$businessQuery
->leftJoin('order o2', 'o2.id = po.linked_pay_order_id')
->leftJoin('qywx_external_contact q2', 'q2.external_userid = o2.payer_external_userid');
MediaChannelService::applyFollowUsersChannelFilter($businessQuery, 'q2.follow_users', $mediaChannel);
$businessQuery->leftJoin('order o2', 'o2.id = po.linked_pay_order_id');
MediaChannelService::applyExternalUserChannelFilter($businessQuery, 'o2.payer_external_userid', $mediaChannel);
}
$businessRows = $businessQuery->select()->toArray();
@@ -1573,6 +1905,8 @@ class ConversionLogic
* @param float $globalAccountCost 由调用方提前计算好的本期总账户消耗(zyt_account_cost SUM)。
* -1 表示让本函数自行 hydrate;>= 0 时直接复用,避免重复 SQL。
* @param int[]|null $visibleAdminIds 可见 admin 集合(null = SCOPE_ALL);用于成员明细的隔离
* @param bool $excludeCancelledAppointments 是否排除已取消挂号,须与部门汇总口径一致
* @param bool $usePerformanceOrderMetrics 是否使用有效业绩订单口径,须与部门汇总口径一致
* @return array<int, array<int, array<string, mixed>>> dept_id => [member_row, ...]
*/
private static function buildMemberRowsByDept(
@@ -1587,7 +1921,9 @@ class ConversionLogic
array $adminToDeptIds,
array $validDeptIds = [],
float $globalAccountCost = -1.0,
?array $visibleAdminIds = null
?array $visibleAdminIds = null,
bool $excludeCancelledAppointments = false,
bool $usePerformanceOrderMetrics = false
): array
{
$assistantEntities = self::loadAdminEntities(2, 0, $visibleAdminIds);
@@ -1596,15 +1932,15 @@ class ConversionLogic
$assistantIds = array_keys($assistantEntities);
$doctorIds = array_keys($doctorEntities);
if ($assistantIds !== []) {
if ($assistantIds !== [] && !$usePerformanceOrderMetrics) {
self::hydrateFanStats($assistantEntities, 'assistant', $assistantIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel, $visibleAdminIds);
self::hydrateAppointmentStats($assistantEntities, 'assistant', $assistantIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $startDate, $endDate, $mediaChannel, $visibleAdminIds);
self::hydrateOrderAndAmountStats($assistantEntities, 'assistant', $assistantIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel, $visibleAdminIds);
self::hydrateAppointmentStats($assistantEntities, 'assistant', $assistantIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $startDate, $endDate, $mediaChannel, $visibleAdminIds, $excludeCancelledAppointments);
self::hydrateOrderAndAmountStats($assistantEntities, 'assistant', $assistantIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel, $visibleAdminIds, $usePerformanceOrderMetrics);
}
if ($doctorIds !== []) {
if ($doctorIds !== [] && !$usePerformanceOrderMetrics) {
self::hydrateFanStats($doctorEntities, 'doctor', $doctorIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel, $visibleAdminIds);
self::hydrateAppointmentStats($doctorEntities, 'doctor', $doctorIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $startDate, $endDate, $mediaChannel, $visibleAdminIds);
self::hydrateOrderAndAmountStats($doctorEntities, 'doctor', $doctorIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel, $visibleAdminIds);
self::hydrateAppointmentStats($doctorEntities, 'doctor', $doctorIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $startDate, $endDate, $mediaChannel, $visibleAdminIds, $excludeCancelledAppointments);
self::hydrateOrderAndAmountStats($doctorEntities, 'doctor', $doctorIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel, $visibleAdminIds, $usePerformanceOrderMetrics);
}
// 复用调用方传入的 global account cost;只有兜底未传时才回查一次(保留向后兼容)。
@@ -1639,6 +1975,15 @@ class ConversionLogic
}
}
if ($usePerformanceOrderMetrics && $combined !== []) {
// 一诊综合转化的部门指标均按业务归属人统计。成员明细也必须沿用同一归属,
// 不能再分别按“医助/医生”统计后相加,否则双角色员工会重复、人员合计也无法与部门汇总对齐。
$combinedIds = array_keys($combined);
self::hydrateFanStats($combined, 'assistant', $combinedIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel, $visibleAdminIds);
self::hydrateAppointmentStats($combined, 'assistant', $combinedIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $startDate, $endDate, $mediaChannel, $visibleAdminIds, $excludeCancelledAppointments, true);
self::hydrateOrderAndAmountStats($combined, 'assistant', $combinedIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel, $visibleAdminIds, true);
}
if ($combined === []) {
return [];
}
@@ -1767,22 +2112,13 @@ class ConversionLogic
*/
private static function buildUnboundFansRows(int $startTimestamp, int $endTimestamp, ?array $mediaChannel): array
{
$query = Db::name('qywx_external_contact_event')
->alias('e')
->leftJoin('admin a', 'a.work_wechat_userid = e.user_id AND a.delete_time IS NULL')
->where('e.change_type', 'add_external_contact')
->where('e.event_time', 'between', [$startTimestamp, $endTimestamp])
->whereNull('a.id')
->where('e.user_id', '<>', '')
->fieldRaw('e.user_id, COUNT(*) AS add_fans_count')
->group('e.user_id');
$fanRows = self::loadFanRows($startTimestamp, $endTimestamp, $mediaChannel);
$adminByUserId = self::loadActiveAdminByWorkWechatUserIds(array_column($fanRows, 'user_id'));
$rows = array_values(array_filter($fanRows, static function (array $row) use ($adminByUserId): bool {
$userId = (string)($row['user_id'] ?? '');
if ($mediaChannel !== null) {
$query->leftJoin('qywx_external_contact q', 'q.external_userid = e.external_userid');
MediaChannelService::applyFollowUsersChannelFilter($query, 'q.follow_users', $mediaChannel);
}
$rows = $query->select()->toArray();
return $userId !== '' && !isset($adminByUserId[$userId]);
}));
if ($rows === []) {
return [];
}
@@ -1838,36 +2174,17 @@ class ConversionLogic
?array $visibleAdminIds = null
): array
{
$query = Db::name('qywx_external_contact_event')
->alias('e')
->join('admin a', 'a.work_wechat_userid = e.user_id AND a.delete_time IS NULL')
->where('e.change_type', 'add_external_contact')
->where('e.event_time', 'between', [$startTimestamp, $endTimestamp])
->fieldRaw('a.id AS admin_id, a.name AS admin_name, COUNT(*) AS add_fans_count')
->group('a.id, a.name');
if ($visibleAdminIds !== null) {
if ($visibleAdminIds === []) {
// 与 HasDataScopeFilter::applyDataScopeByOwner 对齐:空集合用 0=1 闸门让 SQL 自然返回空。
$query->whereRaw('0 = 1');
} else {
$query->whereIn('a.id', $visibleAdminIds);
}
}
if ($mediaChannel !== null) {
$query->leftJoin('qywx_external_contact q', 'q.external_userid = e.external_userid');
MediaChannelService::applyFollowUsersChannelFilter($query, 'q.follow_users', $mediaChannel);
}
$rows = $query->select()->toArray();
if ($rows === []) {
$fanRows = self::loadFanRows($startTimestamp, $endTimestamp, $mediaChannel, $visibleAdminIds);
if ($fanRows === []) {
return [];
}
$adminByUserId = self::loadActiveAdminByWorkWechatUserIds(array_column($fanRows, 'user_id'));
$result = [];
foreach ($rows as $row) {
$adminId = (int)($row['admin_id'] ?? 0);
foreach ($fanRows as $row) {
$userId = (string)($row['user_id'] ?? '');
$admin = $adminByUserId[$userId] ?? null;
$adminId = (int)($admin['id'] ?? 0);
$addFansCount = (int)($row['add_fans_count'] ?? 0);
if ($adminId <= 0 || $addFansCount <= 0) {
continue;
@@ -1875,7 +2192,7 @@ class ConversionLogic
if (isset($assignedAdminIds[$adminId])) {
continue;
}
$name = trim((string)($row['admin_name'] ?? ''));
$name = trim((string)($admin['name'] ?? ''));
if ($name === '') {
$name = 'admin#' . $adminId;
}
@@ -1895,7 +2212,8 @@ class ConversionLogic
/**
* 反查企微员工 user_id(如 CaoTaDuo)对应的中文名。
* 来源依次:admin 表(含已软删的,避免离职后丢失映射)→ qywx_external_contact.follow_users JSON 中的 remark 字段。
* 来源:admin 表(含已软删的,避免离职后丢失映射)。未命中时直接展示原始 userid
* 避免仅为展示名称对十几万行 follow_users TEXT 做前导通配全表扫描。
*
* @param string[] $userIds
* @return array<string, string>
@@ -1924,61 +2242,6 @@ class ConversionLogic
$result[$userId] = $name;
}
$remaining = array_values(array_diff($userIds, array_keys($result)));
if ($remaining === []) {
return $result;
}
// 从 qywx_external_contact.follow_users JSON 的 remark/description 字段尽力反查。
$followRows = Db::name('qywx_external_contact')
->whereNull('delete_time')
->where('follow_users', 'like', '%' . $remaining[0] . '%')
->limit(0)
->field('follow_users')
->select()
->toArray();
if ($followRows === []) {
// 单条 LIKE 没命中再退化全表(量大时会慢,因此仅在极少数员工场景下兜底)。
$followRows = Db::name('qywx_external_contact')
->whereNull('delete_time')
->whereNotNull('follow_users')
->where('follow_users', '<>', '')
->limit(2000)
->field('follow_users')
->select()
->toArray();
}
$remainingMap = array_fill_keys($remaining, true);
foreach ($followRows as $row) {
if ($remainingMap === []) {
break;
}
$followUsers = json_decode((string)($row['follow_users'] ?? '[]'), true);
if (!is_array($followUsers)) {
continue;
}
foreach ($followUsers as $fu) {
if (!is_array($fu)) {
continue;
}
$uid = trim((string)($fu['userid'] ?? ''));
if ($uid === '' || !isset($remainingMap[$uid])) {
continue;
}
$name = trim((string)($fu['remark_corp_name'] ?? ''));
if ($name === '') {
$name = trim((string)($fu['remark'] ?? ''));
}
if ($name === '') {
$name = trim((string)($fu['description'] ?? ''));
}
if ($name !== '') {
$result[$uid] = $name;
unset($remainingMap[$uid]);
}
}
}
return $result;
}
@@ -2469,6 +2732,42 @@ class ConversionLogic
return round($numerator / $denominator, 2);
}
/**
* @param array<int, mixed> $parts
*/
private static function requestRowsCacheKey(string $namespace, array $parts): string
{
return $namespace . ':' . hash('sha256', serialize($parts));
}
/**
* @param array<int, mixed> $parts
* @param callable(): array<int, array<string, mixed>> $loader
* @return array<int, array<string, mixed>>
*/
private static function cachedRequestRows(string $namespace, array $parts, callable $loader): array
{
$cacheKey = self::requestRowsCacheKey($namespace, $parts);
if (!isset(self::$requestRowsCache[$cacheKey])) {
self::$requestRowsCache[$cacheKey] = $loader();
}
return self::$requestRowsCache[$cacheKey];
}
/**
* @param array<string, mixed>|null $mediaChannel
* @return array{code: string, tag_id: string, tag_name: string}
*/
private static function mediaChannelCacheKey(?array $mediaChannel): array
{
return [
'code' => (string)($mediaChannel['channel_code'] ?? ''),
'tag_id' => (string)($mediaChannel['source_tag_id'] ?? ''),
'tag_name' => (string)($mediaChannel['source_tag_name'] ?? ''),
];
}
/**
* @param int[]|null $visibleAdminIds
* @param int[] $eligibleDeptIds
@@ -433,7 +433,7 @@ class DoctorDailyStatsLogic
->join('tcm_prescription rx', 'rx.id = o.prescription_id AND rx.delete_time IS NULL', 'INNER')
->whereNull('o.delete_time')
->whereBetween('o.create_time', [$startTs, $endTs]);
YejiStatsLogic::applyPrescriptionOrderNotCancelledForPerformanceQuery($q, 'o');
YejiStatsLogic::applyPrescriptionOrderEffectiveAmountQuery($q, 'o');
$q->whereIn('rx.creator_id', $doctorIds)
->where('o.diagnosis_id', '>', 0);
@@ -18,7 +18,7 @@ use think\facade\Db;
* 数据口径:
* - 所有“业绩/接诊诊单”与业绩统计、业务订单列表保持一致:按业务订单创建时间,
* 排除履约已取消/拒收/退款(4/9/10),金额取业务订单 amount,归属人取订单 creator_id。
* - 今日加粉、挂号、面诊和转化率继续沿用 ConversionLogic它们不是业绩指标
* - 今日预约、面诊沿用 ConversionLogic挂号按已支付且实收低于 10 元的订单统计
* - 趋势使用同一业绩条件的轻量按日 SQL,固定补齐最近 7 个自然日。
* - 所有查询都使用 DataScopeService 返回的可见管理员集合收窄。
*/
@@ -29,7 +29,7 @@ class PerformanceDashboardLogic
/**
* @return array<string, mixed>
*/
public static function overview(int $adminId, array $adminInfo): array
public static function overview(int $adminId, array $adminInfo, array $params = []): array
{
$today = date('Y-m-d');
$yesterday = date('Y-m-d', strtotime('-1 day'));
@@ -48,9 +48,15 @@ class PerformanceDashboardLogic
/** @var array<int>|null $visibleAdminIds */
$visibleAdminIds = $scope['_visible_admin_ids'];
unset($scope['_visible_admin_ids']);
$rankingDeptId = self::resolveRankingDeptId(
max(0, (int) ($params['ranking_dept_id'] ?? 0)),
$adminId,
$adminInfo
);
$orderDaily = self::loadPerformanceOrderDaily($previousMonthStart, $today, $visibleAdminIds);
$personalOrderDaily = self::loadPerformanceOrderDaily($monthStart, $today, [$adminId]);
$registrationDaily = self::loadRegistrationDaily($trendStart, $today, $visibleAdminIds);
$monthAmount = self::sumDailyMetric($orderDaily, $monthStart, $today, 'amount');
$previousMonthAmount = self::sumDailyMetric(
@@ -68,6 +74,7 @@ class PerformanceDashboardLogic
'time_type' => 'today',
'include_members' => 0,
'include_filters' => 0,
'exclude_cancelled_appointments' => 1,
'page_no' => 1,
'page_size' => 100,
], $adminId, $adminInfo);
@@ -77,6 +84,7 @@ class PerformanceDashboardLogic
'time_type' => 'yesterday',
'include_members' => 0,
'include_filters' => 0,
'exclude_cancelled_appointments' => 1,
'page_no' => 1,
'page_size' => 100,
], $adminId, $adminInfo);
@@ -89,7 +97,13 @@ class PerformanceDashboardLogic
'start_date' => $today,
'end_date' => $today,
], $adminId, $adminInfo);
$appointmentRanking = self::buildAppointmentRanking($adminId, $adminInfo, $scope);
$appointmentRanking = self::buildRegistrationRanking(
$adminId,
$adminInfo,
$scope,
$visibleAdminIds,
$rankingDeptId
);
$performanceRanking = self::buildPerformanceRanking(
is_array($todayPerformanceOverview['rows'] ?? null) ? $todayPerformanceOverview['rows'] : []
);
@@ -97,7 +111,14 @@ class PerformanceDashboardLogic
'start_date' => $trendStart,
'end_date' => $today,
], $adminId, $adminInfo);
$trend = self::buildTrend($trendStart, $today, $visibleAdminIds, $orderDaily, $trendContext);
$trend = self::buildTrend(
$trendStart,
$today,
$visibleAdminIds,
$orderDaily,
$registrationDaily,
$trendContext
);
$todayTrendIndex = max(0, count($trend['dates'] ?? []) - 1);
$yesterdayTrendIndex = max(0, $todayTrendIndex - 1);
@@ -111,10 +132,15 @@ class PerformanceDashboardLogic
$yesterdayOrderCount = (int) self::dailyMetric($orderDaily, $yesterday, 'count');
$todayOrderAmount = self::dailyMetric($orderDaily, $today, 'amount');
$yesterdayOrderAmount = self::dailyMetric($orderDaily, $yesterday, 'amount');
$todayPaidAppointmentRate = round((float) ($todaySummary['paid_appointment_rate'] ?? 0), 2);
$yesterdayPaidAppointmentRate = round((float) ($yesterdaySummary['paid_appointment_rate'] ?? 0), 2);
$todayInterviewReceiveRate = round((float) ($todaySummary['interview_receive_rate'] ?? 0), 2);
$yesterdayInterviewReceiveRate = round((float) ($yesterdaySummary['interview_receive_rate'] ?? 0), 2);
$todayLowAmountPaymentCount = (int) self::dailyMetric($registrationDaily, $today, 'count');
$yesterdayLowAmountPaymentCount = (int) self::dailyMetric($registrationDaily, $yesterday, 'count');
$todayPaidAppointmentCount = $todayLowAmountPaymentCount;
$yesterdayPaidAppointmentCount = $yesterdayLowAmountPaymentCount;
$todayPaidAppointmentRate = self::percent($todayPaidAppointmentCount, $todayAddFansCount);
$yesterdayPaidAppointmentRate = self::percent($yesterdayPaidAppointmentCount, $yesterdayAddFansCount);
// 接诊卡片使用的是有效业务订单,接诊率必须使用同一订单口径,不能继续读取旧的双审完成单。
$todayInterviewReceiveRate = self::percent($todayOrderCount, $todayInterviewCount);
$yesterdayInterviewReceiveRate = self::percent($yesterdayOrderCount, $yesterdayInterviewCount);
$target = self::buildTargetProgress(
$adminId,
@@ -130,6 +156,9 @@ class PerformanceDashboardLogic
'month_amount' => round($monthAmount, 2),
'month_compare_rate' => self::relativeChange($monthAmount, $previousMonthAmount),
'month_compare_label' => '较上月同期',
'today_amount' => round($todayOrderAmount, 2),
'today_compare_rate' => self::relativeChange($todayOrderAmount, $yesterdayOrderAmount),
'today_compare_label' => '较昨日',
'yesterday_amount' => round($yesterdayAmount, 2),
'yesterday_compare_rate' => self::relativeChange($yesterdayAmount, $dayBeforeAmount),
'yesterday_compare_label' => '较前一日',
@@ -138,10 +167,12 @@ class PerformanceDashboardLogic
'today' => [
'add_fans_count' => $todayAddFansCount,
'appointment_total_count' => $todayAppointmentCount,
'low_amount_payment_count' => $todayLowAmountPaymentCount,
'interview_count' => $todayInterviewCount,
// 保留原响应字段名以兼容已发布前端,数值含义已统一为“计入业绩的业务订单”。
'completed_order_count' => $todayOrderCount,
'completed_order_amount' => $todayOrderAmount,
'paid_appointment_count' => $todayPaidAppointmentCount,
'paid_appointment_rate' => $todayPaidAppointmentRate,
'interview_receive_rate' => $todayInterviewReceiveRate,
'comparisons' => [
@@ -150,6 +181,10 @@ class PerformanceDashboardLogic
$todayAppointmentCount,
$yesterdayAppointmentCount
),
'low_amount_payment_count' => self::buildComparison(
$todayLowAmountPaymentCount,
$yesterdayLowAmountPaymentCount
),
'interview_count' => self::buildComparison($todayInterviewCount, $yesterdayInterviewCount),
'completed_order_count' => self::buildComparison($todayOrderCount, $yesterdayOrderCount),
'completed_order_amount' => self::buildComparison($todayOrderAmount, $yesterdayOrderAmount),
@@ -171,13 +206,17 @@ class PerformanceDashboardLogic
'items' => $performanceRanking,
],
],
'filters' => [
'ranking_departments' => self::rankingDepartmentOptions($adminId, $adminInfo),
'ranking_dept_id' => $rankingDeptId,
],
'trend' => $trend,
'target' => $target,
'meta' => [
'generated_at' => date('Y-m-d H:i:s'),
'timezone' => date_default_timezone_get(),
'commission_note' => '本人业绩按当前账号创建的业务订单统计,排除已取消、拒收和退款订单。',
'rate_note' => '近 7 天趋势与业绩统计一致:挂号排除已取消记录,进线仅统计当前范围内可归属业绩中心的新增客户事件,诊单按订单创建时间统计并排除履约 4/9/10。',
'rate_note' => '挂号及挂号率:按支付时间统计已支付且 0<实收金额<10 元的订单,每笔订单计 1 个挂号,并按订单创建人归属;预约按预约日期统计有效预约记录。接诊率:有效业务诊单数 / 已完成面诊数。',
],
];
}
@@ -241,6 +280,30 @@ class PerformanceDashboardLogic
];
}
private static function resolveRankingDeptId(int $requestedDeptId, int $adminId, array $adminInfo): int
{
if ($requestedDeptId <= 0) {
return 0;
}
$exists = Dept::where('id', $requestedDeptId)->whereNull('delete_time')->count() > 0;
if (!$exists) {
return 0;
}
$allowedDeptSet = DataScopeService::getAllowedDeptIdSet($adminId, $adminInfo);
if ($allowedDeptSet !== null && !isset($allowedDeptSet[$requestedDeptId])) {
return 0;
}
return $requestedDeptId;
}
/** @return array<int, array<string, mixed>> */
private static function rankingDepartmentOptions(int $adminId, array $adminInfo): array
{
return DeptLogic::getAllDataScoped($adminId, $adminInfo);
}
/**
* @param array<int>|null $visibleAdminIds
* @return array<string, array{amount: float, count: int}>
@@ -286,6 +349,53 @@ class PerformanceDashboardLogic
return $out;
}
/**
* 0 < 实收金额 < 10 元的已支付订单笔数;退款订单状态为 4,不会进入统计。
*
* @param array<int>|null $visibleAdminIds
* @return array<string, array{count:int}>
*/
private static function loadRegistrationDaily(
string $startDate,
string $endDate,
?array $visibleAdminIds
): array {
if ($visibleAdminIds === []) {
return [];
}
$query = Db::name('order')
->whereNull('delete_time')
->where('status', 2)
->where('amount', '>', 0)
->where('amount', '<', 10)
// payment_time 是 DATETIME NULLMySQL 8 严格模式下不能与空字符串比较。
->whereNotNull('payment_time')
->whereBetweenTime(
'payment_time',
$startDate . ' 00:00:00',
$endDate . ' 23:59:59'
);
if ($visibleAdminIds !== null) {
$query->whereIn('creator_id', $visibleAdminIds);
}
$rows = $query
->fieldRaw("DATE(payment_time) AS date_label, COUNT(*) AS item_count")
->group('date_label')
->select()
->toArray();
$out = [];
foreach ($rows as $row) {
$date = (string) ($row['date_label'] ?? '');
if ($date !== '') {
$out[$date] = ['count' => (int) ($row['item_count'] ?? 0)];
}
}
return $out;
}
/**
* @param array<string, array{amount: float, count: int}> $daily
*/
@@ -310,6 +420,15 @@ class PerformanceDashboardLogic
return round((float) ($daily[$date][$metric] ?? 0), 2);
}
private static function percent(float $numerator, int $denominator): float
{
if ($denominator <= 0) {
return 0.0;
}
return round(($numerator / $denominator) * 100, 2);
}
private static function relativeChange(float $current, float $previous): ?float
{
if (abs($previous) < 0.00001) {
@@ -340,41 +459,20 @@ class PerformanceDashboardLogic
* @param array<string, mixed> $scope
* @return array<string, mixed>
*/
private static function buildAppointmentRanking(int $adminId, array $adminInfo, array $scope): array
private static function buildRegistrationRanking(
int $adminId,
array $adminInfo,
array $scope,
?array $baseVisibleAdminIds,
int $rankingDeptId
): array
{
$roleIds = array_map('intval', $scope['role_ids'] ?? []);
$isDoctorSelf = ($scope['key'] ?? '') === 'self'
&& in_array(1, $roleIds, true)
&& !in_array(2, $roleIds, true);
if ($isDoctorSelf) {
$doctorStats = DoctorDailyStatsLogic::overview([
'start_date' => date('Y-m-d'),
'end_date' => date('Y-m-d'),
], $adminId, $adminInfo);
$items = [];
foreach (array_slice($doctorStats['rows'] ?? [], 0, 5) as $row) {
$items[] = [
'id' => (int) ($row['admin_id'] ?? 0),
'name' => (string) ($row['doctor_name'] ?? ''),
// DoctorDailyStats 的 total 含已取消;驾驶舱实时排行只统计有效挂号。
'count' => max(
0,
(int) ($row['appointment_total'] ?? 0) - (int) ($row['appointment_cancelled'] ?? 0)
),
'amount' => round((float) ($row['deal_amount'] ?? 0), 2),
];
}
return [
'title' => '实时挂号排行',
'kind' => 'doctor',
'scope_label' => (string) ($scope['label'] ?? ''),
'items' => $items,
];
}
$rankingVisibleAdminIds = null;
$rankingVisibleAdminIds = $baseVisibleAdminIds;
$rankingScopeLabel = (string) ($scope['label'] ?? '');
if (
(int) ($scope['scope_value'] ?? DataScopeService::SCOPE_SELF) === DataScopeService::SCOPE_SELF
@@ -386,48 +484,72 @@ class PerformanceDashboardLogic
$rankingScopeLabel = '本人所属部门';
}
}
$assistantStats = ConversionLogic::overview([
'dimension' => 'assistant',
'time_type' => 'today',
'include_filters' => 0,
'exclude_cancelled_appointments' => 1,
'page_no' => 1,
'page_size' => $rankingVisibleAdminIds !== null ? max(1, count($rankingVisibleAdminIds)) : 100,
], $adminId, $adminInfo, $rankingVisibleAdminIds);
$rows = is_array($assistantStats['lists'] ?? null) ? $assistantStats['lists'] : [];
usort($rows, static function (array $a, array $b): int {
$byAppointment = (int) ($b['appointment_total_count'] ?? 0) <=> (int) ($a['appointment_total_count'] ?? 0);
if ($byAppointment !== 0) {
return $byAppointment;
}
$byAmount = (float) ($b['completed_order_amount'] ?? 0) <=> (float) ($a['completed_order_amount'] ?? 0);
if ($byAmount !== 0) {
return $byAmount;
}
return (int) ($a['id'] ?? 0) <=> (int) ($b['id'] ?? 0);
});
if ($rankingDeptId > 0) {
$deptAdminIds = self::departmentAdminIds($rankingDeptId);
$rankingVisibleAdminIds = $rankingVisibleAdminIds === null
? $deptAdminIds
: array_values(array_intersect($rankingVisibleAdminIds, $deptAdminIds));
$rankingScopeLabel = (string) (Dept::where('id', $rankingDeptId)->value('name') ?? $rankingScopeLabel);
}
$items = [];
foreach (array_slice($rows, 0, 5) as $row) {
$items[] = [
'id' => (int) ($row['id'] ?? 0),
'name' => (string) ($row['name'] ?? ''),
'count' => (int) ($row['appointment_total_count'] ?? 0),
'amount' => round((float) ($row['completed_order_amount'] ?? 0), 2),
];
if ($rankingVisibleAdminIds !== []) {
$query = Db::name('order')
->alias('o')
->join('admin a', 'a.id = o.creator_id AND a.delete_time IS NULL', 'INNER')
->whereNull('o.delete_time')
->where('o.status', 2)
->where('o.amount', '>', 0)
->where('o.amount', '<', 10)
// payment_time 是 DATETIME NULLMySQL 8 严格模式下不能与空字符串比较。
->whereNotNull('o.payment_time')
->whereBetweenTime('o.payment_time', date('Y-m-d 00:00:00'), date('Y-m-d 23:59:59'));
if ($rankingVisibleAdminIds !== null) {
$query->whereIn('o.creator_id', $rankingVisibleAdminIds);
}
$rows = $query
->fieldRaw('o.creator_id AS id, a.name, COUNT(*) AS item_count, SUM(o.amount) AS amount_sum')
->group(['o.creator_id', 'a.name'])
->orderRaw('item_count DESC, amount_sum DESC, o.creator_id ASC')
->limit(5)
->select()
->toArray();
foreach ($rows as $row) {
$items[] = [
'id' => (int) ($row['id'] ?? 0),
'name' => (string) ($row['name'] ?? ''),
'count' => (int) ($row['item_count'] ?? 0),
'amount' => round((float) ($row['amount_sum'] ?? 0), 2),
];
}
}
return [
'title' => '实时挂号排行',
'kind' => 'assistant',
'kind' => $isDoctorSelf ? 'doctor' : 'member',
'scope_label' => $rankingScopeLabel,
'items' => $items,
];
}
/** @return int[] */
private static function departmentAdminIds(int $deptId): array
{
$deptIds = array_values(array_unique(array_filter(
array_map('intval', DeptLogic::getSelfAndDescendantIds($deptId)),
static fn (int $id): bool => $id > 0
)));
if ($deptIds === []) {
return [];
}
return array_values(array_unique(array_filter(
array_map('intval', AdminDept::whereIn('dept_id', $deptIds)->column('admin_id')),
static fn (int $id): bool => $id > 0
)));
}
/**
* SELF 医助排行的卡片级例外:只扩展到当前账号所有有效直接部门内的有效医助。
* 不展开子部门,也不改变驾驶舱其它指标的数据范围。
@@ -512,6 +634,7 @@ class PerformanceDashboardLogic
/**
* @param array<int>|null $visibleAdminIds
* @param array<string, array{amount: float, count: int}> $orderDaily
* @param array<string, array{count: int}> $registrationDaily
* @param array<string, mixed> $trendContext
* @return array<string, mixed>
*/
@@ -520,6 +643,7 @@ class PerformanceDashboardLogic
string $endDate,
?array $visibleAdminIds,
array $orderDaily,
array $registrationDaily,
array $trendContext
): array {
$adminToPrimary = is_array($trendContext['adminToPrimary'] ?? null)
@@ -537,6 +661,7 @@ class PerformanceDashboardLogic
$tableRowDeptIds
);
$dates = [];
$registrations = [];
$appointments = [];
$leads = [];
$orders = [];
@@ -546,6 +671,7 @@ class PerformanceDashboardLogic
while ($cursor <= $end) {
$date = date('Y-m-d', $cursor);
$dates[] = date('m-d', $cursor);
$registrations[] = (int) ($registrationDaily[$date]['count'] ?? 0);
$appointments[] = (int) ($appointmentDaily[$date] ?? 0);
$leads[] = (int) ($leadDaily[$date] ?? 0);
$orders[] = (int) ($orderDaily[$date]['count'] ?? 0);
@@ -555,6 +681,7 @@ class PerformanceDashboardLogic
return [
'date_range' => [$startDate, $endDate],
'dates' => $dates,
'registrations' => $registrations,
'appointments' => $appointments,
'leads' => $leads,
'orders' => $orders,
@@ -3893,6 +3893,18 @@ class YejiStatsLogic
}
}
/**
* 有效金额统计:在业绩履约状态口径上,再排除任何已发生退款(含部分退款)的订单。
*
* @param \think\db\BaseQuery|\think\Model $query
*/
public static function applyPrescriptionOrderEffectiveAmountQuery($query, string $tableAlias = ''): void
{
self::applyPrescriptionOrderNotCancelledForPerformanceQuery($query, $tableAlias);
$refundField = $tableAlias !== '' ? "{$tableAlias}.refund_amount" : 'refund_amount';
$query->whereRaw("({$refundField} IS NULL OR {$refundField} <= 0)");
}
/**
* @param \think\db\BaseQuery|\think\Model $query
*/
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace app\api\controller;
use app\adminapi\logic\qywx\CustomerLogic;
use app\common\service\qywx\QywxCustomerAcquisitionCustomerService;
use EasyWeChat\Kernel\Exceptions\BadRequestException;
use EasyWeChat\Work\Application;
use EasyWeChat\Work\Message;
@@ -17,7 +18,7 @@ use think\facade\Log;
*
* ⚠️ 关于"员工↔客户消息内容"接收:
* 企业微信 **不会** 通过本回调推送客户与员工之间真实的聊天消息内容,
* 这里只处理"添加 / 编辑 / 删除客户"等业务事件(change_external_contact 变体)
* 这里只处理客户关系事件,以及 customer_acquisition 回调中的累计收消息次数;不保存消息正文
* 实时消息接收走「会话内容存档」独立通道:
* - 命令: php think qywx:sync-msg-archive
* - 服务: app\common\service\wechat\QywxMsgArchiveService
@@ -33,15 +34,18 @@ class QywxExternalContactCallbackController extends BaseApiController
{
$corpId = (string) config('pay.wechat_work.corp_id', '');
$customerSecret = (string) config('pay.wechat_work.customer_contact_secret', '');
$acquisitionSecret = (string) config('qywx_customer_acquisition.secret', '');
$payContactSecret = (string) config('pay.wechat_work.external_pay_secret', '');
// 客户联系回调验签需用「接收事件服务器」所属应用的 Secret,优先使用 customer_contact_secret
// 缺省回退到 external_pay_secret 保持向后兼容(同一应用同时具备两类权限的旧部署可继续工作)。
$secret = $customerSecret !== '' ? $customerSecret : $payContactSecret;
$secret = $customerSecret !== ''
? $customerSecret
: ($acquisitionSecret !== '' ? $acquisitionSecret : $payContactSecret);
$token = (string) config('pay.wechat_work.contact_callback_token', '');
$aesKey = (string) config('pay.wechat_work.contact_callback_aes_key', '');
if ($corpId === '' || $secret === '' || $token === '' || $aesKey === '') {
Log::error('qywx external contact callback: 缺少配置 corp_id / customer_contact_secret(或 external_pay_secret) / contact_callback_token / contact_callback_aes_key');
Log::error('qywx external contact callback: 缺少配置 corp_id / customer_contact_secret(或获客助手应用 secret) / contact_callback_token / contact_callback_aes_key');
return response('config error', 503, ['Content-Type' => 'text/plain; charset=utf-8']);
}
@@ -67,6 +71,17 @@ class QywxExternalContactCallbackController extends BaseApiController
return $next($message);
});
$server->addEventListener('customer_acquisition', function (Message $message, \Closure $next) {
try {
(new QywxCustomerAcquisitionCustomerService())->handleCallback($message->toArray());
} catch (\Throwable $e) {
// 服务已将失败事件与 next_retry 落库,定时命令会在 ChatKey 30 分钟内继续重试。
Log::error('qywx customer acquisition callback: ' . $e->getMessage(), ['exception' => $e]);
}
return $next($message);
});
$psr = $server->serve();
$body = $psr->getBody();
$body->rewind();
@@ -4,41 +4,30 @@ declare(strict_types=1);
namespace app\api\controller;
use app\BaseController;
use app\common\service\qywx\QywxPromotionOpenWorkService;
use app\common\service\qywx\QywxPromotionRedirectService;
use think\facade\Log;
use app\common\service\qywx\QywxPromotionWidgetService;
/** 企业微信推广公开端点:服务商回调、授权回跳、JS 与随机跳转。 */
class QywxPromotionPublicController extends BaseController
/** 企业微信获客助手公开端点:JS 与随机跳转。 */
class QywxPromotionPublicController extends BaseApiController
{
/** 公开安装代码与随机跳转不依赖前台用户登录。 */
public array $notNeedLogin = ['script', 'redirect'];
public function script(string $key)
{
if (!QywxPromotionRedirectService::poolExists($key)) {
$pool = QywxPromotionRedirectService::publicPoolConfig($key);
if ($pool === null) {
return response('/* promotion pool not found */', 404, ['Content-Type' => 'application/javascript; charset=utf-8']);
}
$goUrl = rtrim($this->request->domain(), '/') . '/api/qywx-promotion/go/' . $key;
$jsonKey = json_encode($key, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
$jsonGo = json_encode($goUrl, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
$javascript = <<<JS
(function(w,d){
'use strict';
var key={$jsonKey}, go={$jsonGo};
function openPromotion(){
var source=w.location.href;
w.location.assign(go+'?from='+encodeURIComponent(source));
}
d.addEventListener('click',function(event){
var node=event.target&&event.target.closest?event.target.closest('[data-wecom-promotion="'+key+'"],.wecom-promotion-link[data-pool="'+key+'"]'):null;
if(!node){return;}
event.preventDefault();
event.stopPropagation();
openPromotion();
},true);
w.WecomPromotion=w.WecomPromotion||{};
w.WecomPromotion[key]={open:openPromotion};
})(window,document);
JS;
// 由安装脚本自身的 src 解析 API 域名,避免把请求 Host 写入可公开缓存的 JavaScript。
$goUrl = '/api/qywx-promotion/go/' . $key;
$config = QywxPromotionWidgetService::decode($pool['widget_config_json'] ?? null);
$javascript = QywxPromotionWidgetService::renderScript(
$key,
$goUrl,
$config,
(int) ($pool['status'] ?? 0) === 1
);
return response($javascript, 200, [
'Content-Type' => 'application/javascript; charset=utf-8',
@@ -56,7 +45,7 @@ JS;
'ip' => (string) $this->request->ip(),
]);
if (!$picked) {
return response('当前暂无可用的企业微信推广链接,请稍后再试。', 503, [
return response('当前暂无可用的企业微信获客助手链接,请稍后再试。', 503, [
'Content-Type' => 'text/plain; charset=utf-8',
'Cache-Control' => 'no-store',
]);
@@ -68,40 +57,4 @@ JS;
]);
}
public function providerCallback()
{
try {
$psr = QywxPromotionOpenWorkService::serveProviderCallback();
$body = $psr->getBody();
$body->rewind();
$headers = [];
if ($psr->getHeaderLine('Content-Type') !== '') {
$headers['Content-Type'] = $psr->getHeaderLine('Content-Type');
}
return response($body->getContents(), $psr->getStatusCode(), $headers);
} catch (\Throwable $e) {
Log::error('企微推广服务商回调失败:' . $e->getMessage(), ['exception' => $e]);
return response('error', 500, ['Content-Type' => 'text/plain; charset=utf-8']);
}
}
public function authCallback()
{
$fallback = rtrim($this->request->domain(), '/') . '/admin/first_visit/wecom_promotion';
$returnUrl = QywxPromotionOpenWorkService::configuredAdminReturnUrl($fallback);
try {
$result = QywxPromotionOpenWorkService::consumeAuthorizationCallback(
trim((string) $this->request->get('auth_code', '')),
trim((string) $this->request->get('state', ''))
);
$query = ['wecom_auth' => 'success', 'account_id' => (int) $result['id']];
} catch (\Throwable $e) {
Log::error('企微推广授权回跳失败:' . $e->getMessage(), ['exception' => $e]);
$query = ['wecom_auth' => 'failed', 'message' => mb_substr($e->getMessage(), 0, 160)];
}
return redirect($returnUrl . (str_contains($returnUrl, '?') ? '&' : '?') . http_build_query($query), 302);
}
}
+1 -3
View File
@@ -11,8 +11,6 @@ use think\facade\Route;
Route::rule('qywx/external-contact/notify', 'QywxExternalContactCallback/notify', 'GET|POST');
Route::post('ej-pharmacy/webhook', 'EjPharmacyCallback/webhook');
// 企业微信推广助手:服务商应用指令、授权回跳、公开 JS 与随机分流。
Route::rule('qywx-promotion/provider/callback', 'QywxPromotionPublic/providerCallback', 'GET|POST');
Route::get('qywx-promotion/auth/callback', 'QywxPromotionPublic/authCallback');
// 企业微信内部应用推广助手:公开 JS 与服务端随机分流。
Route::get('qywx-promotion/js/:key', 'QywxPromotionPublic/script');
Route::get('qywx-promotion/go/:key', 'QywxPromotionPublic/redirect');
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace app\command;
use app\common\service\qywx\QywxCustomerAcquisitionCustomerService;
use think\console\Command;
use think\console\Input;
use think\console\Output;
/** 每分钟重试获客助手 message_from_customer/customer_start_chat 回调。 */
class QywxRetryCustomerAcquisitionEvents extends Command
{
protected function configure()
{
$this->setName('qywx:retry-customer-acquisition-events')
->setDescription('重试 30 分钟有效期内失败的企业微信获客会话回调');
}
protected function execute(Input $input, Output $output): int
{
$result = (new QywxCustomerAcquisitionCustomerService())->retryPending(100);
$output->writeln(sprintf(
'QYWX_CUSTOMER_ACQUISITION_RETRY selected=%d success=%d failed=%d expired=%d',
$result['selected'],
$result['success'],
$result['failed'],
$result['expired']
));
return 0;
}
}
@@ -25,6 +25,9 @@ use think\Exception;
*/
class ControllerExtendException extends Exception
{
/** PHP 8.2 不再允许通过赋值隐式创建动态属性。 */
protected string $model = '';
/**
* 构造方法
* @access public
@@ -37,4 +40,4 @@ class ControllerExtendException extends Exception
$this->message = '控制器需要继承模块的基础控制器:' . $message;
$this->model = $model;
}
}
}
@@ -173,6 +173,53 @@ class MediaChannelService
$query->whereRaw('(' . implode(' OR ', $segments) . ')', $bindings);
}
/**
* Filter a fact table by its external_userid without joining the denormalized
* contact rows. The contact table may contain several rows for one customer;
* a normal JOIN therefore both scans follow_users TEXT repeatedly and
* multiplies facts. Enterprise tag channels use the normalized relation
* table, while legacy name-only channels keep a deduplicated JSON fallback.
*
* @param array<string, mixed>|null $channel
*/
public static function applyExternalUserChannelFilter(Query $query, string $field, ?array $channel): void
{
if ($channel === null) {
return;
}
$tagId = trim((string) ($channel['source_tag_id'] ?? ''));
if ($tagId !== '') {
$tagTable = self::tableWithPrefix('qywx_external_contact_tag');
$query->whereRaw(
"{$field} IN (SELECT channel_tag.external_userid FROM {$tagTable} channel_tag WHERE channel_tag.tag_id = ?)",
[$tagId]
);
return;
}
$patterns = self::buildLikePatterns($channel);
if ($patterns === []) {
$query->whereRaw('1 = 0');
return;
}
$segments = [];
$bindings = [];
foreach ($patterns as $pattern) {
$segments[] = 'channel_contact.follow_users LIKE ?';
$bindings[] = $pattern;
}
$contactTable = self::tableWithPrefix('qywx_external_contact');
$query->whereRaw(
"{$field} IN (SELECT channel_contact.external_userid FROM {$contactTable} channel_contact"
. ' WHERE channel_contact.delete_time IS NULL AND (' . implode(' OR ', $segments) . '))',
$bindings
);
}
/**
* @return array{scanned_contacts: int, discovered_tags: int, inserted_or_updated: int}
*/
@@ -275,6 +322,13 @@ class MediaChannelService
return array_values(array_unique($patterns));
}
private static function tableWithPrefix(string $table): string
{
$prefix = (string) (Db::getConfig('connections.mysql.prefix') ?: 'zyt_');
return $prefix . $table;
}
/**
* @param array<int, mixed> $followUsers
* @return array<int, array{source_tag_id: string, source_tag_name: string, source_group_name: string}>
@@ -0,0 +1,249 @@
<?php
declare(strict_types=1);
namespace app\common\service\qywx;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
use RuntimeException;
use think\facade\Cache;
/**
* 企业微信内部应用获客链接 API。
*
* @see https://developer.work.weixin.qq.com/document/path/97297
*/
class QywxCustomerAcquisitionApiService
{
private const TOKEN_INVALID_CODES = [40001, 40014, 42001];
private string $corpId;
private string $secret;
private Client $client;
/** @var null|callable():string */
private $accessTokenResolver;
/** @param null|callable():string $accessTokenResolver 仅用于测试或托管 token 场景。 */
public function __construct(?Client $client = null, ?callable $accessTokenResolver = null)
{
$this->corpId = trim((string) config('qywx_customer_acquisition.corp_id', ''));
$this->secret = trim((string) config('qywx_customer_acquisition.secret', ''));
$this->client = $client ?? new Client([
'base_uri' => rtrim((string) config('qywx_customer_acquisition.base_uri', 'https://qyapi.weixin.qq.com'), '/') . '/',
'timeout' => max(5, (int) config('qywx_customer_acquisition.timeout', 20)),
'connect_timeout' => 8,
'http_errors' => false,
'verify' => config('qywx_customer_acquisition.verify', true),
'headers' => ['Accept' => 'application/json'],
]);
$this->accessTokenResolver = $accessTokenResolver;
}
/** @return array{configured:bool,missing:list<string>} */
public static function configurationStatus(): array
{
$missing = [];
if (trim((string) config('qywx_customer_acquisition.corp_id', '')) === '') {
$missing[] = 'work_wechat.corp_id';
}
if (trim((string) config('qywx_customer_acquisition.secret', '')) === '') {
$missing[] = 'work_wechat.customer_acquisition_secret / secret';
}
return ['configured' => $missing === [], 'missing' => $missing];
}
/** @return array{link_id_list:list<string>,next_cursor:string} */
public function listLinks(string $cursor = '', int $limit = 100): array
{
$body = ['limit' => min(100, max(1, $limit))];
if ($cursor !== '') {
$body['cursor'] = $cursor;
}
$response = $this->request('POST', 'cgi-bin/externalcontact/customer_acquisition/list_link', $body);
return [
'link_id_list' => array_values(array_filter(array_map('strval', (array) ($response['link_id_list'] ?? [])))),
'next_cursor' => trim((string) ($response['next_cursor'] ?? '')),
];
}
/** @return array<string,mixed> */
public function getLink(string $linkId): array
{
$this->assertLinkId($linkId);
return $this->request('POST', 'cgi-bin/externalcontact/customer_acquisition/get', ['link_id' => $linkId]);
}
/** @return array<string,mixed> */
public function createLink(array $payload): array
{
return $this->request('POST', 'cgi-bin/externalcontact/customer_acquisition/create_link', $payload);
}
/** @return array<string,mixed> */
public function updateLink(array $payload): array
{
$this->assertLinkId((string) ($payload['link_id'] ?? ''));
return $this->request('POST', 'cgi-bin/externalcontact/customer_acquisition/update_link', $payload);
}
public function deleteLink(string $linkId): void
{
$this->assertLinkId($linkId);
$this->request('POST', 'cgi-bin/externalcontact/customer_acquisition/delete_link', ['link_id' => $linkId]);
}
/**
* 获取指定获客链接添加的客户。单页最多 1000 条。
*
* @return array{customer_list:list<array<string,mixed>>,next_cursor:string}
*/
public function listCustomers(string $linkId, string $cursor = '', int $limit = 1000): array
{
$this->assertLinkId($linkId);
$body = [
'link_id' => $linkId,
'limit' => min(1000, max(1, $limit)),
];
if ($cursor !== '') {
$body['cursor'] = $cursor;
}
$response = $this->request('POST', 'cgi-bin/externalcontact/customer_acquisition/customer', $body);
$customers = array_values(array_filter(
(array) ($response['customer_list'] ?? []),
static fn (mixed $row): bool => is_array($row)
));
return [
'customer_list' => $customers,
'next_cursor' => trim((string) ($response['next_cursor'] ?? '')),
];
}
/** @return array<string,mixed> */
public function getChatInfo(string $chatKey): array
{
$chatKey = trim($chatKey);
if ($chatKey === '' || strlen($chatKey) > 512) {
throw new RuntimeException('获客会话 ChatKey 不正确');
}
return $this->request(
'POST',
'cgi-bin/externalcontact/customer_acquisition/get_chat_info',
['chat_key' => $chatKey]
);
}
/** 通过只读列表接口验证 token、可信 IP、获客助手开通状态与应用权限。 */
public function checkPermission(): array
{
$result = $this->listLinks('', 1);
$hasLink = $result['link_id_list'] !== [];
return [
'ok' => true,
'message' => $hasLink
? '获客助手 API 权限验证通过,当前应用已有官方获客链接'
: '获客助手 API 权限验证通过,但当前应用尚未通过 API 创建官方获客链接',
'has_link' => $hasLink,
];
}
/** @return array<string,mixed> */
private function request(string $method, string $path, array $body = [], bool $retried = false): array
{
$this->assertConfigured();
$cacheKey = $this->tokenCacheKey();
$token = $this->accessToken();
try {
$options = ['query' => ['access_token' => $token]];
if (strtoupper($method) === 'POST') {
$options['json'] = $body;
}
$response = $this->client->request($method, ltrim($path, '/'), $options);
} catch (GuzzleException $e) {
throw new RuntimeException('企业微信获客助手接口连接失败,请检查服务器网络与可信 IP 配置', 0, $e);
}
$decoded = json_decode((string) $response->getBody(), true);
if (!is_array($decoded)) {
throw new RuntimeException('企业微信获客助手接口返回了无法解析的数据');
}
$errcode = (int) ($decoded['errcode'] ?? 0);
if ($errcode === 0) {
return $decoded;
}
if (!$retried && in_array($errcode, self::TOKEN_INVALID_CODES, true)) {
Cache::delete($cacheKey);
return $this->request($method, $path, $body, true);
}
throw new RuntimeException(sprintf(
'企业微信获客助手接口失败[%d]%s',
$errcode,
trim((string) ($decoded['errmsg'] ?? '未知错误')) ?: '未知错误'
));
}
private function accessToken(): string
{
if ($this->accessTokenResolver !== null) {
$token = trim((string) call_user_func($this->accessTokenResolver));
if ($token === '') {
throw new RuntimeException('托管 access_token 为空');
}
return $token;
}
$cacheKey = $this->tokenCacheKey();
$cached = trim((string) Cache::get($cacheKey, ''));
if ($cached !== '') {
return $cached;
}
try {
$response = $this->client->request('GET', 'cgi-bin/gettoken', [
'query' => ['corpid' => $this->corpId, 'corpsecret' => $this->secret],
]);
} catch (GuzzleException $e) {
throw new RuntimeException('获取企业微信 access_token 失败,请检查服务器网络', 0, $e);
}
$decoded = json_decode((string) $response->getBody(), true);
if (!is_array($decoded) || (int) ($decoded['errcode'] ?? 0) !== 0 || empty($decoded['access_token'])) {
throw new RuntimeException(sprintf(
'获取企业微信 access_token 失败[%d]%s',
(int) ($decoded['errcode'] ?? -1),
trim((string) ($decoded['errmsg'] ?? '未知错误')) ?: '未知错误'
));
}
$token = (string) $decoded['access_token'];
Cache::set($cacheKey, $token, max(60, (int) ($decoded['expires_in'] ?? 7200) - 300));
return $token;
}
private function tokenCacheKey(): string
{
return 'qywx_customer_acquisition_token:' . hash('sha256', $this->corpId . '|' . $this->secret);
}
private function assertConfigured(): void
{
$status = self::configurationStatus();
if (!$status['configured']) {
throw new RuntimeException('获客助手应用配置不完整:缺少 ' . implode('、', $status['missing']));
}
}
private function assertLinkId(string $linkId): void
{
if ($linkId === '' || strlen($linkId) > 128) {
throw new RuntimeException('获客链接 ID 不正确');
}
}
}
@@ -0,0 +1,428 @@
<?php
declare(strict_types=1);
namespace app\common\service\qywx;
use RuntimeException;
use think\facade\Db;
/** 获客客户归因、会话统计与回调幂等落库。 */
class QywxCustomerAcquisitionCustomerService
{
private QywxCustomerAcquisitionApiService $api;
public function __construct(?QywxCustomerAcquisitionApiService $api = null)
{
$this->api = $api ?? new QywxCustomerAcquisitionApiService();
}
/**
* 同步一个远端获客链接的全部客户,远端列表字段采用覆盖语义。
* recv_msg_cnt 不在列表接口中返回,因此同步时保留本地值。
*
* @return array{scanned:int,created:int,updated:int,pages:int,truncated:bool}
*/
public function syncLink(string $remoteLinkId, int $maxCustomers = 20000): array
{
$remoteLinkId = trim($remoteLinkId);
if ($remoteLinkId === '') {
throw new RuntimeException('获客链接 ID 不能为空');
}
$cursor = '';
$scanned = 0;
$created = 0;
$updated = 0;
$pages = 0;
do {
$page = $this->api->listCustomers($remoteLinkId, $cursor, 1000);
$pages++;
foreach ($page['customer_list'] as $customer) {
if ($scanned >= $maxCustomers) {
break 2;
}
$scanned++;
$result = self::upsertCustomer($remoteLinkId, $customer, false);
$result === 'created' ? $created++ : $updated++;
}
$cursor = (string) ($page['next_cursor'] ?? '');
} while ($cursor !== '');
return compact('scanned', 'created', 'updated', 'pages') + ['truncated' => $cursor !== ''];
}
/**
* 处理 customer_acquisition 回调。相同事件只成功处理一次;失败事件保留审计并允许企微重试。
*
* @return array{duplicate:bool,status:string}
*/
public function handleCallback(array $message): array
{
$changeType = trim((string) ($message['ChangeType'] ?? $message['change_type'] ?? ''));
if (!in_array($changeType, ['customer_start_chat', 'message_from_customer'], true)) {
return ['duplicate' => false, 'status' => 'ignored'];
}
$chatKey = trim((string) ($message['ChatKey'] ?? $message['Chatkey'] ?? $message['chat_key'] ?? ''));
$eventTime = (int) ($message['CreateTime'] ?? $message['create_time'] ?? 0);
$eventKey = self::eventKey($message, $changeType, $chatKey, $eventTime);
$event = self::beginEvent($eventKey, $changeType, $chatKey, $eventTime, $message);
if (($event['duplicate'] ?? false) === true) {
return ['duplicate' => true, 'status' => 'success'];
}
$eventId = (int) ($event['id'] ?? 0);
try {
// customer_start_chat 仅能确认“客户已发起会话”,企业微信不保证该事件携带 ChatKey。
// 此时先落归因与聊天状态,精确消息数等待 message_from_customer 回调补齐。
if ($changeType === 'customer_start_chat' && $chatKey === '') {
$remoteLinkId = trim((string) (
$message['LinkID'] ?? $message['LinkId'] ?? $message['link_id'] ?? ''
));
$externalUserId = trim((string) (
$message['ExternalUserID'] ?? $message['ExternalUserId'] ?? $message['external_userid'] ?? ''
));
$userId = trim((string) ($message['UserID'] ?? $message['UserId'] ?? $message['userid'] ?? ''));
if ($remoteLinkId === '' || $externalUserId === '' || $userId === '') {
throw new RuntimeException('customer_start_chat 回调缺少 link_id / external_userid / userid');
}
self::upsertCustomer($remoteLinkId, [
'external_userid' => $externalUserId,
'userid' => $userId,
'chat_status' => 1,
'state' => (string) ($message['State'] ?? $message['state'] ?? ''),
'event_time' => $eventTime,
'snapshot' => $message,
], false);
self::finishEvent($eventId, 1, '', $remoteLinkId, $externalUserId, $userId);
return ['duplicate' => false, 'status' => 'success'];
}
if ($chatKey === '') {
self::finishEvent($eventId, 3, 'failed_invalid: message_from_customer 回调缺少 ChatKey');
throw new RuntimeException('message_from_customer 回调缺少 ChatKey');
}
$now = time();
if ($eventTime > 0 && ($now - $eventTime) >= 1800) {
throw new RuntimeException('获客回调 ChatKey 已超过 30 分钟有效期');
}
$chat = $this->api->getChatInfo($chatKey);
$chatInfo = is_array($chat['chat_info'] ?? null) ? $chat['chat_info'] : [];
$remoteLinkId = trim((string) (
$chatInfo['link_id'] ?? $message['LinkID'] ?? $message['LinkId'] ?? $message['link_id'] ?? ''
));
$externalUserId = trim((string) (
$chat['external_userid'] ?? $message['ExternalUserID'] ?? $message['ExternalUserId'] ?? ''
));
$userId = trim((string) ($chat['userid'] ?? $message['UserID'] ?? $message['UserId'] ?? ''));
if ($remoteLinkId === '' || $externalUserId === '' || $userId === '') {
throw new RuntimeException('get_chat_info 未返回完整的 link_id / external_userid / userid');
}
self::upsertCustomer($remoteLinkId, [
'external_userid' => $externalUserId,
'userid' => $userId,
'chat_status' => max(1, (int) ($message['ChatStatus'] ?? 1)),
'recv_msg_cnt' => max(0, (int) ($chatInfo['recv_msg_cnt'] ?? 0)),
'state' => (string) ($chatInfo['state'] ?? $message['State'] ?? ''),
'event_time' => $eventTime,
'snapshot' => $chat,
], true);
self::finishEvent($eventId, 1, '', $remoteLinkId, $externalUserId, $userId);
return ['duplicate' => false, 'status' => 'success'];
} catch (\Throwable $e) {
if ($eventId > 0 && str_contains($e->getMessage(), 'message_from_customer 回调缺少 ChatKey')) {
throw $e;
}
self::scheduleRetryOrExpire($eventId, $eventTime, $e->getMessage());
throw $e;
}
}
/**
* 重试仍在 ChatKey 30 分钟有效期内的失败回调,并把到期记录明确标记 failed_expired。
*
* @return array{selected:int,success:int,failed:int,expired:int}
*/
public function retryPending(int $limit = 100): array
{
$now = time();
// 进程在 beginEvent 后异常退出时,处理中事件会卡在 status=0;一分钟后自动回收再试。
Db::name('qywx_customer_acquisition_event')
->where('status', 0)
->where('update_time', '<=', $now - 60)
->where('expire_time', '>', $now)
->update([
'status' => 2,
'next_retry' => $now,
'error_message' => 'watchdog_recovered: 上次处理未正常结束',
'update_time' => $now,
]);
$expired = (int) Db::name('qywx_customer_acquisition_event')
->whereIn('status', [0, 2])
->where('expire_time', '>', 0)
->where('expire_time', '<=', $now)
->update([
'status' => 3,
'next_retry' => 0,
'error_message' => 'failed_expired: ChatKey 已超过 30 分钟有效期',
'chat_key' => '',
'raw_json' => null,
'update_time' => $now,
]);
$rows = Db::name('qywx_customer_acquisition_event')
->where('status', 2)
->where('next_retry', '<=', $now)
->where('expire_time', '>', $now)
->order('next_retry', 'asc')
->limit(min(500, max(1, $limit)))
->select()->toArray();
$success = 0;
$failed = 0;
foreach ($rows as $row) {
$message = json_decode((string) ($row['raw_json'] ?? ''), true);
if (!is_array($message)) {
self::scheduleRetryOrExpire(
(int) $row['id'],
(int) ($row['event_time'] ?? 0),
'回调原始数据无法解析'
);
$failed++;
continue;
}
try {
$this->handleCallback($message);
$success++;
} catch (\Throwable) {
$failed++;
}
}
return ['selected' => count($rows), 'success' => $success, 'failed' => $failed, 'expired' => $expired];
}
public static function eventKey(array $message, string $changeType, string $chatKey, int $eventTime): string
{
$parts = [
(string) ($message['MsgId'] ?? $message['MsgID'] ?? ''),
$changeType,
$chatKey,
(string) $eventTime,
(string) ($message['LinkID'] ?? $message['LinkId'] ?? ''),
(string) ($message['ExternalUserID'] ?? $message['ExternalUserId'] ?? ''),
(string) ($message['UserID'] ?? $message['UserId'] ?? ''),
];
return hash('sha256', implode('|', $parts));
}
/** @return array{expire_time:int,next_retry:int,expired:bool} */
public static function retryDecision(int $eventTime, int $now, int $storedExpireTime = 0): array
{
$expireTime = $storedExpireTime > 0
? $storedExpireTime
: ($eventTime > 0 ? $eventTime + 1800 : $now + 1800);
$expired = $expireTime <= $now;
return [
'expire_time' => $expireTime,
'next_retry' => $expired ? 0 : min($expireTime - 1, $now + 30),
'expired' => $expired,
];
}
/** @return array{id:int,duplicate:bool} */
private static function beginEvent(
string $eventKey,
string $changeType,
string $chatKey,
int $eventTime,
array $message
): array {
$now = time();
$raw = self::encodeJson($message);
$expireTime = self::retryDecision($eventTime, $now)['expire_time'];
try {
$id = (int) Db::name('qywx_customer_acquisition_event')->insertGetId([
'event_key' => $eventKey,
'change_type' => $changeType,
'chat_key' => $chatKey,
'status' => 0,
'attempts' => 1,
'event_time' => max(0, $eventTime),
'expire_time' => $expireTime,
'next_retry' => 0,
'error_message' => '',
'raw_json' => $raw,
'create_time' => $now,
'update_time' => $now,
]);
return ['id' => $id, 'duplicate' => false];
} catch (\Throwable $e) {
$existing = Db::name('qywx_customer_acquisition_event')->where('event_key', $eventKey)->find();
if (!$existing) {
throw $e;
}
if ((int) ($existing['status'] ?? 0) === 1) {
return ['id' => (int) $existing['id'], 'duplicate' => true];
}
if ((int) ($existing['status'] ?? 0) !== 2) {
return ['id' => (int) $existing['id'], 'duplicate' => true];
}
$claimed = Db::name('qywx_customer_acquisition_event')
->where('id', (int) $existing['id'])
->where('status', 2)
->update([
'status' => 0,
'attempts' => (int) ($existing['attempts'] ?? 0) + 1,
'error_message' => '',
'raw_json' => $raw,
'update_time' => $now,
]);
if ($claimed <= 0) {
return ['id' => (int) $existing['id'], 'duplicate' => true];
}
return ['id' => (int) $existing['id'], 'duplicate' => false];
}
}
private static function finishEvent(
int $id,
int $status,
string $error = '',
string $remoteLinkId = '',
string $externalUserId = '',
string $userId = ''
): void {
if ($id <= 0) {
return;
}
Db::name('qywx_customer_acquisition_event')->where('id', $id)->update([
'status' => $status,
'link_id' => $remoteLinkId,
'external_userid' => $externalUserId,
'userid' => $userId,
'error_message' => mb_substr($error, 0, 1000),
'next_retry' => 0,
// ChatKey 是短时敏感凭证,终态后不再保留;原始回调也随之清理。
'chat_key' => '',
'raw_json' => null,
'update_time' => time(),
]);
}
private static function scheduleRetryOrExpire(int $id, int $eventTime, string $error): void
{
if ($id <= 0) {
return;
}
$now = time();
$expireTime = (int) (Db::name('qywx_customer_acquisition_event')
->where('id', $id)->value('expire_time') ?? 0);
$decision = self::retryDecision($eventTime, $now, $expireTime);
$expireTime = $decision['expire_time'];
$expired = $decision['expired'];
Db::name('qywx_customer_acquisition_event')->where('id', $id)->update([
'status' => $expired ? 3 : 2,
'expire_time' => $expireTime,
'next_retry' => $decision['next_retry'],
'error_message' => mb_substr(
$expired ? 'failed_expired: ' . $error : $error,
0,
1000
),
'chat_key' => $expired ? '' : Db::raw('chat_key'),
'raw_json' => $expired ? null : Db::raw('raw_json'),
'update_time' => $now,
]);
}
/** @return 'created'|'updated' */
private static function upsertCustomer(string $remoteLinkId, array $customer, bool $messageCountKnown): string
{
$externalUserId = trim((string) ($customer['external_userid'] ?? ''));
$userId = trim((string) ($customer['userid'] ?? ''));
if ($remoteLinkId === '' || $externalUserId === '' || $userId === '') {
throw new RuntimeException('获客客户数据缺少 link_id / external_userid / userid');
}
[$ownerAdminId, $deptId] = self::resolveOwner($userId);
$now = time();
$existing = Db::name('qywx_customer_acquisition_customer')
->where('link_id', $remoteLinkId)
->where('external_userid', $externalUserId)
->where('userid', $userId)
->find();
$snapshot = $customer['snapshot'] ?? $customer;
$incomingChatStatus = max(0, min(2, (int) ($customer['chat_status'] ?? 0)));
$data = [
'promotion_link_id' => (int) (Db::name('qywx_promotion_link')
->where('remote_link_id', $remoteLinkId)->value('id') ?? 0),
'owner_admin_id' => $ownerAdminId,
'dept_id' => $deptId,
'state' => mb_substr((string) ($customer['state'] ?? ''), 0, 255),
// 已确认发过消息后,列表同步返回的“未发/未知”不得把状态回退。
'chat_status' => $existing
? Db::raw('CASE WHEN chat_status = 1 OR ' . $incomingChatStatus . ' = 1 THEN 1 ELSE ' . $incomingChatStatus . ' END')
: $incomingChatStatus,
'last_sync_time' => $now,
'raw_snapshot' => self::encodeJson($snapshot),
'update_time' => $now,
];
if ($messageCountKnown) {
$remoteCount = max(0, (int) ($customer['recv_msg_cnt'] ?? 0));
// get_chat_info 返回累计值,必须 max/覆盖,绝不按回调次数累加。
$data['recv_msg_cnt'] = $existing
? Db::raw('GREATEST(recv_msg_cnt,' . $remoteCount . ')')
: $remoteCount;
$data['message_count_known'] = 1;
}
if ($incomingChatStatus === 1 || $messageCountKnown) {
$eventTime = max(0, (int) ($customer['event_time'] ?? $now));
$data['last_chat_time'] = $existing
? Db::raw('GREATEST(last_chat_time,' . $eventTime . ')')
: $eventTime;
}
if ($existing) {
Db::name('qywx_customer_acquisition_customer')->where('id', (int) $existing['id'])->update($data);
return 'updated';
}
$data += [
'link_id' => $remoteLinkId,
'external_userid' => $externalUserId,
'userid' => $userId,
'recv_msg_cnt' => $messageCountKnown ? max(0, (int) ($customer['recv_msg_cnt'] ?? 0)) : 0,
'message_count_known' => $messageCountKnown ? 1 : 0,
'first_acquired_time' => max(0, (int) ($customer['create_time'] ?? $customer['event_time'] ?? $now)),
'last_chat_time' => ($incomingChatStatus === 1 || $messageCountKnown)
? max(0, (int) ($customer['event_time'] ?? $now))
: 0,
'create_time' => $now,
];
Db::name('qywx_customer_acquisition_customer')->insert($data);
return 'created';
}
/** @return array{0:int,1:int} */
private static function resolveOwner(string $userId): array
{
$adminId = (int) (Db::name('admin')->where('work_wechat_userid', $userId)
->whereNull('delete_time')->value('id') ?? 0);
if ($adminId <= 0) {
return [0, 0];
}
$deptId = (int) (Db::name('admin_dept')->where('admin_id', $adminId)
->order('dept_id', 'asc')->value('dept_id') ?? 0);
return [$adminId, $deptId];
}
private static function encodeJson(mixed $value): string
{
$json = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
return $json === false ? '{}' : $json;
}
}
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace app\common\service\qywx;
/** 企业微信获客助手链接校验。 */
class QywxCustomerAcquisitionLinkService
{
private const HOST = 'work.weixin.qq.com';
/**
* 只接受企业微信获客助手生成的 https://work.weixin.qq.com/ca/... 链接。
*/
public static function isAllowed(string $url, bool $allowEmpty = false): bool
{
$url = trim($url);
if ($url === '') {
return $allowEmpty;
}
$parts = parse_url($url);
if (!is_array($parts)
|| strtolower((string) ($parts['scheme'] ?? '')) !== 'https'
|| strtolower((string) ($parts['host'] ?? '')) !== self::HOST
|| isset($parts['user'])
|| isset($parts['pass'])
|| (isset($parts['port']) && (int) $parts['port'] !== 443)
) {
return false;
}
$path = (string) ($parts['path'] ?? '');
return preg_match('#^/ca/[A-Za-z0-9_-]+/?$#', $path) === 1;
}
public static function example(): string
{
return 'https://work.weixin.qq.com/ca/xxxxxxxx';
}
}
@@ -6,9 +6,33 @@ namespace app\common\service\qywx;
use think\facade\Db;
/** 公开推广链接分流:按权重随机,并在事务内维护当日限额与点击计数。 */
/** 公开获客助手链接分流:按权重随机,并在事务内维护当日限额与点击计数。 */
class QywxPromotionRedirectService
{
/** @return array{status:int,widget_config_json:?string}|null */
public static function publicPoolConfig(string $publicKey): ?array
{
if (preg_match('/^[a-f0-9]{32}$/', $publicKey) !== 1) {
return null;
}
$row = Db::name('qywx_promotion_pool')
->where('public_key', $publicKey)
->whereNull('delete_time')
->field('status,widget_config_json')
->find();
if (!$row) {
return null;
}
return [
'status' => (int) ($row['status'] ?? 0),
'widget_config_json' => isset($row['widget_config_json'])
? (string) $row['widget_config_json']
: null,
];
}
/** @return array{url:string,link_id:int}|null */
public static function pick(string $publicKey, array $context = []): ?array
{
@@ -30,11 +54,9 @@ class QywxPromotionRedirectService
$now = time();
$today = date('Y-m-d', $now);
$links = Db::name('qywx_promotion_link')->alias('l')
->leftJoin('qywx_promotion_account a', 'a.id = l.account_id AND a.delete_time IS NULL')
->where('l.pool_id', (int) $pool['id'])
->where('l.status', 1)
->whereNull('l.delete_time')
->whereRaw('(l.account_id = 0 OR a.auth_status = 1)')
->whereRaw('(l.active_start = 0 OR l.active_start <= ' . $now . ')')
->whereRaw('(l.active_end = 0 OR l.active_end >= ' . $now . ')')
->whereRaw("(l.daily_limit = 0 OR l.today_date IS NULL OR l.today_date <> '" . addslashes($today) . "' OR l.today_count < l.daily_limit)")
@@ -43,10 +65,16 @@ class QywxPromotionRedirectService
->select()
->toArray();
// 兼容历史数据:旧的普通外链或客户群链接即使仍在库中,也不能参与分流。
$links = array_values(array_filter(
$links,
static fn (array $link): bool => QywxCustomerAcquisitionLinkService::isAllowed((string) ($link['wecom_url'] ?? ''))
));
$selected = self::weightedRandom($links);
if (!$selected) {
$fallback = trim((string) ($pool['fallback_url'] ?? ''));
if (QywxPromotionOpenWorkService::isAllowedPromotionUrl($fallback, true) && $fallback !== '') {
if (QywxCustomerAcquisitionLinkService::isAllowed($fallback, true) && $fallback !== '') {
return ['url' => $fallback, 'link_id' => 0];
}
@@ -72,8 +100,7 @@ class QywxPromotionRedirectService
public static function poolExists(string $publicKey): bool
{
return preg_match('/^[a-f0-9]{32}$/', $publicKey) === 1
&& Db::name('qywx_promotion_pool')->where('public_key', $publicKey)->whereNull('delete_time')->count() > 0;
return self::publicPoolConfig($publicKey) !== null;
}
/** @param array<int,array<string,mixed>> $links */
@@ -0,0 +1,400 @@
<?php
declare(strict_types=1);
namespace app\common\service\qywx;
use InvalidArgumentException;
/**
* 获客助手公开浮窗配置与脚本。
*
* 管理端输入严格校验;数据库中的未知版本或损坏配置一律回退为关闭状态。
*/
class QywxPromotionWidgetService
{
private const VERSION = 1;
private const TEMPLATES = ['bubble', 'pill', 'card', 'message', 'edge', 'bar'];
private const POSITIONS = ['bottom-right', 'bottom-left'];
/** @return array<string, mixed> */
public static function defaults(): array
{
return [
'v' => self::VERSION,
'enabled' => false,
'template' => 'bubble',
'position' => 'bottom-right',
'title' => '专属顾问在线',
'subtitle' => '点击添加企业微信,获取一对一服务',
'button_text' => '立即咨询',
'primary_color' => '#139A8C',
'bottom_offset' => 28,
'show_mobile' => true,
];
}
/**
* @return array<string, mixed>
* @throws InvalidArgumentException
*/
public static function fromInput(mixed $input): array
{
if (!is_array($input)) {
throw new InvalidArgumentException('浮窗配置格式无效');
}
$defaults = self::defaults();
$version = self::integerValue(self::inputValue($input, 'v', self::VERSION), '配置版本');
if ($version !== self::VERSION) {
throw new InvalidArgumentException('不支持的浮窗配置版本');
}
$template = self::textValue(self::inputValue($input, 'template', $defaults['template']), '模板', 1, 20);
if (!in_array($template, self::TEMPLATES, true)) {
throw new InvalidArgumentException('浮窗模板无效');
}
$position = self::textValue(self::inputValue($input, 'position', $defaults['position']), '位置', 1, 20);
if (!in_array($position, self::POSITIONS, true)) {
throw new InvalidArgumentException('浮窗位置无效');
}
$color = strtoupper(trim(self::stringValue(
self::inputValue($input, 'primary_color', $defaults['primary_color']),
'主题色'
)));
if (preg_match('/^#[0-9A-F]{6}$/D', $color) !== 1) {
throw new InvalidArgumentException('主题色必须是 #RRGGBB 格式');
}
$bottomOffset = self::integerValue(
self::inputValue($input, 'bottom_offset', $defaults['bottom_offset']),
'底部距离'
);
if ($bottomOffset < 16 || $bottomOffset > 160) {
throw new InvalidArgumentException('底部距离必须在 16-160 之间');
}
return [
'v' => self::VERSION,
'enabled' => self::booleanValue(self::inputValue($input, 'enabled', $defaults['enabled']), '启用状态'),
'template' => $template,
'position' => $position,
'title' => self::textValue(self::inputValue($input, 'title', $defaults['title']), '标题', 1, 24),
'subtitle' => self::textValue(self::inputValue($input, 'subtitle', $defaults['subtitle']), '副标题', 0, 48),
'button_text' => self::textValue(
self::inputValue($input, 'button_text', $defaults['button_text']),
'按钮文案',
1,
12
),
'primary_color' => $color,
'bottom_offset' => $bottomOffset,
'show_mobile' => self::booleanValue(
self::inputValue($input, 'show_mobile', $defaults['show_mobile']),
'移动端展示状态'
),
];
}
/** @return array<string, mixed> */
public static function decode(mixed $stored): array
{
if (!is_string($stored) || trim($stored) === '') {
return self::defaults();
}
try {
$decoded = json_decode($stored, true, 16, JSON_THROW_ON_ERROR);
if (!is_array($decoded)) {
return self::defaults();
}
foreach (array_keys(self::defaults()) as $key) {
if (!array_key_exists($key, $decoded)) {
return self::defaults();
}
}
return self::fromInput($decoded);
} catch (\Throwable) {
return self::defaults();
}
}
/** @param array<string, mixed> $config */
public static function encode(array $config): string
{
return self::jsonForScript(self::fromInput($config));
}
/**
* 生成可直接跨站安装的完整脚本。真实获客链接始终只由跳转端点选择。
*
* @param array<string, mixed> $config
*/
public static function renderScript(string $key, string $goUrl, array $config, bool $poolEnabled = true): string
{
$config = self::fromInput($config);
if (!$poolEnabled) {
$config['enabled'] = false;
}
$jsonKey = self::jsonForScript($key);
$jsonGo = self::jsonForScript($goUrl);
$jsonConfig = self::jsonForScript($config);
return <<<JS
(function(w,d){
'use strict';
var key={$jsonKey},goPath={$jsonGo},config={$jsonConfig},scriptNode=d.currentScript||null;
var go=resolveGoUrl(goPath);
var registry=w.WecomPromotion=w.WecomPromotion||{};
var previous=registry[key];
if(previous&&previous.__widgetVersion===1&&typeof previous.destroy==='function'){
previous.destroy();
}
var root=null,mediaQuery=null,readyHandler=null,destroyed=false,manuallyHidden=false,api=null;
var rootId='wecom-promotion-widget-'+key;
var selector='[data-wecom-promotion="'+key+'"],.wecom-promotion-link[data-pool="'+key+'"]';
function findScriptNode(){
if(scriptNode&&scriptNode.src){return scriptNode;}
var scripts=d.getElementsByTagName('script');
var marker='/api/qywx-promotion/js/'+key;
for(var index=scripts.length-1;index>=0;index--){
if((scripts[index].src||'').indexOf(marker)!==-1){scriptNode=scripts[index];return scriptNode;}
}
return null;
}
function resolveGoUrl(value){
if(/^https?:\/\//i.test(value)){return value;}
var node=findScriptNode();
if(node&&node.src&&typeof w.URL==='function'){
try{return new w.URL(value,node.src).href;}catch(error){}
}
return value;
}
function sourceUrl(){
var location=w.location||{};
var origin=location.origin||((location.protocol&&location.host)?location.protocol+'//'+location.host:'');
return origin+(location.pathname||'/');
}
function openPromotion(){
w.location.assign(go+'?from='+encodeURIComponent(sourceUrl()));
}
function handleDocumentClick(event){
var path=typeof event.composedPath==='function'?event.composedPath():[];
var node=null;
for(var index=0;index<path.length;index++){
var candidate=path[index];
if(candidate&&candidate.nodeType===1&&candidate.matches&&candidate.matches(selector)){node=candidate;break;}
}
var target=event.target;
if(!node){node=target&&target.closest?target.closest(selector):null;}
if(!node){return;}
event.preventDefault();
event.stopPropagation();
openPromotion();
}
function isMobileHidden(){
return config.show_mobile===false&&mediaQuery&&mediaQuery.matches;
}
function applyVisibility(){
if(root){root.hidden=manuallyHidden||isMobileHidden();}
}
function handleViewportChange(){
applyVisibility();
}
function appendText(parent,tag,className,value){
var node=d.createElement(tag);
node.className=className;
node.textContent=value;
parent.appendChild(node);
return node;
}
function mount(){
if(destroyed||root||!config.enabled||!d.body){return;}
var stale=d.getElementById(rootId);
if(stale&&stale.parentNode){stale.parentNode.removeChild(stale);}
root=d.createElement('div');
root.id=rootId;
root.className='wcp-host wcp-host-'+config.position+' wcp-host-'+config.template;
root.setAttribute('data-wecom-promotion-widget',key);
var surface=root.attachShadow?root.attachShadow({mode:'open'}):root;
var style=d.createElement('style');
var nonceNode=findScriptNode();
var nonce=nonceNode?(nonceNode.nonce||nonceNode.getAttribute('nonce')||''):'';
if(nonce){style.setAttribute('nonce',nonce);}
var hostRules='position:fixed;z-index:2147483000;right:20px;bottom:calc('+config.bottom_offset+'px + env(safe-area-inset-bottom, 0px));max-width:calc(100vw - 32px);pointer-events:none;--wcp-primary:'+config.primary_color+';font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;color:#fff;line-height:1.4;-webkit-font-smoothing:antialiased';
style.textContent=':host{'+hostRules+'}.wcp-host{'+hostRules+'}' +
':host(.wcp-host-bottom-left){right:auto;left:20px}.wcp-host-bottom-left{right:auto;left:20px}' +
':host(.wcp-host-edge.wcp-host-bottom-right){right:0}.wcp-host-edge.wcp-host-bottom-right{right:0}' +
':host(.wcp-host-edge.wcp-host-bottom-left){right:auto;left:0}.wcp-host-edge.wcp-host-bottom-left{right:auto;left:0}' +
':host([hidden]){display:none!important}.wcp-host[hidden]{display:none!important}.wcp-root,.wcp-root *{box-sizing:border-box}.wcp-root{pointer-events:none}' +
'.wcp-button{pointer-events:auto;position:relative;display:flex;align-items:center;gap:12px;margin:0;border:0;cursor:pointer;color:#fff;background:var(--wcp-primary);font:inherit;text-align:left;box-shadow:0 14px 38px rgba(18,48,46,.24);transition:transform .2s ease,box-shadow .2s ease;appearance:none;-webkit-appearance:none}' +
'.wcp-button:hover{transform:translateY(-2px);box-shadow:0 18px 44px rgba(18,48,46,.3)}.wcp-button:active{transform:translateY(0)}.wcp-button:focus-visible{outline:3px solid rgba(255,255,255,.96);outline-offset:3px}' +
'.wcp-icon{display:flex;flex:0 0 auto;align-items:center;justify-content:center;width:38px;height:38px;border-radius:50%;background:rgba(255,255,255,.18);font-size:17px;font-weight:800}' +
'.wcp-copy{display:flex;min-width:0;flex-direction:column}.wcp-title{font-size:15px;font-weight:750;line-height:1.25}.wcp-subtitle{margin-top:2px;max-width:240px;font-size:12px;line-height:1.4;opacity:.86}' +
'.wcp-cta{flex:0 0 auto;padding:7px 11px;border-radius:999px;background:#fff;color:var(--wcp-primary);font-size:12px;font-weight:750;white-space:nowrap}' +
'.wcp-bubble .wcp-button{width:66px;height:66px;justify-content:center;padding:0;border-radius:50%}.wcp-bubble .wcp-icon{width:46px;height:46px;font-size:19px}.wcp-bubble .wcp-copy,.wcp-bubble .wcp-cta{position:absolute;right:76px;visibility:hidden;opacity:0;transform:translateX(8px);transition:opacity .18s ease,transform .18s ease;pointer-events:none}' +
'.wcp-bottom-left.wcp-bubble .wcp-copy,.wcp-bottom-left.wcp-bubble .wcp-cta{right:auto;left:76px}.wcp-bubble .wcp-copy{bottom:27px;width:220px;padding:11px 13px;border-radius:12px;background:#173f3b;box-shadow:0 12px 30px rgba(0,0,0,.2)}.wcp-bubble .wcp-cta{bottom:-1px;padding:5px 10px}' +
'.wcp-bubble .wcp-button:hover .wcp-copy,.wcp-bubble .wcp-button:hover .wcp-cta,.wcp-bubble .wcp-button:focus-visible .wcp-copy,.wcp-bubble .wcp-button:focus-visible .wcp-cta{visibility:visible;opacity:1;transform:translateX(0)}' +
'.wcp-pill .wcp-button{min-height:58px;padding:9px 12px;border-radius:999px}.wcp-pill .wcp-subtitle{display:none}' +
'.wcp-card .wcp-button{width:min(340px,calc(100vw - 40px));padding:15px;border-radius:18px}.wcp-card .wcp-icon{width:46px;height:46px}.wcp-card .wcp-copy{flex:1}.wcp-card .wcp-cta{border-radius:10px}' +
'.wcp-message .wcp-button{width:min(330px,calc(100vw - 40px));padding:13px 14px;border-radius:18px 18px 4px 18px}.wcp-bottom-left.wcp-message .wcp-button{border-radius:18px 18px 18px 4px}.wcp-message .wcp-copy{flex:1}.wcp-message .wcp-cta{padding:6px 9px}' +
'.wcp-edge .wcp-button{min-height:62px;max-width:270px;padding:10px 15px;border-radius:16px 0 0 16px}.wcp-bottom-left.wcp-edge .wcp-button{border-radius:0 16px 16px 0}.wcp-edge .wcp-subtitle{display:none}.wcp-edge .wcp-cta{padding:6px 9px}' +
'.wcp-bar .wcp-button{width:min(420px,calc(100vw - 40px));padding:11px 14px;border-radius:12px}.wcp-bar .wcp-copy{flex:1}.wcp-bar .wcp-icon{width:34px;height:34px}.wcp-bar .wcp-subtitle{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}' +
'@media(max-width:767px){.wcp-host{max-width:calc(100vw - 24px)}.wcp-card .wcp-button,.wcp-message .wcp-button,.wcp-bar .wcp-button{width:calc(100vw - 40px)}.wcp-subtitle{max-width:180px}.wcp-card .wcp-cta,.wcp-message .wcp-cta{display:none}}' +
'@media(prefers-reduced-motion:reduce){.wcp-button,.wcp-bubble .wcp-copy,.wcp-bubble .wcp-cta{transition:none!important}}';
surface.appendChild(style);
var container=d.createElement('div');
container.className='wcp-root wcp-'+config.template+' wcp-'+config.position;
var button=d.createElement('button');
button.type='button';
button.className='wcp-button';
button.setAttribute('aria-label',config.title+''+config.button_text);
appendText(button,'span','wcp-icon','企');
var copy=d.createElement('span');
copy.className='wcp-copy';
appendText(copy,'strong','wcp-title',config.title);
if(config.subtitle!==''){appendText(copy,'span','wcp-subtitle',config.subtitle);}
button.appendChild(copy);
appendText(button,'span','wcp-cta',config.button_text);
button.addEventListener('click',function(event){
event.preventDefault();
event.stopPropagation();
openPromotion();
});
container.appendChild(button);
surface.appendChild(container);
d.body.appendChild(root);
if(config.show_mobile===false&&typeof w.matchMedia==='function'){
mediaQuery=w.matchMedia('(max-width: 767px)');
if(mediaQuery.addEventListener){mediaQuery.addEventListener('change',handleViewportChange);}
else if(mediaQuery.addListener){mediaQuery.addListener(handleViewportChange);}
}
applyVisibility();
}
function show(){
if(destroyed||!config.enabled){return;}
manuallyHidden=false;
if(root){applyVisibility();return;}
if(d.body){mount();}
else if(!readyHandler){
readyHandler=function(){readyHandler=null;mount();};
d.addEventListener('DOMContentLoaded',readyHandler,{once:true});
}
}
function hide(){
manuallyHidden=true;
applyVisibility();
}
function destroy(){
if(destroyed){return;}
destroyed=true;
d.removeEventListener('click',handleDocumentClick,true);
if(readyHandler){d.removeEventListener('DOMContentLoaded',readyHandler);readyHandler=null;}
if(mediaQuery){
if(mediaQuery.removeEventListener){mediaQuery.removeEventListener('change',handleViewportChange);}
else if(mediaQuery.removeListener){mediaQuery.removeListener(handleViewportChange);}
mediaQuery=null;
}
if(root&&root.parentNode){root.parentNode.removeChild(root);}
root=null;
if(registry[key]===api){delete registry[key];}
}
d.addEventListener('click',handleDocumentClick,true);
api={open:openPromotion,show:show,hide:hide,destroy:destroy,config:config,__widgetVersion:1};
registry[key]=api;
if(config.enabled){show();}
})(window,document);
JS;
}
private static function booleanValue(mixed $value, string $label): bool
{
if (is_bool($value)) {
return $value;
}
if ($value === 1 || $value === '1') {
return true;
}
if ($value === 0 || $value === '0') {
return false;
}
throw new InvalidArgumentException($label . '必须是布尔值');
}
private static function inputValue(array $input, string $key, mixed $default): mixed
{
return array_key_exists($key, $input) ? $input[$key] : $default;
}
private static function integerValue(mixed $value, string $label): int
{
if (is_int($value)) {
return $value;
}
if (is_string($value) && preg_match('/^-?\d+$/D', $value) === 1) {
return (int) $value;
}
throw new InvalidArgumentException($label . '必须是整数');
}
private static function stringValue(mixed $value, string $label): string
{
if (!is_string($value)) {
throw new InvalidArgumentException($label . '必须是字符串');
}
return $value;
}
private static function textValue(mixed $value, string $label, int $min, int $max): string
{
$value = self::stringValue($value, $label);
$value = preg_replace('/\s+/u', ' ', trim($value)) ?? '';
$length = mb_strlen($value);
if ($length < $min || $length > $max) {
throw new InvalidArgumentException(sprintf('%s长度必须在 %d-%d 个字符之间', $label, $min, $max));
}
return $value;
}
private static function jsonForScript(mixed $value): string
{
return json_encode(
$value,
JSON_UNESCAPED_UNICODE
| JSON_UNESCAPED_SLASHES
| JSON_HEX_TAG
| JSON_HEX_AMP
| JSON_HEX_APOS
| JSON_HEX_QUOT
| JSON_THROW_ON_ERROR
);
}
}
+2
View File
@@ -30,6 +30,8 @@ return [
'qywx:scan-media-channel' => 'app\\command\\QywxScanMediaChannel',
// 企业微信会话内容存档同步(需开通会话存档 License 并配置 msgaudit_* 相关项 + 动态库)
'qywx:sync-msg-archive' => 'app\\command\\QywxSyncMsgArchive',
// 获客助手 ChatKey 仅 30 分钟有效,部署时应每分钟执行一次
'qywx:retry-customer-acquisition-events' => 'app\\command\\QywxRetryCustomerAcquisitionEvents',
// 甘草订单物流路由同步(GET_TASK_ROUTE_LIST
'gancao:sync-logistics' => 'app\\command\\GancaoSyncLogisticsRoute',
'ej-pharmacy:sync-catalog' => 'app\\command\\EjPharmacySyncCatalog',
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
return [
// 企业微信获客助手“可调用应用”的 CorpID 与应用 Secret。
// 优先使用语义明确的独立配置,兼容复用现有内部应用 Secret。
'corp_id' => trim((string) (env('WECHAT_WORK_CORP_ID', '') ?: env('work_wechat.corp_id', ''))),
'secret' => trim((string) (
env('WECHAT_WORK_CUSTOMER_ACQUISITION_SECRET', '')
?: env('work_wechat.customer_acquisition_secret', '')
?: env('WECHAT_WORK_AGENT_SECRET', '')
?: env('work_wechat.agent_secret', '')
?: env('work_wechat.secret', '')
)),
'base_uri' => 'https://qyapi.weixin.qq.com',
'timeout' => 20,
// Windows PHP 常未配置系统 CA;使用项目内 CA 文件,仍保持严格证书校验。
'verify' => is_file(dirname(__DIR__) . '/cacert.pem') ? dirname(__DIR__) . '/cacert.pem' : true,
];
+78 -23
View File
@@ -1,42 +1,97 @@
# 企业微信推广助手配置
# 企业微信获客助手配置
管理端菜单:`一诊 / 企业微信推广助手`
管理端菜单:`一诊 / 企业微信获客助手`
该功能使用企业微信“服务商第三方应用”授权模式。服务商参数只保存在服务器环境变量中,企业永久授权码会使用 AES-256-GCM 加密后存入数据库
该功能使用当前企业的内部自建应用配置,不使用服务商第三方应用,也不需要 SuiteID、suite_ticket、永久授权码或企业扫码安装
`server/.env` 中增加
系统直接复用 `server/.env` 已有配置
```ini
[qywx_promotion]
ENABLED = true
PROVIDER_CORP_ID = "服务商企业 CorpID"
SUITE_ID = "第三方应用 SuiteID"
SUITE_SECRET = "第三方应用 SuiteSecret"
TOKEN = "应用指令回调 Token"
AES_KEY = "应用指令回调 EncodingAESKey"
CREDENTIAL_KEY = "至少32字节的独立随机密钥"
REDIRECT_URI = "https://你的域名/api/qywx-promotion/auth/callback"
ADMIN_RETURN_URL = "https://你的域名/admin/first_visit/wecom_promotion"
[work_wechat]
CORP_ID = "当前企业 CorpID"
AGENT_ID = "内部自建应用 AgentID"
CUSTOMER_ACQUISITION_SECRET = "获客助手可调用应用 Secret"
[app]
HOST = "https://公开访问域名"
```
企业微信服务商后台需要配置
兼容已有项目:没有 `CUSTOMER_ACQUISITION_SECRET` 时,会依次回退读取 `AGENT_SECRET``SECRET`。如果现有 `SECRET` 就是获客助手中配置的“可调用应用”Secret,无需重复配置
- 应用指令回调 URL`https://你的域名/api/qywx-promotion/provider/callback`
- 授权完成回调域名:与你的 `REDIRECT_URI` 域名一致
企业微信管理后台还需完成三项外部配置:开通获客助手、将该内部应用设置为获客助手可调用应用、将接口服务器公网 IP 加入可信 IP。页面“验证获客助手 API”会通过只读列表接口检查这些条件。
保存配置并让企业微信成功推送一次 `suite_ticket` 后,管理端的“发起企业授权”按钮才会生成安装链接。
## 官方 API 对接范围
推广链接默认仅允许企业微信官方域名。如需跳转企业自有的可信中间页,可在环境变量中追加
按[企业微信获客链接管理文档](https://developer.work.weixin.qq.com/document/path/97297)完成以下五个接口
```ini
ALLOWED_LINK_HOSTS = "promo.example.com,crm.example.com"
- 获取获客链接列表 `list_link`
- 获取获客链接详情 `get`
- 创建获客链接 `create_link`
- 更新获客链接 `update_link`
- 删除获客链接 `delete_link`
“永久删除企业微信链接”会调用官方删除接口且无法恢复;“从本地移除”只退出当前分流池,不会修改企业微信后台。
获客成员来自后台管理员的 `work_wechat_userid`。管理员可管理全量;组长、医助等账号只返回 `DataScopeService` 当前角色与部门范围内的成员。同步远端链接时,非全量账号只导入 `range.user_list` 与其可见成员有交集的数据;企业微信部门 ID 尚未建立本地映射时按安全原则隐藏,不会越权放行。
`list_link` 只返回当前获客助手可调用应用通过 API 创建的官方链接。后台历史手工粘贴的 `work.weixin.qq.com/ca/...` 链接,以及其他应用创建的链接,不会出现在当前应用的同步列表中,也无法仅凭 URL 反查为官方 `link_id`。需要官方客户、统计和消息归因时,应在本页面使用“创建官方获客链接”。
链接分流只接受企业微信获客助手生成的链接:
```text
https://work.weixin.qq.com/ca/xxxxxxxx
```
“联系我”、客户群、自有网页或其他外部链接均会被拒绝;已有的非获客助手历史链接也不会参与随机分流。Secret 与 access_token 不会返回到浏览器,也不会写入接口错误日志。
如果需要为点击 IP 生成不可逆服务端哈希,可在 `[qywx_promotion]` 下额外设置独立的 `CREDENTIAL_KEY`
公开 JS 示例:
```html
<script src="https://你的域名/api/qywx-promotion/js/分流方案KEY" defer></script>
<a href="#" data-wecom-promotion="分流方案KEY">添加企业微信</a>
<a href="https://你的域名/api/qywx-promotion/go/分流方案KEY" data-wecom-promotion="分流方案KEY">添加企业微信</a>
```
随机分流在服务端完成。候选链接必须同时满足:方案启用、链接上线、授权企业有效、处于有效时间段、未超过当日上限。权重越大,被选中的概率越高。
## 公开浮窗
每个分流方案可选择是否由同一段公开 JS 自动挂载客服浮窗。关闭浮窗时,已有的
`data-wecom-promotion``.wecom-promotion-link[data-pool]`
`window.WecomPromotion[KEY].open()` 手动触发方式仍然可用。
浮窗配置保存在分流方案的 `widget_config_json` 中。当前配置版本为 `v=1`,支持:
- 模板:`bubble``pill``card``message``edge``bar`
- 位置:`bottom-right``bottom-left`
- 标题、副标题、按钮文案和 `#RRGGBB` 主题色
- 16-160 像素底部距离、移动端展示开关和浮窗总开关
公开脚本仅下发经过白名单校验的展示配置,不下发兜底链接或真实获客链接池。模板
由脚本内置,管理端文案通过 DOM `textContent` 写入,不接受自定义 HTML、CSS 或脚本。
损坏配置、未知版本和非法枚举会按关闭浮窗处理。
脚本会暴露以下运行时方法:
```js
window.WecomPromotion['分流方案KEY'].open()
window.WecomPromotion['分流方案KEY'].show()
window.WecomPromotion['分流方案KEY'].hide()
window.WecomPromotion['分流方案KEY'].destroy()
```
公开脚本缓存 60 秒,因此浮窗样式或开关更新最多延迟约 60 秒;方案运行状态仍会在
每次服务端跳转时即时校验。脚本会从自身 `src` 解析跳转接口域名,不会把公开请求的
Host 写入缓存内容。管理端安装代码优先使用 `[app] HOST`,请在生产环境配置唯一的
HTTPS 公开域名。
接入站点若启用了严格 CSP,需要允许脚本域名,并给安装 `<script>` 添加站点当前请求
`nonce`。公开脚本会把该 `nonce` 传给 Shadow DOM 内的动态样式:
```html
<script nonce="当前请求的 nonce" src="https://你的域名/api/qywx-promotion/js/分流方案KEY" defer></script>
```
点击来源只上报页面的 origin 与 pathname,不包含查询参数或 fragment。推广页路径中也
不应放置手机号、患者 ID、重置令牌等敏感信息。
随机分流在服务端完成。候选链接必须同时满足:方案启用、链接上线、处于有效时间段、未超过当日上限。权重越大,被选中的概率越高。
+26
View File
@@ -0,0 +1,26 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<!-- 字符编码:确保中文等复杂字符正确显示 -->
<meta charset="UTF-8">
<!-- 视口设置:确保移动端设备正确缩放,响应式设计的核心 -->
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- 页面标题:显示在浏览器标签页上 -->
<title>网页标题</title>
<!-- 描述:用于 SEO 和社交媒体分享时的摘要 -->
<meta name="description" content="这是一段关于网页内容的简短描述,有利于搜索引擎优化。">
<!-- 关键词(可选,现代SEO中权重较低,但可保留) -->
<meta name="keywords" content="HTML, 网页结构, 前端">
<script src="https://css.zhenyangtang.com.cn/api/qywx-promotion/js/88ac0ab3a549ccc891bab091b800ac68" defer></script>
</head>
<body>
<!-- 语义化标签:头部区域 -->
<a href="https://css.zhenyangtang.com.cn/api/qywx-promotion/go/88ac0ab3a549ccc891bab091b800ac68" data-wecom-promotion="88ac0ab3a549ccc891bab091b800ac68">添加企业微信</a>
</body>
</html>
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
import t from"./error-B8hA_PO-.js";import{o,q as r,r as a,v as n,D as c,s}from"./.pnpm-CJoARskO.js";import"./index-DGIIeNfq.js";const p="/admin/assets/no_perms-jDxcYpYC.png",i={class:"error404"},x=o({__name:"403",setup(m){return(_,e)=>(r(),a("div",i,[n(t,{code:"403",title:"您的账号权限不足,请联系管理员添加权限!","show-btn":!1},{content:c(()=>[...e[0]||(e[0]=[s("div",{class:"flex justify-center"},[s("img",{class:"w-[150px] h-[150px]",src:p,alt:""})],-1)])]),_:1})]))}});export{x as default};
import t from"./error-BHZCg2xT.js";import{o,q as r,r as a,v as n,D as c,s}from"./.pnpm-CC7b2w5D.js";import"./index-DNEphR7N.js";const p="/admin/assets/no_perms-jDxcYpYC.png",i={class:"error404"},x=o({__name:"403",setup(m){return(_,e)=>(r(),a("div",i,[n(t,{code:"403",title:"您的账号权限不足,请联系管理员添加权限!","show-btn":!1},{content:c(()=>[...e[0]||(e[0]=[s("div",{class:"flex justify-center"},[s("img",{class:"w-[150px] h-[150px]",src:p,alt:""})],-1)])]),_:1})]))}});export{x as default};
@@ -1 +1 @@
import e from"./error-B8hA_PO-.js";import{o,q as r,r as t,v as s}from"./.pnpm-CJoARskO.js";import"./index-DGIIeNfq.js";const a={class:"error404"},d=o({__name:"404",setup(c){return(n,_)=>(r(),t("div",a,[s(e,{code:"404",title:"哎呀,出错了!您访问的页面不存在…"})]))}});export{d as default};
import e from"./error-BHZCg2xT.js";import{o,q as r,r as t,v as s}from"./.pnpm-CC7b2w5D.js";import"./index-DNEphR7N.js";const a={class:"error404"},d=o({__name:"404",setup(c){return(n,_)=>(r(),t("div",a,[s(e,{code:"404",title:"哎呀,出错了!您访问的页面不存在…"})]))}});export{d as default};
@@ -1 +1 @@
import{o as C,R as D,q as d,r as I,ac as N,O as u,bj as B,D as o,v as l,bk as E,br as L,L as i,T as r,s as p,bi as R,M as w}from"./.pnpm-CJoARskO.js";import{a as V}from"./doctor-CDLpp8Jc.js";import{m as A,_ as M}from"./index-DGIIeNfq.js";const P={class:"appointment-record-panel"},$={class:"cell-stack"},j={class:"font-medium"},q={class:"text-gray-500 text-sm"},O={class:"cell-stack"},F={class:"text-gray-500 text-sm"},G=C({__name:"AppointmentRecordPanel",props:{diagnosisId:{}},setup(v,{expose:y}){const f=v,m=w(!1),g=w([]),h=w({});function x(t){return t?String(t).length>=8?String(t).slice(0,5):t:""}function k(t){return t==="morning"?"上午":t==="afternoon"?"下午":t==="all"?"全天":t||"—"}function S(t){const n=t.channel_source??t.channels??"";if(n===""||n===null||n===void 0)return"—";const a=String(n),s=h.value[a]||a,c=String(t.channel_source_detail??"").trim();return c!==""?`${s}${c}`:s}function T(t){return[...t].sort((n,a)=>{const s=String(n.appointment_date||""),c=String(a.appointment_date||"");if(s!==c)return c.localeCompare(s);const _=String(n.appointment_time||""),e=String(a.appointment_time||"");return _!==e?e.localeCompare(_):Number(a.id||0)-Number(n.id||0)})}const z=async()=>{try{const t=await A({type:"channels"}),n=((t==null?void 0:t.channels)||[]).filter(s=>s.status!==0),a={};for(const s of n)s.value!=null&&(a[String(s.value)]=s.name||String(s.value));h.value=a}catch{h.value={}}},b=async()=>{if(f.diagnosisId){m.value=!0;try{await z();const t=await V({patient_id:f.diagnosisId,diag_scope_relax:1,page_no:1,page_size:500}),n=(t==null?void 0:t.lists)||[];g.value=T(n)}catch(t){console.error(t),g.value=[]}finally{m.value=!1}}};return D(()=>f.diagnosisId,()=>{b()},{immediate:!0}),y({refresh:b}),(t,n)=>{const a=E,s=L,c=B,_=R;return d(),I("div",P,[N((d(),u(c,{data:g.value,border:"",stripe:"","empty-text":"暂无挂号记录"},{default:o(()=>[l(a,{label:"ID",prop:"id",width:"72",align:"center"}),l(a,{label:"状态",width:"100",align:"center"},{default:o(({row:e})=>[l(s,{type:e.status===1?"success":e.status===2?"info":e.status===3?"primary":"danger",size:"small",effect:"light"},{default:o(()=>[i(r(e.status_desc||"—"),1)]),_:2},1032,["type"])]),_:1}),l(a,{label:"患者(挂号人)","min-width":"150"},{default:o(({row:e})=>[p("div",$,[p("span",j,r(e.patient_name||"—"),1),p("span",q,r(e.patient_phone||""),1)])]),_:1}),l(a,{label:"挂号医生",prop:"doctor_name",width:"110","show-overflow-tooltip":""}),l(a,{label:"挂号助理",width:"110","show-overflow-tooltip":""},{default:o(({row:e})=>[i(r(e.assistant_name||"—"),1)]),_:1}),l(a,{label:"预约时间","min-width":"130"},{default:o(({row:e})=>[p("div",O,[p("span",null,r(e.appointment_date||"—"),1),p("span",F,r(x(e.appointment_time)),1)])]),_:1}),l(a,{label:"时段",width:"80",align:"center"},{default:o(({row:e})=>[i(r(k(e.period)),1)]),_:1}),l(a,{label:"类型",width:"100","show-overflow-tooltip":""},{default:o(({row:e})=>[i(r(e.appointment_type_desc||"—"),1)]),_:1}),l(a,{label:"渠道",width:"110","show-overflow-tooltip":""},{default:o(({row:e})=>[i(r(S(e)),1)]),_:1}),l(a,{label:"确认诊单",width:"92",align:"center"},{default:o(({row:e})=>[e.diagnosis_confirmed?(d(),u(s,{key:0,type:"success",size:"small",effect:"plain"},{default:o(()=>[...n[0]||(n[0]=[i("已确认",-1)])]),_:1})):(d(),u(s,{key:1,type:"warning",size:"small",effect:"plain"},{default:o(()=>[...n[1]||(n[1]=[i("未确认",-1)])]),_:1}))]),_:1}),l(a,{label:"开方",width:"80",align:"center"},{default:o(({row:e})=>[e.has_prescription?(d(),u(s,{key:0,type:"success",size:"small",effect:"plain"},{default:o(()=>[...n[2]||(n[2]=[i("已开方",-1)])]),_:1})):(d(),u(s,{key:1,type:"info",size:"small",effect:"plain"},{default:o(()=>[...n[3]||(n[3]=[i("未开方",-1)])]),_:1}))]),_:1}),l(a,{label:"备注",prop:"remark","min-width":"100","show-overflow-tooltip":""}),l(a,{label:"创建时间",width:"165",prop:"create_time"})]),_:1},8,["data"])),[[_,m.value]])])}}}),Q=M(G,[["__scopeId","data-v-4de87dfa"]]);export{Q as default};
import{o as C,R as D,q as d,r as I,ac as N,O as u,bj as B,D as o,v as l,bk as E,br as L,L as i,T as r,s as p,bi as R,M as w}from"./.pnpm-CC7b2w5D.js";import{a as V}from"./doctor-ePW7Z4IQ.js";import{m as A,_ as M}from"./index-DNEphR7N.js";const P={class:"appointment-record-panel"},$={class:"cell-stack"},j={class:"font-medium"},q={class:"text-gray-500 text-sm"},O={class:"cell-stack"},F={class:"text-gray-500 text-sm"},G=C({__name:"AppointmentRecordPanel",props:{diagnosisId:{}},setup(v,{expose:y}){const f=v,m=w(!1),g=w([]),h=w({});function x(t){return t?String(t).length>=8?String(t).slice(0,5):t:""}function k(t){return t==="morning"?"上午":t==="afternoon"?"下午":t==="all"?"全天":t||"—"}function S(t){const n=t.channel_source??t.channels??"";if(n===""||n===null||n===void 0)return"—";const a=String(n),s=h.value[a]||a,c=String(t.channel_source_detail??"").trim();return c!==""?`${s}${c}`:s}function T(t){return[...t].sort((n,a)=>{const s=String(n.appointment_date||""),c=String(a.appointment_date||"");if(s!==c)return c.localeCompare(s);const _=String(n.appointment_time||""),e=String(a.appointment_time||"");return _!==e?e.localeCompare(_):Number(a.id||0)-Number(n.id||0)})}const z=async()=>{try{const t=await A({type:"channels"}),n=((t==null?void 0:t.channels)||[]).filter(s=>s.status!==0),a={};for(const s of n)s.value!=null&&(a[String(s.value)]=s.name||String(s.value));h.value=a}catch{h.value={}}},b=async()=>{if(f.diagnosisId){m.value=!0;try{await z();const t=await V({patient_id:f.diagnosisId,diag_scope_relax:1,page_no:1,page_size:500}),n=(t==null?void 0:t.lists)||[];g.value=T(n)}catch(t){console.error(t),g.value=[]}finally{m.value=!1}}};return D(()=>f.diagnosisId,()=>{b()},{immediate:!0}),y({refresh:b}),(t,n)=>{const a=E,s=L,c=B,_=R;return d(),I("div",P,[N((d(),u(c,{data:g.value,border:"",stripe:"","empty-text":"暂无挂号记录"},{default:o(()=>[l(a,{label:"ID",prop:"id",width:"72",align:"center"}),l(a,{label:"状态",width:"100",align:"center"},{default:o(({row:e})=>[l(s,{type:e.status===1?"success":e.status===2?"info":e.status===3?"primary":"danger",size:"small",effect:"light"},{default:o(()=>[i(r(e.status_desc||"—"),1)]),_:2},1032,["type"])]),_:1}),l(a,{label:"患者(挂号人)","min-width":"150"},{default:o(({row:e})=>[p("div",$,[p("span",j,r(e.patient_name||"—"),1),p("span",q,r(e.patient_phone||""),1)])]),_:1}),l(a,{label:"挂号医生",prop:"doctor_name",width:"110","show-overflow-tooltip":""}),l(a,{label:"挂号助理",width:"110","show-overflow-tooltip":""},{default:o(({row:e})=>[i(r(e.assistant_name||"—"),1)]),_:1}),l(a,{label:"预约时间","min-width":"130"},{default:o(({row:e})=>[p("div",O,[p("span",null,r(e.appointment_date||"—"),1),p("span",F,r(x(e.appointment_time)),1)])]),_:1}),l(a,{label:"时段",width:"80",align:"center"},{default:o(({row:e})=>[i(r(k(e.period)),1)]),_:1}),l(a,{label:"类型",width:"100","show-overflow-tooltip":""},{default:o(({row:e})=>[i(r(e.appointment_type_desc||"—"),1)]),_:1}),l(a,{label:"渠道",width:"110","show-overflow-tooltip":""},{default:o(({row:e})=>[i(r(S(e)),1)]),_:1}),l(a,{label:"确认诊单",width:"92",align:"center"},{default:o(({row:e})=>[e.diagnosis_confirmed?(d(),u(s,{key:0,type:"success",size:"small",effect:"plain"},{default:o(()=>[...n[0]||(n[0]=[i("已确认",-1)])]),_:1})):(d(),u(s,{key:1,type:"warning",size:"small",effect:"plain"},{default:o(()=>[...n[1]||(n[1]=[i("未确认",-1)])]),_:1}))]),_:1}),l(a,{label:"开方",width:"80",align:"center"},{default:o(({row:e})=>[e.has_prescription?(d(),u(s,{key:0,type:"success",size:"small",effect:"plain"},{default:o(()=>[...n[2]||(n[2]=[i("已开方",-1)])]),_:1})):(d(),u(s,{key:1,type:"info",size:"small",effect:"plain"},{default:o(()=>[...n[3]||(n[3]=[i("未开方",-1)])]),_:1}))]),_:1}),l(a,{label:"备注",prop:"remark","min-width":"100","show-overflow-tooltip":""}),l(a,{label:"创建时间",width:"165",prop:"create_time"})]),_:1},8,["data"])),[[_,m.value]])])}}}),Q=M(G,[["__scopeId","data-v-4de87dfa"]]);export{Q as default};
@@ -1 +1 @@
import{o as T,R as C,q as _,r as b,ac as D,O as h,bj as $,D as r,v as n,bk as L,L as c,T as d,br as k,s as E,a1 as A,w as P,u as B,ch as F,bi as M,M as w}from"./.pnpm-CJoARskO.js";import{aa as V}from"./tcm-BCWrWaNC.js";import{_ as q}from"./index-DGIIeNfq.js";const K={class:"assign-log-panel"},j={key:1,class:"text-gray-400"},z=T({__name:"AssignLogPanel",props:{diagnosisId:{}},setup(v,{expose:y}){const u=v,m=w(!1),p=w([]);function N(a){const e=a.related_po_creator_name;if(e!=null&&String(e).trim()!=="")return String(e);const t=Number(a.related_po_creator_id);return Number.isFinite(t)&&t>0?`ID:${t}`:"—"}function x(a){const e=a.related_po_create_time_text;if(e!=null&&String(e).trim()!=="")return String(e);const t=Number(a.related_po_create_time);if(!Number.isFinite(t)||t<=0)return"—";const s=new Date(t*1e3);if(Number.isNaN(s.getTime()))return"—";const o=l=>String(l).padStart(2,"0");return`${s.getFullYear()}-${o(s.getMonth()+1)}-${o(s.getDate())} ${o(s.getHours())}:${o(s.getMinutes())}:${o(s.getSeconds())}`}function f(a,e){const t=e==="from"?"from_assistant_name":"to_assistant_name",s=e==="from"?"from_assistant_id":"to_assistant_id",o=a[t];if(o!=null&&String(o).trim()!==""&&String(o)!=="—")return String(o);const l=Number(a[s]);return Number.isFinite(l)&&l>0?`ID:${l}`:"—"}const g=async()=>{if(u.diagnosisId){m.value=!0;try{const a=await V({id:u.diagnosisId}),e=Array.isArray(a)?a:[];p.value=e}catch(a){console.error(a),p.value=[]}finally{m.value=!1}}};return C(()=>u.diagnosisId,()=>{g()},{immediate:!0}),y({refresh:g}),(a,e)=>{const t=L,s=k,o=P,l=A,S=$,I=M;return _(),b("div",K,[D((_(),h(S,{data:p.value,border:"",stripe:"","empty-text":"暂无指派记录"},{default:r(()=>[n(t,{label:"操作时间",width:"175",prop:"create_time_text"}),n(t,{label:"原医助","min-width":"120"},{default:r(({row:i})=>[c(d(f(i,"from")),1)]),_:1}),n(t,{label:"新医助","min-width":"120"},{default:r(({row:i})=>[c(d(f(i,"to")),1)]),_:1}),n(t,{label:"继承",width:"72",align:"center"},{default:r(({row:i})=>[Number(i.is_inherit)===1?(_(),h(s,{key:0,type:"success",size:"small"},{default:r(()=>[...e[0]||(e[0]=[c("是",-1)])]),_:1})):(_(),b("span",j,"否"))]),_:1}),n(t,{label:"快照·业务单创建人","min-width":"130","show-overflow-tooltip":""},{default:r(({row:i})=>[c(d(N(i)),1)]),_:1}),n(t,{label:"快照·业务单创建时间",width:"190"},{header:r(()=>[e[1]||(e[1]=E("span",null,"快照·业务单创建时间",-1)),n(l,{placement:"top",content:"related_po_create_time:触发本次操作的处方业务订单 create_time;与原文助一致时表示医助创建订单时间"},{default:r(()=>[n(o,{class:"assign-log-col-hint"},{default:r(()=>[n(B(F))]),_:1})]),_:1})]),default:r(({row:i})=>[c(d(x(i)),1)]),_:1}),n(t,{label:"操作人",width:"110",prop:"operator_name"}),n(t,{label:"操作账号",width:"120",prop:"operator_account","show-overflow-tooltip":""}),n(t,{label:"IP",width:"130",prop:"ip","show-overflow-tooltip":""})]),_:1},8,["data"])),[[I,m.value]])])}}}),Y=q(z,[["__scopeId","data-v-f670e3e6"]]);export{Y as default};
import{o as T,R as C,q as _,r as b,ac as D,O as h,bj as $,D as r,v as n,bk as L,L as c,T as d,br as k,s as E,a1 as A,w as P,u as B,ch as F,bi as M,M as w}from"./.pnpm-CC7b2w5D.js";import{aa as V}from"./tcm-vtnJvw47.js";import{_ as q}from"./index-DNEphR7N.js";const K={class:"assign-log-panel"},j={key:1,class:"text-gray-400"},z=T({__name:"AssignLogPanel",props:{diagnosisId:{}},setup(v,{expose:y}){const u=v,m=w(!1),p=w([]);function N(a){const e=a.related_po_creator_name;if(e!=null&&String(e).trim()!=="")return String(e);const t=Number(a.related_po_creator_id);return Number.isFinite(t)&&t>0?`ID:${t}`:"—"}function x(a){const e=a.related_po_create_time_text;if(e!=null&&String(e).trim()!=="")return String(e);const t=Number(a.related_po_create_time);if(!Number.isFinite(t)||t<=0)return"—";const s=new Date(t*1e3);if(Number.isNaN(s.getTime()))return"—";const o=l=>String(l).padStart(2,"0");return`${s.getFullYear()}-${o(s.getMonth()+1)}-${o(s.getDate())} ${o(s.getHours())}:${o(s.getMinutes())}:${o(s.getSeconds())}`}function f(a,e){const t=e==="from"?"from_assistant_name":"to_assistant_name",s=e==="from"?"from_assistant_id":"to_assistant_id",o=a[t];if(o!=null&&String(o).trim()!==""&&String(o)!=="—")return String(o);const l=Number(a[s]);return Number.isFinite(l)&&l>0?`ID:${l}`:"—"}const g=async()=>{if(u.diagnosisId){m.value=!0;try{const a=await V({id:u.diagnosisId}),e=Array.isArray(a)?a:[];p.value=e}catch(a){console.error(a),p.value=[]}finally{m.value=!1}}};return C(()=>u.diagnosisId,()=>{g()},{immediate:!0}),y({refresh:g}),(a,e)=>{const t=L,s=k,o=P,l=A,S=$,I=M;return _(),b("div",K,[D((_(),h(S,{data:p.value,border:"",stripe:"","empty-text":"暂无指派记录"},{default:r(()=>[n(t,{label:"操作时间",width:"175",prop:"create_time_text"}),n(t,{label:"原医助","min-width":"120"},{default:r(({row:i})=>[c(d(f(i,"from")),1)]),_:1}),n(t,{label:"新医助","min-width":"120"},{default:r(({row:i})=>[c(d(f(i,"to")),1)]),_:1}),n(t,{label:"继承",width:"72",align:"center"},{default:r(({row:i})=>[Number(i.is_inherit)===1?(_(),h(s,{key:0,type:"success",size:"small"},{default:r(()=>[...e[0]||(e[0]=[c("是",-1)])]),_:1})):(_(),b("span",j,"否"))]),_:1}),n(t,{label:"快照·业务单创建人","min-width":"130","show-overflow-tooltip":""},{default:r(({row:i})=>[c(d(N(i)),1)]),_:1}),n(t,{label:"快照·业务单创建时间",width:"190"},{header:r(()=>[e[1]||(e[1]=E("span",null,"快照·业务单创建时间",-1)),n(l,{placement:"top",content:"related_po_create_time:触发本次操作的处方业务订单 create_time;与原文助一致时表示医助创建订单时间"},{default:r(()=>[n(o,{class:"assign-log-col-hint"},{default:r(()=>[n(B(F))]),_:1})]),_:1})]),default:r(({row:i})=>[c(d(x(i)),1)]),_:1}),n(t,{label:"操作人",width:"110",prop:"operator_name"}),n(t,{label:"操作账号",width:"120",prop:"operator_account","show-overflow-tooltip":""}),n(t,{label:"IP",width:"130",prop:"ip","show-overflow-tooltip":""})]),_:1},8,["data"])),[[I,m.value]])])}}}),Y=q(z,[["__scopeId","data-v-f670e3e6"]]);export{Y as default};
@@ -1 +1 @@
import{o as B,R as D,q as E,O,D as I,r as _,T as S,P as x,ac as W,s as h,ad as P,v as U,K as $,L as j,bt as H,p as K,M as v,de as c}from"./.pnpm-CJoARskO.js";import{ab as Y}from"./tcm-BCWrWaNC.js";import{_ as q}from"./index-DGIIeNfq.js";const z={key:0,class:"watch-state"},F={key:1,class:"watch-state watch-error"},G=B({__name:"AssistantWatchCallDialog",props:{modelValue:{type:Boolean},diagnosisId:{}},emits:["update:modelValue","closed"],setup(R,{emit:A}){const u=R,g=A,y=K({get:()=>u.modelValue,set:t=>g("update:modelValue",t)}),r=v(null),l=v(!1),i=v(""),f=v("旁观视频通话"),n=new Map;let a=null,p=0;function w(t,e){return`${t}\0${String(e)}`}function N(t){return t.startsWith("patient_")?"患者":t.startsWith("doctor_")?"医护":t}async function T(t){if(!a||!r.value||t.streamType!==c.TYPE.STREAM_TYPE_MAIN)return;const e=w(t.userId,t.streamType);if(n.has(e))return;const o=document.createElement("div");o.className="watch-tile";const s=document.createElement("div");s.className="watch-tile-cap",s.textContent=N(t.userId);const d=document.createElement("div");d.className="watch-tile-view",o.appendChild(s),o.appendChild(d),r.value.appendChild(o),n.set(e,{wrap:o,userId:t.userId,streamType:t.streamType});try{await a.startRemoteVideo({userId:t.userId,streamType:t.streamType,view:d})}catch(M){console.warn("[AssistantWatchCall] startRemoteVideo",M)}}async function V(t){if(!a)return;const e=w(t.userId,t.streamType),o=n.get(e);if(o){try{await a.stopRemoteVideo({userId:t.userId,streamType:t.streamType})}catch{}o.wrap.remove(),n.delete(e)}}function C(){a&&(a.on(c.EVENT.REMOTE_VIDEO_AVAILABLE,T),a.on(c.EVENT.REMOTE_VIDEO_UNAVAILABLE,V))}function k(){a&&(a.off(c.EVENT.REMOTE_VIDEO_AVAILABLE,T),a.off(c.EVENT.REMOTE_VIDEO_UNAVAILABLE,V))}async function m(){if(k(),a){for(const[,t]of n){try{await a.stopRemoteVideo({userId:t.userId,streamType:t.streamType})}catch{}t.wrap.remove()}n.clear(),r.value&&(r.value.innerHTML="");try{await a.exitRoom()}catch{}try{a.destroy()}catch{}a=null}else n.clear(),r.value&&(r.value.innerHTML="")}async function L(){const t=++p;if(await m(),!u.diagnosisId){i.value="诊单无效";return}l.value=!0,i.value="",f.value="旁观视频通话";try{const e=await Y({diagnosis_id:u.diagnosisId});if(t!==p)return;e.patientName&&(f.value=`旁观视频通话 · ${e.patientName}`),a=c.create(),C();const o={sdkAppId:e.sdkAppId,userId:e.userId,userSig:e.userSig,autoReceiveAudio:!0,autoReceiveVideo:!0,...e.roomId!=null&&e.roomId>0?{roomId:e.roomId}:{strRoomId:e.strRoomId}};if(!(e.roomId!=null&&e.roomId>0)&&!e.strRoomId)throw new Error("缺少房间号");if(await a.enterRoom(o),t!==p){await m();return}l.value=!1}catch(e){l.value=!1;let o="进入房间失败";if(typeof e=="string")o=e;else if(e&&typeof e=="object"){const s=e;s.msg?o=String(s.msg):s.message&&(o=String(s.message))}i.value=o,await m()}}function b(){m(),l.value=!1,i.value="",f.value="旁观视频通话",g("closed")}return D(()=>[u.modelValue,u.diagnosisId],([t,e])=>{if(!t){p++,m();return}e>0&&L()}),(t,e)=>{const o=$,s=H;return E(),O(s,{modelValue:y.value,"onUpdate:modelValue":e[1]||(e[1]=d=>y.value=d),title:f.value,width:"760px","destroy-on-close":"","append-to-body":"","close-on-click-modal":!1,class:"assistant-watch-call-dialog",onClosed:b},{footer:I(()=>[e[3]||(e[3]=h("span",{class:"watch-hint"},"仅观看,不会开启摄像头与麦克风",-1)),U(o,{type:"primary",onClick:e[0]||(e[0]=d=>y.value=!1)},{default:I(()=>[...e[2]||(e[2]=[j("离开",-1)])]),_:1})]),default:I(()=>[l.value?(E(),_("div",z,"正在连接房间…")):i.value?(E(),_("div",F,S(i.value),1)):x("",!0),W(h("div",{ref_key:"gridRef",ref:r,class:"watch-grid"},null,512),[[P,!l.value&&!i.value]])]),_:1},8,["modelValue","title"])}}}),Z=q(G,[["__scopeId","data-v-7aff665d"]]);export{Z as default};
import{o as B,R as D,q as E,O,D as I,r as _,T as S,P as x,ac as W,s as h,ad as P,v as U,K as $,L as j,bt as H,p as K,M as v,dg as c}from"./.pnpm-CC7b2w5D.js";import{ab as Y}from"./tcm-vtnJvw47.js";import{_ as q}from"./index-DNEphR7N.js";const z={key:0,class:"watch-state"},F={key:1,class:"watch-state watch-error"},G=B({__name:"AssistantWatchCallDialog",props:{modelValue:{type:Boolean},diagnosisId:{}},emits:["update:modelValue","closed"],setup(R,{emit:A}){const u=R,g=A,y=K({get:()=>u.modelValue,set:t=>g("update:modelValue",t)}),r=v(null),l=v(!1),i=v(""),f=v("旁观视频通话"),n=new Map;let a=null,p=0;function w(t,e){return`${t}\0${String(e)}`}function N(t){return t.startsWith("patient_")?"患者":t.startsWith("doctor_")?"医护":t}async function T(t){if(!a||!r.value||t.streamType!==c.TYPE.STREAM_TYPE_MAIN)return;const e=w(t.userId,t.streamType);if(n.has(e))return;const o=document.createElement("div");o.className="watch-tile";const s=document.createElement("div");s.className="watch-tile-cap",s.textContent=N(t.userId);const d=document.createElement("div");d.className="watch-tile-view",o.appendChild(s),o.appendChild(d),r.value.appendChild(o),n.set(e,{wrap:o,userId:t.userId,streamType:t.streamType});try{await a.startRemoteVideo({userId:t.userId,streamType:t.streamType,view:d})}catch(M){console.warn("[AssistantWatchCall] startRemoteVideo",M)}}async function V(t){if(!a)return;const e=w(t.userId,t.streamType),o=n.get(e);if(o){try{await a.stopRemoteVideo({userId:t.userId,streamType:t.streamType})}catch{}o.wrap.remove(),n.delete(e)}}function C(){a&&(a.on(c.EVENT.REMOTE_VIDEO_AVAILABLE,T),a.on(c.EVENT.REMOTE_VIDEO_UNAVAILABLE,V))}function k(){a&&(a.off(c.EVENT.REMOTE_VIDEO_AVAILABLE,T),a.off(c.EVENT.REMOTE_VIDEO_UNAVAILABLE,V))}async function m(){if(k(),a){for(const[,t]of n){try{await a.stopRemoteVideo({userId:t.userId,streamType:t.streamType})}catch{}t.wrap.remove()}n.clear(),r.value&&(r.value.innerHTML="");try{await a.exitRoom()}catch{}try{a.destroy()}catch{}a=null}else n.clear(),r.value&&(r.value.innerHTML="")}async function L(){const t=++p;if(await m(),!u.diagnosisId){i.value="诊单无效";return}l.value=!0,i.value="",f.value="旁观视频通话";try{const e=await Y({diagnosis_id:u.diagnosisId});if(t!==p)return;e.patientName&&(f.value=`旁观视频通话 · ${e.patientName}`),a=c.create(),C();const o={sdkAppId:e.sdkAppId,userId:e.userId,userSig:e.userSig,autoReceiveAudio:!0,autoReceiveVideo:!0,...e.roomId!=null&&e.roomId>0?{roomId:e.roomId}:{strRoomId:e.strRoomId}};if(!(e.roomId!=null&&e.roomId>0)&&!e.strRoomId)throw new Error("缺少房间号");if(await a.enterRoom(o),t!==p){await m();return}l.value=!1}catch(e){l.value=!1;let o="进入房间失败";if(typeof e=="string")o=e;else if(e&&typeof e=="object"){const s=e;s.msg?o=String(s.msg):s.message&&(o=String(s.message))}i.value=o,await m()}}function b(){m(),l.value=!1,i.value="",f.value="旁观视频通话",g("closed")}return D(()=>[u.modelValue,u.diagnosisId],([t,e])=>{if(!t){p++,m();return}e>0&&L()}),(t,e)=>{const o=$,s=H;return E(),O(s,{modelValue:y.value,"onUpdate:modelValue":e[1]||(e[1]=d=>y.value=d),title:f.value,width:"760px","destroy-on-close":"","append-to-body":"","close-on-click-modal":!1,class:"assistant-watch-call-dialog",onClosed:b},{footer:I(()=>[e[3]||(e[3]=h("span",{class:"watch-hint"},"仅观看,不会开启摄像头与麦克风",-1)),U(o,{type:"primary",onClick:e[0]||(e[0]=d=>y.value=!1)},{default:I(()=>[...e[2]||(e[2]=[j("离开",-1)])]),_:1})]),default:I(()=>[l.value?(E(),_("div",z,"正在连接房间…")):i.value?(E(),_("div",F,S(i.value),1)):x("",!0),W(h("div",{ref_key:"gridRef",ref:r,class:"watch-grid"},null,512),[[P,!l.value&&!i.value]])]),_:1},8,["modelValue","title"])}}}),Z=q(G,[["__scopeId","data-v-7aff665d"]]);export{Z as default};
@@ -1 +1 @@
import{o as N,dg as O,R as P,q as d,r as f,v as o,D as i,K as V,L as n,P as h,ac as D,O as w,bj as L,bk as z,T as u,s as y,bi as M,M as v}from"./.pnpm-CJoARskO.js";import j from"./RecordingPlaybackBlock-Sc657sK5.js";import{U as x}from"./index-D30MriBb.js";import{i as c,_ as q}from"./index-DGIIeNfq.js";import{af as K,ag as k,ah as A}from"./tcm-BCWrWaNC.js";import"./RecordingVideoPlayer-qwMasORO.js";import"./file-DApWCRJt.js";const F={class:"call-record-panel"},G={key:0,class:"call-record-toolbar"},H={class:"call-record-empty"},J={class:"call-record-empty__desc"},Q={key:0,class:"text-primary"},W={key:1,class:"text-gray-400"},X=N({__name:"CallRecordPanel",props:{diagnosisId:{},readOnly:{type:Boolean,default:!1}},setup(_,{expose:R}){const r=_,p=v(!1),g=v([]),U=O("toolbarUploadRef"),m=async()=>{if(r.diagnosisId){p.value=!0;try{g.value=await K({diagnosis_id:r.diagnosisId})||[]}catch(e){console.error(e),g.value=[]}finally{p.value=!1}}};P(()=>r.diagnosisId,()=>{m()},{immediate:!0}),R({refresh:m});function C(e){return{1:"进行中",2:"已结束",3:"未接听",4:"已取消"}[e]??"—"}async function S(e){const t=b(e);if(!t){c.msgError("上传成功但未返回视频地址");return}try{const a=await A({diagnosis_id:r.diagnosisId});await k({diagnosis_id:r.diagnosisId,call_record_id:Number((a==null?void 0:a.id)||0),file_url:t}),c.msgSuccess("视频回放上传成功"),await m()}catch(a){c.msgError((a==null?void 0:a.message)||"写入回放失败")}}async function E(e,t){const a=b(t);if(!a){c.msgError("上传成功但未返回视频地址");return}try{await k({diagnosis_id:r.diagnosisId,call_record_id:Number(e.id||0),file_url:a}),c.msgSuccess("视频回放上传成功"),await m()}catch(l){c.msgError((l==null?void 0:l.message)||"写入回放失败")}}function b(e){var t,a;return String(((t=e==null?void 0:e.data)==null?void 0:t.uri)||((a=e==null?void 0:e.data)==null?void 0:a.url)||"").trim()}return(e,t)=>{const a=V,l=z,I=L,B=M;return d(),f("div",F,[_.readOnly?h("",!0):(d(),f("div",G,[o(x,{ref_key:"toolbarUploadRef",ref:U,type:"video",direct:"",multiple:!1,limit:1,"show-progress":!0,onSuccess:S},{default:i(()=>[o(a,{type:"primary"},{default:i(()=>[...t[0]||(t[0]=[n("上传视频",-1)])]),_:1})]),_:1},512)])),D((d(),w(I,{data:g.value,border:"",stripe:""},{empty:i(()=>[y("div",H,[t[1]||(t[1]=y("div",{class:"call-record-empty__title"},"暂无通话记录",-1)),y("div",J,u(_.readOnly?"暂无录制回放数据。":"现在可以直接点击上方“上传视频”。系统会自动生成一条默认通话记录来承载回放。"),1)])]),default:i(()=>[o(l,{label:"录制回放","min-width":"320"},{default:i(({row:s})=>[o(j,{"record-id":s.id,urls:s.recording_urls_list},null,8,["record-id","urls"])]),_:1}),o(l,{label:"开始时间",width:"170",prop:"start_time_text"}),o(l,{label:"结束时间",width:"170",prop:"end_time_text"}),o(l,{label:"通话类型",width:"100"},{default:i(({row:s})=>[n(u(s.call_type===1?"语音":"视频"),1)]),_:1}),o(l,{label:"房间号",width:"180"},{default:i(({row:s})=>[s.room_id?(d(),f("span",Q,u(s.room_id),1)):(d(),f("span",W,"—"))]),_:1}),o(l,{label:"时长",width:"110",prop:"duration_text"}),o(l,{label:"状态",width:"90"},{default:i(({row:s})=>[n(u(C(s.status)),1)]),_:1}),o(l,{label:"录制",width:"100"},{default:i(({row:s})=>[n(u(s.recording_status_text||"—"),1)]),_:1}),_.readOnly?h("",!0):(d(),w(l,{key:0,label:"上传回放",width:"180"},{default:i(({row:s})=>[o(x,{type:"video",direct:"",multiple:!1,limit:1,"show-progress":!0,onSuccess:T=>E(s,T)},{default:i(()=>[o(a,{type:"primary",plain:"",size:"small"},{default:i(()=>[...t[2]||(t[2]=[n("上传视频",-1)])]),_:1})]),_:1},8,["onSuccess"])]),_:1}))]),_:1},8,["data"])),[[B,p.value]])])}}}),sa=q(X,[["__scopeId","data-v-78d5c9e4"]]);export{sa as default};
import{o as N,di as O,R as P,q as d,r as f,v as o,D as i,K as V,L as n,P as h,ac as D,O as w,bj as L,bk as z,T as u,s as y,bi as M,M as v}from"./.pnpm-CC7b2w5D.js";import j from"./RecordingPlaybackBlock-BHKOgA1N.js";import{U as x}from"./index-CDdNeWUD.js";import{i as c,_ as q}from"./index-DNEphR7N.js";import{af as K,ag as k,ah as A}from"./tcm-vtnJvw47.js";import"./RecordingVideoPlayer-B483TIrn.js";import"./file-DYMdTeVi.js";const F={class:"call-record-panel"},G={key:0,class:"call-record-toolbar"},H={class:"call-record-empty"},J={class:"call-record-empty__desc"},Q={key:0,class:"text-primary"},W={key:1,class:"text-gray-400"},X=N({__name:"CallRecordPanel",props:{diagnosisId:{},readOnly:{type:Boolean,default:!1}},setup(_,{expose:R}){const r=_,p=v(!1),g=v([]),U=O("toolbarUploadRef"),m=async()=>{if(r.diagnosisId){p.value=!0;try{g.value=await K({diagnosis_id:r.diagnosisId})||[]}catch(e){console.error(e),g.value=[]}finally{p.value=!1}}};P(()=>r.diagnosisId,()=>{m()},{immediate:!0}),R({refresh:m});function C(e){return{1:"进行中",2:"已结束",3:"未接听",4:"已取消"}[e]??"—"}async function S(e){const t=b(e);if(!t){c.msgError("上传成功但未返回视频地址");return}try{const a=await A({diagnosis_id:r.diagnosisId});await k({diagnosis_id:r.diagnosisId,call_record_id:Number((a==null?void 0:a.id)||0),file_url:t}),c.msgSuccess("视频回放上传成功"),await m()}catch(a){c.msgError((a==null?void 0:a.message)||"写入回放失败")}}async function E(e,t){const a=b(t);if(!a){c.msgError("上传成功但未返回视频地址");return}try{await k({diagnosis_id:r.diagnosisId,call_record_id:Number(e.id||0),file_url:a}),c.msgSuccess("视频回放上传成功"),await m()}catch(l){c.msgError((l==null?void 0:l.message)||"写入回放失败")}}function b(e){var t,a;return String(((t=e==null?void 0:e.data)==null?void 0:t.uri)||((a=e==null?void 0:e.data)==null?void 0:a.url)||"").trim()}return(e,t)=>{const a=V,l=z,I=L,B=M;return d(),f("div",F,[_.readOnly?h("",!0):(d(),f("div",G,[o(x,{ref_key:"toolbarUploadRef",ref:U,type:"video",direct:"",multiple:!1,limit:1,"show-progress":!0,onSuccess:S},{default:i(()=>[o(a,{type:"primary"},{default:i(()=>[...t[0]||(t[0]=[n("上传视频",-1)])]),_:1})]),_:1},512)])),D((d(),w(I,{data:g.value,border:"",stripe:""},{empty:i(()=>[y("div",H,[t[1]||(t[1]=y("div",{class:"call-record-empty__title"},"暂无通话记录",-1)),y("div",J,u(_.readOnly?"暂无录制回放数据。":"现在可以直接点击上方“上传视频”。系统会自动生成一条默认通话记录来承载回放。"),1)])]),default:i(()=>[o(l,{label:"录制回放","min-width":"320"},{default:i(({row:s})=>[o(j,{"record-id":s.id,urls:s.recording_urls_list},null,8,["record-id","urls"])]),_:1}),o(l,{label:"开始时间",width:"170",prop:"start_time_text"}),o(l,{label:"结束时间",width:"170",prop:"end_time_text"}),o(l,{label:"通话类型",width:"100"},{default:i(({row:s})=>[n(u(s.call_type===1?"语音":"视频"),1)]),_:1}),o(l,{label:"房间号",width:"180"},{default:i(({row:s})=>[s.room_id?(d(),f("span",Q,u(s.room_id),1)):(d(),f("span",W,"—"))]),_:1}),o(l,{label:"时长",width:"110",prop:"duration_text"}),o(l,{label:"状态",width:"90"},{default:i(({row:s})=>[n(u(C(s.status)),1)]),_:1}),o(l,{label:"录制",width:"100"},{default:i(({row:s})=>[n(u(s.recording_status_text||"—"),1)]),_:1}),_.readOnly?h("",!0):(d(),w(l,{key:0,label:"上传回放",width:"180"},{default:i(({row:s})=>[o(x,{type:"video",direct:"",multiple:!1,limit:1,"show-progress":!0,onSuccess:T=>E(s,T)},{default:i(()=>[o(a,{type:"primary",plain:"",size:"small"},{default:i(()=>[...t[2]||(t[2]=[n("上传视频",-1)])]),_:1})]),_:1},8,["onSuccess"])]),_:1}))]),_:1},8,["data"])),[[B,p.value]])])}}}),sa=q(X,[["__scopeId","data-v-78d5c9e4"]]);export{sa as default};
@@ -1 +1 @@
import{o as T,ap as V,R as I,q as o,r as l,v as a,K as N,D as i,L as c,P as k,ac as z,bi as M,u as p,O as f,bk as O,T as _,F as P,br as j,s as F,bj as R,bB as A,M as w}from"./.pnpm-CJoARskO.js";import{ai as q}from"./tcm-BCWrWaNC.js";import{_ as H}from"./index-DGIIeNfq.js";const K={class:"case-record-list"},Y={key:0,class:"mb-3 flex justify-end"},G={key:0},J={key:1,class:"text-gray-400"},Q={class:"void-detail text-xs text-gray-500 mt-1"},U=T({__name:"CaseRecordList",props:{diagnosisId:{type:Number,default:0},readOnly:{type:Boolean,default:!1}},emits:["view","openPrescription"],setup(y,{expose:x,emit:C}){const m=y,b=C,r=w([]),d=w(!1),u=async()=>{if(m.diagnosisId){d.value=!0;try{const t=await q({diagnosis_id:m.diagnosisId});r.value=Array.isArray(t)?t:[]}catch(t){console.error("获取病历记录失败:",t),r.value=[]}finally{d.value=!1}}},S=t=>{if(!t)return"";const e=new Date(t*1e3);return`${e.getFullYear()}-${String(e.getMonth()+1).padStart(2,"0")}-${String(e.getDate()).padStart(2,"0")} ${String(e.getHours()).padStart(2,"0")}:${String(e.getMinutes()).padStart(2,"0")}`},B=t=>{b("view",t)},$=()=>{b("openPrescription")};return V(()=>{u()}),I(()=>m.diagnosisId,()=>{u()}),x({refresh:u}),(t,e)=>{const h=N,n=O,v=j,E=R,D=A,L=M;return o(),l("div",K,[y.readOnly?k("",!0):(o(),l("div",Y,[a(h,{type:"primary",size:"small",onClick:$},{default:i(()=>[...e[0]||(e[0]=[c("开方",-1)])]),_:1})])),z((o(),f(E,{data:p(r),border:""},{default:i(()=>[a(n,{prop:"prescription_date",label:"就诊日期",width:"120"}),a(n,{prop:"visit_no",label:"门诊号",width:"120"}),a(n,{prop:"clinical_diagnosis",label:"临床诊断","min-width":"160","show-overflow-tooltip":""}),a(n,{label:"处方摘要","min-width":"180"},{default:i(({row:s})=>[s.herbs&&s.herbs.length?(o(),l("span",G,_(s.herbs.slice(0,3).map(g=>`${g.name}${g.dosage}`).join("、"))+_(s.herbs.length>3?"...":""),1)):(o(),l("span",J,"—"))]),_:1}),a(n,{prop:"doctor_name",label:"医师",width:"90","show-overflow-tooltip":""}),a(n,{label:"状态",width:"140",align:"center"},{default:i(({row:s})=>[s.void_status===1?(o(),l(P,{key:0},[a(v,{type:"danger",size:"small"},{default:i(()=>[...e[1]||(e[1]=[c("已作废",-1)])]),_:1}),F("div",Q,_(s.void_by_name||"—")+" "+_(S(s.void_time)),1)],64)):(o(),f(v,{key:1,type:"success",size:"small"},{default:i(()=>[...e[2]||(e[2]=[c("正常",-1)])]),_:1}))]),_:1}),a(n,{label:"操作",width:"120",fixed:"right"},{default:i(({row:s})=>[a(h,{link:"",type:"primary",size:"small",onClick:g=>B(s)},{default:i(()=>[...e[3]||(e[3]=[c(" 查看 ",-1)])]),_:1},8,["onClick"])]),_:1})]),_:1},8,["data"])),[[L,p(d)]]),!p(d)&&p(r).length===0?(o(),f(D,{key:1,description:"暂无病历记录,开方后会自动显示",class:"mt-4"})):k("",!0)])}}}),ee=H(U,[["__scopeId","data-v-043d2738"]]);export{ee as default};
import{o as T,ap as V,R as I,q as o,r as l,v as a,K as N,D as i,L as c,P as k,ac as z,bi as M,u as p,O as f,bk as O,T as _,F as P,br as j,s as F,bj as R,bB as A,M as w}from"./.pnpm-CC7b2w5D.js";import{ai as q}from"./tcm-vtnJvw47.js";import{_ as H}from"./index-DNEphR7N.js";const K={class:"case-record-list"},Y={key:0,class:"mb-3 flex justify-end"},G={key:0},J={key:1,class:"text-gray-400"},Q={class:"void-detail text-xs text-gray-500 mt-1"},U=T({__name:"CaseRecordList",props:{diagnosisId:{type:Number,default:0},readOnly:{type:Boolean,default:!1}},emits:["view","openPrescription"],setup(y,{expose:x,emit:C}){const m=y,b=C,r=w([]),d=w(!1),u=async()=>{if(m.diagnosisId){d.value=!0;try{const t=await q({diagnosis_id:m.diagnosisId});r.value=Array.isArray(t)?t:[]}catch(t){console.error("获取病历记录失败:",t),r.value=[]}finally{d.value=!1}}},S=t=>{if(!t)return"";const e=new Date(t*1e3);return`${e.getFullYear()}-${String(e.getMonth()+1).padStart(2,"0")}-${String(e.getDate()).padStart(2,"0")} ${String(e.getHours()).padStart(2,"0")}:${String(e.getMinutes()).padStart(2,"0")}`},B=t=>{b("view",t)},$=()=>{b("openPrescription")};return V(()=>{u()}),I(()=>m.diagnosisId,()=>{u()}),x({refresh:u}),(t,e)=>{const h=N,n=O,v=j,E=R,D=A,L=M;return o(),l("div",K,[y.readOnly?k("",!0):(o(),l("div",Y,[a(h,{type:"primary",size:"small",onClick:$},{default:i(()=>[...e[0]||(e[0]=[c("开方",-1)])]),_:1})])),z((o(),f(E,{data:p(r),border:""},{default:i(()=>[a(n,{prop:"prescription_date",label:"就诊日期",width:"120"}),a(n,{prop:"visit_no",label:"门诊号",width:"120"}),a(n,{prop:"clinical_diagnosis",label:"临床诊断","min-width":"160","show-overflow-tooltip":""}),a(n,{label:"处方摘要","min-width":"180"},{default:i(({row:s})=>[s.herbs&&s.herbs.length?(o(),l("span",G,_(s.herbs.slice(0,3).map(g=>`${g.name}${g.dosage}`).join("、"))+_(s.herbs.length>3?"...":""),1)):(o(),l("span",J,"—"))]),_:1}),a(n,{prop:"doctor_name",label:"医师",width:"90","show-overflow-tooltip":""}),a(n,{label:"状态",width:"140",align:"center"},{default:i(({row:s})=>[s.void_status===1?(o(),l(P,{key:0},[a(v,{type:"danger",size:"small"},{default:i(()=>[...e[1]||(e[1]=[c("已作废",-1)])]),_:1}),F("div",Q,_(s.void_by_name||"—")+" "+_(S(s.void_time)),1)],64)):(o(),f(v,{key:1,type:"success",size:"small"},{default:i(()=>[...e[2]||(e[2]=[c("正常",-1)])]),_:1}))]),_:1}),a(n,{label:"操作",width:"120",fixed:"right"},{default:i(({row:s})=>[a(h,{link:"",type:"primary",size:"small",onClick:g=>B(s)},{default:i(()=>[...e[3]||(e[3]=[c(" 查看 ",-1)])]),_:1},8,["onClick"])]),_:1})]),_:1},8,["data"])),[[L,p(d)]]),!p(d)&&p(r).length===0?(o(),f(D,{key:1,description:"暂无病历记录,开方后会自动显示",class:"mt-4"})):k("",!0)])}}}),ee=H(U,[["__scopeId","data-v-043d2738"]]);export{ee as default};
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
import{_ as o}from"./GancaoSubmissionReconcileButton.vue_vue_type_script_setup_true_lang-C0YERubF.js";import"./.pnpm-CC7b2w5D.js";import"./tcm-vtnJvw47.js";import"./index-DNEphR7N.js";export{o as default};
@@ -1 +0,0 @@
import{_ as o}from"./GancaoSubmissionReconcileButton.vue_vue_type_script_setup_true_lang-DtlknZDO.js";import"./.pnpm-CJoARskO.js";import"./tcm-BCWrWaNC.js";import"./index-DGIIeNfq.js";export{o as default};
@@ -1 +1 @@
import{o as U,be as F,q as f,r as k,F as M,ac as D,O as E,D as t,L as u,K as G,v as r,bt as B,bh as q,b9 as P,aw as A,b6 as L,bc as T,bf as K,b7 as z,P as g,p as W,M as v,ba as $}from"./.pnpm-CJoARskO.js";import{p as j}from"./tcm-BCWrWaNC.js";import{i as C}from"./index-DGIIeNfq.js";const X=U({__name:"GancaoSubmissionReconcileButton",props:{order:{}},emits:["resolved"],setup(N,{emit:w}){const d=N,y=w,V=W(()=>{var m,p,a;const i=String(((m=d.order)==null?void 0:m.pharmacy_claim_target)||"").trim().toLowerCase(),e=String(((p=d.order)==null?void 0:p.pharmacy_claim_status)||"").trim().toUpperCase(),n=Number(((a=d.order)==null?void 0:a.pharmacy_claim_lease_expires_at)||0),c=e==="PENDING"&&n>0&&n<=Math.floor(Date.now()/1e3);return i==="gancao"&&(c||["UNKNOWN","PENDING_RECONCILE"].includes(e))}),s=v(!1),_=v(!1),o=$({resolution:"CONFIRM_SUCCESS",remote_order_no:"",note:""});function O(){o.resolution="CONFIRM_SUCCESS",o.remote_order_no="",o.note="",s.value=!0}async function b(){var e;const i=Number((e=d.order)==null?void 0:e.id);if(i){if(o.resolution==="CONFIRM_SUCCESS"&&!o.remote_order_no.trim()){C.msgError("请填写甘草药方单号");return}if(!o.note.trim()){C.msgError("请填写甘草后台核对依据");return}_.value=!0;try{await j({id:i,resolution:o.resolution,remote_order_no:o.resolution==="CONFIRM_SUCCESS"?o.remote_order_no.trim():"",note:o.note.trim()}),C.msgSuccess("甘草提交核对已记录"),s.value=!1,y("resolved")}catch{}finally{_.value=!1}}}return(i,e)=>{const n=G,c=q,m=K,p=T,a=L,S=z,R=P,x=B,I=F("perms");return V.value?(f(),k(M,{key:0},[D((f(),E(n,{type:"danger",size:"small",plain:"",onClick:O},{default:t(()=>[...e[5]||(e[5]=[u("核对甘草提交",-1)])]),_:1})),[[I,["tcm.prescriptionOrder/confirmGancaoSubmission"]]]),r(x,{modelValue:s.value,"onUpdate:modelValue":e[4]||(e[4]=l=>s.value=l),title:"人工核对甘草提交",width:"min(92vw, 520px)","append-to-body":"","destroy-on-close":"","close-on-click-modal":!1},{footer:t(()=>[r(n,{onClick:e[3]||(e[3]=l=>s.value=!1)},{default:t(()=>[...e[8]||(e[8]=[u("取消",-1)])]),_:1}),r(n,{type:"primary",loading:_.value,onClick:b},{default:t(()=>[...e[9]||(e[9]=[u("确认并记录",-1)])]),_:1},8,["loading"])]),default:t(()=>[r(c,{title:"请先在甘草后台核对。本操作会写入不可变更的审计记录。",type:"warning",closable:!1,"show-icon":"",class:"mb-4"}),r(R,{"label-width":"100px",onSubmit:A(b,["prevent"])},{default:t(()=>[r(a,{label:"核对结果",required:""},{default:t(()=>[r(p,{modelValue:o.resolution,"onUpdate:modelValue":e[0]||(e[0]=l=>o.resolution=l)},{default:t(()=>[r(m,{label:"CONFIRM_SUCCESS"},{default:t(()=>[...e[6]||(e[6]=[u("确认已创建",-1)])]),_:1}),r(m,{label:"CONFIRM_NOT_CREATED"},{default:t(()=>[...e[7]||(e[7]=[u("确认未创建",-1)])]),_:1})]),_:1},8,["modelValue"])]),_:1}),o.resolution==="CONFIRM_SUCCESS"?(f(),E(a,{key:0,label:"甘草单号",required:""},{default:t(()=>[r(S,{modelValue:o.remote_order_no,"onUpdate:modelValue":e[1]||(e[1]=l=>o.remote_order_no=l),maxlength:"64",clearable:""},null,8,["modelValue"])]),_:1})):g("",!0),r(a,{label:"核对依据",required:""},{default:t(()=>[r(S,{modelValue:o.note,"onUpdate:modelValue":e[2]||(e[2]=l=>o.note=l),type:"textarea",rows:3,maxlength:"1000","show-word-limit":"",placeholder:"例如:核对时间、甘草后台查询条件及结果"},null,8,["modelValue"])]),_:1})]),_:1})]),_:1},8,["modelValue"])],64)):g("",!0)}}});export{X as _};
import{o as U,be as F,q as f,r as k,F as M,ac as D,O as E,D as t,L as u,K as G,v as r,bt as B,bh as q,b9 as P,aw as A,b6 as L,bc as T,bf as K,b7 as z,P as g,p as W,M as v,ba as $}from"./.pnpm-CC7b2w5D.js";import{p as j}from"./tcm-vtnJvw47.js";import{i as C}from"./index-DNEphR7N.js";const X=U({__name:"GancaoSubmissionReconcileButton",props:{order:{}},emits:["resolved"],setup(N,{emit:w}){const d=N,y=w,V=W(()=>{var m,p,a;const i=String(((m=d.order)==null?void 0:m.pharmacy_claim_target)||"").trim().toLowerCase(),e=String(((p=d.order)==null?void 0:p.pharmacy_claim_status)||"").trim().toUpperCase(),n=Number(((a=d.order)==null?void 0:a.pharmacy_claim_lease_expires_at)||0),c=e==="PENDING"&&n>0&&n<=Math.floor(Date.now()/1e3);return i==="gancao"&&(c||["UNKNOWN","PENDING_RECONCILE"].includes(e))}),s=v(!1),_=v(!1),o=$({resolution:"CONFIRM_SUCCESS",remote_order_no:"",note:""});function O(){o.resolution="CONFIRM_SUCCESS",o.remote_order_no="",o.note="",s.value=!0}async function b(){var e;const i=Number((e=d.order)==null?void 0:e.id);if(i){if(o.resolution==="CONFIRM_SUCCESS"&&!o.remote_order_no.trim()){C.msgError("请填写甘草药方单号");return}if(!o.note.trim()){C.msgError("请填写甘草后台核对依据");return}_.value=!0;try{await j({id:i,resolution:o.resolution,remote_order_no:o.resolution==="CONFIRM_SUCCESS"?o.remote_order_no.trim():"",note:o.note.trim()}),C.msgSuccess("甘草提交核对已记录"),s.value=!1,y("resolved")}catch{}finally{_.value=!1}}}return(i,e)=>{const n=G,c=q,m=K,p=T,a=L,S=z,R=P,x=B,I=F("perms");return V.value?(f(),k(M,{key:0},[D((f(),E(n,{type:"danger",size:"small",plain:"",onClick:O},{default:t(()=>[...e[5]||(e[5]=[u("核对甘草提交",-1)])]),_:1})),[[I,["tcm.prescriptionOrder/confirmGancaoSubmission"]]]),r(x,{modelValue:s.value,"onUpdate:modelValue":e[4]||(e[4]=l=>s.value=l),title:"人工核对甘草提交",width:"min(92vw, 520px)","append-to-body":"","destroy-on-close":"","close-on-click-modal":!1},{footer:t(()=>[r(n,{onClick:e[3]||(e[3]=l=>s.value=!1)},{default:t(()=>[...e[8]||(e[8]=[u("取消",-1)])]),_:1}),r(n,{type:"primary",loading:_.value,onClick:b},{default:t(()=>[...e[9]||(e[9]=[u("确认并记录",-1)])]),_:1},8,["loading"])]),default:t(()=>[r(c,{title:"请先在甘草后台核对。本操作会写入不可变更的审计记录。",type:"warning",closable:!1,"show-icon":"",class:"mb-4"}),r(R,{"label-width":"100px",onSubmit:A(b,["prevent"])},{default:t(()=>[r(a,{label:"核对结果",required:""},{default:t(()=>[r(p,{modelValue:o.resolution,"onUpdate:modelValue":e[0]||(e[0]=l=>o.resolution=l)},{default:t(()=>[r(m,{label:"CONFIRM_SUCCESS"},{default:t(()=>[...e[6]||(e[6]=[u("确认已创建",-1)])]),_:1}),r(m,{label:"CONFIRM_NOT_CREATED"},{default:t(()=>[...e[7]||(e[7]=[u("确认未创建",-1)])]),_:1})]),_:1},8,["modelValue"])]),_:1}),o.resolution==="CONFIRM_SUCCESS"?(f(),E(a,{key:0,label:"甘草单号",required:""},{default:t(()=>[r(S,{modelValue:o.remote_order_no,"onUpdate:modelValue":e[1]||(e[1]=l=>o.remote_order_no=l),maxlength:"64",clearable:""},null,8,["modelValue"])]),_:1})):g("",!0),r(a,{label:"核对依据",required:""},{default:t(()=>[r(S,{modelValue:o.note,"onUpdate:modelValue":e[2]||(e[2]=l=>o.note=l),type:"textarea",rows:3,maxlength:"1000","show-word-limit":"",placeholder:"例如:核对时间、甘草后台查询条件及结果"},null,8,["modelValue"])]),_:1})]),_:1})]),_:1},8,["modelValue"])],64)):g("",!0)}}});export{X as _};
@@ -1 +1 @@
import{o as F,R as q,q as t,r as n,v as i,D as c,s as o,L as _,bh as G,w as H,u as x,cV as $,K as j,by as J,ac as K,F as E,G as O,O as f,bB as W,P as v,bi as Q,M as m,p as U,ae as X,T as r,br as Z,aa as ee,bq as ae,a8 as se,E as C}from"./.pnpm-CJoARskO.js";import{d as te}from"./dayjs-HJJmW-S4.js";import{an as ne,ao as oe}from"./tcm-BCWrWaNC.js";import{p as re}from"./im-business-message-parse-DTpJoEdY.js";import{_ as le}from"./index-DGIIeNfq.js";const ie={class:"im-chat-record-panel"},ce={class:"toolbar mb-3"},de={class:"chat-wrap"},_e={key:0,class:"chat-list"},ue={class:"meta"},fe={class:"name"},me={class:"time"},pe={class:"bubble"},ye={key:0,class:"friendly-text"},ge={class:"friendly-main"},ve={key:0,class:"friendly-sub"},he={key:1,class:"text-content"},we={key:2,class:"text-content"},be={key:3,class:"muted"},ke=F({__name:"ImChatRecordPanel",props:{diagnosisId:{}},setup(B,{expose:M}){const u=B,d=m(!1),p=m(!1),y=se([]),L=m(""),g=m(""),D={text:"",image:"图片",file:"文件",sound:"语音",video:"视频",location:"位置",custom:"自定义",face:"表情",other:""},h=U(()=>y.value.map(e=>{const s=P(e);let l="";return s!=null&&s.tag?l=s.tag:l=D[e.msg_type]||(e.msg_type!=="other"?e.msg_type:""),{raw:e,friendly:s,tag:l}}));function N(e){if(e==null||!e)return"—";const s=e>1e12?Math.floor(e/1e3):e;return te.unix(s).format("YYYY-MM-DD HH:mm:ss")}function P(e){const s=(e.text||"").trim();if(!s)return null;const l=s.startsWith("{")&&(/\bbusinessID\b/.test(s)||/\bcmd\b/.test(s));return e.msg_type==="custom"||l?re(s):null}function R(e){return e.is_from_doctor?e.from_staff_name?`医生(${e.from_staff_name}`:"医生/员工":g.value?`患者(${g.value}`:"患者"}async function w(){if(u.diagnosisId){d.value=!0;try{const e=await ne({diagnosis_id:u.diagnosisId,only_archived:1});y.value=(e==null?void 0:e.lists)||[],L.value=(e==null?void 0:e.patient_im_id)||"",g.value=(e==null?void 0:e.patient_name)||""}catch(e){console.error(e),y.value=[]}finally{d.value=!1}}}function b(){w()}async function S(){if(u.diagnosisId){p.value=!0;try{await oe({diagnosis_id:u.diagnosisId}),C.success('已发起后台同步,几秒后请点击"重新加载已归档"查看新消息')}catch(e){console.error(e),C.error("发起同步失败")}finally{p.value=!1}}}return q(()=>u.diagnosisId,()=>{w()},{immediate:!0}),M({refresh:b}),(e,s)=>{const l=G,k=H,I=j,T=Z,V=ee,Y=ae,z=W,A=Q;return t(),n("div",ie,[i(l,{type:"info","show-icon":"",closable:!1,class:"mb-4",title:"说明"},{default:c(()=>[...s[0]||(s[0]=[o("p",{class:"panel-tip"},[_(" 展示单聊记录:已合并患者 与 "),o("strong",null,"所有医生 / 医助账号"),_("分别产生的会话,按时间排序。 数据由后台定时任务从腾讯云 IM 漫游消息同步至本地归档;点击下方按钮可立即触发后台同步。 ")],-1)])]),_:1}),o("div",ce,[i(I,{type:"primary",link:"",loading:p.value,onClick:S},{default:c(()=>[i(k,{class:"mr-1"},{default:c(()=>[i(x($))]),_:1}),s[1]||(s[1]=_(" 同步最新(后台异步) ",-1))]),_:1},8,["loading"]),i(I,{type:"primary",link:"",loading:d.value,onClick:b},{default:c(()=>[i(k,{class:"mr-1"},{default:c(()=>[i(x(J))]),_:1}),s[2]||(s[2]=_(" 重新加载已归档 ",-1))]),_:1},8,["loading"])]),K((t(),n("div",de,[!d.value&&h.value.length?(t(),n("div",_e,[(t(!0),n(E,null,O(h.value,a=>(t(),n("div",{key:a.raw.msg_id,class:X(["chat-row",a.raw.is_from_doctor?"from-doctor":"from-patient"])},[o("div",ue,[o("span",fe,r(R(a.raw)),1),o("span",me,r(N(a.raw.time)),1),a.tag?(t(),f(T,{key:0,size:"small",type:"info",class:"ml-2"},{default:c(()=>[_(r(a.tag),1)]),_:2},1024)):v("",!0)]),o("div",pe,[a.raw.msg_type==="image"&&a.raw.image_url?(t(),f(V,{key:0,src:a.raw.image_url,"preview-src-list":[a.raw.image_url],fit:"contain",class:"chat-img"},null,8,["src","preview-src-list"])):(a.raw.msg_type==="file"||a.raw.msg_type==="sound"||a.raw.msg_type==="video")&&a.raw.file_url?(t(),f(Y,{key:1,href:a.raw.file_url,target:"_blank",type:"primary"},{default:c(()=>[_(r(a.raw.file_name||"打开文件"),1)]),_:2},1032,["href"])):(t(),n(E,{key:2},[a.friendly?(t(),n("div",ye,[o("div",ge,r(a.friendly.main),1),a.friendly.sub?(t(),n("div",ve,r(a.friendly.sub),1)):v("",!0)])):a.raw.msg_type==="text"&&a.raw.text?(t(),n("div",he,r(a.raw.text),1)):a.raw.text?(t(),n("div",we,r(a.raw.text),1)):(t(),n("span",be,"(无法展示该消息类型)"))],64))])],2))),128))])):d.value?v("",!0):(t(),f(z,{key:1,description:"暂无 IM 聊天记录"}))])),[[A,d.value]])])}}}),Me=le(ke,[["__scopeId","data-v-58b699dd"]]);export{Me as default};
import{o as F,R as q,q as t,r as n,v as i,D as c,s as o,L as _,bh as G,w as H,u as x,cV as $,K as j,by as J,ac as K,F as E,G as O,O as f,bB as W,P as v,bi as Q,M as m,p as U,ae as X,T as r,br as Z,aa as ee,bq as ae,a8 as se,E as C}from"./.pnpm-CC7b2w5D.js";import{d as te}from"./dayjs-o4qa-HO_.js";import{an as ne,ao as oe}from"./tcm-vtnJvw47.js";import{p as re}from"./im-business-message-parse-CemYOgNU.js";import{_ as le}from"./index-DNEphR7N.js";const ie={class:"im-chat-record-panel"},ce={class:"toolbar mb-3"},de={class:"chat-wrap"},_e={key:0,class:"chat-list"},ue={class:"meta"},fe={class:"name"},me={class:"time"},pe={class:"bubble"},ye={key:0,class:"friendly-text"},ge={class:"friendly-main"},ve={key:0,class:"friendly-sub"},he={key:1,class:"text-content"},we={key:2,class:"text-content"},be={key:3,class:"muted"},ke=F({__name:"ImChatRecordPanel",props:{diagnosisId:{}},setup(B,{expose:M}){const u=B,d=m(!1),p=m(!1),y=se([]),L=m(""),g=m(""),D={text:"",image:"图片",file:"文件",sound:"语音",video:"视频",location:"位置",custom:"自定义",face:"表情",other:""},h=U(()=>y.value.map(e=>{const s=P(e);let l="";return s!=null&&s.tag?l=s.tag:l=D[e.msg_type]||(e.msg_type!=="other"?e.msg_type:""),{raw:e,friendly:s,tag:l}}));function N(e){if(e==null||!e)return"—";const s=e>1e12?Math.floor(e/1e3):e;return te.unix(s).format("YYYY-MM-DD HH:mm:ss")}function P(e){const s=(e.text||"").trim();if(!s)return null;const l=s.startsWith("{")&&(/\bbusinessID\b/.test(s)||/\bcmd\b/.test(s));return e.msg_type==="custom"||l?re(s):null}function R(e){return e.is_from_doctor?e.from_staff_name?`医生(${e.from_staff_name}`:"医生/员工":g.value?`患者(${g.value}`:"患者"}async function w(){if(u.diagnosisId){d.value=!0;try{const e=await ne({diagnosis_id:u.diagnosisId,only_archived:1});y.value=(e==null?void 0:e.lists)||[],L.value=(e==null?void 0:e.patient_im_id)||"",g.value=(e==null?void 0:e.patient_name)||""}catch(e){console.error(e),y.value=[]}finally{d.value=!1}}}function b(){w()}async function S(){if(u.diagnosisId){p.value=!0;try{await oe({diagnosis_id:u.diagnosisId}),C.success('已发起后台同步,几秒后请点击"重新加载已归档"查看新消息')}catch(e){console.error(e),C.error("发起同步失败")}finally{p.value=!1}}}return q(()=>u.diagnosisId,()=>{w()},{immediate:!0}),M({refresh:b}),(e,s)=>{const l=G,k=H,I=j,T=Z,V=ee,Y=ae,z=W,A=Q;return t(),n("div",ie,[i(l,{type:"info","show-icon":"",closable:!1,class:"mb-4",title:"说明"},{default:c(()=>[...s[0]||(s[0]=[o("p",{class:"panel-tip"},[_(" 展示单聊记录:已合并患者 与 "),o("strong",null,"所有医生 / 医助账号"),_("分别产生的会话,按时间排序。 数据由后台定时任务从腾讯云 IM 漫游消息同步至本地归档;点击下方按钮可立即触发后台同步。 ")],-1)])]),_:1}),o("div",ce,[i(I,{type:"primary",link:"",loading:p.value,onClick:S},{default:c(()=>[i(k,{class:"mr-1"},{default:c(()=>[i(x($))]),_:1}),s[1]||(s[1]=_(" 同步最新(后台异步) ",-1))]),_:1},8,["loading"]),i(I,{type:"primary",link:"",loading:d.value,onClick:b},{default:c(()=>[i(k,{class:"mr-1"},{default:c(()=>[i(x(J))]),_:1}),s[2]||(s[2]=_(" 重新加载已归档 ",-1))]),_:1},8,["loading"])]),K((t(),n("div",de,[!d.value&&h.value.length?(t(),n("div",_e,[(t(!0),n(E,null,O(h.value,a=>(t(),n("div",{key:a.raw.msg_id,class:X(["chat-row",a.raw.is_from_doctor?"from-doctor":"from-patient"])},[o("div",ue,[o("span",fe,r(R(a.raw)),1),o("span",me,r(N(a.raw.time)),1),a.tag?(t(),f(T,{key:0,size:"small",type:"info",class:"ml-2"},{default:c(()=>[_(r(a.tag),1)]),_:2},1024)):v("",!0)]),o("div",pe,[a.raw.msg_type==="image"&&a.raw.image_url?(t(),f(V,{key:0,src:a.raw.image_url,"preview-src-list":[a.raw.image_url],fit:"contain",class:"chat-img"},null,8,["src","preview-src-list"])):(a.raw.msg_type==="file"||a.raw.msg_type==="sound"||a.raw.msg_type==="video")&&a.raw.file_url?(t(),f(Y,{key:1,href:a.raw.file_url,target:"_blank",type:"primary"},{default:c(()=>[_(r(a.raw.file_name||"打开文件"),1)]),_:2},1032,["href"])):(t(),n(E,{key:2},[a.friendly?(t(),n("div",ye,[o("div",ge,r(a.friendly.main),1),a.friendly.sub?(t(),n("div",ve,r(a.friendly.sub),1)):v("",!0)])):a.raw.msg_type==="text"&&a.raw.text?(t(),n("div",he,r(a.raw.text),1)):a.raw.text?(t(),n("div",we,r(a.raw.text),1)):(t(),n("span",be,"(无法展示该消息类型)"))],64))])],2))),128))])):d.value?v("",!0):(t(),f(z,{key:1,description:"暂无 IM 聊天记录"}))])),[[A,d.value]])])}}}),Me=le(ke,[["__scopeId","data-v-58b699dd"]]);export{Me as default};
@@ -1 +1 @@
import{_ as m}from"./MediaSourceSelect.vue_vue_type_script_setup_true_lang-iYm8xAAz.js";import"./.pnpm-CJoARskO.js";export{m as default};
import{_ as m}from"./MediaSourceSelect.vue_vue_type_script_setup_true_lang-Ds8_XODJ.js";import"./.pnpm-CC7b2w5D.js";export{m as default};
@@ -1 +1 @@
import{o as g,q as n,O as s,D as V,r as C,F as v,G as h,bn as B,u as c,P as p,t as w,ae as S,bm as k,p as i}from"./.pnpm-CJoARskO.js";const z=g({__name:"MediaSourceSelect",props:{modelValue:{default:""},options:{},loading:{type:Boolean,default:!1},placeholder:{default:"请选择自媒体来源"},clearable:{type:Boolean,default:!0},filterable:{type:Boolean,default:!0},allowCreate:{type:Boolean,default:!1},disabled:{type:Boolean,default:!1},selectClass:{},selectStyle:{}},emits:["update:modelValue","change","visible-change"],setup(e,{emit:m}){const u=e,o=m,d=i(()=>(u.options||[]).map(l=>typeof l=="string"?{name:l}:{name:l.name,value:l.value})),f=i(()=>d.value.some(l=>l.name===u.modelValue)),b=l=>{o("visible-change",l)};return(l,t)=>{const r=B,y=k;return n(),s(y,{"model-value":e.modelValue,placeholder:e.placeholder,clearable:e.clearable,filterable:e.filterable,"allow-create":e.allowCreate,"default-first-option":e.allowCreate,disabled:e.disabled,loading:e.loading,class:S(e.selectClass),style:w(e.selectStyle),"onUpdate:modelValue":t[0]||(t[0]=a=>o("update:modelValue",a)),onChange:t[1]||(t[1]=a=>o("change",a)),onVisibleChange:b},{default:V(()=>[(n(!0),C(v,null,h(c(d),a=>(n(),s(r,{key:a.name,label:a.name,value:a.name},null,8,["label","value"]))),128)),e.modelValue&&!c(f)?(n(),s(r,{key:`__legacy_${e.modelValue}`,label:`${e.modelValue}(已停用)`,value:e.modelValue},null,8,["label","value"])):p("",!0)]),_:1},8,["model-value","placeholder","clearable","filterable","allow-create","default-first-option","disabled","loading","class","style"])}}});export{z as _};
import{o as g,q as n,O as s,D as V,r as C,F as v,G as h,bn as B,u as c,P as p,t as w,ae as S,bm as k,p as i}from"./.pnpm-CC7b2w5D.js";const z=g({__name:"MediaSourceSelect",props:{modelValue:{default:""},options:{},loading:{type:Boolean,default:!1},placeholder:{default:"请选择自媒体来源"},clearable:{type:Boolean,default:!0},filterable:{type:Boolean,default:!0},allowCreate:{type:Boolean,default:!1},disabled:{type:Boolean,default:!1},selectClass:{},selectStyle:{}},emits:["update:modelValue","change","visible-change"],setup(e,{emit:m}){const u=e,o=m,d=i(()=>(u.options||[]).map(l=>typeof l=="string"?{name:l}:{name:l.name,value:l.value})),f=i(()=>d.value.some(l=>l.name===u.modelValue)),b=l=>{o("visible-change",l)};return(l,t)=>{const r=B,y=k;return n(),s(y,{"model-value":e.modelValue,placeholder:e.placeholder,clearable:e.clearable,filterable:e.filterable,"allow-create":e.allowCreate,"default-first-option":e.allowCreate,disabled:e.disabled,loading:e.loading,class:S(e.selectClass),style:w(e.selectStyle),"onUpdate:modelValue":t[0]||(t[0]=a=>o("update:modelValue",a)),onChange:t[1]||(t[1]=a=>o("change",a)),onVisibleChange:b},{default:V(()=>[(n(!0),C(v,null,h(c(d),a=>(n(),s(r,{key:a.name,label:a.name,value:a.name},null,8,["label","value"]))),128)),e.modelValue&&!c(f)?(n(),s(r,{key:`__legacy_${e.modelValue}`,label:`${e.modelValue}(已停用)`,value:e.modelValue},null,8,["label","value"])):p("",!0)]),_:1},8,["model-value","placeholder","clearable","filterable","allow-create","default-first-option","disabled","loading","class","style"])}}});export{z as _};
@@ -1 +1 @@
import{o as S,q as a,r as l,ae as x,v as r,D as m,L as d,T as i,a0 as D,s as n,O as g,br as G,P as B,aa as K,u as v,bE as L,w as q,bF as A,bG as H,bH as O,F as P,K as U,p as u}from"./.pnpm-CJoARskO.js";import{t as j,_ as J}from"./index-DGIIeNfq.js";const Q={class:"msg-body"},R={class:"msg-meta"},W={class:"msg-sender"},X={class:"msg-time"},Y={key:0,class:"content-text"},Z={key:2,class:"content-pending"},ee=["src"],se={key:4,class:"content-pending"},te=["src"],ae={key:6,class:"content-pending"},ne={key:7,class:"content-file"},le={class:"file-meta"},ie=["title"],oe={class:"file-info"},ce={key:8,class:"content-fallback"},re={class:"fallback-title"},me={class:"fallback-desc"},ue=S({__name:"MessageBubble",props:{message:{}},setup(s){const o=s,w=u(()=>o.message.from_is_staff),c=u(()=>(o.message.media||[]).find(t=>t.status===1)??null),_=u(()=>{const e=o.message.from_profile;return e&&e.name||o.message.from_user}),z=u(()=>{const e=o.message.from_profile;return(e==null?void 0:e.avatar)||""}),F=u(()=>(_.value||"").slice(-2)||"?"),E=u(()=>{const e=o.message.msgtype;return e==="image"||e==="video"||e==="voice"?"bubble-media":e==="file"?"bubble-file":"bubble-text"}),C=u(()=>{switch(o.message.msgtype){case"link":return"[链接]";case"weapp":return"[小程序]";case"chatrecord":return"[聊天记录]";case"location":return"[位置]";case"card":return"[名片]";case"meeting":return"[会议]";case"docmsg":return"[文档]";case"mixed":return"[混合消息]";case"emotion":return"[表情]";default:return`[${o.message.msgtype}]`}});function M(e){return e?j(e*1e3,"mm-dd hh:MM"):""}function N(e){return e?e<1024?`${e} B`:e<1024*1024?`${(e/1024).toFixed(1)} KB`:e<1024*1024*1024?`${(e/1024/1024).toFixed(2)} MB`:`${(e/1024/1024/1024).toFixed(2)} GB`:""}function $(e){window.open(e,"_blank","noopener")}return(e,t)=>{var k,y,p,h;const T=D,b=G,V=K,f=q,I=U;return a(),l("div",{class:x(["msg-bubble",{"is-staff":w.value}])},[r(T,{class:"msg-avatar",size:36,src:z.value},{default:m(()=>[d(i(F.value),1)]),_:1},8,["src"]),n("div",Q,[n("div",R,[n("span",W,i(_.value),1),n("span",X,i(M(s.message.send_time)),1),s.message.action==="recall"?(a(),g(b,{key:0,size:"small",type:"info"},{default:m(()=>[...t[1]||(t[1]=[d(" 已撤回 ",-1)])]),_:1})):B("",!0)]),n("div",{class:x(["msg-content",E.value])},[s.message.msgtype==="text"||s.message.msgtype==="markdown"?(a(),l("div",Y,i(s.message.content),1)):s.message.msgtype==="image"&&((k=c.value)!=null&&k.file_url)?(a(),g(V,{key:1,src:c.value.file_url,"preview-src-list":[c.value.file_url],fit:"contain",class:"content-image","hide-on-click-modal":""},null,8,["src","preview-src-list"])):s.message.msgtype==="image"?(a(),l("div",Z,[r(f,null,{default:m(()=>[r(v(L))]),_:1}),t[2]||(t[2]=n("span",null,"图片下载中…",-1))])):s.message.msgtype==="video"&&((y=c.value)!=null&&y.file_url)?(a(),l("video",{key:3,src:c.value.file_url,controls:"",class:"content-video"},null,8,ee)):s.message.msgtype==="video"?(a(),l("div",se,[r(f,null,{default:m(()=>[r(v(A))]),_:1}),t[3]||(t[3]=n("span",null,"视频下载中…",-1))])):s.message.msgtype==="voice"&&((p=c.value)!=null&&p.file_url)?(a(),l("audio",{key:5,src:c.value.file_url,controls:"",class:"content-audio"},null,8,te)):s.message.msgtype==="voice"?(a(),l("div",ae,[r(f,null,{default:m(()=>[r(v(H))]),_:1}),n("span",null,"语音下载中…("+i(s.message.play_length)+"s",1)])):s.message.msgtype==="file"?(a(),l("div",ne,[r(f,{size:24},{default:m(()=>[r(v(O))]),_:1}),n("div",le,[n("div",{class:"file-name",title:s.message.file_name},i(s.message.file_name||"未命名文件"),9,ie),n("div",oe,[d(i(N(s.message.file_size))+" ",1),s.message.file_ext?(a(),l(P,{key:0},[d("· "+i(s.message.file_ext),1)],64)):B("",!0)])]),(h=c.value)!=null&&h.file_url?(a(),g(I,{key:0,size:"small",type:"primary",link:"",onClick:t[0]||(t[0]=de=>$(c.value.file_url))},{default:m(()=>[...t[4]||(t[4]=[d(" 下载 ",-1)])]),_:1})):(a(),g(b,{key:1,size:"small",type:"info"},{default:m(()=>[...t[5]||(t[5]=[d("下载中…",-1)])]),_:1}))])):(a(),l("div",ce,[n("div",re,i(C.value),1),n("div",me,i(s.message.content),1)]))],2)])],2)}}}),ve=J(ue,[["__scopeId","data-v-b5ff1168"]]);export{ve as default};
import{o as S,q as a,r as l,ae as x,v as r,D as m,L as d,T as i,a0 as D,s as n,O as g,br as G,P as B,aa as K,u as v,bE as L,w as q,bF as A,bG as H,bH as O,F as P,K as U,p as u}from"./.pnpm-CC7b2w5D.js";import{t as j,_ as J}from"./index-DNEphR7N.js";const Q={class:"msg-body"},R={class:"msg-meta"},W={class:"msg-sender"},X={class:"msg-time"},Y={key:0,class:"content-text"},Z={key:2,class:"content-pending"},ee=["src"],se={key:4,class:"content-pending"},te=["src"],ae={key:6,class:"content-pending"},ne={key:7,class:"content-file"},le={class:"file-meta"},ie=["title"],oe={class:"file-info"},ce={key:8,class:"content-fallback"},re={class:"fallback-title"},me={class:"fallback-desc"},ue=S({__name:"MessageBubble",props:{message:{}},setup(s){const o=s,w=u(()=>o.message.from_is_staff),c=u(()=>(o.message.media||[]).find(t=>t.status===1)??null),_=u(()=>{const e=o.message.from_profile;return e&&e.name||o.message.from_user}),z=u(()=>{const e=o.message.from_profile;return(e==null?void 0:e.avatar)||""}),F=u(()=>(_.value||"").slice(-2)||"?"),E=u(()=>{const e=o.message.msgtype;return e==="image"||e==="video"||e==="voice"?"bubble-media":e==="file"?"bubble-file":"bubble-text"}),C=u(()=>{switch(o.message.msgtype){case"link":return"[链接]";case"weapp":return"[小程序]";case"chatrecord":return"[聊天记录]";case"location":return"[位置]";case"card":return"[名片]";case"meeting":return"[会议]";case"docmsg":return"[文档]";case"mixed":return"[混合消息]";case"emotion":return"[表情]";default:return`[${o.message.msgtype}]`}});function M(e){return e?j(e*1e3,"mm-dd hh:MM"):""}function N(e){return e?e<1024?`${e} B`:e<1024*1024?`${(e/1024).toFixed(1)} KB`:e<1024*1024*1024?`${(e/1024/1024).toFixed(2)} MB`:`${(e/1024/1024/1024).toFixed(2)} GB`:""}function $(e){window.open(e,"_blank","noopener")}return(e,t)=>{var k,y,p,h;const T=D,b=G,V=K,f=q,I=U;return a(),l("div",{class:x(["msg-bubble",{"is-staff":w.value}])},[r(T,{class:"msg-avatar",size:36,src:z.value},{default:m(()=>[d(i(F.value),1)]),_:1},8,["src"]),n("div",Q,[n("div",R,[n("span",W,i(_.value),1),n("span",X,i(M(s.message.send_time)),1),s.message.action==="recall"?(a(),g(b,{key:0,size:"small",type:"info"},{default:m(()=>[...t[1]||(t[1]=[d(" 已撤回 ",-1)])]),_:1})):B("",!0)]),n("div",{class:x(["msg-content",E.value])},[s.message.msgtype==="text"||s.message.msgtype==="markdown"?(a(),l("div",Y,i(s.message.content),1)):s.message.msgtype==="image"&&((k=c.value)!=null&&k.file_url)?(a(),g(V,{key:1,src:c.value.file_url,"preview-src-list":[c.value.file_url],fit:"contain",class:"content-image","hide-on-click-modal":""},null,8,["src","preview-src-list"])):s.message.msgtype==="image"?(a(),l("div",Z,[r(f,null,{default:m(()=>[r(v(L))]),_:1}),t[2]||(t[2]=n("span",null,"图片下载中…",-1))])):s.message.msgtype==="video"&&((y=c.value)!=null&&y.file_url)?(a(),l("video",{key:3,src:c.value.file_url,controls:"",class:"content-video"},null,8,ee)):s.message.msgtype==="video"?(a(),l("div",se,[r(f,null,{default:m(()=>[r(v(A))]),_:1}),t[3]||(t[3]=n("span",null,"视频下载中…",-1))])):s.message.msgtype==="voice"&&((p=c.value)!=null&&p.file_url)?(a(),l("audio",{key:5,src:c.value.file_url,controls:"",class:"content-audio"},null,8,te)):s.message.msgtype==="voice"?(a(),l("div",ae,[r(f,null,{default:m(()=>[r(v(H))]),_:1}),n("span",null,"语音下载中…("+i(s.message.play_length)+"s",1)])):s.message.msgtype==="file"?(a(),l("div",ne,[r(f,{size:24},{default:m(()=>[r(v(O))]),_:1}),n("div",le,[n("div",{class:"file-name",title:s.message.file_name},i(s.message.file_name||"未命名文件"),9,ie),n("div",oe,[d(i(N(s.message.file_size))+" ",1),s.message.file_ext?(a(),l(P,{key:0},[d("· "+i(s.message.file_ext),1)],64)):B("",!0)])]),(h=c.value)!=null&&h.file_url?(a(),g(I,{key:0,size:"small",type:"primary",link:"",onClick:t[0]||(t[0]=de=>$(c.value.file_url))},{default:m(()=>[...t[4]||(t[4]=[d(" 下载 ",-1)])]),_:1})):(a(),g(b,{key:1,size:"small",type:"info"},{default:m(()=>[...t[5]||(t[5]=[d("下载中…",-1)])]),_:1}))])):(a(),l("div",ce,[n("div",re,i(C.value),1),n("div",me,i(s.message.content),1)]))],2)])],2)}}}),ve=J(ue,[["__scopeId","data-v-b5ff1168"]]);export{ve as default};
@@ -1,2 +1,2 @@
import{o as ie,R as oe,q as n,r as o,O as k,K as le,D as r,L as P,P as m,v as l,s as d,F as w,G as E,bB as ae,b7 as re,bt as de,p as ue,M as g,T as U,aa as me,u as V,d5 as z,aw as M,w as ce,bH as pe,e as ge}from"./.pnpm-CJoARskO.js";import{_ as fe}from"./picker-HtQX0ZkW.js";import{e as ve,c as _e,i as f,_ as ye}from"./index-DGIIeNfq.js";import{a as T,d as he}from"./patient-B5KvakhF.js";import{h as ke}from"./perm-DQosAa6e.js";import"./index-CMaTIYFB.js";import"./index-yt_wDMDM.js";import"./index.vue_vue_type_script_setup_true_lang-CI_v6sRz.js";import"./index-Dxb7lLkm.js";import"./index-D30MriBb.js";import"./file-DApWCRJt.js";import"./index.vue_vue_type_script_setup_true_lang-rPwCQIWi.js";import"./usePaging-DKtm3XdH.js";const we={class:"note-timeline-wrap"},Ie={key:0,class:"timeline-actions"},Ce={class:"upload-trigger"},be={class:"upload-trigger"},xe={key:1,class:"note-timeline"},Ee={class:"timeline-date"},Ve={class:"timeline-body"},Ne={key:0,class:"timeline-content"},Se={key:1,class:"timeline-images"},De={key:2,class:"timeline-images"},Be={key:0,class:"thumb-wrap"},Ae={key:1,class:"file-wrap"},Pe=["href","title"],Ue={class:"file-name"},W=8e3,ze=ie({__name:"NoteTimeline",props:{notes:{},diagnosisId:{},readonly:{type:Boolean}},emits:["refresh"],setup(u,{emit:j}){const c=u,I=j,X=ue(()=>ke(["doctor.appointment/addDoctorNote"])),v=g(!1),C=g(""),N=g(!1),q=ve(),_=t=>q.getImageUrl(t),S=g([]),D=g([]),y=g(0),h=g(0),H=["jpg","jpeg","png","gif","bmp","webp","svg"],B=t=>{var i;const e=((i=t.split(".").pop())==null?void 0:i.toLowerCase().split("?")[0])||"";return H.includes(e)},$=t=>{var i;const e=t.split("/");return decodeURIComponent(((i=e[e.length-1])==null?void 0:i.split("?")[0])||"文件")},K=t=>t.filter(B).map(_),Z=(t,e)=>{const i=t.filter(B),b=t[e];return i.indexOf(b)},J=t=>t?t.split(`
import{o as ie,R as oe,q as n,r as o,O as k,K as le,D as r,L as P,P as m,v as l,s as d,F as w,G as E,bB as ae,b7 as re,bt as de,p as ue,M as g,T as U,aa as me,u as V,d7 as z,aw as M,w as ce,bH as pe,e as ge}from"./.pnpm-CC7b2w5D.js";import{_ as fe}from"./picker-CWjtHu2u.js";import{e as ve,c as _e,i as f,_ as ye}from"./index-DNEphR7N.js";import{a as T,d as he}from"./patient-Db-pZv1c.js";import{h as ke}from"./perm-Bmx-4ilk.js";import"./index-CMrRViEP.js";import"./index-CFIUBLbf.js";import"./index.vue_vue_type_script_setup_true_lang-DHXV5Zdc.js";import"./index-BD56dHco.js";import"./index-CDdNeWUD.js";import"./file-DYMdTeVi.js";import"./index.vue_vue_type_script_setup_true_lang-CYjIL6rl.js";import"./usePaging-Dpj0ZiHV.js";const we={class:"note-timeline-wrap"},Ie={key:0,class:"timeline-actions"},Ce={class:"upload-trigger"},be={class:"upload-trigger"},xe={key:1,class:"note-timeline"},Ee={class:"timeline-date"},Ve={class:"timeline-body"},Ne={key:0,class:"timeline-content"},Se={key:1,class:"timeline-images"},De={key:2,class:"timeline-images"},Be={key:0,class:"thumb-wrap"},Ae={key:1,class:"file-wrap"},Pe=["href","title"],Ue={class:"file-name"},W=8e3,ze=ie({__name:"NoteTimeline",props:{notes:{},diagnosisId:{},readonly:{type:Boolean}},emits:["refresh"],setup(u,{emit:j}){const c=u,I=j,X=ue(()=>ke(["doctor.appointment/addDoctorNote"])),v=g(!1),C=g(""),N=g(!1),q=ve(),_=t=>q.getImageUrl(t),S=g([]),D=g([]),y=g(0),h=g(0),H=["jpg","jpeg","png","gif","bmp","webp","svg"],B=t=>{var i;const e=((i=t.split(".").pop())==null?void 0:i.toLowerCase().split("?")[0])||"";return H.includes(e)},$=t=>{var i;const e=t.split("/");return decodeURIComponent(((i=e[e.length-1])==null?void 0:i.split("?")[0])||"文件")},K=t=>t.filter(B).map(_),Z=(t,e)=>{const i=t.filter(B),b=t[e];return i.indexOf(b)},J=t=>t?t.split(`
`).filter(Boolean):[],Q=t=>{if(!c.diagnosisId)return;const e=Array.isArray(t)?t:[t];if(e.length<=y.value){y.value=e.length;return}const i=e.slice(y.value);y.value=e.length,i.length>0&&T({diagnosis_id:c.diagnosisId,tongue_images:i}).then(()=>{f.msgSuccess("舌苔照片已添加"),I("refresh")})},Y=t=>{if(!c.diagnosisId)return;const e=Array.isArray(t)?t:[t];if(e.length<=h.value){h.value=e.length;return}const i=e.slice(h.value);h.value=e.length,i.length>0&&T({diagnosis_id:c.diagnosisId,report_files:i}).then(()=>{f.msgSuccess("检查报告已添加"),I("refresh")})};oe(()=>c.notes,()=>{S.value=[],D.value=[],y.value=0,h.value=0});const ee=async()=>{if(!c.diagnosisId){f.msgWarning("当前无诊单,无法添加备注");return}const t=C.value.trim();if(!t){f.msgWarning("请输入备注内容");return}N.value=!0;try{await T({diagnosis_id:c.diagnosisId,content:t}),f.msgSuccess("备注保存成功"),C.value="",v.value=!1,I("refresh")}catch(e){f.msgError((e==null?void 0:e.message)||"保存失败")}finally{N.value=!1}},A=async(t,e,i)=>{try{await ge.confirm("确认删除?","提示",{type:"warning"})}catch{return}await he({note_id:t,image_type:e,image_path:i}),f.msgSuccess("已删除"),I("refresh")};return(t,e)=>{const i=le,b=_e,F=fe,L=me,x=ce,te=ae,se=re,ne=de;return n(),o("div",we,[!u.readonly&&u.diagnosisId?(n(),o("div",Ie,[X.value?(n(),k(i,{key:0,type:"primary",plain:"",size:"small",onClick:e[0]||(e[0]=s=>v.value=!0)},{default:r(()=>[...e[6]||(e[6]=[P(" 添加备注 ",-1)])]),_:1})):m("",!0),l(F,{modelValue:S.value,"onUpdate:modelValue":e[1]||(e[1]=s=>S.value=s),limit:99,type:"image","exclude-domain":!0,onChange:Q},{upload:r(()=>[d("div",Ce,[l(b,{size:20,name:"el-icon-Plus"}),e[7]||(e[7]=d("span",null,"舌苔照片",-1))])]),_:1},8,["modelValue"]),l(F,{modelValue:D.value,"onUpdate:modelValue":e[2]||(e[2]=s=>D.value=s),limit:99,type:"file","exclude-domain":!0,onChange:Y},{upload:r(()=>[d("div",be,[l(b,{size:20,name:"el-icon-Plus"}),e[8]||(e[8]=d("span",null,"检查报告",-1))])]),_:1},8,["modelValue"])])):m("",!0),u.notes.length?(n(),o("div",xe,[(n(!0),o(w,null,E(u.notes,s=>{var R,G;return n(),o("div",{key:s.id,class:"timeline-node"},[e[11]||(e[11]=d("div",{class:"timeline-dot"},null,-1)),d("div",Ee,U(s.note_date),1),d("div",Ve,[s.content?(n(),o("div",Ne,[(n(!0),o(w,null,E(J(s.content),(a,p)=>(n(),o("div",{key:p,class:"content-line"},U(a),1))),128))])):m("",!0),(R=s.tongue_images)!=null&&R.length?(n(),o("div",Se,[e[9]||(e[9]=d("span",{class:"images-label"},"舌苔照片",-1)),(n(!0),o(w,null,E(s.tongue_images,(a,p)=>(n(),o("div",{key:p,class:"thumb-wrap"},[l(L,{src:_(a),"preview-src-list":s.tongue_images.map(_),"initial-index":p,"z-index":W,fit:"cover",class:"timeline-thumb","preview-teleported":""},null,8,["src","preview-src-list","initial-index"]),u.readonly?m("",!0):(n(),k(x,{key:0,class:"thumb-delete",onClick:M(O=>A(s.id,"tongue_images",a),["stop"])},{default:r(()=>[l(V(z))]),_:1},8,["onClick"]))]))),128))])):m("",!0),(G=s.report_files)!=null&&G.length?(n(),o("div",De,[e[10]||(e[10]=d("span",{class:"images-label"},"检查报告",-1)),(n(!0),o(w,null,E(s.report_files,(a,p)=>(n(),o(w,{key:p},[B(a)?(n(),o("div",Be,[l(L,{src:_(a),"preview-src-list":K(s.report_files),"initial-index":Z(s.report_files,p),"z-index":W,fit:"cover",class:"timeline-thumb","preview-teleported":""},null,8,["src","preview-src-list","initial-index"]),u.readonly?m("",!0):(n(),k(x,{key:0,class:"thumb-delete",onClick:M(O=>A(s.id,"report_files",a),["stop"])},{default:r(()=>[l(V(z))]),_:1},8,["onClick"]))])):(n(),o("div",Ae,[d("a",{href:_(a),target:"_blank",class:"file-link",title:$(a)},[l(x,{size:20},{default:r(()=>[l(V(pe))]),_:1}),d("span",Ue,U($(a)),1)],8,Pe),u.readonly?m("",!0):(n(),k(x,{key:0,class:"file-delete",onClick:M(O=>A(s.id,"report_files",a),["stop"])},{default:r(()=>[l(V(z))]),_:1},8,["onClick"]))]))],64))),128))])):m("",!0)])])}),128))])):m("",!0),!u.notes.length&&u.readonly?(n(),k(te,{key:2,description:"暂无备注","image-size":48})):m("",!0),l(ne,{modelValue:v.value,"onUpdate:modelValue":e[5]||(e[5]=s=>v.value=s),title:"添加备注",width:"480px","append-to-body":""},{footer:r(()=>[l(i,{onClick:e[4]||(e[4]=s=>v.value=!1)},{default:r(()=>[...e[12]||(e[12]=[P("取消",-1)])]),_:1}),l(i,{type:"primary",loading:N.value,onClick:ee},{default:r(()=>[...e[13]||(e[13]=[P("保存",-1)])]),_:1},8,["loading"])]),default:r(()=>[l(se,{modelValue:C.value,"onUpdate:modelValue":e[3]||(e[3]=s=>C.value=s),type:"textarea",rows:4,placeholder:"输入今日备注...",maxlength:"500","show-word-limit":""},null,8,["modelValue"])]),_:1},8,["modelValue"])])}}}),Ke=ye(ze,[["__scopeId","data-v-530ad386"]]);export{Ke as default};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
.embedded-panel[data-v-78709492]{min-height:420px}.panel-toolbar[data-v-78709492]{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:14px}.panel-toolbar h2[data-v-78709492]{margin:0;font-size:17px}.panel-toolbar p[data-v-78709492]{margin:4px 0 0;color:#8a95a6;font-size:12px}.toolbar-actions[data-v-78709492],.scope-chip[data-v-78709492]{display:flex;align-items:center;gap:8px}.scope-chip[data-v-78709492]{color:#0f766e;font-size:12px}.filter-panel[data-v-78709492]{display:flex;align-items:center;flex-wrap:wrap;gap:10px;padding:14px;border:1px solid #e3e8ef;border-radius:10px;background:#fbfcfd}.filter-panel[data-v-78709492] .el-select{width:132px}.keyword-input[data-v-78709492]{width:min(340px,100%)}.date-range[data-v-78709492]{width:250px}.metric-grid[data-v-78709492]{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px;margin:14px 0}.metric-card[data-v-78709492]{min-height:82px;padding:14px 16px;border:1px solid #e3e8ef;border-radius:10px;background:#fff}.metric-card span[data-v-78709492],.metric-card small[data-v-78709492]{color:#8a95a6;font-size:12px}.metric-card strong[data-v-78709492]{display:block;margin:7px 0 3px;color:#172033;font-size:22px;line-height:1}.metric-warning[data-v-78709492]{border-color:#f3d8aa;background:#fffcf5}.metric-success[data-v-78709492]{border-color:#b9e2dc;background:#f7fcfb}.embedded-table[data-v-78709492]{width:100%;border:1px solid #e7ebf0;border-radius:9px;overflow:hidden}.embedded-table[data-v-78709492] th.el-table__cell{height:44px;color:#5f6b7d;background:#f7f9fb;font-weight:600}.embedded-table[data-v-78709492] .order-row-risk>td.el-table__cell{background:#fff8f7}.embedded-table[data-v-78709492] .order-row-done>td.el-table__cell{background:#f8fcfb}.primary-cell[data-v-78709492],.id-stack[data-v-78709492]{display:flex;flex-direction:column;gap:3px}.primary-cell strong[data-v-78709492],.id-stack strong[data-v-78709492]{color:#202939;font-size:13px}.primary-cell span[data-v-78709492],.id-stack span[data-v-78709492]{color:#8b96a8;font-size:12px}.amount[data-v-78709492]{color:#d04f3f;font-variant-numeric:tabular-nums}.order-actions[data-v-78709492]{display:flex;align-items:center;gap:4px;white-space:nowrap}.order-actions[data-v-78709492] .el-button+.el-button{margin-left:0}.danger-menu-item{color:var(--el-color-danger)!important}.pagination-wrap[data-v-78709492]{display:flex;justify-content:flex-end;padding-top:16px}@media (max-width: 1080px){.metric-grid[data-v-78709492]{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (max-width: 760px){.panel-toolbar[data-v-78709492]{align-items:flex-start;flex-direction:column}.metric-grid[data-v-78709492]{grid-template-columns:1fr}.filter-panel[data-v-78709492]>*,.filter-panel[data-v-78709492] .el-select,.date-range[data-v-78709492]{width:100%}}
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
import{m as I,f as p,a as w}from"./diag-display-DCz_VAqj.js";import{o as y,q as x,r as B,s as e,T as a,u as i,P as N}from"./.pnpm-CJoARskO.js";import{_ as V}from"./index-DGIIeNfq.js";const b={class:"card patient-card"},q={class:"patient-hero"},D={class:"patient-name"},E={class:"patient-meta"},G={key:0},S=y({__name:"PatientInfoCard",props:{apt:{default:()=>({})},diag:{default:()=>({})}},setup(t){return(T,n)=>{var d,s,o,c,m,l,r,g,u,f,h,v,k,P,C;return x(),B("div",b,[n[0]||(n[0]=e("div",{class:"card-title"},"患者信息",-1)),e("div",q,[e("div",D,a(((d=t.apt)==null?void 0:d.patient_name)||"—"),1),e("div",E,[e("div",null,a(i(I)((s=t.apt)==null?void 0:s.patient_phone))+" · "+a(i(p)((o=t.diag)==null?void 0:o.gender))+" · "+a(((c=t.diag)==null?void 0:c.age)!=null?t.diag.age+"岁":"—"),1),e("div",null,a((m=t.diag)!=null&&m.height?t.diag.height+"cm":"—")+" / "+a((l=t.diag)!=null&&l.weight?t.diag.weight+"kg":"—")+" · "+a(((r=t.diag)==null?void 0:r.region)||"—"),1),e("div",null," 预约:"+a((g=t.apt)==null?void 0:g.appointment_date)+" "+a((u=t.apt)==null?void 0:u.appointment_time)+" · "+a(i(w)((f=t.apt)==null?void 0:f.period)),1),e("div",null,"医生:"+a(((h=t.apt)==null?void 0:h.doctor_name)||"—")+" 客服:"+a(((v=t.apt)==null?void 0:v.assistant_name)||"—"),1),e("div",null," 状态:"+a(((k=t.apt)==null?void 0:k.status_desc)||"—")+" · "+a((P=t.apt)!=null&&P.has_prescription?"已开方":"未开方"),1),(C=t.apt)!=null&&C.remark?(x(),B("div",G,"备注:"+a(t.apt.remark),1)):N("",!0)])])])}}}),F=V(S,[["__scopeId","data-v-1b0de09c"]]);export{F as default};
import{m as I,f as p,a as w}from"./diag-display-DCz_VAqj.js";import{o as y,q as x,r as B,s as e,T as a,u as i,P as N}from"./.pnpm-CC7b2w5D.js";import{_ as V}from"./index-DNEphR7N.js";const b={class:"card patient-card"},q={class:"patient-hero"},D={class:"patient-name"},E={class:"patient-meta"},G={key:0},S=y({__name:"PatientInfoCard",props:{apt:{default:()=>({})},diag:{default:()=>({})}},setup(t){return(T,n)=>{var d,s,o,c,m,l,r,g,u,f,h,v,k,P,C;return x(),B("div",b,[n[0]||(n[0]=e("div",{class:"card-title"},"患者信息",-1)),e("div",q,[e("div",D,a(((d=t.apt)==null?void 0:d.patient_name)||"—"),1),e("div",E,[e("div",null,a(i(I)((s=t.apt)==null?void 0:s.patient_phone))+" · "+a(i(p)((o=t.diag)==null?void 0:o.gender))+" · "+a(((c=t.diag)==null?void 0:c.age)!=null?t.diag.age+"岁":"—"),1),e("div",null,a((m=t.diag)!=null&&m.height?t.diag.height+"cm":"—")+" / "+a((l=t.diag)!=null&&l.weight?t.diag.weight+"kg":"—")+" · "+a(((r=t.diag)==null?void 0:r.region)||"—"),1),e("div",null," 预约:"+a((g=t.apt)==null?void 0:g.appointment_date)+" "+a((u=t.apt)==null?void 0:u.appointment_time)+" · "+a(i(w)((f=t.apt)==null?void 0:f.period)),1),e("div",null,"医生:"+a(((h=t.apt)==null?void 0:h.doctor_name)||"—")+" 客服:"+a(((v=t.apt)==null?void 0:v.assistant_name)||"—"),1),e("div",null," 状态:"+a(((k=t.apt)==null?void 0:k.status_desc)||"—")+" · "+a((P=t.apt)!=null&&P.has_prescription?"已开方":"未开方"),1),(C=t.apt)!=null&&C.remark?(x(),B("div",G,"备注:"+a(t.apt.remark),1)):N("",!0)])])])}}}),F=V(S,[["__scopeId","data-v-1b0de09c"]]);export{F as default};
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
import{o as L,q as i,r as c,s as d,F as v,O as m,bq as q,D as _,L as k,P as y,G as B,p as f,T as w}from"./.pnpm-CJoARskO.js";import H from"./RecordingVideoPlayer-qwMasORO.js";import{e as I,_ as P}from"./index-DGIIeNfq.js";const R={key:0,class:"recording-list"},C={class:"recording-item"},V={key:1,class:"recording-alternates"},N={class:"recording-alternates__links"},D={key:1,class:"text-gray-400"},E=L({__name:"RecordingPlaybackBlock",props:{recordId:{},urls:{}},setup(l){const $=l,h=I(),u=f(()=>{const e=$.urls||[],t=new Set,r=[];for(const o of e){const s=String(o??"").trim();!s||t.has(s)||(t.add(s),r.push(s))}return r}),a=f(()=>{const e=u.value;if(!e.length)return null;const t=e.find(n=>/\.mp4(\?|#|$)/i.test(n));if(t)return t;const r=e.find(n=>/\.m3u8(\?|#|$)/i.test(n)&&/vod-qcloud\.com/i.test(n));if(r)return r;const o=e.find(n=>/\.m3u8(\?|#|$)/i.test(n)&&/\.cos\.[^/]+\.myqcloud\.com/i.test(n));if(o)return o;const s=e.find(n=>/\.m3u8(\?|#|$)/i.test(n));return s||e[0]}),p=f(()=>{const e=u.value,t=a.value;return t?e.filter(r=>r!==t):e.slice(1)});function b(e){if(!e||typeof e!="string")return!1;const t=e.trim();return/^https?:\/\//i.test(t)?/\.(mp4|webm|ogg|mov|mkv|m4v|m3u8)(\?|#|$)/i.test(t):/\.(mp4|webm|ogg|mov|mkv|m4v|m3u8)(\?|#|$)/i.test(t)||t.startsWith("/uploads/")}function g(e){return h.getImageUrl(String(e||"").trim())}function S(e,t){const r=e.trim();return/\.mp4(\?|#|$)/i.test(r)?`MP4 ${t+1}`:/vod-qcloud\.com/i.test(r)&&/\.m3u8/i.test(r)?`点播 ${t+1}`:/\.cos\.[^/]+\.myqcloud\.com/i.test(r)&&/\.m3u8/i.test(r)?`COS HLS ${t+1}`:/\.m3u8/i.test(r)?`HLS ${t+1}`:`链接 ${t+1}`}return(e,t)=>{const r=q;return u.value.length?(i(),c("div",R,[d("div",C,[a.value?(i(),c(v,{key:0},[b(a.value)?(i(),m(H,{key:`${l.recordId}-${a.value}`,src:a.value},null,8,["src"])):(i(),m(r,{key:1,href:g(a.value),target:"_blank",type:"primary"},{default:_(()=>[...t[0]||(t[0]=[k(" 打开回放 ",-1)])]),_:1},8,["href"]))],64)):y("",!0),p.value.length?(i(),c("div",V,[t[1]||(t[1]=d("span",{class:"recording-alternates__label"},"备用地址",-1)),d("div",N,[(i(!0),c(v,null,B(p.value,(o,s)=>(i(),m(r,{key:`${l.recordId}-alt-${s}-${o.slice(-32)}`,href:g(o),target:"_blank",type:"primary",class:"recording-alternates__link"},{default:_(()=>[k(w(S(o,s)),1)]),_:2},1032,["href"]))),128))])])):y("",!0)])])):(i(),c("span",D,"暂无"))}}}),x=P(E,[["__scopeId","data-v-d67b2e91"]]);export{x as default};
import{o as L,q as i,r as c,s as d,F as v,O as m,bq as q,D as _,L as k,P as y,G as B,p as f,T as w}from"./.pnpm-CC7b2w5D.js";import H from"./RecordingVideoPlayer-B483TIrn.js";import{e as I,_ as P}from"./index-DNEphR7N.js";const R={key:0,class:"recording-list"},C={class:"recording-item"},V={key:1,class:"recording-alternates"},N={class:"recording-alternates__links"},D={key:1,class:"text-gray-400"},E=L({__name:"RecordingPlaybackBlock",props:{recordId:{},urls:{}},setup(l){const $=l,h=I(),u=f(()=>{const e=$.urls||[],t=new Set,r=[];for(const o of e){const s=String(o??"").trim();!s||t.has(s)||(t.add(s),r.push(s))}return r}),a=f(()=>{const e=u.value;if(!e.length)return null;const t=e.find(n=>/\.mp4(\?|#|$)/i.test(n));if(t)return t;const r=e.find(n=>/\.m3u8(\?|#|$)/i.test(n)&&/vod-qcloud\.com/i.test(n));if(r)return r;const o=e.find(n=>/\.m3u8(\?|#|$)/i.test(n)&&/\.cos\.[^/]+\.myqcloud\.com/i.test(n));if(o)return o;const s=e.find(n=>/\.m3u8(\?|#|$)/i.test(n));return s||e[0]}),p=f(()=>{const e=u.value,t=a.value;return t?e.filter(r=>r!==t):e.slice(1)});function b(e){if(!e||typeof e!="string")return!1;const t=e.trim();return/^https?:\/\//i.test(t)?/\.(mp4|webm|ogg|mov|mkv|m4v|m3u8)(\?|#|$)/i.test(t):/\.(mp4|webm|ogg|mov|mkv|m4v|m3u8)(\?|#|$)/i.test(t)||t.startsWith("/uploads/")}function g(e){return h.getImageUrl(String(e||"").trim())}function S(e,t){const r=e.trim();return/\.mp4(\?|#|$)/i.test(r)?`MP4 ${t+1}`:/vod-qcloud\.com/i.test(r)&&/\.m3u8/i.test(r)?`点播 ${t+1}`:/\.cos\.[^/]+\.myqcloud\.com/i.test(r)&&/\.m3u8/i.test(r)?`COS HLS ${t+1}`:/\.m3u8/i.test(r)?`HLS ${t+1}`:`链接 ${t+1}`}return(e,t)=>{const r=q;return u.value.length?(i(),c("div",R,[d("div",C,[a.value?(i(),c(v,{key:0},[b(a.value)?(i(),m(H,{key:`${l.recordId}-${a.value}`,src:a.value},null,8,["src"])):(i(),m(r,{key:1,href:g(a.value),target:"_blank",type:"primary"},{default:_(()=>[...t[0]||(t[0]=[k(" 打开回放 ",-1)])]),_:1},8,["href"]))],64)):y("",!0),p.value.length?(i(),c("div",V,[t[1]||(t[1]=d("span",{class:"recording-alternates__label"},"备用地址",-1)),d("div",N,[(i(!0),c(v,null,B(p.value,(o,s)=>(i(),m(r,{key:`${l.recordId}-alt-${s}-${o.slice(-32)}`,href:g(o),target:"_blank",type:"primary",class:"recording-alternates__link"},{default:_(()=>[k(w(S(o,s)),1)]),_:2},1032,["href"]))),128))])])):y("",!0)])])):(i(),c("span",D,"暂无"))}}}),x=P(E,[["__scopeId","data-v-d67b2e91"]]);export{x as default};
@@ -1,2 +1,2 @@
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/.pnpm-CJoARskO.js","assets/.pnpm-BtiqMGM_.css"])))=>i.map(i=>d[i]);
import{o as K,ap as z,n as S,R as H,aq as G,q as v,r as p,s as f,ac as O,ad as W,b8 as $,aw as j,F as w,v as k,D as I,u as J,b5 as Q,w as X,P,L as Y,bq as Z,M as _,p as E,al as ee}from"./.pnpm-CJoARskO.js";import{e as ae,_ as ne}from"./index-DGIIeNfq.js";const te={class:"recording-playback__shell"},re=["preload"],oe=["onKeydown"],le=K({__name:"RecordingVideoPlayer",props:{src:{}},setup(B){const V=B,A=ae(),b=_(null),m=_(null),c=_(!1),s=_(!1),t=_(!1);let d=null,r=null,l=0,u=null;function i(){u!==null&&(clearTimeout(u),u=null)}const y=E(()=>{const e=String(V.src||"").trim();return e?U(A.getImageUrl(e)):""}),D=E(()=>{if(!s.value)return"none";const e=y.value;return e&&!L(e)?"metadata":"none"}),q=E(()=>!s.value||t.value);function U(e){if(!e||!/^https?:\/\//i.test(e))return e;try{const a=new URL(e),n=a.hostname.toLowerCase();if(n.includes("myqcloud.com")||n.includes("vod-qcloud.com")||n.includes("vod-qcloud"))return a.pathname.includes("+")&&(a.pathname=a.pathname.replace(/\+/g,"%2B")),a.href}catch{}return e}function L(e){return/\.m3u8(\?|#|$)/i.test(e)}function C(){var n;t.value=!1,i();const e=m.value,a=(n=e==null?void 0:e.error)==null?void 0:n.code;a===MediaError.MEDIA_ERR_ABORTED||a===1||(c.value=!0)}function h(){d&&(d.destroy(),d=null);const e=m.value;e&&(e.pause(),e.removeAttribute("src"),e.load())}function R(e,a){let n=!1;const o=()=>{n||a!==l||(n=!0,i(),t.value=!1)};e.addEventListener("loadedmetadata",()=>o(),{once:!0}),e.addEventListener("loadeddata",()=>o(),{once:!0}),e.addEventListener("canplay",()=>o(),{once:!0}),e.addEventListener("playing",()=>o(),{once:!0}),e.addEventListener("progress",()=>{try{e.buffered.length>0&&e.buffered.end(0)>0&&o("progress-buffered")}catch{}},{passive:!0})}async function g(){if(!s.value)return;await S();const e=m.value,a=y.value;if(!e)return;if(!a){t.value=!1,i();return}const n=++l;if(t.value=!0,i(),h(),c.value=!1,L(a)){try{const o=await ee(()=>import("./.pnpm-CJoARskO.js").then(M=>M.dL),__vite__mapDeps([0,1])),x=o.default;if(x.isSupported()){d=new x({enableWorker:!0,lowLatencyMode:!1,maxBufferLength:20,maxMaxBufferLength:120}),d.on(o.Events.MANIFEST_PARSED,()=>{n===l&&(t.value=!1,i())}),d.on(o.Events.ERROR,(M,F)=>{if(F.fatal){if(n!==l)return;t.value=!1,i(),c.value=!0,h()}}),d.loadSource(a),d.attachMedia(e),u=setTimeout(()=>{u=null,n===l&&t.value&&(t.value=!1)},45e3);return}if(e.canPlayType("application/vnd.apple.mpegurl")){R(e,n),e.src=a,u=setTimeout(()=>{u=null,n===l&&t.value&&(t.value=!1)},45e3);return}}catch{if(n!==l)return;t.value=!1,i(),c.value=!0;return}if(n!==l)return;t.value=!1,i(),c.value=!0;return}R(e,n),e.src=a,u=setTimeout(()=>{u=null,n===l&&t.value&&(t.value=!1)},25e3)}function N(){const e=b.value;if(!e||typeof IntersectionObserver>"u"){s.value=!0,g();return}r=new IntersectionObserver(a=>{for(const n of a)if(n.isIntersecting){r==null||r.disconnect(),r=null,s.value=!0,g();break}},{root:null,rootMargin:"240px 0px",threshold:0}),r.observe(e)}function T(){r==null||r.disconnect(),r=null,s.value||(s.value=!0),g()}return z(()=>{S(()=>{N()})}),H(y,()=>{s.value&&g()}),G(()=>{r==null||r.disconnect(),r=null,i(),l++,h()}),(e,a)=>{const n=X,o=Z;return v(),p("div",{ref_key:"wrapRef",ref:b,class:"recording-playback"},[f("div",te,[O(f("video",{ref_key:"videoRef",ref:m,class:"recording-video",controls:"",playsinline:"","webkit-playsinline":"",preload:D.value,onError:C},null,40,re),[[W,!c.value]]),!c.value&&q.value?(v(),p("div",{key:0,class:"recording-playback__overlay",role:"button",tabindex:"0",onClick:T,onKeydown:$(j(T,["prevent"]),["enter"])},[s.value?(v(),p(w,{key:1},[k(n,{class:"recording-playback__spin"},{default:I(()=>[k(J(Q))]),_:1}),a[2]||(a[2]=f("span",null,"正在加载…",-1))],64)):(v(),p(w,{key:0},[a[0]||(a[0]=f("span",{class:"recording-playback__overlay-title"},"预览待加载",-1)),a[1]||(a[1]=f("span",{class:"recording-playback__overlay-desc"},"滚动至此处将自动加载(减轻并发卡顿),也可点击立即加载",-1))],64))],40,oe)):P("",!0)]),c.value?(v(),p(w,{key:0},[k(o,{href:y.value||"#",target:"_blank",type:"primary"},{default:I(()=>[...a[3]||(a[3]=[Y("在新窗口打开",-1)])]),_:1},8,["href"]),a[4]||(a[4]=f("div",{class:"recording-playback__hint"}," 内嵌播放失败(编码不支持、跨域或私有存储等)时可尝试在新窗口打开。 ",-1))],64)):P("",!0)],512)}}}),ie=ne(le,[["__scopeId","data-v-749b0199"]]);export{ie as default};
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/.pnpm-CC7b2w5D.js","assets/.pnpm-B3v8nGpq.css"])))=>i.map(i=>d[i]);
import{o as K,ap as z,n as S,R as H,aq as G,q as v,r as p,s as f,ac as O,ad as W,b8 as $,aw as j,F as w,v as k,D as I,u as J,b5 as Q,w as X,P,L as Y,bq as Z,M as _,p as E,al as ee}from"./.pnpm-CC7b2w5D.js";import{e as ae,_ as ne}from"./index-DNEphR7N.js";const te={class:"recording-playback__shell"},re=["preload"],oe=["onKeydown"],le=K({__name:"RecordingVideoPlayer",props:{src:{}},setup(B){const V=B,A=ae(),b=_(null),m=_(null),c=_(!1),s=_(!1),t=_(!1);let d=null,r=null,l=0,u=null;function i(){u!==null&&(clearTimeout(u),u=null)}const y=E(()=>{const e=String(V.src||"").trim();return e?N(A.getImageUrl(e)):""}),D=E(()=>{if(!s.value)return"none";const e=y.value;return e&&!L(e)?"metadata":"none"}),q=E(()=>!s.value||t.value);function N(e){if(!e||!/^https?:\/\//i.test(e))return e;try{const a=new URL(e),n=a.hostname.toLowerCase();if(n.includes("myqcloud.com")||n.includes("vod-qcloud.com")||n.includes("vod-qcloud"))return a.pathname.includes("+")&&(a.pathname=a.pathname.replace(/\+/g,"%2B")),a.href}catch{}return e}function L(e){return/\.m3u8(\?|#|$)/i.test(e)}function U(){var n;t.value=!1,i();const e=m.value,a=(n=e==null?void 0:e.error)==null?void 0:n.code;a===MediaError.MEDIA_ERR_ABORTED||a===1||(c.value=!0)}function h(){d&&(d.destroy(),d=null);const e=m.value;e&&(e.pause(),e.removeAttribute("src"),e.load())}function R(e,a){let n=!1;const o=()=>{n||a!==l||(n=!0,i(),t.value=!1)};e.addEventListener("loadedmetadata",()=>o(),{once:!0}),e.addEventListener("loadeddata",()=>o(),{once:!0}),e.addEventListener("canplay",()=>o(),{once:!0}),e.addEventListener("playing",()=>o(),{once:!0}),e.addEventListener("progress",()=>{try{e.buffered.length>0&&e.buffered.end(0)>0&&o("progress-buffered")}catch{}},{passive:!0})}async function g(){if(!s.value)return;await S();const e=m.value,a=y.value;if(!e)return;if(!a){t.value=!1,i();return}const n=++l;if(t.value=!0,i(),h(),c.value=!1,L(a)){try{const o=await ee(()=>import("./.pnpm-CC7b2w5D.js").then(M=>M.dN),__vite__mapDeps([0,1])),x=o.default;if(x.isSupported()){d=new x({enableWorker:!0,lowLatencyMode:!1,maxBufferLength:20,maxMaxBufferLength:120}),d.on(o.Events.MANIFEST_PARSED,()=>{n===l&&(t.value=!1,i())}),d.on(o.Events.ERROR,(M,F)=>{if(F.fatal){if(n!==l)return;t.value=!1,i(),c.value=!0,h()}}),d.loadSource(a),d.attachMedia(e),u=setTimeout(()=>{u=null,n===l&&t.value&&(t.value=!1)},45e3);return}if(e.canPlayType("application/vnd.apple.mpegurl")){R(e,n),e.src=a,u=setTimeout(()=>{u=null,n===l&&t.value&&(t.value=!1)},45e3);return}}catch{if(n!==l)return;t.value=!1,i(),c.value=!0;return}if(n!==l)return;t.value=!1,i(),c.value=!0;return}R(e,n),e.src=a,u=setTimeout(()=>{u=null,n===l&&t.value&&(t.value=!1)},25e3)}function C(){const e=b.value;if(!e||typeof IntersectionObserver>"u"){s.value=!0,g();return}r=new IntersectionObserver(a=>{for(const n of a)if(n.isIntersecting){r==null||r.disconnect(),r=null,s.value=!0,g();break}},{root:null,rootMargin:"240px 0px",threshold:0}),r.observe(e)}function T(){r==null||r.disconnect(),r=null,s.value||(s.value=!0),g()}return z(()=>{S(()=>{C()})}),H(y,()=>{s.value&&g()}),G(()=>{r==null||r.disconnect(),r=null,i(),l++,h()}),(e,a)=>{const n=X,o=Z;return v(),p("div",{ref_key:"wrapRef",ref:b,class:"recording-playback"},[f("div",te,[O(f("video",{ref_key:"videoRef",ref:m,class:"recording-video",controls:"",playsinline:"","webkit-playsinline":"",preload:D.value,onError:U},null,40,re),[[W,!c.value]]),!c.value&&q.value?(v(),p("div",{key:0,class:"recording-playback__overlay",role:"button",tabindex:"0",onClick:T,onKeydown:$(j(T,["prevent"]),["enter"])},[s.value?(v(),p(w,{key:1},[k(n,{class:"recording-playback__spin"},{default:I(()=>[k(J(Q))]),_:1}),a[2]||(a[2]=f("span",null,"正在加载…",-1))],64)):(v(),p(w,{key:0},[a[0]||(a[0]=f("span",{class:"recording-playback__overlay-title"},"预览待加载",-1)),a[1]||(a[1]=f("span",{class:"recording-playback__overlay-desc"},"滚动至此处将自动加载(减轻并发卡顿),也可点击立即加载",-1))],64))],40,oe)):P("",!0)]),c.value?(v(),p(w,{key:0},[k(o,{href:y.value||"#",target:"_blank",type:"primary"},{default:I(()=>[...a[3]||(a[3]=[Y("在新窗口打开",-1)])]),_:1},8,["href"]),a[4]||(a[4]=f("div",{class:"recording-playback__hint"}," 内嵌播放失败(编码不支持、跨域或私有存储等)时可尝试在新窗口打开。 ",-1))],64)):P("",!0)],512)}}}),ie=ne(le,[["__scopeId","data-v-749b0199"]]);export{ie as default};
File diff suppressed because one or more lines are too long
@@ -1,2 +1,2 @@
import{o as T,q as e,r as t,v as m,b7 as V,s as d,D as w,L as I,K as E,P as r,F as u,G as v,T as g,O as C,bB as z,M as p}from"./.pnpm-CJoARskO.js";import{$ as L}from"./tcm-BCWrWaNC.js";import{i as M,_ as S}from"./index-DGIIeNfq.js";/* empty css */const D={class:"tracking-timeline-wrap"},F={key:0,class:"timeline-input"},H={class:"input-actions"},q={key:1,class:"tracking-timeline"},A={class:"timeline-date"},G={class:"timeline-body"},K={key:0,class:"timeline-content"},O=T({__name:"TrackingNoteTimeline",props:{notes:{},diagnosisId:{},readonly:{type:Boolean}},emits:["refresh"],setup(n,{emit:f}){const c=n,y=f,o=p(""),l=p(!1),_=s=>s?s.split(`
`).filter(Boolean):[],k=async()=>{if(!c.diagnosisId)return;const s=o.value.trim();if(s){l.value=!0;try{await L({diagnosis_id:c.diagnosisId,tracking_content:s}),M.msgSuccess("已添加"),o.value="",y("refresh")}finally{l.value=!1}}};return(s,i)=>{const b=V,h=E,B=z;return e(),t("div",D,[!n.readonly&&n.diagnosisId?(e(),t("div",F,[m(b,{modelValue:o.value,"onUpdate:modelValue":i[0]||(i[0]=a=>o.value=a),type:"textarea",rows:2,placeholder:"输入跟踪备注,回车换行;保存后将以「[HH:MM] 内容」追加到当天记录",maxlength:"1000","show-word-limit":"",resize:"none",disabled:l.value},null,8,["modelValue","disabled"]),d("div",H,[m(h,{type:"primary",size:"small",loading:l.value,disabled:!o.value.trim(),onClick:k},{default:w(()=>[...i[1]||(i[1]=[I(" 添加 ",-1)])]),_:1},8,["loading","disabled"])])])):r("",!0),n.notes.length?(e(),t("div",q,[(e(!0),t(u,null,v(n.notes,a=>(e(),t("div",{key:a.id,class:"timeline-node"},[i[2]||(i[2]=d("div",{class:"timeline-dot"},null,-1)),d("div",A,g(a.note_date),1),d("div",G,[a.content?(e(),t("div",K,[(e(!0),t(u,null,v(_(a.content),(x,N)=>(e(),t("div",{key:N,class:"content-line"},g(x),1))),128))])):r("",!0)])]))),128))])):r("",!0),!n.notes.length&&n.readonly?(e(),C(B,{key:2,description:"暂无跟踪备注","image-size":48})):r("",!0)])}}}),J=S(O,[["__scopeId","data-v-82b635bd"]]);export{J as default};
import{o as T,q as e,r as t,v as m,b7 as V,s as d,D as w,L as I,K as E,P as r,F as u,G as v,T as g,O as C,bB as z,M as p}from"./.pnpm-CC7b2w5D.js";import{a0 as L}from"./tcm-vtnJvw47.js";import{i as M,_ as S}from"./index-DNEphR7N.js";/* empty css */const D={class:"tracking-timeline-wrap"},F={key:0,class:"timeline-input"},H={class:"input-actions"},q={key:1,class:"tracking-timeline"},A={class:"timeline-date"},G={class:"timeline-body"},K={key:0,class:"timeline-content"},O=T({__name:"TrackingNoteTimeline",props:{notes:{},diagnosisId:{},readonly:{type:Boolean}},emits:["refresh"],setup(n,{emit:f}){const c=n,y=f,o=p(""),l=p(!1),_=s=>s?s.split(`
`).filter(Boolean):[],k=async()=>{if(!c.diagnosisId)return;const s=o.value.trim();if(s){l.value=!0;try{await L({diagnosis_id:c.diagnosisId,tracking_content:s}),M.msgSuccess("已添加"),o.value="",y("refresh")}finally{l.value=!1}}};return(s,i)=>{const b=V,h=E,B=z;return e(),t("div",D,[!n.readonly&&n.diagnosisId?(e(),t("div",F,[m(b,{modelValue:o.value,"onUpdate:modelValue":i[0]||(i[0]=a=>o.value=a),type:"textarea",rows:2,placeholder:"输入跟踪备注,回车换行;保存后将以「[HH:MM] 内容」追加到当天记录",maxlength:"1000","show-word-limit":"",resize:"none",disabled:l.value},null,8,["modelValue","disabled"]),d("div",H,[m(h,{type:"primary",size:"small",loading:l.value,disabled:!o.value.trim(),onClick:k},{default:w(()=>[...i[1]||(i[1]=[I(" 添加 ",-1)])]),_:1},8,["loading","disabled"])])])):r("",!0),n.notes.length?(e(),t("div",q,[(e(!0),t(u,null,v(n.notes,a=>(e(),t("div",{key:a.id,class:"timeline-node"},[i[2]||(i[2]=d("div",{class:"timeline-dot"},null,-1)),d("div",A,g(a.note_date),1),d("div",G,[a.content?(e(),t("div",K,[(e(!0),t(u,null,v(_(a.content),(x,N)=>(e(),t("div",{key:N,class:"content-line"},g(x),1))),128))])):r("",!0)])]))),128))])):r("",!0),!n.notes.length&&n.readonly?(e(),C(B,{key:2,description:"暂无跟踪备注","image-size":48})):r("",!0)])}}}),Q=S(O,[["__scopeId","data-v-82b635bd"]]);export{Q as default};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
import{_ as o}from"./account-adjust.vue_vue_type_script_setup_true_lang-Dncvc6qj.js";import"./.pnpm-CC7b2w5D.js";import"./index-CMrRViEP.js";import"./index-DNEphR7N.js";export{o as default};
@@ -1 +0,0 @@
import{_ as o}from"./account-adjust.vue_vue_type_script_setup_true_lang-DYse2vam.js";import"./.pnpm-CJoARskO.js";import"./index-CMaTIYFB.js";import"./index-DGIIeNfq.js";export{o as default};
@@ -1 +1 @@
import{o as h,R as v,q,O as B,D as l,s as D,v as t,b9 as F,u as n,b6 as I,L as u,T as w,bc as j,bf as S,b7 as T,a8 as y,ba as U,p as G}from"./.pnpm-CJoARskO.js";import{_ as L}from"./index-CMaTIYFB.js";import{i as V}from"./index-DGIIeNfq.js";const M={class:"pr-8"},H=h({__name:"account-adjust",props:{show:{type:Boolean,required:!0},value:{type:[Number,String],required:!0}},emits:["update:show","confirm"],setup(d,{emit:k}){const s=y(),i=d,f=k,o=U({action:1,num:"",remark:""}),m=y(),c=G(()=>Number(i.value)+Number(o.num)*(o.action==1?1:-1)),R={num:[{required:!0,message:"请输入调整的金额"}]},g=e=>{if(e.includes("-"))return V.msgError("请输入正整数");o.num=e},x=async()=>{var e;await((e=s.value)==null?void 0:e.validate()),f("confirm",o)},E=()=>{var e;f("update:show",!1),(e=s.value)==null||e.resetFields()};return v(()=>i.show,e=>{var a,r;e?(a=m.value)==null||a.open():(r=m.value)==null||r.close()}),v(c,e=>{e<0&&(V.msgError("调整后余额需大于0"),o.num="")}),(e,a)=>{const r=I,_=S,C=j,b=T,N=F;return q(),B(L,{ref_key:"popupRef",ref:m,title:"余额调整",width:"500px",onConfirm:x,async:!0,onClose:E},{default:l(()=>[D("div",M,[t(N,{ref_key:"formRef",ref:s,model:n(o),"label-width":"120px",rules:R},{default:l(()=>[t(r,{label:"当前余额"},{default:l(()=>[u("¥ "+w(d.value),1)]),_:1}),t(r,{label:"余额增减",required:"",prop:"action"},{default:l(()=>[t(C,{modelValue:n(o).action,"onUpdate:modelValue":a[0]||(a[0]=p=>n(o).action=p)},{default:l(()=>[t(_,{value:1},{default:l(()=>[...a[2]||(a[2]=[u("增加余额",-1)])]),_:1}),t(_,{value:2},{default:l(()=>[...a[3]||(a[3]=[u("扣减余额",-1)])]),_:1})]),_:1},8,["modelValue"])]),_:1}),t(r,{label:"调整余额",prop:"num"},{default:l(()=>[t(b,{"model-value":n(o).num,placeholder:"请输入调整的金额",type:"number",onInput:g},null,8,["model-value"])]),_:1}),t(r,{label:"调整后余额"},{default:l(()=>[u(" ¥ "+w(n(c)),1)]),_:1}),t(r,{label:"备注",prop:"remark"},{default:l(()=>[t(b,{modelValue:n(o).remark,"onUpdate:modelValue":a[1]||(a[1]=p=>n(o).remark=p),type:"textarea",rows:4},null,8,["modelValue"])]),_:1})]),_:1},8,["model"])])]),_:1},512)}}});export{H as _};
import{o as h,R as v,q,O as B,D as l,s as D,v as t,b9 as F,u as n,b6 as I,L as u,T as w,bc as j,bf as S,b7 as T,a8 as y,ba as U,p as G}from"./.pnpm-CC7b2w5D.js";import{_ as L}from"./index-CMrRViEP.js";import{i as V}from"./index-DNEphR7N.js";const M={class:"pr-8"},H=h({__name:"account-adjust",props:{show:{type:Boolean,required:!0},value:{type:[Number,String],required:!0}},emits:["update:show","confirm"],setup(d,{emit:k}){const s=y(),i=d,f=k,o=U({action:1,num:"",remark:""}),m=y(),c=G(()=>Number(i.value)+Number(o.num)*(o.action==1?1:-1)),R={num:[{required:!0,message:"请输入调整的金额"}]},g=e=>{if(e.includes("-"))return V.msgError("请输入正整数");o.num=e},x=async()=>{var e;await((e=s.value)==null?void 0:e.validate()),f("confirm",o)},E=()=>{var e;f("update:show",!1),(e=s.value)==null||e.resetFields()};return v(()=>i.show,e=>{var a,r;e?(a=m.value)==null||a.open():(r=m.value)==null||r.close()}),v(c,e=>{e<0&&(V.msgError("调整后余额需大于0"),o.num="")}),(e,a)=>{const r=I,_=S,C=j,b=T,N=F;return q(),B(L,{ref_key:"popupRef",ref:m,title:"余额调整",width:"500px",onConfirm:x,async:!0,onClose:E},{default:l(()=>[D("div",M,[t(N,{ref_key:"formRef",ref:s,model:n(o),"label-width":"120px",rules:R},{default:l(()=>[t(r,{label:"当前余额"},{default:l(()=>[u("¥ "+w(d.value),1)]),_:1}),t(r,{label:"余额增减",required:"",prop:"action"},{default:l(()=>[t(C,{modelValue:n(o).action,"onUpdate:modelValue":a[0]||(a[0]=p=>n(o).action=p)},{default:l(()=>[t(_,{value:1},{default:l(()=>[...a[2]||(a[2]=[u("增加余额",-1)])]),_:1}),t(_,{value:2},{default:l(()=>[...a[3]||(a[3]=[u("扣减余额",-1)])]),_:1})]),_:1},8,["modelValue"])]),_:1}),t(r,{label:"调整余额",prop:"num"},{default:l(()=>[t(b,{"model-value":n(o).num,placeholder:"请输入调整的金额",type:"number",onInput:g},null,8,["model-value"])]),_:1}),t(r,{label:"调整后余额"},{default:l(()=>[u(" ¥ "+w(n(c)),1)]),_:1}),t(r,{label:"备注",prop:"remark"},{default:l(()=>[t(b,{modelValue:n(o).remark,"onUpdate:modelValue":a[1]||(a[1]=p=>n(o).remark=p),type:"textarea",rows:4},null,8,["modelValue"])]),_:1})]),_:1},8,["model"])])]),_:1},512)}}});export{H as _};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
import{_ as o}from"./add-nav.vue_vue_type_script_setup_true_lang-EZEjnWH_.js";import"./.pnpm-CJoARskO.js";import"./index-Dxb7lLkm.js";import"./index-DGIIeNfq.js";import"./picker-DLmHiiql.js";import"./index-CMaTIYFB.js";import"./index.vue_vue_type_script_setup_true_lang-CI_v6sRz.js";import"./article-DVqqV0rl.js";import"./usePaging-DKtm3XdH.js";import"./picker-HtQX0ZkW.js";import"./index-yt_wDMDM.js";import"./index-D30MriBb.js";import"./file-DApWCRJt.js";import"./index.vue_vue_type_script_setup_true_lang-rPwCQIWi.js";export{o as default};
@@ -0,0 +1 @@
import{_ as o}from"./add-nav.vue_vue_type_script_setup_true_lang-Dxxeay12.js";import"./.pnpm-CC7b2w5D.js";import"./index-BD56dHco.js";import"./index-DNEphR7N.js";import"./picker-Dba5IYH3.js";import"./index-CMrRViEP.js";import"./index.vue_vue_type_script_setup_true_lang-DHXV5Zdc.js";import"./article-CeLy7Zul.js";import"./usePaging-Dpj0ZiHV.js";import"./picker-CWjtHu2u.js";import"./index-CFIUBLbf.js";import"./index-CDdNeWUD.js";import"./file-DYMdTeVi.js";import"./index.vue_vue_type_script_setup_true_lang-CYjIL6rl.js";export{o as default};
@@ -1 +1 @@
import{o as E,q as p,r as C,s as l,v as a,u as c,bQ as B,C as N,D as d,O as $,b7 as z,b6 as D,I,K as A,L,p as R}from"./.pnpm-CJoARskO.js";import{_ as q}from"./index-Dxb7lLkm.js";import{_ as F}from"./picker-DLmHiiql.js";import{_ as K}from"./picker-HtQX0ZkW.js";import{c as O,i as r}from"./index-DGIIeNfq.js";const P={class:"bg-fill-light flex items-center w-full p-4 mb-4"},Q={class:"upload-btn w-[60px] h-[60px]"},S={class:"ml-3 flex-1"},T={class:"flex items-center"},j={class:"flex items-center mt-[18px]"},G={class:"flex-1 flex items-center"},H={class:"drag-move cursor-move ml-auto"},Z=E({__name:"add-nav",props:{modelValue:{type:Array,default:()=>[]},max:{type:Number,default:100},min:{type:Number,default:1}},emits:["update:modelValue"],setup(_,{emit:f}){const t=_,V=f,m=R({get(){return t.modelValue},set(s){V("update:modelValue",s)}}),x=()=>{var s;((s=t.modelValue)==null?void 0:s.length)<t.max?m.value.push({image:"",name:"导航名称",link:{},is_show:"1"}):r.msgError(`最多添加${t.max}`)},v=s=>{var e;if(((e=t.modelValue)==null?void 0:e.length)<=t.min)return r.msgError(`最少保留${t.min}`);m.value.splice(s,1)};return(s,e)=>{const u=O,g=K,b=z,h=F,k=I,w=D,y=q,U=A;return p(),C("div",null,[l("div",null,[a(c(B),{class:"draggable",modelValue:c(m),"onUpdate:modelValue":e[0]||(e[0]=o=>N(m)?m.value=o:null),animation:"300",handle:".drag-move","item-key":"index"},{item:d(({element:o,index:i})=>[(p(),$(y,{class:"w-[467px]",key:i,onClose:n=>v(i)},{default:d(()=>[l("div",P,[a(g,{modelValue:o.image,"onUpdate:modelValue":n=>o.image=n,"upload-class":"bg-body",size:"60px","exclude-domain":""},{upload:d(()=>[l("div",Q,[a(u,{name:"el-icon-Plus",size:20})])]),_:1},8,["modelValue","onUpdate:modelValue"]),l("div",S,[l("div",T,[e[1]||(e[1]=l("span",{class:"text-tx-regular flex-none mr-3"},"名称",-1)),a(b,{modelValue:o.name,"onUpdate:modelValue":n=>o.name=n,placeholder:"请输入名称"},null,8,["modelValue","onUpdate:modelValue"])]),l("div",j,[e[2]||(e[2]=l("span",{class:"text-tx-regular flex-none mr-3"},"链接",-1)),a(h,{modelValue:o.link,"onUpdate:modelValue":n=>o.link=n},null,8,["modelValue","onUpdate:modelValue"])]),a(w,{label:"是否显示",class:"mt-[18px]"},{default:d(()=>[l("div",G,[a(k,{modelValue:o.is_show,"onUpdate:modelValue":n=>o.is_show=n,"active-value":"1","inactive-value":"0"},null,8,["modelValue","onUpdate:modelValue"]),l("div",H,[a(u,{name:"el-icon-Rank",size:"18"})])])]),_:2},1024)])])]),_:2},1032,["onClose"]))]),_:1},8,["modelValue"])]),l("div",null,[a(U,{type:"primary",onClick:x},{default:d(()=>[...e[3]||(e[3]=[L("添加",-1)])]),_:1})])])}}});export{Z as _};
import{o as E,q as p,r as C,s as l,v as a,u as c,bQ as B,C as N,D as d,O as $,b7 as z,b6 as D,I,K as A,L,p as R}from"./.pnpm-CC7b2w5D.js";import{_ as q}from"./index-BD56dHco.js";import{_ as F}from"./picker-Dba5IYH3.js";import{_ as K}from"./picker-CWjtHu2u.js";import{c as O,i as r}from"./index-DNEphR7N.js";const P={class:"bg-fill-light flex items-center w-full p-4 mb-4"},Q={class:"upload-btn w-[60px] h-[60px]"},S={class:"ml-3 flex-1"},T={class:"flex items-center"},j={class:"flex items-center mt-[18px]"},G={class:"flex-1 flex items-center"},H={class:"drag-move cursor-move ml-auto"},Z=E({__name:"add-nav",props:{modelValue:{type:Array,default:()=>[]},max:{type:Number,default:100},min:{type:Number,default:1}},emits:["update:modelValue"],setup(_,{emit:f}){const t=_,V=f,m=R({get(){return t.modelValue},set(s){V("update:modelValue",s)}}),x=()=>{var s;((s=t.modelValue)==null?void 0:s.length)<t.max?m.value.push({image:"",name:"导航名称",link:{},is_show:"1"}):r.msgError(`最多添加${t.max}`)},v=s=>{var e;if(((e=t.modelValue)==null?void 0:e.length)<=t.min)return r.msgError(`最少保留${t.min}`);m.value.splice(s,1)};return(s,e)=>{const u=O,g=K,b=z,h=F,k=I,w=D,y=q,U=A;return p(),C("div",null,[l("div",null,[a(c(B),{class:"draggable",modelValue:c(m),"onUpdate:modelValue":e[0]||(e[0]=o=>N(m)?m.value=o:null),animation:"300",handle:".drag-move","item-key":"index"},{item:d(({element:o,index:i})=>[(p(),$(y,{class:"w-[467px]",key:i,onClose:n=>v(i)},{default:d(()=>[l("div",P,[a(g,{modelValue:o.image,"onUpdate:modelValue":n=>o.image=n,"upload-class":"bg-body",size:"60px","exclude-domain":""},{upload:d(()=>[l("div",Q,[a(u,{name:"el-icon-Plus",size:20})])]),_:1},8,["modelValue","onUpdate:modelValue"]),l("div",S,[l("div",T,[e[1]||(e[1]=l("span",{class:"text-tx-regular flex-none mr-3"},"名称",-1)),a(b,{modelValue:o.name,"onUpdate:modelValue":n=>o.name=n,placeholder:"请输入名称"},null,8,["modelValue","onUpdate:modelValue"])]),l("div",j,[e[2]||(e[2]=l("span",{class:"text-tx-regular flex-none mr-3"},"链接",-1)),a(h,{modelValue:o.link,"onUpdate:modelValue":n=>o.link=n},null,8,["modelValue","onUpdate:modelValue"])]),a(w,{label:"是否显示",class:"mt-[18px]"},{default:d(()=>[l("div",G,[a(k,{modelValue:o.is_show,"onUpdate:modelValue":n=>o.is_show=n,"active-value":"1","inactive-value":"0"},null,8,["modelValue","onUpdate:modelValue"]),l("div",H,[a(u,{name:"el-icon-Rank",size:"18"})])])]),_:2},1024)])])]),_:2},1032,["onClose"]))]),_:1},8,["modelValue"])]),l("div",null,[a(U,{type:"primary",onClick:x},{default:d(()=>[...e[3]||(e[3]=[L("添加",-1)])]),_:1})])])}}});export{Z as _};
@@ -1 +1 @@
import{r as n}from"./index-DGIIeNfq.js";function e(t){return n.get({url:"/auth.admin/lists",params:t},{ignoreCancelToken:!0})}function i(t){return n.post({url:"/auth.admin/add",params:t})}function r(t){return n.post({url:"/auth.admin/edit",params:t})}function u(t){return n.post({url:"/auth.admin/delete",params:t})}function d(t){return n.get({url:"/auth.admin/detail",params:t})}export{e as a,r as b,u as c,i as d,d as e};
import{r as n}from"./index-DNEphR7N.js";function e(t){return n.get({url:"/auth.admin/lists",params:t},{ignoreCancelToken:!0})}function i(t){return n.post({url:"/auth.admin/add",params:t})}function r(t){return n.post({url:"/auth.admin/edit",params:t})}function u(t){return n.post({url:"/auth.admin/delete",params:t})}function d(t){return n.get({url:"/auth.admin/detail",params:t})}export{e as a,r as b,u as c,i as d,d as e};
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
import{r as e}from"./index-DGIIeNfq.js";function a(t){return e.get({url:"/article.articleCate/lists",params:t})}function l(t){return e.get({url:"/article.articleCate/all",params:t})}function i(t){return e.post({url:"/article.articleCate/add",params:t})}function c(t){return e.post({url:"/article.articleCate/edit",params:t})}function u(t){return e.post({url:"/article.articleCate/delete",params:t})}function n(t){return e.get({url:"/article.articleCate/detail",params:t})}function s(t){return e.post({url:"/article.articleCate/updateStatus",params:t})}function o(t){return e.get({url:"/article.article/lists",params:t})}function d(t){return e.post({url:"/article.article/add",params:t})}function f(t){return e.post({url:"/article.article/edit",params:t})}function C(t){return e.post({url:"/article.article/delete",params:t})}function p(t){return e.get({url:"/article.article/detail",params:t})}function g(t){return e.post({url:"/article.article/updateStatus",params:t})}export{a,u as b,s as c,c as d,i as e,n as f,p as g,f as h,d as i,l as j,o as k,g as l,C as m};
import{r as e}from"./index-DNEphR7N.js";function a(t){return e.get({url:"/article.articleCate/lists",params:t})}function l(t){return e.get({url:"/article.articleCate/all",params:t})}function i(t){return e.post({url:"/article.articleCate/add",params:t})}function c(t){return e.post({url:"/article.articleCate/edit",params:t})}function u(t){return e.post({url:"/article.articleCate/delete",params:t})}function n(t){return e.get({url:"/article.articleCate/detail",params:t})}function s(t){return e.post({url:"/article.articleCate/updateStatus",params:t})}function o(t){return e.get({url:"/article.article/lists",params:t})}function d(t){return e.post({url:"/article.article/add",params:t})}function f(t){return e.post({url:"/article.article/edit",params:t})}function C(t){return e.post({url:"/article.article/delete",params:t})}function p(t){return e.get({url:"/article.article/detail",params:t})}function g(t){return e.post({url:"/article.article/updateStatus",params:t})}export{a,u as b,s as c,c as d,i as e,n as f,p as g,f as h,d as i,l as j,o as k,g as l,C as m};
@@ -1 +1 @@
import{r as e}from"./index-DGIIeNfq.js";function r(s){return e.get({url:"/asset.AssetUser/lists",params:s})}function u(s){return e.post({url:"/asset.AssetUser/add",params:s})}function a(s){return e.post({url:"/asset.AssetUser/edit",params:s})}function i(s){return e.post({url:"/asset.AssetUser/delete",params:s})}function o(s){return e.get({url:"/asset.AssetResource/lists",params:s})}function n(s){return e.post({url:"/asset.AssetResource/add",params:s})}function A(s){return e.post({url:"/asset.AssetResource/edit",params:s})}function c(s){return e.post({url:"/asset.AssetResource/delete",params:s})}export{o as a,c as b,A as c,n as d,r as e,a as f,i as g,u as h};
import{r as e}from"./index-DNEphR7N.js";function r(s){return e.get({url:"/asset.AssetUser/lists",params:s})}function u(s){return e.post({url:"/asset.AssetUser/add",params:s})}function a(s){return e.post({url:"/asset.AssetUser/edit",params:s})}function i(s){return e.post({url:"/asset.AssetUser/delete",params:s})}function o(s){return e.get({url:"/asset.AssetResource/lists",params:s})}function n(s){return e.post({url:"/asset.AssetResource/add",params:s})}function A(s){return e.post({url:"/asset.AssetResource/edit",params:s})}function c(s){return e.post({url:"/asset.AssetResource/delete",params:s})}export{o as a,c as b,A as c,n as d,r as e,a as f,i as g,u as h};
@@ -0,0 +1 @@
import{_ as o}from"./attr.vue_vue_type_script_setup_true_lang-1OiI27Rm.js";import"./.pnpm-CC7b2w5D.js";import"./add-nav.vue_vue_type_script_setup_true_lang-Dxxeay12.js";import"./index-BD56dHco.js";import"./index-DNEphR7N.js";import"./picker-Dba5IYH3.js";import"./index-CMrRViEP.js";import"./index.vue_vue_type_script_setup_true_lang-DHXV5Zdc.js";import"./article-CeLy7Zul.js";import"./usePaging-Dpj0ZiHV.js";import"./picker-CWjtHu2u.js";import"./index-CFIUBLbf.js";import"./index-CDdNeWUD.js";import"./file-DYMdTeVi.js";import"./index.vue_vue_type_script_setup_true_lang-CYjIL6rl.js";export{o as default};
@@ -0,0 +1 @@
import{_ as m}from"./attr.vue_vue_type_script_setup_true_lang-CJsXI2CE.js";import"./.pnpm-CC7b2w5D.js";export{m as default};
@@ -1 +0,0 @@
import{_ as o}from"./attr.vue_vue_type_script_setup_true_lang-Bvjm08ts.js";import"./.pnpm-CJoARskO.js";import"./index.vue_vue_type_script_setup_true_lang-BZrtzFNp.js";import"./picker-HtQX0ZkW.js";import"./index-CMaTIYFB.js";import"./index-DGIIeNfq.js";import"./index-yt_wDMDM.js";import"./index.vue_vue_type_script_setup_true_lang-CI_v6sRz.js";import"./index-Dxb7lLkm.js";import"./index-D30MriBb.js";import"./file-DApWCRJt.js";import"./index.vue_vue_type_script_setup_true_lang-rPwCQIWi.js";import"./usePaging-DKtm3XdH.js";export{o as default};
File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show More