Compare commits

...
34 Commits
Author SHA1 Message Date
Your Name de990a921b 更新 2026-08-11 10:02:40 +08:00
Your Name cfe4c82c90 更新 2026-08-11 09:12:51 +08:00
Your Name c3ceb0dd0f 更新 2026-08-10 17:46:04 +08:00
Your Name 9add23e019 更新 2026-08-10 17:29:05 +08:00
Your Name 2199887c07 更新
:i# Please enter the commit message for your changes. Lines starting
2026-08-08 18:08:23 +08:00
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
667 changed files with 68444 additions and 1385 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 }
)
}
+16 -2
View File
@@ -8,6 +8,7 @@ interface Options {
params?: Record<any, any>
fixedParams?: Record<any, any>
firstLoading?: boolean
latestOnly?: boolean
}
export function usePaging(options: Options) {
@@ -17,7 +18,8 @@ export function usePaging(options: Options) {
fetchFun,
params = {},
fixedParams = {},
firstLoading = false
firstLoading = false,
latestOnly = false
} = options
// 记录分页初始参数
const paramsInit: Record<any, any> = Object.assign({}, toRaw(params))
@@ -30,8 +32,10 @@ export function usePaging(options: Options) {
lists: [] as any[],
extend: {} as Record<string, any>
})
let latestRequestId = 0
// 请求分页接口;silent: true 时不改 loading(用于定时静默刷新,避免表格闪 loading)
const getLists = (opts?: { silent?: boolean }) => {
const requestId = ++latestRequestId
const silent = opts?.silent === true
if (!silent) {
pager.loading = true
@@ -43,16 +47,26 @@ export function usePaging(options: Options) {
...fixedParams
})
.then((res: any) => {
if (latestOnly && requestId !== latestRequestId) {
return Promise.resolve(res)
}
pager.count = res?.count
pager.lists = res?.lists
pager.extend = res?.extend
return Promise.resolve(res)
})
.catch((err: any) => {
if (latestOnly && requestId !== latestRequestId) {
return Promise.resolve(undefined)
}
return Promise.reject(err)
})
.finally(() => {
if (!silent) {
if (latestOnly) {
if (requestId === latestRequestId) {
pager.loading = false
}
} else if (!silent) {
pager.loading = false
}
})
+4 -2
View File
@@ -89,7 +89,7 @@
>
<div class="slot-time">{{ slot.time }}</div>
<div class="slot-status" :class="{ 'status-available': slot.available }">
{{ slot.available ? '可约' : '已约' }}
{{ slot.available ? '可约' : slot.hasAppointment ? '已约' : '空号' }}
</div>
</div>
</div>
@@ -115,6 +115,7 @@ dayjs.extend(isoWeek)
interface TimeSlot {
time: string
available: boolean
hasAppointment: boolean
quota: number
}
@@ -186,7 +187,7 @@ const filteredTimeSlots = computed(() => {
const slotDateTime = dayjs(`${form.date} ${slot.time}`)
const isPast = slotDateTime.isBefore(now) || slotDateTime.isSame(now, 'minute')
// 如果时间已过,标记为不可用
// 如果时间已过,标记为不可用;保留原始挂号状态以区分“已约”和“空号”
if (isPast) {
return {
...slot,
@@ -315,6 +316,7 @@ const loadTimeSlots = async (silent = false) => {
timeSlots.value = (response?.slots || []).map((slot: any) => ({
time: slot.time,
available: slot.available,
hasAppointment: Boolean(slot.has_appointment ?? (slot.available === false)),
quota: slot.available ? 1 : 0
}))
} catch (error) {
+123 -16
View File
@@ -51,6 +51,35 @@
@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-group
v-for="group in mediaChannelGroups"
:key="group.group_name || '_'"
:label="`${group.group_name || '(未分组)'} · ${group.customer_count}`"
>
<el-option
v-for="item in group.channels"
:key="item.code"
:label="item.name"
:value="item.code"
>
<span class="channel-option">
<span>{{ item.name }}</span>
<span>{{ item.customer_count }} </span>
</span>
</el-option>
</el-option-group>
</el-select>
</div>
<span class="range-text">{{ dashboard.meta.start_date }} {{ dashboard.meta.end_date }}</span>
</section>
@@ -67,7 +96,7 @@
<div class="panel-heading">
<div>
<h2>部门订单量占比</h2>
<p>当前范围内审核通过的接诊诊单</p>
<p>按订单创建人归属排除取消拒收及退款</p>
</div>
<span>单位</span>
</div>
@@ -85,7 +114,7 @@
<div class="panel-heading">
<div>
<h2>部门金额占比</h2>
<p>与诊单金额指标保持同一审核口径</p>
<p>仅统计未取消未拒收且未退款的有效金额</p>
</div>
<span>单位</span>
</div>
@@ -104,7 +133,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 +144,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>
@@ -208,13 +257,25 @@ import vCharts from 'vue-echarts'
import { firstVisitConversionOverview, type FirstVisitConversionParams } from '@/api/first_visit'
type MetricType = 'count' | 'money' | 'ratio'
type MediaChannelOption = {
code: string
name: string
tag_id?: string
group_name?: string
customer_count?: number
}
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 MediaChannelOption[]
},
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 +288,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 +306,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,8 +318,26 @@ 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 mediaChannelGroups = computed(() => {
const groups = new Map<string, {
group_name: string
customer_count: number
channels: MediaChannelOption[]
}>()
for (const channel of dashboard.filters.media_channels) {
const groupName = channel.group_name || ''
if (!groups.has(groupName)) {
groups.set(groupName, { group_name: groupName, customer_count: 0, channels: [] })
}
const group = groups.get(groupName)!
group.channels.push(channel)
group.customer_count = Math.max(group.customer_count, Number(channel.customer_count || 0))
}
return Array.from(groups.values())
})
const maxOrderValue = computed(() => Math.max(0, ...dashboard.rankings.orders.map(item => Number(item.value || 0))))
const maxAmountValue = computed(() => Math.max(0, ...dashboard.rankings.amounts.map(item => Number(item.value || 0))))
const targetChartOption = computed(() => ({
@@ -268,15 +353,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 +479,19 @@ onMounted(loadDashboard)
.range-text { margin-left: auto; }
.employee-select { width: 190px; }
.dept-select { width: 220px; }
.channel-select { width: 180px; }
.channel-option {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
width: 100%;
span:last-child {
color: #98a2b3;
font-size: 12px;
}
}
.metric-grid {
display: grid;
@@ -434,6 +539,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 +575,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>
@@ -120,7 +120,7 @@
<section class="board-section queue-section">
<div class="section-heading queue-heading">
<div class="heading-copy">
<h2>候诊列表</h2>
<h2>{{ queueDateLabel }}候诊列表</h2>
<span class="heading-badge">按医生排队</span>
<span class="queue-count"> {{ pager.count }} </span>
</div>
@@ -168,7 +168,7 @@
</template>
</el-table-column>
<template #empty>
<el-empty description="当前权限范围内今日暂无候诊患者" />
<el-empty :description="`当前权限范围内${queueDateLabel}暂无候诊患者`" />
</template>
</el-table>
@@ -196,11 +196,12 @@ const formData = reactive({
end_date: today
})
const { pager, getLists } = usePaging({
const { pager, getLists, resetPage } = usePaging({
fetchFun: myPatientProgressLists as any,
params: formData,
size: 15,
firstLoading: true
firstLoading: true,
latestOnly: true
})
const todayOverview = computed(() => ({
@@ -259,6 +260,10 @@ const selectedScheduleLabel = computed(() => {
const day = selectedScheduleDay.value
return `${day.date_text || ''} ${day.weekday || ''} `
})
const queueDateLabel = computed(() => {
if (selectedScheduleDate.value === today) return '今日'
return selectedScheduleLabel.value.trim() || selectedScheduleDate.value
})
const scopeLabel = computed(() => pager.extend?.scope?.label || '按权限加载')
const scheduleRange = computed(() => {
@@ -274,7 +279,11 @@ function refreshPanel(options?: { silent?: boolean }) {
}
function selectScheduleDay(date: string) {
if (!date) return
selectedScheduleDate.value = date
formData.start_date = date
formData.end_date = date
resetPage()
}
function doctorWindows(doctor: Record<string, any>) {
@@ -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
+25
View File
@@ -0,0 +1,25 @@
# 此文件是源码开发/企业部署模板,不会被复制进发布 ZIP/.app。
# 生产配置应由受控启动器、设备管理或进程环境注入;禁止把密码、token、
# UserSig、TRTC SecretKey 或其他长期凭据写进本文件或发布包。
# 后端根地址。程序会自动追加 /adminapi;也可直接填写以 /adminapi 结尾的地址。
DOCTOR_API_BASE_URL=https://api.example.com
# 首次验收可设为 true,使用内置演示数据;生产必须设为 false。
DOCTOR_DEMO_MODE=true
# 当前只支持 embedded。browser 在后端提供一次性 handoff 前会被明确拒绝,且不会自动打开系统浏览器。
DOCTOR_VIDEO_MODE=embedded
# 可选:本地 dist 缺失时由 QtWebEngine 内嵌加载的可信 HTTPS 页面;不是 browser handoff URL,禁止携带 UserSig。
DOCTOR_VIDEO_WEB_URL=
# 生产环境必须保持 true。仅内网自签证书调试时临时关闭。
DOCTOR_VERIFY_SSL=true
DOCTOR_REQUEST_TIMEOUT=30
# 日志级别:DEBUG / INFO / WARNING / ERROR。日志会自动脱敏 token 与 UserSig。
DOCTOR_LOG_LEVEL=INFO
# 可选:仅供企业部署/自动化验收隔离用户数据目录。
# DOCTOR_CONFIG_DIR=
# DOCTOR_LOG_DIR=
+23
View File
@@ -0,0 +1,23 @@
.env
.venv/
.venv-build/
.uv-cache/
.uv-python/
__pycache__/
*.py[cod]
.pytest_cache/
.pytest-tmp-*/
artifacts/pytest_*/
.ruff_cache/
.coverage
htmlcov/
build/
dist/
!video_companion/dist/
!video_companion/dist/**
*.spec.bak
node_modules/
video_companion/node_modules/
*.log
.DS_Store
Thumbs.db
+22
View File
@@ -0,0 +1,22 @@
@echo off
setlocal EnableExtensions DisableDelayedExpansion
set "PROJECT_ROOT=%~dp0"
set "POWERSHELL_EXE=%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe"
"%POWERSHELL_EXE%" -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%PROJECT_ROOT%scripts\package_windows.ps1" %*
set "RESULT=%ERRORLEVEL%"
if "%RESULT%"=="0" (
echo.
echo Package ready in: %PROJECT_ROOT%dist
if /I not "%~1"=="-ValidateOnly" (
start "" "%SystemRoot%\explorer.exe" "%PROJECT_ROOT%dist"
)
) else (
echo.
echo DoctorWorkstation packaging failed. Exit code: %RESULT%
echo Press any key to close this window.
pause >nul
)
endlocal & exit /b %RESULT%
+132
View File
@@ -0,0 +1,132 @@
# 臻阳堂医生工作站
一个以 Python + PySide6 编写的跨平台医生桌面端,面向 Windows 10/11 与 macOS 13+。项目按现有 `admin` 源码的真实接口契约实现,覆盖登录、接诊台、我的处方库、已开处方、患者列表、问诊列表和腾讯云视频面诊。
> 当前版本提供完整的演示数据模式,便于在没有后端账号或腾讯云配置时验收界面与流程。切换到生产模式后,数据与权限均由现有后端返回。
## 一键运行与一键打包
Windows 直接在项目根目录双击:
- `一键运行_医生工作站.bat`:优先启动现有成品;没有成品时自动使用 `uv` 准备源码环境并运行。
- `一键打包_医生工作站.bat`:自动同步锁定的 Python/Node 依赖,检查冻结 QtWebEngine/QtMultimedia 文件,执行应用与媒体离屏冒烟验证,最后生成 `dist/DoctorWorkstation-Windows-x64-<版本>.zip` 和 SHA-256 文件。
英文稳定别名分别是 `Run_DoctorWorkstation.bat``Build_DoctorWorkstation.bat`。分发 ZIP 解压后,可直接双击其中的 `Start_DoctorWorkstation.bat`
macOS 在 Finder 中双击:
- `一键运行.command`:优先打开现有 `DoctorWorkstation.app`,否则自动准备源码环境并运行。
- `一键打包.command`:构建、QtWebEngine/QtMultimedia 文件门禁、签名检查和两项冻结冒烟验证后,生成 `.app`、可分发 ZIP 及 SHA-256 文件。
Windows 打包机需预先安装 `uv` 与 Node.js 20+;脚本会自动处理项目虚拟环境和锁定依赖。首次打包需要联网下载依赖,之后会复用本机缓存。macOS 发布源码中的根 `.command` 与操作型 `scripts/*.sh` 必须以 Git mode `100755` 跟踪;源码压缩包在传输中丢失权限时,可在项目目录执行一次 `chmod +x *.command scripts/*.sh`。若 Gatekeeper 拦截未签名内部测试版,请使用右键“打开”。
## 已实现范围
- 账号密码登录、token 会话、记住账号(不保存密码)和退出登录。
- 登录后读取 `/adminapi/auth.admin/mySelf`,按 `permissions` 动态控制页面和操作按钮;`*` 超级权限兼容现有后台。
- 接诊台:今日待接诊/已过号、患者详情、医生备注、通知医助、完成接诊、发起视频面诊。
- 我的处方库:主方/辅方与公开范围筛选,药材动态编辑,模板所有权和增删改权限。
- 已开处方:处方号/患者/审核状态筛选、状态展示和只读详情。
- 患者列表:复用 `/firstvisit.myPatient/lists` 的服务端数据范围,不在客户端伪造医生或部门过滤。
- 问诊列表:按日期、状态和患者筛选,支持从有效预约发起视频。
- 腾讯视频:沿用现有项目的 `@trtc/calls-uikit-vue` 主链;UserSig 只从后端短时获取,客户端不包含 SDKSecretKey。当前仅支持隔离的 QtWebEngine 内嵌模式。
- PyInstaller Windows/macOS 构建脚本、macOS 摄像头/麦克风权限配置与自动化测试。
## 手动运行
先安装 [uv](https://docs.astral.sh/uv/),然后在项目根目录执行:
```powershell
uv sync --extra dev --extra build
Copy-Item .env.example .env
uv run doctor-workstation
```
macOS/Linux
```bash
uv sync --extra dev --extra build
cp .env.example .env
uv run doctor-workstation
```
`.env.example` 默认启用演示模式。演示账号:`doctor`,密码:`doctor123`
`.env.example` 仅作为源码开发/企业部署模板,不会复制进发布 ZIP 或 `.app`。生产环境请通过受控启动器、设备管理或进程环境注入配置;不要把密码、token、UserSig、TRTC SecretKey 等凭据放进 `.env` 或发布包。
## 连接现有后端
`.env` 调整为:
```dotenv
DOCTOR_API_BASE_URL=https://your-api.example.com
DOCTOR_DEMO_MODE=false
DOCTOR_VERIFY_SSL=true
```
程序会自动在地址末尾追加 `/adminapi`。它与现有管理端保持相同约定:
- 登录:`POST /login/account`,请求包含 `account``password``terminal=1`
- 鉴权请求头:`token: <登录 token>``version: 1.9.4`
- 响应 envelope`code=1` 成功、`0` 业务失败、`-1` 登录失效、`10` 需绑定企业微信。
- 权限与数据范围:完全以后端 `/auth.admin/mySelf` 返回为准。
环境配置不会保存密码、TRTC SecretKey 或腾讯云长期凭据。登录 token 优先存入系统凭据库;无法使用时仅回退到用户配置目录中的受限文件。
## 视频伴随页
腾讯云没有官方 Python/PySide6 客户端 SDK。本项目因此采用 Python 业务主程序 + 腾讯官方 Web TUICallKit 伴随页:
```powershell
Set-Location video_companion
npm ci
npm run build
```
构建输出位于 `video_companion/dist`,由桌面端内嵌加载。生产 UserSig 必须由现有 `/tcm.diagnosis/getCallSignature` 接口签发;不要把 SDKSecretKey 写入 `.env` 或 JavaScript。
当前仅支持 `embedded`。在业务后端提供服务端签发、一次性消费的 browser handoff 之前,`browser` 模式会被明确拒绝,QtWebEngine 不可用时也不会自动打开系统浏览器。这样可以避免后端已记录 `startCall`、浏览器页面却没有通话票据的“幽灵通话”。
```dotenv
DOCTOR_VIDEO_MODE=embedded
DOCTOR_VIDEO_WEB_URL=https://rtc.example.com/doctor-call
```
`DOCTOR_VIDEO_WEB_URL` 仅用于本地 `dist` 缺失时,在 QtWebEngine 中内嵌加载受信任的 HTTPS 主文档;它不是 browser handoff URL,也不得在 URL 中携带 UserSig 或其他 RTC 凭据。
## 测试与打包
```powershell
uv run pytest
uv run ruff check src tests
.\scripts\build_windows.ps1
```
`build_windows.ps1` 只生成并验证 onedir;正式一键发布请运行 `Build_DoctorWorkstation.bat`,它在所有冻结文件/媒体门禁通过后再生成版本 ZIP 与 SHA-256。
macOS 必须在 macOS 机器上构建、签名和公证:
```bash
./scripts/build_macos.sh
```
`build_macos.sh` 只生成并验证 `.app`;正式一键发布请双击 `一键打包.command`(或 `package_macos.command`),通过相同门禁后再归档并生成 SHA-256。
Windows 与 macOS 的 Qt/媒体权限和签名产物不能交叉编译。首次生产发布前,应按 [research/tencent_rtc.md](research/tencent_rtc.md) 的准入清单完成摄像头、麦克风、设备插拔、休眠恢复和弱网实测。
## 工程结构
```text
src/doctor_workstation/
core/ 业务模型、权限、会话与异常
services/ HTTP 客户端、远程仓库、演示仓库与安全 token 存储
ui/ PySide6 登录、主框架、页面和对话框
video/ 视频请求规范化、异步生命周期与隔离的内嵌窗口
video_companion/ 腾讯 TUICallKit 页面
packaging/ PyInstaller 与 macOS 权限配置
scripts/ Windows/macOS 构建脚本
tests/ 不依赖真实后端和腾讯云的自动化测试
research/ admin 源码审计、架构和腾讯 RTC 官方资料研究
```
详细的接口/字段与权限审计见 [research/admin_audit.md](research/admin_audit.md)。
+16
View File
@@ -0,0 +1,16 @@
@echo off
setlocal EnableExtensions DisableDelayedExpansion
set "PROJECT_ROOT=%~dp0"
set "POWERSHELL_EXE=%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe"
"%POWERSHELL_EXE%" -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%PROJECT_ROOT%scripts\run_windows.ps1" %*
set "RESULT=%ERRORLEVEL%"
if not "%RESULT%"=="0" (
echo.
echo DoctorWorkstation failed to start. Exit code: %RESULT%
echo Press any key to close this window.
pause >nul
)
endlocal & exit /b %RESULT%
+14
View File
@@ -0,0 +1,14 @@
# 安全与隐私约束
医生工作台会处理患者身份、病历、处方和音视频等敏感信息。生产部署必须遵守以下基线:
- 客户端不包含腾讯云 `SDKSecretKey`、COS Secret 或后端数据库凭据。TRTC `UserSig` 由服务端按当前医生和单次通话短时签发。
- 不在 URL 查询参数、命令行或日志中传递 token、UserSig、患者身份证号和完整病历。项目日志过滤器会遮蔽常见凭据,但调用代码仍应避免记录完整请求/响应。
- 密码从不落盘;“记住账号”只保存账号。登录 token 优先写入 Windows Credential Manager / macOS Keychain。
- 生产 API 与浏览器视频页必须使用 HTTPS 并验证证书。`DOCTOR_VERIFY_SSL=false` 只允许在受控开发环境临时使用。
- 页面可见性与按钮权限来自 `/auth.admin/mySelf`,但客户端权限仅用于界面体验;服务端仍必须对每个接口执行身份、租户、数据范围和动作权限校验。
- 本地不缓存患者列表、病历、处方和通话票据。演示数据是完全虚构的静态数据。
- 通话录制、截图和报告上传属于单独的合规能力;启用前必须确认患者告知/同意、留存周期、访问审计和删除流程。
- 软件发布必须签名。Windows 建议 AuthenticodemacOS 需要 Developer ID、Hardened Runtime、摄像头/麦克风用途说明与公证。
发现凭据泄露、越权、患者数据写入日志或视频房间被未授权加入时,应立即停用相关凭据、保留审计证据并按组织的安全响应流程处置。
Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

+10
View File
@@ -0,0 +1,10 @@
"""Development entry point.
The installed application uses the ``doctor-workstation`` console script. Keeping
this tiny launcher makes ``python main.py`` convenient for local development.
"""
from doctor_workstation.app import main
if __name__ == "__main__":
raise SystemExit(main())
+5
View File
@@ -0,0 +1,5 @@
#!/bin/bash
set -u
project_root="$(cd "$(dirname "$0")" && pwd -P)"
exec /bin/bash "$project_root/scripts/package_macos.sh"
+33
View File
@@ -0,0 +1,33 @@
# Video-enabled desktop packaging
The spec creates an `onedir` build and embeds `video_companion/dist` as `video_companion_dist`. Explicit QtWebEngine imports activate PyInstaller's maintained PySide6 hooks; the build scripts then fail if the resulting artifact does not contain `QtWebEngineProcess` or Chromium `.pak` resources.
`PySide6.QtMultimedia` and `PySide6.QtMultimediaWidgets` are also explicit hidden imports. Their maintained PyInstaller hooks collect the Python extension modules, `Qt6Multimedia`/`Qt6MultimediaWidgets` DLLs, dylibs or frameworks, `plugins/multimedia`, and the platform media backends. The build scripts assert those frozen files before accepting an artifact.
Both build scripts launch the frozen executable twice. `--media-smoke-test` is handled by a PyInstaller runtime hook before the normal application entry point: it imports both multimedia modules, constructs `QMediaPlayer`, `QAudioOutput`, and `QVideoWidget`, checks that a decoder backend exposes formats, runs one offscreen event-loop turn, and returns non-zero on any failure. The existing `--smoke-test` then validates the packaged application bootstrap. Each process uses a temporary user/config directory, loopback-only proxy settings, and a 30-second deadline; a non-zero exit or an unhandled exception in its logs fails the build.
Run the build on the target operating system. PyInstaller cannot cross-build Windows and macOS artifacts.
## Windows
```powershell
.\scripts\build_windows.ps1
```
The default interpreter is `.venv-build\Scripts\python.exe`; override it with `-Python C:\path\to\python.exe`.
For the one-click release ZIP and SHA-256 manifest, run `Build_DoctorWorkstation.bat`. It prepares locked dependencies, invokes the build/file/smoke gates, and archives only after all gates pass.
## macOS
```bash
bash ./scripts/build_macos.sh
```
The default interpreter is `.venv-build/bin/python`. For release signing, export `MACOS_CODESIGN_IDENTITY` before building. The generated app includes camera/microphone usage descriptions and the main-process entitlements in `macos/entitlements.plist`.
For the one-click release ZIP and SHA-256 file, use `package_macos.command` (or `一键打包.command`). All root `.command` files and operational `scripts/*.sh` files must be tracked with mode `100755`; `scripts/check_macos_entrypoints.sh` verifies both filesystem executability and Git index mode before packaging.
Before notarization, verify the nested `QtWebEngineProcess.app` signature and preserve its Qt-provided helper entitlements. Sign nested code before the outer app, then notarize and staple the final distribution artifact.
`.env.example` is a source/deployment template and is intentionally not included in the release archives. Production endpoints and non-secret policy values should be injected into the process environment by the managed launcher/MDM. Never place passwords, tokens, UserSig, TRTC SecretKey, or other long-lived credentials in a release archive.
+127
View File
@@ -0,0 +1,127 @@
# -*- mode: python ; coding: utf-8 -*-
"""Cross-platform PyInstaller onedir spec for the video-enabled workstation.
PyInstaller's official PySide6 QtWebEngine hooks are activated by the explicit
hidden imports below. Those hooks retain QtWebEngineProcess, Chromium .pak/
ICU resources, locales, Qt plugins, and the macOS framework/helper layout.
The explicit QtMultimedia imports likewise activate PyInstaller's official Qt
dependency scan, which collects the QtMultimedia/QtMultimediaWidgets extension
modules, Qt6Multimedia shared libraries/frameworks, multimedia plugins, and
their platform media backends.
"""
import os
import sys
from pathlib import Path
PROJECT_ROOT = Path(SPEC).resolve().parent.parent
SOURCE_ROOT = PROJECT_ROOT / "src"
ENTRY_POINT = SOURCE_ROOT / "doctor_workstation" / "__main__.py"
VIDEO_DIST = PROJECT_ROOT / "video_companion" / "dist"
RESOURCES = PROJECT_ROOT / "resources"
ENTITLEMENTS = PROJECT_ROOT / "packaging" / "macos" / "entitlements.plist"
VERSION_FILE = PROJECT_ROOT / "packaging" / "windows" / "version_info.txt"
MEDIA_SMOKE_HOOK = PROJECT_ROOT / "packaging" / "runtime_media_smoke.py"
if not ENTRY_POINT.is_file():
raise SystemExit(f"Application entry point is missing: {ENTRY_POINT}")
if not (VIDEO_DIST / "index.html").is_file():
raise SystemExit("Build video_companion before running PyInstaller")
if not MEDIA_SMOKE_HOOK.is_file():
raise SystemExit(f"Frozen multimedia smoke hook is missing: {MEDIA_SMOKE_HOOK}")
# Some Windows developer tools add an unrelated OpenSSL installation to PATH.
# PyInstaller's dependency scanner would then pair Python's ``_ssl.pyd`` with
# those incompatible DLLs. Put the running interpreter's DLL directory first
# and collect the exact same files explicitly so the build is reproducible.
python_runtime_binaries = []
if sys.platform == "win32":
python_dll_dir = Path(sys.base_prefix) / "DLLs"
for dll_name in ("libssl-3-x64.dll", "libcrypto-3-x64.dll"):
dll_path = python_dll_dir / dll_name
if not dll_path.is_file():
raise SystemExit(f"Python runtime dependency is missing: {dll_path}")
python_runtime_binaries.append((str(dll_path), "."))
os.environ["PATH"] = os.pathsep.join((str(python_dll_dir), os.environ.get("PATH", "")))
qt_webengine_hiddenimports = [
# Importing these modules lets PyInstaller's official Qt hooks collect the
# helper executable/app, resources, locales, frameworks, and plugins.
"PySide6.QtWebChannel",
"PySide6.QtWebEngineCore",
"PySide6.QtWebEngineWidgets",
"PySide6.QtNetwork",
"PySide6.QtPrintSupport",
]
qt_multimedia_hiddenimports = [
# These are intentionally explicit instead of relying on imports hidden by
# the application's optional media fallback. PyInstaller's official
# hooks collect Qt6Multimedia*.dll/.dylib/framework, plugins/multimedia,
# and the platform FFmpeg/native backend dependencies.
"PySide6.QtMultimedia",
"PySide6.QtMultimediaWidgets",
]
analysis = Analysis(
[str(ENTRY_POINT)],
pathex=[str(SOURCE_ROOT)],
binaries=python_runtime_binaries,
datas=[
(str(VIDEO_DIST), "video_companion_dist"),
(str(RESOURCES), "resources"),
],
hiddenimports=qt_webengine_hiddenimports + qt_multimedia_hiddenimports,
hookspath=[],
hooksconfig={},
runtime_hooks=[str(MEDIA_SMOKE_HOOK)],
excludes=[],
noarchive=False,
optimize=0,
)
pyz = PYZ(analysis.pure)
is_macos = sys.platform == "darwin"
exe = EXE(
pyz,
analysis.scripts,
[],
exclude_binaries=True,
name="DoctorWorkstation",
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=False,
console=False,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=os.environ.get("MACOS_CODESIGN_IDENTITY") if is_macos else None,
entitlements_file=str(ENTITLEMENTS) if is_macos else None,
version=str(VERSION_FILE) if sys.platform == "win32" else None,
)
collection = COLLECT(
exe,
analysis.binaries,
analysis.datas,
strip=False,
upx=False,
name="DoctorWorkstation",
)
if is_macos:
app = BUNDLE(
collection,
name="DoctorWorkstation.app",
icon=None,
bundle_identifier="com.zyt.doctor-workstation",
info_plist={
"CFBundleDisplayName": "",
"NSCameraUsageDescription": "",
"NSMicrophoneUsageDescription": "",
"NSHighResolutionCapable": True,
},
)
+20
View File
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.device.camera</key>
<true/>
<key>com.apple.security.device.audio-input</key>
<true/>
<key>com.apple.security.device.microphone</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
</dict>
</plist>
+85
View File
@@ -0,0 +1,85 @@
"""Frozen-process gate for the Qt multimedia runtime.
PyInstaller executes this file as a runtime hook. Normal application starts
are untouched; ``--media-smoke-test`` exits before the application entry point
after proving that the frozen Qt multimedia modules and a decoder backend can
be loaded in an offscreen Qt event loop.
"""
from __future__ import annotations
import sys
MEDIA_SMOKE_ARGUMENT = "--media-smoke-test"
def _emit(message: str, *, error: bool = False) -> None:
# Windowed PyInstaller executables set stdout/stderr to None on Windows.
# The exit status is the gate contract; diagnostics are best-effort.
stream = sys.stderr if error else sys.stdout
if stream is not None:
print(message, file=stream, flush=True)
def _run_media_smoke_gate() -> None:
# Keep these imports inside the gate. Missing frozen extension modules or
# linked Qt multimedia libraries must make this process fail, while normal
# launches retain the application's existing fallback behaviour.
from PySide6.QtCore import QTimer
from PySide6.QtMultimedia import QAudioOutput, QMediaFormat, QMediaPlayer
from PySide6.QtMultimediaWidgets import QVideoWidget
from PySide6.QtWidgets import QApplication
application = QApplication.instance()
owns_application = application is None
if application is None:
application = QApplication(["DoctorWorkstationMediaSmoke"])
player = QMediaPlayer()
audio_output = QAudioOutput()
video_widget = QVideoWidget()
player.setAudioOutput(audio_output)
player.setVideoOutput(video_widget)
if not player.isAvailable():
raise RuntimeError("Qt reports that no multimedia backend is available")
decode_formats = QMediaFormat().supportedFileFormats(QMediaFormat.ConversionMode.Decode)
if not decode_formats:
raise RuntimeError("Qt multimedia loaded without a supported decoder format")
video_widget.resize(16, 16)
video_widget.show()
QTimer.singleShot(0, application.quit)
event_status = application.exec()
video_widget.close()
player.setVideoOutput(None)
player.setAudioOutput(None)
if event_status != 0:
raise RuntimeError(f"Qt multimedia offscreen event loop exited with {event_status}")
# QApplication cannot be recreated safely in-process. The runtime hook is
# a one-shot frozen gate, but retaining this distinction makes direct test
# imports predictable.
if owns_application:
application.processEvents()
def _dispatch() -> int | None:
if MEDIA_SMOKE_ARGUMENT not in sys.argv[1:]:
return None
try:
_run_media_smoke_gate()
except Exception as exc:
_emit(
f"Frozen Qt multimedia smoke gate failed: {type(exc).__name__}: {exc}",
error=True,
)
return 70
_emit("Frozen Qt multimedia smoke gate passed.")
return 0
_media_smoke_status = _dispatch()
if _media_smoke_status is not None:
raise SystemExit(_media_smoke_status)
+19
View File
@@ -0,0 +1,19 @@
@echo off
setlocal EnableExtensions DisableDelayedExpansion
set "APPLICATION=%~dp0DoctorWorkstation\DoctorWorkstation.exe"
if not exist "%APPLICATION%" (
echo DoctorWorkstation.exe was not found.
echo Please keep this launcher beside the DoctorWorkstation folder.
pause
exit /b 1
)
start "" "%APPLICATION%"
if errorlevel 1 (
echo DoctorWorkstation failed to start.
pause
exit /b 1
)
exit /b 0
+31
View File
@@ -0,0 +1,31 @@
# UTF-8
# Example PyInstaller version resource. Update all four version tuples together.
VSVersionInfo(
ffi=FixedFileInfo(
filevers=(0, 1, 0, 0),
prodvers=(0, 1, 0, 0),
mask=0x3f,
flags=0x0,
OS=0x40004,
fileType=0x1,
subtype=0x0,
date=(0, 0)
),
kids=[
StringFileInfo([
StringTable(
'080404B0',
[
StringStruct('CompanyName', 'ZYT'),
StringStruct('FileDescription', '医生工作台'),
StringStruct('FileVersion', '0.1.0.0'),
StringStruct('InternalName', 'DoctorWorkstation'),
StringStruct('OriginalFilename', 'DoctorWorkstation.exe'),
StringStruct('ProductName', '医生工作台'),
StringStruct('ProductVersion', '0.1.0.0')
]
)
]),
VarFileInfo([VarStruct('Translation', [2052, 1200])])
]
)
+49
View File
@@ -0,0 +1,49 @@
[build-system]
requires = ["hatchling>=1.25"]
build-backend = "hatchling.build"
[project]
name = "zhenyang-doctor-workstation"
version = "0.1.0"
description = "Cross-platform doctor consultation workstation for Windows and macOS"
readme = "README.md"
requires-python = ">=3.11"
license = { text = "Proprietary" }
authors = [{ name = "Zhenyangtang" }]
dependencies = [
"httpx>=0.27.2,<1",
"keyring>=25.5,<26",
"platformdirs>=4.3,<5",
"PySide6>=6.8.2,<7",
"python-dotenv>=1.0.1,<2",
]
[project.optional-dependencies]
dev = [
"pytest>=8.3,<9",
"pytest-cov>=6,<7",
"ruff>=0.9,<1",
]
build = [
"pyinstaller>=6.11,<7",
]
[project.scripts]
doctor-workstation = "doctor_workstation.app:main"
[tool.hatch.build.targets.wheel]
packages = ["src/doctor_workstation"]
[tool.pytest.ini_options]
addopts = "-q"
pythonpath = ["src"]
testpaths = ["tests"]
[tool.ruff]
line-length = 100
target-version = "py311"
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "SIM"]
ignore = ["E501"]
+820
View File
@@ -0,0 +1,820 @@
# admin 医生端源码审计
审计日期:2026-08-10
参考项目:D:\web\zyt\admin
审计方式:只读检查 Vue/TypeScript 源码、API 封装、路由守卫、Pinia store、业务组件和权限判断;未修改 admin 项目,也未把 README 当作结论来源。
## 1. 结论摘要
1. admin 是 Vue 3 + TypeScript + Vite + Element Plus + Pinia 项目。医生端并不是一套独立的静态路由:除登录、H5 诊单和只读诊单外,页面路径、标题、组件和菜单权限都由登录后 GET /adminapi/auth.admin/mySelf 返回的 menu 动态注入。
2. 医生相关页面的全局状态很少。Pinia 只持有认证用户、权限、动态菜单、全局站点配置、布局与多标签;接诊台、处方、患者、问诊列表的查询条件和业务状态均保留在各页面的 ref/reactive 中,分页统一使用 usePaging。
3. “患者列表”有两个不同实现:
- 医生/一诊工作台的“我的患者”:src/views/first_visit/my_patients/index.vue,带患者、订单、面诊进度三个工作区,服务端按当前角色和部门数据范围收窄。
- 平台注册用户列表:src/views/consumer/lists/index.vue,仅展示头像、昵称、账号、手机号、渠道和注册时间,不是医生业务患者工作台。
4. “问诊列表”也有两个相关实现:
- 挂号/问诊执行列表:src/views/tcm/appointment/list.vue,默认“今天 + 待接诊”,支持通话、视频二维码、完成、开方、取消。
- 诊单/患者业务列表:src/views/tcm/diagnosis/index.vue,围绕诊单、挂号、确认、开方、医助指派、二维码和视频旁观。
最终菜单叫什么、URL 是什么取决于后端 menu 配置,不应仅根据文件名硬编码。
5. 实际视频问诊主链是 src/components/chat-dialog/index.vue:腾讯云 Chat UIKit 单聊 + TUICallKit 音视频;通话前后还串联后端通话记录、TRTC 房间绑定、云端混流录制、可选浏览器本地录制、截屏写医生备注。src/components/video-call/index.vue 是另一套旧/独立实现,目前源码中没有被任何页面引用。
6. 处方领域要区分三类对象:
- 处方库模板:tcm.prescriptionLibrary,供医生复用药材组合。
- 已开处方:tcm.prescription,处方笺、患者、医师签名、主辅方、审核与作废。
- 处方业务订单:tcm.prescriptionOrder,收货、费用、双审、支付单、药房和物流履约;它不是支付单 zyt_order。
## 2. 关键源码与路由
### 2.1 页面定位
| 业务 | 关键源文件(绝对路径) | 路由结论 |
|---|---|---|
| 登录 | D:\web\zyt\admin\src\views\account\login.vue | 静态精确路由 /login |
| 接诊台 | D:\web\zyt\admin\src\views\patient\reception\index.vue | 动态菜单组件键应指向 patient/reception/index;实际 URL 取 menu[].paths |
| 我的处方库 | D:\web\zyt\admin\src\views\consumer\prescription\list.vue | 动态菜单组件键应指向 consumer/prescription/list;实际 URL 取 menu[].paths |
| 药品库(不是处方库) | D:\web\zyt\admin\src\views\doctor\medicine.vue | 动态菜单组件键应指向 doctor/medicine |
| 已开处方/处方管理 | D:\web\zyt\admin\src\views\consumer\prescription\index.vue | 动态菜单组件键应指向 consumer/prescription/index |
| 处方业务订单 | D:\web\zyt\admin\src\views\consumer\prescription\order_list.vue | 动态菜单组件键应指向 consumer/prescription/order_list |
| 我的患者 | D:\web\zyt\admin\src\views\first_visit\my_patients\index.vue | 动态菜单组件键应指向 first_visit/my_patients/index |
| 平台用户列表 | D:\web\zyt\admin\src\views\consumer\lists\index.vue | 动态菜单组件键应指向 consumer/lists/index |
| 问诊/挂号列表 | D:\web\zyt\admin\src\views\tcm\appointment\list.vue | 动态菜单组件键应指向 tcm/appointment/list |
| 诊单列表 | D:\web\zyt\admin\src\views\tcm\diagnosis\index.vue | 动态菜单组件键应指向 tcm/diagnosis/index;另有静态 H5 路由 /tcm/diagnosis/h5 |
| 诊单编辑/只读抽屉 | D:\web\zyt\admin\src\views\tcm\diagnosis\edit.vue | 被多个页面异步复用,不一定是独立菜单 |
| 患者只读详情 | D:\web\zyt\admin\src\views\tcm\diagnosis\readonly.vue | 静态精确路由 /tcm/diagnosis-readonly?id=诊单ID |
| 预约视频问诊 | D:\web\zyt\admin\src\views\tcm\diagnosis\appointment.vue | 抽屉组件,由诊单列表/我的患者调用 |
| 诊间开方 | D:\web\zyt\admin\src\components\tcm-prescription\index.vue | 复用组件,由问诊列表和诊单编辑调用 |
| 聊天与视频问诊 | D:\web\zyt\admin\src\components\chat-dialog\index.vue | 浮动组件,由接诊台及问诊列表调用 |
| 医助视频旁观 | D:\web\zyt\admin\src\views\tcm\diagnosis\components\AssistantWatchCallDialog.vue | 诊单列表内异步组件 |
### 2.2 动态路由机制
关键文件:
- D:\web\zyt\admin\src\router\routes.ts
- D:\web\zyt\admin\src\router\index.ts
- D:\web\zyt\admin\src\permission.ts
- D:\web\zyt\admin\src\stores\modules\user.ts
流程:
1. 常量路由只注册 /login、/403、/change-password、/bind-work-wechat、/user/setting、/doctor/progress、/tcm/diagnosis/h5 和 /tcm/diagnosis-readonly 等少数页面。
2. 登录成功后 GET /auth.admin/mySelf。
3. user store 保存 data.user、data.permissions,并把 data.menu 交给 filterAsyncRoutes。
4. 每个后端菜单项使用以下字段转为 Vue Router:
- paths:路由路径;
- componentsrc/views 下的组件键;
- name:菜单标题;
- perms:写入 route.meta.perms
- is_show:控制 meta.hidden
- is_cache:控制 keepAlive
- params:默认 query
- selectedactiveMenu
- type:目录或菜单。
5. permission.ts 把转换后的路由动态挂到根布局;第一个可见菜单成为 / 的重定向目标。
因此,本仓库源码能确定组件和静态路由,但不能单独确定接诊台、处方库、已开处方、我的患者、问诊列表的生产 URL 与菜单标题。要得到精确值,必须取得当前环境 /auth.admin/mySelf 的 menu 响应或检查服务端菜单表。
## 3. 登录、认证、权限和状态管理
### 3.1 登录链路
关键文件:
- D:\web\zyt\admin\src\views\account\login.vue
- D:\web\zyt\admin\src\api\user.ts
- D:\web\zyt\admin\src\stores\modules\user.ts
- D:\web\zyt\admin\src\utils\request\index.ts
- D:\web\zyt\admin\src\utils\auth.ts
账号密码:
- POST /login/account
- 请求:account、password、terminal=1。
- 响应被页面使用的字段:token、is_paw、need_bind_work_wechat。
- token 写入本地缓存键 token,后续请求通过请求拦截器放到 HTTP 头 token。
- “记住账号”只缓存 account,不缓存密码,缓存键为 account。
企业微信:
- GET /login/workWechatConfig,使用 enabled、corp_id、agent_id。
- 企业微信内置浏览器走 OAuthscope=snsapi_privateinfo、state=admin_login。
- 普通浏览器动态加载 https://wwcdn.weixin.qq.com/node/wework/wwopen/js/wwLogin-1.2.7.js 显示扫码登录。
- 回调 code 通过 POST /login/workWechatLogin,参数 code、terminal=1。
- 另有 POST /auth.admin/bindWorkWechat、POST /auth.admin/unbindWorkWechat。
守卫:
- is_paw=0 强制跳转 /change-password,并通过 POST /login/changeFirstPassword 修改。
- need_bind_work_wechat=true 强制进入 /bind-work-wechat。
- 没有 token 的非白名单路由跳 /login?redirect=原地址。
- /auth.admin/mySelf 没有任何有效菜单时清认证并跳 /403。
- 响应码约定:1 成功、0 失败、-1 登录失效、10 需要绑定企微、2 打开新页面、-2 未安装。
### 3.2 Pinia 与页面状态
| Store/Hook | 文件 | 职责 |
|---|---|---|
| user | D:\web\zyt\admin\src\stores\modules\user.ts | token、userInfo、routes、perms、isPaw;登录、退出、企微登录、加载个人信息 |
| app | D:\web\zyt\admin\src\stores\modules\app.ts | 网站配置、OSS 图片地址、移动端/侧栏状态、视图刷新 |
| tabs | D:\web\zyt\admin\src\stores\modules\multipleTabs.ts | 多标签与 keep-alive 缓存 |
| setting | D:\web\zyt\admin\src\stores\modules\setting.ts | 本地布局、主题配置 |
| usePaging | D:\web\zyt\admin\src\hooks\usePaging.ts | 页码、page_size、loading、count、lists、extend;支持 silent 静默刷新 |
列表接口统一期待服务端 data 为:
- lists:当前页数组;
- count:总数;
- extend:额外统计、日期、权限范围等扩展数据。
页面内筛选和弹窗状态不进入 Pinia。这一约定适合桌面端复用:认证/权限做全局 store,业务工作台保持页面级 store 或 view-model。
### 3.3 权限判断语义
关键文件:
- D:\web\zyt\admin\src\install\directives\perms.ts
- D:\web\zyt\admin\src\utils\perm.ts
需要特别注意两套语义不同:
- v-perms 数组是“任一权限命中即可显示”(OR)。
- hasPermission 数组是“数组内每个权限都必须存在”(AND)。
- permissions 含星号时视为全部权限。
多数业务调用只传单个权限,因此差异暂时不明显;复用时不要把多权限数组在两处互换。
## 4. 业务页面审计
### 4.1 接诊台
源码:
- D:\web\zyt\admin\src\views\patient\reception\index.vue
- D:\web\zyt\admin\src\api\patient.ts
- D:\web\zyt\admin\src\views\tcm\diagnosis\components\PatientInfoCard.vue
- D:\web\zyt\admin\src\views\tcm\diagnosis\components\PatientCaseCard.vue
- D:\web\zyt\admin\src\views\tcm\diagnosis\components\DailyMatrix.vue
- D:\web\zyt\admin\src\views\patient\reception\components\NoteTimeline.vue
页面行为:
- 默认显示当天 status=1 待接诊;可切到 status=4 已过号。
- 搜索字段 patient_name;分页 page_no/page_size,固定每页 15。
- 每 5 秒静默刷新队列和已选患者详情;页面隐藏时暂停,恢复可见后立即刷新。
- 队列使用无限滚动,并按 id 去重。
- 队列行主要字段:id、patient_id、patient_name、patient_phone、diagnosis_id、doctor_id/name、assistant_id/name、appointment_date/time、gender、age、status/status_desc、has_prescription、remark。
- 详情结构按源码使用为:
- appointment:挂号;
- diagnosis:诊单/病例;
- doctor_notes:医生备注、舌苔和报告。
- 日常记录不依赖 reception 响应完整下发,而由 DailyMatrix 继续按 diagnosis_id 调 trackingWindow/trackingNotes。
操作:
- 通知医助:POST /doctor.appointment/notifyAssistant,参数 id=挂号ID。
- 发起通话:先 POST /tcm.diagnosis/getCallSignature,参数 patient_id、diagnosis_id,再打开 ChatDialog。
- 备注:POST /doctor.appointment/addDoctorNote,参数 diagnosis_id、content,可追加 tongue_images、report_files。
- 编辑病历:复用 tcm/diagnosis/edit.vue。
- 完成接诊:POST /doctor.appointment/complete,参数 id=挂号ID;页面允许 status=1 或 4。
权限:
- doctor.appointment/addDoctorNote
- tcm.diagnosis/edit
- doctor.appointment/complete
源码中的“通知医助”和“发起通话”按钮没有 v-perms;只能依赖页面菜单权限和后端接口鉴权,桌面端若拆成独立入口应补显式能力判断。
### 4.2 我的处方库
源码:
- D:\web\zyt\admin\src\views\consumer\prescription\list.vue
- D:\web\zyt\admin\src\api\tcm.ts
- D:\web\zyt\admin\src\components\medicine-name-select\index.vue
模型与筛选:
- 查询:prescription_name、formula_type(主方/辅方)、is_public(0 仅自己、1 所有人)。
- 列表:id、prescription_name、formula_type、herbs、is_public、disable_edit、creator_id/name、create_time。
- herbs 项:medicine_id(可选)、name、dosage。
- 编辑:id、prescription_name、formula_type、herbs、is_public、disable_edit。
- disable_edit=1 表示导入模板后锁定整张处方的药材,不可增删改,只能再次导入覆盖。
接口:
- GET /tcm.prescriptionLibrary/lists
- POST /tcm.prescriptionLibrary/add
- POST /tcm.prescriptionLibrary/edit
- POST /tcm.prescriptionLibrary/delete,参数 id
- GET /tcm.prescriptionLibrary/detail,参数 id(API 已封装,但当前列表弹窗直接使用行数据)
权限:
- wcf.prescription/add
- wcf.prescription/read
- wcf.prescription/edit
- wcf.prescription/delete
所有权:
- 普通用户只可编辑/删除 creator_id 等于当前 userInfo.id 的模板。
- root=1 或 role_ids 包含 0、3 可管理全部模板。
- 诊间/已开处方导入模板时会额外传 prescribing_creator_id,通常取处方 creator_id,新增时取当前登录用户 id。
### 4.3 药品库(容易和处方库混淆)
源码:D:\web\zyt\admin\src\views\doctor\medicine.vue
接口:
- GET /doctor.medicine/lists
- POST /doctor.medicine/add
- POST /doctor.medicine/edit
- POST /doctor.medicine/delete
- GET /doctor.medicine/detail
模型:
- id、name、supplier、unit、settlement_price、retail_price、stock、image、status、remark。
- 图片上传直接 POST 到 VITE_APP_BASE_URL + /api/upload/image,并携带 token 头。
当前页面的增删改按钮没有 v-perms。它是药材主数据管理,不应直接当作“我的处方库”复刻。
### 4.4 已开处方/处方管理
源码:
- D:\web\zyt\admin\src\views\consumer\prescription\index.vue
- D:\web\zyt\admin\src\components\tcm-prescription\index.vue
- D:\web\zyt\admin\src\api\tcm.ts
列表筛选:
- sn:处方编号模糊查;
- patient_name
- creator_ids:开方医师多选;
- audit_filterall、pending、passed、not_passed、rejected
- source_filterall、manual、system
- start_time、end_time(按创建时间)。
列表核心字段:
- id、sn、prescription_type
- is_system_auto0 手工、1 空白处方/系统代开;
- patient_name、gender、age、phone
- audit_status、audit_remark、business_prescription_audit_rejected、business_prescription_audit_remark
- void_status、void_by_name、void_time
- doctor_name、creator_id、assistant_name、prescription_date、create_time
- has_prescription_order。
处方编辑/详情模型:
- 关联:id、diagnosis_id、creator_id。
- 患者:patient_name、gender、age、visit_no、prescription_date。
- 诊断:tongue、tongue_image、pulse、pulse_condition、clinical_diagnosis。
- 药材:herbs,每项 medicine_id、name、dosage、formula_type(主方/辅方)、locked。
- 剂型/用法:prescription_type、dosage_amount、dosage_unit、dosage_bag_count、need_decoction、bags_per_dose、dose_count、dose_unit、usage_days、times_per_day、usage_instruction、usage_time、usage_way、dietary_taboo、usage_notes。
- 辅方用法 aux_usagedosage_amount、dosage_bag_count、need_decoction、bags_per_dose、times_per_day、usage_days、prescription_name(部分页面保留模板名)。
- 医师:doctor_name、doctor_signaturePNG data URL,保存前必填)。
- 可见性/审核:is_shared、visible_role_ids、audit_status、audit_time、audit_by_name、audit_remark。
状态规则:
- audit_status0 待审核、1 已通过、2 已驳回。
- 驳回处方会同时作废。
- “已通过且未作废”的有效处方不能普通编辑/删除。
- 新增时前端强制 audit_status=0;编辑保存后提示重新进入待审核。
- 诊间开方组件会保存 case_record 病历快照,并在已有 appointment_id 处方时直接进入只读查看。
- 已存在业务订单时,诊间组件禁止作废处方。
主要接口:
- GET /tcm.prescription/lists
- GET /tcm.prescription/detail,参数 id
- POST /tcm.prescription/add
- POST /tcm.prescription/edit
- POST /tcm.prescription/delete,参数 id
- POST /tcm.prescription/patchPatient,参数 id、patient_name、phone、gender
- POST /tcm.prescription/audit,参数 id、action=approve|reject、remark
- POST /tcm.prescription/void,参数 id
- GET /tcm.prescription/listByDiagnosis,参数 diagnosis_id
- GET /tcm.prescription/getByAppointment,参数 appointment_id
权限:
- cf.prescription/add、read、edit、audit、del
- tcm.prescription/patchPatient
- tcm.prescriptionLibrary/lists
- tcm.prescriptionOrder/create、lists、setShipMode
- finance.account_log/lists
- tcm.prescriptionOrder/editRemarkExtra
角色补充:消费者处方页将 root 或 role_ids 0、3 视为可审核角色;仍应以后端和 cf.prescription/audit 为最终判定。
### 4.5 处方业务订单
源码:
- D:\web\zyt\admin\src\views\consumer\prescription\order_list.vue
- D:\web\zyt\admin\src\views\consumer\prescription\components\PrescriptionOrderDetailDrawer.vue
- D:\web\zyt\admin\src\views\consumer\prescription\components\prescription-order-utils.ts
此页面是已开处方的相邻履约域。核心字段:
- id、order_no、prescription_id、diagnosis_id
- recipient_name、recipient_phone、region、shipping_address
- fee_type、amount、internal_cost
- prescription_audit_status、payment_slip_audit_status
- fulfillment_status
- linked_pay_order_count、linked_pay_order_id、linked_pay_paid_total
- medication_days、service_channel、service_package
- express_company、tracking_number、ship_mode
- doctor_name、creator_id/name、assistant_id
- remark_extra、remark_assistant
- 药房提交号和状态。
审核状态统一为 0 待审核、1 已通过、2 已驳回。履约状态:
- 1 待双审通过
- 2 待发货
- 3 已完成
- 4 已取消
- 5 已发货
- 6 已签收
- 7 进行中
- 8 暂不制药
- 9 拒收
- 10 退款
- 11 保留药方
- 12 制药缓发
核心接口:
- GET /tcm.prescriptionOrder/lists、detail、paidPayOrders、logs、logisticsTrace、export
- POST /tcm.prescriptionOrder/create、edit、withdraw、ddcode
- POST /tcm.prescriptionOrder/auditPrescription、auditPayment、revokeRxAudit、revokePayAudit
- POST /tcm.prescriptionOrder/ship、complete、refund、requestCompletion
- POST /tcm.prescriptionOrder/addPayOrder、linkPayOrder
- POST /tcm.prescriptionOrder/patchPrescriptionPatient、patchPrescriptionUsage、updateAmount
- POST /tcm.prescriptionOrder/setShipMode、uploadToPharmacy
- POST /tcm.prescriptionOrder/submitGancaoRecipel、previewGancaoRecipel、confirmGancaoSubmission
- POST /tcm.prescriptionOrder/batchAssignAssistant、addLog
主要权限:
- tcm.prescriptionOrder/detail、edit、export、ddcode、ship、addPayOrder、complete、refund、withdraw
- tcm.prescriptionOrder/auditPrescription、auditPayment
- tcm.prescriptionOrder/setShipMode、uploadToPharmacy、editRemarkExtra
- finance.account_log/lists、prescription.order/finance
前端还存在角色级显示规则:
- role 2:医助;
- role 6:下单角色;
- role 3、8:下单筛选豁免;
- role 0、3、6:财务字段;
- role 0、3:可绕过双审后的创建人编辑锁、可批量改派;
- 业务订单处方审核角色在共享工具中为 0、3、6。
这些数字与服务端配置耦合,不宜在新客户端再次散落硬编码。
### 4.6 我的患者
首选医生端实现:
- D:\web\zyt\admin\src\views\first_visit\my_patients\index.vue
- D:\web\zyt\admin\src\api\first_visit.ts
页面结构:
- “患者列表”“订单管理”“面诊进度”三个工作区。
- 筛选 keyword、status_filter、start_date、end_date。
- status_filterunbooked 未预约、pending_interview 待面诊、completed 已完成、missed 已过号。
- 日期快捷:今天、明天、后天、近 7 天、近 30 天、自定义。
- extend.summary 返回 today/tomorrow/day_after 计数。
- extend.dates 返回对应日期。
- extend.scope.label 直接展示后端判定的数据范围。
列表使用字段:
- diagnosis_id 或 id、source_patient_id
- patient_name、gender_desc、age、phone_masked、has_id_card
- assistant_id/name
- appointment_id、appointment_doctor_id/name、appointment_status、appointment_status_text、appointment_time_text
- revisit_count、confirmed、confirmation_text、diagnosis_date_text。
操作与接口:
- GET /firstvisit.myPatient/lists
- GET /firstvisit.myPatient/assistants
- POST /firstvisit.myPatient/assignid、assistant_id、is_inherit=0|1
- POST /firstvisit.myPatient/fillIdCardid、id_card
- POST /firstvisit.myPatient/createAppointment:预约完整参数
- POST /firstvisit.myPatient/cancelAppointmentid=挂号ID
- 订单工作区另使用 /firstvisit.myPatient/orders、orderDetail、orderEdit 及双审/发货/退款等受限代理接口。
- 面诊进度使用 GET /firstvisit.myPatient/progress。
权限:
- tcm.diagnosis/edit
- tcm.diagnosis/readonlyDetail
- tcm.diagnosis/guahao
- tcm.diagnosis/assign
服务端按当前角色与部门范围裁剪数据;前端不自行拼接 doctor_id 或 department_id 来模拟数据权限。
平台用户列表 D:\web\zyt\admin\src\views\consumer\lists\index.vue 使用 GET /user.user/lists,字段是 avatar、nickname、account、mobile、channel、create_time,只适合账号管理,不适合医生患者列表。
### 4.7 问诊/挂号列表
源码:
- D:\web\zyt\admin\src\views\tcm\appointment\list.vue
- D:\web\zyt\admin\src\api\doctor.ts
默认条件:
- status=1 待接诊;
- start_date=end_date=今天;
- date_preset=today
- 20 秒静默轮询;
- include_status_counts=1 时从 extend.status_count 一次返回各状态角标。
状态:
- 1 待接诊/已预约
- 2 已取消
- 3 已完成
- 4 已过号
筛选:
- patient_name、doctor_name
- status
- start_date、end_date、date_preset
- diagnosis_confirmed
- assistant_dept_id(选父部门含子级)。
行字段:
- id、patient_id、diagnosis_id
- patient_name、patient_phone、gender、age、height、weight
- doctor_id/name、assistant_name
- appointment_date、appointment_time、period
- diagnosis_confirmed
- has_prescription、prescription_is_system_auto、prescription_audit_status、prescription_void_status
- status/status_desc、remark。
操作:
- 编辑患者:复用诊单编辑抽屉。
- 视频二维码:生成小程序码。
- 通话:获取签名并打开 ChatDialog。
- 完成:POST /doctor.appointment/complete,可同时 POST addDoctorNote。
- 开方/查看:复用 TcmPrescription;有效已审核处方显示“查看”。
- 取消:POST /doctor.appointment/cancel。
权限:
- tcm.diagnosis/edit
- tcm.diagnosis/videoQr
- doctor.appointment/prescription
- doctor.appointment/complete
- tcm.diagnosis/kaifang
- doctor.appointment/cancel
- doctor.appointment/addDoctorNote(完成时备注能力)
角色判断:role_id 为 1 医生、2 医助;两者之外才显示医生姓名筛选。此处同时兼容 role_id 单值或数组,但其他页面多使用 role_ids,说明 user 模型尚未完全统一。
### 4.8 诊单列表
源码:
- D:\web\zyt\admin\src\views\tcm\diagnosis\index.vue
- D:\web\zyt\admin\src\views\tcm\diagnosis\edit.vue
- D:\web\zyt\admin\src\views\tcm\diagnosis\readonly.vue
列表筛选字段:
- keyword、diagnosis_type、syndrome_type、assistant_id
- diagnosis_confirmed、appointment_date、has_appointment
- latest_appointment_start_date/end_date/channel_source
- latest_assign_start_date/end_date
- pending_booking、completed_appointment、pending_assign
- pending_assign_order_month、pending_assign_keyword
- sort_unserved_days。
列表使用字段:
- id、patient_name、gender_desc、age
- assistant_id/assistant、assign_read_at
- appointments 或聚合的 appointment_doctor_name、appointment_time_text、appointment_status
- has_appointment、diagnosis_confirmed
- has_prescription、followup_time_text、followup_doctor_name、followup_rx_voided
- unserved_days、last_blood_record_at
- video_call_hint。
video_call_hint
- statenone、pending_room、live 等;
- label
- start_time、end_time。
操作权限:
- tcm.diagnosis/add、edit、delete、readonlyDetail
- tcm.diagnosis/assign
- tcm.diagnosis/kaifang
- tcm.diagnosis/guahao、guahaoLogList
- tcm.diagnosis/videoQr
- tcm.diagnosis/order
- tcm.diagnosis/watchCall
诊单编辑模型的核心字段:
- 标识:id、patient_id。
- 患者:patient_name、id_card、phone、gender、age、marital_status、height、weight、region。
- 诊断:diagnosis_date、diagnosis_type、syndrome_type、diabetes_type、diabetes_discovery_year、local_hospital_diagnosis、local_hospital_name。
- 指标:systolic_pressure、diastolic_pressure、fasting_blood_sugar。
- 现病史多选:appetite、water_intake、diet_condition、weight_change、body_feeling、sleep_condition、eye_condition、head_feeling、sweat_condition、skin_condition、urine_condition、stool_condition、kidney_condition、fatty_liver_degree。
- 既往史:past_history、trauma_history、surgery_history、allergy_history、family_history、pregnancy_history。
- 医疗内容:symptoms、tongue_coating、pulse、treatment_principle、prescription、doctor_advice、remark、current_medications。
- 归属与来源:assistant_id、status、create_source、show_card、external_userid。
详情响应还使用 patient_basic_locked、can_edit_patient_basic、latest_prescription_order。手机号/身份证是否显示明文由 tcm.diagnosis/phonePlain 控制;已有身份证通常只有明文权限才能修改。
诊单详情 Tab 权限:
- tcm.diagnosis/chufang:处方
- tcm.diagnosis/patientOrders:业务订单
- tcm.diagnosis/huifang:视频回放
- tcm.diagnosis/chat:聊天记录
- tcm.diagnosis/assign 或 detail:指派记录
- doctor.appointment/lists:挂号记录
- tcm.diagnosis/dailyRecord:日常记录
## 5. 视频问诊完整链路
### 5.1 预约
源码:D:\web\zyt\admin\src\views\tcm\diagnosis\appointment.vue
目前预约类型只有 video。请求字段:
- patient_id
- doctor_id
- appointment_date
- period=all
- appointment_time
- appointment_type=video
- remark
- channel_source
- channel_source_detail
普通入口 POST /doctor.appointment/create;“我的患者”入口 POST /firstvisit.myPatient/createAppointment。可用时段来自 GET /doctor.appointment/availableSlots,字段 doctor_id、appointment_date、period=all,响应使用 slots[].time、slots[].available。页面还读取医生排班并限制不能重复预约当天 status=1/4 的号。
### 5.2 医生发起通话
生产主组件:D:\web\zyt\admin\src\components\chat-dialog\index.vue
1. 页面调用 open({ patientId, patientName, diagnosisId })。
2. POST /tcm.diagnosis/getCallSignature,请求 patient_id、diagnosis_id。
3. 响应实际使用:
- sdkAppId
- userId(医生 IM/TRTC user ID
- userSig
- patientUserId,缺省回退 patient_加患者ID
- assistant_id(群视频邀请)
- isLochostVod(是否启用浏览器本地录制)
4. Chat UIKit 登录并创建与 patientUserId 的 C2C 会话。
5. TUICallKitServer.init 初始化通话能力;只有成功后才显示 AudioCallPicker、VideoCallPicker 和群视频按钮。
6. beforeCalling 时 POST /tcm.diagnosis/startCall
- diagnosis_id
- patient_id
- call_type=2(视频)
7. 呼叫状态进入 calling/connected 后从 TUIStore 或 TUICallEngine 捕获 roomID/strRoomID,再 POST /tcm.diagnosis/bindCallRoom
- diagnosis_id
- room_id(字符串)
8. bindCallRoom 的响应可带 cloud_recording.started、task_id、message;源码注释说明后端在这里触发腾讯云 CreateCloudRecording,混流模式由后端负责。
9. 接通后,若 isLochostVod=true,前端从 TUICallKit 视频元素启动 MediaRecorder/Canvas 本地录制。
10. 通话结束、挂断、IM 自定义挂断消息或用户关闭窗口时先 POST /tcm.diagnosis/endCall,再完成本地视频上传并 POST attachLocalCallRecording。
群视频调用 TUICallKitServer.callsuserIDList=[patientUserId, assistant_id]type=VIDEO_CALL。
### 5.3 截屏、录制与回放
关键文件:
- D:\web\zyt\admin\src\utils\call-local-recorder.ts
- D:\web\zyt\admin\src\utils\call-video-screenshot.ts
- D:\web\zyt\admin\src\views\tcm\diagnosis\components\CallRecordPanel.vue
- D:\web\zyt\admin\src\views\tcm\diagnosis\components\RecordingPlaybackBlock.vue
视频浮窗“截屏”会:
1. 抓取当前 video frame
2. 上传图片;
3. POST /doctor.appointment/addDoctorNote,把路径追加到 tongue_images。
通话记录字段:
- id、call_type1 语音、其他视为视频)
- room_id
- status:1 进行中、2 已结束、3 未接听、4 已取消
- recording_status_text
- recording_urls_list
- start_time_text、end_time_text、duration_text
接口:
- GET /tcm.diagnosis/getCallRecordsdiagnosis_id
- POST /tcm.diagnosis/attachLocalCallRecordingdiagnosis_id、file_url、可选 call_record_id
- POST /tcm.diagnosis/createManualCallRecorddiagnosis_id
- POST /tcm.diagnosis/startCloudRecordingdiagnosis_idAPI 有封装,主组件当前通过 bindCallRoom 的后端联动启动)
### 5.4 医助旁观
入口在诊单列表。只有同时满足:
- 当前 userInfo.id 等于该诊单 assistant_id
- 拥有 tcm.diagnosis/watchCall
- video_call_hint.state=live
才可真正进入。
GET /tcm.diagnosis/watchCall,参数 diagnosis_id,响应使用:
- sdkAppId
- userId
- userSig
- roomId 或 strRoomId
- patientName
旁观组件直接使用 trtc-sdk-v5 进入房间,只调用 startRemoteVideo,不开启本地摄像头或麦克风。pending_room 时显示入口提示但点击会阻止进入,等待房间号同步。
### 5.5 视频二维码
页面通过 POST /tcm.diagnosis/generateMiniProgramQrcode 生成 qrcode_url,常用字段:
- diagnosis_id
- patient_id
- doctor_id(部分入口)
- share_user_id
- mini_program_path=pages/login/login(问诊列表入口)
调用前先 GET 小程序配置并校验 app_id。
## 6. API 总表
所有 URL 会被请求层加上 baseUrl 和 adminapi 前缀;下表写的是 API 封装中的业务路径。
### 6.1 认证
| 方法 | 路径 | 关键请求/响应 |
|---|---|---|
| POST | /login/account | account、password、terminal;返回 token、is_paw、need_bind_work_wechat |
| POST | /login/workWechatLogin | code、terminal;返回同登录结果 |
| GET | /login/workWechatConfig | enabled、corp_id、agent_id |
| GET | /auth.admin/mySelf | 返回 user、permissions、menu |
| POST | /login/logout | 退出 |
| POST | /login/changeFirstPassword | password、password_confirm |
| POST | /auth.admin/bindWorkWechat | code |
### 6.2 挂号与接诊
| 方法 | 路径 | 关键字段 |
|---|---|---|
| GET | /doctor.appointment/lists | status、start_date、end_date、patient_name、doctor_name、diagnosis_confirmed、assistant_dept_id、page_no、page_size |
| GET | /doctor.appointment/reception | id=挂号ID;返回 appointment、diagnosis、doctor_notes |
| GET | /doctor.appointment/detail | id |
| GET | /doctor.appointment/availableSlots | doctor_id、appointment_date、period |
| POST | /doctor.appointment/create | patient_id、doctor_id、appointment_date/time、appointment_type、渠道等 |
| POST | /doctor.appointment/cancel | id |
| POST | /doctor.appointment/complete | id |
| POST | /doctor.appointment/notifyAssistant | id |
| POST | /doctor.appointment/addDoctorNote | diagnosis_id、content、tongue_images、report_files |
| GET | /doctor.appointment/doctorNotes | diagnosis_id |
| POST | /doctor.appointment/deleteDoctorNoteImage | note_id、image_type、image_path |
### 6.3 诊单
| 方法 | 路径 | 关键字段 |
|---|---|---|
| GET | /tcm.diagnosis/lists | 诊单列表全部筛选 + page_no/page_size;返回 lists/count/extend |
| GET | /tcm.diagnosis/detail | id |
| GET | /tcm.diagnosis/readonlyDetail | id;返回 appointment、diagnosis、unserved_days、last_blood_record_at、doctor_notes |
| POST | /tcm.diagnosis/add | 完整诊单模型 |
| POST | /tcm.diagnosis/edit | 完整诊单模型 |
| POST | /tcm.diagnosis/delete | id |
| POST | /tcm.diagnosis/assign | id、assistant_id、可选 is_inherit;批量场景由前端逐条调用 |
| GET | /tcm.diagnosis/getAssistants | 医助选项 |
| GET | /tcm.diagnosis/getDoctors | 医生选项 |
| POST | /tcm.diagnosis/checkPhone | phone 及排除 id |
| POST | /tcm.diagnosis/checkIdCard | id_card 及排除 id |
| POST | /tcm.diagnosis/fillIdCard | id、id_card |
| GET | /tcm.diagnosis/trackingWindow | id、start_date、end_date |
| GET | /tcm.diagnosis/trackingNotes | diagnosis_id |
| POST | /tcm.diagnosis/addTrackingNote | diagnosis_id、tracking_content |
### 6.4 处方与业务订单
处方、处方库、业务订单接口已在 4.2、4.4、4.5 分节完整列出。实现时必须保留三个资源命名空间,不要把 prescription、prescriptionLibrary、prescriptionOrder 合并成一个“处方”接口。
### 6.5 视频
| 方法 | 路径 | 关键字段 |
|---|---|---|
| POST | /tcm.diagnosis/getCallSignature | patient_id、diagnosis_id;返回 sdkAppId、userId、userSig、patientUserId、assistant_id、isLochostVod |
| POST | /tcm.diagnosis/startCall | diagnosis_id、patient_id、call_type |
| POST | /tcm.diagnosis/bindCallRoom | diagnosis_id、room_id |
| POST | /tcm.diagnosis/startCloudRecording | diagnosis_id |
| POST | /tcm.diagnosis/endCall | diagnosis_id |
| GET | /tcm.diagnosis/getCallRecords | diagnosis_id |
| POST | /tcm.diagnosis/attachLocalCallRecording | diagnosis_id、file_url、可选 call_record_id |
| POST | /tcm.diagnosis/createManualCallRecord | diagnosis_id |
| GET | /tcm.diagnosis/watchCall | diagnosis_id |
| POST | /tcm.diagnosis/generateMiniProgramQrcode | diagnosis_id、patient_id、doctor_id、share_user_id 等 |
## 7. 可复用约定
1. 请求协议
- baseURL 来自 VITE_APP_BASE_URL,统一 URL 前缀 adminapi。
- token 放在名为 token 的请求头,不是 Bearer Authorization。
- POST 默认把 params 转为 bodyGET 使用 params。
- 标准成功响应是 code=1,业务数据自动解包为 data。
- GET 网络失败默认最多重试 2 次,POST 不自动重试。
2. 列表协议
- 请求 page_no、page_size。
- 响应 lists、count、extend。
- 定时刷新使用 getLists({ silent: true }),避免表格白屏闪烁。
3. 标识约定
- diagnosis_id 是诊单主键。
- appointment.id 是挂号主键。
- prescription.id 是处方主键。
- prescriptionOrder.id 是处方业务订单主键。
- patientUserId 是腾讯云 IM/TRTC 用户名,通常 patient_加患者标识。
4. 隐私
- 默认手机号 3-4-4 脱敏,身份证保留前 6 后 4。
- tcm.diagnosis/phonePlain 控制诊单编辑中的明文能力。
- 数据范围由后端按角色、部门、归属医助裁剪,前端只做 UI 能力门控。
5. 复用组件
- 患者摘要/病例:PatientInfoCard、PatientCaseCard。
- 日常记录:DailyMatrix。
- 医生备注:NoteTimeline。
- 诊单编辑和只读:tcm/diagnosis/edit.vue 的 open、openViewOnly。
- 处方开立/查看:TcmPrescription 的 open、openById。
- 视频通讯:ChatDialog 的 open。
- 业务订单详情:PrescriptionOrderDetailDrawer。
6. 状态文本
- 不建议在新客户端重复定义状态映射。优先抽取 D:\web\zyt\admin\src\views\consumer\prescription\components\prescription-order-utils.ts 中的审核、履约、支付、供货和物流格式化逻辑为共享领域模块。
## 8. 未知点、歧义与风险
1. 动态菜单缺口:admin 前端仓库没有生产环境 /auth.admin/mySelf 的 menu 数据,因此接诊台、处方库、患者、问诊列表的精确 URL、菜单标题和页面级 route.meta.perms 仍未知。
2. API 类型不足:tcm.ts、doctor.ts 多数参数和返回值是 any;本文列出的响应字段来自实际页面读取,不等于完整服务端 schema。后续实现应抓取真实响应或检查服务端 DTO。
3. patient_id 语义有重载:
- 挂号行中 patient_id 常被当作诊单/患者标识;
- 我的患者 openAppointment 又把 diagnosis_id 或 id 同时写进 id 和 patient_id
- ChatDialog 则把它转换为 patient_前缀的腾讯云用户。
新客户端必须先确认数据库实体关系,不能只按字段名推断。
4. 接诊台发起通话使用 diagnosis_id || row.idrow.id 本身是挂号 ID。若后端要求真正诊单 ID,这个回退可能只在特定历史数据下成立。
5. 视频二维码参数疑点:tcm/appointment/list.vue 的一个入口把 diagnosis_id 赋为 row.doctor_id,而其他入口使用真正诊单 ID;这很可能是历史兼容或缺陷,应向后端核实后再复用。
6. 权限命名不统一:
- 处方库用 wcf.prescription/*
- 已开处方用 cf.prescription/*
- 新增能力又混用 tcm.prescription/* 与 tcm.prescriptionOrder/*。
不能按字符串前缀自动推导资源。
7. 角色配置存在差异:
- 消费者处方审核页面写死 0、3
- 业务订单共享工具写死 0、3、6
- 多处注释都声称与服务端配置一致。
最终角色应由服务端下发 capability,避免继续硬编码。
8. 权限 UI 不是安全边界:部分接诊、药品和通话按钮没有 v-perms;所有写接口必须继续依赖服务端鉴权。
9. 双 SDK 并存:
- 实际 ChatDialog 使用 @tencentcloud/call-uikit-vue
- 未引用的 video-call/index.vue 使用 @trtc/calls-uikit-vue
- 医助旁观直接使用 trtc-sdk-v5。
新项目应明确只保留一套主叫/被叫 UI SDK,并将纯 TRTC 旁观作为独立只拉流能力。
10. src/components/video-call/index.vue 当前没有被任何 Vue/TS 源码引用,不应误认为生产主链。
11. PatientCaseCard 的 caseTypeLabel 无论 consultation_type 都返回“复诊”,属于明显展示逻辑疑点。
12. 录制启动/停止部分依赖 TUICallKit 内部 store、引擎属性和方法包装,升级腾讯云 SDK 时风险较高,必须用真实双端通话、拒接、对端挂断、网络中断和房间号延迟场景回归。
13. 处方业务订单大量前端角色规则与 server/config/project.php 注释耦合;当前审计范围只有 admin 前端,无法验证服务端配置是否已同步。
## 9. 面向新医生端的建议映射
若新项目要复刻医生工作流,建议按领域而不是按现有目录命名:
- /login:复用认证协议和企业微信登录。
- /reception:复用接诊台队列、详情、5 秒静默刷新与 ChatDialog。
- /prescription-library:复用 prescriptionLibrary 模板及所有权规则。
- /prescriptions:复用 tcm.prescription 列表、审核、作废、打印/下载。
- /patients:优先复用 firstvisit.myPatient,而不是 user.user/lists。
- /consultations:复用 doctor.appointment/lists 的今天待接诊视图。
- /diagnoses:复用 tcm.diagnosis/lists 的完整诊单工作台。
- /video-consultation:主叫链路复用 ChatDialog/TUICallKit;医助旁观保持独立 TRTC 只拉流组件。
这些建议 URL 是新端的信息架构建议,不是对 admin 当前动态 URL 的断言。

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