Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c444a4a04 | ||
|
|
cc6173e5b0 | ||
|
|
5794f60c5d | ||
|
|
35f91ee37a | ||
|
|
bc1228a310 |
@@ -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
|
||||
|
||||
@@ -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.…` → 拆出序言 + 编号列表
|
||||
- 短中文标签 `XX:YY` 开头 → 视为 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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 || '获取聊天签名失败')
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
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 缓存目录无写权限警告,不影响测试结果。
|
||||
|
After Width: | Height: | Size: 156 KiB |
|
After Width: | Height: | Size: 201 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 167 KiB |
|
After Width: | Height: | Size: 115 KiB |
|
After Width: | Height: | Size: 167 KiB |
|
After Width: | Height: | Size: 586 KiB |
|
After Width: | Height: | Size: 143 KiB |
|
After Width: | Height: | Size: 207 KiB |
|
After Width: | Height: | Size: 159 KiB |
|
After Width: | Height: | Size: 203 KiB |
|
After Width: | Height: | Size: 149 KiB |
@@ -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())
|
||||
@@ -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
|
||||
|
||||
@@ -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": "演示模式不录制"},
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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()
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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() == "操作"
|
||||
|
||||
@@ -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
|
||||
@@ -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"):
|
||||
|
||||
@@ -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("第三位千问风险")
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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, ...]] = []
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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'])) {
|
||||
|
||||
@@ -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) {
|
||||
// 前端常重复回调 endCall(afterCalling + 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
|
||||
|
||||