Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de990a921b | ||
|
|
cfe4c82c90 | ||
|
|
c3ceb0dd0f | ||
|
|
9add23e019 | ||
|
|
2199887c07 | ||
|
|
56dbd7f115 | ||
|
|
d10f213573 | ||
|
|
a968945057 | ||
|
|
971a627288 | ||
|
|
c37d5abac4 | ||
|
|
ebc463864b | ||
|
|
ab140ab05e | ||
|
|
894f52e875 | ||
|
|
a133d5d85d | ||
|
|
2c238d6599 | ||
|
|
6ce58bcd85 | ||
|
|
1ca87f8d72 | ||
|
|
5bf6ea01f6 | ||
|
|
58a7197d3e |
@@ -190,6 +190,10 @@ export function wecomPromotionSavePool(params: Record<string, unknown>) {
|
||||
return request.post({ url: '/firstvisit.wecomPromotion/savePool', params })
|
||||
}
|
||||
|
||||
export function wecomPromotionSaveWidget(params: Record<string, unknown>) {
|
||||
return request.post({ url: '/firstvisit.wecomPromotion/saveWidget', params })
|
||||
}
|
||||
|
||||
export function wecomPromotionDeletePool(params: { id: number }) {
|
||||
return request.post({ url: '/firstvisit.wecomPromotion/deletePool', params })
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
/** 角色数据驾驶舱:服务端统一按当前管理员的数据范围聚合。 */
|
||||
export function performanceDashboardOverview(params?: { ranking_dept_id?: number }) {
|
||||
export function performanceDashboardOverview(params?: { ranking_dept_id?: number; _t?: number }) {
|
||||
return request.get(
|
||||
{ url: '/stats.performanceDashboard/overview', params, timeout: 120000 },
|
||||
{ ignoreCancelToken: true }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -61,12 +61,23 @@
|
||||
class="channel-select"
|
||||
@change="loadDashboard"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in dashboard.filters.media_channels"
|
||||
:key="item.code"
|
||||
:label="item.name"
|
||||
:value="item.code"
|
||||
/>
|
||||
<el-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>
|
||||
@@ -122,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>
|
||||
@@ -133,16 +144,23 @@
|
||||
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">
|
||||
@@ -154,6 +172,9 @@
|
||||
<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>
|
||||
@@ -236,6 +257,13 @@ 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: {
|
||||
@@ -246,7 +274,7 @@ const emptyDashboard = () => ({
|
||||
filters: {
|
||||
departments: [] as any[],
|
||||
assistants: [] as Array<{ id: number; name: string }>,
|
||||
media_channels: [] as Array<{ code: string; name: string }>
|
||||
media_channels: [] as MediaChannelOption[]
|
||||
},
|
||||
summary: {} as Record<string, any>,
|
||||
rankings: { orders: [] as any[], amounts: [] as any[] },
|
||||
@@ -278,7 +306,7 @@ 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: '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: '诊单金额 / 接诊诊单' },
|
||||
@@ -293,6 +321,23 @@ const scopeDescription = computed(() => {
|
||||
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(() => ({
|
||||
@@ -308,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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -428,6 +480,18 @@ onMounted(loadDashboard)
|
||||
.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;
|
||||
@@ -475,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; }
|
||||
|
||||
@@ -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">
|
||||
@@ -109,9 +109,9 @@
|
||||
<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>
|
||||
@@ -212,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>
|
||||
@@ -248,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>
|
||||
@@ -279,14 +279,14 @@ 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
|
||||
},
|
||||
@@ -453,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,
|
||||
|
||||
@@ -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')">
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -323,39 +323,20 @@
|
||||
<div v-else class="tab-content install-tab">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<h2>安装 JS 到推广落地页</h2>
|
||||
<p>基础脚本只负责捕获点击并请求服务端分流,不携带授权凭证,也不在浏览器中计算随机规则。</p>
|
||||
<h2>安装 JS 与浮窗客服</h2>
|
||||
<p>为每个分流方案配置独立浮窗;基础脚本仍只负责展示入口、捕获点击并请求服务端分流。</p>
|
||||
</div>
|
||||
<el-select v-model="selectedInstallPoolId" placeholder="选择分流方案" class="install-pool-select">
|
||||
<el-option v-for="pool in overview.pools" :key="pool.id" :label="pool.name" :value="Number(pool.id)" />
|
||||
</el-select>
|
||||
</div>
|
||||
|
||||
<template v-if="selectedInstallPool">
|
||||
<div class="install-grid">
|
||||
<article class="code-card">
|
||||
<div class="code-heading"><span>1</span><div><strong>安装基础脚本</strong><p>放在页面 <code></head></code> 标签之前,只需安装一次。</p></div></div>
|
||||
<pre><code>{{ selectedInstallPool.install_code }}</code></pre>
|
||||
<el-button type="primary" plain :icon="DocumentCopy" @click="copyText(selectedInstallPool.install_code, '基础脚本')">复制代码</el-button>
|
||||
</article>
|
||||
<article class="code-card">
|
||||
<div class="code-heading"><span>2</span><div><strong>标记点击元素</strong><p>按钮、图片或文字链接都可以使用同一个数据属性。</p></div></div>
|
||||
<pre><code>{{ selectedInstallPool.trigger_code }}</code></pre>
|
||||
<el-button type="primary" plain :icon="DocumentCopy" @click="copyText(selectedInstallPool.trigger_code, '点击元素代码')">复制代码</el-button>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="rule-panel">
|
||||
<div><el-icon><Select /></el-icon><span><strong>可用性筛选</strong>自动排除停用、超出时间段和达到每日上限的链接。</span></div>
|
||||
<div><el-icon><Opportunity /></el-icon><span><strong>权重随机</strong>权重越高,被选中的概率越大;没有可用链接时才使用兜底链接。</span></div>
|
||||
<div><el-icon><View /></el-icon><span><strong>隐私与安全</strong>前端看不到完整链接池;访问 IP 只保存带服务端密钥的不可逆哈希。</span></div>
|
||||
</div>
|
||||
|
||||
<div class="test-row">
|
||||
<div><strong>分流测试</strong><p>每次打开都会执行与线上相同的筛选和随机规则,并计入点击数据。</p></div>
|
||||
<el-button type="primary" :icon="TopRight" @click="openTestLink">打开测试链接</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<WecomFloatingWidgetBuilder
|
||||
v-if="selectedInstallPool"
|
||||
:key="Number(selectedInstallPool.id)"
|
||||
:pool="selectedInstallPool"
|
||||
@saved="handleWidgetSaved"
|
||||
/>
|
||||
<el-empty v-else description="请先创建分流方案,系统会自动生成 JS 安装代码" />
|
||||
</div>
|
||||
</section>
|
||||
@@ -412,8 +393,8 @@ import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
ArrowDown, ArrowRight, ChatDotRound, CircleCheck, Connection, DataAnalysis, Delete,
|
||||
DocumentCopy, Edit, Key, Link, Lock, Mouse, OfficeBuilding, Opportunity, Plus,
|
||||
Promotion, Refresh, Search, Select, SetUp, TopRight, User, View, Warning
|
||||
DocumentCopy, Edit, Key, Link, Lock, Mouse, OfficeBuilding, Plus,
|
||||
Promotion, Refresh, Search, SetUp, User, Warning
|
||||
} from '@element-plus/icons-vue'
|
||||
import {
|
||||
wecomPromotionCheckApiPermission,
|
||||
@@ -431,10 +412,12 @@ import {
|
||||
} from '@/api/first_visit'
|
||||
import type { WecomPromotionCustomerChatStatus } from '@/api/first_visit'
|
||||
|
||||
import WecomFloatingWidgetBuilder from './components/WecomFloatingWidgetBuilder.vue'
|
||||
|
||||
type TabName = 'links' | 'customer-stats' | 'configuration' | 'install'
|
||||
const emptyOverview = () => ({
|
||||
meta: { scope_label: '', generated_at: '' },
|
||||
config: { mode: 'internal', configured: false, ready: false, missing: [] as string[], corp_id_masked: '', agent_id: '', secret_configured: false, official_doc: '' },
|
||||
config: { mode: 'internal', configured: false, ready: false, missing: [] as string[], corp_id_masked: '', agent_id: '', secret_configured: false, callback_ready: false, callback_url: '', official_doc: '' },
|
||||
summary: { configured_apps: 0, pool_count: 0, online_links: 0, today_clicks: 0 },
|
||||
pools: [] as any[], links: [] as any[], member_options: [] as any[], customer_acquisition_link_example: 'https://work.weixin.qq.com/ca/xxxxxxxx'
|
||||
})
|
||||
@@ -930,8 +913,8 @@ async function copyText(value: string, label: string) {
|
||||
ElMessage.success(`${label}已复制`)
|
||||
}
|
||||
|
||||
function openTestLink() {
|
||||
if (selectedInstallPool.value?.go_url) window.open(selectedInstallPool.value.go_url, '_blank', 'noopener,noreferrer')
|
||||
async function handleWidgetSaved() {
|
||||
await loadOverview()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -998,10 +981,8 @@ h1, h2, h3, p { margin: 0; }
|
||||
.missing-fields { margin-top: 12px; color: #9c651f; font-size: 11px; }.missing-fields code { margin-left: 6px; padding: 3px 6px; border-radius: 4px; background: rgba(224,153,63,.11); }
|
||||
.callback-list { margin: 14px 0 0; border-top: 1px solid rgba(124,150,157,.16); }.callback-list > div { display: grid; grid-template-columns: 120px 1fr 50px; align-items: center; min-height: 40px; border-bottom: 1px solid rgba(124,150,157,.12); font-size: 11px; }.callback-list dt { color: #657488; }.callback-list dd { overflow: hidden; margin: 0; color: #27374c; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; text-overflow: ellipsis; white-space: nowrap; }.callback-list button { border: 0; color: #148f83; background: transparent; cursor: pointer; }
|
||||
.configuration-actions { display: flex; align-items: center; gap: 14px; margin-top: 16px; }
|
||||
.install-pool-select { width: 220px; }.install-grid { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 14px; }.code-card { min-width: 0; padding: 16px; border: 1px solid var(--line); border-radius: 10px; background: #fbfcfd; }.code-heading { display: flex; align-items: center; gap: 10px; }.code-heading > span { display: grid; width: 30px; height: 30px; flex: 0 0 30px; place-items: center; border-radius: 8px; color: #fff; background: var(--teal); font-weight: 700; }.code-heading strong { font-size: 13px; }.code-heading p { margin-top: 3px; color: #7a889a; font-size: 10px; }.code-card pre { min-height: 92px; margin: 14px 0; padding: 13px; overflow: auto; border-radius: 7px; color: #cbe9e6; background: #172a36; white-space: pre-wrap; word-break: break-all; }.code-card pre code { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 11px; line-height: 1.7; }
|
||||
.rule-panel { display: grid; grid-template-columns: repeat(3, minmax(0,1fr)); gap: 10px; margin-top: 14px; }.rule-panel > div { display: flex; align-items: flex-start; gap: 9px; min-height: 68px; padding: 12px; border-radius: 9px; color: #69788b; background: #f3f7f8; font-size: 11px; line-height: 1.65; }.rule-panel .el-icon { margin-top: 2px; color: var(--teal); font-size: 17px; }.rule-panel strong { display: block; color: #26364a; }
|
||||
.test-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-top: 14px; padding: 14px 16px; border: 1px dashed #b8d9d4; border-radius: 9px; background: #f7fcfb; }.test-row strong { font-size: 13px; }.test-row p { margin-top: 4px; color: #7c8999; font-size: 10px; }
|
||||
.install-pool-select { width: 220px; }
|
||||
.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 14px; }.form-tip { display: block; margin-top: 5px; color: #8b97a6; font-size: 10px; }.link-form .el-select, .link-form .el-input-number { width: 100%; }
|
||||
@media (max-width: 1100px) { .heading-actions { flex-wrap: wrap; justify-content: flex-end; }.metric-grid, .customer-metric-grid { grid-template-columns: repeat(2, minmax(0,1fr)); }.pool-layout { grid-template-columns: 210px minmax(0,1fr); }.pool-toolbar { align-items: flex-start; flex-direction: column; }.rule-panel { grid-template-columns: 1fr; } }
|
||||
@media (max-width: 760px) { .promotion-page { padding: 10px; }.page-header, .section-heading { align-items: flex-start; flex-direction: column; }.update-time { display: none; }.metric-grid, .customer-metric-grid, .install-grid, .form-grid { grid-template-columns: 1fr; }.tab-nav { overflow-x: auto; }.tab-nav button { min-width: 112px; }.tab-content { padding: 14px; }.pool-layout { grid-template-columns: 1fr; }.pool-sidebar { display: flex; overflow-x: auto; border-right: 0; border-bottom: 1px solid var(--line); }.pool-item { min-width: 190px; }.callback-list > div { grid-template-columns: 1fr 48px; padding: 8px 0; }.callback-list dt { grid-column: 1 / -1; }.install-pool-select { width: 100%; }.customer-heading-actions { width: 100%; justify-content: flex-end; }.customer-filter-bar :deep(.el-form-item) { width: 100%; margin-right: 0; }.customer-filter-bar :deep(.el-form-item__content), .customer-filter-bar .el-select { width: 100%; }.customer-filter-bar .filter-actions :deep(.el-form-item__content) { justify-content: flex-end; }.customer-pagination { align-items: flex-start; flex-direction: column; }.customer-pagination :deep(.el-pagination) { max-width: 100%; flex-wrap: wrap; justify-content: flex-start; } }
|
||||
@media (max-width: 1100px) { .heading-actions { flex-wrap: wrap; justify-content: flex-end; }.metric-grid, .customer-metric-grid { grid-template-columns: repeat(2, minmax(0,1fr)); }.pool-layout { grid-template-columns: 210px minmax(0,1fr); }.pool-toolbar { align-items: flex-start; flex-direction: column; } }
|
||||
@media (max-width: 760px) { .promotion-page { padding: 10px; }.page-header, .section-heading { align-items: flex-start; flex-direction: column; }.update-time { display: none; }.metric-grid, .customer-metric-grid, .form-grid { grid-template-columns: 1fr; }.tab-nav { overflow-x: auto; }.tab-nav button { min-width: 112px; }.tab-content { padding: 14px; }.pool-layout { grid-template-columns: 1fr; }.pool-sidebar { display: flex; overflow-x: auto; border-right: 0; border-bottom: 1px solid var(--line); }.pool-item { min-width: 190px; }.callback-list > div { grid-template-columns: 1fr 48px; padding: 8px 0; }.callback-list dt { grid-column: 1 / -1; }.install-pool-select { width: 100%; }.customer-heading-actions { width: 100%; justify-content: flex-end; }.customer-filter-bar :deep(.el-form-item) { width: 100%; margin-right: 0; }.customer-filter-bar :deep(.el-form-item__content), .customer-filter-bar .el-select { width: 100%; }.customer-filter-bar .filter-actions :deep(.el-form-item__content) { justify-content: flex-end; }.customer-pagination { align-items: flex-start; flex-direction: column; }.customer-pagination :deep(.el-pagination) { max-width: 100%; flex-wrap: wrap; justify-content: flex-start; } }
|
||||
</style>
|
||||
|
||||
@@ -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 || '数据范围' }}
|
||||
@@ -181,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) }" />
|
||||
@@ -314,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,
|
||||
@@ -328,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
|
||||
@@ -417,6 +417,7 @@ const createInitialDashboard = () => ({
|
||||
trend: {
|
||||
date_range: [] as string[],
|
||||
dates: [] as string[],
|
||||
registrations: [] as number[],
|
||||
appointments: [] as number[],
|
||||
leads: [] as number[],
|
||||
orders: [] as number[],
|
||||
@@ -442,14 +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' },
|
||||
]
|
||||
@@ -472,16 +474,16 @@ 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: '今日 10 元及以下收款',
|
||||
value: `${formatNumber(dashboard.today.low_amount_payment_count)} 笔`,
|
||||
hint: '实收金额大于 0 且不超过 10 元的已支付订单',
|
||||
label: '今日挂号',
|
||||
value: `${formatNumber(dashboard.today.low_amount_payment_count)} `,
|
||||
hint: '已支付且实收金额大于 0、低于 10 元的订单',
|
||||
comparisons: [dashboard.today.comparisons.low_amount_payment_count],
|
||||
},
|
||||
{
|
||||
@@ -496,9 +498,9 @@ const todayMetrics = computed(() => [
|
||||
},
|
||||
{
|
||||
key: 'appointmentRate',
|
||||
label: '付费挂号率',
|
||||
label: '挂号率',
|
||||
value: formatPercent(dashboard.today.paid_appointment_rate),
|
||||
hint: `付费挂号 ${formatNumber(dashboard.today.paid_appointment_count)} / 加粉数`,
|
||||
hint: `挂号 ${formatNumber(dashboard.today.paid_appointment_count)} / 加粉数`,
|
||||
comparisons: [dashboard.today.comparisons.paid_appointment_rate],
|
||||
},
|
||||
{
|
||||
@@ -512,7 +514,7 @@ const todayMetrics = computed(() => [
|
||||
key: 'interviews',
|
||||
label: '今日面诊',
|
||||
value: formatNumber(dashboard.today.interview_count),
|
||||
hint: '状态为已完成的挂号',
|
||||
hint: '状态为已完成的预约',
|
||||
comparisons: [dashboard.today.comparisons.interview_count],
|
||||
},
|
||||
])
|
||||
@@ -532,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(
|
||||
@@ -548,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 },
|
||||
}
|
||||
@@ -652,7 +655,11 @@ const loadDashboard = async () => {
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
try {
|
||||
const res: any = await performanceDashboardOverview({ ranking_dept_id: rankingDeptId.value })
|
||||
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
|
||||
@@ -674,6 +681,11 @@ onMounted(() => {
|
||||
}, 1000)
|
||||
})
|
||||
|
||||
// 后台标签页使用 KeepAlive;从其它菜单返回驾驶舱时必须重新取实时数据。
|
||||
onActivated(() => {
|
||||
if (loaded.value) loadDashboard()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (clockTimer) clearInterval(clockTimer)
|
||||
})
|
||||
|
||||
@@ -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=
|
||||
@@ -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
|
||||
@@ -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%
|
||||
@@ -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)。
|
||||
@@ -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%
|
||||
@@ -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 建议 Authenticode;macOS 需要 Developer ID、Hardened Runtime、摄像头/麦克风用途说明与公证。
|
||||
|
||||
发现凭据泄露、越权、患者数据写入日志或视频房间被未授权加入时,应立即停用相关凭据、保留审计证据并按组织的安全响应流程处置。
|
||||
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 192 KiB |
|
After Width: | Height: | Size: 138 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 146 KiB |
|
After Width: | Height: | Size: 152 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 146 KiB |
|
After Width: | Height: | Size: 133 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 130 KiB |
|
After Width: | Height: | Size: 149 KiB |
|
After Width: | Height: | Size: 130 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 83 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 81 KiB |
@@ -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())
|
||||
@@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
set -u
|
||||
project_root="$(cd "$(dirname "$0")" && pwd -P)"
|
||||
exec /bin/bash "$project_root/scripts/package_macos.sh"
|
||||
|
||||
@@ -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.
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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])])
|
||||
]
|
||||
)
|
||||
@@ -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"]
|
||||
|
||||
@@ -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:路由路径;
|
||||
- component:src/views 下的组件键;
|
||||
- name:菜单标题;
|
||||
- perms:写入 route.meta.perms;
|
||||
- is_show:控制 meta.hidden;
|
||||
- is_cache:控制 keepAlive;
|
||||
- params:默认 query;
|
||||
- selected:activeMenu;
|
||||
- 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。
|
||||
- 企业微信内置浏览器走 OAuth,scope=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_filter:all、pending、passed、not_passed、rejected;
|
||||
- source_filter:all、manual、system;
|
||||
- start_time、end_time(按创建时间)。
|
||||
|
||||
列表核心字段:
|
||||
|
||||
- id、sn、prescription_type;
|
||||
- is_system_auto:0 手工、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_usage:dosage_amount、dosage_bag_count、need_decoction、bags_per_dose、times_per_day、usage_days、prescription_name(部分页面保留模板名)。
|
||||
- 医师:doctor_name、doctor_signature(PNG data URL,保存前必填)。
|
||||
- 可见性/审核:is_shared、visible_role_ids、audit_status、audit_time、audit_by_name、audit_remark。
|
||||
|
||||
状态规则:
|
||||
|
||||
- audit_status:0 待审核、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_filter:unbooked 未预约、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/assign:id、assistant_id、is_inherit=0|1
|
||||
- POST /firstvisit.myPatient/fillIdCard:id、id_card
|
||||
- POST /firstvisit.myPatient/createAppointment:预约完整参数
|
||||
- POST /firstvisit.myPatient/cancelAppointment:id=挂号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:
|
||||
|
||||
- state:none、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.calls,userIDList=[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_type(1 语音、其他视为视频)
|
||||
- room_id
|
||||
- status:1 进行中、2 已结束、3 未接听、4 已取消
|
||||
- recording_status_text
|
||||
- recording_urls_list
|
||||
- start_time_text、end_time_text、duration_text
|
||||
|
||||
接口:
|
||||
|
||||
- GET /tcm.diagnosis/getCallRecords,diagnosis_id
|
||||
- POST /tcm.diagnosis/attachLocalCallRecording,diagnosis_id、file_url、可选 call_record_id
|
||||
- POST /tcm.diagnosis/createManualCallRecord,diagnosis_id
|
||||
- POST /tcm.diagnosis/startCloudRecording,diagnosis_id(API 有封装,主组件当前通过 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 转为 body;GET 使用 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.id;row.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 的断言。
|
||||
@@ -0,0 +1,461 @@
|
||||
# 医生桌面端工程架构与打包方案(Windows / macOS)
|
||||
|
||||
> 结论先行:采用 **Python 3.12 + PySide6 Qt Widgets** 构建原生业务界面,以分层的 `httpx` API client 连接现有后端;会话、权限、离线队列和本地安全存储统一放在 core 层。视频不是整套应用的实现基础,而是独立的可选集成:只有当现有腾讯 TRTC Web 方案无法由原生 SDK 替代时,才在受限的 `QWebEngineView` 中承载单一视频页面。发布使用 **PyInstaller onedir**,Windows 与 macOS 必须在各自原生 CI runner 上分别构建、签名和验收,不能交叉编译。
|
||||
|
||||
## 1. 已知上下文、边界与待确认项
|
||||
|
||||
本结论只对 `admin/package.json` 和环境配置做了最小只读核对,没有审计管理端实现。
|
||||
|
||||
- 管理端以 `VITE_APP_BASE_URL` 注入后端根地址;示例文件故意留空,开发示例注释仅以 `http://127.0.0.1:8080` 举例。现有环境文件还出现了 `https://css.zhenyangtang.com.cn/`、`https://admin.zhenyangtang.com.cn/` 和 60 秒请求超时,但这些地址可能是网关或前端站点,**不能据此认定为稳定的桌面 API 地址**。
|
||||
- 管理端依赖包含 Axios、腾讯 TRTC/Call/Chat UI、`hls.js` 和 COS JS SDK。可以据此判断视频、聊天、流媒体和对象存储是潜在集成面,但不能推断接口路径、认证协议、权限码或 RTC 凭证格式。
|
||||
- 桌面端不得读取或复用 Vite 环境变量,不得硬编码管理端 URL,也不得在客户端持有 COS Secret、TRTC SecretKey 或任何服务端签名密钥。
|
||||
|
||||
编码前必须由后端确认以下契约,并固化为 OpenAPI 或最小接口文档:
|
||||
|
||||
1. API 的正式 base URL、版本前缀、响应 envelope、错误码、分页和时间格式。
|
||||
2. 登录协议(账号密码、短信、SSO/OIDC 或 Cookie)、access/refresh 生命周期、登出和吊销语义。
|
||||
3. `/me` 等当前用户接口返回的医生身份、机构/租户、角色与细粒度权限码。
|
||||
4. 预约、患者、病历、处方等写操作的幂等键、乐观锁版本号及审计要求。
|
||||
5. 聊天的拉取/推送协议、断线续传游标;COS 上传应由后端提供短期预签名 URL 或临时凭证。
|
||||
6. TRTC 房间、`userSig` 等凭证必须由后端短时签发;确认现有 Web 页面能否作为受支持的嵌入入口。
|
||||
7. 桌面端的 CORS、代理、私有 CA、设备绑定、强制升级和最低版本策略。
|
||||
|
||||
在这些问题确认前可以完成壳层、接口抽象和模拟服务器,但不应猜测生产 endpoint。
|
||||
|
||||
## 2. 目标平台与技术选择
|
||||
|
||||
### 2.1 建议支持矩阵
|
||||
|
||||
| 项目 | 首发建议 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| Python | CPython 3.12,固定 patch 版本 | 生命周期长,第三方包成熟;每个平台使用相同 minor |
|
||||
| Windows | Windows 10 22H2 / Windows 11,x86-64 | ARM64 可作为后续独立制品,不与 x64 混装 |
|
||||
| macOS | macOS 13+,先 arm64,再按客户量增加 x86-64 | 当前 PySide6 wheel 的最低系统版本必须在锁版本时再次核对 |
|
||||
| UI | PySide6 Qt Widgets | 医疗表单、表格、快捷键、打印和可访问性更稳定 |
|
||||
| 视频 | 可选 PySide6 Addons / QtWebEngineWidgets | 只隔离承载视频页,不用 WebEngine 包住整个应用 |
|
||||
| 打包 | PyInstaller onedir | 对 QtWebEngine helper、资源、签名和启动性能最稳妥 |
|
||||
|
||||
macOS 推荐分别产出 `arm64` 与 `x86_64` 制品。`universal2` 只有在 Python、PySide6 和所有二进制依赖均提供 universal2 slice,且真实验证签名/视频后再启用;两个单架构制品更易排障且体积更小。
|
||||
|
||||
### 2.2 依赖分档
|
||||
|
||||
建立一份代码、两种构建 profile:
|
||||
|
||||
- `core`:`PySide6-Essentials`、`httpx`、`pydantic`、`pydantic-settings`、`platformdirs`、`keyring`、`cryptography`。包含 QtCore/Gui/Widgets/Network/Sql/Svg/PrintSupport,不包含 WebEngine。
|
||||
- `video`:在 core 上增加与 Essentials **完全相同版本**的 `PySide6-Addons`,从而获得 QtWebEngineWidgets、WebChannel、Multimedia 等模块。
|
||||
- 开发/测试:`pytest`、`pytest-qt`、`respx`、`coverage`、`ruff`、`mypy`、`pip-audit`。
|
||||
- 构建:锁定 `PyInstaller` 及其 hooks 版本。以 2026-08-10 可验证组合为基线,可先验证 Python 3.12 + PySide6 Essentials/Addons 6.11.1 + PyInstaller 6.21.0;只有在两端打包 smoke test 通过后才更新锁。
|
||||
|
||||
不要同时安装 PyQt、PySide2 或系统级 PySide6;必须从干净虚拟环境构建。使用平台专属、带 hash 的锁文件(例如 `requirements-win-x64.lock`、`requirements-macos-arm64.lock`),而不是在发布任务中直接安装“最新版”。
|
||||
|
||||
如果所有医生都需要视频,可只发布 `video` 制品;仍保留 profile 边界,以便定位 WebEngine 问题。若视频是少数场景,可以发布 core 制品并在系统浏览器打开受支持的视频页,避免让每个安装包承担 Chromium 的体积和攻击面。
|
||||
|
||||
## 3. 分层架构
|
||||
|
||||
依赖方向固定为:`ui -> application -> domain`,`infrastructure` 在 composition root 中实现 domain/application 定义的 port。View 不允许直接调用 `httpx`、SQLite 或 keyring。
|
||||
|
||||
```text
|
||||
app/
|
||||
├─ pyproject.toml # 项目元数据、依赖分组、工具配置
|
||||
├─ requirements/ # 各 OS/架构的发布锁及 hash
|
||||
├─ src/
|
||||
│ └─ zyt_doctor/
|
||||
│ ├─ __main__.py # 极薄入口,只调用 bootstrap.main()
|
||||
│ ├─ bootstrap.py # QApplication、配置、DI、异常钩子、主窗口
|
||||
│ ├─ build_info.py # 版本、commit、channel;构建时生成
|
||||
│ ├─ config/
|
||||
│ │ ├─ models.py # 强类型配置及校验
|
||||
│ │ └─ loader.py # defaults -> 受管配置 -> 开发环境变量
|
||||
│ ├─ domain/
|
||||
│ │ ├─ identity.py # Principal、Tenant、Permission
|
||||
│ │ ├─ errors.py # 与 UI/HTTP 无关的错误类型
|
||||
│ │ └─ ports.py # Repository、Clock、SecretStore 等协议
|
||||
│ ├─ application/
|
||||
│ │ ├─ commands.py # 写用例、幂等键和确认规则
|
||||
│ │ ├─ queries.py # 读用例和缓存策略
|
||||
│ │ └─ result.py # Result / Page / OperationState
|
||||
│ ├─ core/
|
||||
│ │ ├─ api/
|
||||
│ │ │ ├─ client.py # httpx client、header、超时、重试
|
||||
│ │ │ ├─ auth.py # token/cookie adapter 与 refresh single-flight
|
||||
│ │ │ ├─ errors.py # HTTP/业务错误归一化
|
||||
│ │ │ ├─ models.py # 公共 DTO
|
||||
│ │ │ └─ generated/ # 若有 OpenAPI,生成代码仅放此处
|
||||
│ │ ├─ session/
|
||||
│ │ │ ├─ manager.py # 会话状态机、锁屏、租户切换、登出清理
|
||||
│ │ │ └─ permissions.py # 权限快照与 guard
|
||||
│ │ ├─ storage/
|
||||
│ │ │ ├─ database.py # SQLite、migration、单写线程
|
||||
│ │ │ ├─ secure_store.py # Windows Credential Manager / macOS Keychain
|
||||
│ │ │ └─ cache.py # 加密缓存、TTL、容量控制
|
||||
│ │ ├─ offline/
|
||||
│ │ │ ├─ connectivity.py # 网络状态提示,不作为唯一真相
|
||||
│ │ │ ├─ outbox.py # 离线写队列状态机
|
||||
│ │ │ └─ sync.py # 重放、冲突和人工处理
|
||||
│ │ ├─ jobs.py # QThreadPool 任务、取消、signal 适配
|
||||
│ │ ├─ events.py # 进程内 typed event bus
|
||||
│ │ ├─ logging.py # 脱敏日志和诊断包
|
||||
│ │ └─ paths.py # QStandardPaths/platformdirs;绝不写 bundle
|
||||
│ ├─ modules/
|
||||
│ │ ├─ auth/
|
||||
│ │ ├─ dashboard/
|
||||
│ │ ├─ patients/
|
||||
│ │ ├─ appointments/
|
||||
│ │ ├─ consultations/
|
||||
│ │ ├─ medical_records/
|
||||
│ │ ├─ prescriptions/
|
||||
│ │ ├─ chat/
|
||||
│ │ ├─ followups/
|
||||
│ │ └─ settings/
|
||||
│ │ # 每个模块内含 domain.py、service.py、viewmodel.py、views.py、permissions.py
|
||||
│ ├─ integrations/
|
||||
│ │ ├─ realtime/ # WebSocket/轮询 adapter,不侵入模块
|
||||
│ │ ├─ object_storage/ # 仅消费后端预签名 URL/临时凭证
|
||||
│ │ └─ video/
|
||||
│ │ ├─ port.py # join/leave/mute 等抽象
|
||||
│ │ ├─ external_browser.py
|
||||
│ │ └─ webengine.py # 唯一允许 import QtWebEngine 的文件
|
||||
│ ├─ ui/
|
||||
│ │ ├─ shell/ # 导航、标题栏、全局离线/会话提示
|
||||
│ │ ├─ widgets/ # Loading、Empty、Error、PermissionDenied
|
||||
│ │ ├─ dialogs/
|
||||
│ │ └─ theme/
|
||||
│ └─ resources/ # qrc、图标、字体许可、翻译、默认配置
|
||||
├─ tests/
|
||||
│ ├─ unit/
|
||||
│ ├─ contract/
|
||||
│ ├─ integration/
|
||||
│ ├─ ui/
|
||||
│ ├─ packaging/
|
||||
│ └─ fixtures/ # 全部为合成数据,禁止生产病患数据
|
||||
├─ packaging/
|
||||
│ ├─ windows/doctor-core.spec
|
||||
│ ├─ windows/doctor-video.spec
|
||||
│ ├─ macos/doctor-core.spec
|
||||
│ ├─ macos/doctor-video.spec
|
||||
│ ├─ macos/entitlements.plist
|
||||
│ └─ hooks/
|
||||
├─ scripts/ # build、self-check、sign、notarize
|
||||
└─ docs/ # API 映射、权限矩阵、发布 runbook
|
||||
```
|
||||
|
||||
每个业务模块只公开一个 facade 和路由描述,例如 `ModuleDescriptor(id, title, permissions, view_factory)`。主壳根据权限注册菜单,模块内再对按钮和 command 做 guard;这样既不会形成一个巨型主窗口,也不会把权限判断散落在控件代码中。
|
||||
|
||||
## 4. API client、并发和会话
|
||||
|
||||
### 4.1 API client
|
||||
|
||||
建议使用一个长生命周期 `httpx.Client`,由 composition root 创建并注入 service。所有同步请求放入 `QThreadPool/QRunnable`,结果通过 Qt signal 回到主线程;严禁 UI 线程阻塞网络。窗口关闭或查询条件变化时取消尚未开始的任务,并忽略带旧 generation id 的晚到响应。
|
||||
|
||||
Client 的固定行为:
|
||||
|
||||
- production base URL 只允许 HTTPS;HTTP 仅在 debug profile 且 host 为 localhost 时允许。
|
||||
- production 允许的 API、视频和上传 host 必须来自签名/受管配置或内置 allowlist,避免篡改本地配置后窃取 token。
|
||||
- 超时拆分为 connect/read/write/pool,不只设一个总数。可从 connect 10 秒、read 60 秒起步,上传/导出另设长超时。
|
||||
- 每次请求加入 `Authorization`(若契约采用 bearer)、`X-Request-ID`、客户端版本、平台、时区和租户信息;不得写入日志的 header 列表默认包含 Authorization、Cookie 和所有临时凭证。
|
||||
- GET/HEAD 和带服务端认可幂等键的写操作,才可对连接错误、超时、429、502、503、504 做指数退避 + jitter;遵守 `Retry-After`。验证错误、普通 4xx 和未知写请求不自动重试。
|
||||
- 所有关键 mutation 生成并持久化 `Idempotency-Key`,直到收到确定结果;请求超时后的状态为“结果未知”,先按键查询/重放,不能直接再创建一条。
|
||||
- 业务错误映射为稳定类型:`ValidationError`、`Unauthenticated`、`Forbidden`、`Conflict`、`RateLimited`、`Maintenance`、`TransportError`、`UnknownServerError`。UI 不解析后端文案。
|
||||
- 支持 ETag/版本字段做乐观锁。收到 409/412 时进入冲突页,展示服务器版本与本地草稿,不做静默覆盖。
|
||||
- 下载/上传流式处理并限制文件大小、MIME 和保存目录。COS 只使用后端签发的预签名 URL或短期临时凭证,绝不打包永久密钥。
|
||||
|
||||
若后端有 OpenAPI,生成的 models/client 放入 `core/api/generated`,外面再包一层业务 adapter;模块不得直接依赖生成器的数据结构。若无 OpenAPI,先写小而明确的 typed endpoint,不做一个接受任意 path/dict 的“万能客户端”。
|
||||
|
||||
实时聊天使用独立 adapter:WebSocket 可运行在一个后台 asyncio loop/thread 中,通过 signal 投递事件;实现心跳、指数重连、服务器 sequence/cursor 补拉、重复消息去重和应用休眠恢复。首版若后端没有可靠续传契约,应采用短轮询而不是假装 WebSocket 永不丢消息。
|
||||
|
||||
### 4.2 Session 状态机
|
||||
|
||||
`SessionManager` 是唯一会话真相,显式状态为:
|
||||
|
||||
```text
|
||||
SIGNED_OUT -> AUTHENTICATING -> AUTHENTICATED
|
||||
AUTHENTICATED -> REFRESHING -> AUTHENTICATED
|
||||
AUTHENTICATED/REFRESHING -> LOCKED | EXPIRED | SIGNED_OUT
|
||||
```
|
||||
|
||||
- access token 只保存在内存;需要“保持登录”时,refresh token 或可续期凭证保存在 OS keychain,不能放在 QSettings、SQLite 明文或日志。
|
||||
- `keyring` 启动时必须检查实际 backend。没有 Windows Credential Manager/macOS Keychain 等安全 backend 时禁用持久登录,而不是退化到明文文件。
|
||||
- 多请求同时遇到 401 时只能有一个 refresh 在飞行,其他请求等待同一个 future;refresh 失败统一切到 `EXPIRED`,避免 401 风暴。
|
||||
- 登出、切换医生或切换租户时:取消网络任务、停止实时连接、退出视频、清内存 token、清空 WebEngine profile、关闭并按用户/租户清理本地缓存密钥。
|
||||
- 支持工作站空闲自动锁定。解锁方式由后端安全策略决定;锁定界面不得继续显示患者姓名、通知正文或缩略图。
|
||||
- 用 `QLocalServer/QLocalSocket` 实现单实例,第二次启动只唤醒现有窗口,避免同一用户同时运行两个 outbox。
|
||||
|
||||
### 4.3 权限模型
|
||||
|
||||
权限码由后端返回并作为服务端授权的镜像,例如 `patient.read`、`record.write`、`prescription.sign`;具体字符串必须以真实契约为准。
|
||||
|
||||
客户端执行三层防误操作:
|
||||
|
||||
1. 路由层:无模块权限时不注册菜单/路由。
|
||||
2. ViewModel/command 层:按钮显示与执行前都检查 `PermissionGuard.require(...)`。
|
||||
3. API 层:403 统一转为 `Forbidden`,刷新权限快照并提示“权限已变更”。
|
||||
|
||||
这些仅改善体验,真正的 RBAC/ABAC、租户隔离和审计必须由后端再次校验。不能因为客户端隐藏了按钮就省略服务端授权。对开方、签名、删除等高风险操作增加 step-up authentication 或明确二次确认,并把 request id/idempotency key 传给服务端审计。
|
||||
|
||||
## 5. 本地数据、离线与错误态
|
||||
|
||||
### 5.1 数据目录与加密
|
||||
|
||||
使用 `QStandardPaths` 或 `platformdirs` 获取每用户目录:Windows 通常位于 `%LOCALAPPDATA%`,macOS 位于 `~/Library/Application Support`。安装目录和 `.app` bundle 始终只读;业务代码不要访问 `sys._MEIPASS`,资源通过 `importlib.resources`/Qt resource system 读取。
|
||||
|
||||
SQLite 使用 WAL、schema migration 和单写入 worker(或每线程独立 connection),不在线程间共享 `sqlite3.Connection`。默认只缓存必要元数据;如果确需缓存患者/病历或离线草稿:
|
||||
|
||||
- 使用 `cryptography` 的 AES-GCM 做版本化记录加密,随机 nonce,密钥由 OS keychain 保存;AAD 包含 tenant/user/table/record id,防止记录调包。
|
||||
- outbox、cache 和密钥按机构 + 用户分区;退出账号做 crypto-erasure(删除密钥)并清理索引。SSD 上不能承诺可靠覆盖删除,因此不能用“反复覆盖文件”作为安全保证。
|
||||
- 设置缓存 TTL、容量上限和最少字段;搜索索引不放诊断正文等敏感内容。
|
||||
- QSettings 只保存主题、窗口大小等无敏感偏好。
|
||||
- 日志不记录患者姓名、手机号、证件号、病历正文、处方内容、token、Cookie 或 URL query;提供用户确认后的脱敏诊断包。
|
||||
|
||||
### 5.2 离线策略
|
||||
|
||||
不要只依赖系统“在线/离线”事件;真正状态以最近请求结果和轻量 health check 综合判定。主壳常驻显示 `在线 / 网络不稳定 / 离线 / 服务维护`,且标明数据最后更新时间。
|
||||
|
||||
写操作按风险分类:
|
||||
|
||||
| 类别 | 离线行为 | 恢复后 |
|
||||
| --- | --- | --- |
|
||||
| 只读列表/详情 | 展示有时间戳的加密缓存,明显标记“可能已过期” | 后台重新验证并原子替换 |
|
||||
| 普通草稿、低风险备注 | 可进入 outbox,保存幂等键、base version 和依赖 | 自动重放;冲突转人工处理 |
|
||||
| 病历最终提交、开方/签方、医嘱、删除等高风险操作 | 只允许保存为本地草稿,禁止假显示“已提交” | 恢复网络后重新拉取服务端版本,由医生确认再提交 |
|
||||
| 视频/实时聊天 | 显示不可用或重连,不能伪造发送成功 | 按 cursor 补拉并去重 |
|
||||
|
||||
Outbox 状态至少包含 `QUEUED -> SENDING -> SUCCEEDED`,以及 `NEEDS_ATTENTION`、`DEAD_LETTER`。保存 payload schema version、创建人/租户、幂等键、重试次数、next attempt、最后错误和服务端 base version。只自动重放白名单 action;切换用户时绝不重放前一用户队列。
|
||||
|
||||
### 5.3 统一错误体验
|
||||
|
||||
所有页面复用下列状态组件,而不是把异常 traceback 或后端原文弹给用户:
|
||||
|
||||
- 首次加载 skeleton;刷新时保留旧数据并显示非阻塞进度。
|
||||
- 真空数据(业务上没有记录)与加载失败严格区分。
|
||||
- 离线且有缓存、离线且无缓存、权限不足、登录过期、字段校验、版本冲突、维护中、未知错误各有独立文案和可行动按钮。
|
||||
- 未知错误显示 request id、发生时间、“重试/复制诊断编号”,详细堆栈只进入脱敏日志。
|
||||
- 全局未捕获异常写入 rotating log 并打开安全错误页;不要自动上传包含医疗数据的 crash dump。
|
||||
|
||||
## 6. 视频与 QtWebEngine 的可执行边界
|
||||
|
||||
### 6.1 首选集成顺序
|
||||
|
||||
1. 先确认腾讯或现有供应商是否提供受支持的 Windows/macOS 原生桌面 SDK 以及 Python 可调用层。如果维护成本可接受,原生 adapter 最可控。
|
||||
2. 若现有成熟能力是 TRTC Web 页面,后端提供一个专用、窄功能、HTTPS 的 `/desktop-call` 类入口(实际路径待定),由 `QWebEngineView` 嵌入。
|
||||
3. 若 SDK/UA/DRM/屏幕共享在 Qt Chromium 中不受支持,可靠回退是系统默认浏览器,不通过修改 UA 或关闭浏览器安全策略强行兼容。
|
||||
|
||||
不要把整套 admin 嵌入桌面壳。JS SDK 的版本和构建产物留在专用 Web 页面侧,Python 只持有 `VideoPort(join, leave, mute, device_changed)` 抽象,这样管理端升级 TRTC SDK 不要求桌面二进制同步发版。
|
||||
|
||||
### 6.2 安全桥接
|
||||
|
||||
- 桌面从后端申请一次性、短有效期的 call ticket;Web 页面再用 ticket 换房间凭证。禁止把 access token、`userSig` 长期放在 URL、日志或 localStorage。
|
||||
- 使用专用 `QWebEngineProfile`。优先 off-the-record;若必须持久化设备选择,也只能保存无认证数据。通话结束时清 Cookie、HTTP cache、permissions 和页面内容。
|
||||
- 导航只允许精确的 HTTPS origin/path allowlist;拦截新窗口、任意下载、`file://`、未知 scheme、跨域跳转和证书错误。证书错误 fail closed。
|
||||
- 相机/麦克风权限只对当前 allowlisted 通话 origin、活跃通话和明确用户操作放行,结束即撤销。屏幕共享另做显式确认。
|
||||
- 若使用 Qt WebChannel,bridge 只暴露少量 typed method/signal,不暴露文件系统、shell、通用 HTTP client、token getter 或任意 Python 调用。每次调用再次校验当前 page origin 和 session/call id。
|
||||
- release 不启用 remote debugging,不设置 `QTWEBENGINE_DISABLE_SANDBOX=1`,不使用 `--no-sandbox`。应用也不以管理员/root 身份运行。
|
||||
|
||||
### 6.3 兼容性与体积现实
|
||||
|
||||
`PySide6` 顶层 wheel 会同时拉入 Essentials 和 Addons;core profile 应直接依赖 `PySide6-Essentials`。WebEngine 位于 Addons,wheel 和最终制品都会显著增大,不能把它当成“小插件”。最终体积以两端产物为准,不承诺一个固定数字。
|
||||
|
||||
QtWebEngine 使用多进程 Chromium,发布物必须保留:
|
||||
|
||||
- `QtWebEngineProcess` helper;
|
||||
- QtWebEngineCore/Widgets 库与所需平台插件;
|
||||
- `qtwebengine_resources*.pak`、`icudtl.dat`、V8 snapshot;
|
||||
- `qtwebengine_locales`(至少完整验证 `zh-CN`、`en-US` 后才可裁剪);
|
||||
- macOS framework/helper 的 bundle 结构和 entitlements。
|
||||
|
||||
H.264/AAC/MP3 等专有 codec 是否可用取决于 QtWebEngine 构建与许可,不能因为 `hls.js` 存在就假设一定能播放。首发验收必须覆盖真实 TRTC/WebRTC、摄像头、麦克风、扬声器切换、屏幕共享(若需求存在)、HLS/录播格式和弱网;若要自行构建启用 proprietary codecs,先完成专利/分发许可评审。
|
||||
|
||||
## 7. PyInstaller 构建与发布
|
||||
|
||||
### 7.1 为什么固定 onedir
|
||||
|
||||
虽然 PyInstaller 支持 onefile,但本项目默认 `onedir`:
|
||||
|
||||
- onefile 每次启动需解压大体积 Chromium,冷启动慢,易触发杀软且临时目录空间不可控;
|
||||
- WebEngine 是多进程,helper、framework、资源和 macOS 签名/沙箱都依赖正确目录结构;
|
||||
- onedir 更容易做增量诊断、签名验证和 installer 管理。
|
||||
|
||||
用户最终仍拿到一个 `.exe` 安装程序或 `.dmg/.pkg`,无需手动管理 onedir 目录。
|
||||
|
||||
### 7.2 spec 设计原则
|
||||
|
||||
维护四个薄 spec,公共配置放 `packaging/common.py`。入口始终为 `src/zyt_doctor/__main__.py`,`pathex=["src"]`;只收集应用 resources 和必要 metadata。WebEngine profile 因 `integrations/video/webengine.py` 中有显式 import,触发 PyInstaller 官方 PySide6 hook;如通过 feature registry 动态加载,再显式加入这些 hidden imports:
|
||||
|
||||
```python
|
||||
VIDEO_HIDDEN_IMPORTS = [
|
||||
"zyt_doctor.integrations.video.webengine",
|
||||
"PySide6.QtWebEngineCore",
|
||||
"PySide6.QtWebEngineWidgets",
|
||||
"PySide6.QtWebChannel",
|
||||
]
|
||||
|
||||
# core spec 中排除,且 core 构建环境根本不安装 Addons
|
||||
CORE_EXCLUDES = [
|
||||
"PySide6.QtWebEngineCore",
|
||||
"PySide6.QtWebEngineWidgets",
|
||||
"PySide6.QtWebEngineQuick",
|
||||
]
|
||||
```
|
||||
|
||||
不要手工把整个 `site-packages/PySide6` 复制进 datas,也不要用 `collect_all("PySide6")`;这会拉入无关 Qt 模块并可能破坏 hook 期望的目录。优先依赖当前锁定 PyInstaller 的 Qt hooks,仅对 self-check 证实遗漏的自有动态模块写自定义 hook。
|
||||
|
||||
macOS `BUNDLE` 至少设置稳定的 bundle id、版本、图标,并在 `Info.plist` 中写清:
|
||||
|
||||
```python
|
||||
info_plist = {
|
||||
"CFBundleIdentifier": "com.zhenyangtang.doctor",
|
||||
"NSCameraUsageDescription": "用于医生视频问诊",
|
||||
"NSMicrophoneUsageDescription": "用于医生视频问诊",
|
||||
"NSHighResolutionCapable": True,
|
||||
}
|
||||
```
|
||||
|
||||
仅当有实际功能时再加入其他 TCC 权限说明。不得加入放宽 ATS 的全局例外。Windows spec 使用有版本信息的 manifest、`.ico` 和 GUI subsystem,同时保留内部异常日志;开发 smoke build 可临时打开 console。
|
||||
|
||||
### 7.3 构建命令骨架
|
||||
|
||||
Windows x64 runner:
|
||||
|
||||
```powershell
|
||||
py -3.12 -m venv .venv-build
|
||||
.venv-build\Scripts\python -m pip install --require-hashes -r requirements\video-win-x64.lock
|
||||
.venv-build\Scripts\python -m PyInstaller --noconfirm --clean packaging\windows\doctor-video.spec
|
||||
dist\DoctorDesktop\DoctorDesktop.exe --self-check
|
||||
```
|
||||
|
||||
macOS arm64 runner:
|
||||
|
||||
```bash
|
||||
python3.12 -m venv .venv-build
|
||||
.venv-build/bin/python -m pip install --require-hashes -r requirements/video-macos-arm64.lock
|
||||
.venv-build/bin/python -m PyInstaller --noconfirm --clean packaging/macos/doctor-video.spec
|
||||
dist/DoctorDesktop.app/Contents/MacOS/DoctorDesktop --self-check
|
||||
```
|
||||
|
||||
PyInstaller 不能从 Windows 生成 macOS `.app`,反之亦然。每次构建从干净环境执行 `--clean`,记录 Python/PySide6/PyInstaller/OS SDK 版本、锁文件 hash、git commit 和产物 SHA-256,保证可追溯。
|
||||
|
||||
### 7.4 WebEngine 自检
|
||||
|
||||
应用提供 `--self-check`,不访问患者数据,检查:
|
||||
|
||||
- build info、只读 resources、可写 data/log/cache 路径;
|
||||
- keyring backend 是否安全、SQLite migration 是否可运行;
|
||||
- TLS CA、production 配置和 host allowlist;
|
||||
- video profile 中通过 `QLibraryInfo` 定位 helper/resources/locales,禁止依赖写死的 `_internal` 路径;
|
||||
- release 环境没有 `QTWEBENGINE_DISABLE_SANDBOX`/`--no-sandbox`;
|
||||
- GUI smoke 模式实际创建 `QWebEngineView`,加载本地无网络测试页,然后退出;真实视频另由端到端测试覆盖。
|
||||
|
||||
任何 helper、`.pak`、ICU、snapshot、platform plugin 或 locale 缺失都应让发布流水线失败,不在运行时静默降级。
|
||||
|
||||
### 7.5 Windows 发布
|
||||
|
||||
1. 使用固定、受控的 Windows x64 runner 构建;不要在装有多套 Qt/Anaconda 的个人机上发正式包。
|
||||
2. QtWebEngine 运行依赖合适的 MSVC runtime。由安装器包含/检查 Microsoft Visual C++ Redistributable(Qt 官方要求的版本下限需按锁定 Qt 再核对),并在干净 VM 验证。
|
||||
3. 用组织的 Authenticode 证书和 RFC 3161 时间戳签名主程序及最终 MSI/EXE 安装器;验证 `signtool verify /pa /all`。
|
||||
4. 安装到 Program Files,用户数据仍进 LocalAppData;普通用户可运行/升级。可用 WiX Toolset/MSIX 或 Inno Setup,选择后固定 UpgradeCode/AppUserModelID 和回滚策略。
|
||||
5. 在 Windows 10/11 干净 VM 上验证安装、升级、卸载后保留/清除用户数据的明确策略、SmartScreen、企业代理、中文路径和非管理员账户。
|
||||
|
||||
### 7.6 macOS 发布
|
||||
|
||||
1. 在目标架构的 macOS runner 构建。PyInstaller 修改 Mach-O 后必须重新签名;使用 Developer ID Application 身份,不用 ad-hoc 签名发布。
|
||||
2. QtWebEngine helper 是嵌套 app/process。必须保留 framework bundle 结构,并确认 helper 使用 Qt 自带的 `QtWebEngineProcess.entitlements` 所需权限签名;主 app 使用项目的 camera/microphone 权限说明和最小 entitlements。签名顺序由内向外,避免用 `codesign --deep` 掩盖错误。
|
||||
3. 执行 `codesign --verify --deep --strict --verbose=2 DoctorDesktop.app` 和 `spctl --assess --type execute`;随后用 `xcrun notarytool submit ... --wait` 公证,staple ticket,再在离线干净 Mac 验证 Gatekeeper。
|
||||
4. 用 DMG/PKG 或能保留 symlink 的方式分发。PyInstaller 6+ 的 POSIX bundle 广泛使用 symlink,普通 zip 若不保留 symlink 可能膨胀或破坏运行。
|
||||
5. 在 Intel(若支持)与 Apple Silicon 真机上分别验证 keychain 升级连续性、摄像头/麦克风 TCC、休眠唤醒、Retina、多显示器和 WebEngine helper 签名。
|
||||
|
||||
### 7.7 常见打包坑清单
|
||||
|
||||
- **Qt binding 混装**:同环境存在 PyQt/PySide2 或系统 Qt,hook 收到冲突库。解决:干净 venv、只安装一种 binding、锁版本。
|
||||
- **误用 onefile**:启动慢、helper/沙箱/签名问题更难复现。解决:正式版固定 onedir。
|
||||
- **动态 import 未分析**:业务模块或 WebEngine 在 registry 中字符串加载。解决:显式 import 或最小 hiddenimports,并用 frozen smoke test 覆盖。
|
||||
- **资源路径错误**:开发机相对路径可用,安装后 CWD 变化。解决:`importlib.resources`/qrc;用户数据用 QStandardPaths。
|
||||
- **过度裁剪 Qt**:删除 `.pak`、ICU、snapshot、locale、platform plugin 后只在某些机器崩。解决:先保留 hook 输出,按真实清单与测试有证据地裁剪。
|
||||
- **macOS 签名次序/entitlements 错**:QtWebEngineProcess 启动即退出或 TCC 不弹窗。解决:嵌套 helper 真机测试、由内到外签、notarize/staple。
|
||||
- **归档破坏 symlink**:`.app` 体积暴涨或 framework 无法加载。解决:DMG/ditto 或明确保留 symlink 的归档工具。
|
||||
- **GPU/远程桌面差异**:WebEngine 黑屏。不要默认全局 `--disable-gpu`;收集诊断后提供经验证的软件渲染或外部浏览器 fallback。
|
||||
- **codec 误判**:开发机能播 H.264,发布 wheel 不能播。解决:把真实媒体矩阵列入 artifact 验收和许可评审。
|
||||
- **杀软与信誉**:大量 DLL/helper 或未签名 nightly 被拦截。解决:正式证书、时间戳、稳定 installer identity、干净 VM/主流安全软件测试。
|
||||
- **升级破坏 keychain/数据**:bundle id、签名 identity 或 schema 不稳定。解决:这些值从首版固定,migration 支持备份与回滚。
|
||||
|
||||
## 8. 安全与隐私基线
|
||||
|
||||
- TLS 校验永远开启。企业私有 CA 应通过受管安装进入系统 trust store;不得用 `verify=False`。如需兼容企业代理,可用 `truststore` 接入 OS 证书库并做专项测试。
|
||||
- 本地配置不能提供任意 production host 重定向;敏感 token 永远不进入 URL query、clipboard、日志、analytics 或 crash report。
|
||||
- HTML 病历优先用受限 `QTextBrowser`/原生富文本展示并在服务端净化;不要因为“要展示 HTML”就引入 WebEngine。任何外链由用户确认后交给系统浏览器。
|
||||
- 限制剪贴板和通知中的患者信息;自动锁定后遮蔽窗口内容。截图阻止在跨平台上不可靠,不能作为合规控制。
|
||||
- 所有高风险业务写操作由服务端保留不可抵赖审计;客户端日志只记录事件名、耗时、状态、request id 和脱敏技术上下文。
|
||||
- 发布前完成依赖 SBOM、许可证清单和漏洞扫描。PySide6 采用 LGPLv3/GPLv3 或商业许可,QtWebEngine/Chromium/codec 还包含额外 notices;由法务确认采用的 Qt 许可与分发义务,安装包附第三方 notices。
|
||||
- 自动升级若后续实现,更新 manifest 必须签名,制品必须校验 SHA-256 与平台签名,并支持回滚;首版宁可用已签名安装器提示升级,也不要执行未签名下载内容。
|
||||
|
||||
## 9. 测试与发布门槛
|
||||
|
||||
### 9.1 自动化测试分层
|
||||
|
||||
- **unit**:权限 guard、会话状态机、单飞 refresh、重试白名单、错误映射、缓存 TTL、加解密、outbox 状态和冲突决策。用 fake clock/random/secret store,保证确定性。
|
||||
- **API contract**:以 OpenAPI schema 或后端 mock 验证字段、错误码、分页、时间和幂等语义;`respx` 模拟超时、断流、401 并发、429/Retry-After、5xx 和结果未知。
|
||||
- **integration**:临时 SQLite + fake keyring + staging API,覆盖 migration、损坏缓存、磁盘满、退出清理、租户切换和代理/私有 CA。
|
||||
- **UI**:`pytest-qt` 验证路由权限、loading/empty/error/offline、键盘导航、取消和 late response;不要用脆弱的像素级截图替代行为断言。
|
||||
- **frozen artifact**:Windows/macOS 各自安装后运行 `--self-check`,启动主窗口、登录 mock/staging、访问资源、写用户目录、升级 migration、卸载。
|
||||
- **视频真机**:摄像头/麦克风授权与拒绝、无设备、设备热插拔、回声设备、弱网/断网重连、休眠唤醒、屏幕共享、录播 codec、结束后权限与 Cookie 清理。
|
||||
|
||||
重点故障用例包括:十个请求同时 401、提交后响应丢失、服务器版本冲突、系统时钟偏差、刷新时退出、缓存被截断、keychain 被锁、两实例竞争、磁盘只读、API 维护、WebEngine helper 被杀、证书过期。测试数据必须是合成数据。
|
||||
|
||||
### 9.2 CI 矩阵与质量门槛
|
||||
|
||||
PR 阶段可并行运行 Windows x64 和 macOS arm64 的 lint/type/unit/UI headless 测试。release tag 阶段在原生 runner 生成制品并执行:
|
||||
|
||||
1. `ruff`、`mypy`、unit/contract/integration 测试全绿,覆盖率阈值重点约束 core 状态机而非 UI 行数。
|
||||
2. 依赖 lock、SBOM、license、`pip-audit` 无未批准的高危项。
|
||||
3. 两端 frozen self-check 与安装/升级 smoke 通过。
|
||||
4. 签名、公证、hash、版本资源和 update channel 验证通过。
|
||||
5. video profile 在目标硬件的人工/自动验收清单签字;core profile 证明不会意外收集 QtWebEngine。
|
||||
6. staging 完成登录、权限变更、患者查询、一个低风险写操作、一个高风险确认、登出清理和离线恢复闭环。
|
||||
|
||||
## 10. 实施顺序与验收里程碑
|
||||
|
||||
### M0:契约和风险封板(约 3–5 天)
|
||||
|
||||
- 获取 OpenAPI/认证/权限/RTC/对象存储契约;形成 endpoint 与权限矩阵。
|
||||
- 在 Windows/macOS 原型中用 QtWebEngine 打开专用测试页,验证 TRTC/WebRTC、设备权限和目标 codec。
|
||||
- 决定首发是 core、video 还是“core + 外部浏览器”。
|
||||
|
||||
**退出条件**:API base 和 auth 不再是假设;视频路线有真实 PoC,而非只证明网页能打开。
|
||||
|
||||
### M1:可发布骨架
|
||||
|
||||
- 完成 bootstrap、配置校验、日志/路径、API client、SessionManager、PermissionGuard、shell 和统一状态组件。
|
||||
- mock server 下完成登录、`/me`、权限菜单、401 single-flight refresh、登出清理。
|
||||
- 两端 onedir unsigned nightly 可安装并通过 self-check。
|
||||
|
||||
### M2:业务纵切
|
||||
|
||||
- 先选一个完整纵切(例如预约 -> 患者概要 -> 诊间记录草稿),按 module 结构贯穿 UI、service、API、权限和测试。
|
||||
- 再并行扩展患者、病历、处方、随访、聊天;高风险操作必须有后端幂等/审计。
|
||||
|
||||
### M3:离线与视频
|
||||
|
||||
- 实现加密 cache/outbox、冲突页、断网/恢复和数据清理。
|
||||
- video adapter、受限 profile、一次性 ticket、权限撤销和外部浏览器 fallback 完成。
|
||||
|
||||
### M4:生产发布
|
||||
|
||||
- 锁依赖和 runner image,完成 Windows Authenticode、macOS Developer ID/notarization、SBOM/许可证。
|
||||
- 干净 VM/真机、企业代理、非管理员、升级/回滚、视频设备矩阵全部通过。
|
||||
|
||||
## 11. 最终架构决策摘要
|
||||
|
||||
1. 业务 UI 用原生 Qt Widgets;QtWebEngine 是隔离的视频实现细节,不是应用架构。
|
||||
2. 后端契约、服务端授权和服务端审计是权威;桌面端只做强类型 adapter、体验 guard 和安全状态管理。
|
||||
3. access token 仅在内存,长期凭证进 OS keychain;敏感离线数据加密且按用户/租户隔离。
|
||||
4. 高风险医疗写操作离线时只能保存草稿,恢复后由医生确认;普通 outbox 依赖幂等键和乐观锁。
|
||||
5. 发布固定 PyInstaller onedir、平台原生构建、签名和 artifact 级测试;不交叉编译,不依赖开发机“能跑”。
|
||||
6. production URL、SDK secret、对象存储密钥和 RTC 签名密钥都不能硬编码进客户端。
|
||||
|
||||
## 参考资料
|
||||
|
||||
- [Qt for Python package details](https://doc.qt.io/qtforpython-6.10/package_details.html):Essentials/Addons 拆分和 wheel 内容。
|
||||
- [Qt for Python 与 PyInstaller](https://doc.qt.io/qtforpython-6.10/deployment/deployment-pyinstaller.html):官方 PyInstaller 基础部署说明。
|
||||
- [Qt WebEngine 部署](https://doc.qt.io/qt-6/qtwebengine-deploying.html):helper、resources、locales、macOS entitlements 等必需项。
|
||||
- [Qt WebEngine features](https://doc.qt.io/qt-6/qtwebengine-features.html):WebRTC/媒体能力及专有 codec 许可提醒。
|
||||
- [PyInstaller macOS multi-arch 与签名](https://pyinstaller.org/en/stable/feature-notes.html):架构 slice 和 codesign 行为。
|
||||
- [PyInstaller symlink/common pitfalls](https://pyinstaller.org/en/stable/common-issues-and-pitfalls.html):PyInstaller 6+ POSIX bundle 的 symlink 分发要求。
|
||||
|
||||