Compare commits

...
1 Commits
Author SHA1 Message Date
Your Name bc1228a310 geng 2026-08-18 14:08:38 +08:00
77 changed files with 10763 additions and 1181 deletions
+155 -117
View File
@@ -81,10 +81,10 @@
<el-option
v-for="item in group.channels"
:key="item.code"
:label="item.name"
:label="item.search_label || item.name"
:value="item.code"
>
<span class="channel-option">
<span class="channel-option" :class="{ 'is-group': item.kind === 'group' }">
<span>{{ item.name }}</span>
<span>{{ item.customer_count }} </span>
</span>
@@ -96,47 +96,47 @@
</section>
<section class="metric-grid" aria-label="综合转化指标">
<article v-for="metric in metricCards" :key="metric.key" class="metric-card">
<article v-for="metric in visibleMetricCards" :key="metric.key" class="metric-card">
<span>{{ metric.label }}</span>
<strong>{{ formatMetric(metric.key, metric.type) }}</strong>
<small>{{ metric.hint }}</small>
</article>
</section>
<section v-if="showRankings" class="ranking-grid">
<article class="panel ranking-panel">
<div class="panel-heading">
<div>
<h2>{{ rankingSubject }}订单量占比排名</h2>
<p>{{ rankingSubject }}归属统计排除取消拒收及退款</p>
</div>
<span>单位</span>
</div>
<div v-if="dashboard.rankings.orders.length" class="bar-list">
<div v-for="(item, index) in dashboard.rankings.orders" :key="`order-${item.id}`" class="bar-row">
<span class="bar-name" :title="item.name"><b>{{ index + 1 }}</b>{{ item.name }}</span>
<div class="bar-track"><i class="is-teal" :style="{ width: barWidth(item.value, totalOrderValue) }" /></div>
<strong><span>{{ formatNumber(item.value) }} </span><small>{{ formatShare(item.value, totalOrderValue) }}</small></strong>
</div>
</div>
<el-empty v-else :image-size="54" description="当前范围暂无订单数据" />
<section v-if="showRankings" class="ranking-grid">
<article class="panel ranking-panel">
<div class="panel-heading">
<div>
<h2>{{ rankingSubject }}订单量占比排名</h2>
<p>{{ rankingSubject }}归属统计排除取消拒收及退款</p>
</div>
<span>单位</span>
</div>
<div v-if="dashboard.rankings.orders.length" class="bar-list">
<div v-for="(item, index) in dashboard.rankings.orders" :key="`order-${item.id}`" class="bar-row">
<span class="bar-name" :title="item.name"><b>{{ index + 1 }}</b>{{ item.name }}</span>
<div class="bar-track"><i class="is-teal" :style="{ width: barWidth(item.value, totalOrderValue) }" /></div>
<strong><span>{{ formatNumber(item.value) }} </span><small>{{ formatShare(item.value, totalOrderValue) }}</small></strong>
</div>
</div>
<el-empty v-else :image-size="54" description="当前范围暂无订单数据" />
</article>
<article class="panel ranking-panel">
<div class="panel-heading">
<div>
<h2>{{ rankingSubject }}金额占比排名</h2>
<p>{{ rankingSubject }}归属统计仅含未取消未拒收且未退款的有效金额</p>
</div>
<span>单位</span>
</div>
<div v-if="dashboard.rankings.amounts.length" class="bar-list">
<div v-for="(item, index) in dashboard.rankings.amounts" :key="`amount-${item.id}`" class="bar-row">
<span class="bar-name" :title="item.name"><b>{{ index + 1 }}</b>{{ item.name }}</span>
<div class="bar-track"><i class="is-blue" :style="{ width: barWidth(item.value, totalAmountValue) }" /></div>
<strong><span>{{ formatMoney(item.value) }}</span><small>{{ formatShare(item.value, totalAmountValue) }}</small></strong>
</div>
</div>
<div class="panel-heading">
<div>
<h2>{{ rankingSubject }}金额占比排名</h2>
<p>{{ rankingSubject }}归属统计仅含未取消未拒收且未退款的有效金额</p>
</div>
<span>单位</span>
</div>
<div v-if="dashboard.rankings.amounts.length" class="bar-list">
<div v-for="(item, index) in dashboard.rankings.amounts" :key="`amount-${item.id}`" class="bar-row">
<span class="bar-name" :title="item.name"><b>{{ index + 1 }}</b>{{ item.name }}</span>
<div class="bar-track"><i class="is-blue" :style="{ width: barWidth(item.value, totalAmountValue) }" /></div>
<strong><span>{{ formatMoney(item.value) }}</span><small>{{ formatShare(item.value, totalAmountValue) }}</small></strong>
</div>
</div>
<el-empty v-else :image-size="54" description="当前范围暂无金额数据" />
</article>
</section>
@@ -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[],
@@ -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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

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

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

