Compare commits

..
5 Commits
Author SHA1 Message Date
Your Name 6c444a4a04 更新 2026-08-20 18:01:36 +08:00
Your Name cc6173e5b0 更新 2026-08-20 17:56:08 +08:00
Your Name 5794f60c5d 更新bug 2026-08-20 17:47:14 +08:00
Your Name 35f91ee37a 更新 2026-08-18 14:14:43 +08:00
Your Name bc1228a310 geng 2026-08-18 14:08:38 +08:00
494 changed files with 21359 additions and 3456 deletions
+34 -33
View File
@@ -1,33 +1,34 @@
# Build and Release Folders
bin-debug/
bin-release/
[Oo]bj/
[Bb]in/
# Other files and folders
.settings/
# Executables
*.swf
*.air
*.ipa
*.apk
# Project files, i.e. `.project`, `.actionScriptProperties` and `.flexProperties`
# should NOT be excluded as they contain compiler settings and other important
# information for Eclipse / Flash Builder.
/.idea
/.codex-tasks
/.trellis
/.claude
/.agent
/.shared
/.cursor
/.codex
/.agents
/server/.spool
/server/.claude
/.spool
TUICallKit-Vue3/.env
/.codegraph
# Build and Release Folders
bin-debug/
bin-release/
[Oo]bj/
[Bb]in/
# Other files and folders
.settings/
# Executables
*.swf
*.air
*.ipa
*.apk
# Project files, i.e. `.project`, `.actionScriptProperties` and `.flexProperties`
# should NOT be excluded as they contain compiler settings and other important
# information for Eclipse / Flash Builder.
# 测试临时目录
app/.test-tmp-stream/
/.idea
/.codex-tasks
/.trellis
/.claude
/.agent
/.shared
/.cursor
/.codex
/.agents
/server/.spool
/server/.claude
/.spool
TUICallKit-Vue3/.env
/.codegraph
+59
View File
@@ -0,0 +1,59 @@
# 2026-08-20 — 医生工作站 AI 问诊对话框布局优化
## 改动
- 文件: `app/src/doctor_workstation/ui/dialogs/ai_consult.py`
- 模块: `AiConsultDialog` 右侧「智能分析」卡片 + 问诊对话卡片密度优化
- 新增 helpers: `_InsightItem`, `_split_insight_segments`, `_split_numbered_list`, `_split_plain_paragraph`, `_split_label_or_sentence`, `_escape_html`
- `_set_insights` 改成将稠密段落拆成 lead / list / plain 三类, 每类单独 QLabel 显示
- CSS 新增 `AiConsultInsightMarker` / `AiConsultInsightText` / `AiConsultInsightPlaceholder`
- 卡片 padding `10,8,10,8``14,12,14,14`; 卡间距 `8``10`
- 聊天气泡垂直 margin `4``6`, chat_layout spacing `10``12`
## 拆分策略
- 文本以 `1./2、/3)/4` 起头 → 视为完整编号列表
- 文本中嵌入 `…:1.…2.…` → 拆出序言 + 编号列表
- 短中文标签 `XXYY` 开头 → 视为 lead, 标签部分加粗
- 其余按 `。//;/` 分句 → 单独成行
## 测试
- 现有 `tests/test_ai_consult_ui.py` 通过 `widget.objectName() == "AiConsultRecordTitle"` 校验标题仍在, 未受影响
- 暂未运行 UI 测试 (开发环境未装 PySide6); 解析逻辑单独脚本验证通过
## 注意
- PySide6 QSS 对 `QLabel``line-height` 支持有限; 主要靠 item 间距 `8px``padding:1px 0` 提供节奏
- 如果后续接入 `AiConsultInsightText` 还嫌密集, 可再调 `body_host.setSpacing`
---
# 接诊台 AI 智能分析详情对话框 (`ReceptionAiAnalysisDialog`) 优化
用户截图(`clipboard-2026-08-20T08-42-40`...)反馈: 「问诊详情 → AI 智能分析」卡片里的诊断建议 / 风险评估 / 治疗建议三段文字太稠密, 不利于阅读。
## 关键差异
这次的目标文件和上次不同 — 用户截图里出现 "诊断建议 / 风险评估 / 治疗建议" 标题, 而 `ai_consult.py` 用的是 "血糖控制评估 / 并发症风险评估 / 用药合理性评估"。实际是 `app/src/doctor_workstation/ui/pages/reception.py` 中的 `_ReceptionAiAnalysisDialog` 类。
## 改动
- 文件: `app/src/doctor_workstation/ui/pages/reception.py`
- 新增 helpers (与 ai_consult.py 类似但导出名加 `_ai_` 前缀):
- `_ai_split_into_segments` / `_ai_split_numbered_list` / `_ai_split_plain_paragraph`
- `_ai_segments_to_html` / `_ai_narrative_structured_html`
- `_ai_split_label_or_sentence` / `_ai_escape_html`
- 正则: `_AI_INSIGHT_LIST_RE`, `_AI_INSIGHT_LIST_LOOKAHEAD_RE`, `_AI_INSIGHT_LABEL_RE`
- `add_text_section` 闭包内多挂一个 RichText `_structured_labels[name]` 标签 (`.setObjectName(name + "Structured")`, `property="dialogAiStructured"`)
- 原 PlainText `value_label` 仍存在 (`hide()` + `setMaximumHeight(0)`) 用于兼容既有 findChild 测试
- `_render_model` 同步刷新两个结构化标签; 空 HTML 时回退显示原 label 文本
- 风险评估 FlowLayout spacing `7×7``9×9`
- `body_layout.setSpacing` `12``14`
- 卡片 contentsMargins `14,13,16,15``16,14,18,16`
- QSS: `dialogAiBody``line-height: 1.85`, 新增 `dialogAiStructured` 选择器 (line-height 175%); 风险 pill `min-height` `28``30`, padding `4 10``5 12`, font 12 → 12.5, border-radius `7``8`
## 兼容约束
- `tests/test_reception_parity_ui.py` 严格校验:
- `findChild(QLabel, "ReceptionAiAnalysisDialogDiagnosisText")` 必须能找到
- `.text()` 必须等于 `_ai_narrative_text` 归一化结果
- `.textFormat() == Qt.TextFormat.PlainText`
- 因此 PlainText 标签必须原样保留, 仅做 `.hide()` + `setMaximumHeight(0)` (避免 QSS `>` 子选择器不被 Qt 支持)
## 验证
- `py_compile` 通过, 无 `SyntaxWarning`
- 单独脚本跑过 4 组样本文本 (稠密段落 / 嵌入式编号列表 / 短句 / 纯编号列表), 拆分结果符合预期
- 未在本机跑 UI 测试 (缺 PySide6)
@@ -331,9 +331,8 @@ function onSelectionChange(rows: Record<string, unknown>[]) {
selectedRows.value = rows
}
/** 与诊单预约弹窗一致:这些字典 name 需填「自媒体补充」 */
/** 与诊单预约弹窗一致:自媒体4H/4Q 无需补充,仅以下字典 name 需填「自媒体补充」 */
const CHANNEL_NAMES_REQUIRING_SELF_MEDIA_DETAIL = new Set([
'自媒体4H',
'自媒体3Q',
'自媒体3H',
'自媒体2H',
+157 -119
View File
@@ -81,10 +81,10 @@
<el-option
v-for="item in group.channels"
:key="item.code"
:label="item.name"
:label="item.search_label || item.name"
:value="item.code"
>
<span class="channel-option">
<span class="channel-option" :class="{ 'is-group': item.kind === 'group' }">
<span>{{ item.name }}</span>
<span>{{ item.customer_count }} </span>
</span>
@@ -96,47 +96,47 @@
</section>
<section class="metric-grid" aria-label="综合转化指标">
<article v-for="metric in metricCards" :key="metric.key" class="metric-card">
<article v-for="metric in visibleMetricCards" :key="metric.key" class="metric-card">
<span>{{ metric.label }}</span>
<strong>{{ formatMetric(metric.key, metric.type) }}</strong>
<small>{{ metric.hint }}</small>
</article>
</section>
<section v-if="showRankings" class="ranking-grid">
<article class="panel ranking-panel">
<div class="panel-heading">
<div>
<h2>{{ rankingSubject }}订单量占比排名</h2>
<p>{{ rankingSubject }}归属统计排除取消拒收及退款</p>
</div>
<span>单位</span>
</div>
<div v-if="dashboard.rankings.orders.length" class="bar-list">
<div v-for="(item, index) in dashboard.rankings.orders" :key="`order-${item.id}`" class="bar-row">
<span class="bar-name" :title="item.name"><b>{{ index + 1 }}</b>{{ item.name }}</span>
<div class="bar-track"><i class="is-teal" :style="{ width: barWidth(item.value, totalOrderValue) }" /></div>
<strong><span>{{ formatNumber(item.value) }} </span><small>{{ formatShare(item.value, totalOrderValue) }}</small></strong>
</div>
</div>
<el-empty v-else :image-size="54" description="当前范围暂无订单数据" />
<section v-if="showRankings" class="ranking-grid">
<article class="panel ranking-panel">
<div class="panel-heading">
<div>
<h2>{{ rankingSubject }}订单量占比排名</h2>
<p>{{ rankingSubject }}归属统计排除取消拒收及退款</p>
</div>
<span>单位</span>
</div>
<div v-if="dashboard.rankings.orders.length" class="bar-list">
<div v-for="(item, index) in dashboard.rankings.orders" :key="`order-${item.id}`" class="bar-row">
<span class="bar-name" :title="item.name"><b>{{ index + 1 }}</b>{{ item.name }}</span>
<div class="bar-track"><i class="is-teal" :style="{ width: barWidth(item.value, totalOrderValue) }" /></div>
<strong><span>{{ formatNumber(item.value) }} </span><small>{{ formatShare(item.value, totalOrderValue) }}</small></strong>
</div>
</div>
<el-empty v-else :image-size="54" description="当前范围暂无订单数据" />
</article>
<article class="panel ranking-panel">
<div class="panel-heading">
<div>
<h2>{{ rankingSubject }}金额占比排名</h2>
<p>{{ rankingSubject }}归属统计仅含未取消未拒收且未退款的有效金额</p>
</div>
<span>单位</span>
</div>
<div v-if="dashboard.rankings.amounts.length" class="bar-list">
<div v-for="(item, index) in dashboard.rankings.amounts" :key="`amount-${item.id}`" class="bar-row">
<span class="bar-name" :title="item.name"><b>{{ index + 1 }}</b>{{ item.name }}</span>
<div class="bar-track"><i class="is-blue" :style="{ width: barWidth(item.value, totalAmountValue) }" /></div>
<strong><span>{{ formatMoney(item.value) }}</span><small>{{ formatShare(item.value, totalAmountValue) }}</small></strong>
</div>
</div>
<div class="panel-heading">
<div>
<h2>{{ rankingSubject }}金额占比排名</h2>
<p>{{ rankingSubject }}归属统计仅含未取消未拒收且未退款的有效金额</p>
</div>
<span>单位</span>
</div>
<div v-if="dashboard.rankings.amounts.length" class="bar-list">
<div v-for="(item, index) in dashboard.rankings.amounts" :key="`amount-${item.id}`" class="bar-row">
<span class="bar-name" :title="item.name"><b>{{ index + 1 }}</b>{{ item.name }}</span>
<div class="bar-track"><i class="is-blue" :style="{ width: barWidth(item.value, totalAmountValue) }" /></div>
<strong><span>{{ formatMoney(item.value) }}</span><small>{{ formatShare(item.value, totalAmountValue) }}</small></strong>
</div>
</div>
<el-empty v-else :image-size="54" description="当前范围暂无金额数据" />
</article>
</section>
@@ -145,7 +145,7 @@
<div class="panel-heading panel-heading--table">
<div>
<h2>明细数据列表</h2>
<p>展开部门可查看人员明细加粉=总进线=区间有效加粉按员工+客户去重须会话同意剔除已删客户继承客户扫一扫/搜手机号/名片分享添加及区间前已加过的重加挂号=已支付且实收低于 10 元的订单预约=有效预约记录开口率=开口/加粉挂号率=挂号/加粉面诊率=面诊/挂号看挂号后流失预约率=面诊/预约看预约后未面诊面诊接诊率=接诊诊单/面诊接诊率=接诊诊单/总进线</p>
<p>展开部门可查看人员明细加粉=总进线=区间有效加粉按员工+客户去重剔除已删客户继承客户扫一扫/搜手机号/名片分享添加及区间前已加过的重加挂号=已支付且实收低于 10 元的订单预约=有效预约记录开口率=开口/加粉挂号率=挂号/加粉面诊率=面诊/挂号看挂号后流失预约率=面诊/预约看预约后未面诊面诊接诊率=接诊诊单/面诊接诊率=接诊诊单/总进线</p>
</div>
<span>{{ dashboard.rows.length }} 个顶层节点</span>
</div>
@@ -156,7 +156,7 @@
default-expand-all
class="detail-table"
>
<el-table-column prop="name" label="部门 / 人员" min-width="220" fixed="left">
<el-table-column prop="name" label="部门 / 人员" min-width="220" fixed="left">
<template #default="{ row }">
<strong :class="{ 'is-parent': Array.isArray(row.children) && row.children.length, 'is-member': row.type === 'member' }">
{{ row.name }}
@@ -178,34 +178,34 @@
</el-tag>
</template>
</el-table-column>
<el-table-column prop="add_fans_count" label="加粉" min-width="72" align="right" />
<el-table-column prop="total_open_count" label="开口" min-width="72" align="right" />
<el-table-column prop="paid_appointment_count" label="挂号" min-width="72" align="right" />
<el-table-column prop="appointment_total_count" label="预约" min-width="72" align="right" />
<el-table-column prop="interview_count" label="面诊" min-width="72" align="right" />
<el-table-column prop="completed_order_count" label="接诊诊单" min-width="88" align="right" />
<el-table-column label="接诊金额" min-width="104" align="right">
<el-table-column prop="add_fans_count" label="加粉" min-width="72" align="right" />
<el-table-column prop="total_open_count" label="开口" min-width="72" align="right" />
<el-table-column prop="paid_appointment_count" label="挂号" min-width="72" align="right" />
<el-table-column prop="appointment_total_count" label="预约" min-width="72" align="right" />
<el-table-column prop="interview_count" label="面诊" min-width="72" align="right" />
<el-table-column prop="completed_order_count" label="接诊诊单" min-width="88" align="right" />
<el-table-column label="接诊金额" min-width="104" align="right">
<template #default="{ row }">{{ formatMoney(row.completed_order_amount) }}</template>
</el-table-column>
<el-table-column label="开口率" min-width="82" align="right">
<el-table-column label="开口率" min-width="82" align="right">
<template #default="{ row }">{{ formatPercent(row.total_open_rate) }}</template>
</el-table-column>
<el-table-column label="挂号率" min-width="82" align="right">
<el-table-column label="挂号率" min-width="82" align="right">
<template #default="{ row }">{{ formatPercent(row.paid_appointment_rate) }}</template>
</el-table-column>
<el-table-column label="面诊率" min-width="82" align="right">
<el-table-column label="面诊率" min-width="82" align="right">
<template #default="{ row }">{{ formatPercent(row.interview_paid_rate) }}</template>
</el-table-column>
<el-table-column label="预约率" min-width="82" align="right">
<el-table-column label="预约率" min-width="82" align="right">
<template #default="{ row }">{{ formatPercent(row.interview_rate) }}</template>
</el-table-column>
<el-table-column label="面诊接诊率" min-width="100" align="right">
<el-table-column label="面诊接诊率" min-width="100" align="right">
<template #default="{ row }">{{ formatPercent(row.interview_receive_rate) }}</template>
</el-table-column>
<el-table-column label="接诊率" min-width="82" align="right">
<el-table-column label="接诊率" min-width="82" align="right">
<template #default="{ row }">{{ formatPercent(row.receive_rate) }}</template>
</el-table-column>
<el-table-column label="ROI" min-width="72" align="right">
<el-table-column v-if="canViewFinance" label="ROI" min-width="72" align="right">
<template #default="{ row }">{{ formatRatio(row.roi) }}</template>
</el-table-column>
<template #empty><el-empty description="当前权限范围内暂无转化数据" /></template>
@@ -287,13 +287,18 @@ type MediaChannelOption = {
tag_id?: string
group_name?: string
customer_count?: number
kind?: 'channel' | 'group'
search_label?: string
}
const FINANCE_METRIC_KEYS = new Set(['account_cost', 'roi'])
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: '',
selected_media_channel_code: '', selected_media_channel_name: '', open_count_source: '', ranking_kind: 'hidden'
time_type: 'today', time_label: '今日', start_date: '', end_date: '', generated_at: '',
scope_value: 4, scope_label: '', selected_dept_name: '', selected_assistant_name: '',
selected_media_channel_code: '', selected_media_channel_name: '', open_count_source: '', ranking_kind: 'hidden',
can_view_finance: false
},
filters: {
departments: [] as any[],
@@ -330,7 +335,7 @@ const timeOptions = [
{ label: '自定义', value: 'custom' }
]
const metricCards: Array<{ key: string; label: string; type: MetricType; hint: string }> = [
{ key: 'add_fans_count', label: '加粉数', type: 'count', hint: '区间有效加粉:去重,须会话同意,剔除已删客户、继承客户、扫一扫/搜手机号/名片分享添加及区间前已加过的重加' },
{ key: 'add_fans_count', label: '加粉数', type: 'count', hint: '区间有效加粉:去重,剔除已删客户、继承客户、扫一扫/搜手机号/名片分享添加及区间前已加过的重加' },
{ key: 'total_open_count', label: '开口数', type: 'count', hint: '来源于个人业绩录入' },
{ key: 'interview_count', label: '面诊', type: 'count', hint: '已完成预约' },
{ key: 'completed_order_count', label: '接诊诊单', type: 'count', hint: '业务订单,按创建人归属并过滤无效单' },
@@ -347,30 +352,58 @@ const scopeDescription = computed(() => {
if (dashboard.meta.selected_media_channel_name) parts.push(`渠道:${dashboard.meta.selected_media_channel_name}`)
return parts.join(' · ')
})
const canViewFinance = computed(() => Boolean(dashboard.meta.can_view_finance))
const visibleMetricCards = computed(() =>
canViewFinance.value
? metricCards
: metricCards.filter((card) => !FINANCE_METRIC_KEYS.has(card.key))
)
const mediaChannelGroups = computed(() => {
const groups = new Map<string, {
const groups: Array<{
group_name: string
customer_count: number
channels: MediaChannelOption[]
}>()
}> = []
const indexByName = new Map<string, number>()
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: [] })
if (channel.kind === 'group') continue
const groupName = String(channel.group_name || '').trim()
let groupIndex = indexByName.get(groupName)
if (groupIndex === undefined) {
groupIndex = groups.length
indexByName.set(groupName, groupIndex)
groups.push({ group_name: groupName, customer_count: 0, channels: [] })
}
const group = groups.get(groupName)!
group.channels.push(channel)
const group = groups[groupIndex]
const searchLabel = groupName !== '' && !channel.name.includes(groupName)
? `${channel.name} ${groupName}`
: ''
group.channels.push(searchLabel === '' ? channel : { ...channel, search_label: searchLabel })
group.customer_count = Math.max(group.customer_count, Number(channel.customer_count || 0))
}
return Array.from(groups.values())
for (const group of groups) {
if (group.group_name === '') continue
const hasSameNameLeaf = group.channels.some((item) => item.name === group.group_name)
if (group.channels.length < 2 && hasSameNameLeaf) continue
group.channels.unshift({
code: `group:${group.group_name}`,
name: `${group.group_name}(全部)`,
group_name: group.group_name,
customer_count: group.customer_count,
kind: 'group'
})
}
return groups
})
const rankingKind = computed(() => dashboard.meta.ranking_kind || (
Number(dashboard.meta.scope_value) === 4 ? 'hidden' : Number(dashboard.meta.scope_value) === 3 ? 'member' : 'group'
))
const showRankings = computed(() => rankingKind.value !== 'hidden')
const rankingSubject = computed(() => rankingKind.value === 'member' ? '组内成员' : '小组')
const totalOrderValue = computed(() => dashboard.rankings.orders.reduce((total, item) => total + Number(item.value || 0), 0))
const totalAmountValue = computed(() => dashboard.rankings.amounts.reduce((total, item) => total + Number(item.value || 0), 0))
const rankingKind = computed(() => dashboard.meta.ranking_kind || (
Number(dashboard.meta.scope_value) === 4 ? 'hidden' : Number(dashboard.meta.scope_value) === 3 ? 'member' : 'group'
))
const showRankings = computed(() => rankingKind.value !== 'hidden')
const rankingSubject = computed(() => rankingKind.value === 'member' ? '组内成员' : '小组')
const totalOrderValue = computed(() => dashboard.rankings.orders.reduce((total, item) => total + Number(item.value || 0), 0))
const totalAmountValue = computed(() => dashboard.rankings.amounts.reduce((total, item) => total + Number(item.value || 0), 0))
const targetChartOption = computed(() => ({
animationDuration: 450,
color: ['#0f9185', '#2f78df'],
@@ -452,14 +485,14 @@ function formatMoney(value: any) {
return `¥${Number(value || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
}
function formatPercent(value: any) {
return `${Number(value || 0).toFixed(1)}%`
}
function formatShare(value: any, total: number) {
if (total <= 0) return '0.0%'
return `${(Number(value || 0) / total * 100).toFixed(1)}%`
}
function formatPercent(value: any) {
return `${Number(value || 0).toFixed(1)}%`
}
function formatShare(value: any, total: number) {
if (total <= 0) return '0.0%'
return `${(Number(value || 0) / total * 100).toFixed(1)}%`
}
function nullablePercent(value: any) {
return value === null || value === undefined ? '未设置' : formatPercent(value)
@@ -474,11 +507,11 @@ function compactNumber(value: number) {
return String(Math.round(value))
}
function barWidth(value: any, total: number) {
const numericValue = Number(value || 0)
if (total <= 0 || numericValue <= 0) return '0%'
return `${Math.max(4, Math.min(100, numericValue / total * 100))}%`
}
function barWidth(value: any, total: number) {
const numericValue = Number(value || 0)
if (total <= 0 || numericValue <= 0) return '0%'
return `${Math.max(4, Math.min(100, numericValue / total * 100))}%`
}
function progressValue(value: any) {
return Math.max(0, Math.min(100, Number(value || 0)))
@@ -488,29 +521,29 @@ onMounted(loadDashboard)
</script>
<style scoped lang="scss">
.conversion-page {
display: grid;
gap: 14px;
width: 100%;
max-width: 100%;
min-width: 0;
min-height: 640px;
padding: 16px;
overflow-x: hidden;
box-sizing: border-box;
color: #172033;
background: #f4f6f8;
}
.conversion-page {
display: grid;
gap: 14px;
width: 100%;
max-width: 100%;
min-width: 0;
min-height: 640px;
padding: 16px;
overflow-x: hidden;
box-sizing: border-box;
color: #172033;
background: #f4f6f8;
}
.page-heading,
.filter-strip,
.panel,
.metric-card {
min-width: 0;
box-sizing: border-box;
border: 1px solid #dfe5ec;
background: #fff;
}
.metric-card {
min-width: 0;
box-sizing: border-box;
border: 1px solid #dfe5ec;
background: #fff;
}
.page-heading {
display: flex;
@@ -553,7 +586,7 @@ onMounted(loadDashboard)
.date-range-picker { width: 260px; }
.employee-select { width: 190px; }
.dept-select { width: 220px; }
.channel-select { width: 180px; }
.channel-select { width: 220px; }
.channel-option {
display: flex;
align-items: center;
@@ -565,6 +598,11 @@ onMounted(loadDashboard)
color: #98a2b3;
font-size: 12px;
}
&.is-group span:first-child {
font-weight: 650;
color: #172033;
}
}
.metric-grid {
@@ -584,7 +622,7 @@ onMounted(loadDashboard)
}
.ranking-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
.panel { min-width: 0; max-width: 100%; padding: 16px; border-radius: 10px; box-sizing: border-box; }
.panel { min-width: 0; max-width: 100%; padding: 16px; border-radius: 10px; box-sizing: border-box; }
.panel-heading {
display: flex;
@@ -598,26 +636,26 @@ onMounted(loadDashboard)
> span { color: #929dac; font-size: 11px; white-space: nowrap; }
}
.bar-list { display: grid; gap: 13px; max-height: 340px; overflow-y: auto; padding-right: 4px; }
.bar-row { display: grid; grid-template-columns: 130px minmax(80px, 1fr) 116px; align-items: center; gap: 10px; }
.bar-name { overflow: hidden; color: #66748a; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
.bar-name b { display: inline-block; width: 20px; margin-right: 7px; color: #98a2b3; font-size: 11px; font-weight: 650; text-align: center; }
.bar-row > strong { display: flex; align-items: baseline; justify-content: flex-end; gap: 7px; text-align: right; font-size: 12px; font-variant-numeric: tabular-nums; }
.bar-row > strong small { color: #8a95a5; font-size: 10px; font-weight: 500; }
.bar-list { display: grid; gap: 13px; max-height: 340px; overflow-y: auto; padding-right: 4px; }
.bar-row { display: grid; grid-template-columns: 130px minmax(80px, 1fr) 116px; align-items: center; gap: 10px; }
.bar-name { overflow: hidden; color: #66748a; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
.bar-name b { display: inline-block; width: 20px; margin-right: 7px; color: #98a2b3; font-size: 11px; font-weight: 650; text-align: center; }
.bar-row > strong { display: flex; align-items: baseline; justify-content: flex-end; gap: 7px; text-align: right; font-size: 12px; font-variant-numeric: tabular-nums; }
.bar-row > strong small { color: #8a95a5; font-size: 10px; font-weight: 500; }
.bar-track { height: 18px; overflow: hidden; border-radius: 5px; background: #edf1f5; }
.bar-track i { display: block; height: 100%; border-radius: 5px; transition: width .35s ease; }
.bar-track i.is-teal { background: #15998d; }
.bar-track i.is-blue { background: #307bdf; }
.detail-panel { padding-bottom: 10px; overflow: hidden; }
.detail-table {
width: 100%;
max-width: 100%;
min-width: 0;
:deep(.el-table__inner-wrapper),
:deep(.el-scrollbar) { max-width: 100%; }
:deep(th.el-table__cell) { color: #66748a; background: #f7f9fb; font-size: 12px; }
.detail-panel { padding-bottom: 10px; overflow: hidden; }
.detail-table {
width: 100%;
max-width: 100%;
min-width: 0;
:deep(.el-table__inner-wrapper),
:deep(.el-scrollbar) { max-width: 100%; }
:deep(th.el-table__cell) { color: #66748a; background: #f7f9fb; font-size: 12px; }
: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; }
@@ -658,7 +696,7 @@ onMounted(loadDashboard)
.metric-grid, .ranking-grid { grid-template-columns: 1fr; }
.filter-item, .filter-item--time { width: 100%; align-items: flex-start; flex-direction: column; }
.employee-select, .dept-select, .channel-select { width: 100%; }
.bar-row { grid-template-columns: 100px minmax(70px, 1fr) 96px; }
.bar-row { grid-template-columns: 100px minmax(70px, 1fr) 96px; }
.target-summary { grid-template-columns: 1fr; }
}
</style>
+12 -5
View File
@@ -215,6 +215,7 @@ const PAGE_SIZE = 15
interface QueueRow {
id: number
patient_id: number
source_patient_id?: number
patient_name: string
patient_phone?: string
diagnosis_id?: number
@@ -436,19 +437,25 @@ const handleSearch = async () => {
}
const handleCall = async (row: QueueRow) => {
if (!row.patient_id) {
const sourcePatientId = Number(row.source_patient_id || 0)
const diagnosisId = Number(row.diagnosis_id || 0)
if (!sourcePatientId) {
feedback.msgWarning('患者信息不完整')
return
}
if (!diagnosisId) {
feedback.msgWarning('预约信息不完整,无法发起聊天')
return
}
try {
const res = await getCallSignature({
patient_id: row.patient_id,
diagnosis_id: row.diagnosis_id || row.id
patient_id: sourcePatientId,
diagnosis_id: diagnosisId
})
chatDialogRef.value?.open({
patientId: row.patient_id,
patientId: sourcePatientId,
patientName: row.patient_name,
diagnosisId: row.diagnosis_id || row.id,
diagnosisId,
signatureData: res
})
} catch (error: any) {
+28 -28
View File
@@ -996,34 +996,34 @@ const handleEdit = (row: any) => {
}
// 聊天
const handleChat = async (row: any) => {
if (!row.patient_id) {
feedback.msgWarning('患者信息不完整')
return
}
// 检查是否有诊单ID
if (!row.diagnosis_id && !row.id) {
feedback.msgWarning('预约信息不完整,无法发起聊天')
return
}
try {
// 获取聊天签名信息
const res = await getCallSignature({
patient_id: row.patient_id,
diagnosis_id: row.diagnosis_id || row.id
})
console.log('获取聊天签名成功:', res)
// 直接打开聊天对话框,传入必要的参数
chatDialogRef.value?.open({
patientId: row.patient_id,
patientName: row.patient_name,
diagnosisId: row.diagnosis_id || row.id,
signatureData: res // 传入签名数据
})
const handleChat = async (row: any) => {
const sourcePatientId = Number(row.source_patient_id || 0)
const diagnosisId = Number(row.diagnosis_id || 0)
if (!sourcePatientId) {
feedback.msgWarning('患者信息不完整')
return
}
if (!diagnosisId) {
feedback.msgWarning('预约信息不完整,无法发起聊天')
return
}
try {
// 获取聊天签名信息
const res = await getCallSignature({
patient_id: sourcePatientId,
diagnosis_id: diagnosisId
})
console.log('获取聊天签名成功:', res)
// 直接打开聊天对话框,传入必要的参数
chatDialogRef.value?.open({
patientId: sourcePatientId,
patientName: row.patient_name,
diagnosisId,
signatureData: res // 传入签名数据
})
} catch (error: any) {
console.error('获取聊天签名失败:', error)
feedback.msgError(error.message || '获取聊天签名失败')
+22 -16
View File
@@ -642,22 +642,28 @@ const handleEdit = (row: any) => {
}
editRef.value?.open('edit', row.patient_id)
}
const handleChat = async (row: any) => {
if (!row.patient_id) {
feedback.msgWarning('患者信息不完整')
return
}
try {
const res = await getCallSignature({
patient_id: row.patient_id,
diagnosis_id: row.diagnosis_id || row.id
})
chatDialogRef.value?.open({
patientId: row.patient_id,
patientName: row.patient_name,
diagnosisId: row.diagnosis_id || row.id,
signatureData: res
})
const handleChat = async (row: any) => {
const sourcePatientId = Number(row.source_patient_id || 0)
const diagnosisId = Number(row.diagnosis_id || 0)
if (!sourcePatientId) {
feedback.msgWarning('患者信息不完整')
return
}
if (!diagnosisId) {
feedback.msgWarning('预约信息不完整,无法发起聊天')
return
}
try {
const res = await getCallSignature({
patient_id: sourcePatientId,
diagnosis_id: diagnosisId
})
chatDialogRef.value?.open({
patientId: sourcePatientId,
patientName: row.patient_name,
diagnosisId,
signatureData: res
})
} catch (e: any) {
feedback.msgError(e?.msg || e?.message || '获取通话签名失败')
}
@@ -262,9 +262,8 @@ const form = reactive({
channel_source_detail: '' as string
})
/** 仅这些渠道字典 name 需填「自媒体补充」(与后台字典名称完全一致) */
/** 自媒体4H/4Q 无需补充;仅这些渠道字典 name 需填「自媒体补充」(与后台字典名称完全一致) */
const CHANNEL_NAMES_REQUIRING_SELF_MEDIA_DETAIL = new Set([
'自媒体4H',
'自媒体3Q',
'自媒体3H',
'自媒体2H',
+31 -6
View File
@@ -1335,13 +1335,38 @@ const handleSubmit = async () => {
}
if (mode.value === 'add') {
await tcmDiagnosisAdd(submitData)
emit('success')
handleClose()
return
}
const result = await tcmDiagnosisAdd(submitData)
// 如果是新增,保存成功后切换到编辑模式,这样可以添加血糖血压记录
if (result && result.id) {
mode.value = 'edit'
phoneRevealUnlocked.value = false
idCardRevealUnlocked.value = false
formData.value.id = result.id
await tcmDiagnosisEdit(submitData)
// 重新获取详情,确保patient_id等字段正确
try {
const detail = await tcmDiagnosisDetail({ id: result.id })
formData.value.patient_id = detail.patient_id
// 更新原始数据
originalPhone.value = detail.phone || ''
originalIdCard.value = detail.id_card || ''
formData.value.phone = hasPhonePlainPermission.value
? detail.phone || ''
: maskPhone(detail.phone || '')
formData.value.id_card = hasPhonePlainPermission.value
? detail.id_card || ''
: maskIdCard(detail.id_card || '')
} catch (error) {
console.error('获取详情失败:', error)
}
}
} else {
await tcmDiagnosisEdit(submitData)
}
emit('success')
} catch (error) {
console.error('提交失败:', error)
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

@@ -0,0 +1,39 @@
# AiConsultDialog 诊单工作区最终验收
日期:2026-08-18
范围:诊单 `501`;复用 `tests/test_ai_consult_workspace_ui.py``WorkspaceRepository` 与即时异步执行方式;只读验收,未修改业务源码或测试。
## 结论
内容、控件数量、安全过滤与横向几何均通过。四个页签均有非空中文内容,没有出现 `['...']``{'...'}` 等 Python 集合 repr。默认窗口 `1280 × 820` 和最小窗口 `1080 × 680` 下,四页签横向滚动最大值均为 `0`,内容宽度等于视口宽度,未发现后代控件越界。
存在 1 类状态提示问题:测试仓储故意返回外诊单数据时,检查检验页和健康档案页已正确过滤这些数据,但把“已拒绝 1 条归属其他诊单的数据”呈现为红色 `error` 状态并显示“重新加载”。有效内容仍完整可见,安全边界也生效;建议后续改成 warning/info 提示,避免被误解为数据加载失败。
## 截图
- `01_case_records.png`:病历资料
- `02_exam_tests.png`:检查检验
- `03_prescriptions.png`:处方记录
- `04_health_profile.png`:健康档案
截图均为 `1280 × 820`,按真实应用启动路径调用全局主题,字体为 `Microsoft YaHei UI`
## 内容与控件核验
| 页签 | 核验结果 |
| --- | --- |
| 病历资料 | 文本 969 字符;`AiConsultCaseGrid` 1 个;中文集合已人类可读化;Python repr 标记 0 个。 |
| 检查检验 | 文本 173 字符;时间线 1 个;舌苔缩略图 1 个;附件按钮 3 个,其中 PDF 报告 2 个;安全 HTTP(S) 按钮可用,本地 `file:` 附件按钮禁用。 |
| 处方记录 | 文本 316 字符;处方卡 3 张;详情按钮 3 个;处方 ID 为 `5011 / 5012 / 5013`。 |
| 健康档案 | 文本 443 字符;患者信息网格 1 个;本诊单健康概览 1 个;血糖/血压、饮食、运动三类跟踪记录均存在;手机号和身份证号已脱敏。 |
## 几何
- `1280 × 820`:记录面板约 `876 × 586`;有纵向滚动条时视口/内容宽度均为 `824`,检查检验页为 `834`;所有页签 `hbar maximum = 0`、后代越界量 `0`
- `1080 × 680`:记录面板 `676 × 446`;有纵向滚动条时视口/内容宽度均为 `624`,检查检验页为 `634`;所有页签 `hbar maximum = 0`、后代越界量 `0`
- 长内容通过纵向滚动呈现:病历、处方、健康档案的默认窗口纵向最大值分别为 `1064 / 215 / 455`;未发现横向裁切。
## Smoke
执行:`uv run --offline pytest -q tests/test_ai_consult_workspace_ui.py`
结果:`11 passed`。仅有 pytest 缓存目录无写权限警告,不影响测试结果。
Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 586 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

+27 -2
View File
@@ -31,6 +31,8 @@ class _ScreenshotDiagnosisDialog(QWidget):
consultations_module.DiagnosisDialog = _ScreenshotDiagnosisDialog
DENSITY_SIZES = ((1366, 768), (1710, 920))
def _row(identifier: int, variant: int) -> dict[str, Any]:
common: dict[str, Any] = {
@@ -335,7 +337,7 @@ def _save_with_payment_qr(
return path
def render() -> list[Path]:
def _application() -> QApplication:
app = QApplication.instance() or QApplication([])
# The offscreen Windows plugin does not enumerate system fonts. Register
# the same CJK face used by the production QSS when it is available.
@@ -345,12 +347,35 @@ def render() -> list[Path]:
families = QFontDatabase.applicationFontFamilies(font_id)
if families:
app.setFont(QFont(families[0], 9))
return app
def render_density() -> list[Path]:
"""Render only the two desktop-density acceptance sizes."""
app = _application()
root = Path(__file__).resolve().parents[1]
output = root / "artifacts" / "diagnosis_visual"
output.mkdir(parents=True, exist_ok=True)
paths: list[Path] = []
repository = ScreenshotRepository()
for width, height in ((1024, 640), (1440, 900)):
for width, height in DENSITY_SIZES:
page = _new_page(app, repository, width, height)
path = output / f"diagnosis_{width}x{height}.png"
paths.append(_save(page, path))
page.close()
app.processEvents()
return paths
def render() -> list[Path]:
app = _application()
root = Path(__file__).resolve().parents[1]
output = root / "artifacts" / "diagnosis_visual"
output.mkdir(parents=True, exist_ok=True)
paths: list[Path] = []
repository = ScreenshotRepository()
for width, height in ((1024, 640), *DENSITY_SIZES, (1440, 900)):
page = _new_page(app, repository, width, height)
path = output / f"diagnosis_{width}x{height}.png"
paths.append(_save(page, path))
@@ -0,0 +1,119 @@
"""Render patient-level AI report layout regressions with the offscreen Qt backend."""
from __future__ import annotations
import os
from pathlib import Path
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtGui import QFontDatabase
from PySide6.QtWidgets import QApplication, QLabel, QScrollArea
from doctor_workstation.ui.pages.reception import _ReceptionAiAnalysisDialog
def _report_payload(model_key: str) -> dict[str, object]:
model_label = "OpenAI" if model_key == "openai" else "千问"
return {
"model_key": model_key,
"model_label": model_label,
"generated_at": "2026-08-17 10:20:00",
"diagnosis_advice": [
"2型糖尿病,HbA1c 7.5%,近期空腹血糖仍有波动。",
"建议:1. 监测空腹血糖 2. 记录餐后2小时血糖 3. 复核低血糖症状",
r"保留患者原始报告结构。\n结合复诊记录动态调整随访频率。",
],
"risk_assessment": [
{"label": "低血糖", "level": "high"},
{"label": "依从性风险", "level": "medium"},
{"label": "并发症筛查延误风险", "level": "low"},
{"label": "复诊中断风险", "level": "medium"},
{
"label": "肾功能变化可能影响二甲双胍方案,需要结合复查结果持续评估。",
"level": "high",
},
{"label": "饮食波动风险", "level": "low"},
],
"treatment_advice": [
r"二甲双胍 0.5g,每日2次。\n复查肾功能后再评估剂量。",
"继续糖尿病饮食教育,并记录运动后的血糖变化。",
"如出现心悸、出汗或意识异常,及时复测血糖并按流程处置。",
],
}
def _render(
app: QApplication,
output: Path,
*,
width: int,
height: int,
) -> None:
histories = {
"qwen": [_report_payload("qwen")],
"openai": [_report_payload("openai")],
}
dialog = _ReceptionAiAnalysisDialog(histories, preferred_model="qwen")
dialog.resize(width, height)
dialog.show()
for _index in range(3):
app.processEvents()
pixmap = dialog.grab()
if pixmap.width() != width or pixmap.height() != height:
raise RuntimeError(
f"unexpected render size: {pixmap.width()}x{pixmap.height()} "
f"(expected {width}x{height})"
)
output.parent.mkdir(parents=True, exist_ok=True)
if not pixmap.save(str(output), "PNG"):
raise RuntimeError(f"failed to save {output}")
scrolls = dialog.findChildren(QScrollArea)
risks = [
label
for label in dialog.findChildren(QLabel)
if label.property("dialogAiRisk")
]
print(
"PATIENT_AI_LAYOUT",
f"{width}x{height}",
f"scrolls={len(scrolls)}",
f"horizontal_max={dialog.scroll_area.horizontalScrollBar().maximum()}",
f"risk_rows={len({label.y() for label in risks})}",
f"body_height={dialog.scroll_area.widget().height()}",
)
print(output)
dialog.close()
app.processEvents()
def main() -> int:
app = QApplication.instance() or QApplication([])
font_path = Path(r"C:\Windows\Fonts\msyh.ttc")
if font_path.is_file():
QFontDatabase.addApplicationFont(str(font_path))
output_dir = (
Path(__file__).resolve().parents[1]
/ "artifacts"
/ "patient_ai_report_layout"
)
_render(
app,
output_dir / "patient_ai_report_920x760.png",
width=920,
height=760,
)
_render(
app,
output_dir / "patient_ai_report_720x560.png",
width=720,
height=560,
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,190 @@
"""Render patient and appointment density gates in the real desktop shell."""
from __future__ import annotations
import os
from datetime import date
from pathlib import Path
from typing import Any
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtCore import QThreadPool
from PySide6.QtGui import QFont, QFontDatabase
from PySide6.QtWidgets import QApplication
from doctor_workstation.services import DemoDoctorRepository
from doctor_workstation.ui import ShellWindow, apply_theme
def _drain(application: QApplication) -> None:
QThreadPool.globalInstance().waitForDone(5_000)
for _index in range(8):
application.processEvents()
def _patient_rows() -> list[dict[str, Any]]:
names = ("林晓岚", "赵明远", "吴诗雨", "周安然", "程静", "许清和")
rows: list[dict[str, Any]] = []
for index in range(15):
rows.append(
{
"id": 101 + index,
"diagnosis_id": 501 + index,
"patient_id": 301 + index,
"patient_name": names[index % len(names)],
"gender": 2 if index % 2 == 0 else 1,
"age": 34 + index,
"phone_masked": f"138****{1200 + index:04d}",
"assistant_id": 8,
"assistant_name": "周医助",
"appointment_id": 701 + index,
"appointment_status": 1,
"appointment_doctor_name": "陈医生",
"appointment_time_text": f"2026-08-{17 + index % 3:02d} {9 + index % 7:02d}:00",
"revisit_count": index % 4,
"confirmation_text": "已确认" if index % 2 == 0 else "待确认",
"diagnosis_date_text": "第 2 次复诊" if index % 3 else "初诊",
"has_id_card": index % 4 != 0,
}
)
return rows
def _appointment_rows() -> list[dict[str, Any]]:
names = ("林晓岚", "赵明远", "吴诗雨", "周安然", "程静", "许清和")
today = date.today().isoformat()
rows: list[dict[str, Any]] = []
for index in range(8):
rows.append(
{
"id": 801 + index,
"diagnosis_id": 901 + index,
"patient_id": 401 + index,
"source_patient_id": 401 + index,
"patient_name": names[index % len(names)],
"patient_phone": f"1380013{8000 + index}",
"gender": 2 if index % 2 == 0 else 1,
"age": 38 + index,
"doctor_name": "陈医生",
"assistant_id": 8,
"assistant_name": "周医助",
"appointment_date": today,
"appointment_time": f"{9 + index:02d}:00",
"channel_name": "线上复诊",
"diagnosis_confirmed": index % 2 == 0,
"has_prescription": index % 3 == 0,
"status": 1,
"status_desc": "已挂号",
"revisit_time": "复诊" if index % 2 else "初诊",
"unserved_days": index,
"video_call_hint": (
{"state": "live", "label": "视频通话进行中"}
if index == 0
else {"state": "idle", "label": "等待医生发起"}
),
}
)
return rows
def render() -> list[Path]:
application = QApplication.instance() or QApplication([])
apply_theme(application)
font_path = Path("C:/Windows/Fonts/msyh.ttc")
if font_path.is_file():
font_id = QFontDatabase.addApplicationFont(str(font_path))
families = QFontDatabase.applicationFontFamilies(font_id)
if families:
application.setFont(QFont(families[0], 9))
output = Path(__file__).resolve().parents[1] / "artifacts" / "patient_appointment_density"
output.mkdir(parents=True, exist_ok=True)
paths: list[Path] = []
for width, height in ((1366, 768), (1024, 640)):
repository = DemoDoctorRepository()
session = repository.login(repository.DEMO_ACCOUNT, repository.DEMO_PASSWORD)
shell = ShellWindow(
repository,
{"session": session, "demo_mode": True},
permissions=session.permissions,
)
shell.resize(width, height)
shell.show()
_drain(application)
if not shell.navigate("patients"):
raise RuntimeError("patients navigation is unavailable")
_drain(application)
patients = shell.pages["patients"]
patient_rows = _patient_rows()
patients.patient_workspace._apply_result(
{
"lists": patient_rows,
"count": len(patient_rows),
"extend": {
"scope": {"label": "当前医生与部门"},
"summary": {"today": 6, "tomorrow": 5, "day_after": 4},
},
},
patients.patient_workspace._generation,
)
application.processEvents()
patient_slots = patients.patient_workspace.table.viewport().height() // 40
if width == 1366 and patient_slots < 6:
raise RuntimeError(f"patient table only exposes {patient_slots} ordinary rows")
patient_path = output / f"patients_{width}x{height}.png"
if not shell.grab().save(str(patient_path), "PNG"):
raise RuntimeError(f"failed to save {patient_path}")
paths.append(patient_path)
if not shell.navigate("appointments"):
raise RuntimeError("appointments navigation is unavailable")
_drain(application)
appointments = shell.pages["appointments"]
appointments.poll_timer.stop()
appointment_rows = _appointment_rows()
appointments._loaded(
{
"lists": appointment_rows,
"count": len(appointment_rows),
"extend": {
"status_count": {"1": len(appointment_rows), "3": 0},
"unassigned_count": 0,
},
},
appointments._generation,
False,
)
application.processEvents()
row_heights = [
appointments.table.rowHeight(index)
for index in range(appointments.table.rowCount())
]
appointment_slots = appointments.table.viewport().height() // max(row_heights)
if width == 1366 and appointment_slots < 4:
raise RuntimeError(
f"appointment table only exposes {appointment_slots} ordinary rows"
)
if appointments.content_layout.count() != 1:
raise RuntimeError("appointment list still reserves a secondary side panel")
if appointments.table_card.width() != appointments.content_host.width():
raise RuntimeError("appointment table does not fill the content viewport")
appointment_path = output / f"appointments_{width}x{height}.png"
if not shell.grab().save(str(appointment_path), "PNG"):
raise RuntimeError(f"failed to save {appointment_path}")
paths.append(appointment_path)
print(
f"{width}x{height}: patient_slots={patient_slots}, "
f"appointment_slots={appointment_slots}, table_full_width=True"
)
shell.close()
application.processEvents()
return paths
if __name__ == "__main__":
for rendered in render():
print(rendered)
@@ -0,0 +1,180 @@
"""Render deterministic prescription-list density acceptance screenshots."""
from __future__ import annotations
import os
from pathlib import Path
from types import SimpleNamespace
from typing import Any
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtGui import QFont, QFontDatabase
from PySide6.QtWidgets import QApplication
from doctor_workstation.ui.pages import prescription_library as library_module
from doctor_workstation.ui.pages import prescriptions as prescriptions_module
from doctor_workstation.ui.pages.prescription_library import PrescriptionLibraryPage
from doctor_workstation.ui.pages.prescriptions import PrescriptionsPage
from doctor_workstation.ui.theme import apply_theme
DENSITY_SIZES = ((1366, 768), (1710, 920))
def _run_immediately(
function: Any,
*args: Any,
on_success: Any = None,
on_error: Any = None,
on_finished: Any = None,
**kwargs: Any,
) -> object:
try:
result = function(*args, **kwargs)
except Exception as error:
if on_error is not None:
on_error(error)
else:
if on_success is not None:
on_success(result)
finally:
if on_finished is not None:
on_finished()
return object()
prescriptions_module.run_async = _run_immediately
library_module.run_async = _run_immediately
def _issued_row(index: int) -> dict[str, Any]:
return {
"id": 1000 + index,
"sn": f"CF-202608-{1000 + index}",
"prescription_type": "汤剂",
"is_system_auto": index % 2,
"patient_name": ("林晓岚", "周明远", "许安然")[index % 3],
"gender": 2 if index % 2 else 1,
"age": 29 + index,
"audit_status": index % 3,
"void_status": 0,
"has_prescription_order": index % 2,
"creator_id": 7,
"doctor_name": "陈医生",
"assistant_name": "赵医助",
"create_time": f"2026-08-{(index % 9) + 10:02d} 09:30:00",
"herbs": [{"name": "黄芪", "dosage": 15}],
}
def _library_row(index: int) -> dict[str, Any]:
return {
"id": 2000 + index,
"prescription_name": ("益气养阴方", "清热祛湿方", "滋阴调和方")[index % 3],
"formula_type": "主方" if index % 3 else "辅方",
"herbs": [
{"name": "黄芪", "dosage": 15},
{"name": "党参", "dosage": 12},
],
"efficacy": ("益气养阴", "清热祛湿", "滋阴补肾")[index % 3],
"is_public": index % 2,
"disable_edit": 0,
"creator_id": 7,
"creator_name": "陈医生",
"create_time": f"2026-08-{(index % 9) + 10:02d} 08:20:00",
}
class ScreenshotRepository:
def __init__(self) -> None:
self.issued_rows = [_issued_row(index) for index in range(15)]
self.library_rows = [_library_row(index) for index in range(15)]
def list_diagnosis_doctors(self) -> list[dict[str, Any]]:
return [{"id": 7, "name": "陈医生"}, {"id": 8, "name": "孙医生"}]
def list_prescriptions(self, **_filters: Any) -> dict[str, Any]:
return {"lists": self.issued_rows, "count": 44}
def list_prescription_templates(self, **_filters: Any) -> dict[str, Any]:
return {"lists": self.library_rows, "count": 41}
def _application() -> QApplication:
app = QApplication.instance() or QApplication([])
font_path = Path("C:/Windows/Fonts/msyh.ttc")
if font_path.is_file():
font_id = QFontDatabase.addApplicationFont(str(font_path))
families = QFontDatabase.applicationFontFamilies(font_id)
if families:
app.setFont(QFont(families[0], 9))
apply_theme(app)
return app
def _settle(app: QApplication) -> None:
for _ in range(6):
app.processEvents()
def _visible_rows(page: PrescriptionsPage | PrescriptionLibraryPage) -> int:
viewport = page.table.viewport()
return sum(
1
for row in range(page.table.rowCount())
if (
(item := page.table.item(row, 0)) is not None
and (rect := page.table.visualItemRect(item)).isValid()
and rect.top() >= 0
and rect.bottom() < viewport.height()
)
)
def _new_page(
kind: str,
repository: ScreenshotRepository,
) -> PrescriptionsPage | PrescriptionLibraryPage:
current_user = SimpleNamespace(id=7, name="陈医生", root=1, role_ids=[0])
if kind == "prescriptions":
page: PrescriptionsPage | PrescriptionLibraryPage = PrescriptionsPage(
repository, {"*"}, current_user
)
else:
page = PrescriptionLibraryPage(repository, {"*"}, current_user)
page.refresh()
return page
def render() -> list[Path]:
app = _application()
root = Path(__file__).resolve().parents[1]
output = root / "artifacts" / "prescription_list_density"
output.mkdir(parents=True, exist_ok=True)
repository = ScreenshotRepository()
paths: list[Path] = []
for kind in ("prescriptions", "prescription_library"):
for width, height in DENSITY_SIZES:
page = _new_page(kind, repository)
page.resize(width, height)
page.show()
_settle(app)
minimum_rows = 6 if height == 768 else 9
visible_rows = _visible_rows(page)
if visible_rows < minimum_rows:
raise RuntimeError(
f"{kind} at {width}x{height} exposes only {visible_rows} full rows"
)
path = output / f"{kind}_{width}x{height}.png"
if not page.grab().save(str(path), "PNG"):
raise RuntimeError(f"failed to save {path}")
paths.append(path)
page.close()
_settle(app)
return paths
if __name__ == "__main__":
for rendered in render():
print(rendered)
@@ -0,0 +1,69 @@
"""Render the reception daily-record matrix for visual acceptance."""
from __future__ import annotations
import os
from pathlib import Path
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtCore import QThreadPool
from PySide6.QtGui import QFontDatabase
from PySide6.QtWidgets import QApplication
from doctor_workstation.services.mock_repository import DemoDoctorRepository
from doctor_workstation.ui.shell import ShellWindow
from doctor_workstation.ui.theme import apply_theme
def main() -> int:
application = QApplication.instance() or QApplication([])
apply_theme(application)
font_path = Path(r"C:\Windows\Fonts\msyh.ttc")
if font_path.is_file():
QFontDatabase.addApplicationFont(str(font_path))
repository = DemoDoctorRepository()
session = repository.login("doctor", "doctor123")
window = ShellWindow(repository, session)
window.resize(1710, 920)
window.show()
if not window.navigate("reception"):
raise RuntimeError("reception navigation is unavailable")
for _index in range(5):
application.processEvents()
QThreadPool.globalInstance().waitForDone(10_000)
page = window.pages["reception"]
daily_index = next(
index
for index in range(page.detail_tabs.count())
if page.detail_tabs.tabText(index) == "日常记录"
)
page.detail_tabs.setCurrentIndex(daily_index)
for _index in range(3):
application.processEvents()
QThreadPool.globalInstance().waitForDone(10_000)
output = (
Path(__file__).resolve().parents[1]
/ "artifacts"
/ "reception_daily_records"
/ "reception_daily_records_1710x920.png"
)
output.parent.mkdir(parents=True, exist_ok=True)
if not window.grab().save(str(output), "PNG"):
raise RuntimeError(f"failed to save {output}")
print(output)
print(
"DAILY_MATRIX",
page.daily_panel.matrix.rowCount(),
page.daily_panel.matrix.columnCount(),
page.daily_panel.current_range(),
)
window.close()
return 0
if __name__ == "__main__":
raise SystemExit(main())
+68 -19
View File
@@ -138,20 +138,20 @@ class DemoVideoDialog(QDialog):
self.setWindowTitle("视频面诊 · 演示模式")
self.setMinimumSize(760, 520)
self.resize(980, 660)
self.setModal(False)
self.setStyleSheet(
"QDialog{background:#F7F9FE;color:#111F46;}"
"QLabel{color:#111F46;}"
"QFrame#RemoteStage{background:#0E1421;border:1px solid #29334F;border-radius:16px;}"
"QFrame#RemoteStage QLabel{color:#F7F9FE;}"
"QFrame#LocalStage{background:#151D31;border:1px solid #3F4E75;border-radius:12px;}"
"QPushButton{min-width:96px;min-height:40px;padding:0 16px;border-radius:9px;"
"background:#FFFFFF;color:#3F4E75;border:1px solid #E6EAF5;font-weight:600;}"
"QPushButton:hover{color:#4451E2;background:#F0F2FF;border-color:#5761F4;}"
"QPushButton:checked{color:#FFFFFF;background:#5761F4;border-color:#5761F4;}"
"QPushButton#Hangup{color:#FFFFFF;background:#F15B67;border-color:#F15B67;}"
"QPushButton#Hangup:hover{background:#D94857;border-color:#D94857;}"
)
self.setModal(False)
self.setStyleSheet(
"QDialog{background:#F7F9FE;color:#111F46;}"
"QLabel{color:#111F46;}"
"QFrame#RemoteStage{background:#0E1421;border:1px solid #29334F;border-radius:16px;}"
"QFrame#RemoteStage QLabel{color:#F7F9FE;}"
"QFrame#LocalStage{background:#151D31;border:1px solid #3F4E75;border-radius:12px;}"
"QPushButton{min-width:96px;min-height:40px;padding:0 16px;border-radius:9px;"
"background:#FFFFFF;color:#3F4E75;border:1px solid #E6EAF5;font-weight:600;}"
"QPushButton:hover{color:#4451E2;background:#F0F2FF;border-color:#5761F4;}"
"QPushButton:checked{color:#FFFFFF;background:#5761F4;border-color:#5761F4;}"
"QPushButton#Hangup{color:#FFFFFF;background:#F15B67;border-color:#F15B67;}"
"QPushButton#Hangup:hover{background:#D94857;border-color:#D94857;}"
)
root = QVBoxLayout(self)
root.setContentsMargins(22, 18, 22, 22)
@@ -162,7 +162,7 @@ class DemoVideoDialog(QDialog):
header.addWidget(title)
header.addStretch(1)
demo = QLabel("● 演示模式 · 未连接腾讯云")
demo.setStyleSheet("color:#7886AA;font-size:12px;")
demo.setStyleSheet("color:#7886AA;font-size:12px;")
header.addWidget(demo)
self.duration_label = QLabel("00:00")
self.duration_label.setStyleSheet("font-weight:700;")
@@ -177,8 +177,8 @@ class DemoVideoDialog(QDialog):
avatar = QLabel((patient_name or "")[:1])
avatar.setAlignment(Qt.AlignmentFlag.AlignCenter)
avatar.setFixedSize(104, 104)
avatar.setStyleSheet(
"background:#F0F2FF;color:#5761F4;border-radius:52px;font-size:42px;font-weight:700;"
avatar.setStyleSheet(
"background:#F0F2FF;color:#5761F4;border-radius:52px;font-size:42px;font-weight:700;"
)
stage_layout.addWidget(avatar, 0, Qt.AlignmentFlag.AlignHCenter)
waiting = QLabel("等待患者接听…")
@@ -187,7 +187,7 @@ class DemoVideoDialog(QDialog):
stage_layout.addWidget(waiting)
hint = QLabel("生产模式将通过后端短时 UserSig 初始化腾讯 TUICallKit")
hint.setAlignment(Qt.AlignmentFlag.AlignCenter)
hint.setStyleSheet("color:#A4ADC3;font-size:12px;")
hint.setStyleSheet("color:#A4ADC3;font-size:12px;")
stage_layout.addWidget(hint)
stage_layout.addStretch(1)
@@ -197,7 +197,7 @@ class DemoVideoDialog(QDialog):
local_layout = QVBoxLayout(local)
local_label = QLabel("医生画面")
local_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
local_label.setStyleSheet("color:#D9E0F2;font-weight:600;")
local_label.setStyleSheet("color:#D9E0F2;font-weight:600;")
local_layout.addWidget(local_label)
root.addWidget(stage, 1)
@@ -788,6 +788,53 @@ class ApplicationController(QObject):
self.remote_repository.client.close()
def _prefer_native_tls_backend() -> None:
"""Keep Qt's HTTPS stack off OpenSSL.
PySide6 on Windows ships no OpenSSL DLLs of its own, so Qt's OpenSSL
TLS backend resolves to CPython's ``libcrypto-3-x64.dll`` and ends up
sharing one OpenSSL instance with the httpx stack. Concurrent use from
both stacks has crashed the process inside libcrypto (access violation
at a stable offset) while pages downloaded images through
``QNetworkAccessManager``. Route Qt network requests through the
native Schannel backend instead, which uses the Windows certificate
store and never touches OpenSSL.
"""
with suppress(Exception):
from PySide6.QtNetwork import QSslSocket
backends = QSslSocket.availableBackends()
if "schannel" in backends and "openssl" in backends:
QSslSocket.setActiveBackend("schannel")
_CRASH_LOG_HANDLE: Any = None
def _install_crash_handler(config: AppConfig) -> None:
"""Write per-thread Python tracebacks of native crashes into the log dir.
Debug launchers already pass ``-X faulthandler`` and dump to stderr;
keep that behavior and only redirect into ``crash.log`` for packaged
or plain runs where stderr is lost.
"""
global _CRASH_LOG_HANDLE
try:
import faulthandler
if faulthandler.is_enabled():
return
path = config.log_dir / "crash.log"
handle = path.open("a", encoding="utf-8", buffering=1)
handle.write(f"\n=== process started {time.strftime('%Y-%m-%d %H:%M:%S')} ===\n")
faulthandler.enable(file=handle, all_threads=True)
_CRASH_LOG_HANDLE = handle
except Exception:
LOGGER.exception("crash handler could not be installed")
def _create_application(argv: list[str]) -> QApplication:
with suppress(AttributeError):
QGuiApplication.setHighDpiScaleFactorRoundingPolicy(
@@ -796,6 +843,7 @@ def _create_application(argv: list[str]) -> QApplication:
with suppress(AttributeError):
QApplication.setAttribute(Qt.ApplicationAttribute.AA_DontCreateNativeWidgetSiblings, True)
application = QApplication(argv)
_prefer_native_tls_backend()
_install_chinese_translations(application)
application.setApplicationName("甄养堂医生工作站")
application.setApplicationDisplayName("甄养堂医生工作站")
@@ -814,6 +862,7 @@ def main(argv: list[str] | None = None) -> int:
config = AppConfig.load()
configure_logging(config.log_dir, config.log_level)
_install_crash_handler(config)
LOGGER.info("doctor workstation starting", extra={"demo_mode": config.demo_mode})
raw_argv = list(sys.argv if argv is None else argv)
smoke_test = "--smoke-test" in raw_argv
+258 -39
View File
@@ -2,9 +2,11 @@
from __future__ import annotations
import json
import time
from collections.abc import Callable, Mapping
from threading import RLock
from collections.abc import Callable, Iterator, Mapping
from contextlib import contextmanager
from threading import Condition, RLock
from typing import Any
from urllib.parse import urljoin, urlsplit, urlunsplit
@@ -32,6 +34,7 @@ class ApiClient:
"""
API_VERSION = "1.9.4"
DEFAULT_MAX_PARALLEL_REQUESTS = 8
def __init__(
self,
@@ -45,6 +48,7 @@ class ApiClient:
transport: httpx.BaseTransport | None = None,
client: httpx.Client | None = None,
sleep: Callable[[float], None] = time.sleep,
max_parallel_requests: int = DEFAULT_MAX_PARALLEL_REQUESTS,
) -> None:
"""Create a client without performing any network requests."""
@@ -54,6 +58,8 @@ class ApiClient:
raise ValueError("retry_backoff must be non-negative")
if client is not None and transport is not None:
raise ValueError("pass either client or transport, not both")
if max_parallel_requests <= 0:
raise ValueError("max_parallel_requests must be positive")
self.base_url = self.normalise_base_url(base_url)
self.timeout = timeout
self.max_retries = max_retries
@@ -61,8 +67,68 @@ class ApiClient:
self._sleep = sleep
self._token = token.strip()
self._lock = RLock()
self._transport_lock = RLock()
self._client_condition = Condition(RLock())
self._available_clients: list[httpx.Client] = []
self._pooled_clients: list[httpx.Client] = []
self._active_requests = 0
self._closing = False
self._closed = False
self._max_parallel_requests = max_parallel_requests
# Production requests use a bounded pool of independent clients. This
# permits unrelated patient requests to run concurrently without
# sharing the SSLContext that previously caused native TLS crashes.
# A pool also works across PySide QRunnable boundaries, where Python
# thread-local state is not retained reliably.
# Explicit clients/transports remain on one locked client because
# their ownership and thread-safety contracts are unknown.
self._uses_client_pool = client is None and transport is None
self._owns_client = client is None
self._client = client or httpx.Client(transport=transport, verify=verify)
self._stream_transport = transport
self._verify = verify
self._client = (
None
if self._uses_client_pool
else client or httpx.Client(transport=transport, verify=verify)
)
@contextmanager
def _borrow_client(self) -> Iterator[httpx.Client]:
"""Yield a safe synchronous client for the calling worker thread."""
if not self._uses_client_pool:
with self._transport_lock:
with self._client_condition:
if self._closing or self._closed:
raise RuntimeError("ApiClient is closed")
if self._client is None: # Defensive; this mode always has one.
raise RuntimeError("ApiClient transport is unavailable")
yield self._client
return
with self._client_condition:
while True:
if self._closing or self._closed:
raise RuntimeError("ApiClient is closed")
if self._available_clients:
pooled_client = self._available_clients.pop()
break
if len(self._pooled_clients) < self._max_parallel_requests:
pooled_client = httpx.Client(verify=self._verify)
self._pooled_clients.append(pooled_client)
break
self._client_condition.wait()
self._active_requests += 1
try:
yield pooled_client
finally:
with self._client_condition:
self._active_requests -= 1
self._available_clients.append(pooled_client)
if self._active_requests == 0:
self._client_condition.notify_all()
else:
self._client_condition.notify()
@staticmethod
def normalise_base_url(base_url: str) -> str:
@@ -162,6 +228,64 @@ class ApiClient:
headers=headers,
)
def post_event_stream(
self,
endpoint: str,
payload: Mapping[str, Any],
*,
timeout: float | httpx.Timeout | None = None,
cancelled: Callable[[], bool] | None = None,
) -> Iterator[dict[str, Any]]:
"""POST JSON and yield parsed server-sent events on an isolated client.
Streaming deliberately does not use the bounded short-request client
pool. A diagnosis response can remain open for more than a minute and
must not consume capacity needed by unrelated page requests.
"""
url = self._endpoint_url(endpoint)
headers = self._headers({"Accept": "text/event-stream"})
request_timeout = self.timeout if timeout is None else timeout
try:
with httpx.Client(
transport=self._stream_transport,
verify=self._verify,
) as stream_client, stream_client.stream(
"POST",
url,
json=dict(payload),
headers=headers,
timeout=request_timeout,
) as response:
request_id = self._request_id(response)
if not 200 <= response.status_code < 300:
raise ApiHttpError(
f"API returned HTTP {response.status_code}",
status_code=response.status_code,
request_id=request_id,
)
content_type = response.headers.get("content-type", "").lower()
if "text/event-stream" not in content_type:
response.read()
if "json" in content_type:
self._unwrap(response)
raise ApiProtocolError(
"API response is not an event stream",
status_code=response.status_code,
request_id=request_id,
)
yield from _iter_event_stream(response.iter_lines(), cancelled=cancelled)
except httpx.TimeoutException as exc:
raise ApiTimeoutError(
f"POST {endpoint} stream timed out",
data={"method": "POST", "endpoint": endpoint},
) from exc
except httpx.RequestError as exc:
raise ApiTransportError(
f"POST {endpoint} stream failed: {exc}",
data={"method": "POST", "endpoint": endpoint},
) from exc
def get_bytes(self, url: str, *, max_bytes: int = 5 * 1024 * 1024) -> bytes:
"""Download a public binary asset without applying the JSON envelope contract."""
@@ -175,12 +299,13 @@ class ApiClient:
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
raise ValueError("url must resolve to an absolute http(s) URL")
try:
response = self._client.get(
target,
headers={"Accept": "image/*,application/octet-stream;q=0.8"},
timeout=self.timeout,
follow_redirects=True,
)
with self._borrow_client() as client:
response = client.get(
target,
headers={"Accept": "image/*,application/octet-stream;q=0.8"},
timeout=self.timeout,
follow_redirects=True,
)
except httpx.TimeoutException as exc:
raise ApiTimeoutError("Image download timed out", data={"url": target}) from exc
except httpx.RequestError as exc:
@@ -253,34 +378,35 @@ class ApiClient:
attempts = self.max_retries + 1 if verb == "GET" else 1
response: httpx.Response | None = None
request_timeout = self.timeout if timeout is None else timeout
for attempt in range(attempts):
try:
response = self._client.request(
verb,
url,
params=dict(params) if params is not None else None,
json=dict(json) if verb == "POST" and json is not None else None,
data=dict(data) if verb == "POST" and data is not None else None,
files=dict(files) if files is not None else None,
headers=request_headers,
timeout=request_timeout,
)
break
except httpx.TimeoutException as exc:
if attempt + 1 < attempts:
delay = self.retry_backoff * (2**attempt)
if delay:
self._sleep(delay)
continue
raise ApiTimeoutError(
f"{verb} {endpoint} timed out after {attempt + 1} attempt(s)",
data={"method": verb, "endpoint": endpoint, "attempts": attempt + 1},
) from exc
except httpx.RequestError as exc:
raise ApiTransportError(
f"{verb} {endpoint} failed: {exc}",
data={"method": verb, "endpoint": endpoint},
) from exc
with self._borrow_client() as client:
for attempt in range(attempts):
try:
response = client.request(
verb,
url,
params=dict(params) if params is not None else None,
json=dict(json) if verb == "POST" and json is not None else None,
data=dict(data) if verb == "POST" and data is not None else None,
files=dict(files) if files is not None else None,
headers=request_headers,
timeout=request_timeout,
)
break
except httpx.TimeoutException as exc:
if attempt + 1 < attempts:
delay = self.retry_backoff * (2**attempt)
if delay:
self._sleep(delay)
continue
raise ApiTimeoutError(
f"{verb} {endpoint} timed out after {attempt + 1} attempt(s)",
data={"method": verb, "endpoint": endpoint, "attempts": attempt + 1},
) from exc
except httpx.RequestError as exc:
raise ApiTransportError(
f"{verb} {endpoint} failed: {exc}",
data={"method": verb, "endpoint": endpoint},
) from exc
if response is None: # Defensive; the loop always returns or raises.
raise ApiTransportError(f"{verb} {endpoint} produced no response")
return self._unwrap(response)
@@ -288,8 +414,42 @@ class ApiClient:
def close(self) -> None:
"""Close the internally-created HTTP transport."""
if self._owns_client:
self._client.close()
with self._client_condition:
if self._closed:
return
if self._closing:
while not self._closed:
self._client_condition.wait()
return
self._closing = True
self._client_condition.notify_all()
clients: list[httpx.Client] = []
if self._uses_client_pool:
with self._client_condition:
while self._active_requests:
self._client_condition.wait()
clients = list(self._pooled_clients)
self._available_clients.clear()
self._pooled_clients.clear()
else:
with self._transport_lock:
if self._owns_client and self._client is not None:
clients = [self._client]
close_error: Exception | None = None
for client in clients:
try:
client.close()
except Exception as error: # Close every transport before surfacing one failure.
if close_error is None:
close_error = error
with self._client_condition:
self._closed = True
self._closing = False
self._client_condition.notify_all()
if close_error is not None:
raise close_error
def __enter__(self) -> ApiClient:
"""Return this client for use as a context manager."""
@@ -414,3 +574,62 @@ class ApiClient:
def _mapping(value: object) -> Mapping[str, Any]:
return value if isinstance(value, Mapping) else {}
def _iter_event_stream(
lines: Iterator[str],
*,
cancelled: Callable[[], bool] | None = None,
) -> Iterator[dict[str, Any]]:
"""Parse SSE fields, including multi-line data and a final unterminated event."""
event_name = ""
event_id = ""
data_lines: list[str] = []
def build_event() -> dict[str, Any] | None:
nonlocal event_name, event_id, data_lines
if not event_name and not data_lines:
event_id = ""
return None
raw_data = "\n".join(data_lines)
try:
data: Any = json.loads(raw_data) if raw_data else {}
except (TypeError, ValueError):
data = raw_data
inferred = data.get("event") or data.get("type") if isinstance(data, Mapping) else ""
kind = event_name or str(inferred or "message")
if raw_data.strip() == "[DONE]":
kind, data = "done", {}
event = {"event": kind, "data": data}
if event_id:
event["id"] = event_id
event_name = ""
event_id = ""
data_lines = []
return event
for raw_line in lines:
if cancelled is not None and cancelled():
return
line = raw_line.lstrip("\ufeff")
if not line:
event = build_event()
if event is not None:
yield event
continue
if line.startswith(":"):
continue
field, separator, value = line.partition(":")
if separator and value.startswith(" "):
value = value[1:]
if field == "event":
event_name = value
elif field == "data":
data_lines.append(value)
elif field == "id":
event_id = value
if cancelled is None or not cancelled():
event = build_event()
if event is not None:
yield event
@@ -0,0 +1,602 @@
"""Persistent local-audio capture and COS upload queue.
Local call recordings are medical records. They must survive application
restarts and failed network requests, so the queue deliberately keeps the
audio file after a successful upload as well as after a failed one.
"""
from __future__ import annotations
import queue
import sqlite3
import threading
from collections.abc import Callable
from concurrent.futures import Future
from contextlib import suppress
from dataclasses import dataclass
from datetime import UTC, datetime
from os import PathLike
from pathlib import Path
from typing import Any
from uuid import uuid4
from platformdirs import user_data_path
_MAX_UPLOAD_WORKERS = 3
_RETRYABLE_STATUSES = {"pending", "failed"}
_RECOVERY_LOCK = threading.Lock()
_RECOVERED_DATABASES: set[Path] = set()
_MANAGER_LOCK = threading.Lock()
_MANAGERS: dict[tuple[int, Path], LocalAudioUploadManager] = {}
def _utc_now() -> str:
return datetime.now(UTC).isoformat(timespec="seconds")
def _clean_room_id(value: Any) -> str:
room_id = str(value or "").strip()
return "" if room_id == "0" else room_id
def _clean_call_record_id(value: Any, *, required: bool) -> int | None:
if value in (None, "") and not required:
return None
try:
call_record_id = int(value)
except (TypeError, ValueError) as error:
raise ValueError("通话记录 ID 无效。") from error
if call_record_id <= 0:
raise ValueError("通话记录 ID 无效。")
return call_record_id
def _default_root() -> Path:
return (
user_data_path(
"ZhenyangDoctorWorkstation",
appauthor="ZhenYangTang",
ensure_exists=True,
)
/ "local-call-audio"
)
@dataclass(frozen=True, slots=True)
class LocalAudioRecord:
id: int
session_id: str
diagnosis_id: int
call_record_id: int | None
room_id: str
mime_type: str
file_path: Path
size_bytes: int
status: str
error_text: str
uploaded_url: str
attempts: int
created_at: str
updated_at: str
uploaded_at: str
@property
def exists(self) -> bool:
return self.file_path.is_file()
class LocalAudioQueueStore:
"""SQLite-backed manifest for durable local call recordings."""
def __init__(self, root: str | PathLike[str] | None = None) -> None:
self.root = Path(root).expanduser().resolve() if root else _default_root().resolve()
self.files_dir = self.root / "files"
self.files_dir.mkdir(parents=True, exist_ok=True)
self.database_path = self.root / "queue.sqlite3"
self._initialize()
def _connect(self) -> sqlite3.Connection:
connection = sqlite3.connect(self.database_path, timeout=10.0)
connection.row_factory = sqlite3.Row
connection.execute("PRAGMA busy_timeout = 10000")
return connection
def _initialize(self) -> None:
with self._connect() as connection:
connection.execute("PRAGMA journal_mode = WAL")
# Serialise schema inspection and ALTER TABLE across two workstation
# processes. The second process waits, then re-checks the columns.
connection.execute("BEGIN IMMEDIATE")
connection.execute(
"""
CREATE TABLE IF NOT EXISTS local_audio_uploads (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL UNIQUE,
diagnosis_id INTEGER NOT NULL,
call_record_id INTEGER,
room_id TEXT NOT NULL DEFAULT '',
mime_type TEXT NOT NULL,
file_path TEXT NOT NULL,
size_bytes INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL,
error_text TEXT NOT NULL DEFAULT '',
uploaded_url TEXT NOT NULL DEFAULT '',
attempts INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
uploaded_at TEXT NOT NULL DEFAULT ''
)
"""
)
columns = {
str(row["name"])
for row in connection.execute(
"PRAGMA table_info(local_audio_uploads)"
).fetchall()
}
if "room_id" not in columns:
connection.execute(
"ALTER TABLE local_audio_uploads "
"ADD COLUMN room_id TEXT NOT NULL DEFAULT ''"
)
connection.execute(
"CREATE INDEX IF NOT EXISTS idx_local_audio_diagnosis "
"ON local_audio_uploads(diagnosis_id, id DESC)"
)
# Recover interrupted work once per process. Creating the management
# dialog while a worker is active must not move that worker back to
# ``pending`` and accidentally upload the same medical file twice.
with _RECOVERY_LOCK:
if self.database_path in _RECOVERED_DATABASES:
return
with self._connect() as connection:
now = _utc_now()
connection.execute(
"""
UPDATE local_audio_uploads
SET status = 'pending',
error_text = '应用上次退出时上传尚未完成,可重试。',
updated_at = ?
WHERE status = 'uploading'
""",
(now,),
)
connection.execute(
"""
UPDATE local_audio_uploads
SET status = 'invalid',
error_text = '应用上次退出时录音尚未完成。',
updated_at = ?
WHERE status = 'recording'
""",
(now,),
)
_RECOVERED_DATABASES.add(self.database_path)
@staticmethod
def _from_row(row: sqlite3.Row) -> LocalAudioRecord:
raw_call_record_id = row["call_record_id"]
return LocalAudioRecord(
id=int(row["id"]),
session_id=str(row["session_id"]),
diagnosis_id=int(row["diagnosis_id"]),
call_record_id=(
int(raw_call_record_id) if raw_call_record_id not in (None, "") else None
),
room_id=_clean_room_id(row["room_id"]),
mime_type=str(row["mime_type"]),
file_path=Path(str(row["file_path"])).resolve(),
size_bytes=int(row["size_bytes"] or 0),
status=str(row["status"]),
error_text=str(row["error_text"] or ""),
uploaded_url=str(row["uploaded_url"] or ""),
attempts=int(row["attempts"] or 0),
created_at=str(row["created_at"]),
updated_at=str(row["updated_at"]),
uploaded_at=str(row["uploaded_at"] or ""),
)
def begin_recording(
self,
*,
session_id: str,
diagnosis_id: int,
mime_type: str,
call_record_id: int | None = None,
room_id: str = "",
) -> LocalAudioRecord:
suffix = ".ogg" if mime_type.startswith("audio/ogg") else ".webm"
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
name = f"call-{int(diagnosis_id)}-{timestamp}-{uuid4().hex[:10]}{suffix}.part"
path = (self.files_dir / name).resolve()
now = _utc_now()
clean_call_record_id = _clean_call_record_id(call_record_id, required=False)
clean_room_id = _clean_room_id(room_id)
with self._connect() as connection:
cursor = connection.execute(
"""
INSERT INTO local_audio_uploads (
session_id, diagnosis_id, call_record_id, room_id,
mime_type, file_path,
status, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, 'recording', ?, ?)
""",
(
session_id,
int(diagnosis_id),
clean_call_record_id,
clean_room_id,
mime_type,
str(path),
now,
now,
),
)
record_id = int(cursor.lastrowid)
record = self.get(record_id)
if record is None: # pragma: no cover - SQLite insert/read invariant.
raise RuntimeError("无法创建本地录音记录。")
return record
def finalize_recording(self, record_id: int, *, size_bytes: int) -> LocalAudioRecord:
record = self.require(record_id)
if record.status != "recording":
raise RuntimeError("本地录音记录状态不允许完成。")
if record.file_path.suffix == ".part":
final_path = record.file_path.with_suffix("")
record.file_path.replace(final_path)
else:
final_path = record.file_path
now = _utc_now()
with self._connect() as connection:
connection.execute(
"""
UPDATE local_audio_uploads
SET file_path = ?, size_bytes = ?, status = 'pending',
error_text = '', updated_at = ?
WHERE id = ?
""",
(str(final_path), int(size_bytes), now, int(record_id)),
)
return self.require(record_id)
def mark_invalid(self, record_id: int, message: str) -> LocalAudioRecord:
record = self.require(record_id)
size = record.file_path.stat().st_size if record.file_path.is_file() else 0
now = _utc_now()
with self._connect() as connection:
connection.execute(
"""
UPDATE local_audio_uploads
SET size_bytes = ?, status = 'invalid', error_text = ?, updated_at = ?
WHERE id = ?
""",
(size, str(message)[:1000], now, int(record_id)),
)
return self.require(record_id)
def bind_identity(
self,
record_id: int,
*,
call_record_id: int,
room_id: str = "",
) -> LocalAudioRecord:
"""Bind one recording to a call without overwriting a different identity."""
clean_call_record_id = _clean_call_record_id(call_record_id, required=True)
assert clean_call_record_id is not None
clean_room_id = _clean_room_id(room_id)
now = _utc_now()
with self._connect() as connection:
cursor = connection.execute(
"""
UPDATE local_audio_uploads
SET call_record_id = CASE
WHEN call_record_id IS NULL THEN ?
ELSE call_record_id
END,
room_id = CASE
WHEN room_id = '' AND ? <> '' THEN ?
ELSE room_id
END,
updated_at = ?
WHERE id = ?
AND (call_record_id IS NULL OR call_record_id = ?)
AND (? = '' OR room_id = '' OR room_id = ?)
""",
(
clean_call_record_id,
clean_room_id,
clean_room_id,
now,
int(record_id),
clean_call_record_id,
clean_room_id,
clean_room_id,
),
)
if cursor.rowcount != 1:
exists = connection.execute(
"SELECT 1 FROM local_audio_uploads WHERE id = ?",
(int(record_id),),
).fetchone()
if exists is None:
raise LookupError("本地录音记录不存在。")
raise RuntimeError("本地录音的通话记录 ID 或房间号发生冲突。")
return self.require(record_id)
def set_call_record_id(self, record_id: int, call_record_id: int) -> None:
self.bind_identity(record_id, call_record_id=call_record_id)
def update_status(self, record_id: int, status: str, message: str = "") -> None:
if status not in {"pending", "uploading", "uploaded", "failed", "invalid"}:
raise ValueError("unsupported local-audio status")
now = _utc_now()
with self._connect() as connection:
connection.execute(
"""
UPDATE local_audio_uploads
SET status = ?, error_text = ?, updated_at = ?
WHERE id = ?
""",
(status, str(message)[:1000], now, int(record_id)),
)
def get(self, record_id: int) -> LocalAudioRecord | None:
with self._connect() as connection:
row = connection.execute(
"SELECT * FROM local_audio_uploads WHERE id = ?",
(int(record_id),),
).fetchone()
return self._from_row(row) if row is not None else None
def require(self, record_id: int) -> LocalAudioRecord:
record = self.get(record_id)
if record is None:
raise LookupError("本地录音记录不存在。")
return record
def list_records(self, *, diagnosis_id: int | None = None) -> list[LocalAudioRecord]:
with self._connect() as connection:
if diagnosis_id is None:
rows = connection.execute(
"SELECT * FROM local_audio_uploads ORDER BY id DESC"
).fetchall()
else:
rows = connection.execute(
"""
SELECT * FROM local_audio_uploads
WHERE diagnosis_id = ?
ORDER BY id DESC
""",
(int(diagnosis_id),),
).fetchall()
return [self._from_row(row) for row in rows]
def claim_upload(self, record_id: int) -> LocalAudioRecord | None:
now = _utc_now()
with self._connect() as connection:
cursor = connection.execute(
"""
UPDATE local_audio_uploads
SET status = 'uploading', error_text = '', attempts = attempts + 1,
updated_at = ?
WHERE id = ? AND status IN ('pending', 'failed')
""",
(now, int(record_id)),
)
if cursor.rowcount != 1:
return None
return self.require(record_id)
def mark_uploaded(self, record_id: int, uploaded_url: str = "") -> None:
now = _utc_now()
with self._connect() as connection:
connection.execute(
"""
UPDATE local_audio_uploads
SET status = 'uploaded', uploaded_url = ?, error_text = '',
uploaded_at = ?, updated_at = ?
WHERE id = ?
""",
(str(uploaded_url)[:2000], now, now, int(record_id)),
)
def mark_failed(self, record_id: int, message: str) -> None:
now = _utc_now()
with self._connect() as connection:
connection.execute(
"""
UPDATE local_audio_uploads
SET status = 'failed', error_text = ?, updated_at = ?
WHERE id = ?
""",
(str(message)[:1000] or "上传失败。", now, int(record_id)),
)
def retry(self, record_id: int) -> LocalAudioRecord:
record = self.require(record_id)
if record.status not in {"failed", "pending"}:
raise RuntimeError("当前录音状态不允许重试上传。")
if not record.file_path.is_file() or record.file_path.stat().st_size <= 0:
raise RuntimeError("本地录音文件不存在或为空。")
now = _utc_now()
with self._connect() as connection:
connection.execute(
"""
UPDATE local_audio_uploads
SET status = 'pending', error_text = '', updated_at = ?
WHERE id = ?
""",
(now, int(record_id)),
)
return self.require(record_id)
def _upload_url(result: Any) -> str:
if not isinstance(result, dict):
return ""
data = result.get("data")
payload = data if isinstance(data, dict) else result
for key in ("file_url", "fileUrl", "url", "recording_url"):
value = payload.get(key)
if value:
return str(value)
return ""
class LocalAudioUploadManager:
"""Small daemon worker pool that uploads up to three recordings at once."""
def __init__(
self,
repository: Any,
store: LocalAudioQueueStore,
*,
max_workers: int = _MAX_UPLOAD_WORKERS,
) -> None:
self.repository = repository
self.store = store
self.max_workers = max(1, min(int(max_workers), 8))
self._jobs: queue.Queue[tuple[int, Future[bool]]] = queue.Queue()
self._active: dict[int, Future[bool]] = {}
self._upload_listeners: set[Callable[[LocalAudioRecord], None]] = set()
self._lock = threading.Lock()
for index in range(self.max_workers):
threading.Thread(
target=self._worker,
name=f"local-audio-upload-{index + 1}",
daemon=True,
).start()
def submit(self, record_id: int) -> Future[bool]:
record_id = int(record_id)
with self._lock:
existing = self._active.get(record_id)
if existing is not None:
return existing
future: Future[bool] = Future()
self._active[record_id] = future
self._jobs.put((record_id, future))
return future
def add_upload_listener(
self,
listener: Callable[[LocalAudioRecord], None],
) -> None:
with self._lock:
self._upload_listeners.add(listener)
def remove_upload_listener(
self,
listener: Callable[[LocalAudioRecord], None],
) -> None:
with self._lock:
self._upload_listeners.discard(listener)
def _notify_uploaded(self, record_id: int) -> None:
try:
record = self.store.require(record_id)
except Exception:
return
with self._lock:
listeners = tuple(self._upload_listeners)
for listener in listeners:
with suppress(Exception):
listener(record)
def submit_retryable(self, *, diagnosis_id: int | None = None) -> list[Future[bool]]:
futures: list[Future[bool]] = []
for record in self.store.list_records(diagnosis_id=diagnosis_id):
if record.status in _RETRYABLE_STATUSES:
futures.append(self.submit(record.id))
return futures
def submit_pending(self, *, diagnosis_id: int | None = None) -> list[Future[bool]]:
"""Resume only queued work; failed uploads require an explicit retry."""
return [
self.submit(record.id)
for record in self.store.list_records(diagnosis_id=diagnosis_id)
if record.status == "pending"
]
def _worker(self) -> None:
while True:
record_id, future = self._jobs.get()
try:
try:
succeeded = self._upload(record_id)
except Exception as error: # Defensive boundary around a daemon worker.
with suppress(Exception):
self.store.mark_failed(
record_id,
str(error) or "本地录音上传失败。",
)
succeeded = False
if not future.done():
future.set_result(succeeded)
finally:
with self._lock:
self._active.pop(record_id, None)
self._jobs.task_done()
def _upload(self, record_id: int) -> bool:
record = self.store.claim_upload(record_id)
if record is None:
current = self.store.get(record_id)
return bool(current and current.status == "uploaded")
if not record.file_path.is_file() or record.file_path.stat().st_size <= 0:
self.store.mark_failed(record.id, "本地录音文件不存在或为空。")
return False
if record.call_record_id is None or record.call_record_id <= 0:
self.store.mark_failed(record.id, "未保存对应的通话记录编号,暂不能上传。")
return False
upload = getattr(self.repository, "upload_call_recording", None)
if not callable(upload):
self.store.mark_failed(record.id, "当前服务未提供本地录音上传接口。")
return False
try:
result = upload(
path=record.file_path,
diagnosis_id=record.diagnosis_id,
call_record_id=record.call_record_id,
mime_type=record.mime_type,
)
except Exception as error:
self.store.mark_failed(record.id, str(error) or "本地录音上传 COS 失败。")
return False
uploaded_url = _upload_url(result)
if not uploaded_url:
self.store.mark_failed(record.id, "服务器未返回本地录音文件地址。")
return False
self.store.mark_uploaded(record.id, uploaded_url)
self._notify_uploaded(record.id)
return True
def get_local_audio_upload_manager(
repository: Any,
store: LocalAudioQueueStore | None = None,
) -> tuple[LocalAudioQueueStore, LocalAudioUploadManager]:
"""Return one three-worker uploader per repository and manifest.
Video windows and the recording-management dialog may be opened many times
during a workstation session. Sharing the pool prevents each window from
creating another set of background threads while SQLite's atomic claim still
protects against duplicate uploads across processes.
"""
resolved_store = store or LocalAudioQueueStore()
key = (id(repository), resolved_store.database_path)
with _MANAGER_LOCK:
manager = _MANAGERS.get(key)
if manager is None:
manager = LocalAudioUploadManager(
repository,
resolved_store,
max_workers=_MAX_UPLOAD_WORKERS,
)
_MANAGERS[key] = manager
return resolved_store, manager
@@ -2588,8 +2588,11 @@ class DemoDoctorRepository:
"room_id": "manual-upload",
"status": 2,
"status_text": "已结束",
"recording_status_text": "待上传",
"recording_urls_list": [],
"recording_status_text": "待上传",
"recording_urls_list": [],
"local_audio_status": 0,
"local_audio_status_text": "无本地录音",
"local_audio_urls_list": [],
"transcription_status": "not_started",
"transcription_status_text": "未生成文字",
"transcript_text": "",
@@ -2633,36 +2636,75 @@ class DemoDoctorRepository:
record["recording_status_text"] = "录制完成"
return deepcopy(record)
def upload_call_recording(
self,
path: str | PathLike[str],
diagnosis_id: int,
*,
call_record_id: int | None = None,
) -> dict[str, Any]:
"""Upload then attach a demo video using the production sequence."""
file_url = self.upload_material(path, "video")
target_id = call_record_id
if target_id is None:
target_id = int(self.create_manual_call_record(diagnosis_id)["id"])
self.attach_local_call_recording(diagnosis_id, file_url, call_record_id=target_id)
return {
"diagnosis_id": diagnosis_id,
"call_record_id": target_id,
"file_url": file_url,
}
def upload_call_recording(
self,
path: str | PathLike[str],
diagnosis_id: int,
*,
call_record_id: int | None = None,
mime_type: str = "audio/webm",
) -> dict[str, Any]:
"""Upload then attach a demo local-audio file using the production sequence."""
source_path = Path(path)
is_audio = source_path.suffix.lower() in {
".ogg",
".opus",
".mp3",
".wav",
".m4a",
".aac",
".amr",
".wma",
} or (
source_path.suffix.lower() == ".webm"
and str(mime_type or "").lower().startswith("audio/")
)
file_url = self.upload_material(path, "file" if is_audio else "video")
target_id = call_record_id
if target_id is None:
target_id = int(self.create_manual_call_record(diagnosis_id)["id"])
if is_audio:
with self._lock:
record = next(
(
row
for row in self._call_records.setdefault(diagnosis_id, [])
if int(row.get("id") or 0) == int(target_id)
),
None,
)
if record is None:
raise RepositoryNotFoundError("call record not found")
urls = record.setdefault("local_audio_urls_list", [])
if file_url not in urls:
urls.append(file_url)
record["local_audio_status"] = 2
record["local_audio_status_text"] = "已保存"
else:
self.attach_local_call_recording(diagnosis_id, file_url, call_record_id=target_id)
return {
"diagnosis_id": diagnosis_id,
"call_record_id": target_id,
"file_url": file_url,
"media_kind": "local_audio" if is_audio else "video",
"completed": True,
}
def list_im_chat_messages(
self, diagnosis_id: int, *, only_archived: bool = True
) -> dict[str, Any]:
"""Return the demo archive in the server response envelope shape."""
with self._lock:
consultation = self._find_consultation(diagnosis_id)
return deepcopy(
{
"lists": self._im_messages.get(diagnosis_id, []),
with self._lock:
consultation = self._find_consultation(diagnosis_id)
rows = [
{**row, "diagnosis_id": diagnosis_id}
for row in self._im_messages.get(diagnosis_id, [])
]
return deepcopy(
{
"lists": rows,
"patient_im_id": f"patient_{consultation.patient_id}",
"patient_name": consultation.patient_name,
"doctor_accounts_queried": [] if only_archived else ["doctor_1001"],
@@ -2890,8 +2932,11 @@ class DemoDoctorRepository:
replay = {
**record,
"status_text": "呼叫中",
"recording_status_text": "未录制",
"recording_urls_list": [],
"recording_status_text": "未录制",
"recording_urls_list": [],
"local_audio_status": 0,
"local_audio_status_text": "无本地录音",
"local_audio_urls_list": [],
"transcription_status": "not_started",
"transcription_status_text": "未生成文字",
"transcript_text": "",
@@ -2903,13 +2948,21 @@ class DemoDoctorRepository:
self._call_records.setdefault(diagnosis_id, []).insert(0, replay)
return deepcopy(record)
def end_call(self, diagnosis_id: int) -> dict[str, Any]:
def end_call(
self, diagnosis_id: int, *, call_record_id: int | str | None = None
) -> dict[str, Any]:
"""Persistently mark the demo call record ended."""
with self._lock:
record = self._calls.get(diagnosis_id)
if record is None:
raise RepositoryNotFoundError(f"active call for {diagnosis_id} not found")
record = self._calls.get(diagnosis_id)
if record is None:
raise RepositoryNotFoundError(f"active call for {diagnosis_id} not found")
if call_record_id is not None and str(record.get("id") or "") != str(
call_record_id
):
raise RepositoryNotFoundError(
f"active call record {call_record_id} for {diagnosis_id} not found"
)
record["status"] = "ended"
record["ended_at"] = datetime.now().replace(microsecond=0).isoformat(sep=" ")
replay = next(
@@ -2930,15 +2983,27 @@ class DemoDoctorRepository:
)
return deepcopy(record)
def bind_call_room(self, diagnosis_id: int, room_id: str) -> dict[str, Any]:
def bind_call_room(
self,
diagnosis_id: int,
room_id: str,
*,
call_record_id: int | str | None = None,
) -> dict[str, Any]:
"""Persist the room identifier on the active demo call."""
if not room_id.strip():
raise ValueError("room_id is required")
with self._lock:
record = self._calls.get(diagnosis_id)
if record is None:
raise RepositoryNotFoundError(f"active call for {diagnosis_id} not found")
record = self._calls.get(diagnosis_id)
if record is None:
raise RepositoryNotFoundError(f"active call for {diagnosis_id} not found")
if call_record_id is not None and str(record.get("id") or "") != str(
call_record_id
):
raise RepositoryNotFoundError(
f"active call record {call_record_id} for {diagnosis_id} not found"
)
record["room_id"] = room_id.strip()
record["status"] = "connected"
replay = next(
@@ -2957,9 +3022,10 @@ class DemoDoctorRepository:
"status_text": "通话中",
}
)
return {
"diagnosis_id": diagnosis_id,
"room_id": room_id.strip(),
return {
"diagnosis_id": diagnosis_id,
"call_record_id": record["id"],
"room_id": room_id.strip(),
"cloud_recording": {"started": False, "message": "演示模式不录制"},
}
+312 -97
View File
@@ -2,22 +2,27 @@
from __future__ import annotations
import mimetypes
import re
import time
from collections.abc import Mapping, Sequence
from contextlib import suppress
from datetime import date
import mimetypes
import re
import time
from collections.abc import Callable, Iterator, Mapping, Sequence
from contextlib import suppress
from datetime import date
from inspect import Parameter, signature
from io import BytesIO
from os import PathLike
from pathlib import Path
from typing import Any, Final, Literal, Protocol
from pathlib import Path
from typing import Any, Final, Literal, Protocol
from uuid import uuid4
from doctor_workstation.core.errors import (
ApiProtocolError,
ApiTransportError,
AuthenticationExpiredError,
)
from doctor_workstation.core.errors import (
ApiBusinessError,
ApiHttpError,
ApiProtocolError,
ApiTimeoutError,
ApiTransportError,
AuthenticationExpiredError,
)
from doctor_workstation.core.models import (
Appointment,
CallTicket,
@@ -191,6 +196,16 @@ class DoctorRepository(Protocol):
) -> dict[str, Any]:
"""Ask the first-party diagnosis assistant; the server selects the model."""
def stream_diagnosis_ai(
self,
diagnosis_id: int,
prompt: str,
*,
task: str = "custom",
cancelled: Callable[[], bool] | None = None,
) -> Iterator[dict[str, Any]]:
"""Yield normalized ``start``/``delta``/``done`` assistant events."""
def get_diagnosis_ai_analysis(
self,
diagnosis_id: int,
@@ -618,14 +633,15 @@ class DoctorRepository(Protocol):
) -> Any:
"""Attach one uploaded replay URI to a call record."""
def upload_call_recording(
self,
path: str | PathLike[str],
diagnosis_id: int,
*,
call_record_id: int | None = None,
) -> dict[str, Any]:
"""Upload a local video then attach it to a real call record."""
def upload_call_recording(
self,
path: str | PathLike[str],
diagnosis_id: int,
*,
call_record_id: int | None = None,
mime_type: str = "audio/webm",
) -> dict[str, Any]:
"""Upload a locally captured call recording to a real call record."""
def list_im_chat_messages(
self, diagnosis_id: int, *, only_archived: bool = True
@@ -686,11 +702,19 @@ class DoctorRepository(Protocol):
def start_call(self, diagnosis_id: int, patient_id: int, *, call_type: int = 2) -> Any:
"""Create a call record."""
def end_call(self, diagnosis_id: int) -> Any:
"""End the active diagnosis call."""
def bind_call_room(self, diagnosis_id: int, room_id: str) -> Any:
"""Bind a TRTC room to the active call."""
def end_call(
self, diagnosis_id: int, *, call_record_id: int | str | None = None
) -> Any:
"""End the exact active call when its server identity is available."""
def bind_call_room(
self,
diagnosis_id: int,
room_id: str,
*,
call_record_id: int | str | None = None,
) -> Any:
"""Bind a TRTC room to the exact active call and start cloud recording."""
def start_call_transcription(
self,
@@ -1328,25 +1352,7 @@ class RemoteDoctorRepository:
) -> dict[str, Any]:
"""Submit a diagnosis question to the first-party server assistant."""
if diagnosis_id <= 0:
raise ValueError("diagnosis_id must be positive")
clean_prompt = prompt.strip()
if not clean_prompt:
raise ValueError("prompt is required")
if len(clean_prompt) > 500:
raise ValueError("prompt must not exceed 500 characters")
clean_task = task.strip().lower() or "custom"
if clean_task not in {
"summary",
"tcm_pattern",
"prescription_review",
"medication_review",
"exam_review",
"complication_risk",
"guideline_review",
"custom",
}:
raise ValueError("task is not supported")
clean_prompt, clean_task = _diagnosis_ai_request(diagnosis_id, prompt, task)
payload = _client_request(
self.client,
"post",
@@ -1359,6 +1365,63 @@ class RemoteDoctorRepository:
)
return dict(_require_mapping(payload, "tcm.diagnosis/aiAssistant"))
def stream_diagnosis_ai(
self,
diagnosis_id: int,
prompt: str,
*,
task: str = "custom",
cancelled: Callable[[], bool] | None = None,
) -> Iterator[dict[str, Any]]:
"""Stream a diagnosis answer, with one legacy fallback before first content."""
clean_prompt, clean_task = _diagnosis_ai_request(diagnosis_id, prompt, task)
body = {"id": diagnosis_id, "prompt": clean_prompt, "task": clean_task}
received_delta = False
received_done = False
try:
for raw_event in self.client.post_event_stream(
"tcm.diagnosis/aiAssistantStream",
body,
timeout=105.0,
cancelled=cancelled,
):
if cancelled is not None and cancelled():
return
event = _normalise_diagnosis_ai_event(raw_event)
if event is None:
continue
kind = event["event"]
if kind == "delta":
received_delta = True
elif kind == "done":
received_done = True
yield event
if kind == "done":
return
if cancelled is not None and cancelled():
return
if not received_done:
raise ApiProtocolError("AI assistant stream ended before done")
except (ApiHttpError, ApiProtocolError, ApiTimeoutError, ApiTransportError):
if received_delta or (cancelled is not None and cancelled()):
raise
# Older deployments do not expose the stream route. Submit exactly one
# request through the confirmed non-streaming endpoint in that case.
result = self.analyze_diagnosis_ai(
diagnosis_id,
clean_prompt,
task=clean_task,
)
if cancelled is not None and cancelled():
return
yield {"event": "start", "fallback": True}
answer = str(result.get("answer") or result.get("content") or "")
if answer:
yield {"event": "delta", "text": answer, "fallback": True}
yield {**result, "event": "done", "fallback": True}
def get_diagnosis_ai_analysis(
self,
diagnosis_id: int,
@@ -2270,31 +2333,75 @@ class RemoteDoctorRepository:
body["call_record_id"] = call_record_id
return self.client.post("tcm.diagnosis/attachLocalCallRecording", body)
def upload_call_recording(
self,
path: str | PathLike[str],
diagnosis_id: int,
*,
call_record_id: int | None = None,
) -> dict[str, Any]:
"""Perform the same upload/create/attach sequence as ``CallRecordPanel``."""
if diagnosis_id <= 0:
raise ValueError("diagnosis_id must be positive")
# Match the admin component exactly: upload first so a failed upload does
# not leave behind an empty synthetic call record.
file_url = self.upload_material(path, "video")
target_id = call_record_id
if target_id is None:
target_id = int(self.create_manual_call_record(diagnosis_id).get("id") or 0)
if target_id <= 0:
raise ApiProtocolError("tcm.diagnosis/createManualCallRecord returned no id")
self.attach_local_call_recording(diagnosis_id, file_url, call_record_id=target_id)
return {
"diagnosis_id": diagnosis_id,
"call_record_id": target_id,
"file_url": file_url,
}
def upload_call_recording(
self,
path: str | PathLike[str],
diagnosis_id: int,
*,
call_record_id: int | None = None,
mime_type: str = "audio/webm",
) -> dict[str, Any]:
"""Chunk-upload one local audio recording and attach it atomically."""
if diagnosis_id <= 0:
raise ValueError("diagnosis_id must be positive")
if call_record_id is not None and call_record_id <= 0:
raise ValueError("call_record_id must be positive")
recording_path = Path(path).expanduser().resolve()
if not recording_path.is_file():
raise ValueError(f"recording file does not exist: {recording_path}")
file_size = recording_path.stat().st_size
if file_size <= 0:
raise ValueError("recording file must not be empty")
chunk_size = 4 * 1024 * 1024
chunk_total = (file_size + chunk_size - 1) // chunk_size
upload_id = f"local_audio_{uuid4().hex}"
file_name = recording_path.name
clean_mime = str(mime_type or "audio/webm").strip()[:120] or "audio/webm"
final_result: Mapping[str, Any] | None = None
bound_call_record_id = call_record_id
with recording_path.open("rb") as stream:
for chunk_index in range(chunk_total):
content = stream.read(chunk_size)
if not content:
raise ApiProtocolError("local recording ended before all chunks were read")
result = self.client.post_multipart(
"tcm.diagnosis/uploadCallRecording",
files={"file": (file_name, content, clean_mime)},
data={
"diagnosis_id": diagnosis_id,
"call_record_id": bound_call_record_id or 0,
"upload_id": upload_id,
"file_name": file_name,
"file_size": file_size,
"mime_type": clean_mime,
"chunk_index": chunk_index,
"chunk_total": chunk_total,
},
)
final_result = _require_mapping(
result,
"tcm.diagnosis/uploadCallRecording",
)
returned_id = int(final_result.get("call_record_id") or 0)
if returned_id <= 0:
raise ApiProtocolError(
"uploadCallRecording returned no call_record_id"
)
if bound_call_record_id is None:
bound_call_record_id = returned_id
elif returned_id != bound_call_record_id:
raise ApiProtocolError(
"uploadCallRecording returned a different call_record_id"
)
if final_result is None or not bool(final_result.get("completed")):
raise ApiProtocolError("uploadCallRecording did not complete the local audio upload")
file_url = str(final_result.get("file_url") or "").strip()
if not file_url:
raise ApiProtocolError("uploadCallRecording returned no COS file URL")
return dict(final_result)
def list_im_chat_messages(
self, diagnosis_id: int, *, only_archived: bool = True
@@ -2494,20 +2601,53 @@ class RemoteDoctorRepository:
)
return {"call_record_id": call_record_id}
def end_call(self, diagnosis_id: int) -> Any:
"""End the active call/recording associated with a diagnosis."""
return self.client.post("tcm.diagnosis/endCall", {"diagnosis_id": diagnosis_id})
def bind_call_room(self, diagnosis_id: int, room_id: str) -> Any:
"""Bind the actual TRTC room to the active call record."""
if not room_id.strip():
raise ValueError("room_id is required")
return self.client.post(
"tcm.diagnosis/bindCallRoom",
{"diagnosis_id": diagnosis_id, "room_id": room_id.strip()},
)
@staticmethod
def _optional_call_record_id(call_record_id: int | str | None) -> int | str | None:
if call_record_id is None:
return None
if isinstance(call_record_id, bool) or not str(call_record_id).strip():
raise ValueError("call_record_id must be a positive identifier")
try:
normalized = int(str(call_record_id).strip())
except (TypeError, ValueError) as exc:
raise ValueError("call_record_id must be a positive identifier") from exc
if normalized <= 0:
raise ValueError("call_record_id must be a positive identifier")
return normalized
def end_call(
self, diagnosis_id: int, *, call_record_id: int | str | None = None
) -> Any:
"""Stop COS cloud recording and end the exact call record."""
body: dict[str, Any] = {"diagnosis_id": diagnosis_id}
normalized_id = self._optional_call_record_id(call_record_id)
if normalized_id is not None:
body["call_record_id"] = normalized_id
return self.client.post("tcm.diagnosis/endCall", body)
def bind_call_room(
self,
diagnosis_id: int,
room_id: str,
*,
call_record_id: int | str | None = None,
) -> Any:
"""Bind the actual TRTC room and auto-start COS cloud recording."""
if not room_id.strip():
raise ValueError("room_id is required")
body: dict[str, Any] = {
"diagnosis_id": diagnosis_id,
"room_id": room_id.strip(),
}
normalized_id = self._optional_call_record_id(call_record_id)
if normalized_id is not None:
body["call_record_id"] = normalized_id
return self.client.post(
"tcm.diagnosis/bindCallRoom",
body,
)
@staticmethod
def _transcription_identity(
@@ -2638,7 +2778,66 @@ class RemoteDoctorRepository:
return self.update_prescription_template(template, changes, **fields)
def _page_params(page_no: int, page_size: int, filters: Mapping[str, Any]) -> dict[str, Any]:
def _diagnosis_ai_request(diagnosis_id: int, prompt: str, task: str) -> tuple[str, str]:
if diagnosis_id <= 0:
raise ValueError("diagnosis_id must be positive")
clean_prompt = prompt.strip()
if not clean_prompt:
raise ValueError("prompt is required")
if len(clean_prompt) > 500:
raise ValueError("prompt must not exceed 500 characters")
clean_task = task.strip().lower() or "custom"
if clean_task not in {
"summary",
"tcm_pattern",
"prescription_review",
"medication_review",
"exam_review",
"complication_risk",
"guideline_review",
"custom",
}:
raise ValueError("task is not supported")
return clean_prompt, clean_task
def _normalise_diagnosis_ai_event(raw: Mapping[str, Any]) -> dict[str, Any] | None:
kind = str(raw.get("event") or "message").strip().lower()
data = raw.get("data")
payload = dict(data) if isinstance(data, Mapping) else {}
if kind == "message":
kind = str(payload.get("event") or payload.get("type") or "message").lower()
if kind == "start":
payload.pop("event", None)
payload.pop("type", None)
return {**payload, "event": "start"}
if kind == "delta":
text = (
data
if isinstance(data, str)
else payload.get("delta")
or payload.get("content")
or payload.get("text")
or ""
)
if not isinstance(text, str):
raise ApiProtocolError("AI assistant delta content must be text", data=data)
return {"event": "delta", "text": text}
if kind == "done":
payload.pop("event", None)
payload.pop("type", None)
return {**payload, "event": "done"}
if kind == "error":
message = (
data
if isinstance(data, str)
else payload.get("message") or payload.get("msg") or payload.get("error")
)
raise ApiBusinessError(str(message or "AI assistant stream failed"), data=data)
return None
def _page_params(page_no: int, page_size: int, filters: Mapping[str, Any]) -> dict[str, Any]:
if page_no < 1 or page_size < 1:
raise ValueError("page_no and page_size must be positive")
result = {
@@ -2656,24 +2855,40 @@ def _require_mapping(value: object, endpoint: str) -> Mapping[str, Any]:
return value
def _client_request(
def _client_request(
client: Any,
method: str,
endpoint: str,
payload: Mapping[str, Any] | None = None,
*,
timeout: float | None = None,
) -> Any:
"""Call get/post, ignoring timeout kwargs that test doubles do not accept."""
fn = getattr(client, method)
params = dict(payload or {})
try:
if timeout is None:
return fn(endpoint, params)
return fn(endpoint, params, timeout=timeout)
except TypeError:
return fn(endpoint, params)
) -> Any:
"""Call get/post once, omitting timeout for legacy test doubles.
Capability is determined before invocation. Catching ``TypeError`` from
the call itself is unsafe for POST because the transport may already have
submitted the write before raising, and a fallback call would duplicate it.
"""
fn = getattr(client, method)
params = dict(payload or {})
if timeout is not None:
try:
parameters = signature(fn).parameters.values()
except (TypeError, ValueError):
parameters = ()
accepts_timeout = any(
parameter.kind is Parameter.VAR_KEYWORD
or (
parameter.name == "timeout"
and parameter.kind
in {Parameter.POSITIONAL_OR_KEYWORD, Parameter.KEYWORD_ONLY}
)
for parameter in parameters
)
if accepts_timeout:
return fn(endpoint, params, timeout=timeout)
return fn(endpoint, params)
def _material_kind(
@@ -2526,14 +2526,29 @@ class _RemoteImageButton(QPushButton):
request = QNetworkRequest(url)
request.setTransferTimeout(10_000)
request.setMaximumRedirectsAllowed(4)
request.setAttribute(
QNetworkRequest.Attribute.RedirectPolicyAttribute,
QNetworkRequest.RedirectPolicy.NoLessSafeRedirectPolicy,
)
reply = self._manager.get(request)
self._reply = reply
reply.setProperty("diagnosisImageGeneration", generation)
reply.finished.connect(self._reply_finished)
request.setAttribute(
QNetworkRequest.Attribute.RedirectPolicyAttribute,
QNetworkRequest.RedirectPolicy.SameOriginRedirectPolicy,
)
reply = self._manager.get(request)
self._reply = reply
reply.setProperty("diagnosisImageGeneration", generation)
reply.setProperty("diagnosisImageOversize", False)
reply.downloadProgress.connect(self._download_progress)
reply.finished.connect(self._reply_finished)
def _download_progress(self, bytes_received: int, bytes_total: int) -> None:
"""Abort a current reply as soon as its received or declared size is unsafe."""
reply = self.sender()
if reply is not self._reply:
return
if bytes_received <= self._MAX_IMAGE_BYTES and (
bytes_total < 0 or bytes_total <= self._MAX_IMAGE_BYTES
):
return
reply.setProperty("diagnosisImageOversize", True)
reply.abort()
def _reply_finished(self) -> None:
"""Use a QObject receiver connection so destruction auto-disconnects the callback."""
@@ -2553,16 +2568,19 @@ class _RemoteImageButton(QPushButton):
reply.deleteLater()
return
self._reply = None
if not self._owner_is_current():
reply.deleteLater()
return
error = reply.error()
payload = bytes(reply.readAll())
reply.deleteLater()
if error != QNetworkReply.NetworkError.NoError:
self._show_fallback()
return
self._apply_payload(payload, generation)
if not self._owner_is_current():
reply.deleteLater()
return
error = reply.error()
if bool(reply.property("diagnosisImageOversize")) or (
error != QNetworkReply.NetworkError.NoError
):
reply.deleteLater()
self._show_fallback()
return
payload = bytes(reply.readAll())
reply.deleteLater()
self._apply_payload(payload, generation)
def _apply_payload(self, payload: bytes, generation: int) -> bool:
"""Decode a current reply; kept separate so offline tests can exercise rendering."""
@@ -67,11 +67,33 @@ PRIMARY = QColor("#5265F6")
TEXT = QColor("#15224A")
SECONDARY = QColor("#7481A3")
PLACEHOLDER = QColor("#A4ADC3")
_INVALID_INDEX = QModelIndex()
_TABLE_COLUMN_WIDTHS = (48, 70, 60, 100, 175, 88, 120, 100, 72, 110, 120, 340)
def _menu_action_icon(kind: str, *, danger: bool = False) -> QIcon:
_INVALID_INDEX = QModelIndex()
_TABLE_COLUMN_WIDTHS = (48, 70, 60, 100, 175, 88, 120, 100, 72, 110, 120, 410)
def _render_signature(value: Any) -> Any:
"""Freeze repository DTOs into a stable, order-independent render key."""
if isinstance(value, Mapping):
items = ((str(key), _render_signature(item)) for key, item in value.items())
return ("mapping", tuple(sorted(items, key=lambda item: item[0])))
if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
return ("sequence", tuple(_render_signature(item) for item in value))
if isinstance(value, set | frozenset):
items = (_render_signature(item) for item in value)
return ("set", tuple(sorted(items, key=repr)))
try:
hash(value)
except TypeError:
return ("repr", repr(value))
return ("value", value)
def _rows_render_signature(rows: Sequence[Any]) -> tuple[Any, ...]:
return tuple(_render_signature(row) for row in rows)
def _menu_action_icon(kind: str, *, danger: bool = False) -> QIcon:
"""Draw the admin menu glyphs without relying on a symbol font."""
size = 18
@@ -238,11 +260,32 @@ def _appointment_status(record: Any) -> int:
return _as_int(status, -1)
def _appointment_active(record: Any) -> bool:
return _has_appointment(record) and _appointment_status(record) == 1
def _single_cancellable_appointment(record: Any) -> Any | None:
def _appointment_active(record: Any) -> bool:
return _has_appointment(record) and _appointment_status(record) == 1
def video_call_state(record: Any) -> str:
"""Return the server-owned state of the latest video-call session."""
return str(
first_value(
record,
"video_call_hint.state",
"video_hint.state",
"video_hint",
default="none",
)
or "none"
).strip().lower()
def video_call_is_live(record: Any) -> bool:
"""Only a room already opened by the doctor may be joined from this list."""
return video_call_state(record) == "live"
def _single_cancellable_appointment(record: Any) -> Any | None:
"""Return the sole cancellable appointment, never an ambiguous row-level fallback."""
if not _has_appointment(record) or _appointment_status(record) not in {1, 4}:
@@ -295,12 +338,34 @@ def _video_ids_complete(record: Any) -> bool:
)
def _prescription_action_label(record: Any, *, force_open: bool = False) -> str:
if force_open:
return "开方"
audit = _as_int(first_value(record, "prescription_audit_status", "audit_status"), -1)
voided = _as_int(first_value(record, "prescription_void_status", "void_status"), 0)
return "查看处方" if audit == 1 and voided != 1 else "开方"
def prescription_action(record: Any, *, force_open: bool = False) -> tuple[str, str]:
"""Return the label and immutable click intent for the current appointment."""
if force_open:
return "开方", "open"
audit = _as_int(first_value(record, "prescription_audit_status", "audit_status"), -1)
voided = _as_int(first_value(record, "prescription_void_status", "void_status"), 0)
explicit_current = first_value(record, "current_has_prescription", default=None)
if explicit_current is not None:
has_current = _as_bool(explicit_current)
elif _as_int(first_value(record, "current_prescription_id", default=0)) > 0:
has_current = True
else:
explicit_legacy = first_value(record, "has_prescription", default=None)
has_current = (
_as_bool(explicit_legacy)
if explicit_legacy is not None
else audit in {0, 1, 2}
)
if not has_current:
return "开方", "open"
if audit == 1 and voided != 1:
return "查看处方", "view"
return "编辑处方", "edit"
def _prescription_action_label(record: Any, *, force_open: bool = False) -> str:
return prescription_action(record, force_open=force_open)[0]
class FlowLayout(QLayout):
@@ -450,12 +515,13 @@ class DiagnosisTableModel(QAbstractTableModel):
"操作",
)
def __init__(self, rows: Iterable[Any] = (), parent: QWidget | None = None) -> None:
super().__init__(parent)
self.rows: list[Any] = list(rows)
self._checked_ids: set[int] = set()
self._hover_row = -1
self._sort_direction = ""
def __init__(self, rows: Iterable[Any] = (), parent: QWidget | None = None) -> None:
super().__init__(parent)
self.rows: list[Any] = list(rows)
self._render_signature = _rows_render_signature(self.rows)
self._checked_ids: set[int] = set()
self._hover_row = -1
self._sort_direction = ""
def rowCount(self, parent: QModelIndex = _INVALID_INDEX) -> int: # noqa: N802
return 0 if parent.isValid() else len(self.rows)
@@ -526,15 +592,24 @@ class DiagnosisTableModel(QAbstractTableModel):
def record(self, row: int) -> Any:
return self.rows[row] if 0 <= row < len(self.rows) else None
def set_rows(self, rows: Iterable[Any]) -> None:
materialized = list(rows)
valid_ids = {self.record_id(row) for row in materialized}
self.beginResetModel()
self.rows = materialized
self._checked_ids.intersection_update(valid_ids)
self._hover_row = -1
self.endResetModel()
self.selection_changed.emit(len(self._checked_ids))
@property
def render_signature(self) -> tuple[Any, ...]:
return self._render_signature
def set_rows(self, rows: Iterable[Any]) -> bool:
materialized = list(rows)
render_signature = _rows_render_signature(materialized)
if render_signature == self._render_signature:
return False
valid_ids = {self.record_id(row) for row in materialized}
self.beginResetModel()
self.rows = materialized
self._render_signature = render_signature
self._checked_ids.intersection_update(valid_ids)
self._hover_row = -1
self.endResetModel()
self.selection_changed.emit(len(self._checked_ids))
return True
def checked_records(self) -> list[Any]:
return [row for row in self.rows if self.record_id(row) in self._checked_ids]
@@ -1157,11 +1232,12 @@ class DiagnosisTableView(QTableView):
def _current_row_changed(self, _current: QModelIndex, _previous: QModelIndex) -> None:
self.itemSelectionChanged.emit()
def set_rows(self, rows: Iterable[Any]) -> None:
self._diagnosis_model().set_rows(rows)
self.clearSelection()
self.setCurrentIndex(QModelIndex())
self.rows_replaced.emit()
def set_rows(self, rows: Iterable[Any]) -> None:
if not self._diagnosis_model().set_rows(rows):
return
self.clearSelection()
self.setCurrentIndex(QModelIndex())
self.rows_replaced.emit()
def rowCount(self) -> int: # noqa: N802 - compatibility
return self._diagnosis_model().rowCount()
@@ -1303,6 +1379,8 @@ class DiagnosisTableHost(QFrame):
) -> None:
super().__init__(parent)
self.setObjectName("DiagnosisTableHost")
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
self.setMinimumHeight(0)
self.action_policy = dict(action_policy or {})
self.force_open_prescription = False
self.model = DiagnosisTableModel(parent=self)
@@ -1316,10 +1394,13 @@ class DiagnosisTableHost(QFrame):
view.setModel(self.model)
view.setSelectionModel(self.selection)
view.setItemDelegate(self.delegate)
view.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
view.setMinimumHeight(0)
view.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
view.setHorizontalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
view.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Fixed)
view.horizontalHeader().setStretchLastSection(False)
self.main.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
self.fixed.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.main.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
self.fixed.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.main.setMinimumWidth(0)
@@ -1335,6 +1416,7 @@ class DiagnosisTableHost(QFrame):
layout.setSpacing(0)
layout.addWidget(self.main, 1)
layout.addWidget(self.fixed)
layout.setAlignment(self.fixed, Qt.AlignmentFlag.AlignTop)
self.fixed_shadow = _FixedColumnShadow(self)
self.fixed_shadow.show()
self.empty_label = QLabel("暂无数据", self)
@@ -1348,22 +1430,27 @@ class DiagnosisTableHost(QFrame):
self.main.hovered_row.connect(self._set_hover_row)
self.fixed.hovered_row.connect(self._set_hover_row)
self.model.selection_changed.connect(self._checked_changed)
self.main.horizontalHeader().sectionClicked.connect(self._header_clicked)
self._update_height()
def set_rows(self, rows: Iterable[Any]) -> None:
previous_id = DiagnosisTableModel.record_id(self.main.current_data())
self.model.set_rows(rows)
self.empty_label.setText("暂无数据")
self.empty_label.setProperty("stateKind", "empty")
self.empty_label.style().unpolish(self.empty_label)
self.empty_label.style().polish(self.empty_label)
self._rows_changed()
if previous_id > 0:
for index, record in enumerate(self.model.rows):
if DiagnosisTableModel.record_id(record) == previous_id:
self.main.selectRow(index)
break
self.main.horizontalHeader().sectionClicked.connect(self._header_clicked)
self.main.verticalScrollBar().valueChanged.connect(self.fixed.verticalScrollBar().setValue)
self.fixed.verticalScrollBar().valueChanged.connect(self.main.verticalScrollBar().setValue)
self.main.horizontalScrollBar().rangeChanged.connect(self._schedule_fixed_height_sync)
self._fixed_render_signature: tuple[Any, ...] | None = None
def set_rows(self, rows: Iterable[Any]) -> None:
previous_id = DiagnosisTableModel.record_id(self.main.current_data())
rows_changed = self.model.set_rows(rows)
self.empty_label.setText("暂无数据")
self.empty_label.setProperty("stateKind", "empty")
self.empty_label.style().unpolish(self.empty_label)
self.empty_label.style().polish(self.empty_label)
fixed_widgets_changed = self._fixed_widgets_signature() != self._fixed_render_signature
if rows_changed or fixed_widgets_changed:
self._rows_changed()
if rows_changed and previous_id > 0:
for index, record in enumerate(self.model.rows):
if DiagnosisTableModel.record_id(record) == previous_id:
self.main.selectRow(index)
break
def selected_records(self) -> list[Any]:
return self.model.checked_records()
@@ -1389,13 +1476,21 @@ class DiagnosisTableHost(QFrame):
def set_sort_direction(self, direction: str) -> None:
self.model.set_sort_direction(direction)
def _rows_changed(self) -> None:
self._sync_row_heights()
self._install_fixed_widgets()
self.empty_label.setVisible(self.model.rowCount() == 0)
self._update_height()
self._position_empty()
self._position_fixed_shadow()
def _rows_changed(self) -> None:
self._sync_row_heights()
self._install_fixed_widgets()
self._fixed_render_signature = self._fixed_widgets_signature()
self.empty_label.setVisible(self.model.rowCount() == 0)
self._position_empty()
self._position_fixed_shadow()
self._schedule_fixed_height_sync()
def _fixed_widgets_signature(self) -> tuple[Any, ...]:
return (
self.model.render_signature,
self.force_open_prescription,
tuple(sorted(self.action_policy.items())),
)
def _sync_row_heights(self) -> None:
for row, record in enumerate(self.model.rows):
@@ -1434,25 +1529,32 @@ class DiagnosisTableHost(QFrame):
layout.setContentsMargins(3, 2, 3, 2)
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
video_capable = self.action_policy.get("video_call", False)
if video_capable and _appointment_active(record):
button = QToolButton(host)
button.setText("进入视频问诊")
button.setProperty("rowLink", "primary")
button.setCursor(Qt.CursorShape.PointingHandCursor)
button.setToolTip("进入医生端视频问诊(将使用摄像头和麦克风)")
button.setEnabled(_video_ids_complete(record))
if not button.isEnabled():
button.setToolTip("患者、诊单或挂号标识不完整,无法进入视频问诊")
button.clicked.connect(
lambda _checked=False, item=record: self.video_requested.emit(item)
)
layout.addWidget(button)
else:
status = ""
if video_capable:
status = {3: "已完成", 4: "已过号"}.get(
_appointment_status(record), "未挂号" if not _has_appointment(record) else ""
)
call_state = video_call_state(record)
if video_capable and _appointment_active(record) and call_state == "live":
button = QToolButton(host)
button.setText("进入视频问诊")
button.setProperty("rowLink", "primary")
button.setCursor(Qt.CursorShape.PointingHandCursor)
button.setToolTip("医生已发起视频会话,点击进入(将使用摄像头和麦克风)")
button.setEnabled(_video_ids_complete(record))
if not button.isEnabled():
button.setToolTip("患者、诊单或挂号标识不完整,无法进入视频问诊")
button.clicked.connect(
lambda _checked=False, item=record: self.video_requested.emit(item)
)
layout.addWidget(button)
else:
status = ""
if video_capable:
if call_state == "pending_room":
status = "等待接通"
elif _appointment_active(record):
status = "暂无通话"
else:
status = {3: "已完成", 4: "已过号"}.get(
_appointment_status(record),
"未挂号" if not _has_appointment(record) else "",
)
label = QLabel(display_text(status), host)
label.setProperty("fixedMuted", True)
label.setAlignment(Qt.AlignmentFlag.AlignCenter)
@@ -1479,6 +1581,8 @@ class DiagnosisTableHost(QFrame):
"primary",
)
)
if self.action_policy.get("ai_consult", False):
layout.addWidget(self._action_button("AI 分析", "ai_consult", record, "primary"))
if self.action_policy.get("appointment", False):
layout.addWidget(self._action_button("预约", "appointment", record, "success"))
if self.action_policy.get("edit", False):
@@ -1604,14 +1708,23 @@ class DiagnosisTableHost(QFrame):
self.set_sort_direction(direction)
self.sort_unserved_requested.emit(direction)
def _update_height(self) -> None:
rows_height = sum(self.main.rowHeight(row) for row in range(self.model.rowCount()))
body_height = rows_height if rows_height else 60
horizontal = self.main.horizontalScrollBar().sizeHint().height()
self.setFixedHeight(39 + body_height + horizontal + 2)
def _schedule_fixed_height_sync(self, *_range: int) -> None:
"""Keep both table viewports equally tall when the main x-scrollbar appears."""
QTimer.singleShot(0, self._sync_fixed_height)
def _sync_fixed_height(self) -> None:
horizontal = self.main.horizontalScrollBar()
reserved = horizontal.sizeHint().height() if horizontal.maximum() > 0 else 0
target = max(0, self.height() - reserved)
if self.fixed.height() != target:
self.fixed.setFixedHeight(target)
def _position_empty(self) -> None:
self.empty_label.setGeometry(0, 39, self.width(), 60)
horizontal = self.main.horizontalScrollBar()
reserved = horizontal.sizeHint().height() if horizontal.maximum() > 0 else 0
body_height = max(0, self.height() - 39 - reserved)
self.empty_label.setGeometry(0, 39, self.width(), body_height)
self.empty_label.raise_()
def _position_fixed_shadow(self) -> None:
@@ -1626,6 +1739,7 @@ class DiagnosisTableHost(QFrame):
def resizeEvent(self, event: QResizeEvent) -> None:
super().resizeEvent(event)
self._sync_fixed_height()
self._position_empty()
self._position_fixed_shadow()
@@ -1639,14 +1753,16 @@ class DiagnosisPager(QWidget):
def __init__(self, page_size: int = 15, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.setObjectName("DiagnosisPager")
self.setFixedHeight(42)
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
self.page = 1
self.page_size = page_size
self.total = 0
self._setting = False
self._page_buttons: list[QToolButton] = []
layout = QHBoxLayout(self)
layout.setContentsMargins(16, 10, 16, 16)
layout.setSpacing(8)
layout.setContentsMargins(12, 4, 12, 4)
layout.setSpacing(6)
layout.addStretch(1)
self.summary = QLabel("共 0 条")
self.summary.setProperty("pagerMuted", True)
@@ -2135,7 +2251,8 @@ __all__ = [
"DiagnosisLoadingOverlay",
"DiagnosisPager",
"DiagnosisTableHost",
"DiagnosisTableModel",
"FlowLayout",
"FlowWidget",
]
"DiagnosisTableModel",
"FlowLayout",
"FlowWidget",
"prescription_action",
]
@@ -1,5 +1,13 @@
"""Reusable doctor-workstation dialogs."""
from .ai_consult import AiConsultDialog, can_open_ai_consult, present_ai_consult
from .diagnosis import DiagnosisDialog, OrderDetailDrawer, present_order_detail
__all__ = ["DiagnosisDialog", "OrderDetailDrawer", "present_order_detail"]
__all__ = [
"AiConsultDialog",
"DiagnosisDialog",
"OrderDetailDrawer",
"can_open_ai_consult",
"present_ai_consult",
"present_order_detail",
]
File diff suppressed because it is too large Load Diff
@@ -3,6 +3,7 @@
from __future__ import annotations
import re
import sqlite3
from collections.abc import Mapping, Sequence
from contextlib import suppress
from typing import Any
@@ -69,6 +70,7 @@ from ..widgets import (
page_total,
run_async,
)
from .local_audio_queue import LocalAudioQueueDialog
from .prescription_ai import can_open_diagnosis_ai_report, present_diagnosis_ai_report
_PHONE_PERMISSION = "tcm.diagnosis/phonePlain"
@@ -747,6 +749,7 @@ class DiagnosisDialog(QDialog):
"""
saved = Signal()
_local_audio_upload_completed = Signal(int, int)
def __init__(
self,
@@ -841,6 +844,10 @@ class DiagnosisDialog(QDialog):
self._recording_players: list[RecordingPlayerDialog] = []
self._inline_recording_cells: list[RecordingPlaybackCell] = []
self._last_order_detail_dialog: QDialog | None = None
self._local_audio_dialog: LocalAudioQueueDialog | None = None
self._local_audio_upload_manager: Any = None
self._local_audio_upload_listener: Any = None
self._video_reload_pending = False
self._orders_page = 1
self._orders_page_size = 10
self._orders_total = 0
@@ -868,6 +875,10 @@ class DiagnosisDialog(QDialog):
self._daily_panels: list[DailyRecordPanel] = []
self._owner = None
self._owner_filter_installed = False
self._local_audio_upload_completed.connect(
self._local_audio_upload_succeeded,
type=Qt.ConnectionType.QueuedConnection,
)
self.setObjectName("DiagnosisDialogRoot")
self.setWindowFlags(Qt.WindowType.Dialog | Qt.WindowType.FramelessWindowHint)
@@ -1433,6 +1444,11 @@ class DiagnosisDialog(QDialog):
hint.setWordWrap(True)
toolbar.addWidget(hint, 1)
toolbar.addStretch(1)
self.local_audio_queue_button = QPushButton("本机录音文件")
self.local_audio_queue_button.setObjectName("DiagnosisLocalAudioQueueButton")
self.local_audio_queue_button.setAccessibleName("查看本机录音文件及上传状态")
self.local_audio_queue_button.clicked.connect(self._open_local_audio_queue)
toolbar.addWidget(self.local_audio_queue_button)
self.video_upload_button = QPushButton("上传本地回放")
self.video_upload_button.setProperty("variant", "primary")
self.video_upload_button.clicked.connect(
@@ -1556,10 +1572,10 @@ class DiagnosisDialog(QDialog):
),
"video": (
("录制回放", 440),
("房间号", 180),
("开始时间", 170),
("结束时间", 170),
("通话类型", 100),
("房间号", 180),
("时长", 110),
("状态", 90),
("录制 / 文字", 120),
@@ -1695,6 +1711,10 @@ class DiagnosisDialog(QDialog):
)
self.prescribe_button.setVisible(self._editable and self._can_prescribe)
self.video_upload_button.setVisible(self._editable and self._can_video_upload)
self.local_audio_queue_button.setVisible(self._can_video_upload)
self.local_audio_queue_button.setEnabled(
self._diagnosis_id > 0 and self._can_video_upload
)
offset_visible = self._editable and self._can_offset
self.order_offset.setVisible(offset_visible)
self.order_offset_save.setVisible(offset_visible)
@@ -1914,6 +1934,10 @@ class DiagnosisDialog(QDialog):
with suppress(RuntimeError):
self._last_order_detail_dialog.close()
self._last_order_detail_dialog = None
if self._local_audio_dialog is not None:
with suppress(RuntimeError):
self._local_audio_dialog.close()
self._local_audio_dialog = None
self._diagnosis_id = int(diagnosis_id)
self._patient_id = 0
self._editable = bool(
@@ -1939,6 +1963,7 @@ class DiagnosisDialog(QDialog):
self._tab_generations[key] += 1
self._loaded_tabs.clear()
self._loading_tabs.clear()
self._video_reload_pending = False
self._daily_todo_status = None
self._orders_page = 1
self._orders_total = 0
@@ -2208,6 +2233,8 @@ class DiagnosisDialog(QDialog):
self._ensure_tab_loaded(self._current_tab_key(), force=True)
def _invalidate_requests(self) -> None:
self._stop_watching_local_audio_uploads()
self._video_reload_pending = False
self._generation += 1
self._save_generation += 1
self._orders_generation += 1
@@ -2232,6 +2259,11 @@ class DiagnosisDialog(QDialog):
with suppress(RuntimeError):
order_dialog.close()
self._last_order_detail_dialog = None
local_audio_dialog = getattr(self, "_local_audio_dialog", None)
if local_audio_dialog is not None:
with suppress(RuntimeError):
local_audio_dialog.close()
self._local_audio_dialog = None
if hasattr(self, "readonly_loading"):
self.readonly_loading.hide()
if hasattr(self, "drawer_loading"):
@@ -2474,7 +2506,11 @@ class DiagnosisDialog(QDialog):
def _tab_changed(self, _index: int) -> None:
self._sync_save_button()
if self._authoritative_detail_loaded and not self._standalone_readonly:
self._ensure_tab_loaded(self._current_tab_key())
key = self._current_tab_key()
if key == "video":
self._reload_video_records(self._diagnosis_id)
else:
self._ensure_tab_loaded(key)
def _load_visible_readonly_sections(self) -> None:
permission_map = {key: codes for key, _label, codes in _TAB_DEFINITIONS}
@@ -2635,6 +2671,8 @@ class DiagnosisDialog(QDialog):
panel.set_messages(rows)
if not self._standalone_readonly and self._current_tab_key() == key:
self._clear_message()
if key == "video":
self._flush_video_reload_if_pending(diagnosis_id)
def _tab_load_error(
self,
@@ -2650,6 +2688,8 @@ class DiagnosisDialog(QDialog):
self._set_tab_error(key, text)
if not self._standalone_readonly and self._current_tab_key() == key:
self._show_message(text, "danger", action_text="重试")
if key == "video":
self._flush_video_reload_if_pending(diagnosis_id)
def _load_daily_range(self, start: str, end: str, *, force: bool = False) -> None:
key = "daily"
@@ -3116,6 +3156,91 @@ class DiagnosisDialog(QDialog):
"video",
)
def _open_local_audio_queue(self) -> None:
if self._diagnosis_id <= 0 or not self._can_video_upload:
self._show_message("当前账号无本机录音上传权限或接口不可用。", "warning")
return
current = self._local_audio_dialog
if current is not None and current.isVisible():
current.raise_()
current.activateWindow()
return
try:
dialog = LocalAudioQueueDialog(
self.repository,
self._diagnosis_id,
self,
)
except (OSError, RuntimeError, sqlite3.Error) as error:
self._show_message(f"无法打开本机录音文件:{error}", "danger")
return
self._local_audio_dialog = dialog
opened_diagnosis_id = self._diagnosis_id
self._watch_local_audio_uploads(dialog.manager)
def clear_dialog(_result: int) -> None:
if self._local_audio_dialog is dialog:
self._local_audio_dialog = None
dialog.finished.connect(clear_dialog)
dialog.open()
# Opening the queue commonly follows a background upload that completed
# before this dialog existed, so also refresh the server-backed rows now.
self._reload_video_records(opened_diagnosis_id)
def _local_audio_upload_succeeded(
self,
diagnosis_id: int,
_call_record_id: int,
) -> None:
if self._local_audio_upload_manager is None:
return
self._reload_video_records(diagnosis_id)
def _reload_video_records(self, diagnosis_id: int) -> None:
if int(diagnosis_id) != self._diagnosis_id:
return
if "video" in self._loading_tabs:
self._video_reload_pending = True
return
self._video_reload_pending = False
self._loaded_tabs.discard("video")
self._ensure_tab_loaded("video", force=True)
def _watch_local_audio_uploads(self, manager: Any) -> None:
if self._local_audio_upload_manager is manager:
return
self._stop_watching_local_audio_uploads()
def notify(record: Any) -> None:
with suppress(RuntimeError):
self._local_audio_upload_completed.emit(
int(record.diagnosis_id),
int(record.call_record_id or 0),
)
manager.add_upload_listener(notify)
self._local_audio_upload_manager = manager
self._local_audio_upload_listener = notify
def _stop_watching_local_audio_uploads(self) -> None:
manager = self._local_audio_upload_manager
listener = self._local_audio_upload_listener
self._local_audio_upload_manager = None
self._local_audio_upload_listener = None
if manager is not None and listener is not None:
with suppress(Exception):
manager.remove_upload_listener(listener)
def _flush_video_reload_if_pending(self, diagnosis_id: int) -> None:
if not self._video_reload_pending or diagnosis_id != self._diagnosis_id:
return
self._video_reload_pending = False
QTimer.singleShot(
0,
lambda expected_id=diagnosis_id: self._reload_video_records(expected_id),
)
def _sync_chat_archive(self) -> None:
if not self._can_chat_sync or self._diagnosis_id <= 0:
self._show_message("当前账号无聊天归档同步权限或接口不可用。", "warning")
@@ -3616,6 +3741,7 @@ class DiagnosisDialog(QDialog):
self._stop_inline_recordings()
matrix: list[tuple[Any, ...]] = []
row_urls: list[list[str]] = []
row_audio_urls: list[list[str]] = []
record_ids: list[int] = []
transcripts: list[str] = []
for row in rows:
@@ -3629,6 +3755,22 @@ class DiagnosisDialog(QDialog):
if url and url not in normalized:
normalized.append(url)
row_urls.append(normalized)
raw_audio_urls = first_value(
row,
"local_audio_urls_list",
"local_audio_urls",
default=[],
) or []
if isinstance(raw_audio_urls, str) or not isinstance(
raw_audio_urls, Sequence
):
raw_audio_urls = [raw_audio_urls]
normalized_audio: list[str] = []
for candidate in raw_audio_urls:
url = str(candidate or "").strip()
if url and url not in normalized_audio:
normalized_audio.append(url)
row_audio_urls.append(normalized_audio)
record_ids.append(_int(first_value(row, "id", "call_record_id"), 0))
transcript = self._call_transcript_text(row)
transcripts.append(transcript)
@@ -3651,22 +3793,41 @@ class DiagnosisDialog(QDialog):
recording_status = first_value(
row, "recording_status_text", "record_status_text", "record_status"
)
local_audio_status = first_value(
row,
"local_audio_status_text",
default="本机录音已保存" if normalized_audio else "无本机录音",
)
transcript_status = first_value(
row,
"transcription_status_text",
"transcript_status_text",
default="文字已生成" if transcript else "未生成文字",
)
room_id = str(
first_value(
row,
"room_id_text",
"room_id",
"room_no",
default="",
)
or ""
).strip()
matrix.append(
(
"" if normalized else "暂无录制回放",
room_id or "历史记录未保存",
first_value(row, "start_time_text", "start_time"),
first_value(row, "end_time_text", "end_time"),
call_type,
first_value(row, "room_id", "room_no"),
first_value(row, "duration_text", "duration"),
status,
f"{display_text(recording_status)}\n{display_text(transcript_status)}",
(
f"云端视频:{display_text(recording_status)}\n"
f"本机录音:{display_text(local_audio_status)}\n"
f"转写文字:{display_text(transcript_status)}"
),
"",
)
)
@@ -3692,6 +3853,19 @@ class DiagnosisDialog(QDialog):
item.setText("")
call_record_id = record_ids[row_index]
actions: list[QPushButton] = []
audio_urls = row_audio_urls[row_index]
if audio_urls and call_record_id > 0:
play_audio = self._action_button(
"播放录音", f"播放通话记录 #{call_record_id} 的本机混音录音"
)
play_audio.setObjectName("DiagnosisLocalAudioPlayback")
play_audio.setProperty("callRecordId", call_record_id)
play_audio.clicked.connect(
lambda _checked=False, target=audio_urls[0]: self._open_recording_player(
target
)
)
actions.append(play_audio)
transcript = transcripts[row_index]
if transcript and call_record_id > 0:
view_transcript = self._action_button(
@@ -0,0 +1,625 @@
"""Local call-audio upload queue shown from a diagnosis video-history tab."""
from __future__ import annotations
from collections.abc import Mapping
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any
from PySide6.QtCore import Qt, QTimer, QUrl
from PySide6.QtGui import QColor, QDesktopServices
from PySide6.QtWidgets import (
QAbstractItemView,
QDialog,
QFrame,
QHBoxLayout,
QHeaderView,
QLabel,
QPushButton,
QTableWidget,
QTableWidgetItem,
QVBoxLayout,
QWidget,
)
from ...services.local_audio_queue import (
LocalAudioQueueStore,
LocalAudioRecord,
LocalAudioUploadManager,
get_local_audio_upload_manager,
)
from ..widgets import run_async
_STATUS_LABELS = {
"recording": "录制中",
"pending": "待上传",
"uploading": "上传中",
"uploaded": "已上传",
"failed": "上传失败",
"invalid": "无效录音",
}
_STATUS_COLORS = {
"recording": "#5364F5",
"pending": "#B26A00",
"uploading": "#2F6FEB",
"uploaded": "#07966B",
"failed": "#DC4054",
"invalid": "#7886AA",
}
_BUSINESS_TIMEZONE = timezone(timedelta(hours=8))
_LOCAL_AUDIO_QSS = """
QDialog#LocalAudioQueueDialog {
background: #F6F8FD;
color: #111F46;
}
QFrame#LocalAudioQueueHeader, QFrame#LocalAudioQueueSummary,
QFrame#LocalAudioQueueTableCard, QFrame#LocalAudioQueueFooter {
background: #FFFFFF;
border: 1px solid #E2E7F4;
border-radius: 14px;
}
QLabel#LocalAudioQueueTitle {
color: #111F46;
font-size: 20px;
font-weight: 700;
}
QLabel#LocalAudioQueueSubtitle, QLabel#LocalAudioQueueHint {
color: #6E7C9F;
font-size: 13px;
}
QLabel[queueSummary="true"] {
background: #F3F5FB;
border: 1px solid #E6EAF5;
border-radius: 10px;
color: #3F4E75;
font-size: 13px;
font-weight: 600;
padding: 8px 12px;
}
QPushButton {
min-height: 34px;
border: 1px solid #D9E0F2;
border-radius: 9px;
background: #FFFFFF;
color: #354365;
padding: 0 14px;
font-weight: 600;
}
QPushButton:hover { background: #F1F3FF; border-color: #AEB8FF; }
QPushButton:disabled { color: #A5AFC6; background: #F7F8FC; }
QPushButton[variant="primary"] {
color: #FFFFFF;
background: #5661F4;
border-color: #5661F4;
}
QPushButton[variant="danger"] { color: #D83E51; background: #FFF6F7; }
QTableWidget#LocalAudioQueueTable {
background: #FFFFFF;
alternate-background-color: #FAFBFE;
border: 0;
gridline-color: #E8ECF5;
color: #263452;
selection-background-color: #EEF1FF;
selection-color: #111F46;
}
QTableWidget#LocalAudioQueueTable::item { padding: 8px; }
QHeaderView::section {
background: #F5F7FC;
color: #53617F;
border: 0;
border-bottom: 1px solid #E1E6F1;
padding: 10px 8px;
font-weight: 700;
}
"""
def _human_size(size: int) -> str:
value = max(0, int(size))
if value < 1024:
return f"{value} B"
if value < 1024 * 1024:
return f"{value / 1024:.1f} KB"
return f"{value / 1024 / 1024:.1f} MB"
def _display_time(value: str) -> str:
raw = str(value or "").strip()
if not raw:
return ""
try:
parsed = datetime.fromisoformat(raw.replace("Z", "+00:00"))
except ValueError:
return raw.replace("T", " ")
if parsed.tzinfo is not None:
parsed = parsed.astimezone(_BUSINESS_TIMEZONE)
return parsed.strftime("%Y-%m-%d %H:%M:%S")
class LocalAudioQueueDialog(QDialog):
"""Persistent local-audio list with concurrent upload and retry."""
def __init__(
self,
repository: Any,
diagnosis_id: int | None,
parent: QWidget | None = None,
*,
store: LocalAudioQueueStore | None = None,
manager: LocalAudioUploadManager | None = None,
) -> None:
super().__init__(parent)
self.repository = repository
self._global_scope = diagnosis_id is None
self.diagnosis_id = int(diagnosis_id) if diagnosis_id is not None else None
if manager is None:
self.store, self.manager = get_local_audio_upload_manager(repository, store)
else:
self.store = store or manager.store
self.manager = manager
self._signature: tuple[Any, ...] = ()
self._room_fetch_generation = 0
self._room_fetch_inflight: set[int] = set()
self._room_fetch_attempted: set[int] = set()
self._room_fetch_errors: dict[int, str] = {}
self._closed = False
self.setObjectName("LocalAudioQueueDialog")
self.setProperty("businessDialog", True)
self.setWindowTitle(
"本机录音上传管理" if self._global_scope else "本机录音文件"
)
self.setModal(True)
self.setMinimumSize(760, 480)
self.resize(1160, 620)
self.setStyleSheet(_LOCAL_AUDIO_QSS)
self._build_ui()
self.poll_timer = QTimer(self)
self.poll_timer.setInterval(500)
self.poll_timer.timeout.connect(self.refresh_records)
self.poll_timer.start()
self.manager.submit_pending(diagnosis_id=self.diagnosis_id)
self.refresh_records(force=True)
def _build_ui(self) -> None:
root = QVBoxLayout(self)
root.setContentsMargins(18, 18, 18, 18)
root.setSpacing(12)
header = QFrame()
header.setObjectName("LocalAudioQueueHeader")
header_layout = QVBoxLayout(header)
header_layout.setContentsMargins(18, 14, 18, 14)
header_layout.setSpacing(4)
self.title_label = QLabel(
"本机录音上传管理" if self._global_scope else "本机录音文件"
)
self.title_label.setObjectName("LocalAudioQueueTitle")
header_layout.addWidget(self.title_label)
if self._global_scope:
subtitle_text = (
"集中查看所有诊单的本机录音上传记录;成功文件和未成功文件都会保留。"
"最多同时上传 3 个文件,失败后可重试。"
)
else:
subtitle_text = (
"每次通话的本地录音会先保存在本机,再上传到 COS;最多同时上传 3 个文件。"
"上传失败不会删除文件,可随时重试。"
)
subtitle = QLabel(subtitle_text)
subtitle.setObjectName("LocalAudioQueueSubtitle")
subtitle.setWordWrap(True)
header_layout.addWidget(subtitle)
root.addWidget(header)
summary = QFrame()
summary.setObjectName("LocalAudioQueueSummary")
summary_layout = QHBoxLayout(summary)
summary_layout.setContentsMargins(12, 10, 12, 10)
summary_layout.setSpacing(8)
self.summary_total = self._summary_label()
self.summary_pending = self._summary_label()
self.summary_uploading = self._summary_label()
self.summary_uploaded = self._summary_label()
self.summary_failed = self._summary_label()
for label in (
self.summary_total,
self.summary_pending,
self.summary_uploading,
self.summary_uploaded,
self.summary_failed,
):
summary_layout.addWidget(label)
summary_layout.addStretch(1)
self.upload_pending_button = QPushButton("上传待处理")
self.upload_pending_button.setObjectName("LocalAudioUploadPendingButton")
self.upload_pending_button.setProperty("variant", "primary")
self.upload_pending_button.clicked.connect(self._upload_pending)
summary_layout.addWidget(self.upload_pending_button)
self.retry_failed_button = QPushButton("重试全部失败")
self.retry_failed_button.setObjectName("LocalAudioRetryAllButton")
self.retry_failed_button.setProperty("variant", "danger")
self.retry_failed_button.clicked.connect(self._retry_all_failed)
summary_layout.addWidget(self.retry_failed_button)
refresh_button = QPushButton("刷新")
refresh_button.setObjectName("LocalAudioRefreshButton")
refresh_button.clicked.connect(self._manual_refresh)
summary_layout.addWidget(refresh_button)
root.addWidget(summary)
card = QFrame()
card.setObjectName("LocalAudioQueueTableCard")
card_layout = QVBoxLayout(card)
card_layout.setContentsMargins(1, 1, 1, 1)
headers = ["录制时间"]
if self._global_scope:
headers.append("诊单 ID")
headers.extend(
[
"通话记录 ID",
"房间号",
"音频文件",
"大小",
"上传状态",
"失败原因",
"操作",
]
)
self._diagnosis_column = 1 if self._global_scope else None
self._call_column = 2 if self._global_scope else 1
self._room_column = 3 if self._global_scope else 2
self._file_column = 4 if self._global_scope else 3
self._size_column = 5 if self._global_scope else 4
self._status_column = 6 if self._global_scope else 5
self._error_column = 7 if self._global_scope else 6
self._action_column = 8 if self._global_scope else 7
self.table = QTableWidget(0, len(headers))
self.table.setObjectName("LocalAudioQueueTable")
self.table.setHorizontalHeaderLabels(headers)
self.table.verticalHeader().hide()
self.table.setAlternatingRowColors(True)
self.table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
self.table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
self.table.setWordWrap(False)
header_view = self.table.horizontalHeader()
header_view.setSectionResizeMode(QHeaderView.ResizeMode.Interactive)
header_view.setSectionResizeMode(
self._file_column, QHeaderView.ResizeMode.Stretch
)
header_view.setSectionResizeMode(
self._error_column, QHeaderView.ResizeMode.Stretch
)
self.table.setColumnWidth(0, 160)
if self._diagnosis_column is not None:
self.table.setColumnWidth(self._diagnosis_column, 90)
self.table.setColumnWidth(self._call_column, 90)
self.table.setColumnWidth(self._room_column, 180)
self.table.setColumnWidth(self._size_column, 88)
self.table.setColumnWidth(self._status_column, 100)
self.table.setColumnWidth(self._action_column, 190)
card_layout.addWidget(self.table)
root.addWidget(card, 1)
footer = QFrame()
footer.setObjectName("LocalAudioQueueFooter")
footer_layout = QHBoxLayout(footer)
footer_layout.setContentsMargins(14, 9, 14, 9)
hint = QLabel("已上传文件仍保留在本机;请按机构的数据保留制度定期归档。")
hint.setObjectName("LocalAudioQueueHint")
footer_layout.addWidget(hint, 1)
close_button = QPushButton("关闭")
close_button.setObjectName("LocalAudioQueueCloseButton")
close_button.setProperty("variant", "primary")
close_button.clicked.connect(self.accept)
footer_layout.addWidget(close_button)
root.addWidget(footer)
@staticmethod
def _summary_label() -> QLabel:
label = QLabel()
label.setProperty("queueSummary", True)
return label
def _records(self) -> list[LocalAudioRecord]:
return self.store.list_records(diagnosis_id=self.diagnosis_id)
def refresh_records(self, *, force: bool = False) -> None:
records = self._records()
self._schedule_room_lookups(records)
signature = tuple(
(
record.id,
record.diagnosis_id,
record.status,
record.error_text,
record.call_record_id,
record.room_id,
record.size_bytes,
record.uploaded_url,
record.exists,
record.attempts,
self._room_fetch_state(record),
)
for record in records
)
if not force and signature == self._signature:
return
self._signature = signature
self._update_summary(records)
self.table.setRowCount(len(records))
for row, record in enumerate(records):
self.table.setRowHeight(row, 54)
self._set_item(row, 0, _display_time(record.created_at))
if self._diagnosis_column is not None:
self._set_item(row, self._diagnosis_column, str(record.diagnosis_id))
self._set_item(
row, self._call_column, str(record.call_record_id or "待关联")
)
room_text, room_tooltip = self._room_display(record)
room_item = self._set_item(row, self._room_column, room_text)
room_item.setToolTip(room_tooltip)
file_item = self._set_item(row, self._file_column, record.file_path.name)
file_item.setToolTip(str(record.file_path))
self._set_item(row, self._size_column, _human_size(record.size_bytes))
status_item = self._set_item(
row,
self._status_column,
_STATUS_LABELS.get(record.status, record.status),
)
status_item.setForeground(QColor(_STATUS_COLORS.get(record.status, "#53617F")))
status_item.setToolTip(
f"已尝试 {record.attempts}"
+ (f"\nCOS{record.uploaded_url}" if record.uploaded_url else "")
)
error_item = self._set_item(
row, self._error_column, record.error_text or ""
)
error_item.setToolTip(record.error_text)
self.table.setCellWidget(row, self._action_column, self._actions(record))
def _room_fetch_state(self, record: LocalAudioRecord) -> str:
diagnosis_id = record.diagnosis_id
if record.room_id:
return record.room_id
if diagnosis_id in self._room_fetch_inflight:
return "loading"
if diagnosis_id in self._room_fetch_errors:
return f"error:{self._room_fetch_errors[diagnosis_id]}"
if diagnosis_id in self._room_fetch_attempted:
return "missing"
return "idle"
def _room_display(self, record: LocalAudioRecord) -> tuple[str, str]:
if record.room_id:
return record.room_id, record.room_id
if record.call_record_id is None:
return "", "尚未关联通话记录,无法取得房间号。"
diagnosis_id = record.diagnosis_id
if diagnosis_id in self._room_fetch_inflight:
return "查询中…", "正在从服务端通话记录查询房间号。"
error = self._room_fetch_errors.get(diagnosis_id)
if error:
return "获取失败", error
return "未记录", "服务端通话记录未保存房间号。"
def _schedule_room_lookups(self, records: list[LocalAudioRecord]) -> None:
if self._closed:
return
method = getattr(self.repository, "list_call_records", None)
if not callable(method):
method = getattr(self.repository, "get_call_records", None)
if not callable(method):
return
target_ids: dict[int, set[int]] = {}
for record in records:
call_record_id = int(record.call_record_id or 0)
if record.room_id or call_record_id <= 0:
continue
target_ids.setdefault(record.diagnosis_id, set()).add(call_record_id)
diagnosis_ids = tuple(
sorted(
diagnosis_id
for diagnosis_id in target_ids
if diagnosis_id not in self._room_fetch_inflight
and diagnosis_id not in self._room_fetch_attempted
)
)
if not diagnosis_ids:
return
generation = self._room_fetch_generation
self._room_fetch_inflight.update(diagnosis_ids)
self._room_fetch_attempted.update(diagnosis_ids)
def load_rooms() -> tuple[dict[tuple[int, int], str], dict[int, str]]:
rooms: dict[tuple[int, int], str] = {}
errors: dict[int, str] = {}
for diagnosis_id in diagnosis_ids:
try:
payload = method(diagnosis_id)
rows = payload if isinstance(payload, list) else []
for row in rows:
if not isinstance(row, Mapping):
continue
raw_record_id = row.get("id", row.get("call_record_id"))
try:
call_record_id = int(raw_record_id or 0)
except (TypeError, ValueError):
continue
if call_record_id not in target_ids[diagnosis_id]:
continue
room_id = str(
row.get("room_id")
or row.get("roomId")
or row.get("room_id_text")
or row.get("room_no")
or ""
).strip()
if room_id and room_id != "0":
rooms[(diagnosis_id, call_record_id)] = room_id
except Exception as error:
errors[diagnosis_id] = str(error)[:300] or "房间号获取失败。"
return rooms, errors
run_async(
load_rooms,
on_success=lambda result: self._apply_room_lookups(
generation,
diagnosis_ids,
result,
),
on_error=lambda error: self._fail_room_lookups(
generation,
diagnosis_ids,
error,
),
)
def _apply_room_lookups(
self,
generation: int,
diagnosis_ids: tuple[int, ...],
result: tuple[dict[tuple[int, int], str], dict[int, str]],
) -> None:
if self._closed or generation != self._room_fetch_generation:
return
rooms, errors = result
self._room_fetch_inflight.difference_update(diagnosis_ids)
self._room_fetch_errors.update(errors)
records_by_key: dict[tuple[int, int], list[LocalAudioRecord]] = {}
for record in self._records():
if record.call_record_id is None:
continue
key = (record.diagnosis_id, record.call_record_id)
records_by_key.setdefault(key, []).append(record)
for key, room_id in rooms.items():
for record in records_by_key.get(key, []):
try:
self.store.bind_identity(
record.id,
call_record_id=key[1],
room_id=room_id,
)
except (LookupError, RuntimeError, ValueError) as error:
self._room_fetch_errors[key[0]] = str(error)[:300]
self.refresh_records(force=True)
def _fail_room_lookups(
self,
generation: int,
diagnosis_ids: tuple[int, ...],
error: Exception,
) -> None:
if self._closed or generation != self._room_fetch_generation:
return
self._room_fetch_inflight.difference_update(diagnosis_ids)
message = str(error)[:300] or "房间号获取失败。"
self._room_fetch_errors.update(
{diagnosis_id: message for diagnosis_id in diagnosis_ids}
)
self.refresh_records(force=True)
def _manual_refresh(self) -> None:
self._room_fetch_generation += 1
self._room_fetch_inflight.clear()
self._room_fetch_attempted.clear()
self._room_fetch_errors.clear()
self.refresh_records(force=True)
def _set_item(self, row: int, column: int, text: str) -> QTableWidgetItem:
item = QTableWidgetItem(str(text))
item.setTextAlignment(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft)
self.table.setItem(row, column, item)
return item
def _update_summary(self, records: list[LocalAudioRecord]) -> None:
counts = {status: 0 for status in _STATUS_LABELS}
for record in records:
counts[record.status] = counts.get(record.status, 0) + 1
self.summary_total.setText(f"全部 {len(records)}")
self.summary_pending.setText(f"待上传 {counts['pending']}")
self.summary_uploading.setText(f"上传中 {counts['uploading']}")
self.summary_uploaded.setText(f"已上传 {counts['uploaded']}")
self.summary_failed.setText(f"失败 {counts['failed']}")
self.upload_pending_button.setEnabled(counts["pending"] > 0)
self.retry_failed_button.setEnabled(counts["failed"] > 0)
def _actions(self, record: LocalAudioRecord) -> QWidget:
host = QWidget()
layout = QHBoxLayout(host)
layout.setContentsMargins(4, 5, 4, 5)
layout.setSpacing(5)
if record.status == "pending":
upload = QPushButton("上传")
upload.setAccessibleName(f"上传录音 {record.file_path.name}")
upload.clicked.connect(lambda _checked=False, record_id=record.id: self._upload_one(record_id))
layout.addWidget(upload)
elif record.status == "failed":
retry = QPushButton("重试")
retry.setProperty("variant", "danger")
retry.setAccessibleName(f"重试上传录音 {record.file_path.name}")
retry.clicked.connect(lambda _checked=False, record_id=record.id: self._retry_one(record_id))
layout.addWidget(retry)
elif record.status == "uploading":
busy = QPushButton("上传中")
busy.setEnabled(False)
layout.addWidget(busy)
if record.exists:
reveal = QPushButton("打开位置")
reveal.setAccessibleName(f"打开录音文件位置 {record.file_path.name}")
reveal.clicked.connect(
lambda _checked=False, path=record.file_path: self._open_location(path)
)
layout.addWidget(reveal)
layout.addStretch(1)
return host
def _upload_pending(self) -> None:
self.manager.submit_pending(diagnosis_id=self.diagnosis_id)
self.refresh_records(force=True)
def _upload_one(self, record_id: int) -> None:
self.manager.submit(record_id)
self.refresh_records(force=True)
def _retry_one(self, record_id: int) -> None:
try:
self.store.retry(record_id)
except (OSError, RuntimeError, LookupError) as error:
self._show_local_error(str(error))
return
self.manager.submit(record_id)
self.refresh_records(force=True)
def _retry_all_failed(self) -> None:
for record in self._records():
if record.status != "failed":
continue
try:
self.store.retry(record.id)
except (OSError, RuntimeError, LookupError):
continue
self.manager.submit(record.id)
self.refresh_records(force=True)
def _show_local_error(self, message: str) -> None:
self.summary_failed.setText(str(message)[:160])
self.summary_failed.setToolTip(str(message))
@staticmethod
def _open_location(path: Path) -> None:
target = path.parent if path.parent.is_dir() else path
QDesktopServices.openUrl(QUrl.fromLocalFile(str(target)))
def done(self, result: int) -> None:
self._closed = True
self._room_fetch_generation += 1
if hasattr(self, "poll_timer"):
self.poll_timer.stop()
super().done(result)
@@ -5117,6 +5117,10 @@ class DiagnosisDetailDialog(QDialog):
source = _mapping(diagnosis)
self.repository = repository
self.permissions = permissions
self._order_detail_generation = 0
self._order_detail_order_id = 0
self._order_detail_table: QTableWidget | None = None
self._order_detail_button: QPushButton | None = None
self.setWindowTitle("诊单详情(只读)")
self.resize(880, 700)
root = QVBoxLayout(self)
@@ -5195,6 +5199,7 @@ class DiagnosisDetailDialog(QDialog):
table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
table.verticalHeader().hide()
table.horizontalHeader().setStretchLastSection(True)
self._order_detail_table = table
for row_index, row in enumerate(rows):
values = (
first_value(row, "order_no", "sn", "id"),
@@ -5212,36 +5217,78 @@ class DiagnosisDetailDialog(QDialog):
actions = QHBoxLayout()
view = QPushButton("查看订单详情")
view.setProperty("variant", "primary")
def open_selected() -> None:
row = table.currentRow()
item = table.item(row, 0) if row >= 0 else None
order = item.data(Qt.ItemDataRole.UserRole) if item is not None else None
if order is None:
return
order_id = _int(first_value(order, "id", "order_id"), 0)
if order_id > 0 and callable(getattr(self.repository, "get_prescription_order", None)):
try: # noqa: SIM105 - retain the embedded row if detail lookup fails
order = self.repository.get_prescription_order(order_id)
except Exception: # noqa: BLE001 - fall back to embedded row
pass
from .diagnosis import present_order_detail
present_order_detail(
self.window() if self.window() is not None else self,
order,
order_id=order_id,
permissions=self.permissions,
exec_=True,
)
view.clicked.connect(open_selected)
table.itemDoubleClicked.connect(lambda _item: open_selected())
self._order_detail_button = view
view.clicked.connect(self._open_selected_order)
table.itemDoubleClicked.connect(lambda _item: self._open_selected_order())
actions.addWidget(view)
actions.addStretch(1)
layout.addLayout(actions)
return host
def _set_order_detail_loading(self, loading: bool) -> None:
if self._order_detail_table is not None:
self._order_detail_table.setEnabled(not loading)
if self._order_detail_button is not None:
self._order_detail_button.setEnabled(not loading)
def _open_selected_order(self) -> None:
table = self._order_detail_table
if table is None:
return
row = table.currentRow()
item = table.item(row, 0) if row >= 0 else None
order = item.data(Qt.ItemDataRole.UserRole) if item is not None else None
if order is None:
return
order_id = _int(first_value(order, "id", "order_id"), 0)
self._order_detail_generation += 1
generation = self._order_detail_generation
self._order_detail_order_id = order_id
getter = getattr(self.repository, "get_prescription_order", None)
if order_id <= 0 or not callable(getter):
self._set_order_detail_loading(False)
self._present_order_detail(order, order_id)
return
self._set_order_detail_loading(True)
run_async(
lambda: getter(order_id),
on_success=lambda result: self._order_detail_success(result, order_id, generation),
on_error=lambda error: self._order_detail_error(error, order, order_id, generation),
on_finished=lambda: self._order_detail_finished(order_id, generation),
)
def _order_detail_success(self, order: Any, order_id: int, generation: int) -> None:
if generation != self._order_detail_generation or order_id != self._order_detail_order_id:
return
self._present_order_detail(order, order_id)
def _order_detail_error(
self,
_error: Exception,
fallback_order: Any,
order_id: int,
generation: int,
) -> None:
if generation != self._order_detail_generation or order_id != self._order_detail_order_id:
return
self._present_order_detail(fallback_order, order_id)
def _order_detail_finished(self, order_id: int, generation: int) -> None:
if generation == self._order_detail_generation and order_id == self._order_detail_order_id:
self._set_order_detail_loading(False)
def _present_order_detail(self, order: Any, order_id: int) -> None:
from .diagnosis import present_order_detail
present_order_detail(
self.window() if self.window() is not None else self,
order,
order_id=order_id,
permissions=self.permissions,
exec_=True,
)
class PrescriptionOrderDialog(QDialog):
"""Create a fulfilment order from one issued prescription."""
@@ -8,7 +8,7 @@ from collections.abc import Callable
from dataclasses import dataclass, field
from typing import Any
from PySide6.QtCore import Qt
from PySide6.QtCore import QCoreApplication, QEvent, Qt
from PySide6.QtWidgets import (
QDialog,
QFrame,
@@ -1086,11 +1086,15 @@ class PrescriptionAiReportDialog(QDialog):
def _clear_host(self) -> None:
while self.host_layout.count():
item = self.host_layout.takeAt(0)
widget = item.widget()
if widget is not None:
widget.setParent(None)
widget.deleteLater()
item = self.host_layout.takeAt(0)
widget = item.widget()
if widget is not None:
# Keep the parent until Qt processes the deferred deletion.
# Detaching a visible child promotes it to a native top-level
# window on Windows and causes a small title-bar window to flash.
widget.hide()
widget.deleteLater()
QCoreApplication.sendPostedEvents(widget, QEvent.Type.DeferredDelete)
def _add_label(
self,
File diff suppressed because it is too large Load Diff
@@ -29,6 +29,7 @@ from PySide6.QtWidgets import (
QMessageBox,
QPushButton,
QScrollArea,
QSizePolicy,
QSpinBox,
QTableWidget,
QTableWidgetItem,
@@ -44,8 +45,11 @@ from ..diagnosis_index_widgets import (
DiagnosisPager,
DiagnosisTableHost,
FlowWidget,
prescription_action,
video_call_is_live,
)
from ..dialogs import DiagnosisDialog
from ..dialogs.ai_consult import can_open_ai_consult, present_ai_consult
from ..dialogs.prescription import (
PrescriptionDetailDialog,
PrescriptionEditorDialog,
@@ -68,8 +72,12 @@ from ..widgets import (
show_toast,
)
_PAGE_HEADER_HEIGHT = 62
_STATUS_CARD_HEIGHT = 50
_FILTERS_COLLAPSED_HEIGHT = 90
CONSULTATIONS_REFERENCE_QSS = """
#DiagnosisIndex QWidget#PageHeader { min-height: 72px; }
#DiagnosisIndex QWidget#PageHeader { min-height: 62px; max-height: 62px; }
#DiagnosisIndex QLabel[role="pageTitle"] {
color: #15224A; font-size: 22px; font-weight: 700;
}
@@ -82,7 +90,7 @@ CONSULTATIONS_REFERENCE_QSS = """
background: #FFFFFF; border: 1px solid #E2E7F4; border-radius: 13px;
}
#DiagnosisIndex QFrame#DiagnosisStatusCard {
min-height: 60px; max-height: 60px;
min-height: 50px; max-height: 50px;
}
#DiagnosisIndex QFrame#DiagnosisStatusCard QToolButton[diagnosisChip="true"] {
min-height: 34px; max-height: 34px; min-width: 56px;
@@ -103,7 +111,7 @@ CONSULTATIONS_REFERENCE_QSS = """
min-width: 116px; max-width: 116px;
}
#DiagnosisIndex QFrame#DiagnosisFilterCard {
min-height: 106px;
min-height: 88px;
}
#DiagnosisIndex QWidget#DiagnosisDateFilters,
#DiagnosisIndex QWidget#DiagnosisSecondaryFilters {
@@ -147,7 +155,7 @@ CONSULTATIONS_REFERENCE_QSS = """
border-radius: 8px; font-size: 12px;
}
#DiagnosisIndex QFrame#DiagnosisListToolbar {
min-height: 54px; max-height: 54px; background: #FFFFFF;
min-height: 44px; max-height: 44px; background: #FFFFFF;
border-bottom: 1px solid #E7EBF5;
}
#DiagnosisIndex QFrame#DiagnosisListToolbar QPushButton {
@@ -167,7 +175,7 @@ CONSULTATIONS_REFERENCE_QSS = """
}
#DiagnosisIndex QTableView { background: #FFFFFF; alternate-background-color: #FBFCFF; }
#DiagnosisIndex QToolButton[rowLink] { font-size: 11px; padding: 2px; }
#DiagnosisIndex QWidget#DiagnosisPager { min-height: 48px; max-height: 48px; }
#DiagnosisIndex QWidget#DiagnosisPager { min-height: 42px; max-height: 42px; }
#DiagnosisIndex QToolButton[pagerButton="true"] {
min-width: 32px; min-height: 32px; max-height: 32px;
border: 1px solid #E2E7F4; border-radius: 7px; background: #FFFFFF;
@@ -367,6 +375,9 @@ def _video_payload(record: Any) -> dict[str, Any]:
"patient_id": first_value(record, "patient_id", "source_patient_id"),
"diagnosis_id": first_value(record, "diagnosis_id", "id"),
"patient_name": first_value(record, "patient_name", default="患者"),
# This page is the assistant/admin receiver. IM mode logs in and waits
# for the doctor's existing invitation; it must never call startCall.
"mode": "im",
"record": record,
}
@@ -383,17 +394,7 @@ def is_diagnosis_confirmed(record: Any) -> bool:
def prescription_action_label(record: Any) -> str:
audit = _as_int(first_value(record, "prescription_audit_status", "audit_status"), -1)
voided = _as_int(first_value(record, "prescription_void_status", "void_status"), 0)
explicit = first_value(record, "has_prescription", default=None)
has_prescription = (
_as_bool(explicit)
if explicit is not None
else _as_int(first_value(record, "prescription_id", default=0), 0) > 0 or audit in {0, 1, 2}
)
if not has_prescription:
return "开方"
return "查看处方" if audit == 1 and voided != 1 else "编辑处方"
return prescription_action(record)[0]
def can_void_prescription(record: Any) -> bool:
@@ -1068,30 +1069,31 @@ class ConsultationsPage(QWidget):
self.page_scroll.setWidgetResizable(True)
self.page_scroll.setFrameShape(QFrame.Shape.NoFrame)
self.page_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.page_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
root.addWidget(self.page_scroll)
content = QWidget()
content.setObjectName("DiagnosisIndexContent")
content.setAutoFillBackground(False)
self.page_scroll.setWidget(content)
page_layout = QVBoxLayout(content)
page_layout.setContentsMargins(20, 18, 29, 16)
page_layout.setSpacing(12)
page_layout.setContentsMargins(18, 10, 18, 10)
page_layout.setSpacing(8)
page_layout.addWidget(
PageHeader(
"问诊列表",
"按状态与日期管理患者队列,完成通话、开方与接诊闭环。",
content,
)
self.page_header = PageHeader(
"问诊列表",
"按状态与日期管理患者队列,完成通话、开方与接诊闭环。",
content,
)
self.page_header.setFixedHeight(_PAGE_HEADER_HEIGHT)
page_layout.addWidget(self.page_header)
status_card = QFrame()
status_card.setObjectName("DiagnosisStatusCard")
status_card.setFixedHeight(62)
status_card.setFixedHeight(_STATUS_CARD_HEIGHT)
self.status_card = status_card
status_card_layout = QHBoxLayout(status_card)
status_card_layout.setContentsMargins(12, 7, 12, 7)
status_card_layout.setSpacing(12)
status_card_layout.setContentsMargins(12, 5, 12, 5)
status_card_layout.setSpacing(10)
status_tabs = QWidget(status_card)
status_tabs.setObjectName("DiagnosisStatusTabs")
@@ -1160,8 +1162,8 @@ class ConsultationsPage(QWidget):
filters.setObjectName("DiagnosisFilterCard")
self.filters_card = filters
filter_layout = QVBoxLayout(filters)
filter_layout.setContentsMargins(14, 10, 14, 10)
filter_layout.setSpacing(7)
filter_layout.setContentsMargins(12, 6, 12, 6)
filter_layout.setSpacing(4)
date_filters = QWidget(filters)
date_filters.setObjectName("DiagnosisDateFilters")
@@ -1365,11 +1367,12 @@ class ConsultationsPage(QWidget):
advanced_layout.addWidget(self.advanced_filter_flow)
self.advanced_filters.hide()
filter_layout.addWidget(self.advanced_filters)
filters.setFixedHeight(108)
filters.setFixedHeight(_FILTERS_COLLAPSED_HEIGHT)
page_layout.addWidget(filters)
card = QFrame()
card.setObjectName("DiagnosisListCard")
card.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
self.list_card = card
card_layout = QVBoxLayout(card)
card_layout.setContentsMargins(0, 0, 0, 0)
@@ -1377,8 +1380,8 @@ class ConsultationsPage(QWidget):
toolbar = QFrame()
toolbar.setObjectName("DiagnosisListToolbar")
toolbar_layout = QHBoxLayout(toolbar)
toolbar_layout.setContentsMargins(14, 6, 14, 6)
toolbar_layout.setSpacing(8)
toolbar_layout.setContentsMargins(12, 4, 12, 4)
toolbar_layout.setSpacing(6)
self.add_button = QPushButton(" 新增患者", toolbar)
self.add_button.setProperty("variant", "primary")
self.add_button.setVisible(_canonical_allowed(permissions, "tcm.diagnosis/add"))
@@ -1479,14 +1482,16 @@ class ConsultationsPage(QWidget):
toolbar_layout.addWidget(self.refresh_button)
table_wrap = QWidget()
table_wrap.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
table_wrap_layout = QVBoxLayout(table_wrap)
table_wrap_layout.setContentsMargins(12, 8, 12, 0)
table_wrap_layout.setContentsMargins(12, 4, 12, 0)
table_wrap_layout.setSpacing(0)
self.table_host = DiagnosisTableHost(
action_policy={
"view": _canonical_allowed(permissions, "tcm.diagnosis/readonlyDetail"),
"edit": _canonical_allowed(permissions, "tcm.diagnosis/edit"),
"prescription": _canonical_allowed(permissions, "tcm.diagnosis/kaifang"),
"ai_consult": can_open_ai_consult(permissions),
"appointment": _canonical_allowed(permissions, "tcm.diagnosis/guahao"),
"assign": _canonical_allowed(permissions, "tcm.diagnosis/assign"),
"delete": _canonical_allowed(permissions, "tcm.diagnosis/delete"),
@@ -1526,15 +1531,14 @@ class ConsultationsPage(QWidget):
self.table_host.video_requested.connect(self._row_video)
self.table_host.checked_changed.connect(self._checked_changed)
self.table_host.sort_unserved_requested.connect(self._sort_unserved)
table_wrap_layout.addWidget(self.table_host)
table_wrap_layout.addWidget(self.table_host, 1)
self.loading_overlay = DiagnosisLoadingOverlay(self.table_host)
card_layout.addWidget(table_wrap)
card_layout.addWidget(table_wrap, 1)
self.pager = DiagnosisPager(self._page_size)
self.pager.page_changed.connect(self._change_page)
self.pager.page_size_changed.connect(self._change_page_size)
card_layout.addWidget(self.pager)
page_layout.addWidget(card)
page_layout.addStretch(1)
page_layout.addWidget(card, 1)
self.poll_timer = QTimer(self)
self.poll_timer.setInterval(20_000)
@@ -1703,7 +1707,9 @@ class ConsultationsPage(QWidget):
def _toggle_advanced_filters(self, checked: bool) -> None:
self.advanced_filters.setVisible(checked)
self.filters_card.setFixedHeight(
108 + self.advanced_filters.sizeHint().height() + 8 if checked else 108
_FILTERS_COLLAPSED_HEIGHT + self.advanced_filters.sizeHint().height() + 8
if checked
else _FILTERS_COLLAPSED_HEIGHT
)
self.more_filter_button.setText("收起" if checked else "更多筛选")
self.more_filter_button.setArrowType(
@@ -2244,6 +2250,7 @@ class ConsultationsPage(QWidget):
"view": self._open_readonly,
"edit": self._open_edit,
"prescription": self._open_prescription,
"ai_consult": self._open_ai_consult,
"appointment": self._book_selected_appointment,
"fill_id_card": self._fill_selected_id_card,
"assign": self._assign_selected,
@@ -2919,10 +2926,21 @@ class ConsultationsPage(QWidget):
self.edit_button.setEnabled(has_record)
self.delete_button.setEnabled(has_record)
self.prescription_button.setText(
prescription_action_label(record) if record is not None else "开方"
prescription_action(
record,
force_open=self.table_host.force_open_prescription,
)[0]
if record is not None
else "开方"
)
self.prescription_button.setEnabled(has_record and not self._prescription_busy)
row_has_prescription = _as_bool(first_value(record, "has_prescription", default=False))
row_has_prescription = _as_bool(
first_value(
record,
"current_has_prescription",
default=first_value(record, "has_prescription", default=False),
)
)
self.void_button.setEnabled(
has_record
and row_has_prescription
@@ -2934,7 +2952,12 @@ class ConsultationsPage(QWidget):
_as_int(payload.get(key), 0) > 0
for key in ("appointment_id", "patient_id", "diagnosis_id")
)
self.video_button.setEnabled(has_record and is_video_available(record) and valid_ids)
self.video_button.setEnabled(
has_record
and is_video_available(record)
and video_call_is_live(record)
and valid_ids
)
@property
def _diagnosis_dialog(self) -> DiagnosisDialog:
@@ -2954,6 +2977,34 @@ class ConsultationsPage(QWidget):
return
self._diagnosis_dialog.open_view_only(diagnosis_id, seed=record)
def open_selected_ai_consult(self) -> bool:
"""Open the assistant with the selected diagnosis as its authority key."""
record = self.table.current_data()
diagnosis_id = _as_int(first_value(record, "diagnosis_id", "id", default=0))
if (
record is None
or diagnosis_id <= 0
or not can_open_ai_consult(self.permissions)
):
return False
present_ai_consult(
self.repository,
self.permissions,
self,
diagnosis_id=diagnosis_id,
patient_id=_as_int(
first_value(record, "source_patient_id", "patient_id", default=0)
),
seed=record,
source_title="问诊列表",
)
return True
def _open_ai_consult(self) -> None:
if not self.open_selected_ai_consult():
show_toast(self, "请先选择一条有效诊单。", "warning")
def _open_edit(self) -> None:
if not _canonical_allowed(self.permissions, "tcm.diagnosis/edit"):
return
@@ -3049,7 +3100,11 @@ class ConsultationsPage(QWidget):
record = self.table.current_data()
if _as_int(first_value(record, "diagnosis_id", "id", default=0)) <= 0:
return
self._begin_prescription_load(record, mode="open")
_label, mode = prescription_action(
record,
force_open=self.table_host.force_open_prescription,
)
self._begin_prescription_load(record, mode=mode)
def _void_selected_prescription(self) -> None:
if not _canonical_allowed(self.permissions, "tcm.diagnosis/kaifang"):
@@ -3087,6 +3142,28 @@ class ConsultationsPage(QWidget):
if existing is not None:
approved = _as_int(first_value(existing, "audit_status", "status"), -1) == 1
voided = _as_int(first_value(existing, "void_status", "is_void"), 0) == 1
actual_id = _as_int(first_value(existing, "id", "prescription_id", default=0))
expected_id = _as_int(
first_value(record, "current_prescription_id", default=0)
)
actual_appointment_id = _as_int(
first_value(existing, "appointment_id", default=0)
)
expected_appointment_id = _appointment_id(record)
identity_changed = (
(expected_id > 0 and actual_id != expected_id)
or (
actual_appointment_id > 0
and expected_appointment_id > 0
and actual_appointment_id != expected_appointment_id
)
)
if mode == "view" and (not approved or voided or identity_changed):
self.banner.show_message(
"当前挂号的处方状态已变化,请刷新列表后重试。",
"warning",
)
return
if not approved or voided:
self._open_existing_prescription_editor(existing)
else:
@@ -3100,6 +3177,12 @@ class ConsultationsPage(QWidget):
detail.diagnosis_requested.connect(self._open_diagnosis_id)
detail.exec()
return
if mode == "view":
self.banner.show_message(
"当前挂号没有可查看的处方,请刷新列表后重试。",
"warning",
)
return
self._begin_case_record_load(record)
def _open_existing_prescription_editor(self, prescription: Any) -> None:
@@ -3312,6 +3395,9 @@ class ConsultationsPage(QWidget):
if record is None or not is_video_available(record):
self.banner.show_message("仅当前“已预约”的挂号可进入视频问诊。", "warning")
return
if not video_call_is_live(record):
self.banner.show_message("医生尚未发起视频会话,请等待会话开始。", "warning")
return
payload = _video_payload(record)
if not all(
_as_int(payload.get(key), 0) > 0
@@ -3324,7 +3410,11 @@ class ConsultationsPage(QWidget):
def _poll_refresh(self) -> None:
"""Refresh rows and chip counts without showing the table mask."""
if not self.isVisible() or self._order_flow_generation is not None:
if (
not self.isVisible()
or self._loading
or self._order_flow_generation is not None
):
return
self.refresh(silent=True)
self._refresh_counts()
+148 -79
View File
@@ -37,7 +37,8 @@ from PySide6.QtWidgets import (
)
from ..appointment_drawer import AppointmentDrawer
from ..dialogs import DiagnosisDialog, present_order_detail
from ..dialogs import DiagnosisDialog, present_ai_consult, present_order_detail
from ..dialogs.ai_consult import can_open_ai_consult
from ..dialogs.prescription import PrescriptionOrderListDialog
from ..theme import mark_business_dialog
from ..widgets import (
@@ -98,7 +99,7 @@ _SEMANTIC_COLORS = {
}
PATIENTS_LIGHT_QSS = """
#PatientsPage QWidget#PageHeader { min-height: 88px; max-height: 88px; }
#PatientsPage QWidget#PageHeader { min-height: 62px; max-height: 62px; }
#PatientsPage QWidget#PageHeader QLabel[role="pageTitle"] {
color: #10204A;
font-size: 20px;
@@ -109,8 +110,8 @@ PATIENTS_LIGHT_QSS = """
font-size: 12px;
}
#PatientsPage QPushButton[patientSearchAction="true"] {
min-height: 34px;
max-height: 34px;
min-height: 32px;
max-height: 32px;
color: #FFFFFF;
background-color: #5265F6;
border: 1px solid #5265F6;
@@ -122,24 +123,24 @@ PATIENTS_LIGHT_QSS = """
border-color: #4557E7;
}
#PatientsPage QFrame[patientListFilter="true"] {
min-height: 112px;
max-height: 112px;
min-height: 88px;
max-height: 88px;
background-color: #FFFFFF;
border: 1px solid #E6EAF5;
border-radius: 11px;
}
#PatientsPage QFrame[patientListFilter="true"] QLineEdit,
#PatientsPage QFrame[patientListFilter="true"] QDateEdit {
min-height: 34px;
max-height: 34px;
min-height: 32px;
max-height: 32px;
background-color: #FFFFFF;
border: 1px solid #E2E7F4;
border-radius: 7px;
}
#PatientsPage QPushButton[patientStatusChip="true"] {
min-height: 34px;
max-height: 34px;
padding: 0 13px;
min-height: 32px;
max-height: 32px;
padding: 0 10px;
color: #405074;
background-color: #F8F9FD;
border: 0;
@@ -155,9 +156,9 @@ PATIENTS_LIGHT_QSS = """
border: 1px solid #9EA8FF;
}
#PatientsPage QPushButton[patientQuickDate="true"] {
min-height: 34px;
max-height: 34px;
padding: 0 12px;
min-height: 32px;
max-height: 32px;
padding: 0 9px;
color: #29365C;
background-color: transparent;
border: 1px solid transparent;
@@ -179,7 +180,7 @@ PATIENTS_LIGHT_QSS = """
}
#PatientsPage QTabWidget#PatientWorkspaceTabs QTabBar::tab {
min-width: 86px;
min-height: 40px;
min-height: 34px;
padding: 0 6px;
margin-right: 8px;
color: #59698E;
@@ -193,9 +194,9 @@ PATIENTS_LIGHT_QSS = """
border-bottom-color: #5265F6;
}
#PatientsPage QPushButton[summaryCard="true"] {
min-height: 54px;
max-height: 54px;
padding: 0 13px;
min-height: 42px;
max-height: 42px;
padding: 0 11px;
color: #5265F6;
background-color: #F7F8FF;
border: 1px solid #E1E5FF;
@@ -1277,6 +1278,7 @@ class PatientListWorkspace(QWidget):
fill_id_requested = Signal(object)
cancel_requested = Signal(object)
orders_requested = Signal(object)
ai_consult_requested = Signal(object)
scope_changed = Signal(str)
def __init__(self, repository: Any, permissions: Any, parent: QWidget | None = None) -> None:
@@ -1289,15 +1291,18 @@ class PatientListWorkspace(QWidget):
self._date_mode = "all"
self._scope = "按权限加载"
self._setting_dates = False
self.setMinimumHeight(0)
root = QVBoxLayout(self)
root.setContentsMargins(0, 2, 0, 0)
root.setSpacing(10)
root.addWidget(self._build_filters())
root.setSpacing(6)
self.filter_card = self._build_filters()
root.addWidget(self.filter_card)
root.addLayout(self._build_summary())
self.banner = MessageBanner()
root.addWidget(self.banner)
root.addWidget(self._build_table(), 1)
self.table_card = self._build_table()
root.addWidget(self.table_card, 1)
@property
def scope(self) -> str:
@@ -1308,20 +1313,21 @@ class PatientListWorkspace(QWidget):
card.setObjectName("FilterBar")
card.setProperty("patientListFilter", True)
panel = QVBoxLayout(card)
panel.setContentsMargins(18, 18, 18, 12)
panel.setSpacing(10)
panel.setContentsMargins(12, 8, 12, 8)
panel.setSpacing(6)
top_row = QHBoxLayout()
top_row.setObjectName("PatientFilterTopRow")
top_row.setContentsMargins(0, 0, 0, 0)
top_row.setSpacing(0)
top_row.setSpacing(6)
top_row.setAlignment(Qt.AlignmentFlag.AlignLeft)
self.keyword_edit = QLineEdit()
self.keyword_edit.setObjectName("PatientKeywordInput")
self.keyword_edit.setMaximumWidth(620)
self.keyword_edit.setPlaceholderText("患者姓名 / 手机号 / 助理 / 医生")
self.keyword_edit.setClearButtonEnabled(True)
self.keyword_edit.setMinimumWidth(300)
self.keyword_edit.setMaximumWidth(605)
self.keyword_edit.returnPressed.connect(self.search)
top_row.addWidget(self.keyword_edit, 1)
top_row.addSpacing(21)
top_row.addWidget(self.keyword_edit, 3)
# Compatibility-only control: it is intentionally hidden and never
# inserted into a layout, so it needs an explicit parent to avoid
# becoming a transient top-level Windows HWND during page creation.
@@ -1332,11 +1338,12 @@ class PatientListWorkspace(QWidget):
self.status_combo.addItem("已完成", "completed")
self.status_combo.addItem("已过号", "missed")
self.status_combo.hide()
status_host = QWidget()
status_host.setFixedWidth(396)
status_row = QHBoxLayout(status_host)
self.status_host = QWidget()
self.status_host.setObjectName("PatientStatusFilterHost")
self.status_host.setMaximumWidth(440)
status_row = QHBoxLayout(self.status_host)
status_row.setContentsMargins(0, 0, 0, 0)
status_row.setSpacing(0)
status_row.setSpacing(2)
self.status_group = QButtonGroup(self)
self.status_group.setExclusive(True)
self.status_buttons: dict[str, QPushButton] = {}
@@ -1368,30 +1375,31 @@ class PatientListWorkspace(QWidget):
self.status_buttons[value] = button
status_row.addWidget(button, 1)
self.status_buttons[""].setChecked(True)
top_row.addWidget(status_host)
top_row.addStretch(1)
search = QPushButton("查询")
search.setProperty("variant", "primary")
search.setProperty("patientSearchAction", True)
search.setFixedWidth(72)
search.clicked.connect(self.search)
top_row.addWidget(search)
top_row.addSpacing(12)
reset = QPushButton("重置")
reset.setProperty("variant", "ghost")
reset.setFixedWidth(72)
reset.clicked.connect(self.reset_filters)
top_row.addWidget(reset)
top_row.addWidget(self.status_host, 2)
self.search_button = QPushButton("查询")
self.search_button.setObjectName("PatientSearchButton")
self.search_button.setProperty("variant", "primary")
self.search_button.setProperty("patientSearchAction", True)
self.search_button.clicked.connect(self.search)
top_row.addWidget(self.search_button)
self.reset_button = QPushButton("重置")
self.reset_button.setObjectName("PatientResetButton")
self.reset_button.setProperty("variant", "ghost")
self.reset_button.clicked.connect(self.reset_filters)
top_row.addWidget(self.reset_button)
panel.addLayout(top_row)
bottom_row = QHBoxLayout()
bottom_row.setObjectName("PatientFilterDateRow")
bottom_row.setContentsMargins(0, 0, 0, 0)
bottom_row.setSpacing(0)
quick_host = QWidget()
quick_host.setFixedWidth(730)
quick = QHBoxLayout(quick_host)
bottom_row.setSpacing(6)
bottom_row.setAlignment(Qt.AlignmentFlag.AlignLeft)
self.quick_host = QWidget()
self.quick_host.setObjectName("PatientQuickDateHost")
self.quick_host.setMaximumWidth(620)
quick = QHBoxLayout(self.quick_host)
quick.setContentsMargins(0, 0, 0, 0)
quick.setSpacing(0)
quick.setSpacing(2)
self.quick_group = QButtonGroup(self)
self.quick_group.setExclusive(True)
self.quick_buttons: dict[str, QPushButton] = {}
@@ -1412,16 +1420,16 @@ class PatientListWorkspace(QWidget):
self.quick_buttons[mode] = button
quick.addWidget(button, 1)
self.quick_buttons["all"].setChecked(True)
bottom_row.addWidget(quick_host)
bottom_row.addSpacing(20)
bottom_row.addWidget(self.quick_host, 3)
date_host = QWidget()
date_host.setMinimumWidth(390)
date_host.setMaximumWidth(476)
dates = QHBoxLayout(date_host)
self.date_host = QWidget()
self.date_host.setObjectName("PatientDateRangeHost")
self.date_host.setMaximumWidth(420)
dates = QHBoxLayout(self.date_host)
dates.setContentsMargins(0, 0, 0, 0)
dates.setSpacing(10)
dates.setSpacing(6)
self.start_date = QDateEdit(QDate.currentDate())
self.start_date.setObjectName("PatientStartDateEdit")
self.start_date.setCalendarPopup(True)
self.start_date.setDisplayFormat("yyyy-MM-dd")
self.start_date.setEnabled(False)
@@ -1430,28 +1438,28 @@ class PatientListWorkspace(QWidget):
separator = QLabel("~")
separator.setAlignment(Qt.AlignmentFlag.AlignCenter)
separator.setProperty("role", "muted")
separator.setFixedWidth(24)
dates.addWidget(separator)
self.end_date = QDateEdit(QDate.currentDate())
self.end_date.setObjectName("PatientEndDateEdit")
self.end_date.setCalendarPopup(True)
self.end_date.setDisplayFormat("yyyy-MM-dd")
self.end_date.setEnabled(False)
self.end_date.editingFinished.connect(self._custom_date_changed)
dates.addWidget(self.end_date, 1)
bottom_row.addWidget(date_host, 1)
bottom_row.addSpacing(20)
custom = QPushButton("自定义")
custom.setProperty("variant", "ghost")
custom.setFixedWidth(100)
custom.clicked.connect(lambda: self.set_date_mode("custom"))
bottom_row.addWidget(custom)
bottom_row.addStretch(1)
bottom_row.addWidget(self.date_host, 2)
self.custom_date_button = QPushButton("自定义")
self.custom_date_button.setObjectName("PatientCustomDateButton")
self.custom_date_button.setProperty("variant", "ghost")
self.custom_date_button.clicked.connect(lambda: self.set_date_mode("custom"))
bottom_row.addWidget(self.custom_date_button)
panel.addLayout(bottom_row)
return card
def _build_summary(self) -> QHBoxLayout:
layout = QHBoxLayout()
layout.setObjectName("PatientSummaryRow")
layout.setSpacing(8)
layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
self.summary_buttons: dict[str, QPushButton] = {}
for key, label, mode in (
("today", "今日预约", "today"),
@@ -1459,8 +1467,16 @@ class PatientListWorkspace(QWidget):
("day_after", "后天预约", "day_after"),
):
button = QPushButton(f"{label}\n0 人")
button.setObjectName(
{
"today": "PatientSummaryToday",
"tomorrow": "PatientSummaryTomorrow",
"day_after": "PatientSummaryDayAfter",
}[key]
)
button.setProperty("summaryCard", True)
button.setFixedHeight(56)
button.setFixedHeight(42)
button.setMaximumWidth(420)
button.setIcon(_summary_calendar_icon())
button.setIconSize(QSize(34, 34))
button.clicked.connect(lambda _checked=False, value=mode: self.set_date_mode(value))
@@ -1471,9 +1487,10 @@ class PatientListWorkspace(QWidget):
def _build_table(self) -> QWidget:
card = QFrame()
card.setObjectName("Card")
card.setMinimumHeight(0)
layout = QVBoxLayout(card)
layout.setContentsMargins(14, 12, 14, 12)
layout.setSpacing(8)
layout.setContentsMargins(10, 8, 10, 8)
layout.setSpacing(6)
heading = QHBoxLayout()
title = QLabel("患者列表")
title.setProperty("role", "sectionTitle")
@@ -1485,10 +1502,12 @@ class PatientListWorkspace(QWidget):
heading.addWidget(self.scope_label)
layout.addLayout(heading)
self.content_stack = QStackedWidget()
self.content_stack.setMinimumHeight(0)
host = QWidget()
host.setMinimumHeight(0)
host_layout = QVBoxLayout(host)
host_layout.setContentsMargins(0, 0, 0, 0)
host_layout.setSpacing(8)
host_layout.setSpacing(6)
self.table = SortableTable(
[
TableColumn("_selected", "", 40, alignment=Qt.AlignmentFlag.AlignCenter),
@@ -1524,17 +1543,23 @@ class PatientListWorkspace(QWidget):
TableColumn("confirmation_text", "确认信息", 88),
TableColumn("diagnosis_date_text", "诊单日期", 96),
TableColumn("phone_masked", "手机", 116),
TableColumn("_actions", "操作", 430),
TableColumn("_actions", "操作", 500),
]
)
self.table.setObjectName("PatientTable")
self.table.horizontalHeader().setFixedHeight(38)
self.table.setMinimumHeight(0)
self.table.horizontalHeader().setFixedHeight(34)
self.table.verticalHeader().setDefaultSectionSize(40)
self.table.itemSelectionChanged.connect(self._update_actions)
self.table.itemDoubleClicked.connect(lambda _item: self._open_selected_diagnosis())
host_layout.addWidget(self.table, 1)
host_layout.addLayout(self._build_actions())
self.bottom_actions = QWidget(host)
self.bottom_actions.setObjectName("PatientBottomActions")
self.bottom_actions.setLayout(self._build_actions())
self.bottom_actions.hide()
host_layout.addWidget(self.bottom_actions)
self.pager = Pager(self._page_size)
self.pager.setMaximumHeight(38)
self.pager.page_changed.connect(self._change_page)
host_layout.addWidget(self.pager)
self.content_stack.addWidget(host)
@@ -1594,6 +1619,11 @@ class PatientListWorkspace(QWidget):
value, can_edit
),
)
if can_open_ai_consult(self.permissions):
add_action(
"AI 分析",
lambda _checked=False, value=row: self.ai_consult_requested.emit(value),
)
if can_book:
add_action(
"预约",
@@ -1636,6 +1666,12 @@ class PatientListWorkspace(QWidget):
self.diagnosis_button.setProperty("variant", "primary")
self.diagnosis_button.clicked.connect(self._open_selected_diagnosis)
layout.addWidget(self.diagnosis_button)
self.ai_consult_button = QPushButton("AI 分析", self)
self.ai_consult_button.clicked.connect(
lambda: self._emit_selected(self.ai_consult_requested)
)
self.ai_consult_button.setVisible(can_open_ai_consult(self.permissions))
layout.addWidget(self.ai_consult_button)
self.appointment_button = QPushButton("预约", self)
self.appointment_button.clicked.connect(
lambda: self._emit_selected(self.appointment_requested)
@@ -1682,6 +1718,8 @@ class PatientListWorkspace(QWidget):
self.diagnosis_button.setVisible(editable or readable)
self.diagnosis_button.setText("诊单" if editable else "查看")
self.diagnosis_button.setEnabled(selected)
self.ai_consult_button.setVisible(can_open_ai_consult(self.permissions))
self.ai_consult_button.setEnabled(selected)
self.appointment_button.setVisible(can_book)
self.appointment_button.setEnabled(selected)
self.assign_button.setVisible(can_assign)
@@ -2606,19 +2644,20 @@ class PatientsPage(QWidget):
self._assistant_generation = 0
root = QVBoxLayout(self)
root.setContentsMargins(25, 4, 30, 14)
root.setContentsMargins(20, 2, 24, 10)
root.setSpacing(0)
header = PageHeader("我的患者", "患者、挂号与诊单按当前角色和部门数据范围展示。")
self.header = PageHeader("我的患者", "患者、挂号与诊单按当前角色和部门数据范围展示。")
self.scope_badge = StatusBadge("按权限加载", "neutral")
header.add_action(self.scope_badge)
self.header.add_action(self.scope_badge)
refresh = QPushButton("刷新")
refresh.setProperty("variant", "primary")
refresh.clicked.connect(self.refresh)
header.add_action(refresh)
root.addWidget(header)
self.header.add_action(refresh)
root.addWidget(self.header)
self.tabs = QTabWidget()
self.tabs.setObjectName("PatientWorkspaceTabs")
self.tabs.setMinimumHeight(0)
self.patient_workspace = PatientListWorkspace(repository, permissions)
self.order_workspace = PatientOrdersWorkspace(repository, permissions)
self.progress_workspace = PatientProgressWorkspace(repository)
@@ -2628,6 +2667,7 @@ class PatientsPage(QWidget):
root.addWidget(self.tabs, 1)
self.patient_workspace.diagnosis_requested.connect(self._open_diagnosis)
self.patient_workspace.ai_consult_requested.connect(self._open_ai_consult)
self.patient_workspace.appointment_requested.connect(self._book_appointment)
self.patient_workspace.assign_requested.connect(self._load_assistants)
self.patient_workspace.fill_id_requested.connect(self._fill_id_card)
@@ -2689,6 +2729,35 @@ class PatientsPage(QWidget):
else:
self._ensure_diagnosis_dialog().open_view_only(diagnosis_id, seed=row)
def _open_ai_consult(self, row: Any) -> None:
present_ai_consult(
self.repository,
self.permissions,
self,
diagnosis_id=self._diagnosis_id(row),
patient_id=_as_int(first_value(row, "patient_id", "source_patient_id", default=0)),
seed=row,
source_title="我的患者",
)
def open_selected_ai_consult(self) -> bool:
"""Open AI chat for the selected row in the active patient workspace."""
if not can_open_ai_consult(self.permissions):
return False
workspace = self.tabs.currentWidget()
row = None
for table_name in ("table", "queue_table"):
table = getattr(workspace, table_name, None)
current_data = getattr(table, "current_data", None)
if callable(current_data):
row = current_data()
break
if row is None or self._diagnosis_id(row) <= 0:
return False
self._open_ai_consult(row)
return True
def _open_order_diagnosis(self, row: Any) -> None:
editable = _canonical_allowed(self.permissions, "tcm.diagnosis/edit")
readable = _canonical_allowed(self.permissions, "tcm.diagnosis/readonlyDetail")
@@ -8,7 +8,6 @@ from typing import Any
from PySide6.QtCore import QSize, Qt
from PySide6.QtGui import QColor, QFont
from PySide6.QtWidgets import (
QAbstractItemView,
QComboBox,
QDialog,
QFrame,
@@ -26,6 +25,7 @@ from PySide6.QtWidgets import (
from ..dialogs.prescription import PrescriptionTemplateDialog
from ..dialogs.prescription_ai import PrescriptionAiReportDialog, can_open_ai_explain
from ..widgets import (
BusinessPager,
EmptyState,
MessageBanner,
MetricCard,
@@ -44,7 +44,6 @@ from ..widgets import (
show_toast,
)
from .prescriptions import (
BusinessPager,
_cell_host,
_painted_icon,
_row_action_button,
@@ -54,27 +53,27 @@ from .prescriptions import (
PRESCRIPTION_LIBRARY_PAGE_QSS = """
#PrescriptionLibraryPage { background: #F8FAFF; }
#PrescriptionLibraryPage QWidget#PageHeader { min-height: 84px; max-height: 84px; }
#PrescriptionLibraryPage QWidget#PageHeader { min-height: 62px; max-height: 62px; }
#PrescriptionLibraryPage QWidget#PageHeader QLabel[role="breadcrumb"],
#PrescriptionLibraryPage QWidget#PageHeader QLabel[role="breadcrumbSeparator"],
#PrescriptionLibraryPage QWidget#PageHeader QLabel[role="breadcrumbCurrent"] {
min-height: 16px; max-height: 16px;
min-height: 14px; max-height: 14px;
}
#PrescriptionLibraryPage QLabel[role="pageTitle"] {
color: #15224A; font-size: 20px; font-weight: 700;
}
#PrescriptionLibraryPage QWidget#PageHeader QLabel[role="muted"] {
color: #7481A3; font-size: 12px; padding-top: 5px;
color: #7481A3; font-size: 12px;
}
#PrescriptionLibraryPage QFrame#MetricCard {
min-height: 80px; max-height: 80px;
min-height: 64px; max-height: 64px;
border: 1px solid #E2E7F4; border-radius: 12px; background: #FFFFFF;
}
#PrescriptionLibraryPage QFrame#MetricCard QLabel[role="metricTitle"] {
color: #405074; font-size: 12px; font-weight: 600;
}
#PrescriptionLibraryPage QFrame#MetricCard QLabel[role="metricValue"] {
color: #5265F6; font-size: 22px; font-weight: 700;
color: #5265F6; font-size: 20px; font-weight: 700;
}
#PrescriptionLibraryPage QLabel[metricIcon="true"] {
border: 1px solid #DCE3FF; border-radius: 11px; background: #EEF1FF;
@@ -97,7 +96,7 @@ PRESCRIPTION_LIBRARY_PAGE_QSS = """
border-top-left-radius: 13px; border-top-right-radius: 13px;
}
#PrescriptionLibraryPage QFrame#PrescriptionLibraryToolbar QPushButton[toolbarTab="true"] {
min-width: 82px; min-height: 36px; max-height: 36px;
min-width: 82px; min-height: 32px; max-height: 32px;
padding: 0 8px; margin: 0 4px 0 0;
color: #59698E; background: transparent; border: 0;
border-bottom: 2px solid transparent; border-radius: 0;
@@ -107,7 +106,7 @@ PRESCRIPTION_LIBRARY_PAGE_QSS = """
}
#PrescriptionLibraryPage QFrame#PrescriptionLibraryFilterBar QLineEdit,
#PrescriptionLibraryPage QFrame#PrescriptionLibraryFilterBar QComboBox {
min-height: 36px; max-height: 36px; padding: 0 11px;
min-height: 32px; max-height: 32px; padding: 0 11px;
border-radius: 8px; font-size: 12px;
}
#PrescriptionLibraryPage QPushButton {
@@ -159,11 +158,14 @@ PRESCRIPTION_LIBRARY_PAGE_QSS = """
min-width: 34px; max-width: 34px; min-height: 32px; max-height: 32px;
padding: 0; background: #FFFFFF; color: #405074; border-color: #E2E7F4;
}
#PrescriptionLibraryPage QWidget#BusinessPager QPushButton {
min-height: 32px; max-height: 32px;
}
#PrescriptionLibraryPage QWidget#BusinessPager QPushButton[pagerPage="true"][active="true"] {
background: #5265F6; color: #FFFFFF; border-color: #5265F6;
}
#PrescriptionLibraryPage QWidget#BusinessPager QComboBox {
min-height: 32px; max-height: 32px; min-width: 92px; padding: 0 9px;
#PrescriptionLibraryPage QWidget#BusinessPager QLabel[pagerSize="true"] {
min-width: 64px; color: #7481A3; font-size: 12px;
}
"""
@@ -214,13 +216,13 @@ def _efficacy_text(_value: Any, row: Any) -> str:
def _metric_card(title: str, kind: str = "accent") -> MetricCard:
card = MetricCard(title, "0", kind=kind, glyph="")
card.setFixedHeight(80)
card.layout().setContentsMargins(18, 12, 16, 12)
card.setFixedHeight(64)
card.layout().setContentsMargins(16, 8, 14, 8)
icon = QLabel(card)
icon.setProperty("metricIcon", True)
icon.setProperty("kind", kind)
icon.setAlignment(Qt.AlignmentFlag.AlignCenter)
icon.setFixedSize(42, 42)
icon.setFixedSize(36, 36)
colors = {
"accent": "#5365F5",
"info": "#8268E8",
@@ -261,6 +263,7 @@ class PrescriptionLibraryPage(QWidget):
"处方库",
"管理常用处方模板,支持 AI 解析辅助开方。",
)
header.layout().setSpacing(4)
header.actions.setSpacing(16)
self.new_button = QPushButton("新增处方", header)
self.new_button.setMinimumWidth(124)
@@ -274,8 +277,8 @@ class PrescriptionLibraryPage(QWidget):
root.addWidget(header)
metrics = QHBoxLayout()
metrics.setContentsMargins(0, 0, 0, 5)
metrics.setSpacing(24)
metrics.setContentsMargins(0, 0, 0, 0)
metrics.setSpacing(16)
self.metric_cards = {
"total": _metric_card("全部处方"),
"private": _metric_card("仅自己", "info"),
@@ -295,9 +298,10 @@ class PrescriptionLibraryPage(QWidget):
filters = QFrame()
filters.setObjectName("PrescriptionLibraryFilterBar")
filters.setFixedHeight(52)
grid = QGridLayout(filters)
grid.setContentsMargins(16, 11, 16, 11)
grid.setHorizontalSpacing(20)
grid.setContentsMargins(16, 9, 16, 9)
grid.setHorizontalSpacing(12)
self.name_filter = QLineEdit()
self.name_filter.setPlaceholderText("搜索处方名称、药材、功效等关键词")
self.name_filter.setClearButtonEnabled(True)
@@ -319,10 +323,10 @@ class PrescriptionLibraryPage(QWidget):
self.effect_filter.addItem("清热祛湿", "清热祛湿")
self.effect_filter.addItem("滋阴补肾", "滋阴补肾")
grid.addWidget(self.effect_filter, 0, 3)
self.name_filter.setMinimumWidth(500)
self.formula_filter.setFixedWidth(190)
self.visibility_filter.setFixedWidth(174)
self.effect_filter.setFixedWidth(190)
self.name_filter.setMinimumWidth(220)
self.formula_filter.setMinimumWidth(132)
self.visibility_filter.setMinimumWidth(148)
self.effect_filter.setMinimumWidth(144)
self.query_button = QPushButton("查询")
self.query_button.setFixedWidth(66)
self.query_button.setProperty("variant", "secondary")
@@ -335,7 +339,10 @@ class PrescriptionLibraryPage(QWidget):
self.reset_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.reset_button.clicked.connect(self._reset_filters)
grid.addWidget(self.reset_button, 0, 5)
grid.setColumnStretch(0, 1)
grid.setColumnStretch(0, 4)
grid.setColumnStretch(1, 1)
grid.setColumnStretch(2, 1)
grid.setColumnStretch(3, 1)
root.addWidget(filters)
self.banner = MessageBanner()
@@ -347,8 +354,9 @@ class PrescriptionLibraryPage(QWidget):
card_layout.setSpacing(0)
toolbar_host = QFrame(card)
toolbar_host.setObjectName("PrescriptionLibraryToolbar")
toolbar_host.setFixedHeight(46)
toolbar = QHBoxLayout(toolbar_host)
toolbar.setContentsMargins(16, 10, 16, 9)
toolbar.setContentsMargins(16, 7, 16, 7)
toolbar.setSpacing(8)
self.all_tab = QPushButton("处方列表", toolbar_host)
self.all_tab.setProperty("toolbarTab", True)
@@ -423,10 +431,9 @@ class PrescriptionLibraryPage(QWidget):
TableColumn("__actions__", "操作", 160, lambda _value, _row: ""),
]
)
self.table.verticalHeader().setDefaultSectionSize(37)
self.table.horizontalHeader().setFixedHeight(41)
self.table.verticalHeader().setDefaultSectionSize(36)
self.table.horizontalHeader().setFixedHeight(38)
self.table.setWordWrap(False)
self.table.setHorizontalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
self.table.itemSelectionChanged.connect(self._selection_changed)
self.table.itemDoubleClicked.connect(lambda _item: self._view_selected())
table_layout.addWidget(self.table, 1)
@@ -6,10 +6,9 @@ from collections.abc import Callable, Mapping
from datetime import datetime, timedelta
from typing import Any
from PySide6.QtCore import QDateTime, QRectF, QSize, Qt, Signal
from PySide6.QtCore import QDateTime, QRectF, QSize, Qt
from PySide6.QtGui import QColor, QFont, QIcon, QPainter, QPen, QPixmap
from PySide6.QtWidgets import (
QAbstractItemView,
QComboBox,
QDateTimeEdit,
QDialog,
@@ -38,6 +37,7 @@ from ..dialogs.prescription import (
PrescriptionOrderListDialog,
)
from ..widgets import (
BusinessPager,
EmptyState,
MessageBanner,
PageHeader,
@@ -57,17 +57,17 @@ from ..widgets import (
PRESCRIPTIONS_PAGE_QSS = """
#PrescriptionsPage { background: #F8FAFF; }
#PrescriptionsPage QWidget#PageHeader { min-height: 84px; max-height: 84px; }
#PrescriptionsPage QWidget#PageHeader { min-height: 62px; max-height: 62px; }
#PrescriptionsPage QWidget#PageHeader QLabel[role="breadcrumb"],
#PrescriptionsPage QWidget#PageHeader QLabel[role="breadcrumbSeparator"],
#PrescriptionsPage QWidget#PageHeader QLabel[role="breadcrumbCurrent"] {
min-height: 16px; max-height: 16px;
min-height: 14px; max-height: 14px;
}
#PrescriptionsPage QLabel[role="pageTitle"] {
color: #15224A; font-size: 20px; font-weight: 700;
}
#PrescriptionsPage QWidget#PageHeader QLabel[role="muted"] {
color: #7481A3; font-size: 12px; padding-top: 5px;
color: #7481A3; font-size: 12px;
}
#PrescriptionsPage QFrame#PrescriptionFilterBar,
#PrescriptionsPage QFrame#PrescriptionTableCard {
@@ -85,7 +85,7 @@ PRESCRIPTIONS_PAGE_QSS = """
#PrescriptionsPage QFrame#PrescriptionFilterBar QLineEdit,
#PrescriptionsPage QFrame#PrescriptionFilterBar QComboBox,
#PrescriptionsPage QFrame#PrescriptionFilterBar QDateTimeEdit {
min-height: 34px; max-height: 34px; padding: 0 11px;
min-height: 32px; max-height: 32px; padding: 0 11px;
border-radius: 8px; font-size: 12px;
}
#PrescriptionsPage QPushButton {
@@ -142,11 +142,14 @@ PRESCRIPTIONS_PAGE_QSS = """
min-width: 34px; max-width: 34px; min-height: 32px; max-height: 32px;
padding: 0; background: #FFFFFF; color: #405074; border-color: #E2E7F4;
}
#PrescriptionsPage QWidget#BusinessPager QPushButton {
min-height: 32px; max-height: 32px;
}
#PrescriptionsPage QWidget#BusinessPager QPushButton[pagerPage="true"][active="true"] {
background: #5265F6; color: #FFFFFF; border-color: #5265F6;
}
#PrescriptionsPage QWidget#BusinessPager QComboBox {
min-height: 32px; max-height: 32px; min-width: 92px; padding: 0 9px;
#PrescriptionsPage QWidget#BusinessPager QLabel[pagerSize="true"] {
min-width: 64px; color: #7481A3; font-size: 12px;
}
"""
@@ -276,88 +279,6 @@ def _row_action_button(
return button
class BusinessPager(QWidget):
"""Reference-style numbered pager while retaining the page's fixed size contract."""
page_changed = Signal(int)
def __init__(self, page_size: int = 15, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.setObjectName("BusinessPager")
self.page = 1
self.page_size = page_size
self.total = 0
layout = QHBoxLayout(self)
layout.setContentsMargins(0, 6, 0, 0)
layout.setSpacing(7)
self.summary = QLabel("共 0 条", self)
self.summary.setProperty("role", "muted")
layout.addWidget(self.summary)
layout.addStretch(1)
self.previous = QPushButton("", self)
self.previous.setProperty("variant", "ghost")
self.previous.clicked.connect(lambda: self._request(self.page - 1))
layout.addWidget(self.previous)
self.pages_host = QWidget(self)
self.pages_layout = QHBoxLayout(self.pages_host)
self.pages_layout.setContentsMargins(0, 0, 0, 0)
self.pages_layout.setSpacing(5)
layout.addWidget(self.pages_host)
self.next = QPushButton("", self)
self.next.setProperty("variant", "ghost")
self.next.clicked.connect(lambda: self._request(self.page + 1))
layout.addWidget(self.next)
self.size_combo = QComboBox(self)
self.size_combo.addItem(f"{page_size} 条/页", page_size)
layout.addWidget(self.size_combo)
self.page_label: QPushButton | None = None
self.update_state(1, 0)
@property
def page_count(self) -> int:
return max(1, (self.total + self.page_size - 1) // self.page_size)
def update_state(self, page: int, total: int) -> None:
self.page = max(1, page)
self.total = max(0, total)
self.summary.setText(f"{self.total}")
while self.pages_layout.count():
item = self.pages_layout.takeAt(0)
if item.widget() is not None:
item.widget().deleteLater()
count = self.page_count
if count <= 4:
pages: list[int | None] = list(range(1, count + 1))
elif self.page <= 3:
pages = [1, 2, 3, None, count]
elif self.page >= count - 2:
pages = [1, None, count - 2, count - 1, count]
else:
pages = [1, None, self.page, None, count]
self.page_label = None
for number in pages:
if number is None:
ellipsis = QLabel("", self.pages_host)
ellipsis.setAlignment(Qt.AlignmentFlag.AlignCenter)
ellipsis.setFixedWidth(24)
self.pages_layout.addWidget(ellipsis)
continue
button = QPushButton(str(number), self.pages_host)
button.setProperty("pagerPage", True)
button.setProperty("active", number == self.page)
button.setCursor(Qt.CursorShape.PointingHandCursor)
button.clicked.connect(lambda _checked=False, value=number: self._request(value))
self.pages_layout.addWidget(button)
if number == self.page:
self.page_label = button
self.previous.setEnabled(self.page > 1)
self.next.setEnabled(self.page < count)
def _request(self, page: int) -> None:
if 1 <= page <= self.page_count and page != self.page:
self.page_changed.emit(page)
def _int(value: Any, default: int = 0) -> int:
try:
return int(value)
@@ -609,6 +530,7 @@ class PrescriptionsPage(QWidget):
"已开处方",
"管理处方审核、患者修正与履约订单;已通过且未作废的处方只允许查看。",
)
header.layout().setSpacing(4)
header.actions.setSpacing(16)
self.orders_button = QPushButton("业务订单", header)
self.orders_button.setMinimumWidth(86)
@@ -635,10 +557,11 @@ class PrescriptionsPage(QWidget):
def _build_filters(self) -> QWidget:
frame = QFrame()
frame.setObjectName("PrescriptionFilterBar")
frame.setFixedHeight(88)
grid = QGridLayout(frame)
grid.setContentsMargins(14, 17, 14, 17)
grid.setContentsMargins(16, 8, 16, 8)
grid.setHorizontalSpacing(18)
grid.setVerticalSpacing(15)
grid.setVerticalSpacing(8)
self.quick_date = QComboBox()
self.quick_date.addItem("全部时间", "all")
self.quick_date.addItem("今日", "today")
@@ -712,8 +635,9 @@ class PrescriptionsPage(QWidget):
layout.setSpacing(0)
toolbar_host = QFrame(card)
toolbar_host.setObjectName("PrescriptionToolbar")
toolbar_host.setFixedHeight(46)
toolbar = QHBoxLayout(toolbar_host)
toolbar.setContentsMargins(16, 11, 16, 11)
toolbar.setContentsMargins(16, 7, 16, 7)
toolbar.setSpacing(8)
title = QLabel("处方列表")
title.setProperty("role", "sectionTitle")
@@ -772,7 +696,6 @@ class PrescriptionsPage(QWidget):
self.table.verticalHeader().setDefaultSectionSize(36)
self.table.horizontalHeader().setFixedHeight(38)
self.table.setWordWrap(False)
self.table.setHorizontalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
self.table.horizontalHeaderItem(0).setIcon(_painted_icon("checkbox", "#AEB9D4", 14))
self.table.horizontalHeaderItem(0).setTextAlignment(Qt.AlignmentFlag.AlignCenter)
self.table.itemSelectionChanged.connect(self._selection_changed)
@@ -1434,6 +1357,7 @@ class PrescriptionsPage(QWidget):
__all__ = [
"BusinessPager",
"PrescriptionsPage",
"can_audit",
"can_create_order",
File diff suppressed because it is too large Load Diff
+208 -33
View File
@@ -2,11 +2,13 @@
from __future__ import annotations
import sqlite3
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from functools import wraps
from typing import Any
from PySide6.QtCore import QPointF, QRectF, QSize, Qt, Signal
from PySide6.QtCore import QPointF, QRectF, QSize, Qt, QTimer, Signal
from PySide6.QtGui import (
QColor,
QFont,
@@ -21,6 +23,7 @@ from PySide6.QtGui import (
QShortcut,
)
from PySide6.QtWidgets import (
QApplication,
QButtonGroup,
QFrame,
QHBoxLayout,
@@ -37,6 +40,8 @@ from PySide6.QtWidgets import (
QWidget,
)
from .dialogs.ai_consult import can_open_ai_consult
from .dialogs.local_audio_queue import LocalAudioQueueDialog
from .pages import (
AppointmentsPage,
ConsultationsPage,
@@ -60,6 +65,25 @@ _SHELL_TOPBAR_HEIGHT = 62
_SHELL_TABS_HEIGHT = 0
_SHELL_OUTER_GUTTER = 13
_SHELL_PANEL_GAP = 0
_SHELL_DESIGN_SIZE = QSize(1710, 920)
_SHELL_MINIMUM_SIZE = QSize(1024, 640)
def _bounded_initial_window_size(available_size: QSize | None) -> QSize:
"""Fit the design viewport inside the screen's logical available geometry."""
if available_size is None or not available_size.isValid():
return QSize(_SHELL_DESIGN_SIZE.width(), _SHELL_DESIGN_SIZE.height())
return QSize(
max(
_SHELL_MINIMUM_SIZE.width(),
min(_SHELL_DESIGN_SIZE.width(), available_size.width()),
),
max(
_SHELL_MINIMUM_SIZE.height(),
min(_SHELL_DESIGN_SIZE.height(), available_size.height()),
),
)
# The references place the workspace at page-specific global x anchors while
# keeping a 13 px outer gutter. These are the actual rail widths inside that
@@ -659,24 +683,29 @@ class _PaintedIconButton(QToolButton):
self.setFixedSize(size, size)
self.setIcon(_painted_shell_icon(kind, icon_size))
self.setIconSize(QSize(icon_size, icon_size))
content_size = max(0, size - 2)
self.setStyleSheet(
"""
QToolButton#ShellPaintedIconButton {
f"""
QToolButton#ShellPaintedIconButton {{
min-width: {content_size}px;
max-width: {content_size}px;
min-height: {content_size}px;
max-height: {content_size}px;
background: transparent;
border: 1px solid transparent;
border-radius: 9px;
}
QToolButton#ShellPaintedIconButton:hover {
}}
QToolButton#ShellPaintedIconButton:hover {{
background: #F0F3FC;
border-color: #E2E7F4;
}
QToolButton#ShellPaintedIconButton:pressed { background: #EEF1FF; }
QToolButton#ShellPaintedIconButton:focus { border-color: #8D9BFF; }
QToolButton#ShellPaintedIconButton[windowControl="close"]:hover {
}}
QToolButton#ShellPaintedIconButton:pressed {{ background: #EEF1FF; }}
QToolButton#ShellPaintedIconButton:focus {{ border-color: #8D9BFF; }}
QToolButton#ShellPaintedIconButton[windowControl="close"]:hover {{
background: #FFF0F2;
border-color: #F4C4CC;
}
QToolButton#ShellPaintedIconButton::menu-indicator { image: none; width: 0; }
}}
QToolButton#ShellPaintedIconButton::menu-indicator {{ image: none; width: 0; }}
"""
)
@@ -844,6 +873,7 @@ class ShellWindow(QMainWindow):
if session_permissions is not None
else get_value(self.current_user, "permissions", None)
)
self._can_ai_assistant = can_open_ai_consult(self.permissions)
session_menu = get_value(self.session, "menu", None)
self.menu = (
session_menu if session_menu is not None else get_value(session, "menu", [])
@@ -863,9 +893,16 @@ class ShellWindow(QMainWindow):
self.page_titles: dict[int, str] = {}
self._fixed_tab_key: str | None = None
self._sidebar_collapsed = False
self._active_page_key: str | None = None
self._activation_page: QWidget | None = None
self._activation_generation = 0
self._activation_refreshed = False
self._local_audio_settings_dialog: LocalAudioQueueDialog | None = None
self.setMinimumSize(1024, 640)
self.resize(1710, 920)
self.setMinimumSize(_SHELL_MINIMUM_SIZE)
screen = self.screen() or QApplication.primaryScreen()
available_size = screen.availableGeometry().size() if screen is not None else None
self.resize(_bounded_initial_window_size(available_size))
canvas = _ShellCanvas(self)
canvas.setObjectName("AppCanvas")
@@ -997,7 +1034,20 @@ class ShellWindow(QMainWindow):
font-weight: 700;
}
QPushButton#ShellAssistantButton:hover { background-color: #4658E8; }
QLabel#ShellModelLabel { color: #8A95AF; font-size: 10px; }
QPushButton#ShellSettingsButton {
min-height: 42px;
color: #6E7C9F;
font-size: 11px;
font-weight: 600;
background-color: transparent;
border: 1px solid transparent;
border-radius: 10px;
}
QPushButton#ShellSettingsButton:hover {
color: #5265F6;
background-color: #EEF1FF;
}
QPushButton#ShellSettingsButton:focus { border-color: #8D9BFF; }
"""
)
layout = QVBoxLayout(sidebar)
@@ -1068,12 +1118,19 @@ class ShellWindow(QMainWindow):
outer_assistant.setContentsMargins(13, 0, 13, 0)
outer_assistant.addWidget(self.assistant_card)
layout.addLayout(outer_assistant)
self.assistant_card.setVisible(self._can_ai_assistant)
self.model_label = QLabel("模型:GPT-4o 医疗版 ", sidebar)
self.model_label.setObjectName("ShellModelLabel")
self.model_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.model_label.setFixedHeight(76)
layout.addWidget(self.model_label)
self.upload_settings_button = QPushButton("设置 ", sidebar)
self.upload_settings_button.setObjectName("ShellSettingsButton")
self.upload_settings_button.setAccessibleName("本机录音上传设置")
self.upload_settings_button.setToolTip("查看本机录音上传记录")
self.upload_settings_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.upload_settings_button.setFixedHeight(76)
self.upload_settings_button.clicked.connect(self._open_local_audio_settings)
# Keep the former attribute as a compatibility alias for integrations
# that inspect the bottom sidebar control.
self.model_label = self.upload_settings_button
layout.addWidget(self.upload_settings_button)
return sidebar
def _build_topbar(self) -> QWidget:
@@ -1090,10 +1147,6 @@ class ShellWindow(QMainWindow):
border-top-right-radius: 16px;
}
QFrame#ShellGlobalSearch {
min-width: 265px;
max-width: 265px;
min-height: 36px;
max-height: 36px;
background-color: #F8FAFF;
border: 1px solid #E2E7F4;
border-radius: 10px;
@@ -1107,6 +1160,23 @@ class ShellWindow(QMainWindow):
border: 0;
border-radius: 9px;
}
QLineEdit#ShellGlobalSearchInput QToolButton {
min-width: 22px;
max-width: 22px;
min-height: 18px;
max-height: 18px;
margin: 0;
padding: 0;
background-color: transparent;
border: 0;
border-radius: 0;
}
QLineEdit#ShellGlobalSearchInput QToolButton:hover,
QLineEdit#ShellGlobalSearchInput QToolButton:pressed,
QLineEdit#ShellGlobalSearchInput QToolButton:focus {
background-color: transparent;
border: 0;
}
QLabel#ShellShortcutHint {
color: #8A95AF;
background-color: #F0F3FC;
@@ -1202,7 +1272,9 @@ class ShellWindow(QMainWindow):
search_layout.addWidget(self.global_search, 1)
shortcut_hint = QLabel("Ctrl K", search_host)
shortcut_hint.setObjectName("ShellShortcutHint")
search_layout.addWidget(shortcut_hint)
shortcut_hint.setAlignment(Qt.AlignmentFlag.AlignCenter)
shortcut_hint.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
search_layout.addWidget(shortcut_hint, 0, Qt.AlignmentFlag.AlignVCenter)
layout.addWidget(search_host)
self.context_label = QLabel("工作台", topbar)
@@ -1219,6 +1291,7 @@ class ShellWindow(QMainWindow):
self.ai_top_button.setAccessibleName("AI 助手")
self.ai_top_button.clicked.connect(self._open_ai_assistant)
layout.addWidget(self.ai_top_button)
self.ai_top_button.setVisible(self._can_ai_assistant)
self.notification_button = _PaintedIconButton(
"notification", size=38, parent=topbar
@@ -1387,8 +1460,10 @@ class ShellWindow(QMainWindow):
else self._expanded_sidebar_width()
)
self.brand_copy.setVisible(not self._sidebar_collapsed)
self.assistant_card.setVisible(not self._sidebar_collapsed)
self.model_label.setVisible(not self._sidebar_collapsed)
self.assistant_card.setVisible(
self._can_ai_assistant and not self._sidebar_collapsed
)
self.upload_settings_button.setVisible(not self._sidebar_collapsed)
margins = (7, 14, 7, 0) if self._sidebar_collapsed else (12, 19, 12, 0)
self.nav_layout.setContentsMargins(*margins)
self.fold_button.kind = "expand" if self._sidebar_collapsed else "fold"
@@ -1473,14 +1548,57 @@ class ShellWindow(QMainWindow):
show_toast(self, f"正在当前页面搜索“{query}”。", "info")
def _open_ai_assistant(self) -> None:
self.navigate("reception")
if not self._can_ai_assistant:
show_toast(self, "当前账号没有使用 AI 问诊助手的权限。", "danger")
return
current_page = self.stack.currentWidget()
opener = getattr(current_page, "open_selected_ai_consult", None)
if callable(opener) and bool(opener()):
return
reception = self.pages.get("reception")
if reception is None or not self.navigate("reception"):
show_toast(
self,
"当前账号没有可用的接诊台;请从问诊列表或患者列表选择诊单后使用 AI 分析。",
"warning",
4800,
)
return
if reception is not current_page:
reception_opener = getattr(reception, "open_selected_ai_consult", None)
if callable(reception_opener) and bool(reception_opener()):
return
show_toast(
self,
"已进入接诊台选择患者后可查看 AI 报告与辅助分析",
"请先在接诊台选择一位有诊单的患者,再点击“开始对话”",
"info",
3600,
4200,
)
def _open_local_audio_settings(self) -> None:
current = self._local_audio_settings_dialog
if current is not None and current.isVisible():
current.raise_()
current.activateWindow()
return
try:
dialog = LocalAudioQueueDialog(self.repository, None, self)
except (OSError, RuntimeError, ValueError, sqlite3.Error) as error:
show_toast(self, f"无法打开本机录音上传设置:{error}", "error")
return
self._local_audio_settings_dialog = dialog
def clear_dialog(_result: int) -> None:
if self._local_audio_settings_dialog is dialog:
self._local_audio_settings_dialog = None
dialog.finished.connect(clear_dialog)
dialog.open()
def _tab_index_for_key(self, key: str) -> int:
for index in range(self.tab_bar.count()):
if self.tab_bar.tabData(index) == key:
@@ -1614,6 +1732,7 @@ class ShellWindow(QMainWindow):
current_user=self.current_user,
parent=self.stack,
)
self._install_activation_refresh_gate(page)
if hasattr(page, "video_requested"):
page.video_requested.connect(
lambda payload: self.video_requested.emit(payload)
@@ -1662,9 +1781,49 @@ class ShellWindow(QMainWindow):
),
)
def _install_activation_refresh_gate(self, page: QWidget) -> None:
"""Coalesce lifecycle and shell refreshes during one page activation."""
refresh = getattr(page, "refresh", None)
if not callable(refresh):
return
@wraps(refresh)
def activation_refresh(*args: Any, **kwargs: Any) -> Any:
if page is self._activation_page:
if self._activation_refreshed:
return None
self._activation_refreshed = True
return refresh(*args, **kwargs)
page.refresh = activation_refresh # type: ignore[attr-defined,method-assign]
def _ensure_activation_refresh(self, page: QWidget, generation: int) -> None:
if generation != self._activation_generation or page is not self._activation_page:
return
if not self._activation_refreshed:
refresh = getattr(page, "refresh", None)
if callable(refresh):
refresh()
def _finish_activation(self, page: QWidget, generation: int) -> None:
if generation != self._activation_generation or page is not self._activation_page:
return
self._ensure_activation_refresh(page, generation)
self._activation_page = None
def _navigate(self, index: int, key: str) -> None:
if index < 0 or index >= self.stack.count():
return
page = self.stack.widget(index)
if page is None:
return
if self._active_page_key == key and self.stack.currentWidget() is page:
return
self._activation_generation += 1
activation_generation = self._activation_generation
self._activation_page = page
self._activation_refreshed = False
self.stack.setCurrentIndex(index)
if not self._sidebar_collapsed:
self.sidebar.setFixedWidth(self._expanded_sidebar_width(key))
@@ -1675,10 +1834,15 @@ class ShellWindow(QMainWindow):
button = self.nav_buttons.get(key)
if button is not None:
button.setChecked(True)
page = self.stack.widget(index)
refresh = getattr(page, "refresh", None)
if callable(refresh):
refresh()
self._active_page_key = key
self._ensure_activation_refresh(page, activation_generation)
if self.isVisible():
QTimer.singleShot(
0,
lambda page=page, generation=activation_generation: self._finish_activation(
page, generation
),
)
self.page_changed.emit(key)
def navigate(self, key: str) -> bool:
@@ -1697,6 +1861,17 @@ class ShellWindow(QMainWindow):
if callable(refresh):
refresh()
def showEvent(self, event: Any) -> None: # noqa: N802 - Qt virtual
super().showEvent(event)
page = self.stack.currentWidget()
if page is None or page is not self._activation_page:
return
generation = self._activation_generation
QTimer.singleShot(
0,
lambda page=page, generation=generation: self._finish_activation(page, generation),
)
def setVisible(self, visible: bool) -> None: # noqa: N802 - Qt API
if visible:
self.setAttribute(Qt.WidgetAttribute.WA_DontShowOnScreen, False)
+110 -2
View File
@@ -312,9 +312,14 @@ def run_async(
on_error: Callable[[Exception], None] | None = None,
on_finished: Callable[[], None] | None = None,
pool: QThreadPool | None = None,
priority: int = 0,
**kwargs: Any,
) -> Worker:
"""Run ``function`` off the GUI thread and return its Worker handle."""
"""Run ``function`` off the GUI thread and return its Worker handle.
Higher-priority queued work starts first when a pool thread becomes free.
Running work is never interrupted.
"""
worker = Worker(function, *args, **kwargs)
_RUNNING_WORKERS.add(worker)
@@ -324,7 +329,7 @@ def run_async(
if on_finished is not None:
worker.signals.finished.connect(on_finished)
worker.signals.finished.connect(lambda: _RUNNING_WORKERS.discard(worker))
(pool or QThreadPool.globalInstance()).start(worker)
(pool or QThreadPool.globalInstance()).start(worker, priority)
return worker
@@ -716,6 +721,9 @@ class SortableTable(QTableWidget):
self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
self.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
self.setHorizontalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
self.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
self.setMinimumHeight(0)
self.setSortingEnabled(True)
self.verticalHeader().setVisible(False)
self.horizontalHeader().setStretchLastSection(True)
@@ -754,6 +762,105 @@ class SortableTable(QTableWidget):
return item.data(Qt.ItemDataRole.UserRole) if item is not None else None
class BusinessPager(QWidget):
"""Compact numbered pager shared by dense business-list pages."""
page_changed = Signal(int)
def __init__(self, page_size: int = 15, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.setObjectName("BusinessPager")
self.setFixedHeight(42)
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
self.page = 1
self.page_size = page_size
self.total = 0
layout = QHBoxLayout(self)
layout.setContentsMargins(16, 4, 16, 4)
layout.setSpacing(7)
self.summary = QLabel("共 0 条", self)
self.summary.setProperty("role", "muted")
layout.addWidget(self.summary)
layout.addStretch(1)
self.previous = QPushButton("", self)
self.previous.setProperty("variant", "ghost")
self.previous.setAccessibleName("上一页")
self.previous.clicked.connect(lambda: self._request(self.page - 1))
layout.addWidget(self.previous)
self.pages_host = QWidget(self)
self.pages_layout = QHBoxLayout(self.pages_host)
self.pages_layout.setContentsMargins(0, 0, 0, 0)
self.pages_layout.setSpacing(5)
layout.addWidget(self.pages_host)
self.next = QPushButton("", self)
self.next.setProperty("variant", "ghost")
self.next.setAccessibleName("下一页")
self.next.clicked.connect(lambda: self._request(self.page + 1))
layout.addWidget(self.next)
# The business contract fixes this list to one page size. A label is
# intentionally used instead of a one-option combo box so the control
# does not advertise an interaction that cannot change anything.
self.page_size_label = QLabel(f"{page_size} 条/页", self)
self.page_size_label.setProperty("pagerSize", True)
self.page_size_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.addWidget(self.page_size_label)
self.page_label: QPushButton | None = None
self.update_state(1, 0)
@property
def page_count(self) -> int:
return max(1, (self.total + self.page_size - 1) // self.page_size)
def update_state(self, page: int, total: int) -> None:
self.total = max(0, total)
self.page = min(max(1, page), self.page_count)
self.summary.setText(f"{self.total}")
while self.pages_layout.count():
item = self.pages_layout.takeAt(0)
widget = item.widget()
if widget is not None:
widget.deleteLater()
count = self.page_count
if count <= 4:
pages: list[int | None] = list(range(1, count + 1))
elif self.page <= 3:
pages = [1, 2, 3, None, count]
elif self.page >= count - 2:
pages = [1, None, count - 2, count - 1, count]
else:
pages = [1, None, self.page, None, count]
self.page_label = None
for number in pages:
if number is None:
ellipsis = QLabel("", self.pages_host)
ellipsis.setAlignment(Qt.AlignmentFlag.AlignCenter)
ellipsis.setFixedWidth(24)
self.pages_layout.addWidget(ellipsis)
continue
button = QPushButton(str(number), self.pages_host)
button.setProperty("pagerPage", True)
button.setProperty("active", number == self.page)
button.setCursor(Qt.CursorShape.PointingHandCursor)
button.clicked.connect(lambda _checked=False, value=number: self._request(value))
self.pages_layout.addWidget(button)
if number == self.page:
self.page_label = button
self.previous.setEnabled(self.page > 1)
self.next.setEnabled(self.page < count)
def _request(self, page: int) -> None:
if 1 <= page <= self.page_count and page != self.page:
self.page_changed.emit(page)
class Pager(QWidget):
page_changed = Signal(int)
@@ -833,6 +940,7 @@ def clear_layout(layout: QVBoxLayout | QHBoxLayout) -> None:
__all__ = [
"BusinessPager",
"BusyOverlay",
"EmptyState",
"MessageBanner",
+133 -5
View File
@@ -15,6 +15,7 @@ import threading
from collections.abc import Callable, Mapping
from concurrent.futures import Future
from dataclasses import dataclass
from pathlib import Path
from typing import Any, TypeVar
from .launcher import VideoCallRequest
@@ -110,6 +111,29 @@ def _extract_call_record_id(result: Any) -> int | str | None:
return None
def _extract_cloud_recording_outcome(result: Any) -> tuple[bool, str] | None:
"""Read the cloud mixed-video recording outcome returned by room binding."""
pending = [result]
visited: set[int] = set()
while pending:
candidate = pending.pop(0)
mapping = _mapping_candidate(candidate)
if mapping is None or id(mapping) in visited:
continue
visited.add(id(mapping))
recording = mapping.get("cloud_recording", mapping.get("cloudRecording"))
if isinstance(recording, Mapping):
started = bool(recording.get("started"))
message = str(recording.get("message") or "").strip()[:200]
return started, message
for key in ("data", "result"):
nested = mapping.get(key)
if isinstance(nested, Mapping):
pending.append(nested)
return None
def _clean_transcript_segment(segment: Mapping[str, Any], session_id: str) -> dict[str, Any]:
segment_id = str(segment.get("segment_id", segment.get("segmentId", ""))).strip()
text = str(segment.get("text", segment.get("sourceText", ""))).strip()
@@ -239,6 +263,7 @@ class OrderedCallLifecycle:
self._claimed_room_id: str | None = None
self._start_future: Future[bool] | None = None
self._bind_future: Future[bool] | None = None
self._local_recording_future: Future[bool] | None = None
self._end_future: Future[bool] | None = None
self._transcription_start_future: Future[bool] | None = None
self._transcription_finish_future: Future[bool] | None = None
@@ -256,6 +281,13 @@ class OrderedCallLifecycle:
def transcription_session_id(self) -> str | None:
return self._transcription_session_id
@property
def current_room_id(self) -> str | None:
"""Return the room observed for this call cycle, including an active bind."""
with self._lock:
return self.bound_room_id or self._claimed_room_id
def start(self) -> Future[bool]:
with self._lock:
if self._start_future is not None:
@@ -318,18 +350,31 @@ class OrderedCallLifecycle:
def operation() -> bool:
with self._lock:
started = self.started
record_id = self.call_record_id
if not started:
return False
if record_id is None:
raise ValueError("server did not return the current call_record_id")
if not callable(method):
self.logger.warning(
"video repository does not implement bind_call_room",
extra={"video_call": self.request.safe_log_context()},
)
return False
_call_repository_method(
result = _call_repository_method(
method,
{"diagnosis_id": self.request.diagnosis_id, "room_id": cleaned},
{
"diagnosis_id": self.request.diagnosis_id,
"room_id": cleaned,
"call_record_id": record_id,
},
)
recording_outcome = _extract_cloud_recording_outcome(result)
if recording_outcome is not None and not recording_outcome[0]:
raise RuntimeError(
recording_outcome[1]
or "automatic Tencent cloud mixed-video recording did not start"
)
with self._lock:
self.bound_room_id = cleaned
self.logger.info(
@@ -338,8 +383,85 @@ class OrderedCallLifecycle:
)
return True
self._bind_future = self._worker.submit("bind", operation)
return self._bind_future
future = self._worker.submit("bind", operation)
self._bind_future = future
def release_failed_claim(completed: Future[bool]) -> None:
try:
succeeded = bool(completed.result())
except Exception:
succeeded = False
if succeeded:
return
# A transient bridge/API failure must not permanently pin the
# room to a failed Future. The companion retries the exact
# same room after the host acknowledgement, so release only
# this failed claim while preserving successful bindings.
with self._lock:
if self._bind_future is completed and self.bound_room_id is None:
self._bind_future = None
self._claimed_room_id = None
future.add_done_callback(release_failed_claim)
return future
def save_local_audio_recording(
self,
path: str | Path,
*,
mime_type: str = "audio/webm",
) -> Future[bool]:
"""Upload one locally mixed audio file to this exact call record.
The operation shares the lifecycle FIFO, so a caller that queues this
before :meth:`end` is guaranteed to attach the COS object before the
call record is marked ended.
"""
recording_path = Path(path).expanduser().resolve()
clean_mime = str(mime_type or "audio/webm").strip()[:120] or "audio/webm"
if not recording_path.is_file() or recording_path.stat().st_size <= 0:
raise ValueError("local audio recording is empty or missing")
with self._lock:
if self._end_future is not None:
raise RuntimeError("video call has already ended")
if self._local_recording_future is not None:
return self._local_recording_future
if self._start_future is None:
self.start()
method = getattr(self.repository, "upload_call_recording", None)
if not callable(method):
raise ValueError("video repository does not implement local recording upload")
def operation() -> bool:
with self._lock:
started = self.started
record_id = self.call_record_id
if not started:
return False
if record_id is None:
raise ValueError("server did not return the current call_record_id")
result = _call_repository_method(
method,
{
"path": recording_path,
"diagnosis_id": self.request.diagnosis_id,
"call_record_id": record_id,
"mime_type": clean_mime,
},
)
if result is False:
raise RuntimeError("local audio recording upload failed")
self.logger.info(
"local call audio uploaded and attached",
extra={"video_call": self.request.safe_log_context()},
)
return True
self._local_recording_future = self._worker.submit(
"local-audio-upload", operation
)
return self._local_recording_future
def save_screenshot(self, content: bytes, filename: str) -> Future[str]:
"""Upload one video frame and append it to the diagnosis doctor notes."""
@@ -554,15 +676,21 @@ class OrderedCallLifecycle:
def operation() -> bool:
with self._lock:
started = self.started
record_id = self.call_record_id
if not started:
with self._lock:
self.ended = True
return False
if not callable(method):
raise ValueError("video repository does not implement end_call")
if record_id is None:
raise ValueError("server did not return the current call_record_id")
_call_repository_method(
method,
{"diagnosis_id": self.request.diagnosis_id},
{
"diagnosis_id": self.request.diagnosis_id,
"call_record_id": record_id,
},
)
with self._lock:
self.ended = True
+504 -10
View File
@@ -11,6 +11,9 @@ import base64
import binascii
import json
import logging
import os
import re
import sqlite3
import sys
from collections.abc import Callable, Mapping
from concurrent.futures import Future
@@ -20,6 +23,11 @@ from pathlib import Path
from typing import Any
from urllib.parse import parse_qsl, urlsplit
from ..services.local_audio_queue import (
LocalAudioQueueStore,
LocalAudioUploadManager,
get_local_audio_upload_manager,
)
from .launcher import (
VideoCallRequest,
VideoTicketError,
@@ -29,7 +37,7 @@ from .lifecycle import OrderedCallLifecycle
from .security import TrustedDocumentError, TrustedDocumentPolicy
try: # Optional by design: core-only builds must still import this module.
from PySide6.QtCore import QObject, Qt, QUrl, Signal, Slot
from PySide6.QtCore import QObject, Qt, QTimer, QUrl, Signal, Slot
from PySide6.QtWebChannel import QWebChannel
from PySide6.QtWebEngineCore import (
QWebEnginePage,
@@ -39,7 +47,7 @@ try: # Optional by design: core-only builds must still import this module.
from PySide6.QtWebEngineWidgets import QWebEngineView
from PySide6.QtWidgets import QApplication, QMainWindow
except (ImportError, OSError) as _qt_import_error: # pragma: no cover - no Qt runtime.
QObject = Qt = QUrl = Signal = Slot = None # type: ignore[assignment]
QObject = QTimer = Qt = QUrl = Signal = Slot = None # type: ignore[assignment]
QWebChannel = QWebEnginePage = QWebEngineProfile = None # type: ignore[assignment]
QWebEngineSettings = QWebEngineView = None # type: ignore[assignment]
QApplication = QMainWindow = None # type: ignore[assignment]
@@ -63,6 +71,18 @@ class CompanionLocation:
is_local: bool
@dataclass(slots=True)
class _LocalAudioCapture:
record_id: int
session_id: str
mime_type: str
path: Path
handle: Any
lifecycle: OrderedCallLifecycle
next_sequence: int = 0
bytes_written: int = 0
def _validate_remote_url(value: str) -> str:
parsed = urlsplit(value)
if parsed.scheme.lower() != "https" or not parsed.hostname:
@@ -204,13 +224,67 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
}
)
@Slot(str, str) # type: ignore[misc]
def startLocalAudioRecording( # noqa: N802 - Qt bridge API
self, session_id: str, mime_type: str
) -> None:
self._callback(
{
"source": "doctor-call",
"event": "local-audio-start",
"sessionId": session_id,
"mimeType": mime_type,
}
)
@Slot(str, int, str) # type: ignore[misc]
def appendLocalAudioChunk( # noqa: N802 - Qt bridge API
self, session_id: str, sequence: int, encoded: str
) -> None:
self._callback(
{
"source": "doctor-call",
"event": "local-audio-chunk",
"sessionId": session_id,
"sequence": sequence,
"data": encoded,
}
)
@Slot(str, int) # type: ignore[misc]
def finishLocalAudioRecording( # noqa: N802 - Qt bridge API
self, session_id: str, total_bytes: int
) -> None:
self._callback(
{
"source": "doctor-call",
"event": "local-audio-finish",
"sessionId": session_id,
"totalBytes": total_bytes,
}
)
@Slot(str) # type: ignore[misc]
def abortLocalAudioRecording(self, session_id: str) -> None: # noqa: N802
self._callback(
{
"source": "doctor-call",
"event": "local-audio-abort",
"sessionId": session_id,
}
)
class _EmbeddedVideoWindow(QMainWindow): # type: ignore[misc, valid-type]
status_changed = Signal(str) # type: ignore[misc]
call_ended = Signal(str) # type: ignore[misc]
call_error = Signal(str) # type: ignore[misc]
_start_completed = Signal(bool) # type: ignore[misc]
_room_completed = Signal(str, bool, str) # type: ignore[misc]
_screenshot_completed = Signal(bool, str) # type: ignore[misc]
_transcription_completed = Signal(str, str, str, bool, str) # type: ignore[misc]
_local_recording_completed = Signal( # type: ignore[misc]
str, str, int, bool, str
)
def __init__(
self,
@@ -247,8 +321,15 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
self._close_reason = "window-closed"
self._call_cycle_closed = False
self._start_requested = False
self._shutdown_requested = False
self._local_audio_capture: _LocalAudioCapture | None = None
self._local_audio_store: LocalAudioQueueStore | None = None
self._local_audio_uploads: LocalAudioUploadManager | None = None
self._legacy_grants: list[tuple[Any, Any]] = []
self._permission_grants: list[Any] = []
self._shutdown_timer = QTimer(self)
self._shutdown_timer.setSingleShot(True)
self._shutdown_timer.timeout.connect(self._force_requested_shutdown)
self.setWindowTitle(
f"{self.patient_name} IM 问诊" if self.open_im else "视频面诊"
@@ -284,8 +365,10 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
self._connect_permissions()
self._start_completed.connect(self._on_lifecycle_started)
self._room_completed.connect(self._on_room_completed)
self._screenshot_completed.connect(self._on_screenshot_completed)
self._transcription_completed.connect(self._on_transcription_completed)
self._local_recording_completed.connect(self._on_local_recording_completed)
self.web_view.loadFinished.connect(self._on_load_finished)
self.web_view.setUrl(QUrl(self.location.url))
@@ -428,6 +511,28 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
str(message.get("message") or "截屏图片无效。")[:200],
)
return
if event == "local-audio-start":
self._start_local_audio_recording(
str(message.get("sessionId") or ""),
str(message.get("mimeType") or "audio/webm"),
)
return
if event == "local-audio-chunk":
self._append_local_audio_chunk(
str(message.get("sessionId") or ""),
message.get("sequence"),
str(message.get("data") or ""),
)
return
if event == "local-audio-finish":
self._finish_local_audio_recording(
str(message.get("sessionId") or ""),
message.get("totalBytes"),
)
return
if event == "local-audio-abort":
self._abort_local_audio_recording(str(message.get("sessionId") or ""))
return
if event == "transcription-start-request":
self._start_transcription(
str(message.get("sessionId") or ""),
@@ -445,7 +550,13 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
return
room_id = message.get("roomId", message.get("room_id"))
if room_id not in (None, ""):
self.lifecycle.bind_room(room_id)
clean_room_id = str(room_id).strip()
future = self.lifecycle.bind_room(clean_room_id)
future.add_done_callback(
lambda completed, current_room_id=clean_room_id: (
self._notify_room_completed(current_room_id, completed)
)
)
if event == "room":
return
if event == "status":
@@ -457,7 +568,7 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
self.lifecycle.end(f"companion-{status}")
self._call_cycle_closed = True
self._start_requested = False
if not self.open_im:
if not self.open_im or self._shutdown_requested:
self._close_from_companion("companion-hangup")
elif event == "error":
message_text = str(message.get("message", "视频通话错误"))[:400]
@@ -466,9 +577,343 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
self.lifecycle.end("companion-error")
self._call_cycle_closed = True
self._start_requested = False
if not self.open_im:
if not self.open_im or self._shutdown_requested:
self._close_from_companion("companion-error")
def _notify_room_completed(self, room_id: str, future: Future[bool]) -> None:
try:
succeeded = bool(future.result())
except Exception as error:
succeeded = False
message = str(error)[:200] or "腾讯云混流视频录制未启动。"
else:
message = (
"腾讯云混流视频已启动;本机录音将在结束后另行上传 COS。"
if succeeded
else "通话房间尚未绑定,云端视频和本机录音无法关联通话记录。"
)
with suppress(RuntimeError):
self._room_completed.emit(room_id, succeeded, message)
def _on_room_completed(
self,
room_id: str,
succeeded: bool,
message: str,
) -> None:
if self._closing:
return
room_payload = json.dumps(str(room_id)[:160], ensure_ascii=True)
payload = json.dumps(str(message)[:200], ensure_ascii=True)
state = "true" if succeeded else "false"
self._page.runJavaScript(
"window.doctorConsultation?.roomBindingResult?.("
f"{room_payload}, {state}, {payload});"
)
def _emit_local_recording_result(
self,
operation: str,
session_id: str,
sequence: int,
succeeded: bool,
message: str,
) -> None:
with suppress(RuntimeError):
self._local_recording_completed.emit(
operation,
session_id,
sequence,
succeeded,
str(message)[:200],
)
def _on_local_recording_completed(
self,
operation: str,
session_id: str,
sequence: int,
succeeded: bool,
message: str,
) -> None:
if self._closing or self._released:
return
self._page.runJavaScript(
"window.doctorConsultation?.localRecordingResult?.("
f"{json.dumps(operation)}, {json.dumps(session_id)}, {sequence}, "
f"{'true' if succeeded else 'false'}, "
f"{json.dumps(str(message)[:200], ensure_ascii=True)});"
)
def _start_local_audio_recording(self, session_id: str, mime_type: str) -> None:
cleaned = str(session_id or "").strip()
clean_mime = str(mime_type or "audio/webm").strip().lower()[:120]
if not re.fullmatch(r"[A-Za-z0-9_-]{12,64}", cleaned):
self._emit_local_recording_result(
"start", cleaned, -1, False, "本地录音会话标识无效。"
)
return
if not clean_mime.startswith(("audio/webm", "audio/ogg")):
self._emit_local_recording_result(
"start", cleaned, -1, False, "当前浏览器录音格式不受支持。"
)
return
if self._local_audio_capture is not None:
existing = self._local_audio_capture.session_id == cleaned
self._emit_local_recording_result(
"start",
cleaned,
-1,
existing,
"本地录音已启动。" if existing else "已有另一条本地录音正在进行。",
)
return
try:
lifecycle = self.lifecycle
raw_call_record_id = lifecycle.call_record_id
call_record_id = (
int(raw_call_record_id) if raw_call_record_id not in (None, "") else None
)
store, uploads = get_local_audio_upload_manager(
lifecycle.repository,
self._local_audio_store,
)
self._local_audio_store = store
self._local_audio_uploads = uploads
record = store.begin_recording(
session_id=cleaned,
diagnosis_id=self.request.diagnosis_id,
mime_type=clean_mime,
call_record_id=call_record_id,
room_id=lifecycle.current_room_id or "",
)
# The handle intentionally remains open across WebChannel chunks.
handle = record.file_path.open("w+b")
except (OSError, RuntimeError, sqlite3.Error) as error:
self._emit_local_recording_result(
"start", cleaned, -1, False, str(error)[:200]
)
return
self._local_audio_capture = _LocalAudioCapture(
record_id=record.id,
session_id=cleaned,
mime_type=clean_mime,
path=record.file_path,
handle=handle,
lifecycle=lifecycle,
)
self._emit_local_recording_result(
"start", cleaned, -1, True, "本机语音录音已启动。"
)
def _append_local_audio_chunk(
self,
session_id: str,
sequence_value: Any,
encoded: str,
) -> None:
capture = self._local_audio_capture
try:
sequence = int(sequence_value)
except (TypeError, ValueError):
sequence = -1
if capture is None or session_id != capture.session_id:
self._emit_local_recording_result(
"chunk", session_id, sequence, False, "本地录音会话标识不匹配。"
)
return
if sequence != capture.next_sequence:
self._emit_local_recording_result(
"chunk", session_id, sequence, False, "本地录音分片顺序不连续。"
)
return
if not encoded or len(encoded) > 16_384:
self._emit_local_recording_result(
"chunk", session_id, sequence, False, "本地录音分片过大或为空。"
)
return
try:
content = base64.b64decode(encoded, validate=True)
except (ValueError, binascii.Error):
self._emit_local_recording_result(
"chunk", session_id, sequence, False, "本地录音分片解析失败。"
)
return
if not content or len(content) > 12 * 1024:
self._emit_local_recording_result(
"chunk", session_id, sequence, False, "本地录音分片大小无效。"
)
return
if capture.bytes_written + len(content) > 512 * 1024 * 1024:
self._emit_local_recording_result(
"chunk", session_id, sequence, False, "本地录音超过 512 MB 限制。"
)
self._abort_local_audio_recording(session_id)
return
try:
capture.handle.write(content)
except OSError as error:
self._emit_local_recording_result(
"chunk", session_id, sequence, False, str(error)[:200]
)
self._abort_local_audio_recording(session_id)
return
capture.bytes_written += len(content)
capture.next_sequence += 1
def _finish_local_audio_recording(
self, session_id: str, total_bytes_value: Any
) -> None:
capture = self._local_audio_capture
try:
total_bytes = int(total_bytes_value)
except (TypeError, ValueError):
total_bytes = -1
if capture is None or session_id != capture.session_id:
self._emit_local_recording_result(
"finish", session_id, -1, False, "本地录音会话标识不匹配。"
)
return
self._local_audio_capture = None
try:
capture.handle.flush()
os.fsync(capture.handle.fileno())
capture.handle.close()
except OSError as error:
self._mark_local_audio_invalid(capture.record_id, str(error))
self._emit_local_recording_result(
"finish", session_id, -1, False, str(error)[:200]
)
return
if total_bytes != capture.bytes_written or total_bytes <= 0:
self._mark_local_audio_invalid(
capture.record_id, "本地录音文件不完整。"
)
self._emit_local_recording_result(
"finish", session_id, -1, False, "本地录音文件不完整。"
)
return
if capture.bytes_written < 1024:
self._mark_local_audio_invalid(
capture.record_id, "本地录音文件为空或只有容器信息。"
)
self._emit_local_recording_result(
"finish",
session_id,
-1,
False,
"本地录音文件为空或只有容器信息,已阻止上传。",
)
return
try:
with capture.path.open("rb") as recording:
signature = recording.read(4)
except OSError as error:
self._mark_local_audio_invalid(capture.record_id, str(error))
self._emit_local_recording_result(
"finish", session_id, -1, False, str(error)[:200]
)
return
valid_signature = (
capture.mime_type.startswith("audio/webm")
and signature == b"\x1aE\xdf\xa3"
) or (
capture.mime_type.startswith("audio/ogg") and signature == b"OggS"
)
if not valid_signature:
self._mark_local_audio_invalid(
capture.record_id, "本地录音格式校验失败。"
)
self._emit_local_recording_result(
"finish",
session_id,
-1,
False,
"本地录音格式校验失败,已阻止上传无效文件。",
)
return
store = self._local_audio_store
uploads = self._local_audio_uploads
if store is None or uploads is None:
self._emit_local_recording_result(
"finish", session_id, -1, False, "本机录音队列尚未初始化。"
)
return
try:
store.finalize_recording(
capture.record_id,
size_bytes=capture.bytes_written,
)
except (OSError, RuntimeError, sqlite3.Error) as error:
self._mark_local_audio_invalid(capture.record_id, str(error))
self._emit_local_recording_result(
"finish", session_id, -1, False, str(error)[:200]
)
return
lifecycle = capture.lifecycle
def enqueue_upload(start_result: Future[bool] | None = None) -> None:
try:
if start_result is not None and not bool(start_result.result()):
raise RuntimeError("通话记录创建失败,录音已保存在本机,可稍后重试。")
raw_call_record_id = lifecycle.call_record_id
call_record_id = int(raw_call_record_id or 0)
if call_record_id <= 0:
raise RuntimeError("未取得通话记录编号,录音已保存在本机,可稍后重试。")
store.bind_identity(
capture.record_id,
call_record_id=call_record_id,
room_id=lifecycle.current_room_id or "",
)
uploads.submit(capture.record_id)
except Exception as error:
with suppress(Exception):
store.update_status(
capture.record_id,
"failed",
str(error)[:1000],
)
if lifecycle.call_record_id:
enqueue_upload()
else:
try:
lifecycle.start().add_done_callback(enqueue_upload)
except Exception as error:
store.update_status(
capture.record_id,
"failed",
str(error)[:1000] or "通话记录创建失败。",
)
self._emit_local_recording_result(
"finish",
session_id,
-1,
True,
"本地录音已保存,正在后台上传 COS。",
)
def _mark_local_audio_invalid(self, record_id: int, message: str) -> None:
store = self._local_audio_store
if store is None:
return
with suppress(Exception):
store.mark_invalid(record_id, message)
def _abort_local_audio_recording(self, session_id: str) -> None:
capture = self._local_audio_capture
if capture is None or (session_id and capture.session_id != session_id):
return
self._local_audio_capture = None
with suppress(OSError):
capture.handle.flush()
capture.handle.close()
self._mark_local_audio_invalid(
capture.record_id,
"本次本地录音未正常结束,文件已保留以便排查。",
)
def _start_call_cycle(self) -> None:
if self._closing or self._start_requested:
return
@@ -633,7 +1078,49 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
self.close()
def hangup(self) -> None:
self._close_reason = "desktop-hangup"
self._request_companion_shutdown("desktop-hangup")
def _request_companion_shutdown(self, reason: str) -> None:
"""Let MediaRecorder finish and upload before WebEngine is destroyed."""
self._close_reason = reason
if self._closing or self._released:
return
should_wait_for_companion = (
self._injected
and self._start_requested
and not self._call_cycle_closed
and not self._companion_ended
)
if not should_wait_for_companion:
self.close()
return
if self._shutdown_requested:
return
self._shutdown_requested = True
# doctorConsultation.close() stops MediaRecorder, drains every queued
# WebChannel chunk, waits for the Qt/COS finish acknowledgement, and
# only then emits hangup. Keeping _closing false here is essential:
# bridge callbacks are deliberately rejected once final destruction
# begins.
self._page.runJavaScript(
"void window.doctorConsultation?.close?.().catch(() => undefined)"
)
self._shutdown_timer.start(190_000)
def _force_requested_shutdown(self) -> None:
"""Bound a failed companion shutdown without racing queued uploads."""
if not self._shutdown_requested or self._closing or self._released:
return
if self._start_requested and not self._call_cycle_closed:
# OrderedCallLifecycle places end after any upload that already
# reached the Qt bridge.
self.lifecycle.end(f"{self._close_reason}-timeout")
self._call_cycle_closed = True
self._start_requested = False
self._abort_local_audio_recording("")
self._companion_ended = True
self.close()
def _begin_shutdown(self) -> None:
@@ -641,12 +1128,10 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
return
self._closing = True
self._media_active = False
if self._injected and not self._companion_ended and not self._released:
self._page.runJavaScript(
"void window.doctorConsultation?.close?.().catch(() => undefined)"
)
self._shutdown_timer.stop()
if self._start_requested and not self._call_cycle_closed:
self.lifecycle.end(self._close_reason)
self._abort_local_audio_recording("")
self._release_webengine()
def wait_for_lifecycles(self, timeout: float) -> bool:
@@ -698,6 +1183,15 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
self._profile.deleteLater()
def closeEvent(self, event: Any) -> None:
if (
self._injected
and self._start_requested
and not self._call_cycle_closed
and not self._companion_ended
):
event.ignore()
self._request_companion_shutdown(self._close_reason)
return
self._begin_shutdown()
event.accept()
+409
View File
@@ -0,0 +1,409 @@
from __future__ import annotations
import os
from types import SimpleNamespace
from typing import Any
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from PySide6.QtWidgets import QApplication, QLabel, QTextBrowser
from doctor_workstation.core import PermissionSet
from doctor_workstation.services import DemoDoctorRepository
from doctor_workstation.ui.dialogs import ai_consult as ai_consult_module
from doctor_workstation.ui.dialogs.ai_consult import (
AiConsultDialog,
can_open_ai_consult,
present_ai_consult,
render_chat_payload,
)
from doctor_workstation.ui.pages import appointments as appointments_module
from doctor_workstation.ui.pages import consultations as consultations_module
from doctor_workstation.ui.pages import patients as patients_module
from doctor_workstation.ui.pages import reception as reception_module
from doctor_workstation.ui.pages.appointments import AppointmentsPage
from doctor_workstation.ui.pages.consultations import ConsultationsPage
from doctor_workstation.ui.pages.patients import PatientListWorkspace, PatientsPage
from doctor_workstation.ui.pages.reception import ReceptionPage
@pytest.fixture(scope="module")
def application() -> QApplication:
return QApplication.instance() or QApplication([])
@pytest.fixture
def immediate_async(monkeypatch: pytest.MonkeyPatch) -> None:
def run_immediately(
function: Any,
*args: Any,
on_success: Any = None,
on_error: Any = None,
on_finished: Any = None,
**kwargs: Any,
) -> object:
try:
result = function(*args, **kwargs)
except Exception as error:
if on_error:
on_error(error)
else:
if on_success:
on_success(result)
finally:
if on_finished:
on_finished()
return object()
monkeypatch.setattr(ai_consult_module, "run_async", run_immediately)
def test_ai_consult_dialog_matches_workspace_chrome(
application: QApplication,
immediate_async: None,
) -> None:
repository = DemoDoctorRepository()
dialog = AiConsultDialog(repository, PermissionSet(["tcm.diagnosis/aiAssistant"]))
dialog.open_for(
diagnosis_id=501,
patient_id=301,
seed={"patient_name": "杨永", "age": 52, "clinical_diagnosis": "2型糖尿病"},
source_title="问诊列表",
)
dialog.show()
application.processEvents()
labels = [widget.text() for widget in dialog.findChildren(QLabel) if widget.text()]
assert "问诊详情" in labels
assert "AI 助手" in labels
assert "智能分析" in labels
assert "快捷工具" in labels
assert "对话建议" in labels
assert dialog.tabs.tabText(0) == "问诊对话"
assert dialog.send_button.objectName() == "AiConsultSend"
dialog.close()
def test_present_ai_consult_requires_diagnosis_id(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
opened: list[int] = []
monkeypatch.setattr(ai_consult_module.AiConsultDialog, "exec", lambda self: opened.append(self.diagnosis_id))
present_ai_consult(
DemoDoctorRepository(),
PermissionSet(["tcm.diagnosis/aiAssistant"]),
None,
diagnosis_id=0,
)
assert opened == []
present_ai_consult(
DemoDoctorRepository(),
PermissionSet(["tcm.diagnosis/aiAssistant"]),
None,
diagnosis_id=501,
seed={"patient_name": "杨永"},
)
assert opened == [501]
def test_four_entry_points_expose_ai_consult_action(application: QApplication) -> None:
repository = DemoDoctorRepository()
allowed = PermissionSet(["*", "tcm.diagnosis/aiAssistant"])
assert can_open_ai_consult(allowed)
patients = PatientListWorkspace(repository, allowed)
patients.show()
application.processEvents()
assert patients.ai_consult_button.text() == "AI 分析"
assert not patients.ai_consult_button.isHidden()
reception = ReceptionPage(repository, allowed)
reception.show()
application.processEvents()
menu_titles = [action.text() for action in reception.more_button.menu().actions()]
assert "AI 分析" in menu_titles
assert reception.ai_consult_button.text() == "AI 分析"
appointments = AppointmentsPage(repository, permissions=allowed)
appointments.show()
application.processEvents()
assert appointments.toolbar_ai_consult_button.text() == "AI 分析"
assert not appointments.toolbar_ai_consult_button.isHidden()
consultations = ConsultationsPage(repository, permissions=allowed)
assert consultations.table_host.action_policy.get("ai_consult") is True
patients.close()
reception.close()
appointments.close()
consultations.close()
def test_global_ai_openers_keep_the_selected_diagnosis_context(
monkeypatch: pytest.MonkeyPatch,
) -> None:
opened: list[tuple[int, int, str]] = []
def capture(_repository: Any, _permissions: Any, _parent: Any, **kwargs: Any) -> None:
opened.append(
(
int(kwargs["diagnosis_id"]),
int(kwargs["patient_id"]),
str(kwargs["source_title"]),
)
)
for module in (
appointments_module,
consultations_module,
patients_module,
reception_module,
):
monkeypatch.setattr(module, "present_ai_consult", capture)
allowed = PermissionSet(["tcm.diagnosis/aiAssistant"])
row = {"diagnosis_id": 501, "source_patient_id": 301, "patient_id": 301}
appointment_page = SimpleNamespace(
repository=object(),
permissions=allowed,
_current_row=lambda: row,
)
assert AppointmentsPage.open_selected_ai_consult(appointment_page)
consultation_page = SimpleNamespace(
repository=object(),
permissions=allowed,
table=SimpleNamespace(current_data=lambda: row),
)
assert ConsultationsPage.open_selected_ai_consult(consultation_page)
patient_workspace = SimpleNamespace(
table=SimpleNamespace(current_data=lambda: row),
)
patient_page = SimpleNamespace(
repository=object(),
permissions=allowed,
tabs=SimpleNamespace(currentWidget=lambda: patient_workspace),
)
patient_page._diagnosis_id = lambda value: PatientsPage._diagnosis_id(
patient_page, value
)
patient_page._open_ai_consult = lambda value: PatientsPage._open_ai_consult(
patient_page, value
)
assert PatientsPage.open_selected_ai_consult(patient_page)
reception_page = SimpleNamespace(
repository=object(),
permissions=allowed,
_can_ai_assistant=True,
_detail_loading=False,
_selection_context=lambda: (1, 101, 501, 301),
_selected_detail=row,
_selected_record=None,
)
assert ReceptionPage.open_selected_ai_consult(reception_page)
assert opened == [
(501, 301, "问诊列表"),
(501, 301, "问诊列表"),
(501, 301, "我的患者"),
(501, 301, "接诊台"),
]
def test_ai_consult_sidebar_loads_patient_facts_and_reports(
application: QApplication,
immediate_async: None,
) -> None:
dialog = AiConsultDialog(
DemoDoctorRepository(),
PermissionSet(["tcm.diagnosis/aiAssistant"]),
)
dialog.open_for(diagnosis_id=501, patient_id=301, seed={"patient_name": "林晓岚"})
dialog.show()
application.processEvents()
values = {
widget.text()
for widget in dialog.findChildren(QLabel)
if widget.objectName() == "AiConsultKeyValue"
}
assert "22.1" in values
assert any("病程" in text or "3" in text for text in values)
titles = {
widget.text()
for widget in dialog.findChildren(QLabel)
if widget.objectName() == "AiConsultRecordTitle"
}
assert "血糖控制评估" in titles
assert "并发症风险评估" in titles
bodies = [
widget.toPlainText()
for widget in dialog.findChildren(QTextBrowser)
if widget.objectName() == "AiConsultBubbleText"
]
assert any("病情与证候分析" in text for text in bodies)
assert any("###" not in text for text in bodies if "病情与证候分析" in text)
dialog.close()
def test_ai_consult_sidebar_survives_chat_archive_errors(
application: QApplication,
immediate_async: None,
) -> None:
class BrokenChatRepository(DemoDoctorRepository):
def list_im_chat_messages(self, diagnosis_id: int, *, only_archived: bool = True):
raise RuntimeError("archive unavailable")
dialog = AiConsultDialog(
BrokenChatRepository(),
PermissionSet(["tcm.diagnosis/aiAssistant"]),
)
dialog.open_for(diagnosis_id=501, patient_id=301)
dialog.show()
application.processEvents()
values = {
widget.text()
for widget in dialog.findChildren(QLabel)
if widget.objectName() == "AiConsultKeyValue"
}
titles = {
widget.text()
for widget in dialog.findChildren(QLabel)
if widget.objectName() == "AiConsultRecordTitle"
}
assert "22.1" in values
assert "血糖控制评估" in titles
dialog.close()
def test_chat_payload_parses_markdown_html_and_json(application: QApplication) -> None:
browser = QTextBrowser()
render_chat_payload(browser, "### 病情摘要\n\n**核心病机**\n\n- 口干")
assert "病情摘要" in browser.toPlainText()
assert "核心病机" in browser.toPlainText()
assert "###" not in browser.toPlainText()
assert "<h3" in browser.toHtml().lower()
render_chat_payload(browser, "<p>空腹血糖 <strong>6.8</strong></p>")
assert "空腹血糖" in browser.toPlainText()
assert "6.8" in browser.toPlainText()
render_chat_payload(browser, '{"diagnosis":"肝郁脾虚证","risk":["血糖波动"]}')
assert "肝郁脾虚证" in browser.toPlainText()
browser.deleteLater()
def test_stream_chunks_update_one_ai_bubble_before_done_and_preserve_order(
application: QApplication,
) -> None:
dialog = AiConsultDialog(
DemoDoctorRepository(),
PermissionSet(["tcm.diagnosis/aiAssistant"]),
)
dialog.show()
dialog._stream_bubble = dialog._append_bubble("ai", "")
bubble = dialog._stream_bubble
generation = dialog._generation
stream_generation = dialog._stream_generation
dialog._stream_event(
generation,
stream_generation,
{"event": "delta", "text": "第一段"},
)
dialog._flush_timer.stop()
dialog._flush_stream_chunks()
application.processEvents()
assert bubble is not None and bubble.body is not None
assert bubble.body.toPlainText() == "第一段"
ai_bubble_count = len(
[frame for frame in dialog.findChildren(ai_consult_module.QFrame) if frame.objectName() == "AiConsultBubbleAi"]
)
dialog._stream_event(
generation,
stream_generation,
{"event": "delta", "text": "第二段"},
)
dialog._stream_event(
generation,
stream_generation,
{"event": "done", "model_label": "千问"},
)
application.processEvents()
assert bubble.body.toPlainText() == "第一段第二段"
assert len(
[frame for frame in dialog.findChildren(ai_consult_module.QFrame) if frame.objectName() == "AiConsultBubbleAi"]
) == ai_bubble_count
dialog.close()
def test_stream_error_and_cancelled_late_chunk_reuse_or_leave_current_bubble(
application: QApplication,
) -> None:
dialog = AiConsultDialog(
DemoDoctorRepository(),
PermissionSet(["tcm.diagnosis/aiAssistant"]),
)
dialog.show()
dialog._stream_bubble = dialog._append_bubble("ai", "")
bubble = dialog._stream_bubble
generation = dialog._generation
stream_generation = dialog._stream_generation
dialog._stream_event(
generation,
stream_generation,
{"event": "delta", "text": "已生成"},
)
dialog._stream_failed(generation, stream_generation, RuntimeError("模型繁忙"))
application.processEvents()
assert bubble is not None and bubble.body is not None
assert "已生成" in bubble.body.toPlainText()
assert "模型繁忙" in bubble.body.toPlainText()
before_cancel = bubble.body.toPlainText()
dialog.close()
application.processEvents()
dialog._stream_event(
generation,
stream_generation,
{"event": "delta", "text": "迟到内容"},
)
application.processEvents()
assert bubble.body.toPlainText() == before_cancel
def test_chat_scroll_follows_bottom_but_respects_user_scroll_and_send_restores_it(
application: QApplication,
) -> None:
dialog = AiConsultDialog(
DemoDoctorRepository(),
PermissionSet(["tcm.diagnosis/aiAssistant"]),
)
dialog.diagnosis_id = 501
dialog.show()
for index in range(28):
dialog._append_bubble("ai", f"历史消息 {index}" + "辨证内容" * 16)
application.processEvents()
bar = dialog.chat_scroll.verticalScrollBar()
bar.setValue(bar.maximum())
application.processEvents()
assert dialog._follow_chat
bar.setValue(max(0, bar.maximum() // 3))
application.processEvents()
reading_position = bar.value()
assert not dialog._follow_chat
dialog._append_bubble("ai", "新的流式内容" * 20)
application.processEvents()
assert bar.value() == reading_position
dialog._ask("请继续分析")
application.processEvents()
assert dialog._follow_chat
assert bar.value() == bar.maximum()
dialog.close()
+896
View File
@@ -0,0 +1,896 @@
from __future__ import annotations
import os
from collections.abc import Callable
from typing import Any
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from PySide6.QtWidgets import (
QApplication,
QLabel,
QLineEdit,
QPushButton,
QScrollArea,
QTextBrowser,
QTextEdit,
QWidget,
)
from doctor_workstation.core import PermissionSet
from doctor_workstation.ui.dialogs import ai_consult as ai_consult_module
from doctor_workstation.ui.dialogs import prescription as prescription_module
from doctor_workstation.ui.dialogs.ai_consult import AiConsultDialog
@pytest.fixture(scope="module")
def application() -> QApplication:
return QApplication.instance() or QApplication([])
@pytest.fixture
def immediate_async(monkeypatch: pytest.MonkeyPatch) -> None:
def run_immediately(
function: Callable[..., Any],
*args: Any,
on_success: Callable[[Any], Any] | None = None,
on_error: Callable[[Exception], Any] | None = None,
on_finished: Callable[[], Any] | None = None,
**_kwargs: Any,
) -> object:
try:
result = function(*args)
except Exception as error:
if on_error:
on_error(error)
else:
if on_success:
on_success(result)
finally:
if on_finished:
on_finished()
return object()
monkeypatch.setattr(ai_consult_module, "run_async", run_immediately)
class DeferredAsync:
def __init__(self) -> None:
self.pending: list[dict[str, Any]] = []
def __call__(
self,
function: Callable[..., Any],
*args: Any,
on_success: Callable[[Any], Any] | None = None,
on_error: Callable[[Exception], Any] | None = None,
on_finished: Callable[[], Any] | None = None,
**_kwargs: Any,
) -> object:
self.pending.append(
{
"function": function,
"args": args,
"on_success": on_success,
"on_error": on_error,
"on_finished": on_finished,
}
)
return object()
def complete(self, index: int) -> None:
pending = self.pending[index]
try:
result = pending["function"](*pending["args"])
except Exception as error:
if pending["on_error"]:
pending["on_error"](error)
else:
if pending["on_success"]:
pending["on_success"](result)
finally:
if pending["on_finished"]:
pending["on_finished"]()
def _detail(diagnosis_id: int, marker: str) -> dict[str, Any]:
diagnosis = {
"id": diagnosis_id,
"patient_id": diagnosis_id + 1000,
"patient_name": f"{marker}患者",
"phone": "13800138000",
"id_card": "110105199203071234",
"gender": 0,
"age": 34,
"region": f"{marker}杭州",
"address": f"{marker}健康路 8 号",
"height": 162,
"weight": 54.5,
"bmi": 20.8,
"systolic_pressure": 146,
"diastolic_pressure": 92,
"fasting_blood_sugar": 8.2,
"chief_complaint": f"{marker}主诉口渴乏力",
"present_illness": f"{marker}现病史半年血糖波动",
"past_history": f"{marker}既往高血压五年",
"allergy_history": f"{marker}青霉素过敏",
"family_history": f"{marker}父亲糖尿病",
"clinical_diagnosis": f"{marker}气阴两虚",
"diabetes_discovery_year": 6,
"current_medications": [f"{marker}二甲双胍", "阿卡波糖"],
"smoking": "不吸烟",
"sleep_condition": [f"{marker}易醒", "多梦"],
"local_hospital_diagnosis": [f"{marker}2 型糖尿病", "高血压"],
"diet_condition": [f"{marker}偏甜", "夜宵"],
"body_feeling": [f"{marker}乏力", "四肢沉重"],
"tongue": f"{marker}舌淡红",
"tongue_coating": f"{marker}苔薄白",
"pulse": f"{marker}脉细",
"remark": f"{marker}继续监测",
"latest_prescription_order": {
"id": f"{marker}-RX-09",
"status_text": "待配药",
},
}
for index in range(12):
diagnosis[f"custom_field_{index}"] = f"{marker}扩展病历字段 {index}"
return {
"diagnosis": diagnosis,
"patient": {
"id": diagnosis_id + 1000,
"patient_name": f"{marker}患者",
"phone": "13800138000",
"id_card": "110105199203071234",
"gender": 0,
"age": 34,
"region": f"{marker}杭州",
"address": f"{marker}健康路 8 号",
},
"appointment": {"doctor_name": f"{marker}陈医生"},
}
class WorkspaceRepository:
def __init__(self, *, include_foreign_rows: bool = True) -> None:
self.details = {501: _detail(501, ""), 502: _detail(502, "")}
self.include_foreign_rows = include_foreign_rows
self.failures: set[tuple[str, int]] = set()
self.calls: list[tuple[str, int]] = []
self.prescription_detail_calls: list[int] = []
self.prescription_overrides: dict[int, dict[str, Any]] = {}
self.report_payload: Any = []
def _check(self, name: str, diagnosis_id: int) -> None:
self.calls.append((name, diagnosis_id))
if (name, diagnosis_id) in self.failures:
raise RuntimeError(f"{name} 暂时不可用")
def get_diagnosis_detail(
self,
diagnosis_id: int,
*,
readonly: bool = False,
) -> dict[str, Any]:
del readonly
self._check("get_diagnosis_detail", diagnosis_id)
return self.details[diagnosis_id]
def list_im_chat_messages(
self,
diagnosis_id: int,
*,
only_archived: bool = True,
) -> list[dict[str, Any]]:
del only_archived
self._check("list_im_chat_messages", diagnosis_id)
return []
def list_patient_ai_reports(self, patient_id: int) -> Any:
self.calls.append(("list_patient_ai_reports", patient_id))
return self.report_payload
def get_doctor_notes(self, diagnosis_id: int) -> list[dict[str, Any]]:
self._check("get_doctor_notes", diagnosis_id)
marker = "" if diagnosis_id == 501 else ""
rows = [
{
"id": diagnosis_id * 10 + 1,
"diagnosis_id": diagnosis_id,
"create_time": "2026-08-18 09:20",
"content": f"{marker}医生检查记录",
"tongue_images": [
{
"name": f"{marker}舌苔照片.jpg",
"url": f"https://media.example.invalid/{marker}/tongue.jpg",
}
],
"report_files": [
{
"name": f"{marker}血糖报告.pdf",
"url": f"https://media.example.invalid/{marker}/report.pdf",
},
{
"name": f"{marker}本地危险附件.pdf",
"url": "file:///C:/private/unsafe.pdf",
},
],
}
]
if self.include_foreign_rows:
rows.append(
{
"id": 99901,
"diagnosis_id": 999,
"content": "错误诊单附件哨兵",
"tongue_images": [
{
"name": "错误诊单舌苔.jpg",
"url": "https://media.example.invalid/wrong.jpg",
}
],
}
)
return rows
def get_tracking_window(self, diagnosis_id: int) -> dict[str, Any]:
self._check("get_tracking_window", diagnosis_id)
marker = "" if diagnosis_id == 501 else ""
blood_records = [
{
"id": diagnosis_id * 10 + 2,
"diagnosis_id": diagnosis_id,
"record_date": "2026-08-18",
"fasting_blood_sugar": f"{marker}8.2",
"postprandial_blood_sugar": f"{marker}12.4",
"systolic_pressure": f"{marker}146",
"diastolic_pressure": f"{marker}92",
}
]
if self.include_foreign_rows:
blood_records.append(
{
"id": 99902,
"diagnosis_id": 999,
"record_date": "2026-08-18",
"fasting_blood_sugar": "错误诊单血糖 19.9",
}
)
return {
"diagnosis_id": diagnosis_id,
"blood_records": blood_records,
"diet_records": [
{
"id": diagnosis_id * 10 + 3,
"diagnosis_id": diagnosis_id,
"record_date": "2026-08-18",
"breakfast_foods": f"{marker}燕麦鸡蛋",
"lunch_foods": f"{marker}杂粮饭",
}
],
"exercise_records": [
{
"id": diagnosis_id * 10 + 4,
"diagnosis_id": diagnosis_id,
"record_date": "2026-08-17",
"exercise_type": f"{marker}散步",
"duration": 35,
}
],
}
def list_prescriptions_by_diagnosis(
self,
diagnosis_id: int,
) -> list[dict[str, Any]]:
self._check("list_prescriptions_by_diagnosis", diagnosis_id)
marker = "" if diagnosis_id == 501 else ""
return [
{
"id": diagnosis_id * 10 + index,
"diagnosis_id": diagnosis_id,
"sn": f"{marker}-RX-{index}",
"prescription_date": f"2026-08-{10 + index}",
"prescription_summary": f"{marker}方剂 {index}",
"doctor_name": f"{marker}陈医生",
"status_text": "已审核",
"herbs": [{"name": f"{marker}黄芪", "dosage": index * 5, "unit": "g"}],
}
for index in range(1, 4)
]
def get_prescription(self, prescription_id: int) -> dict[str, Any]:
self.prescription_detail_calls.append(prescription_id)
if prescription_id in self.prescription_overrides:
return self.prescription_overrides[prescription_id]
diagnosis_id = prescription_id // 10
return {
"id": prescription_id,
"diagnosis_id": diagnosis_id,
"sn": f"FULL-{prescription_id}",
"clinical_diagnosis": "气阴两虚",
"herbs": [{"name": "黄芪", "dosage": 15, "unit": "g"}],
}
def _permissions() -> PermissionSet:
return PermissionSet(["tcm.diagnosis/aiAssistant", "cf.prescription/read"])
def _pane_text(widget: QWidget) -> str:
parts = [child.text() for child in widget.findChildren(QLabel)]
parts.extend(child.text() for child in widget.findChildren(QPushButton))
parts.extend(child.text() for child in widget.findChildren(QLineEdit))
parts.extend(child.toPlainText() for child in widget.findChildren(QTextBrowser))
parts.extend(child.toPlainText() for child in widget.findChildren(QTextEdit))
return "\n".join(part for part in parts if part)
def _open_dialog(
application: QApplication,
repository: WorkspaceRepository,
diagnosis_id: int = 501,
) -> AiConsultDialog:
dialog = AiConsultDialog(repository, _permissions())
dialog.open_for(diagnosis_id=diagnosis_id, patient_id=diagnosis_id + 1000)
dialog.show()
application.processEvents()
return dialog
def test_case_tab_renders_complete_owned_detail_as_readable_chinese(
application: QApplication,
immediate_async: None,
) -> None:
dialog = _open_dialog(application, WorkspaceRepository())
pane = dialog.records["病历资料"]
dialog.tabs.setCurrentIndex(1)
application.processEvents()
assert pane.findChild(QWidget, "AiConsultCaseGrid") is not None
text = _pane_text(pane)
for sentinel in (
"甲主诉口渴乏力",
"甲现病史半年血糖波动",
"甲既往高血压五年",
"甲青霉素过敏",
"甲父亲糖尿病",
"甲气阴两虚",
"甲2 型糖尿病",
"高血压",
"甲偏甜",
"夜宵",
"甲-RX-09",
"待配药",
):
assert sentinel in text
assert "['" not in text
assert "{'" not in text
scroll = pane.findChild(QScrollArea)
assert scroll is not None and scroll.widgetResizable()
assert pane.geometry().isValid() and scroll.viewport().geometry().isValid()
assert scroll.verticalScrollBar().maximum() > 0
dialog.close()
def test_all_four_record_tabs_use_the_selected_diagnosis_id(
application: QApplication,
immediate_async: None,
) -> None:
repository = WorkspaceRepository(include_foreign_rows=False)
dialog = _open_dialog(application, repository, diagnosis_id=501)
for method in (
"get_diagnosis_detail",
"get_doctor_notes",
"get_tracking_window",
"list_prescriptions_by_diagnosis",
):
assert (method, 501) in repository.calls
assert all(
called_id == 501
for called_method, called_id in repository.calls
if called_method == method
)
assert ("list_patient_ai_reports", 1501) in repository.calls
dialog.close()
def test_seed_cannot_replace_the_authoritative_patient_id(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
deferred = DeferredAsync()
monkeypatch.setattr(ai_consult_module, "run_async", deferred)
repository = WorkspaceRepository(include_foreign_rows=False)
dialog = AiConsultDialog(repository, _permissions())
dialog.open_for(
diagnosis_id=501,
patient_id=1501,
seed={"patient_id": 501, "patient_name": "错误种子"},
)
assert dialog.patient_id == 1501
deferred.complete(0)
application.processEvents()
assert dialog.patient_id == 1501
assert ("list_patient_ai_reports", 1501) in repository.calls
dialog.close()
def test_detail_failure_or_wrong_owner_never_requests_patient_reports(
application: QApplication,
immediate_async: None,
) -> None:
failed_repository = WorkspaceRepository(include_foreign_rows=False)
failed_repository.failures.add(("get_diagnosis_detail", 501))
failed = _open_dialog(application, failed_repository)
assert all(
method != "list_patient_ai_reports"
for method, _owner in failed_repository.calls
)
failed.close()
wrong_repository = WorkspaceRepository(include_foreign_rows=False)
wrong_repository.details[501] = _detail(999, "越权")
wrong = _open_dialog(application, wrong_repository)
assert all(
method != "list_patient_ai_reports"
for method, _owner in wrong_repository.calls
)
wrong.close()
def test_patient_report_response_owner_must_match_exactly(
application: QApplication,
immediate_async: None,
) -> None:
repository = WorkspaceRepository(include_foreign_rows=False)
repository.report_payload = {
"patient_id": "1501",
"reports": [
{
"patient_id": 1501,
"report": {"diagnosis": "不应显示的越权报告"},
}
],
}
dialog = _open_dialog(application, repository)
assert ("list_patient_ai_reports", 1501) in repository.calls
assert "不应显示的越权报告" not in _pane_text(dialog)
assert not ai_consult_module._report_response_matches_patient(
repository.report_payload,
1501,
)
dialog.close()
def test_exam_tab_filters_foreign_attachments_and_blocks_file_urls(
application: QApplication,
immediate_async: None,
monkeypatch: pytest.MonkeyPatch,
) -> None:
opened: list[str] = []
monkeypatch.setattr(
ai_consult_module,
"open_safe_http_url",
lambda target: opened.append(target) or True,
)
dialog = _open_dialog(application, WorkspaceRepository())
pane = dialog.records["检查检验"]
dialog.tabs.setCurrentIndex(2)
application.processEvents()
assert pane.findChild(QWidget, "AiConsultExamTimeline") is not None
text = _pane_text(pane)
assert "甲舌苔照片.jpg" in text
assert "甲血糖报告.pdf" in text
assert "甲本地危险附件.pdf" in text
assert "错误诊单附件哨兵" not in text
assert "错误诊单舌苔.jpg" not in text
buttons = pane.findChildren(QPushButton, "AiConsultMediaOpen")
assert len(buttons) == 3
thumbnails = pane.findChildren(QPushButton, "AiConsultTongueThumb")
assert len(thumbnails) == 1
assert thumbnails[0].isEnabled()
assert thumbnails[0].accessibleName() == "舌苔图片点击查看"
assert thumbnails[0].property("loadState") == "blocked"
assert pane.state_label.property("state") == "warning" # type: ignore[attr-defined]
assert pane.retry_button.isHidden() # type: ignore[attr-defined]
unsafe = next(button for button in buttons if "本地危险附件" in button.text())
assert not unsafe.isEnabled()
for button in buttons:
button.click()
assert len(opened) == 2
assert all(target.startswith(("http://", "https://")) for target in opened)
assert all(not target.startswith("file:") for target in opened)
dialog.close()
def test_tongue_thumbnail_auto_get_requires_configured_https_origin(
application: QApplication,
immediate_async: None,
monkeypatch: pytest.MonkeyPatch,
) -> None:
requested: list[str] = []
class RecordingRemoteImageButton(QPushButton):
def __init__(self, source: str, **kwargs: Any) -> None:
super().__init__(kwargs.get("parent"))
requested.append(source)
self.setObjectName(str(kwargs.get("object_name") or ""))
self.setAccessibleName(
str(kwargs.get("fallback_text") or "").replace("\n", "")
)
monkeypatch.setattr(
ai_consult_module,
"_RemoteImageButton",
RecordingRemoteImageButton,
)
untrusted = _open_dialog(application, WorkspaceRepository())
assert requested == []
untrusted.close()
trusted_repository = WorkspaceRepository()
trusted_repository.trusted_media_domains = ["media.example.invalid"]
assert not ai_consult_module._trusted_thumbnail_url(
trusted_repository,
"http://media.example.invalid/甲/tongue.jpg",
)
assert not ai_consult_module._trusted_thumbnail_url(
trusted_repository,
"https://sub.media.example.invalid/甲/tongue.jpg",
)
trusted = _open_dialog(application, trusted_repository)
assert requested == ["https://media.example.invalid/甲/tongue.jpg"]
trusted.close()
def test_three_prescription_cards_open_exact_details_and_reject_wrong_or_late_ids(
application: QApplication,
immediate_async: None,
monkeypatch: pytest.MonkeyPatch,
) -> None:
repository = WorkspaceRepository(include_foreign_rows=False)
opened: list[int] = []
class FakePrescriptionDetailDialog:
def __init__(self, prescription: Any, **_kwargs: Any) -> None:
self.prescription = prescription
def exec(self) -> None:
opened.append(int(self.prescription["id"]))
monkeypatch.setattr(
prescription_module,
"PrescriptionDetailDialog",
FakePrescriptionDetailDialog,
)
dialog = _open_dialog(application, repository)
pane = dialog.records["处方记录"]
dialog.tabs.setCurrentIndex(3)
application.processEvents()
cards = pane.findChildren(QWidget, "AiConsultPrescriptionCard")
buttons = sorted(
pane.findChildren(QPushButton, "AiConsultPrescriptionOpen"),
key=lambda button: int(button.property("prescriptionId")),
)
expected_ids = [5011, 5012, 5013]
assert len(cards) == len(buttons) == 3
assert [int(button.property("prescriptionId")) for button in buttons] == expected_ids
assert all(button.text() == "查看详情" for button in buttons)
for button in buttons:
button.click()
assert repository.prescription_detail_calls == expected_ids
assert opened == expected_ids
repository.prescription_overrides[5011] = {
"id": 9999,
"diagnosis_id": 501,
}
buttons[0].click()
assert repository.prescription_detail_calls[-1] == 5011
assert opened == expected_ids
repository.prescription_overrides.pop(5011)
repository.prescription_overrides[5011] = {"id": 5011}
buttons[0].click()
assert repository.prescription_detail_calls[-1] == 5011
assert opened == expected_ids
repository.prescription_overrides.pop(5011)
calls_before_unowned_source = list(repository.prescription_detail_calls)
dialog._open_prescription_detail({"id": 5011})
assert repository.prescription_detail_calls == calls_before_unowned_source
deferred = DeferredAsync()
monkeypatch.setattr(ai_consult_module, "run_async", deferred)
buttons[0].click()
buttons[1].click()
assert len(deferred.pending) == 2
deferred.complete(1)
application.processEvents()
deferred.complete(0)
application.processEvents()
assert repository.prescription_detail_calls[-2:] == [5012, 5011]
assert opened == [*expected_ids, 5012]
dialog.close()
def test_health_tab_masks_sensitive_patient_data_and_renders_tracking_window(
application: QApplication,
immediate_async: None,
) -> None:
dialog = _open_dialog(application, WorkspaceRepository())
pane = dialog.records["健康档案"]
dialog.tabs.setCurrentIndex(4)
application.processEvents()
assert pane.findChild(QWidget, "AiConsultHealthGrid") is not None
text = _pane_text(pane)
for sentinel in (
"甲患者",
"甲杭州",
"甲健康路 8 号",
"138****8000",
"110***********1234",
"甲8.2",
"甲12.4",
"甲146",
"甲92",
"甲燕麦鸡蛋",
"甲杂粮饭",
"甲散步",
"35",
"甲气阴两虚",
"甲二甲双胍",
"阿卡波糖",
"不吸烟",
"甲易醒",
"多梦",
):
assert sentinel in text
assert pane.findChild(QWidget, "AiConsultDiagnosisHealthSummary") is not None
assert "13800138000" not in text
assert "110105199203071234" not in text
assert "错误诊单血糖 19.9" not in text
assert pane.state_label.property("state") == "warning" # type: ignore[attr-defined]
assert pane.retry_button.isHidden() # type: ignore[attr-defined]
dialog.close()
def test_ownerless_notes_prescriptions_and_tracking_rows_fail_closed_as_warning(
application: QApplication,
immediate_async: None,
) -> None:
class OwnerlessRepository(WorkspaceRepository):
def get_doctor_notes(self, diagnosis_id: int) -> list[dict[str, Any]]:
rows = super().get_doctor_notes(diagnosis_id)
for row in rows:
row.pop("diagnosis_id", None)
return rows
def list_prescriptions_by_diagnosis(
self,
diagnosis_id: int,
) -> list[dict[str, Any]]:
rows = super().list_prescriptions_by_diagnosis(diagnosis_id)
for row in rows:
row.pop("diagnosis_id", None)
return rows
def get_tracking_window(self, diagnosis_id: int) -> dict[str, Any]:
result = super().get_tracking_window(diagnosis_id)
for key in ("blood_records", "diet_records", "exercise_records"):
for row in result[key]:
row.pop("diagnosis_id", None)
return result
dialog = _open_dialog(
application,
OwnerlessRepository(include_foreign_rows=False),
)
exam = dialog.records["检查检验"]
prescriptions = dialog.records["处方记录"]
health = dialog.records["健康档案"]
assert "甲医生检查记录" not in _pane_text(exam)
assert not prescriptions.findChildren(QWidget, "AiConsultPrescriptionCard")
assert "甲燕麦鸡蛋" not in _pane_text(health)
for pane in (exam, prescriptions, health):
assert pane.state_label.property("state") == "warning" # type: ignore[attr-defined]
assert pane.retry_button.isHidden() # type: ignore[attr-defined]
dialog.close()
def test_ownerless_im_messages_fail_closed_before_chat_rendering(
application: QApplication,
immediate_async: None,
) -> None:
class OwnerlessMessageRepository(WorkspaceRepository):
def list_im_chat_messages(
self,
diagnosis_id: int,
*,
only_archived: bool = True,
) -> list[dict[str, Any]]:
del diagnosis_id, only_archived
return [
{
"msg_id": "ownerless-message",
"msg_type": "text",
"text": "不应展示的无归属会话",
"is_from_doctor": False,
}
]
dialog = _open_dialog(application, OwnerlessMessageRepository())
chat_text = "\n".join(
label.text() for label in dialog.chat_host.findChildren(QLabel)
)
assert "不应展示的无归属会话" not in chat_text
dialog.close()
def test_tracking_response_without_diagnosis_owner_is_filtered_without_retry(
application: QApplication,
immediate_async: None,
) -> None:
class OwnerlessTrackingRepository(WorkspaceRepository):
def get_tracking_window(self, diagnosis_id: int) -> dict[str, Any]:
result = super().get_tracking_window(diagnosis_id)
result.pop("diagnosis_id", None)
return result
dialog = _open_dialog(
application,
OwnerlessTrackingRepository(include_foreign_rows=False),
)
pane = dialog.records["健康档案"]
assert "甲燕麦鸡蛋" not in _pane_text(pane)
assert pane.state_label.property("state") == "warning" # type: ignore[attr-defined]
assert pane.retry_button.isHidden() # type: ignore[attr-defined]
dialog.close()
def test_late_workspace_a_response_cannot_pollute_selected_workspace_b(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
repository = WorkspaceRepository(include_foreign_rows=False)
deferred = DeferredAsync()
monkeypatch.setattr(ai_consult_module, "run_async", deferred)
dialog = AiConsultDialog(repository, _permissions())
dialog.open_for(diagnosis_id=501, patient_id=1501)
dialog.open_for(diagnosis_id=502, patient_id=1502)
dialog.show()
assert len(deferred.pending) == 2
deferred.complete(1)
application.processEvents()
deferred.complete(0)
application.processEvents()
for title in ("病历资料", "检查检验", "处方记录", "健康档案"):
text = _pane_text(dialog.records[title])
assert "" in text
assert "甲主诉口渴乏力" not in text
assert "甲医生检查记录" not in text
assert "甲-RX-1" not in text
assert "甲燕麦鸡蛋" not in text
assert dialog.diagnosis_id == 502
assert dialog._detail["diagnosis"]["id"] == 502
dialog.close()
def test_mismatched_detail_owner_fails_closed_across_all_record_tabs(
application: QApplication,
immediate_async: None,
) -> None:
repository = WorkspaceRepository()
repository.details[501] = _detail(999, "越权")
dialog = _open_dialog(application, repository)
forbidden = (
"越权主诉口渴乏力",
"甲医生检查记录",
"甲舌苔照片.jpg",
"甲-RX-1",
"甲燕麦鸡蛋",
)
for title in ("病历资料", "检查检验", "处方记录", "健康档案"):
pane = dialog.records[title]
text = _pane_text(pane)
assert all(sentinel not in text for sentinel in forbidden)
state = pane.state_label # type: ignore[attr-defined]
retry = pane.retry_button # type: ignore[attr-defined]
assert state.objectName() == "AiConsultRecordState"
assert retry.objectName() == "AiConsultRecordRetry"
assert state is not None and state.property("state") == "warning"
assert retry is not None and retry.isHidden()
assert all(
method != "list_patient_ai_reports" for method, _owner in repository.calls
)
dialog.close()
@pytest.mark.parametrize(
("method", "error_tabs", "success_sentinels"),
[
(
"get_diagnosis_detail",
{"病历资料", "健康档案"},
{"检查检验": "甲医生检查记录", "处方记录": "甲-RX-1"},
),
(
"get_doctor_notes",
{"检查检验"},
{
"病历资料": "甲主诉口渴乏力",
"处方记录": "甲-RX-1",
"健康档案": "甲燕麦鸡蛋",
},
),
(
"get_tracking_window",
{"健康档案"},
{
"病历资料": "甲主诉口渴乏力",
"检查检验": "甲医生检查记录",
"处方记录": "甲-RX-1",
},
),
(
"list_prescriptions_by_diagnosis",
{"处方记录"},
{
"病历资料": "甲主诉口渴乏力",
"检查检验": "甲医生检查记录",
"健康档案": "甲燕麦鸡蛋",
},
),
],
)
def test_one_failed_source_has_local_error_retry_and_preserves_other_sections(
application: QApplication,
immediate_async: None,
method: str,
error_tabs: set[str],
success_sentinels: dict[str, str],
) -> None:
repository = WorkspaceRepository(include_foreign_rows=False)
repository.failures.add((method, 501))
dialog = _open_dialog(application, repository)
for title in error_tabs:
pane = dialog.records[title]
state = pane.state_label # type: ignore[attr-defined]
retry = pane.retry_button # type: ignore[attr-defined]
assert state.objectName() == "AiConsultRecordState"
assert retry.objectName() == "AiConsultRecordRetry"
assert state is not None and state.property("state") == "error"
assert retry is not None and not retry.isHidden() and retry.isEnabled()
for title, sentinel in success_sentinels.items():
assert sentinel in _pane_text(dialog.records[title])
state = dialog.records[title].state_label # type: ignore[attr-defined]
assert state is not None and state.property("state") != "error", (
title,
state.text(),
state.property("state"),
)
repository.failures.clear()
dialog.records[next(iter(error_tabs))].retry_button.click() # type: ignore[attr-defined]
application.processEvents()
for pane in dialog.records.values():
state = pane.state_label # type: ignore[attr-defined]
assert state is not None and state.property("state") != "error"
dialog.close()
+85
View File
@@ -3,7 +3,10 @@
from __future__ import annotations
import json
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from threading import Barrier, get_ident
from typing import Any
import httpx
import pytest
@@ -17,6 +20,7 @@ from doctor_workstation.core.errors import (
OpenPageRequiredError,
WorkWechatBindingRequiredError,
)
from doctor_workstation.services import api_client as api_client_module
from doctor_workstation.services.api_client import ApiClient
from doctor_workstation.services.token_store import TokenStore
@@ -48,6 +52,41 @@ def test_get_normalises_adminapi_and_sends_contract_headers() -> None:
)
def test_default_client_allows_parallel_requests_with_independent_transports(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Production workers must not queue behind one process-wide HTTP lock."""
rendezvous = Barrier(2, timeout=2)
created: list[Any] = []
request_threads: set[int] = set()
class PooledClient:
def __init__(self, **_options: Any) -> None:
self.closed = False
created.append(self)
def request(self, _method: str, url: str, **_options: Any) -> httpx.Response:
request_threads.add(get_ident())
rendezvous.wait()
return httpx.Response(200, json={"code": 1, "data": url.rsplit("/", 1)[-1]})
def close(self) -> None:
self.closed = True
monkeypatch.setattr(api_client_module.httpx, "Client", PooledClient)
client = ApiClient("https://example.test", max_retries=0)
with ThreadPoolExecutor(max_workers=2) as executor:
first = executor.submit(client.get, "patient/first")
second = executor.submit(client.get, "patient/second")
assert {first.result(timeout=3), second.result(timeout=3)} == {"first", "second"}
client.close()
assert len(created) == 2
assert len(request_threads) == 2
assert all(item.closed for item in created)
def test_post_uses_json_and_never_retries_timeout() -> None:
"""Writes use JSON and a timeout never causes an automatic duplicate POST."""
@@ -74,6 +113,52 @@ def test_post_uses_json_and_never_retries_timeout() -> None:
assert caught.value.data["attempts"] == 1
def test_post_event_stream_sends_exact_contract_and_preserves_event_order() -> None:
requests: list[httpx.Request] = []
content = (
'event: start\ndata: {"model_key":"qwen"}\n\n'
'event: delta\ndata: {"content":""}\n\n'
'event: delta\ndata: {"content":""}\n\n'
'event: done\ndata: {"model_label":"千问"}\n\n'
)
def handler(request: httpx.Request) -> httpx.Response:
requests.append(request)
return httpx.Response(
200,
headers={"content-type": "text/event-stream; charset=utf-8"},
text=content,
)
client = ApiClient(
"https://example.test",
token="stream-token",
transport=httpx.MockTransport(handler),
)
events = list(
client.post_event_stream(
"tcm.diagnosis/aiAssistantStream",
{"id": 501, "task": "custom", "prompt": "如何辨证?"},
)
)
client.close()
assert [event["event"] for event in events] == ["start", "delta", "delta", "done"]
assert [event["data"] for event in events[1:3]] == [
{"content": ""},
{"content": ""},
]
request = requests[0]
assert request.headers["accept"] == "text/event-stream"
assert request.headers["token"] == "stream-token"
assert str(request.url).endswith("/adminapi/tcm.diagnosis/aiAssistantStream")
assert json.loads(request.content) == {
"id": 501,
"task": "custom",
"prompt": "如何辨证?",
}
def test_multipart_post_lets_httpx_set_boundary_and_sends_form_fields(
tmp_path: Path,
) -> None:
+363 -7
View File
@@ -3,13 +3,21 @@
from __future__ import annotations
import os
from copy import deepcopy
from types import SimpleNamespace
from typing import Any
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from PySide6.QtWidgets import QApplication, QDialog, QDialogButtonBox, QLabel
from PySide6.QtWidgets import (
QAbstractItemView,
QApplication,
QDialog,
QDialogButtonBox,
QLabel,
QPushButton,
)
from doctor_workstation.core.errors import ApiProtocolError
from doctor_workstation.core.models import Appointment, PageResult
@@ -163,6 +171,8 @@ def test_diagnosis_and_patient_ids_stay_distinct_for_video() -> None:
)
assert _diagnosis_id(row) == 501
assert _video_patient_id(row) == 301
assert _video_patient_id({"diagnosis_id": 501, "patient_id": 301}) == 0
assert _video_patient_id({"diagnosis_id": 501, "patient_id": 501}) == 0
assert prescription_action_label(row) == "开方"
approved = Appointment.from_dict(
@@ -185,6 +195,106 @@ def test_diagnosis_and_patient_ids_stay_distinct_for_video() -> None:
)
assert prescription_action_label(pending) == "编辑处方"
historical_only = Appointment.from_dict(
{
"id": 104,
"prescription_audit_status": 1,
"prescription_void_status": 0,
"has_prescription": 1,
"current_has_prescription": 0,
"current_prescription_id": 0,
}
)
assert prescription_action_label(historical_only) == "开方"
def test_appointment_view_intent_never_degrades_to_edit(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
row = {
"id": 101,
"appointment_id": 101,
"diagnosis_id": 501,
"current_has_prescription": 1,
"current_prescription_id": 81,
"prescription_audit_status": 1,
"prescription_void_status": 0,
}
page = AppointmentsPage(SimpleNamespace(), permissions=PermissionSet(["*"]))
monkeypatch.setattr(page, "_current_row", lambda: row)
requested: list[tuple[Any, str]] = []
monkeypatch.setattr(
page,
"_begin_prescription_load",
lambda source, *, mode="open": requested.append((source, mode)),
)
page._open_prescription()
assert requested == [(row, "view")]
monkeypatch.setattr(
page,
"_open_existing_prescription_editor",
lambda _existing: pytest.fail("view intent must never open the editor"),
)
page._prescription_loaded(
{
"id": 81,
"appointment_id": 101,
"audit_status": 2,
"void_status": 0,
},
row,
page._prescription_generation,
"view",
)
assert "状态已变化" in page.banner.label.text()
page.close()
application.processEvents()
def test_appointment_im_action_opens_chat_without_an_existing_live_call(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
page = AppointmentsPage(
DemoDoctorRepository(),
permissions=PermissionSet(["doctor.appointment/prescription"]),
)
row = {
"id": 101,
"appointment_id": 101,
"diagnosis_id": 501,
"patient_id": 501,
"source_patient_id": 301,
"patient_name": "测试患者",
"status": 1,
}
emitted: list[dict[str, Any]] = []
monkeypatch.setattr(page, "_current_row", lambda: row)
page.video_requested.connect(emitted.append)
page._request_video()
assert len(emitted) == 1
assert emitted[0]["mode"] == "im"
assert emitted[0]["appointment_id"] == 101
assert emitted[0]["diagnosis_id"] == 501
assert emitted[0]["patient_id"] == 301
warnings: list[str] = []
monkeypatch.setattr(
appointments_module,
"show_toast",
lambda _parent, message, _kind: warnings.append(message),
)
row["status"] = 2
page._request_video()
assert len(emitted) == 1
assert warnings == ["已取消的挂号不可进入 IM 问诊。"]
page.close()
application.processEvents()
def test_appointment_pending_prescription_uses_full_edit_contract(
application: QApplication,
@@ -354,7 +464,8 @@ def test_appointment_multiline_cells_receive_enough_row_height(
assert appointment_text.count("\n") == 2
assert "2026-08-11 14:30" in appointment_text
required = 3 * max(16, page.table.fontMetrics().lineSpacing()) + 10
assert page.table.rowHeight(0) >= required
assert 60 <= page.table.rowHeight(0) <= 66
assert page.table.rowHeight(0) >= min(required, 66)
assert page.table.item(0, 4).toolTip() == appointment_text
page.close()
@@ -504,21 +615,28 @@ def test_appointment_ai_report_button_visible_with_reception_permission(
application.processEvents()
def test_appointments_reference_split_layout_and_video_list(
def test_appointments_use_full_width_table_with_per_row_im_consult(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(appointments_module, "run_async", lambda *_args, **_kwargs: object())
page = AppointmentsPage(
DemoDoctorRepository(),
permissions=PermissionSet(["doctor.appointment/lists"]),
permissions=PermissionSet(
["doctor.appointment/lists", "doctor.appointment/prescription"]
),
current_user={"id": 1001, "role_id": 1},
)
page.resize(1460, 820)
page.show()
page._apply_responsive_layout()
page._loaded(
{
"lists": [
{
"id": 101,
"diagnosis_id": 501,
"source_patient_id": 301,
"patient_name": "赵俊霞",
"gender": 0,
"age": 53,
@@ -527,6 +645,7 @@ def test_appointments_reference_split_layout_and_video_list(
"appointment_time": "09:50",
"status": 1,
"status_desc": "已挂号",
"video_call_hint": {"state": "live", "label": "视频通话进行中"},
}
],
"count": 1,
@@ -536,10 +655,247 @@ def test_appointments_reference_split_layout_and_video_list(
)
application.processEvents()
assert page.video_list.count() == 1
assert "赵俊霞" in page.video_list.item(0).text()
assert page.video_list.parentWidget().width() == 420
assert page.content_layout.count() == 1
assert not hasattr(page, "video_panel")
assert not hasattr(page, "video_list")
assert page.table_card.width() == page.content_host.width()
assert page.table.objectName() == "AppointmentTable"
assert (
page.table.verticalScrollMode()
== QAbstractItemView.ScrollMode.ScrollPerPixel
)
assert page.table.horizontalHeaderItem(10).text() == "IM 问诊"
im_host = page.table.cellWidget(0, 10)
assert im_host is not None
im_button = im_host.findChild(QPushButton, "AppointmentImConsultButton")
assert im_button is not None
assert im_button.text() == "IM 问诊"
assert im_button.isEnabled()
assert im_button.accessibleName() == "与赵俊霞进行 IM 问诊"
emitted: list[dict[str, Any]] = []
page.video_requested.connect(emitted.append)
im_button.click()
assert emitted and emitted[0]["mode"] == "im"
assert emitted[0]["appointment_id"] == 101
assert page.date_buttons["today"].isChecked()
page.resize(1024, 640)
page._apply_responsive_layout()
application.processEvents()
assert page.table_card.width() == page.content_host.width()
assert not page.date_overflow_button.isHidden()
assert page.date_buttons["yesterday"].isHidden()
assert not page.date_buttons["today"].isHidden()
page.close()
application.processEvents()
def test_im_entry_does_not_require_a_live_video_hint(
application: QApplication,
) -> None:
page = AppointmentsPage(
DemoDoctorRepository(),
permissions=PermissionSet(["doctor.appointment/prescription"]),
)
rows = [
{
"id": 101,
"diagnosis_id": 501,
"source_patient_id": 301,
"patient_name": "已接通患者",
"status": 1,
"video_call_hint": {"state": "ended", "label": "视频通话已结束"},
},
{
"id": 102,
"diagnosis_id": 502,
"source_patient_id": 302,
"patient_name": "等待患者",
"status": 1,
},
]
page._loaded({"lists": rows, "count": 2}, page._generation, False)
application.processEvents()
buttons = [
page.table.cellWidget(index, 10).findChild(
QPushButton,
"AppointmentImConsultButton",
)
for index in range(page.table.rowCount())
]
buttons_by_name = {
button.accessibleName(): button for button in buttons if button is not None
}
assert set(buttons_by_name) == {
"与已接通患者进行 IM 问诊",
"与等待患者进行 IM 问诊",
}
assert buttons_by_name["与已接通患者进行 IM 问诊"].isEnabled()
waiting = buttons_by_name["与等待患者进行 IM 问诊"]
assert waiting.isEnabled()
assert waiting.toolTip() == "打开患者 IM,可发送消息并从会话中发起视频"
page.close()
application.processEvents()
def test_im_entry_allows_fulfillable_statuses_and_rejects_terminal_or_unknown_statuses(
application: QApplication,
) -> None:
page = AppointmentsPage(
DemoDoctorRepository(),
permissions=PermissionSet(["doctor.appointment/prescription"]),
)
rows = [
{
"id": index + 100,
"diagnosis_id": index + 500,
"source_patient_id": index + 300,
"patient_name": name,
"status": status,
}
for index, (name, status) in enumerate(
(
("已预约患者", 1),
("已过号患者", 4),
("已取消患者", 2),
("已完成患者", 3),
("未知状态患者", 0),
)
)
]
page._loaded({"lists": rows, "count": len(rows)}, page._generation, False)
application.processEvents()
buttons = {
button.accessibleName(): button
for row_index in range(page.table.rowCount())
if (
button := page.table.cellWidget(row_index, 10).findChild(
QPushButton,
"AppointmentImConsultButton",
)
)
is not None
}
assert buttons["与已预约患者进行 IM 问诊"].isEnabled()
assert buttons["与已过号患者进行 IM 问诊"].isEnabled()
assert not buttons["与已取消患者进行 IM 问诊"].isEnabled()
assert buttons["与已取消患者进行 IM 问诊"].toolTip() == (
"已取消的挂号不可进入 IM 问诊"
)
assert not buttons["与已完成患者进行 IM 问诊"].isEnabled()
assert buttons["与已完成患者进行 IM 问诊"].toolTip() == (
"已完成的挂号不可再进入 IM 问诊"
)
assert not buttons["与未知状态患者进行 IM 问诊"].isEnabled()
assert buttons["与未知状态患者进行 IM 问诊"].toolTip() == (
"当前挂号状态不可进入 IM 问诊"
)
page.close()
application.processEvents()
def test_identical_appointment_poll_keeps_existing_cell_widgets(
application: QApplication,
) -> None:
page = AppointmentsPage(
DemoDoctorRepository(),
permissions=PermissionSet(["doctor.appointment/lists"]),
current_user={"id": 1001, "role_id": 1},
)
result = {
"lists": [
{
"id": 101,
"diagnosis_id": 501,
"patient_id": 301,
"patient_name": "赵俊霞",
"gender": 2,
"age": 53,
"assistant_name": "周医助",
"appointment_date": "2026-08-17",
"appointment_time": "09:50",
"status": 1,
"status_desc": "已挂号",
}
],
"count": 1,
"extend": {"status_count": {"1": 1}},
}
page._loaded(result, page._generation, True)
selector = page.table.cellWidget(0, 0)
appointment_info = page.table.cellWidget(0, 4)
im_action = page.table.cellWidget(0, 10)
page._loaded(deepcopy(result), page._generation, True)
assert page.table.cellWidget(0, 0) is selector
assert page.table.cellWidget(0, 4) is appointment_info
assert page.table.cellWidget(0, 10) is im_action
changed = deepcopy(result)
changed["lists"][0]["assistant_name"] = "新医助"
page._loaded(changed, page._generation, True)
assert page.table.cellWidget(0, 0) is not selector
assert page.table.cellWidget(0, 4) is not appointment_info
assert page.table.cellWidget(0, 10) is not im_action
page.close()
application.processEvents()
def test_appointments_density_fits_four_rows_in_1366_shell_viewport(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(appointments_module, "run_async", lambda *_args, **_kwargs: object())
page = AppointmentsPage(
DemoDoctorRepository(),
permissions=PermissionSet(["*"]),
current_user={"id": 1001, "role_id": 1},
)
# 1366x768 shell minus its 179 px appointment rail, 26 px outer gutter,
# and 62 px top bar leaves a 1161x680 page viewport.
page.resize(1161, 680)
page.show()
application.processEvents()
rows = [
{
"id": 100 + index,
"diagnosis_id": 500 + index,
"patient_id": 300 + index,
"patient_name": f"患者{index}",
"gender": 2,
"age": 40 + index,
"doctor_name": "陈医生",
"assistant_name": "周医助",
"appointment_date": "2026-08-17",
"appointment_time": f"{8 + index:02d}:00",
"status": 1,
"status_desc": "已挂号",
"diagnosis_confirmed": 0,
"has_prescription": 0,
}
for index in range(8)
]
page._loaded(
{"lists": rows, "count": len(rows), "extend": {"status_count": {"1": 8}}},
page._generation,
False,
)
application.processEvents()
heights = [page.table.rowHeight(index) for index in range(page.table.rowCount())]
assert page.header.height() == 26
assert page.filter_panel.height() <= 84
assert all(60 <= height <= 66 for height in heights)
assert page.table.viewport().height() // max(heights) >= 4
assert page.pager.isVisibleTo(page)
assert page.content_layout.count() == 1
assert page.table_card.width() == page.content_host.width()
page.resize(1024, 640)
application.processEvents()
assert page.table_card.width() == page.content_host.width()
page.close()
application.processEvents()
+194 -18
View File
@@ -169,7 +169,7 @@ def test_video_condition_never_uses_diagnosis_status_or_missed_status() -> None:
assert payload["patient_id"] == 301
def test_nested_appointments_confirmation_and_prescription_labels() -> None:
def test_nested_appointments_confirmation_and_prescription_labels() -> None:
row = _row(
appointment_id=None,
appointment_status=None,
@@ -195,6 +195,62 @@ def test_nested_appointments_confirmation_and_prescription_labels() -> None:
)
== "编辑处方"
)
assert (
prescription_action_label(
{
"has_prescription": 1,
"current_has_prescription": 0,
"prescription_audit_status": 1,
"prescription_void_status": 0,
}
)
== "开方"
)
def test_view_prescription_intent_is_readonly_and_fails_closed_on_state_change(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
row = _row(
has_prescription=1,
current_has_prescription=1,
current_prescription_id=701,
prescription_audit_status=1,
prescription_void_status=0,
)
page = ConsultationsPage(SimpleNamespace(), permissions=PermissionSet(["*"]))
page.table_host.set_rows([row])
page.table.selectRow(0)
requested: list[tuple[Any, str]] = []
monkeypatch.setattr(
page,
"_begin_prescription_load",
lambda record, *, mode: requested.append((record, mode)),
)
page._open_prescription()
assert requested == [(row, "view")]
monkeypatch.setattr(
page,
"_open_existing_prescription_editor",
lambda _existing: pytest.fail("view intent must never open the editor"),
)
page._prescription_loaded(
{
"id": 701,
"appointment_id": 101,
"audit_status": 0,
"void_status": 0,
},
row,
"view",
page._prescription_generation,
)
assert "状态已变化" in page.banner.label.text()
page.close()
application.processEvents()
def test_default_query_matches_admin_today_and_page_size_contract(
@@ -298,7 +354,7 @@ def test_action_visibility_requires_exact_canonical_permissions(
application.processEvents()
def test_refresh_generation_ignores_late_results(
def test_refresh_generation_ignores_late_results(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
@@ -318,11 +374,81 @@ def test_refresh_generation_ignores_late_results(
application.processEvents()
assert page.table.rowCount() == 1
assert page.table.item(0, 0).text().startswith("902")
page.close()
def test_current_appointment_is_the_only_prescription_authority(
assert page.table.item(0, 0).text().startswith("902")
page.close()
def test_identical_silent_refresh_has_zero_model_reset_and_fixed_widget_budget(
application: QApplication,
immediate_async: None,
monkeypatch: pytest.MonkeyPatch,
) -> None:
class Repository:
calls = 0
def list_consultations(self, **_kwargs: Any) -> dict[str, Any]:
self.calls += 1
return {"lists": [_row()], "count": 1}
repository = Repository()
page = ConsultationsPage(repository, permissions=PermissionSet(["*"]))
page.refresh(silent=True)
page.table.selectRow(0)
model = page.table_host.model
action_widget = page.table_host.fixed.indexWidget(model.index(0, 11))
video_widget = page.table_host.fixed.indexWidget(model.index(0, 10))
resets: list[str] = []
model.modelAboutToBeReset.connect(lambda: resets.append("begin"))
model.modelReset.connect(lambda: resets.append("end"))
install_calls: list[None] = []
original_install = page.table_host._install_fixed_widgets
def count_install() -> None:
install_calls.append(None)
original_install()
monkeypatch.setattr(page.table_host, "_install_fixed_widgets", count_install)
page.refresh(silent=True)
assert repository.calls == 2
assert resets == []
assert install_calls == []
assert page.table_host.fixed.indexWidget(model.index(0, 11)) is action_widget
assert page.table_host.fixed.indexWidget(model.index(0, 10)) is video_widget
assert page.table.currentIndex().row() == 0
page.close()
application.processEvents()
def test_timer_poll_has_one_request_budget_while_refresh_is_in_flight(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
jobs: list[dict[str, Any]] = []
def queue_async(_function: Any, **options: Any) -> object:
jobs.append(options)
return object()
monkeypatch.setattr(consultations_module, "run_async", queue_async)
page = ConsultationsPage(SimpleNamespace(), permissions=PermissionSet(["*"]))
monkeypatch.setattr(page, "isVisible", lambda: True)
monkeypatch.setattr(page, "_refresh_counts", lambda: None)
page._poll_refresh()
page._poll_refresh()
page._poll_refresh()
assert len(jobs) == 1
assert page._loading
jobs[0]["on_success"]({"lists": [_row()], "count": 1})
jobs[0]["on_finished"]()
assert not page._loading
page.close()
application.processEvents()
def test_current_appointment_is_the_only_prescription_authority(
application: QApplication,
) -> None:
calls: list[tuple[str, int]] = []
@@ -541,7 +667,7 @@ def test_switching_rows_invalidates_prescription_worker_and_clears_busy(
application.processEvents()
def test_native_call_does_not_reuse_video_qr_permission(
def test_native_call_does_not_reuse_video_qr_permission(
application: QApplication,
) -> None:
class VideoRepository:
@@ -557,10 +683,17 @@ def test_native_call_does_not_reuse_video_qr_permission(
def end_call(self, diagnosis_id: int) -> None:
pass
page = ConsultationsPage(VideoRepository(), permissions=PermissionSet([]))
emitted: list[dict[str, Any]] = []
page.video_requested.connect(emitted.append)
page.table.set_rows([_row()])
live_row = _row(
video_call_hint={
"state": "live",
"label": "视频通话进行中",
"start_time": 1787102100,
}
)
page = ConsultationsPage(VideoRepository(), permissions=PermissionSet([]))
emitted: list[dict[str, Any]] = []
page.video_requested.connect(emitted.append)
page.table.set_rows([live_row])
page.table.selectRow(0)
application.processEvents()
@@ -568,12 +701,55 @@ def test_native_call_does_not_reuse_video_qr_permission(
assert page.video_button.isEnabled()
video_cell = page.table_host.fixed.indexWidget(page.table_host.model.index(0, 10))
video_action = next(button for button in video_cell.findChildren(QToolButton))
assert video_action.text() == "进入视频问诊"
assert "摄像头和麦克风" in video_action.toolTip()
page._request_video()
assert emitted == [_video_payload(_row())]
page.close()
application.processEvents()
assert video_action.text() == "进入视频问诊"
assert emitted == []
assert "摄像头和麦克风" in video_action.toolTip()
page._request_video()
assert emitted == [_video_payload(live_row)]
assert emitted[0]["mode"] == "im"
page.close()
application.processEvents()
@pytest.mark.parametrize(
("hint", "status_text"),
[
({"state": "none", "label": ""}, "暂无通话"),
({"state": "pending_room", "label": "通话发起中,待同步房间"}, "等待接通"),
],
)
def test_video_join_action_is_hidden_until_doctor_session_is_live(
application: QApplication,
hint: dict[str, Any],
status_text: str,
) -> None:
class VideoRepository:
def get_call_ticket(self, patient_id: int, diagnosis_id: int) -> None:
pass
def start_call(self, diagnosis_id: int, patient_id: int, *, call_type: int = 2) -> None:
pass
def bind_call_room(self, diagnosis_id: int, room_id: str) -> None:
pass
def end_call(self, diagnosis_id: int) -> None:
pass
page = ConsultationsPage(VideoRepository(), permissions=PermissionSet([]))
emitted: list[dict[str, Any]] = []
page.video_requested.connect(emitted.append)
page.table.set_rows([_row(video_call_hint=hint)])
page.table.selectRow(0)
application.processEvents()
video_cell = page.table_host.fixed.indexWidget(page.table_host.model.index(0, 10))
assert video_cell.findChildren(QToolButton) == []
assert status_text in " ".join(label.text() for label in video_cell.findChildren(QLabel))
page._request_video()
assert emitted == []
page.close()
application.processEvents()
@pytest.mark.parametrize(
+16 -9
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import os
from datetime import date, timedelta
from pathlib import Path
from typing import Any
@@ -276,13 +277,15 @@ class VisualRepository:
) -> dict[str, Any]:
assert diagnosis_id == 501
self.tracking_calls.append((start_date, end_date))
newest_date = end_date or date.today().isoformat()
previous_date = (date.fromisoformat(newest_date) - timedelta(days=1)).isoformat()
return {
"blood_records": [
{
"id": 6201,
"diagnosis_id": 501,
"patient_id": 1501,
"record_date": "2026-08-10",
"record_date": newest_date,
"fasting_blood_sugar": 8.2,
"systolic_pressure": 146,
"source": 1,
@@ -291,7 +294,7 @@ class VisualRepository:
"id": 6202,
"diagnosis_id": 501,
"patient_id": 1501,
"record_date": "2026-08-10",
"record_date": newest_date,
"postprandial_blood_sugar": 12.4,
"diastolic_pressure": 92,
"western_medicine": "二甲双胍",
@@ -300,7 +303,7 @@ class VisualRepository:
"id": 6203,
"diagnosis_id": 501,
"patient_id": 1501,
"record_date": "2026-08-09",
"record_date": previous_date,
"fasting_blood_sugar": 7.6,
"postprandial_blood_sugar": 10.8,
},
@@ -310,7 +313,7 @@ class VisualRepository:
"id": 6301,
"diagnosis_id": 501,
"patient_id": 1501,
"record_date": "2026-08-10",
"record_date": newest_date,
"breakfast_foods": "燕麦、鸡蛋",
"lunch_foods": "杂粮饭",
}
@@ -320,7 +323,7 @@ class VisualRepository:
"id": 6401,
"diagnosis_id": 501,
"patient_id": 1501,
"record_date": "2026-08-09",
"record_date": previous_date,
"exercise_type": "散步",
"duration": 35,
"intensity": 2,
@@ -331,7 +334,7 @@ class VisualRepository:
def list_tracking_notes(self, diagnosis_id: int) -> list[dict[str, Any]]:
assert diagnosis_id == 501
self.tracking_note_calls += 1
return [{"note_date": "2026-08-10", "content": "饭后散步,继续观察。"}]
return [{"note_date": date.today().isoformat(), "content": "饭后散步,继续观察。"}]
def list_diagnosis_todos(
self,
@@ -871,10 +874,11 @@ def test_tabs_lazy_load_real_repository_data_and_daily_matrix_structure(
panel = dialog._daily_panels[1]
assert panel.matrix.rowCount() == 11
assert panel.matrix.columnCount() == 8
newest_header = repository.tracking_calls[-1][1][5:]
blood_column = next(
column
for column in range(1, panel.matrix.columnCount())
if panel.matrix.horizontalHeaderItem(column).text() == "08-10"
if panel.matrix.horizontalHeaderItem(column).text() == newest_header
)
assert panel.matrix.item(0, blood_column).text() == "↑ 8.2 · 自录"
assert panel.matrix.item(3, blood_column).text() == "↑ 146/92 · 自录"
@@ -1270,15 +1274,18 @@ def test_existing_daily_cells_edit_real_records_and_reject_wrong_owner(
return payload
monkeypatch.setattr(diagnosis_module, "DailyRecordEditorDialog", AcceptedEditor)
newest_date = date.fromisoformat(repository.tracking_calls[-1][1])
newest_header = newest_date.strftime("%m-%d")
previous_header = (newest_date - timedelta(days=1)).strftime("%m-%d")
blood_column = next(
column
for column in range(1, panel.matrix.columnCount())
if panel.matrix.horizontalHeaderItem(column).text() == "08-10"
if panel.matrix.horizontalHeaderItem(column).text() == newest_header
)
exercise_column = next(
column
for column in range(1, panel.matrix.columnCount())
if panel.matrix.horizontalHeaderItem(column).text() == "08-09"
if panel.matrix.horizontalHeaderItem(column).text() == previous_header
)
blood_role = panel.matrix.item(0, blood_column).data(Qt.ItemDataRole.UserRole)
diet_role = panel.matrix.item(6, blood_column).data(Qt.ItemDataRole.UserRole)
+110 -14
View File
@@ -8,9 +8,16 @@ from typing import Any
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from PySide6.QtCore import QAbstractTableModel, QRect, Qt, Signal
from PySide6.QtCore import QAbstractTableModel, QPoint, QRect, Qt, Signal
from PySide6.QtGui import QColor, QImage, QPainter
from PySide6.QtWidgets import QApplication, QFrame, QToolButton, QWidget
from PySide6.QtWidgets import (
QAbstractItemView,
QApplication,
QFrame,
QSizePolicy,
QToolButton,
QWidget,
)
from doctor_workstation.core import PermissionSet
from doctor_workstation.ui.diagnosis_index_widgets import (
@@ -139,6 +146,8 @@ def _row(identifier: int, **changes: Any) -> dict[str, Any]:
"assistant_name": "赵医助",
"assign_read_at": None,
"has_prescription": 1,
"current_has_prescription": 0,
"current_prescription_id": 0,
"followup_time_text": "2026-08-17 09:00",
"followup_doctor_name": "陈医生",
"unserved_days": 2,
@@ -158,14 +167,15 @@ def test_visual_hierarchy_and_filter_contract(
page = _page()
content_layout = page.page_scroll.widget().layout()
margins = content_layout.contentsMargins()
assert (margins.left(), margins.top(), margins.right(), margins.bottom()) == (20, 18, 29, 16)
assert content_layout.spacing() == 12
assert (margins.left(), margins.top(), margins.right(), margins.bottom()) == (18, 10, 18, 10)
assert content_layout.spacing() == 8
status_card = page.findChild(QFrame, "DiagnosisStatusCard")
assert status_card is not None
assert status_card.height() == 62
assert page.page_header.height() == 62
assert status_card.height() == 50
assert page.findChild(QFrame, "DiagnosisFilterCard") is not None
assert page.findChild(QFrame, "DiagnosisListCard") is not None
assert page.filters_card.height() == 108
assert page.filters_card.height() == 90
assert page.keyword_edit.maximumWidth() == 380
assert list(page.status_buttons) == ["1", "", "4", "2", "3"]
assert page.status_buttons["1"].isChecked()
@@ -234,12 +244,17 @@ def test_dedicated_model_fixed_columns_selection_and_sort(
assert isinstance(page.table.model(), QAbstractTableModel)
assert isinstance(page.table.model(), DiagnosisTableModel)
assert page.table_host.LEFT_WIDTHS == (48, 70, 60, 100, 175, 88, 120, 100, 72, 110)
assert page.table_host.FIXED_WIDTHS == (120, 340)
assert page.table_host.fixed.width() == 462
assert page.table_host.FIXED_WIDTHS == (120, 410)
assert page.table_host.fixed.width() == 532
assert page.table.isColumnHidden(10)
assert page.table_host.fixed.isColumnHidden(9)
assert not page.table_host.fixed.isColumnHidden(10)
assert page.table.verticalScrollBarPolicy() == Qt.ScrollBarPolicy.ScrollBarAlwaysOff
assert page.table.verticalScrollBarPolicy() == Qt.ScrollBarPolicy.ScrollBarAsNeeded
assert page.table.verticalScrollMode() == QAbstractItemView.ScrollMode.ScrollPerPixel
assert page.table_host.fixed.verticalScrollMode() == QAbstractItemView.ScrollMode.ScrollPerPixel
assert page.table_host.fixed.verticalScrollBarPolicy() == Qt.ScrollBarPolicy.ScrollBarAlwaysOff
assert page.table_host.minimumHeight() == 0
assert page.table_host.sizePolicy().verticalPolicy() == QSizePolicy.Policy.Expanding
rows = [_row(501), _row(502, has_appointment=0, appointments=[])]
page.table_host.set_rows(rows)
@@ -303,6 +318,10 @@ def test_empty_loading_and_full_pager_keep_the_table_shell(
page.loading_overlay.stop()
page.pager.update_state(3, 97)
assert 40 <= page.pager.height() <= 44
pager_margins = page.pager.layout().contentsMargins()
assert pager_margins.top() >= 4
assert pager_margins.bottom() >= 4
assert [page.pager.size_combo.itemData(index) for index in range(4)] == [15, 20, 30, 40]
assert len([button for button in page.pager._page_buttons if not button.isHidden()]) == 5
assert page.pager.jumper.maximum() == 7
@@ -472,6 +491,7 @@ def test_full_more_menu_requires_each_real_repository_capability(
"view": True,
"edit": True,
"prescription": True,
"ai_consult": True,
"appointment": True,
"assign": True,
"delete": True,
@@ -612,30 +632,106 @@ def test_error_state_is_persistent_until_rows_replace_it(application: QApplicati
application.processEvents()
@pytest.mark.parametrize("size", [(1024, 640), (1440, 900)])
def test_two_desktop_sizes_scroll_vertically_without_horizontal_page_clipping(
@pytest.mark.parametrize(
("size", "minimum_visible_rows"),
[((1366, 768), 4), ((1710, 920), 7)],
)
def test_two_desktop_sizes_keep_pager_visible_and_scroll_rows_inside_table(
application: QApplication,
size: tuple[int, int],
minimum_visible_rows: int,
) -> None:
page = _page()
rows = [_row(600 + index, patient_name=f"患者{index:02d}") for index in range(15)]
rows = [
_row(
600 + index,
patient_name=f"患者{index:02d}",
latest_appointment_channel_text="健康顾问转介",
)
for index in range(40)
]
page.table_host.set_rows(rows)
page.resize(*size)
page.show()
application.processEvents()
for _ in range(4):
application.processEvents()
viewport = page.table.viewport()
visible_rows = sum(
1
for row in range(page.table_host.model.rowCount())
if (
(rect := page.table.visualRect(page.table_host.model.index(row, 0))).isValid()
and rect.top() >= 0
and rect.bottom() < viewport.height()
)
)
pager_top = page.pager.mapTo(page.page_scroll.viewport(), QPoint()).y()
assert page.page_scroll.horizontalScrollBar().maximum() == 0
assert page.page_scroll.verticalScrollBar().maximum() > 0
assert page.page_scroll.verticalScrollBar().maximum() == 0
assert pager_top >= 0
assert pager_top + page.pager.height() <= page.page_scroll.viewport().height()
assert visible_rows >= minimum_visible_rows
assert page.table.verticalScrollBar().maximum() > 0
assert page.table_host.fixed.geometry().right() <= page.table_host.rect().right()
assert page.search_button.geometry().right() <= page.search_button.parentWidget().rect().right()
page.close()
application.processEvents()
def test_frozen_rows_track_main_pixel_scroll_and_host_height_is_page_size_stable(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
page = _page()
rows = [
_row(
800 + index,
latest_appointment_channel_text="健康顾问转介",
)
for index in range(40)
]
page.table_host.set_rows(rows[:15])
page.resize(1366, 768)
page.show()
for _ in range(4):
application.processEvents()
host_height = page.table_host.height()
monkeypatch.setattr(page, "refresh", lambda silent=False: None)
page._change_page_size(40)
page.table_host.set_rows(rows)
for _ in range(4):
application.processEvents()
main_scroll = page.table.verticalScrollBar()
fixed_scroll = page.table_host.fixed.verticalScrollBar()
assert page.table_host.height() == host_height
assert main_scroll.maximum() == fixed_scroll.maximum()
main_scroll.setValue(main_scroll.maximum() // 2)
application.processEvents()
assert fixed_scroll.value() == main_scroll.value()
fixed_scroll.setValue(fixed_scroll.maximum() // 3)
application.processEvents()
assert main_scroll.value() == fixed_scroll.value()
center_index = page.table.indexAt(page.table.viewport().rect().center())
assert center_index.isValid()
main_top = page.table.visualRect(page.table_host.model.index(center_index.row(), 0)).top()
fixed_top = page.table_host.fixed.visualRect(
page.table_host.model.index(center_index.row(), 10)
).top()
assert main_top == fixed_top
page.close()
application.processEvents()
def test_required_reference_artifacts_exist() -> None:
root = Path(__file__).resolve().parents[1]
expected = {
root / "artifacts" / "diagnosis_visual" / "diagnosis_1024x640.png": (1024, 640),
root / "artifacts" / "diagnosis_visual" / "diagnosis_1440x900.png": (1440, 900),
root / "artifacts" / "diagnosis_visual" / "diagnosis_1366x768.png": (1366, 768),
root / "artifacts" / "diagnosis_visual" / "diagnosis_1710x920.png": (1710, 920),
root / "artifacts" / "diagnosis_visual" / "diagnosis_loading_1280x800.png": (
1280,
800,
@@ -7,7 +7,7 @@ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from PySide6.QtCore import QBuffer, QByteArray, QIODevice, QObject, QSize, Signal
from PySide6.QtGui import QColor, QImage
from PySide6.QtNetwork import QNetworkReply
from PySide6.QtNetwork import QNetworkReply, QNetworkRequest
from PySide6.QtWidgets import QApplication, QPushButton, QWidget
from doctor_workstation.ui.diagnosis_drawer import (
@@ -34,6 +34,7 @@ def _png_bytes(width: int, height: int, color: str = "#0F766E") -> bytes:
class _FakeReply(QObject):
finished = Signal()
downloadProgress = Signal(int, int)
def __init__(
self,
@@ -45,6 +46,7 @@ class _FakeReply(QObject):
self.payload = payload
self.network_error = error
self.aborted = False
self.read_all_calls = 0
def abort(self) -> None:
self.aborted = True
@@ -53,6 +55,7 @@ class _FakeReply(QObject):
return self.network_error
def readAll(self) -> QByteArray: # noqa: N802 - mirrors QNetworkReply
self.read_all_calls += 1
return QByteArray(self.payload)
@@ -61,6 +64,7 @@ class _FakeManager(QObject):
super().__init__(parent)
self.responses: list[tuple[bytes, QNetworkReply.NetworkError]] = []
self.requests: list[str] = []
self.request_objects: list[object] = []
self.replies: list[_FakeReply] = []
def queue(
@@ -73,6 +77,7 @@ class _FakeManager(QObject):
def get(self, request: object) -> _FakeReply:
payload, error = self.responses.pop(0)
self.requests.append(request.url().toString())
self.request_objects.append(request)
reply = _FakeReply(payload, error, self)
self.replies.append(reply)
return reply
@@ -174,6 +179,45 @@ def test_remote_image_uses_text_only_after_request_or_decode_failure(
assert application.thread() == button.thread()
def test_remote_image_enforces_same_origin_redirects_and_aborts_oversize_download(
application: QApplication,
) -> None:
owner = _RenderOwner(5)
button = _RemoteImageButton(
"",
render_owner=owner,
owner_generation=5,
maximum_size=QSize(64, 64),
fallback_text="image unavailable",
cover=True,
object_name="DiagnosisTongueThumb",
parent=owner,
)
button._manager.deleteLater()
manager = _FakeManager(button)
button._manager = manager
manager.queue(b"must-not-be-read")
button.load_url("https://media.example.invalid/oversize.png")
request = manager.request_objects[-1]
assert request.attribute(QNetworkRequest.Attribute.RedirectPolicyAttribute) == (
QNetworkRequest.RedirectPolicy.SameOriginRedirectPolicy
)
reply = manager.replies[-1]
reply.downloadProgress.emit(button._MAX_IMAGE_BYTES, -1)
assert reply.aborted is False
reply.downloadProgress.emit(button._MAX_IMAGE_BYTES + 1, -1)
assert reply.aborted is True
assert reply.property("diagnosisImageOversize") is True
reply.finished.emit()
assert reply.read_all_calls == 0
assert button.property("loadState") == "failed"
assert button.text() == "image unavailable"
assert application.thread() == button.thread()
def test_notes_render_cover_thumbnail_and_keep_safe_open_and_single_delete(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
+136 -1
View File
@@ -1,12 +1,14 @@
from __future__ import annotations
import os
import threading
from types import SimpleNamespace
from typing import Any
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from PySide6.QtWidgets import QApplication, QFrame, QLabel, QPushButton, QVBoxLayout
from PySide6.QtWidgets import QApplication, QDialog, QFrame, QLabel, QPushButton, QVBoxLayout
from doctor_workstation.ui import diagnosis_media
from doctor_workstation.ui.diagnosis_drawer import RecordTable
@@ -378,6 +380,7 @@ def test_video_table_embeds_player_and_preserves_row_bound_upload(
[
{
"id": 48,
"room_id": "doctor-501-20260819-143247",
"recording_urls_list": [
"https://media.example.invalid/replay.mp4",
"https://media.example.invalid/replay-backup.m3u8",
@@ -407,6 +410,12 @@ def test_video_table_embeds_player_and_preserves_row_bound_upload(
application.processEvents()
table = dialog._table_registry["video"][1]
assert table.horizontalHeaderItem(1).text() == "房间号"
assert table.item(0, 1).text() == "doctor-501-20260819-143247"
assert table.item(1, 1).text() == "历史记录未保存"
room_rect = table.visualItemRect(table.item(0, 1))
assert room_rect.left() >= 0
assert room_rect.right() < table.viewport().width()
playback = table.cellWidget(0, 0)
assert isinstance(playback, RecordingPlaybackCell)
assert playback.property("callRecordId") == 48
@@ -465,6 +474,132 @@ def test_video_table_embeds_player_and_preserves_row_bound_upload(
application.processEvents()
def test_video_table_exposes_local_audio_separately_from_cloud_video_and_text(
application: QApplication,
) -> None:
dialog = DiagnosisDialog(_Repository(), permissions=["*"])
dialog._editable = False
dialog._can_video_upload = False
dialog._diagnosis_id = 501
opened: list[str] = []
dialog._open_recording_player = lambda target: opened.append(target) # type: ignore[method-assign]
audio_url = "https://cos.example.invalid/calls/local-audio.webm"
dialog._fill_video(
[
{
"id": 49,
"recording_urls_list": [
"https://cos.example.invalid/calls/cloud-mixed-video.mp4"
],
"local_audio_urls_list": [audio_url],
"local_audio_status_text": "已保存",
"transcript_text": "医生:请描述症状。\n患者:最近口渴。",
"transcription_status_text": "已完成",
"call_type": 2,
"status": 2,
"recording_status_text": "已生成",
}
]
)
table = dialog._table_registry["video"][1]
action_host = table.cellWidget(0, 8)
assert action_host is not None
audio = action_host.findChild(QPushButton, "DiagnosisLocalAudioPlayback")
transcript = action_host.findChild(QPushButton, "DiagnosisVideoTranscriptView")
assert audio is not None and audio.property("callRecordId") == 49
assert transcript is not None and transcript.property("callRecordId") == 49
status = table.item(0, 7).text()
assert "云端视频:已生成" in status
assert "本机录音:已保存" in status
assert "转写文字:已完成" in status
audio.click()
assert opened == [audio_url]
dialog.close()
application.processEvents()
def test_local_audio_upload_and_queue_close_refresh_server_backed_video_rows(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
created: list[QDialog] = []
class _UploadManager:
def __init__(self) -> None:
self.listeners: set[Any] = set()
def add_upload_listener(self, listener: Any) -> None:
self.listeners.add(listener)
def remove_upload_listener(self, listener: Any) -> None:
self.listeners.discard(listener)
def complete(self, diagnosis_id: int, call_record_id: int) -> None:
record = SimpleNamespace(
diagnosis_id=diagnosis_id,
call_record_id=call_record_id,
)
for listener in tuple(self.listeners):
listener(record)
manager = _UploadManager()
class _QueueDialog(QDialog):
def __init__(
self,
_repository: Any,
_diagnosis_id: int,
parent: QDialog,
) -> None:
super().__init__(parent)
self.manager = manager
created.append(self)
monkeypatch.setattr(diagnosis_module, "LocalAudioQueueDialog", _QueueDialog)
dialog = DiagnosisDialog(_Repository(), permissions=["*"])
dialog._diagnosis_id = 501
dialog._can_video_upload = True
reloads: list[tuple[str, bool]] = []
dialog._ensure_tab_loaded = ( # type: ignore[method-assign]
lambda key, force=False: reloads.append((key, force))
)
dialog._open_local_audio_queue()
assert len(created) == 1
queue_dialog = created[0]
assert reloads == [("video", True)]
dialog._loading_tabs.add("video")
manager.complete(501, 49)
manager.complete(501, 50)
manager.complete(999, 51)
application.processEvents()
assert reloads == [("video", True)]
assert dialog._video_reload_pending is True
dialog._loading_tabs.discard("video")
dialog._flush_video_reload_if_pending(501)
application.processEvents()
assert reloads == [("video", True), ("video", True)]
queue_dialog.accept()
application.processEvents()
assert reloads == [("video", True), ("video", True)]
assert manager.listeners
worker = threading.Thread(target=lambda: manager.complete(501, 52))
worker.start()
worker.join(timeout=5)
assert worker.is_alive() is False
application.processEvents()
assert reloads == [("video", True), ("video", True), ("video", True)]
dialog.reject()
application.processEvents()
assert manager.listeners == set()
def test_inline_player_rejects_stale_owner_generation(application: QApplication) -> None:
class _Owner:
_tab_generations = {"video": 4}
+482
View File
@@ -0,0 +1,482 @@
from __future__ import annotations
import sqlite3
import threading
from pathlib import Path
from typing import Any
import pytest
from PySide6.QtTest import QTest
from PySide6.QtWidgets import QApplication
from doctor_workstation.services.local_audio_queue import (
LocalAudioQueueStore,
LocalAudioUploadManager,
)
from doctor_workstation.ui.dialogs.local_audio_queue import (
LocalAudioQueueDialog,
_display_time,
)
def _application() -> QApplication:
return QApplication.instance() or QApplication([])
def _ready_record(
store: LocalAudioQueueStore,
*,
diagnosis_id: int,
call_record_id: int,
session_id: str,
room_id: str = "",
) -> int:
record = store.begin_recording(
session_id=session_id,
diagnosis_id=diagnosis_id,
mime_type="audio/webm",
call_record_id=call_record_id,
room_id=room_id,
)
payload = b"\x1aE\xdf\xa3" + (b"local-call-audio" * 128)
record.file_path.write_bytes(payload)
finalized = store.finalize_recording(record.id, size_bytes=len(payload))
return finalized.id
class _ConcurrentRepository:
def __init__(self, expected: int) -> None:
self.expected = expected
self.lock = threading.Lock()
self.release = threading.Event()
self.all_started = threading.Event()
self.active = 0
self.maximum_active = 0
self.calls: list[dict[str, Any]] = []
def upload_call_recording(self, **payload: Any) -> dict[str, str]:
path = Path(payload["path"])
assert path.is_file()
with self.lock:
self.active += 1
self.maximum_active = max(self.maximum_active, self.active)
self.calls.append(payload)
if self.active >= self.expected:
self.all_started.set()
try:
assert self.release.wait(5), "concurrent uploads did not receive release"
return {"file_url": f"cos://recordings/{path.name}"}
finally:
with self.lock:
self.active -= 1
class _RetryRepository:
def __init__(self) -> None:
self.calls = 0
self.call_records: dict[int, list[dict[str, Any]]] = {}
self.list_calls: list[int] = []
def upload_call_recording(self, **payload: Any) -> dict[str, str]:
self.calls += 1
if self.calls == 1:
raise RuntimeError("COS 暂时不可用")
return {"file_url": f"cos://recordings/{Path(payload['path']).name}"}
def list_call_records(self, diagnosis_id: int) -> list[dict[str, Any]]:
self.list_calls.append(int(diagnosis_id))
return list(self.call_records.get(int(diagnosis_id), []))
def test_existing_queue_schema_adds_room_id_without_losing_rows(
tmp_path: Path,
) -> None:
root = tmp_path / "old-audio-queue"
root.mkdir()
database = root / "queue.sqlite3"
with sqlite3.connect(database) as connection:
connection.execute(
"""
CREATE TABLE local_audio_uploads (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL UNIQUE,
diagnosis_id INTEGER NOT NULL,
call_record_id INTEGER,
mime_type TEXT NOT NULL,
file_path TEXT NOT NULL,
size_bytes INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL,
error_text TEXT NOT NULL DEFAULT '',
uploaded_url TEXT NOT NULL DEFAULT '',
attempts INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
uploaded_at TEXT NOT NULL DEFAULT ''
)
"""
)
connection.execute(
"""
INSERT INTO local_audio_uploads (
session_id, diagnosis_id, call_record_id, mime_type, file_path,
size_bytes, status, error_text, uploaded_url, attempts,
created_at, updated_at, uploaded_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
"legacy-uploaded-session",
8169,
901,
"audio/webm",
str(root / "legacy.webm"),
2048,
"uploaded",
"",
"cos://recordings/legacy.webm",
2,
"2026-08-20T01:00:00+00:00",
"2026-08-20T01:02:00+00:00",
"2026-08-20T01:02:00+00:00",
),
)
store = LocalAudioQueueStore(root)
record = store.list_records()[0]
with sqlite3.connect(database) as connection:
columns = {
str(row[1])
for row in connection.execute(
"PRAGMA table_info(local_audio_uploads)"
).fetchall()
}
assert "room_id" in columns
assert record.room_id == ""
assert record.status == "uploaded"
assert record.uploaded_url == "cos://recordings/legacy.webm"
assert record.attempts == 2
assert LocalAudioQueueStore(root).require(record.id).room_id == ""
def test_local_audio_identity_binding_is_idempotent_and_rejects_conflicts(
tmp_path: Path,
) -> None:
store = LocalAudioQueueStore(tmp_path / "audio-queue")
record = store.begin_recording(
session_id="identity-binding-session",
diagnosis_id=8169,
mime_type="audio/webm",
call_record_id=901,
room_id="00123456",
)
assert record.call_record_id == 901
assert record.room_id == "00123456"
assert store.bind_identity(
record.id,
call_record_id=901,
room_id="00123456",
).room_id == "00123456"
assert store.bind_identity(record.id, call_record_id=901).room_id == "00123456"
with pytest.raises(RuntimeError, match="房间号发生冲突"):
store.bind_identity(record.id, call_record_id=901, room_id="99887766")
with pytest.raises(RuntimeError, match="通话记录 ID 或房间号发生冲突"):
store.bind_identity(record.id, call_record_id=902, room_id="00123456")
assert store.require(record.id).call_record_id == 901
assert store.require(record.id).room_id == "00123456"
def test_local_audio_queue_uploads_three_files_concurrently(tmp_path: Path) -> None:
store = LocalAudioQueueStore(tmp_path / "audio-queue")
record_ids = [
_ready_record(
store,
diagnosis_id=8169,
call_record_id=900 + index,
session_id=f"session-{index}",
)
for index in range(3)
]
repository = _ConcurrentRepository(expected=3)
manager = LocalAudioUploadManager(repository, store, max_workers=3)
futures = [manager.submit(record_id) for record_id in record_ids]
try:
assert repository.all_started.wait(5)
assert repository.maximum_active == 3
finally:
repository.release.set()
assert [future.result(timeout=5) for future in futures] == [True, True, True]
records = [store.require(record_id) for record_id in record_ids]
assert all(record.status == "uploaded" for record in records)
assert all(record.exists for record in records)
assert all(record.uploaded_url.startswith("cos://recordings/") for record in records)
assert {call["call_record_id"] for call in repository.calls} == {900, 901, 902}
def test_failed_local_audio_is_kept_and_can_be_retried(tmp_path: Path) -> None:
store = LocalAudioQueueStore(tmp_path / "audio-queue")
record_id = _ready_record(
store,
diagnosis_id=8169,
call_record_id=901,
session_id="retry-session",
)
repository = _RetryRepository()
manager = LocalAudioUploadManager(repository, store, max_workers=1)
assert manager.submit(record_id).result(timeout=5) is False
failed = store.require(record_id)
assert failed.status == "failed"
assert failed.exists
assert failed.attempts == 1
assert "COS 暂时不可用" in failed.error_text
store.retry(record_id)
assert manager.submit(record_id).result(timeout=5) is True
uploaded = store.require(record_id)
assert uploaded.status == "uploaded"
assert uploaded.exists
assert uploaded.attempts == 2
assert uploaded.uploaded_url.startswith("cos://recordings/")
def test_local_audio_manager_notifies_when_an_upload_reaches_uploaded(
tmp_path: Path,
) -> None:
store = LocalAudioQueueStore(tmp_path / "audio-queue")
repository = _RetryRepository()
repository.calls = 1
manager = LocalAudioUploadManager(repository, store, max_workers=1)
record_id = _ready_record(
store,
diagnosis_id=8169,
call_record_id=901,
session_id="upload-notification-session",
)
uploads: list[tuple[int, int, str]] = []
def record_upload(record: Any) -> None:
uploads.append(
(record.diagnosis_id, int(record.call_record_id or 0), record.uploaded_url)
)
manager.add_upload_listener(record_upload)
assert manager.submit(record_id).result(timeout=5) is True
assert manager.submit(record_id).result(timeout=5) is True
manager.remove_upload_listener(record_upload)
assert uploads == [
(
8169,
901,
store.require(record_id).uploaded_url,
)
]
def test_local_audio_manager_does_not_report_success_without_uploaded_url(
tmp_path: Path,
) -> None:
class _IncompleteRepository:
@staticmethod
def upload_call_recording(**_payload: Any) -> dict[str, bool]:
return {"completed": True}
store = LocalAudioQueueStore(tmp_path / "audio-queue")
record_id = _ready_record(
store,
diagnosis_id=8169,
call_record_id=901,
session_id="missing-url-session",
)
manager = LocalAudioUploadManager(_IncompleteRepository(), store, max_workers=1)
uploads: list[Any] = []
manager.add_upload_listener(uploads.append)
assert manager.submit(record_id).result(timeout=5) is False
record = store.require(record_id)
assert record.status == "failed"
assert "文件地址" in record.error_text
assert uploads == []
def test_local_audio_dialog_displays_utc_recording_time_in_business_timezone() -> None:
assert _display_time("2026-08-20T01:51:00+00:00") == (
"2026-08-20 09:51:00"
)
def test_local_audio_dialog_lists_status_and_retry_controls(tmp_path: Path) -> None:
application = _application()
store = LocalAudioQueueStore(tmp_path / "audio-queue")
repository = _RetryRepository()
manager = LocalAudioUploadManager(repository, store, max_workers=1)
failed_id = _ready_record(
store,
diagnosis_id=8169,
call_record_id=901,
session_id="failed-session",
room_id="67534825",
)
store.update_status(failed_id, "failed", "等待医生重试")
uploaded_id = _ready_record(
store,
diagnosis_id=8169,
call_record_id=902,
session_id="uploaded-session",
room_id="1692231119",
)
store.mark_uploaded(uploaded_id, "cos://recordings/uploaded.webm")
dialog = LocalAudioQueueDialog(
repository,
8169,
store=store,
manager=manager,
)
dialog.show()
application.processEvents()
try:
assert dialog.objectName() == "LocalAudioQueueDialog"
assert dialog.table.columnCount() == 8
assert dialog.table.rowCount() == 2
assert dialog.summary_failed.text() == "失败 1"
assert dialog.summary_uploaded.text() == "已上传 1"
assert dialog.retry_failed_button.isEnabled()
assert dialog.table.horizontalHeaderItem(1).text() == "通话记录 ID"
assert dialog.table.horizontalHeaderItem(2).text() == "房间号"
assert dialog.table.horizontalHeaderItem(5).text() == "上传状态"
assert dialog.table.horizontalHeaderItem(6).text() == "失败原因"
assert dialog.table.columnWidth(dialog._room_column) == 180
rooms = {
dialog.table.item(row, 2).text() for row in range(dialog.table.rowCount())
}
assert rooms == {"67534825", "1692231119"}
statuses = {
dialog.table.item(row, 5).text() for row in range(dialog.table.rowCount())
}
assert statuses == {"上传失败", "已上传"}
finally:
dialog.close()
application.processEvents()
def test_global_local_audio_dialog_lists_all_diagnoses_and_outcomes(
tmp_path: Path,
) -> None:
application = _application()
store = LocalAudioQueueStore(tmp_path / "audio-queue")
repository = _RetryRepository()
manager = LocalAudioUploadManager(repository, store, max_workers=1)
failed_id = _ready_record(
store,
diagnosis_id=8169,
call_record_id=901,
session_id="global-failed-session",
room_id="67534825",
)
store.update_status(failed_id, "failed", "等待医生重试")
uploaded_id = _ready_record(
store,
diagnosis_id=9001,
call_record_id=902,
session_id="global-uploaded-session",
room_id="407179477",
)
store.mark_uploaded(uploaded_id, "cos://recordings/global-uploaded.webm")
dialog = LocalAudioQueueDialog(
repository,
None,
store=store,
manager=manager,
)
dialog.show()
application.processEvents()
try:
assert dialog.title_label.text() == "本机录音上传管理"
assert dialog.table.columnCount() == 9
assert dialog.table.rowCount() == 2
assert dialog.table.horizontalHeaderItem(1).text() == "诊单 ID"
assert dialog.table.horizontalHeaderItem(2).text() == "通话记录 ID"
assert dialog.table.horizontalHeaderItem(3).text() == "房间号"
assert dialog.table.horizontalHeaderItem(6).text() == "上传状态"
diagnosis_ids = {
dialog.table.item(row, 1).text()
for row in range(dialog.table.rowCount())
}
assert diagnosis_ids == {"8169", "9001"}
statuses = {
dialog.table.item(row, 6).text()
for row in range(dialog.table.rowCount())
}
assert statuses == {"上传失败", "已上传"}
rooms = {
dialog.table.item(row, 3).text()
for row in range(dialog.table.rowCount())
}
assert rooms == {"67534825", "407179477"}
assert dialog.summary_failed.text() == "失败 1"
assert dialog.summary_uploaded.text() == "已上传 1"
finally:
dialog.close()
application.processEvents()
def test_dialog_fetches_and_persists_historical_room_ids_once_per_diagnosis(
tmp_path: Path,
) -> None:
application = _application()
store = LocalAudioQueueStore(tmp_path / "audio-queue")
repository = _RetryRepository()
repository.call_records[8169] = [
{"id": 902, "room_id": "1692231119"},
{"id": 901, "room_id": "67534825"},
]
manager = LocalAudioUploadManager(repository, store, max_workers=1)
first_id = _ready_record(
store,
diagnosis_id=8169,
call_record_id=901,
session_id="legacy-room-first",
)
second_id = _ready_record(
store,
diagnosis_id=8169,
call_record_id=902,
session_id="legacy-room-second",
)
dialog = LocalAudioQueueDialog(
repository,
8169,
store=store,
manager=manager,
)
dialog.show()
try:
for _ in range(200):
application.processEvents()
if store.require(first_id).room_id and store.require(second_id).room_id:
break
QTest.qWait(10)
assert store.require(first_id).room_id == "67534825"
assert store.require(second_id).room_id == "1692231119"
assert repository.list_calls == [8169]
for _ in range(3):
dialog.refresh_records()
application.processEvents()
assert repository.list_calls == [8169]
assert {
dialog.table.item(row, 2).text()
for row in range(dialog.table.rowCount())
} == {"67534825", "1692231119"}
finally:
dialog.close()
application.processEvents()
+28
View File
@@ -295,6 +295,34 @@ def test_demo_transcript_upsert_and_finish_round_trip_in_call_records(
assert "final words" in record["transcript_text"]
def test_demo_local_audio_is_preserved_separately_from_cloud_video_and_text(
repository: DemoDoctorRepository,
tmp_path: Path,
) -> None:
started = repository.start_call(501, 301)
call_record_id = int(started["id"])
audio = tmp_path / "local-call.webm"
audio.write_bytes(b"webm-opus-audio")
result = repository.upload_call_recording(
audio,
501,
call_record_id=call_record_id,
mime_type="audio/webm;codecs=opus",
)
record = next(
row for row in repository.list_call_records(501) if row["id"] == call_record_id
)
assert result["media_kind"] == "local_audio"
assert result["call_record_id"] == call_record_id
assert record["local_audio_status"] == 2
assert record["local_audio_status_text"] == "已保存"
assert record["local_audio_urls_list"] == [result["file_url"]]
assert record["recording_urls_list"] == []
assert record["transcript_text"] == ""
def test_tolerant_page_parsing_accepts_aliases_and_bad_rows() -> None:
"""List parsing handles nullable fields, aliases and non-object rows safely."""
+808 -1
View File
@@ -3,13 +3,15 @@
from __future__ import annotations
import os
from concurrent.futures import ThreadPoolExecutor
from copy import deepcopy
from datetime import date
from typing import Any
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from PySide6.QtWidgets import QApplication
from PySide6.QtWidgets import QApplication, QLabel, QScrollArea
from doctor_workstation.core import PermissionSet
from doctor_workstation.services.mock_repository import DemoDoctorRepository
@@ -18,7 +20,9 @@ from doctor_workstation.ui.pages import reception as reception_module
from doctor_workstation.ui.pages.reception import (
AI_MEDICAL_DISCLAIMER,
ReceptionPage,
_ai_narrative_text,
_generated_patient_report,
_normalize_patient_report,
_patient_report_rows,
_ReceptionAiAnalysisDialog,
)
@@ -37,8 +41,11 @@ def immediate_async(monkeypatch: pytest.MonkeyPatch) -> None:
on_success: Any = None,
on_error: Any = None,
on_finished: Any = None,
pool: Any = None,
priority: int = 0,
**kwargs: Any,
) -> object:
del pool, priority
try:
result = function(*args, **kwargs)
except Exception as error:
@@ -302,6 +309,64 @@ def test_openai_failure_keeps_new_qwen_snapshot(
page.close()
def test_finished_only_cached_regeneration_unlocks_retry_and_keeps_snapshot(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
detail = _detail(109, 301, 509)
saved = _snapshot("qwen", 1, "2026-08-14 10:45:00")
jobs: list[dict[str, Any]] = []
def queue(function: Any, *args: Any, **options: Any) -> object:
jobs.append({"function": function, "args": args, **options})
return object()
monkeypatch.setattr(reception_module, "run_async", queue)
class Repository:
def list_patient_ai_reports(self, patient_id: int) -> dict[str, Any]:
return {"patient_id": patient_id, "reports": [saved]}
def generate_patient_ai_report(
self,
patient_id: int,
*,
model: str,
) -> dict[str, Any]:
raise AssertionError(f"queued worker must not run inline: {patient_id}/{model}")
page = ReceptionPage(
Repository(),
PermissionSet(
[
"tcm.diagnosis/patientAiReports",
"tcm.diagnosis/generatePatientAiReport",
]
),
)
page._select_record(detail["appointment"])
jobs[0]["on_success"]({"detail": detail, "warnings": []})
jobs[1]["on_success"]({"patient_id": 301, "reports": [saved]})
jobs[1]["on_finished"]()
assert page._ai_analysis_model_states["qwen"] == "success"
assert page.ai_analysis_regenerate_button.isEnabled()
page.ai_analysis_regenerate_button.click()
assert len(jobs) == 3
assert page._ai_analysis_regenerating
assert not page.ai_analysis_regenerate_button.isEnabled()
jobs[2]["on_finished"]()
assert not page._ai_analysis_regenerating
assert page._ai_analysis_regeneration_model is None
assert page._ai_analysis_model_states["qwen"] == "success"
assert page.ai_summary_label.text() == "千问第 1 版诊断建议"
assert page.ai_analysis_regenerate_button.isEnabled()
assert "未返回有效结果" in page.ai_analysis_secondary_status.text()
page.close()
def test_late_patient_history_response_is_discarded_after_switch(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
@@ -350,6 +415,7 @@ def test_late_patient_history_response_is_discarded_after_switch(
first_history_job = jobs[1]
page._select_record(second["appointment"])
assert first_history_job["function"]() is reception_module._ASYNC_REQUEST_CANCELLED
finish(jobs[2])
second_history_job = jobs[3]
finish(second_history_job)
@@ -361,6 +427,563 @@ def test_late_patient_history_response_is_discarded_after_switch(
page.close()
def test_aba_switch_attaches_to_inflight_patient_generation_without_duplicate_post(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
first = _detail(110, 301, 510)
second = _detail(111, 302, 511)
jobs: list[dict[str, Any]] = []
def queue(function: Any, *args: Any, **options: Any) -> object:
jobs.append({"function": function, "args": args, **options})
return object()
monkeypatch.setattr(reception_module, "run_async", queue)
class Repository:
def __init__(self) -> None:
self.generate_calls: list[tuple[int, str]] = []
def list_patient_ai_reports(self, patient_id: int) -> dict[str, Any]:
return {"patient_id": patient_id, "reports": []}
def generate_patient_ai_report(
self,
patient_id: int,
*,
model: str,
) -> dict[str, Any]:
self.generate_calls.append((patient_id, model))
generated = _snapshot(model, 1, "2026-08-14 12:00:00")
generated["patient_id"] = patient_id
return {
"patient_id": patient_id,
"generated_report": generated,
"report": generated,
}
repository = Repository()
page = ReceptionPage(
repository,
PermissionSet(
[
"tcm.diagnosis/patientAiReports",
"tcm.diagnosis/generatePatientAiReport",
]
),
)
page._select_record(first["appointment"])
jobs[0]["on_success"]({"detail": first, "warnings": []})
jobs[1]["on_success"]({"patient_id": 301, "reports": []})
first_qwen_job = jobs[2]
qwen_result = first_qwen_job["function"]()
page._select_record(second["appointment"])
page._select_record(first["appointment"])
jobs[4]["on_success"]({"detail": first, "warnings": []})
assert len(jobs) == 5
first_qwen_job["on_success"](qwen_result)
first_qwen_job["on_finished"]()
assert repository.generate_calls == [(301, "qwen")]
assert len(jobs) == 6
assert page._ai_analysis_model_states["qwen"] == "success"
assert page.ai_summary_label.text() == "千问第 1 版诊断建议"
page.close()
application.processEvents()
def test_patient_history_get_is_singleflight_across_aba_switch(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
first = _detail(112, 301, 512)
second = _detail(113, 302, 513)
saved = _snapshot("qwen", 1, "2026-08-14 12:10:00")
jobs: list[dict[str, Any]] = []
def queue(function: Any, *args: Any, **options: Any) -> object:
jobs.append({"function": function, "args": args, **options})
return object()
monkeypatch.setattr(reception_module, "run_async", queue)
class Repository:
def __init__(self) -> None:
self.list_calls: list[int] = []
def list_patient_ai_reports(self, patient_id: int) -> dict[str, Any]:
self.list_calls.append(patient_id)
return {"patient_id": patient_id, "reports": [saved]}
def generate_patient_ai_report(self, patient_id: int, *, model: str) -> Any:
raise AssertionError(f"history exists: {patient_id}/{model}")
repository = Repository()
page = ReceptionPage(
repository,
PermissionSet(
[
"tcm.diagnosis/patientAiReports",
"tcm.diagnosis/generatePatientAiReport",
]
),
)
page._select_record(first["appointment"])
jobs[0]["on_success"]({"detail": first, "warnings": []})
first_list_job = jobs[1]
list_result = first_list_job["function"]()
page._select_record(second["appointment"])
page._select_record(first["appointment"])
jobs[3]["on_success"]({"detail": first, "warnings": []})
assert len(jobs) == 4
assert repository.list_calls == [301]
first_list_job["on_success"](list_result)
first_list_job["on_finished"]()
assert page._ai_analysis_model_states["qwen"] == "success"
assert page.ai_summary_label.text() == "千问第 1 版诊断建议"
page.close()
application.processEvents()
def test_patient_ai_finished_only_tracks_qwen_workers_not_unrelated_openai(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
detail = _detail(114, 301, 514)
jobs: list[dict[str, Any]] = []
def queue(function: Any, *args: Any, **options: Any) -> object:
jobs.append({"function": function, "args": args, **options})
return object()
monkeypatch.setattr(reception_module, "run_async", queue)
class Repository:
def list_patient_ai_reports(self, patient_id: int) -> dict[str, Any]:
return {"patient_id": patient_id, "reports": []}
def generate_patient_ai_report(self, patient_id: int, *, model: str) -> Any:
raise AssertionError(f"worker must remain queued: {patient_id}/{model}")
page = ReceptionPage(
Repository(),
PermissionSet(
[
"tcm.diagnosis/patientAiReports",
"tcm.diagnosis/generatePatientAiReport",
]
),
)
page._select_record(detail["appointment"])
jobs[0]["on_success"]({"detail": detail, "warnings": []})
current_list_job = jobs[1]
stale_qwen = (page._ai_analysis_generation - 2, 114, 301, "qwen")
stale_openai = (page._ai_analysis_generation - 1, 114, 301, "openai")
page._patient_ai_generation_requests.add(stale_qwen)
page._patient_ai_generation_finished(*stale_qwen)
assert page._ai_analysis_model_states["qwen"] == "loading"
page._patient_ai_generation_requests.add(stale_openai)
current_list_job["on_finished"]()
page._patient_ai_generation_finished(*stale_openai)
assert not page._patient_ai_list_requests
assert not page._patient_ai_generation_requests
assert page._ai_analysis_model_states["qwen"] == "error"
assert not page._ai_analysis_loading
assert "未返回有效结果" in page.ai_analysis_state_label.text()
page.close()
application.processEvents()
def test_aba_cancelled_generation_is_replaced_instead_of_becoming_false_error(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
first = _detail(115, 301, 515)
second = _detail(116, 302, 516)
jobs: list[dict[str, Any]] = []
def queue(function: Any, *args: Any, **options: Any) -> object:
jobs.append({"function": function, "args": args, **options})
return object()
monkeypatch.setattr(reception_module, "run_async", queue)
monkeypatch.setattr(
reception_module,
"_AI_AUTOMATIC_GENERATION_SETTLE_SECONDS",
5.0,
)
class Repository:
def __init__(self) -> None:
self.generate_calls: list[tuple[int, str]] = []
def list_patient_ai_reports(self, patient_id: int) -> dict[str, Any]:
return {"patient_id": patient_id, "reports": []}
def generate_patient_ai_report(
self,
patient_id: int,
*,
model: str,
) -> dict[str, Any]:
self.generate_calls.append((patient_id, model))
generated = _snapshot(model, 1, "2026-08-14 12:20:00")
generated["patient_id"] = patient_id
return {
"patient_id": patient_id,
"generated_report": generated,
"report": generated,
}
repository = Repository()
page = ReceptionPage(
repository,
PermissionSet(
[
"tcm.diagnosis/patientAiReports",
"tcm.diagnosis/generatePatientAiReport",
]
),
)
page._select_record(first["appointment"])
jobs[0]["on_success"]({"detail": first, "warnings": []})
jobs[1]["on_success"]({"patient_id": 301, "reports": []})
old_qwen_job = jobs[2]
with ThreadPoolExecutor(max_workers=1) as executor:
cancelled_future = executor.submit(old_qwen_job["function"])
page._select_record(second["appointment"])
cancelled = cancelled_future.result(timeout=1.0)
monkeypatch.setattr(
reception_module,
"_AI_AUTOMATIC_GENERATION_SETTLE_SECONDS",
0.0,
)
assert cancelled is reception_module._ASYNC_REQUEST_CANCELLED
assert repository.generate_calls == []
page._select_record(first["appointment"])
jobs[4]["on_success"]({"detail": first, "warnings": []})
assert len(jobs) == 6
current_list_job = jobs[5]
current_list_job["on_success"]({"patient_id": 301, "reports": []})
assert len(jobs) == 7
replacement_qwen_job = jobs[6]
current_list_job["on_finished"]()
assert len(jobs) == 7
qwen_result = replacement_qwen_job["function"]()
replacement_qwen_job["on_success"](qwen_result)
replacement_qwen_job["on_finished"]()
jobs_after_replacement = len(jobs)
old_qwen_job["on_success"](cancelled)
old_qwen_job["on_finished"]()
assert repository.generate_calls == [(301, "qwen")]
assert len(jobs) == jobs_after_replacement
assert page._ai_analysis_model_states["qwen"] == "success"
assert page.ai_summary_label.text() == "千问第 1 版诊断建议"
page.close()
application.processEvents()
@pytest.mark.parametrize("late_completion", ["cancelled", "error"])
def test_late_history_completion_cannot_clear_newer_generated_snapshot(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
late_completion: str,
) -> None:
detail = _detail(117, 301, 517)
jobs: list[dict[str, Any]] = []
def queue(function: Any, *args: Any, **options: Any) -> object:
jobs.append({"function": function, "args": args, **options})
return object()
monkeypatch.setattr(reception_module, "run_async", queue)
class Repository:
def __init__(self) -> None:
self.generate_calls: list[tuple[int, str]] = []
def list_patient_ai_reports(self, patient_id: int) -> dict[str, Any]:
raise AssertionError(f"history worker remains pending: {patient_id}")
def generate_patient_ai_report(
self,
patient_id: int,
*,
model: str,
) -> dict[str, Any]:
self.generate_calls.append((patient_id, model))
generated = _snapshot(model, 9, "2026-08-14 12:30:00")
generated["id"] = 901 if model == "qwen" else 902
generated["patient_id"] = patient_id
return {
"patient_id": patient_id,
"generated_report": generated,
"report": generated,
}
repository = Repository()
page = ReceptionPage(
repository,
PermissionSet(
[
"tcm.diagnosis/patientAiReports",
"tcm.diagnosis/generatePatientAiReport",
]
),
)
page._select_record(detail["appointment"])
jobs[0]["on_success"]({"detail": detail, "warnings": []})
history_job = jobs[1]
current_context = page._current_patient_ai_context(301)
assert current_context is not None
generation, appointment_id, _patient_id = current_context
page._request_patient_ai_generation("qwen", generation, appointment_id, 301)
qwen_job = jobs[2]
qwen_result = qwen_job["function"]()
qwen_job["on_success"](qwen_result)
qwen_job["on_finished"]()
jobs_after_generation = len(jobs)
if late_completion == "cancelled":
history_job["on_success"](reception_module._ASYNC_REQUEST_CANCELLED)
else:
history_job["on_error"](RuntimeError("late history failure"))
history_job["on_finished"]()
assert repository.generate_calls == [(301, "qwen")]
assert len(jobs) == jobs_after_generation
assert page._ai_analysis_model_states["qwen"] == "success"
assert page._ai_analysis_payloads["qwen"]["id"] == 901
assert page.ai_summary_label.text() == "千问第 9 版诊断建议"
page.close()
application.processEvents()
def test_cancelled_queued_generation_does_not_invalidate_valid_history_get(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
first = _detail(118, 301, 518)
second = _detail(119, 302, 519)
saved = _snapshot("qwen", 10, "2026-08-14 12:40:00")
jobs: list[dict[str, Any]] = []
def queue(function: Any, *args: Any, **options: Any) -> object:
jobs.append({"function": function, "args": args, **options})
return object()
monkeypatch.setattr(reception_module, "run_async", queue)
class Repository:
def __init__(self) -> None:
self.list_calls: list[int] = []
self.generate_calls: list[tuple[int, str]] = []
def list_patient_ai_reports(self, patient_id: int) -> dict[str, Any]:
self.list_calls.append(patient_id)
return {"patient_id": patient_id, "reports": [saved]}
def generate_patient_ai_report(self, patient_id: int, *, model: str) -> Any:
self.generate_calls.append((patient_id, model))
raise AssertionError("cancelled queued POST must not enter repository")
repository = Repository()
page = ReceptionPage(
repository,
PermissionSet(
[
"tcm.diagnosis/patientAiReports",
"tcm.diagnosis/generatePatientAiReport",
]
),
)
page._select_record(first["appointment"])
jobs[0]["on_success"]({"detail": first, "warnings": []})
history_job = jobs[1]
history_result = history_job["function"]()
current_context = page._current_patient_ai_context(301)
assert current_context is not None
generation, appointment_id, _patient_id = current_context
page._request_patient_ai_generation("qwen", generation, appointment_id, 301)
queued_qwen_job = jobs[2]
page._select_record(second["appointment"])
cancelled = queued_qwen_job["function"]()
page._select_record(first["appointment"])
jobs[4]["on_success"]({"detail": first, "warnings": []})
queued_qwen_job["on_success"](cancelled)
queued_qwen_job["on_finished"]()
history_job["on_success"](history_result)
history_job["on_finished"]()
assert repository.list_calls == [301]
assert repository.generate_calls == []
assert len(jobs) == 5
assert page._ai_analysis_model_states["qwen"] == "success"
assert page.ai_summary_label.text() == "千问第 10 版诊断建议"
page.close()
application.processEvents()
def test_saturated_automatic_ai_slots_end_in_retryable_state_without_post(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
detail = _detail(120, 301, 520)
jobs: list[dict[str, Any]] = []
def queue(function: Any, *args: Any, **options: Any) -> object:
jobs.append({"function": function, "args": args, **options})
return object()
class BusyAutomaticSlots:
@staticmethod
def acquire(*, blocking: bool) -> bool:
assert not blocking
return False
@staticmethod
def release() -> None:
raise AssertionError("an unacquired slot must not be released")
monkeypatch.setattr(reception_module, "run_async", queue)
monkeypatch.setattr(
reception_module,
"_AI_AUTOMATIC_GENERATION_SETTLE_SECONDS",
0.0,
)
monkeypatch.setattr(
reception_module,
"_AI_AUTOMATIC_REQUEST_SLOTS",
BusyAutomaticSlots(),
)
class Repository:
def __init__(self) -> None:
self.generate_calls: list[tuple[int, str]] = []
def list_patient_ai_reports(self, patient_id: int) -> dict[str, Any]:
return {"patient_id": patient_id, "reports": []}
def generate_patient_ai_report(self, patient_id: int, *, model: str) -> Any:
self.generate_calls.append((patient_id, model))
raise AssertionError("busy automatic work must not submit a POST")
repository = Repository()
page = ReceptionPage(
repository,
PermissionSet(
[
"tcm.diagnosis/patientAiReports",
"tcm.diagnosis/generatePatientAiReport",
]
),
)
page._select_record(detail["appointment"])
jobs[0]["on_success"]({"detail": detail, "warnings": []})
jobs[1]["on_success"]({"patient_id": 301, "reports": []})
automatic_qwen_job = jobs[2]
deferred = automatic_qwen_job["function"]()
automatic_qwen_job["on_success"](deferred)
automatic_qwen_job["on_finished"]()
assert deferred is reception_module._ASYNC_REQUEST_DEFERRED
assert repository.generate_calls == []
assert page._ai_analysis_model_states["qwen"] == "error"
assert not page._ai_analysis_loading
assert not page._ai_analysis_regenerating
assert page.ai_analysis_regenerate_button.isEnabled()
assert "后台分析任务较多" in page.ai_analysis_state_label.text()
page.close()
application.processEvents()
def test_saturated_history_slots_end_in_retryable_state_without_get(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
detail = _detail(121, 301, 521)
jobs: list[dict[str, Any]] = []
def queue(function: Any, *args: Any, **options: Any) -> object:
jobs.append({"function": function, "args": args, **options})
return object()
class BusyAutomaticSlots:
@staticmethod
def acquire(*, blocking: bool) -> bool:
assert not blocking
return False
@staticmethod
def release() -> None:
raise AssertionError("an unacquired slot must not be released")
monkeypatch.setattr(reception_module, "run_async", queue)
monkeypatch.setattr(
reception_module,
"_AI_AUTOMATIC_GENERATION_SETTLE_SECONDS",
0.0,
)
monkeypatch.setattr(
reception_module,
"_AI_AUTOMATIC_REQUEST_SLOTS",
BusyAutomaticSlots(),
)
class Repository:
def __init__(self) -> None:
self.list_calls: list[int] = []
def list_patient_ai_reports(self, patient_id: int) -> dict[str, Any]:
self.list_calls.append(patient_id)
raise AssertionError("busy automatic read must not enter repository")
def generate_patient_ai_report(self, patient_id: int, *, model: str) -> Any:
raise AssertionError(f"history did not complete: {patient_id}/{model}")
repository = Repository()
page = ReceptionPage(
repository,
PermissionSet(
[
"tcm.diagnosis/patientAiReports",
"tcm.diagnosis/generatePatientAiReport",
]
),
)
page._select_record(detail["appointment"])
jobs[0]["on_success"]({"detail": detail, "warnings": []})
history_job = jobs[1]
deferred = history_job["function"]()
history_job["on_success"](deferred)
history_job["on_finished"]()
assert deferred is reception_module._ASYNC_REQUEST_DEFERRED
assert repository.list_calls == []
assert page._ai_analysis_model_states["qwen"] == "error"
assert not page._ai_analysis_loading
assert page.ai_analysis_retry_button.isEnabled()
assert "查询任务较多" in page.ai_analysis_state_label.text()
page.close()
application.processEvents()
def test_get_history_requires_exact_top_level_and_row_patient_ids() -> None:
row = _snapshot("qwen", 1, "2026-08-14 11:00:00")
valid = {"patient_id": 301, "reports": [row]}
@@ -596,3 +1219,187 @@ def test_patient_ai_disclaimer_remains_the_unified_text() -> None:
"仅供临床辅助参考,不可替代医生诊断,不得直接用于开方、用药调整或其他医疗决策。"
"系统未对舌像、报告附件或视频画面进行视觉诊断;仅分析已录入、归档或转写的文字及附件元数据。"
)
def test_ai_narrative_formatter_preserves_lists_arrays_and_medical_numbers() -> None:
diagnosis: list[Any] = [
"2型糖尿病,HbA1c 7.5%,当前控制未达标。",
{"text": r"二甲双胍 0.5g,每日2次。\n复查肾功能。"},
"建议:1. 监测空腹血糖 2. 记录餐后2小时血糖",
]
original = deepcopy(diagnosis)
rendered = _ai_narrative_text(diagnosis)
assert diagnosis == original
assert rendered == _ai_narrative_text(rendered)
assert rendered.splitlines() == [
"• 2型糖尿病,HbA1c 7.5%,当前控制未达标。",
"• 二甲双胍 0.5g,每日2次。",
"复查肾功能。",
"• 建议:",
"1. 监测空腹血糖",
"2. 记录餐后2小时血糖",
]
assert "7.5%" in rendered
assert "0.5g" in rendered
assert "2型糖尿病" in rendered
assert "7.\n5" not in rendered
assert "0.\n5" not in rendered
assert "2\n型糖尿病" not in rendered
payload = {
"model_key": "qwen",
"diagnosis_advice": diagnosis,
"treatment_advice": ["控制总热量", "规律复诊"],
"risk_assessment": ["低血糖风险", {"label": "依从性风险", "level": "medium"}],
}
payload_before = deepcopy(payload)
normalized = _normalize_patient_report(payload)
assert payload == payload_before
assert normalized is not None
assert normalized["diagnosis_advice"] == rendered
assert normalized["treatment_advice"] == "• 控制总热量\n• 规律复诊"
assert normalized["risk_assessment"] == [
{"label": "低血糖风险", "level": "low"},
{"label": "依从性风险", "level": "medium"},
]
def test_patient_report_dialog_uses_one_scroll_owner_and_wrapped_risk_flow(
application: QApplication,
) -> None:
long_risk = (
"这是一个需要换行展示的较长风险项目,用于验证标签不会超出正文区域,"
"并且能够在流式布局中可靠折行。"
)
payload = {
"model_key": "qwen",
"model_label": "千问",
"generated_at": "2026-08-17 10:20:00",
"diagnosis_advice": [
"2型糖尿病,HbA1c 7.5%,建议继续分层监测。",
"1. 监测空腹血糖 2. 记录餐后2小时血糖",
]
* 10
+ ["[诊断末尾]"],
"risk_assessment": [
{"label": "低血糖", "level": "high"},
{"label": "依从性风险", "level": "medium"},
{"label": "并发症筛查延误风险", "level": "low"},
{"label": "复诊中断风险", "level": "medium"},
{"label": long_risk, "level": "high"},
{"label": "饮食波动风险", "level": "low"},
],
"treatment_advice": [r"二甲双胍 0.5g,每日2次。\n复查肾功能。"] * 12
+ ["[治疗末尾]"],
}
dialog = _ReceptionAiAnalysisDialog({"qwen": [payload]})
dialog.resize(720, 560)
dialog.show()
application.processEvents()
assert dialog.minimumWidth() == 720
assert dialog.minimumHeight() == 560
scrolls = dialog.findChildren(QScrollArea)
assert scrolls == [dialog.scroll_area]
assert dialog.scroll_area.horizontalScrollBar().maximum() == 0
assert dialog.scroll_area.verticalScrollBar().maximum() > 0
body = dialog.scroll_area.widget()
assert body is not None and body.layout() is not None
assert body.height() <= max(
dialog.scroll_area.viewport().height(),
body.layout().sizeHint().height(),
) + 40
assert dialog.diagnosis_label.text().endswith("[诊断末尾]")
assert dialog.treatment_label.text().endswith("[治疗末尾]")
assert "7.5%" in dialog.diagnosis_label.text()
assert "0.5g" in dialog.treatment_label.text()
risk_labels = [
label
for label in dialog.findChildren(QLabel)
if label.property("dialogAiRisk")
]
assert len(risk_labels) == 6
assert len({label.y() for label in risk_labels}) >= 2
short_risk = risk_labels[0]
wrapped_risk = next(label for label in risk_labels if label.text() == long_risk)
assert short_risk.width() < dialog.risk_items.width() // 2
assert wrapped_risk.width() <= 340
assert wrapped_risk.height() > short_risk.height()
assert max(label.y() + label.height() for label in risk_labels) <= dialog.risk_items.height()
dialog.close()
application.processEvents()
def test_reception_ai_card_is_compact_preview_without_nested_scroll(
application: QApplication,
) -> None:
payload = {
"diagnosis_advice": ["2型糖尿病,HbA1c 7.5%,需要继续监测。"] * 12,
"risk_assessment": [
{"label": "低血糖", "level": "high"},
{"label": "依从性风险", "level": "medium"},
{
"label": "这是一个需要在紧凑卡片内自行换行而不能向右溢出的长风险项目。",
"level": "low",
},
{"label": "复诊中断", "level": "medium"},
{"label": "饮食波动", "level": "low"},
{"label": "并发症筛查延误", "level": "high"},
],
"treatment_advice": ["二甲双胍 0.5g,每日2次。"] * 10,
"model_key": "qwen",
"model_label": "千问",
}
payload_before = deepcopy(payload)
page = ReceptionPage(object(), PermissionSet([]))
page._render_ai_analysis_payload(payload, "qwen")
page.ai_analysis_stack.setCurrentWidget(page.ai_analysis_content_page)
page.detail_stack.setCurrentIndex(1)
page.resize(1494, 832)
page.show()
application.processEvents()
assert payload == payload_before
assert not isinstance(page.ai_analysis_content_page, QScrollArea)
assert page.ai_analysis_card.findChildren(QScrollArea) == []
assert page.ai_analysis_card.minimumHeight() < 470
assert page.ai_analysis_card.maximumHeight() > 520
assert page.ai_analysis_card.sizeHint().height() < 470
assert page.ai_summary_label.fullText() == _ai_narrative_text(
payload["diagnosis_advice"]
)
assert page.ai_treatment_label.fullText() == _ai_narrative_text(
payload["treatment_advice"]
)
assert page.ai_summary_label.text().count("\n") + 1 == 3
assert page.ai_treatment_label.text().count("\n") + 1 == 2
assert page.ai_summary_label.text().endswith("")
assert page.ai_treatment_label.text().endswith("")
chips = [
label
for label in page.ai_risk_chip_host.findChildren(QLabel)
if label.property("receptionRiskChip")
]
overflow = [
label
for label in page.ai_risk_chip_host.findChildren(QLabel)
if label.property("receptionRiskOverflow")
]
assert len(chips) == 3
assert [label.text() for label in overflow] == ["+3 项"]
assert max(label.x() + label.width() for label in [*chips, *overflow]) <= (
page.ai_risk_chip_host.width()
)
assert max(label.y() + label.height() for label in [*chips, *overflow]) <= (
page.ai_risk_chip_host.height()
)
assert page.ai_risk_label.text().count("") == 5
page.close()
application.processEvents()
+42 -3
View File
@@ -7,7 +7,7 @@ from typing import Any
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from PySide6.QtCore import QDate
from PySide6.QtCore import QDate, QPoint
from PySide6.QtWidgets import QApplication, QDialogButtonBox, QInputDialog, QLabel
from doctor_workstation.core import PermissionSet
@@ -621,17 +621,56 @@ def test_patient_list_reference_geometry_and_row_actions(
repository = DemoDoctorRepository()
session = repository.login(repository.DEMO_ACCOUNT, repository.DEMO_PASSWORD)
page = PatientsPage(repository, permissions=session.permissions, current_user=session.user)
page.resize(1460, 820)
# 1366x768 shell minus its 170 px patient rail, 26 px outer gutter,
# and 62 px top bar leaves a 1170x680 page viewport.
page.resize(1170, 680)
page.show()
application.processEvents()
page.patient_workspace.refresh()
application.processEvents()
workspace = page.patient_workspace
assert page.header.height() == 62
assert workspace.filter_card.height() <= 92
assert all(
button.minimumHeight() == 56 and button.maximumHeight() == 56
button.minimumHeight() == 44 and button.maximumHeight() == 44
for button in workspace.summary_buttons.values()
)
assert workspace.keyword_edit.objectName() == "PatientKeywordInput"
assert workspace.status_host.objectName() == "PatientStatusFilterHost"
assert workspace.quick_host.objectName() == "PatientQuickDateHost"
assert workspace.date_host.objectName() == "PatientDateRangeHost"
assert (
workspace.keyword_edit.maximumWidth(),
workspace.status_host.maximumWidth(),
workspace.quick_host.maximumWidth(),
workspace.date_host.maximumWidth(),
) == (620, 440, 620, 420)
assert workspace.search_button.objectName() == "PatientSearchButton"
assert workspace.reset_button.objectName() == "PatientResetButton"
assert workspace.custom_date_button.objectName() == "PatientCustomDateButton"
for width in (1170, 1290, 1514):
page.resize(width, 680)
application.processEvents()
for widget in (
workspace.keyword_edit,
workspace.status_host,
workspace.search_button,
workspace.reset_button,
workspace.quick_host,
workspace.date_host,
workspace.custom_date_button,
):
top_left = widget.mapTo(workspace.filter_card, QPoint(0, 0))
assert top_left.x() >= 0
assert top_left.x() + widget.width() <= workspace.filter_card.width()
assert all(button.maximumWidth() == 420 for button in workspace.summary_buttons.values())
assert page.tabs.minimumHeight() == 0
assert workspace.content_stack.minimumHeight() == 0
assert workspace.table.minimumHeight() == 0
assert workspace.bottom_actions.isHidden()
assert workspace.table.viewport().height() // 40 >= 6
assert workspace.pager.isVisibleTo(page)
assert workspace.table.objectName() == "PatientTable"
assert workspace.table.columnCount() == 10
assert workspace.table.horizontalHeaderItem(9).text() == "操作"
+247
View File
@@ -0,0 +1,247 @@
from __future__ import annotations
import os
from pathlib import Path
from types import SimpleNamespace
from typing import Any
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from PySide6.QtCore import QPoint
from PySide6.QtGui import QImage
from PySide6.QtWidgets import QAbstractItemView, QApplication, QComboBox, QFrame, QWidget
from doctor_workstation.ui.pages import prescription_library as library_module
from doctor_workstation.ui.pages import prescriptions as prescriptions_module
from doctor_workstation.ui.pages.prescription_library import PrescriptionLibraryPage
from doctor_workstation.ui.pages.prescriptions import PrescriptionsPage
from doctor_workstation.ui.widgets import BusinessPager
@pytest.fixture(scope="module")
def application() -> QApplication:
return QApplication.instance() or QApplication([])
@pytest.fixture(autouse=True)
def immediate_async(monkeypatch: pytest.MonkeyPatch) -> None:
def run_immediately(
function: Any,
*args: Any,
on_success: Any = None,
on_error: Any = None,
on_finished: Any = None,
**kwargs: Any,
) -> object:
try:
result = function(*args, **kwargs)
except Exception as error:
if on_error is not None:
on_error(error)
else:
if on_success is not None:
on_success(result)
finally:
if on_finished is not None:
on_finished()
return object()
monkeypatch.setattr(prescriptions_module, "run_async", run_immediately)
monkeypatch.setattr(library_module, "run_async", run_immediately)
def _issued_row(index: int) -> dict[str, Any]:
return {
"id": 1000 + index,
"sn": f"CF-202608-{1000 + index}",
"prescription_type": "汤剂",
"is_system_auto": index % 2,
"patient_name": ("林晓岚", "周明远", "许安然")[index % 3],
"gender": 2 if index % 2 else 1,
"age": 29 + index,
"audit_status": index % 3,
"void_status": 0,
"has_prescription_order": index % 2,
"creator_id": 7,
"doctor_name": "陈医生",
"assistant_name": "赵医助",
"create_time": f"2026-08-{(index % 9) + 10:02d} 09:30:00",
"herbs": [{"name": "黄芪", "dosage": 15}],
}
def _library_row(index: int) -> dict[str, Any]:
return {
"id": 2000 + index,
"prescription_name": ("益气养阴方", "清热祛湿方", "滋阴调和方")[index % 3],
"formula_type": "主方" if index % 3 else "辅方",
"herbs": [
{"name": "黄芪", "dosage": 15},
{"name": "党参", "dosage": 12},
],
"efficacy": ("益气养阴", "清热祛湿", "滋阴补肾")[index % 3],
"is_public": index % 2,
"disable_edit": 0,
"creator_id": 7,
"creator_name": "陈医生",
"create_time": f"2026-08-{(index % 9) + 10:02d} 08:20:00",
}
class DensityRepository:
def __init__(self) -> None:
self.issued_rows = [_issued_row(index) for index in range(15)]
self.library_rows = [_library_row(index) for index in range(15)]
def list_diagnosis_doctors(self) -> list[dict[str, Any]]:
return [{"id": 7, "name": "陈医生"}, {"id": 8, "name": "孙医生"}]
def list_prescriptions(self, **_filters: Any) -> dict[str, Any]:
return {"lists": self.issued_rows, "count": 44}
def list_prescription_templates(self, **_filters: Any) -> dict[str, Any]:
return {"lists": self.library_rows, "count": 41}
def _new_page(kind: str) -> PrescriptionsPage | PrescriptionLibraryPage:
repository = DensityRepository()
user = SimpleNamespace(id=7, name="陈医生", root=1, role_ids=[0])
permissions = {"*"}
if kind == "issued":
page: PrescriptionsPage | PrescriptionLibraryPage = PrescriptionsPage(
repository, permissions, user
)
else:
page = PrescriptionLibraryPage(repository, permissions, user)
page.refresh()
return page
def _settle(application: QApplication) -> None:
for _ in range(5):
application.processEvents()
def _fully_visible_rows(page: PrescriptionsPage | PrescriptionLibraryPage) -> int:
viewport = page.table.viewport()
return sum(
1
for row in range(page.table.rowCount())
if (
(item := page.table.item(row, 0)) is not None
and (rect := page.table.visualItemRect(item)).isValid()
and rect.top() >= 0
and rect.bottom() < viewport.height()
)
)
def test_business_pager_is_shared_fixed_and_not_a_fake_dropdown(
application: QApplication,
) -> None:
pager = BusinessPager(15)
pager.update_state(2, 44)
pager.show()
_settle(application)
assert prescriptions_module.BusinessPager is BusinessPager
assert 40 <= pager.height() <= 44
assert pager.minimumHeight() == pager.maximumHeight() == 42
assert pager.findChildren(QComboBox) == []
assert pager.page_size_label.text() == "15 条/页"
margins = pager.layout().contentsMargins()
assert (margins.left(), margins.top(), margins.right(), margins.bottom()) == (16, 4, 16, 4)
assert pager.page_label is not None and pager.page_label.text() == "2"
pager.close()
@pytest.mark.parametrize("kind", ["issued", "library"])
@pytest.mark.parametrize(
("size", "minimum_visible_rows"),
[((1366, 768), 6), ((1710, 920), 9)],
)
def test_desktop_sizes_keep_rows_and_pager_visible_and_aligned(
application: QApplication,
kind: str,
size: tuple[int, int],
minimum_visible_rows: int,
) -> None:
page = _new_page(kind)
page.resize(*size)
page.show()
_settle(application)
header = page.findChild(QWidget, "PageHeader")
toolbar_name = "PrescriptionToolbar" if kind == "issued" else "PrescriptionLibraryToolbar"
toolbar = page.findChild(QFrame, toolbar_name)
assert header is not None and 60 <= header.height() <= 64
assert toolbar is not None and 44 <= toolbar.height() <= 48
assert 40 <= page.pager.height() <= 44
assert page.pager.minimumHeight() == page.pager.maximumHeight()
assert page.table.minimumHeight() == 0
assert page.table.horizontalScrollMode() == QAbstractItemView.ScrollMode.ScrollPerPixel
assert page.table.verticalScrollMode() == QAbstractItemView.ScrollMode.ScrollPerPixel
assert _fully_visible_rows(page) >= minimum_visible_rows
pager_position = page.pager.mapTo(page, QPoint())
assert pager_position.x() >= 0
assert pager_position.x() + page.pager.width() <= page.width()
assert pager_position.y() >= 0
assert pager_position.y() + page.pager.height() <= page.height()
page_size_right = page.pager.page_size_label.mapTo(page, QPoint()).x() + (
page.pager.page_size_label.width()
)
assert page_size_right <= page.width()
pager_margins = page.pager.layout().contentsMargins()
toolbar_margins = toolbar.layout().contentsMargins()
assert pager_margins.left() == toolbar_margins.left() == 16
assert pager_margins.right() == toolbar_margins.right() == 16
if kind == "issued":
filters = page.findChild(QFrame, "PrescriptionFilterBar")
assert filters is not None and 84 <= filters.height() <= 92
else:
filters = page.findChild(QFrame, "PrescriptionLibraryFilterBar")
assert filters is not None
assert page.name_filter.minimumWidth() < 500
filter_right = filters.contentsRect().right()
for control in (
page.name_filter,
page.formula_filter,
page.visibility_filter,
page.effect_filter,
page.query_button,
page.reset_button,
):
right = control.mapTo(filters, QPoint()).x() + control.width()
assert right <= filter_right
page.close()
_settle(application)
def test_density_reference_artifacts_exist() -> None:
root = Path(__file__).resolve().parents[1]
expected = {
root / "artifacts" / "prescription_list_density" / "prescriptions_1366x768.png": (
1366,
768,
),
root / "artifacts" / "prescription_list_density" / "prescriptions_1710x920.png": (
1710,
920,
),
root
/ "artifacts"
/ "prescription_list_density"
/ "prescription_library_1366x768.png": (1366, 768),
root
/ "artifacts"
/ "prescription_list_density"
/ "prescription_library_1710x920.png": (1710, 920),
}
for path, dimensions in expected.items():
image = QImage(str(path))
assert not image.isNull(), path
assert (image.width(), image.height()) == dimensions
+120
View File
@@ -16,6 +16,7 @@ from doctor_workstation.ui.dialogs import diagnosis as diagnosis_module
from doctor_workstation.ui.dialogs import prescription as dialog_module
from doctor_workstation.ui.dialogs.diagnosis import DiagnosisDialog
from doctor_workstation.ui.dialogs.prescription import (
DiagnosisDetailDialog,
PrescriptionEditorDialog,
PrescriptionOrderDialog,
PrescriptionTemplateDialog,
@@ -287,6 +288,125 @@ def test_paid_order_response_is_bound_to_active_diagnosis_and_blocks_save(
application.processEvents()
def test_diagnosis_order_detail_lookup_is_queued_before_repository_call(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
queued: list[tuple[Any, dict[str, Any]]] = []
requested: list[int] = []
shown: list[tuple[int, str]] = []
class Repository:
def get_prescription_order(self, order_id: int) -> dict[str, Any]:
requested.append(order_id)
return {"id": order_id, "order_no": f"DETAIL-{order_id}"}
def queue_async(function: Any, **options: Any) -> object:
queued.append((function, options))
return object()
def present_order_detail(
_host: Any,
order: dict[str, Any],
*,
order_id: int,
permissions: Any,
exec_: bool,
) -> None:
del permissions, exec_
shown.append((order_id, order["order_no"]))
monkeypatch.setattr(dialog_module, "run_async", queue_async)
monkeypatch.setattr(diagnosis_module, "present_order_detail", present_order_detail)
dialog = DiagnosisDetailDialog(
{"orders": [{"id": 17, "order_no": "ROW-17"}]},
repository=Repository(),
)
table = dialog._order_detail_table
button = dialog._order_detail_button
assert table is not None
assert button is not None
table.setCurrentCell(0, 0)
button.click()
assert len(queued) == 1
assert requested == []
assert shown == []
assert not table.isEnabled()
assert not button.isEnabled()
function, options = queued[0]
options["on_success"](function())
options["on_finished"]()
assert requested == [17]
assert shown == [(17, "DETAIL-17")]
assert table.isEnabled()
assert button.isEnabled()
dialog.close()
application.processEvents()
def test_diagnosis_order_detail_ignores_stale_result_and_keeps_row_fallback(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
queued: list[dict[str, Any]] = []
shown: list[tuple[int, str]] = []
def queue_async(_function: Any, **options: Any) -> object:
queued.append(options)
return object()
def present_order_detail(
_host: Any,
order: dict[str, Any],
*,
order_id: int,
permissions: Any,
exec_: bool,
) -> None:
del permissions, exec_
shown.append((order_id, order["order_no"]))
repository = SimpleNamespace(get_prescription_order=lambda order_id: {"id": order_id})
monkeypatch.setattr(dialog_module, "run_async", queue_async)
monkeypatch.setattr(diagnosis_module, "present_order_detail", present_order_detail)
dialog = DiagnosisDetailDialog(
{
"orders": [
{"id": 21, "order_no": "ROW-21"},
{"id": 22, "order_no": "ROW-22"},
]
},
repository=repository,
)
table = dialog._order_detail_table
button = dialog._order_detail_button
assert table is not None
assert button is not None
table.setCurrentCell(0, 0)
dialog._open_selected_order()
table.setCurrentCell(1, 0)
dialog._open_selected_order()
assert len(queued) == 2
queued[0]["on_success"]({"id": 21, "order_no": "STALE-21"})
queued[0]["on_finished"]()
assert shown == []
assert not table.isEnabled()
assert not button.isEnabled()
queued[1]["on_error"](RuntimeError("detail unavailable"))
queued[1]["on_finished"]()
assert shown == [(22, "ROW-22")]
assert table.isEnabled()
assert button.isEnabled()
dialog.close()
application.processEvents()
def _finish_queued(callback: dict[str, Any], result: Any) -> None:
callback["on_success"](result)
if callback.get("on_finished"):
+650 -11
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
import json
import os
from datetime import date
from datetime import date, timedelta
from pathlib import Path
from typing import Any
@@ -10,14 +10,32 @@ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import httpx
import pytest
from PySide6.QtCore import QDate, QPoint, Qt
from PySide6.QtCore import (
QDate,
QEvent,
QEventLoop,
QObject,
QPoint,
Qt,
QThreadPool,
QTimer,
)
from PySide6.QtGui import QPalette
from PySide6.QtWidgets import QApplication, QLabel, QPushButton, QScrollArea
from PySide6.QtWidgets import (
QApplication,
QLabel,
QPushButton,
QScrollArea,
QVBoxLayout,
QWidget,
)
from doctor_workstation.core import PermissionSet
from doctor_workstation.services import api_client as api_client_module
from doctor_workstation.services.api_client import ApiClient
from doctor_workstation.services.mock_repository import DemoDoctorRepository
from doctor_workstation.services.repository import RemoteDoctorRepository
from doctor_workstation.ui import widgets as widgets_module
from doctor_workstation.ui.pages import reception as reception_module
from doctor_workstation.ui.pages.reception import (
NOTE_LIMIT,
@@ -41,8 +59,11 @@ def immediate_async(monkeypatch: pytest.MonkeyPatch) -> None:
on_success: Any = None,
on_error: Any = None,
on_finished: Any = None,
pool: Any = None,
priority: int = 0,
**kwargs: Any,
) -> object:
del pool, priority
try:
result = function(*args, **kwargs)
except Exception as error:
@@ -79,6 +100,135 @@ def queued_async(monkeypatch: pytest.MonkeyPatch) -> list[dict[str, Any]]:
return jobs
def test_api_client_reuses_a_bounded_transport_across_qt_runnables(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
created: list[Any] = []
class PooledClient:
def __init__(self, **_options: Any) -> None:
self.closed = False
created.append(self)
def request(self, _method: str, url: str, **_options: Any) -> httpx.Response:
return httpx.Response(200, json={"code": 1, "data": url})
def close(self) -> None:
self.closed = True
monkeypatch.setattr(api_client_module.httpx, "Client", PooledClient)
client = ApiClient("https://example.test", max_parallel_requests=2)
pool = QThreadPool()
pool.setMaxThreadCount(1)
pool.setExpiryTimeout(-1)
remaining = 40
results: list[str] = []
loop = QEventLoop()
def finished() -> None:
nonlocal remaining
remaining -= 1
if remaining == 0:
loop.quit()
for index in range(remaining):
widgets_module.run_async(
lambda index=index: client.get(f"patient/{index}"),
on_success=results.append,
on_finished=finished,
pool=pool,
)
QTimer.singleShot(5_000, loop.quit)
loop.exec()
assert pool.waitForDone(2_000)
client.close()
assert remaining == 0
assert len(results) == 40
assert len(created) == 1
assert created[0].closed
application.processEvents()
def test_clearing_ai_layout_does_not_promote_children_to_windows(
application: QApplication,
) -> None:
host = QWidget()
layout = QVBoxLayout(host)
dynamic_label = QLabel("正在加载患者 AI 分析", host)
layout.addWidget(dynamic_label)
host.show()
application.processEvents()
destroyed: list[bool] = []
dynamic_label.destroyed.connect(lambda: destroyed.append(True))
reception_module._clear_ai_layout(layout)
assert layout.count() == 0
assert destroyed == [True]
assert dynamic_label not in application.topLevelWidgets()
host.close()
host.deleteLater()
def test_queue_row_never_shows_loading_fields_as_parentless_windows(
application: QApplication,
) -> None:
tracked_names = {
"StatusBadge",
"ReceptionQueueTime",
"ReceptionQueueMetric",
}
class OrphanShowRecorder(QObject):
def __init__(self) -> None:
super().__init__()
self.object_names: list[str] = []
def eventFilter(self, watched: QObject, event: QEvent) -> bool: # noqa: N802
if (
event.type() == QEvent.Type.Show
and isinstance(watched, QWidget)
and watched.objectName() in tracked_names
and watched.parentWidget() is None
and watched.isWindow()
):
self.object_names.append(watched.objectName())
return False
recorder = OrphanShowRecorder()
application.installEventFilter(recorder)
try:
active_row = QueueRow(
{
"patient_name": "问诊患者",
"status": 2,
"status_desc": "问诊中",
"fasting_blood_sugar": 8.6,
}
)
waiting_row = QueueRow(
{
"patient_name": "待接诊患者",
"status": 1,
"appointment_time": "14:30",
}
)
finally:
application.removeEventFilter(recorder)
assert recorder.object_names == []
for row in (active_row, waiting_row):
for object_name in tracked_names:
widget = row.findChild(QWidget, object_name)
assert widget is not None
assert widget.parentWidget() is row
assert not widget.isWindow()
row.deleteLater()
@pytest.mark.parametrize(
("path", "expected"),
[
@@ -283,9 +433,10 @@ def test_queue_uses_admin_same_day_contract(
"status": 1,
"start_date": date.today().isoformat(),
"end_date": date.today().isoformat(),
"page_no": 1,
"page_size": 15,
"patient_name": "王小明",
"page_no": 1,
"page_size": 15,
"patient_name": "王小明",
"include_status_counts": 1,
}
]
@@ -296,6 +447,167 @@ def test_queue_uses_admin_same_day_contract(
application.processEvents()
def test_reception_daily_records_use_backend_matrix_contract(
application: QApplication,
immediate_async: None,
) -> None:
today = date.today().isoformat()
tracking_calls: list[tuple[int, str, str]] = []
note_calls: list[int] = []
class Repository:
fail_tracking = False
def list_appointments(self, **_kwargs: Any) -> dict[str, Any]:
return {
"lists": [
{
"id": 71,
"diagnosis_id": 271,
# Production appointment.patient_id is the diagnosis id.
"patient_id": 271,
"patient_name": "日常记录患者",
"status": 1,
"appointment_time": "09:30:00",
}
],
"count": 1,
}
def get_reception(self, appointment_id: int) -> dict[str, Any]:
assert appointment_id == 71
return {
"appointment": {
"id": 71,
"patient_id": 271,
"patient_name": "日常记录患者",
"status": 1,
},
"diagnosis": {
"id": 271,
"patient_id": 971,
"patient_name": "日常记录患者",
"age": 56,
},
"tracking_notes": [
{"note_date": today, "content": "内嵌备注降级数据"}
],
}
def get_tracking_window(
self,
diagnosis_id: int,
*,
start_date: str,
end_date: str,
) -> dict[str, Any]:
tracking_calls.append((diagnosis_id, start_date, end_date))
if self.fail_tracking:
raise RuntimeError("tracking unavailable")
return {
"diagnosis_id": diagnosis_id,
"start_date": start_date,
"end_date": end_date,
"blood_records": [
{
"record_date": today,
"record_time": "08:30:00",
"fasting_blood_sugar": 9.6,
"postprandial_blood_sugar": 11.4,
"other_blood_sugar": 8.5,
"systolic_pressure": 141,
"diastolic_pressure": 90,
"western_medicine": "二甲双胍",
"insulin": "睡前 8U",
"source": 1,
}
],
"diet_records": [
{
"record_date": today,
"breakfast_foods": ["小米粥"],
"lunch_foods": ["杂粮饭"],
"dinner_foods": ["青菜"],
}
],
"exercise_records": [
{
"record_date": today,
"exercise_type": "快走",
"duration": 45,
"intensity_text": "中等",
}
],
}
def list_tracking_notes(self, diagnosis_id: int) -> list[dict[str, Any]]:
note_calls.append(diagnosis_id)
return [{"note_date": today, "content": "睡眠改善,继续随访"}]
repository = Repository()
page = ReceptionPage(repository, PermissionSet([]))
page.refresh()
application.processEvents()
assert [
page.detail_tabs.tabText(index) for index in range(page.detail_tabs.count())
] == ["问诊信息", "检查报告", "用药记录", "日常记录", "随访记录", "健康数据"]
assert tracking_calls == [
(271, (date.today() - timedelta(days=6)).isoformat(), today)
]
assert note_calls == [271]
assert page._selection_context()[-1] == 971
matrix = page.daily_panel.matrix
assert matrix.objectName() == "ReceptionDailyRecordsTable"
assert matrix.rowCount() == 11
assert matrix.columnCount() == 8
assert matrix.horizontalHeaderItem(0).text() == "指标"
assert matrix.horizontalHeaderItem(1).text() == today[5:]
assert [matrix.item(row, 0).text() for row in range(11)] == [
"空腹血糖",
"餐后2h血糖",
"其他血糖",
"血压",
"西药",
"胰岛素",
"早餐",
"午餐",
"晚餐",
"运动",
"跟踪备注",
]
assert matrix.item(0, 1).text() == "9.6 · 自录 ↑"
assert matrix.item(0, 1).data(Qt.ItemDataRole.UserRole)["high"] is True
assert matrix.item(1, 1).text() == "11.4 · 自录 ↑"
assert matrix.item(2, 1).text() == "8.5 · 自录"
assert matrix.item(2, 1).data(Qt.ItemDataRole.UserRole)["high"] is False
assert matrix.item(3, 1).text() == "141/90 · 自录 ↑"
assert matrix.item(4, 1).text() == "二甲双胍"
assert matrix.item(5, 1).text() == "睡前 8U"
assert matrix.item(6, 1).text() == "已记录"
assert matrix.item(9, 1).text() == "45min"
assert matrix.item(10, 1).text() == "睡眠改善,继续随访"
assert "睡眠改善" in page.followup_text.text()
page.daily_panel.range_buttons["30"].click()
assert tracking_calls[-1] == (
271,
(date.today() - timedelta(days=29)).isoformat(),
today,
)
assert page.daily_panel.matrix.columnCount() == 31
preserved = page.daily_panel.matrix.item(0, 1).text()
repository.fail_tracking = True
page.daily_panel.refresh_button.click()
assert page.daily_panel.matrix.item(0, 1).text() == preserved
assert "已保留上次数据" in page.daily_panel.state.label.text()
page.close()
application.processEvents()
def test_queue_date_picker_filters_the_selected_day(
application: QApplication,
immediate_async: None,
@@ -394,6 +706,69 @@ def test_silent_queue_polls_reuse_rows_and_do_not_restart_detail_or_ai(
application.processEvents()
def test_silent_poll_replaces_only_the_queue_row_with_visible_changes(
application: QApplication,
immediate_async: None,
monkeypatch: pytest.MonkeyPatch,
) -> None:
original_queue_row = reception_module.QueueRow
constructed: list[int] = []
class CountingQueueRow(original_queue_row):
def __init__(self, record: Any, parent: QWidget | None = None) -> None:
constructed.append(int(record["id"]))
super().__init__(record, parent)
monkeypatch.setattr(reception_module, "QueueRow", CountingQueueRow)
class Repository:
calls = 0
def list_appointments(self, **_kwargs: Any) -> dict[str, Any]:
self.calls += 1
rows = [
{
"id": index,
"diagnosis_id": 200 + index,
"patient_id": 100 + index,
"patient_name": f"患者{index}",
"clinical_diagnosis": "消渴",
"status": 1,
}
for index in range(1, 4)
]
if self.calls > 1:
rows[1]["clinical_diagnosis"] = "消渴 · 气阴两虚"
return {"lists": rows, "count": 3}
def get_reception(self, appointment_id: int) -> dict[str, Any]:
return {
"appointment": {"id": appointment_id, "patient_id": 100 + appointment_id},
"diagnosis": {
"id": 200 + appointment_id,
"patient_id": 100 + appointment_id,
},
}
page = ReceptionPage(Repository(), PermissionSet([]))
page.refresh()
items = [page.queue_list.item(index) for index in range(3)]
widgets = [page.queue_list.itemWidget(item) for item in items]
assert constructed == [1, 2, 3]
page.refresh(silent=True)
assert all(page.queue_list.item(index) is items[index] for index in range(3))
assert page.queue_list.itemWidget(items[0]) is widgets[0]
assert page.queue_list.itemWidget(items[1]) is not widgets[1]
assert page.queue_list.itemWidget(items[2]) is widgets[2]
assert constructed == [1, 2, 3, 2]
changed_row = page.queue_list.itemWidget(items[1])
assert changed_row.findChild(QLabel, "ReceptionQueueSubline").text() == "消渴 · 气阴两虚"
page.close()
application.processEvents()
def test_timer_poll_does_not_supersede_an_in_flight_queue_request(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
@@ -489,6 +864,101 @@ def test_fast_patient_switch_rejects_late_detail(
application.processEvents()
def test_rapid_aba_switch_prioritizes_current_detail_and_skips_stale_bundles(
application: QApplication,
queued_async: list[dict[str, Any]],
) -> None:
first = _detail(73, name="甲患者")
second = _detail(74, name="乙患者")
class Repository:
def __init__(self) -> None:
self.calls: list[int] = []
def get_reception(self, appointment_id: int) -> dict[str, Any]:
self.calls.append(appointment_id)
return first if appointment_id == 73 else second
repository = Repository()
page = ReceptionPage(repository, PermissionSet([]))
page._select_record(first["appointment"])
page._select_record(second["appointment"])
page._select_record(first["appointment"])
assert [job["priority"] for job in queued_async] == [1, 2, 3]
for stale_job in queued_async[:2]:
assert stale_job["function"]() == {"cancelled": True}
stale_job["on_finished"]()
assert repository.calls == []
current_job = queued_async[2]
current_job["on_success"](current_job["function"]())
current_job["on_finished"]()
assert repository.calls == [73]
assert page._selected_appointment_id == 73
assert page.patient_name_label.text() == "甲患者"
assert not page._detail_loading
page.close()
application.processEvents()
def test_switching_patient_resets_stale_daily_panel_loading(
application: QApplication,
) -> None:
page = ReceptionPage(object(), PermissionSet([]))
page.daily_panel.set_loading(True)
assert page.daily_panel._loading
assert not page.daily_panel.refresh_button.isEnabled()
page._reset_detail_content(
seed={"id": 75, "patient_name": "新患者", "diagnosis_id": 275}
)
assert not page.daily_panel._loading
assert page.daily_panel.refresh_button.isEnabled()
assert all(button.isEnabled() for button in page.daily_panel.range_buttons.values())
assert page.daily_panel.start_date.isEnabled()
assert page.daily_panel.end_date.isEnabled()
page.close()
application.processEvents()
def test_stale_daily_worker_skips_repository_after_patient_switch(
application: QApplication,
queued_async: list[dict[str, Any]],
) -> None:
first = _detail(76, name="日常甲患者")
second = _detail(77, name="日常乙患者")
class Repository:
def __init__(self) -> None:
self.tracking_calls: list[int] = []
def get_tracking_window(self, diagnosis_id: int, **_options: Any) -> dict[str, Any]:
self.tracking_calls.append(diagnosis_id)
return {}
repository = Repository()
page = ReceptionPage(repository, PermissionSet([]))
page._select_record(first["appointment"])
queued_async[0]["on_success"]({"detail": first, "warnings": []})
page._request_daily_range("2026-08-13", "2026-08-19")
stale_daily_job = queued_async[1]
page._select_record(second["appointment"])
assert stale_daily_job["function"]() == {"cancelled": True}
assert repository.tracking_calls == []
assert not page.daily_panel._loading
assert page.daily_panel.refresh_button.isEnabled()
page.close()
application.processEvents()
def test_medication_case_prioritizes_clinical_information_and_keeps_plain_summary(
application: QApplication,
) -> None:
@@ -905,9 +1375,10 @@ def test_queue_worker_uses_frozen_widget_snapshot(
"status": 1,
"start_date": date.today().isoformat(),
"end_date": date.today().isoformat(),
"page_no": 1,
"page_size": 15,
"patient_name": "甲患者",
"page_no": 1,
"page_size": 15,
"patient_name": "甲患者",
"include_status_counts": 1,
}
]
page.close()
@@ -943,6 +1414,28 @@ def test_phone_permission_and_ungated_notify_video_actions(
application.processEvents()
def test_im_consult_is_visible_immediately_after_history(
application: QApplication,
) -> None:
page = ReceptionPage(DemoDoctorRepository(), PermissionSet([]))
page.resize(1280, 760)
page.detail_stack.setCurrentIndex(1)
page.show()
application.processEvents()
assert page.video_button.text() == "IM 问诊"
assert page.video_button.objectName() == "ReceptionImButton"
assert not page.video_button.isHidden()
assert page.history_button.geometry().right() < page.video_button.geometry().left()
assert page.video_button.geometry().right() < page.more_button.geometry().left()
assert "IM 问诊" not in [
action.text() for action in page.more_button.menu().actions()
]
page.close()
application.processEvents()
def test_reception_ai_report_button_follows_permission(
application: QApplication,
immediate_async: None,
@@ -1094,7 +1587,10 @@ def test_reception_auto_loads_structured_ai_analysis_and_matches_reference_geome
left = page.ai_analysis_card.geometry()
right = page.ai_assistant_card.geometry()
assert 470 <= left.height() <= 520
assert left.height() < 470
assert page.ai_analysis_card.minimumHeight() == 0
assert page.ai_analysis_card.maximumHeight() > 520
assert page.ai_analysis_card.findChildren(QScrollArea) == []
assert left.height() == right.height()
assert 0 <= right.left() - left.right() - 1 <= 2
assert abs(left.width() * 5 - right.width() * 4) <= 10
@@ -1194,6 +1690,65 @@ def test_reception_dual_ai_queues_openai_only_after_qwen_is_visible(
application.processEvents()
def test_saturated_fallback_ai_slots_do_not_leave_current_analysis_loading(
application: QApplication,
queued_async: list[dict[str, Any]],
monkeypatch: pytest.MonkeyPatch,
) -> None:
detail = _detail(86, name="后台分析繁忙患者")
calls: list[tuple[int, str]] = []
class BusyAutomaticSlots:
@staticmethod
def acquire(*, blocking: bool) -> bool:
assert not blocking
return False
@staticmethod
def release() -> None:
raise AssertionError("an unacquired slot must not be released")
monkeypatch.setattr(
reception_module,
"_AI_AUTOMATIC_GENERATION_SETTLE_SECONDS",
0.0,
)
monkeypatch.setattr(
reception_module,
"_AI_AUTOMATIC_REQUEST_SLOTS",
BusyAutomaticSlots(),
)
class Repository:
def get_diagnosis_ai_analysis(
self,
diagnosis_id: int,
model: str,
) -> dict[str, Any]:
calls.append((diagnosis_id, model))
raise AssertionError("busy automatic work must not enter repository")
page = ReceptionPage(
Repository(),
PermissionSet(["tcm.diagnosis/aiAnalysis"]),
)
page._select_record(detail["appointment"])
queued_async[0]["on_success"]({"detail": detail, "warnings": []})
automatic_qwen_job = queued_async[1]
deferred = automatic_qwen_job["function"](*automatic_qwen_job["args"])
automatic_qwen_job["on_success"](deferred)
automatic_qwen_job["on_finished"]()
assert deferred is reception_module._ASYNC_REQUEST_DEFERRED
assert calls == []
assert page._ai_analysis_model_states["qwen"] == "error"
assert not page._ai_analysis_loading
assert page.ai_analysis_retry_button.isEnabled()
assert "后台分析任务较多" in page.ai_analysis_state_label.text()
page.close()
application.processEvents()
def test_reception_openai_failure_keeps_qwen_success_visible(
application: QApplication,
queued_async: list[dict[str, Any]],
@@ -1464,7 +2019,6 @@ def test_ai_analysis_dialog_switches_complete_cached_payloads_without_requests(
"qwen 风险项目 1",
"qwen 风险项目 2",
"qwen 风险项目 3",
"qwen 风险项目 4",
]
calls_before_dialog = list(repository.analysis_calls)
page.ai_analysis_expand_button.click()
@@ -1658,6 +2212,89 @@ def test_detail_failure_stops_ai_loading_and_keeps_retry_available(
jobs[0]["on_finished"]()
assert not page._detail_loading
assert "正在" not in page.patient_meta_label.text()
assert "正在" not in page.diagnosis_text.text()
assert "正在" not in page.health_summary_label.text()
assert "正在" not in page.followup_text.text()
assert not page.daily_panel._loading
assert page.daily_panel.refresh_button.isEnabled()
page.close()
application.processEvents()
def test_finished_only_detail_request_cannot_leave_loading_placeholders(
application: QApplication,
queued_async: list[dict[str, Any]],
) -> None:
page = ReceptionPage(object(), PermissionSet(["*"]))
record = {
"id": 86,
"patient_id": 186,
"diagnosis_id": 286,
"patient_name": "无结果患者",
}
page._select_record(record)
queued_async[0]["on_finished"]()
assert not page._detail_loading
assert page._selected_detail is None
assert page.detail_banner.property("kind") == "danger"
assert "正在" not in page.patient_meta_label.text()
assert "正在" not in page.diagnosis_text.text()
assert "正在" not in page.health_summary_label.text()
assert "正在" not in page.followup_text.text()
assert not page.daily_panel._loading
assert page.daily_panel.refresh_button.isEnabled()
assert page._ai_analysis_state == "error"
page.close()
application.processEvents()
def test_finished_only_patient_ai_query_becomes_retryable(
application: QApplication,
queued_async: list[dict[str, Any]],
) -> None:
detail = _detail(87, name="AI 无结果患者")
class Repository:
def list_patient_ai_reports(self, patient_id: int) -> dict[str, Any]:
raise AssertionError(f"queued worker must not run inline: {patient_id}")
def generate_patient_ai_report(
self,
patient_id: int,
*,
model: str,
) -> dict[str, Any]:
raise AssertionError(f"queued worker must not run inline: {patient_id}/{model}")
page = ReceptionPage(
Repository(),
PermissionSet(
[
"tcm.diagnosis/patientAiReports",
"tcm.diagnosis/generatePatientAiReport",
]
),
)
page._select_record(detail["appointment"])
queued_async[0]["on_success"]({"detail": detail, "warnings": []})
assert len(queued_async) == 2
assert page._ai_analysis_state == "loading"
queued_async[1]["on_finished"]()
assert page._ai_analysis_state == "error"
assert not page._ai_analysis_loading
assert "请重试" in page.ai_analysis_state_label.text()
assert not page.ai_analysis_retry_button.isHidden()
page.ai_analysis_retry_button.click()
assert len(queued_async) == 3
assert page._ai_analysis_state == "loading"
page.close()
application.processEvents()
@@ -1732,6 +2369,7 @@ def test_reception_ai_analysis_discards_late_qwen_and_openai_results(
assert len(queued_async) == 2
page._select_record(second["appointment"])
assert queued_async[1]["function"]() is reception_module._ASYNC_REQUEST_CANCELLED
queued_async[2]["on_success"]({"detail": second, "warnings": []})
assert len(queued_async) == 4
second_qwen = _analysis_payload("第二位千问风险")
@@ -1740,6 +2378,7 @@ def test_reception_ai_analysis_discards_late_qwen_and_openai_results(
assert len(queued_async) == 5
page._select_record(third["appointment"])
assert queued_async[4]["function"]() is reception_module._ASYNC_REQUEST_CANCELLED
queued_async[5]["on_success"]({"detail": third, "warnings": []})
assert len(queued_async) == 7
third_qwen = _analysis_payload("第三位千问风险")
+200 -1
View File
@@ -3,11 +3,12 @@
from __future__ import annotations
from datetime import date
from pathlib import Path
from typing import Any
import pytest
from doctor_workstation.core.errors import ApiProtocolError
from doctor_workstation.core.errors import ApiBusinessError, ApiHttpError, ApiProtocolError
from doctor_workstation.core.models import Appointment, Consultation, PageResult, Prescription
from doctor_workstation.services.mock_repository import DemoDoctorRepository
from doctor_workstation.services.repository import (
@@ -271,6 +272,51 @@ def test_remote_reception_is_forcibly_scoped_to_today() -> None:
}
def test_remote_reception_daily_records_use_admin_endpoints_exactly() -> None:
client = RecordingClient()
repository = RemoteDoctorRepository(client) # type: ignore[arg-type]
repository.list_appointments(
status=1,
start_date="2026-08-11",
end_date="2026-08-17",
include_status_counts=1,
page_no=1,
page_size=15,
)
repository.get_reception(71)
repository.get_tracking_window(
271,
start_date="2026-08-11",
end_date="2026-08-17",
)
repository.list_tracking_notes(271)
assert client.get_calls[-4:] == [
(
"doctor.appointment/lists",
{
"status": 1,
"start_date": "2026-08-11",
"end_date": "2026-08-17",
"include_status_counts": 1,
"page_no": 1,
"page_size": 15,
},
),
("doctor.appointment/reception", {"id": 71}),
(
"tcm.diagnosis/trackingWindow",
{
"id": 271,
"start_date": "2026-08-11",
"end_date": "2026-08-17",
},
),
("tcm.diagnosis/trackingNotes", {"diagnosis_id": 271}),
]
def test_remote_new_contracts_use_exact_admin_endpoints_and_dtos() -> None:
"""Prescription, patient and diagnosis methods remain thin endpoint adapters."""
@@ -435,6 +481,81 @@ def test_remote_start_call_requires_and_normalizes_current_record_id() -> None:
]
def test_remote_call_record_identity_is_reused_for_room_recording_and_end() -> None:
"""Room binding and COS finalization must never select a different latest call."""
client = RecordingClient()
repository = RemoteDoctorRepository(client) # type: ignore[arg-type]
repository.bind_call_room(501, " room-901 ", call_record_id=901)
repository.end_call(501, call_record_id=901)
assert client.post_calls == [
(
"tcm.diagnosis/bindCallRoom",
{"diagnosis_id": 501, "room_id": "room-901", "call_record_id": 901},
),
(
"tcm.diagnosis/endCall",
{"diagnosis_id": 501, "call_record_id": 901},
),
]
def test_remote_local_audio_upload_keeps_exact_call_identity_and_mime(
tmp_path: Path,
) -> None:
class MultipartClient(RecordingClient):
def __init__(self) -> None:
super().__init__()
self.multipart_calls: list[
tuple[str, dict[str, tuple[str, bytes, str]], dict[str, Any]]
] = []
def post_multipart(
self,
endpoint: str,
*,
files: dict[str, tuple[str, bytes, str]],
data: dict[str, Any],
) -> dict[str, Any]:
self.multipart_calls.append((endpoint, files, data))
completed = int(data["chunk_index"]) == int(data["chunk_total"]) - 1
return {
"call_record_id": 901,
"completed": completed,
"file_url": "https://cos.example.test/calls/local-audio.webm"
if completed
else "",
"media_kind": "local_audio",
}
recording = tmp_path / "local-audio.webm"
recording.write_bytes(b"a" * (4 * 1024 * 1024 + 3))
client = MultipartClient()
repository = RemoteDoctorRepository(client) # type: ignore[arg-type]
result = repository.upload_call_recording(
recording,
501,
call_record_id=901,
mime_type="audio/webm;codecs=opus",
)
assert result["completed"] is True
assert result["call_record_id"] == 901
assert result["media_kind"] == "local_audio"
assert len(client.multipart_calls) == 2
assert all(call[0] == "tcm.diagnosis/uploadCallRecording" for call in client.multipart_calls)
for _endpoint, files, data in client.multipart_calls:
assert data["diagnosis_id"] == 501
assert data["call_record_id"] == 901
assert data["mime_type"] == "audio/webm;codecs=opus"
assert str(data["upload_id"]).startswith("local_audio_")
assert files["file"][0] == "local-audio.webm"
assert files["file"][2] == "audio/webm;codecs=opus"
@pytest.mark.parametrize(
"response",
[None, {}, {"ok": True}, {"call_record_id": 0}, {"callRecordId": -1}, {"id": True}],
@@ -600,6 +721,84 @@ def test_remote_diagnosis_ai_assistant_uses_first_party_endpoint_only() -> None:
assert client.timeouts == [105.0]
def test_ai_post_type_error_after_dispatch_is_never_retried() -> None:
class FailingAfterDispatchClient:
token = "token"
def __init__(self) -> None:
self.calls = 0
def post(
self,
endpoint: str,
payload: dict[str, Any] | None = None,
*,
timeout: float | None = None,
) -> Any:
assert endpoint == "tcm.diagnosis/generatePatientAiReport"
assert payload == {"patient_id": 301, "model": "qwen"}
assert timeout == 105.0
self.calls += 1
raise TypeError("transport failed after dispatch")
client = FailingAfterDispatchClient()
with pytest.raises(TypeError, match="after dispatch"):
RemoteDoctorRepository(client).generate_patient_ai_report(301, model="qwen")
assert client.calls == 1
def test_remote_diagnosis_ai_stream_normalises_chunks_in_order() -> None:
class StreamingClient(RecordingClient):
def post_event_stream(self, endpoint: str, payload: dict[str, Any], **kwargs: Any):
assert endpoint == "tcm.diagnosis/aiAssistantStream"
assert payload == {"id": 501, "prompt": "请辨证", "task": "tcm_pattern"}
assert kwargs["timeout"] == 105.0
yield {"event": "start", "data": {"model_key": "qwen"}}
yield {"event": "delta", "data": {"content": "肝郁"}}
yield {"event": "delta", "data": {"delta": "脾虚"}}
yield {"event": "done", "data": {"model_label": "千问"}}
client = StreamingClient()
events = list(
RemoteDoctorRepository(client).stream_diagnosis_ai(
501,
"请辨证",
task="tcm_pattern",
)
)
assert [event["event"] for event in events] == ["start", "delta", "delta", "done"]
assert "".join(event.get("text", "") for event in events) == "肝郁脾虚"
assert client.post_calls == []
def test_remote_diagnosis_ai_stream_falls_back_once_but_not_for_error_event() -> None:
class MissingStreamClient(RecordingClient):
def post_event_stream(self, *args: Any, **kwargs: Any):
raise ApiHttpError("missing", status_code=404)
missing_client = MissingStreamClient()
events = list(
RemoteDoctorRepository(missing_client).stream_diagnosis_ai(501, "请分析")
)
assert [event["event"] for event in events] == ["start", "delta", "done"]
assert events[1]["text"] == "服务端分析结果"
assert [call[0] for call in missing_client.post_calls] == [
"tcm.diagnosis/aiAssistant"
]
class ErrorStreamClient(RecordingClient):
def post_event_stream(self, *args: Any, **kwargs: Any):
yield {"event": "error", "data": {"message": "模型繁忙"}}
error_client = ErrorStreamClient()
with pytest.raises(ApiBusinessError, match="模型繁忙"):
list(RemoteDoctorRepository(error_client).stream_diagnosis_ai(501, "请分析"))
assert error_client.post_calls == []
def test_remote_diagnosis_ai_analysis_uses_exact_post_contract() -> None:
"""The legacy default is qwen, followed by an explicit OpenAI request."""
+292 -8
View File
@@ -6,11 +6,17 @@ from typing import Any
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QApplication, QWidget
from PySide6.QtCore import QSize, Qt
from PySide6.QtWidgets import QApplication, QDialog, QFrame, QToolButton, QWidget
from doctor_workstation.ui import shell as shell_module
from doctor_workstation.ui.shell import NavigationItem, ShellWindow
from doctor_workstation.ui.theme import apply_theme
def _logical_pixel(image: Any, x: int, y: int):
device_scale = image.devicePixelRatio()
return image.pixelColor(round(x * device_scale), round(y * device_scale))
class _ShellPageDouble(QWidget):
@@ -26,14 +32,45 @@ class _ShellPageDouble(QWidget):
self.permissions = permissions
self.current_user = current_user
self.refresh_count = 0
self.show_count = 0
self.ai_context_available = False
self.ai_open_count = 0
def refresh(self) -> None:
self.refresh_count += 1
def open_selected_ai_consult(self) -> bool:
self.ai_open_count += 1
return self.ai_context_available
def showEvent(self, event: Any) -> None: # noqa: N802 - Qt virtual
super().showEvent(event)
self.show_count += 1
self.refresh()
@pytest.fixture(scope="module")
def application() -> QApplication:
return QApplication.instance() or QApplication([])
app = QApplication.instance() or QApplication([])
apply_theme(app)
return app
@pytest.mark.parametrize(
("available_size", "expected_size"),
[
(QSize(1920, 1080), QSize(1710, 920)),
(QSize(1486, 1000), QSize(1486, 920)),
(QSize(1600, 800), QSize(1600, 800)),
(QSize(800, 600), QSize(1024, 640)),
(None, QSize(1710, 920)),
],
)
def test_shell_initial_size_is_bounded_by_logical_available_geometry(
available_size: QSize | None,
expected_size: QSize,
) -> None:
assert shell_module._bounded_initial_window_size(available_size) == expected_size
def test_patients_navigation_keeps_the_product_menu_title() -> None:
@@ -112,7 +149,8 @@ def shell_window(
"user": {"name": "陈医生", "department_name": "中医门诊", "role_ids": [1]},
"demo_mode": True,
},
permissions={item.permissions[0] for item in navigation},
permissions={item.permissions[0] for item in navigation}
| {"tcm.diagnosis/aiAssistant"},
)
window.show()
application.processEvents()
@@ -139,14 +177,60 @@ def test_shell_matches_reference_geometry_at_both_acceptance_sizes(
assert shell_window.stack.geometry().bottom() < shell_window.workspace.height()
image = shell_window.grab().toImage()
assert image.pixelColor(20, 300).name().lower() in {
assert _logical_pixel(image, 20, 300).name().lower() in {
"#f2f5fd",
"#f3f6fd",
"#f2f6fe",
"#f3f6fe",
}
assert image.pixelColor(610, 20).name().lower() == "#ffffff"
assert image.pixelColor(220, 90).name().lower() == "#fcfdfe"
assert _logical_pixel(image, 610, 20).name().lower() == "#ffffff"
assert _logical_pixel(image, 220, 90).name().lower() == "#fcfdfe"
def test_topbar_search_actions_and_navigation_controls_stay_aligned(
application: QApplication,
shell_window: ShellWindow,
) -> None:
for width, height in ((1024, 640), (1366, 768)):
shell_window.resize(width, height)
application.processEvents()
search_host = shell_window.topbar.findChild(QFrame, "ShellGlobalSearch")
shortcut_hint = search_host.findChild(QWidget, "ShellShortcutHint")
assert search_host.size().toTuple() == (265, 36)
assert shell_window.fold_button.size().toTuple() == (38, 38)
assert shortcut_hint.size() == shortcut_hint.sizeHint()
assert (
abs(search_host.geometry().center().y() - shell_window.fold_button.geometry().center().y())
<= 1
)
assert (
abs(
shortcut_hint.mapTo(search_host, shortcut_hint.rect().center()).y()
- search_host.rect().center().y()
)
<= 1
)
shell_window.global_search.setText("患者")
application.processEvents()
action_buttons = shell_window.global_search.findChildren(QToolButton)
assert len(action_buttons) == 2
for button in action_buttons:
assert button.size().toTuple() == (22, 18)
assert shell_window.global_search.rect().contains(button.geometry())
assert (
abs(
button.geometry().center().y()
- shell_window.global_search.rect().center().y()
)
<= 1
)
clear_button = max(action_buttons, key=lambda button: button.x())
clear_right = clear_button.mapTo(search_host, clear_button.rect().topRight()).x()
assert clear_right < shortcut_hint.x()
shell_window.global_search.clear()
def test_registered_pages_are_not_top_level_windows(shell_window: ShellWindow) -> None:
@@ -164,7 +248,11 @@ def test_reference_shell_has_integrated_search_ai_card_and_window_controls(
)
assert shell_window.assistant_card.isVisible()
assert shell_window.assistant_button.text() == "开始对话"
assert "GPT-4o 医疗版" in shell_window.model_label.text()
assert shell_window.upload_settings_button.text().strip().startswith("设置")
assert (
shell_window.upload_settings_button.accessibleName() == "本机录音上传设置"
)
assert shell_window.model_label is shell_window.upload_settings_button
assert shell_window.minimize_button.text() == ""
assert shell_window.close_button.text() == ""
@@ -174,6 +262,174 @@ def test_reference_shell_has_integrated_search_ai_card_and_window_controls(
assert "在线" in shell_window.assistant_status.text()
def test_shell_ai_entry_opens_the_current_selected_diagnosis(
application: QApplication,
shell_window: ShellWindow,
) -> None:
current = shell_window.pages["appointments"]
assert isinstance(current, _ShellPageDouble)
current.ai_context_available = True
shell_window.assistant_button.click()
application.processEvents()
assert current.ai_open_count == 1
assert shell_window.stack.currentWidget() is current
def test_shell_ai_entry_falls_back_to_reception_and_opens_its_selection(
application: QApplication,
shell_window: ShellWindow,
) -> None:
appointments = shell_window.pages["appointments"]
reception = shell_window.pages["reception"]
assert isinstance(appointments, _ShellPageDouble)
assert isinstance(reception, _ShellPageDouble)
reception.ai_context_available = True
shell_window.ai_top_button.click()
application.processEvents()
assert appointments.ai_open_count == 1
assert reception.ai_open_count == 1
assert shell_window.stack.currentWidget() is reception
def test_shell_ai_entry_on_reception_opens_chat_instead_of_noop(
application: QApplication,
shell_window: ShellWindow,
) -> None:
reception = shell_window.pages["reception"]
assert isinstance(reception, _ShellPageDouble)
assert shell_window.navigate("reception")
reception.ai_context_available = True
shell_window.ai_top_button.click()
application.processEvents()
assert reception.ai_open_count == 1
assert shell_window.stack.currentWidget() is reception
def test_shell_hides_global_ai_entries_without_ai_permission(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
navigation = [
NavigationItem(
"appointments",
"问诊列表",
"",
_ShellPageDouble,
("doctor.appointment/lists",),
)
]
monkeypatch.setattr(
shell_module,
"_resolve_navigation",
lambda _menu, _permissions, *, demo_mode: [
(item, item.title) for item in navigation
],
)
window = ShellWindow(
object(),
{"user": {"name": "无 AI 权限医生"}, "demo_mode": True},
permissions={"doctor.appointment/lists"},
)
window.show()
application.processEvents()
assert window.assistant_card.isHidden()
assert window.ai_top_button.isHidden()
window.close()
application.processEvents()
def test_shell_ai_entry_reports_when_reception_is_not_available(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
navigation = [
NavigationItem(
"appointments",
"问诊列表",
"",
_ShellPageDouble,
("doctor.appointment/lists",),
)
]
monkeypatch.setattr(
shell_module,
"_resolve_navigation",
lambda _menu, _permissions, *, demo_mode: [
(item, item.title) for item in navigation
],
)
messages: list[str] = []
monkeypatch.setattr(
shell_module,
"show_toast",
lambda _parent, message, *_args, **_kwargs: messages.append(message),
)
window = ShellWindow(
object(),
{"user": {"name": "无接诊台医生"}, "demo_mode": True},
permissions={
"doctor.appointment/lists",
"tcm.diagnosis/aiAssistant",
},
)
window.show()
application.processEvents()
window.assistant_button.click()
application.processEvents()
assert any("没有可用的接诊台" in message for message in messages)
assert all("已进入接诊台" not in message for message in messages)
assert window.stack.currentWidget() is window.pages["appointments"]
window.close()
application.processEvents()
def test_shell_settings_opens_global_local_audio_upload_manager(
application: QApplication,
shell_window: ShellWindow,
monkeypatch: pytest.MonkeyPatch,
) -> None:
captured: dict[str, Any] = {}
def dialog_factory(
repository: Any,
diagnosis_id: int | None,
parent: QWidget,
) -> QDialog:
dialog = QDialog(parent)
dialog.setObjectName("LocalAudioQueueDialog")
captured.update(
repository=repository,
diagnosis_id=diagnosis_id,
parent=parent,
dialog=dialog,
)
return dialog
monkeypatch.setattr(shell_module, "LocalAudioQueueDialog", dialog_factory)
shell_window.upload_settings_button.click()
application.processEvents()
assert captured["repository"] is shell_window.repository
assert captured["diagnosis_id"] is None
assert captured["parent"] is shell_window
assert captured["dialog"].isVisible()
captured["dialog"].reject()
application.processEvents()
assert shell_window._local_audio_settings_dialog is None
def test_every_visible_page_navigates_and_visited_tabs_track_active_page(
shell_window: ShellWindow,
) -> None:
@@ -211,6 +467,34 @@ def test_every_visible_page_navigates_and_visited_tabs_track_active_page(
]
def test_real_navigation_refreshes_once_and_current_page_click_is_a_noop(
application: QApplication,
shell_window: ShellWindow,
) -> None:
appointments = shell_window.pages["appointments"]
reception = shell_window.pages["reception"]
assert isinstance(appointments, _ShellPageDouble)
assert isinstance(reception, _ShellPageDouble)
assert appointments.refresh_count == 1
assert appointments.show_count == 1
assert reception.refresh_count == 0
shell_window.nav_buttons["reception"].click()
application.processEvents()
assert reception.refresh_count == 1
assert reception.show_count == 1
shell_window.nav_buttons["reception"].click()
application.processEvents()
assert reception.refresh_count == 1
assert reception.show_count == 1
shell_window.nav_buttons["appointments"].click()
application.processEvents()
assert appointments.refresh_count == 2
assert appointments.show_count == 2
def test_non_fixed_tabs_close_and_active_close_renavigates(
shell_window: ShellWindow,
) -> None:
+386 -6
View File
@@ -28,6 +28,233 @@ from doctor_workstation.video.security import ( # noqa: E402
)
def test_companion_uses_legacy_safe_transcription_session_identity() -> None:
"""Generated session IDs stay below 32 chars so upgraded databases cannot truncate."""
source = (PROJECT_ROOT / "video_companion" / "src" / "main.ts").read_text(
encoding="utf-8"
)
function_source = source.split("function newTranscriptionSessionId", 1)[1].split(
"function requestTranscriptionStart", 1
)[0]
assert "replaceAll('-', '')" in function_source
assert ".slice(0, 28)" in function_source
assert "return `tr-${" in function_source
def test_companion_archives_cloud_video_local_mixed_audio_and_transcript() -> None:
"""A connected call starts three independent artifacts before hangup."""
source = (PROJECT_ROOT / "video_companion" / "src" / "main.ts").read_text(
encoding="utf-8"
)
assert "context.createMediaStreamDestination()" in source
assert "cloud.getAudioTrack({ processed: true })" in source
assert "userId: activeConfig.targetUserId" in source
assert "new MediaRecorder(destination.stream" in source
assert "recorder.start(1000)" in source
assert "bridge.startLocalAudioRecording(sessionId, mimeType)" in source
assert "bridge.appendLocalAudioChunk(" in source
assert "bridge.finishLocalAudioRecording(sessionId, totalBytes)" in source
assert "operations.push(stopLocalRecording())" in source
assert "operations.push(stopTranscription('completed'))" in source
assert "Promise.allSettled(operations)" in source
assert "腾讯云混流视频、本机语音录音和实时转写均已启动" in source
def test_companion_watches_room_id_for_the_entire_call_cycle() -> None:
"""A slowly-created TRTC room must still bind to the exact call record."""
source = (PROJECT_ROOT / "video_companion" / "src" / "main.ts").read_text(
encoding="utf-8"
)
room_source = source.split("function readRoomId", 1)[1].split(
"function handleStatusChanged", 1
)[0]
assert "TUIStore.watch(StoreName.CALL, roomIdWatchOptions)" in room_source
assert "[NAME.ROOM_ID]: handleRoomIdChanged" in room_source
assert "cycle === callCycleGeneration" in room_source
assert "while (activeConfig && !endNotified" in room_source
assert "attempt < 40" not in room_source
assert "diagnosisId: activeConfig.diagnosisId" in room_source
def test_room_binding_is_acknowledged_and_transcriber_room_is_a_fallback() -> None:
companion_source = (
PROJECT_ROOT / "video_companion" / "src" / "main.ts"
).read_text(encoding="utf-8")
window_source = (
PROJECT_ROOT / "src" / "doctor_workstation" / "video" / "window.py"
).read_text(encoding="utf-8")
assert "observeRoomId(roomId)" in companion_source
assert "onRealtimeTranscriberStarted: (roomId)" in companion_source
assert "function roomBindingResult(" in companion_source
assert "if (boundRoomId) return" in companion_source
assert "roomBindingResult?.(" in window_source
def test_im_conversation_renders_deduplicated_video_call_status_timeline() -> None:
"""Video lifecycle feedback belongs in the IM timeline as local status events."""
companion_root = PROJECT_ROOT / "video_companion" / "src"
source = (companion_root / "main.ts").read_text(encoding="utf-8")
app_source = (companion_root / "App.vue").read_text(encoding="utf-8")
styles = (companion_root / "style.css").read_text(encoding="utf-8")
timeline_source = source.split("function appendVideoCallStatus", 1)[1].split(
"function onMessageReceived", 1
)[0]
assert "activeConfig.mode !== 'chat'" in timeline_source
assert "local-video-call-${callCycleGeneration}-${callStatus}" in timeline_source
assert "findIndex((item) => item.id === id)" in timeline_source
assert "appendVideoCallStatus('starting', '正在创建安全视频通话')" in source
assert "appendVideoCallStatus('dialing', '正在呼叫患者')" in source
assert "appendVideoCallStatus('connected', '视频通话已接通')" in source
assert "appendVideoCallStatus('ended', '视频通话已结束')" in source
assert "appendVideoCallStatus('failed', `视频通话发起失败:${message}`)" in source
assert "message.type === 'call-status'" in app_source
assert 'class="call-status-event"' in app_source
assert 'role="status"' in app_source
assert "IM 已连接 · ${props.statusText.value}" in app_source
assert ".message-row--call-status" in styles
assert ".call-status-event--connected" in styles
assert ".call-status-event--failed" in styles
def test_companion_local_recording_waits_for_real_audio_and_has_runtime_fallbacks() -> None:
source = (PROJECT_ROOT / "video_companion" / "src" / "main.ts").read_text(
encoding="utf-8"
)
assert "querySelectorAll<HTMLMediaElement>('video, audio')" in source
assert "stream.getAudioTracks()" in source
assert "navigator.mediaDevices.getUserMedia" in source
assert "await waitForCallAudioTracks(cloud, sessionId)" in source
assert "localRecordingAttachedSourceCount <= 0" in source
assert "localRecordingBytes < 1024" in source
assert "已阻止上传空文件" in source
def test_qt_close_waits_for_local_audio_finish_before_destroying_webengine() -> None:
"""A title-bar/desktop hangup must keep accepting bridge chunks until COS ack."""
source = (
PROJECT_ROOT / "src" / "doctor_workstation" / "video" / "window.py"
).read_text(encoding="utf-8")
request_shutdown = source.split(
"def _request_companion_shutdown", 1
)[1].split("def _force_requested_shutdown", 1)[0]
begin_shutdown = source.split("def _begin_shutdown", 1)[1].split(
"def wait_for_lifecycles", 1
)[0]
close_event = source.split("def closeEvent", 1)[1].split(
"else:", 1
)[0]
assert "window.doctorConsultation?.close?.()" in request_shutdown
assert "self._shutdown_requested = True" in request_shutdown
assert "self._closing = True" not in request_shutdown
assert "event.ignore()" in close_event
assert "self._request_companion_shutdown" in close_event
assert "self._shutdown_timer.stop()" in begin_shutdown
assert "window.doctorConsultation?.close?.()" not in begin_shutdown
assert "if not self.open_im or self._shutdown_requested" in source
def test_local_audio_capture_keeps_and_persists_its_own_call_room_identity() -> None:
"""A later IM call cycle must not relabel an earlier recording."""
source = (
PROJECT_ROOT / "src" / "doctor_workstation" / "video" / "window.py"
).read_text(encoding="utf-8")
assert "lifecycle: OrderedCallLifecycle" in source
assert "lifecycle = capture.lifecycle" in source
assert "call_record_id=call_record_id" in source
assert 'room_id=lifecycle.current_room_id or ""' in source
assert "store.bind_identity(" in source
def test_companion_shows_incremental_subtitles_but_only_persists_final_segments() -> None:
main_source = (
PROJECT_ROOT / "video_companion" / "src" / "main.ts"
).read_text(encoding="utf-8")
component_source = (
PROJECT_ROOT / "video_companion" / "src" / "App.vue"
).read_text(encoding="utf-8")
handler = main_source.split("function handleTranscriberMessage", 1)[1].split(
"function subscribeTranscriber", 1
)[0]
assert "showLiveCaption(message)" in handler
assert "if (message.isCompleted !== true) return" in handler
assert handler.index("showLiveCaption(message)") < handler.index(
"if (message.isCompleted !== true) return"
)
assert 'aria-label="实时语音字幕"' in component_source
assert "liveCaptions.value" in component_source
assert "caption.speaker" in component_source
assert "caption.text" in component_source
def test_companion_screenshot_requires_doctor_confirmation_before_upload() -> None:
source = (PROJECT_ROOT / "video_companion" / "src" / "App.vue").read_text(
encoding="utf-8"
)
capture = source.split("async function captureScreenshot", 1)[1].split(
"function discardScreenshot", 1
)[0]
confirm = source.split("async function confirmScreenshot", 1)[1].split(
"watch(", 1
)[0]
assert "screenshotPreview.value = canvas.toDataURL" in capture
assert "onSaveScreenshot" not in capture
assert "await props.onSaveScreenshot(screenshotPreview.value)" in confirm
assert "确认画面后再保存到患者资料" in source
assert "确认并上传" in source
assert "取消" in source
def test_companion_loads_im_history_without_an_empty_first_page_cursor() -> None:
source = (PROJECT_ROOT / "video_companion" / "src" / "main.ts").read_text(
encoding="utf-8"
)
load_messages = source.split("async function loadMessages", 1)[1].split(
"async function sendText", 1
)[0]
assert "nextReqMessageID: prepend ? nextReqMessageID : ''" not in load_messages
assert "nextReqMessageID?: string" in load_messages
assert (
"if (prepend && nextReqMessageID) request.nextReqMessageID = nextReqMessageID"
in load_messages
)
assert "chat.getMessageList(request)" in load_messages
def test_companion_preserves_im_scroll_position_for_live_and_older_messages() -> None:
source = (PROJECT_ROOT / "video_companion" / "src" / "App.vue").read_text(
encoding="utf-8"
)
watcher = source.split("() => props.messages.value.length", 1)[1].split(
"watch(", 1
)[0]
load_earlier = source.split("async function loadEarlierMessages", 1)[1].split(
"async function runAction", 1
)[0]
assert "if (!stickToMessageBottom.value) return" in watcher
assert "stickToMessageBottom.value = false" in load_earlier
assert "container.scrollHeight - previousHeight" in load_earlier
assert '@scroll="handleMessageScroll"' in source
assert '@click="loadEarlierMessages"' in source
def test_normalizes_admin_ticket_aliases_to_companion_contract() -> None:
request = normalize_backend_ticket(
{
@@ -196,11 +423,13 @@ def test_call_lifecycle_is_fifo_daemon_and_never_blocks_caller() -> None:
events.append(("start", diagnosis_id, patient_id, call_type))
return {"call_record_id": 900}
def bind_call_room(self, diagnosis_id: int, room_id: str) -> None:
events.append(("bind", diagnosis_id, room_id))
def bind_call_room(
self, diagnosis_id: int, room_id: str, *, call_record_id: int
) -> None:
events.append(("bind", diagnosis_id, room_id, call_record_id))
def end_call(self, diagnosis_id: int) -> None:
events.append(("end", diagnosis_id))
def end_call(self, diagnosis_id: int, *, call_record_id: int) -> None:
events.append(("end", diagnosis_id, call_record_id))
request = VideoCallRequest(
sdk_app_id=1400123456,
@@ -226,17 +455,19 @@ def test_call_lifecycle_is_fifo_daemon_and_never_blocks_caller() -> None:
assert lifecycle.wait(0.01) is False
assert duplicate_bind is bind_future
assert changed_bind.result(timeout=0) is False
assert lifecycle.current_room_id == "456789"
release_start.set()
assert start_future.result(timeout=2) is True
assert bind_future.result(timeout=2) is True
assert end_future.result(timeout=2) is True
assert lifecycle.wait(1) is True
assert lifecycle.current_room_id == "456789"
assert events == [
("start", 123, 8, 2),
("bind", 123, "456789"),
("end", 123),
("bind", 123, "456789", 900),
("end", 123, 900),
]
@@ -479,6 +710,155 @@ def test_failed_start_prevents_bind_and_end_writes() -> None:
assert events == ["start"]
def test_explicit_cos_recording_failure_fails_room_binding_without_losing_call_identity() -> None:
class Repository:
def start_call(
self, diagnosis_id: int, patient_id: int, *, call_type: int
) -> dict[str, int]:
del diagnosis_id, patient_id, call_type
return {"call_record_id": 904}
def bind_call_room(
self, diagnosis_id: int, room_id: str, *, call_record_id: int
) -> dict[str, object]:
assert (diagnosis_id, room_id, call_record_id) == (123, "456789", 904)
return {
"call_record_id": 904,
"cloud_recording": {
"started": False,
"message": "COS bucket is unavailable",
},
}
request = VideoCallRequest(
sdk_app_id=1400123456,
user_id="doctor_42",
user_sig="short-lived-ticket",
target_user_id="patient_8",
diagnosis_id=123,
patient_id=8,
)
lifecycle = OrderedCallLifecycle(request, Repository(), logging.getLogger(__name__))
with pytest.raises(RuntimeError, match="COS bucket is unavailable"):
lifecycle.bind_room("456789").result(timeout=2)
assert lifecycle.call_record_id == 904
assert lifecycle.bound_room_id is None
assert lifecycle.wait(1) is True
def test_failed_room_binding_releases_claim_and_can_retry_same_room() -> None:
bind_attempts = 0
class Repository:
def start_call(
self, diagnosis_id: int, patient_id: int, *, call_type: int
) -> dict[str, int]:
del diagnosis_id, patient_id, call_type
return {"call_record_id": 906}
def bind_call_room(
self, diagnosis_id: int, room_id: str, *, call_record_id: int
) -> dict[str, object]:
nonlocal bind_attempts
assert (diagnosis_id, room_id, call_record_id) == (123, "456789", 906)
bind_attempts += 1
if bind_attempts == 1:
raise RuntimeError("temporary bind failure")
return {
"call_record_id": 906,
"cloud_recording": {"started": True},
}
request = VideoCallRequest(
sdk_app_id=1400123456,
user_id="doctor_42",
user_sig="short-lived-ticket",
target_user_id="patient_8",
diagnosis_id=123,
patient_id=8,
)
lifecycle = OrderedCallLifecycle(request, Repository(), logging.getLogger(__name__))
with pytest.raises(RuntimeError, match="temporary bind failure"):
lifecycle.bind_room("456789").result(timeout=2)
assert lifecycle.current_room_id is None
assert lifecycle.bind_room("456789").result(timeout=2) is True
assert lifecycle.bound_room_id == "456789"
assert lifecycle.current_room_id == "456789"
assert bind_attempts == 2
assert lifecycle.wait(1) is True
def test_local_audio_upload_uses_exact_started_record_and_precedes_end(
tmp_path: Path,
) -> None:
events: list[tuple[object, ...]] = []
class Repository:
def start_call(
self, diagnosis_id: int, patient_id: int, *, call_type: int
) -> dict[str, int]:
events.append(("start", diagnosis_id, patient_id, call_type))
return {"call_record_id": 905}
def upload_call_recording(
self,
path: Path,
diagnosis_id: int,
*,
call_record_id: int,
mime_type: str,
) -> dict[str, object]:
events.append(
(
"local-audio",
path.read_bytes(),
diagnosis_id,
call_record_id,
mime_type,
)
)
return {
"completed": True,
"call_record_id": call_record_id,
"media_kind": "local_audio",
}
def end_call(self, diagnosis_id: int, *, call_record_id: int) -> None:
events.append(("end", diagnosis_id, call_record_id))
recording = tmp_path / "call-audio.webm"
recording.write_bytes(b"opus-webm-audio")
request = VideoCallRequest(
sdk_app_id=1400123456,
user_id="doctor_42",
user_sig="short-lived-ticket",
target_user_id="patient_8",
diagnosis_id=123,
patient_id=8,
)
lifecycle = OrderedCallLifecycle(request, Repository(), logging.getLogger(__name__))
lifecycle.start()
uploaded = lifecycle.save_local_audio_recording(
recording,
mime_type="audio/webm;codecs=opus",
)
ended = lifecycle.end("doctor-hangup")
assert uploaded.result(timeout=2) is True
assert ended.result(timeout=2) is True
assert lifecycle.wait(1) is True
assert events == [
("start", 123, 8, 2),
("local-audio", b"opus-webm-audio", 123, 905, "audio/webm;codecs=opus"),
("end", 123, 905),
]
def test_video_screenshot_is_uploaded_and_appended_to_patient_tongue_images() -> None:
events: list[tuple[object, ...]] = []
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light" />
<title>视频面诊</title>
<script type="module" crossorigin src="./assets/index-DXNYj41g.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-sts1UBl3.css">
<script type="module" crossorigin src="./assets/index-DhmAWjut.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-BsDbRyxy.css">
</head>
<body>
<div id="app"></div>
+165 -18
View File
@@ -6,11 +6,19 @@ import type { Ref } from 'vue'
interface ChatMessage {
id: string
mine: boolean
type: 'text' | 'image' | 'file' | 'audio' | 'video' | 'system'
type: 'text' | 'image' | 'file' | 'audio' | 'video' | 'system' | 'call-status'
text: string
url: string
name: string
time: string
callStatus?: 'starting' | 'dialing' | 'connected' | 'ended' | 'failed'
}
interface LiveCaption {
id: string
speaker: string
text: string
completed: boolean
}
const props = defineProps<{
@@ -24,6 +32,8 @@ const props = defineProps<{
notice: Readonly<Ref<string>>
hasMoreMessages: Readonly<Ref<boolean>>
transcriptionState: Readonly<Ref<string>>
localRecordingState: Readonly<Ref<string>>
liveCaptions: Readonly<Ref<LiveCaption[]>>
onSendText: (text: string) => Promise<void>
onSendAttachment: (file: File) => Promise<void>
onLoadMore: () => Promise<void>
@@ -38,29 +48,81 @@ const actionBusy = ref(false)
const localError = ref('')
const messageList = ref<HTMLElement | null>(null)
const fileInput = ref<HTMLInputElement | null>(null)
const screenshotPreview = ref('')
const stickToMessageBottom = ref(true)
const isChat = computed(() => props.mode.value === 'chat')
const isCalling = computed(() => ['starting', 'dialing', 'connected'].includes(props.phase.value))
const videoVisible = computed(() => !isChat.value || isCalling.value)
const canCapture = computed(() => props.phase.value === 'connected')
const transcriptionActive = computed(() => props.transcriptionState.value === 'recording')
const transcriptionFailed = computed(() => props.transcriptionState.value === 'error')
const chatConnectionText = computed(() => {
if (!props.chatReady.value) return props.statusText.value
if (['starting', 'dialing', 'connected', 'ended', 'error'].includes(props.phase.value)) {
return `IM 已连接 · ${props.statusText.value}`
}
return 'IM 已连接'
})
const transcriptionActive = computed(() => (
props.transcriptionState.value === 'recording'
|| props.localRecordingState.value === 'recording'
))
const transcriptionFailed = computed(() => (
props.transcriptionState.value === 'error'
|| props.localRecordingState.value === 'error'
))
const transcriptionStatusText = computed(() => {
if (props.transcriptionState.value === 'starting') return '自动录音启动中…'
if (props.transcriptionState.value === 'recording') return '自动录音并转文字中'
if (props.transcriptionState.value === 'stopping') return '正在保存录音文字…'
if (props.transcriptionState.value === 'error') return '自动录音转文字失败'
return '自动录音已结束'
const localState = props.localRecordingState.value
const textState = props.transcriptionState.value
if (localState === 'uploading') return '正在上传本机录音到 COS…'
if (localState === 'stopping' || textState === 'stopping') return '正在保存录音与转写文字…'
if (localState === 'starting' || textState === 'starting') return '自动录音与转写启动中…'
if (localState === 'recording' && textState === 'recording') return '本机录音与实时转写中'
if (localState === 'recording') return '本机录音中,实时转写未就绪'
if (textState === 'recording') return '实时转写中,本机录音未就绪'
if (localState === 'error' && textState === 'error') return '本机录音与实时转写均失败'
if (localState === 'error') return '本机录音失败,实时转写仍在运行'
if (textState === 'error') return '实时转写失败,本机录音仍在运行'
return '录音与转写已结束'
})
watch(
() => props.messages.value.length,
async () => {
if (!stickToMessageBottom.value) return
await nextTick()
if (messageList.value) messageList.value.scrollTop = messageList.value.scrollHeight
},
)
watch(
() => [props.patientName.value, props.mode.value],
async () => {
stickToMessageBottom.value = true
await nextTick()
if (messageList.value) messageList.value.scrollTop = messageList.value.scrollHeight
},
)
function handleMessageScroll(): void {
const container = messageList.value
if (!container) return
stickToMessageBottom.value = (
container.scrollHeight - container.scrollTop - container.clientHeight <= 96
)
}
async function loadEarlierMessages(): Promise<void> {
const container = messageList.value
if (!container || actionBusy.value) return
const previousHeight = container.scrollHeight
const previousTop = container.scrollTop
stickToMessageBottom.value = false
await runAction(props.onLoadMore)
await nextTick()
container.scrollTop = previousTop + (container.scrollHeight - previousHeight)
handleMessageScroll()
}
async function runAction(action: () => Promise<void>): Promise<void> {
if (actionBusy.value) return
actionBusy.value = true
@@ -113,9 +175,35 @@ async function captureScreenshot(): Promise<void> {
const context = canvas.getContext('2d')
if (!context) throw new Error('无法创建截图画布')
context.drawImage(video, 0, 0, canvas.width, canvas.height)
await props.onSaveScreenshot(canvas.toDataURL('image/jpeg', 0.9))
screenshotPreview.value = canvas.toDataURL('image/jpeg', 0.9)
})
}
function discardScreenshot(): void {
if (actionBusy.value) return
screenshotPreview.value = ''
}
async function confirmScreenshot(): Promise<void> {
if (!screenshotPreview.value || actionBusy.value) return
actionBusy.value = true
localError.value = ''
try {
await props.onSaveScreenshot(screenshotPreview.value)
screenshotPreview.value = ''
} catch (error) {
localError.value = error instanceof Error ? error.message : '截图上传失败,请稍后重试'
} finally {
actionBusy.value = false
}
}
watch(
() => props.phase.value,
(value) => {
if (value !== 'connected') screenshotPreview.value = ''
},
)
</script>
<template>
@@ -127,7 +215,7 @@ async function captureScreenshot(): Promise<void> {
<h1>{{ patientName.value }}</h1>
<p>
<span class="connection-dot" :class="{ 'connection-dot--online': chatReady.value }" />
{{ chatReady.value ? 'IM 已连接' : statusText.value }}
{{ chatConnectionText }}
</p>
</div>
<button
@@ -150,13 +238,13 @@ async function captureScreenshot(): Promise<void> {
</button>
</header>
<div ref="messageList" class="message-list" aria-live="polite">
<div ref="messageList" class="message-list" aria-live="polite" @scroll="handleMessageScroll">
<button
v-if="hasMoreMessages.value"
class="load-more"
type="button"
:disabled="chatBusy.value"
@click="runAction(onLoadMore)"
@click="loadEarlierMessages"
>
{{ chatBusy.value ? '正在读取…' : '查看更早消息' }}
</button>
@@ -171,10 +259,26 @@ async function captureScreenshot(): Promise<void> {
v-for="message in messages.value"
:key="message.id"
class="message-row"
:class="{ 'message-row--mine': message.mine }"
:class="{
'message-row--mine': message.mine,
'message-row--call-status': message.type === 'call-status',
}"
>
<div class="message-meta">{{ message.mine ? '我' : patientName.value }} · {{ message.time }}</div>
<div class="message-bubble">
<template v-if="message.type === 'call-status'">
<div
class="call-status-event"
:class="`call-status-event--${message.callStatus || 'ended'}`"
role="status"
:aria-label="`${message.text}${message.time}`"
>
<span class="call-status-event__icon" aria-hidden="true"></span>
<strong>{{ message.text }}</strong>
<time>{{ message.time }}</time>
</div>
</template>
<template v-else>
<div class="message-meta">{{ message.mine ? '我' : patientName.value }} · {{ message.time }}</div>
<div class="message-bubble">
<p v-if="message.type === 'text'">{{ message.text }}</p>
<img
v-else-if="message.type === 'image' && message.url"
@@ -193,8 +297,9 @@ async function captureScreenshot(): Promise<void> {
</a>
<audio v-else-if="message.type === 'audio' && message.url" :src="message.url" controls />
<video v-else-if="message.type === 'video' && message.url" class="message-video" :src="message.url" controls />
<p v-else>{{ message.text }}</p>
</div>
<p v-else>{{ message.text }}</p>
</div>
</template>
</article>
</div>
@@ -251,6 +356,19 @@ async function captureScreenshot(): Promise<void> {
{{ statusText.value }}
</div>
<div
v-if="canCapture && liveCaptions.value.length"
class="live-captions"
role="log"
aria-live="polite"
aria-label="实时语音字幕"
>
<p v-for="caption in liveCaptions.value" :key="caption.id">
<strong>{{ caption.speaker }}</strong>
<span>{{ caption.text }}</span>
</p>
</div>
<div v-if="isCalling" class="video-actions">
<div
v-if="canCapture"
@@ -271,7 +389,7 @@ async function captureScreenshot(): Promise<void> {
:disabled="!canCapture || actionBusy"
@click="captureScreenshot"
>
截屏并保存患者资料
截屏预览
</button>
<button class="hangup-button" type="button" :disabled="actionBusy" @click="runAction(onHangup)">
结束视频
@@ -281,6 +399,35 @@ async function captureScreenshot(): Promise<void> {
<div v-if="localError || notice.value" class="video-notice" :class="{ 'video-notice--error': localError }">
{{ localError || notice.value }}
</div>
<div
v-if="screenshotPreview"
class="screenshot-dialog-backdrop"
role="dialog"
aria-modal="true"
aria-labelledby="screenshot-preview-title"
@click.self="discardScreenshot"
>
<section class="screenshot-dialog">
<header>
<div>
<p class="eyebrow">视频截图确认</p>
<h2 id="screenshot-preview-title">确认画面后再保存到患者资料</h2>
</div>
<button type="button" aria-label="关闭截图预览" :disabled="actionBusy" @click="discardScreenshot">×</button>
</header>
<div class="screenshot-preview-frame">
<img :src="screenshotPreview" alt="本次视频截图预览">
</div>
<p class="screenshot-dialog__hint">只有点击确认并上传截图才会上传并写入患者资料</p>
<footer>
<button class="screenshot-cancel" type="button" :disabled="actionBusy" @click="discardScreenshot">取消</button>
<button class="screenshot-confirm" type="button" :disabled="actionBusy" @click="confirmScreenshot">
{{ actionBusy ? '正在上传…' : '确认并上传' }}
</button>
</footer>
</section>
</div>
</section>
</main>
</template>
+13
View File
@@ -29,6 +29,8 @@ interface DoctorConsultationApi {
startVideo(): Promise<void>
hangup(): Promise<void>
hostCallReady(ok: boolean, message?: string): void
recordingResult(ok: boolean, message: string): void
roomBindingResult(roomId: string, ok: boolean, message: string): void
screenshotResult(ok: boolean, message: string): void
transcriptionResult(
operation: 'start' | 'segment' | 'stop',
@@ -37,11 +39,22 @@ interface DoctorConsultationApi {
ok: boolean,
message: string,
): void
localRecordingResult(
operation: 'start' | 'chunk' | 'finish',
sessionId: string,
sequence: number,
ok: boolean,
message: string,
): void
}
interface QtVideoBridge {
notify?: (payload: string) => void
saveScreenshot?: (dataUrl: string) => void
startLocalAudioRecording?: (sessionId: string, mimeType: string) => void
appendLocalAudioChunk?: (sessionId: string, sequence: number, encoded: string) => void
finishLocalAudioRecording?: (sessionId: string, totalBytes: number) => void
abortLocalAudioRecording?: (sessionId: string) => void
}
interface Window {
+707 -33
View File
@@ -14,8 +14,10 @@ import './style.css'
type CallPhase = 'ready' | 'starting' | 'dialing' | 'connected' | 'ended' | 'error'
type CompanionMode = 'chat' | 'video'
type ChatMessageType = 'text' | 'image' | 'file' | 'audio' | 'video' | 'system'
type ChatMessageType = 'text' | 'image' | 'file' | 'audio' | 'video' | 'system' | 'call-status'
type VideoCallStatus = 'starting' | 'dialing' | 'connected' | 'ended' | 'failed'
type TranscriptionState = 'idle' | 'starting' | 'recording' | 'stopping' | 'error'
type LocalRecordingState = 'idle' | 'starting' | 'recording' | 'stopping' | 'uploading' | 'error'
interface NormalizedCallConfig {
SDKAppID: number
@@ -35,6 +37,14 @@ interface UiChatMessage {
url: string
name: string
time: string
callStatus?: VideoCallStatus
}
interface UiLiveCaption {
id: string
speaker: string
text: string
completed: boolean
}
interface BridgeMessage {
@@ -109,6 +119,25 @@ interface PendingSegment {
attempts: number
}
interface PendingLocalRecordingReply {
resolve: (value: boolean) => void
promise: Promise<boolean>
}
interface TrtcAudioTrackEvent {
userId?: string
track?: MediaStreamTrack
}
interface TrtcAudioCloud {
getAudioTrack?(configOrUserId?: {
userId?: string
processed?: boolean
} | string): MediaStreamTrack | null
on?(event: 'track', handler: (event: TrtcAudioTrackEvent) => void): void
off?(event: 'track', handler: (event: TrtcAudioTrackEvent) => void): void
}
const phase = ref<CallPhase>('ready')
const statusText = ref('正在连接问诊服务')
const patientName = ref('患者')
@@ -119,6 +148,8 @@ const chatBusy = ref(false)
const notice = ref('')
const hasMoreMessages = ref(false)
const transcriptionState = ref<TranscriptionState>('idle')
const localRecordingState = ref<LocalRecordingState>('idle')
const liveCaptions = ref<UiLiveCaption[]>([])
let activeConfig: NormalizedCallConfig | null = null
let chat: any = null
@@ -129,6 +160,10 @@ let nextReqMessageID = ''
let endNotified = true
let starting = false
let emittedRoomId = ''
let pendingRoomId = ''
let boundRoomId = ''
let roomBindingSentAt = 0
let roomBindingAttempts = 0
let resolveHostCallReady: ((value: boolean) => void) | null = null
const pendingTranscriptionStarts = new Map<string, PendingTranscriptionReply>()
const pendingTranscriptionStops = new Map<string, PendingTranscriptionReply>()
@@ -143,10 +178,34 @@ let transcriptionStopPromise: Promise<void> | null = null
let hangupNotification: Promise<void> | null = null
let callCycleGeneration = 0
let autoTranscriptionAttemptedGeneration = -1
let autoLocalRecordingAttemptedGeneration = -1
let lastTranscriberMessageAt = 0
let transcriberStoppedAt = 0
const acknowledgedSegmentIds = new Set<string>()
const pendingSegments = new Map<string, PendingSegment>()
const pendingLocalRecordingStarts = new Map<string, PendingLocalRecordingReply>()
const pendingLocalRecordingFinishes = new Map<string, PendingLocalRecordingReply>()
let localRecordingSessionId = ''
let localRecordingMimeType = ''
let localRecorder: MediaRecorder | null = null
let localAudioContext: AudioContext | null = null
let localAudioDestination: MediaStreamAudioDestinationNode | null = null
let localAudioCloud: TrtcAudioCloud | null = null
let localAudioTrackHandler: ((event: TrtcAudioTrackEvent) => void) | null = null
let localAudioSources: MediaStreamAudioSourceNode[] = []
let localAudioTrackIds = new Set<string>()
let localAudioOwnedTracks: MediaStreamTrack[] = []
let localAudioDiscoveryTimer: number | null = null
let localAudioHasDoctorSource = false
let localAudioHasPatientSource = false
let localRecordingAttachedSourceCount = 0
let localRecordingChunkSequence = 0
let localRecordingBytes = 0
let localRecordingChunkChain: Promise<void> = Promise.resolve()
let localRecordingStartPromise: Promise<void> | null = null
let localRecordingStopPromise: Promise<void> | null = null
let localRecordingFatalError = ''
let liveCaptionClearTimer: number | null = null
function initializeQtWebChannel(): void {
const transport = window.qt?.webChannelTransport
@@ -361,6 +420,29 @@ function mergeMessages(rawList: any[], prepend = false): void {
})
}
function appendVideoCallStatus(callStatus: VideoCallStatus, text: string): void {
if (!activeConfig || activeConfig.mode !== 'chat') return
const id = `local-video-call-${callCycleGeneration}-${callStatus}`
const message: UiChatMessage = {
id,
mine: false,
type: 'call-status',
text,
url: '',
name: '',
time: timeText(undefined),
callStatus,
}
const existingIndex = messages.value.findIndex((item) => item.id === id)
if (existingIndex < 0) {
messages.value = [...messages.value, message]
return
}
const next = [...messages.value]
next[existingIndex] = { ...next[existingIndex], ...message }
messages.value = next
}
function onMessageReceived(event: any): void {
if (!activeConfig) return
const expected = `C2C${activeConfig.targetUserId}`
@@ -465,11 +547,19 @@ async function loadMessages(prepend: boolean): Promise<void> {
if (!chat || !activeConfig || chatBusy.value) return
chatBusy.value = true
try {
const response = await chat.getMessageList({
const request: {
conversationID: string
count: number
nextReqMessageID?: string
} = {
conversationID: `C2C${activeConfig.targetUserId}`,
nextReqMessageID: prepend ? nextReqMessageID : '',
count: 30,
})
}
// Tencent Cloud IM requires the first roaming-history request to omit the
// pagination cursor. Passing an empty string is not equivalent and can
// return an empty page even though the C2C conversation has history.
if (prepend && nextReqMessageID) request.nextReqMessageID = nextReqMessageID
const response = await chat.getMessageList(request)
const data = response?.data ?? {}
const list = Array.isArray(data.messageList) ? data.messageList : []
nextReqMessageID = String(data.nextReqMessageID ?? '')
@@ -539,9 +629,454 @@ async function sendAttachment(file: File): Promise<void> {
}
}
function newLocalRecordingSessionId(): string {
const random = window.crypto?.randomUUID?.().replaceAll('-', '')
const entropy = random || `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`
return `audio-${entropy.replace(/[^a-zA-Z0-9]/g, '').slice(0, 32)}`
}
function selectLocalRecordingMimeType(): string {
if (typeof MediaRecorder === 'undefined') return ''
const candidates = [
'audio/webm;codecs=opus',
'audio/webm',
'audio/ogg;codecs=opus',
'audio/ogg',
]
return candidates.find((candidate) => MediaRecorder.isTypeSupported(candidate)) ?? ''
}
function requestLocalRecordingStart(sessionId: string, mimeType: string): Promise<boolean> {
const bridge = window.qtVideoBridge
if (!bridge || typeof bridge.startLocalAudioRecording !== 'function') {
return Promise.resolve(false)
}
const existing = pendingLocalRecordingStarts.get(sessionId)
if (existing) return existing.promise
const pending = {} as PendingLocalRecordingReply
pending.promise = new Promise<boolean>((resolve) => {
pending.resolve = resolve
})
pendingLocalRecordingStarts.set(sessionId, pending)
bridge.startLocalAudioRecording(sessionId, mimeType)
window.setTimeout(() => {
if (pendingLocalRecordingStarts.get(sessionId) !== pending) return
pendingLocalRecordingStarts.delete(sessionId)
pending.resolve(false)
}, 15000)
return pending.promise
}
function requestLocalRecordingFinish(sessionId: string, totalBytes: number): Promise<boolean> {
const bridge = window.qtVideoBridge
if (!bridge || typeof bridge.finishLocalAudioRecording !== 'function') {
return Promise.resolve(false)
}
const existing = pendingLocalRecordingFinishes.get(sessionId)
if (existing) return existing.promise
const pending = {} as PendingLocalRecordingReply
pending.promise = new Promise<boolean>((resolve) => {
pending.resolve = resolve
})
pendingLocalRecordingFinishes.set(sessionId, pending)
bridge.finishLocalAudioRecording(sessionId, totalBytes)
window.setTimeout(() => {
if (pendingLocalRecordingFinishes.get(sessionId) !== pending) return
pendingLocalRecordingFinishes.delete(sessionId)
pending.resolve(false)
}, 180000)
return pending.promise
}
function bytesToBase64(bytes: Uint8Array): string {
let binary = ''
for (let index = 0; index < bytes.length; index += 1) {
binary += String.fromCharCode(bytes[index])
}
return window.btoa(binary)
}
async function sendLocalRecordingBlob(blob: Blob, sessionId: string): Promise<void> {
if (!blob.size || sessionId !== localRecordingSessionId) return
const bridge = window.qtVideoBridge
if (!bridge || typeof bridge.appendLocalAudioChunk !== 'function') {
throw new Error('桌面端本地录音分片通道不可用')
}
const bytes = new Uint8Array(await blob.arrayBuffer())
const maxChunkBytes = 8 * 1024
for (let offset = 0; offset < bytes.length; offset += maxChunkBytes) {
const chunk = bytes.subarray(offset, Math.min(offset + maxChunkBytes, bytes.length))
bridge.appendLocalAudioChunk(
sessionId,
localRecordingChunkSequence,
bytesToBase64(chunk),
)
localRecordingChunkSequence += 1
localRecordingBytes += chunk.length
}
}
function getTrtcAudioCloud(): TrtcAudioCloud | null {
const engine = TUICallKitAPI.getTUICallEngineInstance?.()
const cloud = engine?.getTRTCCloudInstance?.() as Partial<TrtcAudioCloud> | null
return cloud ? cloud as TrtcAudioCloud : null
}
function attachLocalRecordingTrack(
track: MediaStreamTrack | null | undefined,
sourceKind: 'doctor' | 'patient' | 'unknown' = 'unknown',
): boolean {
const context = localAudioContext
const destination = localAudioDestination
if (
!context
|| !destination
|| !track
|| track.kind !== 'audio'
|| track.readyState === 'ended'
) return false
if (sourceKind === 'doctor') localAudioHasDoctorSource = true
if (sourceKind === 'patient') localAudioHasPatientSource = true
if (localAudioTrackIds.has(track.id)) return true
const source = context.createMediaStreamSource(new MediaStream([track]))
source.connect(destination)
localAudioSources.push(source)
localAudioTrackIds.add(track.id)
localRecordingAttachedSourceCount += 1
track.addEventListener('ended', () => localAudioTrackIds.delete(track.id), { once: true })
return true
}
function attachCurrentCallAudioTracks(cloud: TrtcAudioCloud | null): void {
if (!activeConfig) return
if (typeof cloud?.getAudioTrack === 'function') {
try {
attachLocalRecordingTrack(cloud.getAudioTrack({ processed: true }), 'doctor')
} catch {
try {
attachLocalRecordingTrack(cloud.getAudioTrack(), 'doctor')
} catch {
// The rendered media elements below remain a supported fallback.
}
}
try {
attachLocalRecordingTrack(cloud.getAudioTrack({
userId: activeConfig.targetUserId,
processed: true,
}), 'patient')
} catch {
try {
attachLocalRecordingTrack(cloud.getAudioTrack(activeConfig.targetUserId), 'patient')
} catch {
// Remote audio can become available a few frames after connected.
}
}
}
for (const media of document.querySelectorAll<HTMLMediaElement>('video, audio')) {
const stream = media.srcObject
if (!(stream instanceof MediaStream)) continue
const sourceKind = media.muted ? 'doctor' : 'patient'
for (const track of stream.getAudioTracks()) {
attachLocalRecordingTrack(track, sourceKind)
}
}
}
async function attachDoctorMicrophoneFallback(): Promise<void> {
if (localAudioHasDoctorSource || !navigator.mediaDevices?.getUserMedia) return
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
},
video: false,
})
const tracks = stream.getAudioTracks()
localAudioOwnedTracks.push(...tracks)
for (const track of tracks) attachLocalRecordingTrack(track, 'doctor')
}
async function waitForCallAudioTracks(
cloud: TrtcAudioCloud | null,
sessionId: string,
timeoutMs = 7000,
): Promise<void> {
const startedAt = Date.now()
let microphoneAttempted = false
while (Date.now() - startedAt < timeoutMs) {
if (sessionId !== localRecordingSessionId || endNotified) {
throw new Error('视频已结束,本机录音未启动')
}
attachCurrentCallAudioTracks(cloud)
const elapsed = Date.now() - startedAt
if (!localAudioHasDoctorSource && !microphoneAttempted && elapsed >= 800) {
microphoneAttempted = true
try {
await attachDoctorMicrophoneFallback()
} catch (error) {
console.warn(
'[doctor-consultation] 本机麦克风录音兜底不可用',
safeErrorMessage(error),
)
}
}
if (
localAudioTrackIds.size > 0
&& (localAudioHasDoctorSource && localAudioHasPatientSource || elapsed >= 2200)
) return
await new Promise((resolve) => window.setTimeout(resolve, 160))
}
if (localAudioTrackIds.size <= 0) {
throw new Error('未检测到医生或患者的实时语音音轨,请检查麦克风权限')
}
}
function startCallAudioDiscovery(cloud: TrtcAudioCloud | null): void {
if (localAudioDiscoveryTimer !== null) window.clearInterval(localAudioDiscoveryTimer)
localAudioDiscoveryTimer = window.setInterval(() => {
attachCurrentCallAudioTracks(cloud)
}, 500)
}
async function cleanupLocalRecordingGraph(): Promise<void> {
if (localAudioDiscoveryTimer !== null) {
window.clearInterval(localAudioDiscoveryTimer)
localAudioDiscoveryTimer = null
}
if (localAudioCloud && localAudioTrackHandler && typeof localAudioCloud.off === 'function') {
try {
localAudioCloud.off('track', localAudioTrackHandler)
} catch {
// The call engine may already have released its event dispatcher.
}
}
localAudioCloud = null
localAudioTrackHandler = null
for (const source of localAudioSources) {
try {
source.disconnect()
} catch {
// A closed AudioContext has already disconnected its graph.
}
}
localAudioSources = []
localAudioTrackIds.clear()
for (const track of localAudioOwnedTracks) track.stop()
localAudioOwnedTracks = []
localAudioHasDoctorSource = false
localAudioHasPatientSource = false
localRecordingAttachedSourceCount = 0
const context = localAudioContext
localAudioContext = null
localAudioDestination = null
if (context && context.state !== 'closed') {
try {
await context.close()
} catch {
// Closing the consultation must not be blocked by a released device.
}
}
localRecorder = null
}
async function performStartLocalRecording(): Promise<void> {
if (!activeConfig || phase.value !== 'connected' || endNotified) {
throw new Error('视频接通后才能启动本机录音')
}
if (!window.qtVideoBridge?.startLocalAudioRecording) {
throw new Error('桌面端本机录音存储通道不可用')
}
const mimeType = selectLocalRecordingMimeType()
if (!mimeType) throw new Error('当前浏览器不支持 Opus 本机录音')
localRecordingState.value = 'starting'
localRecordingFatalError = ''
localRecordingChunkSequence = 0
localRecordingBytes = 0
localRecordingChunkChain = Promise.resolve()
localRecordingAttachedSourceCount = 0
localAudioHasDoctorSource = false
localAudioHasPatientSource = false
const sessionId = newLocalRecordingSessionId()
localRecordingSessionId = sessionId
localRecordingMimeType = mimeType
try {
const AudioContextConstructor = window.AudioContext
const context = new AudioContextConstructor()
localAudioContext = context
localAudioDestination = context.createMediaStreamDestination()
const cloud = getTrtcAudioCloud()
localAudioCloud = cloud
localAudioTrackHandler = (event) => attachLocalRecordingTrack(
event.track,
event.userId === activeConfig?.userID
? 'doctor'
: event.userId === activeConfig?.targetUserId
? 'patient'
: 'unknown',
)
if (typeof cloud?.on === 'function') cloud.on('track', localAudioTrackHandler)
if (context.state === 'suspended') await context.resume()
await waitForCallAudioTracks(cloud, sessionId)
startCallAudioDiscovery(cloud)
const storageReady = await requestLocalRecordingStart(sessionId, mimeType)
if (!storageReady || sessionId !== localRecordingSessionId || endNotified) {
throw new Error(notice.value || '服务端未能创建本机录音临时文件')
}
const destination = localAudioDestination
if (!destination) throw new Error('本机录音混音器未就绪')
if (localRecordingAttachedSourceCount <= 0) {
throw new Error('本机录音没有连接到医生或患者语音')
}
const recorder = new MediaRecorder(destination.stream, { mimeType })
localRecorder = recorder
recorder.addEventListener('dataavailable', (event) => {
if (!event.data.size || sessionId !== localRecordingSessionId) return
localRecordingChunkChain = localRecordingChunkChain
.then(() => sendLocalRecordingBlob(event.data, sessionId))
.catch((error) => {
localRecordingFatalError = safeErrorMessage(error, '本机录音分片保存失败')
localRecordingState.value = 'error'
notice.value = localRecordingFatalError
window.qtVideoBridge?.abortLocalAudioRecording?.(sessionId)
if (recorder.state !== 'inactive') recorder.stop()
})
})
recorder.addEventListener('error', (event) => {
localRecordingFatalError = safeErrorMessage(
(event as ErrorEvent).error,
'浏览器本机录音发生错误',
)
localRecordingState.value = 'error'
notice.value = localRecordingFatalError
})
recorder.start(1000)
localRecordingState.value = 'recording'
notice.value = '腾讯云混流视频、本机语音录音和实时转写均已启动'
} catch (error) {
window.qtVideoBridge?.abortLocalAudioRecording?.(sessionId)
await cleanupLocalRecordingGraph()
if (sessionId === localRecordingSessionId) {
localRecordingSessionId = ''
localRecordingMimeType = ''
localRecordingState.value = 'error'
}
throw new Error(safeErrorMessage(error, '本机语音录音启动失败'))
}
}
function startLocalRecording(): Promise<void> {
if (localRecordingStartPromise) return localRecordingStartPromise
if (localRecordingState.value === 'recording') return Promise.resolve()
const operation = performStartLocalRecording()
const tracked = operation.finally(() => {
if (localRecordingStartPromise === tracked) localRecordingStartPromise = null
})
localRecordingStartPromise = tracked
return tracked
}
async function performStopLocalRecording(): Promise<void> {
const startInFlight = localRecordingStartPromise
if (startInFlight) {
try {
await startInFlight
} catch {
return
}
}
const sessionId = localRecordingSessionId
const recorder = localRecorder
if (!sessionId || !recorder) {
if (localRecordingState.value !== 'error') localRecordingState.value = 'idle'
return
}
localRecordingState.value = 'stopping'
try {
if (recorder.state !== 'inactive') {
await new Promise<void>((resolve) => {
recorder.addEventListener('stop', () => resolve(), { once: true })
recorder.stop()
})
}
await localRecordingChunkChain
if (localRecordingFatalError) throw new Error(localRecordingFatalError)
if (localRecordingAttachedSourceCount <= 0) {
throw new Error('本机录音没有连接到有效语音音轨')
}
if (localRecordingBytes < 1024) {
throw new Error('本机录音文件为空或不完整,已阻止上传空文件')
}
localRecordingState.value = 'uploading'
notice.value = '正在把本机语音录音上传到 COS…'
const persisted = await requestLocalRecordingFinish(sessionId, localRecordingBytes)
if (!persisted) throw new Error(notice.value || '本机语音录音上传 COS 失败')
localRecordingState.value = 'idle'
notice.value = '混流视频、本机录音和转写文字均已归档到本次通话记录'
} catch (error) {
window.qtVideoBridge?.abortLocalAudioRecording?.(sessionId)
localRecordingState.value = 'error'
throw new Error(safeErrorMessage(error, '本机语音录音保存失败'))
} finally {
await cleanupLocalRecordingGraph()
if (sessionId === localRecordingSessionId) {
localRecordingSessionId = ''
localRecordingMimeType = ''
}
}
}
function stopLocalRecording(): Promise<void> {
if (localRecordingStopPromise) return localRecordingStopPromise
if (localRecordingState.value === 'idle' && !localRecordingSessionId) return Promise.resolve()
const operation = performStopLocalRecording()
const tracked = operation.finally(() => {
if (localRecordingStopPromise === tracked) localRecordingStopPromise = null
})
localRecordingStopPromise = tracked
return tracked
}
function localRecordingResult(
operation: 'start' | 'chunk' | 'finish',
sessionId: string,
_sequence: number,
ok: boolean,
message: string,
): void {
if (operation === 'start') {
const pending = pendingLocalRecordingStarts.get(sessionId)
if (!pending) return
pendingLocalRecordingStarts.delete(sessionId)
if (message && sessionId === localRecordingSessionId) notice.value = message
pending.resolve(Boolean(ok))
} else if (operation === 'finish') {
const pending = pendingLocalRecordingFinishes.get(sessionId)
if (!pending) return
pendingLocalRecordingFinishes.delete(sessionId)
if (message && sessionId === localRecordingSessionId) notice.value = message
pending.resolve(Boolean(ok))
} else if (!ok && sessionId === localRecordingSessionId) {
localRecordingFatalError = message || '本机录音分片保存失败'
localRecordingState.value = 'error'
notice.value = localRecordingFatalError
window.qtVideoBridge?.abortLocalAudioRecording?.(sessionId)
if (localRecorder && localRecorder.state !== 'inactive') localRecorder.stop()
}
}
function newTranscriptionSessionId(): string {
const random = window.crypto?.randomUUID?.()
return random ? `call-${random}` : `call-${Date.now()}-${Math.random().toString(16).slice(2)}`
// Keep this identity within 32 ASCII characters. Some upgraded deployments
// still have the legacy varchar(32) column; a UUID with hyphens was silently
// truncated there, so the first transcript segment no longer matched the
// session that startCallTranscription had acknowledged.
const random = window.crypto?.randomUUID?.().replaceAll('-', '')
const entropy = random || `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`
return `tr-${entropy.replace(/[^a-zA-Z0-9]/g, '').slice(0, 28)}`
}
function requestTranscriptionStart(sessionId: string): Promise<boolean> {
@@ -615,19 +1150,59 @@ function getTranscriberManager(): RealtimeTranscriberManager {
return manager as RealtimeTranscriberManager
}
function clearLiveCaptions(): void {
if (liveCaptionClearTimer !== null) {
window.clearTimeout(liveCaptionClearTimer)
liveCaptionClearTimer = null
}
liveCaptions.value = []
}
function showLiveCaption(message: RealtimeTranscriberMessage): void {
if (!activeConfig) return
const text = String(message.sourceText ?? '').trim()
if (!text) return
const speakerUserId = String(message.speakerUserId ?? '').trim()
const segmentId = String(message.segmentId ?? '').trim()
const id = segmentId || `speaker-${speakerUserId || 'unknown'}`
const speaker = speakerUserId === activeConfig.userID
? '医生'
: speakerUserId === activeConfig.targetUserId
? patientName.value || '患者'
: '对话'
const caption: UiLiveCaption = {
id,
speaker,
text: text.slice(0, 500),
completed: message.isCompleted === true,
}
const previous = liveCaptions.value.filter((item) => item.id !== id)
liveCaptions.value = [...previous, caption].slice(-2)
if (liveCaptionClearTimer !== null) window.clearTimeout(liveCaptionClearTimer)
liveCaptionClearTimer = window.setTimeout(() => {
liveCaptions.value = []
liveCaptionClearTimer = null
}, message.isCompleted === true ? 9000 : 5000)
}
function handleTranscriberMessage(
_roomId: string | number,
roomId: string | number,
message: RealtimeTranscriberMessage,
): void {
// The realtime transcriber always identifies the active TRTC room. Use it
// as a second authoritative source because some CallKit versions clear the
// ROOM_ID store before the desktop host has persisted it.
observeRoomId(roomId)
if (
!activeConfig
|| !['recording', 'stopping'].includes(transcriptionState.value)
|| !transcriptionSessionId
|| message.isCompleted !== true
) return
lastTranscriberMessageAt = Date.now()
const segmentId = String(message.segmentId ?? '').trim()
const text = String(message.sourceText ?? '').trim()
showLiveCaption(message)
if (message.isCompleted !== true) return
if (
!segmentId
|| acknowledgedSegmentIds.has(segmentId)
@@ -666,8 +1241,11 @@ function subscribeTranscriber(): {
const manager = getTranscriberManager()
const listener: RealtimeTranscriberListener = {
onReceiveTranscriberMessage: handleTranscriberMessage,
onRealtimeTranscriberStarted: () => undefined,
onRealtimeTranscriberStopped: (_roomId, robotId) => {
onRealtimeTranscriberStarted: (roomId) => {
observeRoomId(roomId)
},
onRealtimeTranscriberStopped: (roomId, robotId) => {
observeRoomId(roomId)
if (robotId !== transcriberRobotId) return
transcriberRunning = false
transcriberStoppedAt = Date.now()
@@ -675,7 +1253,8 @@ function subscribeTranscriber(): {
void stopTranscription('partial', true)
}
},
onRealtimeTranscriberError: (_roomId, robotId, _error, errorMessage) => {
onRealtimeTranscriberError: (roomId, robotId, _error, errorMessage) => {
observeRoomId(roomId)
if (robotId !== transcriberRobotId || transcriptionState.value === 'stopping') return
notice.value = safeErrorMessage(new Error(errorMessage), '实时语音转写发生错误')
void stopTranscription('partial', true)
@@ -738,6 +1317,7 @@ async function performStartTranscription(): Promise<void> {
transcriptionSessionId = sessionId
acknowledgedSegmentIds.clear()
pendingSegments.clear()
clearLiveCaptions()
lastTranscriberMessageAt = Date.now()
transcriberStoppedAt = 0
notice.value = '正在准备录音文字存储…'
@@ -841,6 +1421,7 @@ async function stopTranscription(
transcriptionSessionId = ''
acknowledgedSegmentIds.clear()
pendingSegments.clear()
clearLiveCaptions()
})().finally(() => {
transcriptionStopPromise = null
})
@@ -889,46 +1470,83 @@ function notifyHangup(status = 'ended'): Promise<void> {
endNotified = true
phase.value = 'ended'
statusText.value = mode.value === 'chat' ? '视频通话已结束,IM 保持连接' : '视频问诊已结束'
appendVideoCallStatus('ended', '视频通话已结束')
hangupNotification = (async () => {
try {
if (transcriptionState.value !== 'idle') await stopTranscription('completed')
} catch (error) {
notice.value = safeErrorMessage(error, '录音文字收尾失败,请稍后检查面诊记录')
console.warn('[doctor-consultation] 录音文字收尾失败', notice.value)
} finally {
emit({
source: 'doctor-call',
event: 'hangup',
diagnosisId: activeConfig?.diagnosisId,
status,
})
const operations: Promise<void>[] = []
if (localRecordingState.value !== 'idle' || localRecordingSessionId) {
operations.push(stopLocalRecording())
}
if (transcriptionState.value !== 'idle') {
operations.push(stopTranscription('completed'))
}
const results = await Promise.allSettled(operations)
const failures = results
.filter((result): result is PromiseRejectedResult => result.status === 'rejected')
.map((result) => safeErrorMessage(result.reason, '录音资料收尾失败'))
if (failures.length) {
notice.value = failures.join('').slice(0, 400)
console.warn('[doctor-consultation] 录音资料收尾失败', notice.value)
}
emit({
source: 'doctor-call',
event: 'hangup',
diagnosisId: activeConfig?.diagnosisId,
status,
})
})()
return hangupNotification
}
function readRoomId(): string {
const raw = TUIStore.getData(StoreName.CALL, NAME.ROOM_ID)
function normalizeRoomId(raw: unknown): string {
if (raw === undefined || raw === null) return ''
const value = String(raw).trim()
return value && value !== '0' ? value : ''
}
function emitRoomId(): boolean {
const roomId = readRoomId()
if (!roomId || roomId === emittedRoomId) return Boolean(roomId)
function readRoomId(): string {
return normalizeRoomId(TUIStore.getData(StoreName.CALL, NAME.ROOM_ID))
}
function observeRoomId(rawRoomId: unknown): boolean {
if (!activeConfig || endNotified) return false
const roomId = normalizeRoomId(rawRoomId)
if (!roomId) return false
if (boundRoomId) return boundRoomId === roomId
const now = Date.now()
const retryDelay = Math.min(10_000, 750 * (2 ** Math.min(roomBindingAttempts, 4)))
if (pendingRoomId === roomId && now - roomBindingSentAt < retryDelay) return false
emittedRoomId = roomId
emit({ source: 'doctor-call', event: 'room', diagnosisId: activeConfig?.diagnosisId, roomId })
return true
pendingRoomId = roomId
roomBindingSentAt = now
roomBindingAttempts += 1
emit({ source: 'doctor-call', event: 'room', diagnosisId: activeConfig.diagnosisId, roomId })
return false
}
function emitRoomId(): boolean {
return observeRoomId(readRoomId())
}
async function pollRoomId(): Promise<void> {
for (let attempt = 0; attempt < 40 && activeConfig && !endNotified; attempt += 1) {
if (emitRoomId()) return
await new Promise((resolve) => window.setTimeout(resolve, 50))
const cycle = callCycleGeneration
while (activeConfig && !endNotified && cycle === callCycleGeneration) {
if (boundRoomId) return
emitRoomId()
await new Promise((resolve) => window.setTimeout(resolve, 250))
}
}
function handleRoomIdChanged(): void {
emitRoomId()
}
const roomIdWatchOptions = {
[NAME.ROOM_ID]: handleRoomIdChanged,
}
TUIStore.watch(StoreName.CALL, roomIdWatchOptions)
function handleStatusChanged(payload: unknown): void {
const value = payload && typeof payload === 'object'
? (payload as { newStatus?: unknown }).newStatus
@@ -939,6 +1557,7 @@ function handleStatusChanged(payload: unknown): void {
const cycle = callCycleGeneration
phase.value = 'connected'
statusText.value = '视频问诊进行中'
appendVideoCallStatus('connected', '视频通话已接通')
void pollRoomId()
if (autoTranscriptionAttemptedGeneration !== cycle) {
autoTranscriptionAttemptedGeneration = cycle
@@ -947,10 +1566,18 @@ function handleStatusChanged(payload: unknown): void {
notice.value = safeErrorMessage(error, '自动录音转文字启动失败')
})
}
if (autoLocalRecordingAttemptedGeneration !== cycle) {
autoLocalRecordingAttemptedGeneration = cycle
void startLocalRecording().catch((error) => {
if (cycle !== callCycleGeneration || endNotified) return
notice.value = safeErrorMessage(error, '本机语音录音启动失败')
})
}
} else if (status === 'calling' || status.startsWith('dialing')) {
if (endNotified || !activeConfig) return
phase.value = 'dialing'
statusText.value = '正在等待患者接听'
appendVideoCallStatus('dialing', '正在等待患者接听')
} else if (status === 'idle' && activeConfig && !starting) {
void notifyHangup(status)
}
@@ -998,8 +1625,13 @@ async function startVideo(): Promise<void> {
endNotified = false
phase.value = 'starting'
statusText.value = '正在创建安全视频通话'
appendVideoCallStatus('starting', '正在创建安全视频通话')
transcriptionState.value = 'idle'
transcriptionSessionId = ''
clearLiveCaptions()
localRecordingState.value = 'idle'
localRecordingSessionId = ''
localRecordingMimeType = ''
notice.value = ''
const allowed = await requestHostCallStart()
if (!allowed) throw new Error(notice.value || '服务器未能创建视频通话记录')
@@ -1012,7 +1644,12 @@ async function startVideo(): Promise<void> {
await nextTick()
phase.value = 'dialing'
statusText.value = '正在呼叫患者'
appendVideoCallStatus('dialing', '正在呼叫患者')
emittedRoomId = ''
pendingRoomId = ''
boundRoomId = ''
roomBindingSentAt = 0
roomBindingAttempts = 0
await TUICallKitAPI.calls({
userIDList: [activeConfig.targetUserId],
type: TUICallType.VIDEO_CALL,
@@ -1023,6 +1660,7 @@ async function startVideo(): Promise<void> {
const message = safeErrorMessage(error, '无法发起视频通话')
phase.value = 'error'
statusText.value = message
appendVideoCallStatus('failed', `视频通话发起失败:${message}`)
endNotified = true
emit({ source: 'doctor-call', event: 'error', diagnosisId: activeConfig.diagnosisId, message })
throw new Error(message)
@@ -1058,6 +1696,25 @@ function screenshotResult(ok: boolean, message: string): void {
notice.value = message || (ok ? '截图已保存到患者舌像资料' : '截图保存失败')
}
function recordingResult(ok: boolean, message: string): void {
notice.value = message || (ok
? '通话已自动录制,结束后将由云端完成 COS 文件收尾。'
: '自动云端录制未启动,请结束本次通话并检查 COS 配置。')
}
function roomBindingResult(roomId: string, ok: boolean, message: string): void {
const normalized = normalizeRoomId(roomId)
if (!normalized || (emittedRoomId && normalized !== emittedRoomId)) return
if (ok) {
boundRoomId = normalized
pendingRoomId = ''
} else if (pendingRoomId === normalized) {
// Keep the failed room pending so pollRoomId retries it with exponential
// backoff. The desktop lifecycle releases only failed claims.
}
recordingResult(ok, message)
}
async function open(config: DoctorCallConfig): Promise<void> {
if (activeConfig) await close()
activeConfig = normalizeConfig(config)
@@ -1070,14 +1727,20 @@ async function open(config: DoctorCallConfig): Promise<void> {
hangupNotification = null
callCycleGeneration += 1
autoTranscriptionAttemptedGeneration = -1
autoLocalRecordingAttemptedGeneration = -1
phase.value = 'ready'
transcriptionState.value = 'idle'
transcriptionSessionId = ''
clearLiveCaptions()
transcriptionGeneration += 1
transcriberRunning = false
transcriberRobotId = ''
acknowledgedSegmentIds.clear()
pendingSegments.clear()
localRecordingState.value = 'idle'
localRecordingSessionId = ''
localRecordingMimeType = ''
localRecordingFatalError = ''
notice.value = ''
if (activeConfig.mode === 'chat') {
try {
@@ -1103,6 +1766,12 @@ async function close(): Promise<void> {
for (const pending of pendingTranscriptionStops.values()) pending.resolve(false)
pendingTranscriptionStarts.clear()
pendingTranscriptionStops.clear()
clearLiveCaptions()
for (const pending of pendingLocalRecordingStarts.values()) pending.resolve(false)
for (const pending of pendingLocalRecordingFinishes.values()) pending.resolve(false)
pendingLocalRecordingStarts.clear()
pendingLocalRecordingFinishes.clear()
await cleanupLocalRecordingGraph()
await logoutChat()
activeConfig = null
phase.value = 'ended'
@@ -1114,8 +1783,11 @@ window.doctorConsultation = {
startVideo,
hangup,
hostCallReady,
recordingResult,
roomBindingResult,
screenshotResult,
transcriptionResult,
localRecordingResult,
}
window.doctorCall = { start: open, hangup }
initializeQtWebChannel()
@@ -1131,6 +1803,8 @@ createApp(App, {
notice: readonly(notice),
hasMoreMessages: readonly(hasMoreMessages),
transcriptionState: readonly(transcriptionState),
localRecordingState: readonly(localRecordingState),
liveCaptions: readonly(liveCaptions),
onSendText: sendText,
onSendAttachment: sendAttachment,
onLoadMore: () => loadMessages(true),
+156
View File
@@ -151,6 +151,57 @@ button:disabled { cursor: not-allowed; opacity: .55; }
margin: 12px 0;
}
.message-row--mine { align-items: flex-end; }
.message-row--call-status {
align-items: center;
margin: 16px 0;
}
.call-status-event {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 32px;
padding: 0 12px;
border: 1px solid #dce2f2;
border-radius: 999px;
color: #617092;
background: rgba(255, 255, 255, .92);
font-size: 12px;
box-shadow: 0 4px 14px rgba(17, 31, 70, .04);
}
.call-status-event__icon {
display: grid;
place-items: center;
width: 20px;
height: 20px;
border-radius: 7px;
color: #5761f4;
background: #eef0ff;
font-size: 11px;
}
.call-status-event strong {
color: #34425f;
font-weight: 700;
}
.call-status-event time {
color: #8b97b2;
font-variant-numeric: tabular-nums;
}
.call-status-event--connected {
border-color: #bdebdc;
background: #f2fbf7;
}
.call-status-event--connected .call-status-event__icon {
color: #11986f;
background: #dff7ee;
}
.call-status-event--failed {
border-color: #f3c8cf;
background: #fff5f6;
}
.call-status-event--failed .call-status-event__icon {
color: #c43f50;
background: #ffe6e9;
}
.message-meta { margin: 0 8px 5px; color: #8995a9; font-size: 11px; }
.message-bubble {
max-width: min(72%, 620px);
@@ -285,6 +336,41 @@ button:disabled { cursor: not-allowed; opacity: .55; }
}
.live-status .status-dot { width: 7px; height: 7px; margin: 0; box-shadow: none; }
.live-captions {
position: absolute;
z-index: 38;
left: 50%;
bottom: 92px;
display: grid;
gap: 7px;
width: min(820px, calc(100% - 360px));
transform: translateX(-50%);
pointer-events: none;
}
.live-captions p {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 10px;
align-items: start;
width: fit-content;
max-width: 100%;
margin: 0 auto;
padding: 8px 14px;
border: 1px solid rgba(255, 255, 255, .2);
border-radius: 10px;
color: #fff;
background: rgba(9, 13, 20, .78);
box-shadow: 0 6px 24px rgba(0, 0, 0, .2);
font-size: 16px;
line-height: 1.55;
backdrop-filter: blur(8px);
}
.live-captions strong {
color: #aeb8ff;
white-space: nowrap;
}
.live-captions span { min-width: 0; word-break: break-word; }
.video-actions {
position: absolute;
z-index: 40;
@@ -336,10 +422,80 @@ button:disabled { cursor: not-allowed; opacity: .55; }
}
.video-notice--error { border-color: rgba(242, 109, 109, .4); color: #ffe4e7; background: rgba(100, 31, 40, .88); }
.screenshot-dialog-backdrop {
position: absolute;
z-index: 120;
inset: 0;
display: grid;
place-items: center;
padding: 24px;
background: rgba(7, 11, 19, .76);
backdrop-filter: blur(5px);
}
.screenshot-dialog {
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto auto;
gap: 14px;
width: min(920px, 92vw);
max-height: calc(100vh - 48px);
padding: 20px;
overflow: hidden;
border: 1px solid #e2e7f4;
border-radius: 18px;
color: #111f46;
background: #fff;
box-shadow: 0 28px 90px rgba(4, 10, 27, .34);
}
.screenshot-dialog > header,
.screenshot-dialog > footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.screenshot-dialog h2 { margin: 2px 0 0; font-size: 20px; line-height: 1.35; }
.screenshot-dialog .eyebrow { margin: 0; color: #5761f4; }
.screenshot-dialog > header > button {
width: 36px;
height: 36px;
border: 0;
border-radius: 9px;
color: #7886aa;
background: #f2f4fb;
font-size: 24px;
}
.screenshot-preview-frame {
display: grid;
place-items: center;
min-height: 260px;
overflow: hidden;
border-radius: 12px;
background: #0b0f16;
}
.screenshot-preview-frame img {
display: block;
max-width: 100%;
max-height: min(62vh, 650px);
object-fit: contain;
}
.screenshot-dialog__hint { margin: 0; color: #617092; font-size: 13px; }
.screenshot-dialog > footer { justify-content: flex-end; }
.screenshot-dialog > footer button {
min-width: 112px;
padding: 10px 18px;
border-radius: 9px;
font-weight: 700;
}
.screenshot-cancel { border: 1px solid #dfe4f1; color: #3f4e75; background: #fff; }
.screenshot-confirm { border: 1px solid #5761f4; color: #fff; background: #5761f4; }
.screenshot-confirm:hover { background: #4c57e9; }
@media (max-width: 820px) {
.consultation-shell { min-width: 620px; }
.message-list { padding-inline: 18px; }
.message-bubble { max-width: 82%; }
.live-captions { width: calc(100% - 36px); bottom: 88px; }
.live-captions p { font-size: 14px; }
}
/* Doctor workstation blue-white subwindow contract. Video pixels remain on
@@ -4,9 +4,10 @@ namespace app\adminapi\controller\doctor;
use app\adminapi\controller\BaseAdminController;
use app\adminapi\lists\doctor\AppointmentLists;
use app\adminapi\logic\doctor\AppointmentLogic;
use app\adminapi\logic\doctor\DoctorNoteLogic;
use app\adminapi\validate\doctor\AppointmentValidate;
use app\adminapi\logic\doctor\AppointmentLogic;
use app\adminapi\logic\doctor\DoctorNoteLogic;
use app\adminapi\logic\tcm\DiagnosisLogic;
use app\adminapi\validate\doctor\AppointmentValidate;
/**
* 医生预约控制器
@@ -147,8 +148,11 @@ class AppointmentController extends BaseAdminController
public function reception()
{
$params = (new AppointmentValidate())->goCheck('reception');
$result = AppointmentLogic::reception($params);
return $this->data($result);
$result = AppointmentLogic::reception($params, $this->adminId, $this->adminInfo);
if (empty($result)) {
return $this->fail('预约记录不存在或无权访问');
}
return $this->data($result);
}
/**
@@ -165,10 +169,17 @@ class AppointmentController extends BaseAdminController
return $this->success('通知已发送');
}
public function addDoctorNote()
{
$params = (new AppointmentValidate())->post()->goCheck('addDoctorNote');
$params['doctor_id'] = $this->adminId;
public function addDoctorNote()
{
$params = (new AppointmentValidate())->post()->goCheck('addDoctorNote');
if (!DiagnosisLogic::canManageDiagnosis(
(int) $params['diagnosis_id'],
$this->adminId,
$this->adminInfo
)) {
return $this->fail(DiagnosisLogic::getError() ?: '诊单不存在或无权访问');
}
$params['doctor_id'] = $this->adminId;
$result = DoctorNoteLogic::addOrAppend($params);
if ($result === false) {
return $this->fail(DoctorNoteLogic::getError());
@@ -176,10 +187,17 @@ class AppointmentController extends BaseAdminController
return $this->success('保存成功');
}
public function doctorNotes()
{
$params = (new AppointmentValidate())->goCheck('doctorNotes');
return $this->data(DoctorNoteLogic::getByDiagnosis((int) $params['diagnosis_id']));
public function doctorNotes()
{
$params = (new AppointmentValidate())->goCheck('doctorNotes');
if (!DiagnosisLogic::canViewReadonlyDiagnosis(
(int) $params['diagnosis_id'],
$this->adminId,
$this->adminInfo
)) {
return $this->fail('诊单不存在或无权访问');
}
return $this->data(DoctorNoteLogic::getByDiagnosis((int) $params['diagnosis_id']));
}
public function deleteDoctorNoteImage()
@@ -21,7 +21,8 @@ use app\adminapi\logic\tcm\DiagnosisAiLogic;
use app\adminapi\logic\tcm\DiagnosisLogic;
use app\adminapi\logic\tcm\PatientAiReportLogic;
use app\adminapi\logic\tcm\TrackingNoteLogic;
use app\adminapi\validate\tcm\DiagnosisValidate;
use app\adminapi\service\AssistantSseProtocol;
use app\adminapi\validate\tcm\DiagnosisValidate;
use app\common\model\Order;
use app\common\model\WechatChatRecord;
@@ -168,10 +169,13 @@ class DiagnosisController extends BaseAdminController
*
* @return \think\response\Json
*/
public function trackingWindow()
{
$params = (new DiagnosisValidate())->goCheck('trackingWindow');
$result = DiagnosisLogic::fetchTrackingWindow(
public function trackingWindow()
{
$params = (new DiagnosisValidate())->goCheck('trackingWindow');
if (!DiagnosisLogic::canViewReadonlyDiagnosis((int) $params['id'], $this->adminId, $this->adminInfo)) {
return $this->fail(DiagnosisLogic::getError() ?: '诊单不存在或无权访问');
}
$result = DiagnosisLogic::fetchTrackingWindow(
(int) $params['id'],
(string) ($params['start_date'] ?? ''),
(string) ($params['end_date'] ?? '')
@@ -326,25 +330,15 @@ class DiagnosisController extends BaseAdminController
* @notes 获取通话签名
* @return \think\response\Json
*/
public function getCallSignature()
{
$params = $this->request->post();
if (empty($params['diagnosis_id'])) {
return $this->fail('诊单ID不能为空');
}
if (empty($params['patient_id'])) {
return $this->fail('患者ID不能为空');
}
// 传递当前管理员ID
$params['admin_id'] = $this->adminId;
$result = DiagnosisLogic::getCallSignature($params);
if ($result) {
return $this->data($result);
}
public function getCallSignature()
{
$params = (new DiagnosisValidate())->post()->goCheck('callIdentity');
$params['admin_id'] = (int) $this->adminId;
$result = DiagnosisLogic::getCallSignature($params, $this->adminInfo);
if ($result) {
return $this->data($result);
}
return $this->fail(DiagnosisLogic::getError());
}
@@ -453,11 +447,18 @@ class DiagnosisController extends BaseAdminController
set_time_limit(120);
$diagnosisId = (int)$this->request->get('diagnosis_id', 0);
if ($diagnosisId <= 0) {
return $this->fail('诊单ID不能为空');
}
$onlyArchived = (int)$this->request->get('only_archived', 0) === 1;
$result = DiagnosisLogic::getImChatMessagesForDiagnosis($diagnosisId, $onlyArchived);
if ($diagnosisId <= 0) {
return $this->fail('诊单ID不能为空');
}
if (!DiagnosisLogic::canViewReadonlyDiagnosis(
$diagnosisId,
(int) $this->adminId,
$this->adminInfo
)) {
return $this->fail(DiagnosisLogic::getError() ?: '诊单不存在或无权访问');
}
$onlyArchived = (int)$this->request->get('only_archived', 0) === 1;
$result = DiagnosisLogic::getImChatMessagesForDiagnosis($diagnosisId, $onlyArchived);
if ($result === false) {
return $this->fail(DiagnosisLogic::getError());
}
@@ -474,11 +475,18 @@ class DiagnosisController extends BaseAdminController
if ($diagnosisId <= 0) {
$diagnosisId = (int)$this->request->get('diagnosis_id', 0);
}
if ($diagnosisId <= 0) {
return $this->fail('诊单ID不能为空');
}
register_shutdown_function(function () use ($diagnosisId) {
if ($diagnosisId <= 0) {
return $this->fail('诊单ID不能为空');
}
if (!DiagnosisLogic::canViewReadonlyDiagnosis(
$diagnosisId,
(int) $this->adminId,
$this->adminInfo
)) {
return $this->fail(DiagnosisLogic::getError() ?: '诊单不存在或无权访问');
}
register_shutdown_function(function () use ($diagnosisId) {
try {
@set_time_limit(300);
ignore_user_abort(true);
@@ -521,9 +529,9 @@ class DiagnosisController extends BaseAdminController
* @notes 接通后发起腾讯云云端混流录制(需配置 CAM 与云点播)
* @return \think\response\Json
*/
public function startCloudRecording()
{
$params = $this->request->post();
public function startCloudRecording()
{
$params = $this->request->post();
if (empty($params['diagnosis_id'])) {
return $this->fail('诊单ID不能为空');
}
@@ -532,12 +540,32 @@ class DiagnosisController extends BaseAdminController
if ($result === false) {
return $this->fail(DiagnosisLogic::getError());
}
return $this->data($result);
}
/**
* @notes 浏览器本地上传通话录制后,关联到通话记录(合并 recording_urls
return $this->data($result);
}
/**
* @notes 分片上传本机通话录音或手动视频回放
* @return \think\response\Json
*/
public function uploadCallRecording()
{
$params = $this->request->post();
if (empty($params['diagnosis_id'])) {
return $this->fail('诊单ID不能为空');
}
$params['admin_id'] = (int)$this->adminId;
$result = DiagnosisLogic::uploadCallRecording($params);
if ($result === false) {
return $this->fail(DiagnosisLogic::getError());
}
return $this->data($result);
}
/**
* @notes 浏览器本地上传通话录制后,关联到通话记录(合并 recording_urls
* @return \think\response\Json
*/
public function attachLocalCallRecording()
@@ -865,9 +893,9 @@ class DiagnosisController extends BaseAdminController
/**
* @notes 基于当前授权诊单向 AI 助手提问,不接收客户端上游配置
*/
public function aiAssistant()
{
$params = (new DiagnosisValidate())->post()->goCheck('aiAssistant');
public function aiAssistant()
{
$params = (new DiagnosisValidate())->post()->goCheck('aiAssistant');
$result = DiagnosisAiLogic::assistant(
(int) $params['id'],
(string) $params['task'],
@@ -877,11 +905,113 @@ class DiagnosisController extends BaseAdminController
);
if ($result === null) {
return $this->fail(DiagnosisAiLogic::getError());
}
return $this->data($result);
}
/**
}
return $this->data($result);
}
/**
* @notes 基于当前授权诊单向 AI 助手提问(SSE 真流式)
*
* 路由:POST tcm.diagnosis/aiAssistantStream body: id, task, prompt
*/
public function aiAssistantStream()
{
// 登录由全局中间件完成;请求校验、旧助手权限与 DataScope 必须全部
// 在任何 SSE header / start 事件之前完成,失败时仍返回标准 JSON。
$params = (new DiagnosisValidate())->post()->goCheck('aiAssistant');
$prepared = DiagnosisAiLogic::prepareAssistant(
(int) $params['id'],
(string) $params['task'],
(string) ($params['prompt'] ?? ''),
$this->adminId,
$this->adminInfo
);
if ($prepared === null) {
return $this->fail(DiagnosisAiLogic::getError());
}
$this->runAssistantSse($prepared);
}
/** @param array<string,mixed> $prepared */
private function runAssistantSse(array $prepared): void
{
while (ob_get_level() > 0) {
ob_end_clean();
}
@ini_set('output_buffering', 'off');
@ini_set('zlib.output_compression', '0');
ignore_user_abort(true);
if (function_exists('apache_setenv')) {
@apache_setenv('no-gzip', '1');
}
header('Content-Type: text/event-stream; charset=utf-8');
header('Cache-Control: no-cache, no-transform');
header('Connection: keep-alive');
header('X-Accel-Buffering: no');
header('Content-Encoding: none');
echo ':' . str_repeat(' ', 2048) . "\n\n";
$this->flushSseOutput();
$protocol = new AssistantSseProtocol();
$emit = function (string $event, array $payload) use ($protocol): bool {
if ($protocol->isTerminal() || connection_aborted()) {
return false;
}
$encoded = $protocol->encode($event, $payload);
if ($encoded === null) {
return false;
}
echo $encoded;
$this->flushSseOutput();
return !connection_aborted();
};
$emit('start', [
'task' => (string) ($prepared['task'] ?? ''),
'model_key' => (string) ($prepared['profile'] ?? ''),
'message' => '已连接,正在生成…',
]);
try {
$result = DiagnosisAiLogic::streamPreparedAssistant(
$prepared,
static fn (string $delta): bool => $emit('delta', ['text' => $delta]),
static fn (): bool => connection_aborted() === 1
);
if (connection_aborted()) {
exit;
}
if ($result === null) {
$emit('error', [
'code' => 'AI_ASSISTANT_FAILED',
'message' => 'AI 助手暂时不可用,请稍后重试',
]);
} else {
$emit('done', $result);
}
} catch (\Throwable $e) {
$emit('error', [
'code' => 'AI_ASSISTANT_FAILED',
'message' => 'AI 助手暂时不可用,请稍后重试',
]);
}
exit;
}
private function flushSseOutput(): void
{
if (function_exists('ob_flush')) {
@ob_flush();
}
flush();
}
/**
* @notes 对当前授权诊单生成一次结构化 AI 智能分析,仅接受 qwen/openai 模型键
*/
public function aiAnalysis()
@@ -137,9 +137,12 @@ class PrescriptionController extends BaseAdminController
$diagnosisId = (int)($this->request->get('diagnosis_id') ?? 0);
if (!$diagnosisId) {
return $this->fail('诊单ID不能为空');
}
$list = PrescriptionLogic::listByDiagnosis($diagnosisId);
return $this->data($list);
}
$list = PrescriptionLogic::listByDiagnosis($diagnosisId, (int) $this->adminId, $this->adminInfo);
if (PrescriptionLogic::getError() !== '') {
return $this->fail(PrescriptionLogic::getError());
}
return $this->data($list);
}
/**
@@ -74,11 +74,13 @@ class AuthMiddleware
// 全部路由
$allUri = $this->formatUrl($adminAuthCache->getAllUri());
// 判断该当前访问的uri是否存在,不存在无需验证
if (!in_array($accessUri, $allUri, true)
&& !PharmacyUploadPermissionAlias::allows($accessUri, $allUri)) {
return $next($request);
}
// 判断该当前访问的uri是否存在,不存在无需验证
if (!in_array($accessUri, $allUri, true)
&& !PharmacyUploadPermissionAlias::allows($accessUri, $allUri)
&& !($accessUri === 'tcm.diagnosis/aiassistantstream'
&& in_array('tcm.diagnosis/aiassistant', $allUri, true))) {
return $next($request);
}
// 当前管理员拥有的路由权限
$AdminUris = $adminAuthCache->getAdminUri() ?? [];
@@ -109,9 +111,16 @@ class AuthMiddleware
* 日常记录权限域:前端统一收口到 tcm.diagnosis/dailyRecord
* 但待办/跟踪备注接口仍保留历史路由名,故在鉴权层做精确别名映射。
*/
private function matchPermissionAlias(string $accessUri, array $adminUris): bool
{
if (PharmacyUploadPermissionAlias::isControlled($accessUri)) {
private function matchPermissionAlias(string $accessUri, array $adminUris): bool
{
// AI 助手流式端点与旧 blocking 端点共享同一权限;别名同时用于
// allUri 判定和当前管理员权限判定,确保在 SSE headers 前完成鉴权。
if ($accessUri === 'tcm.diagnosis/aiassistantstream'
&& in_array('tcm.diagnosis/aiassistant', $adminUris, true)) {
return true;
}
if (PharmacyUploadPermissionAlias::isControlled($accessUri)) {
return PharmacyUploadPermissionAlias::allows($accessUri, $adminUris);
}
@@ -215,7 +215,7 @@ class AppointmentLists extends BaseAdminDataLists implements ListsSearchInterfac
->leftJoin('tcm_diagnosis u', 'a.patient_id = u.id')
->leftJoin('admin ad', 'a.doctor_id = ad.id')
->leftJoin('admin asst', 'u.assistant_id = asst.id')
->field('a.*, u.patient_name as patient_name, u.phone as patient_phone, u.gender as gender, u.age as age, u.weight as weight, u.height as height, u.assistant_id as assistant_id, ad.name as doctor_name, asst.name as assistant_name, u.id as diagnosis_id, a.assistant_id as appointment_assistant_id');
->field('a.*, u.patient_id AS source_patient_id, u.patient_name as patient_name, u.phone as patient_phone, u.gender as gender, u.age as age, u.weight as weight, u.height as height, u.assistant_id as assistant_id, ad.name as doctor_name, asst.name as assistant_name, u.id as diagnosis_id, a.assistant_id as appointment_assistant_id');
if ($this->searchWhere !== []) {
$query->where($this->searchWhere);
}
@@ -293,20 +293,32 @@ class AppointmentLists extends BaseAdminDataLists implements ListsSearchInterfac
$prescribedDiagnosisIds = $rxQ->column('diagnosis_id');
$prescribedDiagnosisIds = array_flip($prescribedDiagnosisIds ?: []);
}
// 当前页预约关联的处方(按 appointment_id取最新一条):用于「开方/查看」与审核状态
// 当前页预约关联的处方(按 appointment_id优先最新未作废,否则最新一条):
// 用于本次挂号的「开方/编辑/查看」与审核状态。
$appointmentIds = array_filter(array_map('intval', array_column($lists, 'id')));
$rxByAppointmentId = [];
$fallbackRxByAppointmentId = [];
if (!empty($appointmentIds)) {
$rxRows = Prescription::whereIn('appointment_id', $appointmentIds)
->whereNull('delete_time')
->where('void_status', 0)
->order('id', 'desc')
->field(['id', 'appointment_id', 'audit_status', 'void_status', 'is_system_auto'])
->select()
->toArray();
foreach ($rxRows as $rx) {
$aid = (int) ($rx['appointment_id'] ?? 0);
if ($aid > 0 && !isset($rxByAppointmentId[$aid])) {
if ($aid <= 0) {
continue;
}
if (!isset($fallbackRxByAppointmentId[$aid])) {
$fallbackRxByAppointmentId[$aid] = $rx;
}
if ((int) ($rx['void_status'] ?? 0) === 0 && !isset($rxByAppointmentId[$aid])) {
$rxByAppointmentId[$aid] = $rx;
}
}
foreach ($fallbackRxByAppointmentId as $aid => $rx) {
if (!isset($rxByAppointmentId[$aid])) {
$rxByAppointmentId[$aid] = $rx;
}
}
@@ -353,6 +365,8 @@ class AppointmentLists extends BaseAdminDataLists implements ListsSearchInterfac
$apptId = (int) ($item['id'] ?? 0);
$apptRx = $rxByAppointmentId[$apptId] ?? null;
$item['current_has_prescription'] = $apptRx !== null ? 1 : 0;
$item['current_prescription_id'] = $apptRx !== null ? (int) ($apptRx['id'] ?? 0) : 0;
$item['prescription_audit_status'] = $apptRx !== null ? (int) ($apptRx['audit_status'] ?? -1) : -1;
$item['prescription_void_status'] = $apptRx !== null ? (int) ($apptRx['void_status'] ?? 0) : 0;
$item['prescription_is_system_auto'] = $apptRx !== null ? (int) ($apptRx['is_system_auto'] ?? 0) : 0;
@@ -282,17 +282,51 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface
// 关联是否开方:诊单是否有处方记录
$diagnosisIds = array_column($lists, 'id');
$prescriptionMap = [];
if (!empty($diagnosisIds)) {
$prescriptionTbl = (new Prescription())->getTable();
$prescribedIds = Prescription::whereIn('diagnosis_id', $diagnosisIds)
->whereNull('delete_time')
->column('diagnosis_id');
$prescriptionMap = array_fill_keys($prescribedIds, 1);
}
foreach ($lists as &$item) {
$item['has_prescription'] = isset($prescriptionMap[$item['id']]) ? 1 : 0;
}
$prescriptionMap = [];
if (!empty($diagnosisIds)) {
$prescribedIds = Prescription::whereIn('diagnosis_id', $diagnosisIds)
->whereNull('delete_time')
->column('diagnosis_id');
$prescriptionMap = array_fill_keys($prescribedIds, 1);
}
foreach ($lists as &$item) {
$item['has_prescription'] = isset($prescriptionMap[$item['id']]) ? 1 : 0;
}
// 当前操作必须只认当前挂号的处方。has_prescription 继续表示诊单历史上曾开方,
// 不能再用它驱动本次挂号的“开方/编辑/查看”按钮。
$currentRxByAppointment = [];
$fallbackRxByAppointment = [];
$appointmentIds = array_values(array_filter(array_unique(array_map(
'intval',
array_column($lists, 'appointment_id')
))));
if ($appointmentIds !== []) {
$currentRxRows = Prescription::whereIn('appointment_id', $appointmentIds)
->whereNull('delete_time')
->field(['id', 'appointment_id', 'audit_status', 'void_status'])
->order('id', 'desc')
->select()
->toArray();
foreach ($currentRxRows as $rx) {
$appointmentId = (int) ($rx['appointment_id'] ?? 0);
if ($appointmentId <= 0) {
continue;
}
if (!isset($fallbackRxByAppointment[$appointmentId])) {
$fallbackRxByAppointment[$appointmentId] = $rx;
}
if ((int) ($rx['void_status'] ?? 0) === 0
&& !isset($currentRxByAppointment[$appointmentId])) {
$currentRxByAppointment[$appointmentId] = $rx;
}
}
foreach ($fallbackRxByAppointment as $appointmentId => $rx) {
if (!isset($currentRxByAppointment[$appointmentId])) {
$currentRxByAppointment[$appointmentId] = $rx;
}
}
}
// 复诊展示:业务上「已开方」即算有复诊,取该诊单下最新一条处方的时间与医师(优先未作废)
$followupByDiag = [];
@@ -346,17 +380,21 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface
];
}
}
foreach ($lists as &$item) {
$did = (int) $item['id'];
$fu = $followupByDiag[$did] ?? null;
$item['followup_time_text'] = ($item['has_prescription'] && $fu) ? ($fu['time_text'] ?? '') : '';
$item['followup_doctor_name'] = ($item['has_prescription'] && $fu) ? ($fu['doctor_name'] ?? '—') : '';
$item['followup_rx_voided'] = ($item['has_prescription'] && $fu && !empty($fu['voided'])) ? 1 : 0;
$item['prescription_audit_status'] = ($item['has_prescription'] && $fu) ? (int) ($fu['audit_status'] ?? -1) : -1;
$item['prescription_void_status'] = ($item['has_prescription'] && $fu) ? (int) ($fu['void_status'] ?? 0) : 0;
// 开方次数即复诊次数:1 张处方 = 第 1 次复诊,以此类推
$item['followup_prescription_count'] = (int) ($rxCountByDiag[$did] ?? 0);
}
foreach ($lists as &$item) {
$did = (int) $item['id'];
$fu = $followupByDiag[$did] ?? null;
$currentAppointmentId = (int) ($item['appointment_id'] ?? 0);
$currentRx = $currentRxByAppointment[$currentAppointmentId] ?? null;
$item['followup_time_text'] = ($item['has_prescription'] && $fu) ? ($fu['time_text'] ?? '') : '';
$item['followup_doctor_name'] = ($item['has_prescription'] && $fu) ? ($fu['doctor_name'] ?? '—') : '';
$item['followup_rx_voided'] = ($item['has_prescription'] && $fu && !empty($fu['voided'])) ? 1 : 0;
$item['current_has_prescription'] = $currentRx !== null ? 1 : 0;
$item['current_prescription_id'] = $currentRx !== null ? (int) ($currentRx['id'] ?? 0) : 0;
$item['prescription_audit_status'] = $currentRx !== null ? (int) ($currentRx['audit_status'] ?? -1) : -1;
$item['prescription_void_status'] = $currentRx !== null ? (int) ($currentRx['void_status'] ?? 0) : 0;
// 开方次数即复诊次数:1 张处方 = 第 1 次复诊,以此类推
$item['followup_prescription_count'] = (int) ($rxCountByDiag[$did] ?? 0);
}
// 最近一条通话记录状态(列表行展示:通话中 / 已结束等)
$latestCallByDiag = [];
@@ -30,9 +30,8 @@ use think\facade\Log;
*/
class AppointmentLogic extends BaseLogic
{
/** 仅这些字典名称需填写 channel_source_detail(与 admin 预约弹窗白名单一致,按 name 全等) */
/** 自媒体4H/4Q 无需补充;仅这些字典名称需填写 channel_source_detail(与 admin 预约弹窗白名单一致,按 name 全等) */
private const CHANNEL_NAMES_REQUIRING_SOURCE_DETAIL = [
'自媒体4H',
'自媒体3Q',
'自媒体3H',
'自媒体2H',
@@ -627,10 +626,35 @@ class AppointmentLogic extends BaseLogic
* @param array $params
* @return array
*/
public static function reception(array $params): array
{
// 1) 挂号详情(已包含 patient_name / patient_phone / doctor_name / status_desc 等)
$appointment = self::detail($params);
public static function reception(array $params, int $adminId, array $adminInfo): array
{
self::$error = '';
$appointmentId = (int) ($params['id'] ?? 0);
$appointmentRow = $appointmentId > 0
? Appointment::where('id', $appointmentId)->field(['id', 'patient_id', 'doctor_id'])->find()
: null;
if (!$appointmentRow) {
self::setError('预约记录不存在或无权访问');
return [];
}
$diagnosisRow = Diagnosis::where('id', (int) $appointmentRow->patient_id)
->whereNull('delete_time')
->field(['id', 'assistant_id'])
->find();
if (!self::appointmentRowManageableByAdmin(
$appointmentRow,
$diagnosisRow ?: null,
$adminId,
$adminInfo
)) {
self::setError('预约记录不存在或无权访问');
return [];
}
// 1) 挂号详情(已包含 patient_name / patient_phone / doctor_name / status_desc 等)
$appointment = self::detail($params);
if (empty($appointment)) {
return [];
}
@@ -883,40 +907,62 @@ class AppointmentLogic extends BaseLogic
/**
* AppointmentLists 一致的可见性(不含 progress_board / diag_scope_relax
*/
private static function appointmentRowManageableByAdmin(
Appointment $appointment,
?Diagnosis $diag,
int $adminId,
array $adminInfo
): bool {
$roleIds = array_map('intval', AdminRole::where('admin_id', $adminId)->column('role_id'));
if (in_array(1, $roleIds, true) && (int) $appointment->doctor_id !== $adminId) {
return false;
}
if (in_array(2, $roleIds, true)) {
$asst = $diag ? (int) $diag->assistant_id : 0;
if ($asst !== $adminId) {
return false;
}
}
if (!DataScopeService::isEnabled()) {
return true;
}
$ids = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
if ($ids === []) {
return false;
}
if ($ids === null) {
return true;
}
$docId = (int) $appointment->doctor_id;
$asstId = $diag ? (int) $diag->assistant_id : 0;
return in_array($docId, $ids, true)
|| ($asstId > 0 && in_array($asstId, $ids, true));
}
private static function appointmentRowManageableByAdmin(
Appointment $appointment,
?Diagnosis $diag,
int $adminId,
array $adminInfo
): bool {
$docId = (int) $appointment->doctor_id;
$asstId = $diag ? (int) $diag->assistant_id : 0;
$isRoot = !empty($adminInfo['root']) && (int) $adminInfo['root'] === 1;
$roleIds = $isRoot
? []
: array_map('intval', AdminRole::where('admin_id', $adminId)->column('role_id'));
$visibleIds = null;
if (!$isRoot && DataScopeService::isEnabled()) {
$visibleIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
}
return self::appointmentRowManageableForScope(
$docId,
$asstId,
$adminId,
$roleIds,
$visibleIds,
$isRoot
);
}
/**
* @param array<int, int> $roleIds
* @param array<int, int>|null $visibleIds null 表示未启用数据范围或全量可见
*/
private static function appointmentRowManageableForScope(
int $doctorId,
int $assistantId,
int $adminId,
array $roleIds,
?array $visibleIds,
bool $isRoot
): bool {
if ($isRoot) {
return true;
}
if (in_array(1, $roleIds, true) && $doctorId !== $adminId) {
return false;
}
if (in_array(2, $roleIds, true) && $assistantId !== $adminId) {
return false;
}
if ($visibleIds === []) {
return false;
}
return $visibleIds === null
|| in_array($doctorId, $visibleIds, true)
|| ($assistantId > 0 && in_array($assistantId, $visibleIds, true));
}
/**
* 后台编辑挂号(预约日期/时段/类型/状态/备注/医助)
@@ -25,8 +25,8 @@ class DoctorNoteLogic extends BaseLogic
->find();
$newContent = trim($params['content'] ?? '');
$newImages = array_map([self::class, 'toRelativePath'], self::parseJsonArray($params['tongue_images'] ?? []));
$newReports = array_map([self::class, 'toRelativePath'], self::parseJsonArray($params['report_files'] ?? []));
$newImages = self::normalizeNewAttachmentPaths($params['tongue_images'] ?? []);
$newReports = self::normalizeNewAttachmentPaths($params['report_files'] ?? []);
if ($existing) {
$data = [];
@@ -169,15 +169,51 @@ class DoctorNoteLogic extends BaseLogic
*/
private static function toRelativePath(string $url): string
{
if (empty($url)) return $url;
if (stripos($url, 'http://') !== 0 && stripos($url, 'https://') !== 0) {
$url = trim($url);
if ($url === '') return $url;
$urlParts = parse_url($url);
if (!is_array($urlParts) || empty($urlParts['scheme'])) {
return $url;
}
$scheme = strtolower((string) $urlParts['scheme']);
if (!in_array($scheme, ['http', 'https'], true)) {
return $url;
}
// 获取当前存储域名
$domain = self::getStorageDomain();
if ($domain && stripos($url, rtrim($domain, '/')) === 0) {
$relative = substr($url, strlen(rtrim($domain, '/')));
return ltrim($relative, '/');
$domain = rtrim(self::getStorageDomain(), '/');
$domainParts = $domain !== '' ? parse_url($domain) : false;
if (is_array($domainParts)) {
$domainScheme = strtolower((string) ($domainParts['scheme'] ?? ''));
$urlHost = strtolower(rtrim((string) ($urlParts['host'] ?? ''), '.'));
$domainHost = strtolower(rtrim((string) ($domainParts['host'] ?? ''), '.'));
$urlPort = (int) ($urlParts['port'] ?? ($scheme === 'https' ? 443 : 80));
$domainPort = (int) (
$domainParts['port'] ?? ($domainScheme === 'https' ? 443 : 80)
);
$urlPath = (string) ($urlParts['path'] ?? '');
$domainPath = rtrim((string) ($domainParts['path'] ?? ''), '/');
$pathInsideDomain = $domainPath === ''
|| $urlPath === $domainPath
|| str_starts_with($urlPath, $domainPath . '/');
if (
$domainScheme === $scheme
&& $domainHost !== ''
&& $domainHost === $urlHost
&& $domainPort === $urlPort
&& $pathInsideDomain
) {
$relative = $domainPath === ''
? $urlPath
: substr($urlPath, strlen($domainPath));
if (isset($urlParts['query']) && $urlParts['query'] !== '') {
$relative .= '?' . $urlParts['query'];
}
return ltrim($relative, '/');
}
}
// 非当前存储域名,保留完整 URL
return $url;
@@ -193,6 +229,36 @@ class DoctorNoteLogic extends BaseLogic
return $storage ? ($storage['domain'] ?? '') : '';
}
/**
* 备注附件只接受站内相对路径或当前存储域已上传的 URL。
* 存储域 URL 先转为相对路径,避免将任意外部 URL 持久化到病例页。
*
* @param mixed $value
* @return array<int, string>
*/
private static function normalizeNewAttachmentPaths($value): array
{
$paths = [];
foreach (self::parseJsonArray($value) as $rawPath) {
$path = trim((string) $rawPath);
if ($path === '') {
continue;
}
$path = self::toRelativePath($path);
$scheme = parse_url($path, PHP_URL_SCHEME);
if (
(is_string($scheme) && $scheme !== '')
|| str_starts_with($path, '//')
|| str_contains($path, "\0")
) {
throw new \InvalidArgumentException('备注附件必须来自当前文件存储域');
}
$paths[] = $path;
}
return array_values(array_unique($paths));
}
private static function parseJsonArray($value): array
{
if (is_array($value)) return $value;
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace app\adminapi\logic\firstvisit;
use app\adminapi\logic\auth\AuthLogic;
use app\adminapi\logic\dept\DeptLogic;
use app\adminapi\logic\stats\ConversionLogic;
use app\adminapi\logic\stats\YejiStatsLogic;
@@ -23,6 +24,9 @@ use think\facade\Db;
class FirstVisitConversionLogic
{
private const ASSISTANT_ROLE_ID = 2;
private const FINANCE_PERMISSION = 'firstvisit.conversion/viewFinance';
private const FINANCE_ALWAYS_ROLE_NAMES = ['经理', '管理员', '系统管理员'];
private const FINANCE_FIELD_KEYS = ['account_cost', 'cash_cost', 'roi'];
/** @return array<string,mixed> */
public static function overview(array $params, int $adminId, array $adminInfo): array
@@ -64,11 +68,11 @@ class FirstVisitConversionLogic
$effectiveAdminIds = self::intersectVisibleIds($effectiveAdminIds, $deptAdminIds);
}
$selectedAssistantValid = $selectedAssistantId <= 0;
if ($selectedAssistantId > 0) {
$selectedAssistantValid = self::isActiveAssistant($selectedAssistantId)
&& ($effectiveAdminIds === null || in_array($selectedAssistantId, $effectiveAdminIds, true));
$effectiveAdminIds = $selectedAssistantValid ? [$selectedAssistantId] : [];
$selectedAssistantValid = $selectedAssistantId <= 0;
if ($selectedAssistantId > 0) {
$selectedAssistantValid = self::isActiveAssistant($selectedAssistantId)
&& ($effectiveAdminIds === null || in_array($selectedAssistantId, $effectiveAdminIds, true));
$effectiveAdminIds = $selectedAssistantValid ? [$selectedAssistantId] : [];
}
$costAllocationAdminIds = self::costAllocationAdminIds(
$effectiveAdminIds,
@@ -137,23 +141,36 @@ class FirstVisitConversionLogic
(int) $summary['total_open_count']
);
$rankingKind = self::rankingKind($scopeValue, $selectedAssistantId);
$rankingRows = self::rankingRows($rows, $rankingKind);
$rankingKind = self::rankingKind($scopeValue, $selectedAssistantId);
$rankingRows = self::rankingRows($rows, $rankingKind);
// 目前只维护了部门月度目标;本人范围或筛选单个员工时不能拿整个部门目标冒充个人目标。
$targetDeptIds = ($scopeValue === DataScopeService::SCOPE_SELF || $selectedAssistantId > 0)
? []
: self::resolveTargetDeptIds($allowedDeptSet, $selectedDeptIds, $selectedDeptId);
$target = self::buildTargetProgress((int) date('Y'), $effectiveAdminIds, $targetDeptIds);
$selectedDeptName = $selectedDeptId > 0 && $deptSelectionValid
? (string) (Db::name('dept')->where('id', $selectedDeptId)->whereNull('delete_time')->value('name') ?? '')
: '';
$selectedAssistantName = $selectedAssistantId > 0 && $selectedAssistantValid
? (string) (Admin::where('id', $selectedAssistantId)->whereNull('delete_time')->value('name') ?? '')
: '';
$selectedDeptName = $selectedDeptId > 0 && $deptSelectionValid
? (string) (Db::name('dept')->where('id', $selectedDeptId)->whereNull('delete_time')->value('name') ?? '')
: '';
$selectedAssistantName = $selectedAssistantId > 0 && $selectedAssistantValid
? (string) (Admin::where('id', $selectedAssistantId)->whereNull('delete_time')->value('name') ?? '')
: '';
$selectedMediaChannelName = $selectedMediaChannelCode !== ''
? (string) ($selectedMediaChannel['channel_name'] ?? $selectedMediaChannelCode)
: '';
if ($selectedMediaChannelName !== '' && !empty($selectedMediaChannel['is_group'])) {
$selectedMediaChannelName .= '(全部)';
}
$canViewFinance = self::canViewFinance($adminId, $adminInfo);
if (!$canViewFinance) {
$summary = self::maskFinanceFields($summary);
foreach ($rows as &$row) {
if (is_array($row)) {
$row = self::maskFinanceFields($row);
}
}
unset($row);
}
return [
'meta' => [
'time_type' => $timeType,
@@ -161,9 +178,9 @@ class FirstVisitConversionLogic
'start_date' => $startDate,
'end_date' => $endDate,
'generated_at' => date('Y-m-d H:i:s'),
'scope_value' => $scopeValue,
'scope_label' => DataScopeService::scopeLabel($scopeValue),
'ranking_kind' => $rankingKind,
'scope_value' => $scopeValue,
'scope_label' => DataScopeService::scopeLabel($scopeValue),
'ranking_kind' => $rankingKind,
'selected_dept_name' => $selectedDeptName,
'selected_assistant_name' => $selectedAssistantName,
'selected_media_channel_code' => $selectedMediaChannelCode,
@@ -171,6 +188,7 @@ class FirstVisitConversionLogic
'open_count_source' => $selectedMediaChannelCode === ''
? '个人业绩录入'
: '个人业绩录入(按渠道名称匹配)',
'can_view_finance' => $canViewFinance,
'appointment_rule' => '按预约日期统计,归属优先挂号医助、再回退诊单医助;仅含已预约、已完成和已过号',
'registration_rule' => '按支付时间统计已支付且实收金额大于 0、低于 10 元的订单,每笔计 1 个挂号并按订单创建人归属',
'performance_rule' => '按业务订单创建时间和创建人统计,排除取消、拒收、退款及已发生退款的订单',
@@ -520,45 +538,101 @@ class FirstVisitConversionLogic
return [];
}
$values = [
$channelCode,
$channel['channel_name'] ?? '',
$channel['source_tag_name'] ?? '',
$channel['legacy_channel_name'] ?? '',
$channel['legacy_source_tag_name'] ?? '',
];
foreach (['channel_codes', 'channel_names', 'source_tag_names'] as $listKey) {
if (!isset($channel[$listKey]) || !is_array($channel[$listKey])) {
continue;
}
foreach ($channel[$listKey] as $item) {
$values[] = $item;
}
}
return array_values(array_unique(array_filter(array_map(
static fn ($value): string => trim((string) $value),
[
$channelCode,
$channel['channel_name'] ?? '',
$channel['source_tag_name'] ?? '',
$channel['legacy_channel_name'] ?? '',
$channel['legacy_source_tag_name'] ?? '',
]
), static fn (string $value): bool => $value !== '')));
$values
), static fn (string $value): bool => $value !== '' && !str_starts_with($value, MediaChannelService::GROUP_CODE_PREFIX))));
}
/** 根据生效数据范围返回排行榜展示维度,不能把 scope_value 当作角色枚举。 */
private static function rankingKind(int $scopeValue, int $selectedAssistantId = 0): string
{
if ($scopeValue === DataScopeService::SCOPE_SELF || $selectedAssistantId > 0) {
return 'hidden';
}
return $scopeValue === DataScopeService::SCOPE_DEPT ? 'member' : 'group';
}
/** @param array<int,array<string,mixed>> $rows @return array<int,array<string,mixed>> */
private static function rankingRows(array $rows, string $rankingKind): array
{
if ($rankingKind === 'hidden') {
return [];
}
// “仅本部门”范围使用可见成员维度;更大范围使用当前可见组织根节点的
// 直属下级,避免父子汇总同时参与占比。
if ($rankingKind === 'member') {
$members = [];
self::collectRankingMembers($rows, $members);
return array_values($members);
}
// lists 里可能同时存在“未绑定/未分配部门”等虚拟根节点。它们会让顶层节点数量
private static function canViewFinance(int $adminId, array $adminInfo): bool
{
if ((int) ($adminInfo['root'] ?? 0) === 1) {
return true;
}
foreach (self::roleNamesFromAdminInfo($adminInfo) as $roleName) {
if (in_array($roleName, self::FINANCE_ALWAYS_ROLE_NAMES, true)) {
return true;
}
}
if ($adminId <= 0) {
return false;
}
return in_array(self::FINANCE_PERMISSION, AuthLogic::getAuthByAdminId($adminId), true);
}
/** @return string[] */
private static function roleNamesFromAdminInfo(array $adminInfo): array
{
$names = preg_split('/[\/,,、]/u', (string) ($adminInfo['role_name'] ?? '')) ?: [];
return array_values(array_filter(array_map('trim', $names), static fn (string $name): bool => $name !== ''));
}
/**
* @param array<string, mixed> $entity
* @return array<string, mixed>
*/
private static function maskFinanceFields(array $entity): array
{
foreach (self::FINANCE_FIELD_KEYS as $key) {
unset($entity[$key]);
}
if (isset($entity['children']) && is_array($entity['children'])) {
foreach ($entity['children'] as &$child) {
if (is_array($child)) {
$child = self::maskFinanceFields($child);
}
}
unset($child);
}
return $entity;
}
/** 根据生效数据范围返回排行榜展示维度,不能把 scope_value 当作角色枚举。 */
private static function rankingKind(int $scopeValue, int $selectedAssistantId = 0): string
{
if ($scopeValue === DataScopeService::SCOPE_SELF || $selectedAssistantId > 0) {
return 'hidden';
}
return $scopeValue === DataScopeService::SCOPE_DEPT ? 'member' : 'group';
}
/** @param array<int,array<string,mixed>> $rows @return array<int,array<string,mixed>> */
private static function rankingRows(array $rows, string $rankingKind): array
{
if ($rankingKind === 'hidden') {
return [];
}
// “仅本部门”范围使用可见成员维度;更大范围使用当前可见组织根节点的
// 直属下级,避免父子汇总同时参与占比。
if ($rankingKind === 'member') {
$members = [];
self::collectRankingMembers($rows, $members);
return array_values($members);
}
// lists 里可能同时存在“未绑定/未分配部门”等虚拟根节点。它们会让顶层节点数量
// 大于 1,导致原逻辑无法展开唯一的真实组织根节点,图表最终只显示医院汇总行。
$visibleRows = array_values(array_filter($rows, static function (array $row): bool {
return (int) ($row['id'] ?? 0) > 0 && !((bool) ($row['_virtual_bucket'] ?? false));
@@ -583,59 +657,59 @@ class FirstVisitConversionLogic
$chartRows[] = $row;
}
return $chartRows;
}
/**
* @param array<int,array<string,mixed>> $rows
* @param array<int,array<string,mixed>> $members
*/
private static function collectRankingMembers(array $rows, array &$members): void
{
foreach ($rows as $row) {
if ((string) ($row['type'] ?? '') === 'member') {
$adminId = (int) ($row['admin_id'] ?? 0);
if ($adminId > 0) {
$members[$adminId] = $row;
}
continue;
}
self::collectRankingMembers(
is_array($row['children'] ?? null) ? $row['children'] : [],
$members
);
}
}
return $chartRows;
}
/**
* @param array<int,array<string,mixed>> $rows
* @param array<int,array<string,mixed>> $members
*/
private static function collectRankingMembers(array $rows, array &$members): void
{
foreach ($rows as $row) {
if ((string) ($row['type'] ?? '') === 'member') {
$adminId = (int) ($row['admin_id'] ?? 0);
if ($adminId > 0) {
$members[$adminId] = $row;
}
continue;
}
self::collectRankingMembers(
is_array($row['children'] ?? null) ? $row['children'] : [],
$members
);
}
}
/** @param array<int,array<string,mixed>> $rows @return array<int,array<string,mixed>> */
private static function topRows(array $rows, string $metric): array
{
$rows = array_values(array_filter($rows, static function (array $row): bool {
if ((string) ($row['type'] ?? '') === 'member') {
return (int) ($row['admin_id'] ?? 0) > 0;
}
return (int) ($row['id'] ?? 0) > 0;
}));
usort($rows, static function (array $left, array $right) use ($metric): int {
$valueCompare = (float) ($right[$metric] ?? 0) <=> (float) ($left[$metric] ?? 0);
if ($valueCompare !== 0) {
return $valueCompare;
}
$nameCompare = strnatcasecmp((string) ($left['name'] ?? ''), (string) ($right['name'] ?? ''));
if ($nameCompare !== 0) {
return $nameCompare;
}
return strcmp((string) ($left['id'] ?? ''), (string) ($right['id'] ?? ''));
});
return array_map(static fn (array $row): array => [
'id' => $row['id'] ?? 0,
'name' => (string) ($row['name'] ?? ''),
'value' => round((float) ($row[$metric] ?? 0), 2),
], $rows);
}
private static function topRows(array $rows, string $metric): array
{
$rows = array_values(array_filter($rows, static function (array $row): bool {
if ((string) ($row['type'] ?? '') === 'member') {
return (int) ($row['admin_id'] ?? 0) > 0;
}
return (int) ($row['id'] ?? 0) > 0;
}));
usort($rows, static function (array $left, array $right) use ($metric): int {
$valueCompare = (float) ($right[$metric] ?? 0) <=> (float) ($left[$metric] ?? 0);
if ($valueCompare !== 0) {
return $valueCompare;
}
$nameCompare = strnatcasecmp((string) ($left['name'] ?? ''), (string) ($right['name'] ?? ''));
if ($nameCompare !== 0) {
return $nameCompare;
}
return strcmp((string) ($left['id'] ?? ''), (string) ($right['id'] ?? ''));
});
return array_map(static fn (array $row): array => [
'id' => $row['id'] ?? 0,
'name' => (string) ($row['name'] ?? ''),
'value' => round((float) ($row[$metric] ?? 0), 2),
], $rows);
}
/** @param int[]|null $baseVisibleAdminIds @param int[] $selectedDeptIds @return array<int,array{id:int,name:string}> */
private static function assistantOptions(?array $baseVisibleAdminIds, array $selectedDeptIds, int $selectedDeptId): array
@@ -62,7 +62,9 @@ class ConversionLogic
if ($mediaChannel === null && $requestedMediaChannelCode !== '') {
$mediaChannel = MediaChannelService::getChannelByCode($requestedMediaChannelCode);
}
$mediaChannelCode = $mediaChannel !== null ? $requestedMediaChannelCode : '';
$mediaChannelCodes = $mediaChannel !== null
? MediaChannelService::getChannelCodesForStats($mediaChannel)
: null;
$filterEmptyEntities = $mediaChannel !== null;
[$startTimestamp, $endTimestamp, $startDate, $endDate] = self::resolveTimeRange($params);
$pageNo = max(1, (int)($params['page_no'] ?? 1));
@@ -188,11 +190,11 @@ class ConversionLogic
);
// 数据隔离:可见部门 = 可见 admin 所属部门并集;用于 account_cost 与下游 cost 分摊。
$visibleDeptIds = self::resolveVisibleDeptIds($visibleAdminIds);
[$globalAccountCost, $accountCostDeptIds] = self::hydrateAccountCostStats($entities, $startDate, $endDate, $mediaChannelCode, $visibleDeptIds);
[$globalAccountCost, $accountCostDeptIds] = self::hydrateAccountCostStats($entities, $startDate, $endDate, $mediaChannelCodes, $visibleDeptIds);
$supportsDeptBinding = AccountCost::supportsDeptBinding();
$restrictAccountCostByDept = $supportsDeptBinding;
$channelBoundDeptIds = $supportsDeptBinding && $mediaChannelCode !== ''
? self::loadChannelBoundDeptIds($mediaChannelCode)
$channelBoundDeptIds = $supportsDeptBinding && $mediaChannelCodes !== null && $mediaChannelCodes !== []
? self::loadChannelBoundDeptIds($mediaChannelCodes)
: [];
// 渠道尚未维护投放成本时,不能把真实的加粉、挂号和订单一并过滤为空。
// 已维护绑定关系的渠道继续按绑定部门收窄;成本本身仍只在实际成本部门内分摊。
@@ -267,7 +269,7 @@ class ConversionLogic
$startDate,
$endDate,
$mediaChannel,
$mediaChannelCode,
$mediaChannelCodes,
$restrictAccountCostByDept,
$eligibleDeptIds,
$adminToDeptIds,
@@ -824,17 +826,18 @@ class ConversionLogic
/**
* 渠道绑定部门不依赖当前统计区间,避免某天没有录入成本时把统计实体过滤为空。
*
* @param string[] $mediaChannelCodes
* @return int[]
*/
private static function loadChannelBoundDeptIds(string $mediaChannelCode): array
private static function loadChannelBoundDeptIds(array $mediaChannelCodes): array
{
$mediaChannelCode = trim($mediaChannelCode);
if ($mediaChannelCode === '' || !AccountCost::supportsDeptBinding()) {
$mediaChannelCodes = self::normalizeMediaChannelCodes($mediaChannelCodes);
if ($mediaChannelCodes === [] || !AccountCost::supportsDeptBinding()) {
return [];
}
$deptIds = Db::name('account_cost')
->where('media_channel_code', $mediaChannelCode)
->whereIn('media_channel_code', $mediaChannelCodes)
->where('dept_id', '>', 0)
->distinct(true)
->column('dept_id');
@@ -963,7 +966,8 @@ class ConversionLogic
* - 统计区间内 add_external_contact,按 (user_id, external_userid) 去重;
* - 同一员工在区间开始前已加过该客户的重加不计(企微「添加时间」仍是首次跟进时间,
* 删后再加会再推 add_external_contact,但不能当当天新客,否则会跨日重复计);
* - 加粉之后、统计结束前须有 msg_audit_approved(排除未完成链路的幽灵事件);
* - add_external_contact 是企微确认客户关系已建立后的权威事件;会话存档同意
* msg_audit_approved 属于独立能力,不能作为加粉前置条件,否则未开通会话存档的员工会被整批清零;
* - 加粉之后、统计结束前若出现 del_external_contact(客户从企微侧删除)则不计。
* 不扣减 del_follow_user:企微客户列表中改名带「删」的客户往往仍在列表中;
* - 剔除非投放加粉:跟进人 add_way∈{1 扫一扫, 2 搜索手机号, 3 名片分享}
@@ -990,7 +994,7 @@ class ConversionLogic
}
}
$baseKey = self::requestRowsCacheKey('fans-effective-v6', [
$baseKey = self::requestRowsCacheKey('fans-effective-v7', [
$startTimestamp,
$endTimestamp,
self::mediaChannelCacheKey($mediaChannel),
@@ -1032,20 +1036,11 @@ class ConversionLogic
$query = Db::name('qywx_external_contact_event')
->alias('e')
->where('e.change_type', 'add_external_contact')
->where('e.event_time', 'between', [$startTimestamp, $endTimestamp])
->where('e.user_id', '<>', '')
->where('e.external_userid', '<>', '')
->whereRaw(
'EXISTS (SELECT 1 FROM `' . $eventTable . '` audit_e'
. ' WHERE audit_e.user_id = e.user_id'
. ' AND audit_e.external_userid = e.external_userid'
. ' AND audit_e.change_type = ?'
. ' AND audit_e.event_time >= e.event_time'
. ' AND audit_e.event_time <= ?)',
['msg_audit_approved', $endTimestamp]
)
->whereRaw(
'NOT EXISTS (SELECT 1 FROM `' . $eventTable . '` del_e'
->where('e.event_time', 'between', [$startTimestamp, $endTimestamp])
->where('e.user_id', '<>', '')
->where('e.external_userid', '<>', '')
->whereRaw(
'NOT EXISTS (SELECT 1 FROM `' . $eventTable . '` del_e'
. ' WHERE del_e.user_id = e.user_id'
. ' AND del_e.external_userid = e.external_userid'
. ' AND del_e.change_type = ?'
@@ -1596,6 +1591,7 @@ class ConversionLogic
* 账户消耗:来源于独立维护表 zyt_account_cost。
*
* @param array<int, array<string, mixed>> $entities
* @param string[]|null $mediaChannelCodes null=不按渠道过滤;[]=已选渠道但无匹配 code,成本记 0
* @param int[]|null $visibleDeptIds 可见部门集合(null = SCOPE_ALL,不收窄)
* @return array{0: float, 1: int[]}
*/
@@ -1603,10 +1599,21 @@ class ConversionLogic
array &$entities,
string $startDate,
string $endDate,
string $mediaChannelCode,
?array $mediaChannelCodes,
?array $visibleDeptIds = null
): array
{
$mediaChannelCodes = $mediaChannelCodes === null ? null : self::normalizeMediaChannelCodes($mediaChannelCodes);
if ($mediaChannelCodes === []) {
foreach ($entities as &$entity) {
$entity['account_cost'] = 0.0;
$entity['_global_account_cost'] = 0.0;
}
unset($entity);
return [0.0, []];
}
$supportsDeptBinding = AccountCost::supportsDeptBinding();
$query = Db::name('account_cost')
->where('cost_date', '>=', $startDate)
@@ -1618,8 +1625,8 @@ class ConversionLogic
$query->field('amount');
}
if ($mediaChannelCode !== '') {
$query->where('media_channel_code', $mediaChannelCode);
if ($mediaChannelCodes !== null) {
$query->whereIn('media_channel_code', $mediaChannelCodes);
}
if ($supportsDeptBinding) {
@@ -2049,7 +2056,7 @@ class ConversionLogic
string $startDate,
string $endDate,
?array $mediaChannel,
string $mediaChannelCode,
?array $mediaChannelCodes,
bool $restrictAccountCostByDept,
array $eligibleDeptIds,
array $adminToDeptIds,
@@ -2081,9 +2088,9 @@ class ConversionLogic
if ($globalAccountCost < 0) {
// 任选一个非空 entity 集合查一次即可——查询本身只与日期 / 渠道相关。
if ($assistantIds !== []) {
[$globalAccountCost] = self::hydrateAccountCostStats($assistantEntities, $startDate, $endDate, $mediaChannelCode);
[$globalAccountCost] = self::hydrateAccountCostStats($assistantEntities, $startDate, $endDate, $mediaChannelCodes);
} elseif ($doctorIds !== []) {
[$globalAccountCost] = self::hydrateAccountCostStats($doctorEntities, $startDate, $endDate, $mediaChannelCode);
[$globalAccountCost] = self::hydrateAccountCostStats($doctorEntities, $startDate, $endDate, $mediaChannelCodes);
} else {
$globalAccountCost = 0.0;
}
@@ -2905,10 +2912,29 @@ class ConversionLogic
return [
'code' => (string)($mediaChannel['channel_code'] ?? ''),
'tag_id' => (string)($mediaChannel['source_tag_id'] ?? ''),
'tag_ids' => $mediaChannel['source_tag_ids'] ?? [],
'tag_name' => (string)($mediaChannel['source_tag_name'] ?? ''),
];
}
/**
* @param string|string[] $mediaChannelCode
* @return string[]
*/
private static function normalizeMediaChannelCodes(string|array $mediaChannelCode): array
{
$values = is_array($mediaChannelCode) ? $mediaChannelCode : [$mediaChannelCode];
$codes = [];
foreach ($values as $value) {
$code = trim((string)$value);
if ($code !== '' && !str_starts_with($code, MediaChannelService::GROUP_CODE_PREFIX)) {
$codes[$code] = $code;
}
}
return array_values($codes);
}
/**
* @param int[]|null $visibleAdminIds
* @param int[] $eligibleDeptIds
@@ -4,13 +4,12 @@ declare(strict_types=1);
namespace app\adminapi\logic\tcm;
use app\common\cache\AdminAuthCache;
use app\common\logic\BaseLogic;
use app\common\model\auth\AdminRole;
use app\common\model\tcm\Diagnosis;
use app\common\model\tcm\DiagnosisAiReport;
use app\common\service\DataScope\DataScopeService;
use app\common\service\DifyChatService;
use app\common\cache\AdminAuthCache;
use app\adminapi\logic\firstvisit\MyPatientLogic;
use app\common\logic\BaseLogic;
use app\common\model\tcm\Diagnosis;
use app\common\model\tcm\DiagnosisAiReport;
use app\common\service\DifyChatService;
use think\facade\Db;
use think\facade\Log;
@@ -201,6 +200,44 @@ class DiagnosisAiLogic extends BaseLogic
string $prompt,
int $adminId,
array $adminInfo
): ?array {
$prepared = self::prepareAssistant($diagnosisId, $task, $prompt, $adminId, $adminInfo);
if ($prepared === null) {
return null;
}
try {
$result = DifyChatService::chat(
$prepared['profile'],
$prepared['inputs'],
$prepared['query'],
$prepared['user']
);
} catch (\Throwable $e) {
self::logAssistantFailure($diagnosisId, $prepared['profile'], $adminId, $e);
self::setError('AI 助手暂时不可用,请稍后重试');
return null;
}
return self::formatAssistantResult($prepared, $result);
}
/**
* SSE headers 发出前完成参数、权限、DataScope、病例和模型选择预检。
* 返回值只供同一请求内的流执行使用,绝不能直接序列化给客户端。
*
* @param array<string,mixed> $adminInfo
* @return array{
* diagnosis_id:int,profile:string,model_name:string,model_label:string,task:string,
* inputs:array<string,mixed>,query:string,user:string,admin_id:int
* }|null
*/
public static function prepareAssistant(
int $diagnosisId,
string $task,
string $prompt,
int $adminId,
array $adminInfo
): ?array {
$diagnosis = self::loadAuthorizedDiagnosis(
$diagnosisId,
@@ -239,28 +276,63 @@ class DiagnosisAiLogic extends BaseLogic
return null;
}
return [
'diagnosis_id' => $diagnosisId,
'profile' => $profile,
'model_name' => $model,
'model_label' => $modelLabel,
'task' => $task,
'inputs' => self::buildUpstreamInputs(
$context,
'病例问诊助手',
self::ASSISTANT_PROMPT_VERSION
),
'query' => self::buildAssistantPrompt($context, $task, $prompt),
'user' => 'admin-diagnosis-assistant-' . $adminId,
'admin_id' => $adminId,
];
}
/**
* @param array<string,mixed> $prepared prepareAssistant() 的内部返回值
* @param callable(string):mixed $onDelta
* @param callable():bool|null $shouldAbort
* @return array{answer:string,model_key:string,model_label:string,model_name:string,task:string}|null
*/
public static function streamPreparedAssistant(
array $prepared,
callable $onDelta,
?callable $shouldAbort = null
): ?array {
$diagnosisId = (int) ($prepared['diagnosis_id'] ?? 0);
$profile = (string) ($prepared['profile'] ?? '');
$adminId = (int) ($prepared['admin_id'] ?? 0);
try {
$result = DifyChatService::chat(
$result = DifyChatService::streamChat(
$profile,
self::buildUpstreamInputs(
$context,
'病例问诊助手',
self::ASSISTANT_PROMPT_VERSION
),
self::buildAssistantPrompt($context, $task, $prompt),
'admin-diagnosis-assistant-' . $adminId
is_array($prepared['inputs'] ?? null) ? $prepared['inputs'] : [],
(string) ($prepared['query'] ?? ''),
(string) ($prepared['user'] ?? ''),
$onDelta,
$shouldAbort
);
} catch (\Throwable $e) {
Log::warning('diagnosis ai assistant upstream call failed', [
'diagnosis_id' => $diagnosisId,
'profile' => $profile,
'admin_id' => $adminId,
'exception_class' => get_class($e),
]);
self::logAssistantFailure($diagnosisId, $profile, $adminId, $e);
self::setError('AI 助手暂时不可用,请稍后重试');
return null;
}
return self::formatAssistantResult($prepared, $result);
}
/**
* @param array<string,mixed> $prepared
* @param array<string,mixed> $result
* @return array{answer:string,model_key:string,model_label:string,model_name:string,task:string}|null
*/
private static function formatAssistantResult(array $prepared, array $result): ?array
{
if (empty($result['ok'])) {
self::setError((string) ($result['error'] ?? 'AI 助手暂时不可用,请稍后重试'));
return null;
@@ -273,13 +345,27 @@ class DiagnosisAiLogic extends BaseLogic
return [
'answer' => $content,
'model_key' => $profile,
'model_label' => $modelLabel,
'model_name' => $model,
'task' => $task,
'model_key' => (string) ($prepared['profile'] ?? ''),
'model_label' => (string) ($prepared['model_label'] ?? ''),
'model_name' => (string) ($prepared['model_name'] ?? ''),
'task' => (string) ($prepared['task'] ?? ''),
];
}
private static function logAssistantFailure(
int $diagnosisId,
string $profile,
int $adminId,
\Throwable $exception
): void {
Log::warning('diagnosis ai assistant upstream call failed', [
'diagnosis_id' => $diagnosisId,
'profile' => $profile,
'admin_id' => $adminId,
'exception_class' => get_class($exception),
]);
}
/**
* 接诊台结构化 AI 智能分析。每次只调用客户端白名单键对应的服务端模型,
* 上游失败或响应不符合契约时直接失败,不构造本地伪分析。
@@ -604,28 +690,10 @@ class DiagnosisAiLogic extends BaseLogic
return null;
}
$accessQuery = Diagnosis::where('id', $id)->whereNull('delete_time');
$isRoot = !empty($adminInfo['root']) && (int) $adminInfo['root'] === 1;
if (!$isRoot) {
$roleIds = array_map('intval', AdminRole::where('admin_id', $adminId)->column('role_id'));
if (in_array(2, $roleIds, true)) {
$accessQuery->where('assistant_id', $adminId);
}
if (DataScopeService::isEnabled()) {
$visibleIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
if ($visibleIds === []) {
self::setError('诊单不存在或无权访问');
return null;
}
if (is_array($visibleIds)) {
$accessQuery->whereIn('assistant_id', $visibleIds);
}
}
if (!MyPatientLogic::canAccessDiagnosis($id, $adminId, $adminInfo)) {
self::setError('诊单不存在或无权访问');
return null;
}
if (!$accessQuery->find()) {
self::setError('诊单不存在或无权访问');
return null;
}
$diagnosis = DiagnosisLogic::detail(['id' => $id], $adminInfo);
if ($diagnosis === [] || empty($diagnosis['id'])) {
+483 -251
View File
@@ -28,10 +28,11 @@ use app\common\model\DiagnosisViewRecord;
use app\common\model\doctor\Appointment;
use app\common\model\auth\Admin;
use app\common\model\auth\AdminRole;
use app\adminapi\logic\auth\AuthLogic;
use app\adminapi\logic\doctor\DoctorNoteLogic;
use app\adminapi\logic\doctor\AppointmentLogic;
use app\adminapi\logic\tcm\TrackingNoteLogic;
use app\adminapi\logic\auth\AuthLogic;
use app\adminapi\logic\doctor\DoctorNoteLogic;
use app\adminapi\logic\doctor\AppointmentLogic;
use app\adminapi\logic\firstvisit\MyPatientLogic;
use app\adminapi\logic\tcm\TrackingNoteLogic;
use app\common\service\ConfigService;
use app\common\service\FileService;
use app\common\service\DataScope\DataScopeService;
@@ -854,68 +855,77 @@ class DiagnosisLogic extends BaseLogic
}
/**
* @notes 获取通话签名
* @param array $params
* @return array|bool
*/
public static function getCallSignature(array $params)
{
try {
// 获取配置
$config = self::getTrtcConfig();
if (!$config) {
self::setError('请先配置腾讯云TRTC参数');
return false;
}
// 获取当前管理员ID(从参数中获取)
$adminId = $params['admin_id'] ?? 0;
$patientId = $params['patient_id'] ?? 0;
if (!$adminId) {
self::setError('获取管理员信息失败');
return false;
}
if (!$patientId) {
self::setError('获取患者信息失败');
return false;
}
// 医生userId
$doctorUserId = 'doctor_' . $adminId;
$query = Diagnosis::where('id', $patientId)
->where('delete_time', null)->find();
if(!$query){
self::setError('患者诊单已被删除');
return false;
}
// 患者userId(必须与小程序端一致)
$patientUserId = 'patient_' . $patientId;
// 生成医生的 UserSig
$userSig = self::generateUserSig($config['sdkAppId'], $config['secretKey'], $doctorUserId);
if (!$userSig) {
self::setError('生成签名失败');
return false;
}
// 导入医生账号到IM
self::importDoctorAccountToIm($adminId, $doctorUserId);
// 确保患者账号也已导入IM(用于跨平台通话)
self::ensurePatientImAccount($patientId, $patientUserId);
return [
'sdkAppId' => (int)$config['sdkAppId'], // 确保返回整数
'userId' => $doctorUserId, // 医生的userId
'userSig' => $userSig,
'assistant_id'=>$query->assistant_id?'doctor_'.$query->assistant_id:'',
'patientUserId' => $patientUserId, // 患者的userId(用于发起通话)
'expireTime' => 86400, // 24小时
* @notes 获取通话签名
* @param array $params
* @param array $adminInfo
* @return array|bool
*/
public static function getCallSignature(array $params, array $adminInfo = [])
{
try {
$adminId = (int) ($params['admin_id'] ?? 0);
$diagnosisId = (int) ($params['diagnosis_id'] ?? 0);
$patientId = (int) ($params['patient_id'] ?? 0);
if ($adminId <= 0) {
self::setError('获取管理员信息失败');
return false;
}
if ($diagnosisId <= 0 || $patientId <= 0) {
self::setError('诊单或患者信息不完整');
return false;
}
// Authorize the diagnosis before reading TRTC configuration or importing IM accounts.
if (!self::canManageDiagnosis($diagnosisId, $adminId, $adminInfo)) {
self::setError('诊单不存在、患者不匹配或无权访问');
return false;
}
$diagnosis = Diagnosis::where('id', $diagnosisId)
->where('patient_id', $patientId)
->whereNull('delete_time')
->where('status', 1)
->find();
if (!$diagnosis) {
self::setError('诊单不存在、患者不匹配或无权访问');
return false;
}
$config = self::getTrtcConfig();
if (!$config) {
self::setError('请先配置腾讯云TRTC参数');
return false;
}
$doctorUserId = 'doctor_' . $adminId;
// 患者userId(必须与小程序端一致)
$patientUserId = 'patient_' . $patientId;
// 生成医生的 UserSig
$userSig = self::generateUserSig($config['sdkAppId'], $config['secretKey'], $doctorUserId);
if (!$userSig) {
self::setError('生成签名失败');
return false;
}
// 导入医生账号到IM
self::importDoctorAccountToIm($adminId, $doctorUserId);
// 确保患者账号也已导入IM(用于跨平台通话)
self::ensurePatientImAccount($patientId, $patientUserId);
return [
'sdkAppId' => (int)$config['sdkAppId'], // 确保返回整数
'userId' => $doctorUserId, // 医生的userId
'userSig' => $userSig,
'assistant_id' => $diagnosis->assistant_id ? 'doctor_' . $diagnosis->assistant_id : '',
'patientUserId' => $patientUserId, // 患者的userId(用于发起通话)
'diagnosis_id' => $diagnosisId,
'patient_id' => $patientId,
'expireTime' => 86400, // 24小时
// 与 .env [trtc] ISLOCHOSTVOD 一致:true 允许浏览器本地录制并上传
'isLochostVod' => (bool)config('trtc.is_lochost_vod', false),
];
@@ -1034,9 +1044,12 @@ class DiagnosisLogic extends BaseLogic
$patientImId = 'patient_' . $patientId;
$archived = self::loadArchivedImChatRows($diagnosisId);
if ($onlyArchived) {
$lists = self::enrichImMessagesWithStaffNames($archived);
return [
if ($onlyArchived) {
$lists = self::attachDiagnosisIdToImMessages(
self::enrichImMessagesWithStaffNames($archived),
$diagnosisId
);
return [
'lists' => $lists,
'patient_im_id' => $patientImId,
'patient_name' => $diag['patient_name'] ?? '',
@@ -1051,7 +1064,10 @@ class DiagnosisLogic extends BaseLogic
self::setError('请先配置腾讯云 TRTC / IM 参数');
return false;
}
$lists = self::enrichImMessagesWithStaffNames($archived);
$lists = self::attachDiagnosisIdToImMessages(
self::enrichImMessagesWithStaffNames($archived),
$diagnosisId
);
return [
'lists' => $lists,
@@ -1072,9 +1088,12 @@ class DiagnosisLogic extends BaseLogic
return false;
}
$live = self::pullLiveImChatMessagesForDiagnosis($diag, $doctorAccounts);
$merged = self::mergeImMessagesByMsgId($archived, $live);
$merged = self::enrichImMessagesWithStaffNames($merged);
$live = self::pullLiveImChatMessagesForDiagnosis($diag, $doctorAccounts);
$merged = self::mergeImMessagesByMsgId($archived, $live);
$merged = self::attachDiagnosisIdToImMessages(
self::enrichImMessagesWithStaffNames($merged),
$diagnosisId
);
// 首次云端拉取后异步落库,下一次即可直接读归档,无需再全量扫描医生账号
if (!empty($live)) {
@@ -1165,7 +1184,7 @@ class DiagnosisLogic extends BaseLogic
/**
* @return array<int, array<string, mixed>>
*/
private static function loadArchivedImChatRows(int $diagnosisId): array
private static function loadArchivedImChatRows(int $diagnosisId): array
{
if ($diagnosisId <= 0) {
return [];
@@ -1193,8 +1212,25 @@ class DiagnosisLogic extends BaseLogic
'doctor_peer_account' => (string)($row['doctor_peer_account'] ?? ''),
];
}
return $out;
}
return $out;
}
/**
* Keep the parent diagnosis on every child row so clients can reject
* accidentally mixed or stale IM payloads before rendering them.
*
* @param array<int, array<string, mixed>> $rows
* @return array<int, array<string, mixed>>
*/
private static function attachDiagnosisIdToImMessages(array $rows, int $diagnosisId): array
{
foreach ($rows as &$row) {
$row['diagnosis_id'] = $diagnosisId;
}
unset($row);
return $rows;
}
/**
* @param array<int, array<string, mixed>> $archived
@@ -1644,12 +1680,13 @@ class DiagnosisLogic extends BaseLogic
* @param array $params
* @return bool
*/
public static function endCall(array $params): bool
{
try {
// 获取当前管理员ID(从参数中获取)
$adminId = (int)($params['admin_id'] ?? 0);
$diagnosisId = (int)($params['diagnosis_id'] ?? 0);
public static function endCall(array $params): bool
{
try {
// 获取当前管理员ID(从参数中获取)
$adminId = (int)($params['admin_id'] ?? 0);
$diagnosisId = (int)($params['diagnosis_id'] ?? 0);
$callRecordId = (int)($params['call_record_id'] ?? 0);
if ($adminId <= 0) {
self::setError('获取管理员信息失败');
@@ -1660,26 +1697,47 @@ class DiagnosisLogic extends BaseLogic
return false;
}
// 优先匹配「当前医生 + 进行中」,与 startCloudRecording / bindCallRoom 一致
$record = \app\common\model\tcm\CallRecord::where('diagnosis_id', $diagnosisId)
->where('caller_id', $adminId)
->where('status', 1)
->order('id', 'desc')
->find();
if (!$record) {
$record = \app\common\model\tcm\CallRecord::where('diagnosis_id', $diagnosisId)
->where('status', 1)
->order('id', 'desc')
->find();
if ($record) {
\think\facade\Log::warning('endCall: 未匹配 caller_id,已回退到该诊单最新进行中记录', [
'diagnosis_id' => $diagnosisId,
'admin_id' => $adminId,
'record_caller_id' => $record['caller_id'] ?? null,
'call_record_id' => $record['id'] ?? null,
]);
}
}
$record = null;
if ($callRecordId > 0) {
$record = \app\common\model\tcm\CallRecord::where('id', $callRecordId)
->where('diagnosis_id', $diagnosisId)
->where('caller_id', $adminId)
->where('caller_type', 'doctor')
->find();
if (!$record) {
self::setError('通话记录不存在或无权操作');
return false;
}
if ((int)($record['status'] ?? 0) === 2) {
// afterCalling / Store idle may report the same exact call twice.
return true;
}
if ((int)($record['status'] ?? 0) !== 1) {
self::setError('通话记录状态不可结束');
return false;
}
} else {
// Legacy web callers may not yet send call_record_id.
$record = \app\common\model\tcm\CallRecord::where('diagnosis_id', $diagnosisId)
->where('caller_id', $adminId)
->where('status', 1)
->order('id', 'desc')
->find();
if (!$record) {
$record = \app\common\model\tcm\CallRecord::where('diagnosis_id', $diagnosisId)
->where('status', 1)
->order('id', 'desc')
->find();
if ($record) {
\think\facade\Log::warning('endCall: 未传 call_record_id 且未匹配 caller_id,已回退到该诊单最新进行中记录', [
'diagnosis_id' => $diagnosisId,
'admin_id' => $adminId,
'record_caller_id' => $record['caller_id'] ?? null,
'call_record_id' => $record['id'] ?? null,
]);
}
}
}
if (!$record) {
// 前端常重复回调 endCallafterCalling + Store idle),第一条已结束则不再告警
@@ -1860,9 +1918,20 @@ class DiagnosisLogic extends BaseLogic
if (is_array($decoded)) {
$urls = $decoded;
}
}
}
$record['recording_urls_list'] = $urls;
$record['recording_status_text'] = self::recordingStatusText((int)($record['recording_status'] ?? 0));
$localAudioUrls = [];
if (!empty($record['local_audio_urls'])) {
$decodedLocalAudioUrls = json_decode((string)$record['local_audio_urls'], true);
if (is_array($decodedLocalAudioUrls)) {
$localAudioUrls = $decodedLocalAudioUrls;
}
}
$record['local_audio_urls_list'] = $localAudioUrls;
$record['local_audio_status_text'] = self::localAudioStatusText(
(int)($record['local_audio_status'] ?? 0)
);
$record['transcription_status_text'] = self::transcriptionStatusText(
(string)($record['transcription_status'] ?? '')
);
@@ -2183,11 +2252,12 @@ class DiagnosisLogic extends BaseLogic
* @notes TRTC 房间号写入当前诊单通话记录,并尝试 API 合流云端录制
* @return array{cloud_recording?:array}|false 成功返回 data 数组(供接口带给前端);失败 false
*/
public static function bindCallRoom(array $params)
{
try {
$diagnosisId = (int)($params['diagnosis_id'] ?? 0);
$roomId = trim((string)($params['room_id'] ?? ''));
public static function bindCallRoom(array $params)
{
try {
$diagnosisId = (int)($params['diagnosis_id'] ?? 0);
$callRecordId = (int)($params['call_record_id'] ?? 0);
$roomId = trim((string)($params['room_id'] ?? ''));
if ($diagnosisId <= 0 || $roomId === '') {
self::setError('诊单ID或房间号不能为空');
return false;
@@ -2195,24 +2265,34 @@ class DiagnosisLogic extends BaseLogic
$adminId = (int)($params['admin_id'] ?? 0);
// 必须与 startCloudRecording 使用同一条「进行中 + 当前管理员」记录写 room_id,否则会写到别的记录上,合流 API 读到 room_id 仍为空 → 关闭全局录制后无任何文件
$record = null;
if ($adminId > 0) {
$record = \app\common\model\tcm\CallRecord::where('diagnosis_id', $diagnosisId)
->where('status', 1)
->where('caller_id', $adminId)
$record = null;
if ($callRecordId > 0) {
$record = \app\common\model\tcm\CallRecord::where('id', $callRecordId)
->where('diagnosis_id', $diagnosisId)
->where('status', 1)
->where('caller_id', $adminId)
->where('caller_type', 'doctor')
->find();
if (!$record) {
self::setError('通话记录不存在或无权绑定房间');
return false;
}
} elseif ($adminId > 0) {
$record = \app\common\model\tcm\CallRecord::where('diagnosis_id', $diagnosisId)
->where('status', 1)
->where('caller_id', $adminId)
->order('id', 'desc')
->find();
}
if (!$record) {
$record = \app\common\model\tcm\CallRecord::where('diagnosis_id', $diagnosisId)
->where('status', 1)
if (!$record && $callRecordId <= 0) {
$record = \app\common\model\tcm\CallRecord::where('diagnosis_id', $diagnosisId)
->where('status', 1)
->order('id', 'desc')
->find();
}
if (!$record) {
$record = \app\common\model\tcm\CallRecord::where('diagnosis_id', $diagnosisId)
->where('room_id', '')
if (!$record && $callRecordId <= 0) {
$record = \app\common\model\tcm\CallRecord::where('diagnosis_id', $diagnosisId)
->where('room_id', '')
->order('id', 'desc')
->find();
}
@@ -2232,10 +2312,11 @@ class DiagnosisLogic extends BaseLogic
'message' => '未尝试合流录制(admin_id 为空)',
];
if ($adminId > 0) {
$cloudRec = self::startCloudRecording([
'diagnosis_id' => $diagnosisId,
'admin_id' => $adminId,
], true);
$cloudRec = self::startCloudRecording([
'diagnosis_id' => $diagnosisId,
'admin_id' => $adminId,
'call_record_id' => (int)$record['id'],
], true);
if ($cloudRec === false) {
\think\facade\Log::warning('bindCallRoom: startCloudRecording 未执行或异常', [
'diagnosis_id' => $diagnosisId,
@@ -2285,10 +2366,11 @@ class DiagnosisLogic extends BaseLogic
*/
public static function startCloudRecording(array $params, bool $silent = false)
{
try {
$diagnosisId = (int)($params['diagnosis_id'] ?? 0);
$adminId = (int)($params['admin_id'] ?? 0);
if ($diagnosisId <= 0 || $adminId <= 0) {
try {
$diagnosisId = (int)($params['diagnosis_id'] ?? 0);
$adminId = (int)($params['admin_id'] ?? 0);
$callRecordId = (int)($params['call_record_id'] ?? 0);
if ($diagnosisId <= 0 || $adminId <= 0) {
if (!$silent) {
self::setError('参数错误');
}
@@ -2296,24 +2378,40 @@ class DiagnosisLogic extends BaseLogic
return false;
}
$record = \app\common\model\tcm\CallRecord::where('diagnosis_id', $diagnosisId)
->where('caller_id', $adminId)
->where('status', 1)
->order('id', 'desc')
->find();
if (!$record) {
$record = \app\common\model\tcm\CallRecord::where('diagnosis_id', $diagnosisId)
->where('status', 1)
->order('id', 'desc')
->find();
if ($record) {
\think\facade\Log::warning('startCloudRecording: 未找到 caller_id 匹配的进行中记录,已回退到该诊单最新进行中记录', [
'diagnosis_id' => $diagnosisId,
'admin_id' => $adminId,
'record_caller_id' => $record['caller_id'] ?? null,
]);
}
}
$record = null;
if ($callRecordId > 0) {
$record = \app\common\model\tcm\CallRecord::where('id', $callRecordId)
->where('diagnosis_id', $diagnosisId)
->where('caller_id', $adminId)
->where('caller_type', 'doctor')
->where('status', 1)
->find();
if (!$record) {
if (!$silent) {
self::setError('通话记录不存在或无权开启云端录制');
}
return false;
}
} else {
$record = \app\common\model\tcm\CallRecord::where('diagnosis_id', $diagnosisId)
->where('caller_id', $adminId)
->where('status', 1)
->order('id', 'desc')
->find();
if (!$record) {
$record = \app\common\model\tcm\CallRecord::where('diagnosis_id', $diagnosisId)
->where('status', 1)
->order('id', 'desc')
->find();
if ($record) {
\think\facade\Log::warning('startCloudRecording: 未传 call_record_id 且未找到 caller_id 匹配的进行中记录,已回退到该诊单最新进行中记录', [
'diagnosis_id' => $diagnosisId,
'admin_id' => $adminId,
'record_caller_id' => $record['caller_id'] ?? null,
]);
}
}
}
if (!$record) {
if (!$silent) {
self::setError('没有进行中的通话记录');
@@ -2388,9 +2486,9 @@ class DiagnosisLogic extends BaseLogic
/**
* @notes 医生端浏览器本地录制上传后,将文件访问地址合并写入当前诊单下该医生的最近一条通话记录
*/
public static function attachLocalCallRecording(array $params): bool
{
try {
public static function attachLocalCallRecording(array $params): bool
{
try {
$diagnosisId = (int)($params['diagnosis_id'] ?? 0);
$adminId = (int)($params['admin_id'] ?? 0);
$fileUrl = trim((string)($params['file_url'] ?? ''));
@@ -2427,12 +2525,61 @@ class DiagnosisLogic extends BaseLogic
} catch (\Exception $e) {
self::setError($e->getMessage());
return false;
}
}
/**
* @notes 诊单回放视频分片上传,完成后显式关联到指定通话记录
return false;
}
}
/**
* 将医生工作站本机录音独立关联到通话记录
*
* 本机音频与腾讯云混流视频分别持久化,避免录音上传成功后被误计为
* 云端视频已生成。
*/
public static function attachLocalCallAudio(array $params): bool
{
try {
$diagnosisId = (int)($params['diagnosis_id'] ?? 0);
$adminId = (int)($params['admin_id'] ?? 0);
$fileUrl = trim((string)($params['file_url'] ?? ''));
$callRecordId = (int)($params['call_record_id'] ?? 0);
if ($diagnosisId <= 0 || $adminId <= 0 || $fileUrl === '') {
self::setError('参数错误');
return false;
}
$record = self::resolveCallRecordForAttachment($diagnosisId, $adminId, $callRecordId);
if (!$record) {
self::setError('未找到通话记录');
return false;
}
$previousUrls = [];
if (!empty($record->local_audio_urls)) {
$decodedUrls = json_decode((string)$record->local_audio_urls, true);
if (is_array($decodedUrls)) {
$previousUrls = $decodedUrls;
}
}
$mergedUrls = array_values(array_unique(array_merge($previousUrls, [$fileUrl])));
$record->save([
'local_audio_urls' => json_encode($mergedUrls, JSON_UNESCAPED_UNICODE),
'local_audio_status' => 2,
'update_time' => time(),
]);
return true;
} catch (\Exception $e) {
self::setError($e->getMessage());
return false;
}
}
/**
* @notes 诊单回放视频分片上传,完成后显式关联到指定通话记录
* @param array $params
* @return array|false
*/
@@ -2444,11 +2591,12 @@ class DiagnosisLogic extends BaseLogic
$diagnosisId = (int)($params['diagnosis_id'] ?? 0);
$callRecordId = (int)($params['call_record_id'] ?? 0);
$adminId = (int)($params['admin_id'] ?? 0);
$uploadId = trim((string)($params['upload_id'] ?? ''));
$fileName = trim((string)($params['file_name'] ?? ''));
$fileSize = (int)($params['file_size'] ?? 0);
$chunkIndex = (int)($params['chunk_index'] ?? -1);
$chunkTotal = (int)($params['chunk_total'] ?? 0);
$uploadId = trim((string)($params['upload_id'] ?? ''));
$fileName = trim((string)($params['file_name'] ?? ''));
$mimeType = strtolower(trim((string)($params['mime_type'] ?? '')));
$fileSize = (int)($params['file_size'] ?? 0);
$chunkIndex = (int)($params['chunk_index'] ?? -1);
$chunkTotal = (int)($params['chunk_total'] ?? 0);
if (
$diagnosisId <= 0 ||
@@ -2461,13 +2609,27 @@ class DiagnosisLogic extends BaseLogic
) {
self::setError('上传参数不完整');
return false;
}
$ext = strtolower((string)pathinfo($fileName, PATHINFO_EXTENSION));
if ($ext === '' || !in_array($ext, config('project.file_video'), true)) {
self::setError('视频格式不支持');
return false;
}
}
$ext = strtolower((string)pathinfo($fileName, PATHINFO_EXTENSION));
$audioExtensions = ['webm', 'ogg', 'opus', 'mp3', 'wav', 'm4a', 'aac', 'amr', 'wma'];
$videoExtensions = (array)config('project.file_video');
$hasAudioMime = str_starts_with($mimeType, 'audio/');
$hasVideoMime = str_starts_with($mimeType, 'video/');
$isAmbiguousWebm = $ext === 'webm';
$isVideo = in_array($ext, $videoExtensions, true)
&& (
$mimeType === ''
|| $hasVideoMime
|| (!$isAmbiguousWebm && $hasAudioMime)
);
$isLocalAudio = !$isVideo
&& $hasAudioMime
&& in_array($ext, $audioExtensions, true);
if (!$isLocalAudio && !$isVideo) {
self::setError('音视频格式不支持');
return false;
}
if ($callRecordId <= 0) {
$record = self::createSyntheticCallRecord($diagnosisId, $adminId, $fileName);
@@ -2496,10 +2658,12 @@ class DiagnosisLogic extends BaseLogic
'diagnosis_id' => $diagnosisId,
'call_record_id' => $callRecordId,
'admin_id' => $adminId,
'file_name' => $fileName,
'file_size' => $fileSize,
'chunk_total' => $chunkTotal,
], JSON_UNESCAPED_UNICODE));
'file_name' => $fileName,
'file_size' => $fileSize,
'mime_type' => $mimeType,
'media_kind' => $isLocalAudio ? 'local_audio' : 'video',
'chunk_total' => $chunkTotal,
], JSON_UNESCAPED_UNICODE));
$chunkPath = $uploadDir . DIRECTORY_SEPARATOR . self::callRecordingChunkName($chunkIndex);
$moved = $chunkFile->move($uploadDir, basename($chunkPath));
@@ -2526,21 +2690,29 @@ class DiagnosisLogic extends BaseLogic
return false;
}
$uploadResult = self::storeMergedCallRecording($mergedPath, $fileName, $adminId);
if (empty($uploadResult['uri'])) {
self::setError('保存视频失败');
return false;
}
$attached = self::attachLocalCallRecording([
'diagnosis_id' => $diagnosisId,
'call_record_id' => $callRecordId,
'admin_id' => $adminId,
'file_url' => (string)$uploadResult['uri'],
]);
if (!$attached) {
return false;
}
$uploadResult = self::storeMergedCallRecording(
$mergedPath,
$fileName,
$adminId,
$isLocalAudio
);
if (empty($uploadResult['uri'])) {
self::setError($isLocalAudio ? '保存本机录音失败' : '保存视频失败');
return false;
}
$attachmentParams = [
'diagnosis_id' => $diagnosisId,
'call_record_id' => $callRecordId,
'admin_id' => $adminId,
'file_url' => (string)$uploadResult['uri'],
];
$attached = $isLocalAudio
? self::attachLocalCallAudio($attachmentParams)
: self::attachLocalCallRecording($attachmentParams);
if (!$attached) {
return false;
}
self::cleanupCallRecordingChunkDir($uploadDir);
@@ -2549,10 +2721,12 @@ class DiagnosisLogic extends BaseLogic
'uploaded_chunks' => $chunkTotal,
'chunk_total' => $chunkTotal,
'call_record_id' => $callRecordId,
'file_id' => $uploadResult['id'] ?? 0,
'file_url' => $uploadResult['uri'] ?? '',
'recording_status' => 2,
];
'file_id' => $uploadResult['id'] ?? 0,
'file_url' => $uploadResult['uri'] ?? '',
'media_kind' => $isLocalAudio ? 'local_audio' : 'video',
'recording_status' => $isLocalAudio ? 0 : 2,
'local_audio_status' => $isLocalAudio ? 2 : 0,
];
} catch (\Throwable $e) {
if ($mergedPath !== '' && is_file($mergedPath)) {
@unlink($mergedPath);
@@ -2655,19 +2829,31 @@ class DiagnosisLogic extends BaseLogic
}
}
private static function recordingStatusText(int $status): string
{
$map = [
private static function recordingStatusText(int $status): string
{
$map = [
0 => '无录制',
1 => '录制中',
2 => '已生成',
3 => '录制失败',
];
return $map[$status] ?? '未知';
}
/**
return $map[$status] ?? '未知';
}
private static function localAudioStatusText(int $status): string
{
$map = [
0 => '无本地录音',
1 => '上传中',
2 => '已保存',
3 => '上传失败',
];
return $map[$status] ?? '未知';
}
/**
* @notes 获取TRTC配置
* @return array|null
*/
@@ -4182,32 +4368,10 @@ class DiagnosisLogic extends BaseLogic
return [];
}
// 1) 数据权限闸 — 不通过则返回「不存在或无权访问」
$accessQuery = Diagnosis::where('id', $diagnosisId)->whereNull('delete_time');
$roleIds = array_map('intval', AdminRole::where('admin_id', $adminId)->column('role_id'));
if (in_array(2, $roleIds, true)) {
// 医助仅看自己被指派的
$accessQuery->where('assistant_id', $adminId);
}
if (DataScopeService::isEnabled()) {
$visibleIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
if ($visibleIds === []) {
self::setError('诊单不存在或无权访问');
return [];
}
if (is_array($visibleIds)) {
$accessQuery->whereIn('assistant_id', $visibleIds);
}
}
if (!$accessQuery->find()) {
self::setError('诊单不存在或无权访问');
return [];
}
// 1) 数据权限闸 — 不通过则返回「不存在或无权访问」
if (!self::canViewReadonlyDiagnosis($diagnosisId, $adminId, $adminInfo)) {
return [];
}
// 2) 诊单详情(含图片聚合等)+ 字典翻译
$diagnosis = self::detail(['id' => $diagnosisId]);
@@ -4238,24 +4402,83 @@ class DiagnosisLogic extends BaseLogic
$unservedDays = $maxRecordTs > 0 ? max(0, (int) floor((time() - $maxRecordTs) / 86400)) : null;
$lastBloodRecordAt = $maxRecordTs > 0 ? date('Y-m-d', $maxRecordTs) : null;
return [
return [
'appointment' => $appointment,
'diagnosis' => $diagnosis,
'doctor_notes' => $doctorNotes,
'tracking_notes' => $trackingNotes,
'unserved_days' => $unservedDays,
'last_blood_record_at' => $lastBloodRecordAt,
];
}
/**
];
}
/**
* 与诊单列表共用语义的只读行级可见性入口。
*
* 医助仍仅能查看本人归属诊单;其他角色按 DataScope 查看列表范围内的诊单。
* “我的患者”的本人接诊关系只用于管理/写操作,不能限制通用诊单只读页。
* 不存在与越权使用同一错误,避免枚举诊单。
*/
public static function canViewReadonlyDiagnosis(int $diagnosisId, int $adminId, array $adminInfo): bool
{
self::$error = '';
if ($diagnosisId <= 0 || $adminId <= 0) {
self::setError('诊单不存在或无权访问');
return false;
}
$query = Diagnosis::where('id', $diagnosisId)->whereNull('delete_time');
$roleIds = array_map('intval', AdminRole::where('admin_id', $adminId)->column('role_id'));
if (in_array(2, $roleIds, true)) {
$query->where('assistant_id', $adminId);
}
if (DataScopeService::isEnabled()) {
$visibleIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
if ($visibleIds === []) {
self::setError('诊单不存在或无权访问');
return false;
}
if (is_array($visibleIds)) {
$query->whereIn('assistant_id', $visibleIds);
}
}
if (!$query->find()) {
self::setError('诊单不存在或无权访问');
return false;
}
return true;
}
/**
* 诊单管理/写操作的行级权限入口,保留“我的患者”的本人关系约束。
*/
public static function canManageDiagnosis(int $diagnosisId, int $adminId, array $adminInfo): bool
{
self::$error = '';
if (!MyPatientLogic::canAccessDiagnosis($diagnosisId, $adminId, $adminInfo)) {
self::setError('诊单不存在或无权访问');
return false;
}
return true;
}
/**
* 取指定日期区间内的三类跟踪记录(血糖血压 / 饮食 / 运动),供 readonlyDetail
* 医生接诊台 reception 通过独立接口 lazy load。
*
* 区间语义:闭区间 [startDate, endDate]Y-m-d),均不传则不限。
*
* @return array{
* blood_records: array<int,array<string,mixed>>,
* diagnosis_id: int,
* blood_records: array<int,array<string,mixed>>,
* diet_records: array<int,array<string,mixed>>,
* exercise_records: array<int,array<string,mixed>>,
* start_date: string,
@@ -4267,10 +4490,11 @@ class DiagnosisLogic extends BaseLogic
$sinceTs = $startDate !== '' ? (int) strtotime($startDate . ' 00:00:00') : 0;
$untilTs = $endDate !== '' ? (int) strtotime($endDate . ' 23:59:59') : 0;
$sinceTs = $sinceTs > 0 ? $sinceTs : 0;
$untilTs = $untilTs > 0 ? $untilTs : 0;
return [
'blood_records' => self::fetchBloodRecordsForReadonly($diagnosisId, $sinceTs, $untilTs),
$untilTs = $untilTs > 0 ? $untilTs : 0;
return [
'diagnosis_id' => $diagnosisId,
'blood_records' => self::fetchBloodRecordsForReadonly($diagnosisId, $sinceTs, $untilTs),
'diet_records' => self::fetchDietRecordsForReadonly($diagnosisId, $sinceTs, $untilTs),
'exercise_records' => self::fetchExerciseRecordsForReadonly($diagnosisId, $sinceTs, $untilTs),
'start_date' => $startDate,
@@ -4527,25 +4751,33 @@ class DiagnosisLogic extends BaseLogic
}
}
private static function storeMergedCallRecording(string $mergedPath, string $fileName, int $adminId): array
{
$config = [
'default' => ConfigService::get('storage', 'default', 'local'),
private static function storeMergedCallRecording(
string $mergedPath,
string $fileName,
int $adminId,
bool $isLocalAudio = false
): array
{
$config = [
'default' => ConfigService::get('storage', 'default', 'local'),
'engine' => ConfigService::get('storage') ?? ['local' => []],
];
$storageDriver = new StorageDriver($config);
$storageDriver->setUploadFileByReal($mergedPath);
$saveDir = 'uploads/video/' . date('Ymd');
if (!$storageDriver->upload($saveDir)) {
throw new \RuntimeException($storageDriver->getError() ?: '上传视频到存储失败');
}
$relativePath = $saveDir . '/' . str_replace('\\', '/', $storageDriver->getFileName());
$storedFile = FileModel::create([
'cid' => 0,
'type' => FileEnum::VIDEO_TYPE,
'name' => mb_substr($fileName, 0, 128),
$storageDriver = new StorageDriver($config);
$storageDriver->setUploadFileByReal($mergedPath);
$saveDir = ($isLocalAudio ? 'uploads/audio/' : 'uploads/video/') . date('Ymd');
if (!$storageDriver->upload($saveDir)) {
throw new \RuntimeException(
$storageDriver->getError()
?: ($isLocalAudio ? '上传本机录音到存储失败' : '上传视频到存储失败')
);
}
$relativePath = $saveDir . '/' . str_replace('\\', '/', $storageDriver->getFileName());
$storedFile = FileModel::create([
'cid' => 0,
'type' => $isLocalAudio ? FileEnum::FILE_TYPE : FileEnum::VIDEO_TYPE,
'name' => mb_substr($fileName, 0, 128),
'uri' => $relativePath,
'source' => FileEnum::SOURCE_ADMIN,
'source_id' => $adminId,
@@ -2,9 +2,9 @@
declare(strict_types=1);
namespace app\adminapi\logic\tcm;
use app\common\model\auth\Admin;
namespace app\adminapi\logic\tcm;
use app\common\model\auth\Admin;
use app\common\model\doctor\Appointment;
use app\common\model\doctor\Medicine as DoctorMedicine;
use app\common\model\tcm\Prescription;
@@ -934,28 +934,62 @@ class PrescriptionLogic
/**
* 根据诊单ID获取处方列表
*/
public static function listByDiagnosis(int $diagnosisId): array
{
return Prescription::where('diagnosis_id', $diagnosisId)
->whereNull('delete_time')
->order('id', 'desc')
->select()
->toArray();
}
public static function listByDiagnosis(int $diagnosisId, int $viewerAdminId, array $viewerAdminInfo): array
{
self::$error = '';
if (!DiagnosisLogic::canViewReadonlyDiagnosis($diagnosisId, $viewerAdminId, $viewerAdminInfo)) {
self::setError(DiagnosisLogic::getError() ?: '诊单不存在或无权访问');
return [];
}
$rows = Prescription::where('diagnosis_id', $diagnosisId)
->whereNull('delete_time')
->order('id', 'desc')
->select()
->toArray();
return self::filterViewablePrescriptions($rows, $viewerAdminId, $viewerAdminInfo);
}
/**
* @param array<int, array<string,mixed>> $rows
* @return array<int, array<string,mixed>>
*/
private static function filterViewablePrescriptions(array $rows, int $viewerAdminId, array $viewerAdminInfo): array
{
return array_values(array_filter(
$rows,
static fn (array $row): bool => self::canViewPrescription($row, $viewerAdminId, $viewerAdminInfo)
));
}
/**
* 根据预约ID获取处方(带权限检查)
*/
public static function getByAppointment(int $appointmentId, int $viewerAdminId, array $viewerAdminInfo): ?array
{
public static function getByAppointment(int $appointmentId, int $viewerAdminId, array $viewerAdminInfo): ?array
{
self::$error = '';
$row = Prescription::where('appointment_id', $appointmentId)
$rows = Prescription::where('appointment_id', $appointmentId)
->whereNull('delete_time')
->order('id', 'desc')
->find();
if (!$row) {
return null;
->select();
$row = null;
$fallback = null;
foreach ($rows as $candidate) {
if ($fallback === null) {
$fallback = $candidate;
}
if ((int) ($candidate->void_status ?? 0) === 0) {
$row = $candidate;
break;
}
}
$row = $row ?? $fallback;
if (!$row) {
return null;
}
// 权限检查:只返回当前用户有权限查看的处方
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace app\adminapi\service;
/**
* 诊单 AI 助手 SSE 事件状态机:start -> delta* -> done|error。
*/
final class AssistantSseProtocol
{
private int $seq = 0;
private bool $started = false;
private bool $terminal = false;
/** @param array<string,mixed> $payload */
public function encode(string $event, array $payload): ?string
{
if ($this->terminal || !in_array($event, ['start', 'delta', 'done', 'error'], true)) {
return null;
}
if ((!$this->started && $event !== 'start') || ($this->started && $event === 'start')) {
return null;
}
$nextSeq = $this->seq + 1;
$encoded = json_encode(
['seq' => $nextSeq] + $payload,
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE
);
if (!is_string($encoded)) {
return null;
}
$this->seq = $nextSeq;
$this->started = true;
if (in_array($event, ['done', 'error'], true)) {
$this->terminal = true;
}
return 'event: ' . $event . "\n" . 'data: ' . $encoded . "\n\n";
}
public function isTerminal(): bool
{
return $this->terminal;
}
}
@@ -94,18 +94,6 @@ class DiagnosisValidate extends BaseValidate
public function sceneAdd()
{
<<<<<<< HEAD
// 与 sceneEdit 一样用 only() 白名单,避免全局规则里的 AI 助手 / 跟踪备注字段泄漏到新增诊单
return $this->only([
'patient_name', 'id_card', 'phone', 'gender', 'age', 'diagnosis_date', 'diagnosis_type',
'syndrome_type', 'marital_status', 'height', 'weight', 'region', 'systolic_pressure',
'diastolic_pressure', 'fasting_blood_sugar', 'diabetes_discovery_year',
'local_hospital_diagnosis', 'local_hospital_name', 'past_history', 'symptoms',
'tongue_coating', 'pulse', 'treatment_principle', 'prescription', 'doctor_advice',
'remark', 'current_medications', 'status', 'show_card', 'create_source',
'tongue_images', 'report_files',
]);
=======
// 全局规则里混有跟踪备注、AI 助手字段;新增诊单只去掉这些无关校验。
// 不要改成 only([...]):add 还会写入现病史等多选字段,only 会把它们从请求里丢掉。
return $this->remove('id', true)
@@ -116,7 +104,6 @@ class DiagnosisValidate extends BaseValidate
->remove('model', true)
->remove('report_id', true)
->remove('content', true);
>>>>>>> master
}
public function sceneEdit()
@@ -146,12 +133,21 @@ class DiagnosisValidate extends BaseValidate
}
/** 拉取跟踪备注列表:诊单ID */
public function sceneTrackingNotes()
{
return $this->only(['diagnosis_id']);
}
public function sceneGenerateQrcode()
public function sceneTrackingNotes()
{
return $this->only(['diagnosis_id']);
}
/** IM / video identity: validate shape here; ownership is checked in the logic layer. */
public function sceneCallIdentity()
{
return $this->only(['diagnosis_id', 'patient_id'])
->remove('diagnosis_id', 'checkDiagnosisId')
->append('diagnosis_id', 'gt:0')
->append('patient_id', 'require|integer|gt:0');
}
public function sceneGenerateQrcode()
{
return $this->only(['diagnosis_id', 'doctor_id', 'patient_id', 'share_user_id', 'mini_program_path'])
// The global diagnosis_id rule is required for diagnosis APIs, but

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