+27 -2
View File
@@ -31,6 +31,8 @@ class _ScreenshotDiagnosisDialog(QWidget):
consultations_module.DiagnosisDialog = _ScreenshotDiagnosisDialog
DENSITY_SIZES = ((1366, 768), (1710, 920))
def _row(identifier: int, variant: int) -> dict[str, Any]:
common: dict[str, Any] = {
@@ -335,7 +337,7 @@ def _save_with_payment_qr(
return path
def render() -> list[Path]:
def _application() -> QApplication:
app = QApplication.instance() or QApplication([])
# The offscreen Windows plugin does not enumerate system fonts. Register
# the same CJK face used by the production QSS when it is available.
@@ -345,12 +347,35 @@ def render() -> list[Path]:
families = QFontDatabase.applicationFontFamilies(font_id)
if families:
app.setFont(QFont(families[0], 9))
return app
def render_density() -> list[Path]:
"""Render only the two desktop-density acceptance sizes."""
app = _application()
root = Path(__file__).resolve().parents[1]
output = root / "artifacts" / "diagnosis_visual"
output.mkdir(parents=True, exist_ok=True)
paths: list[Path] = []
repository = ScreenshotRepository()
for width, height in ((1024, 640), (1440, 900)):
for width, height in DENSITY_SIZES:
page = _new_page(app, repository, width, height)
path = output / f"diagnosis_{width}x{height}.png"
paths.append(_save(page, path))
page.close()
app.processEvents()
return paths
def render() -> list[Path]:
app = _application()
root = Path(__file__).resolve().parents[1]
output = root / "artifacts" / "diagnosis_visual"
output.mkdir(parents=True, exist_ok=True)
paths: list[Path] = []
repository = ScreenshotRepository()
for width, height in ((1024, 640), *DENSITY_SIZES, (1440, 900)):
page = _new_page(app, repository, width, height)
path = output / f"diagnosis_{width}x{height}.png"
paths.append(_save(page, path))
@@ -0,0 +1,119 @@
"""Render patient-level AI report layout regressions with the offscreen Qt backend."""
from __future__ import annotations
import os
from pathlib import Path
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtGui import QFontDatabase
from PySide6.QtWidgets import QApplication, QLabel, QScrollArea
from doctor_workstation.ui.pages.reception import _ReceptionAiAnalysisDialog
def _report_payload(model_key: str) -> dict[str, object]:
model_label = "OpenAI" if model_key == "openai" else "千问"
return {
"model_key": model_key,
"model_label": model_label,
"generated_at": "2026-08-17 10:20:00",
"diagnosis_advice": [
"2型糖尿病,HbA1c 7.5%,近期空腹血糖仍有波动。",
"建议:1. 监测空腹血糖 2. 记录餐后2小时血糖 3. 复核低血糖症状",
r"保留患者原始报告结构。\n结合复诊记录动态调整随访频率。",
],
"risk_assessment": [
{"label": "低血糖", "level": "high"},
{"label": "依从性风险", "level": "medium"},
{"label": "并发症筛查延误风险", "level": "low"},
{"label": "复诊中断风险", "level": "medium"},
{
"label": "肾功能变化可能影响二甲双胍方案,需要结合复查结果持续评估。",
"level": "high",
},
{"label": "饮食波动风险", "level": "low"},
],
"treatment_advice": [
r"二甲双胍 0.5g,每日2次。\n复查肾功能后再评估剂量。",
"继续糖尿病饮食教育,并记录运动后的血糖变化。",
"如出现心悸、出汗或意识异常,及时复测血糖并按流程处置。",
],
}
def _render(
app: QApplication,
output: Path,
*,
width: int,
height: int,
) -> None:
histories = {
"qwen": [_report_payload("qwen")],
"openai": [_report_payload("openai")],
}
dialog = _ReceptionAiAnalysisDialog(histories, preferred_model="qwen")
dialog.resize(width, height)
dialog.show()
for _index in range(3):
app.processEvents()
pixmap = dialog.grab()
if pixmap.width() != width or pixmap.height() != height:
raise RuntimeError(
f"unexpected render size: {pixmap.width()}x{pixmap.height()} "
f"(expected {width}x{height})"
)
output.parent.mkdir(parents=True, exist_ok=True)
if not pixmap.save(str(output), "PNG"):
raise RuntimeError(f"failed to save {output}")
scrolls = dialog.findChildren(QScrollArea)
risks = [
label
for label in dialog.findChildren(QLabel)
if label.property("dialogAiRisk")
]
print(
"PATIENT_AI_LAYOUT",
f"{width}x{height}",
f"scrolls={len(scrolls)}",
f"horizontal_max={dialog.scroll_area.horizontalScrollBar().maximum()}",
f"risk_rows={len({label.y() for label in risks})}",
f"body_height={dialog.scroll_area.widget().height()}",
)
print(output)
dialog.close()
app.processEvents()
def main() -> int:
app = QApplication.instance() or QApplication([])
font_path = Path(r"C:\Windows\Fonts\msyh.ttc")
if font_path.is_file():
QFontDatabase.addApplicationFont(str(font_path))
output_dir = (
Path(__file__).resolve().parents[1]
/ "artifacts"
/ "patient_ai_report_layout"
)
_render(
app,
output_dir / "patient_ai_report_920x760.png",
width=920,
height=760,
)
_render(
app,
output_dir / "patient_ai_report_720x560.png",
width=720,
height=560,
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,182 @@
"""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,
"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,
}
)
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 width == 1024 and not appointments.video_panel.isHidden():
raise RuntimeError("narrow appointment viewport did not collapse the video panel")
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}, video_hidden={appointments.video_panel.isHidden()}"
)
shell.close()
application.processEvents()
return paths
if __name__ == "__main__":
for rendered in render():
print(rendered)
@@ -0,0 +1,180 @@
"""Render deterministic prescription-list density acceptance screenshots."""
from __future__ import annotations
import os
from pathlib import Path
from types import SimpleNamespace
from typing import Any
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtGui import QFont, QFontDatabase
from PySide6.QtWidgets import QApplication
from doctor_workstation.ui.pages import prescription_library as library_module
from doctor_workstation.ui.pages import prescriptions as prescriptions_module
from doctor_workstation.ui.pages.prescription_library import PrescriptionLibraryPage
from doctor_workstation.ui.pages.prescriptions import PrescriptionsPage
from doctor_workstation.ui.theme import apply_theme
DENSITY_SIZES = ((1366, 768), (1710, 920))
def _run_immediately(
function: Any,
*args: Any,
on_success: Any = None,
on_error: Any = None,
on_finished: Any = None,
**kwargs: Any,
) -> object:
try:
result = function(*args, **kwargs)
except Exception as error:
if on_error is not None:
on_error(error)
else:
if on_success is not None:
on_success(result)
finally:
if on_finished is not None:
on_finished()
return object()
prescriptions_module.run_async = _run_immediately
library_module.run_async = _run_immediately
def _issued_row(index: int) -> dict[str, Any]:
return {
"id": 1000 + index,
"sn": f"CF-202608-{1000 + index}",
"prescription_type": "汤剂",
"is_system_auto": index % 2,
"patient_name": ("林晓岚", "周明远", "许安然")[index % 3],
"gender": 2 if index % 2 else 1,
"age": 29 + index,
"audit_status": index % 3,
"void_status": 0,
"has_prescription_order": index % 2,
"creator_id": 7,
"doctor_name": "陈医生",
"assistant_name": "赵医助",
"create_time": f"2026-08-{(index % 9) + 10:02d} 09:30:00",
"herbs": [{"name": "黄芪", "dosage": 15}],
}
def _library_row(index: int) -> dict[str, Any]:
return {
"id": 2000 + index,
"prescription_name": ("益气养阴方", "清热祛湿方", "滋阴调和方")[index % 3],
"formula_type": "主方" if index % 3 else "辅方",
"herbs": [
{"name": "黄芪", "dosage": 15},
{"name": "党参", "dosage": 12},
],
"efficacy": ("益气养阴", "清热祛湿", "滋阴补肾")[index % 3],
"is_public": index % 2,
"disable_edit": 0,
"creator_id": 7,
"creator_name": "陈医生",
"create_time": f"2026-08-{(index % 9) + 10:02d} 08:20:00",
}
class ScreenshotRepository:
def __init__(self) -> None:
self.issued_rows = [_issued_row(index) for index in range(15)]
self.library_rows = [_library_row(index) for index in range(15)]
def list_diagnosis_doctors(self) -> list[dict[str, Any]]:
return [{"id": 7, "name": "陈医生"}, {"id": 8, "name": "孙医生"}]
def list_prescriptions(self, **_filters: Any) -> dict[str, Any]:
return {"lists": self.issued_rows, "count": 44}
def list_prescription_templates(self, **_filters: Any) -> dict[str, Any]:
return {"lists": self.library_rows, "count": 41}
def _application() -> QApplication:
app = QApplication.instance() or QApplication([])
font_path = Path("C:/Windows/Fonts/msyh.ttc")
if font_path.is_file():
font_id = QFontDatabase.addApplicationFont(str(font_path))
families = QFontDatabase.applicationFontFamilies(font_id)
if families:
app.setFont(QFont(families[0], 9))
apply_theme(app)
return app
def _settle(app: QApplication) -> None:
for _ in range(6):
app.processEvents()
def _visible_rows(page: PrescriptionsPage | PrescriptionLibraryPage) -> int:
viewport = page.table.viewport()
return sum(
1
for row in range(page.table.rowCount())
if (
(item := page.table.item(row, 0)) is not None
and (rect := page.table.visualItemRect(item)).isValid()
and rect.top() >= 0
and rect.bottom() < viewport.height()
)
)
def _new_page(
kind: str,
repository: ScreenshotRepository,
) -> PrescriptionsPage | PrescriptionLibraryPage:
current_user = SimpleNamespace(id=7, name="陈医生", root=1, role_ids=[0])
if kind == "prescriptions":
page: PrescriptionsPage | PrescriptionLibraryPage = PrescriptionsPage(
repository, {"*"}, current_user
)
else:
page = PrescriptionLibraryPage(repository, {"*"}, current_user)
page.refresh()
return page
def render() -> list[Path]:
app = _application()
root = Path(__file__).resolve().parents[1]
output = root / "artifacts" / "prescription_list_density"
output.mkdir(parents=True, exist_ok=True)
repository = ScreenshotRepository()
paths: list[Path] = []
for kind in ("prescriptions", "prescription_library"):
for width, height in DENSITY_SIZES:
page = _new_page(kind, repository)
page.resize(width, height)
page.show()
_settle(app)
minimum_rows = 6 if height == 768 else 9
visible_rows = _visible_rows(page)
if visible_rows < minimum_rows:
raise RuntimeError(
f"{kind} at {width}x{height} exposes only {visible_rows} full rows"
)
path = output / f"{kind}_{width}x{height}.png"
if not page.grab().save(str(path), "PNG"):
raise RuntimeError(f"failed to save {path}")
paths.append(path)
page.close()
_settle(app)
return paths
if __name__ == "__main__":
for rendered in render():
print(rendered)
@@ -0,0 +1,69 @@
"""Render the reception daily-record matrix for visual acceptance."""
from __future__ import annotations
import os
from pathlib import Path
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtCore import QThreadPool
from PySide6.QtGui import QFontDatabase
from PySide6.QtWidgets import QApplication
from doctor_workstation.services.mock_repository import DemoDoctorRepository
from doctor_workstation.ui.shell import ShellWindow
from doctor_workstation.ui.theme import apply_theme
def main() -> int:
application = QApplication.instance() or QApplication([])
apply_theme(application)
font_path = Path(r"C:\Windows\Fonts\msyh.ttc")
if font_path.is_file():
QFontDatabase.addApplicationFont(str(font_path))
repository = DemoDoctorRepository()
session = repository.login("doctor", "doctor123")
window = ShellWindow(repository, session)
window.resize(1710, 920)
window.show()
if not window.navigate("reception"):
raise RuntimeError("reception navigation is unavailable")
for _index in range(5):
application.processEvents()
QThreadPool.globalInstance().waitForDone(10_000)
page = window.pages["reception"]
daily_index = next(
index
for index in range(page.detail_tabs.count())
if page.detail_tabs.tabText(index) == "日常记录"
)
page.detail_tabs.setCurrentIndex(daily_index)
for _index in range(3):
application.processEvents()
QThreadPool.globalInstance().waitForDone(10_000)
output = (
Path(__file__).resolve().parents[1]
/ "artifacts"
/ "reception_daily_records"
/ "reception_daily_records_1710x920.png"
)
output.parent.mkdir(parents=True, exist_ok=True)
if not window.grab().save(str(output), "PNG"):
raise RuntimeError(f"failed to save {output}")
print(output)
print(
"DAILY_MATRIX",
page.daily_panel.matrix.rowCount(),
page.daily_panel.matrix.columnCount(),
page.daily_panel.current_range(),
)
window.close()
return 0
if __name__ == "__main__":
raise SystemExit(main())
+68 -19
View File
@@ -138,20 +138,20 @@ class DemoVideoDialog(QDialog):
self.setWindowTitle("视频面诊 · 演示模式")
self.setMinimumSize(760, 520)
self.resize(980, 660)
self.setModal(False)
self.setStyleSheet(
"QDialog{background:#F7F9FE;color:#111F46;}"
"QLabel{color:#111F46;}"
"QFrame#RemoteStage{background:#0E1421;border:1px solid #29334F;border-radius:16px;}"
"QFrame#RemoteStage QLabel{color:#F7F9FE;}"
"QFrame#LocalStage{background:#151D31;border:1px solid #3F4E75;border-radius:12px;}"
"QPushButton{min-width:96px;min-height:40px;padding:0 16px;border-radius:9px;"
"background:#FFFFFF;color:#3F4E75;border:1px solid #E6EAF5;font-weight:600;}"
"QPushButton:hover{color:#4451E2;background:#F0F2FF;border-color:#5761F4;}"
"QPushButton:checked{color:#FFFFFF;background:#5761F4;border-color:#5761F4;}"
"QPushButton#Hangup{color:#FFFFFF;background:#F15B67;border-color:#F15B67;}"
"QPushButton#Hangup:hover{background:#D94857;border-color:#D94857;}"
)
self.setModal(False)
self.setStyleSheet(
"QDialog{background:#F7F9FE;color:#111F46;}"
"QLabel{color:#111F46;}"
"QFrame#RemoteStage{background:#0E1421;border:1px solid #29334F;border-radius:16px;}"
"QFrame#RemoteStage QLabel{color:#F7F9FE;}"
"QFrame#LocalStage{background:#151D31;border:1px solid #3F4E75;border-radius:12px;}"
"QPushButton{min-width:96px;min-height:40px;padding:0 16px;border-radius:9px;"
"background:#FFFFFF;color:#3F4E75;border:1px solid #E6EAF5;font-weight:600;}"
"QPushButton:hover{color:#4451E2;background:#F0F2FF;border-color:#5761F4;}"
"QPushButton:checked{color:#FFFFFF;background:#5761F4;border-color:#5761F4;}"
"QPushButton#Hangup{color:#FFFFFF;background:#F15B67;border-color:#F15B67;}"
"QPushButton#Hangup:hover{background:#D94857;border-color:#D94857;}"
)
root = QVBoxLayout(self)
root.setContentsMargins(22, 18, 22, 22)
@@ -162,7 +162,7 @@ class DemoVideoDialog(QDialog):
header.addWidget(title)
header.addStretch(1)
demo = QLabel("● 演示模式 · 未连接腾讯云")
demo.setStyleSheet("color:#7886AA;font-size:12px;")
demo.setStyleSheet("color:#7886AA;font-size:12px;")
header.addWidget(demo)
self.duration_label = QLabel("00:00")
self.duration_label.setStyleSheet("font-weight:700;")
@@ -177,8 +177,8 @@ class DemoVideoDialog(QDialog):
avatar = QLabel((patient_name or "")[:1])
avatar.setAlignment(Qt.AlignmentFlag.AlignCenter)
avatar.setFixedSize(104, 104)
avatar.setStyleSheet(
"background:#F0F2FF;color:#5761F4;border-radius:52px;font-size:42px;font-weight:700;"
avatar.setStyleSheet(
"background:#F0F2FF;color:#5761F4;border-radius:52px;font-size:42px;font-weight:700;"
)
stage_layout.addWidget(avatar, 0, Qt.AlignmentFlag.AlignHCenter)
waiting = QLabel("等待患者接听…")
@@ -187,7 +187,7 @@ class DemoVideoDialog(QDialog):
stage_layout.addWidget(waiting)
hint = QLabel("生产模式将通过后端短时 UserSig 初始化腾讯 TUICallKit")
hint.setAlignment(Qt.AlignmentFlag.AlignCenter)
hint.setStyleSheet("color:#A4ADC3;font-size:12px;")
hint.setStyleSheet("color:#A4ADC3;font-size:12px;")
stage_layout.addWidget(hint)
stage_layout.addStretch(1)
@@ -197,7 +197,7 @@ class DemoVideoDialog(QDialog):
local_layout = QVBoxLayout(local)
local_label = QLabel("医生画面")
local_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
local_label.setStyleSheet("color:#D9E0F2;font-weight:600;")
local_label.setStyleSheet("color:#D9E0F2;font-weight:600;")
local_layout.addWidget(local_label)
root.addWidget(stage, 1)
@@ -788,6 +788,53 @@ class ApplicationController(QObject):
self.remote_repository.client.close()
def _prefer_native_tls_backend() -> None:
"""Keep Qt's HTTPS stack off OpenSSL.
PySide6 on Windows ships no OpenSSL DLLs of its own, so Qt's OpenSSL
TLS backend resolves to CPython's ``libcrypto-3-x64.dll`` and ends up
sharing one OpenSSL instance with the httpx stack. Concurrent use from
both stacks has crashed the process inside libcrypto (access violation
at a stable offset) while pages downloaded images through
``QNetworkAccessManager``. Route Qt network requests through the
native Schannel backend instead, which uses the Windows certificate
store and never touches OpenSSL.
"""
with suppress(Exception):
from PySide6.QtNetwork import QSslSocket
backends = QSslSocket.availableBackends()
if "schannel" in backends and "openssl" in backends:
QSslSocket.setActiveBackend("schannel")
_CRASH_LOG_HANDLE: Any = None
def _install_crash_handler(config: AppConfig) -> None:
"""Write per-thread Python tracebacks of native crashes into the log dir.
Debug launchers already pass ``-X faulthandler`` and dump to stderr;
keep that behavior and only redirect into ``crash.log`` for packaged
or plain runs where stderr is lost.
"""
global _CRASH_LOG_HANDLE
try:
import faulthandler
if faulthandler.is_enabled():
return
path = config.log_dir / "crash.log"
handle = path.open("a", encoding="utf-8", buffering=1)
handle.write(f"\n=== process started {time.strftime('%Y-%m-%d %H:%M:%S')} ===\n")
faulthandler.enable(file=handle, all_threads=True)
_CRASH_LOG_HANDLE = handle
except Exception:
LOGGER.exception("crash handler could not be installed")
def _create_application(argv: list[str]) -> QApplication:
with suppress(AttributeError):
QGuiApplication.setHighDpiScaleFactorRoundingPolicy(
@@ -796,6 +843,7 @@ def _create_application(argv: list[str]) -> QApplication:
with suppress(AttributeError):
QApplication.setAttribute(Qt.ApplicationAttribute.AA_DontCreateNativeWidgetSiblings, True)
application = QApplication(argv)
_prefer_native_tls_backend()
_install_chinese_translations(application)
application.setApplicationName("甄养堂医生工作站")
application.setApplicationDisplayName("甄养堂医生工作站")
@@ -814,6 +862,7 @@ def main(argv: list[str] | None = None) -> int:
config = AppConfig.load()
configure_logging(config.log_dir, config.log_level)
_install_crash_handler(config)
LOGGER.info("doctor workstation starting", extra={"demo_mode": config.demo_mode})
raw_argv = list(sys.argv if argv is None else argv)
smoke_test = "--smoke-test" in raw_argv
+169 -37
View File
@@ -2,9 +2,10 @@
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 threading import Lock, RLock
from typing import Any
from urllib.parse import urljoin, urlsplit, urlunsplit
@@ -22,6 +23,15 @@ from doctor_workstation.core.errors import (
WorkWechatBindingRequiredError,
)
# One shared transport lock serializes every HTTP exchange in the process.
# The GUI fires page loads, dialog bundles and polling timers from many
# QThreadPool workers that all share a single ``httpx.Client``. Concurrent
# TLS handshakes on one shared SSLContext have repeatedly crashed the
# process inside libcrypto (access violation at a stable offset), so all
# network traffic now goes through this single lock. Requests stay
# off-thread, so the GUI never blocks; only the network itself is serialized.
_HTTP_TRANSPORT_LOCK = Lock()
class ApiClient:
"""A small, testable client implementing the admin API contract.
@@ -62,6 +72,8 @@ class ApiClient:
self._token = token.strip()
self._lock = RLock()
self._owns_client = client is None
self._stream_transport = transport
self._verify = verify
self._client = client or httpx.Client(transport=transport, verify=verify)
@staticmethod
@@ -162,6 +174,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 ``_HTTP_TRANSPORT_LOCK`` or the
process-wide JSON client. A diagnosis response can remain open for
more than a minute and must not block 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 +245,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 _HTTP_TRANSPORT_LOCK:
response = self._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 +324,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 _HTTP_TRANSPORT_LOCK:
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
if response is None: # Defensive; the loop always returns or raises.
raise ApiTransportError(f"{verb} {endpoint} produced no response")
return self._unwrap(response)
@@ -289,7 +361,8 @@ class ApiClient:
"""Close the internally-created HTTP transport."""
if self._owns_client:
self._client.close()
with _HTTP_TRANSPORT_LOCK:
self._client.close()
def __enter__(self) -> ApiClient:
"""Return this client for use as a context manager."""
@@ -414,3 +487,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
+137 -26
View File
@@ -5,7 +5,7 @@ from __future__ import annotations
import mimetypes
import re
import time
from collections.abc import Mapping, Sequence
from collections.abc import Callable, Iterator, Mapping, Sequence
from contextlib import suppress
from datetime import date
from io import BytesIO
@@ -13,11 +13,14 @@ from os import PathLike
from pathlib import Path
from typing import Any, Final, Literal, Protocol
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 +194,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,
@@ -1328,25 +1341,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 +1354,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,
@@ -2638,7 +2690,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 = {
@@ -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
@@ -450,12 +472,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 +549,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 +1189,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 +1336,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 +1351,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 +1373,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 +1387,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 +1433,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):
@@ -1479,6 +1531,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 +1658,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 +1689,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 +1703,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)
@@ -1,5 +1,13 @@
"""Reusable doctor-workstation dialogs."""
from .ai_consult import AiConsultDialog, can_open_ai_consult, present_ai_consult
from .diagnosis import DiagnosisDialog, OrderDetailDrawer, present_order_detail
__all__ = ["DiagnosisDialog", "OrderDetailDrawer", "present_order_detail"]
__all__ = [
"AiConsultDialog",
"DiagnosisDialog",
"OrderDetailDrawer",
"can_open_ai_consult",
"present_ai_consult",
"present_order_detail",
]
File diff suppressed because it is too large Load Diff
@@ -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."""
@@ -12,6 +12,7 @@ from typing import Any
from PySide6.QtCore import QDate, QSize, Qt, QTimer, QUrl, Signal
from PySide6.QtGui import QAction, QBrush, QColor, QDesktopServices, QPixmap
from PySide6.QtWidgets import (
QAbstractItemView,
QButtonGroup,
QCheckBox,
QComboBox,
@@ -34,6 +35,7 @@ from PySide6.QtWidgets import (
)
from ..dialogs import DiagnosisDialog
from ..dialogs.ai_consult import can_open_ai_consult, present_ai_consult
from ..dialogs.prescription import (
PrescriptionDetailDialog,
PrescriptionEditorDialog,
@@ -92,12 +94,12 @@ _SEMANTIC_COLORS = {
APPOINTMENTS_LIGHT_QSS = """
#AppointmentsPage QWidget#PageHeader {
min-height: 42px;
max-height: 42px;
min-height: 26px;
max-height: 26px;
}
#AppointmentsPage QFrame#AppointmentFilterPanel {
min-height: 92px;
max-height: 92px;
min-height: 80px;
max-height: 80px;
background-color: #FFFFFF;
border: 1px solid #E2E7F4;
border-radius: 10px;
@@ -109,9 +111,9 @@ APPOINTMENTS_LIGHT_QSS = """
border-radius: 10px;
}
#AppointmentsPage QPushButton[appointmentStat="true"] {
min-height: 34px;
max-height: 34px;
padding: 0 10px;
min-height: 30px;
max-height: 30px;
padding: 0 7px;
color: #59698E;
background-color: #F8F9FD;
border: 0;
@@ -141,8 +143,8 @@ APPOINTMENTS_LIGHT_QSS = """
background-color: #FFF8ED;
}
#AppointmentsPage QLineEdit#AppointmentPatientSearch {
min-height: 34px;
max-height: 34px;
min-height: 30px;
max-height: 30px;
background-color: #FFFFFF;
border: 1px solid #DDE3F0;
border-radius: 7px;
@@ -157,10 +159,10 @@ APPOINTMENTS_LIGHT_QSS = """
padding: 0 2px;
}
#AppointmentsPage QTableWidget#AppointmentTable::item {
padding: 6px 8px;
padding: 5px 7px;
}
#AppointmentsPage QTableWidget#AppointmentTable QHeaderView::section {
min-height: 35px;
min-height: 34px;
background-color: #F8FAFF;
}
#AppointmentsPage QTableWidget#AppointmentTable {
@@ -211,8 +213,9 @@ APPOINTMENTS_LIGHT_QSS = """
background-color: #FFFFFF;
}
#AppointmentsPage QPushButton[compactAction="true"] {
min-height: 30px;
padding: 0 10px;
min-height: 28px;
max-height: 28px;
padding: 0 8px;
border-radius: 7px;
font-size: 11px;
}
@@ -238,7 +241,7 @@ APPOINTMENTS_LIGHT_QSS = """
#AppointmentsPage QPushButton[variant="chip"]:focus { border-color: #8D9BFF; }
#AppointmentsPage QTabBar#AppointmentStatusTabs::tab {
min-width: 62px;
min-height: 32px;
min-height: 28px;
padding: 0 7px;
color: #7481A3;
background-color: transparent;
@@ -326,9 +329,9 @@ APPOINTMENTS_LIGHT_QSS = """
#AppointmentsPage QPushButton[videoActionKind="success"] { color: #159C79; }
#AppointmentsPage QPushButton[videoActionKind="warning"] { color: #C17A16; }
#AppointmentsPage QPushButton[filterChoice="true"] {
min-height: 32px;
max-height: 32px;
padding: 0 13px;
min-height: 28px;
max-height: 28px;
padding: 0 10px;
color: #405074;
background-color: transparent;
border: 1px solid transparent;
@@ -436,14 +439,9 @@ def _diagnosis_id(row: Any) -> int:
def _video_patient_id(row: Any) -> int:
source = _as_int(first_value(row, "source_patient_id", default=0))
if source > 0:
return source
diagnosis = _as_int(first_value(row, "diagnosis_id", default=0))
patient = _as_int(first_value(row, "patient_id", default=0))
if diagnosis > 0 and patient > 0 and diagnosis != patient:
return patient
return patient
"""Return only the appointment row's explicitly separated patient owner."""
return _as_int(first_value(row, "source_patient_id", default=0))
def _appointment_id(row: Any) -> int:
@@ -604,6 +602,56 @@ def _flatten_departments(
return result
def _stable_signature_value(value: Any) -> Any:
"""Return a deterministic, order-independent representation of API data."""
if value is None or isinstance(value, (bool, int, str)):
return value
if isinstance(value, float):
return repr(value)
if isinstance(value, Mapping):
items = (
(str(key), _stable_signature_value(item))
for key, item in value.items()
)
return tuple(sorted(items, key=lambda pair: pair[0]))
if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
return tuple(_stable_signature_value(item) for item in value)
if isinstance(value, (set, frozenset)):
frozen = (_stable_signature_value(item) for item in value)
return tuple(sorted(frozen, key=repr))
dataclass_fields = getattr(type(value), "__dataclass_fields__", None)
if isinstance(dataclass_fields, Mapping):
return (
type(value).__qualname__,
tuple(
(name, _stable_signature_value(getattr(value, name, None)))
for name in dataclass_fields
),
)
state = getattr(value, "__dict__", None)
if isinstance(state, Mapping):
return (type(value).__qualname__, _stable_signature_value(state))
isoformat = getattr(value, "isoformat", None)
if callable(isoformat):
try:
return (type(value).__qualname__, isoformat())
except (TypeError, ValueError):
pass
return (type(value).__qualname__, str(value))
def _appointment_result_signature(rows: Sequence[Any], total: int, extend: Any) -> tuple[Any, ...]:
"""Build the stable render signature used to short-circuit unchanged polling."""
return (
"appointments-render-v1",
total,
_stable_signature_value(rows),
_stable_signature_value(extend),
)
class AppointmentsPage(QWidget):
"""Desktop appointment list with status tabs, filters, call and prescription."""
@@ -639,6 +687,9 @@ class AppointmentsPage(QWidget):
self._end_date = self._start_date
self._status_counts: dict[int, int] = {1: 0, 2: 0, 3: 0, 4: 0}
self._dict_labels: dict[str, dict[str, str]] = {}
self._last_render_signature: tuple[Any, ...] | None = None
self._responsive_narrow: bool | None = None
self._video_panel_user_open = False
self._can_plain_phone = _canonical_allowed(
permissions, "tcm.diagnosis/phonePlain", default=False
)
@@ -646,20 +697,23 @@ class AppointmentsPage(QWidget):
self._is_admin = _is_admin_user(current_user)
root = QVBoxLayout(self)
root.setContentsMargins(22, 7, 6, 8)
root.setContentsMargins(18, 3, 6, 8)
root.setSpacing(4)
header = PageHeader("问诊列表")
header.title_label.hide()
header.subtitle_label.hide()
root.addWidget(header)
root.addWidget(self._build_filter_panel())
self.header = PageHeader("问诊列表")
self.header.title_label.hide()
self.header.subtitle_label.hide()
root.addWidget(self.header)
self.filter_panel = self._build_filter_panel()
root.addWidget(self.filter_panel)
self.banner = MessageBanner()
root.addWidget(self.banner)
root.addWidget(self._build_content(), 1)
self.content_host = self._build_content()
root.addWidget(self.content_host, 1)
self.poll_timer = QTimer(self)
self.poll_timer.setInterval(LIST_POLL_MS)
self.poll_timer.timeout.connect(lambda: self.refresh(silent=True))
self._apply_responsive_layout()
def _diagnosis_dialog(self) -> DiagnosisDialog:
dialog = getattr(self, "_diagnosis_dialog_impl", None)
@@ -673,76 +727,77 @@ class AppointmentsPage(QWidget):
frame = QFrame()
frame.setObjectName("AppointmentFilterPanel")
layout = QVBoxLayout(frame)
layout.setContentsMargins(4, 2, 12, 12)
layout.setSpacing(10)
layout.setContentsMargins(6, 4, 8, 6)
layout.setSpacing(4)
date_row = QHBoxLayout()
date_row.setSpacing(8)
date_row.setSpacing(4)
self.date_buttons: dict[str, QPushButton] = {}
self._date_stat_labels: dict[str, str] = {}
stat_widths = {
"yesterday": 106,
"day_before": 98,
"today": 98,
"tomorrow": 94,
"day_after": 94,
"": 110,
}
for source_label, preset in DATE_PRESETS:
label = "全部" if preset == "" else source_label
button = QPushButton(f"{label} 0")
button.setCheckable(True)
button.setProperty("appointmentStat", True)
button.setFixedWidth(stat_widths[preset])
button.setMinimumWidth(0)
button.clicked.connect(
lambda _checked=False, value=preset: self._set_date_preset(value)
)
self.date_buttons[preset] = button
self._date_stat_labels[preset] = label
date_row.addWidget(button)
date_row.addWidget(button, 1)
self.date_buttons["today"].setChecked(True)
self.date_overflow_button = QPushButton("日期")
self.date_overflow_button.setProperty("appointmentStat", True)
date_menu = QMenu(self.date_overflow_button)
for source_label, preset in DATE_PRESETS:
action = date_menu.addAction(source_label if preset else "不限日期")
action.triggered.connect(
lambda _checked=False, value=preset: self._set_date_preset(value)
)
date_menu.addSeparator()
date_menu.addAction("自定义日期").triggered.connect(self._open_custom_date)
self.date_overflow_button.setMenu(date_menu)
date_row.addWidget(self.date_overflow_button)
self.pending_stat_button = QPushButton("待预约 0")
self.pending_stat_button.setProperty("appointmentStat", True)
self.pending_stat_button.setProperty("appointmentStatKind", "pending")
self.pending_stat_button.setFixedWidth(104)
self.pending_stat_button.setMinimumWidth(0)
self.pending_stat_button.clicked.connect(lambda: self._set_status_from_stat(1))
date_row.addWidget(self.pending_stat_button)
date_row.addWidget(self.pending_stat_button, 1)
self.completed_stat_button = QPushButton("已完成 0")
self.completed_stat_button.setProperty("appointmentStat", True)
self.completed_stat_button.setProperty("appointmentStatKind", "success")
self.completed_stat_button.setFixedWidth(104)
self.completed_stat_button.setMinimumWidth(0)
self.completed_stat_button.clicked.connect(lambda: self._set_status_from_stat(3))
date_row.addWidget(self.completed_stat_button)
date_row.addWidget(self.completed_stat_button, 1)
self.unassigned_stat_button = QPushButton("待分配医助 0")
self.unassigned_stat_button.setCheckable(True)
self.unassigned_stat_button.setProperty("appointmentStat", True)
self.unassigned_stat_button.setProperty("appointmentStatKind", "warning")
self.unassigned_stat_button.setFixedWidth(130)
self.unassigned_stat_button.setMinimumWidth(0)
self.unassigned_stat_button.clicked.connect(self._toggle_unassigned_filter)
date_row.addWidget(self.unassigned_stat_button)
date_row.addStretch(1)
date_row.addWidget(self.unassigned_stat_button, 1)
self.patient_input = QLineEdit()
self.patient_input.setObjectName("AppointmentPatientSearch")
self.patient_input.setPlaceholderText("患者姓名 / 手机号")
self.patient_input.setClearButtonEnabled(True)
self.patient_input.setFixedWidth(204)
self.patient_input.returnPressed.connect(self._search)
date_row.addWidget(self.patient_input)
date_row.addWidget(self.patient_input, 2)
search = QPushButton("查询")
search.setProperty("variant", "primary")
search.setFixedWidth(62)
search.clicked.connect(self._search)
date_row.addWidget(search)
layout.addLayout(date_row)
filter_row = QHBoxLayout()
filter_row.setSpacing(7)
status_label = QLabel("挂号状态:")
status_label.setObjectName("FilterRowLabel")
filter_row.addWidget(status_label)
filter_row.setSpacing(5)
self.status_filter_label = QLabel("挂号状态:")
self.status_filter_label.setObjectName("FilterRowLabel")
filter_row.addWidget(self.status_filter_label)
self.tab_bar = QTabBar()
self.tab_bar.setObjectName("AppointmentStatusTabs")
self._tab_indexes: dict[str | int, int] = {}
@@ -753,13 +808,15 @@ class AppointmentsPage(QWidget):
self.tab_bar.currentChanged.connect(self._tab_changed)
filter_row.addWidget(self.tab_bar)
self.filter_dividers: list[QLabel] = []
divider = QLabel("")
divider.setObjectName("FilterDivider")
self.filter_dividers.append(divider)
filter_row.addWidget(divider)
confirm_label = QLabel("确认状态:")
confirm_label.setObjectName("FilterRowLabel")
filter_row.addWidget(confirm_label)
self.confirm_filter_label = QLabel("确认状态:")
self.confirm_filter_label.setObjectName("FilterRowLabel")
filter_row.addWidget(self.confirm_filter_label)
self.confirmed_filter = QComboBox(frame)
self.confirmed_filter.addItem("全部", "")
self.confirmed_filter.addItem("已确认", "1")
@@ -783,6 +840,7 @@ class AppointmentsPage(QWidget):
divider = QLabel("")
divider.setObjectName("FilterDivider")
self.filter_dividers.append(divider)
filter_row.addWidget(divider)
self.more_filters_button = QPushButton("更多筛选")
@@ -825,26 +883,32 @@ class AppointmentsPage(QWidget):
def _build_content(self) -> QWidget:
host = QWidget()
host.setMinimumHeight(0)
layout = QHBoxLayout(host)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(4)
layout.addWidget(self._build_table_card(), 3)
layout.addWidget(self._build_video_panel(), 1)
self.content_layout = layout
self.table_card = self._build_table_card()
self.video_panel = self._build_video_panel()
layout.addWidget(self.table_card, 3)
layout.addWidget(self.video_panel, 1)
return host
def _build_video_panel(self) -> QWidget:
panel = QFrame()
panel.setObjectName("VideoConsultPanel")
panel.setFixedWidth(420)
panel.setMinimumWidth(0)
panel.setMaximumWidth(420)
layout = QVBoxLayout(panel)
layout.setContentsMargins(18, 17, 18, 10)
layout.setSpacing(10)
layout.setContentsMargins(14, 12, 14, 8)
layout.setSpacing(8)
title = QLabel("视频问诊")
title.setObjectName("VideoConsultTitle")
layout.addWidget(title)
self.video_list = QListWidget()
self.video_list.setObjectName("VideoConsultList")
self.video_list.setSpacing(0)
self.video_list.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
self.video_list.currentRowChanged.connect(self._video_row_selected)
self.video_list.itemDoubleClicked.connect(lambda _item: self._open_detail())
layout.addWidget(self.video_list, 1)
@@ -857,9 +921,10 @@ class AppointmentsPage(QWidget):
def _build_table_card(self) -> QWidget:
card = QFrame()
card.setObjectName("AppointmentMainCard")
card.setMinimumHeight(0)
layout = QVBoxLayout(card)
layout.setContentsMargins(4, 18, 10, 10)
layout.setSpacing(14)
layout.setContentsMargins(4, 8, 8, 8)
layout.setSpacing(8)
compatibility_host = QWidget(card)
compatibility_host.hide()
@@ -904,7 +969,7 @@ class AppointmentsPage(QWidget):
widget.setProperty("compactAction", True)
actions = QHBoxLayout()
actions.setSpacing(9)
actions.setSpacing(6)
self.toolbar_edit_button = QPushButton("编辑患者", card)
self.toolbar_edit_button.setProperty("variant", "primary")
self.toolbar_edit_button.setProperty("compactAction", True)
@@ -923,6 +988,13 @@ class AppointmentsPage(QWidget):
self.toolbar_qr_button.setEnabled(False)
self.toolbar_qr_button.clicked.connect(self._request_video_qr)
actions.addWidget(self.toolbar_qr_button)
self.toolbar_ai_consult_button = QPushButton("AI 分析", card)
self.toolbar_ai_consult_button.setProperty("variant", "secondary")
self.toolbar_ai_consult_button.setProperty("compactAction", True)
self.toolbar_ai_consult_button.setVisible(can_open_ai_consult(self.permissions))
self.toolbar_ai_consult_button.setEnabled(False)
self.toolbar_ai_consult_button.clicked.connect(self._open_ai_consult)
actions.addWidget(self.toolbar_ai_consult_button)
self.toolbar_cancel_button = QPushButton("取消挂号", card)
self.toolbar_cancel_button.setProperty("variant", "danger")
self.toolbar_cancel_button.setProperty("compactAction", True)
@@ -932,9 +1004,16 @@ class AppointmentsPage(QWidget):
self.toolbar_cancel_button.setEnabled(False)
self.toolbar_cancel_button.clicked.connect(self._cancel_selected)
actions.addWidget(self.toolbar_cancel_button)
self.video_panel_button = QPushButton("视频问诊", card)
self.video_panel_button.setCheckable(True)
self.video_panel_button.setProperty("variant", "ghost")
self.video_panel_button.setProperty("compactAction", True)
self.video_panel_button.clicked.connect(self._toggle_video_panel)
actions.addWidget(self.video_panel_button)
actions.addStretch(1)
refresh = QPushButton("刷新", card)
refresh.setProperty("variant", "ghost")
refresh.setProperty("compactAction", True)
refresh.clicked.connect(lambda: self.refresh())
actions.addWidget(refresh)
layout.addLayout(actions)
@@ -958,17 +1037,56 @@ class AppointmentsPage(QWidget):
]
)
self.table.setObjectName("AppointmentTable")
self.table.setMinimumHeight(0)
self.table.setWordWrap(True)
self.table.horizontalHeader().setFixedHeight(38)
self.table.verticalHeader().setDefaultSectionSize(66)
self.table.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
self.table.horizontalHeader().setFixedHeight(34)
self.table.verticalHeader().setDefaultSectionSize(60)
self.table.itemSelectionChanged.connect(self._selection_changed)
self.table.itemDoubleClicked.connect(lambda _item: self._open_detail())
layout.addWidget(self.table, 1)
self.pager = Pager(self._page_size)
self.pager.setMaximumHeight(38)
self.pager.page_changed.connect(self._page_changed)
layout.addWidget(self.pager)
return card
def _toggle_video_panel(self, checked: bool) -> None:
self._video_panel_user_open = checked
self._apply_responsive_layout()
def _apply_responsive_layout(self) -> None:
"""Keep the table usable while retaining a video-list entry at low widths."""
panel = getattr(self, "video_panel", None)
button = getattr(self, "video_panel_button", None)
if not isinstance(panel, QWidget) or not isinstance(button, QPushButton):
return
narrow = self.width() < 1120
for preset in ("yesterday", "day_before", "tomorrow", "day_after"):
self.date_buttons[preset].setVisible(not narrow)
self.date_overflow_button.setVisible(narrow)
self.status_filter_label.setVisible(not narrow)
self.confirm_filter_label.setVisible(not narrow)
if narrow:
if self._responsive_narrow is not True:
self._video_panel_user_open = False
button.show()
button.setChecked(self._video_panel_user_open)
panel.setVisible(self._video_panel_user_open)
if self._video_panel_user_open:
panel.setFixedWidth(max(250, min(300, round(self.width() * 0.32))))
else:
button.hide()
button.setChecked(False)
panel.show()
panel.setFixedWidth(max(300, min(420, round(self.width() * 0.30))))
self._responsive_narrow = narrow
def resizeEvent(self, event: Any) -> None:
super().resizeEvent(event)
self._apply_responsive_layout()
def _sync_video_list(self, rows: Sequence[Any]) -> None:
selected_id = (
_appointment_id(self.video_list.currentItem().data(Qt.ItemDataRole.UserRole))
@@ -1002,6 +1120,7 @@ class AppointmentsPage(QWidget):
self.video_list.setCurrentRow(row_to_select)
else:
self.video_list.setCurrentRow(-1)
self.video_panel_button.setText(f"视频问诊 {len(rows)}")
def _build_video_patient_card(self, row: Any) -> QWidget:
card = QFrame()
@@ -1066,6 +1185,8 @@ class AppointmentsPage(QWidget):
)
add_action("查看", self._open_detail)
add_action("诊单", self._edit_patient, "tcm.diagnosis/edit")
if can_open_ai_consult(self.permissions):
add_action("AI 分析", self._open_ai_consult)
add_action("开方", self._open_prescription, "tcm.diagnosis/kaifang")
add_action("二维码", self._request_video_qr, "tcm.diagnosis/videoQr", "success")
more = QPushButton("更多", card)
@@ -1109,6 +1230,8 @@ class AppointmentsPage(QWidget):
add_action("完成问诊", self._complete_selected, "doctor.appointment/complete")
add_action("查看病历", self._view_case, "tcm.diagnosis/kaifang")
if can_open_ai_consult(self.permissions):
add_action("AI 分析", self._open_ai_consult)
if can_open_diagnosis_ai_report(self.permissions):
add_action("AI 报告", self._open_ai_report)
add_action("取消挂号", self._cancel_selected, "doctor.appointment/cancel")
@@ -1193,6 +1316,13 @@ class AppointmentsPage(QWidget):
self._date_preset = preset
for key, button in self.date_buttons.items():
button.setChecked(key == preset)
overflow_labels = {
"yesterday": "昨天",
"day_before": "前天",
"tomorrow": "明天",
"day_after": "后天",
}
self.date_overflow_button.setText(overflow_labels.get(preset, "日期"))
target = _offset_date(preset)
if target is None:
self._start_date = ""
@@ -1248,6 +1378,7 @@ class AppointmentsPage(QWidget):
for button in self.date_buttons.values():
button.setChecked(False)
self.date_buttons[""].setChecked(True)
self.date_overflow_button.setText("自定义")
self._start_date = start_date
self._end_date = end_date
self._page = 1
@@ -1332,6 +1463,16 @@ class AppointmentsPage(QWidget):
rows = page_items(result)
total = page_total(result)
extend = get_value(result, "extend", {}) or {}
signature = (
self._page,
_stable_signature_value(self._query_filters()),
*_appointment_result_signature(rows, total, extend),
)
if signature == self._last_render_signature:
if not silent:
self.banner.clear()
return
self._last_render_signature = signature
status_count = get_value(extend, "status_count", {}) or {}
if isinstance(status_count, Mapping):
for key in (1, 2, 3, 4):
@@ -1507,7 +1648,7 @@ class AppointmentsPage(QWidget):
default=1,
)
height = line_count * line_height + TABLE_CELL_VERTICAL_PADDING
self.table.setRowHeight(row_index, max(66, height))
self.table.setRowHeight(row_index, max(60, min(66, height)))
def _load_error(self, error: Exception, generation: int, silent: bool) -> None:
if generation != self._generation:
@@ -1551,6 +1692,9 @@ class AppointmentsPage(QWidget):
self.prescription_button.setText(prescription_action_label(row) if has_row else "开方")
self.case_button.setEnabled(has_row and _appointment_id(row) > 0)
self.ai_button.setEnabled(has_row and _diagnosis_id(row) > 0)
self.toolbar_ai_consult_button.setEnabled(
has_row and can_open_ai_consult(self.permissions) and _diagnosis_id(row) > 0
)
self.cancel_button.setEnabled(has_row and status == 1)
self.toolbar_edit_button.setEnabled(has_row and _diagnosis_id(row) > 0)
self.toolbar_qr_button.setEnabled(
@@ -1592,6 +1736,19 @@ class AppointmentsPage(QWidget):
)
present_diagnosis_ai_report(self.repository, self.permissions, self, payload)
def _open_ai_consult(self) -> None:
row = self._current_row()
diagnosis_id = _diagnosis_id(row)
present_ai_consult(
self.repository,
self.permissions,
self,
diagnosis_id=diagnosis_id,
patient_id=_video_patient_id(row),
seed=row,
source_title="问诊列表",
)
def _request_video(self) -> None:
if not _canonical_allowed(
self.permissions, "doctor.appointment/prescription", default=False
@@ -1640,7 +1797,7 @@ class AppointmentsPage(QWidget):
if row is None or _status_value(row) == 3:
return
diagnosis_id = _diagnosis_id(row)
patient_id = _video_patient_id(row) or _as_int(first_value(row, "patient_id", default=0))
patient_id = _video_patient_id(row)
doctor_id = _as_int(first_value(row, "doctor_id", default=0))
share_user_id = _as_int(first_value(self.current_user, "id", default=0))
if diagnosis_id <= 0 or patient_id <= 0 or doctor_id <= 0 or share_user_id <= 0:
@@ -29,6 +29,7 @@ from PySide6.QtWidgets import (
QMessageBox,
QPushButton,
QScrollArea,
QSizePolicy,
QSpinBox,
QTableWidget,
QTableWidgetItem,
@@ -46,6 +47,7 @@ from ..diagnosis_index_widgets import (
FlowWidget,
)
from ..dialogs import DiagnosisDialog
from ..dialogs.ai_consult import can_open_ai_consult, present_ai_consult
from ..dialogs.prescription import (
PrescriptionDetailDialog,
PrescriptionEditorDialog,
@@ -68,8 +70,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 +88,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 +109,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 +153,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 +173,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;
@@ -1068,30 +1074,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 +1167,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 +1372,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 +1385,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 +1487,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 +1536,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 +1712,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 +2255,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,
@@ -2954,6 +2966,21 @@ class ConsultationsPage(QWidget):
return
self._diagnosis_dialog.open_view_only(diagnosis_id, seed=record)
def _open_ai_consult(self) -> None:
record = self.table.current_data()
diagnosis_id = _as_int(first_value(record, "diagnosis_id", "id", default=0))
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="患者列表",
)
def _open_edit(self) -> None:
if not _canonical_allowed(self.permissions, "tcm.diagnosis/edit"):
return
@@ -3324,7 +3351,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()
+90 -66
View File
@@ -37,7 +37,8 @@ from PySide6.QtWidgets import (
)
from ..appointment_drawer import AppointmentDrawer
from ..dialogs import DiagnosisDialog, present_order_detail
from ..dialogs import DiagnosisDialog, present_ai_consult, present_order_detail
from ..dialogs.ai_consult import can_open_ai_consult
from ..dialogs.prescription import PrescriptionOrderListDialog
from ..theme import mark_business_dialog
from ..widgets import (
@@ -98,7 +99,7 @@ _SEMANTIC_COLORS = {
}
PATIENTS_LIGHT_QSS = """
#PatientsPage QWidget#PageHeader { min-height: 88px; max-height: 88px; }
#PatientsPage QWidget#PageHeader { min-height: 62px; max-height: 62px; }
#PatientsPage QWidget#PageHeader QLabel[role="pageTitle"] {
color: #10204A;
font-size: 20px;
@@ -109,8 +110,8 @@ PATIENTS_LIGHT_QSS = """
font-size: 12px;
}
#PatientsPage QPushButton[patientSearchAction="true"] {
min-height: 34px;
max-height: 34px;
min-height: 32px;
max-height: 32px;
color: #FFFFFF;
background-color: #5265F6;
border: 1px solid #5265F6;
@@ -122,24 +123,24 @@ PATIENTS_LIGHT_QSS = """
border-color: #4557E7;
}
#PatientsPage QFrame[patientListFilter="true"] {
min-height: 112px;
max-height: 112px;
min-height: 88px;
max-height: 88px;
background-color: #FFFFFF;
border: 1px solid #E6EAF5;
border-radius: 11px;
}
#PatientsPage QFrame[patientListFilter="true"] QLineEdit,
#PatientsPage QFrame[patientListFilter="true"] QDateEdit {
min-height: 34px;
max-height: 34px;
min-height: 32px;
max-height: 32px;
background-color: #FFFFFF;
border: 1px solid #E2E7F4;
border-radius: 7px;
}
#PatientsPage QPushButton[patientStatusChip="true"] {
min-height: 34px;
max-height: 34px;
padding: 0 13px;
min-height: 32px;
max-height: 32px;
padding: 0 10px;
color: #405074;
background-color: #F8F9FD;
border: 0;
@@ -155,9 +156,9 @@ PATIENTS_LIGHT_QSS = """
border: 1px solid #9EA8FF;
}
#PatientsPage QPushButton[patientQuickDate="true"] {
min-height: 34px;
max-height: 34px;
padding: 0 12px;
min-height: 32px;
max-height: 32px;
padding: 0 9px;
color: #29365C;
background-color: transparent;
border: 1px solid transparent;
@@ -179,7 +180,7 @@ PATIENTS_LIGHT_QSS = """
}
#PatientsPage QTabWidget#PatientWorkspaceTabs QTabBar::tab {
min-width: 86px;
min-height: 40px;
min-height: 34px;
padding: 0 6px;
margin-right: 8px;
color: #59698E;
@@ -193,9 +194,9 @@ PATIENTS_LIGHT_QSS = """
border-bottom-color: #5265F6;
}
#PatientsPage QPushButton[summaryCard="true"] {
min-height: 54px;
max-height: 54px;
padding: 0 13px;
min-height: 42px;
max-height: 42px;
padding: 0 11px;
color: #5265F6;
background-color: #F7F8FF;
border: 1px solid #E1E5FF;
@@ -1277,6 +1278,7 @@ class PatientListWorkspace(QWidget):
fill_id_requested = Signal(object)
cancel_requested = Signal(object)
orders_requested = Signal(object)
ai_consult_requested = Signal(object)
scope_changed = Signal(str)
def __init__(self, repository: Any, permissions: Any, parent: QWidget | None = None) -> None:
@@ -1289,15 +1291,18 @@ class PatientListWorkspace(QWidget):
self._date_mode = "all"
self._scope = "按权限加载"
self._setting_dates = False
self.setMinimumHeight(0)
root = QVBoxLayout(self)
root.setContentsMargins(0, 2, 0, 0)
root.setSpacing(10)
root.addWidget(self._build_filters())
root.setSpacing(6)
self.filter_card = self._build_filters()
root.addWidget(self.filter_card)
root.addLayout(self._build_summary())
self.banner = MessageBanner()
root.addWidget(self.banner)
root.addWidget(self._build_table(), 1)
self.table_card = self._build_table()
root.addWidget(self.table_card, 1)
@property
def scope(self) -> str:
@@ -1308,20 +1313,17 @@ 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.setContentsMargins(0, 0, 0, 0)
top_row.setSpacing(0)
top_row.setSpacing(6)
self.keyword_edit = QLineEdit()
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 +1334,10 @@ 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()
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 +1369,25 @@ 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)
top_row.addWidget(self.status_host, 2)
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)
panel.addLayout(top_row)
bottom_row = QHBoxLayout()
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)
self.quick_host = QWidget()
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,15 +1408,12 @@ 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()
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.setCalendarPopup(True)
self.start_date.setDisplayFormat("yyyy-MM-dd")
@@ -1430,7 +1423,6 @@ 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.setCalendarPopup(True)
@@ -1438,14 +1430,11 @@ class PatientListWorkspace(QWidget):
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)
bottom_row.addWidget(self.date_host, 2)
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)
panel.addLayout(bottom_row)
return card
@@ -1460,7 +1449,7 @@ class PatientListWorkspace(QWidget):
):
button = QPushButton(f"{label}\n0 人")
button.setProperty("summaryCard", True)
button.setFixedHeight(56)
button.setFixedHeight(42)
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 +1460,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 +1475,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 +1516,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 +1592,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 +1639,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 +1691,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 +2617,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 +2640,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 +2702,17 @@ 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_order_diagnosis(self, row: Any) -> None:
editable = _canonical_allowed(self.permissions, "tcm.diagnosis/edit")
readable = _canonical_allowed(self.permissions, "tcm.diagnosis/readonlyDetail")
@@ -8,7 +8,6 @@ from typing import Any
from PySide6.QtCore import QSize, Qt
from PySide6.QtGui import QColor, QFont
from PySide6.QtWidgets import (
QAbstractItemView,
QComboBox,
QDialog,
QFrame,
@@ -26,6 +25,7 @@ from PySide6.QtWidgets import (
from ..dialogs.prescription import PrescriptionTemplateDialog
from ..dialogs.prescription_ai import PrescriptionAiReportDialog, can_open_ai_explain
from ..widgets import (
BusinessPager,
EmptyState,
MessageBanner,
MetricCard,
@@ -44,7 +44,6 @@ from ..widgets import (
show_toast,
)
from .prescriptions import (
BusinessPager,
_cell_host,
_painted_icon,
_row_action_button,
@@ -54,27 +53,27 @@ from .prescriptions import (
PRESCRIPTION_LIBRARY_PAGE_QSS = """
#PrescriptionLibraryPage { background: #F8FAFF; }
#PrescriptionLibraryPage QWidget#PageHeader { min-height: 84px; max-height: 84px; }
#PrescriptionLibraryPage QWidget#PageHeader { min-height: 62px; max-height: 62px; }
#PrescriptionLibraryPage QWidget#PageHeader QLabel[role="breadcrumb"],
#PrescriptionLibraryPage QWidget#PageHeader QLabel[role="breadcrumbSeparator"],
#PrescriptionLibraryPage QWidget#PageHeader QLabel[role="breadcrumbCurrent"] {
min-height: 16px; max-height: 16px;
min-height: 14px; max-height: 14px;
}
#PrescriptionLibraryPage QLabel[role="pageTitle"] {
color: #15224A; font-size: 20px; font-weight: 700;
}
#PrescriptionLibraryPage QWidget#PageHeader QLabel[role="muted"] {
color: #7481A3; font-size: 12px; padding-top: 5px;
color: #7481A3; font-size: 12px;
}
#PrescriptionLibraryPage QFrame#MetricCard {
min-height: 80px; max-height: 80px;
min-height: 64px; max-height: 64px;
border: 1px solid #E2E7F4; border-radius: 12px; background: #FFFFFF;
}
#PrescriptionLibraryPage QFrame#MetricCard QLabel[role="metricTitle"] {
color: #405074; font-size: 12px; font-weight: 600;
}
#PrescriptionLibraryPage QFrame#MetricCard QLabel[role="metricValue"] {
color: #5265F6; font-size: 22px; font-weight: 700;
color: #5265F6; font-size: 20px; font-weight: 700;
}
#PrescriptionLibraryPage QLabel[metricIcon="true"] {
border: 1px solid #DCE3FF; border-radius: 11px; background: #EEF1FF;
@@ -97,7 +96,7 @@ PRESCRIPTION_LIBRARY_PAGE_QSS = """
border-top-left-radius: 13px; border-top-right-radius: 13px;
}
#PrescriptionLibraryPage QFrame#PrescriptionLibraryToolbar QPushButton[toolbarTab="true"] {
min-width: 82px; min-height: 36px; max-height: 36px;
min-width: 82px; min-height: 32px; max-height: 32px;
padding: 0 8px; margin: 0 4px 0 0;
color: #59698E; background: transparent; border: 0;
border-bottom: 2px solid transparent; border-radius: 0;
@@ -107,7 +106,7 @@ PRESCRIPTION_LIBRARY_PAGE_QSS = """
}
#PrescriptionLibraryPage QFrame#PrescriptionLibraryFilterBar QLineEdit,
#PrescriptionLibraryPage QFrame#PrescriptionLibraryFilterBar QComboBox {
min-height: 36px; max-height: 36px; padding: 0 11px;
min-height: 32px; max-height: 32px; padding: 0 11px;
border-radius: 8px; font-size: 12px;
}
#PrescriptionLibraryPage QPushButton {
@@ -159,11 +158,14 @@ PRESCRIPTION_LIBRARY_PAGE_QSS = """
min-width: 34px; max-width: 34px; min-height: 32px; max-height: 32px;
padding: 0; background: #FFFFFF; color: #405074; border-color: #E2E7F4;
}
#PrescriptionLibraryPage QWidget#BusinessPager QPushButton {
min-height: 32px; max-height: 32px;
}
#PrescriptionLibraryPage QWidget#BusinessPager QPushButton[pagerPage="true"][active="true"] {
background: #5265F6; color: #FFFFFF; border-color: #5265F6;
}
#PrescriptionLibraryPage QWidget#BusinessPager QComboBox {
min-height: 32px; max-height: 32px; min-width: 92px; padding: 0 9px;
#PrescriptionLibraryPage QWidget#BusinessPager QLabel[pagerSize="true"] {
min-width: 64px; color: #7481A3; font-size: 12px;
}
"""
@@ -214,13 +216,13 @@ def _efficacy_text(_value: Any, row: Any) -> str:
def _metric_card(title: str, kind: str = "accent") -> MetricCard:
card = MetricCard(title, "0", kind=kind, glyph="")
card.setFixedHeight(80)
card.layout().setContentsMargins(18, 12, 16, 12)
card.setFixedHeight(64)
card.layout().setContentsMargins(16, 8, 14, 8)
icon = QLabel(card)
icon.setProperty("metricIcon", True)
icon.setProperty("kind", kind)
icon.setAlignment(Qt.AlignmentFlag.AlignCenter)
icon.setFixedSize(42, 42)
icon.setFixedSize(36, 36)
colors = {
"accent": "#5365F5",
"info": "#8268E8",
@@ -261,6 +263,7 @@ class PrescriptionLibraryPage(QWidget):
"处方库",
"管理常用处方模板,支持 AI 解析辅助开方。",
)
header.layout().setSpacing(4)
header.actions.setSpacing(16)
self.new_button = QPushButton("新增处方", header)
self.new_button.setMinimumWidth(124)
@@ -274,8 +277,8 @@ class PrescriptionLibraryPage(QWidget):
root.addWidget(header)
metrics = QHBoxLayout()
metrics.setContentsMargins(0, 0, 0, 5)
metrics.setSpacing(24)
metrics.setContentsMargins(0, 0, 0, 0)
metrics.setSpacing(16)
self.metric_cards = {
"total": _metric_card("全部处方"),
"private": _metric_card("仅自己", "info"),
@@ -295,9 +298,10 @@ class PrescriptionLibraryPage(QWidget):
filters = QFrame()
filters.setObjectName("PrescriptionLibraryFilterBar")
filters.setFixedHeight(52)
grid = QGridLayout(filters)
grid.setContentsMargins(16, 11, 16, 11)
grid.setHorizontalSpacing(20)
grid.setContentsMargins(16, 9, 16, 9)
grid.setHorizontalSpacing(12)
self.name_filter = QLineEdit()
self.name_filter.setPlaceholderText("搜索处方名称、药材、功效等关键词")
self.name_filter.setClearButtonEnabled(True)
@@ -319,10 +323,10 @@ class PrescriptionLibraryPage(QWidget):
self.effect_filter.addItem("清热祛湿", "清热祛湿")
self.effect_filter.addItem("滋阴补肾", "滋阴补肾")
grid.addWidget(self.effect_filter, 0, 3)
self.name_filter.setMinimumWidth(500)
self.formula_filter.setFixedWidth(190)
self.visibility_filter.setFixedWidth(174)
self.effect_filter.setFixedWidth(190)
self.name_filter.setMinimumWidth(220)
self.formula_filter.setMinimumWidth(132)
self.visibility_filter.setMinimumWidth(148)
self.effect_filter.setMinimumWidth(144)
self.query_button = QPushButton("查询")
self.query_button.setFixedWidth(66)
self.query_button.setProperty("variant", "secondary")
@@ -335,7 +339,10 @@ class PrescriptionLibraryPage(QWidget):
self.reset_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.reset_button.clicked.connect(self._reset_filters)
grid.addWidget(self.reset_button, 0, 5)
grid.setColumnStretch(0, 1)
grid.setColumnStretch(0, 4)
grid.setColumnStretch(1, 1)
grid.setColumnStretch(2, 1)
grid.setColumnStretch(3, 1)
root.addWidget(filters)
self.banner = MessageBanner()
@@ -347,8 +354,9 @@ class PrescriptionLibraryPage(QWidget):
card_layout.setSpacing(0)
toolbar_host = QFrame(card)
toolbar_host.setObjectName("PrescriptionLibraryToolbar")
toolbar_host.setFixedHeight(46)
toolbar = QHBoxLayout(toolbar_host)
toolbar.setContentsMargins(16, 10, 16, 9)
toolbar.setContentsMargins(16, 7, 16, 7)
toolbar.setSpacing(8)
self.all_tab = QPushButton("处方列表", toolbar_host)
self.all_tab.setProperty("toolbarTab", True)
@@ -423,10 +431,9 @@ class PrescriptionLibraryPage(QWidget):
TableColumn("__actions__", "操作", 160, lambda _value, _row: ""),
]
)
self.table.verticalHeader().setDefaultSectionSize(37)
self.table.horizontalHeader().setFixedHeight(41)
self.table.verticalHeader().setDefaultSectionSize(36)
self.table.horizontalHeader().setFixedHeight(38)
self.table.setWordWrap(False)
self.table.setHorizontalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
self.table.itemSelectionChanged.connect(self._selection_changed)
self.table.itemDoubleClicked.connect(lambda _item: self._view_selected())
table_layout.addWidget(self.table, 1)
@@ -6,10 +6,9 @@ from collections.abc import Callable, Mapping
from datetime import datetime, timedelta
from typing import Any
from PySide6.QtCore import QDateTime, QRectF, QSize, Qt, Signal
from PySide6.QtCore import QDateTime, QRectF, QSize, Qt
from PySide6.QtGui import QColor, QFont, QIcon, QPainter, QPen, QPixmap
from PySide6.QtWidgets import (
QAbstractItemView,
QComboBox,
QDateTimeEdit,
QDialog,
@@ -38,6 +37,7 @@ from ..dialogs.prescription import (
PrescriptionOrderListDialog,
)
from ..widgets import (
BusinessPager,
EmptyState,
MessageBanner,
PageHeader,
@@ -57,17 +57,17 @@ from ..widgets import (
PRESCRIPTIONS_PAGE_QSS = """
#PrescriptionsPage { background: #F8FAFF; }
#PrescriptionsPage QWidget#PageHeader { min-height: 84px; max-height: 84px; }
#PrescriptionsPage QWidget#PageHeader { min-height: 62px; max-height: 62px; }
#PrescriptionsPage QWidget#PageHeader QLabel[role="breadcrumb"],
#PrescriptionsPage QWidget#PageHeader QLabel[role="breadcrumbSeparator"],
#PrescriptionsPage QWidget#PageHeader QLabel[role="breadcrumbCurrent"] {
min-height: 16px; max-height: 16px;
min-height: 14px; max-height: 14px;
}
#PrescriptionsPage QLabel[role="pageTitle"] {
color: #15224A; font-size: 20px; font-weight: 700;
}
#PrescriptionsPage QWidget#PageHeader QLabel[role="muted"] {
color: #7481A3; font-size: 12px; padding-top: 5px;
color: #7481A3; font-size: 12px;
}
#PrescriptionsPage QFrame#PrescriptionFilterBar,
#PrescriptionsPage QFrame#PrescriptionTableCard {
@@ -85,7 +85,7 @@ PRESCRIPTIONS_PAGE_QSS = """
#PrescriptionsPage QFrame#PrescriptionFilterBar QLineEdit,
#PrescriptionsPage QFrame#PrescriptionFilterBar QComboBox,
#PrescriptionsPage QFrame#PrescriptionFilterBar QDateTimeEdit {
min-height: 34px; max-height: 34px; padding: 0 11px;
min-height: 32px; max-height: 32px; padding: 0 11px;
border-radius: 8px; font-size: 12px;
}
#PrescriptionsPage QPushButton {
@@ -142,11 +142,14 @@ PRESCRIPTIONS_PAGE_QSS = """
min-width: 34px; max-width: 34px; min-height: 32px; max-height: 32px;
padding: 0; background: #FFFFFF; color: #405074; border-color: #E2E7F4;
}
#PrescriptionsPage QWidget#BusinessPager QPushButton {
min-height: 32px; max-height: 32px;
}
#PrescriptionsPage QWidget#BusinessPager QPushButton[pagerPage="true"][active="true"] {
background: #5265F6; color: #FFFFFF; border-color: #5265F6;
}
#PrescriptionsPage QWidget#BusinessPager QComboBox {
min-height: 32px; max-height: 32px; min-width: 92px; padding: 0 9px;
#PrescriptionsPage QWidget#BusinessPager QLabel[pagerSize="true"] {
min-width: 64px; color: #7481A3; font-size: 12px;
}
"""
@@ -276,88 +279,6 @@ def _row_action_button(
return button
class BusinessPager(QWidget):
"""Reference-style numbered pager while retaining the page's fixed size contract."""
page_changed = Signal(int)
def __init__(self, page_size: int = 15, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.setObjectName("BusinessPager")
self.page = 1
self.page_size = page_size
self.total = 0
layout = QHBoxLayout(self)
layout.setContentsMargins(0, 6, 0, 0)
layout.setSpacing(7)
self.summary = QLabel("共 0 条", self)
self.summary.setProperty("role", "muted")
layout.addWidget(self.summary)
layout.addStretch(1)
self.previous = QPushButton("", self)
self.previous.setProperty("variant", "ghost")
self.previous.clicked.connect(lambda: self._request(self.page - 1))
layout.addWidget(self.previous)
self.pages_host = QWidget(self)
self.pages_layout = QHBoxLayout(self.pages_host)
self.pages_layout.setContentsMargins(0, 0, 0, 0)
self.pages_layout.setSpacing(5)
layout.addWidget(self.pages_host)
self.next = QPushButton("", self)
self.next.setProperty("variant", "ghost")
self.next.clicked.connect(lambda: self._request(self.page + 1))
layout.addWidget(self.next)
self.size_combo = QComboBox(self)
self.size_combo.addItem(f"{page_size} 条/页", page_size)
layout.addWidget(self.size_combo)
self.page_label: QPushButton | None = None
self.update_state(1, 0)
@property
def page_count(self) -> int:
return max(1, (self.total + self.page_size - 1) // self.page_size)
def update_state(self, page: int, total: int) -> None:
self.page = max(1, page)
self.total = max(0, total)
self.summary.setText(f"{self.total}")
while self.pages_layout.count():
item = self.pages_layout.takeAt(0)
if item.widget() is not None:
item.widget().deleteLater()
count = self.page_count
if count <= 4:
pages: list[int | None] = list(range(1, count + 1))
elif self.page <= 3:
pages = [1, 2, 3, None, count]
elif self.page >= count - 2:
pages = [1, None, count - 2, count - 1, count]
else:
pages = [1, None, self.page, None, count]
self.page_label = None
for number in pages:
if number is None:
ellipsis = QLabel("", self.pages_host)
ellipsis.setAlignment(Qt.AlignmentFlag.AlignCenter)
ellipsis.setFixedWidth(24)
self.pages_layout.addWidget(ellipsis)
continue
button = QPushButton(str(number), self.pages_host)
button.setProperty("pagerPage", True)
button.setProperty("active", number == self.page)
button.setCursor(Qt.CursorShape.PointingHandCursor)
button.clicked.connect(lambda _checked=False, value=number: self._request(value))
self.pages_layout.addWidget(button)
if number == self.page:
self.page_label = button
self.previous.setEnabled(self.page > 1)
self.next.setEnabled(self.page < count)
def _request(self, page: int) -> None:
if 1 <= page <= self.page_count and page != self.page:
self.page_changed.emit(page)
def _int(value: Any, default: int = 0) -> int:
try:
return int(value)
@@ -609,6 +530,7 @@ class PrescriptionsPage(QWidget):
"已开处方",
"管理处方审核、患者修正与履约订单;已通过且未作废的处方只允许查看。",
)
header.layout().setSpacing(4)
header.actions.setSpacing(16)
self.orders_button = QPushButton("业务订单", header)
self.orders_button.setMinimumWidth(86)
@@ -635,10 +557,11 @@ class PrescriptionsPage(QWidget):
def _build_filters(self) -> QWidget:
frame = QFrame()
frame.setObjectName("PrescriptionFilterBar")
frame.setFixedHeight(88)
grid = QGridLayout(frame)
grid.setContentsMargins(14, 17, 14, 17)
grid.setContentsMargins(16, 8, 16, 8)
grid.setHorizontalSpacing(18)
grid.setVerticalSpacing(15)
grid.setVerticalSpacing(8)
self.quick_date = QComboBox()
self.quick_date.addItem("全部时间", "all")
self.quick_date.addItem("今日", "today")
@@ -712,8 +635,9 @@ class PrescriptionsPage(QWidget):
layout.setSpacing(0)
toolbar_host = QFrame(card)
toolbar_host.setObjectName("PrescriptionToolbar")
toolbar_host.setFixedHeight(46)
toolbar = QHBoxLayout(toolbar_host)
toolbar.setContentsMargins(16, 11, 16, 11)
toolbar.setContentsMargins(16, 7, 16, 7)
toolbar.setSpacing(8)
title = QLabel("处方列表")
title.setProperty("role", "sectionTitle")
@@ -772,7 +696,6 @@ class PrescriptionsPage(QWidget):
self.table.verticalHeader().setDefaultSectionSize(36)
self.table.horizontalHeader().setFixedHeight(38)
self.table.setWordWrap(False)
self.table.setHorizontalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
self.table.horizontalHeaderItem(0).setIcon(_painted_icon("checkbox", "#AEB9D4", 14))
self.table.horizontalHeaderItem(0).setTextAlignment(Qt.AlignmentFlag.AlignCenter)
self.table.itemSelectionChanged.connect(self._selection_changed)
@@ -1434,6 +1357,7 @@ class PrescriptionsPage(QWidget):
__all__ = [
"BusinessPager",
"PrescriptionsPage",
"can_audit",
"can_create_order",
File diff suppressed because it is too large Load Diff
+67 -5
View File
@@ -4,9 +4,10 @@ from __future__ import annotations
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,
@@ -863,6 +864,10 @@ 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.setMinimumSize(1024, 640)
self.resize(1710, 920)
@@ -1614,6 +1619,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 +1668,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 +1721,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 +1748,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)
+103
View File
@@ -716,6 +716,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 +757,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 +935,7 @@ def clear_layout(layout: QVBoxLayout | QHBoxLayout) -> None:
__all__ = [
"BusinessPager",
"BusyOverlay",
"EmptyState",
"MessageBanner",
+330
View File
@@ -0,0 +1,330 @@
from __future__ import annotations
import os
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.appointments import AppointmentsPage
from doctor_workstation.ui.pages.consultations import ConsultationsPage
from doctor_workstation.ui.pages.patients import PatientListWorkspace
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_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()
+866
View File
@@ -0,0 +1,866 @@
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_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()
+46
View File
@@ -74,6 +74,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:
+133 -3
View File
@@ -3,13 +3,14 @@
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
from doctor_workstation.core.errors import ApiProtocolError
from doctor_workstation.core.models import Appointment, PageResult
@@ -163,6 +164,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(
@@ -354,7 +357,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()
@@ -513,6 +517,7 @@ def test_appointments_reference_split_layout_and_video_list(
current_user={"id": 1001, "role_id": 1},
)
page.resize(1460, 820)
page._apply_responsive_layout()
page._loaded(
{
"lists": [
@@ -538,8 +543,133 @@ def test_appointments_reference_split_layout_and_video_list(
assert page.video_list.count() == 1
assert "赵俊霞" in page.video_list.item(0).text()
assert page.video_list.parentWidget().width() == 420
assert 300 <= page.video_panel.width() <= 420
assert page.table.objectName() == "AppointmentTable"
assert (
page.table.verticalScrollMode()
== QAbstractItemView.ScrollMode.ScrollPerPixel
)
assert (
page.video_list.verticalScrollMode()
== QAbstractItemView.ScrollMode.ScrollPerPixel
)
assert page.date_buttons["today"].isChecked()
page.resize(1024, 640)
page._apply_responsive_layout()
assert page.video_panel.isHidden()
assert not page.video_panel_button.isHidden()
assert not page.date_overflow_button.isHidden()
assert page.date_buttons["yesterday"].isHidden()
assert not page.date_buttons["today"].isHidden()
page.video_panel_button.click()
assert not page.video_panel.isHidden()
assert 250 <= page.video_panel.width() <= 300
page.video_panel_button.click()
assert page.video_panel.isHidden()
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)
video_card = page.video_list.itemWidget(page.video_list.item(0))
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.video_list.itemWidget(page.video_list.item(0)) is video_card
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
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 300 <= page.video_panel.width() < 420
page.resize(1024, 640)
application.processEvents()
assert page.video_panel.isHidden()
assert not page.video_panel_button.isHidden()
page.close()
application.processEvents()
+76 -6
View File
@@ -298,7 +298,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 +318,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]] = []
+16 -9
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import os
from datetime import date, timedelta
from pathlib import Path
from typing import Any
@@ -276,13 +277,15 @@ class VisualRepository:
) -> dict[str, Any]:
assert diagnosis_id == 501
self.tracking_calls.append((start_date, end_date))
newest_date = end_date or date.today().isoformat()
previous_date = (date.fromisoformat(newest_date) - timedelta(days=1)).isoformat()
return {
"blood_records": [
{
"id": 6201,
"diagnosis_id": 501,
"patient_id": 1501,
"record_date": "2026-08-10",
"record_date": newest_date,
"fasting_blood_sugar": 8.2,
"systolic_pressure": 146,
"source": 1,
@@ -291,7 +294,7 @@ class VisualRepository:
"id": 6202,
"diagnosis_id": 501,
"patient_id": 1501,
"record_date": "2026-08-10",
"record_date": newest_date,
"postprandial_blood_sugar": 12.4,
"diastolic_pressure": 92,
"western_medicine": "二甲双胍",
@@ -300,7 +303,7 @@ class VisualRepository:
"id": 6203,
"diagnosis_id": 501,
"patient_id": 1501,
"record_date": "2026-08-09",
"record_date": previous_date,
"fasting_blood_sugar": 7.6,
"postprandial_blood_sugar": 10.8,
},
@@ -310,7 +313,7 @@ class VisualRepository:
"id": 6301,
"diagnosis_id": 501,
"patient_id": 1501,
"record_date": "2026-08-10",
"record_date": newest_date,
"breakfast_foods": "燕麦、鸡蛋",
"lunch_foods": "杂粮饭",
}
@@ -320,7 +323,7 @@ class VisualRepository:
"id": 6401,
"diagnosis_id": 501,
"patient_id": 1501,
"record_date": "2026-08-09",
"record_date": previous_date,
"exercise_type": "散步",
"duration": 35,
"intensity": 2,
@@ -331,7 +334,7 @@ class VisualRepository:
def list_tracking_notes(self, diagnosis_id: int) -> list[dict[str, Any]]:
assert diagnosis_id == 501
self.tracking_note_calls += 1
return [{"note_date": "2026-08-10", "content": "饭后散步,继续观察。"}]
return [{"note_date": date.today().isoformat(), "content": "饭后散步,继续观察。"}]
def list_diagnosis_todos(
self,
@@ -871,10 +874,11 @@ def test_tabs_lazy_load_real_repository_data_and_daily_matrix_structure(
panel = dialog._daily_panels[1]
assert panel.matrix.rowCount() == 11
assert panel.matrix.columnCount() == 8
newest_header = repository.tracking_calls[-1][1][5:]
blood_column = next(
column
for column in range(1, panel.matrix.columnCount())
if panel.matrix.horizontalHeaderItem(column).text() == "08-10"
if panel.matrix.horizontalHeaderItem(column).text() == newest_header
)
assert panel.matrix.item(0, blood_column).text() == "↑ 8.2 · 自录"
assert panel.matrix.item(3, blood_column).text() == "↑ 146/92 · 自录"
@@ -1270,15 +1274,18 @@ def test_existing_daily_cells_edit_real_records_and_reject_wrong_owner(
return payload
monkeypatch.setattr(diagnosis_module, "DailyRecordEditorDialog", AcceptedEditor)
newest_date = date.fromisoformat(repository.tracking_calls[-1][1])
newest_header = newest_date.strftime("%m-%d")
previous_header = (newest_date - timedelta(days=1)).strftime("%m-%d")
blood_column = next(
column
for column in range(1, panel.matrix.columnCount())
if panel.matrix.horizontalHeaderItem(column).text() == "08-10"
if panel.matrix.horizontalHeaderItem(column).text() == newest_header
)
exercise_column = next(
column
for column in range(1, panel.matrix.columnCount())
if panel.matrix.horizontalHeaderItem(column).text() == "08-09"
if panel.matrix.horizontalHeaderItem(column).text() == previous_header
)
blood_role = panel.matrix.item(0, blood_column).data(Qt.ItemDataRole.UserRole)
diet_role = panel.matrix.item(6, blood_column).data(Qt.ItemDataRole.UserRole)
+108 -14
View File
@@ -8,9 +8,16 @@ from typing import Any
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from PySide6.QtCore import QAbstractTableModel, QRect, Qt, Signal
from PySide6.QtCore import QAbstractTableModel, QPoint, QRect, Qt, Signal
from PySide6.QtGui import QColor, QImage, QPainter
from PySide6.QtWidgets import QApplication, QFrame, QToolButton, QWidget
from PySide6.QtWidgets import (
QAbstractItemView,
QApplication,
QFrame,
QSizePolicy,
QToolButton,
QWidget,
)
from doctor_workstation.core import PermissionSet
from doctor_workstation.ui.diagnosis_index_widgets import (
@@ -158,14 +165,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 +242,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 +316,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 +489,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 +630,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,
+188 -1
View File
@@ -3,13 +3,14 @@
from __future__ import annotations
import os
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 +19,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,
)
@@ -596,3 +599,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()
+21 -2
View File
@@ -621,17 +621,36 @@ 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 all(
widget.minimumWidth() == 0 and widget.maximumWidth() > 1000
for widget in (
workspace.keyword_edit,
workspace.status_host,
workspace.quick_host,
workspace.date_host,
)
)
assert page.tabs.minimumHeight() == 0
assert workspace.content_stack.minimumHeight() == 0
assert workspace.table.minimumHeight() == 0
assert workspace.bottom_actions.isHidden()
assert workspace.table.viewport().height() // 40 >= 6
assert workspace.pager.isVisibleTo(page)
assert workspace.table.objectName() == "PatientTable"
assert workspace.table.columnCount() == 10
assert workspace.table.horizontalHeaderItem(9).text() == "操作"
+247
View File
@@ -0,0 +1,247 @@
from __future__ import annotations
import os
from pathlib import Path
from types import SimpleNamespace
from typing import Any
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from PySide6.QtCore import QPoint
from PySide6.QtGui import QImage
from PySide6.QtWidgets import QAbstractItemView, QApplication, QComboBox, QFrame, QWidget
from doctor_workstation.ui.pages import prescription_library as library_module
from doctor_workstation.ui.pages import prescriptions as prescriptions_module
from doctor_workstation.ui.pages.prescription_library import PrescriptionLibraryPage
from doctor_workstation.ui.pages.prescriptions import PrescriptionsPage
from doctor_workstation.ui.widgets import BusinessPager
@pytest.fixture(scope="module")
def application() -> QApplication:
return QApplication.instance() or QApplication([])
@pytest.fixture(autouse=True)
def immediate_async(monkeypatch: pytest.MonkeyPatch) -> None:
def run_immediately(
function: Any,
*args: Any,
on_success: Any = None,
on_error: Any = None,
on_finished: Any = None,
**kwargs: Any,
) -> object:
try:
result = function(*args, **kwargs)
except Exception as error:
if on_error is not None:
on_error(error)
else:
if on_success is not None:
on_success(result)
finally:
if on_finished is not None:
on_finished()
return object()
monkeypatch.setattr(prescriptions_module, "run_async", run_immediately)
monkeypatch.setattr(library_module, "run_async", run_immediately)
def _issued_row(index: int) -> dict[str, Any]:
return {
"id": 1000 + index,
"sn": f"CF-202608-{1000 + index}",
"prescription_type": "汤剂",
"is_system_auto": index % 2,
"patient_name": ("林晓岚", "周明远", "许安然")[index % 3],
"gender": 2 if index % 2 else 1,
"age": 29 + index,
"audit_status": index % 3,
"void_status": 0,
"has_prescription_order": index % 2,
"creator_id": 7,
"doctor_name": "陈医生",
"assistant_name": "赵医助",
"create_time": f"2026-08-{(index % 9) + 10:02d} 09:30:00",
"herbs": [{"name": "黄芪", "dosage": 15}],
}
def _library_row(index: int) -> dict[str, Any]:
return {
"id": 2000 + index,
"prescription_name": ("益气养阴方", "清热祛湿方", "滋阴调和方")[index % 3],
"formula_type": "主方" if index % 3 else "辅方",
"herbs": [
{"name": "黄芪", "dosage": 15},
{"name": "党参", "dosage": 12},
],
"efficacy": ("益气养阴", "清热祛湿", "滋阴补肾")[index % 3],
"is_public": index % 2,
"disable_edit": 0,
"creator_id": 7,
"creator_name": "陈医生",
"create_time": f"2026-08-{(index % 9) + 10:02d} 08:20:00",
}
class DensityRepository:
def __init__(self) -> None:
self.issued_rows = [_issued_row(index) for index in range(15)]
self.library_rows = [_library_row(index) for index in range(15)]
def list_diagnosis_doctors(self) -> list[dict[str, Any]]:
return [{"id": 7, "name": "陈医生"}, {"id": 8, "name": "孙医生"}]
def list_prescriptions(self, **_filters: Any) -> dict[str, Any]:
return {"lists": self.issued_rows, "count": 44}
def list_prescription_templates(self, **_filters: Any) -> dict[str, Any]:
return {"lists": self.library_rows, "count": 41}
def _new_page(kind: str) -> PrescriptionsPage | PrescriptionLibraryPage:
repository = DensityRepository()
user = SimpleNamespace(id=7, name="陈医生", root=1, role_ids=[0])
permissions = {"*"}
if kind == "issued":
page: PrescriptionsPage | PrescriptionLibraryPage = PrescriptionsPage(
repository, permissions, user
)
else:
page = PrescriptionLibraryPage(repository, permissions, user)
page.refresh()
return page
def _settle(application: QApplication) -> None:
for _ in range(5):
application.processEvents()
def _fully_visible_rows(page: PrescriptionsPage | PrescriptionLibraryPage) -> int:
viewport = page.table.viewport()
return sum(
1
for row in range(page.table.rowCount())
if (
(item := page.table.item(row, 0)) is not None
and (rect := page.table.visualItemRect(item)).isValid()
and rect.top() >= 0
and rect.bottom() < viewport.height()
)
)
def test_business_pager_is_shared_fixed_and_not_a_fake_dropdown(
application: QApplication,
) -> None:
pager = BusinessPager(15)
pager.update_state(2, 44)
pager.show()
_settle(application)
assert prescriptions_module.BusinessPager is BusinessPager
assert 40 <= pager.height() <= 44
assert pager.minimumHeight() == pager.maximumHeight() == 42
assert pager.findChildren(QComboBox) == []
assert pager.page_size_label.text() == "15 条/页"
margins = pager.layout().contentsMargins()
assert (margins.left(), margins.top(), margins.right(), margins.bottom()) == (16, 4, 16, 4)
assert pager.page_label is not None and pager.page_label.text() == "2"
pager.close()
@pytest.mark.parametrize("kind", ["issued", "library"])
@pytest.mark.parametrize(
("size", "minimum_visible_rows"),
[((1366, 768), 6), ((1710, 920), 9)],
)
def test_desktop_sizes_keep_rows_and_pager_visible_and_aligned(
application: QApplication,
kind: str,
size: tuple[int, int],
minimum_visible_rows: int,
) -> None:
page = _new_page(kind)
page.resize(*size)
page.show()
_settle(application)
header = page.findChild(QWidget, "PageHeader")
toolbar_name = "PrescriptionToolbar" if kind == "issued" else "PrescriptionLibraryToolbar"
toolbar = page.findChild(QFrame, toolbar_name)
assert header is not None and 60 <= header.height() <= 64
assert toolbar is not None and 44 <= toolbar.height() <= 48
assert 40 <= page.pager.height() <= 44
assert page.pager.minimumHeight() == page.pager.maximumHeight()
assert page.table.minimumHeight() == 0
assert page.table.horizontalScrollMode() == QAbstractItemView.ScrollMode.ScrollPerPixel
assert page.table.verticalScrollMode() == QAbstractItemView.ScrollMode.ScrollPerPixel
assert _fully_visible_rows(page) >= minimum_visible_rows
pager_position = page.pager.mapTo(page, QPoint())
assert pager_position.x() >= 0
assert pager_position.x() + page.pager.width() <= page.width()
assert pager_position.y() >= 0
assert pager_position.y() + page.pager.height() <= page.height()
page_size_right = page.pager.page_size_label.mapTo(page, QPoint()).x() + (
page.pager.page_size_label.width()
)
assert page_size_right <= page.width()
pager_margins = page.pager.layout().contentsMargins()
toolbar_margins = toolbar.layout().contentsMargins()
assert pager_margins.left() == toolbar_margins.left() == 16
assert pager_margins.right() == toolbar_margins.right() == 16
if kind == "issued":
filters = page.findChild(QFrame, "PrescriptionFilterBar")
assert filters is not None and 84 <= filters.height() <= 92
else:
filters = page.findChild(QFrame, "PrescriptionLibraryFilterBar")
assert filters is not None
assert page.name_filter.minimumWidth() < 500
filter_right = filters.contentsRect().right()
for control in (
page.name_filter,
page.formula_filter,
page.visibility_filter,
page.effect_filter,
page.query_button,
page.reset_button,
):
right = control.mapTo(filters, QPoint()).x() + control.width()
assert right <= filter_right
page.close()
_settle(application)
def test_density_reference_artifacts_exist() -> None:
root = Path(__file__).resolve().parents[1]
expected = {
root / "artifacts" / "prescription_list_density" / "prescriptions_1366x768.png": (
1366,
768,
),
root / "artifacts" / "prescription_list_density" / "prescriptions_1710x920.png": (
1710,
920,
),
root
/ "artifacts"
/ "prescription_list_density"
/ "prescription_library_1366x768.png": (1366, 768),
root
/ "artifacts"
/ "prescription_list_density"
/ "prescription_library_1710x920.png": (1710, 920),
}
for path, dimensions in expected.items():
image = QImage(str(path))
assert not image.isNull(), path
assert (image.width(), image.height()) == dimensions
+120
View File
@@ -16,6 +16,7 @@ from doctor_workstation.ui.dialogs import diagnosis as diagnosis_module
from doctor_workstation.ui.dialogs import prescription as dialog_module
from doctor_workstation.ui.dialogs.diagnosis import DiagnosisDialog
from doctor_workstation.ui.dialogs.prescription import (
DiagnosisDetailDialog,
PrescriptionEditorDialog,
PrescriptionOrderDialog,
PrescriptionTemplateDialog,
@@ -287,6 +288,125 @@ def test_paid_order_response_is_bound_to_active_diagnosis_and_blocks_save(
application.processEvents()
def test_diagnosis_order_detail_lookup_is_queued_before_repository_call(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
queued: list[tuple[Any, dict[str, Any]]] = []
requested: list[int] = []
shown: list[tuple[int, str]] = []
class Repository:
def get_prescription_order(self, order_id: int) -> dict[str, Any]:
requested.append(order_id)
return {"id": order_id, "order_no": f"DETAIL-{order_id}"}
def queue_async(function: Any, **options: Any) -> object:
queued.append((function, options))
return object()
def present_order_detail(
_host: Any,
order: dict[str, Any],
*,
order_id: int,
permissions: Any,
exec_: bool,
) -> None:
del permissions, exec_
shown.append((order_id, order["order_no"]))
monkeypatch.setattr(dialog_module, "run_async", queue_async)
monkeypatch.setattr(diagnosis_module, "present_order_detail", present_order_detail)
dialog = DiagnosisDetailDialog(
{"orders": [{"id": 17, "order_no": "ROW-17"}]},
repository=Repository(),
)
table = dialog._order_detail_table
button = dialog._order_detail_button
assert table is not None
assert button is not None
table.setCurrentCell(0, 0)
button.click()
assert len(queued) == 1
assert requested == []
assert shown == []
assert not table.isEnabled()
assert not button.isEnabled()
function, options = queued[0]
options["on_success"](function())
options["on_finished"]()
assert requested == [17]
assert shown == [(17, "DETAIL-17")]
assert table.isEnabled()
assert button.isEnabled()
dialog.close()
application.processEvents()
def test_diagnosis_order_detail_ignores_stale_result_and_keeps_row_fallback(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
queued: list[dict[str, Any]] = []
shown: list[tuple[int, str]] = []
def queue_async(_function: Any, **options: Any) -> object:
queued.append(options)
return object()
def present_order_detail(
_host: Any,
order: dict[str, Any],
*,
order_id: int,
permissions: Any,
exec_: bool,
) -> None:
del permissions, exec_
shown.append((order_id, order["order_no"]))
repository = SimpleNamespace(get_prescription_order=lambda order_id: {"id": order_id})
monkeypatch.setattr(dialog_module, "run_async", queue_async)
monkeypatch.setattr(diagnosis_module, "present_order_detail", present_order_detail)
dialog = DiagnosisDetailDialog(
{
"orders": [
{"id": 21, "order_no": "ROW-21"},
{"id": 22, "order_no": "ROW-22"},
]
},
repository=repository,
)
table = dialog._order_detail_table
button = dialog._order_detail_button
assert table is not None
assert button is not None
table.setCurrentCell(0, 0)
dialog._open_selected_order()
table.setCurrentCell(1, 0)
dialog._open_selected_order()
assert len(queued) == 2
queued[0]["on_success"]({"id": 21, "order_no": "STALE-21"})
queued[0]["on_finished"]()
assert shown == []
assert not table.isEnabled()
assert not button.isEnabled()
queued[1]["on_error"](RuntimeError("detail unavailable"))
queued[1]["on_finished"]()
assert shown == [(22, "ROW-22")]
assert table.isEnabled()
assert button.isEnabled()
dialog.close()
application.processEvents()
def _finish_queued(callback: dict[str, Any], result: Any) -> None:
callback["on_success"](result)
if callback.get("on_finished"):
+238 -10
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
import json
import os
from datetime import date
from datetime import date, timedelta
from pathlib import Path
from typing import Any
@@ -12,7 +12,7 @@ import httpx
import pytest
from PySide6.QtCore import QDate, QPoint, Qt
from PySide6.QtGui import QPalette
from PySide6.QtWidgets import QApplication, QLabel, QPushButton, QScrollArea
from PySide6.QtWidgets import QApplication, QLabel, QPushButton, QScrollArea, QWidget
from doctor_workstation.core import PermissionSet
from doctor_workstation.services.api_client import ApiClient
@@ -283,9 +283,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 +297,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 +556,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,
@@ -905,9 +1130,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()
@@ -1094,7 +1320,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
@@ -1464,7 +1693,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()
+96 -1
View File
@@ -7,7 +7,7 @@ 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 +271,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."""
@@ -600,6 +645,56 @@ def test_remote_diagnosis_ai_assistant_uses_first_party_endpoint_only() -> None:
assert client.timeouts == [105.0]
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."""
+34
View File
@@ -26,10 +26,16 @@ class _ShellPageDouble(QWidget):
self.permissions = permissions
self.current_user = current_user
self.refresh_count = 0
self.show_count = 0
def refresh(self) -> None:
self.refresh_count += 1
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:
@@ -211,6 +217,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:
@@ -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::canViewReadonlyDiagnosis(
(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'] ?? '')
@@ -865,9 +869,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 +881,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);
}
@@ -627,10 +627,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 +908,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');
@@ -1596,6 +1599,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 +1607,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 +1633,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 +2064,7 @@ class ConversionLogic
string $startDate,
string $endDate,
?array $mediaChannel,
string $mediaChannelCode,
?array $mediaChannelCodes,
bool $restrictAccountCostByDept,
array $eligibleDeptIds,
array $adminToDeptIds,
@@ -2081,9 +2096,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 +2920,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;
@@ -4182,32 +4183,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 +4217,43 @@ 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,
];
}
/**
];
}
/**
* readonlyDetail 共用的诊单行级可见性入口。
*
* 复用“我的患者”统一行权策略:医生按有效接诊关系,医助按归属关系,
* 团队管理角色才使用 DataScope。不存在与越权使用同一错误避免枚举。
*/
public static function canViewReadonlyDiagnosis(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 +4265,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,
@@ -2,9 +2,10 @@
declare(strict_types=1);
namespace app\adminapi\logic\tcm;
use app\common\model\auth\Admin;
namespace app\adminapi\logic\tcm;
use app\adminapi\logic\firstvisit\MyPatientLogic;
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,14 +935,35 @@ 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 (!MyPatientLogic::canAccessDiagnosis($diagnosisId, $viewerAdminId, $viewerAdminInfo)) {
self::setError('诊单不存在或无权访问');
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获取处方(带权限检查)
@@ -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;
}
}
+467 -2
View File
@@ -98,6 +98,114 @@ class DifyChatService
], $startedAt);
}
/**
* 流式调用 Dify / OpenAI-compatible 接口。上游原始响应与凭据不会进入返回值。
*
* @param array<string,mixed> $inputs
* @param callable(string):mixed $onDelta
* @param callable():bool|null $shouldAbort
* @return array{ok:bool,content?:string,message_id?:string,latency_ms:int,error_code?:string,error?:string}
*/
public static function streamChat(
string $profile,
array $inputs,
string $query,
string $user,
callable $onDelta,
?callable $shouldAbort = null
): array {
$config = config('prescription_ai') ?: [];
if (empty($config['enable'])) {
return self::error('CONFIG_DISABLED', 'AI 报告功能未启用');
}
$modelConfig = self::resolveProfileConfig($config, $profile);
if ($modelConfig === null) {
return self::error('INVALID_PROFILE', '不支持的 AI 模型');
}
$baseUrl = trim((string) ($config['base_url'] ?? ''));
$rawApiKey = (string) ($modelConfig['api_key'] ?? '');
$apiKey = trim($rawApiKey);
if ($baseUrl === '' || $apiKey === '') {
return self::error('CONFIG_MISSING', '该模型服务尚未完整配置');
}
if (!self::isValidBaseUrl($baseUrl) || strpbrk($rawApiKey, "\r\n") !== false) {
return self::error('CONFIG_INVALID', 'AI 服务配置无效');
}
$timeout = (int) ($config['timeout'] ?? 0);
if (!self::isValidTimeout($timeout)) {
return self::error('CONFIG_INVALID', 'AI 服务超时配置无效');
}
if (!function_exists('curl_init')) {
return self::error('CURL_UNAVAILABLE', '服务器尚未启用 cURL 扩展');
}
$model = trim((string) ($modelConfig['name'] ?? ''));
if ($model === '') {
return self::error('CONFIG_INVALID', 'AI 模型配置无效');
}
$requestSpecs = self::buildRequestSpecs(
$baseUrl,
$model,
$inputs,
$query,
$user,
true
);
$startedAt = microtime(true);
$lastResponse = null;
foreach ($requestSpecs as $index => $requestSpec) {
$elapsedSeconds = (int) floor(microtime(true) - $startedAt);
$remainingTimeout = $timeout - $elapsedSeconds;
if ($remainingTimeout < self::MIN_TIMEOUT) {
return self::error(
'UPSTREAM_TIMEOUT',
'模型响应超时,请稍后重试',
self::elapsedMilliseconds($startedAt)
);
}
$response = self::sendStreamRequest(
$requestSpec['protocol'],
$requestSpec['url'],
$requestSpec['payload'],
$apiKey,
$remainingTimeout,
$onDelta,
$shouldAbort
);
$lastResponse = $response;
// 只在尚未向下游发送任何文本、且明确为路径不支持时尝试另一协议。
$hasFallback = isset($requestSpecs[$index + 1]);
if (
$hasFallback
&& empty($response['emitted'])
&& in_array($response['http_code'], [404, 405], true)
) {
continue;
}
return self::formatStreamResponse($response, $startedAt);
}
return self::formatStreamResponse($lastResponse ?? [
'errno' => 0,
'http_code' => 0,
'content' => '',
'message_id' => '',
'emitted' => false,
'upstream_error' => false,
'client_aborted' => false,
'callback_error' => false,
'finished' => false,
], $startedAt);
}
/**
* @param array<string,mixed> $config
* @return array<string,mixed>|null
@@ -120,7 +228,8 @@ class DifyChatService
string $model,
array $inputs,
string $query,
string $user
string $user,
bool $streaming = false
): array {
$baseUrl = rtrim($baseUrl, '/');
$path = strtolower((string) (parse_url($baseUrl, PHP_URL_PATH) ?? ''));
@@ -131,7 +240,7 @@ class DifyChatService
'payload' => [
'inputs' => $inputs,
'query' => $query,
'response_mode' => 'blocking',
'response_mode' => $streaming ? 'streaming' : 'blocking',
'user' => $user,
],
];
@@ -143,9 +252,14 @@ class DifyChatService
'messages' => [
['role' => 'user', 'content' => $query],
],
'stream' => $streaming,
],
];
if (!$streaming) {
unset($openAiSpec['payload']['stream']);
}
if (str_ends_with($path, '/chat-messages')) {
return [$difySpec];
}
@@ -240,6 +354,357 @@ class DifyChatService
];
}
/**
* @param array<string,mixed> $payload
* @param callable(string):mixed $onDelta
* @param callable():bool|null $shouldAbort
* @return array{
* errno:int,http_code:int,content:string,message_id:string,emitted:bool,
* upstream_error:bool,client_aborted:bool,callback_error:bool,finished:bool
* }
*/
private static function sendStreamRequest(
string $protocol,
string $url,
array $payload,
string $apiKey,
int $timeout,
callable $onDelta,
?callable $shouldAbort
): array {
$body = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE);
if ($body === false) {
return self::emptyStreamResponse(-1);
}
$ch = curl_init();
if ($ch === false) {
return self::emptyStreamResponse(-2);
}
$buffer = '';
$state = self::newStreamState();
$responseCode = 0;
$header = static function ($handle, string $line) use (&$responseCode): int {
if (preg_match('/^HTTP\/\S+\s+(\d{3})(?:\s|$)/i', trim($line), $matches) === 1) {
$responseCode = (int) $matches[1];
}
return strlen($line);
};
$write = static function ($handle, string $chunk) use (
$protocol,
&$buffer,
&$state,
&$responseCode,
$onDelta,
$shouldAbort
): int {
if ($shouldAbort !== null && $shouldAbort()) {
$state['client_aborted'] = true;
return 0;
}
if ($responseCode < 200 || $responseCode >= 300) {
// Never decode or forward an error response body. Besides preventing
// leakage, this keeps 404/405 protocol fallback side-effect free.
return strlen($chunk);
}
self::consumeStreamBytes($protocol, $buffer, $chunk, $state, $onDelta);
return $state['callback_error'] ? 0 : strlen($chunk);
};
$progress = static function () use (&$state, $shouldAbort): int {
if ($shouldAbort !== null && $shouldAbort()) {
$state['client_aborted'] = true;
return 1;
}
return 0;
};
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
CURLOPT_RETURNTRANSFER => false,
CURLOPT_CONNECTTIMEOUT => min(8, max(1, (int) ceil($timeout / 4))),
CURLOPT_TIMEOUT => $timeout,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Accept: text/event-stream',
'Authorization: Bearer ' . $apiKey,
],
CURLOPT_HEADERFUNCTION => $header,
CURLOPT_WRITEFUNCTION => $write,
CURLOPT_NOPROGRESS => false,
CURLOPT_XFERINFOFUNCTION => $progress,
]);
curl_exec($ch);
$errno = curl_errno($ch);
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if (!$state['client_aborted'] && !$state['callback_error']) {
self::consumeStreamBytes($protocol, $buffer, '', $state, $onDelta, true);
}
return [
'errno' => $errno,
'http_code' => $httpCode,
'content' => $state['content'],
'message_id' => $state['message_id'],
'emitted' => $state['emitted'],
'upstream_error' => $state['upstream_error'],
'client_aborted' => $state['client_aborted'],
'callback_error' => $state['callback_error'],
'finished' => $state['finished'],
];
}
/**
* @return array{
* content:string,message_id:string,emitted:bool,upstream_error:bool,
* client_aborted:bool,callback_error:bool,finished:bool
* }
*/
private static function newStreamState(): array
{
return [
'content' => '',
'message_id' => '',
'emitted' => false,
'upstream_error' => false,
'client_aborted' => false,
'callback_error' => false,
'finished' => false,
];
}
/**
* SSE 空行分帧;仅在完整 data frame json_decode,因此可安全接收任意字节边界。
*
* @param array<string,mixed> $state
* @param callable(string):mixed $onDelta
*/
private static function consumeStreamBytes(
string $protocol,
string &$buffer,
string $chunk,
array &$state,
callable $onDelta,
bool $final = false
): void {
$buffer .= $chunk;
while (preg_match('/(?:\r\n|\r|\n){2}/', $buffer, $match, PREG_OFFSET_CAPTURE) === 1) {
$delimiter = $match[0][0];
$offset = $match[0][1];
$frame = substr($buffer, 0, $offset);
$buffer = (string) substr($buffer, $offset + strlen($delimiter));
self::consumeStreamFrame($protocol, $frame, $state, $onDelta);
}
if ($final && trim($buffer) !== '') {
self::consumeStreamFrame($protocol, $buffer, $state, $onDelta);
$buffer = '';
}
}
/**
* @param array<string,mixed> $state
* @param callable(string):mixed $onDelta
*/
private static function consumeStreamFrame(
string $protocol,
string $frame,
array &$state,
callable $onDelta
): void {
if ($state['finished'] || $state['upstream_error'] || $state['callback_error']) {
return;
}
$dataLines = [];
foreach (preg_split('/\r\n|\r|\n/', $frame) ?: [] as $line) {
if ($line === '' || str_starts_with($line, ':')) {
continue;
}
if (str_starts_with($line, 'data:')) {
$dataLines[] = ltrim(substr($line, 5), ' ');
}
}
if ($dataLines === []) {
return;
}
$data = implode("\n", $dataLines);
if ($data === '[DONE]') {
$state['finished'] = true;
return;
}
$decoded = json_decode($data, true);
if (!is_array($decoded)) {
return;
}
$delta = '';
if ($protocol === 'dify') {
$event = strtolower((string) ($decoded['event'] ?? ''));
if ($event === 'message_end') {
$state['message_id'] = (string) ($decoded['message_id'] ?? $state['message_id']);
$state['finished'] = true;
return;
}
if ($event === 'error') {
$state['upstream_error'] = true;
return;
}
if (!in_array($event, ['message', 'agent_message'], true)) {
return;
}
$delta = is_string($decoded['answer'] ?? null) ? $decoded['answer'] : '';
$state['message_id'] = (string) ($decoded['message_id'] ?? $state['message_id']);
} else {
$delta = self::extractStreamDelta($decoded);
$state['message_id'] = (string) ($decoded['id'] ?? $state['message_id']);
}
if ($delta === '') {
return;
}
try {
$accepted = $onDelta($delta);
if ($accepted === false) {
$state['callback_error'] = true;
return;
}
} catch (\Throwable $e) {
$state['callback_error'] = true;
return;
}
$state['content'] .= $delta;
$state['emitted'] = true;
}
/** @param array<string,mixed> $decoded */
private static function extractStreamDelta(array $decoded): string
{
$content = $decoded['choices'][0]['delta']['content'] ?? '';
if (is_string($content)) {
return $content;
}
if (!is_array($content)) {
return '';
}
$parts = [];
foreach ($content as $part) {
if (is_array($part) && ($part['type'] ?? '') === 'text' && is_string($part['text'] ?? null)) {
$parts[] = $part['text'];
}
}
return implode('', $parts);
}
/**
* 纯解析测试入口:生产流与测试使用同一逐字节解码路径。
*
* @param array<int,string> $chunks
* @return array{content:string,deltas:array<int,string>,message_id:string,finished:bool,upstream_error:bool}
*/
private static function decodeStreamChunks(string $protocol, array $chunks): array
{
$buffer = '';
$state = self::newStreamState();
$deltas = [];
$onDelta = static function (string $delta) use (&$deltas): void {
$deltas[] = $delta;
};
foreach ($chunks as $chunk) {
self::consumeStreamBytes($protocol, $buffer, $chunk, $state, $onDelta);
}
self::consumeStreamBytes($protocol, $buffer, '', $state, $onDelta, true);
return [
'content' => $state['content'],
'deltas' => $deltas,
'message_id' => $state['message_id'],
'finished' => $state['finished'],
'upstream_error' => $state['upstream_error'],
];
}
/**
* @return array{
* errno:int,http_code:int,content:string,message_id:string,emitted:bool,
* upstream_error:bool,client_aborted:bool,callback_error:bool,finished:bool
* }
*/
private static function emptyStreamResponse(int $errno): array
{
return [
'errno' => $errno,
'http_code' => 0,
'content' => '',
'message_id' => '',
'emitted' => false,
'upstream_error' => false,
'client_aborted' => false,
'callback_error' => false,
'finished' => false,
];
}
/**
* @param array<string,mixed> $response
* @return array{ok:bool,content?:string,message_id?:string,latency_ms:int,error_code?:string,error?:string}
*/
private static function formatStreamResponse(array $response, float $startedAt): array
{
$latencyMs = self::elapsedMilliseconds($startedAt);
if (!empty($response['client_aborted'])) {
return self::error('CLIENT_DISCONNECTED', '客户端已断开连接', $latencyMs);
}
if (!empty($response['callback_error'])) {
return self::error('STREAM_DELIVERY_FAILED', '流式响应已中止', $latencyMs);
}
$errno = (int) ($response['errno'] ?? 0);
if ($errno !== 0) {
if ($errno === CURLE_OPERATION_TIMEDOUT) {
return self::error('UPSTREAM_TIMEOUT', '模型响应超时,请稍后重试', $latencyMs);
}
if ($errno === -1) {
return self::error('REQUEST_BUILD_FAILED', '病例数据编码失败', $latencyMs);
}
if ($errno === -2) {
return self::error('CURL_INIT_FAILED', '无法初始化 AI 请求', $latencyMs);
}
return self::error('UPSTREAM_UNAVAILABLE', '暂时无法连接 AI 服务,请稍后重试', $latencyMs);
}
$httpCode = (int) ($response['http_code'] ?? 0);
if ($httpCode === 401 || $httpCode === 403) {
return self::error('CONFIG_INVALID', 'AI 服务凭据无效或无权限', $latencyMs);
}
if ($httpCode === 429 || $httpCode >= 500) {
return self::error('UPSTREAM_BUSY', '模型服务繁忙,请稍后重试', $latencyMs);
}
if ($httpCode >= 400 || $httpCode < 200 || !empty($response['upstream_error'])) {
return self::error('UPSTREAM_REJECTED', '模型未能处理本次请求', $latencyMs);
}
if (empty($response['finished'])) {
return self::error('INCOMPLETE_RESPONSE', '模型响应不完整,请重试', $latencyMs);
}
$content = (string) ($response['content'] ?? '');
if (trim($content) === '') {
return self::error('EMPTY_RESPONSE', '模型未返回报告内容,请重试', $latencyMs);
}
return [
'ok' => true,
'content' => $content,
'message_id' => (string) ($response['message_id'] ?? ''),
'latency_ms' => $latencyMs,
];
}
/**
* @param array{body:string,errno:int,http_code:int} $response
* @return array{ok:bool,content?:string,message_id?:string,latency_ms:int,error_code?:string,error?:string}
@@ -21,6 +21,8 @@ class MediaChannelService
'update_time',
];
public const GROUP_CODE_PREFIX = 'group:';
/** @var array<int, array<string, mixed>>|null */
private static ?array $activeChannelRowsCache = null;
@@ -199,8 +201,9 @@ SQL;
'code' => (string) ($row['channel_code'] ?? ''),
'name' => (string) ($row['channel_name'] ?? ''),
'tag_id' => (string) ($row['source_tag_id'] ?? ''),
'group_name' => (string) ($row['source_group_name'] ?? ''),
'group_name' => trim((string) ($row['source_group_name'] ?? '')),
'customer_count' => (int) ($row['customer_count'] ?? 0),
'kind' => 'channel',
], self::getCurrentTagChannelRows());
}
@@ -217,6 +220,11 @@ SQL;
return null;
}
$groupName = self::parseGroupName($channelCode);
if ($groupName !== '') {
return self::buildCurrentTagGroupChannel($groupName);
}
foreach (self::getCurrentTagChannelRows() as $row) {
if ((string) ($row['channel_code'] ?? '') === $channelCode) {
return $row;
@@ -226,6 +234,57 @@ SQL;
return null;
}
public static function isGroupCode(string $channelCode): bool
{
return self::parseGroupName($channelCode) !== '';
}
public static function buildGroupCode(string $groupName): string
{
$groupName = trim($groupName);
return $groupName === '' ? '' : self::GROUP_CODE_PREFIX . $groupName;
}
public static function parseGroupName(string $channelCode): string
{
$channelCode = trim($channelCode);
if (!str_starts_with($channelCode, self::GROUP_CODE_PREFIX)) {
return '';
}
return trim(substr($channelCode, strlen(self::GROUP_CODE_PREFIX)));
}
/**
* 账户消耗等事实表使用的真实渠道 code;分组筛选会展开为组内全部叶子渠道。
*
* @param array<string, mixed>|null $channel
* @return string[]
*/
public static function getChannelCodesForStats(?array $channel): array
{
if ($channel === null) {
return [];
}
$codes = [];
if (isset($channel['channel_codes']) && is_array($channel['channel_codes'])) {
foreach ($channel['channel_codes'] as $code) {
$code = trim((string) $code);
if ($code !== '' && !str_starts_with($code, self::GROUP_CODE_PREFIX)) {
$codes[$code] = $code;
}
}
}
$code = trim((string) ($channel['channel_code'] ?? ''));
if ($code !== '' && !str_starts_with($code, self::GROUP_CODE_PREFIX)) {
$codes[$code] = $code;
}
return array_values($codes);
}
public static function getDefaultCode(): string
{
$rows = self::getActiveChannelRows();
@@ -303,12 +362,21 @@ SQL;
return [];
}
$names = array_values(array_unique(array_filter([
$names = [
trim((string) ($channel['channel_name'] ?? '')),
trim((string) ($channel['source_tag_name'] ?? '')),
trim((string) ($channel['legacy_channel_name'] ?? '')),
trim((string) ($channel['legacy_source_tag_name'] ?? '')),
])));
];
foreach (['channel_names', 'source_tag_names'] as $listKey) {
if (!isset($channel[$listKey]) || !is_array($channel[$listKey])) {
continue;
}
foreach ($channel[$listKey] as $name) {
$names[] = trim((string) $name);
}
}
$names = array_values(array_unique(array_filter($names, static fn (string $name): bool => $name !== '')));
if ($names === []) {
return [];
@@ -362,18 +430,22 @@ SQL;
return;
}
$tagId = trim((string) ($channel['source_tag_id'] ?? ''));
if ($tagId !== '') {
$tagIds = self::channelTagIds($channel);
if ($tagIds !== []) {
$tagTable = self::tableWithPrefix('qywx_external_contact_tag');
$contactTable = self::tableWithPrefix('qywx_external_contact');
$tagPredicate = count($tagIds) === 1
? 'channel_tag.tag_id = ?'
: 'channel_tag.tag_id IN (' . implode(', ', array_fill(0, count($tagIds), '?')) . ')';
// 相关 EXISTS 走 (tag_id, external_userid) 索引,避免先物化整渠客户 ID 再 IN。
$query->whereRaw(
"{$field} IN ("
. "SELECT channel_tag.external_userid FROM {$tagTable} channel_tag "
. 'WHERE channel_tag.tag_id = ? '
"EXISTS (SELECT 1 FROM {$tagTable} channel_tag "
. "WHERE channel_tag.external_userid = {$field} "
. "AND {$tagPredicate} "
. "AND EXISTS (SELECT 1 FROM {$contactTable} active_channel_contact "
. 'WHERE active_channel_contact.external_userid = channel_tag.external_userid '
. 'AND active_channel_contact.delete_time IS NULL))',
[$tagId]
$tagIds
);
return;
@@ -768,16 +840,24 @@ SQL;
private static function buildLikePatterns(array $channel): array
{
$patterns = [];
$tagId = trim((string) ($channel['source_tag_id'] ?? ''));
$tagName = trim((string) ($channel['source_tag_name'] ?? ''));
if ($tagId !== '') {
foreach (self::channelTagIds($channel) as $tagId) {
$escapedTagId = addcslashes($tagId, '%_\\');
$patterns[] = '%"tag_id":"' . $escapedTagId . '"%';
$patterns[] = '%"id":"' . $escapedTagId . '"%';
}
if ($tagName !== '') {
$tagNames = [trim((string) ($channel['source_tag_name'] ?? ''))];
if (isset($channel['channel_names']) && is_array($channel['channel_names'])) {
foreach ($channel['channel_names'] as $name) {
$tagNames[] = trim((string) $name);
}
}
if (isset($channel['source_tag_names']) && is_array($channel['source_tag_names'])) {
foreach ($channel['source_tag_names'] as $name) {
$tagNames[] = trim((string) $name);
}
}
foreach (array_unique(array_filter($tagNames, static fn (string $name): bool => $name !== '')) as $tagName) {
$escapedTagName = addcslashes($tagName, '%_\\');
$patterns[] = '%"name":"' . $escapedTagName . '"%';
$patterns[] = '%"tag_name":"' . $escapedTagName . '"%';
@@ -786,6 +866,86 @@ SQL;
return array_values(array_unique($patterns));
}
/**
* @param array<string, mixed> $channel
* @return string[]
*/
private static function channelTagIds(array $channel): array
{
$tagIds = [];
if (isset($channel['source_tag_ids']) && is_array($channel['source_tag_ids'])) {
foreach ($channel['source_tag_ids'] as $tagId) {
$tagId = trim((string) $tagId);
if ($tagId !== '') {
$tagIds[$tagId] = $tagId;
}
}
}
$tagId = trim((string) ($channel['source_tag_id'] ?? ''));
if ($tagId !== '') {
$tagIds[$tagId] = $tagId;
}
return array_values($tagIds);
}
/**
* @return array<string, mixed>|null
*/
private static function buildCurrentTagGroupChannel(string $groupName): ?array
{
$groupName = trim($groupName);
if ($groupName === '') {
return null;
}
$rows = [];
foreach (self::getCurrentTagChannelRows() as $row) {
if (trim((string) ($row['source_group_name'] ?? '')) === $groupName) {
$rows[] = $row;
}
}
if ($rows === []) {
return null;
}
$tagIds = [];
$codes = [];
$names = [];
$customerCount = 0;
foreach ($rows as $row) {
$tagId = trim((string) ($row['source_tag_id'] ?? ''));
if ($tagId !== '') {
$tagIds[$tagId] = $tagId;
}
$code = trim((string) ($row['channel_code'] ?? ''));
if ($code !== '' && !str_starts_with($code, self::GROUP_CODE_PREFIX)) {
$codes[$code] = $code;
}
foreach (['channel_name', 'source_tag_name', 'legacy_channel_name', 'legacy_source_tag_name'] as $nameKey) {
$name = trim((string) ($row[$nameKey] ?? ''));
if ($name !== '') {
$names[$name] = $name;
}
}
$customerCount = max($customerCount, (int) ($row['customer_count'] ?? 0));
}
return [
'channel_code' => self::buildGroupCode($groupName),
'channel_name' => $groupName,
'source_group_name' => $groupName,
'source_tag_id' => '',
'source_tag_name' => $groupName,
'source_tag_ids' => array_values($tagIds),
'channel_codes' => array_values($codes),
'channel_names' => array_values($names),
'customer_count' => $customerCount,
'is_group' => true,
'status' => 1,
];
}
private static function tableWithPrefix(string $table): string
{
$prefix = (string) (Db::getConfig('connections.mysql.prefix') ?: 'zyt_');
@@ -0,0 +1,42 @@
-- 一诊 / 综合数据转化:现金成本与 ROI 可见权限
-- 权限:firstvisit.conversion/viewFinance
-- 经理、管理员默认可见;诊室组长、医助需在角色里勾选本权限后才可见。
START TRANSACTION;
SET @first_visit_conversion_menu_id = (
SELECT `id` FROM `zyt_system_menu`
WHERE `perms` = 'firstvisit.conversion/overview'
OR TRIM(`component`) = 'first_visit/conversion/index'
ORDER BY CASE WHEN `perms` = 'firstvisit.conversion/overview' THEN 0 ELSE 1 END, `id`
LIMIT 1
);
INSERT INTO `zyt_system_menu`
(`pid`, `type`, `name`, `icon`, `sort`, `perms`, `paths`, `component`, `selected`, `params`, `is_cache`, `is_show`, `is_disable`, `create_time`, `update_time`)
SELECT
@first_visit_conversion_menu_id, 'A', '查看现金成本与ROI', '', 10,
'firstvisit.conversion/viewFinance', '', '',
'', '', 0, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
FROM DUAL
WHERE @first_visit_conversion_menu_id IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM `zyt_system_menu`
WHERE `perms` = 'firstvisit.conversion/viewFinance'
);
SET @first_visit_conversion_finance_menu_id = (
SELECT `id` FROM `zyt_system_menu`
WHERE `perms` = 'firstvisit.conversion/viewFinance'
ORDER BY `id`
LIMIT 1
);
INSERT IGNORE INTO `zyt_system_role_menu` (`role_id`, `menu_id`)
SELECT `id`, @first_visit_conversion_finance_menu_id
FROM `zyt_system_role`
WHERE @first_visit_conversion_finance_menu_id IS NOT NULL
AND `delete_time` IS NULL
AND `name` IN ('经理', '管理员', '系统管理员');
COMMIT;
@@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
require dirname(__DIR__) . '/vendor/autoload.php';
use app\adminapi\service\AssistantSseProtocol;
use app\adminapi\http\middleware\AuthMiddleware;
function assistantStreamExpect(bool $condition, string $message): void
{
if (!$condition) {
fwrite(STDERR, "FAIL: {$message}\n");
exit(1);
}
}
/** @return array{event:string,data:array<string,mixed>} */
function parseAssistantSse(string $frame): array
{
$lines = preg_split('/\r\n|\r|\n/', trim($frame)) ?: [];
$event = '';
$data = '';
foreach ($lines as $line) {
if (str_starts_with($line, 'event: ')) {
$event = substr($line, 7);
} elseif (str_starts_with($line, 'data: ')) {
$data .= substr($line, 6);
}
}
$decoded = json_decode($data, true);
assistantStreamExpect($event !== '' && is_array($decoded), 'SSE frame is parseable');
return ['event' => $event, 'data' => $decoded];
}
$protocol = new AssistantSseProtocol();
assistantStreamExpect($protocol->encode('delta', ['text' => 'early']) === null, 'delta cannot precede start');
$start = parseAssistantSse((string) $protocol->encode('start', ['message' => 'ready']));
assistantStreamExpect($start['event'] === 'start' && $start['data']['seq'] === 1, 'start is the first event with seq 1');
assistantStreamExpect($protocol->encode('start', []) === null, 'start can only be emitted once');
$deltaOne = parseAssistantSse((string) $protocol->encode('delta', ['text' => '你']));
$deltaTwo = parseAssistantSse((string) $protocol->encode('delta', ['text' => '好']));
assistantStreamExpect($deltaOne['data']['seq'] === 2 && $deltaTwo['data']['seq'] === 3, 'delta seq is strictly monotonic');
$done = parseAssistantSse((string) $protocol->encode('done', ['answer' => '你好']));
assistantStreamExpect($done['event'] === 'done' && $done['data']['seq'] === 4, 'done is the terminal event');
assistantStreamExpect($protocol->encode('error', ['message' => 'late']) === null, 'a second terminal event is rejected');
assistantStreamExpect($protocol->encode('delta', ['text' => 'late']) === null, 'delta after terminal is rejected');
$errorProtocol = new AssistantSseProtocol();
$errorProtocol->encode('start', []);
$error = parseAssistantSse((string) $errorProtocol->encode('error', [
'code' => 'AI_ASSISTANT_FAILED',
'message' => 'AI 助手暂时不可用,请稍后重试',
]));
assistantStreamExpect($error['data']['seq'] === 2 && $errorProtocol->isTerminal(), 'error is the unique alternative terminal event');
$controller = file_get_contents(dirname(__DIR__) . '/app/adminapi/controller/tcm/DiagnosisController.php');
$logic = file_get_contents(dirname(__DIR__) . '/app/adminapi/logic/tcm/DiagnosisAiLogic.php');
$validate = file_get_contents(dirname(__DIR__) . '/app/adminapi/validate/tcm/DiagnosisValidate.php');
$auth = file_get_contents(dirname(__DIR__) . '/app/adminapi/http/middleware/AuthMiddleware.php');
assistantStreamExpect(is_string($controller) && is_string($logic) && is_string($validate) && is_string($auth), 'stream implementation sources are readable');
$actionStart = strpos($controller, 'public function aiAssistantStream()');
$checkAt = strpos($controller, "goCheck('aiAssistant')", $actionStart);
$prepareAt = strpos($controller, 'DiagnosisAiLogic::prepareAssistant(', $actionStart);
$runAt = strpos($controller, '$this->runAssistantSse($prepared)', $actionStart);
$headerAt = strpos($controller, "header('Content-Type: text/event-stream; charset=utf-8')", $actionStart);
assistantStreamExpect(
$actionStart !== false && $checkAt > $actionStart && $prepareAt > $checkAt && $runAt > $prepareAt && $headerAt > $runAt,
'request validation and authorized preparation occur before every SSE header'
);
assistantStreamExpect(
str_contains($validate, "return \$this->only(['id', 'task', 'prompt']);"),
'stream reuses the strict id/task/prompt assistant scene'
);
assistantStreamExpect(
str_contains($logic, 'self::PERMISSION_ASSISTANT')
&& str_contains($logic, 'MyPatientLogic::canAccessDiagnosis')
&& str_contains($logic, 'streamPreparedAssistant'),
'stream preparation reuses assistant permission and canonical diagnosis row authorization'
);
assistantStreamExpect(
str_contains($auth, "\$accessUri === 'tcm.diagnosis/aiassistantstream'")
&& str_contains($auth, "'tcm.diagnosis/aiassistant', \$adminUris"),
'middleware maps stream access to the old registered assistant permission'
);
$matchPermissionAlias = (new ReflectionClass(AuthMiddleware::class))->getMethod('matchPermissionAlias');
$authMiddleware = new AuthMiddleware();
assistantStreamExpect(
$matchPermissionAlias->invoke(
$authMiddleware,
'tcm.diagnosis/aiassistantstream',
['tcm.diagnosis/aiassistant']
) === true,
'stream permission alias accepts the old assistant grant'
);
assistantStreamExpect(
$matchPermissionAlias->invoke($authMiddleware, 'tcm.diagnosis/aiassistantstream', []) === false,
'stream permission alias rejects an administrator without the old assistant grant'
);
assistantStreamExpect(
str_contains($controller, "'text' => \$delta")
&& str_contains($controller, "'code' => 'AI_ASSISTANT_FAILED'")
&& str_contains($controller, 'ignore_user_abort(true)')
&& str_contains($controller, 'connection_aborted() === 1')
&& !str_contains($controller, "DiagnosisAiLogic::getError()\n ]"),
'delta carries text, disconnects abort upstream, and errors use a generic prompt-free payload'
);
assistantStreamExpect(
str_contains($logic, 'DifyChatService::chat(')
&& str_contains($logic, 'DifyChatService::streamChat('),
'legacy blocking and new streaming paths coexist'
);
$sensitiveNeedles = ['api_key', 'base_url', 'query', 'inputs', 'user'];
foreach ($sensitiveNeedles as $needle) {
assistantStreamExpect(!array_key_exists($needle, $done['data']), "done event excludes internal {$needle}");
assistantStreamExpect(!array_key_exists($needle, $error['data']), "error event excludes internal {$needle}");
}
echo "Diagnosis AI assistant stream contract: OK\n";
@@ -0,0 +1,240 @@
<?php
declare(strict_types=1);
require dirname(__DIR__) . '/vendor/autoload.php';
use app\adminapi\controller\doctor\AppointmentController;
use app\adminapi\controller\tcm\DiagnosisController;
use app\adminapi\controller\tcm\PrescriptionController;
use app\adminapi\logic\doctor\AppointmentLogic;
use app\adminapi\logic\firstvisit\MyPatientLogic;
use app\adminapi\logic\tcm\DiagnosisAiLogic;
use app\adminapi\logic\tcm\DiagnosisLogic;
use app\adminapi\logic\tcm\PrescriptionLogic;
function diagnosisWorkspaceAuthExpect(bool $condition, string $message): void
{
if (!$condition) {
throw new RuntimeException($message);
}
}
function diagnosisWorkspaceMethodSource(ReflectionMethod $method): string
{
$file = file($method->getFileName());
if (!is_array($file)) {
throw new RuntimeException('authorization method source is readable');
}
return implode('', array_slice(
$file,
$method->getStartLine() - 1,
$method->getEndLine() - $method->getStartLine() + 1
));
}
// Pure policy helpers are invoked directly so this security regression test never needs a real database.
$appointmentScope = (new ReflectionClass(AppointmentLogic::class))
->getMethod('appointmentRowManageableForScope');
$filterPrescriptions = (new ReflectionClass(PrescriptionLogic::class))
->getMethod('filterViewablePrescriptions');
diagnosisWorkspaceAuthExpect(
$appointmentScope->invoke(null, 31, 41, 31, [1], null, false) === true,
'assigned doctor can open the reception row'
);
diagnosisWorkspaceAuthExpect(
$appointmentScope->invoke(null, 32, 41, 31, [1], null, false) === false,
'doctor cannot open another doctor appointment row'
);
diagnosisWorkspaceAuthExpect(
$appointmentScope->invoke(null, 32, 41, 41, [2], null, false) === true,
'assigned assistant can open the reception row'
);
diagnosisWorkspaceAuthExpect(
$appointmentScope->invoke(null, 999, 999, 1, [1, 2], [], true) === true,
'root keeps reception compatibility regardless of role and data scope'
);
$ownPrescription = [
'id' => 51,
'creator_id' => 7,
'assistant_id' => 0,
'is_shared' => 0,
'visible_role_ids' => '',
];
$otherPrescription = [
'id' => 52,
'creator_id' => 8,
'assistant_id' => 9,
'is_shared' => 0,
'visible_role_ids' => '',
];
diagnosisWorkspaceAuthExpect(
$filterPrescriptions->invoke(null, [$ownPrescription], 7, []) === [$ownPrescription],
'visible prescription keeps the existing response row unchanged'
);
diagnosisWorkspaceAuthExpect(
$filterPrescriptions->invoke(null, [$otherPrescription], 1, ['root' => 1]) === [$otherPrescription],
'root keeps prescription compatibility'
);
$diagnosisLogicSource = file_get_contents((new ReflectionClass(DiagnosisLogic::class))->getFileName());
$diagnosisAiLogicSource = file_get_contents((new ReflectionClass(DiagnosisAiLogic::class))->getFileName());
$myPatientLogicSource = file_get_contents((new ReflectionClass(MyPatientLogic::class))->getFileName());
$appointmentLogicSource = file_get_contents((new ReflectionClass(AppointmentLogic::class))->getFileName());
$prescriptionLogicSource = file_get_contents((new ReflectionClass(PrescriptionLogic::class))->getFileName());
$diagnosisControllerSource = file_get_contents(
dirname(__DIR__) . '/app/adminapi/controller/tcm/DiagnosisController.php'
);
$appointmentControllerSource = file_get_contents(
dirname(__DIR__) . '/app/adminapi/controller/doctor/AppointmentController.php'
);
$prescriptionControllerSource = file_get_contents(
dirname(__DIR__) . '/app/adminapi/controller/tcm/PrescriptionController.php'
);
$appointmentListsSource = file_get_contents(
dirname(__DIR__) . '/app/adminapi/lists/doctor/AppointmentLists.php'
);
$doctorNoteLogicSource = file_get_contents(
dirname(__DIR__) . '/app/adminapi/logic/doctor/DoctorNoteLogic.php'
);
foreach ([
$diagnosisLogicSource,
$diagnosisAiLogicSource,
$myPatientLogicSource,
$appointmentLogicSource,
$prescriptionLogicSource,
$diagnosisControllerSource,
$appointmentControllerSource,
$prescriptionControllerSource,
$appointmentListsSource,
$doctorNoteLogicSource,
] as $source) {
diagnosisWorkspaceAuthExpect(is_string($source), 'authorization source is readable');
}
$myPatientScopeMethod = diagnosisWorkspaceMethodSource(
(new ReflectionClass(MyPatientLogic::class))->getMethod('applyScope')
);
$diagnosisReadonlyAuthMethod = diagnosisWorkspaceMethodSource(
(new ReflectionClass(DiagnosisLogic::class))->getMethod('canViewReadonlyDiagnosis')
);
$diagnosisAiAuthMethod = diagnosisWorkspaceMethodSource(
(new ReflectionClass(DiagnosisAiLogic::class))->getMethod('loadAuthorizedDiagnosis')
);
$prescriptionListMethod = diagnosisWorkspaceMethodSource(
(new ReflectionClass(PrescriptionLogic::class))->getMethod('listByDiagnosis')
);
$trackingWindowMethod = diagnosisWorkspaceMethodSource(
(new ReflectionClass(DiagnosisLogic::class))->getMethod('fetchTrackingWindow')
);
$trackingWindowControllerMethod = diagnosisWorkspaceMethodSource(
(new ReflectionClass(DiagnosisController::class))->getMethod('trackingWindow')
);
$doctorNotesControllerMethod = diagnosisWorkspaceMethodSource(
(new ReflectionClass(AppointmentController::class))->getMethod('doctorNotes')
);
$addDoctorNoteControllerMethod = diagnosisWorkspaceMethodSource(
(new ReflectionClass(AppointmentController::class))->getMethod('addDoctorNote')
);
$receptionMethod = diagnosisWorkspaceMethodSource(
(new ReflectionClass(AppointmentLogic::class))->getMethod('reception')
);
$prescriptionControllerMethod = diagnosisWorkspaceMethodSource(
(new ReflectionClass(PrescriptionController::class))->getMethod('listByDiagnosis')
);
diagnosisWorkspaceAuthExpect(
str_contains($myPatientScopeMethod, 'in_array(self::ASSISTANT_ROLE_ID, $roleIds, true)')
&& str_contains($myPatientScopeMethod, "'CAST(d.assistant_id AS UNSIGNED) = ' . \$adminId")
&& str_contains($myPatientScopeMethod, 'in_array(self::DOCTOR_ROLE_ID, $roleIds, true)')
&& str_contains($myPatientScopeMethod, 'scope_apt.doctor_id = {$adminId}'),
'diagnosis row policy keeps assistant assignment and doctor appointment ownership contracts'
);
diagnosisWorkspaceAuthExpect(
str_contains($myPatientScopeMethod, 'array_intersect($roleIds, self::TEAM_ROLE_IDS)')
&& str_contains($myPatientScopeMethod, 'DataScopeService::getVisibleAdminIds($adminId, $adminInfo)')
&& strpos($myPatientScopeMethod, 'array_intersect($roleIds, self::TEAM_ROLE_IDS)')
< strpos($myPatientScopeMethod, 'in_array(self::DOCTOR_ROLE_ID, $roleIds, true)'),
'DataScope ALL is reserved for team roles before ordinary doctor and assistant self-relations'
);
diagnosisWorkspaceAuthExpect(
str_contains($diagnosisReadonlyAuthMethod, 'MyPatientLogic::canAccessDiagnosis(')
&& !str_contains($diagnosisReadonlyAuthMethod, 'DataScopeService::getVisibleAdminIds'),
'readonly diagnosis authorization reuses the canonical patient row policy'
);
diagnosisWorkspaceAuthExpect(
str_contains($diagnosisAiAuthMethod, 'MyPatientLogic::canAccessDiagnosis(')
&& strpos($diagnosisAiAuthMethod, 'MyPatientLogic::canAccessDiagnosis(')
< strpos($diagnosisAiAuthMethod, 'DiagnosisLogic::detail(')
&& !str_contains($diagnosisAiAuthMethod, 'DataScopeService::getVisibleAdminIds'),
'AI diagnosis authorization reuses the canonical row policy before loading case details'
);
diagnosisWorkspaceAuthExpect(
str_contains($trackingWindowControllerMethod, 'canViewReadonlyDiagnosis((int) $params[\'id\']')
&& strpos($trackingWindowControllerMethod, 'canViewReadonlyDiagnosis((int) $params[\'id\']')
< strpos($trackingWindowControllerMethod, 'DiagnosisLogic::fetchTrackingWindow('),
'trackingWindow authorizes the diagnosis before reading tracking records'
);
diagnosisWorkspaceAuthExpect(
str_contains($trackingWindowMethod, "'diagnosis_id' => \$diagnosisId")
&& strpos($trackingWindowMethod, "'diagnosis_id' => \$diagnosisId")
< strpos($trackingWindowMethod, "'blood_records'"),
'trackingWindow returns the authorized diagnosis id at the response top level'
);
diagnosisWorkspaceAuthExpect(
str_contains($doctorNotesControllerMethod, 'DiagnosisLogic::canViewReadonlyDiagnosis(')
&& strpos($doctorNotesControllerMethod, 'DiagnosisLogic::canViewReadonlyDiagnosis(')
< strpos($doctorNotesControllerMethod, 'DoctorNoteLogic::getByDiagnosis('),
'doctorNotes authorizes the diagnosis before reading notes'
);
diagnosisWorkspaceAuthExpect(
str_contains($addDoctorNoteControllerMethod, 'DiagnosisLogic::canViewReadonlyDiagnosis(')
&& strpos($addDoctorNoteControllerMethod, 'DiagnosisLogic::canViewReadonlyDiagnosis(')
< strpos($addDoctorNoteControllerMethod, 'DoctorNoteLogic::addOrAppend('),
'addDoctorNote authorizes the diagnosis before writing any note data'
);
diagnosisWorkspaceAuthExpect(
str_contains($receptionMethod, 'appointmentRowManageableByAdmin(')
&& strpos($receptionMethod, 'appointmentRowManageableByAdmin(')
< strpos($receptionMethod, '$appointment = self::detail($params);'),
'reception authorizes the appointment before loading its detail DTO'
);
diagnosisWorkspaceAuthExpect(
str_contains($prescriptionListMethod, 'MyPatientLogic::canAccessDiagnosis(')
&& strpos($prescriptionListMethod, 'MyPatientLogic::canAccessDiagnosis(')
< strpos($prescriptionListMethod, "Prescription::where('diagnosis_id', \$diagnosisId)"),
'listByDiagnosis authorizes its parent diagnosis before the first prescription SQL query'
);
diagnosisWorkspaceAuthExpect(
str_contains($prescriptionLogicSource, 'self::canViewPrescription($row, $viewerAdminId, $viewerAdminInfo)')
&& str_contains(
$prescriptionControllerMethod,
'PrescriptionLogic::listByDiagnosis($diagnosisId, (int) $this->adminId, $this->adminInfo)'
)
&& str_contains($prescriptionControllerMethod, "PrescriptionLogic::getError() !== ''"),
'listByDiagnosis keeps child visibility filtering and surfaces parent authorization failure'
);
diagnosisWorkspaceAuthExpect(
str_contains($appointmentListsSource, 'u.patient_id AS source_patient_id'),
'appointment DTO exposes the source patient id separately from the diagnosis id'
);
diagnosisWorkspaceAuthExpect(
str_contains($doctorNoteLogicSource, 'normalizeNewAttachmentPaths(')
&& str_contains($doctorNoteLogicSource, "\$domainHost === \$urlHost")
&& str_contains($doctorNoteLogicSource, "\$domainPort === \$urlPort")
&& str_contains($doctorNoteLogicSource, "str_starts_with(\$urlPath, \$domainPath . '/')")
&& str_contains($doctorNoteLogicSource, "str_starts_with(\$path, '//')"),
'new note attachments require an exact configured storage origin and path boundary'
);
diagnosisWorkspaceAuthExpect(
substr_count($diagnosisControllerSource, '诊单不存在或无权访问') >= 2
&& str_contains($appointmentControllerSource, '预约记录不存在或无权访问')
&& str_contains($appointmentControllerSource, '诊单不存在或无权访问'),
'missing and forbidden child-resource lookups share non-enumerating errors'
);
echo "Diagnosis workspace row authorization: OK\n";
+163
View File
@@ -0,0 +1,163 @@
<?php
declare(strict_types=1);
require dirname(__DIR__) . '/vendor/autoload.php';
use app\common\service\DifyChatService;
function difyStreamExpect(bool $condition, string $message): void
{
if (!$condition) {
fwrite(STDERR, "FAIL: {$message}\n");
exit(1);
}
}
/** @return mixed */
function callDifyStreamPrivate(string $method, array $arguments)
{
return (new ReflectionClass(DifyChatService::class))->getMethod($method)->invokeArgs(null, $arguments);
}
$generic = callDifyStreamPrivate('buildRequestSpecs', [
'https://ai.example.test/v1',
'model-safe',
['case' => 'redacted'],
'safe query',
'admin-safe',
true,
]);
difyStreamExpect(count($generic) === 2, 'generic /v1 keeps Dify then OpenAI fallback order');
difyStreamExpect($generic[0]['protocol'] === 'dify', 'Dify remains the first generic protocol');
difyStreamExpect(
$generic[0]['payload']['response_mode'] === 'streaming',
'Dify stream request uses response_mode=streaming'
);
difyStreamExpect($generic[1]['protocol'] === 'openai', 'OpenAI remains the fallback protocol');
difyStreamExpect($generic[1]['payload']['stream'] === true, 'OpenAI stream request uses stream=true');
difyStreamExpect(
$generic[0]['payload']['inputs'] === ['case' => 'redacted']
&& $generic[0]['payload']['query'] === 'safe query'
&& $generic[0]['payload']['user'] === 'admin-safe',
'Dify streaming preserves structured inputs, query and user'
);
$blocking = callDifyStreamPrivate('buildRequestSpecs', [
'https://ai.example.test/v1',
'model-safe',
[],
'safe query',
'admin-safe',
]);
difyStreamExpect(
$blocking[0]['payload']['response_mode'] === 'blocking',
'legacy Dify blocking request remains unchanged'
);
difyStreamExpect(
!array_key_exists('stream', $blocking[1]['payload']),
'legacy OpenAI blocking request does not gain a stream field'
);
$explicitDify = callDifyStreamPrivate('buildRequestSpecs', [
'https://ai.example.test/v1/chat-messages', 'model-safe', [], 'query', 'user', true,
]);
$explicitOpenAi = callDifyStreamPrivate('buildRequestSpecs', [
'https://ai.example.test/v1/chat/completions', 'model-safe', [], 'query', 'user', true,
]);
difyStreamExpect(count($explicitDify) === 1 && $explicitDify[0]['protocol'] === 'dify', 'explicit Dify endpoint never changes protocol');
difyStreamExpect(count($explicitOpenAi) === 1 && $explicitOpenAi[0]['protocol'] === 'openai', 'explicit OpenAI endpoint never changes protocol');
$difyWire = ": ping\r\n\r\n"
. "data: {\"event\":\"message\",\"answer\":\"\",\"message_id\":\"msg-safe\"}\r\n\r\n"
. "data: {\"event\":\"agent_message\",\"answer\":\"\"}\r\n\r\n"
. "data: {\"event\":\"ping\"}\r\n\r\n"
. "data: {\"event\":\"message_end\",\"message_id\":\"msg-safe\"}\r\n\r\n";
$difyChunks = str_split($difyWire, 1);
$decodedDify = callDifyStreamPrivate('decodeStreamChunks', ['dify', $difyChunks]);
difyStreamExpect($decodedDify['content'] === '你好', 'Dify decoder handles every possible byte boundary, including UTF-8 bytes');
difyStreamExpect($decodedDify['deltas'] === ['你', '好'], 'Dify decoder emits only message text');
difyStreamExpect($decodedDify['message_id'] === 'msg-safe', 'Dify decoder retains the safe message id internally');
difyStreamExpect($decodedDify['finished'] === true, 'Dify message_end terminates parsing');
$openAiWire = "data: {\"id\":\"chat-safe\",\"choices\":[{\"delta\":{\"content\":\"A\"}}]}\n\n"
. "data: {\"choices\":[{\"delta\":{\"content\":\"\"}}]}\n\n"
. "data: [DONE]";
$decodedOpenAi = callDifyStreamPrivate('decodeStreamChunks', ['openai', str_split($openAiWire, 2)]);
difyStreamExpect($decodedOpenAi['content'] === 'A中', 'OpenAI decoder handles arbitrary byte chunks and final frame without newline');
difyStreamExpect($decodedOpenAi['deltas'] === ['A', '中'], 'OpenAI decoder emits choices delta content only');
difyStreamExpect($decodedOpenAi['finished'] === true, 'OpenAI [DONE] terminates parsing');
$malformed = callDifyStreamPrivate('decodeStreamChunks', [
'dify',
["data: not-json\n\n", "data: {\"event\":\"error\",\"message\":\"secret-upstream-body\"}\n\n"],
]);
difyStreamExpect($malformed['content'] === '', 'malformed and upstream error frames never become text');
difyStreamExpect($malformed['upstream_error'] === true, 'Dify error frame becomes an internal error flag');
difyStreamExpect(!str_contains(json_encode($malformed), 'secret-upstream-body'), 'upstream error body is not retained');
$safeError = callDifyStreamPrivate('formatStreamResponse', [[
'errno' => 0,
'http_code' => 200,
'content' => '',
'message_id' => '',
'emitted' => false,
'upstream_error' => true,
'client_aborted' => false,
'callback_error' => false,
'finished' => false,
], microtime(true)]);
$encodedError = json_encode($safeError, JSON_UNESCAPED_UNICODE);
difyStreamExpect($safeError['error_code'] === 'UPSTREAM_REJECTED', 'upstream SSE errors map to a stable internal code');
difyStreamExpect(!str_contains($encodedError, 'secret'), 'formatted stream errors contain no upstream body, key or prompt');
$serviceSource = file_get_contents(dirname(__DIR__) . '/app/common/service/DifyChatService.php');
difyStreamExpect(
is_string($serviceSource)
&& str_contains($serviceSource, '$responseCode < 200 || $responseCode >= 300')
&& str_contains($serviceSource, 'CURLOPT_HEADERFUNCTION => $header')
&& str_contains($serviceSource, "'Accept: text/event-stream'")
&& !str_contains($serviceSource, "config('ai')"),
'streaming rejects HTTP error bodies before parsing and never mixes daily-diet AI configuration'
);
$timeoutError = callDifyStreamPrivate('formatStreamResponse', [[
'errno' => CURLE_OPERATION_TIMEDOUT,
'http_code' => 0,
'content' => '',
'message_id' => '',
'emitted' => false,
'upstream_error' => false,
'client_aborted' => false,
'callback_error' => false,
'finished' => false,
], microtime(true)]);
$disconnectError = callDifyStreamPrivate('formatStreamResponse', [[
'errno' => CURLE_ABORTED_BY_CALLBACK,
'http_code' => 200,
'content' => 'partial prompt must not appear',
'message_id' => '',
'emitted' => true,
'upstream_error' => false,
'client_aborted' => true,
'callback_error' => false,
'finished' => false,
], microtime(true)]);
difyStreamExpect($timeoutError['error_code'] === 'UPSTREAM_TIMEOUT', 'curl timeout maps to a stable timeout result');
difyStreamExpect($disconnectError['error_code'] === 'CLIENT_DISCONNECTED', 'client abort takes precedence over curl abort errno');
difyStreamExpect(!str_contains(json_encode($disconnectError), 'partial prompt'), 'disconnect result does not echo partial content');
$incompleteError = callDifyStreamPrivate('formatStreamResponse', [[
'errno' => 0,
'http_code' => 200,
'content' => 'partial answer',
'message_id' => '',
'emitted' => true,
'upstream_error' => false,
'client_aborted' => false,
'callback_error' => false,
'finished' => false,
], microtime(true)]);
difyStreamExpect($incompleteError['error_code'] === 'INCOMPLETE_RESPONSE', 'missing [DONE]/message_end cannot become a successful done');
difyStreamExpect(!str_contains(json_encode($incompleteError), 'partial answer'), 'incomplete response error does not echo partial content');
echo "Dify chat stream contract: OK\n";
@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
use app\adminapi\logic\firstvisit\FirstVisitConversionLogic;
use app\common\service\qywx\MediaChannelService;
require dirname(__DIR__) . '/vendor/autoload.php';
function conversionFinanceExpect(bool $condition, string $message): void
{
if (!$condition) {
throw new RuntimeException($message);
}
}
conversionFinanceExpect(
MediaChannelService::buildGroupCode('自媒体4') === 'group:自媒体4',
'Group codes must use the group: prefix'
);
conversionFinanceExpect(
MediaChannelService::parseGroupName('group:自媒体3') === '自媒体3',
'Group codes must round-trip the group name'
);
conversionFinanceExpect(
MediaChannelService::isGroupCode('group:自媒体4')
&& !MediaChannelService::isGroupCode('tag_et4h'),
'Only group: prefixed values are group codes'
);
$leafCodes = MediaChannelService::getChannelCodesForStats([
'channel_code' => 'group:自媒体4',
'channel_codes' => ['tag_et4h', 'tag_et4q', 'group:ignored'],
'is_group' => true,
]);
conversionFinanceExpect(
$leafCodes === ['tag_et4h', 'tag_et4q'],
'Stats channel codes must expand a group into leaf codes only'
);
$reflection = new ReflectionClass(FirstVisitConversionLogic::class);
$canViewFinance = $reflection->getMethod('canViewFinance');
$maskFinanceFields = $reflection->getMethod('maskFinanceFields');
$personalYejiMediaSources = $reflection->getMethod('personalYejiMediaSources');
$canViewFinance->setAccessible(true);
$maskFinanceFields->setAccessible(true);
$personalYejiMediaSources->setAccessible(true);
conversionFinanceExpect(
$canViewFinance->invoke(null, 1, ['root' => 1, 'role_name' => '医助']) === true,
'Root must always see cash cost and ROI'
);
conversionFinanceExpect(
$canViewFinance->invoke(null, 8, ['root' => 0, 'role_name' => '经理']) === true,
'Managers must always see cash cost and ROI'
);
conversionFinanceExpect(
$canViewFinance->invoke(null, 0, ['root' => 0, 'role_name' => '诊室组长']) === false,
'Group leaders without the finance permission must not see cash cost and ROI'
);
conversionFinanceExpect(
$canViewFinance->invoke(null, 0, ['root' => 0, 'role_name' => '医助']) === false,
'Assistants without the finance permission must not see cash cost and ROI'
);
$masked = $maskFinanceFields->invoke(null, [
'completed_order_count' => 2,
'account_cost' => 88.5,
'cash_cost' => 12.3,
'roi' => 1.5,
'children' => [[
'name' => '医助甲',
'account_cost' => 40,
'roi' => 2,
'children' => [],
]],
]);
conversionFinanceExpect(
!isset($masked['account_cost'], $masked['cash_cost'], $masked['roi'])
&& $masked['completed_order_count'] === 2
&& !isset($masked['children'][0]['account_cost'], $masked['children'][0]['roi']),
'Finance fields must be stripped from summary rows and nested members'
);
$groupSources = $personalYejiMediaSources->invoke(null, 'group:自媒体4', [
'channel_code' => 'group:自媒体4',
'channel_name' => '自媒体4',
'channel_codes' => ['tag_et4h', 'tag_et4q'],
'channel_names' => ['自媒体4H', '自媒体4Q'],
'is_group' => true,
]);
conversionFinanceExpect(
is_array($groupSources)
&& in_array('自媒体4', $groupSources, true)
&& in_array('自媒体4H', $groupSources, true)
&& in_array('自媒体4Q', $groupSources, true)
&& in_array('tag_et4h', $groupSources, true)
&& !in_array('group:自媒体4', $groupSources, true),
'Group channel opening counts must match every leaf name and code, not the synthetic group code'
);
echo "FirstVisitConversionFinanceAndChannelTest passed\n";
@@ -23,8 +23,17 @@ $tagSql = (string)$tagQuery->fetchSql()->select();
if (!str_contains($tagSql, 'qywx_external_contact_tag')) {
throw new RuntimeException('tag 渠道未使用结构化客户标签关系表');
}
if (!str_contains($tagSql, ' IN (SELECT channel_tag.external_userid')) {
throw new RuntimeException('tag 渠道未通过去重子查询过滤 external_userid');
if (!str_contains($tagSql, 'EXISTS (SELECT 1 FROM')) {
throw new RuntimeException('tag 渠道未使用 EXISTS 半连接,避免物化整渠客户 ID');
}
if (!str_contains($tagSql, 'channel_tag.external_userid = e.external_userid')) {
throw new RuntimeException('tag 渠道未按事实表 external_userid 相关查询');
}
if (!str_contains($tagSql, 'tag_id = ')) {
throw new RuntimeException('单标签渠道应使用 tag_id = 走组合索引');
}
if (str_contains($tagSql, 'tag_id IN (')) {
throw new RuntimeException('单标签渠道不应退化为 tag_id IN');
}
if (str_contains($tagSql, 'follow_users') || str_contains($tagSql, 'LIKE')) {
throw new RuntimeException('tag 渠道仍在扫描 follow_users JSON');
@@ -47,4 +56,26 @@ if (!str_contains($legacySql, 'channel_contact.delete_time IS NULL')) {
throw new RuntimeException('老渠道回退包含了已删除客户记录');
}
$groupQuery = Db::name('qywx_external_contact_event')->alias('e');
MediaChannelService::applyExternalUserChannelFilter(
$groupQuery,
'e.external_userid',
[
'source_tag_id' => '',
'source_tag_ids' => ['tag-group-a', 'tag-group-b'],
'channel_name' => '自媒体4',
'is_group' => true,
]
);
$groupSql = (string)$groupQuery->fetchSql()->select();
if (!str_contains($groupSql, 'EXISTS (SELECT 1 FROM')) {
throw new RuntimeException('分组渠道未使用 EXISTS 半连接');
}
if (!str_contains($groupSql, 'tag_id IN (')) {
throw new RuntimeException('分组渠道未按多个 tag_id 过滤');
}
if (str_contains($groupSql, 'follow_users') || str_contains($groupSql, 'LIKE')) {
throw new RuntimeException('分组渠道仍在扫描 follow_users JSON');
}
echo "MEDIA_CHANNEL_EXTERNAL_USER_FILTER_OK\n";