Compare commits
59
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba2e2e4087 | ||
|
|
56f85f7d89 | ||
|
|
c67b3eff69 | ||
|
|
d4b81b40c2 | ||
|
|
bb28d93cec | ||
|
|
bcd42a5c39 | ||
|
|
11306e6314 | ||
|
|
8f7544c1ad | ||
|
|
86041042b9 | ||
|
|
9e3aa9fc61 | ||
|
|
2dfec801e7 | ||
|
|
14e1722291 | ||
|
|
70ddc1a70a | ||
|
|
fc91908cf9 | ||
|
|
b4acad8510 | ||
|
|
f015b26ae1 | ||
|
|
91aa1cb595 | ||
|
|
0349ec0fbd | ||
|
|
68b412495d | ||
|
|
55979da912 | ||
|
|
228d93ac3b | ||
|
|
4704b32807 | ||
|
|
73be0ecedf | ||
|
|
6ccef86dd0 | ||
|
|
e762a367da | ||
|
|
91f56931fd | ||
|
|
9185c6db04 | ||
|
|
5884e000ff | ||
|
|
4d0af7f3f4 | ||
|
|
27772bec61 | ||
|
|
5706e9a6a5 | ||
|
|
51d8fd409e | ||
|
|
88b94c2fc6 | ||
|
|
637c2f1ff2 | ||
|
|
19ea2dadeb | ||
|
|
4dac39c4c2 | ||
|
|
a20d3565af | ||
|
|
67c374d899 | ||
|
|
13948f5982 | ||
|
|
1c9b093e08 | ||
|
|
8bb4bd07ae | ||
|
|
fea33285e8 | ||
|
|
5543f45850 | ||
|
|
70be2fc70f | ||
|
|
3cdc812f2b | ||
|
|
71304f356d | ||
|
|
694c83dc7d | ||
|
|
735b607f80 | ||
|
|
4ef3cef6a0 | ||
|
|
49fc974421 | ||
|
|
88ce52620a | ||
|
|
0a17f071fc | ||
|
|
8e2b4fc763 | ||
|
|
b7e91f5fe3 | ||
|
|
68fc86a167 | ||
|
|
bd22e5f476 | ||
|
|
dbf474ddd7 | ||
|
|
bbe3e870a1 | ||
|
|
caab2cabe3 |
@@ -0,0 +1,3 @@
|
|||||||
|
# The bundled GB18030 source files must retain their original bytes.
|
||||||
|
server/knowledge/tcm-ancient-books/*.txt -text -diff
|
||||||
|
server/knowledge/tcm-ancient-books/*.baiduyun.downloading* -text -diff
|
||||||
@@ -28,6 +28,9 @@ app/.test-tmp-stream/
|
|||||||
/.codex
|
/.codex
|
||||||
/.agents
|
/.agents
|
||||||
/server/.spool
|
/server/.spool
|
||||||
|
# Generated ancient-books search indexes; the source corpus is tracked.
|
||||||
|
/server/knowledge/tcm-ancient-books-index.json
|
||||||
|
/server/knowledge/tcm-ancient-books-index/
|
||||||
/server/.claude
|
/server/.claude
|
||||||
/.spool
|
/.spool
|
||||||
TUICallKit-Vue3/.env
|
TUICallKit-Vue3/.env
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const path = require('node:path')
|
||||||
|
const vm = require('node:vm')
|
||||||
|
const ts = require('typescript')
|
||||||
|
const vue = require('vue')
|
||||||
|
const { parse, compileScript, compileTemplate, compileStyleAsync } = require('@vue/compiler-sfc')
|
||||||
|
|
||||||
|
const sourceFlag = process.argv.indexOf('--source')
|
||||||
|
const filename = sourceFlag >= 0 ? path.resolve(process.argv[sourceFlag + 1]) : path.join(__dirname, '../src/views/tcm/diagnosis/components/DailyMatrix.vue')
|
||||||
|
const source = fs.readFileSync(filename, 'utf8')
|
||||||
|
const { descriptor, errors } = parse(source, { filename })
|
||||||
|
assert.deepEqual(errors, [], 'actual SFC parses')
|
||||||
|
const script = compileScript(descriptor, { id: 'daily-record-test' })
|
||||||
|
const template = compileTemplate({ source: descriptor.template.content, filename, id: 'daily-record-test', compilerOptions: { bindingMetadata: script.bindings } })
|
||||||
|
assert.deepEqual(template.errors, [], 'actual template compiles')
|
||||||
|
const calls = []
|
||||||
|
const pending = { window: null, notes: null, bloodEdit: null }
|
||||||
|
const api = new Proxy({}, { get(_target, name) {
|
||||||
|
return (...args) => {
|
||||||
|
calls.push({ name, args: JSON.parse(JSON.stringify(args)) })
|
||||||
|
if (name === 'diagnosisTrackingWindow') return pending.window ? pending.window(...args) : Promise.resolve({})
|
||||||
|
if (name === 'diagnosisTrackingNotes') return pending.notes ? pending.notes(...args) : Promise.resolve([])
|
||||||
|
if (name === 'bloodRecordEdit' && pending.bloodEdit) return pending.bloodEdit(...args)
|
||||||
|
return Promise.resolve(true)
|
||||||
|
}
|
||||||
|
} })
|
||||||
|
const thresholdSource = fs.readFileSync(path.join(__dirname, '../src/utils/blood-thresholds.ts'), 'utf8')
|
||||||
|
const thresholdExports = {}
|
||||||
|
vm.runInNewContext(ts.transpileModule(thresholdSource, { compilerOptions: { module: ts.ModuleKind.CommonJS } }).outputText, { exports: thresholdExports })
|
||||||
|
const moduleObject = { exports: {} }
|
||||||
|
const lifecycle = { mounted: [], unmounted: [] }
|
||||||
|
const runtime = { ...vue, onMounted: fn => lifecycle.mounted.push(fn), onUnmounted: fn => lifecycle.unmounted.push(fn) }
|
||||||
|
vm.runInNewContext(ts.transpileModule(script.content, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText, {
|
||||||
|
exports: moduleObject.exports, module: moduleObject, console, Date, Map, window: { addEventListener() {}, removeEventListener() {} },
|
||||||
|
require(name) {
|
||||||
|
if (name === 'vue') return runtime
|
||||||
|
if (name === '@/api/tcm') return api
|
||||||
|
if (name === '@/utils/perm') return { hasPermission: () => true }
|
||||||
|
if (name === '@/utils/blood-thresholds') return thresholdExports
|
||||||
|
if (name === '@/utils/feedback') return { default: { msgWarning: message => calls.push({ name: 'warning', message }) } }
|
||||||
|
if (name === 'vue-echarts' || name.endsWith('.vue')) return { default: {} }
|
||||||
|
throw new Error(`Unexpected import: ${name}`)
|
||||||
|
}
|
||||||
|
}, { filename })
|
||||||
|
const props = vue.reactive({ diagnosisId: 7, patientId: 70, age: 50, readOnly: false, patientName: '合成测试' })
|
||||||
|
let exposed
|
||||||
|
const scope = vue.effectScope()
|
||||||
|
const state = scope.run(() => moduleObject.exports.default.setup(props, { expose(value) { exposed = value } }))
|
||||||
|
const date = '2026-09-20'
|
||||||
|
const blood = [
|
||||||
|
{ id: 103, diagnosis_id: 7, record_date: date, record_time: '19:30', fasting_blood_sugar: 7.1, record_time_estimated: 1, record_time_period: 'evening', record_time_text: '昨晚', followup_audio_task_id: 80, remark: '晚间原始行' },
|
||||||
|
{ id: 102, diagnosis_id: 7, record_date: date, record_time: '08:30', fasting_blood_sugar: 7.1, source: 1, remark: '早间原始行' },
|
||||||
|
{ id: 101, diagnosis_id: 7, record_date: date, record_time: '07:00', postprandial_blood_sugar: 8.4, remark: '独立原始行' }
|
||||||
|
]
|
||||||
|
const diet = [ { id: 201, record_date: date, breakfast_foods: '鸡蛋', breakfast_images: ['fixture-a.png'] }, { id: 202, record_date: date, lunch_foods: '豆腐', lunch_images: ['fixture-b.png'] } ]
|
||||||
|
const exercise = [ { id: 301, record_date: date, exercise_type: '散步', duration: 20, intensity: 1, images: ['fixture-c.png'] }, { id: 302, record_date: date, exercise_type: '散步', duration: 20, intensity: 1 } ]
|
||||||
|
function fixtures() {
|
||||||
|
state.bloodRecords.value = structuredClone(blood)
|
||||||
|
state.dietRecords.value = structuredClone(diet)
|
||||||
|
state.exerciseRecords.value = structuredClone(exercise)
|
||||||
|
}
|
||||||
|
function rowCount(map) { const rows = map.value.get(date); return Array.isArray(rows) ? rows.length : rows ? 1 : 0 }
|
||||||
|
function observe() {
|
||||||
|
fixtures()
|
||||||
|
state.handleCellClick('fasting', date)
|
||||||
|
return { bloodRows: rowCount(state.bloodByDate), dietRows: rowCount(state.dietByDate), exerciseRows: rowCount(state.exerciseByDate), dayDetailOpen: !!state.dayDetailVisible?.value, implicitBloodEditId: state.bloodForm.value.id || null, fastingCell: state.getCell('fasting', date).value }
|
||||||
|
}
|
||||||
|
async function main() {
|
||||||
|
for (const style of descriptor.styles) {
|
||||||
|
const result = await compileStyleAsync({ source: style.content, filename, id: 'daily-record-test', scoped: style.scoped, preprocessLang: style.lang })
|
||||||
|
assert.deepEqual(result.errors, [], 'actual styles compile')
|
||||||
|
}
|
||||||
|
const canonicalFlag = process.argv.indexOf('--canonical-fixture')
|
||||||
|
if (canonicalFlag >= 0) {
|
||||||
|
const fixture = JSON.parse(fs.readFileSync(process.argv[canonicalFlag + 1], 'utf8'))
|
||||||
|
assert.equal(fixture.synthetic, true)
|
||||||
|
assert.equal(fixture.source, 'real-disposable-mysql-apply')
|
||||||
|
for (const alias of ['breakfast', 'lunch', 'dinner']) assert.equal(fixture.diet_columns.includes(alias), false)
|
||||||
|
const actual = { ...fixture.diet_model, record_date: new Intl.DateTimeFormat('sv-SE', { timeZone: 'Asia/Shanghai' }).format(new Date(Number(fixture.diet_model.record_date) * 1000)) }
|
||||||
|
state.dietRecords.value = [actual]
|
||||||
|
state.openDietRecord(actual, actual.record_date)
|
||||||
|
for (const meal of ['breakfast', 'lunch', 'dinner']) {
|
||||||
|
assert.equal(state.dietForm.value[`${meal}_foods`], actual[`${meal}_foods`], 'real adopted meal opens in existing edit input')
|
||||||
|
assert.deepEqual(JSON.parse(JSON.stringify(state.dietForm.value[`${meal}_images`])), actual[`${meal}_images`])
|
||||||
|
assert.equal(state.getCell(meal, actual.record_date).value, '✓')
|
||||||
|
}
|
||||||
|
state.dietForm.value.note = 'only synthetic note changed'
|
||||||
|
await state.submitDiet()
|
||||||
|
const payload = calls.findLast(x => x.name === 'dietRecordEdit').args[0]
|
||||||
|
for (const meal of ['breakfast', 'lunch', 'dinner']) {
|
||||||
|
assert.equal(payload[`${meal}_foods`], actual[`${meal}_foods`], 'submit preserves untouched canonical meals')
|
||||||
|
assert.deepEqual(payload[`${meal}_images`], actual[`${meal}_images`], 'submit preserves images')
|
||||||
|
assert.equal(meal in payload, false, 'no extraction alias leaks to existing edit API')
|
||||||
|
}
|
||||||
|
console.log('DAILY_RECORDS_CANONICAL_MYSQL PASS actual_model=1 edit_inputs=1 canonical_payload=1 preserved_images=1')
|
||||||
|
}
|
||||||
|
if (process.argv.includes('--observe')) { console.log(JSON.stringify(observe())); scope.stop(); return }
|
||||||
|
assert.deepEqual(observe(), { bloodRows: 3, dietRows: 2, exerciseRows: 2, dayDetailOpen: true, implicitBloodEditId: null, fastingCell: '7.1 / 7.1' })
|
||||||
|
assert.equal(state.dayBloodRecords.value.length, 3, 'all same-day blood rows present')
|
||||||
|
assert.equal(state.dayDietRecords.value.length, 2, 'all diet rows present')
|
||||||
|
assert.equal(state.dayExerciseRecords.value.length, 2, 'identical exercise rows remain separate')
|
||||||
|
assert.equal(state.recordTimeLabel(blood[0]), '19:30')
|
||||||
|
assert.equal(state.recordTimePeriodLabel(blood[0]), '晚上')
|
||||||
|
assert.equal(state.recordTimeLabel(diet[0]), '未记录具体时间', 'do not invent times for old diet rows')
|
||||||
|
assert.match(descriptor.template.content, /v-for="record in dayBloodRecords"/)
|
||||||
|
assert.match(descriptor.template.content, /v-for="record in dayDietRecords"/)
|
||||||
|
assert.match(descriptor.template.content, /v-for="record in dayExerciseRecords"/)
|
||||||
|
assert.match(descriptor.template.content, /估计时间/)
|
||||||
|
assert.match(descriptor.template.content, /record\.record_time_text/)
|
||||||
|
|
||||||
|
state.openBloodRecord(blood[0], date)
|
||||||
|
assert.equal(state.bloodForm.value.id, 103)
|
||||||
|
assert.equal(state.bloodForm.value.postprandial_blood_sugar, 0, 'no field from another row merged into edit')
|
||||||
|
state.bloodForm.value.remark = '只改备注'
|
||||||
|
await state.submitBlood()
|
||||||
|
let sent = calls.findLast(x => x.name === 'bloodRecordEdit').args[0]
|
||||||
|
assert.equal(sent.id, 103)
|
||||||
|
assert.equal(sent.record_time_estimated, 1, 'unmodified time retains estimate')
|
||||||
|
assert.equal(sent.record_time_text, '昨晚')
|
||||||
|
assert.equal(sent.followup_audio_task_id, 80, 'keep provenance')
|
||||||
|
state.openBloodRecord(blood[0], date)
|
||||||
|
state.bloodForm.value.record_time = '20:05'
|
||||||
|
await state.submitBlood()
|
||||||
|
sent = calls.findLast(x => x.name === 'bloodRecordEdit').args[0]
|
||||||
|
assert.equal(sent.record_time_estimated, 0, 'manual time correction clears estimate')
|
||||||
|
assert.equal(sent.record_time_period, '')
|
||||||
|
assert.equal(sent.record_time_text, '')
|
||||||
|
assert.equal(sent.followup_audio_task_id, 80)
|
||||||
|
state.openBloodRecord({ ...blood[0], record_time: '19:30:25' }, date)
|
||||||
|
state.bloodForm.value.record_time = '19:30'
|
||||||
|
await state.submitBlood()
|
||||||
|
sent = calls.findLast(x => x.name === 'bloodRecordEdit').args[0]
|
||||||
|
assert.equal(sent.record_time_estimated, 0, 'seconds correction must not retain estimated time')
|
||||||
|
assert.equal(state.recordTimeLabel({ record_time: '19:30:25' }), '19:30:25', 'precise original seconds remain visible')
|
||||||
|
state.openBloodRecord(undefined, date)
|
||||||
|
await state.submitBlood()
|
||||||
|
sent = calls.findLast(x => x.name === 'bloodRecordAdd').args[0]
|
||||||
|
assert.equal(sent.id, '')
|
||||||
|
assert.equal('record_time_estimated' in sent, false, 'legacy manual add does not require migration columns')
|
||||||
|
state.openDietRecord(diet[1], date)
|
||||||
|
assert.equal(state.dietForm.value.id, 202)
|
||||||
|
state.dietForm.value.lunch_images.push('fixture-new.png')
|
||||||
|
assert.equal(diet[1].lunch_images.length, 1, 'edit image arrays cloned')
|
||||||
|
await state.submitDiet()
|
||||||
|
assert.equal(calls.findLast(x => x.name === 'dietRecordEdit').args[0].id, 202)
|
||||||
|
state.openExerciseRecord(exercise[1], date)
|
||||||
|
await state.submitExercise()
|
||||||
|
assert.equal(calls.findLast(x => x.name === 'exerciseRecordEdit').args[0].id, 302)
|
||||||
|
fixtures()
|
||||||
|
props.readOnly = true
|
||||||
|
state.dayDetailVisible.value = false
|
||||||
|
state.handleCellClick('exercise', date)
|
||||||
|
assert.equal(state.dayDetailVisible.value, true, 'readonly viewers can inspect actual rows')
|
||||||
|
state.bloodDialogVisible.value = false
|
||||||
|
state.openBloodRecord(blood[0], date)
|
||||||
|
assert.equal(state.bloodDialogVisible.value, false, 'readonly cannot edit')
|
||||||
|
props.readOnly = false
|
||||||
|
|
||||||
|
const windows = []
|
||||||
|
pending.window = params => new Promise(resolve => windows.push({ params, resolve }))
|
||||||
|
const oldFetch = state.fetchTracking()
|
||||||
|
props.diagnosisId = 8
|
||||||
|
await vue.nextTick()
|
||||||
|
assert.equal(state.dayDetailVisible.value, false, 'patient switch closes prior details')
|
||||||
|
assert.equal(state.bloodRecords.value.length, 0, 'patient switch immediately clears old rows')
|
||||||
|
assert.equal(windows.length, 2)
|
||||||
|
assert.equal(windows[0].params.id, 7)
|
||||||
|
assert.equal(windows[1].params.id, 8)
|
||||||
|
windows[1].resolve({ blood_records: [{ id: 888, record_date: date }] })
|
||||||
|
await new Promise(resolve => setImmediate(resolve))
|
||||||
|
windows[0].resolve({ blood_records: blood })
|
||||||
|
await oldFetch
|
||||||
|
assert.equal(state.bloodRecords.value[0].id, 888, 'late old-patient fetch cannot overwrite new data')
|
||||||
|
assert.deepEqual(calls.filter(x => x.name === 'diagnosisTrackingNotes').slice(-2).map(x => x.args[0].diagnosis_id), [8, 7], 'both sequential requests use their own bound identity')
|
||||||
|
|
||||||
|
let releaseEdit
|
||||||
|
pending.bloodEdit = () => new Promise(resolve => { releaseEdit = resolve })
|
||||||
|
pending.window = null
|
||||||
|
state.openBloodRecord({ ...blood[0], diagnosis_id: 8 }, date)
|
||||||
|
const oldSubmit = state.submitBlood()
|
||||||
|
props.diagnosisId = 9
|
||||||
|
await vue.nextTick()
|
||||||
|
state.bloodDialogVisible.value = true
|
||||||
|
releaseEdit(true)
|
||||||
|
await oldSubmit
|
||||||
|
assert.equal(state.bloodDialogVisible.value, true, 'late old-patient submit cannot close new dialog')
|
||||||
|
|
||||||
|
// Single-flight guard prevents duplicate submissions of the same manual form.
|
||||||
|
state.bloodSubmitting.value = true
|
||||||
|
const callCount = calls.length
|
||||||
|
await state.submitBlood()
|
||||||
|
assert.equal(calls.length, callCount)
|
||||||
|
state.bloodSubmitting.value = false
|
||||||
|
const unmountedWindows = []
|
||||||
|
pending.window = () => new Promise(resolve => unmountedWindows.push(resolve))
|
||||||
|
const unmountedFetch = state.fetchTracking()
|
||||||
|
for (const unmount of lifecycle.unmounted) unmount()
|
||||||
|
unmountedWindows[0]({ blood_records: blood })
|
||||||
|
await unmountedFetch
|
||||||
|
assert.notEqual(state.bloodRecords.value[0]?.id, 103, 'unmounted request cannot repopulate component')
|
||||||
|
scope.stop()
|
||||||
|
assert.equal(typeof exposed.refresh, 'function')
|
||||||
|
console.log('DailyMatrix actual SFC setup/template/style: 3 blood + 2 diet + 2 exercise rows, exact edit IDs, time metadata, readonly detail, stale isolation: PASS')
|
||||||
|
}
|
||||||
|
main().catch(error => { scope.stop(); console.error(error); process.exitCode = 1 })
|
||||||
@@ -9,6 +9,7 @@ import useAppStore from './stores/modules/app'
|
|||||||
import useSettingStore from './stores/modules/setting'
|
import useSettingStore from './stores/modules/setting'
|
||||||
import useUserStore from './stores/modules/user'
|
import useUserStore from './stores/modules/user'
|
||||||
import ChatNotifyToast from './components/chat-notify-toast/index.vue'
|
import ChatNotifyToast from './components/chat-notify-toast/index.vue'
|
||||||
|
import RevisitDueToast from './components/revisit-due-toast/index.vue'
|
||||||
|
|
||||||
const appStore = useAppStore()
|
const appStore = useAppStore()
|
||||||
const settingStore = useSettingStore()
|
const settingStore = useSettingStore()
|
||||||
@@ -58,6 +59,8 @@ watch(
|
|||||||
<el-config-provider :locale="elConfig.locale" :z-index="elConfig.zIndex">
|
<el-config-provider :locale="elConfig.locale" :z-index="elConfig.zIndex">
|
||||||
<router-view />
|
<router-view />
|
||||||
<ChatNotifyToast v-if="showChatNotifyToast" />
|
<ChatNotifyToast v-if="showChatNotifyToast" />
|
||||||
|
<!-- 复诊到期弹窗(右下角):与会话通知并列挂载,复用同一条件避免未绑企微时无意义轮询 -->
|
||||||
|
<RevisitDueToast v-if="showChatNotifyToast" />
|
||||||
</el-config-provider>
|
</el-config-provider>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import request from '@/utils/request'
|
||||||
|
|
||||||
|
/** AI 助手(MCP)后台接口:挂在 /mcp/admin 下,沿用后台登录令牌 */
|
||||||
|
const opts = { urlPrefix: 'mcp' }
|
||||||
|
|
||||||
|
/** AI 授权列表(有 ai.grant/lists 看全部,否则只看自己的) */
|
||||||
|
export function aiGrantLists(params: any) {
|
||||||
|
return request.get({ url: '/admin/grants', params }, opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 撤销 AI 授权 */
|
||||||
|
export function aiGrantRevoke(params: { id: number }) {
|
||||||
|
return request.post({ url: '/admin/revoke', params }, opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** AI 访问日志 */
|
||||||
|
export function aiAccessLogLists(params: any) {
|
||||||
|
return request.get({ url: '/admin/logs', params }, opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** AI 数据目录与覆盖情况 */
|
||||||
|
export function aiCatalogLists(params: any) {
|
||||||
|
return request.get({ url: '/admin/catalog', params }, opts)
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import request from '@/utils/request'
|
||||||
|
|
||||||
|
export type FollowupKind = 'diagnosis' | 'blood' | 'diet' | 'exercise' | 'tracking_note'
|
||||||
|
export type FollowupValue = string | number | boolean | null | Array<string | number>
|
||||||
|
export type FollowupValues = Record<string, FollowupValue>
|
||||||
|
export interface FollowupField {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
type: 'text' | 'number' | 'select' | 'multiselect' | 'date'
|
||||||
|
options?: Array<{ label: string; value: string | number }>
|
||||||
|
readonly?: boolean
|
||||||
|
}
|
||||||
|
export type FollowupFields = Record<FollowupKind, FollowupField[]>
|
||||||
|
export type FollowupAudioChannel = 0 | 1
|
||||||
|
export type FollowupChannelRoles = 'unconfirmed' | 'left_service' | 'right_service'
|
||||||
|
export interface FollowupTranscriptSegment {
|
||||||
|
id: string
|
||||||
|
start_ms: number
|
||||||
|
end_ms: number
|
||||||
|
text: string
|
||||||
|
channel?: FollowupAudioChannel
|
||||||
|
}
|
||||||
|
export interface FollowupCapabilities {
|
||||||
|
enabled: boolean
|
||||||
|
audio_verified: boolean
|
||||||
|
preview_only?: boolean
|
||||||
|
preview_ready?: boolean
|
||||||
|
manual_review_enabled?: boolean
|
||||||
|
manual_review_ready?: boolean
|
||||||
|
can_review?: boolean
|
||||||
|
test_scope_allowed?: boolean
|
||||||
|
can_upload: boolean
|
||||||
|
can_apply: boolean
|
||||||
|
can_daily: boolean
|
||||||
|
models: Array<{ value: string; label: string }>
|
||||||
|
limits: { max_bytes: number; max_seconds: number; chunk_bytes: number }
|
||||||
|
fields: FollowupFields
|
||||||
|
}
|
||||||
|
export interface FollowupReviewItem {
|
||||||
|
id: string
|
||||||
|
kind: FollowupKind
|
||||||
|
values: FollowupValues
|
||||||
|
record_date: string | null
|
||||||
|
record_time: string | null
|
||||||
|
time_period: string | null
|
||||||
|
time_estimated: boolean
|
||||||
|
time_text: string
|
||||||
|
date_text: string
|
||||||
|
evidence: Array<{ text: string; start_ms?: number; end_ms?: number; segment_id?: string; position_type?: 'segment'; channel?: FollowupAudioChannel }>
|
||||||
|
needs_review: boolean
|
||||||
|
selected: boolean
|
||||||
|
target_id: number | null
|
||||||
|
current_values: FollowupValues
|
||||||
|
expected_hash: string
|
||||||
|
conflict: boolean
|
||||||
|
possible_duplicate: boolean
|
||||||
|
candidates?: Array<{
|
||||||
|
id: number
|
||||||
|
values: FollowupValues
|
||||||
|
record_time: string | null
|
||||||
|
time_estimated: boolean
|
||||||
|
time_period: string | null
|
||||||
|
delete_time?: number | null
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
export interface FollowupTaskSummary {
|
||||||
|
id: number
|
||||||
|
diagnosis_id: number
|
||||||
|
file_name: string
|
||||||
|
recorded_at: string
|
||||||
|
status: string
|
||||||
|
stage: string
|
||||||
|
error_code: string
|
||||||
|
error_message: string
|
||||||
|
version: number
|
||||||
|
created_at: number | string
|
||||||
|
expires_at: number | string
|
||||||
|
can_delete?: boolean
|
||||||
|
delete_block_reason?: string
|
||||||
|
can_retry: boolean
|
||||||
|
audio_available: boolean
|
||||||
|
preview_only?: boolean
|
||||||
|
transcript_available?: boolean
|
||||||
|
pipeline_progress?: { transcribed: number; total: number }
|
||||||
|
}
|
||||||
|
export interface FollowupTaskDetail extends FollowupTaskSummary {
|
||||||
|
extraction_revision?: number
|
||||||
|
extraction_channel_roles?: FollowupChannelRoles
|
||||||
|
can_reextract?: boolean
|
||||||
|
reextract_block_reason?: string
|
||||||
|
role_check?: {
|
||||||
|
conflict: boolean
|
||||||
|
suggested_roles: FollowupChannelRoles
|
||||||
|
message: string
|
||||||
|
evidence: Array<{ channel: FollowupAudioChannel; start_ms: number; end_ms: number; text: string }>
|
||||||
|
}
|
||||||
|
summary: string
|
||||||
|
transcript: string
|
||||||
|
transcript_segments?: FollowupTranscriptSegment[]
|
||||||
|
channel_roles?: FollowupChannelRoles
|
||||||
|
uncertainties: unknown[]
|
||||||
|
items: FollowupReviewItem[]
|
||||||
|
fields?: FollowupFields
|
||||||
|
applied_items: unknown[]
|
||||||
|
review_refreshed?: boolean
|
||||||
|
}
|
||||||
|
interface Envelope<T> { code: number; msg?: string; data: T }
|
||||||
|
export class FollowupAudioError extends Error {
|
||||||
|
constructor(message: string, public readonly code = '') { super(message); this.name = 'FollowupAudioError' }
|
||||||
|
}
|
||||||
|
const options = { isTransformResponse: false, ignoreCancelToken: true, isOpenRetry: false, retryCount: 0 }
|
||||||
|
const base = '/tcm.followupAudio/'
|
||||||
|
function unwrap<T>(response: Envelope<T>): T {
|
||||||
|
if (response.code !== 1) {
|
||||||
|
const data = response.data
|
||||||
|
const code = data && typeof data === 'object' && 'code' in data ? String(data.code) : ''
|
||||||
|
throw new FollowupAudioError(response.msg || '回访录音请求失败,请刷新任务状态后确认', code)
|
||||||
|
}
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
async function get<T>(name: string, params: Record<string, number>, signal?: AbortSignal): Promise<T> {
|
||||||
|
return unwrap(await request.get<Envelope<T>>({ url: base + name, params, signal }, options))
|
||||||
|
}
|
||||||
|
async function post<T>(name: string, data: unknown, signal?: AbortSignal): Promise<T> {
|
||||||
|
return unwrap(await request.post<Envelope<T>>({ url: base + name, data, signal, timeout: 120000 }, options))
|
||||||
|
}
|
||||||
|
export const followupAudioCapabilities = (diagnosisId: number, signal?: AbortSignal) => get<FollowupCapabilities>('capabilities', { diagnosis_id: diagnosisId }, signal)
|
||||||
|
export const followupAudioLists = (diagnosisId: number, signal?: AbortSignal) => get<{ items: FollowupTaskSummary[] }>('lists', { diagnosis_id: diagnosisId }, signal)
|
||||||
|
export const followupAudioDetail = (id: number, signal?: AbortSignal) => get<FollowupTaskDetail>('detail', { id }, signal)
|
||||||
|
export const followupAudioUploadSession = (data: { diagnosis_id: number; file_name: string; total_bytes: number }, signal?: AbortSignal) => post<{ upload_id: string; chunk_bytes: number }>('uploadSession', data, signal)
|
||||||
|
export async function followupAudioUploadChunk(uploadId: string, index: number, file: Blob, signal?: AbortSignal) {
|
||||||
|
const data = new FormData()
|
||||||
|
data.append('upload_id', uploadId)
|
||||||
|
data.append('index', String(index))
|
||||||
|
data.append('file', file, `chunk-${index}`)
|
||||||
|
return unwrap(await request.post<Envelope<{ index: number; received: boolean }>>({
|
||||||
|
url: base + 'uploadChunk', data, signal, timeout: 120000,
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
}, options))
|
||||||
|
}
|
||||||
|
export const followupAudioUploadComplete = (uploadId: string, signal?: AbortSignal) => post<{ upload_id: string; duration_seconds: number; sha256: string }>('uploadComplete', { upload_id: uploadId }, signal)
|
||||||
|
export const followupAudioCreate = (data: { diagnosis_id: number; upload_id: string; recorded_at: string; model_key: string; channel_roles?: FollowupChannelRoles }, signal?: AbortSignal) => post<{ task_id: number; status: string; reused?: boolean; reuse_message?: string }>('create', data, signal)
|
||||||
|
export const followupAudioSaveDraft = (data: { id: number; version: number; items: FollowupReviewItem[]; channel_roles?: FollowupChannelRoles }, signal?: AbortSignal) => post<FollowupTaskDetail>('saveDraft', data, signal)
|
||||||
|
export const followupAudioApply = (data: { id: number; version: number; items: FollowupReviewItem[] }, signal?: AbortSignal) => post<{ id: number; status: 'applied'; applied_items: unknown[] }>('apply', data, signal)
|
||||||
|
export const followupAudioReextract = (data: { id: number; version: number; channel_roles: Exclude<FollowupChannelRoles, 'unconfirmed'> }, signal?: AbortSignal) => post<FollowupTaskDetail>('reextract', data, signal)
|
||||||
|
export const followupAudioDelete = (data: { id: number; version: number }, signal?: AbortSignal) => post<{ id: number; status: 'deleted'; version: number }>('delete', data, signal)
|
||||||
|
export const followupAudioRetry = (id: number, signal?: AbortSignal) => post<{ id: number; status: string }>('retry', { id }, signal)
|
||||||
|
/** The existing request interceptor puts the token in a header, never in the audio URL. */
|
||||||
|
export async function followupAudioBlob(id: number, signal?: AbortSignal): Promise<Blob> {
|
||||||
|
const blob = await request.get<Blob>({ url: base + 'audio', params: { id }, responseType: 'blob', signal, timeout: 120000 }, options)
|
||||||
|
if (blob.type.includes('json')) {
|
||||||
|
const response: Envelope<unknown> = JSON.parse(await blob.text())
|
||||||
|
unwrap(response)
|
||||||
|
throw new FollowupAudioError('录音响应不是音频文件')
|
||||||
|
}
|
||||||
|
return blob
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import request from '@/utils/request'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 复诊到期弹窗(需求 4)接口:/adminapi/tcm.revisitDue/<action>
|
||||||
|
* 契约见 docs/plans/assistant-revisit-reach-reminder-2026-10-12.md §3.3。
|
||||||
|
* 写接口后端 show=1,成功提示由 utils/request 统一弹出,调用方不要重复 ElMessage。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 弹窗条目(RevisitDueLogic::formatItem) */
|
||||||
|
export interface RevisitDueToastItem {
|
||||||
|
id: number
|
||||||
|
diagnosis_id: number
|
||||||
|
assistant_id: number
|
||||||
|
patient_name: string
|
||||||
|
current_slot: number
|
||||||
|
next_slot: number
|
||||||
|
medication_days: number
|
||||||
|
/** 'YYYY-MM-DD' 或 '' */
|
||||||
|
due_date: string
|
||||||
|
overdue_days: number | null
|
||||||
|
/** overdue | missed(弹窗只含这两态) */
|
||||||
|
state: string
|
||||||
|
state_text: string
|
||||||
|
severe: 0 | 1
|
||||||
|
missed_count: number
|
||||||
|
snooze_count: number
|
||||||
|
/** 0~3,3 = 居中模态 */
|
||||||
|
escalation_level: number
|
||||||
|
contact_count: number
|
||||||
|
promised_date: string
|
||||||
|
next_remind_at: number
|
||||||
|
next_remind_at_text: string
|
||||||
|
/** 下一次点「延迟」会顺延的秒数(按钮文案用) */
|
||||||
|
next_snooze_seconds: number
|
||||||
|
appointment_date: string
|
||||||
|
closed_state: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 汇总计数(RevisitDueLogic::buildSummary) */
|
||||||
|
export interface RevisitDueToastSummary {
|
||||||
|
enabled: boolean
|
||||||
|
due_total: number
|
||||||
|
max_overdue_days: number
|
||||||
|
warning_total: number
|
||||||
|
promise_max_days: number
|
||||||
|
contact_max_per_cycle: number
|
||||||
|
work_hours: [string, string]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RevisitDuePendingResult {
|
||||||
|
items: RevisitDueToastItem[]
|
||||||
|
summary: RevisitDueToastSummary
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 写接口返回处理后的当前行(幂等:被他人处理时也返回当前行) */
|
||||||
|
export type RevisitDueWriteResult = RevisitDueToastItem
|
||||||
|
|
||||||
|
export interface RevisitDueSnoozeAllResult {
|
||||||
|
items: RevisitDueToastItem[]
|
||||||
|
snoozed: number
|
||||||
|
skipped: number
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 轮询接口不走统一 code 处理:权限不足 / 开关关闭时不要每分钟弹一次错误提示 */
|
||||||
|
const POLL_OPTIONS = {
|
||||||
|
isTransformResponse: false,
|
||||||
|
ignoreCancelToken: true,
|
||||||
|
isOpenRetry: false,
|
||||||
|
retryCount: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 弹窗待办(60s 轮询):本人到期未挂号 / 过号且已到提醒时刻的项,每次返回全集。
|
||||||
|
* 非 code=1 一律 reject(调用方静默处理),避免轮询刷错误提示。
|
||||||
|
*/
|
||||||
|
export async function revisitDuePending(): Promise<RevisitDuePendingResult> {
|
||||||
|
const res = await request.get({ url: '/tcm.revisitDue/pending' }, POLL_OPTIONS)
|
||||||
|
if (res && typeof res === 'object' && Number(res.code) === 1) {
|
||||||
|
return res.data as RevisitDuePendingResult
|
||||||
|
}
|
||||||
|
return Promise.reject(res?.msg ?? 'revisit due pending failed')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 仅计数,不更新弹出时间 */
|
||||||
|
export function revisitDueSummary(): Promise<RevisitDueToastSummary> {
|
||||||
|
return request.get({ url: '/tcm.revisitDue/summary' })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 延迟一次(幂等) */
|
||||||
|
export function revisitDueSnooze(params: { id: number }): Promise<RevisitDueWriteResult> {
|
||||||
|
return request.post({ url: '/tcm.revisitDue/snooze', params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 批量延迟(仅 escalation_level<=1 的项) */
|
||||||
|
export function revisitDueSnoozeAll(params: {
|
||||||
|
ids: number[]
|
||||||
|
}): Promise<RevisitDueSnoozeAllResult> {
|
||||||
|
return request.post({ url: '/tcm.revisitDue/snoozeAll', params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 已联系:记录患者承诺复诊日 */
|
||||||
|
export function revisitDueContact(params: {
|
||||||
|
id: number
|
||||||
|
promised_date: string
|
||||||
|
remark?: string
|
||||||
|
}): Promise<RevisitDueWriteResult> {
|
||||||
|
return request.post({ url: '/tcm.revisitDue/contact', params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 人工关闭(reason lost|other,remark 必填 ≤200) */
|
||||||
|
export function revisitDueClose(params: {
|
||||||
|
id: number
|
||||||
|
reason: 'lost' | 'other'
|
||||||
|
remark: string
|
||||||
|
}): Promise<RevisitDueWriteResult> {
|
||||||
|
return request.post({ url: '/tcm.revisitDue/close', params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 主管视图列表行(RevisitDueLists,按角色 data_scope 收窄) */
|
||||||
|
export interface RevisitDueListRow {
|
||||||
|
id: number
|
||||||
|
diagnosis_id: number
|
||||||
|
patient_name: string
|
||||||
|
assistant_id: number
|
||||||
|
assistant_name: string
|
||||||
|
dept_name: string
|
||||||
|
current_slot: number
|
||||||
|
next_slot: number
|
||||||
|
medication_days: number
|
||||||
|
/** 14/21/28/30 或 0=其它 */
|
||||||
|
rule_days: number
|
||||||
|
anchor_date: string
|
||||||
|
due_date: string
|
||||||
|
overdue_days: number | null
|
||||||
|
days_left: number | null
|
||||||
|
state: string
|
||||||
|
state_text: string
|
||||||
|
severe: 0 | 1
|
||||||
|
appointment_state: number
|
||||||
|
appointment_date: string
|
||||||
|
snooze_count: number
|
||||||
|
escalation_level: number
|
||||||
|
contact_count: number
|
||||||
|
promised_date: string
|
||||||
|
missed_count: number
|
||||||
|
closed_state: number
|
||||||
|
closed_state_text: string
|
||||||
|
handle_reason: string
|
||||||
|
handle_remark: string
|
||||||
|
first_shown_at_text: string
|
||||||
|
last_snoozed_at_text: string
|
||||||
|
computed_at_text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 主管视图筛选参数(state:due|warning|booked|visited|contacted|no_days|pending|closed;days_bucket:14|21|28|30|other) */
|
||||||
|
export interface RevisitDueListsParams {
|
||||||
|
assistant_id?: number | ''
|
||||||
|
dept_id?: number | ''
|
||||||
|
state?: string
|
||||||
|
days_bucket?: string
|
||||||
|
min_snooze?: number | ''
|
||||||
|
keyword?: string
|
||||||
|
page_no?: number
|
||||||
|
page_size?: number
|
||||||
|
/** 1 = 导出(export-data 组件追加) */
|
||||||
|
export?: number
|
||||||
|
[key: string]: any
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 主管视图:复诊到期跟进分页列表(lists/count/page_no/page_size/extend 包络,供 usePaging / export-data 消费) */
|
||||||
|
export function revisitDueLists(params: RevisitDueListsParams) {
|
||||||
|
return request.get({ url: '/tcm.revisitDue/lists', params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 事件流水行(RevisitDueLogic::logs) */
|
||||||
|
export interface RevisitDueLogRow {
|
||||||
|
id: number
|
||||||
|
/** 1 延迟 2 已联系 3 人工关闭 4 自动关闭-已挂号 5 自动关闭-已面诊 6 周期重置 7 重新打开 8 企微推送 9 医助变更 10 形态升级 11 首次弹出 */
|
||||||
|
event: number
|
||||||
|
event_text: string
|
||||||
|
snooze_count_after: number
|
||||||
|
level_after: number
|
||||||
|
overdue_days: number
|
||||||
|
/** payload JSON 解析后的对象(next_remind_at / promised_date / appointment_id / order_id / remark / from_assistant 等) */
|
||||||
|
payload_arr: Record<string, any>
|
||||||
|
operator_id: number
|
||||||
|
operator_name: string
|
||||||
|
create_time_text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 主管视图:某条提醒的事件流水(按 due_id,时间倒序由后端决定) */
|
||||||
|
export function revisitDueLogs(params: { due_id: number }) {
|
||||||
|
return request.get({ url: '/tcm.revisitDue/logs', params })
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import request from '@/utils/request'
|
||||||
|
|
||||||
|
export interface PrescriptionAiVisibilityConfig {
|
||||||
|
role_ids: number[]
|
||||||
|
admin_ids: number[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PrescriptionAiVisibilityOptions {
|
||||||
|
roles: { id: number; name: string }[]
|
||||||
|
admins: { id: number; name: string; account: string }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PrescriptionAiCapabilities {
|
||||||
|
agreement_visible: boolean
|
||||||
|
can_manage_visibility: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// A failed or malformed read must never become an empty policy that can be saved.
|
||||||
|
function readVisibilityConfig(value: unknown): PrescriptionAiVisibilityConfig {
|
||||||
|
const config = value as Partial<PrescriptionAiVisibilityConfig> | null
|
||||||
|
const validIds = (ids: unknown): ids is number[] =>
|
||||||
|
Array.isArray(ids) && ids.every((id) => Number.isSafeInteger(id) && id > 0)
|
||||||
|
if (!config || !validIds(config.role_ids) || !validIds(config.admin_ids)) {
|
||||||
|
throw new Error('可见范围配置响应格式不正确')
|
||||||
|
}
|
||||||
|
return { role_ids: [...config.role_ids], admin_ids: [...config.admin_ids] }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPrescriptionAiVisibilityConfig() {
|
||||||
|
const data = await request.get({ url: '/tcm.prescriptionAi/getVisibilityConfig' })
|
||||||
|
return readVisibilityConfig(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setPrescriptionAiVisibilityConfig(params: PrescriptionAiVisibilityConfig) {
|
||||||
|
const data = await request.post({ url: '/tcm.prescriptionAi/setVisibilityConfig', params })
|
||||||
|
return readVisibilityConfig(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPrescriptionAiVisibilityOptions() {
|
||||||
|
return request.get({
|
||||||
|
url: '/tcm.prescriptionAi/visibilityOptions'
|
||||||
|
}) as Promise<PrescriptionAiVisibilityOptions>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPrescriptionAiCapabilities() {
|
||||||
|
return request.get({
|
||||||
|
url: '/tcm.prescriptionAi/capabilities'
|
||||||
|
}) as Promise<PrescriptionAiCapabilities>
|
||||||
|
}
|
||||||
+88
-4
@@ -204,8 +204,26 @@ export function commissionSettlementConfirmRevoke(params: Record<string, any>) {
|
|||||||
return request.post({ url: '/stats.commissionSettlement/confirmRevoke', params })
|
return request.post({ url: '/stats.commissionSettlement/confirmRevoke', params })
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 复诊接诊率(按月):当月 N 诊接诊率,部门 → 医助分组 + 合计;剔除「继承」指派 */
|
/**
|
||||||
export function revisitRateOverview(params: { month?: string; dept_ids?: string }) {
|
* 复诊接诊率口径:
|
||||||
|
* - slot(默认,后端 config project.revisit_rate.default_inherit_mode):继承按继承时诊次计入每列独立分母(visit{N}_den = 新指派数 + 起算诊次 ≤ N 的继承数)
|
||||||
|
* - exclude:继承不计分母(旧口径,assigned_count / visit{N}_count / visit{N}_rate 与 2026-10 前一致)
|
||||||
|
*/
|
||||||
|
export type RevisitRateInheritMode = 'exclude' | 'slot'
|
||||||
|
|
||||||
|
/** 被指派明细行类型:new 新指派 / inherit 继承 / all 两者(默认) */
|
||||||
|
export type RevisitRateAssignKind = 'new' | 'inherit' | 'all'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 复诊接诊率(按月):部门 → 医助分组 + 合计。
|
||||||
|
* 返回 inherit_mode、slots,行含 assigned_count(新指派)、inherit_count、assigned_total_count、
|
||||||
|
* visit{N}_count/_den/_rate、visit_all_count/_den/_rate、visit2_rate_new_assign(旧口径二诊率)
|
||||||
|
*/
|
||||||
|
export function revisitRateOverview(params: {
|
||||||
|
month?: string
|
||||||
|
dept_ids?: string
|
||||||
|
inherit_mode?: RevisitRateInheritMode
|
||||||
|
}) {
|
||||||
return request.get({ url: '/stats.revisitRate/overview', params })
|
return request.get({ url: '/stats.revisitRate/overview', params })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,23 +232,29 @@ export function revisitRateDeptOptions() {
|
|||||||
return request.get({ url: '/stats.revisitRate/deptOptions' })
|
return request.get({ url: '/stats.revisitRate/deptOptions' })
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 复诊接诊率:「被指派数」点击下钻(按诊单聚合明细) */
|
/**
|
||||||
|
* 复诊接诊率:「新指派数 / 继承数 / 被指派合计」点击下钻(按诊单聚合明细)。
|
||||||
|
* 行含 assign_type(new|inherit)、inherit_visit_slot(继承时诊次 k)、entry_slot(起算诊次)
|
||||||
|
*/
|
||||||
export function revisitRateAssignLines(params: {
|
export function revisitRateAssignLines(params: {
|
||||||
month?: string
|
month?: string
|
||||||
dept_ids?: string
|
dept_ids?: string
|
||||||
assistant_id?: number
|
assistant_id?: number
|
||||||
dept_id?: number
|
dept_id?: number
|
||||||
|
inherit_mode?: RevisitRateInheritMode
|
||||||
|
kind?: RevisitRateAssignKind
|
||||||
}) {
|
}) {
|
||||||
return request.get({ url: '/stats.revisitRate/assignLines', params })
|
return request.get({ url: '/stats.revisitRate/assignLines', params })
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 复诊接诊率:「N 诊单数」点击下钻(具体订单明细) */
|
/** 复诊接诊率:「N 诊单数」点击下钻(具体订单明细);slot=0 为全部 N≥2(复诊合计单数),行含 visit_slot */
|
||||||
export function revisitRateVisitOrderLines(params: {
|
export function revisitRateVisitOrderLines(params: {
|
||||||
month?: string
|
month?: string
|
||||||
slot: number
|
slot: number
|
||||||
dept_ids?: string
|
dept_ids?: string
|
||||||
assistant_id?: number
|
assistant_id?: number
|
||||||
dept_id?: number
|
dept_id?: number
|
||||||
|
inherit_mode?: RevisitRateInheritMode
|
||||||
}) {
|
}) {
|
||||||
return request.get({ url: '/stats.revisitRate/visitOrderLines', params })
|
return request.get({ url: '/stats.revisitRate/visitOrderLines', params })
|
||||||
}
|
}
|
||||||
@@ -253,3 +277,63 @@ export function assistantPerformanceOverview(params: {
|
|||||||
}) {
|
}) {
|
||||||
return request.get({ url: '/stats.assistantPerformance/overview', params })
|
return request.get({ url: '/stats.assistantPerformance/overview', params })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 「我的复诊接诊率」卡片返回(RevisitRateLogic::assistantOverview,仅本人行) */
|
||||||
|
export interface AssistantRevisitRateResult {
|
||||||
|
month: string
|
||||||
|
inherit_mode: RevisitRateInheritMode
|
||||||
|
/** 诊次列范围(2~N) */
|
||||||
|
slots: number[]
|
||||||
|
/** false = 本人不属于二中心子树(复诊接诊率仅统计二中心医助) */
|
||||||
|
in_scope: boolean
|
||||||
|
/** 本人行:assigned_count / inherit_count / assigned_total_count / visit{N}_count|_den|_rate / visit_all_*;无数据为 null */
|
||||||
|
row: Record<string, any> | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 医助个人业绩:我的复诊接诊率(按月,仅本人行;口径与复诊接诊率页一致,
|
||||||
|
* 继承患者按继承时诊次从第 k+1 诊起计入分母)
|
||||||
|
*/
|
||||||
|
export function assistantPerformanceRevisitRate(params: {
|
||||||
|
month?: string
|
||||||
|
}): Promise<AssistantRevisitRateResult> {
|
||||||
|
return request.get({ url: '/stats.assistantPerformance/revisitRate', params })
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/** 独立指派周期统计,与原 revisitRate 按月报表分别调用。 */
|
||||||
|
export type RevisitRateCohortType = 'new' | 'inherit'
|
||||||
|
|
||||||
|
export interface RevisitRateQuery {
|
||||||
|
start_date: string
|
||||||
|
end_date: string
|
||||||
|
dept_ids?: string
|
||||||
|
assistant_id?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RevisitRateDetailQuery extends RevisitRateQuery {
|
||||||
|
scope_assistant_id?: number
|
||||||
|
dept_id?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const revisitCohortPrefix = (cohortType: RevisitRateCohortType) =>
|
||||||
|
cohortType === 'inherit' ? '/stats.revisitInheritRate' : '/stats.revisitAssignRate'
|
||||||
|
|
||||||
|
export function revisitCohortOverview(params: RevisitRateQuery, cohortType: RevisitRateCohortType) {
|
||||||
|
return request.get({ url: `${revisitCohortPrefix(cohortType)}/overview`, params })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function revisitCohortDeptOptions(cohortType: RevisitRateCohortType) {
|
||||||
|
return request.get({ url: `${revisitCohortPrefix(cohortType)}/deptOptions` })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function revisitCohortAssignLines(params: RevisitRateDetailQuery, cohortType: RevisitRateCohortType) {
|
||||||
|
return request.get({ url: `${revisitCohortPrefix(cohortType)}/assignLines`, params })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function revisitCohortVisitOrderLines(
|
||||||
|
params: RevisitRateDetailQuery & { slot: number },
|
||||||
|
cohortType: RevisitRateCohortType
|
||||||
|
) {
|
||||||
|
return request.get({ url: `${revisitCohortPrefix(cohortType)}/visitOrderLines`, params })
|
||||||
|
}
|
||||||
|
|||||||
@@ -467,7 +467,7 @@ export function prescriptionOrderEditTime(params: { id: number; create_time: str
|
|||||||
return request.post({ url: '/tcm.prescriptionOrder/editTime', params })
|
return request.post({ url: '/tcm.prescriptionOrder/editTime', params })
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 仅修改业务订单的承运商与快递单号;所有履约状态均可使用 */
|
/** 仅修改业务订单的承运商与快递单号;处方和支付单均审核通过后,所有履约状态均可使用 */
|
||||||
export function prescriptionOrderDdcode(params: {
|
export function prescriptionOrderDdcode(params: {
|
||||||
id: number
|
id: number
|
||||||
express_company: string
|
express_company: string
|
||||||
@@ -490,8 +490,10 @@ export function prescriptionOrderPatchPrescriptionUsage(params: {
|
|||||||
times_per_day: number
|
times_per_day: number
|
||||||
usage_days: number
|
usage_days: number
|
||||||
medication_days: number
|
medication_days: number
|
||||||
|
dosage_bag_count?: number
|
||||||
aux_times_per_day?: number
|
aux_times_per_day?: number
|
||||||
aux_usage_days?: number
|
aux_usage_days?: number
|
||||||
|
aux_dosage_bag_count?: number
|
||||||
}) {
|
}) {
|
||||||
return request.post({ url: '/tcm.prescriptionOrder/patchPrescriptionUsage', params })
|
return request.post({ url: '/tcm.prescriptionOrder/patchPrescriptionUsage', params })
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,403 @@
|
|||||||
|
/**
|
||||||
|
* 复诊节点(revisit_due)前端纯函数集合
|
||||||
|
*
|
||||||
|
* 数据契约与后端 `RevisitDueService::buildListPayload` 输出一致(方案 docs/plans/assistant-revisit-reach-reminder-2026-10-12.md 契约 C):
|
||||||
|
* 所有日期为 'Y-m-d' 字符串或 '';无快照且无法计算时后端返回 state='none' 的对象,前端显示「—」。
|
||||||
|
*
|
||||||
|
* 本文件只做 state → 文案 / 颜色 class / el-tag type 的映射与日期、天数文案拼装,不含任何请求或响应式逻辑,
|
||||||
|
* 供 PC 列表、H5 卡片、只读页三处共用(RevisitNodeCell.vue + readonly.vue)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type RevisitDueState =
|
||||||
|
| 'visited'
|
||||||
|
| 'booked'
|
||||||
|
| 'missed'
|
||||||
|
| 'contacted'
|
||||||
|
| 'overdue'
|
||||||
|
| 'warning'
|
||||||
|
| 'pending'
|
||||||
|
| 'no_days'
|
||||||
|
| 'none'
|
||||||
|
|
||||||
|
/** 列表行 `revisit_due` 对象(契约 C) */
|
||||||
|
export interface RevisitDue {
|
||||||
|
current_slot: number
|
||||||
|
next_slot: number
|
||||||
|
valid_order_count: number
|
||||||
|
medication_days: number
|
||||||
|
/** 0=缺失 1=订单 2=处方 */
|
||||||
|
days_src: 0 | 1 | 2
|
||||||
|
/** 14/21/28/30 或 0=其它 */
|
||||||
|
rule_days: number
|
||||||
|
/** 0=无 1=签收 2=发货 3=下单 */
|
||||||
|
anchor_src: 0 | 1 | 2 | 3
|
||||||
|
anchor_date: string
|
||||||
|
order_date: string
|
||||||
|
due_date: string
|
||||||
|
days_left: number | null
|
||||||
|
overdue_days: number | null
|
||||||
|
state: RevisitDueState | string
|
||||||
|
state_text: string
|
||||||
|
severe: 0 | 1
|
||||||
|
/** 0=无 1=已约 3=已面诊 4=过号 */
|
||||||
|
appointment_state: number
|
||||||
|
appointment_date: string
|
||||||
|
snooze_count: number
|
||||||
|
escalation_level: number
|
||||||
|
promised_date: string
|
||||||
|
missed_count: number
|
||||||
|
first_touch_date: string
|
||||||
|
in_care_days: number | null
|
||||||
|
total_medication_days: number
|
||||||
|
closed_state: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 「复诊状态」筛选下拉选项(请求参数 revisit_state;due = overdue ∪ missed) */
|
||||||
|
export const REVISIT_STATE_FILTER_OPTIONS: Array<{ label: string; value: string }> = [
|
||||||
|
{ label: '到期未挂号 / 过号', value: 'due' },
|
||||||
|
{ label: '即将到期', value: 'warning' },
|
||||||
|
{ label: '已约待面诊', value: 'booked' },
|
||||||
|
{ label: '已面诊', value: 'visited' },
|
||||||
|
{ label: '已联系', value: 'contacted' },
|
||||||
|
{ label: '缺开药天数', value: 'no_days' },
|
||||||
|
{ label: '未到期', value: 'pending' }
|
||||||
|
]
|
||||||
|
|
||||||
|
/** 「开药天数档」筛选下拉选项(请求参数 revisit_days_bucket) */
|
||||||
|
export const REVISIT_DAYS_BUCKET_OPTIONS: Array<{ label: string; value: string }> = [
|
||||||
|
{ label: '14 天', value: '14' },
|
||||||
|
{ label: '21 天', value: '21' },
|
||||||
|
{ label: '28 天', value: '28' },
|
||||||
|
{ label: '30 天', value: '30' },
|
||||||
|
{ label: '其它天数', value: 'other' }
|
||||||
|
]
|
||||||
|
|
||||||
|
/** state → 默认文案(后端 state_text 缺失时兜底) */
|
||||||
|
const STATE_LABEL: Record<string, string> = {
|
||||||
|
visited: '已面诊',
|
||||||
|
booked: '已约',
|
||||||
|
missed: '过号',
|
||||||
|
contacted: '已联系',
|
||||||
|
overdue: '超期',
|
||||||
|
warning: '即将到期',
|
||||||
|
pending: '未到期',
|
||||||
|
no_days: '缺开药天数',
|
||||||
|
none: '—'
|
||||||
|
}
|
||||||
|
|
||||||
|
const toInt = (v: unknown, fallback = 0): number => {
|
||||||
|
if (v === null || v === undefined || v === '') return fallback
|
||||||
|
const n = Number(v)
|
||||||
|
return Number.isFinite(n) ? Math.trunc(n) : fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
const toNullableInt = (v: unknown): number | null => {
|
||||||
|
if (v === null || v === undefined || v === '') return null
|
||||||
|
const n = Number(v)
|
||||||
|
return Number.isFinite(n) ? Math.trunc(n) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
const toDateStr = (v: unknown): string => {
|
||||||
|
if (v === null || v === undefined) return ''
|
||||||
|
const s = String(v).trim()
|
||||||
|
if (s === '' || s === '0000-00-00') return ''
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把列表行 / 详情里的 `revisit_due` 原始值规整为 RevisitDue;
|
||||||
|
* 字段缺失(旧接口)或非对象时返回 null,调用方据此不渲染。
|
||||||
|
*/
|
||||||
|
export function normalizeRevisitDue(raw: unknown): RevisitDue | null {
|
||||||
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null
|
||||||
|
const r = raw as Record<string, unknown>
|
||||||
|
const state = String(r.state ?? 'none').trim() || 'none'
|
||||||
|
const daysSrc = toInt(r.days_src)
|
||||||
|
const anchorSrc = toInt(r.anchor_src)
|
||||||
|
return {
|
||||||
|
current_slot: toInt(r.current_slot),
|
||||||
|
next_slot: toInt(r.next_slot),
|
||||||
|
valid_order_count: toInt(r.valid_order_count),
|
||||||
|
medication_days: toInt(r.medication_days),
|
||||||
|
days_src: (daysSrc === 1 || daysSrc === 2 ? daysSrc : 0) as 0 | 1 | 2,
|
||||||
|
rule_days: toInt(r.rule_days),
|
||||||
|
anchor_src: (anchorSrc >= 1 && anchorSrc <= 3 ? anchorSrc : 0) as 0 | 1 | 2 | 3,
|
||||||
|
anchor_date: toDateStr(r.anchor_date),
|
||||||
|
order_date: toDateStr(r.order_date),
|
||||||
|
due_date: toDateStr(r.due_date),
|
||||||
|
days_left: toNullableInt(r.days_left),
|
||||||
|
overdue_days: toNullableInt(r.overdue_days),
|
||||||
|
state,
|
||||||
|
state_text: String(r.state_text ?? '').trim(),
|
||||||
|
severe: toInt(r.severe) === 1 ? 1 : 0,
|
||||||
|
appointment_state: toInt(r.appointment_state),
|
||||||
|
appointment_date: toDateStr(r.appointment_date),
|
||||||
|
snooze_count: toInt(r.snooze_count),
|
||||||
|
escalation_level: toInt(r.escalation_level),
|
||||||
|
promised_date: toDateStr(r.promised_date),
|
||||||
|
missed_count: toInt(r.missed_count),
|
||||||
|
first_touch_date: toDateStr(r.first_touch_date),
|
||||||
|
in_care_days: toNullableInt(r.in_care_days),
|
||||||
|
total_medication_days: toInt(r.total_medication_days),
|
||||||
|
closed_state: toInt(r.closed_state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 是否有可展示的复诊节点(state !== 'none') */
|
||||||
|
export function hasRevisitDue(due: RevisitDue | null | undefined): due is RevisitDue {
|
||||||
|
return !!due && due.state !== 'none' && due.state !== ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 行级标红:到期未挂号 / 过号未面诊;人工关闭或已自动关闭(closed_state!==0)的行不标红,与「复诊到期」chip / 筛选口径一致 */
|
||||||
|
export function isRevisitDueHighlight(due: RevisitDue | null | undefined): boolean {
|
||||||
|
return (
|
||||||
|
hasRevisitDue(due) &&
|
||||||
|
toInt(due.closed_state) === 0 &&
|
||||||
|
(due.state === 'overdue' || due.state === 'missed')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 深红:超期达到 severe_overdue_days(后端已算好 severe=1) */
|
||||||
|
export function isRevisitDueSevere(due: RevisitDue | null | undefined): boolean {
|
||||||
|
return isRevisitDueHighlight(due) && Number(due?.severe) === 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* el-table :row-class-name 片段:
|
||||||
|
* '' | 'row-revisit-overdue' | 'row-revisit-overdue row-revisit-overdue-deep'
|
||||||
|
*/
|
||||||
|
export function revisitRowClassName(due: RevisitDue | null | undefined): string {
|
||||||
|
if (!isRevisitDueHighlight(due)) return ''
|
||||||
|
return isRevisitDueSevere(due) ? 'row-revisit-overdue row-revisit-overdue-deep' : 'row-revisit-overdue'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 'YYYY-MM-DD' → 'MM-DD';非法/空值返回 '' */
|
||||||
|
export function formatRevisitMonthDay(date: string | null | undefined): string {
|
||||||
|
const s = toDateStr(date)
|
||||||
|
if (!s) return ''
|
||||||
|
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(s)
|
||||||
|
if (m) return `${m[2]}-${m[3]}`
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 用药起算锚点来源文案(行 2 末尾) */
|
||||||
|
export function revisitAnchorSrcText(src: number | null | undefined): string {
|
||||||
|
const n = toInt(src)
|
||||||
|
if (n === 1) return '签收起'
|
||||||
|
if (n === 2) return '发货起'
|
||||||
|
if (n === 3) return '下单起'
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 开药天数来源文案(tooltip 用) */
|
||||||
|
export function revisitDaysSrcText(src: number | null | undefined): string {
|
||||||
|
const n = toInt(src)
|
||||||
|
if (n === 1) return '订单'
|
||||||
|
if (n === 2) return '处方'
|
||||||
|
return '缺失'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 是否缺开药天数(days_src=0 或 medication_days<=0) */
|
||||||
|
export function isRevisitDaysMissing(due: RevisitDue | null | undefined): boolean {
|
||||||
|
if (!hasRevisitDue(due)) return false
|
||||||
|
return toInt(due.days_src) === 0 || toInt(due.medication_days) <= 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 「第 N 诊」文案 */
|
||||||
|
export function revisitSlotText(due: RevisitDue | null | undefined): string {
|
||||||
|
if (!hasRevisitDue(due)) return ''
|
||||||
|
const slot = toInt(due.current_slot)
|
||||||
|
return slot > 0 ? `第 ${slot} 诊` : '未成交'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 「开药 X 天」或「缺开药天数」 */
|
||||||
|
export function revisitMedicationDaysText(due: RevisitDue | null | undefined): string {
|
||||||
|
if (!hasRevisitDue(due)) return ''
|
||||||
|
if (isRevisitDaysMissing(due)) return '缺开药天数'
|
||||||
|
return `开药 ${toInt(due.medication_days)} 天`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 「药尽 MM-DD」;缺天数无 due_date 时返回 '' */
|
||||||
|
export function revisitDueDateText(due: RevisitDue | null | undefined): string {
|
||||||
|
if (!hasRevisitDue(due)) return ''
|
||||||
|
const md = formatRevisitMonthDay(due.due_date)
|
||||||
|
return md ? `药尽 ${md}` : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/** state 兜底文案(后端 state_text 为空时使用) */
|
||||||
|
export function revisitStateLabel(state: string | null | undefined): string {
|
||||||
|
const key = String(state ?? '').trim()
|
||||||
|
return STATE_LABEL[key] ?? (key || '—')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 状态 chip 文案:优先后端 state_text;为空时按 state + 数字自行拼装,
|
||||||
|
* 保证旧接口 / 字段缺失时也可读。
|
||||||
|
*/
|
||||||
|
export function revisitStateChipText(due: RevisitDue | null | undefined): string {
|
||||||
|
if (!hasRevisitDue(due)) return ''
|
||||||
|
if (due.state_text) return due.state_text
|
||||||
|
const state = String(due.state)
|
||||||
|
switch (state) {
|
||||||
|
case 'overdue': {
|
||||||
|
const n = toInt(due.overdue_days)
|
||||||
|
return n <= 0 ? '今日应挂号' : `超期 ${n} 天`
|
||||||
|
}
|
||||||
|
case 'missed': {
|
||||||
|
const md = formatRevisitMonthDay(due.appointment_date)
|
||||||
|
return md ? `过号 ${md}` : '过号'
|
||||||
|
}
|
||||||
|
case 'booked': {
|
||||||
|
const md = formatRevisitMonthDay(due.appointment_date)
|
||||||
|
return md ? `已约 ${md}` : '已约'
|
||||||
|
}
|
||||||
|
case 'visited': {
|
||||||
|
const md = formatRevisitMonthDay(due.appointment_date)
|
||||||
|
return md ? `已面诊 ${md}` : '已面诊'
|
||||||
|
}
|
||||||
|
case 'contacted': {
|
||||||
|
const md = formatRevisitMonthDay(due.promised_date)
|
||||||
|
return md ? `已联系·约 ${md}` : '已联系'
|
||||||
|
}
|
||||||
|
case 'warning':
|
||||||
|
case 'pending': {
|
||||||
|
const n = due.days_left
|
||||||
|
return n === null ? revisitStateLabel(state) : `剩 ${n} 天`
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return revisitStateLabel(state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 状态 chip 的 el-tag type */
|
||||||
|
export function revisitStateTagType(
|
||||||
|
state: string | null | undefined
|
||||||
|
): 'danger' | 'warning' | 'primary' | 'success' | 'info' {
|
||||||
|
switch (String(state ?? '')) {
|
||||||
|
case 'overdue':
|
||||||
|
case 'missed':
|
||||||
|
return 'danger'
|
||||||
|
case 'warning':
|
||||||
|
case 'no_days':
|
||||||
|
return 'warning'
|
||||||
|
case 'booked':
|
||||||
|
case 'contacted':
|
||||||
|
return 'primary'
|
||||||
|
case 'visited':
|
||||||
|
return 'success'
|
||||||
|
default:
|
||||||
|
return 'info'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 状态 chip 的 el-tag effect:红为 dark;描边态(已联系 / 缺天数)为 plain;其余 light */
|
||||||
|
export function revisitStateTagEffect(state: string | null | undefined): 'dark' | 'light' | 'plain' {
|
||||||
|
switch (String(state ?? '')) {
|
||||||
|
case 'overdue':
|
||||||
|
case 'missed':
|
||||||
|
return 'dark'
|
||||||
|
case 'contacted':
|
||||||
|
case 'no_days':
|
||||||
|
return 'plain'
|
||||||
|
default:
|
||||||
|
return 'light'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 状态 → 徽标颜色 class(复用列表 / 只读页既有的 unserved-* 色板):
|
||||||
|
* overdue/missed → unserved-red;warning/no_days → unserved-orange;
|
||||||
|
* visited → unserved-green;booked/contacted → unserved-blue;其余 → unserved-muted
|
||||||
|
*/
|
||||||
|
export function revisitStateColorClass(
|
||||||
|
state: string | null | undefined
|
||||||
|
): 'unserved-red' | 'unserved-orange' | 'unserved-green' | 'unserved-blue' | 'unserved-muted' {
|
||||||
|
switch (String(state ?? '')) {
|
||||||
|
case 'overdue':
|
||||||
|
case 'missed':
|
||||||
|
return 'unserved-red'
|
||||||
|
case 'warning':
|
||||||
|
case 'no_days':
|
||||||
|
return 'unserved-orange'
|
||||||
|
case 'visited':
|
||||||
|
return 'unserved-green'
|
||||||
|
case 'booked':
|
||||||
|
case 'contacted':
|
||||||
|
return 'unserved-blue'
|
||||||
|
default:
|
||||||
|
return 'unserved-muted'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 状态 chip 附加 class(组件内样式钩子):rv-chip rv-chip--<state> [rv-chip--severe] */
|
||||||
|
export function revisitStateChipClass(due: RevisitDue | null | undefined): string {
|
||||||
|
if (!hasRevisitDue(due)) return 'rv-chip'
|
||||||
|
const cls = ['rv-chip', `rv-chip--${String(due.state)}`]
|
||||||
|
if (isRevisitDueSevere(due)) cls.push('rv-chip--severe')
|
||||||
|
return cls.join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 行 4:「已延迟 N 次」 */
|
||||||
|
export function revisitSnoozeText(due: RevisitDue | null | undefined): string {
|
||||||
|
if (!hasRevisitDue(due)) return ''
|
||||||
|
const n = toInt(due.snooze_count)
|
||||||
|
return n > 0 ? `已延迟 ${n} 次` : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 行 4:「已联系 约 MM-DD」 */
|
||||||
|
export function revisitContactText(due: RevisitDue | null | undefined): string {
|
||||||
|
if (!hasRevisitDue(due)) return ''
|
||||||
|
const md = formatRevisitMonthDay(due.promised_date)
|
||||||
|
return md ? `已联系 约 ${md}` : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 行 4 合并文案:仅 snooze_count>0 或 promised_date 有值时非空 */
|
||||||
|
export function revisitFollowLineText(due: RevisitDue | null | undefined): string {
|
||||||
|
const parts = [revisitSnoozeText(due), revisitContactText(due)].filter((s) => s !== '')
|
||||||
|
return parts.join(' · ')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 行 2:「MM-DD 开药 · 开药 X 天 · 签收起」 */
|
||||||
|
export function revisitOrderLineText(due: RevisitDue | null | undefined): string {
|
||||||
|
if (!hasRevisitDue(due)) return ''
|
||||||
|
const parts: string[] = []
|
||||||
|
const md = formatRevisitMonthDay(due.order_date)
|
||||||
|
if (md) parts.push(`${md} 开药`)
|
||||||
|
parts.push(revisitMedicationDaysText(due))
|
||||||
|
if (!isRevisitDaysMissing(due)) {
|
||||||
|
const anchor = revisitAnchorSrcText(due.anchor_src)
|
||||||
|
if (anchor) parts.push(anchor)
|
||||||
|
}
|
||||||
|
return parts.filter((s) => s !== '').join(' · ')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 诊次 tag tooltip 行 */
|
||||||
|
export function revisitTooltipLines(due: RevisitDue | null | undefined): string[] {
|
||||||
|
if (!hasRevisitDue(due)) return []
|
||||||
|
const lines: string[] = []
|
||||||
|
const offset = Math.max(0, toInt(due.current_slot) - toInt(due.valid_order_count))
|
||||||
|
lines.push(`有效订单 ${toInt(due.valid_order_count)} 笔 · 诊次偏移 +${offset}`)
|
||||||
|
if (toInt(due.next_slot) > 0) lines.push(`待复诊:第 ${toInt(due.next_slot)} 诊`)
|
||||||
|
const first = formatRevisitMonthDay(due.first_touch_date)
|
||||||
|
if (first) {
|
||||||
|
const care = due.in_care_days
|
||||||
|
lines.push(care === null ? `首次触达 ${first}` : `首次触达 ${first} · 在管 ${care} 天`)
|
||||||
|
}
|
||||||
|
if (toInt(due.total_medication_days) > 0) lines.push(`累计开药 ${toInt(due.total_medication_days)} 天`)
|
||||||
|
const anchorMd = formatRevisitMonthDay(due.anchor_date)
|
||||||
|
const anchorSrc = revisitAnchorSrcText(due.anchor_src)
|
||||||
|
if (anchorMd && anchorSrc) lines.push(`用药起算 ${anchorMd}(${anchorSrc.replace(/起$/, '')})`)
|
||||||
|
if (!isRevisitDaysMissing(due)) lines.push(`开药天数来源:${revisitDaysSrcText(due.days_src)}`)
|
||||||
|
if (toInt(due.missed_count) > 0) lines.push(`曾过号 ${toInt(due.missed_count)} 次`)
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 只读页徽标文案:「第 N 诊 · 药尽 MM-DD · 超期 N 天」
|
||||||
|
* 无 due_date 时省略中段;尾段为状态 chip 文案。
|
||||||
|
*/
|
||||||
|
export function revisitBadgeText(due: RevisitDue | null | undefined): string {
|
||||||
|
if (!hasRevisitDue(due)) return ''
|
||||||
|
const parts = [revisitSlotText(due), revisitDueDateText(due), revisitStateChipText(due)]
|
||||||
|
return parts.filter((s) => s !== '').join(' · ')
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
<template>
|
||||||
|
<div class="ai-access-log-page">
|
||||||
|
<el-card class="!border-none" shadow="never">
|
||||||
|
<el-form :inline="true">
|
||||||
|
<el-form-item label="时间">
|
||||||
|
<el-date-picker
|
||||||
|
v-model="timeRange"
|
||||||
|
type="datetimerange"
|
||||||
|
range-separator="至"
|
||||||
|
start-placeholder="开始时间"
|
||||||
|
end-placeholder="结束时间"
|
||||||
|
value-format="YYYY-MM-DD HH:mm:ss"
|
||||||
|
clearable
|
||||||
|
@change="resetPage"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="结果">
|
||||||
|
<el-select v-model="queryParams.status" clearable placeholder="全部" class="w-[120px]" @change="resetPage">
|
||||||
|
<el-option v-for="(label, value) in STATUS" :key="value" :label="label" :value="value" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="数据资源">
|
||||||
|
<el-input v-model="queryParams.resource" placeholder="如 tcm.diagnosis" clearable class="w-[180px]" @keyup.enter="resetPage" @clear="resetPage" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="记录ID">
|
||||||
|
<el-input v-model="queryParams.record_id" placeholder="如诊单ID" clearable class="w-[130px]" @keyup.enter="resetPage" @clear="resetPage" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="任务号">
|
||||||
|
<el-input v-model="queryParams.client_task_id" placeholder="行知任务号" clearable class="w-[200px]" @keyup.enter="resetPage" @clear="resetPage" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" @click="resetPage">查询</el-button>
|
||||||
|
<el-button @click="handleReset">重置</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<div class="page-hint">
|
||||||
|
记录每一次 AI 查询:谁、通过哪个客户端任务、查了哪个数据资源、返回了哪些记录。按“记录ID”可以查某个诊单被哪些账号通过 AI 看过。
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-card class="!border-none mt-3" shadow="never">
|
||||||
|
<el-table :data="pager.lists" v-loading="pager.loading" size="default" stripe>
|
||||||
|
<el-table-column label="时间" width="165" prop="create_time_text" />
|
||||||
|
<el-table-column label="账号" min-width="120">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div>{{ row.admin_name || (row.admin_id ? '#' + row.admin_id : '—') }}</div>
|
||||||
|
<div class="cell-sub">{{ row.ip }}</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="工具" width="170" prop="tool" />
|
||||||
|
<el-table-column label="数据资源" min-width="200">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div>{{ row.resource_name || row.resource || '—' }}</div>
|
||||||
|
<div class="cell-sub">{{ row.resource }}</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="结果" width="90" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.status === 'ok' ? 'success' : 'warning'" size="small">{{ STATUS[row.status] || row.status }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="条数" width="70" align="right" prop="result_rows" />
|
||||||
|
<el-table-column label="记录ID / 原因" min-width="200">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tooltip v-if="row.record_ids" :content="row.record_ids" placement="top">
|
||||||
|
<span class="ellipsis">{{ row.record_ids }}</span>
|
||||||
|
</el-tooltip>
|
||||||
|
<span v-else class="cell-sub">{{ row.message }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="参数" min-width="220">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tooltip v-if="row.arguments" :content="row.arguments" placement="top">
|
||||||
|
<span class="ellipsis">{{ row.arguments }}</span>
|
||||||
|
</el-tooltip>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="耗时" width="80" align="right">
|
||||||
|
<template #default="{ row }">{{ row.duration_ms }}ms</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="任务号" min-width="160" prop="client_task_id" />
|
||||||
|
</el-table>
|
||||||
|
<div class="flex justify-end mt-4">
|
||||||
|
<pagination v-model="pager" @change="getLists" />
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts" name="aiMcpAccessLog">
|
||||||
|
import { aiAccessLogLists } from '@/api/ai_mcp'
|
||||||
|
import { usePaging } from '@/hooks/usePaging'
|
||||||
|
import { onMounted, reactive, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
const STATUS: Record<string, string> = { ok: '成功', denied: '拒绝', invalid: '参数错误', limited: '超限', error: '失败' }
|
||||||
|
const timeRange = ref<[string, string] | null>(null)
|
||||||
|
const queryParams = reactive({ start_time: '', end_time: '', status: '', resource: '', record_id: '', client_task_id: '' })
|
||||||
|
|
||||||
|
watch(timeRange, (val) => {
|
||||||
|
queryParams.start_time = val?.[0] ?? ''
|
||||||
|
queryParams.end_time = val?.[1] ?? ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const { pager, getLists, resetPage, resetParams } = usePaging({
|
||||||
|
fetchFun: aiAccessLogLists,
|
||||||
|
params: queryParams
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
timeRange.value = null
|
||||||
|
resetParams()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => getLists())
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page-hint {
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.cell-sub {
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.ellipsis {
|
||||||
|
display: inline-block;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
vertical-align: bottom;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
<template>
|
||||||
|
<div class="ai-catalog-page">
|
||||||
|
<el-card class="!border-none" shadow="never">
|
||||||
|
<div class="summary">
|
||||||
|
<div class="summary-item">
|
||||||
|
<div class="summary-value success">{{ counts.open ?? 0 }}</div>
|
||||||
|
<div class="summary-label">已开放</div>
|
||||||
|
</div>
|
||||||
|
<div class="summary-item">
|
||||||
|
<div class="summary-value warning">{{ counts.pending ?? 0 }}</div>
|
||||||
|
<div class="summary-label">待整改</div>
|
||||||
|
</div>
|
||||||
|
<div class="summary-item">
|
||||||
|
<div class="summary-value">{{ counts.excluded ?? 0 }}</div>
|
||||||
|
<div class="summary-label">不开放</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<el-form :inline="true" class="mt-3">
|
||||||
|
<el-form-item label="状态">
|
||||||
|
<el-select v-model="queryParams.status" clearable placeholder="全部" class="w-[120px]" @change="resetPage">
|
||||||
|
<el-option label="已开放" value="open" />
|
||||||
|
<el-option label="待整改" value="pending" />
|
||||||
|
<el-option label="不开放" value="excluded" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="业务分组">
|
||||||
|
<el-select v-model="queryParams.domain" clearable filterable placeholder="全部" class="w-[180px]" @change="resetPage">
|
||||||
|
<el-option v-for="d in domains" :key="d" :label="d" :value="d" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="关键词">
|
||||||
|
<el-input v-model="queryParams.keyword" placeholder="名称或资源标识" clearable class="w-[200px]" @keyup.enter="resetPage" @clear="resetPage" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" @click="resetPage">查询</el-button>
|
||||||
|
<el-button @click="resetParams">重置</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<div class="page-hint">
|
||||||
|
后台每个只读接口都是一个“数据资源”。已开放的资源由 AI 以调用账号自己的权限和数据范围执行原有接口;
|
||||||
|
待整改的资源写明了原因(如缺少逐条权限校验、会调用外部接口、权限点未登记),整改或审核通过后开放。
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-card class="!border-none mt-3" shadow="never">
|
||||||
|
<el-table :data="pager.lists" v-loading="pager.loading" size="default" stripe>
|
||||||
|
<el-table-column label="资源" min-width="220">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div>{{ row.name }}</div>
|
||||||
|
<div class="cell-sub">{{ row.resource }}</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="业务分组" width="150" prop="domain" />
|
||||||
|
<el-table-column label="类型" width="80">
|
||||||
|
<template #default="{ row }">{{ KIND[row.kind] || row.kind }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="状态" width="90" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="STATUS[row.status]?.type" size="small">{{ STATUS[row.status]?.label || row.status }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="原因" min-width="260" prop="reason" />
|
||||||
|
<el-table-column label="已审核" width="80" align="center">
|
||||||
|
<template #default="{ row }">{{ row.reviewed ? '是' : '自动' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<div class="flex justify-end mt-4">
|
||||||
|
<pagination v-model="pager" @change="getLists" />
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts" name="aiMcpCatalog">
|
||||||
|
import { aiCatalogLists } from '@/api/ai_mcp'
|
||||||
|
import { usePaging } from '@/hooks/usePaging'
|
||||||
|
import { computed, onMounted, reactive } from 'vue'
|
||||||
|
|
||||||
|
const KIND: Record<string, string> = { list: '列表', detail: '详情', report: '统计', other: '其他', write: '写操作' }
|
||||||
|
const STATUS: Record<string, { label: string; type: 'success' | 'warning' | 'info' }> = {
|
||||||
|
open: { label: '已开放', type: 'success' },
|
||||||
|
pending: { label: '待整改', type: 'warning' },
|
||||||
|
excluded: { label: '不开放', type: 'info' }
|
||||||
|
}
|
||||||
|
const queryParams = reactive({ status: '', domain: '', keyword: '' })
|
||||||
|
|
||||||
|
const { pager, getLists, resetPage, resetParams } = usePaging({
|
||||||
|
fetchFun: aiCatalogLists,
|
||||||
|
params: queryParams,
|
||||||
|
size: 50
|
||||||
|
})
|
||||||
|
|
||||||
|
const counts = computed<Record<string, number>>(() => pager.extend?.counts || {})
|
||||||
|
const domains = computed<string[]>(() => pager.extend?.domains || [])
|
||||||
|
|
||||||
|
onMounted(() => getLists())
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.summary {
|
||||||
|
display: flex;
|
||||||
|
gap: 48px;
|
||||||
|
}
|
||||||
|
.summary-value {
|
||||||
|
font-size: 26px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.summary-value.success {
|
||||||
|
color: var(--el-color-success);
|
||||||
|
}
|
||||||
|
.summary-value.warning {
|
||||||
|
color: var(--el-color-warning);
|
||||||
|
}
|
||||||
|
.summary-label,
|
||||||
|
.page-hint,
|
||||||
|
.cell-sub {
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.page-hint {
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
<template>
|
||||||
|
<div class="ai-grant-page">
|
||||||
|
<el-card class="!border-none" shadow="never">
|
||||||
|
<el-alert
|
||||||
|
v-if="pager.extend && pager.extend.enabled === false"
|
||||||
|
type="warning"
|
||||||
|
:closable="false"
|
||||||
|
show-icon
|
||||||
|
class="mb-3"
|
||||||
|
title="AI 助手接口未启用:服务器 .env 的 [AI_MCP] ENABLED 为 false,客户端暂时无法绑定和查询。"
|
||||||
|
/>
|
||||||
|
<el-form :inline="true">
|
||||||
|
<el-form-item label="状态">
|
||||||
|
<el-select v-model="queryParams.status" clearable placeholder="全部" class="w-[120px]" @change="resetPage">
|
||||||
|
<el-option label="有效" value="1" />
|
||||||
|
<el-option label="已撤销" value="2" />
|
||||||
|
<el-option label="已过期" value="3" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="关键词">
|
||||||
|
<el-input
|
||||||
|
v-model="queryParams.keyword"
|
||||||
|
placeholder="姓名 / 账号 / 备注"
|
||||||
|
clearable
|
||||||
|
class="w-[220px]"
|
||||||
|
@keyup.enter="resetPage"
|
||||||
|
@clear="resetPage"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" @click="resetPage">查询</el-button>
|
||||||
|
<el-button @click="resetParams">重置</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<div class="page-hint">
|
||||||
|
员工在行知等 AI 助手里用甄养堂账号密码绑定后,会在这里生成一条只读授权。AI 只能按该账号自己的权限和数据范围查询;
|
||||||
|
撤销后立即失效。账号改密、停用、失去“允许 AI 助手查询”权限时,授权也会自动失效。
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-card class="!border-none mt-3" shadow="never">
|
||||||
|
<el-table :data="pager.lists" v-loading="pager.loading" size="default" stripe>
|
||||||
|
<el-table-column label="账号" min-width="140">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div>{{ row.admin_name || '—' }}</div>
|
||||||
|
<div class="cell-sub">{{ row.admin_account }}</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="客户端" min-width="150">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div>{{ row.client }}<span v-if="row.client_instance"> · {{ row.client_instance }}</span></div>
|
||||||
|
<div class="cell-sub">{{ row.label }}</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="令牌" width="130" prop="token_prefix" />
|
||||||
|
<el-table-column label="状态" width="90" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.status_text === '有效' ? 'success' : 'info'" size="small">{{ row.status_text }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="最近使用" min-width="150">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div>{{ row.last_used_time_text || '—' }}</div>
|
||||||
|
<div class="cell-sub">{{ row.last_used_ip }}</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="到期时间" width="150" prop="expire_time_text" />
|
||||||
|
<el-table-column label="签发时间" width="150" prop="create_time_text" />
|
||||||
|
<el-table-column label="撤销" min-width="140">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<template v-if="row.revoke_time_text">
|
||||||
|
<div>{{ row.revoke_time_text }}</div>
|
||||||
|
<div class="cell-sub">{{ reasonText(row.revoke_reason) }}</div>
|
||||||
|
</template>
|
||||||
|
<span v-else>—</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="90" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button v-if="row.can_revoke" type="danger" link @click="handleRevoke(row)">撤销</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<div class="flex justify-end mt-4">
|
||||||
|
<pagination v-model="pager" @change="getLists" />
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts" name="aiMcpGrant">
|
||||||
|
import { aiGrantLists, aiGrantRevoke } from '@/api/ai_mcp'
|
||||||
|
import { usePaging } from '@/hooks/usePaging'
|
||||||
|
import feedback from '@/utils/feedback'
|
||||||
|
import { onMounted, reactive } from 'vue'
|
||||||
|
|
||||||
|
const queryParams = reactive({ status: '', keyword: '' })
|
||||||
|
|
||||||
|
const { pager, getLists, resetPage, resetParams } = usePaging({
|
||||||
|
fetchFun: aiGrantLists,
|
||||||
|
params: queryParams
|
||||||
|
})
|
||||||
|
|
||||||
|
const REASONS: Record<string, string> = {
|
||||||
|
client_revoke: '客户端解绑',
|
||||||
|
admin_revoke: '后台撤销',
|
||||||
|
rebind: '重新绑定',
|
||||||
|
expired: '到期/闲置失效',
|
||||||
|
password_changed: '账号改密',
|
||||||
|
admin_disabled: '账号停用',
|
||||||
|
admin_deleted: '账号删除'
|
||||||
|
}
|
||||||
|
const reasonText = (reason: string) => REASONS[reason] || reason || ''
|
||||||
|
|
||||||
|
const handleRevoke = async (row: any) => {
|
||||||
|
await feedback.confirm(`确认撤销 ${row.admin_name || row.admin_account} 的这条 AI 授权?撤销后该客户端需要重新绑定。`)
|
||||||
|
await aiGrantRevoke({ id: row.id })
|
||||||
|
feedback.msgSuccess('已撤销')
|
||||||
|
getLists()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => getLists())
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page-hint {
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.cell-sub {
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -312,6 +312,17 @@
|
|||||||
{{ detailPrescription.dosage_amount }}{{ detailPrescription.dosage_unit || 'g' }}
|
{{ detailPrescription.dosage_amount }}{{ detailPrescription.dosage_unit || 'g' }}
|
||||||
<template v-if="detailPrescription.prescription_type === '浓缩水丸'">
|
<template v-if="detailPrescription.prescription_type === '浓缩水丸'">
|
||||||
· {{ Number(detailPrescription.dosage_bag_count) > 0 ? Number(detailPrescription.dosage_bag_count) : 1 }}袋
|
· {{ Number(detailPrescription.dosage_bag_count) > 0 ? Number(detailPrescription.dosage_bag_count) : 1 }}袋
|
||||||
|
<el-button
|
||||||
|
v-if="!readonly && detailData.prescription_id && !String(detailData.prescription_detail_error || '').trim()"
|
||||||
|
v-perms="['tcm.prescriptionOrder/patchPrescriptionUsage']"
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
link
|
||||||
|
class="ml-2"
|
||||||
|
@click="openPatchUsageDialog"
|
||||||
|
>
|
||||||
|
修改
|
||||||
|
</el-button>
|
||||||
</template>
|
</template>
|
||||||
<span v-if="detailPrescription.prescription_type === '饮片' && detailPrescription.need_decoction !== null" class="ml-2 text-gray-500">
|
<span v-if="detailPrescription.prescription_type === '饮片' && detailPrescription.need_decoction !== null" class="ml-2 text-gray-500">
|
||||||
({{ detailPrescription.need_decoction ? '代煎' : '不代煎' }})
|
({{ detailPrescription.need_decoction ? '代煎' : '不代煎' }})
|
||||||
@@ -325,6 +336,17 @@
|
|||||||
{{ detailAuxUsage.dosage_amount }}{{ detailPrescription.dosage_unit || 'g' }}
|
{{ detailAuxUsage.dosage_amount }}{{ detailPrescription.dosage_unit || 'g' }}
|
||||||
<template v-if="detailPrescription.prescription_type === '浓缩水丸'">
|
<template v-if="detailPrescription.prescription_type === '浓缩水丸'">
|
||||||
· {{ Number(detailAuxUsage.dosage_bag_count) > 0 ? Number(detailAuxUsage.dosage_bag_count) : 1 }}袋
|
· {{ Number(detailAuxUsage.dosage_bag_count) > 0 ? Number(detailAuxUsage.dosage_bag_count) : 1 }}袋
|
||||||
|
<el-button
|
||||||
|
v-if="!readonly && detailData.prescription_id && !String(detailData.prescription_detail_error || '').trim()"
|
||||||
|
v-perms="['tcm.prescriptionOrder/patchPrescriptionUsage']"
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
link
|
||||||
|
class="ml-2"
|
||||||
|
@click="openPatchUsageDialog"
|
||||||
|
>
|
||||||
|
修改
|
||||||
|
</el-button>
|
||||||
</template>
|
</template>
|
||||||
<span v-if="detailPrescription.prescription_type === '饮片'" class="ml-2 text-gray-500">
|
<span v-if="detailPrescription.prescription_type === '饮片'" class="ml-2 text-gray-500">
|
||||||
({{ detailAuxUsage.need_decoction ? '代煎' : '不代煎' }})
|
({{ detailAuxUsage.need_decoction ? '代煎' : '不代煎' }})
|
||||||
@@ -943,6 +965,15 @@
|
|||||||
label-width="108px"
|
label-width="108px"
|
||||||
>
|
>
|
||||||
<div v-if="detailHasAuxHerbs" class="text-xs font-medium text-gray-500 mb-3">主方</div>
|
<div v-if="detailHasAuxHerbs" class="text-xs font-medium text-gray-500 mb-3">主方</div>
|
||||||
|
<el-form-item
|
||||||
|
v-if="detailPrescription?.prescription_type === '浓缩水丸'"
|
||||||
|
label="每次袋数"
|
||||||
|
prop="dosage_bag_count"
|
||||||
|
>
|
||||||
|
<el-select v-model="patchUsageForm.dosage_bag_count" class="w-full">
|
||||||
|
<el-option v-for="count in 5" :key="count" :label="`${count}袋`" :value="count" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
<el-form-item label="每天次数" prop="times_per_day">
|
<el-form-item label="每天次数" prop="times_per_day">
|
||||||
<el-input-number
|
<el-input-number
|
||||||
v-model="patchUsageForm.times_per_day"
|
v-model="patchUsageForm.times_per_day"
|
||||||
@@ -968,6 +999,15 @@
|
|||||||
</el-form-item>
|
</el-form-item>
|
||||||
<template v-if="detailHasAuxHerbs">
|
<template v-if="detailHasAuxHerbs">
|
||||||
<div class="text-xs font-medium text-gray-500 mb-3 mt-2 pt-2 border-t border-gray-100">辅方</div>
|
<div class="text-xs font-medium text-gray-500 mb-3 mt-2 pt-2 border-t border-gray-100">辅方</div>
|
||||||
|
<el-form-item
|
||||||
|
v-if="detailPrescription?.prescription_type === '浓缩水丸'"
|
||||||
|
label="每次袋数"
|
||||||
|
prop="aux_dosage_bag_count"
|
||||||
|
>
|
||||||
|
<el-select v-model="patchUsageForm.aux_dosage_bag_count" class="w-full">
|
||||||
|
<el-option v-for="count in 5" :key="count" :label="`${count}袋`" :value="count" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
<el-form-item label="每天次数" prop="aux_times_per_day">
|
<el-form-item label="每天次数" prop="aux_times_per_day">
|
||||||
<el-input-number
|
<el-input-number
|
||||||
v-model="patchUsageForm.aux_times_per_day"
|
v-model="patchUsageForm.aux_times_per_day"
|
||||||
@@ -1432,10 +1472,12 @@ const patchUsageVisible = ref(false)
|
|||||||
const patchUsageSaving = ref(false)
|
const patchUsageSaving = ref(false)
|
||||||
const patchUsageFormRef = ref<FormInstance>()
|
const patchUsageFormRef = ref<FormInstance>()
|
||||||
const patchUsageForm = reactive({
|
const patchUsageForm = reactive({
|
||||||
|
dosage_bag_count: 1 as number | undefined,
|
||||||
times_per_day: 3 as number | undefined,
|
times_per_day: 3 as number | undefined,
|
||||||
usage_days: 7 as number | undefined,
|
usage_days: 7 as number | undefined,
|
||||||
aux_times_per_day: 3 as number | undefined,
|
aux_times_per_day: 3 as number | undefined,
|
||||||
aux_usage_days: 7 as number | undefined,
|
aux_usage_days: 7 as number | undefined,
|
||||||
|
aux_dosage_bag_count: 1 as number | undefined,
|
||||||
medication_days: undefined as number | undefined
|
medication_days: undefined as number | undefined
|
||||||
})
|
})
|
||||||
const patchUsageRules = computed<FormRules>(() => {
|
const patchUsageRules = computed<FormRules>(() => {
|
||||||
@@ -1444,9 +1486,15 @@ const patchUsageRules = computed<FormRules>(() => {
|
|||||||
usage_days: [{ required: true, message: '请填写主方开立天数', trigger: 'change' }],
|
usage_days: [{ required: true, message: '请填写主方开立天数', trigger: 'change' }],
|
||||||
medication_days: [{ required: true, message: '请填写订单服用天数', trigger: 'change' }]
|
medication_days: [{ required: true, message: '请填写订单服用天数', trigger: 'change' }]
|
||||||
}
|
}
|
||||||
|
if (detailPrescription.value?.prescription_type === '浓缩水丸') {
|
||||||
|
rules.dosage_bag_count = [{ required: true, message: '请选择主方每次袋数', trigger: 'change' }]
|
||||||
|
}
|
||||||
if (detailHasAuxHerbs.value) {
|
if (detailHasAuxHerbs.value) {
|
||||||
rules.aux_times_per_day = [{ required: true, message: '请填写辅方每天次数', trigger: 'change' }]
|
rules.aux_times_per_day = [{ required: true, message: '请填写辅方每天次数', trigger: 'change' }]
|
||||||
rules.aux_usage_days = [{ required: true, message: '请填写辅方开立天数', trigger: 'change' }]
|
rules.aux_usage_days = [{ required: true, message: '请填写辅方开立天数', trigger: 'change' }]
|
||||||
|
if (detailPrescription.value?.prescription_type === '浓缩水丸') {
|
||||||
|
rules.aux_dosage_bag_count = [{ required: true, message: '请选择辅方每次袋数', trigger: 'change' }]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return rules
|
return rules
|
||||||
})
|
})
|
||||||
@@ -1459,6 +1507,7 @@ function openPatchUsageDialog() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const aux = detailAuxUsage.value
|
const aux = detailAuxUsage.value
|
||||||
|
patchUsageForm.dosage_bag_count = Number(rx.dosage_bag_count) > 0 ? Number(rx.dosage_bag_count) : 1
|
||||||
patchUsageForm.times_per_day =
|
patchUsageForm.times_per_day =
|
||||||
Number(rx.times_per_day) > 0 ? Number(rx.times_per_day) : 3
|
Number(rx.times_per_day) > 0 ? Number(rx.times_per_day) : 3
|
||||||
patchUsageForm.usage_days =
|
patchUsageForm.usage_days =
|
||||||
@@ -1468,16 +1517,20 @@ function openPatchUsageDialog() {
|
|||||||
patchUsageForm.aux_usage_days =
|
patchUsageForm.aux_usage_days =
|
||||||
aux && Number(aux.usage_days) > 0 ? Number(aux.usage_days) : 7
|
aux && Number(aux.usage_days) > 0 ? Number(aux.usage_days) : 7
|
||||||
const md = Number(ord.medication_days)
|
const md = Number(ord.medication_days)
|
||||||
|
patchUsageForm.aux_dosage_bag_count =
|
||||||
|
aux && Number(aux.dosage_bag_count) > 0 ? Number(aux.dosage_bag_count) : 1
|
||||||
patchUsageForm.medication_days = md > 0 ? md : Number(rx.usage_days) > 0 ? Number(rx.usage_days) : 7
|
patchUsageForm.medication_days = md > 0 ? md : Number(rx.usage_days) > 0 ? Number(rx.usage_days) : 7
|
||||||
patchUsageVisible.value = true
|
patchUsageVisible.value = true
|
||||||
nextTick(() => patchUsageFormRef.value?.clearValidate())
|
nextTick(() => patchUsageFormRef.value?.clearValidate())
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetPatchUsageForm() {
|
function resetPatchUsageForm() {
|
||||||
|
patchUsageForm.dosage_bag_count = 1
|
||||||
patchUsageForm.times_per_day = 3
|
patchUsageForm.times_per_day = 3
|
||||||
patchUsageForm.usage_days = 7
|
patchUsageForm.usage_days = 7
|
||||||
patchUsageForm.aux_times_per_day = 3
|
patchUsageForm.aux_times_per_day = 3
|
||||||
patchUsageForm.aux_usage_days = 7
|
patchUsageForm.aux_usage_days = 7
|
||||||
|
patchUsageForm.aux_dosage_bag_count = 1
|
||||||
patchUsageForm.medication_days = undefined
|
patchUsageForm.medication_days = undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1498,17 +1551,25 @@ async function submitPatchUsage() {
|
|||||||
times_per_day: number
|
times_per_day: number
|
||||||
usage_days: number
|
usage_days: number
|
||||||
medication_days: number
|
medication_days: number
|
||||||
|
dosage_bag_count?: number
|
||||||
aux_times_per_day?: number
|
aux_times_per_day?: number
|
||||||
aux_usage_days?: number
|
aux_usage_days?: number
|
||||||
|
aux_dosage_bag_count?: number
|
||||||
} = {
|
} = {
|
||||||
id: ordId,
|
id: ordId,
|
||||||
times_per_day: Number(patchUsageForm.times_per_day),
|
times_per_day: Number(patchUsageForm.times_per_day),
|
||||||
usage_days: Number(patchUsageForm.usage_days),
|
usage_days: Number(patchUsageForm.usage_days),
|
||||||
medication_days: Number(patchUsageForm.medication_days)
|
medication_days: Number(patchUsageForm.medication_days)
|
||||||
}
|
}
|
||||||
|
if (detailPrescription.value?.prescription_type === '浓缩水丸') {
|
||||||
|
payload.dosage_bag_count = Number(patchUsageForm.dosage_bag_count)
|
||||||
|
}
|
||||||
if (detailHasAuxHerbs.value) {
|
if (detailHasAuxHerbs.value) {
|
||||||
payload.aux_times_per_day = Number(patchUsageForm.aux_times_per_day)
|
payload.aux_times_per_day = Number(patchUsageForm.aux_times_per_day)
|
||||||
payload.aux_usage_days = Number(patchUsageForm.aux_usage_days)
|
payload.aux_usage_days = Number(patchUsageForm.aux_usage_days)
|
||||||
|
if (detailPrescription.value?.prescription_type === '浓缩水丸') {
|
||||||
|
payload.aux_dosage_bag_count = Number(patchUsageForm.aux_dosage_bag_count)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
await prescriptionOrderPatchPrescriptionUsage(payload)
|
await prescriptionOrderPatchPrescriptionUsage(payload)
|
||||||
feedback.msgSuccess('保存成功')
|
feedback.msgSuccess('保存成功')
|
||||||
@@ -1763,7 +1824,7 @@ async function refresh() {
|
|||||||
detailLogisticsExpress.value = String(d.express_company || 'auto') || 'auto'
|
detailLogisticsExpress.value = String(d.express_company || 'auto') || 'auto'
|
||||||
void fetchLogisticsTrace()
|
void fetchLogisticsTrace()
|
||||||
}
|
}
|
||||||
if (props.loadRelatedData) void fetchLogs(id)
|
if (props.loadRelatedData) await fetchLogs(id)
|
||||||
const diagId = d.diagnosis_id
|
const diagId = d.diagnosis_id
|
||||||
if (props.loadRelatedData && diagId) {
|
if (props.loadRelatedData && diagId) {
|
||||||
void loadDetailUnlinkedPayOrders(diagId, id, d.pay_order_ids || [])
|
void loadDetailUnlinkedPayOrders(diagId, id, d.pay_order_ids || [])
|
||||||
|
|||||||
@@ -871,7 +871,16 @@
|
|||||||
<el-option label="京东快递" value="jd" />
|
<el-option label="京东快递" value="jd" />
|
||||||
<el-option label="极兔速递" value="jt" />
|
<el-option label="极兔速递" value="jt" />
|
||||||
</el-select>
|
</el-select>
|
||||||
<el-input v-model="editForm.tracking_number" maxlength="80" placeholder="快递单号" class="flex-1 min-w-0" />
|
<el-input
|
||||||
|
v-model="editForm.tracking_number"
|
||||||
|
disabled
|
||||||
|
maxlength="80"
|
||||||
|
placeholder="快递单号"
|
||||||
|
class="flex-1 min-w-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-400 mt-1">
|
||||||
|
编辑订单后需重新审核;双审通过后,请通过列表的单号操作填写或修改
|
||||||
</div>
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
@@ -1067,7 +1076,16 @@
|
|||||||
<el-option label="京东快递" value="jd" />
|
<el-option label="京东快递" value="jd" />
|
||||||
<el-option label="极兔速递" value="jt" />
|
<el-option label="极兔速递" value="jt" />
|
||||||
</el-select>
|
</el-select>
|
||||||
<el-input v-model="editForm.tracking_number" maxlength="80" placeholder="快递单号" class="flex-1 min-w-0" />
|
<el-input
|
||||||
|
v-model="editForm.tracking_number"
|
||||||
|
disabled
|
||||||
|
maxlength="80"
|
||||||
|
placeholder="快递单号"
|
||||||
|
class="flex-1 min-w-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-400 mt-1">
|
||||||
|
编辑订单后需重新审核;双审通过后,请通过列表的单号操作填写或修改
|
||||||
</div>
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
@@ -2975,9 +2993,13 @@ function canWithdrawRow(row: { fulfillment_status?: number }) {
|
|||||||
return Number(row.fulfillment_status) === 1
|
return Number(row.fulfillment_status) === 1
|
||||||
}
|
}
|
||||||
|
|
||||||
function canShipRow(row: { fulfillment_status?: number }) {
|
function canShipRow(row: {
|
||||||
// 履约中(2) 可发货
|
fulfillment_status?: number
|
||||||
return Number(row.fulfillment_status) === 2
|
prescription_audit_status?: number
|
||||||
|
payment_slip_audit_status?: number
|
||||||
|
}) {
|
||||||
|
// 履约中(2) 且处方、支付单均审核通过才可发货
|
||||||
|
return Number(row.fulfillment_status) === 2 && isDualAuditPassed(row)
|
||||||
}
|
}
|
||||||
|
|
||||||
type ShipMode = 'gancao' | 'direct'
|
type ShipMode = 'gancao' | 'direct'
|
||||||
@@ -3073,8 +3095,11 @@ function canRefundRow(row: { fulfillment_status?: number; payment_slip_audit_sta
|
|||||||
return (fs === 5 || fs === 6 || fs === 3 || fs === 9) && Number(row.payment_slip_audit_status) === 1
|
return (fs === 5 || fs === 6 || fs === 3 || fs === 9) && Number(row.payment_slip_audit_status) === 1
|
||||||
}
|
}
|
||||||
|
|
||||||
function canQuickTrackRow(_row: { fulfillment_status?: number }) {
|
function canQuickTrackRow(row: {
|
||||||
return true
|
prescription_audit_status?: number
|
||||||
|
payment_slip_audit_status?: number
|
||||||
|
}) {
|
||||||
|
return isDualAuditPassed(row)
|
||||||
}
|
}
|
||||||
|
|
||||||
function canUploadPharmacyRow(row: {
|
function canUploadPharmacyRow(row: {
|
||||||
@@ -3627,7 +3652,6 @@ async function submitEdit() {
|
|||||||
service_channel: editForm.service_channel || '',
|
service_channel: editForm.service_channel || '',
|
||||||
service_package: Array.isArray(editForm.service_package) ? editForm.service_package.join(',') : '',
|
service_package: Array.isArray(editForm.service_package) ? editForm.service_package.join(',') : '',
|
||||||
express_company: editForm.express_company || 'auto',
|
express_company: editForm.express_company || 'auto',
|
||||||
tracking_number: editForm.tracking_number || '',
|
|
||||||
fee_type: editForm.fee_type,
|
fee_type: editForm.fee_type,
|
||||||
amount: editForm.amount,
|
amount: editForm.amount,
|
||||||
remark_extra: editForm.remark_extra || '',
|
remark_extra: editForm.remark_extra || '',
|
||||||
@@ -3915,7 +3939,17 @@ const quickTrackForm = reactive({
|
|||||||
tracking_number: ''
|
tracking_number: ''
|
||||||
})
|
})
|
||||||
|
|
||||||
function openQuickTrack(row: { id: number; express_company?: unknown; tracking_number?: unknown }) {
|
function openQuickTrack(row: {
|
||||||
|
id: number
|
||||||
|
express_company?: unknown
|
||||||
|
tracking_number?: unknown
|
||||||
|
prescription_audit_status?: number
|
||||||
|
payment_slip_audit_status?: number
|
||||||
|
}) {
|
||||||
|
if (!canQuickTrackRow(row)) {
|
||||||
|
feedback.msgWarning('处方审核和支付单审核均通过后,才可填写或修改快递单号')
|
||||||
|
return
|
||||||
|
}
|
||||||
quickTrackRowId.value = row.id
|
quickTrackRowId.value = row.id
|
||||||
quickTrackForm.express_company = String(row.express_company || 'auto') || 'auto'
|
quickTrackForm.express_company = String(row.express_company || 'auto') || 'auto'
|
||||||
quickTrackForm.tracking_number = String(row.tracking_number || '')
|
quickTrackForm.tracking_number = String(row.tracking_number || '')
|
||||||
@@ -3963,7 +3997,19 @@ function resolveShipModeForRow(row: { id: number; ship_mode?: unknown }) {
|
|||||||
return normalizeShipMode(row.ship_mode)
|
return normalizeShipMode(row.ship_mode)
|
||||||
}
|
}
|
||||||
|
|
||||||
function openShip(row: { id: number; express_company?: unknown; tracking_number?: unknown; ship_mode?: unknown }) {
|
function openShip(row: {
|
||||||
|
id: number
|
||||||
|
express_company?: unknown
|
||||||
|
tracking_number?: unknown
|
||||||
|
ship_mode?: unknown
|
||||||
|
fulfillment_status?: number
|
||||||
|
prescription_audit_status?: number
|
||||||
|
payment_slip_audit_status?: number
|
||||||
|
}) {
|
||||||
|
if (!canShipRow(row)) {
|
||||||
|
feedback.msgWarning('仅处方审核和支付单审核均通过的履约中订单可填写单号并发货')
|
||||||
|
return
|
||||||
|
}
|
||||||
shipRowId.value = Number(row.id)
|
shipRowId.value = Number(row.id)
|
||||||
shipForm.ship_mode = resolveShipModeForRow(row)
|
shipForm.ship_mode = resolveShipModeForRow(row)
|
||||||
shipForm.express_company = String(row.express_company || 'auto') || 'auto'
|
shipForm.express_company = String(row.express_company || 'auto') || 'auto'
|
||||||
|
|||||||
@@ -883,6 +883,17 @@
|
|||||||
{{ detailPrescription.dosage_amount }}{{ detailPrescription.dosage_unit || 'g' }}
|
{{ detailPrescription.dosage_amount }}{{ detailPrescription.dosage_unit || 'g' }}
|
||||||
<template v-if="detailPrescription.prescription_type === '浓缩水丸'">
|
<template v-if="detailPrescription.prescription_type === '浓缩水丸'">
|
||||||
· {{ Number(detailPrescription.dosage_bag_count) > 0 ? Number(detailPrescription.dosage_bag_count) : 1 }}袋
|
· {{ Number(detailPrescription.dosage_bag_count) > 0 ? Number(detailPrescription.dosage_bag_count) : 1 }}袋
|
||||||
|
<el-button
|
||||||
|
v-if="detailData.prescription_id && !String(detailData.prescription_detail_error || '').trim()"
|
||||||
|
v-perms="['tcm.prescriptionOrder/patchPrescriptionUsage']"
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
link
|
||||||
|
class="ml-2"
|
||||||
|
@click="openPatchUsageDialog"
|
||||||
|
>
|
||||||
|
修改
|
||||||
|
</el-button>
|
||||||
</template>
|
</template>
|
||||||
<span v-if="detailPrescription.prescription_type === '饮片' && detailPrescription.need_decoction !== null" class="ml-2 text-gray-500">
|
<span v-if="detailPrescription.prescription_type === '饮片' && detailPrescription.need_decoction !== null" class="ml-2 text-gray-500">
|
||||||
({{ detailPrescription.need_decoction ? '代煎' : '不代煎' }})
|
({{ detailPrescription.need_decoction ? '代煎' : '不代煎' }})
|
||||||
@@ -896,6 +907,17 @@
|
|||||||
{{ detailAuxUsage.dosage_amount }}{{ detailPrescription.dosage_unit || 'g' }}
|
{{ detailAuxUsage.dosage_amount }}{{ detailPrescription.dosage_unit || 'g' }}
|
||||||
<template v-if="detailPrescription.prescription_type === '浓缩水丸'">
|
<template v-if="detailPrescription.prescription_type === '浓缩水丸'">
|
||||||
· {{ Number(detailAuxUsage.dosage_bag_count) > 0 ? Number(detailAuxUsage.dosage_bag_count) : 1 }}袋
|
· {{ Number(detailAuxUsage.dosage_bag_count) > 0 ? Number(detailAuxUsage.dosage_bag_count) : 1 }}袋
|
||||||
|
<el-button
|
||||||
|
v-if="detailData.prescription_id && !String(detailData.prescription_detail_error || '').trim()"
|
||||||
|
v-perms="['tcm.prescriptionOrder/patchPrescriptionUsage']"
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
link
|
||||||
|
class="ml-2"
|
||||||
|
@click="openPatchUsageDialog"
|
||||||
|
>
|
||||||
|
修改
|
||||||
|
</el-button>
|
||||||
</template>
|
</template>
|
||||||
<span v-if="detailPrescription.prescription_type === '饮片'" class="ml-2 text-gray-500">
|
<span v-if="detailPrescription.prescription_type === '饮片'" class="ml-2 text-gray-500">
|
||||||
({{ detailAuxUsage.need_decoction ? '代煎' : '不代煎' }})
|
({{ detailAuxUsage.need_decoction ? '代煎' : '不代煎' }})
|
||||||
@@ -1624,7 +1646,16 @@
|
|||||||
<el-option label="京东快递" value="jd" />
|
<el-option label="京东快递" value="jd" />
|
||||||
<el-option label="极兔速递" value="jt" />
|
<el-option label="极兔速递" value="jt" />
|
||||||
</el-select>
|
</el-select>
|
||||||
<el-input v-model="editForm.tracking_number" maxlength="80" placeholder="快递单号" class="flex-1 min-w-0" />
|
<el-input
|
||||||
|
v-model="editForm.tracking_number"
|
||||||
|
disabled
|
||||||
|
maxlength="80"
|
||||||
|
placeholder="快递单号"
|
||||||
|
class="flex-1 min-w-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-400 mt-1">
|
||||||
|
编辑订单后需重新审核;双审通过后,请通过列表的单号操作填写或修改
|
||||||
</div>
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
@@ -2252,6 +2283,15 @@
|
|||||||
label-width="96px"
|
label-width="96px"
|
||||||
>
|
>
|
||||||
<div v-if="detailHasAuxHerbs" class="text-xs font-medium text-gray-500 mb-3">主方</div>
|
<div v-if="detailHasAuxHerbs" class="text-xs font-medium text-gray-500 mb-3">主方</div>
|
||||||
|
<el-form-item
|
||||||
|
v-if="detailPrescription?.prescription_type === '浓缩水丸'"
|
||||||
|
label="每次袋数"
|
||||||
|
prop="dosage_bag_count"
|
||||||
|
>
|
||||||
|
<el-select v-model="patchUsageForm.dosage_bag_count" class="w-full">
|
||||||
|
<el-option v-for="count in 5" :key="count" :label="`${count}袋`" :value="count" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
<el-form-item label="每天次数" prop="times_per_day">
|
<el-form-item label="每天次数" prop="times_per_day">
|
||||||
<el-input-number
|
<el-input-number
|
||||||
v-model="patchUsageForm.times_per_day"
|
v-model="patchUsageForm.times_per_day"
|
||||||
@@ -2277,6 +2317,15 @@
|
|||||||
</el-form-item>
|
</el-form-item>
|
||||||
<template v-if="detailHasAuxHerbs">
|
<template v-if="detailHasAuxHerbs">
|
||||||
<div class="text-xs font-medium text-gray-500 mb-3 mt-2 pt-2 border-t border-gray-100">辅方</div>
|
<div class="text-xs font-medium text-gray-500 mb-3 mt-2 pt-2 border-t border-gray-100">辅方</div>
|
||||||
|
<el-form-item
|
||||||
|
v-if="detailPrescription?.prescription_type === '浓缩水丸'"
|
||||||
|
label="每次袋数"
|
||||||
|
prop="aux_dosage_bag_count"
|
||||||
|
>
|
||||||
|
<el-select v-model="patchUsageForm.aux_dosage_bag_count" class="w-full">
|
||||||
|
<el-option v-for="count in 5" :key="count" :label="`${count}袋`" :value="count" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
<el-form-item label="每天次数" prop="aux_times_per_day">
|
<el-form-item label="每天次数" prop="aux_times_per_day">
|
||||||
<el-input-number
|
<el-input-number
|
||||||
v-model="patchUsageForm.aux_times_per_day"
|
v-model="patchUsageForm.aux_times_per_day"
|
||||||
@@ -3422,9 +3471,13 @@ function canWithdrawRow(row: { fulfillment_status?: number }) {
|
|||||||
return Number(row.fulfillment_status) === 1
|
return Number(row.fulfillment_status) === 1
|
||||||
}
|
}
|
||||||
|
|
||||||
function canShipRow(row: { fulfillment_status?: number }) {
|
function canShipRow(row: {
|
||||||
// 履约中(2) 可发货
|
fulfillment_status?: number
|
||||||
return Number(row.fulfillment_status) === 2
|
prescription_audit_status?: number
|
||||||
|
payment_slip_audit_status?: number
|
||||||
|
}) {
|
||||||
|
// 履约中(2) 且处方、支付单均审核通过才可发货
|
||||||
|
return Number(row.fulfillment_status) === 2 && isDualAuditPassed(row)
|
||||||
}
|
}
|
||||||
|
|
||||||
function canAddPayOrderRow(row: { fulfillment_status?: number }) {
|
function canAddPayOrderRow(row: { fulfillment_status?: number }) {
|
||||||
@@ -3444,8 +3497,11 @@ function canRefundRow(row: { fulfillment_status?: number; payment_slip_audit_sta
|
|||||||
return (fs === 5 || fs === 6 || fs === 3 || fs === 9) && Number(row.payment_slip_audit_status) === 1
|
return (fs === 5 || fs === 6 || fs === 3 || fs === 9) && Number(row.payment_slip_audit_status) === 1
|
||||||
}
|
}
|
||||||
|
|
||||||
function canQuickTrackRow(_row: { fulfillment_status?: number }) {
|
function canQuickTrackRow(row: {
|
||||||
return true
|
prescription_audit_status?: number
|
||||||
|
payment_slip_audit_status?: number
|
||||||
|
}) {
|
||||||
|
return isDualAuditPassed(row)
|
||||||
}
|
}
|
||||||
|
|
||||||
function canUploadPharmacyRow(row: {
|
function canUploadPharmacyRow(row: {
|
||||||
@@ -3938,10 +3994,12 @@ const patchUsageVisible = ref(false)
|
|||||||
const patchUsageSaving = ref(false)
|
const patchUsageSaving = ref(false)
|
||||||
const patchUsageFormRef = ref<FormInstance>()
|
const patchUsageFormRef = ref<FormInstance>()
|
||||||
const patchUsageForm = reactive({
|
const patchUsageForm = reactive({
|
||||||
|
dosage_bag_count: 1 as number | undefined,
|
||||||
times_per_day: 3 as number | undefined,
|
times_per_day: 3 as number | undefined,
|
||||||
usage_days: 7 as number | undefined,
|
usage_days: 7 as number | undefined,
|
||||||
aux_times_per_day: 3 as number | undefined,
|
aux_times_per_day: 3 as number | undefined,
|
||||||
aux_usage_days: 7 as number | undefined,
|
aux_usage_days: 7 as number | undefined,
|
||||||
|
aux_dosage_bag_count: 1 as number | undefined,
|
||||||
medication_days: undefined as number | undefined
|
medication_days: undefined as number | undefined
|
||||||
})
|
})
|
||||||
const patchUsageRules = computed<FormRules>(() => {
|
const patchUsageRules = computed<FormRules>(() => {
|
||||||
@@ -3950,9 +4008,15 @@ const patchUsageRules = computed<FormRules>(() => {
|
|||||||
usage_days: [{ required: true, message: '请填写主方开立天数', trigger: 'change' }],
|
usage_days: [{ required: true, message: '请填写主方开立天数', trigger: 'change' }],
|
||||||
medication_days: [{ required: true, message: '请填写订单服用天数', trigger: 'change' }]
|
medication_days: [{ required: true, message: '请填写订单服用天数', trigger: 'change' }]
|
||||||
}
|
}
|
||||||
|
if (detailPrescription.value?.prescription_type === '浓缩水丸') {
|
||||||
|
rules.dosage_bag_count = [{ required: true, message: '请选择主方每次袋数', trigger: 'change' }]
|
||||||
|
}
|
||||||
if (detailHasAuxHerbs.value) {
|
if (detailHasAuxHerbs.value) {
|
||||||
rules.aux_times_per_day = [{ required: true, message: '请填写辅方每天次数', trigger: 'change' }]
|
rules.aux_times_per_day = [{ required: true, message: '请填写辅方每天次数', trigger: 'change' }]
|
||||||
rules.aux_usage_days = [{ required: true, message: '请填写辅方开立天数', trigger: 'change' }]
|
rules.aux_usage_days = [{ required: true, message: '请填写辅方开立天数', trigger: 'change' }]
|
||||||
|
if (detailPrescription.value?.prescription_type === '浓缩水丸') {
|
||||||
|
rules.aux_dosage_bag_count = [{ required: true, message: '请选择辅方每次袋数', trigger: 'change' }]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return rules
|
return rules
|
||||||
})
|
})
|
||||||
@@ -3965,6 +4029,7 @@ function openPatchUsageDialog() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const aux = detailAuxUsage.value
|
const aux = detailAuxUsage.value
|
||||||
|
patchUsageForm.dosage_bag_count = Number(rx.dosage_bag_count) > 0 ? Number(rx.dosage_bag_count) : 1
|
||||||
patchUsageForm.times_per_day =
|
patchUsageForm.times_per_day =
|
||||||
Number(rx.times_per_day) > 0 ? Number(rx.times_per_day) : 3
|
Number(rx.times_per_day) > 0 ? Number(rx.times_per_day) : 3
|
||||||
patchUsageForm.usage_days =
|
patchUsageForm.usage_days =
|
||||||
@@ -3974,16 +4039,20 @@ function openPatchUsageDialog() {
|
|||||||
patchUsageForm.aux_usage_days =
|
patchUsageForm.aux_usage_days =
|
||||||
aux && Number(aux.usage_days) > 0 ? Number(aux.usage_days) : 7
|
aux && Number(aux.usage_days) > 0 ? Number(aux.usage_days) : 7
|
||||||
const md = Number(ord.medication_days)
|
const md = Number(ord.medication_days)
|
||||||
|
patchUsageForm.aux_dosage_bag_count =
|
||||||
|
aux && Number(aux.dosage_bag_count) > 0 ? Number(aux.dosage_bag_count) : 1
|
||||||
patchUsageForm.medication_days = md > 0 ? md : Number(rx.usage_days) > 0 ? Number(rx.usage_days) : 7
|
patchUsageForm.medication_days = md > 0 ? md : Number(rx.usage_days) > 0 ? Number(rx.usage_days) : 7
|
||||||
patchUsageVisible.value = true
|
patchUsageVisible.value = true
|
||||||
nextTick(() => patchUsageFormRef.value?.clearValidate())
|
nextTick(() => patchUsageFormRef.value?.clearValidate())
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetPatchUsageForm() {
|
function resetPatchUsageForm() {
|
||||||
|
patchUsageForm.dosage_bag_count = 1
|
||||||
patchUsageForm.times_per_day = 3
|
patchUsageForm.times_per_day = 3
|
||||||
patchUsageForm.usage_days = 7
|
patchUsageForm.usage_days = 7
|
||||||
patchUsageForm.aux_times_per_day = 3
|
patchUsageForm.aux_times_per_day = 3
|
||||||
patchUsageForm.aux_usage_days = 7
|
patchUsageForm.aux_usage_days = 7
|
||||||
|
patchUsageForm.aux_dosage_bag_count = 1
|
||||||
patchUsageForm.medication_days = undefined
|
patchUsageForm.medication_days = undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4004,17 +4073,25 @@ async function submitPatchUsage() {
|
|||||||
times_per_day: number
|
times_per_day: number
|
||||||
usage_days: number
|
usage_days: number
|
||||||
medication_days: number
|
medication_days: number
|
||||||
|
dosage_bag_count?: number
|
||||||
aux_times_per_day?: number
|
aux_times_per_day?: number
|
||||||
aux_usage_days?: number
|
aux_usage_days?: number
|
||||||
|
aux_dosage_bag_count?: number
|
||||||
} = {
|
} = {
|
||||||
id: ordId,
|
id: ordId,
|
||||||
times_per_day: Number(patchUsageForm.times_per_day),
|
times_per_day: Number(patchUsageForm.times_per_day),
|
||||||
usage_days: Number(patchUsageForm.usage_days),
|
usage_days: Number(patchUsageForm.usage_days),
|
||||||
medication_days: Number(patchUsageForm.medication_days)
|
medication_days: Number(patchUsageForm.medication_days)
|
||||||
}
|
}
|
||||||
|
if (detailPrescription.value?.prescription_type === '浓缩水丸') {
|
||||||
|
payload.dosage_bag_count = Number(patchUsageForm.dosage_bag_count)
|
||||||
|
}
|
||||||
if (detailHasAuxHerbs.value) {
|
if (detailHasAuxHerbs.value) {
|
||||||
payload.aux_times_per_day = Number(patchUsageForm.aux_times_per_day)
|
payload.aux_times_per_day = Number(patchUsageForm.aux_times_per_day)
|
||||||
payload.aux_usage_days = Number(patchUsageForm.aux_usage_days)
|
payload.aux_usage_days = Number(patchUsageForm.aux_usage_days)
|
||||||
|
if (detailPrescription.value?.prescription_type === '浓缩水丸') {
|
||||||
|
payload.aux_dosage_bag_count = Number(patchUsageForm.aux_dosage_bag_count)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
await prescriptionOrderPatchPrescriptionUsage(payload)
|
await prescriptionOrderPatchPrescriptionUsage(payload)
|
||||||
feedback.msgSuccess('保存成功')
|
feedback.msgSuccess('保存成功')
|
||||||
@@ -4540,7 +4617,6 @@ async function submitEdit() {
|
|||||||
service_channel: editForm.service_channel || '',
|
service_channel: editForm.service_channel || '',
|
||||||
service_package: Array.isArray(editForm.service_package) ? editForm.service_package.join(',') : '',
|
service_package: Array.isArray(editForm.service_package) ? editForm.service_package.join(',') : '',
|
||||||
express_company: editForm.express_company || 'auto',
|
express_company: editForm.express_company || 'auto',
|
||||||
tracking_number: editForm.tracking_number || '',
|
|
||||||
fee_type: editForm.fee_type,
|
fee_type: editForm.fee_type,
|
||||||
amount: editForm.amount,
|
amount: editForm.amount,
|
||||||
remark_extra: editForm.remark_extra || '',
|
remark_extra: editForm.remark_extra || '',
|
||||||
@@ -4788,7 +4864,17 @@ const quickTrackForm = reactive({
|
|||||||
tracking_number: ''
|
tracking_number: ''
|
||||||
})
|
})
|
||||||
|
|
||||||
function openQuickTrack(row: { id: number; express_company?: unknown; tracking_number?: unknown }) {
|
function openQuickTrack(row: {
|
||||||
|
id: number
|
||||||
|
express_company?: unknown
|
||||||
|
tracking_number?: unknown
|
||||||
|
prescription_audit_status?: number
|
||||||
|
payment_slip_audit_status?: number
|
||||||
|
}) {
|
||||||
|
if (!canQuickTrackRow(row)) {
|
||||||
|
feedback.msgWarning('处方审核和支付单审核均通过后,才可填写或修改快递单号')
|
||||||
|
return
|
||||||
|
}
|
||||||
quickTrackRowId.value = row.id
|
quickTrackRowId.value = row.id
|
||||||
quickTrackForm.express_company = String(row.express_company || 'auto') || 'auto'
|
quickTrackForm.express_company = String(row.express_company || 'auto') || 'auto'
|
||||||
quickTrackForm.tracking_number = String(row.tracking_number || '')
|
quickTrackForm.tracking_number = String(row.tracking_number || '')
|
||||||
@@ -4840,7 +4926,19 @@ const shipDialogModeDisplay = computed(() =>
|
|||||||
shipForm.ship_mode === 'direct' ? '洛阳药房直发' : '甘草药房直发'
|
shipForm.ship_mode === 'direct' ? '洛阳药房直发' : '甘草药房直发'
|
||||||
)
|
)
|
||||||
|
|
||||||
function openShip(row: { id: number; express_company?: unknown; tracking_number?: unknown; ship_mode?: unknown }) {
|
function openShip(row: {
|
||||||
|
id: number
|
||||||
|
express_company?: unknown
|
||||||
|
tracking_number?: unknown
|
||||||
|
ship_mode?: unknown
|
||||||
|
fulfillment_status?: number
|
||||||
|
prescription_audit_status?: number
|
||||||
|
payment_slip_audit_status?: number
|
||||||
|
}) {
|
||||||
|
if (!canShipRow(row)) {
|
||||||
|
feedback.msgWarning('仅处方审核和支付单审核均通过的履约中订单可填写单号并发货')
|
||||||
|
return
|
||||||
|
}
|
||||||
shipRowId.value = row.id
|
shipRowId.value = row.id
|
||||||
shipForm.ship_mode = String(row.ship_mode || 'gancao') || 'gancao'
|
shipForm.ship_mode = String(row.ship_mode || 'gancao') || 'gancao'
|
||||||
shipForm.express_company = String(row.express_company || 'auto') || 'auto'
|
shipForm.express_company = String(row.express_company || 'auto') || 'auto'
|
||||||
|
|||||||
@@ -154,7 +154,7 @@
|
|||||||
<div class="panel-heading panel-heading--table">
|
<div class="panel-heading panel-heading--table">
|
||||||
<div>
|
<div>
|
||||||
<h2>明细数据列表</h2>
|
<h2>明细数据列表</h2>
|
||||||
<p>展开部门可查看人员明细;加粉=总进线=区间新增“员工+客户”组合(同一员工的同一客户只计一次,同一客户进入不同员工分别计数;包含区间内添加后已删客户,剔除继承客户、扫一扫/搜手机号/名片分享添加及区间前已存在的相同组合)<template v-if="canViewDeletedFans">,(-N)表示加粉组合中已删除</template>;挂号=已支付且实收低于 10 元的订单,预约=有效预约记录;开口率=开口/加粉,挂号率=挂号/加粉,面诊率=面诊/挂号(看挂号后流失),预约率=面诊/预约(看预约后未面诊),面诊接诊率=接诊诊单/面诊,接诊率=接诊诊单/总进线</p>
|
<p>展开部门可查看人员明细;加粉=总进线=区间新增“员工+客户”组合(同一员工的同一客户只计一次,同一客户进入不同员工分别计数;包含区间内添加后已删客户,剔除继承客户、扫一扫/搜手机号/名片分享添加及区间前已存在的相同组合)<template v-if="canViewDeletedFans">,(-N)表示加粉组合中已删除</template>;开口=获客客户首次发起会话事件,按开口时间计入,同一员工的同一客户只计一次;挂号=已支付且实收低于 10 元的订单,预约=有效预约记录;开口率=开口/加粉,挂号率=挂号/加粉,面诊率=面诊/挂号(看挂号后流失),预约率=面诊/预约(看预约后未面诊),面诊接诊率=接诊诊单/面诊,接诊率=接诊诊单/总进线</p>
|
||||||
</div>
|
</div>
|
||||||
<span>{{ dashboard.rows.length }} 个顶层节点</span>
|
<span>{{ dashboard.rows.length }} 个顶层节点</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -449,7 +449,7 @@ const timeOptions = [
|
|||||||
]
|
]
|
||||||
const metricCards: Array<{ key: string; label: string; type: MetricType; hint: string }> = [
|
const metricCards: Array<{ key: string; label: string; type: MetricType; hint: string }> = [
|
||||||
{ key: 'add_fans_count', label: '加粉数', type: 'count', hint: '区间新增员工+客户组合(不同员工分别计数,含已删除)' },
|
{ key: 'add_fans_count', label: '加粉数', type: 'count', hint: '区间新增员工+客户组合(不同员工分别计数,含已删除)' },
|
||||||
{ key: 'total_open_count', label: '开口数', type: 'count', hint: '来源于个人业绩录入' },
|
{ key: 'total_open_count', label: '开口数', type: 'count', hint: '获客客户首次发起会话;按开口时间统计,同一员工的同一客户只计一次' },
|
||||||
{ key: 'interview_count', label: '面诊', type: 'count', hint: '已完成预约' },
|
{ key: 'interview_count', label: '面诊', type: 'count', hint: '已完成预约' },
|
||||||
{ key: 'completed_order_count', label: '接诊诊单', type: 'count', hint: '业务订单,按创建人归属并过滤无效单' },
|
{ key: 'completed_order_count', label: '接诊诊单', type: 'count', hint: '业务订单,按创建人归属并过滤无效单' },
|
||||||
{ key: 'completed_order_amount', label: '诊单金额', type: 'money', hint: '排除取消、拒收、退款及已退款订单' },
|
{ key: 'completed_order_amount', label: '诊单金额', type: 'money', hint: '排除取消、拒收、退款及已退款订单' },
|
||||||
|
|||||||
@@ -0,0 +1,227 @@
|
|||||||
|
<template>
|
||||||
|
<div class="prescription-ai-visibility">
|
||||||
|
<el-card v-loading="loading" shadow="never" class="!border-none">
|
||||||
|
<div class="text-xl font-medium mb-[20px]">处方 AI 一致度可见范围</div>
|
||||||
|
<el-alert type="info" :closable="false" show-icon class="mb-5">
|
||||||
|
<template #title>指定用户组或用户可查看一致度</template>
|
||||||
|
<div class="leading-6">
|
||||||
|
用户命中任一指定系统角色,或被单独指定,即可查看桌面端“与 AI
|
||||||
|
一致度”、逐味对照和一致度统计。AI
|
||||||
|
报告继续按原有权限开放,已有报告不受影响,患者与处方的数据范围保持原有规则。
|
||||||
|
</div>
|
||||||
|
</el-alert>
|
||||||
|
|
||||||
|
<el-alert
|
||||||
|
v-if="loadError"
|
||||||
|
:title="loadError"
|
||||||
|
type="error"
|
||||||
|
:closable="false"
|
||||||
|
show-icon
|
||||||
|
class="mb-5"
|
||||||
|
/>
|
||||||
|
<div v-if="loading" role="status" class="mb-5">正在读取当前可见范围…</div>
|
||||||
|
|
||||||
|
<el-form
|
||||||
|
v-if="hasLoaded"
|
||||||
|
class="ls-form"
|
||||||
|
:model="formData"
|
||||||
|
:disabled="!ready || loading || saving"
|
||||||
|
label-width="140px"
|
||||||
|
>
|
||||||
|
<el-form-item label="指定用户组" prop="role_ids">
|
||||||
|
<div class="w-full max-w-[640px]">
|
||||||
|
<el-select
|
||||||
|
v-model="formData.role_ids"
|
||||||
|
class="w-full"
|
||||||
|
multiple
|
||||||
|
filterable
|
||||||
|
clearable
|
||||||
|
placeholder="请选择系统角色,可搜索角色名称"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="role in roleOptions"
|
||||||
|
:key="role.id"
|
||||||
|
:label="role.name"
|
||||||
|
:value="role.id"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
<div class="form-tips">账号拥有任一指定角色即可命中。</div>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="指定用户" prop="admin_ids">
|
||||||
|
<div class="w-full max-w-[640px]">
|
||||||
|
<el-select
|
||||||
|
v-model="formData.admin_ids"
|
||||||
|
class="w-full"
|
||||||
|
multiple
|
||||||
|
filterable
|
||||||
|
clearable
|
||||||
|
placeholder="请选择用户,可搜索姓名或账号"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="admin in adminOptions"
|
||||||
|
:key="admin.id"
|
||||||
|
:label="admin.label"
|
||||||
|
:value="admin.id"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
<div class="form-tips">
|
||||||
|
单独指定的用户无需同时命中用户组。当前不在可选列表中的已配置 ID
|
||||||
|
会保留,移除后保存才会取消该项授权。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="生效规则">
|
||||||
|
<div>
|
||||||
|
<div class="leading-6">指定用户组与指定用户按“或”生效。</div>
|
||||||
|
<div class="form-tips">
|
||||||
|
两项均为空时,所有账号(包括超级管理员)均隐藏一致度、对照与统计。
|
||||||
|
拥有本页管理权限不自动获得一致度查看权,超级管理员也需命中上述名单。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<el-alert
|
||||||
|
v-if="ready && !formData.role_ids.length && !formData.admin_ids.length"
|
||||||
|
title="当前选择为空:保存后所有账号将隐藏一致度、对照与统计,AI 报告仍保留。"
|
||||||
|
type="warning"
|
||||||
|
:closable="false"
|
||||||
|
show-icon
|
||||||
|
class="mb-5"
|
||||||
|
/>
|
||||||
|
<el-alert
|
||||||
|
v-if="saveError"
|
||||||
|
:title="saveError"
|
||||||
|
type="error"
|
||||||
|
:closable="false"
|
||||||
|
show-icon
|
||||||
|
class="mb-5"
|
||||||
|
/>
|
||||||
|
<el-button :disabled="loading || saving" @click="loadConfig">
|
||||||
|
{{ loadError ? '重试加载' : '重新加载' }}
|
||||||
|
</el-button>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<footer-btns v-if="canManage">
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
:loading="saving"
|
||||||
|
:disabled="!ready || loading || saving"
|
||||||
|
@click="handleSubmit"
|
||||||
|
>
|
||||||
|
{{ saving ? '正在保存' : '保存' }}
|
||||||
|
</el-button>
|
||||||
|
</footer-btns>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts" name="prescriptionAiVisibility">
|
||||||
|
import { computed, reactive, ref } from 'vue'
|
||||||
|
|
||||||
|
import type {
|
||||||
|
PrescriptionAiVisibilityConfig,
|
||||||
|
PrescriptionAiVisibilityOptions
|
||||||
|
} from '@/api/setting/prescription_ai_visibility'
|
||||||
|
import {
|
||||||
|
getPrescriptionAiCapabilities,
|
||||||
|
getPrescriptionAiVisibilityConfig,
|
||||||
|
getPrescriptionAiVisibilityOptions,
|
||||||
|
setPrescriptionAiVisibilityConfig
|
||||||
|
} from '@/api/setting/prescription_ai_visibility'
|
||||||
|
import feedback from '@/utils/feedback'
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const saving = ref(false)
|
||||||
|
const ready = ref(false)
|
||||||
|
const hasLoaded = ref(false)
|
||||||
|
const canManage = ref(false)
|
||||||
|
const loadError = ref('')
|
||||||
|
const saveError = ref('')
|
||||||
|
const formData = reactive<PrescriptionAiVisibilityConfig>({ role_ids: [], admin_ids: [] })
|
||||||
|
const optionsData = reactive<PrescriptionAiVisibilityOptions>({ roles: [], admins: [] })
|
||||||
|
|
||||||
|
const roleOptions = computed(() => {
|
||||||
|
const options = new Map(optionsData.roles.map((role) => [role.id, role]))
|
||||||
|
for (const id of formData.role_ids) {
|
||||||
|
if (!options.has(id)) {
|
||||||
|
options.set(id, { id, name: `已配置角色 #${id}(当前不在可选列表)` })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...options.values()]
|
||||||
|
})
|
||||||
|
|
||||||
|
const adminOptions = computed(() => {
|
||||||
|
const options = new Map(
|
||||||
|
optionsData.admins.map((admin) => [
|
||||||
|
admin.id,
|
||||||
|
{ id: admin.id, label: `${admin.name || `用户 #${admin.id}`}(${admin.account})` }
|
||||||
|
])
|
||||||
|
)
|
||||||
|
for (const id of formData.admin_ids) {
|
||||||
|
if (!options.has(id)) {
|
||||||
|
options.set(id, { id, label: `已配置用户 #${id}(当前不在可选列表)` })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...options.values()]
|
||||||
|
})
|
||||||
|
|
||||||
|
const assignConfig = (config: PrescriptionAiVisibilityConfig) => {
|
||||||
|
formData.role_ids = [...config.role_ids]
|
||||||
|
formData.admin_ids = [...config.admin_ids]
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadConfig = async () => {
|
||||||
|
if (loading.value || saving.value) return
|
||||||
|
loading.value = true
|
||||||
|
ready.value = false
|
||||||
|
canManage.value = false
|
||||||
|
loadError.value = ''
|
||||||
|
saveError.value = ''
|
||||||
|
try {
|
||||||
|
const capabilities = await getPrescriptionAiCapabilities()
|
||||||
|
if (capabilities.can_manage_visibility !== true) {
|
||||||
|
loadError.value = '当前账号无权管理处方 AI 一致度可见范围。'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
canManage.value = true
|
||||||
|
const [config, options] = await Promise.all([
|
||||||
|
getPrescriptionAiVisibilityConfig(),
|
||||||
|
getPrescriptionAiVisibilityOptions()
|
||||||
|
])
|
||||||
|
if (!Array.isArray(options?.roles) || !Array.isArray(options?.admins)) {
|
||||||
|
throw new Error('可选用户组或用户响应格式不正确')
|
||||||
|
}
|
||||||
|
// Apply both responses together; an incomplete load cannot overwrite the current policy.
|
||||||
|
assignConfig(config)
|
||||||
|
optionsData.roles = options.roles
|
||||||
|
optionsData.admins = options.admins
|
||||||
|
hasLoaded.value = true
|
||||||
|
ready.value = true
|
||||||
|
} catch {
|
||||||
|
loadError.value = '可见范围加载失败,尚未读取完整配置。请重试加载后再保存。'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!ready.value || !canManage.value || loading.value || saving.value) return
|
||||||
|
saving.value = true
|
||||||
|
saveError.value = ''
|
||||||
|
try {
|
||||||
|
const config = await setPrescriptionAiVisibilityConfig({
|
||||||
|
role_ids: [...formData.role_ids],
|
||||||
|
admin_ids: [...formData.admin_ids]
|
||||||
|
})
|
||||||
|
assignConfig(config)
|
||||||
|
feedback.msgSuccess('可见范围已保存')
|
||||||
|
} catch {
|
||||||
|
saveError.value = '保存结果未确认,当前选择已保留。请重试保存,或重新加载以确认服务端策略。'
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void loadConfig()
|
||||||
|
</script>
|
||||||
@@ -56,6 +56,70 @@
|
|||||||
/>
|
/>
|
||||||
<el-empty v-else description="暂无数据" />
|
<el-empty v-else description="暂无数据" />
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
|
<!-- 我的复诊接诊率(方案 §10 D1-h):仅本人行,口径与「复诊接诊率」报表一致 -->
|
||||||
|
<el-card v-if="canViewRevisitRate" class="!border-none mt-4" shadow="never">
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="card-header-left">
|
||||||
|
<span class="card-title">我的复诊接诊率</span>
|
||||||
|
<el-date-picker
|
||||||
|
v-model="revisitMonth"
|
||||||
|
type="month"
|
||||||
|
value-format="YYYY-MM"
|
||||||
|
placeholder="选择月份"
|
||||||
|
:clearable="false"
|
||||||
|
:disabled-date="disableFutureMonth"
|
||||||
|
size="small"
|
||||||
|
style="width: 130px"
|
||||||
|
@change="fetchRevisitRate"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span class="card-hint">{{ revisitMonthText }} · 与「复诊接诊率」报表同口径</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div v-loading="revisitLoading" class="revisit-rate-body">
|
||||||
|
<template v-if="revisitRow">
|
||||||
|
<div class="revisit-kpi-grid">
|
||||||
|
<div class="revisit-kpi-card">
|
||||||
|
<div class="stats-kpi-label">新指派数</div>
|
||||||
|
<div class="revisit-kpi-value">
|
||||||
|
{{ Number(revisitRow.assigned_count ?? 0) }}
|
||||||
|
</div>
|
||||||
|
<div class="revisit-kpi-sub">当月新指派给我的患者</div>
|
||||||
|
</div>
|
||||||
|
<div class="revisit-kpi-card">
|
||||||
|
<div class="stats-kpi-label">继承数</div>
|
||||||
|
<div class="revisit-kpi-value">
|
||||||
|
{{ Number(revisitRow.inherit_count ?? 0) }}
|
||||||
|
</div>
|
||||||
|
<div class="revisit-kpi-sub">当月继承给我的患者</div>
|
||||||
|
</div>
|
||||||
|
<div class="revisit-kpi-card is-primary">
|
||||||
|
<div class="stats-kpi-label">复诊合计接诊率</div>
|
||||||
|
<div class="revisit-kpi-value">
|
||||||
|
{{ formatRate(revisitRow.visit_all_rate) }}
|
||||||
|
</div>
|
||||||
|
<div class="revisit-kpi-sub">{{ visitAllFractionText }}</div>
|
||||||
|
</div>
|
||||||
|
<div v-for="slot in revisitSlots" :key="slot" class="revisit-kpi-card">
|
||||||
|
<div class="stats-kpi-label">{{ slotLabel(slot) }}接诊率</div>
|
||||||
|
<div class="revisit-kpi-value">
|
||||||
|
{{ formatRate(slotMetric(slot, 'rate')) }}
|
||||||
|
</div>
|
||||||
|
<div class="revisit-kpi-sub">{{ slotFractionText(slot) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<el-empty v-else :description="revisitEmptyText" :image-size="72" />
|
||||||
|
<div class="revisit-caliber">
|
||||||
|
口径:N 诊接诊率 = 当月 N 诊成交单数 ÷ N 诊分母(新指派数 + 起算诊次 ≤ N 的继承数),分母为 0 显示「—」;
|
||||||
|
<template v-if="revisitInheritMode === 'exclude'">当前为旧口径,继承患者不计入分母;</template>
|
||||||
|
<template v-else>继承患者按继承时诊次从第 k+1 诊起计入;</template>
|
||||||
|
复诊合计 = 各诊次成交单数之和 ÷ 被指派合计(新指派 ∪ 继承),可超过 100%。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -63,7 +127,12 @@
|
|||||||
import { computed, onMounted, reactive, ref } from 'vue'
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import vCharts from 'vue-echarts'
|
import vCharts from 'vue-echarts'
|
||||||
import { assistantPerformanceOverview } from '@/api/stats'
|
import {
|
||||||
|
assistantPerformanceOverview,
|
||||||
|
assistantPerformanceRevisitRate,
|
||||||
|
type AssistantRevisitRateResult
|
||||||
|
} from '@/api/stats'
|
||||||
|
import { hasPermission } from '@/utils/perm'
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const dateRange = ref<string[]>([])
|
const dateRange = ref<string[]>([])
|
||||||
@@ -191,8 +260,78 @@ const handleReset = () => {
|
|||||||
fetchData()
|
fetchData()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 我的复诊接诊率(方案 §10 D1-h)────────────────────────
|
||||||
|
/** 权限点由 server/sql/1.9.20261012/10_add_assistant_revisit_rate_menu.sql 登记并自动授予已有个人业绩页的角色 */
|
||||||
|
const canViewRevisitRate = hasPermission(['stats.assistantPerformance/revisitRate'])
|
||||||
|
|
||||||
|
const initNow = new Date()
|
||||||
|
const currentMonth = `${initNow.getFullYear()}-${String(initNow.getMonth() + 1).padStart(2, '0')}`
|
||||||
|
const revisitMonth = ref<string>(currentMonth)
|
||||||
|
const revisitLoading = ref(false)
|
||||||
|
const revisitResult = ref<AssistantRevisitRateResult | null>(null)
|
||||||
|
|
||||||
|
const revisitRow = computed(() => revisitResult.value?.row ?? null)
|
||||||
|
const revisitInheritMode = computed(() => revisitResult.value?.inherit_mode ?? 'slot')
|
||||||
|
const revisitSlots = computed(() =>
|
||||||
|
(revisitResult.value?.slots ?? []).map((s) => Number(s)).filter((s) => s >= 2)
|
||||||
|
)
|
||||||
|
const revisitMonthText = computed(() => {
|
||||||
|
const [y, m] = (revisitResult.value?.month || revisitMonth.value).split('-')
|
||||||
|
return y && m ? `${y} 年 ${Number(m)} 月` : revisitMonth.value
|
||||||
|
})
|
||||||
|
/** row=null:非二中心医助(in_scope=false)与「当月无被指派患者」文案区分 */
|
||||||
|
const revisitEmptyText = computed(() => {
|
||||||
|
if (revisitResult.value && revisitResult.value.in_scope === false) {
|
||||||
|
return '暂无数据(仅二中心医助统计)'
|
||||||
|
}
|
||||||
|
return revisitMonth.value === currentMonth ? '本月暂无被指派患者' : '该月暂无被指派患者'
|
||||||
|
})
|
||||||
|
|
||||||
|
const disableFutureMonth = (date: Date) => {
|
||||||
|
const now = new Date()
|
||||||
|
return date.getFullYear() * 12 + date.getMonth() > now.getFullYear() * 12 + now.getMonth()
|
||||||
|
}
|
||||||
|
|
||||||
|
const cnDigits = ['零', '一', '二', '三', '四', '五', '六', '七', '八', '九', '十']
|
||||||
|
const slotLabel = (slot: number) => {
|
||||||
|
if (slot >= 2 && slot <= 10) return `${cnDigits[slot]}诊`
|
||||||
|
if (slot > 10 && slot < 20) return `十${cnDigits[slot - 10]}诊`
|
||||||
|
return `${slot}诊`
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatRate = (rate: unknown) =>
|
||||||
|
rate === null || rate === undefined || rate === '' ? '—' : `${Number(rate)}%`
|
||||||
|
|
||||||
|
/** 读取本人行的 visit{N}_count / visit{N}_den / visit{N}_rate */
|
||||||
|
const slotMetric = (slot: number, kind: 'count' | 'den' | 'rate') =>
|
||||||
|
revisitRow.value?.[`visit${slot}_${kind}`]
|
||||||
|
|
||||||
|
/** 「成交 N 单 / 分母 M」 */
|
||||||
|
const fractionText = (count: unknown, den: unknown) =>
|
||||||
|
`成交 ${Number(count ?? 0)} 单 / 分母 ${Number(den ?? 0)}`
|
||||||
|
|
||||||
|
const slotFractionText = (slot: number) =>
|
||||||
|
fractionText(slotMetric(slot, 'count'), slotMetric(slot, 'den'))
|
||||||
|
|
||||||
|
const visitAllFractionText = computed(() =>
|
||||||
|
fractionText(revisitRow.value?.visit_all_count, revisitRow.value?.visit_all_den)
|
||||||
|
)
|
||||||
|
|
||||||
|
const fetchRevisitRate = async () => {
|
||||||
|
if (!canViewRevisitRate) return
|
||||||
|
revisitLoading.value = true
|
||||||
|
try {
|
||||||
|
revisitResult.value = await assistantPerformanceRevisitRate({ month: revisitMonth.value })
|
||||||
|
} catch {
|
||||||
|
// 错误提示由 utils/request 统一弹出;失败时保留上一次结果
|
||||||
|
} finally {
|
||||||
|
revisitLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchData()
|
fetchData()
|
||||||
|
fetchRevisitRate()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -270,4 +409,57 @@ onMounted(() => {
|
|||||||
.stats-chart {
|
.stats-chart {
|
||||||
height: 360px;
|
height: 360px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.card-header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revisit-rate-body {
|
||||||
|
min-height: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revisit-kpi-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revisit-kpi-card {
|
||||||
|
background: linear-gradient(145deg, #ffffff, #f7f9ff);
|
||||||
|
border: 1px solid #ebf1ff;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revisit-kpi-card.is-primary {
|
||||||
|
background: linear-gradient(145deg, #ffffff, #eef3ff);
|
||||||
|
border-color: #d9e4ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revisit-kpi-value {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 1.1;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revisit-kpi-card.is-primary .revisit-kpi-value {
|
||||||
|
color: #4a78ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revisit-kpi-sub {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #909399;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revisit-caliber {
|
||||||
|
margin-top: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #909399;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -67,7 +67,10 @@
|
|||||||
</el-form>
|
</el-form>
|
||||||
<div class="log-hint">
|
<div class="log-hint">
|
||||||
由每日定时任务「待分配诊单自动指派」写入:按上月二诊复诊接诊率分档轮询分配(>70% 每人3条/日,60%~70%
|
由每日定时任务「待分配诊单自动指派」写入:按上月二诊复诊接诊率分档轮询分配(>70% 每人3条/日,60%~70%
|
||||||
每人2条/日,50%~60% 每人1条/日,<50% 不分)。「回退」可将已分配医助撤回到自动分配前的原医助(通常为一中心),并写入指派日志。
|
每人2条/日,50%~60% 每人1条/日,<50% 不分)。「回退」可将已分配医助撤回到自动分配前的原医助(通常为一中心),并写入指派日志(原医助重新接手按「继承」记录)。
|
||||||
|
<br />
|
||||||
|
口径版本:<b>v1 旧口径</b> = 分母仅新指派(visit2_rate_new_assign);<b>v2 新口径</b> = 每列独立分母,继承按继承时诊次计入(visit2_rate)。
|
||||||
|
每条日志同时记录两套率并标注本批次分档所用口径(配置 project.revisit_rate.auto_assign_tier_field);2026-10 前的历史行仅有旧口径率。
|
||||||
</div>
|
</div>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
@@ -137,7 +140,7 @@
|
|||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<template v-if="Number(row.assistant_id) > 0">
|
<template v-if="Number(row.assistant_id) > 0">
|
||||||
<div>{{ row.assistant_name || '#' + row.assistant_id }}</div>
|
<div>{{ row.assistant_name || '#' + row.assistant_id }}</div>
|
||||||
<div class="cell-sub">上月二诊接诊率 {{ formatRate(row.visit2_rate) }}</div>
|
<div class="cell-sub">上月二诊接诊率 {{ formatRate(tierRate(row)) }}</div>
|
||||||
</template>
|
</template>
|
||||||
<span v-else>—</span>
|
<span v-else>—</span>
|
||||||
</template>
|
</template>
|
||||||
@@ -145,6 +148,29 @@
|
|||||||
<el-table-column label="档位" width="100" align="center">
|
<el-table-column label="档位" width="100" align="center">
|
||||||
<template #default="{ row }">{{ row.tier_text || '—' }}</template>
|
<template #default="{ row }">{{ row.tier_text || '—' }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
<el-table-column label="分档口径" min-width="150">
|
||||||
|
<template #header>
|
||||||
|
<span>分档口径</span>
|
||||||
|
<el-tooltip
|
||||||
|
placement="top"
|
||||||
|
effect="dark"
|
||||||
|
content="本批次分档所用口径;下方为两套口径的上月二诊接诊率(新口径 = 每列独立分母 / 旧口径 = 分母仅新指派)"
|
||||||
|
>
|
||||||
|
<el-icon class="col-hint"><InfoFilled /></el-icon>
|
||||||
|
</el-tooltip>
|
||||||
|
</template>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<template v-if="Number(row.action) === 1">
|
||||||
|
<el-tag :type="row.rate_version === 'v2' ? 'primary' : 'info'" size="small">
|
||||||
|
{{ row.rate_version_text || row.rate_version || 'v1' }}
|
||||||
|
</el-tag>
|
||||||
|
<div class="cell-sub">
|
||||||
|
新口径 {{ formatRate(row.visit2_rate) }} · 旧口径 {{ formatRate(row.visit2_rate_new_assign) }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<span v-else class="cell-sub">—</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column label="轮次" width="70" align="center">
|
<el-table-column label="轮次" width="70" align="center">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
{{ Number(row.round_no) > 0 ? '第' + row.round_no + '轮' : '—' }}
|
{{ Number(row.round_no) > 0 ? '第' + row.round_no + '轮' : '—' }}
|
||||||
@@ -186,6 +212,7 @@
|
|||||||
import { autoAssignLogLists, autoAssignLogRollback } from '@/api/stats'
|
import { autoAssignLogLists, autoAssignLogRollback } from '@/api/stats'
|
||||||
import { usePaging } from '@/hooks/usePaging'
|
import { usePaging } from '@/hooks/usePaging'
|
||||||
import feedback from '@/utils/feedback'
|
import feedback from '@/utils/feedback'
|
||||||
|
import { InfoFilled } from '@element-plus/icons-vue'
|
||||||
import { onMounted, reactive, ref, watch } from 'vue'
|
import { onMounted, reactive, ref, watch } from 'vue'
|
||||||
|
|
||||||
const dateRange = ref<[string, string] | null>(null)
|
const dateRange = ref<[string, string] | null>(null)
|
||||||
@@ -241,6 +268,14 @@ const handleReset = () => {
|
|||||||
const formatRate = (rate: number | null | undefined) =>
|
const formatRate = (rate: number | null | undefined) =>
|
||||||
rate === null || rate === undefined ? '—' : `${rate}%`
|
rate === null || rate === undefined ? '—' : `${rate}%`
|
||||||
|
|
||||||
|
/** 「医助」列显示的接诊率 = 本批次分档所用口径的率(v1 旧口径取 visit2_rate_new_assign,历史行无该列时回落 visit2_rate) */
|
||||||
|
const tierRate = (row: any): number | null | undefined => {
|
||||||
|
if (row?.rate_version === 'v1' && row?.visit2_rate_new_assign !== null && row?.visit2_rate_new_assign !== undefined) {
|
||||||
|
return row.visit2_rate_new_assign
|
||||||
|
}
|
||||||
|
return row?.visit2_rate
|
||||||
|
}
|
||||||
|
|
||||||
const rowSelectable = (row: any) => Number(row.can_rollback) === 1
|
const rowSelectable = (row: any) => Number(row.can_rollback) === 1
|
||||||
|
|
||||||
const onSelectionChange = (rows: any[]) => {
|
const onSelectionChange = (rows: any[]) => {
|
||||||
@@ -315,5 +350,12 @@ onMounted(() => getLists())
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--el-text-color-secondary);
|
color: var(--el-text-color-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.col-hint {
|
||||||
|
margin-left: 2px;
|
||||||
|
vertical-align: -2px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,901 @@
|
|||||||
|
<template>
|
||||||
|
<div class="revisit-rate-page">
|
||||||
|
<el-card class="!border-none" shadow="never">
|
||||||
|
<el-form :inline="true" class="rate-filter-form">
|
||||||
|
<el-form-item :label="dateLabel">
|
||||||
|
<el-date-picker
|
||||||
|
v-model="dateRange"
|
||||||
|
type="daterange"
|
||||||
|
value-format="YYYY-MM-DD"
|
||||||
|
start-placeholder="开始日期"
|
||||||
|
end-placeholder="结束日期"
|
||||||
|
range-separator="至"
|
||||||
|
:clearable="false"
|
||||||
|
style="width: 280px"
|
||||||
|
@change="loadData"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="部门">
|
||||||
|
<el-tree-select
|
||||||
|
v-model="deptId"
|
||||||
|
:data="deptTreeOptions"
|
||||||
|
placeholder="二中心(全部)"
|
||||||
|
clearable
|
||||||
|
filterable
|
||||||
|
check-strictly
|
||||||
|
node-key="id"
|
||||||
|
:default-expand-all="true"
|
||||||
|
:props="deptTreeProps"
|
||||||
|
style="width: 210px"
|
||||||
|
@change="handleDeptChange"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="医助">
|
||||||
|
<el-select
|
||||||
|
v-model="assistantId"
|
||||||
|
placeholder="全部医助"
|
||||||
|
filterable
|
||||||
|
clearable
|
||||||
|
:loading="optionsLoading"
|
||||||
|
style="width: 180px"
|
||||||
|
@change="loadData"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="assistant in assistantOptions"
|
||||||
|
:key="assistant.id"
|
||||||
|
:label="assistant.name"
|
||||||
|
:value="assistant.id"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" :loading="loading" @click="loadData">查询</el-button>
|
||||||
|
<el-button :disabled="loading" @click="handleReset">重置</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-popover placement="bottom-start" :width="540" trigger="hover">
|
||||||
|
<template #reference>
|
||||||
|
<span class="rate-caliber-trigger"
|
||||||
|
><el-icon><InfoFilled /></el-icon>统计口径</span
|
||||||
|
>
|
||||||
|
</template>
|
||||||
|
<div class="rate-caliber">
|
||||||
|
<p>
|
||||||
|
<b>{{ cohortLabel }}</b
|
||||||
|
>:按所选{{
|
||||||
|
dateLabel
|
||||||
|
}}范围确定患者,订单从对应指派后累计至查询当天;结束日期允许选择未来日期,但未来订单不会提前计入。
|
||||||
|
</p>
|
||||||
|
<p v-if="isInherit">
|
||||||
|
<b>继承诊次重新起算</b>:继承后的第 1 笔有效订单算二诊,第 2
|
||||||
|
笔算三诊,依次递增;不使用继承前的全局诊次,也不叠加诊次偏移。只统计继承患者,不混入新指派患者。
|
||||||
|
</p>
|
||||||
|
<p v-else>
|
||||||
|
只统计新指派患者,不混入继承患者。继承患者在独立的「继承复诊接诊率」页面统计。
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<b>N 诊接诊率</b> = N 诊单数 ÷ 本页{{ cohortLabel }};分母为 0
|
||||||
|
显示「—」。<b>复诊合计接诊率</b> = 复诊合计单数 ÷ 本页{{
|
||||||
|
cohortLabel
|
||||||
|
}},表示人均复诊成交单数,可超过 100%。
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
订单仅计入有效业务订单,剔除已取消 / 拒收 /
|
||||||
|
退款;归属按服务端的患者持有记录计算。医助按人事部门归组;仅统计「二中心」及其组织下级,选定部门时包含其下级。
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
部门行和合计行按诊单去重,不能直接将医助行相加。灰字为「单数 /
|
||||||
|
分母」。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</el-popover>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<el-alert class="mt-3" type="info" :closable="false" show-icon :title="reportHint" />
|
||||||
|
<el-alert
|
||||||
|
v-if="optionsError"
|
||||||
|
class="mt-3"
|
||||||
|
type="warning"
|
||||||
|
:title="optionsError"
|
||||||
|
:closable="false"
|
||||||
|
/>
|
||||||
|
<el-alert
|
||||||
|
v-if="queryError"
|
||||||
|
class="mt-3"
|
||||||
|
type="error"
|
||||||
|
:title="queryError"
|
||||||
|
:closable="false"
|
||||||
|
/>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<div class="rate-kpi-grid">
|
||||||
|
<div
|
||||||
|
v-for="card in summaryCards"
|
||||||
|
:key="card.key"
|
||||||
|
class="rate-kpi-card"
|
||||||
|
:class="{ 'is-clickable': card.clickable }"
|
||||||
|
@click="card.clickable && card.onClick()"
|
||||||
|
>
|
||||||
|
<div class="rate-kpi-label">{{ card.label }}</div>
|
||||||
|
<div class="rate-kpi-value">{{ card.value }}</div>
|
||||||
|
<div v-if="card.sub" class="rate-kpi-sub">{{ card.sub }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-card class="!border-none mt-4" shadow="never">
|
||||||
|
<template #header>
|
||||||
|
<div class="rate-card-header">
|
||||||
|
<span>{{ reportTitle }} · 部门 / 医助明细</span>
|
||||||
|
<span class="rate-card-hint">{{ dateLabel }}:{{ rangeText }}</span>
|
||||||
|
<span v-if="observedUntil" class="rate-card-hint"
|
||||||
|
>订单统计截至:{{ observedUntil }}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="rate-card-hint mt-2">
|
||||||
|
点击{{ cohortLabel }}、各诊次单数可查看患者与订单;底栏合计按诊单去重。
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<el-table
|
||||||
|
v-loading="loading"
|
||||||
|
:data="rows"
|
||||||
|
size="small"
|
||||||
|
border
|
||||||
|
row-key="row_key"
|
||||||
|
:tree-props="{ children: 'children' }"
|
||||||
|
:default-expand-all="true"
|
||||||
|
show-summary
|
||||||
|
:summary-method="getSummaries"
|
||||||
|
>
|
||||||
|
<el-table-column label="部门 / 医助" min-width="200" fixed>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span v-if="row.is_dept" class="rate-dept-cell"
|
||||||
|
>{{ row.dept_name
|
||||||
|
}}<span class="rate-dept-count"
|
||||||
|
>{{ row.assistant_count }} 人</span
|
||||||
|
></span
|
||||||
|
>
|
||||||
|
<span v-else>{{ row.assistant_name }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column
|
||||||
|
:prop="cohortCountKey"
|
||||||
|
:label="cohortLabel"
|
||||||
|
min-width="100"
|
||||||
|
align="right"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<span>{{ cohortLabel }}</span>
|
||||||
|
<el-tooltip
|
||||||
|
effect="dark"
|
||||||
|
placement="top"
|
||||||
|
:content="`所选${dateLabel}范围内的${cohortLabel},部门和合计按诊单去重`"
|
||||||
|
><el-icon class="rate-th-hint"><InfoFilled /></el-icon
|
||||||
|
></el-tooltip>
|
||||||
|
</template>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-link
|
||||||
|
v-if="canViewAssignLines && row[cohortCountKey] > 0"
|
||||||
|
type="primary"
|
||||||
|
:underline="false"
|
||||||
|
@click="openAssignDetail(row)"
|
||||||
|
>{{ row[cohortCountKey] }}</el-link
|
||||||
|
>
|
||||||
|
<span v-else>{{ row[cohortCountKey] ?? 0 }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<template v-for="slot in visitSlots" :key="slot">
|
||||||
|
<el-table-column
|
||||||
|
:prop="`visit${slot}_count`"
|
||||||
|
:label="`${slotLabel(slot)}单数`"
|
||||||
|
min-width="96"
|
||||||
|
align="right"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<span>{{ slotLabel(slot) }}单数</span>
|
||||||
|
<el-tooltip effect="dark" placement="top" :content="slotCountTip(slot)"
|
||||||
|
><el-icon class="rate-th-hint"><InfoFilled /></el-icon
|
||||||
|
></el-tooltip>
|
||||||
|
</template>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-link
|
||||||
|
v-if="canViewOrderLines && row[`visit${slot}_count`] > 0"
|
||||||
|
type="primary"
|
||||||
|
:underline="false"
|
||||||
|
@click="openOrderDetail(slot, row)"
|
||||||
|
>{{ row[`visit${slot}_count`] }}</el-link
|
||||||
|
>
|
||||||
|
<span v-else>{{ row[`visit${slot}_count`] ?? 0 }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column
|
||||||
|
:prop="`visit${slot}_rate`"
|
||||||
|
:label="`${slotLabel(slot)}接诊率`"
|
||||||
|
min-width="110"
|
||||||
|
align="right"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<span>{{ slotLabel(slot) }}接诊率</span>
|
||||||
|
<el-tooltip
|
||||||
|
effect="dark"
|
||||||
|
placement="top"
|
||||||
|
:content="`${slotLabel(slot)}单数 ÷ ${cohortLabel};分母为 0 显示「—」`"
|
||||||
|
><el-icon class="rate-th-hint"><InfoFilled /></el-icon
|
||||||
|
></el-tooltip>
|
||||||
|
</template>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="rate-cell">
|
||||||
|
<span class="rate-cell-main">{{
|
||||||
|
formatRate(row[`visit${slot}_rate`])
|
||||||
|
}}</span
|
||||||
|
><span class="rate-cell-sub">{{
|
||||||
|
denText(row, `visit${slot}`)
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</template>
|
||||||
|
<el-table-column
|
||||||
|
prop="visit_all_count"
|
||||||
|
label="复诊合计单数"
|
||||||
|
min-width="110"
|
||||||
|
align="right"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<span>复诊合计单数</span>
|
||||||
|
<el-tooltip
|
||||||
|
effect="dark"
|
||||||
|
placement="top"
|
||||||
|
content="所选患者从对应指派后累计至查询当天的全部复诊订单数"
|
||||||
|
><el-icon class="rate-th-hint"><InfoFilled /></el-icon
|
||||||
|
></el-tooltip>
|
||||||
|
</template>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-link
|
||||||
|
v-if="canViewOrderLines && row.visit_all_count > 0"
|
||||||
|
type="primary"
|
||||||
|
:underline="false"
|
||||||
|
@click="openOrderDetail(0, row)"
|
||||||
|
>{{ row.visit_all_count }}</el-link
|
||||||
|
>
|
||||||
|
<span v-else>{{ row.visit_all_count ?? 0 }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column
|
||||||
|
prop="visit_all_rate"
|
||||||
|
label="复诊合计接诊率"
|
||||||
|
min-width="128"
|
||||||
|
align="right"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<span>复诊合计接诊率</span>
|
||||||
|
<el-tooltip
|
||||||
|
effect="dark"
|
||||||
|
placement="top"
|
||||||
|
:content="`复诊合计单数 ÷ ${cohortLabel};表示人均复诊成交单数,可超过 100%`"
|
||||||
|
><el-icon class="rate-th-hint"><InfoFilled /></el-icon
|
||||||
|
></el-tooltip>
|
||||||
|
</template>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="rate-cell">
|
||||||
|
<span class="rate-cell-main">{{ formatRate(row.visit_all_rate) }}</span
|
||||||
|
><span class="rate-cell-sub">{{ denText(row, 'visit_all') }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-dialog
|
||||||
|
v-model="detailVisible"
|
||||||
|
:title="detailTitle"
|
||||||
|
width="1080px"
|
||||||
|
top="6vh"
|
||||||
|
destroy-on-close
|
||||||
|
@close="invalidateDetail"
|
||||||
|
>
|
||||||
|
<p class="rate-detail-hint">{{ detailHint }}</p>
|
||||||
|
<el-alert
|
||||||
|
v-if="detailError"
|
||||||
|
class="mb-3"
|
||||||
|
type="error"
|
||||||
|
:title="detailError"
|
||||||
|
:closable="false"
|
||||||
|
/>
|
||||||
|
<el-table
|
||||||
|
v-loading="detailLoading"
|
||||||
|
:data="detailRows"
|
||||||
|
size="small"
|
||||||
|
border
|
||||||
|
stripe
|
||||||
|
max-height="60vh"
|
||||||
|
>
|
||||||
|
<template v-if="detailType === 'assign'">
|
||||||
|
<el-table-column prop="diagnosis_id" label="诊单ID" width="90" />
|
||||||
|
<el-table-column prop="patient_name" label="患者" min-width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-link
|
||||||
|
v-if="canViewPatientDetail && row.diagnosis_id"
|
||||||
|
type="primary"
|
||||||
|
:underline="false"
|
||||||
|
@click="openPatientDetail(row.diagnosis_id)"
|
||||||
|
>{{ row.patient_name || `诊单#${row.diagnosis_id}` }}</el-link
|
||||||
|
>
|
||||||
|
<span v-else>{{ row.patient_name || '—' }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="patient_phone" label="联系电话" min-width="120"
|
||||||
|
><template #default="{ row }">{{
|
||||||
|
row.patient_phone || '—'
|
||||||
|
}}</template></el-table-column
|
||||||
|
>
|
||||||
|
<el-table-column
|
||||||
|
prop="assistant_names"
|
||||||
|
:label="isInherit ? '继承医助' : '被指派医助'"
|
||||||
|
min-width="140"
|
||||||
|
show-overflow-tooltip
|
||||||
|
/>
|
||||||
|
<el-table-column
|
||||||
|
prop="assign_count"
|
||||||
|
:label="isInherit ? '范围内继承次数' : '范围内指派次数'"
|
||||||
|
width="120"
|
||||||
|
align="right"
|
||||||
|
/>
|
||||||
|
<el-table-column
|
||||||
|
prop="first_assign_time_text"
|
||||||
|
:label="isInherit ? '首次继承时间' : '首次指派时间'"
|
||||||
|
min-width="160"
|
||||||
|
/>
|
||||||
|
<el-table-column
|
||||||
|
prop="last_assign_time_text"
|
||||||
|
:label="isInherit ? '最近继承时间' : '最近指派时间'"
|
||||||
|
min-width="160"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<el-table-column
|
||||||
|
prop="order_no"
|
||||||
|
label="订单号"
|
||||||
|
min-width="180"
|
||||||
|
show-overflow-tooltip
|
||||||
|
>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-link
|
||||||
|
v-if="canViewOrderDetail && row.order_id"
|
||||||
|
type="primary"
|
||||||
|
:underline="false"
|
||||||
|
@click="openOrderInfo(row.order_id)"
|
||||||
|
>{{ row.order_no || `#${row.order_id}` }}</el-link
|
||||||
|
>
|
||||||
|
<span v-else>{{ row.order_no || `#${row.order_id}` }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column
|
||||||
|
v-if="detailSlot === 0 || isInherit"
|
||||||
|
prop="visit_slot"
|
||||||
|
:label="isInherit ? '继承后诊次' : '诊次'"
|
||||||
|
width="100"
|
||||||
|
align="center"
|
||||||
|
><template #default="{ row }">{{
|
||||||
|
slotLabel(Number(row.visit_slot))
|
||||||
|
}}</template></el-table-column
|
||||||
|
>
|
||||||
|
<el-table-column
|
||||||
|
v-if="isInherit"
|
||||||
|
prop="global_visit_slot"
|
||||||
|
label="全局诊次(参考)"
|
||||||
|
width="130"
|
||||||
|
align="center"
|
||||||
|
><template #default="{ row }">{{
|
||||||
|
row.global_visit_slot ? slotLabel(Number(row.global_visit_slot)) : '—'
|
||||||
|
}}</template></el-table-column
|
||||||
|
>
|
||||||
|
<el-table-column prop="diagnosis_id" label="诊单ID" width="90" />
|
||||||
|
<el-table-column prop="patient_name" label="患者" min-width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-link
|
||||||
|
v-if="canViewPatientDetail && row.diagnosis_id"
|
||||||
|
type="primary"
|
||||||
|
:underline="false"
|
||||||
|
@click="openPatientDetail(row.diagnosis_id)"
|
||||||
|
>{{ row.patient_name || `诊单#${row.diagnosis_id}` }}</el-link
|
||||||
|
>
|
||||||
|
<span v-else>{{ row.patient_name || '—' }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="patient_phone" label="联系电话" min-width="120"
|
||||||
|
><template #default="{ row }">{{
|
||||||
|
row.patient_phone || '—'
|
||||||
|
}}</template></el-table-column
|
||||||
|
>
|
||||||
|
<el-table-column prop="amount" label="金额" width="100" align="right"
|
||||||
|
><template #default="{ row }">¥ {{ row.amount }}</template></el-table-column
|
||||||
|
>
|
||||||
|
<el-table-column
|
||||||
|
prop="cohort_assign_time_text"
|
||||||
|
:label="isInherit ? '继承时间' : '指派时间'"
|
||||||
|
min-width="160"
|
||||||
|
/>
|
||||||
|
<el-table-column prop="create_time_text" label="下单时间" min-width="160" />
|
||||||
|
<el-table-column prop="assistant_name" label="持有医助" min-width="100" />
|
||||||
|
<el-table-column prop="creator_name" label="订单创建人" min-width="100" />
|
||||||
|
</template>
|
||||||
|
</el-table>
|
||||||
|
<div class="rate-detail-footer">共 {{ detailCount }} 条</div>
|
||||||
|
</el-dialog>
|
||||||
|
<order-detail-drawer ref="orderDetailRef" readonly append-to-body />
|
||||||
|
<diagnosis-edit-popup ref="diagnosisEditRef" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, defineAsyncComponent, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||||
|
import { InfoFilled } from '@element-plus/icons-vue'
|
||||||
|
import {
|
||||||
|
revisitCohortAssignLines,
|
||||||
|
revisitCohortDeptOptions,
|
||||||
|
revisitCohortOverview,
|
||||||
|
revisitCohortVisitOrderLines,
|
||||||
|
type RevisitRateCohortType,
|
||||||
|
type RevisitRateDetailQuery,
|
||||||
|
type RevisitRateQuery
|
||||||
|
} from '@/api/stats'
|
||||||
|
import { hasPermission } from '@/utils/perm'
|
||||||
|
|
||||||
|
const props = defineProps<{ cohortType: RevisitRateCohortType }>()
|
||||||
|
const OrderDetailDrawer = defineAsyncComponent(
|
||||||
|
() => import('@/views/consumer/prescription/components/PrescriptionOrderDetailDrawer.vue')
|
||||||
|
)
|
||||||
|
const DiagnosisEditPopup = defineAsyncComponent(() => import('@/views/tcm/diagnosis/edit.vue'))
|
||||||
|
|
||||||
|
interface MetricPack extends Record<string, any> {
|
||||||
|
assigned_count: number
|
||||||
|
inherit_count: number
|
||||||
|
assigned_total_count: number
|
||||||
|
visit_all_count: number
|
||||||
|
visit_all_den: number
|
||||||
|
visit_all_rate: number | null
|
||||||
|
}
|
||||||
|
interface StatsRow extends MetricPack {
|
||||||
|
row_key: string
|
||||||
|
is_dept: 0 | 1
|
||||||
|
dept_id: number
|
||||||
|
dept_name: string
|
||||||
|
assistant_id?: number
|
||||||
|
assistant_name?: string
|
||||||
|
assistant_count?: number
|
||||||
|
children?: StatsRow[]
|
||||||
|
}
|
||||||
|
interface DeptOption {
|
||||||
|
id: number
|
||||||
|
pid: number
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
interface DeptTreeNode extends DeptOption {
|
||||||
|
children?: DeptTreeNode[]
|
||||||
|
}
|
||||||
|
interface AssistantOption {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
dept_id: number
|
||||||
|
}
|
||||||
|
interface SummaryCard {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
sub: string
|
||||||
|
clickable: boolean
|
||||||
|
onClick: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const isInherit = computed(() => props.cohortType === 'inherit')
|
||||||
|
const cohortLabel = computed(() => (isInherit.value ? '继承数' : '新指派数'))
|
||||||
|
const cohortCountKey = computed(() => (isInherit.value ? 'inherit_count' : 'assigned_count'))
|
||||||
|
const dateLabel = computed(() => (isInherit.value ? '继承日期' : '指派日期'))
|
||||||
|
const reportTitle = computed(() => (isInherit.value ? '继承复诊接诊率' : '按指派周期复诊率'))
|
||||||
|
const reportHint = computed(() =>
|
||||||
|
isInherit.value
|
||||||
|
? '按继承日期范围选择患者,订单累计至查询当天。继承后第 1 笔有效订单算二诊,第 2 笔算三诊;忽略继承前全局诊次及偏移,新指派患者另页统计。'
|
||||||
|
: '按新指派日期范围选择患者,订单累计至查询当天,跨月继续累计。继承患者在独立的「继承复诊接诊率」页面统计。'
|
||||||
|
)
|
||||||
|
const permissionPrefix = computed(() =>
|
||||||
|
isInherit.value ? 'stats.revisitInheritRate' : 'stats.revisitAssignRate'
|
||||||
|
)
|
||||||
|
const canViewAssignLines = computed(() => hasPermission([`${permissionPrefix.value}/assignLines`]))
|
||||||
|
const canViewOrderLines = computed(() =>
|
||||||
|
hasPermission([`${permissionPrefix.value}/visitOrderLines`])
|
||||||
|
)
|
||||||
|
const canViewOrderDetail = hasPermission(['tcm.prescriptionOrder/detail'])
|
||||||
|
const canViewPatientDetail = hasPermission(['tcm.diagnosis/detail'])
|
||||||
|
|
||||||
|
const formatDate = (date: Date) =>
|
||||||
|
`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
|
||||||
|
const defaultDateRange = (): [string, string] => {
|
||||||
|
const now = new Date()
|
||||||
|
return [formatDate(new Date(now.getFullYear(), now.getMonth(), 1)), formatDate(now)]
|
||||||
|
}
|
||||||
|
const dateRange = ref<[string, string]>(defaultDateRange())
|
||||||
|
const deptId = ref<number | undefined>()
|
||||||
|
const assistantId = ref<number | undefined>()
|
||||||
|
const loading = ref(false)
|
||||||
|
const queryError = ref('')
|
||||||
|
const rows = ref<StatsRow[]>([])
|
||||||
|
const total = ref<MetricPack | null>(null)
|
||||||
|
const visitSlots = ref<number[]>([2, 3, 4])
|
||||||
|
const observedUntil = ref('')
|
||||||
|
/** 下钻只复用已展示报表的查询快照,避免读取正在编辑的筛选。 */
|
||||||
|
const loadedQuery = ref<RevisitRateQuery | null>(null)
|
||||||
|
let overviewRequestId = 0
|
||||||
|
let disposed = false
|
||||||
|
const rangeText = computed(() =>
|
||||||
|
loadedQuery.value
|
||||||
|
? `${loadedQuery.value.start_date} 至 ${loadedQuery.value.end_date}`
|
||||||
|
: `${dateRange.value[0]} 至 ${dateRange.value[1]}`
|
||||||
|
)
|
||||||
|
const currentQuery = (): RevisitRateQuery => ({
|
||||||
|
start_date: dateRange.value[0],
|
||||||
|
end_date: dateRange.value[1],
|
||||||
|
dept_ids: deptId.value ? String(deptId.value) : '',
|
||||||
|
assistant_id: assistantId.value || undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
const deptOptions = ref<DeptOption[]>([])
|
||||||
|
const allAssistants = ref<AssistantOption[]>([])
|
||||||
|
const optionsLoading = ref(false)
|
||||||
|
const optionsError = ref('')
|
||||||
|
const deptTreeProps = { value: 'id', label: 'name', children: 'children' }
|
||||||
|
const deptTreeOptions = computed<DeptTreeNode[]>(() => {
|
||||||
|
const nodes = new Map<number, DeptTreeNode>()
|
||||||
|
deptOptions.value.forEach((item) => nodes.set(item.id, { ...item, children: [] }))
|
||||||
|
const roots: DeptTreeNode[] = []
|
||||||
|
nodes.forEach((node) => {
|
||||||
|
const parent = nodes.get(node.pid)
|
||||||
|
if (parent && parent !== node) parent.children!.push(node)
|
||||||
|
else roots.push(node)
|
||||||
|
})
|
||||||
|
const stripEmptyChildren = (node: DeptTreeNode) => {
|
||||||
|
if (node.children?.length) node.children.forEach(stripEmptyChildren)
|
||||||
|
else delete node.children
|
||||||
|
}
|
||||||
|
roots.forEach(stripEmptyChildren)
|
||||||
|
return roots
|
||||||
|
})
|
||||||
|
const selectedDeptIds = computed(() => {
|
||||||
|
if (!deptId.value) return null
|
||||||
|
const ids = new Set<number>([Number(deptId.value)])
|
||||||
|
let expanded = true
|
||||||
|
while (expanded) {
|
||||||
|
expanded = false
|
||||||
|
deptOptions.value.forEach((item) => {
|
||||||
|
if (ids.has(item.pid) && !ids.has(item.id)) {
|
||||||
|
ids.add(item.id)
|
||||||
|
expanded = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
})
|
||||||
|
const assistantOptions = computed(() =>
|
||||||
|
allAssistants.value.filter(
|
||||||
|
(item) => !selectedDeptIds.value || selectedDeptIds.value.has(item.dept_id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
const handleDeptChange = () => {
|
||||||
|
if (assistantId.value && !assistantOptions.value.some((item) => item.id === assistantId.value))
|
||||||
|
assistantId.value = undefined
|
||||||
|
void loadData()
|
||||||
|
}
|
||||||
|
const loadDeptOptions = async () => {
|
||||||
|
optionsLoading.value = true
|
||||||
|
optionsError.value = ''
|
||||||
|
try {
|
||||||
|
const res = await revisitCohortDeptOptions(props.cohortType)
|
||||||
|
if (disposed) return
|
||||||
|
deptOptions.value = (res?.rows ?? []).map((item: DeptOption) => ({
|
||||||
|
...item,
|
||||||
|
id: Number(item.id),
|
||||||
|
pid: Number(item.pid)
|
||||||
|
}))
|
||||||
|
allAssistants.value = (res?.assistants ?? []).map((item: AssistantOption) => ({
|
||||||
|
...item,
|
||||||
|
id: Number(item.id),
|
||||||
|
dept_id: Number(item.dept_id)
|
||||||
|
}))
|
||||||
|
} catch {
|
||||||
|
if (!disposed) optionsError.value = '部门及医助选项加载失败,请刷新页面重试。'
|
||||||
|
} finally {
|
||||||
|
if (!disposed) optionsLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cnDigits = ['零', '一', '二', '三', '四', '五', '六', '七', '八', '九', '十']
|
||||||
|
const slotLabel = (slot: number) => {
|
||||||
|
if (slot === 0) return '复诊合计'
|
||||||
|
if (slot >= 1 && slot <= 10) return `${cnDigits[slot]}诊`
|
||||||
|
if (slot > 10 && slot < 20) return `十${cnDigits[slot - 10]}诊`
|
||||||
|
return `${slot}诊`
|
||||||
|
}
|
||||||
|
const slotCountTip = (slot: number) =>
|
||||||
|
isInherit.value
|
||||||
|
? `所选继承患者在继承后的第 ${slot - 1} 笔有效订单数,累计至查询当天;不使用继承前全局诊次和偏移`
|
||||||
|
: `所选新指派患者的${slotLabel(slot)}有效订单数,从对应指派后累计至查询当天`
|
||||||
|
const formatRate = (rate: number | null | undefined) =>
|
||||||
|
rate === null || rate === undefined ? '—' : `${rate}%`
|
||||||
|
const denText = (row: Record<string, any>, prefix: string) =>
|
||||||
|
`${Number(row[`${prefix}_count`] ?? 0)} / ${row[`${prefix}_den`] ?? '—'}`
|
||||||
|
const summaryCards = computed<SummaryCard[]>(() => {
|
||||||
|
const t = total.value
|
||||||
|
const cards: SummaryCard[] = [
|
||||||
|
{
|
||||||
|
key: 'cohort',
|
||||||
|
label: cohortLabel.value,
|
||||||
|
value: t ? String(t[cohortCountKey.value] ?? 0) : '—',
|
||||||
|
sub: `按${dateLabel.value}范围选择患者`,
|
||||||
|
clickable: !!(canViewAssignLines.value && t && t[cohortCountKey.value] > 0),
|
||||||
|
onClick: () => void openAssignDetail(null)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
visitSlots.value.forEach((slot) =>
|
||||||
|
cards.push({
|
||||||
|
key: `visit${slot}`,
|
||||||
|
label: `${slotLabel(slot)}接诊率`,
|
||||||
|
value: t ? formatRate(t[`visit${slot}_rate`]) : '—',
|
||||||
|
sub: t
|
||||||
|
? `${slotLabel(slot)}下单 ${t[`visit${slot}_count`] ?? 0} 单 / 分母 ${t[`visit${slot}_den`] ?? '—'}`
|
||||||
|
: '',
|
||||||
|
clickable: !!(canViewOrderLines.value && t && t[`visit${slot}_count`] > 0),
|
||||||
|
onClick: () => void openOrderDetail(slot, null)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
cards.push({
|
||||||
|
key: 'visit_all',
|
||||||
|
label: '复诊合计接诊率',
|
||||||
|
value: t ? formatRate(t.visit_all_rate) : '—',
|
||||||
|
sub: t ? `复诊下单 ${t.visit_all_count ?? 0} 单 / 分母 ${t.visit_all_den ?? '—'}` : '',
|
||||||
|
clickable: !!(canViewOrderLines.value && t && t.visit_all_count > 0),
|
||||||
|
onClick: () => void openOrderDetail(0, null)
|
||||||
|
})
|
||||||
|
return cards
|
||||||
|
})
|
||||||
|
/** 服务端已按诊单去重,合计不能由各医助行相加。 */
|
||||||
|
const getSummaries = ({ columns }: { columns: Array<{ property?: string }> }) =>
|
||||||
|
columns.map((column, index) => {
|
||||||
|
if (index === 0) return '合计'
|
||||||
|
const t = total.value
|
||||||
|
if (!t) return ''
|
||||||
|
const prop = column.property || ''
|
||||||
|
if (prop === cohortCountKey.value) return String(t[prop] ?? 0)
|
||||||
|
const match = /^visit(\d+|_all)_(count|rate)$/.exec(prop)
|
||||||
|
if (!match) return ''
|
||||||
|
return match[2] === 'rate'
|
||||||
|
? `${formatRate(t[prop])}(${denText(t, `visit${match[1]}`)})`
|
||||||
|
: String(t[prop] ?? 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
const loadData = async () => {
|
||||||
|
const requestId = ++overviewRequestId
|
||||||
|
invalidateDetail()
|
||||||
|
detailVisible.value = false
|
||||||
|
loadedQuery.value = null
|
||||||
|
rows.value = []
|
||||||
|
total.value = null
|
||||||
|
observedUntil.value = ''
|
||||||
|
queryError.value = ''
|
||||||
|
loading.value = true
|
||||||
|
const query = currentQuery()
|
||||||
|
try {
|
||||||
|
const res = await revisitCohortOverview(query, props.cohortType)
|
||||||
|
if (disposed || requestId !== overviewRequestId) return
|
||||||
|
rows.value = res?.rows ?? []
|
||||||
|
total.value = res?.total ?? null
|
||||||
|
observedUntil.value = res?.observed_until ?? ''
|
||||||
|
const slots: number[] = Array.isArray(res?.slots) ? res.slots.map(Number) : []
|
||||||
|
visitSlots.value = slots.length
|
||||||
|
? [
|
||||||
|
...new Set(
|
||||||
|
slots.filter((slot) => Number.isInteger(slot) && slot >= 2 && slot <= 50)
|
||||||
|
)
|
||||||
|
].sort((a, b) => a - b)
|
||||||
|
: [2, 3, 4]
|
||||||
|
loadedQuery.value = { ...query }
|
||||||
|
} catch {
|
||||||
|
if (!disposed && requestId === overviewRequestId)
|
||||||
|
queryError.value = '报表加载失败,请重新查询。'
|
||||||
|
} finally {
|
||||||
|
if (!disposed && requestId === overviewRequestId) loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const detailVisible = ref(false)
|
||||||
|
const detailType = ref<'assign' | 'orders'>('assign')
|
||||||
|
const detailTitle = ref('')
|
||||||
|
const detailHint = ref('')
|
||||||
|
const detailLoading = ref(false)
|
||||||
|
const detailError = ref('')
|
||||||
|
const detailRows = ref<any[]>([])
|
||||||
|
const detailCount = ref(0)
|
||||||
|
const detailSlot = ref(2)
|
||||||
|
let detailRequestId = 0
|
||||||
|
const invalidateDetail = () => {
|
||||||
|
++detailRequestId
|
||||||
|
detailLoading.value = false
|
||||||
|
detailRows.value = []
|
||||||
|
detailCount.value = 0
|
||||||
|
detailError.value = ''
|
||||||
|
}
|
||||||
|
const rowScope = (
|
||||||
|
row: StatsRow | null
|
||||||
|
): Pick<RevisitRateDetailQuery, 'scope_assistant_id' | 'dept_id'> & { label: string } => {
|
||||||
|
if (!row) return { label: '合计' }
|
||||||
|
return row.is_dept
|
||||||
|
? { dept_id: row.dept_id, label: row.dept_name }
|
||||||
|
: { scope_assistant_id: row.assistant_id, label: row.assistant_name || '' }
|
||||||
|
}
|
||||||
|
const openDetail = async (type: 'assign' | 'orders', slot: number, row: StatsRow | null) => {
|
||||||
|
if (loading.value || !loadedQuery.value) return
|
||||||
|
if (type === 'assign' ? !canViewAssignLines.value : !canViewOrderLines.value) return
|
||||||
|
const query = { ...loadedQuery.value }
|
||||||
|
const { label, ...scope } = rowScope(row)
|
||||||
|
const requestId = ++detailRequestId
|
||||||
|
detailType.value = type
|
||||||
|
detailSlot.value = slot
|
||||||
|
detailTitle.value = `${label} · ${type === 'assign' ? `${cohortLabel.value}明细` : `${slotLabel(slot)}订单明细`}(${query.start_date} 至 ${query.end_date})`
|
||||||
|
detailHint.value = `${dateLabel.value}:${query.start_date} 至 ${query.end_date};订单统计截至:${observedUntil.value || '查询当天'}${isInherit.value && type === 'orders' ? '。继承后首笔有效订单算二诊,全局诊次仅供参考。' : '。'}`
|
||||||
|
detailRows.value = []
|
||||||
|
detailCount.value = 0
|
||||||
|
detailError.value = ''
|
||||||
|
detailLoading.value = true
|
||||||
|
detailVisible.value = true
|
||||||
|
try {
|
||||||
|
// assistant_id 保留顶层筛选,scope_assistant_id 独立约束医助行。
|
||||||
|
const params: RevisitRateDetailQuery = { ...query, ...scope }
|
||||||
|
const res =
|
||||||
|
type === 'assign'
|
||||||
|
? await revisitCohortAssignLines(params, props.cohortType)
|
||||||
|
: await revisitCohortVisitOrderLines({ ...params, slot }, props.cohortType)
|
||||||
|
if (disposed || requestId !== detailRequestId || !detailVisible.value) return
|
||||||
|
detailRows.value = res?.rows ?? []
|
||||||
|
detailCount.value = Number(res?.count ?? detailRows.value.length)
|
||||||
|
} catch {
|
||||||
|
if (!disposed && requestId === detailRequestId)
|
||||||
|
detailError.value = '明细加载失败,请关闭后重试。'
|
||||||
|
} finally {
|
||||||
|
if (!disposed && requestId === detailRequestId) detailLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const openAssignDetail = (row: StatsRow | null) => openDetail('assign', 0, row)
|
||||||
|
const openOrderDetail = (slot: number, row: StatsRow | null) => openDetail('orders', slot, row)
|
||||||
|
const orderDetailRef = ref<{ open: (id: number) => void } | null>(null)
|
||||||
|
const openOrderInfo = (orderId: number) => {
|
||||||
|
if (canViewOrderDetail && orderId) orderDetailRef.value?.open(orderId)
|
||||||
|
}
|
||||||
|
const diagnosisEditRef = ref<{ openViewOnly: (id: number) => void } | null>(null)
|
||||||
|
const openPatientDetail = (diagnosisId: number) => {
|
||||||
|
if (canViewPatientDetail && diagnosisId) diagnosisEditRef.value?.openViewOnly(diagnosisId)
|
||||||
|
}
|
||||||
|
const handleReset = () => {
|
||||||
|
dateRange.value = defaultDateRange()
|
||||||
|
deptId.value = undefined
|
||||||
|
assistantId.value = undefined
|
||||||
|
void loadData()
|
||||||
|
}
|
||||||
|
onMounted(() => {
|
||||||
|
void loadDeptOptions()
|
||||||
|
void loadData()
|
||||||
|
})
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
disposed = true
|
||||||
|
++overviewRequestId
|
||||||
|
++detailRequestId
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.revisit-rate-page {
|
||||||
|
.rate-filter-form {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px 0;
|
||||||
|
:deep(.el-form-item) {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.rate-caliber-trigger {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
.rate-kpi-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
.rate-kpi-card {
|
||||||
|
background: var(--el-bg-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px 20px;
|
||||||
|
&.is-clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: box-shadow 0.15s ease;
|
||||||
|
&:hover {
|
||||||
|
box-shadow: var(--el-box-shadow-light);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.rate-kpi-label {
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.rate-kpi-value {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 26px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
}
|
||||||
|
.rate-kpi-sub {
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.rate-card-header {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.rate-card-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
.rate-dept-cell {
|
||||||
|
font-weight: 600;
|
||||||
|
.rate-dept-count {
|
||||||
|
margin-left: 6px;
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.rate-th-hint {
|
||||||
|
margin-left: 2px;
|
||||||
|
vertical-align: -2px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
.rate-cell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
line-height: 1.3;
|
||||||
|
.rate-cell-main {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.rate-cell-sub {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--el-text-color-placeholder);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.rate-detail-hint {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
.rate-detail-footer {
|
||||||
|
margin-top: 10px;
|
||||||
|
text-align: right;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
.rate-caliber {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: var(--el-text-color-regular);
|
||||||
|
p + p {
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<template>
|
||||||
|
<cohort-report cohort-type="new" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts" name="revisitAssignRateStatsPage">
|
||||||
|
import CohortReport from './components/CohortReport.vue'
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,863 @@
|
|||||||
|
<template>
|
||||||
|
<div class="revisit-due-page">
|
||||||
|
<el-card class="!border-none" shadow="never">
|
||||||
|
<el-form :inline="true" class="due-filter-form">
|
||||||
|
<el-form-item label="医助">
|
||||||
|
<el-select
|
||||||
|
v-model="queryParams.assistant_id"
|
||||||
|
clearable
|
||||||
|
filterable
|
||||||
|
placeholder="全部"
|
||||||
|
class="w-[160px]"
|
||||||
|
@change="doSearch"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="item in assistantOptions"
|
||||||
|
:key="item.id"
|
||||||
|
:label="item.name"
|
||||||
|
:value="Number(item.id)"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="部门">
|
||||||
|
<el-tree-select
|
||||||
|
v-model="queryParams.dept_id"
|
||||||
|
:data="deptTreeOptions"
|
||||||
|
placeholder="全部(二中心)"
|
||||||
|
clearable
|
||||||
|
filterable
|
||||||
|
check-strictly
|
||||||
|
node-key="id"
|
||||||
|
:default-expand-all="true"
|
||||||
|
:props="deptTreeProps"
|
||||||
|
class="w-[200px]"
|
||||||
|
@change="doSearch"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="状态">
|
||||||
|
<el-select
|
||||||
|
v-model="queryParams.state"
|
||||||
|
clearable
|
||||||
|
placeholder="全部"
|
||||||
|
class="w-[170px]"
|
||||||
|
@change="doSearch"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="opt in stateOptions"
|
||||||
|
:key="opt.value"
|
||||||
|
:label="opt.label"
|
||||||
|
:value="opt.value"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="开药天数">
|
||||||
|
<el-select
|
||||||
|
v-model="queryParams.days_bucket"
|
||||||
|
clearable
|
||||||
|
placeholder="全部"
|
||||||
|
class="w-[120px]"
|
||||||
|
@change="doSearch"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="opt in REVISIT_DAYS_BUCKET_OPTIONS"
|
||||||
|
:key="opt.value"
|
||||||
|
:label="opt.label"
|
||||||
|
:value="opt.value"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="延迟次数 ≥">
|
||||||
|
<el-input-number
|
||||||
|
v-model="minSnooze"
|
||||||
|
:min="0"
|
||||||
|
:max="999"
|
||||||
|
:step="1"
|
||||||
|
step-strictly
|
||||||
|
controls-position="right"
|
||||||
|
placeholder="不限"
|
||||||
|
class="!w-[120px]"
|
||||||
|
@change="onMinSnoozeChange"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="患者">
|
||||||
|
<el-input
|
||||||
|
v-model="queryParams.keyword"
|
||||||
|
placeholder="患者姓名"
|
||||||
|
clearable
|
||||||
|
class="w-[160px]"
|
||||||
|
@keyup.enter="doSearch"
|
||||||
|
@clear="doSearch"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" @click="doSearch">查询</el-button>
|
||||||
|
<el-button @click="handleReset">重置</el-button>
|
||||||
|
<export-data
|
||||||
|
class="ml-2.5"
|
||||||
|
:fetch-fun="revisitDueLists"
|
||||||
|
:params="queryParams"
|
||||||
|
:page-size="pager.size"
|
||||||
|
export-hint="导出范围与当前筛选一致(医助 / 部门 / 状态 / 开药天数 / 延迟次数 / 患者)"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<div class="due-hint">
|
||||||
|
每位患者(诊单)一行,取最新有效业务订单为本轮周期:预计复诊日 = 用药起算日(签收 → 发货 → 下单)+ 开药天数;
|
||||||
|
到期仍无有效挂号 / 过号未面诊的行标红,持有医助登录后台会收到右下角弹窗。
|
||||||
|
「延迟次数」为医助在弹窗上点「延迟」的累计次数(越多说明越未跟进),「已联系」为填写患者承诺复诊日的次数,
|
||||||
|
「人工关闭」由医助填原因关闭、在此标记供主管复核。数据范围按角色数据权限收窄;状态按今天实时推导。
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-card class="!border-none mt-3" shadow="never">
|
||||||
|
<el-table
|
||||||
|
:data="pager.lists"
|
||||||
|
v-loading="pager.loading"
|
||||||
|
size="default"
|
||||||
|
stripe
|
||||||
|
row-key="id"
|
||||||
|
:row-class-name="rowClassName"
|
||||||
|
>
|
||||||
|
<el-table-column label="患者" min-width="120" fixed="left">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="cell-main">{{ row.patient_name || '—' }}</div>
|
||||||
|
<div class="cell-sub">诊单 #{{ row.diagnosis_id }}</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="医助" min-width="100">
|
||||||
|
<template #default="{ row }">{{ assistantText(row) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="部门" min-width="110" show-overflow-tooltip>
|
||||||
|
<template #default="{ row }">{{ row.dept_name || '—' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="诊次" width="110" align="center">
|
||||||
|
<template #header>
|
||||||
|
<span>诊次</span>
|
||||||
|
<el-tooltip
|
||||||
|
placement="top"
|
||||||
|
effect="dark"
|
||||||
|
content="患者当前已完成诊次 = 有效业务订单数 + 诊单诊次偏移,与复诊接诊率同口径;下一行为待复诊诊次"
|
||||||
|
>
|
||||||
|
<el-icon class="col-hint"><InfoFilled /></el-icon>
|
||||||
|
</el-tooltip>
|
||||||
|
</template>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="cell-main">{{ slotText(row) }}</div>
|
||||||
|
<div v-if="Number(row.next_slot) > 0" class="cell-sub">
|
||||||
|
待复诊第 {{ row.next_slot }} 诊
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="开药天数" width="110" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<template v-if="Number(row.medication_days) > 0">
|
||||||
|
<span>{{ row.medication_days }} 天</span>
|
||||||
|
<el-tag
|
||||||
|
v-if="Number(row.rule_days) > 0"
|
||||||
|
size="small"
|
||||||
|
type="info"
|
||||||
|
effect="plain"
|
||||||
|
class="ml-1"
|
||||||
|
>
|
||||||
|
{{ row.rule_days }} 档
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
<span v-else class="text-missing">缺开药天数</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="预计复诊" width="110">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="cell-main">{{ row.due_date || '—' }}</div>
|
||||||
|
<div v-if="row.anchor_date" class="cell-sub">
|
||||||
|
起算 {{ formatRevisitMonthDay(row.anchor_date) }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="超期天数" width="100" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span :class="['overdue-cell', overdueCell(row).cls]">
|
||||||
|
{{ overdueCell(row).text }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="状态" min-width="150">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag
|
||||||
|
:type="revisitStateTagType(row.state)"
|
||||||
|
:effect="revisitStateTagEffect(row.state)"
|
||||||
|
size="small"
|
||||||
|
:class="revisitStateChipClass(dueOf(row))"
|
||||||
|
>
|
||||||
|
{{ chipText(row) }}
|
||||||
|
</el-tag>
|
||||||
|
<el-tag
|
||||||
|
v-if="Number(row.closed_state) === 4"
|
||||||
|
size="small"
|
||||||
|
type="info"
|
||||||
|
effect="plain"
|
||||||
|
class="ml-1"
|
||||||
|
>
|
||||||
|
人工关闭
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="延迟次数" width="100" align="center">
|
||||||
|
<template #header>
|
||||||
|
<span>延迟次数</span>
|
||||||
|
<el-tooltip
|
||||||
|
placement="top"
|
||||||
|
effect="dark"
|
||||||
|
content="医助在到期弹窗上点「延迟」的累计次数;1~2 次 L1、3~4 次 L2、≥5 次 L3(居中强制模态)。换医助后清零"
|
||||||
|
>
|
||||||
|
<el-icon class="col-hint"><InfoFilled /></el-icon>
|
||||||
|
</el-tooltip>
|
||||||
|
</template>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span :class="['snooze-cell', snoozeClass(row)]">
|
||||||
|
{{ Number(row.snooze_count ?? 0) }}
|
||||||
|
</span>
|
||||||
|
<div v-if="Number(row.escalation_level) > 0" class="cell-sub">
|
||||||
|
等级 L{{ row.escalation_level }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="已联系" width="80" align="center">
|
||||||
|
<template #default="{ row }">{{ Number(row.contact_count ?? 0) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="承诺日" width="100">
|
||||||
|
<template #default="{ row }">{{ row.promised_date || '—' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="过号" width="70" align="center">
|
||||||
|
<template #default="{ row }">{{ Number(row.missed_count ?? 0) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="关闭原因" min-width="170" show-overflow-tooltip>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<template v-if="Number(row.closed_state) === 4">
|
||||||
|
<span>{{ handleReasonText(row.handle_reason) }}</span>
|
||||||
|
<span v-if="row.handle_remark" class="cell-sub">
|
||||||
|
:{{ row.handle_remark }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<span v-else-if="Number(row.closed_state) > 0" class="cell-sub">
|
||||||
|
{{ row.closed_state_text || '—' }}
|
||||||
|
</span>
|
||||||
|
<span v-else>—</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="130" fixed="right" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button
|
||||||
|
v-perms="['tcm.revisitDue/logs']"
|
||||||
|
link
|
||||||
|
type="primary"
|
||||||
|
@click="openLogs(row)"
|
||||||
|
>
|
||||||
|
流水
|
||||||
|
</el-button>
|
||||||
|
<el-button link type="primary" @click="goDiagnosis(row)">去诊单</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<div class="flex justify-end mt-4">
|
||||||
|
<pagination v-model="pager" @change="getLists" />
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-drawer
|
||||||
|
v-model="logVisible"
|
||||||
|
:title="logTitle"
|
||||||
|
direction="rtl"
|
||||||
|
size="540px"
|
||||||
|
destroy-on-close
|
||||||
|
>
|
||||||
|
<div v-loading="logLoading" class="log-drawer-body">
|
||||||
|
<div v-if="logRow" class="log-summary">
|
||||||
|
<div class="log-summary-row">
|
||||||
|
<span>医助:{{ assistantText(logRow) }}</span>
|
||||||
|
<span>{{ slotText(logRow) }}</span>
|
||||||
|
<span v-if="logRow.due_date">预计复诊 {{ logRow.due_date }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="log-summary-row">
|
||||||
|
<el-tag
|
||||||
|
:type="revisitStateTagType(logRow.state)"
|
||||||
|
:effect="revisitStateTagEffect(logRow.state)"
|
||||||
|
size="small"
|
||||||
|
:class="revisitStateChipClass(dueOf(logRow))"
|
||||||
|
>
|
||||||
|
{{ chipText(logRow) }}
|
||||||
|
</el-tag>
|
||||||
|
<span class="cell-sub">{{ followCountText(logRow) }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="hasShownMeta(logRow)" class="log-summary-row cell-sub">
|
||||||
|
<span v-if="logRow.first_shown_at_text">
|
||||||
|
首次弹出 {{ logRow.first_shown_at_text }}
|
||||||
|
</span>
|
||||||
|
<span v-if="logRow.last_snoozed_at_text">
|
||||||
|
最近延迟 {{ logRow.last_snoozed_at_text }}
|
||||||
|
</span>
|
||||||
|
<span v-if="logRow.computed_at_text">
|
||||||
|
重算于 {{ logRow.computed_at_text }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-timeline v-if="logRows.length" class="mt-3">
|
||||||
|
<el-timeline-item
|
||||||
|
v-for="item in logRows"
|
||||||
|
:key="item.id"
|
||||||
|
:timestamp="item.create_time_text"
|
||||||
|
placement="top"
|
||||||
|
:type="eventTimelineType(item.event)"
|
||||||
|
:hollow="!isUserEvent(item.event)"
|
||||||
|
>
|
||||||
|
<div class="log-event">
|
||||||
|
<span class="log-event-title">
|
||||||
|
{{ item.event_text || eventLabel(item.event) }}
|
||||||
|
</span>
|
||||||
|
<span class="log-event-operator">{{ operatorText(item) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="log-event-meta">
|
||||||
|
<span>延迟 {{ Number(item.snooze_count_after ?? 0) }} 次</span>
|
||||||
|
<span>等级 L{{ Number(item.level_after ?? 0) }}</span>
|
||||||
|
<span>{{ logOverdueText(item.overdue_days) }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="payloadEntries(item).length" class="log-event-payload">
|
||||||
|
<span
|
||||||
|
v-for="p in payloadEntries(item)"
|
||||||
|
:key="p.key"
|
||||||
|
class="log-payload-chip"
|
||||||
|
>
|
||||||
|
{{ p.label }}:{{ p.value }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</el-timeline-item>
|
||||||
|
</el-timeline>
|
||||||
|
<el-empty v-else-if="!logLoading" description="暂无流水" :image-size="80" />
|
||||||
|
</div>
|
||||||
|
</el-drawer>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts" name="statsRevisitDue">
|
||||||
|
import { InfoFilled } from '@element-plus/icons-vue'
|
||||||
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import {
|
||||||
|
revisitDueLists,
|
||||||
|
revisitDueLogs,
|
||||||
|
type RevisitDueListRow,
|
||||||
|
type RevisitDueListsParams,
|
||||||
|
type RevisitDueLogRow
|
||||||
|
} from '@/api/revisitDue'
|
||||||
|
import { revisitRateDeptOptions } from '@/api/stats'
|
||||||
|
import { getAssistants } from '@/api/tcm'
|
||||||
|
import { usePaging } from '@/hooks/usePaging'
|
||||||
|
import {
|
||||||
|
REVISIT_DAYS_BUCKET_OPTIONS,
|
||||||
|
REVISIT_STATE_FILTER_OPTIONS,
|
||||||
|
formatRevisitMonthDay,
|
||||||
|
normalizeRevisitDue,
|
||||||
|
revisitRowClassName,
|
||||||
|
revisitStateChipClass,
|
||||||
|
revisitStateChipText,
|
||||||
|
revisitStateTagEffect,
|
||||||
|
revisitStateTagType,
|
||||||
|
type RevisitDue
|
||||||
|
} from '@/utils/revisitDue'
|
||||||
|
|
||||||
|
interface DeptTreeNode {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
pid: number
|
||||||
|
children?: DeptTreeNode[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
/** 状态筛选:诊单列表的 7 个状态 + 主管视图专有「人工关闭」 */
|
||||||
|
const stateOptions = [...REVISIT_STATE_FILTER_OPTIONS, { label: '人工关闭', value: 'closed' }]
|
||||||
|
|
||||||
|
const assistantOptions = ref<Array<{ id: number; name: string }>>([])
|
||||||
|
const deptTreeOptions = ref<DeptTreeNode[]>([])
|
||||||
|
const deptTreeProps = { value: 'id', label: 'name', children: 'children' }
|
||||||
|
|
||||||
|
const queryParams = reactive<RevisitDueListsParams>({
|
||||||
|
assistant_id: '',
|
||||||
|
dept_id: '',
|
||||||
|
state: '',
|
||||||
|
days_bucket: '',
|
||||||
|
min_snooze: '',
|
||||||
|
keyword: ''
|
||||||
|
})
|
||||||
|
/** el-input-number 清空时为 null/undefined,单独持有后再写回 queryParams 的 '' */
|
||||||
|
const minSnooze = ref<number | undefined>(undefined)
|
||||||
|
|
||||||
|
const { pager, getLists, resetPage } = usePaging({
|
||||||
|
fetchFun: revisitDueLists,
|
||||||
|
params: queryParams
|
||||||
|
})
|
||||||
|
|
||||||
|
const doSearch = () => resetPage()
|
||||||
|
|
||||||
|
const onMinSnoozeChange = (val: number | undefined | null) => {
|
||||||
|
queryParams.min_snooze = val === null || val === undefined ? '' : Number(val)
|
||||||
|
doSearch()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
queryParams.assistant_id = ''
|
||||||
|
queryParams.dept_id = ''
|
||||||
|
queryParams.state = ''
|
||||||
|
queryParams.days_bucket = ''
|
||||||
|
queryParams.min_snooze = ''
|
||||||
|
queryParams.keyword = ''
|
||||||
|
minSnooze.value = undefined
|
||||||
|
doSearch()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 单元格 ──────────────────────────────────────────
|
||||||
|
/** 列表行 → 复诊节点对象(复用 utils/revisitDue 的 chip 映射;缺失字段按默认值补齐) */
|
||||||
|
const dueOf = (row: RevisitDueListRow | Record<string, any>): RevisitDue | null =>
|
||||||
|
normalizeRevisitDue(row)
|
||||||
|
|
||||||
|
const rowClassName = ({ row }: { row: RevisitDueListRow }) => revisitRowClassName(dueOf(row))
|
||||||
|
|
||||||
|
const assistantText = (row: RevisitDueListRow) =>
|
||||||
|
row.assistant_name || (Number(row.assistant_id) > 0 ? `#${row.assistant_id}` : '—')
|
||||||
|
|
||||||
|
const slotText = (row: RevisitDueListRow) => {
|
||||||
|
const slot = Number(row.current_slot ?? 0)
|
||||||
|
return slot > 0 ? `第 ${slot} 诊` : '未成交'
|
||||||
|
}
|
||||||
|
|
||||||
|
const chipText = (row: RevisitDueListRow) =>
|
||||||
|
revisitStateChipText(dueOf(row)) || row.state_text || '—'
|
||||||
|
|
||||||
|
/** 抽屉摘要:「延迟 N 次 · 已联系 N 次 · 过号 N 次」 */
|
||||||
|
const followCountText = (row: RevisitDueListRow) => {
|
||||||
|
const snooze = Number(row.snooze_count ?? 0)
|
||||||
|
const contact = Number(row.contact_count ?? 0)
|
||||||
|
const missed = Number(row.missed_count ?? 0)
|
||||||
|
return `延迟 ${snooze} 次 · 已联系 ${contact} 次 · 过号 ${missed} 次`
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasShownMeta = (row: RevisitDueListRow) =>
|
||||||
|
!!(row.first_shown_at_text || row.last_snoozed_at_text || row.computed_at_text)
|
||||||
|
|
||||||
|
const toNullableNumber = (v: unknown): number | null => {
|
||||||
|
if (v === null || v === undefined || v === '') return null
|
||||||
|
const n = Number(v)
|
||||||
|
return Number.isFinite(n) ? n : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 超期天数列:到期/过号红字(深红 = severe),其它状态显示剩余天数或历史超期 */
|
||||||
|
const overdueCell = (row: RevisitDueListRow): { text: string; cls: string } => {
|
||||||
|
const od = toNullableNumber(row.overdue_days)
|
||||||
|
const dl = toNullableNumber(row.days_left)
|
||||||
|
const isDue = row.state === 'overdue' || row.state === 'missed'
|
||||||
|
if (isDue && od !== null && od >= 0) {
|
||||||
|
return {
|
||||||
|
text: od === 0 ? '今日到期' : `${od} 天`,
|
||||||
|
cls: Number(row.severe) === 1 ? 'is-severe' : 'is-overdue'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (od !== null && od > 0) {
|
||||||
|
return { text: `${od} 天`, cls: 'is-muted' }
|
||||||
|
}
|
||||||
|
if (dl !== null && dl >= 0) {
|
||||||
|
return { text: `剩 ${dl} 天`, cls: 'is-muted' }
|
||||||
|
}
|
||||||
|
return { text: '—', cls: '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const snoozeClass = (row: RevisitDueListRow) => {
|
||||||
|
const level = Number(row.escalation_level ?? 0)
|
||||||
|
if (level >= 3) return 'is-level-3'
|
||||||
|
if (level === 2) return 'is-level-2'
|
||||||
|
if (level === 1) return 'is-level-1'
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleReasonText = (reason: string | null | undefined) => {
|
||||||
|
const key = String(reason ?? '').trim()
|
||||||
|
if (key === 'lost') return '患者流失'
|
||||||
|
if (key === 'other') return '其它'
|
||||||
|
return key || '人工关闭'
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 流水抽屉 ──────────────────────────────────────────
|
||||||
|
const logVisible = ref(false)
|
||||||
|
const logLoading = ref(false)
|
||||||
|
const logRow = ref<RevisitDueListRow | null>(null)
|
||||||
|
const logRows = ref<RevisitDueLogRow[]>([])
|
||||||
|
|
||||||
|
const logTitle = computed(() =>
|
||||||
|
logRow.value ? `${logRow.value.patient_name || '患者'} · 复诊到期流水` : '复诊到期流水'
|
||||||
|
)
|
||||||
|
|
||||||
|
const EVENT_LABEL: Record<number, string> = {
|
||||||
|
1: '延迟',
|
||||||
|
2: '已联系',
|
||||||
|
3: '人工关闭',
|
||||||
|
4: '自动关闭 · 已挂号',
|
||||||
|
5: '自动关闭 · 已面诊',
|
||||||
|
6: '周期重置(新订单)',
|
||||||
|
7: '重新打开(挂号取消 / 过号)',
|
||||||
|
8: '企微推送',
|
||||||
|
9: '医助变更',
|
||||||
|
10: '形态升级',
|
||||||
|
11: '首次弹出'
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventLabel = (event: number) => EVENT_LABEL[Number(event)] ?? `事件 ${event}`
|
||||||
|
|
||||||
|
/** 1 延迟 / 2 已联系 / 3 人工关闭 由医助操作,实心节点;其余系统事件空心 */
|
||||||
|
const isUserEvent = (event: number) => [1, 2, 3].includes(Number(event))
|
||||||
|
|
||||||
|
type TimelineType = 'primary' | 'success' | 'warning' | 'danger' | 'info'
|
||||||
|
|
||||||
|
const eventTimelineType = (event: number): TimelineType => {
|
||||||
|
switch (Number(event)) {
|
||||||
|
case 1:
|
||||||
|
return 'warning'
|
||||||
|
case 2:
|
||||||
|
case 6:
|
||||||
|
return 'primary'
|
||||||
|
case 4:
|
||||||
|
case 5:
|
||||||
|
return 'success'
|
||||||
|
case 7:
|
||||||
|
case 10:
|
||||||
|
return 'danger'
|
||||||
|
default:
|
||||||
|
return 'info'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const operatorText = (item: RevisitDueLogRow) => {
|
||||||
|
if (item.operator_name) return item.operator_name
|
||||||
|
return Number(item.operator_id) > 0 ? `#${item.operator_id}` : '系统'
|
||||||
|
}
|
||||||
|
|
||||||
|
const logOverdueText = (days: number | null | undefined) => {
|
||||||
|
const n = toNullableNumber(days)
|
||||||
|
if (n === null) return ''
|
||||||
|
if (n > 0) return `超期 ${n} 天`
|
||||||
|
if (n === 0) return '到期当天'
|
||||||
|
return `未到期(剩 ${-n} 天)`
|
||||||
|
}
|
||||||
|
|
||||||
|
const PAYLOAD_LABEL: Record<string, string> = {
|
||||||
|
next_remind_at: '下次提醒',
|
||||||
|
next_remind_at_text: '下次提醒',
|
||||||
|
promised_date: '承诺日',
|
||||||
|
appointment_id: '挂号单',
|
||||||
|
appointment_date: '预约日',
|
||||||
|
order_id: '订单',
|
||||||
|
source_order_id: '周期订单',
|
||||||
|
remark: '备注',
|
||||||
|
reason: '原因',
|
||||||
|
from_assistant: '原医助',
|
||||||
|
from_assistant_id: '原医助',
|
||||||
|
to_assistant: '新医助',
|
||||||
|
to_assistant_id: '新医助',
|
||||||
|
interval: '间隔(秒)',
|
||||||
|
level: '等级',
|
||||||
|
channel: '渠道',
|
||||||
|
message: '说明'
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatPayloadValue = (key: string, value: unknown): string => {
|
||||||
|
if (value === null || value === undefined || value === '') return ''
|
||||||
|
if (typeof value === 'object') return JSON.stringify(value)
|
||||||
|
// 秒级时间戳(next_remind_at 等)转本地时间
|
||||||
|
if (/_at$/.test(key) && typeof value === 'number' && value > 1e9) {
|
||||||
|
const d = new Date(value * 1000)
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0')
|
||||||
|
const date = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
||||||
|
return `${date} ${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||||
|
}
|
||||||
|
return String(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PayloadEntry {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** payload_arr → [{key,label,value}],有 *_text 版本时隐藏对应原始时间戳 */
|
||||||
|
const payloadEntries = (item: RevisitDueLogRow): PayloadEntry[] => {
|
||||||
|
const payload = item.payload_arr
|
||||||
|
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return []
|
||||||
|
const out: PayloadEntry[] = []
|
||||||
|
for (const [key, raw] of Object.entries(payload)) {
|
||||||
|
if (`${key}_text` in payload) continue
|
||||||
|
const value = formatPayloadValue(key, raw)
|
||||||
|
if (value === '') continue
|
||||||
|
out.push({ key, label: PAYLOAD_LABEL[key] ?? key, value })
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
const openLogs = async (row: RevisitDueListRow) => {
|
||||||
|
logRow.value = row
|
||||||
|
logVisible.value = true
|
||||||
|
logLoading.value = true
|
||||||
|
logRows.value = []
|
||||||
|
try {
|
||||||
|
// 契约「返回 list」:兼容直接返回数组或 { list } / { lists } 包络
|
||||||
|
const res: any = await revisitDueLogs({ due_id: Number(row.id) })
|
||||||
|
const list = Array.isArray(res) ? res : (res?.list ?? res?.lists ?? [])
|
||||||
|
logRows.value = Array.isArray(list) ? list : []
|
||||||
|
} finally {
|
||||||
|
logLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const goDiagnosis = (row: RevisitDueListRow) => {
|
||||||
|
const id = Number(row.diagnosis_id ?? 0)
|
||||||
|
if (id <= 0) return
|
||||||
|
router.push({ path: '/tcm/diagnosis', query: { id: String(id) } })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 下拉数据 ──────────────────────────────────────────
|
||||||
|
const buildDeptTree = (flat: Array<{ id: number; pid: number; name: string }>): DeptTreeNode[] => {
|
||||||
|
const map = new Map<number, DeptTreeNode>()
|
||||||
|
for (const d of flat) {
|
||||||
|
map.set(Number(d.id), { id: Number(d.id), pid: Number(d.pid), name: d.name, children: [] })
|
||||||
|
}
|
||||||
|
const roots: DeptTreeNode[] = []
|
||||||
|
for (const d of flat) {
|
||||||
|
const node = map.get(Number(d.id))!
|
||||||
|
if (Number(d.pid) > 0 && map.has(Number(d.pid))) {
|
||||||
|
map.get(Number(d.pid))!.children!.push(node)
|
||||||
|
} else {
|
||||||
|
roots.push(node)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const strip = (n: DeptTreeNode) => {
|
||||||
|
if (n.children?.length) {
|
||||||
|
n.children.forEach(strip)
|
||||||
|
} else {
|
||||||
|
delete n.children
|
||||||
|
}
|
||||||
|
}
|
||||||
|
roots.forEach(strip)
|
||||||
|
return roots
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadOptions = async () => {
|
||||||
|
const [assistants, depts] = await Promise.allSettled([
|
||||||
|
getAssistants(),
|
||||||
|
revisitRateDeptOptions()
|
||||||
|
])
|
||||||
|
if (assistants.status === 'fulfilled') {
|
||||||
|
assistantOptions.value = Array.isArray(assistants.value) ? assistants.value : []
|
||||||
|
}
|
||||||
|
if (depts.status === 'fulfilled') {
|
||||||
|
deptTreeOptions.value = buildDeptTree(depts.value?.rows ?? [])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// 下拉与列表并行加载,不阻塞首屏
|
||||||
|
void loadOptions()
|
||||||
|
getLists()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.revisit-due-page {
|
||||||
|
.due-filter-form {
|
||||||
|
:deep(.el-form-item) {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.due-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-main {
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-sub {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-hint {
|
||||||
|
margin-left: 2px;
|
||||||
|
vertical-align: -2px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-missing {
|
||||||
|
color: #ea580c;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overdue-cell.is-overdue {
|
||||||
|
color: var(--el-color-danger);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overdue-cell.is-severe {
|
||||||
|
color: #b91c1c;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overdue-cell.is-muted {
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snooze-cell {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snooze-cell.is-level-1 {
|
||||||
|
color: #ea580c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snooze-cell.is-level-2 {
|
||||||
|
color: var(--el-color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.snooze-cell.is-level-3 {
|
||||||
|
color: #b91c1c;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 行级标红:与诊单列表一致(utils/revisitDue.ts revisitRowClassName) */
|
||||||
|
:deep(.el-table__row.row-revisit-overdue) {
|
||||||
|
border-left: 3px solid var(--el-color-danger);
|
||||||
|
background: linear-gradient(90deg, rgba(var(--el-color-danger-rgb), 0.06) 0%, transparent 100%) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-table__row.row-revisit-overdue:hover > td) {
|
||||||
|
background: linear-gradient(90deg, rgba(var(--el-color-danger-rgb), 0.1) 0%, transparent 100%) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-table__row.row-revisit-overdue.row-revisit-overdue-deep) {
|
||||||
|
border-left-color: #b91c1c;
|
||||||
|
background: linear-gradient(90deg, rgba(var(--el-color-danger-rgb), 0.1) 0%, transparent 100%) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-table__row.row-revisit-overdue.row-revisit-overdue-deep:hover > td) {
|
||||||
|
background: linear-gradient(90deg, rgba(var(--el-color-danger-rgb), 0.14) 0%, transparent 100%) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 状态 chip 配色:与 RevisitNodeCell 一致 */
|
||||||
|
:deep(.rv-chip) {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.rv-chip--overdue),
|
||||||
|
:deep(.rv-chip--missed) {
|
||||||
|
--el-tag-bg-color: #dc2626;
|
||||||
|
--el-tag-border-color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.rv-chip--severe) {
|
||||||
|
font-weight: 700;
|
||||||
|
--el-tag-bg-color: #b91c1c;
|
||||||
|
--el-tag-border-color: #b91c1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.rv-chip--warning) {
|
||||||
|
--el-tag-text-color: #ea580c;
|
||||||
|
--el-tag-bg-color: rgba(234, 88, 12, 0.1);
|
||||||
|
--el-tag-border-color: rgba(234, 88, 12, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.rv-chip--no_days) {
|
||||||
|
--el-tag-text-color: #ea580c;
|
||||||
|
--el-tag-border-color: #ea580c;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.rv-chip--visited) {
|
||||||
|
--el-tag-text-color: #16a34a;
|
||||||
|
--el-tag-bg-color: rgba(22, 163, 74, 0.1);
|
||||||
|
--el-tag-border-color: rgba(22, 163, 74, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.rv-chip--booked) {
|
||||||
|
--el-tag-text-color: #4a5dff;
|
||||||
|
--el-tag-bg-color: rgba(74, 93, 255, 0.1);
|
||||||
|
--el-tag-border-color: rgba(74, 93, 255, 0.3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-drawer-body {
|
||||||
|
min-height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-summary {
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--el-fill-color-light);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-summary-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-event {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-event-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-event-operator {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-event-meta {
|
||||||
|
margin-top: 4px;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-event-payload {
|
||||||
|
margin-top: 6px;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-payload-chip {
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 20px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--el-fill-color);
|
||||||
|
color: var(--el-text-color-regular);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<template>
|
||||||
|
<cohort-report cohort-type="inherit" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts" name="revisitInheritRateStatsPage">
|
||||||
|
import CohortReport from '../revisit_assign_rate/components/CohortReport.vue'
|
||||||
|
</script>
|
||||||
@@ -29,12 +29,18 @@
|
|||||||
@change="loadData"
|
@change="loadData"
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<el-form-item label="口径">
|
||||||
|
<el-radio-group v-model="inheritMode" size="small" @change="loadData">
|
||||||
|
<el-radio-button value="exclude">原口径(继承不计分母)</el-radio-button>
|
||||||
|
<el-radio-button value="slot">新口径(继承按继承时诊次计入)</el-radio-button>
|
||||||
|
</el-radio-group>
|
||||||
|
</el-form-item>
|
||||||
<el-form-item>
|
<el-form-item>
|
||||||
<el-button type="primary" :loading="loading" @click="loadData">查询</el-button>
|
<el-button type="primary" :loading="loading" @click="loadData">查询</el-button>
|
||||||
<el-button :disabled="loading" @click="handleReset">重置</el-button>
|
<el-button :disabled="loading" @click="handleReset">重置</el-button>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item>
|
<el-form-item>
|
||||||
<el-popover placement="bottom-start" :width="500" trigger="hover">
|
<el-popover placement="bottom-start" :width="560" trigger="hover">
|
||||||
<template #reference>
|
<template #reference>
|
||||||
<span class="rate-caliber-trigger">
|
<span class="rate-caliber-trigger">
|
||||||
<el-icon><InfoFilled /></el-icon>
|
<el-icon><InfoFilled /></el-icon>
|
||||||
@@ -43,16 +49,29 @@
|
|||||||
</template>
|
</template>
|
||||||
<div class="rate-caliber">
|
<div class="rate-caliber">
|
||||||
<p>
|
<p>
|
||||||
<b>当月被指派总数</b>:当月内诊单被指派给医助(按指派操作时间落月,<b>剔除勾选「继承」的指派</b>)的诊单数,按「医助 × 诊单」去重;部门行 / 合计行按诊单去重;<b>再剔除</b>名下存在履约「拒收 / 退款」业务订单的诊单。
|
<b>{{ isSlotMode ? '新指派数' : '当月被指派数' }}</b>:当月内诊单被指派给医助(按指派操作时间落月,<b>不含勾选「继承」的指派</b>)的诊单数,按「医助 × 诊单」去重;部门行 / 合计行按诊单去重;<b>再剔除</b>名下存在履约「拒收 / 退款」业务订单的诊单。与业绩看板「被指派数」同口径。
|
||||||
|
</p>
|
||||||
|
<template v-if="isSlotMode">
|
||||||
|
<p>
|
||||||
|
<b>继承数</b>:当月勾选「继承」转接给该医助的诊单数(同一诊单多次继承取最早;同月既新指派又继承给同一医助只算新指派;历史「回退自动分配」行按继承处理;指派给自己的记录忽略)。<b>继承时诊次 k</b> = 继承时刻之前患者已完成的诊次(有效实单数 + 偏移),<b>起算诊次 = max(2, k+1)</b>:继承者只对第 k+1 诊及以后负责。
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<b>N 诊分母</b> = 新指派数 + 起算诊次 ≤ N 的继承数(每列独立);<b>N 诊接诊率</b> = N 诊单数 ÷ N 诊分母,分母为 0 显示「—」。<b>复诊合计</b>:单数 = 各诊次单数之和,分母 = 新指派 ∪ 继承(被指派合计),比率可超过 100%(人均复诊成交单数)。
|
||||||
|
</p>
|
||||||
|
<p class="rate-caliber-note">
|
||||||
|
继承分母依赖指派记录的「继承」标记(2026-06 起写入),之前的转接无法回溯;切换为「旧口径」可对照上线前数字。
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<p>
|
||||||
|
<b>旧口径</b>:继承指派不计入分母,N 诊分母 = 新指派数(对所有 N 相同),新指派数 / N 诊单数 / N 诊接诊率与 2026-10 之前完全一致;继承数恒为 0。切换为「新口径」后继承按继承时诊次计入每列独立分母。
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
<p>
|
||||||
|
<b>诊次(第 N 次下单)</b>:患者(诊单)名下计入业绩的业务订单(剔除已取消 / 拒收 / 退款)按下单时间升序编号为「实单序号」,<b>统计诊次 = 实单序号 + 诊单偏移</b>(默认偏移 0 → 第 1 笔实单为一诊)。诊次<b>跨月累计不重置</b>。诊单可在「业务订单」tab 配置偏移量。
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<b>诊次(第 N 次下单)</b>:患者(诊单)名下计入业绩的业务订单(剔除已取消 / 拒收 / 退款)按下单时间升序编号为「实单序号」,<b>统计诊次 = 实单序号 + 诊单偏移</b>(默认偏移 0 → 第 1 笔实单为一诊;偏移 1 → 第 1 笔实单为二诊;偏移 2 → 第 1 笔实单为三诊,5 笔实单等价七诊)。诊次<b>跨月累计不重置</b>。诊单可在「业务订单」tab 配置偏移量。
|
<b>当月 N 诊单数</b>:当月内下单且诊次为 N 的订单数,归属下单时点<b>持有该患者的医助</b>(指派可在往月;释放后不再归属;继承会转移持有人)。往月指派、当月成交会推高分子,比率可能超过 100%。
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<b>当月 N 诊单数</b>:当月内下单且诊次为 N 的订单数,归属下单时点<b>持有该患者的医助</b>(指派可在往月;释放后不再归属;「继承」指派会转移持有人但不计被指派数)。
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<b>当月 N 诊接诊率</b> = 当月 N 诊单数 ÷ 当月被指派总数。往月指派、当月成交会推高分子,比率可能超过 100%;医助当月无新指派但旗下有成交时,被指派数为 0、比率显示「—」。
|
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
医助按人事部门归组;<b>仅统计「二中心」及其组织下级</b>;部门下拉与未选时的默认范围均限定在该子树,选定部门时含其组织下级。
|
医助按人事部门归组;<b>仅统计「二中心」及其组织下级</b>;部门下拉与未选时的默认范围均限定在该子树,选定部门时含其组织下级。
|
||||||
@@ -81,7 +100,9 @@
|
|||||||
<template #header>
|
<template #header>
|
||||||
<div class="rate-card-header">
|
<div class="rate-card-header">
|
||||||
<span>部门 · 医助明细({{ monthText }})</span>
|
<span>部门 · 医助明细({{ monthText }})</span>
|
||||||
<span class="rate-card-hint">点击「被指派数 / N诊单数」可查看具体诊单与订单;底栏合计的被指派数按诊单去重</span>
|
<span class="rate-card-hint">
|
||||||
|
点击「新指派数 / 继承数 / 被指派合计 / N诊单数 / 复诊合计单数」可查看具体诊单与订单;接诊率下方灰字为「单数 / 分母」;底栏合计按诊单去重
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<el-table
|
<el-table
|
||||||
@@ -104,19 +125,88 @@
|
|||||||
<span v-else>{{ row.assistant_name }}</span>
|
<span v-else>{{ row.assistant_name }}</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="assigned_count" label="当月被指派数" min-width="110" align="right">
|
<el-table-column
|
||||||
|
prop="assigned_count"
|
||||||
|
:label="isSlotMode ? '新指派数' : '当月被指派数'"
|
||||||
|
min-width="96"
|
||||||
|
align="right"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<span>{{ isSlotMode ? '新指派数' : '当月被指派数' }}</span>
|
||||||
|
<el-tooltip
|
||||||
|
effect="dark"
|
||||||
|
placement="top"
|
||||||
|
content="当月非继承指派的「医助 × 诊单」去重数(剔除名下有拒收/退款订单的诊单),与业绩看板「被指派数」同口径"
|
||||||
|
>
|
||||||
|
<el-icon class="rate-th-hint"><InfoFilled /></el-icon>
|
||||||
|
</el-tooltip>
|
||||||
|
</template>
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-link
|
<el-link
|
||||||
v-if="canViewAssignLines && row.assigned_count > 0"
|
v-if="canViewAssignLines && row.assigned_count > 0"
|
||||||
type="primary"
|
type="primary"
|
||||||
:underline="false"
|
:underline="false"
|
||||||
@click="openAssignDetail(row)"
|
@click="openAssignDetail(row, 'new')"
|
||||||
>
|
>
|
||||||
{{ row.assigned_count }}
|
{{ row.assigned_count }}
|
||||||
</el-link>
|
</el-link>
|
||||||
<span v-else>{{ row.assigned_count }}</span>
|
<span v-else>{{ row.assigned_count }}</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
<el-table-column
|
||||||
|
v-if="isSlotMode"
|
||||||
|
prop="inherit_count"
|
||||||
|
label="继承数"
|
||||||
|
min-width="88"
|
||||||
|
align="right"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<span>继承数</span>
|
||||||
|
<el-tooltip
|
||||||
|
effect="dark"
|
||||||
|
placement="top"
|
||||||
|
content="当月勾选「继承」转接给该医助的诊单数(按继承时诊次 k 从第 k+1 诊起计入分母;点击查看每条的继承时诊次与起算诊次)"
|
||||||
|
>
|
||||||
|
<el-icon class="rate-th-hint"><InfoFilled /></el-icon>
|
||||||
|
</el-tooltip>
|
||||||
|
</template>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-link
|
||||||
|
v-if="canViewAssignLines && row.inherit_count > 0"
|
||||||
|
type="primary"
|
||||||
|
:underline="false"
|
||||||
|
@click="openAssignDetail(row, 'inherit')"
|
||||||
|
>
|
||||||
|
{{ row.inherit_count }}
|
||||||
|
</el-link>
|
||||||
|
<span v-else>{{ row.inherit_count ?? 0 }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column
|
||||||
|
v-if="isSlotMode"
|
||||||
|
prop="assigned_total_count"
|
||||||
|
label="被指派合计"
|
||||||
|
min-width="100"
|
||||||
|
align="right"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<span>被指派合计</span>
|
||||||
|
<el-tooltip effect="dark" placement="top" content="新指派 ∪ 继承(按诊单去重),即「复诊合计接诊率」的分母">
|
||||||
|
<el-icon class="rate-th-hint"><InfoFilled /></el-icon>
|
||||||
|
</el-tooltip>
|
||||||
|
</template>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-link
|
||||||
|
v-if="canViewAssignLines && row.assigned_total_count > 0"
|
||||||
|
type="primary"
|
||||||
|
:underline="false"
|
||||||
|
@click="openAssignDetail(row, 'all')"
|
||||||
|
>
|
||||||
|
{{ row.assigned_total_count }}
|
||||||
|
</el-link>
|
||||||
|
<span v-else>{{ row.assigned_total_count ?? 0 }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
<template v-for="slot in visitSlots" :key="slot">
|
<template v-for="slot in visitSlots" :key="slot">
|
||||||
<el-table-column
|
<el-table-column
|
||||||
:prop="`visit${slot}_count`"
|
:prop="`visit${slot}_count`"
|
||||||
@@ -149,12 +239,64 @@
|
|||||||
<el-table-column
|
<el-table-column
|
||||||
:prop="`visit${slot}_rate`"
|
:prop="`visit${slot}_rate`"
|
||||||
:label="`${slotLabel(slot)}接诊率`"
|
:label="`${slotLabel(slot)}接诊率`"
|
||||||
min-width="96"
|
min-width="104"
|
||||||
align="right"
|
align="right"
|
||||||
>
|
>
|
||||||
<template #default="{ row }">{{ formatRate(row[`visit${slot}_rate`]) }}</template>
|
<template #header>
|
||||||
|
<span>{{ slotLabel(slot) }}接诊率</span>
|
||||||
|
<el-tooltip effect="dark" placement="top" :content="rateHeaderTip(slot)">
|
||||||
|
<el-icon class="rate-th-hint"><InfoFilled /></el-icon>
|
||||||
|
</el-tooltip>
|
||||||
|
</template>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="rate-cell">
|
||||||
|
<span class="rate-cell-main">{{ formatRate(row[`visit${slot}_rate`]) }}</span>
|
||||||
|
<span v-if="isSlotMode" class="rate-cell-sub">{{ denText(row, `visit${slot}`) }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</template>
|
</template>
|
||||||
|
<el-table-column prop="visit_all_count" label="复诊合计单数" min-width="110" align="right">
|
||||||
|
<template #header>
|
||||||
|
<span>复诊合计单数</span>
|
||||||
|
<el-tooltip effect="dark" placement="top" content="当月内下单、诊次 ≥ 2 的订单数之和(= 各 N 诊单数之和)">
|
||||||
|
<el-icon class="rate-th-hint"><InfoFilled /></el-icon>
|
||||||
|
</el-tooltip>
|
||||||
|
</template>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-link
|
||||||
|
v-if="canViewOrderLines && row.visit_all_count > 0"
|
||||||
|
type="primary"
|
||||||
|
:underline="false"
|
||||||
|
@click="openOrderDetail(0, row)"
|
||||||
|
>
|
||||||
|
{{ row.visit_all_count }}
|
||||||
|
</el-link>
|
||||||
|
<span v-else>{{ row.visit_all_count ?? 0 }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="visit_all_rate" label="复诊合计接诊率" min-width="118" align="right">
|
||||||
|
<template #header>
|
||||||
|
<span>复诊合计接诊率</span>
|
||||||
|
<el-tooltip
|
||||||
|
effect="dark"
|
||||||
|
placement="top"
|
||||||
|
:content="
|
||||||
|
isSlotMode
|
||||||
|
? '复诊合计单数 ÷ 被指派合计(新指派 ∪ 继承);可超过 100%,语义为人均复诊成交单数'
|
||||||
|
: '复诊合计单数 ÷ 新指派数(旧口径继承不计分母);可超过 100%'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<el-icon class="rate-th-hint"><InfoFilled /></el-icon>
|
||||||
|
</el-tooltip>
|
||||||
|
</template>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="rate-cell">
|
||||||
|
<span class="rate-cell-main">{{ formatRate(row.visit_all_rate) }}</span>
|
||||||
|
<span v-if="isSlotMode" class="rate-cell-sub">{{ denText(row, 'visit_all') }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
@@ -162,7 +304,7 @@
|
|||||||
<el-dialog
|
<el-dialog
|
||||||
v-model="detailVisible"
|
v-model="detailVisible"
|
||||||
:title="detailTitle"
|
:title="detailTitle"
|
||||||
width="980px"
|
width="1080px"
|
||||||
top="6vh"
|
top="6vh"
|
||||||
destroy-on-close
|
destroy-on-close
|
||||||
>
|
>
|
||||||
@@ -193,6 +335,33 @@
|
|||||||
<template #default="{ row }">{{ row.patient_phone || '—' }}</template>
|
<template #default="{ row }">{{ row.patient_phone || '—' }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="assistant_names" label="被指派医助" min-width="140" show-overflow-tooltip />
|
<el-table-column prop="assistant_names" label="被指派医助" min-width="140" show-overflow-tooltip />
|
||||||
|
<el-table-column v-if="isSlotMode" prop="assign_type" label="指派类型" width="110" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.assign_type === 'inherit' ? 'warning' : 'success'" size="small">
|
||||||
|
{{ row.assign_type_text || (row.assign_type === 'inherit' ? '继承' : '新指派') }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column v-if="isSlotMode" prop="inherit_visit_slot" label="继承时诊次" width="100" align="center">
|
||||||
|
<template #header>
|
||||||
|
<span>继承时诊次</span>
|
||||||
|
<el-tooltip effect="dark" placement="top" content="k:继承时刻之前患者已完成的诊次(有效实单数 + 偏移);新指派无此值">
|
||||||
|
<el-icon class="rate-th-hint"><InfoFilled /></el-icon>
|
||||||
|
</el-tooltip>
|
||||||
|
</template>
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ row.assign_type === 'inherit' ? `第 ${Number(row.inherit_visit_slot ?? 0)} 诊` : '—' }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="entry_slot" label="起算诊次" width="96" align="center">
|
||||||
|
<template #header>
|
||||||
|
<span>起算诊次</span>
|
||||||
|
<el-tooltip effect="dark" placement="top" content="从该诊次列起计入分母:继承 = max(2, k+1);新指派 = 2(二诊)">
|
||||||
|
<el-icon class="rate-th-hint"><InfoFilled /></el-icon>
|
||||||
|
</el-tooltip>
|
||||||
|
</template>
|
||||||
|
<template #default="{ row }">{{ entrySlotText(row.entry_slot) }}</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column prop="assign_count" label="当月指派次数" width="110" align="right" />
|
<el-table-column prop="assign_count" label="当月指派次数" width="110" align="right" />
|
||||||
<el-table-column prop="last_assign_time_text" label="最近指派时间" min-width="160" />
|
<el-table-column prop="last_assign_time_text" label="最近指派时间" min-width="160" />
|
||||||
</template>
|
</template>
|
||||||
@@ -210,6 +379,9 @@
|
|||||||
<span v-else>{{ row.order_no || `#${row.order_id}` }}</span>
|
<span v-else>{{ row.order_no || `#${row.order_id}` }}</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
<el-table-column v-if="detailSlot === 0" prop="visit_slot" label="诊次" width="80" align="center">
|
||||||
|
<template #default="{ row }">{{ slotLabel(Number(row.visit_slot)) }}</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column prop="diagnosis_id" label="诊单ID" width="90" />
|
<el-table-column prop="diagnosis_id" label="诊单ID" width="90" />
|
||||||
<el-table-column prop="patient_name" label="患者" min-width="100">
|
<el-table-column prop="patient_name" label="患者" min-width="100">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
@@ -251,7 +423,9 @@ import {
|
|||||||
revisitRateAssignLines,
|
revisitRateAssignLines,
|
||||||
revisitRateDeptOptions,
|
revisitRateDeptOptions,
|
||||||
revisitRateOverview,
|
revisitRateOverview,
|
||||||
revisitRateVisitOrderLines
|
revisitRateVisitOrderLines,
|
||||||
|
type RevisitRateAssignKind,
|
||||||
|
type RevisitRateInheritMode
|
||||||
} from '@/api/stats'
|
} from '@/api/stats'
|
||||||
import { hasPermission } from '@/utils/perm'
|
import { hasPermission } from '@/utils/perm'
|
||||||
|
|
||||||
@@ -263,7 +437,14 @@ const OrderDetailDrawer = defineAsyncComponent(
|
|||||||
const DiagnosisEditPopup = defineAsyncComponent(() => import('@/views/tcm/diagnosis/edit.vue'))
|
const DiagnosisEditPopup = defineAsyncComponent(() => import('@/views/tcm/diagnosis/edit.vue'))
|
||||||
|
|
||||||
interface MetricPack extends Record<string, any> {
|
interface MetricPack extends Record<string, any> {
|
||||||
|
/** 新指派数(语义不变) */
|
||||||
assigned_count: number
|
assigned_count: number
|
||||||
|
inherit_count?: number
|
||||||
|
assigned_total_count?: number
|
||||||
|
visit_all_count?: number
|
||||||
|
visit_all_den?: number
|
||||||
|
visit_all_rate?: number | null
|
||||||
|
visit2_rate_new_assign?: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
interface StatsRow extends MetricPack {
|
interface StatsRow extends MetricPack {
|
||||||
@@ -289,11 +470,14 @@ const canViewOrderLines = hasPermission(['stats.revisitRate/visitOrderLines'])
|
|||||||
const canViewOrderDetail = hasPermission(['tcm.prescriptionOrder/detail'])
|
const canViewOrderDetail = hasPermission(['tcm.prescriptionOrder/detail'])
|
||||||
const canViewPatientDetail = hasPermission(['tcm.diagnosis/detail'])
|
const canViewPatientDetail = hasPermission(['tcm.diagnosis/detail'])
|
||||||
|
|
||||||
/** 接口动态返回的诊次分档(最少 2~4,按数据扩展到五诊、六诊…) */
|
/** 接口动态返回的诊次分档(最少 2~4,按分子最大诊次 / 最大起算诊次扩展到五诊、六诊…) */
|
||||||
const visitSlots = ref<number[]>([2, 3, 4])
|
const visitSlots = ref<number[]>([2, 3, 4])
|
||||||
|
|
||||||
const cnDigits = ['零', '一', '二', '三', '四', '五', '六', '七', '八', '九', '十']
|
const cnDigits = ['零', '一', '二', '三', '四', '五', '六', '七', '八', '九', '十']
|
||||||
const slotLabel = (slot: number) => {
|
const slotLabel = (slot: number) => {
|
||||||
|
if (slot === 0) {
|
||||||
|
return '复诊合计'
|
||||||
|
}
|
||||||
if (slot >= 2 && slot <= 10) {
|
if (slot >= 2 && slot <= 10) {
|
||||||
return `${cnDigits[slot]}诊`
|
return `${cnDigits[slot]}诊`
|
||||||
}
|
}
|
||||||
@@ -307,6 +491,8 @@ const initNow = new Date()
|
|||||||
const currentMonth = `${initNow.getFullYear()}-${String(initNow.getMonth() + 1).padStart(2, '0')}`
|
const currentMonth = `${initNow.getFullYear()}-${String(initNow.getMonth() + 1).padStart(2, '0')}`
|
||||||
const month = ref<string>(currentMonth)
|
const month = ref<string>(currentMonth)
|
||||||
const deptId = ref<number | undefined>(undefined)
|
const deptId = ref<number | undefined>(undefined)
|
||||||
|
/** 口径:首次加载不传、取后端默认(config default_inherit_mode),之后以用户选择为准 */
|
||||||
|
const inheritMode = ref<RevisitRateInheritMode | ''>('')
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const rows = ref<StatsRow[]>([])
|
const rows = ref<StatsRow[]>([])
|
||||||
const total = ref<MetricPack | null>(null)
|
const total = ref<MetricPack | null>(null)
|
||||||
@@ -314,6 +500,8 @@ const total = ref<MetricPack | null>(null)
|
|||||||
const deptTreeOptions = ref<DeptTreeNode[]>([])
|
const deptTreeOptions = ref<DeptTreeNode[]>([])
|
||||||
const deptTreeProps = { value: 'id', label: 'name', children: 'children' }
|
const deptTreeProps = { value: 'id', label: 'name', children: 'children' }
|
||||||
|
|
||||||
|
const isSlotMode = computed(() => inheritMode.value !== 'exclude')
|
||||||
|
|
||||||
const monthText = computed(() => {
|
const monthText = computed(() => {
|
||||||
const [y, m] = month.value.split('-')
|
const [y, m] = month.value.split('-')
|
||||||
return y && m ? `${y}年${Number(m)}月` : month.value
|
return y && m ? `${y}年${Number(m)}月` : month.value
|
||||||
@@ -322,9 +510,30 @@ const monthText = computed(() => {
|
|||||||
const formatRate = (rate: number | null | undefined) =>
|
const formatRate = (rate: number | null | undefined) =>
|
||||||
rate === null || rate === undefined ? '—' : `${rate}%`
|
rate === null || rate === undefined ? '—' : `${rate}%`
|
||||||
|
|
||||||
|
/** 接诊率单元格下方灰字:单数 / 分母 */
|
||||||
|
const denText = (row: Record<string, any>, prefix: string) => {
|
||||||
|
const cnt = Number(row[`${prefix}_count`] ?? 0)
|
||||||
|
const den = row[`${prefix}_den`]
|
||||||
|
return `${cnt} / ${den === null || den === undefined ? '—' : den}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const rateHeaderTip = (slot: number) =>
|
||||||
|
isSlotMode.value
|
||||||
|
? `${slotLabel(slot)}单数 ÷ ${slotLabel(slot)}分母;分母 = 当月新指派数 + 起算诊次 ≤ ${slot} 的继承数;分母为 0 显示「—」`
|
||||||
|
: `${slotLabel(slot)}单数 ÷ 当月新指派数(旧口径:继承不计分母);分母为 0 显示「—」`
|
||||||
|
|
||||||
|
const entrySlotText = (entry: unknown) => {
|
||||||
|
const n = Number(entry ?? 0)
|
||||||
|
if (n <= 0) {
|
||||||
|
return '—'
|
||||||
|
}
|
||||||
|
return n > 50 ? `${n}(仅计合计)` : slotLabel(n)
|
||||||
|
}
|
||||||
|
|
||||||
const baseQuery = () => ({
|
const baseQuery = () => ({
|
||||||
month: month.value,
|
month: month.value,
|
||||||
dept_ids: deptId.value ? String(deptId.value) : ''
|
dept_ids: deptId.value ? String(deptId.value) : '',
|
||||||
|
inherit_mode: (inheritMode.value || undefined) as RevisitRateInheritMode | undefined
|
||||||
})
|
})
|
||||||
|
|
||||||
interface SummaryCard {
|
interface SummaryCard {
|
||||||
@@ -341,11 +550,11 @@ const summaryCards = computed<SummaryCard[]>(() => {
|
|||||||
const cards: SummaryCard[] = [
|
const cards: SummaryCard[] = [
|
||||||
{
|
{
|
||||||
key: 'assigned',
|
key: 'assigned',
|
||||||
label: '当月被指派总数',
|
label: isSlotMode.value ? '当月新指派数' : '当月被指派数',
|
||||||
value: t ? String(t.assigned_count) : '—',
|
value: t ? String(t.assigned_count) : '—',
|
||||||
sub: '',
|
sub: t && isSlotMode.value ? `继承 ${t.inherit_count ?? 0} · 被指派合计 ${t.assigned_total_count ?? 0}` : '',
|
||||||
clickable: !!(canViewAssignLines && t && t.assigned_count > 0),
|
clickable: !!(canViewAssignLines && t && t.assigned_count > 0),
|
||||||
onClick: () => openAssignDetail(null)
|
onClick: () => openAssignDetail(null, 'new')
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
for (const slot of visitSlots.value) {
|
for (const slot of visitSlots.value) {
|
||||||
@@ -355,11 +564,19 @@ const summaryCards = computed<SummaryCard[]>(() => {
|
|||||||
key: `visit${slot}`,
|
key: `visit${slot}`,
|
||||||
label: `当月${slotLabel(slot)}接诊率`,
|
label: `当月${slotLabel(slot)}接诊率`,
|
||||||
value: t ? formatRate(rate) : '—',
|
value: t ? formatRate(rate) : '—',
|
||||||
sub: t ? `${slotLabel(slot)}下单 ${cnt} 单` : '',
|
sub: t ? `${slotLabel(slot)}下单 ${cnt} 单 / 分母 ${t[`visit${slot}_den`] ?? '—'}` : '',
|
||||||
clickable: !!(canViewOrderLines && cnt > 0),
|
clickable: !!(canViewOrderLines && cnt > 0),
|
||||||
onClick: () => openOrderDetail(slot, null)
|
onClick: () => openOrderDetail(slot, null)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
cards.push({
|
||||||
|
key: 'visit_all',
|
||||||
|
label: '当月复诊合计接诊率',
|
||||||
|
value: t ? formatRate(t.visit_all_rate) : '—',
|
||||||
|
sub: t ? `复诊下单 ${t.visit_all_count ?? 0} 单 / 被指派合计 ${t.visit_all_den ?? '—'}` : '',
|
||||||
|
clickable: !!(canViewOrderLines && t && Number(t.visit_all_count) > 0),
|
||||||
|
onClick: () => openOrderDetail(0, null)
|
||||||
|
})
|
||||||
return cards
|
return cards
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -374,15 +591,16 @@ const getSummaries = ({ columns }: { columns: any[] }) => {
|
|||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
const prop = String(col.property || '')
|
const prop = String(col.property || '')
|
||||||
if (prop === 'assigned_count') {
|
if (prop === 'assigned_count' || prop === 'inherit_count' || prop === 'assigned_total_count') {
|
||||||
return String(t.assigned_count)
|
return String(t[prop] ?? 0)
|
||||||
}
|
}
|
||||||
if (/^visit\d+_count$/.test(prop)) {
|
const m = /^visit(\d+|all)_(count|den|rate)$/.exec(prop)
|
||||||
|
if (m) {
|
||||||
|
if (m[2] === 'rate') {
|
||||||
|
return `${formatRate(t[prop])}(${denText(t, `visit${m[1]}`)})`
|
||||||
|
}
|
||||||
return String(t[prop] ?? '')
|
return String(t[prop] ?? '')
|
||||||
}
|
}
|
||||||
if (/^visit\d+_rate$/.test(prop)) {
|
|
||||||
return formatRate(t[prop])
|
|
||||||
}
|
|
||||||
return ''
|
return ''
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -404,6 +622,9 @@ const loadData = async () => {
|
|||||||
if (res?.month) {
|
if (res?.month) {
|
||||||
month.value = res.month
|
month.value = res.month
|
||||||
}
|
}
|
||||||
|
if (res?.inherit_mode === 'slot' || res?.inherit_mode === 'exclude') {
|
||||||
|
inheritMode.value = res.inherit_mode
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
@@ -449,6 +670,8 @@ const detailType = ref<'assign' | 'orders'>('assign')
|
|||||||
const detailTitle = ref('')
|
const detailTitle = ref('')
|
||||||
const detailLoading = ref(false)
|
const detailLoading = ref(false)
|
||||||
const detailRows = ref<any[]>([])
|
const detailRows = ref<any[]>([])
|
||||||
|
/** 订单明细的诊次(0 = 复诊合计,表格多显示「诊次」列) */
|
||||||
|
const detailSlot = ref(2)
|
||||||
|
|
||||||
/** row 为 null 时表示合计(当前部门筛选范围) */
|
/** row 为 null 时表示合计(当前部门筛选范围) */
|
||||||
const rowScope = (row: StatsRow | null): { assistant_id?: number; dept_id?: number; label: string } => {
|
const rowScope = (row: StatsRow | null): { assistant_id?: number; dept_id?: number; label: string } => {
|
||||||
@@ -461,13 +684,19 @@ const rowScope = (row: StatsRow | null): { assistant_id?: number; dept_id?: numb
|
|||||||
return { assistant_id: row.assistant_id, label: row.assistant_name || '' }
|
return { assistant_id: row.assistant_id, label: row.assistant_name || '' }
|
||||||
}
|
}
|
||||||
|
|
||||||
const openAssignDetail = async (row: StatsRow | null) => {
|
const assignKindLabel: Record<RevisitRateAssignKind, string> = {
|
||||||
|
new: '新指派明细',
|
||||||
|
inherit: '继承明细',
|
||||||
|
all: '被指派合计明细'
|
||||||
|
}
|
||||||
|
|
||||||
|
const openAssignDetail = async (row: StatsRow | null, kind: RevisitRateAssignKind = 'all') => {
|
||||||
if (!canViewAssignLines) {
|
if (!canViewAssignLines) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const scope = rowScope(row)
|
const scope = rowScope(row)
|
||||||
detailType.value = 'assign'
|
detailType.value = 'assign'
|
||||||
detailTitle.value = `${scope.label} · 被指派明细(${monthText.value})`
|
detailTitle.value = `${scope.label} · ${assignKindLabel[kind]}(${monthText.value})`
|
||||||
detailVisible.value = true
|
detailVisible.value = true
|
||||||
detailLoading.value = true
|
detailLoading.value = true
|
||||||
detailRows.value = []
|
detailRows.value = []
|
||||||
@@ -475,7 +704,8 @@ const openAssignDetail = async (row: StatsRow | null) => {
|
|||||||
const res = await revisitRateAssignLines({
|
const res = await revisitRateAssignLines({
|
||||||
...baseQuery(),
|
...baseQuery(),
|
||||||
assistant_id: scope.assistant_id,
|
assistant_id: scope.assistant_id,
|
||||||
dept_id: scope.dept_id
|
dept_id: scope.dept_id,
|
||||||
|
kind
|
||||||
})
|
})
|
||||||
detailRows.value = res?.rows ?? []
|
detailRows.value = res?.rows ?? []
|
||||||
} finally {
|
} finally {
|
||||||
@@ -483,12 +713,14 @@ const openAssignDetail = async (row: StatsRow | null) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** slot=0 表示全部诊次 ≥ 2(复诊合计单数) */
|
||||||
const openOrderDetail = async (slot: number, row: StatsRow | null) => {
|
const openOrderDetail = async (slot: number, row: StatsRow | null) => {
|
||||||
if (!canViewOrderLines) {
|
if (!canViewOrderLines) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const scope = rowScope(row)
|
const scope = rowScope(row)
|
||||||
detailType.value = 'orders'
|
detailType.value = 'orders'
|
||||||
|
detailSlot.value = slot
|
||||||
detailTitle.value = `${scope.label} · ${slotLabel(slot)}订单明细(${monthText.value})`
|
detailTitle.value = `${scope.label} · ${slotLabel(slot)}订单明细(${monthText.value})`
|
||||||
detailVisible.value = true
|
detailVisible.value = true
|
||||||
detailLoading.value = true
|
detailLoading.value = true
|
||||||
@@ -529,6 +761,7 @@ const openPatientDetail = (diagnosisId: number) => {
|
|||||||
const handleReset = () => {
|
const handleReset = () => {
|
||||||
month.value = currentMonth
|
month.value = currentMonth
|
||||||
deptId.value = undefined
|
deptId.value = undefined
|
||||||
|
inheritMode.value = ''
|
||||||
loadData()
|
loadData()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -623,6 +856,22 @@ onMounted(() => {
|
|||||||
color: var(--el-text-color-secondary);
|
color: var(--el-text-color-secondary);
|
||||||
cursor: help;
|
cursor: help;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.rate-cell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
line-height: 1.3;
|
||||||
|
|
||||||
|
.rate-cell-main {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rate-cell-sub {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--el-text-color-placeholder);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.rate-detail-footer {
|
.rate-detail-footer {
|
||||||
@@ -640,5 +889,10 @@ onMounted(() => {
|
|||||||
p + p {
|
p + p {
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.rate-caliber-note {
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -12,9 +12,21 @@
|
|||||||
{{ assistantLogCellText(row, 'to') }}
|
{{ assistantLogCellText(row, 'to') }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="继承" width="72" align="center">
|
<el-table-column label="继承" width="118" align="center">
|
||||||
|
<template #header>
|
||||||
|
<span>继承</span>
|
||||||
|
<el-tooltip
|
||||||
|
placement="top"
|
||||||
|
content="继承时第 k 诊:继承时刻之前患者已完成的诊次(有效实单数 + 偏移),复诊接诊率从第 k+1 诊起计入新医助分母;2026-10 前的继承记录无此快照"
|
||||||
|
>
|
||||||
|
<el-icon class="assign-log-col-hint"><QuestionFilled /></el-icon>
|
||||||
|
</el-tooltip>
|
||||||
|
</template>
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag v-if="Number(row.is_inherit) === 1" type="success" size="small">是</el-tag>
|
<template v-if="Number(row.is_inherit) === 1">
|
||||||
|
<el-tag type="success" size="small">是</el-tag>
|
||||||
|
<div v-if="inheritSlotText(row)" class="assign-log-inherit-slot">{{ inheritSlotText(row) }}</div>
|
||||||
|
</template>
|
||||||
<span v-else class="text-gray-400">否</span>
|
<span v-else class="text-gray-400">否</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
@@ -85,6 +97,20 @@ function relatedPoCreateTimeCell(row: Record<string, unknown>): string {
|
|||||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 对应日志表 inherit_visit_slot / inherit_visit_slot_calc_time:后端已拼好「继承时第 k 诊」文案,缺失时按快照列兜底 */
|
||||||
|
function inheritSlotText(row: Record<string, unknown>): string {
|
||||||
|
const text = row.inherit_visit_slot_text
|
||||||
|
if (text != null && String(text).trim() !== '') {
|
||||||
|
return String(text)
|
||||||
|
}
|
||||||
|
const calcTime = Number(row.inherit_visit_slot_calc_time)
|
||||||
|
if (!Number.isFinite(calcTime) || calcTime <= 0) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
const k = Number(row.inherit_visit_slot)
|
||||||
|
return Number.isFinite(k) && k > 0 ? `继承时第 ${k} 诊` : '继承时未成交'
|
||||||
|
}
|
||||||
|
|
||||||
function assistantLogCellText(row: Record<string, unknown>, side: 'from' | 'to'): string {
|
function assistantLogCellText(row: Record<string, unknown>, side: 'from' | 'to'): string {
|
||||||
const nameKey = side === 'from' ? 'from_assistant_name' : 'to_assistant_name'
|
const nameKey = side === 'from' ? 'from_assistant_name' : 'to_assistant_name'
|
||||||
const idKey = side === 'from' ? 'from_assistant_id' : 'to_assistant_id'
|
const idKey = side === 'from' ? 'from_assistant_id' : 'to_assistant_id'
|
||||||
@@ -132,4 +158,11 @@ defineExpose({ refresh: load })
|
|||||||
color: var(--el-text-color-secondary);
|
color: var(--el-text-color-secondary);
|
||||||
cursor: help;
|
cursor: help;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.assign-log-inherit-slot {
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.3;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -68,7 +68,11 @@
|
|||||||
'is-high': getCell(row.key, date).isHigh,
|
'is-high': getCell(row.key, date).isHigh,
|
||||||
'is-patient-self': getCell(row.key, date).isPatientSelf && getCell(row.key, date).hasRecord
|
'is-patient-self': getCell(row.key, date).isPatientSelf && getCell(row.key, date).hasRecord
|
||||||
}"
|
}"
|
||||||
|
:role="isCellClickable(row.key, date) ? 'button' : undefined"
|
||||||
|
:tabindex="isCellClickable(row.key, date) ? 0 : undefined"
|
||||||
@click="handleCellClick(row.key, date)"
|
@click="handleCellClick(row.key, date)"
|
||||||
|
@keydown.enter="handleCellClick(row.key, date)"
|
||||||
|
@keydown.space.prevent="handleCellClick(row.key, date)"
|
||||||
>
|
>
|
||||||
<template v-if="row.key === 'tracking' && getTrackingContent(date)">
|
<template v-if="row.key === 'tracking' && getTrackingContent(date)">
|
||||||
<el-tooltip placement="top" effect="light" :show-after="150">
|
<el-tooltip placement="top" effect="light" :show-after="150">
|
||||||
@@ -90,6 +94,9 @@
|
|||||||
</template>
|
</template>
|
||||||
<template v-else-if="getCell(row.key, date).value">
|
<template v-else-if="getCell(row.key, date).value">
|
||||||
<span>{{ getCell(row.key, date).value }}</span>
|
<span>{{ getCell(row.key, date).value }}</span>
|
||||||
|
<span v-if="getDayRecordCount(row.key, date) > 1" class="daily-matrix__record-count">
|
||||||
|
{{ getDayRecordCount(row.key, date) }}条
|
||||||
|
</span>
|
||||||
<span v-if="getCell(row.key, date).isHigh" class="daily-matrix__cell-up">↑</span>
|
<span v-if="getCell(row.key, date).isHigh" class="daily-matrix__cell-up">↑</span>
|
||||||
<el-tooltip
|
<el-tooltip
|
||||||
v-if="getCell(row.key, date).isPatientSelf"
|
v-if="getCell(row.key, date).isPatientSelf"
|
||||||
@@ -100,7 +107,10 @@
|
|||||||
<span class="daily-matrix__cell-patient">自录</span>
|
<span class="daily-matrix__cell-patient">自录</span>
|
||||||
</el-tooltip>
|
</el-tooltip>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>—</template>
|
<template v-else>
|
||||||
|
<span v-if="getDayRecordCount(row.key, date) > 0" class="daily-matrix__record-count">查看{{ getDayRecordCount(row.key, date) }}条</span>
|
||||||
|
<span v-else>—</span>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
@@ -109,6 +119,7 @@
|
|||||||
|
|
||||||
<div class="daily-matrix__chart">
|
<div class="daily-matrix__chart">
|
||||||
<div class="daily-matrix__section-title">血糖趋势</div>
|
<div class="daily-matrix__section-title">血糖趋势</div>
|
||||||
|
<p class="daily-matrix__hint">按日展示最新有效血糖值;点击表格查看当天全部原始记录。</p>
|
||||||
<v-charts
|
<v-charts
|
||||||
v-if="bloodTrendHasData"
|
v-if="bloodTrendHasData"
|
||||||
class="daily-matrix__chart-canvas"
|
class="daily-matrix__chart-canvas"
|
||||||
@@ -128,6 +139,84 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<el-drawer v-model="dayDetailVisible" :title="`${detailDate} 日常记录明细`" size="min(640px, 100vw)">
|
||||||
|
<p class="daily-matrix__hint">每张卡片是一条独立记录;相同数值也不会合并。请在对应记录上点击编辑。</p>
|
||||||
|
<section class="daily-matrix__detail-section">
|
||||||
|
<div class="daily-matrix__detail-heading">
|
||||||
|
<h3>血糖血压 · {{ dayBloodRecords.length }}条</h3>
|
||||||
|
<el-button v-if="canEditDailyRecord" link type="primary" @click="openBloodRecord(undefined, detailDate)">新增一条</el-button>
|
||||||
|
</div>
|
||||||
|
<el-empty v-if="!dayBloodRecords.length" description="当天暂无血糖血压记录" :image-size="48" />
|
||||||
|
<article v-for="record in dayBloodRecords" :key="record.id" class="daily-matrix__detail-record">
|
||||||
|
<header class="daily-matrix__detail-heading">
|
||||||
|
<strong>{{ recordTimeLabel(record) }} <small>#{{ record.id }}</small></strong>
|
||||||
|
<el-button v-if="canEditDailyRecord" link type="primary" @click="openBloodRecord(record, detailDate)">编辑此条</el-button>
|
||||||
|
</header>
|
||||||
|
<div class="daily-matrix__detail-tags">
|
||||||
|
<el-tag v-if="Number(record.record_time_estimated) === 1" type="warning" size="small">估计时间</el-tag>
|
||||||
|
<el-tag v-if="record.record_time_period" type="info" size="small">时段:{{ recordTimePeriodLabel(record) }}</el-tag>
|
||||||
|
<el-tag v-if="record.followup_audio_task_id" size="small">随访录音</el-tag>
|
||||||
|
<span v-if="Number(record.source) === 1" class="daily-matrix__cell-patient">患者自录</span>
|
||||||
|
</div>
|
||||||
|
<p v-if="record.record_time_text" class="daily-matrix__time-evidence">时间原文:{{ record.record_time_text }}</p>
|
||||||
|
<dl class="daily-matrix__detail-fields">
|
||||||
|
<template v-for="field in bloodDetailFields(record)" :key="field.label">
|
||||||
|
<dt>{{ field.label }}</dt><dd>{{ field.value }}</dd>
|
||||||
|
</template>
|
||||||
|
</dl>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
<section class="daily-matrix__detail-section">
|
||||||
|
<div class="daily-matrix__detail-heading">
|
||||||
|
<h3>饮食 · {{ dayDietRecords.length }}条</h3>
|
||||||
|
<el-button v-if="canEditDailyRecord" link type="primary" @click="openDietRecord(undefined, detailDate)">新增一条</el-button>
|
||||||
|
</div>
|
||||||
|
<el-empty v-if="!dayDietRecords.length" description="当天暂无饮食记录" :image-size="48" />
|
||||||
|
<article v-for="record in dayDietRecords" :key="record.id" class="daily-matrix__detail-record">
|
||||||
|
<header class="daily-matrix__detail-heading">
|
||||||
|
<strong>{{ recordTimeLabel(record) }} <small>#{{ record.id }}</small></strong>
|
||||||
|
<el-button v-if="canEditDailyRecord" link type="primary" @click="openDietRecord(record, detailDate)">编辑此条</el-button>
|
||||||
|
</header>
|
||||||
|
<dl class="daily-matrix__detail-fields">
|
||||||
|
<template v-for="meal in mealFields" :key="meal.key">
|
||||||
|
<dt>{{ meal.label }}</dt>
|
||||||
|
<dd>
|
||||||
|
{{ record[`${meal.key}_foods`] || record[meal.key] || '—' }}
|
||||||
|
<div v-if="record[`${meal.key}_images`]?.length" class="daily-matrix__detail-images">
|
||||||
|
<el-image v-for="(url, index) in record[`${meal.key}_images`]" :key="`${url}-${index}`"
|
||||||
|
:src="url" :preview-src-list="record[`${meal.key}_images`]" :initial-index="index" preview-teleported fit="cover" />
|
||||||
|
</div>
|
||||||
|
</dd>
|
||||||
|
</template>
|
||||||
|
<dt>备注</dt><dd>{{ record.note || '—' }}</dd>
|
||||||
|
</dl>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
<section class="daily-matrix__detail-section">
|
||||||
|
<div class="daily-matrix__detail-heading">
|
||||||
|
<h3>运动 · {{ dayExerciseRecords.length }}条</h3>
|
||||||
|
<el-button v-if="canEditDailyRecord" link type="primary" @click="openExerciseRecord(undefined, detailDate)">新增一条</el-button>
|
||||||
|
</div>
|
||||||
|
<el-empty v-if="!dayExerciseRecords.length" description="当天暂无运动记录" :image-size="48" />
|
||||||
|
<article v-for="record in dayExerciseRecords" :key="record.id" class="daily-matrix__detail-record">
|
||||||
|
<header class="daily-matrix__detail-heading">
|
||||||
|
<strong>{{ recordTimeLabel(record) }} <small>#{{ record.id }}</small></strong>
|
||||||
|
<el-button v-if="canEditDailyRecord" link type="primary" @click="openExerciseRecord(record, detailDate)">编辑此条</el-button>
|
||||||
|
</header>
|
||||||
|
<dl class="daily-matrix__detail-fields">
|
||||||
|
<dt>运动</dt><dd>{{ record.exercise_type || '—' }}</dd>
|
||||||
|
<dt>时长</dt><dd>{{ record.duration == null ? '未记录' : `${record.duration} 分钟` }}</dd>
|
||||||
|
<dt>强度</dt><dd>{{ intensityLabel(record.intensity) }}</dd>
|
||||||
|
<dt>备注</dt><dd>{{ record.note || '—' }}</dd>
|
||||||
|
</dl>
|
||||||
|
<div v-if="record.images?.length" class="daily-matrix__detail-images">
|
||||||
|
<el-image v-for="(url, index) in record.images" :key="`${url}-${index}`" :src="url"
|
||||||
|
:preview-src-list="record.images" :initial-index="index" preview-teleported fit="cover" />
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
</el-drawer>
|
||||||
|
|
||||||
<el-dialog v-model="bloodDialogVisible" :title="bloodDialogTitle" width="600px" :close-on-click-modal="false">
|
<el-dialog v-model="bloodDialogVisible" :title="bloodDialogTitle" width="600px" :close-on-click-modal="false">
|
||||||
<el-form :model="bloodForm" label-width="120px">
|
<el-form :model="bloodForm" label-width="120px">
|
||||||
<el-form-item label="记录日期" required>
|
<el-form-item label="记录日期" required>
|
||||||
@@ -148,6 +237,12 @@
|
|||||||
class="w-full"
|
class="w-full"
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<el-form-item v-if="Number(bloodForm.record_time_estimated) === 1" label="时间说明">
|
||||||
|
<div class="daily-matrix__time-evidence">
|
||||||
|
估计时间 · {{ recordTimePeriodLabel(bloodForm) }};原文:{{ bloodForm.record_time_text || '未提供' }}。
|
||||||
|
手动修改记录时间后会清除估计标记;只改数值则保留来源。
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
<el-form-item label="空腹血糖">
|
<el-form-item label="空腹血糖">
|
||||||
<el-input-number v-model="bloodForm.fasting_blood_sugar" :precision="2" :step="0.1" :min="0" :max="50" />
|
<el-input-number v-model="bloodForm.fasting_blood_sugar" :precision="2" :step="0.1" :min="0" :max="50" />
|
||||||
<span class="ml-2">mmol/L</span>
|
<span class="ml-2">mmol/L</span>
|
||||||
@@ -248,11 +343,13 @@
|
|||||||
<el-option label="其他" value="其他" />
|
<el-option label="其他" value="其他" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="运动时长(分钟)" required>
|
<el-form-item label="运动时长(分钟)" :required="!exerciseForm.id">
|
||||||
<el-input-number v-model="exerciseForm.duration" :min="0" :max="300" />
|
<el-input-number v-model="exerciseForm.duration" :min="0" :max="300" placeholder="未记录" />
|
||||||
|
<span v-if="exerciseForm.id" class="ml-2 text-info">留空表示未记录</span>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="运动强度" required>
|
<el-form-item label="运动强度">
|
||||||
<el-select v-model="exerciseForm.intensity" placeholder="请选择运动强度" class="w-full">
|
<el-select v-model="exerciseIntensity" placeholder="未记录" class="w-full">
|
||||||
|
<el-option label="未记录" value="" />
|
||||||
<el-option label="低强度" :value="1" />
|
<el-option label="低强度" :value="1" />
|
||||||
<el-option label="中强度" :value="2" />
|
<el-option label="中强度" :value="2" />
|
||||||
<el-option label="高强度" :value="3" />
|
<el-option label="高强度" :value="3" />
|
||||||
@@ -353,6 +450,10 @@ interface BloodRecord {
|
|||||||
patient_id?: number
|
patient_id?: number
|
||||||
record_date?: string
|
record_date?: string
|
||||||
record_time?: string
|
record_time?: string
|
||||||
|
record_time_estimated?: number | string
|
||||||
|
record_time_period?: string
|
||||||
|
record_time_text?: string
|
||||||
|
followup_audio_task_id?: number | null
|
||||||
record_date_ts?: number
|
record_date_ts?: number
|
||||||
fasting_blood_sugar?: any
|
fasting_blood_sugar?: any
|
||||||
postprandial_blood_sugar?: any
|
postprandial_blood_sugar?: any
|
||||||
@@ -364,12 +465,11 @@ interface BloodRecord {
|
|||||||
remark?: string
|
remark?: string
|
||||||
/** 来源:0-医生录入 1-患者自录(小程序日常记录页) */
|
/** 来源:0-医生录入 1-患者自录(小程序日常记录页) */
|
||||||
source?: number
|
source?: number
|
||||||
/** 合并后该日是否含有「患者自录」记录 */
|
|
||||||
has_patient_self?: boolean
|
|
||||||
[key: string]: any
|
[key: string]: any
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DietRecord {
|
interface DietRecord {
|
||||||
|
record_time?: string
|
||||||
id?: number | string
|
id?: number | string
|
||||||
diagnosis_id?: number
|
diagnosis_id?: number
|
||||||
patient_id?: number
|
patient_id?: number
|
||||||
@@ -388,13 +488,14 @@ interface DietRecord {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface ExerciseRecord {
|
interface ExerciseRecord {
|
||||||
|
record_time?: string
|
||||||
id?: number | string
|
id?: number | string
|
||||||
diagnosis_id?: number
|
diagnosis_id?: number
|
||||||
patient_id?: number
|
patient_id?: number
|
||||||
record_date?: string
|
record_date?: string
|
||||||
exercise_type?: string
|
exercise_type?: string
|
||||||
duration?: number
|
duration?: number | null
|
||||||
intensity?: number
|
intensity?: number | null
|
||||||
intensity_text?: string
|
intensity_text?: string
|
||||||
images?: string[]
|
images?: string[]
|
||||||
note?: string
|
note?: string
|
||||||
@@ -437,6 +538,9 @@ const bloodDialogVisible = ref(false)
|
|||||||
const dietDialogVisible = ref(false)
|
const dietDialogVisible = ref(false)
|
||||||
const exerciseDialogVisible = ref(false)
|
const exerciseDialogVisible = ref(false)
|
||||||
const trackingDialogVisible = ref(false)
|
const trackingDialogVisible = ref(false)
|
||||||
|
const dayDetailVisible = ref(false)
|
||||||
|
const detailDate = ref('')
|
||||||
|
const originalBloodTime = ref('')
|
||||||
const bloodSubmitting = ref(false)
|
const bloodSubmitting = ref(false)
|
||||||
const dietSubmitting = ref(false)
|
const dietSubmitting = ref(false)
|
||||||
const exerciseSubmitting = ref(false)
|
const exerciseSubmitting = ref(false)
|
||||||
@@ -445,6 +549,11 @@ const trackingSubmitting = ref(false)
|
|||||||
const bloodForm = ref<BloodRecord>(createBloodForm())
|
const bloodForm = ref<BloodRecord>(createBloodForm())
|
||||||
const dietForm = ref<DietRecord>(createDietForm())
|
const dietForm = ref<DietRecord>(createDietForm())
|
||||||
const exerciseForm = ref<ExerciseRecord>(createExerciseForm())
|
const exerciseForm = ref<ExerciseRecord>(createExerciseForm())
|
||||||
|
// el-option uses a string sentinel; persisted unknowns remain SQL/JSON null.
|
||||||
|
const exerciseIntensity = computed<number | ''>({
|
||||||
|
get: () => exerciseForm.value.intensity ?? '',
|
||||||
|
set: (value) => { exerciseForm.value.intensity = value === '' ? null : value }
|
||||||
|
})
|
||||||
const trackingForm = ref({
|
const trackingForm = ref({
|
||||||
note_date: '',
|
note_date: '',
|
||||||
existing_content: '',
|
existing_content: '',
|
||||||
@@ -455,7 +564,7 @@ const bloodDialogTitle = computed(() => (bloodForm.value.id ? '编辑血糖血
|
|||||||
const dietDialogTitle = computed(() => (dietForm.value.id ? '编辑饮食记录' : '新增饮食记录'))
|
const dietDialogTitle = computed(() => (dietForm.value.id ? '编辑饮食记录' : '新增饮食记录'))
|
||||||
const exerciseDialogTitle = computed(() => (exerciseForm.value.id ? '编辑运动记录' : '新增运动记录'))
|
const exerciseDialogTitle = computed(() => (exerciseForm.value.id ? '编辑运动记录' : '新增运动记录'))
|
||||||
const todayDate = computed(() => formatDate(new Date()))
|
const todayDate = computed(() => formatDate(new Date()))
|
||||||
const trackingCanSubmit = computed(() => !readOnly.value && trackingForm.value.note_date === todayDate.value)
|
const trackingCanSubmit = computed(() => canEditDailyRecord.value && trackingForm.value.note_date === todayDate.value)
|
||||||
const trackingDialogTitle = computed(() => (trackingCanSubmit.value ? '新增/追加跟踪备注' : '查看跟踪备注'))
|
const trackingDialogTitle = computed(() => (trackingCanSubmit.value ? '新增/追加跟踪备注' : '查看跟踪备注'))
|
||||||
const tableWrapRef = ref<HTMLElement | null>(null)
|
const tableWrapRef = ref<HTMLElement | null>(null)
|
||||||
const tableWrapWidth = ref(0)
|
const tableWrapWidth = ref(0)
|
||||||
@@ -583,70 +692,63 @@ const hasPatientSelfRecord = computed(() => {
|
|||||||
return bloodRecords.value.some((r) => Number((r as any).source) === 1)
|
return bloodRecords.value.some((r) => Number((r as any).source) === 1)
|
||||||
})
|
})
|
||||||
|
|
||||||
const bloodByDate = computed(() => {
|
// Group actual rows, never a synthetic record bearing the first row's id.
|
||||||
const map = new Map<string, BloodRecord>()
|
function groupRecordsByDate<T extends { record_date?: string }>(records: T[]): Map<string, T[]> {
|
||||||
for (const record of bloodRecords.value) {
|
const map = new Map<string, T[]>()
|
||||||
|
for (const record of records) {
|
||||||
const date = String(record.record_date || '')
|
const date = String(record.record_date || '')
|
||||||
if (!date) continue
|
if (!date) continue
|
||||||
if (!map.has(date)) {
|
const rows = map.get(date) || []
|
||||||
map.set(date, {
|
rows.push(record)
|
||||||
id: record.id,
|
map.set(date, rows)
|
||||||
diagnosis_id: record.diagnosis_id,
|
|
||||||
patient_id: record.patient_id,
|
|
||||||
record_date: date,
|
|
||||||
record_time: record.record_time || '',
|
|
||||||
record_date_ts: record.record_date_ts,
|
|
||||||
has_patient_self: false
|
|
||||||
})
|
|
||||||
}
|
|
||||||
const merged = map.get(date)!
|
|
||||||
const fields: Array<keyof BloodRecord> = [
|
|
||||||
'fasting_blood_sugar',
|
|
||||||
'postprandial_blood_sugar',
|
|
||||||
'other_blood_sugar',
|
|
||||||
'systolic_pressure',
|
|
||||||
'diastolic_pressure',
|
|
||||||
'western_medicine',
|
|
||||||
'insulin',
|
|
||||||
'remark'
|
|
||||||
]
|
|
||||||
for (const field of fields) {
|
|
||||||
if (
|
|
||||||
(merged[field] === undefined || merged[field] === null || merged[field] === '') &&
|
|
||||||
record[field] !== undefined &&
|
|
||||||
record[field] !== null &&
|
|
||||||
record[field] !== ''
|
|
||||||
) {
|
|
||||||
;(merged as any)[field] = record[field]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 当天有任意 source=1 的记录则标记为患者自录
|
|
||||||
if (Number(record.source) === 1) {
|
|
||||||
merged.has_patient_self = true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return map
|
return map
|
||||||
})
|
}
|
||||||
|
|
||||||
const dietByDate = computed(() => {
|
const bloodByDate = computed(() => groupRecordsByDate(bloodRecords.value))
|
||||||
const map = new Map<string, DietRecord>()
|
const dietByDate = computed(() => groupRecordsByDate(dietRecords.value))
|
||||||
for (const record of dietRecords.value) {
|
const exerciseByDate = computed(() => groupRecordsByDate(exerciseRecords.value))
|
||||||
const date = String(record.record_date || '')
|
const dayBloodRecords = computed(() => bloodByDate.value.get(detailDate.value) || [])
|
||||||
if (!date || map.has(date)) continue
|
const dayDietRecords = computed(() => dietByDate.value.get(detailDate.value) || [])
|
||||||
map.set(date, record)
|
const dayExerciseRecords = computed(() => exerciseByDate.value.get(detailDate.value) || [])
|
||||||
}
|
const mealFields = [{ key: 'breakfast', label: '早餐' }, { key: 'lunch', label: '午餐' }, { key: 'dinner', label: '晚餐' }]
|
||||||
return map
|
|
||||||
})
|
|
||||||
|
|
||||||
const exerciseByDate = computed(() => {
|
function recordTimeLabel(record: { record_time?: string }) {
|
||||||
const map = new Map<string, ExerciseRecord>()
|
return record.record_time ? String(record.record_time) : '未记录具体时间'
|
||||||
for (const record of exerciseRecords.value) {
|
}
|
||||||
const date = String(record.record_date || '')
|
|
||||||
if (!date || map.has(date)) continue
|
function recordTimePeriodLabel(record: BloodRecord) {
|
||||||
map.set(date, record)
|
const labels: Record<string, string> = {
|
||||||
|
dawn: '凌晨', early_morning: '清晨', morning: '上午', noon: '中午',
|
||||||
|
afternoon: '下午', evening: '晚上', night: '夜间', bedtime: '睡前'
|
||||||
}
|
}
|
||||||
return map
|
const period = String(record.record_time_period || '')
|
||||||
})
|
return labels[period] || period || '未提供时段'
|
||||||
|
}
|
||||||
|
|
||||||
|
function intensityLabel(value?: number | null) {
|
||||||
|
return ({ 1: '低强度', 2: '中强度', 3: '高强度' } as Record<number, string>)[Number(value)] || '未记录'
|
||||||
|
}
|
||||||
|
|
||||||
|
function bloodDetailFields(record: BloodRecord) {
|
||||||
|
const show = (value: unknown) => value == null || value === '' ? '—' : String(value)
|
||||||
|
return [
|
||||||
|
{ label: '空腹血糖', value: `${show(record.fasting_blood_sugar)} mmol/L` },
|
||||||
|
{ label: '餐后2h血糖', value: `${show(record.postprandial_blood_sugar)} mmol/L` },
|
||||||
|
{ label: '其他血糖', value: `${show(record.other_blood_sugar)} mmol/L` },
|
||||||
|
{ label: '血压', value: record.systolic_pressure || record.diastolic_pressure ? `${formatBp(record)} mmHg` : '—' },
|
||||||
|
{ label: '西药', value: show(record.western_medicine) },
|
||||||
|
{ label: '胰岛素', value: show(record.insulin) },
|
||||||
|
{ label: '备注', value: show(record.remark) }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDayRecordCount(metric: string, date: string) {
|
||||||
|
if (['breakfast', 'lunch', 'dinner'].includes(metric)) return dietByDate.value.get(date)?.length || 0
|
||||||
|
if (metric === 'exercise') return exerciseByDate.value.get(date)?.length || 0
|
||||||
|
if (metric === 'tracking') return 0
|
||||||
|
return bloodByDate.value.get(date)?.length || 0
|
||||||
|
}
|
||||||
|
|
||||||
const trackingByDate = computed(() => {
|
const trackingByDate = computed(() => {
|
||||||
const map = new Map<string, TrackingNoteRecord>()
|
const map = new Map<string, TrackingNoteRecord>()
|
||||||
@@ -663,9 +765,10 @@ const chartDates = computed(() => [...visibleDates.value].reverse())
|
|||||||
const bloodTrendSeries = computed(() => {
|
const bloodTrendSeries = computed(() => {
|
||||||
return chartDates.value.reduce(
|
return chartDates.value.reduce(
|
||||||
(acc, date) => {
|
(acc, date) => {
|
||||||
const blood = bloodByDate.value.get(date)
|
const records = bloodByDate.value.get(date) || []
|
||||||
acc.fasting.push(parseTrendNumber(blood?.fasting_blood_sugar))
|
const latestValue = (field: keyof BloodRecord) => records.map((record) => parseTrendNumber(record[field])).find((value) => value !== null) ?? null
|
||||||
acc.postprandial.push(parseTrendNumber(blood?.postprandial_blood_sugar))
|
acc.fasting.push(latestValue('fasting_blood_sugar'))
|
||||||
|
acc.postprandial.push(latestValue('postprandial_blood_sugar'))
|
||||||
return acc
|
return acc
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -754,89 +857,49 @@ function parseTrendNumber(value: unknown): number | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getCell(metric: string, date: string): { value: string; isHigh: boolean; hasRecord: boolean; isPatientSelf?: boolean } {
|
function getCell(metric: string, date: string): { value: string; isHigh: boolean; hasRecord: boolean; isPatientSelf?: boolean } {
|
||||||
const blood = bloodByDate.value.get(date)
|
const blood = bloodByDate.value.get(date) || []
|
||||||
const diet = dietByDate.value.get(date)
|
const diet = dietByDate.value.get(date) || []
|
||||||
const exercise = exerciseByDate.value.get(date)
|
const exercise = exerciseByDate.value.get(date) || []
|
||||||
const tracking = trackingByDate.value.get(date)
|
const tracking = trackingByDate.value.get(date)
|
||||||
const isPatientSelf = !!blood?.has_patient_self
|
const bloodFields: Record<string, keyof BloodRecord> = {
|
||||||
switch (metric) {
|
fasting: 'fasting_blood_sugar', postprandial: 'postprandial_blood_sugar',
|
||||||
case 'fasting':
|
other: 'other_blood_sugar', western: 'western_medicine', insulin: 'insulin'
|
||||||
return {
|
|
||||||
value: blood?.fasting_blood_sugar != null && blood.fasting_blood_sugar !== '' ? String(blood.fasting_blood_sugar) : '',
|
|
||||||
isHigh: isHighFastingBloodSugar(blood?.fasting_blood_sugar, props.age),
|
|
||||||
hasRecord: !!blood && blood.fasting_blood_sugar != null && blood.fasting_blood_sugar !== '',
|
|
||||||
isPatientSelf
|
|
||||||
}
|
|
||||||
case 'postprandial':
|
|
||||||
return {
|
|
||||||
value: blood?.postprandial_blood_sugar != null && blood.postprandial_blood_sugar !== '' ? String(blood.postprandial_blood_sugar) : '',
|
|
||||||
isHigh: isHighPostprandialBloodSugar(blood?.postprandial_blood_sugar, props.age),
|
|
||||||
hasRecord: !!blood && blood.postprandial_blood_sugar != null && blood.postprandial_blood_sugar !== '',
|
|
||||||
isPatientSelf
|
|
||||||
}
|
|
||||||
case 'other':
|
|
||||||
return {
|
|
||||||
value: blood?.other_blood_sugar != null && blood.other_blood_sugar !== '' ? String(blood.other_blood_sugar) : '',
|
|
||||||
isHigh: isHighOtherBloodSugar(blood?.other_blood_sugar, props.age),
|
|
||||||
hasRecord: !!blood && blood.other_blood_sugar != null && blood.other_blood_sugar !== '',
|
|
||||||
isPatientSelf
|
|
||||||
}
|
|
||||||
case 'bp':
|
|
||||||
return {
|
|
||||||
value: blood && (blood.systolic_pressure || blood.diastolic_pressure) ? formatBp(blood) : '',
|
|
||||||
isHigh: isHighBloodPressure(blood),
|
|
||||||
hasRecord: !!blood && !!(blood.systolic_pressure || blood.diastolic_pressure),
|
|
||||||
isPatientSelf
|
|
||||||
}
|
|
||||||
case 'western':
|
|
||||||
return {
|
|
||||||
value: blood?.western_medicine || '',
|
|
||||||
isHigh: false,
|
|
||||||
hasRecord: !!blood?.western_medicine
|
|
||||||
}
|
|
||||||
case 'insulin':
|
|
||||||
return {
|
|
||||||
value: blood?.insulin || '',
|
|
||||||
isHigh: false,
|
|
||||||
hasRecord: !!blood?.insulin
|
|
||||||
}
|
|
||||||
case 'breakfast':
|
|
||||||
return {
|
|
||||||
value: diet?.breakfast || diet?.breakfast_foods ? '✓' : '',
|
|
||||||
isHigh: false,
|
|
||||||
hasRecord: !!(diet?.breakfast || diet?.breakfast_foods)
|
|
||||||
}
|
|
||||||
case 'lunch':
|
|
||||||
return {
|
|
||||||
value: diet?.lunch || diet?.lunch_foods ? '✓' : '',
|
|
||||||
isHigh: false,
|
|
||||||
hasRecord: !!(diet?.lunch || diet?.lunch_foods)
|
|
||||||
}
|
|
||||||
case 'dinner':
|
|
||||||
return {
|
|
||||||
value: diet?.dinner || diet?.dinner_foods ? '✓' : '',
|
|
||||||
isHigh: false,
|
|
||||||
hasRecord: !!(diet?.dinner || diet?.dinner_foods)
|
|
||||||
}
|
|
||||||
case 'exercise':
|
|
||||||
return {
|
|
||||||
value: exercise?.duration ? `${exercise.duration}min` : '',
|
|
||||||
isHigh: false,
|
|
||||||
hasRecord: !!exercise?.id
|
|
||||||
}
|
|
||||||
case 'tracking':
|
|
||||||
return {
|
|
||||||
value: tracking?.content ? summarizeTracking(tracking.content) : '',
|
|
||||||
isHigh: false,
|
|
||||||
hasRecord: !!tracking?.content
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return { value: '', isHigh: false, hasRecord: false }
|
|
||||||
}
|
}
|
||||||
|
const field = bloodFields[metric]
|
||||||
|
if (field || metric === 'bp') {
|
||||||
|
const rows = blood.filter((record) => metric === 'bp'
|
||||||
|
? !!(record.systolic_pressure || record.diastolic_pressure)
|
||||||
|
: record[field] != null && record[field] !== '')
|
||||||
|
const isHigh = (record: BloodRecord) => {
|
||||||
|
if (metric === 'fasting') return isHighFastingBloodSugar(record.fasting_blood_sugar, props.age)
|
||||||
|
if (metric === 'postprandial') return isHighPostprandialBloodSugar(record.postprandial_blood_sugar, props.age)
|
||||||
|
if (metric === 'other') return isHighOtherBloodSugar(record.other_blood_sugar, props.age)
|
||||||
|
return metric === 'bp' && isHighBloodPressure(record)
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
value: rows.map((record) => metric === 'bp' ? formatBp(record) : String(record[field])).join(' / '),
|
||||||
|
isHigh: rows.some(isHigh), hasRecord: rows.length > 0,
|
||||||
|
isPatientSelf: rows.some((record) => Number(record.source) === 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (['breakfast', 'lunch', 'dinner'].includes(metric)) {
|
||||||
|
const count = diet.filter((record) => record[metric] || record[`${metric}_foods`] || record[`${metric}_images`]?.length).length
|
||||||
|
return { value: count ? '✓' : '', isHigh: false, hasRecord: count > 0 }
|
||||||
|
}
|
||||||
|
if (metric === 'exercise') {
|
||||||
|
return { value: exercise.map((record) => record.duration != null ? `${record.duration}min` : record.exercise_type || '已记录').join(' / '), isHigh: false, hasRecord: exercise.length > 0 }
|
||||||
|
}
|
||||||
|
if (metric === 'tracking') {
|
||||||
|
return { value: tracking?.content ? summarizeTracking(tracking.content) : '', isHigh: false, hasRecord: !!tracking?.content }
|
||||||
|
}
|
||||||
|
return { value: '', isHigh: false, hasRecord: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchTracking() {
|
async function fetchTracking() {
|
||||||
if (!diagnosisId.value) {
|
const seq = ++requestSeq
|
||||||
|
const requestDiagnosisId = diagnosisId.value
|
||||||
|
if (!requestDiagnosisId) {
|
||||||
|
loading.value = false
|
||||||
bloodRecords.value = []
|
bloodRecords.value = []
|
||||||
dietRecords.value = []
|
dietRecords.value = []
|
||||||
exerciseRecords.value = []
|
exerciseRecords.value = []
|
||||||
@@ -845,23 +908,23 @@ async function fetchTracking() {
|
|||||||
}
|
}
|
||||||
const { start, end } = currentRange.value
|
const { start, end } = currentRange.value
|
||||||
if (rangeMode.value === 'custom' && (!start || !end)) return
|
if (rangeMode.value === 'custom' && (!start || !end)) return
|
||||||
const seq = ++requestSeq
|
const requestDates = [...visibleDates.value]
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const data: any = await diagnosisTrackingWindow({
|
const data: any = await diagnosisTrackingWindow({
|
||||||
id: diagnosisId.value,
|
id: requestDiagnosisId,
|
||||||
start_date: start,
|
start_date: start,
|
||||||
end_date: end
|
end_date: end
|
||||||
})
|
})
|
||||||
const noteData: any = await diagnosisTrackingNotes({
|
const noteData: any = await diagnosisTrackingNotes({
|
||||||
diagnosis_id: diagnosisId.value
|
diagnosis_id: requestDiagnosisId
|
||||||
})
|
})
|
||||||
if (seq !== requestSeq) return
|
if (seq !== requestSeq || requestDiagnosisId !== diagnosisId.value) return
|
||||||
bloodRecords.value = Array.isArray(data?.blood_records) ? data.blood_records : []
|
bloodRecords.value = Array.isArray(data?.blood_records) ? data.blood_records : []
|
||||||
dietRecords.value = Array.isArray(data?.diet_records) ? data.diet_records : []
|
dietRecords.value = Array.isArray(data?.diet_records) ? data.diet_records : []
|
||||||
exerciseRecords.value = Array.isArray(data?.exercise_records) ? data.exercise_records : []
|
exerciseRecords.value = Array.isArray(data?.exercise_records) ? data.exercise_records : []
|
||||||
trackingNotes.value = Array.isArray(noteData)
|
trackingNotes.value = Array.isArray(noteData)
|
||||||
? noteData.filter((item: any) => visibleDates.value.includes(String(item?.note_date || '')))
|
? noteData.filter((item: any) => requestDates.includes(String(item?.note_date || '')))
|
||||||
: []
|
: []
|
||||||
} catch {
|
} catch {
|
||||||
if (seq === requestSeq) {
|
if (seq === requestSeq) {
|
||||||
@@ -890,76 +953,88 @@ function handleCustomRangeChange() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function recordTimeKey(value: unknown) {
|
||||||
|
const time = String(value || '')
|
||||||
|
return /^\d{2}:\d{2}$/.test(time) ? `${time}:00` : time
|
||||||
|
}
|
||||||
|
|
||||||
|
function openBloodRecord(source: BloodRecord | undefined, date: string) {
|
||||||
|
if (!canEditDailyRecord.value) return
|
||||||
|
bloodForm.value = { ...createBloodForm(date), ...source }
|
||||||
|
originalBloodTime.value = recordTimeKey(source?.record_time)
|
||||||
|
bloodDialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDietRecord(source: DietRecord | undefined, date: string) {
|
||||||
|
if (!canEditDailyRecord.value) return
|
||||||
|
dietForm.value = {
|
||||||
|
...createDietForm(date), ...source,
|
||||||
|
breakfast_images: [...(source?.breakfast_images || [])],
|
||||||
|
lunch_images: [...(source?.lunch_images || [])], dinner_images: [...(source?.dinner_images || [])]
|
||||||
|
}
|
||||||
|
dietDialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openExerciseRecord(source: ExerciseRecord | undefined, date: string) {
|
||||||
|
if (!canEditDailyRecord.value) return
|
||||||
|
exerciseForm.value = {
|
||||||
|
...createExerciseForm(date), ...source,
|
||||||
|
// Existing omissions must not inherit the defaults used only for manual new records.
|
||||||
|
...(source ? { duration: source.duration ?? null, intensity: source.intensity ?? null } : {}),
|
||||||
|
images: [...(source?.images || [])]
|
||||||
|
}
|
||||||
|
exerciseDialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
function openQuickAdd(kind: DialogKind) {
|
function openQuickAdd(kind: DialogKind) {
|
||||||
if (!canEditDailyRecord.value) return
|
if (!canEditDailyRecord.value) return
|
||||||
const date = visibleDates.value[0] || formatDate(new Date())
|
const date = visibleDates.value[0] || formatDate(new Date())
|
||||||
if (kind === 'blood') {
|
if (kind === 'blood') return openBloodRecord(undefined, date)
|
||||||
bloodForm.value = createBloodForm(date)
|
if (kind === 'diet') return openDietRecord(undefined, date)
|
||||||
bloodDialogVisible.value = true
|
if (kind === 'exercise') return openExerciseRecord(undefined, date)
|
||||||
return
|
handleCellClick('tracking', todayDate.value)
|
||||||
}
|
|
||||||
if (kind === 'diet') {
|
|
||||||
dietForm.value = createDietForm(date)
|
|
||||||
dietDialogVisible.value = true
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (kind === 'tracking') {
|
|
||||||
const today = todayDate.value
|
|
||||||
const source = trackingByDate.value.get(today)
|
|
||||||
trackingForm.value = {
|
|
||||||
note_date: today,
|
|
||||||
existing_content: String(source?.content || ''),
|
|
||||||
content: ''
|
|
||||||
}
|
|
||||||
trackingDialogVisible.value = true
|
|
||||||
return
|
|
||||||
}
|
|
||||||
exerciseForm.value = createExerciseForm(date)
|
|
||||||
exerciseDialogVisible.value = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleCellClick(metric: string, date: string) {
|
function handleCellClick(metric: string, date: string) {
|
||||||
if (!canEditDailyRecord.value) return
|
if (!isCellClickable(metric, date)) return
|
||||||
if (['fasting', 'postprandial', 'other', 'bp', 'western', 'insulin'].includes(metric)) {
|
|
||||||
const source = bloodByDate.value.get(date)
|
|
||||||
bloodForm.value = source ? { ...createBloodForm(date), ...source } : createBloodForm(date)
|
|
||||||
bloodDialogVisible.value = true
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (['breakfast', 'lunch', 'dinner'].includes(metric)) {
|
|
||||||
const source = dietByDate.value.get(date)
|
|
||||||
dietForm.value = source ? { ...createDietForm(date), ...source } : createDietForm(date)
|
|
||||||
dietDialogVisible.value = true
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (metric === 'tracking') {
|
if (metric === 'tracking') {
|
||||||
const source = trackingByDate.value.get(date)
|
const source = trackingByDate.value.get(date)
|
||||||
if (!source && date !== todayDate.value) return
|
trackingForm.value = { note_date: date, existing_content: String(source?.content || ''), content: '' }
|
||||||
trackingForm.value = {
|
|
||||||
note_date: date,
|
|
||||||
existing_content: String(source?.content || ''),
|
|
||||||
content: ''
|
|
||||||
}
|
|
||||||
trackingDialogVisible.value = true
|
trackingDialogVisible.value = true
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const source = exerciseByDate.value.get(date)
|
const count = (bloodByDate.value.get(date)?.length || 0) + (dietByDate.value.get(date)?.length || 0) + (exerciseByDate.value.get(date)?.length || 0)
|
||||||
exerciseForm.value = source ? { ...createExerciseForm(date), ...source } : createExerciseForm(date)
|
if (count) {
|
||||||
exerciseDialogVisible.value = true
|
detailDate.value = date
|
||||||
|
dayDetailVisible.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (['breakfast', 'lunch', 'dinner'].includes(metric)) return openDietRecord(undefined, date)
|
||||||
|
if (metric === 'exercise') return openExerciseRecord(undefined, date)
|
||||||
|
openBloodRecord(undefined, date)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitBlood() {
|
async function submitBlood() {
|
||||||
|
if (!canEditDailyRecord.value || bloodSubmitting.value) return
|
||||||
if (!bloodForm.value.record_date) {
|
if (!bloodForm.value.record_date) {
|
||||||
feedback.msgWarning('请选择记录日期')
|
feedback.msgWarning('请选择记录日期')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
const targetDiagnosisId = diagnosisId.value
|
||||||
|
const payload = { ...bloodForm.value }
|
||||||
|
if ('record_time_estimated' in payload && recordTimeKey(payload.record_time) !== originalBloodTime.value) {
|
||||||
|
payload.record_time_estimated = 0
|
||||||
|
payload.record_time_period = ''
|
||||||
|
payload.record_time_text = ''
|
||||||
|
}
|
||||||
bloodSubmitting.value = true
|
bloodSubmitting.value = true
|
||||||
try {
|
try {
|
||||||
if (bloodForm.value.id) {
|
if (payload.id) {
|
||||||
await bloodRecordEdit(bloodForm.value)
|
await bloodRecordEdit(payload)
|
||||||
} else {
|
} else {
|
||||||
await bloodRecordAdd(bloodForm.value)
|
await bloodRecordAdd(payload)
|
||||||
}
|
}
|
||||||
|
if (targetDiagnosisId !== diagnosisId.value) return
|
||||||
bloodDialogVisible.value = false
|
bloodDialogVisible.value = false
|
||||||
await fetchTracking()
|
await fetchTracking()
|
||||||
} finally {
|
} finally {
|
||||||
@@ -968,17 +1043,21 @@ async function submitBlood() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function submitDiet() {
|
async function submitDiet() {
|
||||||
|
if (!canEditDailyRecord.value || dietSubmitting.value) return
|
||||||
if (!dietForm.value.record_date) {
|
if (!dietForm.value.record_date) {
|
||||||
feedback.msgWarning('请选择记录日期')
|
feedback.msgWarning('请选择记录日期')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
const targetDiagnosisId = diagnosisId.value
|
||||||
|
const payload = { ...dietForm.value }
|
||||||
dietSubmitting.value = true
|
dietSubmitting.value = true
|
||||||
try {
|
try {
|
||||||
if (dietForm.value.id) {
|
if (payload.id) {
|
||||||
await dietRecordEdit(dietForm.value)
|
await dietRecordEdit(payload)
|
||||||
} else {
|
} else {
|
||||||
await dietRecordAdd(dietForm.value)
|
await dietRecordAdd(payload)
|
||||||
}
|
}
|
||||||
|
if (targetDiagnosisId !== diagnosisId.value) return
|
||||||
dietDialogVisible.value = false
|
dietDialogVisible.value = false
|
||||||
await fetchTracking()
|
await fetchTracking()
|
||||||
} finally {
|
} finally {
|
||||||
@@ -987,6 +1066,7 @@ async function submitDiet() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function submitExercise() {
|
async function submitExercise() {
|
||||||
|
if (!canEditDailyRecord.value || exerciseSubmitting.value) return
|
||||||
if (!exerciseForm.value.record_date) {
|
if (!exerciseForm.value.record_date) {
|
||||||
feedback.msgWarning('请选择记录日期')
|
feedback.msgWarning('请选择记录日期')
|
||||||
return
|
return
|
||||||
@@ -995,17 +1075,20 @@ async function submitExercise() {
|
|||||||
feedback.msgWarning('请选择运动类型')
|
feedback.msgWarning('请选择运动类型')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!exerciseForm.value.duration) {
|
if (!exerciseForm.value.id && !exerciseForm.value.duration) {
|
||||||
feedback.msgWarning('请输入运动时长')
|
feedback.msgWarning('请输入运动时长')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
const targetDiagnosisId = diagnosisId.value
|
||||||
|
const payload = { ...exerciseForm.value, duration: exerciseForm.value.duration ?? null, intensity: exerciseForm.value.intensity ?? null }
|
||||||
exerciseSubmitting.value = true
|
exerciseSubmitting.value = true
|
||||||
try {
|
try {
|
||||||
if (exerciseForm.value.id) {
|
if (payload.id) {
|
||||||
await exerciseRecordEdit(exerciseForm.value)
|
await exerciseRecordEdit(payload)
|
||||||
} else {
|
} else {
|
||||||
await exerciseRecordAdd(exerciseForm.value)
|
await exerciseRecordAdd(payload)
|
||||||
}
|
}
|
||||||
|
if (targetDiagnosisId !== diagnosisId.value) return
|
||||||
exerciseDialogVisible.value = false
|
exerciseDialogVisible.value = false
|
||||||
await fetchTracking()
|
await fetchTracking()
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1014,6 +1097,7 @@ async function submitExercise() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function submitTracking() {
|
async function submitTracking() {
|
||||||
|
if (trackingSubmitting.value) return
|
||||||
if (!trackingForm.value.note_date) {
|
if (!trackingForm.value.note_date) {
|
||||||
feedback.msgWarning('请选择记录日期')
|
feedback.msgWarning('请选择记录日期')
|
||||||
return
|
return
|
||||||
@@ -1027,12 +1111,14 @@ async function submitTracking() {
|
|||||||
feedback.msgWarning('请输入跟踪备注')
|
feedback.msgWarning('请输入跟踪备注')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
const targetDiagnosisId = diagnosisId.value
|
||||||
trackingSubmitting.value = true
|
trackingSubmitting.value = true
|
||||||
try {
|
try {
|
||||||
await diagnosisAddTrackingNote({
|
await diagnosisAddTrackingNote({
|
||||||
diagnosis_id: diagnosisId.value,
|
diagnosis_id: targetDiagnosisId,
|
||||||
tracking_content: content
|
tracking_content: content
|
||||||
})
|
})
|
||||||
|
if (targetDiagnosisId !== diagnosisId.value) return
|
||||||
trackingDialogVisible.value = false
|
trackingDialogVisible.value = false
|
||||||
await fetchTracking()
|
await fetchTracking()
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1057,17 +1143,23 @@ function getTrackingPreview(content: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isCellClickable(metric: string, date: string) {
|
function isCellClickable(metric: string, date: string) {
|
||||||
if (!canEditDailyRecord.value) return false
|
if (metric === 'tracking') return !!getTrackingContent(date) || (canEditDailyRecord.value && date === todayDate.value)
|
||||||
if (metric !== 'tracking') return true
|
return canEditDailyRecord.value || getDayRecordCount(metric, date) > 0
|
||||||
return date === todayDate.value || !!getTrackingContent(date)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.diagnosisId,
|
() => props.diagnosisId,
|
||||||
(value) => {
|
() => {
|
||||||
if (value) {
|
dayDetailVisible.value = false
|
||||||
fetchTracking()
|
bloodDialogVisible.value = false
|
||||||
}
|
dietDialogVisible.value = false
|
||||||
|
exerciseDialogVisible.value = false
|
||||||
|
trackingDialogVisible.value = false
|
||||||
|
bloodRecords.value = []
|
||||||
|
dietRecords.value = []
|
||||||
|
exerciseRecords.value = []
|
||||||
|
trackingNotes.value = []
|
||||||
|
void fetchTracking()
|
||||||
void nextTick(updateTableWrapWidth)
|
void nextTick(updateTableWrapWidth)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -1081,6 +1173,7 @@ onMounted(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
requestSeq++
|
||||||
window.removeEventListener('resize', updateTableWrapWidth)
|
window.removeEventListener('resize', updateTableWrapWidth)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1116,6 +1209,29 @@ defineExpose({ refresh: fetchTracking })
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__hint, &__time-evidence {
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__record-count { color: var(--el-color-primary); font-size: 11px; white-space: nowrap; }
|
||||||
|
&__detail-section + &__detail-section { margin-top: 24px; }
|
||||||
|
&__detail-heading { display: flex; justify-content: space-between; align-items: center; gap: 12px; }
|
||||||
|
&__detail-heading h3 { margin: 0 0 8px; }
|
||||||
|
&__detail-heading small { color: var(--el-text-color-secondary); font-weight: normal; }
|
||||||
|
&__detail-record { border: 1px solid var(--el-border-color); border-radius: 8px; padding: 12px; margin-bottom: 12px; }
|
||||||
|
&__detail-tags { display: flex; gap: 8px; flex-wrap: wrap; margin: 8px 0; }
|
||||||
|
&__detail-fields {
|
||||||
|
display: grid; grid-template-columns: 96px minmax(0, 1fr); gap: 8px; margin: 12px 0 0;
|
||||||
|
dt { color: var(--el-text-color-secondary); }
|
||||||
|
dd { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||||
|
}
|
||||||
|
&__detail-images { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; }
|
||||||
|
&__detail-images .el-image { width: 64px; height: 64px; border-radius: 4px; }
|
||||||
|
|
||||||
&__chart {
|
&__chart {
|
||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
padding: 16px 18px;
|
padding: 16px 18px;
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<template>
|
||||||
|
<span v-if="readonly" class="readonly-value">{{ fieldText(modelValue, field) }}</span>
|
||||||
|
<el-input-number v-else-if="field.type === 'number'" :model-value="numberValue" :disabled="disabled" :controls="false" @update:model-value="(value: number | undefined | null) => emit('update:modelValue', value ?? null)" />
|
||||||
|
<el-select v-else-if="field.type === 'select' || field.type === 'multiselect'" :model-value="selectValue" :multiple="field.type === 'multiselect'" :disabled="disabled" clearable filterable @update:model-value="(value: FollowupValue) => emit('update:modelValue', value)">
|
||||||
|
<el-option v-for="option in field.options || []" :key="String(option.value)" :label="option.label" :value="option.value" />
|
||||||
|
</el-select>
|
||||||
|
<el-date-picker v-else-if="field.type === 'date'" :model-value="textValue" type="date" value-format="YYYY-MM-DD" :disabled="disabled" @update:model-value="(value: string | null) => emit('update:modelValue', value || null)" />
|
||||||
|
<el-input v-else :model-value="textValue" type="textarea" :autosize="{ minRows: 1, maxRows: 5 }" :disabled="disabled" @update:model-value="(value: FollowupValue) => emit('update:modelValue', value)" />
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import type { FollowupField, FollowupValue } from '@/api/followupAudio'
|
||||||
|
import { fieldText } from './followupAudioState'
|
||||||
|
const props = defineProps<{ field: FollowupField; modelValue?: FollowupValue; disabled?: boolean; readonly?: boolean }>()
|
||||||
|
const emit = defineEmits<{ (event: 'update:modelValue', value: FollowupValue): void }>()
|
||||||
|
const numberValue = computed(() => props.modelValue === null || props.modelValue === undefined || props.modelValue === '' ? undefined : Number(props.modelValue))
|
||||||
|
const textValue = computed(() => props.modelValue === null || props.modelValue === undefined ? '' : String(props.modelValue))
|
||||||
|
const selectValue = computed(() => {
|
||||||
|
const canonical = (value: string | number) => props.field.options?.find(option => String(option.value) === String(value))?.value ?? value
|
||||||
|
if (props.field.type === 'multiselect') return Array.isArray(props.modelValue) ? props.modelValue.map(canonical) : []
|
||||||
|
return typeof props.modelValue === 'string' || typeof props.modelValue === 'number' ? canonical(props.modelValue) : ''
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
<style scoped>
|
||||||
|
.readonly-value { white-space: pre-wrap; overflow-wrap: anywhere; line-height: 1.6; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,722 @@
|
|||||||
|
<template>
|
||||||
|
<section v-if="featureEnabled" class="followup-panel" aria-label="回访录音">
|
||||||
|
<div class="panel-toolbar">
|
||||||
|
<div class="toolbar-title">
|
||||||
|
<el-button v-if="selectedId" link type="primary" :disabled="busy" @click="backToList">返回列表</el-button>
|
||||||
|
<h3 v-else>回访录音 <span>{{ tasks.length }}</span></h3>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar-actions">
|
||||||
|
<el-button link :aria-expanded="helpVisible" @click="helpVisible = !helpVisible">使用说明</el-button>
|
||||||
|
<el-button :loading="loading" :disabled="busy" @click="refresh">刷新</el-button>
|
||||||
|
<el-button v-if="!selectedId && !viewOnly && capabilities.can_upload" type="primary" :disabled="!canUpload || busy || createUncertain" @click="openUpload">上传录音</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="helpVisible" class="help-box">
|
||||||
|
<p v-if="previewOnly">{{ capabilities.preview_only ? '当前仅开放测试识别与提炼,可核对并保存审核草稿;测试预览任务不提供确认写入。' : '这是历史测试任务,不写入病例;新上传的真实录音可按正常业务流程审核后确认入单。' }}</p>
|
||||||
|
<p v-else>录音只生成建议,不会自动写入。逐项核对后选择采纳,再确认写入;日常记录按日期和时段分别保留。</p>
|
||||||
|
<p>仅当前可见标签页每 5 秒刷新;已编辑内容不会被覆盖。后台分析不受关闭页面影响,未知结果请先核对任务或账单,勿重复上传。</p>
|
||||||
|
<p v-if="detail">任务 #{{ detail.id }} · 版本 {{ detail.version }} · 保留至 {{ formatExpiry(detail.expires_at) }}</p>
|
||||||
|
</div>
|
||||||
|
<el-alert v-if="previewOnly" type="warning" :closable="false" :title="capabilities.preview_only ? '仅测试识别与提炼,不写入病例' : '历史测试任务,仅供查看和审阅'" description="此任务保留测试用途。新业务录音请新建任务,人工核对后确认入单。" show-icon />
|
||||||
|
<el-alert v-if="!audioReady" type="warning" :closable="false" :title="capabilities.preview_only ? '测试预览尚未就绪,暂不可上传或重试。已有任务可查看。' : '当前环境尚未通过音频能力验证,暂不可上传或重试。已有任务可查看。'" />
|
||||||
|
<el-alert v-else-if="capabilities.can_upload && !capabilities.models.length" type="warning" :closable="false" title="暂无可用的分析模型,暂不可上传。已有任务可查看。" />
|
||||||
|
<el-alert v-if="basicDirty" type="warning" :closable="false" :title="previewOnly ? '病历有未保存修改,测试预览不会覆盖这些修改。' : '病历有未保存修改,请先保存病历,再写入录音建议。'" />
|
||||||
|
<el-alert v-if="createUncertain" type="warning" :closable="false" title="创建结果未确认,已停止重复提交。请刷新列表并联系管理员核对,不要重新上传同一录音。" />
|
||||||
|
<el-alert v-if="errorMessage" type="error" :closable="false" :title="errorMessage" show-icon />
|
||||||
|
<div v-if="!selectedId" class="task-list">
|
||||||
|
<el-table :data="tasks" max-height="440" size="small" table-layout="fixed" empty-text="暂无录音,上传后可提炼回访资料" aria-label="录音任务列表">
|
||||||
|
<el-table-column label="录音" min-width="210">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button class="filename-button" link type="primary" :disabled="busy" @click="openTask(row.id)">{{ row.file_name }}</el-button>
|
||||||
|
<div class="task-time">{{ row.recorded_at }}</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="状态" width="145">
|
||||||
|
<template #default="{ row }"><el-tag size="small" :type="statusType(row.status)">{{ followupTaskStatus(row) }}</el-tag><div v-if="row.transcript_available && (row.status === 'failed' || row.status === 'needs_reconciliation')" class="task-time">转写已保留</div></template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="112" align="right">
|
||||||
|
<template #default="{ row }"><el-button link type="primary" :disabled="busy" @click="openTask(row.id)">查看</el-button><el-button v-if="!viewOnly && row.can_delete !== undefined" link type="danger" :disabled="busy || !canDeleteTask(row)" :title="row.delete_block_reason || '删除未入单录音任务'" @click="deleteRecording(row)">删除</el-button></template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="!detail" class="detail-loading" role="status">正在加载录音任务…</div>
|
||||||
|
<section v-if="selectedId && detail" class="task-detail" aria-label="录音任务详情">
|
||||||
|
<div class="detail-heading">
|
||||||
|
<div class="recording-title"><h3>{{ detail.file_name }}</h3><span>通话时间 {{ detail.recorded_at }}</span></div>
|
||||||
|
<div class="detail-actions"><el-tag size="small" :type="statusType(detail.status)">{{ followupTaskStatus(detail) }}</el-tag><el-button v-if="!viewOnly && detail.can_delete !== undefined" link type="danger" :disabled="busy || !canDeleteTask(detail)" :title="detail.delete_block_reason || '删除未入单录音任务'" @click="deleteRecording(detail)">删除录音</el-button></div>
|
||||||
|
</div>
|
||||||
|
<p v-if="detail.status === 'running' || detail.status === 'queued'" class="processing-state" role="status">{{ followupProgressText(detail) }}</p>
|
||||||
|
<el-alert v-if="detail.error_message || detail.status === 'failed'" :type="detail.status === 'failed' ? 'error' : 'warning'" :closable="false" :title="detail.error_message || '任务处理失败,不能写入;请先核对失败原因。'" />
|
||||||
|
<el-alert v-if="retainedTranscriptNotice(detail)" type="warning" :closable="false" :title="retainedTranscriptNotice(detail)" />
|
||||||
|
<el-alert v-if="detail.status === 'needs_reconciliation'" type="error" :closable="false" title="上游结果待核对,禁止重发。请管理员先确认上游任务或账单。" />
|
||||||
|
<el-alert v-if="detail.status === 'expired'" type="warning" :closable="false" title="录音和转录已过保留期,不能继续写入;已采纳的业务记录保留。" />
|
||||||
|
<el-button v-if="detail.can_retry && detail.status === 'failed' && !viewOnly" class="retry-button" :disabled="!canUpload || busy" @click="retryTask">重试已确认失败的任务</el-button>
|
||||||
|
<div class="summary-box">
|
||||||
|
<div class="muted">回访摘要 · AI未核实草稿,不代表已确认病情</div>
|
||||||
|
<p>{{ detail.summary || '暂无回访摘要' }}</p>
|
||||||
|
<div class="summary-counts"><span>{{ detail.status === 'applied' ? '已写入' : '已选' }} <strong>{{ selectedCount }}</strong> 项</span><span>待核实 <strong>{{ pendingCount }}</strong> 项</span></div>
|
||||||
|
</div>
|
||||||
|
<el-alert v-if="detail.status === 'review' && !reviewItems.length" type="warning" :closable="false" title="未生成可写入信息" :description="detail.uncertainties.length ? `本次提炼保留了 ${detail.uncertainties.length} 条待核实原因,已展示在下方。请结合原文回听核对,尤其注意声道角色是否相符。` : '请查看回访摘要并回听录音;本次没有可供确认写入的结构化候选。'" show-icon />
|
||||||
|
<section v-if="hasStereo && !['queued', 'running'].includes(detail.status)" class="channel-role-box" aria-label="声道角色人工标注">
|
||||||
|
<div v-if="savedChannelRoles !== 'unconfirmed' && !editingRoles && !roleDirty" class="role-summary"><span>{{ savedChannelRoles === 'left_service' ? '左客服端 / 右患者或家属端' : '右客服端 / 左患者或家属端' }} · 已确认</span><el-button v-if="canEditReview" link type="primary" @click="editingRoles = true">修改</el-button></div>
|
||||||
|
<label v-else>声道角色(人工确认)<el-select :model-value="channelRoles" :disabled="!canEditReview || (busy && mutation !== 'save')" @update:model-value="setChannelRoles"><el-option label="角色待确认" value="unconfirmed" /><el-option label="左客服端 / 右患者或家属端" value="left_service" /><el-option label="右客服端 / 左患者或家属端" value="right_service" /></el-select></label>
|
||||||
|
<p v-if="savedChannelRoles === 'unconfirmed' || editingRoles || roleDirty" class="muted">声道不等于已核实的说话人身份。角色标注不自动重新提炼;变更后保存草稿会取消全部采纳,须重新核对。</p>
|
||||||
|
<p v-if="savedChannelRoles !== 'unconfirmed' && !roleDirty" class="muted">当前角色已确认,无需再次选择;只有左右标错时才需修改。</p>
|
||||||
|
<p v-if="roleDirty" class="review-error" role="status">角色标注正在等待保存;保存后请逐项重新核对。</p>
|
||||||
|
</section>
|
||||||
|
<section v-if="detail.role_check?.conflict || (detail.can_reextract !== undefined && savedChannelRoles !== extractionRoles && savedChannelRoles !== 'unconfirmed')" class="role-reextract" aria-label="角色纠错与重新提炼">
|
||||||
|
<strong>{{ detail.role_check?.conflict ? '声道角色与转写内容可能不一致' : '审核角色已修改,原提炼结果尚未更新' }}</strong>
|
||||||
|
<p>{{ detail.role_check?.message || '修改角色标注不会自动改变已生成的建议,可按核实后的角色重新提炼。' }}</p>
|
||||||
|
<blockquote v-for="(evidence, index) in detail.role_check?.evidence || []" :key="index"><span>{{ evidence.channel === 0 ? '左声道' : '右声道' }} · {{ audioTimestamp(evidence.start_ms) }}:{{ evidence.text }}</span><el-button v-if="detail.audio_available" link type="primary" @click="loadDialogue(evidence.start_ms)">回听依据</el-button></blockquote>
|
||||||
|
<p>当前提炼依据:{{ extractionRoleLabel(extractionRoles) }}。{{ reextractTarget !== 'unconfirmed' ? `待确认的新角色:${extractionRoleLabel(reextractTarget)}。` : '请先回听并修改上方角色标注。' }}建议仅供核对,系统不会自动交换角色。</p>
|
||||||
|
<el-button type="primary" plain :disabled="!canReextract || busy || dirty || reextractTarget === 'unconfirmed' || reextractTarget === extractionRoles" @click="reextractWithRoles">确认角色并重新提炼</el-button>
|
||||||
|
<p v-if="dirty">请先完成当前单项核对或取消编辑,等待审核草稿保存后再重新提炼。</p>
|
||||||
|
<p v-else-if="detail.reextract_block_reason">{{ detail.reextract_block_reason }}</p>
|
||||||
|
<p class="muted">复用已完成的转写,不重复语音识别;旧结果保留,重新提炼不自动写入病例。</p>
|
||||||
|
</section>
|
||||||
|
<p v-if="(detail.extraction_revision || 0) > 0" class="muted">第 {{ (detail.extraction_revision || 0) + 1 }} 版提炼 · {{ extractionRoleLabel(extractionRoles) }} · 历史结果已保留</p>
|
||||||
|
<FollowupAudioReview :key="selectedId" ref="reviewRef" v-model:items="reviewItems" :fields="detail.fields || capabilities.fields" :disabled="!canEditReview || uploading || (mutation !== '' && mutation !== 'save')" :can-daily="capabilities.can_daily" :applied="detail.status === 'applied'" :uncertainties="detail.uncertainties" :snapshot-items="detail.items" :channel-roles="savedChannelRoles" :save-state="saveState" :persisted-revision="persistedRevision" @saved-next="saveDraft(true)" @seek="loadDialogue">
|
||||||
|
<template #player>
|
||||||
|
<div class="audio-player">
|
||||||
|
<el-button v-if="detail.audio_available" size="small" :loading="audioLoading" @click="reloadAudio">{{ audioUrl ? '重新加载录音' : '加载录音播放' }}</el-button>
|
||||||
|
<span v-else class="muted">录音不可播放或已清理</span>
|
||||||
|
<audio v-if="audioUrl" :key="audioUrl" ref="audioElement" :src="audioUrl" controls preload="metadata" />
|
||||||
|
</div>
|
||||||
|
<div v-if="hasStereo && detail.audio_available" class="channel-playback" aria-label="声道回听">
|
||||||
|
<el-button size="small" :type="playbackChannel === 'original' ? 'primary' : 'default'" :aria-pressed="playbackChannel === 'original'" :disabled="audioLoading || channelLoading" @click="playChannel('original')">完整对话</el-button>
|
||||||
|
<el-button v-for="channel in ([0, 1] as const)" :key="channel" size="small" :type="playbackChannel === channel ? 'primary' : 'default'" :aria-pressed="playbackChannel === channel" :disabled="audioLoading || channelLoading" @click="playChannel(channel)">{{ channelPlaybackLabel(channel, savedChannelRoles) }}</el-button>
|
||||||
|
<span class="muted" role="status">{{ channelLoading ? '正在切换声道…' : !audioUrl ? '尚未加载录音' : playbackChannel === 'original' ? '回听模式:双方对话' : `回听模式:仅${playbackChannel === 0 ? '左' : '右'}声道` }}</span>
|
||||||
|
</div>
|
||||||
|
<p v-if="playbackError" class="review-error" role="alert">{{ playbackError }}</p>
|
||||||
|
<p v-if="detail.audio_available" class="muted">回听对话默认从该区间前 3 秒开始,连续播放双方声音;可随时切换为只听一方。</p>
|
||||||
|
</template>
|
||||||
|
</FollowupAudioReview>
|
||||||
|
<el-collapse v-model="detailSections" class="original-collapse">
|
||||||
|
<el-collapse-item title="完整对话转写" name="original">
|
||||||
|
<div v-if="transcriptSegments.length" class="transcript-segments">
|
||||||
|
<p class="muted">双方转写按同一时间区间并列展示,不表示发言先后;重叠发言与具体问答请结合录音核对。</p>
|
||||||
|
<section v-for="window in conversationWindows" :key="window.id" class="transcript-segment">
|
||||||
|
<div class="segment-heading"><span>{{ audioTimestamp(window.start_ms) }}–{{ audioTimestamp(window.end_ms) }}</span><el-button v-if="detail.audio_available" link type="primary" @click="loadDialogue(window.start_ms)">回听双方对话</el-button></div>
|
||||||
|
<div class="dialogue-sides">
|
||||||
|
<div v-for="segment in window.segments" :key="segment.id" class="dialogue-side">
|
||||||
|
<strong>{{ followupChannelLabel(segment.channel, savedChannelRoles) }}</strong>
|
||||||
|
<pre>{{ segment.text || '(该区间无转写文本,不代表没有说话)' }}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<pre v-else class="transcript-text">{{ detail.transcript || '暂无转录' }}</pre>
|
||||||
|
</el-collapse-item>
|
||||||
|
</el-collapse>
|
||||||
|
<div v-if="detail.status === 'review'" class="review-actions">
|
||||||
|
<div class="selection-summary"><strong>已选 {{ selectedCount }} 项</strong><span>{{ saveStatusText }}</span></div>
|
||||||
|
<div v-if="canEditReview" class="action-buttons">
|
||||||
|
<el-button link :disabled="busy" :loading="mutation === 'save'" @click="saveDraft()">{{ saveState === 'error' || saveState === 'conflict' ? '重试保存草稿' : '立即保存草稿' }}</el-button>
|
||||||
|
<el-button v-if="canApplyReview" type="primary" :disabled="busy || basicDirty || !selectedCount || !!applyIssue" :loading="mutation === 'apply'" @click="applySelected">确认写入</el-button>
|
||||||
|
</div>
|
||||||
|
<p v-if="applyIssue" class="review-error" role="alert">{{ applyIssue }}</p>
|
||||||
|
</div>
|
||||||
|
<p v-if="detail.status === 'applied'" class="applied-note">{{ previewOnly ? '该任务已有历史采纳记录;当前为测试预览,不允许再次写入。' : '本次审核已完成,所选项已写入诊单或日常记录,不能重复写入。' }}</p>
|
||||||
|
</section>
|
||||||
|
<el-dialog v-model="uploadVisible" title="上传回访录音" width="min(520px, calc(100vw - 32px))" append-to-body :close-on-click-modal="false" :close-on-press-escape="!uploading" :show-close="!uploading" :before-close="closeUpload">
|
||||||
|
<el-form label-position="top" class="upload-form">
|
||||||
|
<el-form-item label="录音文件">
|
||||||
|
<input ref="fileInput" type="file" accept="audio/mpeg,audio/mp4,audio/wav,audio/amr,.mp3,.m4a,.wav,.amr" :disabled="!canUpload || busy || createUncertain" aria-label="录音文件" @change="chooseFile" />
|
||||||
|
<small>支持 MP3 / M4A / WAV / AMR,最多 {{ Math.floor(capabilities.limits.max_bytes / 1024 / 1024) }} MB / {{ Math.floor(capabilities.limits.max_seconds / 60) }} 分钟。</small>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="实际通话时间(北京时间)">
|
||||||
|
<el-date-picker v-model="recordedAt" type="datetime" value-format="YYYY-MM-DD HH:mm:ss" placeholder="用于识别昨天、前天等相对日期" :disabled="!canUpload || busy || createUncertain" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="本次录音的声道角色">
|
||||||
|
<el-select v-model="uploadChannelRoles" aria-label="本次录音的声道角色" :disabled="!canUpload || busy || createUncertain">
|
||||||
|
<el-option label="暂不确定 / 单声道" value="unconfirmed" />
|
||||||
|
<el-option label="左客服 / 右患者或家属" value="left_service" />
|
||||||
|
<el-option label="右客服 / 左患者或家属" value="right_service" />
|
||||||
|
</el-select>
|
||||||
|
<small>已知角色会在提炼前提供给模型;不确定请勿猜测。单声道不按左右区分身份。</small>
|
||||||
|
</el-form-item>
|
||||||
|
<el-collapse class="advanced-settings">
|
||||||
|
<el-collapse-item title="高级设置 · 分析模型" name="model">
|
||||||
|
<el-form-item label="分析模型">
|
||||||
|
<el-select v-model="modelKey" :disabled="!canUpload || busy || createUncertain" placeholder="选择已配置的模型"><el-option v-for="model in capabilities.models" :key="model.value" :label="model.label" :value="model.value" /></el-select>
|
||||||
|
</el-form-item>
|
||||||
|
</el-collapse-item>
|
||||||
|
</el-collapse>
|
||||||
|
</el-form>
|
||||||
|
<el-alert v-if="errorMessage" type="error" :closable="false" :title="errorMessage" show-icon />
|
||||||
|
<el-alert v-if="createUncertain && !uploading" type="warning" :closable="false" title="创建结果未确认,请关闭弹窗后刷新任务列表并联系管理员核对,不要重新上传。" />
|
||||||
|
<el-progress v-if="uploading" :percentage="uploadProgress" />
|
||||||
|
<p v-if="uploadStage" class="upload-stage" role="status">{{ uploadStage }}</p>
|
||||||
|
<p class="muted">{{ previewOnly ? '本次仅测试识别与提炼,可保存审核草稿,不允许写入病历。' : '分析仅生成建议,人工确认后才会写入。' }}</p>
|
||||||
|
<template #footer><el-button :disabled="uploading" @click="closeUpload()">取消</el-button><el-button type="primary" :disabled="!canUpload || busy || createUncertain" :loading="uploading" @click="submitUpload">上传并分析</el-button></template>
|
||||||
|
</el-dialog>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import {
|
||||||
|
FollowupAudioError, followupAudioReextract, followupAudioDelete, followupAudioApply, followupAudioBlob, followupAudioCreate,
|
||||||
|
followupAudioDetail, followupAudioLists, followupAudioRetry, followupAudioSaveDraft,
|
||||||
|
followupAudioUploadChunk, followupAudioUploadComplete, followupAudioUploadSession,
|
||||||
|
type FollowupAudioChannel, type FollowupCapabilities, type FollowupChannelRoles, type FollowupReviewItem, type FollowupTaskDetail, type FollowupTaskSummary
|
||||||
|
} from '@/api/followupAudio'
|
||||||
|
import FollowupAudioReview from './FollowupAudioReview.vue'
|
||||||
|
import { channelPlaybackLabel, dialogueWindows, dialoguePlaybackStart, audioTimestamp, changedReviewTarget, cloneReviewItems, createFollowupScope, followupAudioReady, followupCanApply, followupCanReview, followupChannelLabel, followupError, followupFeatureEnabled, followupProgressText, followupTaskStatus, hasStereoTranscript, normalizedChannelRoles, rebaseReviewItems, retainedTranscriptNotice, reviewBatchIssue, reviewFieldIssue, reviewIssue, reviewPending, uploadChunks, uploadIssue } from './followupAudioState'
|
||||||
|
import { createFollowupAudioPlayback, type FollowupPlaybackChannel } from './followupAudioPlayback'
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
diagnosisId: number; viewOnly?: boolean; active?: boolean; capabilities: FollowupCapabilities;
|
||||||
|
basicDirty?: boolean; beforeApply?: () => boolean
|
||||||
|
}>(), { viewOnly: false, active: true, basicDirty: false })
|
||||||
|
const emit = defineEmits<{ (event: 'applied', diagnosisId: number): void }>()
|
||||||
|
const tasks = ref<FollowupTaskSummary[]>([])
|
||||||
|
const detail = ref<FollowupTaskDetail | null>(null)
|
||||||
|
const reviewItems = ref<FollowupReviewItem[]>([])
|
||||||
|
const selectedId = ref(0)
|
||||||
|
const snapshot = ref('[]')
|
||||||
|
const channelRoles = ref<FollowupChannelRoles>('unconfirmed')
|
||||||
|
const roleSnapshot = ref<FollowupChannelRoles>('unconfirmed')
|
||||||
|
const editingRoles = ref(false)
|
||||||
|
const saveState = ref<'idle' | 'pending' | 'saving' | 'saved' | 'error' | 'conflict'>('idle')
|
||||||
|
const persistedRevision = ref(0)
|
||||||
|
const committedDirty = computed(() => roleDirty.value || JSON.stringify(reviewItems.value) !== snapshot.value)
|
||||||
|
const saveStatusText = computed(() => saveState.value === 'saving' ? '正在自动保存…'
|
||||||
|
: saveState.value === 'error' ? '保存失败,修改仍保留;请重试保存'
|
||||||
|
: saveState.value === 'conflict' ? '自动保存已暂停;请重新核对后重试保存'
|
||||||
|
: committedDirty.value ? '等待自动保存…'
|
||||||
|
: reviewRef.value?.hasUnsavedChanges() ? '当前单项尚未完成核对'
|
||||||
|
: saveState.value === 'saved' ? '审核草稿已自动保存' : '草稿已同步')
|
||||||
|
const roleDirty = computed(() => channelRoles.value !== roleSnapshot.value)
|
||||||
|
const savedChannelRoles = computed(() => normalizedChannelRoles(detail.value?.channel_roles))
|
||||||
|
const transcriptSegments = computed(() => [...(detail.value?.transcript_segments || [])].sort((a, b) => a.start_ms - b.start_ms || (a.channel ?? -1) - (b.channel ?? -1)))
|
||||||
|
const conversationWindows = computed(() => dialogueWindows(transcriptSegments.value))
|
||||||
|
const hasStereo = computed(() => hasStereoTranscript(transcriptSegments.value))
|
||||||
|
const reviewRef = ref<{ hasUnsavedChanges: () => boolean } | null>(null)
|
||||||
|
const dirty = computed(() => roleDirty.value || JSON.stringify(reviewItems.value) !== snapshot.value || !!reviewRef.value?.hasUnsavedChanges())
|
||||||
|
const uploadVisible = ref(false)
|
||||||
|
const helpVisible = ref(false)
|
||||||
|
const detailSections = ref<string[]>([])
|
||||||
|
const file = ref<File | null>(null)
|
||||||
|
const fileInput = ref<HTMLInputElement | null>(null)
|
||||||
|
const recordedAt = ref('')
|
||||||
|
const uploadChannelRoles = ref<FollowupChannelRoles>('unconfirmed')
|
||||||
|
const modelKey = ref(props.capabilities.models[0]?.value || '')
|
||||||
|
const loading = ref(false)
|
||||||
|
const uploading = ref(false)
|
||||||
|
const uploadProgress = ref(0)
|
||||||
|
const uploadStage = ref('')
|
||||||
|
const createUncertain = ref(false)
|
||||||
|
const mutation = ref<'save' | 'apply' | 'retry' | 'delete' | 'reextract' | ''>('')
|
||||||
|
const errorMessage = ref('')
|
||||||
|
const audioUrl = ref('')
|
||||||
|
const audioElement = ref<HTMLAudioElement | null>(null)
|
||||||
|
const audioLoading = ref(false)
|
||||||
|
const playbackChannel = ref<FollowupPlaybackChannel>('original')
|
||||||
|
const channelLoading = ref(false)
|
||||||
|
const playbackError = ref('')
|
||||||
|
const documentVisible = ref(typeof document === 'undefined' || document.visibilityState !== 'hidden')
|
||||||
|
const busy = computed(() => uploading.value || !!mutation.value)
|
||||||
|
const featureEnabled = computed(() => followupFeatureEnabled(props.capabilities))
|
||||||
|
const previewOnly = computed(() => !!props.capabilities.preview_only || !!detail.value?.preview_only)
|
||||||
|
const audioReady = computed(() => followupAudioReady(props.capabilities))
|
||||||
|
const canUpload = computed(() => !props.viewOnly && featureEnabled.value && audioReady.value && props.capabilities.can_upload && props.capabilities.models.length > 0)
|
||||||
|
const canEditReview = computed(() => !props.viewOnly && followupCanReview(props.capabilities, !!detail.value?.preview_only) && detail.value?.status === 'review')
|
||||||
|
const canApplyReview = computed(() => !props.viewOnly && followupCanApply(props.capabilities, !!detail.value?.preview_only) && detail.value?.status === 'review')
|
||||||
|
const extractionRoles = computed(() => normalizedChannelRoles(detail.value?.extraction_channel_roles))
|
||||||
|
const reextractTarget = computed<FollowupChannelRoles>(() => {
|
||||||
|
if (savedChannelRoles.value !== 'unconfirmed' && savedChannelRoles.value !== extractionRoles.value) return savedChannelRoles.value
|
||||||
|
return normalizedChannelRoles(detail.value?.role_check?.suggested_roles)
|
||||||
|
})
|
||||||
|
const canReextract = computed(() => !props.viewOnly && featureEnabled.value && detail.value?.status === 'review' && detail.value?.can_reextract === true)
|
||||||
|
const selectedCount = computed(() => reviewItems.value.filter(item => item.selected).length)
|
||||||
|
const pendingCount = computed(() => reviewItems.value.filter(item => reviewPending(item, detail.value?.fields || props.capabilities.fields, props.capabilities.can_daily)).length + (detail.value?.uncertainties.length || 0))
|
||||||
|
const applyIssue = computed(() => previewOnly.value ? '测试预览仅保存审核草稿,不允许确认写入病历'
|
||||||
|
: saveState.value === 'conflict' ? '记录版本已变化,请重新核对后重试保存草稿'
|
||||||
|
: saveState.value === 'error' ? '草稿保存失败,请重试保存成功后再确认写入'
|
||||||
|
: saveState.value === 'saving' || committedDirty.value ? '审核草稿尚未保存完成,请稍候再确认写入'
|
||||||
|
: roleDirty.value ? '声道角色标注尚未保存,请先保存草稿并重新核对建议'
|
||||||
|
: hasStereo.value && savedChannelRoles.value === 'unconfirmed' ? '请先回听并确认声道角色,保存草稿后重新核对建议'
|
||||||
|
: changedReviewTarget(reviewItems.value, detail.value?.items || [])
|
||||||
|
? '写入目标或记录日期已更改,请先保存审核草稿取得新快照,再核对并采纳'
|
||||||
|
: reviewBatchIssue(reviewItems.value, detail.value?.fields || props.capabilities.fields) || reviewItems.value.map(item => reviewFieldIssue(item, detail.value?.fields || props.capabilities.fields) || reviewIssue(item, props.capabilities.can_daily)).find(Boolean) || '')
|
||||||
|
const effectiveActive = computed(() => props.active && featureEnabled.value && documentVisible.value)
|
||||||
|
const scope = createFollowupScope()
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined
|
||||||
|
let autosaveTimer: ReturnType<typeof setTimeout> | undefined
|
||||||
|
let hydrating = false
|
||||||
|
let savePromise: Promise<void> | null = null
|
||||||
|
let pollRunning = false
|
||||||
|
let audioRequest = 0
|
||||||
|
let playbackRequest = 0
|
||||||
|
let playback: ReturnType<typeof createFollowupAudioPlayback> | null = null
|
||||||
|
let playbackElement: HTMLAudioElement | null = null
|
||||||
|
let lastDiagnosis = 0
|
||||||
|
function clearTimer() { if (timer !== undefined) clearTimeout(timer); timer = undefined }
|
||||||
|
function releaseAudio() {
|
||||||
|
audioRequest++; playbackRequest++
|
||||||
|
playback?.dispose(); playback = null; playbackElement = null
|
||||||
|
audioElement.value?.pause()
|
||||||
|
if (audioUrl.value) URL.revokeObjectURL(audioUrl.value)
|
||||||
|
audioUrl.value = ''; audioLoading.value = false; channelLoading.value = false; playbackChannel.value = 'original'; playbackError.value = ''
|
||||||
|
}
|
||||||
|
function stop() {
|
||||||
|
scope.invalidate(); clearTimer(); clearAutosave(); releaseAudio()
|
||||||
|
savePromise = null
|
||||||
|
if (saveState.value === 'saving') { saveState.value = 'error'; errorMessage.value = '保存结果尚未确认;修改仍保留,请刷新核对版本后重试保存。' }
|
||||||
|
loading.value = false; uploading.value = false; mutation.value = ''; pollRunning = false
|
||||||
|
}
|
||||||
|
function installDetail(fresh: FollowupTaskDetail, preserve: boolean, preserveRoles = preserve) {
|
||||||
|
// Completed tasks use the server's confirmed snapshot, never an unsaved local draft.
|
||||||
|
preserve = preserve && fresh.status === 'review'
|
||||||
|
const keepRoles = preserveRoles && roleDirty.value && fresh.status === 'review'
|
||||||
|
if (!fresh.audio_available) releaseAudio()
|
||||||
|
if (retainedTranscriptNotice(fresh) && (!detail.value || detail.value.status !== fresh.status || !detail.value.transcript_available)) {
|
||||||
|
if (!detailSections.value.includes('original')) detailSections.value = [...detailSections.value, 'original']
|
||||||
|
}
|
||||||
|
hydrating = true
|
||||||
|
try {
|
||||||
|
detail.value = fresh
|
||||||
|
const nextItems = preserve ? rebaseReviewItems(reviewItems.value, fresh.items) : cloneReviewItems(fresh.items)
|
||||||
|
if (JSON.stringify(nextItems) !== JSON.stringify(reviewItems.value)) reviewItems.value = nextItems
|
||||||
|
snapshot.value = JSON.stringify(fresh.items)
|
||||||
|
roleSnapshot.value = normalizedChannelRoles(fresh.channel_roles)
|
||||||
|
if (!keepRoles) channelRoles.value = roleSnapshot.value
|
||||||
|
} finally { hydrating = false }
|
||||||
|
}
|
||||||
|
function resetRoles() { hydrating = true; channelRoles.value = 'unconfirmed'; roleSnapshot.value = 'unconfirmed'; hydrating = false; editingRoles.value = false; saveState.value = 'idle'; persistedRevision.value = 0 }
|
||||||
|
function setChannelRoles(value: unknown) {
|
||||||
|
if (!canEditReview.value || (busy.value && mutation.value !== 'save') || !hasStereo.value || !['unconfirmed', 'left_service', 'right_service'].includes(String(value))) return
|
||||||
|
channelRoles.value = value as FollowupChannelRoles
|
||||||
|
}
|
||||||
|
function taskUnavailable(error: unknown): boolean {
|
||||||
|
return error instanceof FollowupAudioError && (error.code === 'FOLLOWUP_AUDIO_TASK_UNAVAILABLE' || error.message.includes('FOLLOWUP_AUDIO_TASK_UNAVAILABLE'))
|
||||||
|
}
|
||||||
|
function forgetUnavailableTask(id: number) {
|
||||||
|
if (selectedId.value !== id) return
|
||||||
|
stop(); resetRoles(); selectedId.value = 0; detail.value = null; reviewItems.value = []; snapshot.value = '[]'; detailSections.value = []
|
||||||
|
tasks.value = tasks.value.filter(row => row.id !== id)
|
||||||
|
errorMessage.value = '录音已删除或不再可访问,已关闭本页缓存与播放;未写入病例。'
|
||||||
|
void refresh()
|
||||||
|
}
|
||||||
|
async function readDetail(id: number, preserve = false, verifyOnly = false) {
|
||||||
|
const ticket = scope.capture()
|
||||||
|
let fresh: FollowupTaskDetail
|
||||||
|
try { fresh = await followupAudioDetail(id, ticket.signal) }
|
||||||
|
catch (error) {
|
||||||
|
if (scope.current(ticket) && id === selectedId.value && taskUnavailable(error)) { forgetUnavailableTask(id); return }
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
if (verifyOnly && detail.value && fresh.version === detail.value.version && fresh.status === detail.value.status) return
|
||||||
|
if (!scope.current(ticket) || id !== selectedId.value || (detail.value && fresh.version < detail.value.version)) return
|
||||||
|
preserve = preserve || (!!detail.value && dirty.value)
|
||||||
|
const newlyApplied = detail.value?.status === 'review' && fresh.status === 'applied'
|
||||||
|
installDetail(fresh, preserve)
|
||||||
|
if (newlyApplied && !previewOnly.value) emit('applied', props.diagnosisId)
|
||||||
|
if (preserve && fresh.status === 'review') { clearAutosave(); saveState.value = 'conflict'; errorMessage.value = '服务端记录或版本已变化;已保留你的字段与日期修改,并取消所有选择。自动保存已暂停,请重新核对后重试保存草稿。' }
|
||||||
|
else if (preserve && newlyApplied) errorMessage.value = '任务已由其他人确认,已显示实际写入结果;本页未保存的修改未写入。'
|
||||||
|
}
|
||||||
|
async function refresh() {
|
||||||
|
if (!effectiveActive.value || !props.diagnosisId || pollRunning || busy.value) return
|
||||||
|
const ticket = scope.capture()
|
||||||
|
pollRunning = true; loading.value = true; clearTimer()
|
||||||
|
try {
|
||||||
|
const response = await followupAudioLists(props.diagnosisId, ticket.signal)
|
||||||
|
if (!scope.current(ticket)) return
|
||||||
|
tasks.value = response.items
|
||||||
|
const updated = tasks.value.find(task => task.id === selectedId.value)
|
||||||
|
if (updated && (!detail.value || updated.version > detail.value.version || (updated.version >= detail.value.version && updated.status !== detail.value.status) || updated.status === 'running')) {
|
||||||
|
await readDetail(updated.id, dirty.value)
|
||||||
|
} else if (!updated && selectedId.value && detail.value) {
|
||||||
|
// A limited list can omit older tasks; only an authoritative detail denial revokes them.
|
||||||
|
await readDetail(selectedId.value, false, true)
|
||||||
|
}
|
||||||
|
} catch (error) { if (scope.current(ticket)) errorMessage.value = followupError(error) }
|
||||||
|
finally {
|
||||||
|
if (scope.current(ticket)) {
|
||||||
|
pollRunning = false; loading.value = false
|
||||||
|
if (effectiveActive.value) timer = setTimeout(() => { void refresh() }, 5000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function confirmLeave(): Promise<boolean> {
|
||||||
|
if (busy.value) { ElMessage.warning('正在上传或提交审核,请等待本次操作结束'); return false }
|
||||||
|
if (!dirty.value) return true
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm('审核修改尚未保存。关闭诊单或切换录音将丢失这些修改,是否继续?', '保留审核修改', { type: 'warning', confirmButtonText: '继续离开', cancelButtonText: '返回保存' })
|
||||||
|
return true
|
||||||
|
} catch { return false }
|
||||||
|
}
|
||||||
|
async function openTask(id: number) {
|
||||||
|
if (!featureEnabled.value || id === selectedId.value || busy.value) return
|
||||||
|
const previous = scope.capture()
|
||||||
|
if (!await confirmLeave() || !scope.current(previous)) return
|
||||||
|
stop(); resetRoles(); selectedId.value = id; detail.value = null; reviewItems.value = []; snapshot.value = '[]'; errorMessage.value = ''; detailSections.value = []
|
||||||
|
const ticket = scope.capture()
|
||||||
|
try { await readDetail(id) } catch (error) { if (scope.current(ticket)) errorMessage.value = followupError(error) }
|
||||||
|
if (scope.current(ticket)) await refresh()
|
||||||
|
}
|
||||||
|
async function backToList() {
|
||||||
|
const ticket = scope.capture()
|
||||||
|
if (!await confirmLeave() || !scope.current(ticket)) return
|
||||||
|
stop(); resetRoles(); selectedId.value = 0; detail.value = null; reviewItems.value = []; snapshot.value = '[]'; detailSections.value = []; errorMessage.value = ''
|
||||||
|
await refresh()
|
||||||
|
}
|
||||||
|
function openUpload() {
|
||||||
|
if (!canUpload.value || busy.value || createUncertain.value) return
|
||||||
|
if (!file.value) uploadChannelRoles.value = 'unconfirmed'
|
||||||
|
uploadVisible.value = true
|
||||||
|
}
|
||||||
|
function closeUpload(done?: () => void) { if (uploading.value) return; uploadVisible.value = false; done?.() }
|
||||||
|
function statusType(status: string) {
|
||||||
|
if (status === 'failed' || status === 'needs_reconciliation') return 'danger'
|
||||||
|
if (status === 'applied') return 'success'
|
||||||
|
if (status === 'review') return 'warning'
|
||||||
|
return 'info'
|
||||||
|
}
|
||||||
|
function chooseFile(event: Event) {
|
||||||
|
const nextFile = (event.target as HTMLInputElement).files?.[0] || null
|
||||||
|
// Cancelling the picker or selecting the first file must not discard a role chosen earlier.
|
||||||
|
if (!nextFile) return
|
||||||
|
if (file.value && nextFile !== file.value) {
|
||||||
|
uploadChannelRoles.value = 'unconfirmed'
|
||||||
|
ElMessage.warning('录音已更换,请重新核对本次录音的声道角色')
|
||||||
|
}
|
||||||
|
file.value = nextFile; uploadProgress.value = 0; uploadStage.value = ''
|
||||||
|
}
|
||||||
|
async function submitUpload() {
|
||||||
|
if (busy.value || createUncertain.value) return
|
||||||
|
const problem = uploadIssue(file.value, recordedAt.value, modelKey.value, props.capabilities, props.viewOnly)
|
||||||
|
if (problem) { ElMessage.warning(problem); return }
|
||||||
|
const recording = file.value
|
||||||
|
if (!recording) return
|
||||||
|
const ticket = scope.capture()
|
||||||
|
uploading.value = true; uploadProgress.value = 0; errorMessage.value = ''; clearTimer()
|
||||||
|
let creating = false
|
||||||
|
try {
|
||||||
|
uploadStage.value = '正在创建私有上传会话'
|
||||||
|
const session = await followupAudioUploadSession({ diagnosis_id: props.diagnosisId, file_name: recording.name, total_bytes: recording.size }, ticket.signal)
|
||||||
|
if (!scope.current(ticket) || !canUpload.value) return
|
||||||
|
uploadStage.value = '正在上传录音分片'
|
||||||
|
const finished = await uploadChunks(recording, session.chunk_bytes,
|
||||||
|
(index, chunk) => followupAudioUploadChunk(session.upload_id, index, chunk, ticket.signal),
|
||||||
|
() => scope.current(ticket) && canUpload.value, percent => { uploadProgress.value = percent })
|
||||||
|
if (!finished) return
|
||||||
|
uploadStage.value = '正在校验录音时长和完整性'
|
||||||
|
await followupAudioUploadComplete(session.upload_id, ticket.signal)
|
||||||
|
if (!scope.current(ticket) || !canUpload.value) return
|
||||||
|
uploadStage.value = '正在创建分析任务'; creating = true; createUncertain.value = true
|
||||||
|
const result = await followupAudioCreate({ diagnosis_id: props.diagnosisId, upload_id: session.upload_id, recorded_at: recordedAt.value, model_key: modelKey.value, channel_roles: uploadChannelRoles.value }, ticket.signal)
|
||||||
|
if (!scope.current(ticket)) return
|
||||||
|
createUncertain.value = false; uploading.value = false; uploadVisible.value = false; file.value = null; if (fileInput.value) fileInput.value.value = ''
|
||||||
|
uploadStage.value = result.reused
|
||||||
|
? result.reuse_message || '同一录音已有任务,已打开原任务,不会重复调用模型'
|
||||||
|
: props.capabilities.preview_only ? '测试任务已创建,后台将识别与提炼;此任务不允许写入业务记录' : '任务已创建,后台将进行分析;审核前不会写入业务记录'
|
||||||
|
if (result.reused) ElMessage.warning(uploadStage.value)
|
||||||
|
await openTask(result.task_id)
|
||||||
|
} catch (error) {
|
||||||
|
if (scope.current(ticket)) { errorMessage.value = followupError(error); createUncertain.value = creating && !(error instanceof FollowupAudioError); uploadStage.value = '本次操作已停止,未自动重试' }
|
||||||
|
} finally { if (scope.current(ticket)) { uploading.value = false; await refresh() } }
|
||||||
|
}
|
||||||
|
async function handleReviewError(error: unknown) {
|
||||||
|
const ticket = scope.capture()
|
||||||
|
if (taskUnavailable(error) && detail.value) { forgetUnavailableTask(detail.value.id); return }
|
||||||
|
errorMessage.value = followupError(error)
|
||||||
|
const stale = error instanceof FollowupAudioError && error.code === 'FOLLOWUP_AUDIO_STALE_REVIEW'
|
||||||
|
if (stale || /STALE_REVIEW|版本|快照|已变化/.test(errorMessage.value)) {
|
||||||
|
clearAutosave(); saveState.value = 'conflict'
|
||||||
|
try { await readDetail(selectedId.value, true) } catch { if (scope.current(ticket)) errorMessage.value += ';刷新失败,当前编辑仍已保留,请稍后刷新。' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function clearAutosave() { if (autosaveTimer !== undefined) clearTimeout(autosaveTimer); autosaveTimer = undefined }
|
||||||
|
function scheduleAutosave() {
|
||||||
|
clearAutosave()
|
||||||
|
if (hydrating || !effectiveActive.value || !canEditReview.value || !committedDirty.value || ['error', 'conflict'].includes(saveState.value)) return
|
||||||
|
if (mutation.value === 'save') return
|
||||||
|
saveState.value = 'pending'
|
||||||
|
autosaveTimer = setTimeout(() => { autosaveTimer = undefined; void saveDraft(true) }, 650)
|
||||||
|
}
|
||||||
|
// Merge only changes made after the request began. Server-owned evidence and hashes always win.
|
||||||
|
function mergeSaveResponse(sent: FollowupReviewItem[], saved: FollowupReviewItem[], local: FollowupReviewItem[]) {
|
||||||
|
const before = new Map(sent.map(item => [item.id, item]))
|
||||||
|
const latest = new Map(local.map(item => [item.id, item]))
|
||||||
|
return cloneReviewItems(saved).map(item => {
|
||||||
|
const previous = before.get(item.id), edit = latest.get(item.id)
|
||||||
|
if (!previous || !edit) return item
|
||||||
|
const serverRequiresReview = (item.needs_review && !previous.needs_review) || (previous.selected && !item.selected)
|
||||||
|
for (const key of ['values', 'record_date', 'record_time', 'time_period', 'target_id', 'selected', 'needs_review'] as const) {
|
||||||
|
if (serverRequiresReview && (key === 'selected' || key === 'needs_review')) continue
|
||||||
|
if (JSON.stringify(previous[key]) !== JSON.stringify(edit[key])) Object.assign(item, { [key]: edit[key] })
|
||||||
|
}
|
||||||
|
return item
|
||||||
|
})
|
||||||
|
}
|
||||||
|
async function saveDraft(automatic = false): Promise<void> {
|
||||||
|
if (savePromise) return savePromise
|
||||||
|
if (!canEditReview.value || !detail.value || busy.value || !effectiveActive.value) return
|
||||||
|
if (!automatic && reviewRef.value?.hasUnsavedChanges()) { ElMessage.warning('请先保存或取消正在核对的单项修改'); return }
|
||||||
|
if (automatic && (!committedDirty.value || ['error', 'conflict'].includes(saveState.value))) return
|
||||||
|
clearAutosave()
|
||||||
|
const ticket = scope.capture(), taskId = detail.value.id, diagnosisId = props.diagnosisId
|
||||||
|
const current = () => scope.current(ticket) && selectedId.value === taskId && props.diagnosisId === diagnosisId
|
||||||
|
const changedRoles = roleDirty.value, sentRoles = channelRoles.value
|
||||||
|
const sentItems = cloneReviewItems(reviewItems.value), version = detail.value.version
|
||||||
|
mutation.value = 'save'; saveState.value = 'saving'; errorMessage.value = ''; clearTimer()
|
||||||
|
const operation = (async () => {
|
||||||
|
try {
|
||||||
|
const saved = await followupAudioSaveDraft({ id: taskId, version, items: sentItems, ...(changedRoles && hasStereo.value ? { channel_roles: sentRoles } : {}) }, ticket.signal)
|
||||||
|
if (!current()) return
|
||||||
|
if (detail.value && saved.version < detail.value.version) {
|
||||||
|
saveState.value = 'conflict'; errorMessage.value = '服务端已有更新版本;自动保存已暂停,请核对当前记录后重试保存。'; return
|
||||||
|
}
|
||||||
|
if (saved.review_refreshed) {
|
||||||
|
const latestRoles = channelRoles.value
|
||||||
|
installDetail(saved, true, false)
|
||||||
|
if (latestRoles !== sentRoles) { hydrating = true; channelRoles.value = latestRoles; hydrating = false }
|
||||||
|
saveState.value = 'conflict'
|
||||||
|
errorMessage.value = '业务记录已变化,版本已刷新;当前编辑已保留,全部选择已取消。自动保存已暂停,请重新核对后重试保存草稿。'
|
||||||
|
} else {
|
||||||
|
const latestRoles = channelRoles.value
|
||||||
|
const merged = changedRoles ? rebaseReviewItems(reviewItems.value, saved.items) : mergeSaveResponse(sentItems, saved.items, reviewItems.value)
|
||||||
|
hydrating = true
|
||||||
|
try {
|
||||||
|
detail.value = saved; snapshot.value = JSON.stringify(saved.items)
|
||||||
|
roleSnapshot.value = normalizedChannelRoles(saved.channel_roles)
|
||||||
|
channelRoles.value = latestRoles === sentRoles ? roleSnapshot.value : latestRoles
|
||||||
|
if (JSON.stringify(merged) !== JSON.stringify(reviewItems.value)) reviewItems.value = merged
|
||||||
|
} finally { hydrating = false }
|
||||||
|
editingRoles.value = roleDirty.value
|
||||||
|
saveState.value = committedDirty.value ? 'pending' : 'saved'
|
||||||
|
if (!committedDirty.value) persistedRevision.value++
|
||||||
|
if (!automatic) ElMessage.success(changedRoles ? '角色标注已保存,全部建议须重新核对;不会自动重新提炼' : previewOnly.value ? '测试审核草稿已保存,不会写入病历' : '审核草稿已保存,尚未写入病历')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (current()) {
|
||||||
|
saveState.value = 'error'
|
||||||
|
await handleReviewError(error)
|
||||||
|
if (current() && !['conflict'].includes(saveState.value)) errorMessage.value += ';自动保存已暂停,修改仍保留,请重试保存草稿。'
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (current()) {
|
||||||
|
mutation.value = ''; savePromise = null
|
||||||
|
scheduleAutosave()
|
||||||
|
await refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
savePromise = operation
|
||||||
|
return operation
|
||||||
|
}
|
||||||
|
async function applySelected() {
|
||||||
|
if (!canApplyReview.value || !detail.value || busy.value || !selectedCount.value) return
|
||||||
|
if (reviewRef.value?.hasUnsavedChanges()) { ElMessage.warning('请先保存或取消正在核对的单项修改'); return }
|
||||||
|
if (props.basicDirty || (props.beforeApply && !props.beforeApply())) { ElMessage.warning('请先保存病历表单,当前修改不会被覆盖'); return }
|
||||||
|
if (applyIssue.value) { ElMessage.warning(applyIssue.value); return }
|
||||||
|
const ticket = scope.capture()
|
||||||
|
try { await ElMessageBox.confirm(`将一次性采纳 ${selectedCount.value} 项;任一项冲突则整批不写入。确认已核对原话、日期和时间?`, '确认采纳', { type: 'warning', confirmButtonText: '全部采纳', cancelButtonText: '返回核对' }) } catch { return }
|
||||||
|
if (!scope.current(ticket) || !canApplyReview.value || !selectedCount.value || props.basicDirty || (props.beforeApply && !props.beforeApply()) || !detail.value) return
|
||||||
|
if (applyIssue.value) { ElMessage.warning(applyIssue.value); return }
|
||||||
|
const diagnosisId = props.diagnosisId
|
||||||
|
mutation.value = 'apply'; errorMessage.value = ''; clearTimer()
|
||||||
|
try {
|
||||||
|
await followupAudioApply({ id: detail.value.id, version: detail.value.version, items: cloneReviewItems(reviewItems.value) }, ticket.signal)
|
||||||
|
if (!scope.current(ticket) || !canApplyReview.value) return
|
||||||
|
detail.value = { ...detail.value, status: 'applied' }; snapshot.value = JSON.stringify(reviewItems.value)
|
||||||
|
ElMessage.success('所选记录已全部采纳'); emit('applied', diagnosisId)
|
||||||
|
await readDetail(selectedId.value)
|
||||||
|
} catch (error) { if (scope.current(ticket)) await handleReviewError(error) }
|
||||||
|
finally { if (scope.current(ticket)) { mutation.value = ''; await refresh() } }
|
||||||
|
}
|
||||||
|
function extractionRoleLabel(roles: FollowupChannelRoles): string {
|
||||||
|
return roles === 'left_service' ? '左客服 / 右患者或家属' : roles === 'right_service' ? '右客服 / 左患者或家属' : '角色未确认'
|
||||||
|
}
|
||||||
|
async function reextractWithRoles() {
|
||||||
|
const roles = reextractTarget.value
|
||||||
|
if (!canReextract.value || !detail.value || busy.value || roles === 'unconfirmed' || roles === extractionRoles.value) return
|
||||||
|
if (dirty.value) { ElMessage.warning('请先完成或取消当前审核修改,等待草稿保存后再重新提炼'); return }
|
||||||
|
const ticket = scope.capture(), id = detail.value.id, version = detail.value.version, diagnosisId = props.diagnosisId
|
||||||
|
const current = () => scope.current(ticket) && diagnosisId === props.diagnosisId && selectedId.value === id
|
||||||
|
clearAutosave(); clearTimer(); mutation.value = 'reextract'; errorMessage.value = ''
|
||||||
|
let confirmed = false
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`确认已回听并核实角色为“${extractionRoleLabel(roles)}”?将保留旧版结果,复用已完成的转写重新提炼,不重复语音识别;新建议须重新审核,不自动写入病例。`, '确认角色并重新提炼', { type: 'warning', confirmButtonText: '角色正确,开始重新提炼', cancelButtonText: '返回回听' })
|
||||||
|
if (!current() || !canReextract.value || dirty.value) return
|
||||||
|
confirmed = true
|
||||||
|
const fresh = await followupAudioReextract({ id, version, channel_roles: roles }, ticket.signal)
|
||||||
|
if (!current()) return
|
||||||
|
installDetail(fresh, false); saveState.value = 'idle'; editingRoles.value = false
|
||||||
|
ElMessage.success('已提交重新提炼,将复用原转写;新结果仍需审核,不自动写入病例')
|
||||||
|
} catch (error) { if (confirmed && current()) await handleReviewError(error) }
|
||||||
|
finally { if (current()) { mutation.value = ''; scheduleAutosave(); await refresh() } }
|
||||||
|
}
|
||||||
|
function canDeleteTask(task: FollowupTaskSummary): boolean {
|
||||||
|
return !props.viewOnly && featureEnabled.value && task.can_delete === true && ['review', 'failed'].includes(task.status)
|
||||||
|
}
|
||||||
|
async function deleteRecording(task: FollowupTaskSummary) {
|
||||||
|
if (busy.value || !canDeleteTask(task)) return
|
||||||
|
const ticket = scope.capture(), diagnosisId = props.diagnosisId, id = task.id, version = task.version
|
||||||
|
const current = () => scope.current(ticket) && diagnosisId === props.diagnosisId
|
||||||
|
clearAutosave(); clearTimer(); mutation.value = 'delete'; errorMessage.value = ''
|
||||||
|
let confirmed = false
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm('删除后,该录音任务及审核草稿将从列表移除,未保存修改会丢弃;不删除或回滚病例。原录音和转写仍按原保留期限清理。重新上传同一文件将创建新的分析任务。确认删除?', '删除录音任务', { type: 'warning', confirmButtonText: '确认删除', cancelButtonText: '保留录音' })
|
||||||
|
if (!current() || !canDeleteTask(task)) return
|
||||||
|
confirmed = true
|
||||||
|
await followupAudioDelete({ id, version }, ticket.signal)
|
||||||
|
if (!current()) return
|
||||||
|
// Fence pending playback, autosaves and reads; deletion never revives a stale draft.
|
||||||
|
if (selectedId.value === id) {
|
||||||
|
stop(); resetRoles(); selectedId.value = 0; detail.value = null; reviewItems.value = []; snapshot.value = '[]'; detailSections.value = []
|
||||||
|
}
|
||||||
|
tasks.value = tasks.value.filter(row => row.id !== id)
|
||||||
|
errorMessage.value = ''
|
||||||
|
ElMessage.success('录音任务已删除,未修改病例。原资料按保留策略清理。')
|
||||||
|
await refresh()
|
||||||
|
} catch (error) {
|
||||||
|
if (confirmed && current()) errorMessage.value = followupError(error)
|
||||||
|
} finally {
|
||||||
|
if (current()) { mutation.value = ''; scheduleAutosave(); await refresh() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function retryTask() {
|
||||||
|
if (!detail.value?.can_retry || detail.value.status !== 'failed' || !canUpload.value || busy.value) return
|
||||||
|
const ticket = scope.capture()
|
||||||
|
try { await ElMessageBox.confirm('仅对服务端已确认失败、允许重试的任务重新分析。确认继续?', '确认重试', { type: 'warning' }) } catch { return }
|
||||||
|
if (!scope.current(ticket) || !detail.value?.can_retry || detail.value.status !== 'failed' || !canUpload.value) return
|
||||||
|
mutation.value = 'retry'; clearTimer()
|
||||||
|
try { await followupAudioRetry(detail.value.id, ticket.signal); if (scope.current(ticket)) await readDetail(selectedId.value) }
|
||||||
|
catch (error) { if (scope.current(ticket)) errorMessage.value = followupError(error) }
|
||||||
|
finally { if (scope.current(ticket)) { mutation.value = ''; await refresh() } }
|
||||||
|
}
|
||||||
|
async function loadDialogue(milliseconds: number) { await loadAudio(dialoguePlaybackStart(milliseconds)) }
|
||||||
|
async function reloadAudio() { releaseAudio(); await loadAudio() }
|
||||||
|
async function playChannel(channel: FollowupPlaybackChannel) { await loadAudio((audioElement.value?.currentTime || 0) * 1000, channel === 'original' ? undefined : channel) }
|
||||||
|
async function loadAudio(milliseconds?: number, channel?: FollowupAudioChannel) {
|
||||||
|
if (!featureEnabled.value || !detail.value?.audio_available) return
|
||||||
|
const operation = ++playbackRequest
|
||||||
|
playbackError.value = ''
|
||||||
|
if (channel !== undefined && (!hasStereo.value || (channel !== 0 && channel !== 1))) {
|
||||||
|
releaseAudio(); playbackError.value = '未取得可验证的双声道片段,无法隔离声道;请使用原音播放。'; return
|
||||||
|
}
|
||||||
|
const ticket = scope.capture(); const id = detail.value.id
|
||||||
|
if (!audioUrl.value) {
|
||||||
|
const requestId = ++audioRequest; audioLoading.value = true
|
||||||
|
try {
|
||||||
|
const blob = await followupAudioBlob(id, ticket.signal)
|
||||||
|
if (!scope.current(ticket) || requestId !== audioRequest || id !== selectedId.value) return
|
||||||
|
audioUrl.value = URL.createObjectURL(blob)
|
||||||
|
} catch (error) { if (scope.current(ticket) && requestId === audioRequest) errorMessage.value = followupError(error) }
|
||||||
|
finally { if (scope.current(ticket) && requestId === audioRequest) audioLoading.value = false }
|
||||||
|
}
|
||||||
|
await nextTick()
|
||||||
|
if (!scope.current(ticket) || operation !== playbackRequest || id !== selectedId.value || !audioUrl.value || !audioElement.value) return
|
||||||
|
const element = audioElement.value
|
||||||
|
if (playbackElement !== element) { playback?.dispose(); playback = createFollowupAudioPlayback(element); playbackElement = element }
|
||||||
|
const routing = playback
|
||||||
|
channelLoading.value = true
|
||||||
|
try {
|
||||||
|
if (!routing || !await routing.select(channel ?? 'original')) return
|
||||||
|
if (!scope.current(ticket) || operation !== playbackRequest || id !== selectedId.value) return
|
||||||
|
playbackChannel.value = channel ?? 'original'
|
||||||
|
if (typeof milliseconds !== 'number' || !Number.isFinite(milliseconds)) return
|
||||||
|
element.currentTime = Math.max(0, milliseconds / 1000)
|
||||||
|
try { await element.play() } catch { if (scope.current(ticket) && operation === playbackRequest) playbackError.value = '播放未启动,请使用播放器控件继续。' }
|
||||||
|
} catch (error) {
|
||||||
|
if (scope.current(ticket) && operation === playbackRequest) { playbackChannel.value = 'original'; playbackError.value = followupError(error) }
|
||||||
|
} finally { if (scope.current(ticket) && operation === playbackRequest) channelLoading.value = false }
|
||||||
|
}
|
||||||
|
function formatExpiry(value: number | string) {
|
||||||
|
if (!value) return '由保留策略控制'
|
||||||
|
const date = typeof value === 'number' || /^\d+$/.test(value) ? new Date(Number(value) * 1000) : new Date(value)
|
||||||
|
return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })
|
||||||
|
}
|
||||||
|
function onVisibility() { documentVisible.value = document.visibilityState !== 'hidden' }
|
||||||
|
watch([() => props.diagnosisId, effectiveActive, () => !!props.capabilities.preview_only], ([id, active]) => {
|
||||||
|
stop()
|
||||||
|
if (id !== lastDiagnosis) {
|
||||||
|
resetRoles(); lastDiagnosis = id; selectedId.value = 0; detail.value = null; reviewItems.value = []; snapshot.value = '[]'
|
||||||
|
tasks.value = []; file.value = null; recordedAt.value = ''; uploadChannelRoles.value = 'unconfirmed'; uploadVisible.value = false; detailSections.value = []; helpVisible.value = false; errorMessage.value = ''; uploadStage.value = ''; createUncertain.value = false
|
||||||
|
}
|
||||||
|
if (active) { void refresh(); scheduleAutosave() }
|
||||||
|
}, { immediate: true })
|
||||||
|
watch([() => JSON.stringify(reviewItems.value), channelRoles], () => { if (!hydrating) scheduleAutosave() }, { flush: 'sync' })
|
||||||
|
watch(() => props.capabilities.models, models => {
|
||||||
|
if (!models.some(model => model.value === modelKey.value)) modelKey.value = models[0]?.value || ''
|
||||||
|
})
|
||||||
|
onMounted(() => document.addEventListener('visibilitychange', onVisibility))
|
||||||
|
onBeforeUnmount(() => { stop(); document.removeEventListener('visibilitychange', onVisibility) })
|
||||||
|
defineExpose({ confirmLeave, refresh, hasUnsavedChanges: () => dirty.value })
|
||||||
|
</script>
|
||||||
|
<style scoped>
|
||||||
|
.followup-panel { display: grid; gap: 12px; min-width: 0; color: var(--el-text-color-primary); font-size: 14px; }
|
||||||
|
.panel-toolbar, .toolbar-actions, .detail-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; min-width: 0; }
|
||||||
|
.toolbar-actions { justify-content: flex-end; gap: 8px; flex-wrap: wrap; }
|
||||||
|
.toolbar-actions :deep(.el-button + .el-button) { margin-left: 0; }
|
||||||
|
h3 { margin: 0; font-size: 14px; font-weight: 600; }
|
||||||
|
.toolbar-title h3 span { margin-left: 5px; font-weight: 400; color: var(--el-text-color-secondary); }
|
||||||
|
.help-box { background: #fff; border: 1px solid #e2e8f0; border-radius: 10px; padding: 10px 14px; color: var(--el-text-color-secondary); font-size: 12px; line-height: 1.7; }
|
||||||
|
.help-box p { margin: 3px 0; }
|
||||||
|
.task-list, .task-detail { min-width: 0; max-width: 100%; border: 1px solid #e2e8f0; border-radius: 10px; background: #fff; overflow: hidden; }
|
||||||
|
.task-list { padding: 4px 12px; }
|
||||||
|
.task-list :deep(.el-table) { --el-table-header-bg-color: #f8fafc; }
|
||||||
|
.task-list :deep(.el-table .cell) { line-height: 1.5; }
|
||||||
|
.filename-button { max-width: 100%; height: auto; text-align: left; white-space: normal; word-break: break-word; line-height: 1.6; }
|
||||||
|
.filename-button :deep(span) { overflow-wrap: anywhere; }
|
||||||
|
.task-time, .recording-title > span { display: block; color: var(--el-text-color-secondary); font-size: 12px; margin-top: 3px; }
|
||||||
|
.task-detail { padding: 16px; display: grid; gap: 14px; overflow: visible; }
|
||||||
|
.detail-heading { align-items: flex-start; }
|
||||||
|
.recording-title { min-width: 0; }
|
||||||
|
.recording-title h3 { overflow-wrap: anywhere; line-height: 1.6; }
|
||||||
|
.role-reextract { padding: 12px; border: 1px solid var(--el-color-warning-light-5); background: var(--el-color-warning-light-9); border-radius: 6px; display: grid; gap: 8px; }
|
||||||
|
.role-reextract p { margin: 0; line-height: 1.6; }
|
||||||
|
.role-reextract blockquote { margin: 0; padding: 8px; background: var(--el-bg-color); white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||||
|
.role-reextract :deep(.el-button) { justify-self: start; }
|
||||||
|
.detail-actions { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
|
||||||
|
.detail-heading :deep(.el-tag) { flex-shrink: 0; }
|
||||||
|
.detail-loading { padding: 32px; text-align: center; color: var(--el-text-color-secondary); }
|
||||||
|
.processing-state, .applied-note { margin: 0; color: var(--el-text-color-secondary); font-size: 13px; }
|
||||||
|
.retry-button { justify-self: start; }
|
||||||
|
.summary-box { padding-bottom: 12px; border-bottom: 1px solid #e2e8f0; }
|
||||||
|
.summary-box > p { margin: 0 0 10px; line-height: 1.7; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||||
|
.summary-counts { display: flex; gap: 20px; color: var(--el-text-color-secondary); font-size: 12px; }
|
||||||
|
.summary-counts strong { color: var(--el-text-color-primary); font-weight: 600; }
|
||||||
|
.channel-role-box { display: grid; gap: 6px; font-size: 12px; }
|
||||||
|
.role-summary { display: flex; align-items: center; gap: 12px; }
|
||||||
|
.channel-role-box label { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; }
|
||||||
|
.channel-role-box :deep(.el-select) { width: 260px; max-width: 100%; }
|
||||||
|
.channel-playback { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; margin: 8px 0; }
|
||||||
|
.channel-playback :deep(.el-button + .el-button) { margin-left: 0; }
|
||||||
|
.transcript-segments { max-height: 320px; overflow-y: auto; margin-top: 12px; }
|
||||||
|
.transcript-segment { padding: 10px 0; border-bottom: 1px solid #e2e8f0; }
|
||||||
|
.segment-heading { display: flex; align-items: center; justify-content: space-between; gap: 8px; color: var(--el-text-color-secondary); font-size: 12px; }
|
||||||
|
.transcript-segment pre { margin: 5px 0 0; white-space: pre-wrap; overflow-wrap: anywhere; font-family: inherit; }
|
||||||
|
.dialogue-sides { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 240px), 1fr)); gap: 10px; margin-top: 8px; }
|
||||||
|
.dialogue-side { padding: 10px; background: var(--el-fill-color-light); border-radius: 6px; min-width: 0; }
|
||||||
|
.dialogue-side strong { font-size: 12px; color: var(--el-text-color-secondary); }
|
||||||
|
.original-collapse { --el-collapse-border-color: #e2e8f0; border-top: 0; }
|
||||||
|
.original-collapse :deep(.el-collapse-item__header) { height: 38px; font-size: 13px; color: var(--el-text-color-regular); }
|
||||||
|
.original-collapse :deep(.el-collapse-item__content) { padding-bottom: 10px; }
|
||||||
|
.audio-player { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
|
||||||
|
.audio-player audio { width: 100%; max-width: 440px; height: 36px; }
|
||||||
|
.transcript-text { max-height: 280px; overflow-y: auto; margin: 12px 0 0; padding: 12px; background: #f8fafc; border-radius: 6px; white-space: pre-wrap; overflow-wrap: anywhere; font-family: inherit; }
|
||||||
|
.review-actions { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 12px; align-items: center; padding-top: 2px; }
|
||||||
|
.selection-summary { display: grid; gap: 3px; font-size: 13px; }
|
||||||
|
.selection-summary span { color: var(--el-text-color-secondary); font-size: 12px; }
|
||||||
|
.action-buttons { display: flex; gap: 8px; }
|
||||||
|
.action-buttons :deep(.el-button + .el-button) { margin-left: 0; }
|
||||||
|
.review-error { color: var(--el-color-danger); width: 100%; margin: 0; font-size: 12px; }
|
||||||
|
.muted, .upload-stage { color: var(--el-text-color-secondary); font-size: 12px; line-height: 1.6; margin: 0; }
|
||||||
|
.upload-stage { margin: 10px 0; color: var(--el-text-color-regular); }
|
||||||
|
.upload-form { min-width: 0; }
|
||||||
|
.upload-form input { max-width: 100%; }
|
||||||
|
.upload-form small { display: block; color: var(--el-text-color-secondary); line-height: 1.5; margin-top: 6px; }
|
||||||
|
.upload-form :deep(.el-date-editor), .upload-form :deep(.el-select) { width: 100%; }
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.panel-toolbar { align-items: flex-start; flex-wrap: wrap; gap: 8px; }
|
||||||
|
.toolbar-actions { gap: 6px; }
|
||||||
|
.task-list { padding: 0 4px; }
|
||||||
|
.task-detail { padding: 12px; gap: 12px; }
|
||||||
|
.detail-heading { flex-wrap: wrap; gap: 6px; }
|
||||||
|
.review-actions { align-items: flex-start; }
|
||||||
|
.action-buttons { margin-left: auto; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
<template>
|
||||||
|
<section class="followup-review" aria-label="回访录音人工审核">
|
||||||
|
<el-tabs v-model="group" class="review-tabs" aria-label="提炼结果分类">
|
||||||
|
<el-tab-pane :label="`全部 ${counts.all}`" name="all" />
|
||||||
|
<el-tab-pane :label="`诊单资料 ${counts.diagnosis}`" name="diagnosis" />
|
||||||
|
<el-tab-pane :label="`日常记录 ${counts.daily}`" name="daily" />
|
||||||
|
<el-tab-pane :label="`待核实 ${counts.pending + (uncertainties?.length || 0)}`" name="pending" />
|
||||||
|
</el-tabs>
|
||||||
|
<div class="review-caption"><span>{{ applied ? '审核结果' : '提炼结果' }} · {{ visibleItems.length }} 项</span><span v-if="!applied">跨分类已选 {{ counts.selected }} 项</span></div>
|
||||||
|
<p v-if="!canDaily && counts.daily" class="permission-note">日常记录仅供查看,当前账号没有编辑权限。</p>
|
||||||
|
<div class="review-table">
|
||||||
|
<el-table :data="visibleItems" max-height="360" row-key="id" size="small" table-layout="fixed" :empty-text="items.length ? '此分类暂无内容' : '暂无结构化结果,可查看录音原文'" aria-label="回访提炼结果">
|
||||||
|
<el-table-column v-if="!applied" label="采纳" width="50" align="center">
|
||||||
|
<template #default="{ row }"><el-checkbox :model-value="row.selected" :disabled="disabled || (!row.selected && selectionBlocked(row))" :aria-label="`采纳${itemTitle(row)}`" @update:model-value="(value: boolean | string | number) => toggleSelection(row, !!value)" /></template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="字段 / 内容" min-width="210">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div v-for="field in itemFields(row).slice(0, 2)" :key="field.key" class="result-line"><span>{{ field.label }}:</span>{{ fieldText(row.values[field.key], field) }}</div>
|
||||||
|
<span v-if="!itemFields(row).length" class="muted">暂无有效字段</span>
|
||||||
|
<div class="item-meta">{{ itemKindLabel(row) }}<span v-if="itemFields(row).length > 2"> · 共 {{ itemFields(row).length }} 个字段</span><span v-if="row.target_id"> · 更新已有记录</span></div>
|
||||||
|
<div v-if="row.kind !== 'diagnosis'" class="item-meta mobile-event-time">{{ row.record_date || '日期待核实' }} · {{ reviewTimeValue(row.record_time) || row.time_period || '时间待核实' }}{{ row.time_estimated ? '(推定)' : '' }}</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="日期 / 时段" width="130">
|
||||||
|
<template #default="{ row }"><span>{{ row.kind === 'diagnosis' ? '—' : row.record_date || '待补充日期' }}</span><div v-if="row.kind !== 'diagnosis'" class="item-meta">{{ reviewTimeValue(row.record_time) || row.time_period || '未提及时间' }}{{ row.time_estimated ? '(推定)' : '' }}</div></template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="状态" width="90">
|
||||||
|
<template #default="{ row }"><el-tag size="small" :type="itemStatus(row).type">{{ itemStatus(row).label }}</el-tag></template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="60" align="right" fixed="right">
|
||||||
|
<template #default="{ row }"><el-button link type="primary" @click="openReview(row.id)">{{ applied || disabled ? '查看' : '核对' }}</el-button></template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
|
<section v-if="(group === 'pending' || !items.length) && uncertainties?.length" class="uncertainty-list" aria-label="原文待核实信息">
|
||||||
|
<strong>本次未能确认的信息与原因</strong>
|
||||||
|
<ul><li v-for="(value, index) in uncertainties" :key="index">{{ uncertaintyText(value) }}</li></ul>
|
||||||
|
</section>
|
||||||
|
<section ref="reviewWorkspace" class="review-workspace" tabindex="-1" aria-label="同页核对与回听">
|
||||||
|
<div class="review-player"><slot name="player" /></div>
|
||||||
|
<p v-if="navigationNotice" class="editor-notice" role="status">{{ navigationNotice }}</p>
|
||||||
|
<div v-if="editor" class="inline-editor-heading"><strong>{{ applied || disabled ? '查看审核结果' : '核对提炼内容' }} · {{ editorPosition }} / {{ items.length }}</strong><el-button link @click="cancelReview()">{{ editorDirty ? '取消本项修改' : '收起核对' }}</el-button></div>
|
||||||
|
<section v-if="editor" class="review-editor">
|
||||||
|
<div class="editor-heading"><strong>{{ followupKindLabels[editor.kind] }}</strong><el-tag size="small" :type="itemStatus(editor).type">{{ itemStatus(editor).label }}</el-tag></div>
|
||||||
|
<p v-if="editorNotice" class="editor-notice" role="status">{{ editorNotice }}</p>
|
||||||
|
<div class="evidence">
|
||||||
|
<div class="evidence-title">录音原话</div>
|
||||||
|
<blockquote v-for="(quote, index) in editor.evidence" :key="index"><div v-if="quote.channel === 0 || quote.channel === 1" class="muted">{{ followupChannelLabel(quote.channel, channelRoles) }}</div><span>{{ quote.text }}</span><el-button v-if="evidencePlaybackLabel(quote)" link type="primary" @click="seekEvidence(quote)">{{ evidencePlaybackLabel(quote) }}</el-button></blockquote>
|
||||||
|
<p v-if="editor.evidence.some(quote => quote.position_type === 'segment')" class="muted">回听位置为转写片段范围,不是原话的逐字时间戳;默认回听双方对话,请结合原始录音核对。</p>
|
||||||
|
<p v-if="!editor.evidence.length" class="muted">未提供原话证据,请人工核实。</p>
|
||||||
|
<p v-if="editor.date_text || editor.time_text" class="muted">时间原话:{{ editor.date_text || '未提及日期' }} · {{ editor.time_text || '未提及时间' }}</p>
|
||||||
|
</div>
|
||||||
|
<div v-if="editor.conflict || editor.possible_duplicate || editor.time_estimated" class="review-flags"><span v-if="editor.conflict">与已有值冲突</span><span v-if="editor.possible_duplicate">可能存在重复记录</span><span v-if="editor.time_estimated">时间由时段推定</span></div>
|
||||||
|
<template v-if="editor.kind !== 'diagnosis'">
|
||||||
|
<div v-if="disabled || applied" class="readonly-event"><span>{{ editor.record_date || '未记录日期' }} · {{ reviewTimeValue(editor.record_time) || editor.time_period || '未记录时间' }}</span><span>{{ editor.target_id ? `更新记录 #${editor.target_id}` : '新增独立记录' }}</span></div>
|
||||||
|
<div v-else class="event-time">
|
||||||
|
<label class="target-field">写入目标<el-select :model-value="editor.target_id || 0" @update:model-value="(value: number) => chooseTarget(value)"><el-option label="新增独立记录(不覆盖已有行)" :value="0" /><el-option v-for="candidate in editorCandidates" :key="candidate.id" :value="candidate.id" :disabled="!!candidate.delete_time" :label="candidateLabel(editor, candidate)" /></el-select></label>
|
||||||
|
<label>记录日期<el-date-picker :model-value="editor.record_date" type="date" value-format="YYYY-MM-DD" placeholder="请核实日期" @update:model-value="(value: string | null) => patchEditor({ record_date: value || null, target_id: null })" /></label>
|
||||||
|
<label>记录时间<el-time-picker :model-value="reviewTimeValue(editor.record_time)" value-format="HH:mm" format="HH:mm" placeholder="未提及可留空" @update:model-value="(value: string | null) => patchEditor({ record_time: value || null })" /></label>
|
||||||
|
<label>原始时段<el-input :model-value="editor.time_period || ''" placeholder="如早晨、餐后" @update:model-value="(value: string) => patchEditor({ time_period: value || null })" /></label>
|
||||||
|
</div>
|
||||||
|
<p v-if="targetChanged && !applied" class="target-hint" role="status">日期或目标已更改。保存本项后将自动同步草稿并刷新目标当前值,请依据新值重新核对。</p>
|
||||||
|
</template>
|
||||||
|
<div class="field-grid field-grid--header"><span>字段</span><span>{{ applied ? '写入前值' : targetChanged ? '旧目标值(待刷新)' : '当前值' }}</span><span>{{ applied || disabled ? '审核值' : '拟采纳值' }}</span></div>
|
||||||
|
<div v-for="field in itemFields(editor)" :key="field.key" class="field-grid">
|
||||||
|
<div class="field-label">{{ field.label }}<small v-if="field.readonly">只读</small></div>
|
||||||
|
<div class="current-value">{{ fieldText(editor.current_values[field.key], field) }}</div>
|
||||||
|
<div class="suggested-value"><FollowupAudioField :field="field" :model-value="editor.values[field.key]" :readonly="disabled || applied || field.readonly" @update:model-value="value => setValue(field.key, value)" /><el-button v-if="!disabled && !applied" link type="danger" :disabled="Object.keys(editor.values).length <= 1" @click="removeField(field.key)">移除</el-button></div>
|
||||||
|
</div>
|
||||||
|
<p v-if="!disabled && !applied" class="muted">仅可更正或移除本项提议字段,不采纳整项请取消勾选;新增内容请返回原诊单编辑人工补录。</p>
|
||||||
|
<el-checkbox v-if="!disabled && !applied" :model-value="!editor.needs_review" class="review-confirm" @update:model-value="(value: boolean | string | number) => confirmReview(!!value)">我已核对原话、冲突 / 重复及日期时间,确认本项内容</el-checkbox>
|
||||||
|
<p v-if="editorIssue && !disabled && !applied" class="review-error" role="status">{{ editorIssue }}</p>
|
||||||
|
</section>
|
||||||
|
<div v-if="editor && !disabled && !applied" class="editor-footer">
|
||||||
|
<span class="draft-hint" role="status">{{ saveState === 'saving' || saveState === 'pending' ? '审核草稿同步中,不写入病例' : saveState === 'error' || saveState === 'conflict' ? '草稿同步需处理,请查看下方提示' : '核对结果自动保存为草稿,最终确认后才写入病例' }}</span>
|
||||||
|
<el-button @click="skipAndNext">暂不采纳并下一项</el-button>
|
||||||
|
<el-button :disabled="!editorDirty" @click="saveReview(false)">保存本项修改</el-button>
|
||||||
|
<el-button type="primary" :disabled="!!editorIssue || targetChanged" @click="saveReview(true, true)">{{ nextReviewId ? '采纳并下一项' : '完成核对并选中' }}</el-button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, nextTick, ref, watch } from 'vue'
|
||||||
|
import type { FollowupAudioChannel, FollowupChannelRoles, FollowupField, FollowupFields, FollowupReviewItem, FollowupValue } from '@/api/followupAudio'
|
||||||
|
import FollowupAudioField from './FollowupAudioField.vue'
|
||||||
|
import { cloneReviewItems, evidencePlaybackLabel, fieldText, followupChannelLabel, followupKindLabels, hasEditableReviewFields, reviewCounts, reviewFieldIssue, reviewIssue, reviewPending, reviewTimeValue } from './followupAudioState'
|
||||||
|
const props = defineProps<{ items: FollowupReviewItem[]; fields: FollowupFields; disabled: boolean; canDaily: boolean; applied?: boolean; uncertainties?: unknown[]; snapshotItems?: FollowupReviewItem[]; channelRoles?: FollowupChannelRoles; saveState?: string; persistedRevision?: number }>()
|
||||||
|
const emit = defineEmits<{ (event: 'update:items', items: FollowupReviewItem[]): void; (event: 'seek', milliseconds: number, channel?: FollowupAudioChannel): void; (event: 'saved-next'): void }>()
|
||||||
|
type Group = 'all' | 'diagnosis' | 'daily' | 'pending'
|
||||||
|
type Editable = Partial<Pick<FollowupReviewItem, 'values' | 'record_date' | 'record_time' | 'time_period' | 'selected' | 'target_id' | 'needs_review'>>
|
||||||
|
const group = ref<Group>('all')
|
||||||
|
const editor = ref<FollowupReviewItem | null>(null)
|
||||||
|
const editorSource = ref('')
|
||||||
|
const editorNotice = ref('')
|
||||||
|
const navigationNotice = ref('')
|
||||||
|
const reviewWorkspace = ref<HTMLElement | null>(null)
|
||||||
|
const editorPosition = computed(() => props.items.findIndex(item => item.id === editor.value?.id) + 1)
|
||||||
|
const nextReviewId = computed(() => {
|
||||||
|
const index = visibleItems.value.findIndex(item => item.id === editor.value?.id)
|
||||||
|
return index < 0 ? null : visibleItems.value.slice(index + 1).find(item => !item.selected && !selectionBlocked(item))?.id || null
|
||||||
|
})
|
||||||
|
const counts = computed(() => reviewCounts(props.items, props.fields, props.canDaily))
|
||||||
|
const visibleItems = computed(() => props.items.filter(item => group.value === 'all' || (group.value === 'diagnosis' && item.kind === 'diagnosis') || (group.value === 'daily' && item.kind !== 'diagnosis') || (group.value === 'pending' && reviewPending(item, props.fields, props.canDaily))))
|
||||||
|
const editorDirty = computed(() => !!editor.value && JSON.stringify(editor.value) !== editorSource.value)
|
||||||
|
const targetChanged = computed(() => {
|
||||||
|
if (!editor.value || !editorSource.value) return false
|
||||||
|
const source: FollowupReviewItem = props.snapshotItems?.find(item => item.id === editor.value?.id) || JSON.parse(editorSource.value)
|
||||||
|
return editor.value.target_id !== source.target_id || editor.value.record_date !== source.record_date
|
||||||
|
})
|
||||||
|
const editorCandidates = computed(() => {
|
||||||
|
if (!editor.value || !editorSource.value) return []
|
||||||
|
const source: FollowupReviewItem = props.snapshotItems?.find(item => item.id === editor.value?.id) || JSON.parse(editorSource.value)
|
||||||
|
return editor.value.record_date === source.record_date ? editor.value.candidates || [] : []
|
||||||
|
})
|
||||||
|
const editorIssue = computed(() => editor.value ? reviewFieldIssue({ ...editor.value, selected: true }, props.fields) || reviewIssue({ ...editor.value, selected: true }, props.canDaily) : '')
|
||||||
|
function patch(id: string, update: Editable) { emit('update:items', props.items.map(item => item.id === id ? { ...item, ...update } : item)) }
|
||||||
|
function itemFields(item: FollowupReviewItem): FollowupField[] { return Object.keys(item.values).map(key => (props.fields[item.kind] || []).find(field => field.key === key) || { key, label: key, type: 'text', readonly: true }) }
|
||||||
|
function itemKindLabel(item: FollowupReviewItem) { return followupKindLabels[item.kind] }
|
||||||
|
function itemTitle(item: FollowupReviewItem) { return itemFields(item).map(field => field.label).join('、') || followupKindLabels[item.kind] }
|
||||||
|
function selectionBlocked(item: FollowupReviewItem) { return props.disabled || !!props.applied || !hasEditableReviewFields(item, props.fields) || (item.kind !== 'diagnosis' && !props.canDaily) || !!reviewFieldIssue({ ...item, selected: true }, props.fields) }
|
||||||
|
function toggleSelection(item: FollowupReviewItem, selected: boolean) {
|
||||||
|
if (props.disabled || props.applied) return
|
||||||
|
if (!selected) { patch(item.id, { selected: false }); return }
|
||||||
|
if (selectionBlocked(item)) return
|
||||||
|
if (reviewPending(item, props.fields, props.canDaily)) { openReview(item.id); return }
|
||||||
|
patch(item.id, { selected: true })
|
||||||
|
}
|
||||||
|
function itemStatus(item: FollowupReviewItem): { label: string; type: 'success' | 'info' | 'warning' } {
|
||||||
|
if (props.applied) return { label: item.selected ? '已写入' : '未采纳', type: item.selected ? 'success' : 'info' }
|
||||||
|
if (!hasEditableReviewFields(item, props.fields)) return { label: '只读字段', type: 'info' }
|
||||||
|
if (reviewPending(item, props.fields, props.canDaily)) return { label: '待核实', type: 'warning' }
|
||||||
|
return { label: item.selected ? '已选择' : '已核对', type: item.selected ? 'success' : 'info' }
|
||||||
|
}
|
||||||
|
function openReview(id: string) {
|
||||||
|
if (editor.value?.id === id && editorDirty.value && !props.applied) {
|
||||||
|
void nextTick(() => { reviewWorkspace.value?.scrollIntoView({ block: 'start' }); reviewWorkspace.value?.focus({ preventScroll: true }) })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (editor.value && editor.value.id !== id && editorDirty.value && !props.applied) {
|
||||||
|
editorNotice.value = '当前项有未保存修改,请先保存或取消本项修改,再核对其他项。'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const item = props.items.find(candidate => candidate.id === id)
|
||||||
|
if (!item) return
|
||||||
|
editor.value = cloneReviewItems([item])[0]; editorSource.value = JSON.stringify(editor.value); editorNotice.value = ''; navigationNotice.value = ''
|
||||||
|
void nextTick(() => { reviewWorkspace.value?.scrollIntoView({ block: 'start' }); reviewWorkspace.value?.focus({ preventScroll: true }) })
|
||||||
|
}
|
||||||
|
function cancelReview(done?: () => void) { editor.value = null; editorSource.value = ''; editorNotice.value = ''; done?.() }
|
||||||
|
function patchEditor(update: Editable) {
|
||||||
|
if (!editor.value || props.disabled || props.applied) return
|
||||||
|
// Detail/snapshot items are editable drafts, not the server-owned original proposal.
|
||||||
|
if (update.values && Object.keys(update.values).some(key => !Object.prototype.hasOwnProperty.call(editor.value?.values, key))) return
|
||||||
|
editor.value = { ...editor.value, ...update, selected: false, needs_review: true }
|
||||||
|
}
|
||||||
|
function setValue(key: string, value: FollowupValue) {
|
||||||
|
if (!editor.value || !Object.prototype.hasOwnProperty.call(editor.value.values, key)
|
||||||
|
|| !(props.fields[editor.value.kind] || []).some(field => field.key === key && !field.readonly)) return
|
||||||
|
patchEditor({ values: { ...editor.value.values, [key]: value } })
|
||||||
|
}
|
||||||
|
function removeField(key: string) { if (!editor.value || Object.keys(editor.value.values).length <= 1) return; const values = { ...editor.value.values }; delete values[key]; patchEditor({ values }) }
|
||||||
|
function chooseTarget(id: number) {
|
||||||
|
if (!editor.value || (id && !editorCandidates.value.some(candidate => candidate.id === id && !candidate.delete_time))) return
|
||||||
|
patchEditor({ target_id: id || null })
|
||||||
|
}
|
||||||
|
function confirmReview(confirmed: boolean) {
|
||||||
|
if (!editor.value || props.disabled || props.applied) return
|
||||||
|
editor.value = { ...editor.value, needs_review: !confirmed, selected: confirmed ? editor.value.selected : false }
|
||||||
|
}
|
||||||
|
function saveReview(select: boolean, advance = false) {
|
||||||
|
if (!editor.value || props.disabled || props.applied || (select && (editorIssue.value || targetChanged.value))) return
|
||||||
|
const item = editor.value
|
||||||
|
const nextId = advance ? nextReviewId.value : null
|
||||||
|
patch(item.id, { values: { ...item.values }, record_date: item.record_date, record_time: item.record_time, time_period: item.time_period, target_id: item.target_id, selected: select || (!editorIssue.value && !targetChanged.value && item.selected), needs_review: targetChanged.value || item.needs_review })
|
||||||
|
cancelReview()
|
||||||
|
emit('saved-next')
|
||||||
|
if (nextId) openReview(nextId)
|
||||||
|
else if (advance) navigationNotice.value = '当前列表已核对至末项;可切换分类继续,或确认写入已选内容。'
|
||||||
|
}
|
||||||
|
function skipAndNext() {
|
||||||
|
if (!editor.value || props.disabled || props.applied) return
|
||||||
|
if (editorDirty.value) { editorNotice.value = '当前项有未保存修改,请先保存或取消修改后再跳过。'; return }
|
||||||
|
const nextId = nextReviewId.value
|
||||||
|
patch(editor.value.id, { selected: false })
|
||||||
|
cancelReview(); emit('saved-next')
|
||||||
|
if (nextId) openReview(nextId)
|
||||||
|
else navigationNotice.value = '本项暂不采纳,未写入病例;可切换分类继续核对。'
|
||||||
|
}
|
||||||
|
function candidateLabel(item: FollowupReviewItem, candidate: NonNullable<FollowupReviewItem['candidates']>[number]) {
|
||||||
|
const preview = (props.fields[item.kind] || []).filter(field => Object.prototype.hasOwnProperty.call(candidate.values, field.key)).slice(0, 2).map(field => `${field.label} ${fieldText(candidate.values[field.key], field)}`).join(',')
|
||||||
|
return `#${candidate.id} · ${candidate.record_time || candidate.time_period || '时间未记录'}${candidate.time_estimated ? '(推定)' : ''}${preview ? ` · ${preview}` : ''}`
|
||||||
|
}
|
||||||
|
function seekEvidence(quote: FollowupReviewItem['evidence'][number]) {
|
||||||
|
if (!evidencePlaybackLabel(quote) || typeof quote.start_ms !== 'number') return
|
||||||
|
emit('seek', quote.start_ms)
|
||||||
|
}
|
||||||
|
function uncertaintyText(value: unknown) { return typeof value === 'string' ? value : JSON.stringify(value) }
|
||||||
|
watch(() => props.items.find(item => item.id === editor.value?.id), fresh => {
|
||||||
|
if (!editor.value) return
|
||||||
|
if (!fresh) { cancelReview(); return }
|
||||||
|
const current = JSON.stringify(fresh)
|
||||||
|
if (current === editorSource.value) return
|
||||||
|
const edited = editor.value
|
||||||
|
editor.value = editorDirty.value && !props.applied ? { ...cloneReviewItems([fresh])[0], values: { ...edited.values }, record_date: edited.record_date, record_time: edited.record_time, time_period: edited.time_period, target_id: edited.target_id, selected: false, needs_review: true } : cloneReviewItems([fresh])[0]
|
||||||
|
editorSource.value = current
|
||||||
|
editorNotice.value = '任务数据已更新,当前编辑已保留;请重新核对后选择。'
|
||||||
|
}, { deep: true })
|
||||||
|
watch(() => props.applied, applied => { if (applied && editor.value) openReview(editor.value.id) })
|
||||||
|
defineExpose({ hasUnsavedChanges: () => editorDirty.value })
|
||||||
|
</script>
|
||||||
|
<style scoped>
|
||||||
|
.followup-review, .review-table { min-width: 0; max-width: 100%; }
|
||||||
|
.review-tabs :deep(.el-tabs__header) { margin: 0 0 8px; }
|
||||||
|
.review-tabs :deep(.el-tabs__item) { padding: 0 14px; font-size: 13px; height: 36px; }
|
||||||
|
.review-tabs :deep(.el-tabs__nav-wrap::after) { height: 1px; background: #e2e8f0; }
|
||||||
|
.review-caption { display: flex; gap: 12px; justify-content: space-between; margin: 0 0 8px; color: var(--el-text-color-secondary); font-size: 12px; }
|
||||||
|
.review-table { overflow: hidden; }
|
||||||
|
.review-table :deep(.el-table) { --el-table-header-bg-color: #f8fafc; }
|
||||||
|
.review-table :deep(.el-table .cell) { line-height: 1.6; }
|
||||||
|
.result-line { white-space: nowrap; text-overflow: ellipsis; overflow: hidden; }
|
||||||
|
.result-line > span { color: #64748b; }
|
||||||
|
.item-meta, .muted, .permission-note { color: #64748b; font-size: 12px; }
|
||||||
|
.item-meta { margin-top: 2px; }
|
||||||
|
.mobile-event-time { display: none; }
|
||||||
|
.permission-note { margin: 0 0 8px; }
|
||||||
|
.uncertainty-list { margin-top: 12px; padding: 10px 12px; background: #f8fafc; border-radius: 6px; font-size: 13px; line-height: 1.6; overflow-wrap: anywhere; }
|
||||||
|
.uncertainty-list strong { font-size: 12px; font-weight: 500; color: var(--el-text-color-secondary); }
|
||||||
|
.uncertainty-list ul { padding-left: 18px; margin: 6px 0 0; }
|
||||||
|
.review-editor { display: grid; gap: 12px; min-width: 0; max-height: 65vh; overflow-y: auto; padding-right: 4px; }
|
||||||
|
.editor-heading { display: flex; align-items: center; gap: 10px; }
|
||||||
|
.editor-heading strong { font-size: 14px; }
|
||||||
|
.evidence { padding: 10px 12px; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px; overflow-wrap: anywhere; }
|
||||||
|
.evidence-title { color: var(--el-text-color-secondary); font-size: 12px; }
|
||||||
|
.evidence blockquote { margin: 6px 0; white-space: pre-wrap; line-height: 1.6; }
|
||||||
|
.evidence blockquote :deep(.el-button) { margin-left: 8px; }
|
||||||
|
.evidence p { margin: 6px 0 0; }
|
||||||
|
.review-flags { display: flex; flex-wrap: wrap; gap: 6px 14px; color: var(--el-color-warning-dark-2); font-size: 12px; }
|
||||||
|
.event-time { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
|
||||||
|
.event-time label { display: grid; gap: 5px; font-size: 12px; color: var(--el-text-color-regular); }
|
||||||
|
.event-time .target-field { grid-column: 1 / -1; }
|
||||||
|
.event-time :deep(.el-date-editor), .event-time :deep(.el-select), .suggested-value :deep(.el-input-number), .suggested-value :deep(.el-select), .suggested-value :deep(.el-date-editor) { width: 100%; }
|
||||||
|
.readonly-event { display: flex; flex-wrap: wrap; gap: 8px 20px; font-size: 13px; }
|
||||||
|
.target-hint, .editor-notice { margin: 0; color: var(--el-color-warning-dark-2); font-size: 12px; line-height: 1.6; }
|
||||||
|
.field-grid { display: grid; grid-template-columns: 1fr 1fr 1.6fr; align-items: start; gap: 12px; padding-bottom: 10px; border-bottom: 1px solid #e2e8f0; font-size: 13px; }
|
||||||
|
.field-grid--header { color: var(--el-text-color-secondary); font-size: 12px; padding-bottom: 6px; }
|
||||||
|
.field-label small { display: block; color: var(--el-text-color-secondary); margin-top: 3px; }
|
||||||
|
.current-value { white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||||
|
.suggested-value { min-width: 0; display: grid; gap: 4px; }
|
||||||
|
.suggested-value > :deep(.el-button) { justify-self: end; font-size: 12px; }
|
||||||
|
.review-confirm { white-space: normal; height: auto; min-height: 32px; align-items: flex-start; }
|
||||||
|
.review-confirm :deep(.el-checkbox__input) { margin-top: 4px; }
|
||||||
|
.review-confirm :deep(.el-checkbox__label) { white-space: normal; line-height: 1.6; font-size: 13px; }
|
||||||
|
.review-workspace { margin-top: 14px; scroll-margin-top: 12px; outline: none; }
|
||||||
|
.review-workspace:focus-visible { outline: 2px solid var(--el-color-primary); outline-offset: 4px; }
|
||||||
|
.review-player { position: sticky; top: 0; z-index: 2; background: var(--el-bg-color, #fff); padding: 10px 0; border-bottom: 1px solid var(--el-border-color-lighter); }
|
||||||
|
.inline-editor-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin: 14px 0; }
|
||||||
|
.draft-hint { flex: 1 1 100%; font-size: 12px; color: var(--el-text-color-secondary); }
|
||||||
|
.review-error { color: var(--el-color-danger); font-size: 12px; margin: 0; }
|
||||||
|
.editor-footer { display: flex; justify-content: flex-end; flex-wrap: wrap; gap: 8px; }
|
||||||
|
.editor-footer :deep(.el-button + .el-button) { margin-left: 0; }
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.mobile-event-time { display: block; }
|
||||||
|
.review-tabs :deep(.el-tabs__item) { padding: 0 10px; }
|
||||||
|
.review-caption { gap: 6px; flex-wrap: wrap; }
|
||||||
|
.event-time { grid-template-columns: 1fr; }
|
||||||
|
.field-grid { grid-template-columns: 1fr; gap: 6px; }
|
||||||
|
.field-grid--header { display: none; }
|
||||||
|
.field-label { font-weight: 600; }
|
||||||
|
.current-value::before { content: '当前值:'; color: var(--el-text-color-secondary); }
|
||||||
|
.suggested-value::before { content: '审核值:'; color: var(--el-text-color-secondary); }
|
||||||
|
.editor-footer { gap: 6px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
<template>
|
||||||
|
<div :class="['rv-cell', { 'rv-cell--compact': compact }]">
|
||||||
|
<!-- 行 1:[第 N 诊] tag + 已开方/未开方 + 复诊医生 -->
|
||||||
|
<div class="rv-line rv-line--head">
|
||||||
|
<el-tooltip
|
||||||
|
v-if="showDue && tooltipLines.length"
|
||||||
|
placement="top"
|
||||||
|
:show-after="300"
|
||||||
|
:hide-after="0"
|
||||||
|
>
|
||||||
|
<template #content>
|
||||||
|
<div class="rv-tooltip">
|
||||||
|
<div v-for="line in tooltipLines" :key="line">{{ line }}</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<el-tag size="small" :type="slotTagType" effect="light" class="rv-slot-tag">
|
||||||
|
{{ slotText }}
|
||||||
|
</el-tag>
|
||||||
|
</el-tooltip>
|
||||||
|
<el-tag
|
||||||
|
v-else-if="showDue"
|
||||||
|
size="small"
|
||||||
|
:type="slotTagType"
|
||||||
|
effect="light"
|
||||||
|
class="rv-slot-tag"
|
||||||
|
>
|
||||||
|
{{ slotText }}
|
||||||
|
</el-tag>
|
||||||
|
<span
|
||||||
|
v-if="prescribedLabel"
|
||||||
|
:class="['rv-prescribed', hasPrescription ? 'rv-prescribed--yes' : 'rv-prescribed--no']"
|
||||||
|
>
|
||||||
|
{{ prescribedLabel }}
|
||||||
|
</span>
|
||||||
|
<span v-if="followupDoctorName" class="rv-doctor">
|
||||||
|
<span v-if="prescribedLabel" class="rv-dot">·</span>{{ followupDoctorName }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-if="showDue">
|
||||||
|
<!-- 行 2:最新开药日 · 开药 X 天 · 锚点来源;缺天数时橙色 -->
|
||||||
|
<div class="rv-line rv-line--order">
|
||||||
|
<span v-if="orderDateText" class="rv-order-date">{{ orderDateText }} 开药</span>
|
||||||
|
<span v-if="orderDateText" class="rv-dot">·</span>
|
||||||
|
<span :class="['rv-days', { 'rv-days--missing': daysMissing }]">{{ medicationText }}</span>
|
||||||
|
<template v-if="!daysMissing && anchorText">
|
||||||
|
<span class="rv-dot">·</span>
|
||||||
|
<span class="rv-anchor">{{ anchorText }}</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 行 3:药尽 MM-DD + 状态 chip -->
|
||||||
|
<div class="rv-line rv-line--due">
|
||||||
|
<span v-if="dueDateText" class="rv-due-date">{{ dueDateText }}</span>
|
||||||
|
<el-tag
|
||||||
|
v-if="chipText"
|
||||||
|
size="small"
|
||||||
|
:type="chipType"
|
||||||
|
:effect="chipEffect"
|
||||||
|
:class="chipClass"
|
||||||
|
>
|
||||||
|
{{ chipText }}
|
||||||
|
</el-tag>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 行 4:已延迟 N 次 · 已联系 约 MM-DD(有值才显示) -->
|
||||||
|
<div v-if="followLineText" class="rv-line rv-line--follow">{{ followLineText }}</div>
|
||||||
|
</template>
|
||||||
|
<span v-else class="rv-none">—</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* 复诊节点单元格(PC 列表 / H5 卡片紧凑 / 只读页 三处共用)
|
||||||
|
*
|
||||||
|
* 只负责展示,不发请求;数据来自列表行 `revisit_due`(契约 C),由父组件用
|
||||||
|
* `normalizeRevisitDue` 规整后传入。state='none' 或 due 为 null 时仅显示行 1 + 「—」。
|
||||||
|
*/
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
import {
|
||||||
|
formatRevisitMonthDay,
|
||||||
|
hasRevisitDue,
|
||||||
|
isRevisitDaysMissing,
|
||||||
|
revisitAnchorSrcText,
|
||||||
|
revisitDueDateText,
|
||||||
|
revisitFollowLineText,
|
||||||
|
revisitMedicationDaysText,
|
||||||
|
revisitSlotText,
|
||||||
|
revisitStateChipClass,
|
||||||
|
revisitStateChipText,
|
||||||
|
revisitStateTagEffect,
|
||||||
|
revisitStateTagType,
|
||||||
|
revisitTooltipLines,
|
||||||
|
type RevisitDue
|
||||||
|
} from '@/utils/revisitDue'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
/** 规整后的复诊节点对象;null / state='none' 显示「—」 */
|
||||||
|
due: RevisitDue | null
|
||||||
|
/** H5 紧凑模式:更小字号、更紧行距 */
|
||||||
|
compact?: boolean
|
||||||
|
/** 是否已开方(决定「已开方/未开方」默认文案与颜色) */
|
||||||
|
hasPrescription?: boolean
|
||||||
|
/** 复诊医生(原列表 followup_doctor_name) */
|
||||||
|
followupDoctorName?: string
|
||||||
|
/** 自定义「已开方/未开方」文案;传 '' 隐藏该段(H5 卡片头已有标签时用) */
|
||||||
|
prescribedText?: string
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
compact: false,
|
||||||
|
hasPrescription: false,
|
||||||
|
followupDoctorName: ''
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const showDue = computed(() => hasRevisitDue(props.due))
|
||||||
|
|
||||||
|
const prescribedLabel = computed(() => {
|
||||||
|
if (props.prescribedText !== undefined) return props.prescribedText
|
||||||
|
return props.hasPrescription ? '已开方' : '未开方'
|
||||||
|
})
|
||||||
|
|
||||||
|
const slotText = computed(() => revisitSlotText(props.due))
|
||||||
|
const slotTagType = computed<'primary' | 'info'>(() =>
|
||||||
|
showDue.value && Number(props.due?.current_slot) > 0 ? 'primary' : 'info'
|
||||||
|
)
|
||||||
|
const tooltipLines = computed(() => revisitTooltipLines(props.due))
|
||||||
|
|
||||||
|
const orderDateText = computed(() => (showDue.value ? formatRevisitMonthDay(props.due?.order_date) : ''))
|
||||||
|
const daysMissing = computed(() => isRevisitDaysMissing(props.due))
|
||||||
|
const medicationText = computed(() => revisitMedicationDaysText(props.due))
|
||||||
|
const anchorText = computed(() => (showDue.value ? revisitAnchorSrcText(props.due?.anchor_src) : ''))
|
||||||
|
|
||||||
|
const dueDateText = computed(() => revisitDueDateText(props.due))
|
||||||
|
const chipText = computed(() => revisitStateChipText(props.due))
|
||||||
|
const chipType = computed(() => revisitStateTagType(props.due?.state))
|
||||||
|
const chipEffect = computed(() => revisitStateTagEffect(props.due?.state))
|
||||||
|
const chipClass = computed(() => revisitStateChipClass(props.due))
|
||||||
|
|
||||||
|
const followLineText = computed(() => revisitFollowLineText(props.due))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.rv-cell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
line-height: 1.35;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rv-line {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rv-line--head {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rv-slot-tag {
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rv-prescribed {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rv-prescribed--yes {
|
||||||
|
color: #4a5dff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rv-prescribed--no {
|
||||||
|
color: var(--el-text-color-placeholder);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rv-doctor {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rv-dot {
|
||||||
|
color: var(--el-text-color-placeholder);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rv-line--order {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rv-days--missing {
|
||||||
|
color: #ea580c;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rv-anchor {
|
||||||
|
color: var(--el-text-color-placeholder);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rv-line--due {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rv-due-date {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 状态 chip:el-tag 基础色由 type/effect 决定,这里只补深红加粗与描边细节。
|
||||||
|
前缀 .rv-cell 提高特异性,压过 Element 的 .el-tag--dark.el-tag--danger 变量定义 */
|
||||||
|
.rv-cell .rv-chip {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rv-cell .rv-chip--overdue,
|
||||||
|
.rv-cell .rv-chip--missed {
|
||||||
|
--el-tag-bg-color: #dc2626;
|
||||||
|
--el-tag-border-color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rv-cell .rv-chip--severe,
|
||||||
|
.rv-cell .rv-chip--overdue.rv-chip--severe,
|
||||||
|
.rv-cell .rv-chip--missed.rv-chip--severe {
|
||||||
|
font-weight: 700;
|
||||||
|
--el-tag-bg-color: #b91c1c;
|
||||||
|
--el-tag-border-color: #b91c1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rv-cell .rv-chip--warning {
|
||||||
|
--el-tag-text-color: #ea580c;
|
||||||
|
--el-tag-bg-color: rgba(234, 88, 12, 0.1);
|
||||||
|
--el-tag-border-color: rgba(234, 88, 12, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rv-cell .rv-chip--no_days {
|
||||||
|
--el-tag-text-color: #ea580c;
|
||||||
|
--el-tag-border-color: #ea580c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rv-cell .rv-chip--visited {
|
||||||
|
--el-tag-text-color: #16a34a;
|
||||||
|
--el-tag-bg-color: rgba(22, 163, 74, 0.1);
|
||||||
|
--el-tag-border-color: rgba(22, 163, 74, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rv-cell .rv-chip--booked {
|
||||||
|
--el-tag-text-color: #4a5dff;
|
||||||
|
--el-tag-bg-color: rgba(74, 93, 255, 0.1);
|
||||||
|
--el-tag-border-color: rgba(74, 93, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rv-cell .rv-chip--contacted {
|
||||||
|
--el-tag-text-color: #4a5dff;
|
||||||
|
--el-tag-border-color: #4a5dff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rv-line--follow {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rv-none {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--el-text-color-placeholder);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rv-tooltip {
|
||||||
|
line-height: 1.6;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* H5 紧凑模式 */
|
||||||
|
.rv-cell--compact {
|
||||||
|
gap: 2px;
|
||||||
|
|
||||||
|
.rv-line--head,
|
||||||
|
.rv-line--due,
|
||||||
|
.rv-prescribed {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rv-line--order,
|
||||||
|
.rv-line--follow,
|
||||||
|
.rv-doctor {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rv-none {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
export type FollowupPlaybackChannel = 'original' | 0 | 1
|
||||||
|
|
||||||
|
function browserAudioContext(): AudioContext | null {
|
||||||
|
const browser = globalThis as typeof globalThis & { webkitAudioContext?: typeof AudioContext }
|
||||||
|
const Constructor = browser.AudioContext || browser.webkitAudioContext
|
||||||
|
return Constructor ? new Constructor() : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One authorized HTML audio element owns one lazy graph; isolated audio is duplicated to both speakers. */
|
||||||
|
export function createFollowupAudioPlayback(element: HTMLMediaElement, createContext: () => AudioContext | null = browserAudioContext) {
|
||||||
|
let context: AudioContext | null = null
|
||||||
|
let source: MediaElementAudioSourceNode | null = null
|
||||||
|
let splitter: ChannelSplitterNode | null = null
|
||||||
|
let merger: ChannelMergerNode | null = null
|
||||||
|
let disposed = false
|
||||||
|
let generation = 0
|
||||||
|
function disconnect() { source?.disconnect(); splitter?.disconnect(); merger?.disconnect() }
|
||||||
|
return {
|
||||||
|
async select(channel: FollowupPlaybackChannel): Promise<boolean> {
|
||||||
|
if (disposed) return false
|
||||||
|
const ticket = ++generation
|
||||||
|
element.pause()
|
||||||
|
if (channel === 'original' && !context) return true
|
||||||
|
try {
|
||||||
|
if (!context) {
|
||||||
|
context = createContext()
|
||||||
|
if (!context) throw new Error('当前浏览器不支持 Web Audio,无法隔离声道;请使用原音播放。')
|
||||||
|
source = context.createMediaElementSource(element)
|
||||||
|
splitter = context.createChannelSplitter(2)
|
||||||
|
merger = context.createChannelMerger(2)
|
||||||
|
}
|
||||||
|
if (!source) throw new Error('音频播放节点未就绪')
|
||||||
|
disconnect()
|
||||||
|
if (channel === 'original') source.connect(context.destination)
|
||||||
|
else {
|
||||||
|
if (!splitter || !merger) throw new Error('声道播放节点未就绪')
|
||||||
|
source.connect(splitter)
|
||||||
|
splitter.connect(merger, channel, 0)
|
||||||
|
splitter.connect(merger, channel, 1)
|
||||||
|
merger.connect(context.destination)
|
||||||
|
}
|
||||||
|
await context.resume()
|
||||||
|
if (!disposed && ticket === generation && context.state !== 'running') throw new Error('音频上下文未运行')
|
||||||
|
return !disposed && ticket === generation
|
||||||
|
} catch (error) {
|
||||||
|
if (disposed || ticket !== generation) return false
|
||||||
|
disconnect()
|
||||||
|
// Keep an explicit original-audio fallback; never autoplay it after failed isolation.
|
||||||
|
if (source && context) source.connect(context.destination)
|
||||||
|
throw new Error(`无法隔离声道,已停止本次回听;可选择原音播放。${error instanceof Error ? error.message : ''}`)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dispose() {
|
||||||
|
if (disposed) return
|
||||||
|
disposed = true; generation++; element.pause(); disconnect()
|
||||||
|
if (context) void context.close().catch(() => { /* Element is already detached and all nodes disconnected. */ })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
import type { FollowupAudioChannel, FollowupCapabilities, FollowupChannelRoles, FollowupField, FollowupFields, FollowupReviewItem, FollowupTaskDetail, FollowupTaskSummary, FollowupTranscriptSegment, FollowupValue } from '@/api/followupAudio'
|
||||||
|
|
||||||
|
export const FOLLOWUP_CHUNK_BYTES = 2097152
|
||||||
|
export const followupKindLabels = { diagnosis: '诊单资料', blood: '血糖 / 血压', diet: '饮食', exercise: '运动', tracking_note: '跟踪备注' }
|
||||||
|
export const followupStatusLabels: Record<string, string> = {
|
||||||
|
queued: '等待处理', running: '正在分析', review: '待人工审核', applied: '已采纳',
|
||||||
|
failed: '处理失败', needs_reconciliation: '结果待核对', expired: '已过保留期'
|
||||||
|
}
|
||||||
|
const followupStageLabels: Record<string, string> = { transcribing: '正在转写', extracting: '正在提炼', validating: '正在校验', review: '待人工审核' }
|
||||||
|
export function followupTaskStatus(task: Pick<FollowupTaskSummary, 'status' | 'stage'>): string {
|
||||||
|
return (task.status === 'running' && followupStageLabels[task.stage]) || followupStatusLabels[task.status] || task.status
|
||||||
|
}
|
||||||
|
export function followupProgressText(task: FollowupTaskSummary): string {
|
||||||
|
const progress = task.pipeline_progress
|
||||||
|
const valid = progress && Number.isSafeInteger(progress.transcribed) && Number.isSafeInteger(progress.total)
|
||||||
|
&& progress.total > 0 && progress.transcribed >= 0 && progress.transcribed <= progress.total
|
||||||
|
return `${followupTaskStatus(task)}${valid ? ` · 转写片段 ${progress.transcribed} / ${progress.total}` : ''};人工审核前不会写入业务记录。`
|
||||||
|
}
|
||||||
|
export function retainedTranscriptNotice(task: FollowupTaskDetail): string {
|
||||||
|
if (!task.transcript_available || !task.transcript.trim()) return ''
|
||||||
|
if (task.status === 'failed') return '处理失败,已完成片段的转写仍可查看。保留转写不代表提炼成功,不能直接写入病历。'
|
||||||
|
if (task.status === 'needs_reconciliation') return '结果待核对,已完成片段的转写仍可查看。请先核对上游结果,不要重复发送。'
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
export function evidencePlaybackLabel(quote: FollowupReviewItem['evidence'][number]): string {
|
||||||
|
if (typeof quote.start_ms !== 'number' || !Number.isFinite(quote.start_ms) || quote.start_ms < 0) return ''
|
||||||
|
if (quote.channel !== undefined && quote.channel !== 0 && quote.channel !== 1) return ''
|
||||||
|
if (quote.position_type === 'segment') {
|
||||||
|
if (!quote.segment_id || typeof quote.end_ms !== 'number' || !Number.isFinite(quote.end_ms) || quote.end_ms <= quote.start_ms) return ''
|
||||||
|
return `回听对话 ${audioTimestamp(quote.start_ms)}–${audioTimestamp(quote.end_ms)}`
|
||||||
|
}
|
||||||
|
return `播放 ${audioTimestamp(quote.start_ms)}`
|
||||||
|
}
|
||||||
|
export function audioTimestamp(ms: number): string { const seconds = Math.max(0, Math.floor(ms / 1000)); return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}` }
|
||||||
|
export function normalizedChannelRoles(value: unknown): FollowupChannelRoles { return value === 'left_service' || value === 'right_service' ? value : 'unconfirmed' }
|
||||||
|
export function hasStereoTranscript(segments: FollowupTranscriptSegment[] = []): boolean { return segments.some(segment => segment.channel === 0) && segments.some(segment => segment.channel === 1) }
|
||||||
|
export function followupChannelLabel(channel?: FollowupAudioChannel, roles: FollowupChannelRoles = 'unconfirmed'): string {
|
||||||
|
if (channel !== 0 && channel !== 1) return '未标注声道'
|
||||||
|
const side = channel === 0 ? '左声道' : '右声道'
|
||||||
|
const confirmed = normalizedChannelRoles(roles)
|
||||||
|
if (confirmed === 'unconfirmed') return `${side} · 角色待确认`
|
||||||
|
const service = confirmed === 'left_service' ? channel === 0 : channel === 1
|
||||||
|
return `${side} · ${service ? '客服端' : '患者/家属端'}(人工标注)`
|
||||||
|
}
|
||||||
|
/** Preserve coarse ASR windows; never invent speaker turn order or word-level timestamps. */
|
||||||
|
export function dialogueWindows(segments: FollowupTranscriptSegment[]) {
|
||||||
|
const windows = new Map<string, { id: string; start_ms: number; end_ms: number; segments: FollowupTranscriptSegment[] }>()
|
||||||
|
for (const segment of segments) {
|
||||||
|
const id = `${segment.start_ms}:${segment.end_ms}`
|
||||||
|
if (!windows.has(id)) windows.set(id, { id, start_ms: segment.start_ms, end_ms: segment.end_ms, segments: [] })
|
||||||
|
windows.get(id)!.segments.push(segment)
|
||||||
|
}
|
||||||
|
return [...windows.values()].sort((a, b) => a.start_ms - b.start_ms || a.end_ms - b.end_ms)
|
||||||
|
.map(window => ({ ...window, segments: [...window.segments].sort((a, b) => (a.channel ?? -1) - (b.channel ?? -1)) }))
|
||||||
|
}
|
||||||
|
export function dialoguePlaybackStart(milliseconds: number): number {
|
||||||
|
return Number.isFinite(milliseconds) ? Math.max(0, milliseconds - 3000) : 0
|
||||||
|
}
|
||||||
|
export function channelPlaybackLabel(channel: FollowupAudioChannel, roles: FollowupChannelRoles): string {
|
||||||
|
if (roles === 'unconfirmed') return channel === 0 ? '只听左声道' : '只听右声道'
|
||||||
|
const service = roles === 'left_service' ? channel === 0 : channel === 1
|
||||||
|
return service ? '只听客服' : '只听患者/家属'
|
||||||
|
}
|
||||||
|
export const cloneReviewItems = (items: FollowupReviewItem[]): FollowupReviewItem[] => JSON.parse(JSON.stringify(items))
|
||||||
|
export function fieldText(value: FollowupValue | undefined, field: FollowupField): string {
|
||||||
|
if (value === undefined || value === null || value === '' || (Array.isArray(value) && !value.length)) return '未记录'
|
||||||
|
const display = (part: string | number | boolean) => field.options?.find(option => String(option.value) === String(part))?.label || String(part)
|
||||||
|
return Array.isArray(value) ? value.map(display).join('、') : display(value)
|
||||||
|
}
|
||||||
|
export function hasEditableReviewFields(item: FollowupReviewItem, fields: FollowupFields): boolean {
|
||||||
|
return Object.keys(item.values).some(key => fields[item.kind]?.some(field => field.key === key && !field.readonly))
|
||||||
|
}
|
||||||
|
/** Prevent multiple selected writes to the same diagnosis field; never silently pick a last value. */
|
||||||
|
export function reviewBatchIssue(items: FollowupReviewItem[], fields: FollowupFields): string {
|
||||||
|
const touched = new Set<string>()
|
||||||
|
for (const item of items.filter(item => item.selected)) {
|
||||||
|
if (item.kind === 'diagnosis') {
|
||||||
|
for (const key of Object.keys(item.values)) {
|
||||||
|
if (touched.has(key)) {
|
||||||
|
const label = fields.diagnosis?.find(field => field.key === key)?.label || key
|
||||||
|
return `${label}存在多条选中建议,请合并内容到一项或仅保留一项后再确认写入`
|
||||||
|
}
|
||||||
|
touched.add(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
export function reviewFieldIssue(item: FollowupReviewItem, fields: FollowupFields): string {
|
||||||
|
if (!item.selected) return ''
|
||||||
|
for (const key of Object.keys(item.values)) {
|
||||||
|
const field = fields[item.kind]?.find(candidate => candidate.key === key)
|
||||||
|
if (!field || field.readonly) return `${field?.label || key} 当前不可编辑,请先取消采纳该字段`
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
export function reviewIssue(item: FollowupReviewItem, canDaily: boolean): string {
|
||||||
|
if (!item.selected) return ''
|
||||||
|
if (item.needs_review) return '请明确核对不确定、冲突或重复内容'
|
||||||
|
if (item.kind !== 'diagnosis' && !canDaily) return '没有日常记录编辑权限'
|
||||||
|
if (!Object.keys(item.values).length) return '至少填写一个有效字段'
|
||||||
|
if (item.kind !== 'diagnosis' && !validDate(item.record_date)) return '请核对并填写有效的记录日期'
|
||||||
|
if (item.record_time && !/^([01]\d|2[0-3]):[0-5]\d(?::[0-5]\d)?$/.test(item.record_time)) return '请填写有效的记录时间'
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
/** Pending is independent of selection so filtering never hides unresolved items. */
|
||||||
|
export function reviewPending(item: FollowupReviewItem, fields: FollowupFields, canDaily: boolean): boolean {
|
||||||
|
return item.needs_review || !!reviewFieldIssue({ ...item, selected: true }, fields) || !!reviewIssue({ ...item, selected: true }, canDaily)
|
||||||
|
}
|
||||||
|
export function reviewCounts(items: FollowupReviewItem[], fields: FollowupFields, canDaily: boolean) {
|
||||||
|
return {
|
||||||
|
all: items.length,
|
||||||
|
diagnosis: items.filter(item => item.kind === 'diagnosis').length,
|
||||||
|
daily: items.filter(item => item.kind !== 'diagnosis').length,
|
||||||
|
pending: items.filter(item => reviewPending(item, fields, canDaily)).length,
|
||||||
|
selected: items.filter(item => item.selected).length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export function changedReviewTarget(items: FollowupReviewItem[], snapshot: FollowupReviewItem[]): boolean {
|
||||||
|
const previous = new Map(snapshot.map(item => [item.id, item]))
|
||||||
|
return items.some(item => {
|
||||||
|
const original = previous.get(item.id)
|
||||||
|
return item.selected && original && (item.target_id !== original.target_id || item.record_date !== original.record_date)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/** The API accepts legacy seconds, while the review editor exposes minute precision. */
|
||||||
|
export function reviewTimeValue(value: string | null): string | null {
|
||||||
|
return value && /^([01]\d|2[0-3]):[0-5]\d(?::[0-5]\d)?$/.test(value) ? value.slice(0, 5) : null
|
||||||
|
}
|
||||||
|
export function validDate(value: string | null): boolean {
|
||||||
|
if (!value || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return false
|
||||||
|
const [year, month, day] = value.split('-').map(Number)
|
||||||
|
const date = new Date(Date.UTC(year, month - 1, day))
|
||||||
|
return date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day
|
||||||
|
}
|
||||||
|
/** A stale snapshot refresh never discards user values or reselects an item for them. */
|
||||||
|
export function rebaseReviewItems(local: FollowupReviewItem[], fresh: FollowupReviewItem[]): FollowupReviewItem[] {
|
||||||
|
const previous = new Map(local.map(item => [item.id, item]))
|
||||||
|
return cloneReviewItems(fresh).map(item => {
|
||||||
|
const edit = previous.get(item.id)
|
||||||
|
return edit ? { ...item, values: { ...edit.values }, record_date: edit.record_date, record_time: edit.record_time,
|
||||||
|
time_period: edit.time_period, target_id: edit.target_id, selected: false, needs_review: true } : item
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/** Missing preview readiness/scope fails closed; normal deployments retain their full-verification rules. */
|
||||||
|
export function followupFeatureEnabled(caps: FollowupCapabilities | null): boolean {
|
||||||
|
return !!caps?.enabled && (!caps.preview_only || caps.test_scope_allowed === true)
|
||||||
|
}
|
||||||
|
export function followupAudioReady(caps: FollowupCapabilities | null): boolean {
|
||||||
|
return !!caps && (caps.preview_only ? caps.preview_ready === true && caps.test_scope_allowed === true : caps.audio_verified || (caps.manual_review_enabled === true && caps.manual_review_ready === true))
|
||||||
|
}
|
||||||
|
export function followupCanReview(caps: FollowupCapabilities | null, previewTask = false): boolean {
|
||||||
|
return followupFeatureEnabled(caps) && !!caps && (caps.preview_only || previewTask ? caps.can_review === true : caps.can_apply)
|
||||||
|
}
|
||||||
|
export function followupCanApply(caps: FollowupCapabilities | null, previewTask = false): boolean {
|
||||||
|
return followupFeatureEnabled(caps) && followupAudioReady(caps) && !!caps && !caps.preview_only && !previewTask && caps.can_apply
|
||||||
|
}
|
||||||
|
export function uploadIssue(file: Pick<File, 'size' | 'name'> | null, recordedAt: string, model: string, caps: FollowupCapabilities | null, viewOnly: boolean): string {
|
||||||
|
if (viewOnly || !caps || !followupFeatureEnabled(caps) || !followupAudioReady(caps) || !caps.can_upload) return '当前不可上传录音'
|
||||||
|
if (!file || !file.size) return '请选择非空录音文件'
|
||||||
|
if (file.size > caps.limits.max_bytes) return '录音文件超过大小限制'
|
||||||
|
if (!/\.(mp3|wav|m4a|amr)$/i.test(file.name)) return '请选择支持的录音格式'
|
||||||
|
if (!/^\d{4}-\d{2}-\d{2} ([01]\d|2[0-3]):[0-5]\d:[0-5]\d$/.test(recordedAt) || !validDate(recordedAt.slice(0, 10))) return '请填写实际录音时间(北京时间)'
|
||||||
|
if (!caps.models.some(option => option.value === model)) return '请选择可用的分析模型'
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
/** All async operations capture one generation; invalidate on patient switch, hide or dispose. */
|
||||||
|
export function createFollowupScope() {
|
||||||
|
let generation = 0
|
||||||
|
let controller = new AbortController()
|
||||||
|
return {
|
||||||
|
capture: () => ({ generation, signal: controller.signal }),
|
||||||
|
current: (ticket: { generation: number; signal: AbortSignal }) => ticket.generation === generation && !ticket.signal.aborted,
|
||||||
|
invalidate() { controller.abort(); generation++; controller = new AbortController() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export async function uploadChunks(
|
||||||
|
file: Blob,
|
||||||
|
chunkBytes: number,
|
||||||
|
send: (index: number, chunk: Blob) => Promise<{ received: boolean }>,
|
||||||
|
isCurrent: () => boolean,
|
||||||
|
progress: (percent: number) => void
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!Number.isSafeInteger(chunkBytes) || chunkBytes < 65536 || chunkBytes > FOLLOWUP_CHUNK_BYTES) throw new Error('服务端分片配置不匹配,请联系管理员')
|
||||||
|
for (let start = 0, index = 0; start < file.size; start += chunkBytes, index++) {
|
||||||
|
if (!isCurrent()) return false
|
||||||
|
const result = await send(index, file.slice(start, start + chunkBytes))
|
||||||
|
if (!isCurrent()) return false
|
||||||
|
if (!result.received) throw new Error('录音分片未被服务端确认,上传已停止')
|
||||||
|
progress(Math.round(Math.min(start + chunkBytes, file.size) / file.size * 100))
|
||||||
|
}
|
||||||
|
return isCurrent()
|
||||||
|
}
|
||||||
|
export function followupError(error: unknown): string {
|
||||||
|
return error instanceof Error ? error.message : '请求失败;当前审核内容已保留,请刷新任务状态后确认'
|
||||||
|
}
|
||||||
@@ -52,7 +52,7 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<el-tabs v-model="activeTab" class="diagnosis-tabs tcm-diagnosis-tabs">
|
<el-tabs v-model="activeTab" :before-leave="beforeTabLeave" class="diagnosis-tabs tcm-diagnosis-tabs">
|
||||||
<!-- 基本信息标签页 -->
|
<!-- 基本信息标签页 -->
|
||||||
<el-tab-pane label="病历" name="basic">
|
<el-tab-pane label="病历" name="basic">
|
||||||
<el-form
|
<el-form
|
||||||
@@ -660,6 +660,7 @@
|
|||||||
lazy
|
lazy
|
||||||
>
|
>
|
||||||
<daily-matrix
|
<daily-matrix
|
||||||
|
ref="dailyMatrixRef"
|
||||||
v-if="formData.id"
|
v-if="formData.id"
|
||||||
:diagnosis-id="Number(formData.id)"
|
:diagnosis-id="Number(formData.id)"
|
||||||
:patient-id="Number(formData.patient_id)"
|
:patient-id="Number(formData.patient_id)"
|
||||||
@@ -670,6 +671,33 @@
|
|||||||
<el-empty v-else description="请先保存诊单后查看" />
|
<el-empty v-else description="请先保存诊单后查看" />
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<el-tab-pane v-if="formData.id && hasFollowupAudioPermission" label="回访录音" name="followupAudio" lazy>
|
||||||
|
<el-alert v-if="followupCapabilitiesLoading" title="正在检查回访录音状态…" type="info" :closable="false" show-icon />
|
||||||
|
<div v-else-if="followupCapabilitiesError" role="alert">
|
||||||
|
<el-alert :title="followupCapabilitiesError" type="error" :closable="false" show-icon />
|
||||||
|
<el-button class="mt-3" @click="loadFollowupCapabilities">重新加载</el-button>
|
||||||
|
</div>
|
||||||
|
<FollowupAudioPanel
|
||||||
|
v-else-if="visible && followupCapabilities?.enabled && followupCapabilitiesDiagnosisId === Number(formData.id)"
|
||||||
|
:key="Number(formData.id)"
|
||||||
|
ref="followupAudioRef"
|
||||||
|
:diagnosis-id="Number(formData.id)"
|
||||||
|
:view-only="viewOnly"
|
||||||
|
:active="activeTab === 'followupAudio'"
|
||||||
|
:capabilities="followupCapabilities"
|
||||||
|
:basic-dirty="basicDirty"
|
||||||
|
:before-apply="canApplyFollowup"
|
||||||
|
@applied="handleFollowupApplied"
|
||||||
|
/>
|
||||||
|
<el-alert
|
||||||
|
v-else
|
||||||
|
:title="followupCapabilities?.test_scope_allowed === false ? '当前诊单未开放回访录音' : '当前未启用回访录音,请联系管理员确认'"
|
||||||
|
type="info"
|
||||||
|
:closable="false"
|
||||||
|
show-icon
|
||||||
|
/>
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
<!-- 病历记录标签页(开方后自动显示) -->
|
<!-- 病历记录标签页(开方后自动显示) -->
|
||||||
<el-tab-pane label="处方" name="bingli" :disabled="!formData.id" v-if="hasPermission(['tcm.diagnosis/chufang'])">
|
<el-tab-pane label="处方" name="bingli" :disabled="!formData.id" v-if="hasPermission(['tcm.diagnosis/chufang'])">
|
||||||
<div v-if="formData.id" class="bingli-tab-content">
|
<div v-if="formData.id" class="bingli-tab-content">
|
||||||
@@ -785,6 +813,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||||
import { tcmDiagnosisAdd, tcmDiagnosisEdit, tcmDiagnosisDetail, checkPhone, checkIdCard, diagnosisTrackingNotes } from '@/api/tcm'
|
import { tcmDiagnosisAdd, tcmDiagnosisEdit, tcmDiagnosisDetail, checkPhone, checkIdCard, diagnosisTrackingNotes } from '@/api/tcm'
|
||||||
import { getDictData } from '@/api/app'
|
import { getDictData } from '@/api/app'
|
||||||
import feedback from '@/utils/feedback'
|
import feedback from '@/utils/feedback'
|
||||||
@@ -794,6 +823,8 @@ import useAppStore from '@/stores/modules/app'
|
|||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { QuestionFilled } from '@element-plus/icons-vue'
|
import { QuestionFilled } from '@element-plus/icons-vue'
|
||||||
import DailyMatrix from './components/DailyMatrix.vue'
|
import DailyMatrix from './components/DailyMatrix.vue'
|
||||||
|
import FollowupAudioPanel from './components/FollowupAudioPanel.vue'
|
||||||
|
import { followupAudioCapabilities, type FollowupCapabilities } from '@/api/followupAudio'
|
||||||
import CaseRecordList from './components/CaseRecordList.vue'
|
import CaseRecordList from './components/CaseRecordList.vue'
|
||||||
import CallRecordPanel from './components/CallRecordPanel.vue'
|
import CallRecordPanel from './components/CallRecordPanel.vue'
|
||||||
import ImChatRecordPanel from './components/ImChatRecordPanel.vue'
|
import ImChatRecordPanel from './components/ImChatRecordPanel.vue'
|
||||||
@@ -811,6 +842,15 @@ const getImageUrl = (url: string) => (url?.indexOf?.('http') === 0 ? url : appSt
|
|||||||
const caseRecordListRef = ref()
|
const caseRecordListRef = ref()
|
||||||
const callRecordPanelRef = ref<{ refresh?: () => void } | null>(null)
|
const callRecordPanelRef = ref<{ refresh?: () => void } | null>(null)
|
||||||
const prescriptionRef = ref()
|
const prescriptionRef = ref()
|
||||||
|
const dailyMatrixRef = ref<{ refresh?: () => Promise<void> | void } | null>(null)
|
||||||
|
const followupAudioRef = ref<{ confirmLeave: () => Promise<boolean> } | null>(null)
|
||||||
|
const followupCapabilities = ref<FollowupCapabilities | null>(null)
|
||||||
|
const followupCapabilitiesLoading = ref(false)
|
||||||
|
const followupCapabilitiesError = ref('')
|
||||||
|
const followupCapabilitiesDiagnosisId = ref<number | null>(null)
|
||||||
|
const basicSnapshot = ref('')
|
||||||
|
let diagnosisGeneration = 0
|
||||||
|
let capabilitiesRequest: AbortController | undefined
|
||||||
|
|
||||||
const visible = ref(false)
|
const visible = ref(false)
|
||||||
/** 只读查看:与 consumers 处方等处「查看诊单」一致,全部 Tab 仅展示不可保存 */
|
/** 只读查看:与 consumers 处方等处「查看诊单」一致,全部 Tab 仅展示不可保存 */
|
||||||
@@ -822,6 +862,7 @@ const submitting = ref(false)
|
|||||||
/** 拥有后手机号可正常编辑,失焦不再强制脱敏 */
|
/** 拥有后手机号可正常编辑,失焦不再强制脱敏 */
|
||||||
const hasPhonePlainPermission = computed(() => hasPermission(['tcm.diagnosis/phonePlain']))
|
const hasPhonePlainPermission = computed(() => hasPermission(['tcm.diagnosis/phonePlain']))
|
||||||
const hasDailyRecordPermission = computed(() => hasPermission(['tcm.diagnosis/dailyRecord']))
|
const hasDailyRecordPermission = computed(() => hasPermission(['tcm.diagnosis/dailyRecord']))
|
||||||
|
const hasFollowupAudioPermission = computed(() => hasPermission(['tcm.diagnosis/followupAudio']))
|
||||||
const patientBasicLocked = ref(false)
|
const patientBasicLocked = ref(false)
|
||||||
const canEditPatientBasicFromApi = ref(true)
|
const canEditPatientBasicFromApi = ref(true)
|
||||||
const latestPrescriptionOrder = ref<{
|
const latestPrescriptionOrder = ref<{
|
||||||
@@ -970,6 +1011,77 @@ const formData = ref({
|
|||||||
external_userid: ''
|
external_userid: ''
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Include unmasked identity values so merely revealing a number is not an unsaved edit.
|
||||||
|
const basicFingerprint = () => JSON.stringify({ ...formData.value,
|
||||||
|
phone: originalPhone.value || formData.value.phone, id_card: originalIdCard.value || formData.value.id_card })
|
||||||
|
const basicDirty = computed(() => !viewOnly.value && !!basicSnapshot.value && basicFingerprint() !== basicSnapshot.value)
|
||||||
|
const canApplyFollowup = () => !basicDirty.value && visible.value && !viewOnly.value
|
||||||
|
async function beforeTabLeave(next: string | number, previous: string | number): Promise<boolean> {
|
||||||
|
if (next === 'followupAudio' && basicDirty.value) {
|
||||||
|
feedback.msgWarning('病历有未保存修改,请先保存后再进入回访录音审核,当前输入已保留')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (previous === 'followupAudio') return await followupAudioRef.value?.confirmLeave() ?? true
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
async function handleFollowupApplied(diagnosisId: number) {
|
||||||
|
if (!visible.value || Number(formData.value.id) !== diagnosisId) return
|
||||||
|
const generation = diagnosisGeneration
|
||||||
|
try {
|
||||||
|
// The response itself is also guarded, including edits made while it is in flight.
|
||||||
|
await loadDiagnosisDetailIntoForm(diagnosisId, true)
|
||||||
|
if (generation !== diagnosisGeneration || !visible.value || Number(formData.value.id) !== diagnosisId) return
|
||||||
|
await dailyMatrixRef.value?.refresh?.()
|
||||||
|
emit('success')
|
||||||
|
} catch {
|
||||||
|
if (generation === diagnosisGeneration) feedback.msgWarning('录音已采纳,但病历或日常记录刷新失败,请手动刷新查看')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function resetFollowupCapabilities() {
|
||||||
|
capabilitiesRequest?.abort()
|
||||||
|
capabilitiesRequest = undefined
|
||||||
|
followupCapabilities.value = null
|
||||||
|
followupCapabilitiesDiagnosisId.value = null
|
||||||
|
followupCapabilitiesLoading.value = false
|
||||||
|
followupCapabilitiesError.value = ''
|
||||||
|
}
|
||||||
|
async function loadFollowupCapabilities(): Promise<void> {
|
||||||
|
// A rapid second click must not create another request for the same context.
|
||||||
|
if (followupCapabilitiesLoading.value) return
|
||||||
|
resetFollowupCapabilities()
|
||||||
|
const id = Number(formData.value.id)
|
||||||
|
if (!visible.value || !id || !hasFollowupAudioPermission.value) return
|
||||||
|
const generation = diagnosisGeneration
|
||||||
|
const request = new AbortController()
|
||||||
|
capabilitiesRequest = request
|
||||||
|
followupCapabilitiesLoading.value = true
|
||||||
|
const isCurrent = () => capabilitiesRequest === request && !request.signal.aborted
|
||||||
|
&& generation === diagnosisGeneration && visible.value && Number(formData.value.id) === id
|
||||||
|
try {
|
||||||
|
const result = await followupAudioCapabilities(id, request.signal)
|
||||||
|
if (!isCurrent()) return
|
||||||
|
if (typeof result?.enabled !== 'boolean') throw new Error('Invalid capability response')
|
||||||
|
followupCapabilities.value = result
|
||||||
|
followupCapabilitiesDiagnosisId.value = id
|
||||||
|
} catch {
|
||||||
|
if (!isCurrent()) return
|
||||||
|
// No endpoint failure (including permission denial) may mount the panel.
|
||||||
|
followupCapabilitiesError.value = '回访录音状态加载失败,请重新加载;若仍失败,请联系管理员检查权限或服务状态'
|
||||||
|
} finally {
|
||||||
|
if (isCurrent()) {
|
||||||
|
followupCapabilitiesLoading.value = false
|
||||||
|
capabilitiesRequest = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
watch([visible, () => formData.value.id, hasFollowupAudioPermission], () => {
|
||||||
|
// Invalidate synchronously on patient switches and permission changes.
|
||||||
|
if (!hasFollowupAudioPermission.value && activeTab.value === 'followupAudio') activeTab.value = 'basic'
|
||||||
|
resetFollowupCapabilities()
|
||||||
|
void loadFollowupCapabilities()
|
||||||
|
}, { flush: 'sync' })
|
||||||
|
onBeforeUnmount(() => { diagnosisGeneration++; resetFollowupCapabilities() })
|
||||||
|
|
||||||
/** 就诊卡创建来源(与 zyt_tcm_diagnosis.create_source 一致) */
|
/** 就诊卡创建来源(与 zyt_tcm_diagnosis.create_source 一致) */
|
||||||
const createSourceOptions = [
|
const createSourceOptions = [
|
||||||
{ value: '', label: '未设置' },
|
{ value: '', label: '未设置' },
|
||||||
@@ -1260,8 +1372,14 @@ const getDictOptions = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadDiagnosisDetailIntoForm(id: number): Promise<void> {
|
async function loadDiagnosisDetailIntoForm(id: number, preserveDirty = false): Promise<void> {
|
||||||
|
const generation = diagnosisGeneration
|
||||||
const data = await tcmDiagnosisDetail({ id })
|
const data = await tcmDiagnosisDetail({ id })
|
||||||
|
if (generation !== diagnosisGeneration || !visible.value) return
|
||||||
|
if (preserveDirty && basicDirty.value) {
|
||||||
|
feedback.msgWarning('录音已采纳,病历仍保留你的未保存修改;请核对后再保存,未用刷新覆盖输入')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
originalPhone.value = data.phone || ''
|
originalPhone.value = data.phone || ''
|
||||||
originalIdCard.value = data.id_card || ''
|
originalIdCard.value = data.id_card || ''
|
||||||
@@ -1277,6 +1395,7 @@ async function loadDiagnosisDetailIntoForm(id: number): Promise<void> {
|
|||||||
formData.value.show_card = Number(data.show_card) === 0 ? 0 : 1
|
formData.value.show_card = Number(data.show_card) === 0 ? 0 : 1
|
||||||
const y = data.diabetes_discovery_year
|
const y = data.diabetes_discovery_year
|
||||||
formData.value.diabetes_discovery_year = y == null || y === '' ? '' : String(y)
|
formData.value.diabetes_discovery_year = y == null || y === '' ? '' : String(y)
|
||||||
|
basicSnapshot.value = basicFingerprint()
|
||||||
}
|
}
|
||||||
|
|
||||||
const resetPatientBasicLockState = () => {
|
const resetPatientBasicLockState = () => {
|
||||||
@@ -1286,6 +1405,11 @@ const resetPatientBasicLockState = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const open = async (type: string, id?: number) => {
|
const open = async (type: string, id?: number) => {
|
||||||
|
const generation = ++diagnosisGeneration
|
||||||
|
submitting.value = false
|
||||||
|
basicSnapshot.value = ''
|
||||||
|
resetFollowupCapabilities()
|
||||||
|
formData.value.id = ''
|
||||||
viewOnly.value = false
|
viewOnly.value = false
|
||||||
mode.value = type
|
mode.value = type
|
||||||
visible.value = true
|
visible.value = true
|
||||||
@@ -1296,6 +1420,7 @@ const open = async (type: string, id?: number) => {
|
|||||||
|
|
||||||
// 加载字典数据
|
// 加载字典数据
|
||||||
await getDictOptions()
|
await getDictOptions()
|
||||||
|
if (generation !== diagnosisGeneration || !visible.value) return
|
||||||
|
|
||||||
if (type === 'edit' && id) {
|
if (type === 'edit' && id) {
|
||||||
await loadDiagnosisDetailIntoForm(id)
|
await loadDiagnosisDetailIntoForm(id)
|
||||||
@@ -1310,6 +1435,11 @@ const open = async (type: string, id?: number) => {
|
|||||||
/** 只读打开:与编辑同一套界面与全部 Tab,不可保存 */
|
/** 只读打开:与编辑同一套界面与全部 Tab,不可保存 */
|
||||||
const openViewOnly = async (id: number) => {
|
const openViewOnly = async (id: number) => {
|
||||||
if (!id) return
|
if (!id) return
|
||||||
|
const generation = ++diagnosisGeneration
|
||||||
|
submitting.value = false
|
||||||
|
basicSnapshot.value = ''
|
||||||
|
resetFollowupCapabilities()
|
||||||
|
formData.value.id = ''
|
||||||
viewOnly.value = true
|
viewOnly.value = true
|
||||||
mode.value = 'edit'
|
mode.value = 'edit'
|
||||||
visible.value = true
|
visible.value = true
|
||||||
@@ -1318,13 +1448,17 @@ const openViewOnly = async (id: number) => {
|
|||||||
idCardRevealUnlocked.value = false
|
idCardRevealUnlocked.value = false
|
||||||
resetPatientBasicLockState()
|
resetPatientBasicLockState()
|
||||||
await getDictOptions()
|
await getDictOptions()
|
||||||
|
if (generation !== diagnosisGeneration || !visible.value) return
|
||||||
await loadDiagnosisDetailIntoForm(id)
|
await loadDiagnosisDetailIntoForm(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (viewOnly.value) return
|
if (viewOnly.value || submitting.value) return
|
||||||
|
const generation = diagnosisGeneration
|
||||||
await formRef.value?.validate()
|
await formRef.value?.validate()
|
||||||
|
if (generation !== diagnosisGeneration || !visible.value) return
|
||||||
|
// A user can keep typing while the request is pending. Only mark submitted bytes saved.
|
||||||
|
const submittedForm = JSON.parse(basicFingerprint()) as typeof formData.value
|
||||||
submitting.value = true
|
submitting.value = true
|
||||||
try {
|
try {
|
||||||
// 准备提交数据,使用原始完整数据
|
// 准备提交数据,使用原始完整数据
|
||||||
@@ -1336,6 +1470,7 @@ const handleSubmit = async () => {
|
|||||||
|
|
||||||
if (mode.value === 'add') {
|
if (mode.value === 'add') {
|
||||||
const result = await tcmDiagnosisAdd(submitData)
|
const result = await tcmDiagnosisAdd(submitData)
|
||||||
|
if (generation !== diagnosisGeneration || !visible.value) return
|
||||||
|
|
||||||
// 如果是新增,保存成功后切换到编辑模式,这样可以添加血糖血压记录
|
// 如果是新增,保存成功后切换到编辑模式,这样可以添加血糖血压记录
|
||||||
if (result && result.id) {
|
if (result && result.id) {
|
||||||
@@ -1343,11 +1478,14 @@ const handleSubmit = async () => {
|
|||||||
phoneRevealUnlocked.value = false
|
phoneRevealUnlocked.value = false
|
||||||
idCardRevealUnlocked.value = false
|
idCardRevealUnlocked.value = false
|
||||||
formData.value.id = result.id
|
formData.value.id = result.id
|
||||||
|
submittedForm.id = result.id
|
||||||
|
|
||||||
// 重新获取详情,确保patient_id等字段正确
|
// 重新获取详情,确保patient_id等字段正确
|
||||||
try {
|
try {
|
||||||
const detail = await tcmDiagnosisDetail({ id: result.id })
|
const detail = await tcmDiagnosisDetail({ id: result.id })
|
||||||
|
if (generation !== diagnosisGeneration || !visible.value) return
|
||||||
formData.value.patient_id = detail.patient_id
|
formData.value.patient_id = detail.patient_id
|
||||||
|
submittedForm.patient_id = detail.patient_id
|
||||||
|
|
||||||
// 更新原始数据
|
// 更新原始数据
|
||||||
originalPhone.value = detail.phone || ''
|
originalPhone.value = detail.phone || ''
|
||||||
@@ -1367,11 +1505,13 @@ const handleSubmit = async () => {
|
|||||||
await tcmDiagnosisEdit(submitData)
|
await tcmDiagnosisEdit(submitData)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (generation !== diagnosisGeneration || !visible.value) return
|
||||||
|
basicSnapshot.value = JSON.stringify(submittedForm)
|
||||||
emit('success')
|
emit('success')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('提交失败:', error)
|
console.error('提交失败:', error)
|
||||||
} finally {
|
} finally {
|
||||||
submitting.value = false
|
if (generation === diagnosisGeneration) submitting.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1395,7 +1535,11 @@ const handleOpenPrescription = () => {
|
|||||||
prescriptionRef.value?.open(data)
|
prescriptionRef.value?.open(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = async () => {
|
||||||
|
if (followupAudioRef.value && !await followupAudioRef.value.confirmLeave()) return
|
||||||
|
diagnosisGeneration++
|
||||||
|
resetFollowupCapabilities()
|
||||||
|
basicSnapshot.value = ''
|
||||||
formRef.value?.resetFields()
|
formRef.value?.resetFields()
|
||||||
formData.value = {
|
formData.value = {
|
||||||
id: '',
|
id: '',
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
formData.pending_assign !== '1' &&
|
formData.pending_assign !== '1' &&
|
||||||
formData.pending_booking !== '1' &&
|
formData.pending_booking !== '1' &&
|
||||||
formData.completed_appointment !== '1' &&
|
formData.completed_appointment !== '1' &&
|
||||||
|
formData.revisit_state === '' &&
|
||||||
!hasLatestAppointmentFilter() &&
|
!hasLatestAppointmentFilter() &&
|
||||||
formData.appointment_date === tab.value
|
formData.appointment_date === tab.value
|
||||||
}
|
}
|
||||||
@@ -30,6 +31,14 @@
|
|||||||
待预约
|
待预约
|
||||||
<em v-if="pendingBookingCount !== undefined">{{ pendingBookingCount }}</em>
|
<em v-if="pendingBookingCount !== undefined">{{ pendingBookingCount }}</em>
|
||||||
</span>
|
</span>
|
||||||
|
<!-- 复诊到期:到期未挂号 ∪ 过号未面诊(revisit_state=due),点击默认按药尽日升序 -->
|
||||||
|
<span
|
||||||
|
:class="['chip', 'chip-revisit-due', { active: formData.revisit_state === 'due' }]"
|
||||||
|
@click="handleRevisitDueTabClick"
|
||||||
|
>
|
||||||
|
复诊到期
|
||||||
|
<em v-if="revisitDueCount !== undefined">{{ revisitDueCount }}</em>
|
||||||
|
</span>
|
||||||
<span
|
<span
|
||||||
:class="['chip', 'chip-completed-visit', { active: formData.completed_appointment === '1' }]"
|
:class="['chip', 'chip-completed-visit', { active: formData.completed_appointment === '1' }]"
|
||||||
@click="handleCompletedVisitTabClick"
|
@click="handleCompletedVisitTabClick"
|
||||||
@@ -147,6 +156,36 @@
|
|||||||
:value="String(item.value)"
|
:value="String(item.value)"
|
||||||
/>
|
/>
|
||||||
</el-select>
|
</el-select>
|
||||||
|
<el-select
|
||||||
|
v-model="formData.revisit_state"
|
||||||
|
placeholder="复诊状态"
|
||||||
|
clearable
|
||||||
|
size="small"
|
||||||
|
class="revisit-state-select"
|
||||||
|
@change="handleRevisitFilterChange"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="item in revisitStateFilterOptions"
|
||||||
|
:key="item.value"
|
||||||
|
:label="item.label"
|
||||||
|
:value="item.value"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
<el-select
|
||||||
|
v-model="formData.revisit_days_bucket"
|
||||||
|
placeholder="开药天数档"
|
||||||
|
clearable
|
||||||
|
size="small"
|
||||||
|
class="revisit-days-bucket-select"
|
||||||
|
@change="doSearch"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="item in revisitDaysBucketOptions"
|
||||||
|
:key="item.value"
|
||||||
|
:label="item.label"
|
||||||
|
:value="item.value"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
<el-button size="small" @click="handleReset">重置</el-button>
|
<el-button size="small" @click="handleReset">重置</el-button>
|
||||||
</div>
|
</div>
|
||||||
</el-card>
|
</el-card>
|
||||||
@@ -278,22 +317,20 @@
|
|||||||
<span v-else class="status-unconfirmed">未确认</span>
|
<span v-else class="status-unconfirmed">未确认</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="复诊" min-width="120">
|
<!-- 复诊节点:吞掉原「复诊」「开方」两列;排序走后端 sort_revisit_due(药尽日,NULL 置后) -->
|
||||||
|
<el-table-column
|
||||||
|
label="复诊节点"
|
||||||
|
prop="revisit_due_date"
|
||||||
|
min-width="180"
|
||||||
|
sortable="custom"
|
||||||
|
>
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div v-if="row.has_prescription" class="followup-cell">
|
<revisit-node-cell
|
||||||
<div class="followup-time">{{ row.followup_time_text || '—' }}</div>
|
:due="revisitDueOf(row)"
|
||||||
<div class="followup-doctor">{{ row.followup_doctor_name || '—' }}</div>
|
:has-prescription="!!row.has_prescription"
|
||||||
<el-tag
|
:followup-doctor-name="row.followup_doctor_name || ''"
|
||||||
v-if="row.followup_rx_voided"
|
:prescribed-text="prescribedTextOf(row)"
|
||||||
type="info"
|
/>
|
||||||
size="small"
|
|
||||||
effect="plain"
|
|
||||||
class="followup-void-tag"
|
|
||||||
>
|
|
||||||
处方已作废
|
|
||||||
</el-tag>
|
|
||||||
</div>
|
|
||||||
<span v-else class="followup-none">无</span>
|
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="助理" width="100" show-overflow-tooltip>
|
<el-table-column label="助理" width="100" show-overflow-tooltip>
|
||||||
@@ -301,12 +338,6 @@
|
|||||||
<span class="assistant-cell">{{ getAssistantName(row.assistant_id || row.assistant) }}</span>
|
<span class="assistant-cell">{{ getAssistantName(row.assistant_id || row.assistant) }}</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="开方" width="72" align="center">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<span v-if="row.has_prescription" class="status-prescribed">已开方</span>
|
|
||||||
<span v-else class="status-unprescribed">未开方</span>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column
|
<el-table-column
|
||||||
label="未服务天数"
|
label="未服务天数"
|
||||||
prop="unserved_days"
|
prop="unserved_days"
|
||||||
@@ -519,7 +550,6 @@
|
|||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="继承">
|
<el-form-item label="继承">
|
||||||
<el-checkbox v-model="assignForm.is_inherit">继承</el-checkbox>
|
<el-checkbox v-model="assignForm.is_inherit">继承</el-checkbox>
|
||||||
<span class="text-gray-400 text-xs ml-2">勾选后写入指派记录</span>
|
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item v-if="assignMode === 'batch'" label="已选择">
|
<el-form-item v-if="assignMode === 'batch'" label="已选择">
|
||||||
<div class="text-gray-500">{{ batchAssignIds.length }} 条诊单记录</div>
|
<div class="text-gray-500">{{ batchAssignIds.length }} 条诊单记录</div>
|
||||||
@@ -737,7 +767,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts" name="tcmDiagnosis">
|
<script setup lang="ts" name="tcmDiagnosis">
|
||||||
import { tcmDiagnosisLists, tcmDiagnosisDelete, tcmDiagnosisAssign, tcmDiagnosisGuahaoLogList, getAssistants, generateMiniProgramQrcode, generateOrderQrcode, getWechatChatRecords, addWechatChatRecord, deleteWechatChatRecord, getWechatExternalContact, fillIdCard } from '@/api/tcm'
|
import { tcmDiagnosisLists, tcmDiagnosisDelete, tcmDiagnosisAssign, tcmDiagnosisGuahaoLogList, getAssistants, generateMiniProgramQrcode, generateOrderQrcode, getWechatChatRecords, addWechatChatRecord, deleteWechatChatRecord, getWechatExternalContact, fillIdCard, diagnosisReadonlyDetail } from '@/api/tcm'
|
||||||
import { orderCreate } from '@/api/order'
|
import { orderCreate } from '@/api/order'
|
||||||
import { cancelAppointment } from '@/api/doctor'
|
import { cancelAppointment } from '@/api/doctor'
|
||||||
import { usePaging } from '@/hooks/usePaging'
|
import { usePaging } from '@/hooks/usePaging'
|
||||||
@@ -752,6 +782,15 @@ import { useRoute, useRouter } from 'vue-router'
|
|||||||
import { computed, defineAsyncComponent, onMounted, onUnmounted, watch } from 'vue'
|
import { computed, defineAsyncComponent, onMounted, onUnmounted, watch } from 'vue'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import DaterangePicker from '@/components/daterange-picker/index.vue'
|
import DaterangePicker from '@/components/daterange-picker/index.vue'
|
||||||
|
import RevisitNodeCell from './components/RevisitNodeCell.vue'
|
||||||
|
import { formatGender } from '@/utils/diag-display'
|
||||||
|
import {
|
||||||
|
normalizeRevisitDue,
|
||||||
|
REVISIT_DAYS_BUCKET_OPTIONS,
|
||||||
|
REVISIT_STATE_FILTER_OPTIONS,
|
||||||
|
revisitRowClassName,
|
||||||
|
type RevisitDue
|
||||||
|
} from '@/utils/revisitDue'
|
||||||
|
|
||||||
const EditPopup = defineAsyncComponent(() => import('./edit.vue'))
|
const EditPopup = defineAsyncComponent(() => import('./edit.vue'))
|
||||||
const DetailPopup = defineAsyncComponent(() => import('./detail.vue'))
|
const DetailPopup = defineAsyncComponent(() => import('./detail.vue'))
|
||||||
@@ -798,6 +837,12 @@ const formData = reactive({
|
|||||||
latest_assign_end_date: '' as string,
|
latest_assign_end_date: '' as string,
|
||||||
/** 未服务天数排序:desc=天数多到少 asc=少到多 */
|
/** 未服务天数排序:desc=天数多到少 asc=少到多 */
|
||||||
sort_unserved_days: '' as '' | 'asc' | 'desc',
|
sort_unserved_days: '' as '' | 'asc' | 'desc',
|
||||||
|
/** 复诊状态筛选:due(到期未挂号∪过号)|warning|booked|visited|contacted|no_days|pending,空=不限 */
|
||||||
|
revisit_state: '' as string,
|
||||||
|
/** 开药天数档筛选:14|21|28|30|other,空=不限 */
|
||||||
|
revisit_days_bucket: '' as string,
|
||||||
|
/** 复诊节点(药尽日)排序:asc=最早到期在前(NULL 置后) desc 反之;与 sort_unserved_days 互斥 */
|
||||||
|
sort_revisit_due: '' as '' | 'asc' | 'desc',
|
||||||
diagnosis_confirmed: '' as '' | '0' | '1',
|
diagnosis_confirmed: '' as '' | '0' | '1',
|
||||||
appointment_date: '' as string,
|
appointment_date: '' as string,
|
||||||
has_appointment: '' as '' | '0' | '1',
|
has_appointment: '' as '' | '0' | '1',
|
||||||
@@ -898,11 +943,13 @@ function buildSharedDiagnosisFilterPayload(): Record<string, unknown> {
|
|||||||
latest_appointment_end_date: formData.latest_appointment_end_date,
|
latest_appointment_end_date: formData.latest_appointment_end_date,
|
||||||
latest_appointment_channel_source: formData.latest_appointment_channel_source,
|
latest_appointment_channel_source: formData.latest_appointment_channel_source,
|
||||||
latest_assign_start_date: formData.latest_assign_start_date,
|
latest_assign_start_date: formData.latest_assign_start_date,
|
||||||
latest_assign_end_date: formData.latest_assign_end_date
|
latest_assign_end_date: formData.latest_assign_end_date,
|
||||||
|
revisit_state: formData.revisit_state,
|
||||||
|
revisit_days_bucket: formData.revisit_days_bucket
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 顶部 Tab 角标 count 请求:带上共用筛选,再叠加各 Tab 专属条件 */
|
/** 顶部 Tab 角标 count 请求:带上共用筛选,再叠加各 Tab 专属条件(revisit_state 视为 Tab 条件,角标间互不影响) */
|
||||||
function buildDateCountRequestPayload(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
function buildDateCountRequestPayload(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||||
return {
|
return {
|
||||||
page_no: 1,
|
page_no: 1,
|
||||||
@@ -915,6 +962,8 @@ function buildDateCountRequestPayload(overrides: Record<string, unknown> = {}):
|
|||||||
pending_assign_order_month: '',
|
pending_assign_order_month: '',
|
||||||
pending_assign_keyword: '',
|
pending_assign_keyword: '',
|
||||||
sort_unserved_days: '',
|
sort_unserved_days: '',
|
||||||
|
revisit_state: '',
|
||||||
|
sort_revisit_due: '',
|
||||||
...overrides
|
...overrides
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -960,6 +1009,9 @@ function clearSecondaryFiltersWhenPendingAssignWideSearch() {
|
|||||||
formData.latest_assign_start_date = ''
|
formData.latest_assign_start_date = ''
|
||||||
formData.latest_assign_end_date = ''
|
formData.latest_assign_end_date = ''
|
||||||
formData.sort_unserved_days = ''
|
formData.sort_unserved_days = ''
|
||||||
|
formData.revisit_state = ''
|
||||||
|
formData.revisit_days_bucket = ''
|
||||||
|
formData.sort_revisit_due = ''
|
||||||
formData.pending_assign_order_month = ''
|
formData.pending_assign_order_month = ''
|
||||||
activeTab.value = 'all'
|
activeTab.value = 'all'
|
||||||
if (kw1 !== '') {
|
if (kw1 !== '') {
|
||||||
@@ -996,6 +1048,7 @@ const handleDateTabClick = async (value: string) => {
|
|||||||
formData.pending_booking = ''
|
formData.pending_booking = ''
|
||||||
formData.completed_appointment = ''
|
formData.completed_appointment = ''
|
||||||
formData.has_appointment = ''
|
formData.has_appointment = ''
|
||||||
|
clearRevisitTabFilters()
|
||||||
clearLatestAppointmentFilters()
|
clearLatestAppointmentFilters()
|
||||||
formData.appointment_date = value
|
formData.appointment_date = value
|
||||||
pager.page = 1
|
pager.page = 1
|
||||||
@@ -1012,6 +1065,7 @@ const handlePendingBookingTabClick = async () => {
|
|||||||
formData.completed_appointment = ''
|
formData.completed_appointment = ''
|
||||||
formData.appointment_date = ''
|
formData.appointment_date = ''
|
||||||
formData.has_appointment = '0'
|
formData.has_appointment = '0'
|
||||||
|
clearRevisitTabFilters()
|
||||||
clearLatestAppointmentFilters()
|
clearLatestAppointmentFilters()
|
||||||
pager.page = 1
|
pager.page = 1
|
||||||
await getLists()
|
await getLists()
|
||||||
@@ -1027,6 +1081,7 @@ const handleCompletedVisitTabClick = async () => {
|
|||||||
formData.completed_appointment = '1'
|
formData.completed_appointment = '1'
|
||||||
formData.has_appointment = ''
|
formData.has_appointment = ''
|
||||||
formData.appointment_date = ''
|
formData.appointment_date = ''
|
||||||
|
clearRevisitTabFilters()
|
||||||
clearLatestAppointmentFilters()
|
clearLatestAppointmentFilters()
|
||||||
pager.page = 1
|
pager.page = 1
|
||||||
await getLists()
|
await getLists()
|
||||||
@@ -1039,6 +1094,7 @@ const handlePendingAssignTabClick = async () => {
|
|||||||
formData.completed_appointment = ''
|
formData.completed_appointment = ''
|
||||||
formData.has_appointment = ''
|
formData.has_appointment = ''
|
||||||
formData.appointment_date = ''
|
formData.appointment_date = ''
|
||||||
|
clearRevisitTabFilters()
|
||||||
clearLatestAppointmentFilters()
|
clearLatestAppointmentFilters()
|
||||||
if (!formData.pending_assign_order_month) {
|
if (!formData.pending_assign_order_month) {
|
||||||
formData.pending_assign_order_month = dayjs().format('YYYY-MM')
|
formData.pending_assign_order_month = dayjs().format('YYYY-MM')
|
||||||
@@ -1057,10 +1113,66 @@ const onPendingAssignOrderMonthChange = async (val: string | null) => {
|
|||||||
fetchDateCounts()
|
fetchDateCounts()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取各日期挂号数量 + 全部 + 待预约 + 已完成 + 待分配医助数量
|
/** 复诊状态 / 开药天数档 下拉选项(文案与请求值映射在 utils/revisitDue.ts) */
|
||||||
|
const revisitStateFilterOptions = REVISIT_STATE_FILTER_OPTIONS
|
||||||
|
const revisitDaysBucketOptions = REVISIT_DAYS_BUCKET_OPTIONS
|
||||||
|
|
||||||
|
/** 顶部「复诊到期」角标:revisit_state=due 的 count */
|
||||||
|
const revisitDueCount = ref<number | undefined>(undefined)
|
||||||
|
|
||||||
|
/** 切换其它顶部 Tab 时清掉复诊 chip 状态与其排序(revisit_days_bucket 属「更多筛选」,保留) */
|
||||||
|
const clearRevisitTabFilters = () => {
|
||||||
|
formData.revisit_state = ''
|
||||||
|
formData.sort_revisit_due = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 顶部「复诊到期」chip:到期未挂号 ∪ 过号未面诊,默认按药尽日升序(超期最多在前) */
|
||||||
|
const handleRevisitDueTabClick = async () => {
|
||||||
|
formData.pending_assign = ''
|
||||||
|
formData.pending_assign_order_month = ''
|
||||||
|
formData.pending_assign_keyword = ''
|
||||||
|
formData.pending_booking = ''
|
||||||
|
formData.completed_appointment = ''
|
||||||
|
formData.appointment_date = ''
|
||||||
|
formData.has_appointment = ''
|
||||||
|
clearLatestAppointmentFilters()
|
||||||
|
formData.revisit_state = 'due'
|
||||||
|
formData.sort_revisit_due = 'asc'
|
||||||
|
formData.sort_unserved_days = ''
|
||||||
|
pager.page = 1
|
||||||
|
await getLists()
|
||||||
|
fetchDateCounts()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 「更多筛选 → 复诊状态」下拉:与顶部日期 Tab 互斥(同 chip 语义),选中后不再默认当天挂号 */
|
||||||
|
const handleRevisitFilterChange = () => {
|
||||||
|
if (formData.revisit_state !== '') {
|
||||||
|
formData.appointment_date = ''
|
||||||
|
formData.pending_booking = ''
|
||||||
|
formData.completed_appointment = ''
|
||||||
|
const noSortYet = formData.sort_revisit_due === '' && formData.sort_unserved_days === ''
|
||||||
|
if (formData.revisit_state === 'due' && noSortYet) {
|
||||||
|
formData.sort_revisit_due = 'asc'
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
formData.sort_revisit_due = ''
|
||||||
|
}
|
||||||
|
doSearch()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 列表行 revisit_due → 规整对象(旧接口无字段时为 null,单元格显示「—」) */
|
||||||
|
const revisitDueOf = (row: any): RevisitDue | null => normalizeRevisitDue(row?.revisit_due)
|
||||||
|
|
||||||
|
/** 「复诊节点」行 1 的开方文案:已开方 / 已开方(处方已作废) / 未开方 */
|
||||||
|
const prescribedTextOf = (row: any): string => {
|
||||||
|
if (!row?.has_prescription) return '未开方'
|
||||||
|
return row.followup_rx_voided ? '已开方(处方已作废)' : '已开方'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取各日期挂号数量 + 全部 + 待预约 + 已完成 + 待分配医助数量 + 复诊到期数量
|
||||||
const fetchDateCounts = async () => {
|
const fetchDateCounts = async () => {
|
||||||
try {
|
try {
|
||||||
const [yesterday, dayBefore, today, tomorrow, dayAfter, all, noApt, doneVisit, pending] = await Promise.all([
|
const [yesterday, dayBefore, today, tomorrow, dayAfter, all, noApt, doneVisit, pending, revisitDue] = await Promise.all([
|
||||||
tcmDiagnosisLists(
|
tcmDiagnosisLists(
|
||||||
buildDateCountRequestPayload({ appointment_date: yesterdayStr.value, has_appointment: '' }) as any
|
buildDateCountRequestPayload({ appointment_date: yesterdayStr.value, has_appointment: '' }) as any
|
||||||
),
|
),
|
||||||
@@ -1081,7 +1193,10 @@ const fetchDateCounts = async () => {
|
|||||||
tcmDiagnosisLists(
|
tcmDiagnosisLists(
|
||||||
buildDateCountRequestPayload({ completed_appointment: 1, has_appointment: '' }) as any
|
buildDateCountRequestPayload({ completed_appointment: 1, has_appointment: '' }) as any
|
||||||
),
|
),
|
||||||
tcmDiagnosisLists(buildPendingAssignCountPayload() as any)
|
tcmDiagnosisLists(buildPendingAssignCountPayload() as any),
|
||||||
|
tcmDiagnosisLists(
|
||||||
|
buildDateCountRequestPayload({ revisit_state: 'due', has_appointment: '' }) as any
|
||||||
|
)
|
||||||
])
|
])
|
||||||
dateCounts.value = {
|
dateCounts.value = {
|
||||||
[yesterdayStr.value]: yesterday?.count ?? 0,
|
[yesterdayStr.value]: yesterday?.count ?? 0,
|
||||||
@@ -1094,6 +1209,7 @@ const fetchDateCounts = async () => {
|
|||||||
pendingBookingCount.value = noApt?.count ?? 0
|
pendingBookingCount.value = noApt?.count ?? 0
|
||||||
completedVisitCount.value = doneVisit?.count ?? 0
|
completedVisitCount.value = doneVisit?.count ?? 0
|
||||||
pendingAssignCount.value = pending?.count ?? 0
|
pendingAssignCount.value = pending?.count ?? 0
|
||||||
|
revisitDueCount.value = revisitDue?.count ?? 0
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('获取日期数量失败', e)
|
console.error('获取日期数量失败', e)
|
||||||
}
|
}
|
||||||
@@ -1252,12 +1368,11 @@ const handleTableSortChange = ({
|
|||||||
prop: string
|
prop: string
|
||||||
order: 'ascending' | 'descending' | null
|
order: 'ascending' | 'descending' | null
|
||||||
}) => {
|
}) => {
|
||||||
if (prop === 'unserved_days') {
|
const dir: '' | 'asc' | 'desc' =
|
||||||
formData.sort_unserved_days =
|
order === 'ascending' ? 'asc' : order === 'descending' ? 'desc' : ''
|
||||||
order === 'ascending' ? 'asc' : order === 'descending' ? 'desc' : ''
|
// 按列 prop 分派;两种排序后端互斥,切换列时清掉另一列
|
||||||
} else {
|
formData.sort_unserved_days = prop === 'unserved_days' ? dir : ''
|
||||||
formData.sort_unserved_days = ''
|
formData.sort_revisit_due = prop === 'revisit_due_date' ? dir : ''
|
||||||
}
|
|
||||||
pager.page = 1
|
pager.page = 1
|
||||||
getLists()
|
getLists()
|
||||||
}
|
}
|
||||||
@@ -1311,6 +1426,9 @@ const handleReset = () => {
|
|||||||
formData.latest_assign_start_date = ''
|
formData.latest_assign_start_date = ''
|
||||||
formData.latest_assign_end_date = ''
|
formData.latest_assign_end_date = ''
|
||||||
formData.sort_unserved_days = ''
|
formData.sort_unserved_days = ''
|
||||||
|
formData.revisit_state = ''
|
||||||
|
formData.revisit_days_bucket = ''
|
||||||
|
formData.sort_revisit_due = ''
|
||||||
formData.diagnosis_confirmed = ''
|
formData.diagnosis_confirmed = ''
|
||||||
formData.appointment_date = ''
|
formData.appointment_date = ''
|
||||||
formData.has_appointment = ''
|
formData.has_appointment = ''
|
||||||
@@ -1343,8 +1461,10 @@ const unservedDaysClass = (n: unknown) => {
|
|||||||
return 'unserved-green'
|
return 'unserved-green'
|
||||||
}
|
}
|
||||||
|
|
||||||
// 行高亮:未确认诊单优先,其次未挂号
|
// 行高亮优先级(方案 D3-c 默认 overdue_first):复诊超期/过号 > 未确认 > 未挂号
|
||||||
const getRowClassName = ({ row }: { row: any }) => {
|
const getRowClassName = ({ row }: { row: any }) => {
|
||||||
|
const revisitCls = revisitRowClassName(revisitDueOf(row))
|
||||||
|
if (revisitCls) return revisitCls
|
||||||
if (!isDiagnosisConfirmed(row)) return 'row-unconfirmed'
|
if (!isDiagnosisConfirmed(row)) return 'row-unconfirmed'
|
||||||
if (!row.has_appointment) return 'row-no-appointment'
|
if (!row.has_appointment) return 'row-no-appointment'
|
||||||
return ''
|
return ''
|
||||||
@@ -1356,6 +1476,8 @@ const prescriptionRef = ref()
|
|||||||
const appointmentRef = ref()
|
const appointmentRef = ref()
|
||||||
/** 从路由带 id 进入时,诊单弹窗为异步组件,需在 ref 就绪后再 open */
|
/** 从路由带 id 进入时,诊单弹窗为异步组件,需在 ref 就绪后再 open */
|
||||||
const pendingRouteDiagnosisId = ref<number | null>(null)
|
const pendingRouteDiagnosisId = ref<number | null>(null)
|
||||||
|
/** 路由 ?id=&action=appointment 深链(复诊到期弹窗「去挂号」):挂号弹窗同为异步组件,先暂存行再 open */
|
||||||
|
const pendingRouteAppointmentRow = ref<Record<string, any> | null>(null)
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
@@ -1370,6 +1492,53 @@ watch(
|
|||||||
{ flush: 'post' }
|
{ flush: 'post' }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
[appointmentRef, pendingRouteAppointmentRow],
|
||||||
|
() => {
|
||||||
|
const row = pendingRouteAppointmentRow.value
|
||||||
|
const opener = appointmentRef.value as { open?: (row: any) => void } | undefined
|
||||||
|
if (!row || !opener?.open) return
|
||||||
|
pendingRouteAppointmentRow.value = null
|
||||||
|
handleAppointment(row)
|
||||||
|
},
|
||||||
|
{ flush: 'post' }
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 深链打开挂号弹窗:优先用当前列表里的行(字段最全);不在本页时回退只读详情接口拼最小行
|
||||||
|
* (appointment.vue open() 只读 patient_id / patient_name / gender_desc / age)。
|
||||||
|
*/
|
||||||
|
const openAppointmentFromRoute = async (diagnosisId: number) => {
|
||||||
|
// 深链打开挂号弹窗前先校验挂号权限(与行内「预约」按钮 v-perms 同口径)
|
||||||
|
if (!hasPermission(['tcm.diagnosis/guahao'])) {
|
||||||
|
feedback.msgWarning('无挂号权限')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const inList = (pager.lists as any[]).find((r) => Number(r?.id) === diagnosisId)
|
||||||
|
if (inList) {
|
||||||
|
pendingRouteAppointmentRow.value = inList
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res: any = await diagnosisReadonlyDetail({ id: diagnosisId })
|
||||||
|
const diag = res?.diagnosis
|
||||||
|
if (!diag || !diag.patient_id) {
|
||||||
|
feedback.msgWarning('未找到该诊单或患者信息不完整,无法打开挂号')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pendingRouteAppointmentRow.value = {
|
||||||
|
...diag,
|
||||||
|
id: diagnosisId,
|
||||||
|
patient_id: diag.patient_id,
|
||||||
|
patient_name: diag.patient_name || '',
|
||||||
|
gender_desc: formatGender(diag.gender),
|
||||||
|
age: diag.age ?? 0
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
feedback.msgWarning(e?.msg || e?.message || '加载诊单失败,无法打开挂号')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const watchCallVisible = ref(false)
|
const watchCallVisible = ref(false)
|
||||||
const watchCallDiagnosisId = ref(0)
|
const watchCallDiagnosisId = ref(0)
|
||||||
|
|
||||||
@@ -2220,22 +2389,57 @@ const goReadonly = (row: any) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
// 默认展示当天挂号,突出重点
|
|
||||||
formData.appointment_date = todayStr.value
|
|
||||||
// 字典与列表并行,避免 await 字典阻塞首屏列表(LCP)
|
// 字典与列表并行,避免 await 字典阻塞首屏列表(LCP)
|
||||||
getDictOptions()
|
getDictOptions()
|
||||||
getLists()
|
if (String(route.query.revisit_state ?? '') === 'due') {
|
||||||
scheduleDeferredDateCounts()
|
// 复诊到期弹窗「查看全部」深链:直接激活「复诊到期」chip(内部会 getLists + fetchDateCounts)
|
||||||
|
void handleRevisitDueTabClick()
|
||||||
|
} else {
|
||||||
|
// 默认展示当天挂号,突出重点
|
||||||
|
formData.appointment_date = todayStr.value
|
||||||
|
getLists()
|
||||||
|
scheduleDeferredDateCounts()
|
||||||
|
}
|
||||||
|
|
||||||
silentListPollTimer = setInterval(silentRefreshListsAndDateCounts, SILENT_LIST_POLL_MS)
|
silentListPollTimer = setInterval(silentRefreshListsAndDateCounts, SILENT_LIST_POLL_MS)
|
||||||
|
|
||||||
const raw = route.query.id
|
const raw = route.query.id
|
||||||
if (raw != null && raw !== '') {
|
if (raw != null && raw !== '') {
|
||||||
const num = Number(raw)
|
const num = Number(raw)
|
||||||
if (!Number.isNaN(num)) pendingRouteDiagnosisId.value = num
|
if (!Number.isNaN(num) && num > 0) {
|
||||||
|
if (String(route.query.action ?? '') === 'appointment') {
|
||||||
|
// 复诊到期弹窗「去挂号」深链:等首屏列表回来后再找行,找不到走详情接口
|
||||||
|
void getListsPromiseForRoute().then(() => openAppointmentFromRoute(num))
|
||||||
|
} else {
|
||||||
|
pendingRouteDiagnosisId.value = num
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/** onMounted 里 getLists() 已发起;等待首屏列表 loading 结束(最多 8s)再找行,失败/超时不阻断 */
|
||||||
|
const getListsPromiseForRoute = () =>
|
||||||
|
new Promise<void>((resolve) => {
|
||||||
|
if (!pager.loading) {
|
||||||
|
resolve()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let settled = false
|
||||||
|
const finish = () => {
|
||||||
|
if (settled) return
|
||||||
|
settled = true
|
||||||
|
stop()
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
const stop = watch(
|
||||||
|
() => pager.loading,
|
||||||
|
(loading) => {
|
||||||
|
if (!loading) finish()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
setTimeout(finish, 8000)
|
||||||
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
if (silentListPollTimer != null) {
|
if (silentListPollTimer != null) {
|
||||||
clearInterval(silentListPollTimer)
|
clearInterval(silentListPollTimer)
|
||||||
@@ -2313,6 +2517,21 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chip-revisit-due {
|
||||||
|
margin-left: 8px;
|
||||||
|
color: var(--el-color-danger);
|
||||||
|
background: var(--el-color-danger-light-9);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--el-color-danger-light-8);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
color: #fff;
|
||||||
|
background: var(--el-color-danger);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.chip-completed-visit {
|
.chip-completed-visit {
|
||||||
margin-left: 8px;
|
margin-left: 8px;
|
||||||
color: var(--el-color-success);
|
color: var(--el-color-success);
|
||||||
@@ -2451,6 +2670,14 @@ onUnmounted(() => {
|
|||||||
.latest-appointment-channel {
|
.latest-appointment-channel {
|
||||||
width: 170px;
|
width: 170px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.revisit-state-select {
|
||||||
|
width: 170px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revisit-days-bucket-select {
|
||||||
|
width: 130px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-card {
|
.list-card {
|
||||||
@@ -2641,17 +2868,6 @@ onUnmounted(() => {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-prescribed {
|
|
||||||
font-size: 13px;
|
|
||||||
color: #4A5DFF;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-unprescribed {
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--el-text-color-placeholder);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 未服务天数(T3):null=灰;>=7=红;3-6=橙;<=2=绿 */
|
/* 未服务天数(T3):null=灰;>=7=红;3-6=橙;<=2=绿 */
|
||||||
.unserved-days {
|
.unserved-days {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
@@ -2696,33 +2912,6 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.followup-cell {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
line-height: 1.35;
|
|
||||||
|
|
||||||
.followup-time {
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--el-text-color-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.followup-doctor {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--el-text-color-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.followup-void-tag {
|
|
||||||
align-self: flex-start;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.followup-none {
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--el-text-color-placeholder);
|
|
||||||
}
|
|
||||||
|
|
||||||
.assistant-cell {
|
.assistant-cell {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--el-text-color-secondary);
|
color: var(--el-text-color-secondary);
|
||||||
@@ -2782,6 +2971,22 @@ onUnmounted(() => {
|
|||||||
:deep(.el-table__row.row-no-appointment:hover > td) {
|
:deep(.el-table__row.row-no-appointment:hover > td) {
|
||||||
background: linear-gradient(90deg, rgba(var(--el-color-info-rgb), 0.08) 0%, transparent 100%) !important;
|
background: linear-gradient(90deg, rgba(var(--el-color-info-rgb), 0.08) 0%, transparent 100%) !important;
|
||||||
}
|
}
|
||||||
|
/* 复诊到期未挂号 / 过号未面诊:红色左边条(同 row-unconfirmed 结构,warning → danger) */
|
||||||
|
:deep(.el-table__row.row-revisit-overdue) {
|
||||||
|
border-left: 3px solid var(--el-color-danger);
|
||||||
|
background: linear-gradient(90deg, rgba(var(--el-color-danger-rgb), 0.06) 0%, transparent 100%) !important;
|
||||||
|
}
|
||||||
|
:deep(.el-table__row.row-revisit-overdue:hover > td) {
|
||||||
|
background: linear-gradient(90deg, rgba(var(--el-color-danger-rgb), 0.1) 0%, transparent 100%) !important;
|
||||||
|
}
|
||||||
|
/* 超期 ≥ severe_overdue_days:底色加深到 10%,悬停 14% */
|
||||||
|
:deep(.el-table__row.row-revisit-overdue.row-revisit-overdue-deep) {
|
||||||
|
border-left-color: #b91c1c;
|
||||||
|
background: linear-gradient(90deg, rgba(var(--el-color-danger-rgb), 0.1) 0%, transparent 100%) !important;
|
||||||
|
}
|
||||||
|
:deep(.el-table__row.row-revisit-overdue.row-revisit-overdue-deep:hover > td) {
|
||||||
|
background: linear-gradient(90deg, rgba(var(--el-color-danger-rgb), 0.14) 0%, transparent 100%) !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.record-list {
|
.record-list {
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
formData.pending_assign !== '1' &&
|
formData.pending_assign !== '1' &&
|
||||||
formData.pending_booking !== '1' &&
|
formData.pending_booking !== '1' &&
|
||||||
formData.completed_appointment !== '1' &&
|
formData.completed_appointment !== '1' &&
|
||||||
|
formData.revisit_state === '' &&
|
||||||
formData.appointment_date === tab.value
|
formData.appointment_date === tab.value
|
||||||
}
|
}
|
||||||
]"
|
]"
|
||||||
@@ -40,6 +41,13 @@
|
|||||||
待预约
|
待预约
|
||||||
<em v-if="pendingBookingCount !== undefined">{{ pendingBookingCount }}</em>
|
<em v-if="pendingBookingCount !== undefined">{{ pendingBookingCount }}</em>
|
||||||
</span>
|
</span>
|
||||||
|
<span
|
||||||
|
:class="['dh5-chip', 'dh5-chip--revisit', { active: formData.revisit_state === 'due' }]"
|
||||||
|
@click="handleRevisitDueTabClick"
|
||||||
|
>
|
||||||
|
复诊到期
|
||||||
|
<em v-if="revisitDueCount !== undefined">{{ revisitDueCount }}</em>
|
||||||
|
</span>
|
||||||
<span
|
<span
|
||||||
:class="['dh5-chip', 'dh5-chip--done', { active: formData.completed_appointment === '1' }]"
|
:class="['dh5-chip', 'dh5-chip--done', { active: formData.completed_appointment === '1' }]"
|
||||||
@click="handleCompletedVisitTabClick"
|
@click="handleCompletedVisitTabClick"
|
||||||
@@ -170,13 +178,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 复诊块(仅已开方时显示) -->
|
<!-- 复诊节点块(已开方或有复诊节点数据时显示;卡片头已有「已开方」标签,此处隐藏开方文案) -->
|
||||||
<div v-if="row.has_prescription" class="dh5-row dh5-row--followup">
|
<div v-if="row.has_prescription || hasRevisitDue(revisitDueOf(row))" class="dh5-row dh5-row--followup">
|
||||||
<span class="dh5-row-key">复诊</span>
|
<span class="dh5-row-key">复诊</span>
|
||||||
<div class="dh5-row-val">
|
<div class="dh5-row-val dh5-row-val--revisit">
|
||||||
<span>{{ row.followup_time_text || '—' }}</span>
|
<revisit-node-cell
|
||||||
<span class="dh5-meta-doctor">{{ row.followup_doctor_name || '—' }}</span>
|
compact
|
||||||
<span v-if="row.followup_rx_voided" class="dh5-tag dh5-tag--gray">处方作废</span>
|
:due="revisitDueOf(row)"
|
||||||
|
:has-prescription="!!row.has_prescription"
|
||||||
|
:followup-doctor-name="row.followup_doctor_name || ''"
|
||||||
|
:prescribed-text="row.followup_rx_voided ? '处方作废' : ''"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -625,6 +637,13 @@ import {
|
|||||||
import useUserStore from '@/stores/modules/user'
|
import useUserStore from '@/stores/modules/user'
|
||||||
import { defineAsyncComponent, computed, reactive, ref, onMounted, onUnmounted } from 'vue'
|
import { defineAsyncComponent, computed, reactive, ref, onMounted, onUnmounted } from 'vue'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
|
import RevisitNodeCell from './components/RevisitNodeCell.vue'
|
||||||
|
import {
|
||||||
|
hasRevisitDue,
|
||||||
|
isRevisitDueHighlight,
|
||||||
|
normalizeRevisitDue,
|
||||||
|
type RevisitDue
|
||||||
|
} from '@/utils/revisitDue'
|
||||||
|
|
||||||
const EditPopup = defineAsyncComponent(() => import('./edit.vue'))
|
const EditPopup = defineAsyncComponent(() => import('./edit.vue'))
|
||||||
const DetailPopup = defineAsyncComponent(() => import('./detail.vue'))
|
const DetailPopup = defineAsyncComponent(() => import('./detail.vue'))
|
||||||
@@ -685,7 +704,9 @@ const formData = reactive({
|
|||||||
has_appointment: '' as '' | '0' | '1',
|
has_appointment: '' as '' | '0' | '1',
|
||||||
pending_assign: '' as '' | '1',
|
pending_assign: '' as '' | '1',
|
||||||
pending_booking: '' as '' | '1',
|
pending_booking: '' as '' | '1',
|
||||||
completed_appointment: '' as '' | '1'
|
completed_appointment: '' as '' | '1',
|
||||||
|
/** 顶部「复诊到期」chip:due = 到期未挂号 ∪ 过号未面诊(后端 revisit_state) */
|
||||||
|
revisit_state: '' as '' | 'due'
|
||||||
})
|
})
|
||||||
|
|
||||||
const showMoreFilter = ref(false)
|
const showMoreFilter = ref(false)
|
||||||
@@ -740,6 +761,7 @@ const handleReset = () => {
|
|||||||
formData.pending_assign = ''
|
formData.pending_assign = ''
|
||||||
formData.pending_booking = ''
|
formData.pending_booking = ''
|
||||||
formData.completed_appointment = ''
|
formData.completed_appointment = ''
|
||||||
|
formData.revisit_state = ''
|
||||||
pager.page = 1
|
pager.page = 1
|
||||||
getLists()
|
getLists()
|
||||||
fetchDateCounts()
|
fetchDateCounts()
|
||||||
@@ -767,6 +789,7 @@ const handleDateTabClick = async (value: string) => {
|
|||||||
formData.pending_assign = ''
|
formData.pending_assign = ''
|
||||||
formData.pending_booking = ''
|
formData.pending_booking = ''
|
||||||
formData.completed_appointment = ''
|
formData.completed_appointment = ''
|
||||||
|
formData.revisit_state = ''
|
||||||
formData.has_appointment = ''
|
formData.has_appointment = ''
|
||||||
formData.appointment_date = value
|
formData.appointment_date = value
|
||||||
pager.page = 1
|
pager.page = 1
|
||||||
@@ -778,6 +801,7 @@ const handlePendingBookingTabClick = async () => {
|
|||||||
formData.pending_assign = ''
|
formData.pending_assign = ''
|
||||||
formData.pending_booking = '1'
|
formData.pending_booking = '1'
|
||||||
formData.completed_appointment = ''
|
formData.completed_appointment = ''
|
||||||
|
formData.revisit_state = ''
|
||||||
formData.appointment_date = ''
|
formData.appointment_date = ''
|
||||||
formData.has_appointment = '0'
|
formData.has_appointment = '0'
|
||||||
pager.page = 1
|
pager.page = 1
|
||||||
@@ -789,6 +813,7 @@ const handleCompletedVisitTabClick = async () => {
|
|||||||
formData.pending_assign = ''
|
formData.pending_assign = ''
|
||||||
formData.pending_booking = ''
|
formData.pending_booking = ''
|
||||||
formData.completed_appointment = '1'
|
formData.completed_appointment = '1'
|
||||||
|
formData.revisit_state = ''
|
||||||
formData.has_appointment = ''
|
formData.has_appointment = ''
|
||||||
formData.appointment_date = ''
|
formData.appointment_date = ''
|
||||||
pager.page = 1
|
pager.page = 1
|
||||||
@@ -800,6 +825,7 @@ const handlePendingAssignTabClick = async () => {
|
|||||||
formData.pending_assign = '1'
|
formData.pending_assign = '1'
|
||||||
formData.pending_booking = ''
|
formData.pending_booking = ''
|
||||||
formData.completed_appointment = ''
|
formData.completed_appointment = ''
|
||||||
|
formData.revisit_state = ''
|
||||||
formData.has_appointment = ''
|
formData.has_appointment = ''
|
||||||
formData.appointment_date = ''
|
formData.appointment_date = ''
|
||||||
pager.page = 1
|
pager.page = 1
|
||||||
@@ -807,9 +833,27 @@ const handlePendingAssignTabClick = async () => {
|
|||||||
fetchDateCounts()
|
fetchDateCounts()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 顶部「复诊到期」chip:到期未挂号 ∪ 过号未面诊;后端按 revisit_state=due 过滤,排序默认药尽日升序 */
|
||||||
|
const handleRevisitDueTabClick = async () => {
|
||||||
|
formData.pending_assign = ''
|
||||||
|
formData.pending_booking = ''
|
||||||
|
formData.completed_appointment = ''
|
||||||
|
formData.has_appointment = ''
|
||||||
|
formData.appointment_date = ''
|
||||||
|
formData.revisit_state = 'due'
|
||||||
|
pager.page = 1
|
||||||
|
await getLists()
|
||||||
|
fetchDateCounts()
|
||||||
|
}
|
||||||
|
|
||||||
|
const revisitDueCount = ref<number | undefined>(undefined)
|
||||||
|
|
||||||
|
/** 列表行 revisit_due → 规整对象(旧接口无字段时为 null,不渲染复诊块) */
|
||||||
|
const revisitDueOf = (row: any): RevisitDue | null => normalizeRevisitDue(row?.revisit_due)
|
||||||
|
|
||||||
const fetchDateCounts = async () => {
|
const fetchDateCounts = async () => {
|
||||||
try {
|
try {
|
||||||
const [yesterday, dayBefore, today, tomorrow, dayAfter, all, noApt, doneVisit, pending] = await Promise.all([
|
const [yesterday, dayBefore, today, tomorrow, dayAfter, all, noApt, doneVisit, pending, revisitDue] = await Promise.all([
|
||||||
tcmDiagnosisLists({ appointment_date: yesterdayStr.value, page_no: 1, page_size: 1 }),
|
tcmDiagnosisLists({ appointment_date: yesterdayStr.value, page_no: 1, page_size: 1 }),
|
||||||
tcmDiagnosisLists({ appointment_date: dayBeforeStr.value, page_no: 1, page_size: 1 }),
|
tcmDiagnosisLists({ appointment_date: dayBeforeStr.value, page_no: 1, page_size: 1 }),
|
||||||
tcmDiagnosisLists({ appointment_date: todayStr.value, page_no: 1, page_size: 1 }),
|
tcmDiagnosisLists({ appointment_date: todayStr.value, page_no: 1, page_size: 1 }),
|
||||||
@@ -818,7 +862,8 @@ const fetchDateCounts = async () => {
|
|||||||
tcmDiagnosisLists({ page_no: 1, page_size: 1 }),
|
tcmDiagnosisLists({ page_no: 1, page_size: 1 }),
|
||||||
tcmDiagnosisLists({ has_appointment: 0, page_no: 1, page_size: 1 }),
|
tcmDiagnosisLists({ has_appointment: 0, page_no: 1, page_size: 1 }),
|
||||||
tcmDiagnosisLists({ completed_appointment: 1, page_no: 1, page_size: 1 }),
|
tcmDiagnosisLists({ completed_appointment: 1, page_no: 1, page_size: 1 }),
|
||||||
tcmDiagnosisLists({ pending_assign: 1, page_no: 1, page_size: 1 })
|
tcmDiagnosisLists({ pending_assign: 1, page_no: 1, page_size: 1 }),
|
||||||
|
tcmDiagnosisLists({ revisit_state: 'due', page_no: 1, page_size: 1 })
|
||||||
])
|
])
|
||||||
dateCounts.value = {
|
dateCounts.value = {
|
||||||
[yesterdayStr.value]: yesterday?.count ?? 0,
|
[yesterdayStr.value]: yesterday?.count ?? 0,
|
||||||
@@ -831,6 +876,7 @@ const fetchDateCounts = async () => {
|
|||||||
pendingBookingCount.value = noApt?.count ?? 0
|
pendingBookingCount.value = noApt?.count ?? 0
|
||||||
completedVisitCount.value = doneVisit?.count ?? 0
|
completedVisitCount.value = doneVisit?.count ?? 0
|
||||||
pendingAssignCount.value = pending?.count ?? 0
|
pendingAssignCount.value = pending?.count ?? 0
|
||||||
|
revisitDueCount.value = revisitDue?.count ?? 0
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('获取日期数量失败', e)
|
console.error('获取日期数量失败', e)
|
||||||
}
|
}
|
||||||
@@ -898,7 +944,9 @@ const isDiagnosisConfirmed = (row: any) => {
|
|||||||
return records.length > 0 && records.some((r: any) => r.is_confirmed == 1)
|
return records.length > 0 && records.some((r: any) => r.is_confirmed == 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 卡片左边条优先级(同 PC 行高亮):复诊超期/过号 > 未确认 > 未挂号
|
||||||
const getCardClass = (row: any) => {
|
const getCardClass = (row: any) => {
|
||||||
|
if (isRevisitDueHighlight(revisitDueOf(row))) return 'dh5-card--overdue'
|
||||||
if (!isDiagnosisConfirmed(row)) return 'dh5-card--unconfirmed'
|
if (!isDiagnosisConfirmed(row)) return 'dh5-card--unconfirmed'
|
||||||
if (!row.has_appointment) return 'dh5-card--noapt'
|
if (!row.has_appointment) return 'dh5-card--noapt'
|
||||||
return ''
|
return ''
|
||||||
@@ -1582,6 +1630,8 @@ $dh5-card-bg: #ffffff;
|
|||||||
.dh5-chip--booking.active { background: $dh5-warn; }
|
.dh5-chip--booking.active { background: $dh5-warn; }
|
||||||
.dh5-chip--done.active { background: $dh5-success; }
|
.dh5-chip--done.active { background: $dh5-success; }
|
||||||
.dh5-chip--pending.active { background: $dh5-danger; }
|
.dh5-chip--pending.active { background: $dh5-danger; }
|
||||||
|
.dh5-chip--revisit { color: #b91c1c; background: rgba(239, 68, 68, 0.10); }
|
||||||
|
.dh5-chip--revisit.active { color: #fff; background: $dh5-danger; }
|
||||||
|
|
||||||
.dh5-quick {
|
.dh5-quick {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1675,6 +1725,12 @@ $dh5-card-bg: #ffffff;
|
|||||||
|
|
||||||
.dh5-card--unconfirmed::before { background: $dh5-danger; }
|
.dh5-card--unconfirmed::before { background: $dh5-danger; }
|
||||||
.dh5-card--noapt::before { background: $dh5-warn; }
|
.dh5-card--noapt::before { background: $dh5-warn; }
|
||||||
|
/* 复诊到期未挂号 / 过号未面诊:左边条同 danger,外框与底色轻微染红以区分「未确认」 */
|
||||||
|
.dh5-card--overdue::before { background: $dh5-danger; }
|
||||||
|
.dh5-card--overdue {
|
||||||
|
border-color: rgba(239, 68, 68, 0.35);
|
||||||
|
background: linear-gradient(90deg, rgba(239, 68, 68, 0.05) 0%, $dh5-card-bg 60%);
|
||||||
|
}
|
||||||
|
|
||||||
.dh5-card-head {
|
.dh5-card-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1800,6 +1856,11 @@ $dh5-card-bg: #ffffff;
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 复诊节点块:单元格组件自带多行布局,取消外层 flex 换行间距 */
|
||||||
|
.dh5-row-val--revisit {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
/* 挂号块 - 状态色 */
|
/* 挂号块 - 状态色 */
|
||||||
.dh5-row--apt {
|
.dh5-row--apt {
|
||||||
background: #fafbfc;
|
background: #fafbfc;
|
||||||
|
|||||||
@@ -25,6 +25,17 @@
|
|||||||
</span>
|
</span>
|
||||||
</el-tooltip>
|
</el-tooltip>
|
||||||
<span v-else class="unserved-badge unserved-muted">从未打卡</span>
|
<span v-else class="unserved-badge unserved-muted">从未打卡</span>
|
||||||
|
<!-- 复诊节点徽标:第 N 诊 · 药尽 MM-DD · 超期 N 天(接口无 revisit_due 字段时不渲染) -->
|
||||||
|
<el-tooltip
|
||||||
|
v-if="revisitBadgeLabel"
|
||||||
|
placement="bottom"
|
||||||
|
:disabled="!revisitTooltipText"
|
||||||
|
:content="revisitTooltipText"
|
||||||
|
>
|
||||||
|
<span :class="['unserved-badge', 'revisit-badge', revisitBadgeColorClass]">
|
||||||
|
{{ revisitBadgeLabel }}
|
||||||
|
</span>
|
||||||
|
</el-tooltip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -156,6 +167,12 @@ import { diagnosisReadonlyDetail } from '@/api/tcm'
|
|||||||
import { getDoctorNotes } from '@/api/patient'
|
import { getDoctorNotes } from '@/api/patient'
|
||||||
import { formatGender } from '@/utils/diag-display'
|
import { formatGender } from '@/utils/diag-display'
|
||||||
import { hasPermission } from '@/utils/perm'
|
import { hasPermission } from '@/utils/perm'
|
||||||
|
import {
|
||||||
|
normalizeRevisitDue,
|
||||||
|
revisitBadgeText,
|
||||||
|
revisitStateColorClass,
|
||||||
|
revisitTooltipLines
|
||||||
|
} from '@/utils/revisitDue'
|
||||||
import PatientInfoCard from './components/PatientInfoCard.vue'
|
import PatientInfoCard from './components/PatientInfoCard.vue'
|
||||||
import PatientCaseCard from './components/PatientCaseCard.vue'
|
import PatientCaseCard from './components/PatientCaseCard.vue'
|
||||||
import DailyMatrix from './components/DailyMatrix.vue'
|
import DailyMatrix from './components/DailyMatrix.vue'
|
||||||
@@ -193,6 +210,17 @@ const unservedDays = computed<number | null>(() => {
|
|||||||
})
|
})
|
||||||
const lastBloodAt = computed<string>(() => detail.value?.last_blood_record_at || '')
|
const lastBloodAt = computed<string>(() => detail.value?.last_blood_record_at || '')
|
||||||
|
|
||||||
|
/** 复诊节点:readonlyDetail 顶层 revisit_due(兼容挂在 diagnosis 下);字段不存在 → null → 不渲染徽标 */
|
||||||
|
const revisitDue = computed(() =>
|
||||||
|
normalizeRevisitDue(detail.value?.revisit_due ?? detail.value?.diagnosis?.revisit_due)
|
||||||
|
)
|
||||||
|
const revisitBadgeLabel = computed(() => revisitBadgeText(revisitDue.value))
|
||||||
|
const revisitBadgeColorClass = computed(() => {
|
||||||
|
const due = revisitDue.value
|
||||||
|
return due ? revisitStateColorClass(due.state) : 'unserved-muted'
|
||||||
|
})
|
||||||
|
const revisitTooltipText = computed(() => revisitTooltipLines(revisitDue.value).join(';'))
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 「未服务天数」着色规则(与 T3 列表一致):
|
* 「未服务天数」着色规则(与 T3 列表一致):
|
||||||
* null/undefined → 灰;>=7 → 红;3-6 → 橙;<=2 → 绿
|
* null/undefined → 灰;>=7 → 红;3-6 → 橙;<=2 → 绿
|
||||||
@@ -352,6 +380,13 @@ onMounted(fetchDetail)
|
|||||||
border-color: #fecaca;
|
border-color: #fecaca;
|
||||||
background: rgba(220, 38, 38, 0.08);
|
background: rgba(220, 38, 38, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 复诊节点徽标专用:已约 / 已联系 */
|
||||||
|
&.unserved-blue {
|
||||||
|
color: #4a5dff;
|
||||||
|
border-color: #c7d2fe;
|
||||||
|
background: rgba(74, 93, 255, 0.08);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-state {
|
.empty-state {
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const path = require('node:path')
|
||||||
|
const test = require('node:test')
|
||||||
|
const ts = require('typescript')
|
||||||
|
const vue = require('vue')
|
||||||
|
const { parse, compileScript, compileTemplate } = require('@vue/compiler-sfc')
|
||||||
|
const file = process.env.FOLLOWUP_EXERCISE_MATRIX_SOURCE || path.resolve(__dirname, '../src/views/tcm/diagnosis/components/DailyMatrix.vue')
|
||||||
|
const source = fs.readFileSync(file, 'utf8')
|
||||||
|
function setup() {
|
||||||
|
const descriptor = parse(source, {filename:file}).descriptor
|
||||||
|
const compiled = compileScript(descriptor, {id:'exercise-null'})
|
||||||
|
const output = ts.transpileModule(compiled.content, {compilerOptions:{target:ts.ScriptTarget.ES2022,module:ts.ModuleKind.CommonJS}}).outputText
|
||||||
|
const calls=[]; const warnings=[]; const module={exports:{}}
|
||||||
|
const api={diagnosisTrackingWindow:async()=>({}),diagnosisTrackingNotes:async()=>[],exerciseRecordEdit:async p=>calls.push(p),exerciseRecordAdd:async p=>calls.push(p)}
|
||||||
|
new Function('require','module','exports',output)(name=>name==='vue'?{...vue,onMounted(){},onUnmounted(){}}:name==='@/utils/perm'?{hasPermission:()=>true}:name==='@/api/tcm'?api:name==='@/utils/feedback'?{default:{msgWarning:m=>warnings.push(m)}}:{default:{}},module,module.exports)
|
||||||
|
const scope=vue.effectScope()
|
||||||
|
const matrix=scope.run(()=>module.exports.default.setup({diagnosisId:1,patientId:101,readOnly:false},{expose(){}}))
|
||||||
|
return {matrix,calls,warnings,dispose:()=>scope.stop()}
|
||||||
|
}
|
||||||
|
test('real DailyMatrix edit and submit preserve null duration/intensity',async()=>{
|
||||||
|
const f=setup(); f.matrix.openExerciseRecord({id:1,record_date:'2026-09-29',exercise_type:'散步',duration:null,intensity:null},'2026-09-29')
|
||||||
|
assert.equal(f.matrix.exerciseForm.value.duration,null);assert.equal(f.matrix.exerciseForm.value.intensity,null)
|
||||||
|
f.matrix.exerciseForm.value.note='仅改备注';await f.matrix.submitExercise()
|
||||||
|
assert.equal(f.calls.length,1);assert.equal(f.calls[0].duration,null);assert.equal(f.calls[0].intensity,null);assert.deepEqual(f.warnings,[]);f.dispose()
|
||||||
|
})
|
||||||
|
test('missing keys in existing row do not inherit manual-entry defaults',()=>{
|
||||||
|
const f=setup();f.matrix.openExerciseRecord({id:2,exercise_type:'散步'},'2026-09-29')
|
||||||
|
assert.equal(f.matrix.exerciseForm.value.duration,null);assert.equal(f.matrix.exerciseForm.value.intensity,null);f.dispose()
|
||||||
|
})
|
||||||
|
test('explicit known and zero values remain unchanged; cleared duration submits null',async()=>{
|
||||||
|
const f=setup();f.matrix.openExerciseRecord({id:2,exercise_type:'散步',duration:0,intensity:1},'2026-09-29')
|
||||||
|
await f.matrix.submitExercise();assert.equal(f.calls[0].duration,0);assert.equal(f.calls[0].intensity,1)
|
||||||
|
f.matrix.exerciseForm.value.duration=undefined;await f.matrix.submitExercise();assert.equal(f.calls[1].duration,null);f.dispose()
|
||||||
|
})
|
||||||
|
test('unknown intensity option maps to null without conflating known low intensity',()=>{
|
||||||
|
const f=setup();f.matrix.openExerciseRecord({id:1,exercise_type:'散步',duration:null,intensity:null},'2026-09-29')
|
||||||
|
assert.equal(f.matrix.exerciseIntensity.value,'');f.matrix.exerciseIntensity.value=1;assert.equal(f.matrix.exerciseForm.value.intensity,1)
|
||||||
|
f.matrix.exerciseIntensity.value='';assert.equal(f.matrix.exerciseForm.value.intensity,null)
|
||||||
|
assert.equal(f.matrix.intensityLabel(null),'未记录');assert.equal(f.matrix.intensityLabel(1),'低强度');f.dispose()
|
||||||
|
})
|
||||||
|
test('matrix compiles and detail rendering explicitly distinguishes unknown from zero',()=>{
|
||||||
|
const descriptor=parse(source,{filename:file}).descriptor;const script=compileScript(descriptor,{id:'exercise-compile'})
|
||||||
|
const render=compileTemplate({source:descriptor.template.content,filename:file,id:'exercise-compile',compilerOptions:{bindingMetadata:script.bindings}})
|
||||||
|
assert.deepEqual(render.errors,[]);assert.ok(/<el-option label="未记录" value=""/.test(source), "explicit unknown option exists")
|
||||||
|
assert.ok(/record\.duration == null \? '未记录'/.test(source), 'null duration renders unknown')
|
||||||
|
})
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const path = require('node:path')
|
||||||
|
const test = require('node:test')
|
||||||
|
const ts = require('typescript')
|
||||||
|
const vue = require('vue')
|
||||||
|
const { parse, compileScript, compileTemplate } = require('@vue/compiler-sfc')
|
||||||
|
const dir = path.resolve(__dirname, '../src/views/tcm/diagnosis/components')
|
||||||
|
const reviewFile = process.env.FOLLOWUP_INLINE_REVIEW_SOURCE || path.join(dir, 'FollowupAudioReview.vue')
|
||||||
|
function execute(source, requireFn) {
|
||||||
|
const out = ts.transpileModule(source,{compilerOptions:{target:ts.ScriptTarget.ES2022,module:ts.ModuleKind.CommonJS}}).outputText
|
||||||
|
const module={exports:{}};new Function('require','module','exports',out)(requireFn,module,module.exports);return module.exports
|
||||||
|
}
|
||||||
|
const state=execute(fs.readFileSync(path.join(dir,'followupAudioState.ts'),'utf8'),require)
|
||||||
|
const row=(id,extra={})=>({id,kind:'blood',values:{fasting_blood_sugar:6.2},record_date:'2026-10-12',record_time:'08:00',time_period:'早晨',time_estimated:true,time_text:'早晨',date_text:'今天',evidence:[{text:'六点二',channel:1,segment_id:'right-1',start_ms:0,end_ms:15000,position_type:'segment'}],needs_review:true,selected:false,target_id:null,current_values:{},expected_hash:'original',conflict:false,possible_duplicate:false,...extra})
|
||||||
|
function setup(extra={}){
|
||||||
|
const descriptor=parse(fs.readFileSync(reviewFile,'utf8'),{filename:reviewFile}).descriptor
|
||||||
|
const compiled=compileScript(descriptor,{id:'inline-review'})
|
||||||
|
const mod=execute(compiled.content,name=>name==='vue'?vue:name==='./followupAudioState'?state:{default:{}})
|
||||||
|
const props=vue.reactive({items:[row('one'),row('two'),row('three')],fields:{blood:[{key:'fasting_blood_sugar',label:'空腹血糖',type:'number'}]},disabled:false,canDaily:true,applied:false,saveState:'saved',...extra})
|
||||||
|
const events=[];const scope=vue.effectScope();const review=scope.run(()=>mod.default.setup(props,{expose(){},emit(event,...args){events.push([event,...args]);if(event==='update:items')props.items=args[0]}}))
|
||||||
|
return {review,props,events,dispose:()=>scope.stop()}
|
||||||
|
}
|
||||||
|
test('inline editor has one permanently mounted player slot and no modal overlay',()=>{
|
||||||
|
const source=fs.readFileSync(reviewFile,'utf8');assert.doesNotMatch(source,/<el-dialog/);assert.equal((source.match(/<slot name="player"/g)||[]).length,1)
|
||||||
|
assert.match(source,/position: sticky/);assert.match(source,/aria-label="同页核对与回听"/)
|
||||||
|
const descriptor=parse(source,{filename:reviewFile}).descriptor;const script=compileScript(descriptor,{id:'inline-compile'})
|
||||||
|
const render=compileTemplate({source:descriptor.template.content,filename:reviewFile,id:'inline-compile',compilerOptions:{bindingMetadata:script.bindings}});assert.deepEqual(render.errors,[])
|
||||||
|
})
|
||||||
|
test('accept-next requires explicit verification, retains immutable evidence and opens next unselected item',async()=>{
|
||||||
|
const f=setup({items:[row('one'),row('two',{selected:true,needs_review:false}),row('three')]});f.review.openReview('one')
|
||||||
|
f.review.saveReview(true,true);assert.equal(f.props.items[0].selected,false);assert.equal(f.review.editor.value.id,'one')
|
||||||
|
f.review.confirmReview(true);f.review.saveReview(true,true)
|
||||||
|
assert.equal(f.props.items[0].selected,true);assert.equal(f.props.items[0].needs_review,false);assert.equal(f.review.editor.value.id,'three')
|
||||||
|
assert.deepEqual(f.props.items[0].evidence,row('one').evidence);assert.ok(f.events.some(e=>e[0]==='saved-next'))
|
||||||
|
await vue.nextTick();assert.equal(f.review.editor.value.id,'three');f.dispose()
|
||||||
|
})
|
||||||
|
test('pending-filter accept-next captures next id before accepted row leaves filter',()=>{
|
||||||
|
const f=setup();f.review.group.value='pending';f.review.openReview('one');f.review.confirmReview(true);f.review.saveReview(true,true)
|
||||||
|
assert.equal(f.review.editor.value.id,'two');assert.deepEqual(f.review.visibleItems.value.map(r=>r.id),['two','three']);f.dispose()
|
||||||
|
})
|
||||||
|
test('skip preserves patient values, does not claim review complete, and never writes or selects',()=>{
|
||||||
|
const f=setup();f.review.openReview('one');f.review.skipAndNext();assert.equal(f.review.editor.value.id,'two');assert.equal(f.props.items[0].needs_review,true);assert.equal(f.props.items[0].selected,false)
|
||||||
|
assert.deepEqual(f.props.items[0].values,{fasting_blood_sugar:6.2});assert.ok(f.events.every(e=>['update:items','saved-next'].includes(e[0])));f.dispose()
|
||||||
|
})
|
||||||
|
test('inline row switching or skipping never silently drops unsaved edits',()=>{
|
||||||
|
const f=setup();f.review.openReview('one');f.review.setValue('fasting_blood_sugar',6.8);f.review.openReview('two');assert.equal(f.review.editor.value.id,'one');assert.match(f.review.editorNotice.value,/未保存/)
|
||||||
|
f.review.skipAndNext();assert.equal(f.review.editor.value.id,'one');assert.equal(f.review.editor.value.values.fasting_blood_sugar,6.8);assert.equal(f.props.items[0].values.fasting_blood_sugar,6.2);f.dispose()
|
||||||
|
})
|
||||||
|
test('date change remains gated until saved and authoritative target refreshed',()=>{
|
||||||
|
const f=setup();f.review.openReview('one');f.review.patchEditor({record_date:'2026-10-11'});f.review.confirmReview(true);f.review.saveReview(true,true);assert.equal(f.props.items[0].selected,false);assert.equal(f.review.editor.value.id,'one')
|
||||||
|
f.review.saveReview(false);assert.equal(f.props.items[0].record_date,'2026-10-11');assert.equal(f.props.items[0].needs_review,true);assert.ok(f.events.some(e=>e[0]==='saved-next'));f.dispose()
|
||||||
|
})
|
||||||
|
test('last item completes without wrapping or applying, read-only cannot advance mutations',()=>{
|
||||||
|
const f=setup({items:[row('one')]});f.review.openReview('one');f.review.confirmReview(true);f.review.saveReview(true,true);assert.equal(f.review.editor.value,null);assert.match(f.review.navigationNotice.value,/末项/);assert.equal(f.events.some(e=>e[0]==='apply'),false);f.dispose()
|
||||||
|
const g=setup({disabled:true});g.review.openReview('one');g.review.skipAndNext();g.review.saveReview(true,true);assert.equal(g.events.length,0);g.dispose()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('reopening the same row or toggling its selection preserves unsaved editor data',()=>{
|
||||||
|
const f=setup();f.review.openReview('one');f.review.setValue('fasting_blood_sugar',7.1)
|
||||||
|
f.review.openReview('one');assert.equal(f.review.editor.value.values.fasting_blood_sugar,7.1)
|
||||||
|
f.review.toggleSelection(f.props.items[0],true);assert.equal(f.review.editor.value.values.fasting_blood_sugar,7.1)
|
||||||
|
assert.equal(f.props.items[0].values.fasting_blood_sugar,6.2);assert.equal(f.props.items[0].selected,false);f.dispose()
|
||||||
|
})
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,253 @@
|
|||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const path = require('node:path')
|
||||||
|
const test = require('node:test')
|
||||||
|
const ts = require('typescript')
|
||||||
|
const vue = require('vue')
|
||||||
|
const { parse, compileScript, compileTemplate } = require('@vue/compiler-sfc')
|
||||||
|
|
||||||
|
const root = path.resolve(__dirname, '..')
|
||||||
|
const apiPath = path.join(root, 'src/api/setting/prescription_ai_visibility.ts')
|
||||||
|
const pagePath = path.join(root, 'src/views/setting/prescription_ai_visibility/index.vue')
|
||||||
|
const { descriptor, errors } = parse(fs.readFileSync(pagePath, 'utf8'), { filename: pagePath })
|
||||||
|
assert.deepEqual(errors, [])
|
||||||
|
const script = compileScript(descriptor, { id: 'prescription-ai-visibility' })
|
||||||
|
|
||||||
|
function moduleFrom(source, dependencies) {
|
||||||
|
const compiled = ts.transpileModule(source, {
|
||||||
|
compilerOptions: {
|
||||||
|
target: ts.ScriptTarget.ES2022,
|
||||||
|
module: ts.ModuleKind.CommonJS,
|
||||||
|
esModuleInterop: true
|
||||||
|
}
|
||||||
|
}).outputText
|
||||||
|
const module = { exports: {} }
|
||||||
|
new Function('require', 'module', 'exports', compiled)(dependencies, module, module.exports)
|
||||||
|
return module.exports
|
||||||
|
}
|
||||||
|
|
||||||
|
const makeApi = (request) =>
|
||||||
|
moduleFrom(fs.readFileSync(apiPath, 'utf8'), (name) => {
|
||||||
|
assert.equal(name, '@/utils/request')
|
||||||
|
return request
|
||||||
|
})
|
||||||
|
|
||||||
|
const tick = () => new Promise((resolve) => setImmediate(resolve))
|
||||||
|
const deferred = () => {
|
||||||
|
let resolve
|
||||||
|
let reject
|
||||||
|
const promise = new Promise((yes, no) => {
|
||||||
|
resolve = yes
|
||||||
|
reject = no
|
||||||
|
})
|
||||||
|
return { promise, resolve, reject }
|
||||||
|
}
|
||||||
|
|
||||||
|
function makePage(overrides = {}) {
|
||||||
|
const saved = []
|
||||||
|
const messages = []
|
||||||
|
const calls = []
|
||||||
|
const api = {
|
||||||
|
getPrescriptionAiCapabilities: async () => {
|
||||||
|
calls.push('capabilities')
|
||||||
|
return { agreement_visible: false, can_manage_visibility: true }
|
||||||
|
},
|
||||||
|
getPrescriptionAiVisibilityConfig: async () => {
|
||||||
|
calls.push('config')
|
||||||
|
return { role_ids: [2, 90], admin_ids: [3, 91] }
|
||||||
|
},
|
||||||
|
getPrescriptionAiVisibilityOptions: async () => {
|
||||||
|
calls.push('options')
|
||||||
|
return {
|
||||||
|
roles: [{ id: 2, name: '医生' }],
|
||||||
|
admins: [{ id: 3, name: '张医生', account: 'zhang.doctor' }]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setPrescriptionAiVisibilityConfig: async (params) => {
|
||||||
|
saved.push(params)
|
||||||
|
return params
|
||||||
|
},
|
||||||
|
...overrides
|
||||||
|
}
|
||||||
|
const component = moduleFrom(script.content, (name) => {
|
||||||
|
if (name === 'vue') return vue
|
||||||
|
if (name === '@/api/setting/prescription_ai_visibility') return api
|
||||||
|
if (name === '@/utils/feedback') {
|
||||||
|
return { msgSuccess: (message) => messages.push(message) }
|
||||||
|
}
|
||||||
|
throw new Error(`Unexpected import: ${name}`)
|
||||||
|
}).default
|
||||||
|
const page = component.setup({}, { expose() {} })
|
||||||
|
return { page, api, saved, messages, calls }
|
||||||
|
}
|
||||||
|
|
||||||
|
test('API uses the dedicated routes and rejects malformed policy responses', async () => {
|
||||||
|
const calls = []
|
||||||
|
let response = { role_ids: [2], admin_ids: [3] }
|
||||||
|
const api = makeApi({
|
||||||
|
get: async (params) => {
|
||||||
|
calls.push(['get', params])
|
||||||
|
return response
|
||||||
|
},
|
||||||
|
post: async (params) => {
|
||||||
|
calls.push(['post', params])
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const data = await api.getPrescriptionAiVisibilityConfig()
|
||||||
|
data.role_ids.push(5)
|
||||||
|
assert.deepEqual(response.role_ids, [2])
|
||||||
|
await api.setPrescriptionAiVisibilityConfig({ role_ids: [], admin_ids: [] })
|
||||||
|
await api.getPrescriptionAiVisibilityOptions()
|
||||||
|
await api.getPrescriptionAiCapabilities()
|
||||||
|
assert.deepEqual(calls, [
|
||||||
|
['get', { url: '/tcm.prescriptionAi/getVisibilityConfig' }],
|
||||||
|
[
|
||||||
|
'post',
|
||||||
|
{
|
||||||
|
url: '/tcm.prescriptionAi/setVisibilityConfig',
|
||||||
|
params: { role_ids: [], admin_ids: [] }
|
||||||
|
}
|
||||||
|
],
|
||||||
|
['get', { url: '/tcm.prescriptionAi/visibilityOptions' }],
|
||||||
|
['get', { url: '/tcm.prescriptionAi/capabilities' }]
|
||||||
|
])
|
||||||
|
for (response of [
|
||||||
|
null,
|
||||||
|
{},
|
||||||
|
{ role_ids: [], admin_ids: ['3'] },
|
||||||
|
{ role_ids: [-1], admin_ids: [] }
|
||||||
|
]) {
|
||||||
|
await assert.rejects(api.getPrescriptionAiVisibilityConfig(), /响应格式不正确/)
|
||||||
|
await assert.rejects(
|
||||||
|
api.setPrescriptionAiVisibilityConfig({ role_ids: [], admin_ids: [] }),
|
||||||
|
/响应格式不正确/
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('initial loading is pending and cannot submit before the real policy is read', async () => {
|
||||||
|
const pending = deferred()
|
||||||
|
const { page, saved } = makePage({ getPrescriptionAiVisibilityConfig: () => pending.promise })
|
||||||
|
assert.equal(page.loading.value, true)
|
||||||
|
assert.equal(page.ready.value, false)
|
||||||
|
assert.equal(page.hasLoaded.value, false)
|
||||||
|
await page.handleSubmit()
|
||||||
|
assert.deepEqual(saved, [])
|
||||||
|
pending.reject(new Error('offline'))
|
||||||
|
await tick()
|
||||||
|
assert.equal(page.loading.value, false)
|
||||||
|
assert.equal(page.ready.value, false)
|
||||||
|
assert.match(page.loadError.value, /加载失败/)
|
||||||
|
await page.handleSubmit()
|
||||||
|
assert.deepEqual(saved, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a failed reload cannot apply a partial policy or overwrite saved selections', async () => {
|
||||||
|
const { page, api, saved } = makePage()
|
||||||
|
await tick()
|
||||||
|
api.getPrescriptionAiVisibilityConfig = async () => ({ role_ids: [], admin_ids: [] })
|
||||||
|
api.getPrescriptionAiVisibilityOptions = async () => {
|
||||||
|
throw new Error('options failed')
|
||||||
|
}
|
||||||
|
await page.loadConfig()
|
||||||
|
assert.deepEqual(page.formData.role_ids, [2, 90])
|
||||||
|
assert.deepEqual(page.formData.admin_ids, [3, 91])
|
||||||
|
assert.equal(page.hasLoaded.value, true)
|
||||||
|
assert.equal(page.ready.value, false)
|
||||||
|
await page.handleSubmit()
|
||||||
|
assert.deepEqual(saved, [])
|
||||||
|
api.getPrescriptionAiVisibilityOptions = async () => ({ roles: [], admins: [] })
|
||||||
|
await page.loadConfig()
|
||||||
|
assert.equal(page.ready.value, true)
|
||||||
|
assert.equal(page.loadError.value, '')
|
||||||
|
assert.deepEqual(page.formData.role_ids, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('missing selected IDs stay visible and user labels support both name and account search', async () => {
|
||||||
|
const { page, saved } = makePage()
|
||||||
|
await tick()
|
||||||
|
assert.equal(page.canManage.value, true)
|
||||||
|
assert.equal(page.ready.value, true)
|
||||||
|
assert.deepEqual(
|
||||||
|
page.roleOptions.value.map((row) => row.id),
|
||||||
|
[2, 90]
|
||||||
|
)
|
||||||
|
assert.match(page.roleOptions.value[1].name, /#90.*当前不在可选列表/)
|
||||||
|
assert.deepEqual(
|
||||||
|
page.adminOptions.value.map((row) => row.id),
|
||||||
|
[3, 91]
|
||||||
|
)
|
||||||
|
assert.match(page.adminOptions.value[0].label, /张医生.*zhang\.doctor/)
|
||||||
|
assert.match(page.adminOptions.value[1].label, /#91.*当前不在可选列表/)
|
||||||
|
await page.handleSubmit()
|
||||||
|
assert.deepEqual(saved, [{ role_ids: [2, 90], admin_ids: [3, 91] }])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('explicitly clearing both selectors saves the empty policy and applies normalized response', async () => {
|
||||||
|
const { page, saved, messages, api } = makePage()
|
||||||
|
await tick()
|
||||||
|
page.formData.role_ids = []
|
||||||
|
page.formData.admin_ids = []
|
||||||
|
await page.handleSubmit()
|
||||||
|
assert.deepEqual(saved, [{ role_ids: [], admin_ids: [] }])
|
||||||
|
assert.equal(messages.length, 1)
|
||||||
|
page.formData.role_ids = [90, 2, 90]
|
||||||
|
api.setPrescriptionAiVisibilityConfig = async () => ({ role_ids: [2, 90], admin_ids: [] })
|
||||||
|
await page.handleSubmit()
|
||||||
|
assert.deepEqual(page.formData.role_ids, [2, 90])
|
||||||
|
assert.equal(page.saving.value, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('pending save blocks duplicate writes and reload; failed write keeps the user selection', async () => {
|
||||||
|
const pending = deferred()
|
||||||
|
const saved = []
|
||||||
|
const { page, calls } = makePage({
|
||||||
|
setPrescriptionAiVisibilityConfig: (params) => {
|
||||||
|
saved.push(params)
|
||||||
|
return pending.promise
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await tick()
|
||||||
|
page.formData.role_ids = [90]
|
||||||
|
const first = page.handleSubmit()
|
||||||
|
assert.equal(page.saving.value, true)
|
||||||
|
await page.handleSubmit()
|
||||||
|
await page.loadConfig()
|
||||||
|
assert.equal(saved.length, 1)
|
||||||
|
assert.equal(calls.filter((name) => name === 'capabilities').length, 1)
|
||||||
|
pending.reject(new Error('offline'))
|
||||||
|
await first
|
||||||
|
assert.equal(page.saving.value, false)
|
||||||
|
assert.equal(page.ready.value, true)
|
||||||
|
assert.deepEqual(page.formData.role_ids, [90])
|
||||||
|
assert.deepEqual(page.formData.admin_ids, [3, 91])
|
||||||
|
assert.match(page.saveError.value, /当前选择已保留/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('management capability is independent from viewing and denies policy reads when absent', async () => {
|
||||||
|
const { page, calls, saved } = makePage({
|
||||||
|
getPrescriptionAiCapabilities: async () => ({
|
||||||
|
agreement_visible: true,
|
||||||
|
can_manage_visibility: false
|
||||||
|
})
|
||||||
|
})
|
||||||
|
await tick()
|
||||||
|
assert.deepEqual(calls, [])
|
||||||
|
assert.equal(page.canManage.value, false)
|
||||||
|
assert.equal(page.hasLoaded.value, false)
|
||||||
|
assert.equal(page.ready.value, false)
|
||||||
|
assert.match(page.loadError.value, /无权管理/)
|
||||||
|
await page.handleSubmit()
|
||||||
|
assert.deepEqual(saved, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('actual settings template compiles with its script bindings', () => {
|
||||||
|
const compiled = compileTemplate({
|
||||||
|
source: descriptor.template.content,
|
||||||
|
filename: pagePath,
|
||||||
|
id: 'prescription-ai-visibility',
|
||||||
|
compilerOptions: { bindingMetadata: script.bindings }
|
||||||
|
})
|
||||||
|
assert.deepEqual(compiled.errors, [])
|
||||||
|
})
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const path = require('node:path')
|
||||||
|
const test = require('node:test')
|
||||||
|
const ts = require('typescript')
|
||||||
|
const vue = require('vue')
|
||||||
|
const { parse, compileScript, compileTemplate } = require('@vue/compiler-sfc')
|
||||||
|
|
||||||
|
const selected = [
|
||||||
|
'patchUsageVisible', 'patchUsageSaving', 'patchUsageFormRef', 'patchUsageForm',
|
||||||
|
'patchUsageRules', 'openPatchUsageDialog', 'resetPatchUsageForm', 'submitPatchUsage'
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const page of ['components/PrescriptionOrderDetailDrawer.vue', 'order_list_h5.vue']) {
|
||||||
|
const filename = path.join(__dirname, '../src/views/consumer/prescription', page)
|
||||||
|
const { descriptor, errors } = parse(fs.readFileSync(filename, 'utf8'), { filename })
|
||||||
|
assert.deepEqual(errors, [])
|
||||||
|
const script = compileScript(descriptor, { id: page })
|
||||||
|
const template = compileTemplate({
|
||||||
|
source: descriptor.template.content, filename, id: page,
|
||||||
|
compilerOptions: { bindingMetadata: script.bindings }
|
||||||
|
})
|
||||||
|
assert.deepEqual(template.errors, [])
|
||||||
|
|
||||||
|
// Run the real handlers; replace the network and the rest of the order page.
|
||||||
|
const ast = ts.createSourceFile(filename + '.ts', descriptor.scriptSetup.content, ts.ScriptTarget.Latest, true)
|
||||||
|
const declarations = new Map()
|
||||||
|
for (const node of ast.statements) {
|
||||||
|
if (ts.isFunctionDeclaration(node) && node.name) declarations.set(node.name.text, node.getText(ast))
|
||||||
|
if (ts.isVariableStatement(node)) {
|
||||||
|
for (const declaration of node.declarationList.declarations) {
|
||||||
|
declarations.set(declaration.name.getText(ast), `const ${declaration.getText(ast)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const executed = page.startsWith('components/') ? [...selected, 'refresh'] : selected
|
||||||
|
for (const name of executed) assert.ok(declarations.has(name), `${page}: missing ${name}`)
|
||||||
|
const compiled = ts.transpileModule(executed.map(name => declarations.get(name)).join('\n'), {
|
||||||
|
compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS }
|
||||||
|
}).outputText
|
||||||
|
|
||||||
|
function instance({ hasAux = true, prescriptionType = '浓缩水丸', bagCount = 2, mainBagCount = 2, status = 2, fail = false } = {}) {
|
||||||
|
const calls = []
|
||||||
|
const events = []
|
||||||
|
const aux = { times_per_day: 3, usage_days: 10, dosage_bag_count: bagCount }
|
||||||
|
const detailPrescription = vue.ref({ prescription_type: prescriptionType, times_per_day: 3, usage_days: 10, dosage_bag_count: mainBagCount })
|
||||||
|
const detailAuxUsage = vue.ref(hasAux ? aux : null)
|
||||||
|
const detailData = vue.ref({
|
||||||
|
id: 42, prescription_id: 17, medication_days: 10, fulfillment_status: status,
|
||||||
|
gancao_reciperl_order_no: 'TEST-SUBMITTED', ej_pharmacy_order_no: 'TEST-EJ-SUBMITTED'
|
||||||
|
})
|
||||||
|
const refresh = async () => {
|
||||||
|
if (calls[0]?.dosage_bag_count) detailPrescription.value.dosage_bag_count = calls[0].dosage_bag_count
|
||||||
|
if (calls[0]?.aux_dosage_bag_count) detailAuxUsage.value.dosage_bag_count = calls[0].aux_dosage_bag_count
|
||||||
|
events.push('refresh')
|
||||||
|
return { ...detailData.value }
|
||||||
|
}
|
||||||
|
const globals = {
|
||||||
|
ref: vue.ref, reactive: vue.reactive, computed: vue.computed, nextTick: vue.nextTick,
|
||||||
|
detailPrescription, detailAuxUsage, detailData, detailHasAuxHerbs: vue.ref(hasAux),
|
||||||
|
feedback: { msgWarning() {}, msgSuccess() {} },
|
||||||
|
prescriptionOrderPatchPrescriptionUsage: async payload => {
|
||||||
|
calls.push(payload)
|
||||||
|
if (fail) throw new Error('Save failed')
|
||||||
|
},
|
||||||
|
requestDetail: refresh, refreshCurrentPrescriptionOrderDetail: refresh,
|
||||||
|
props: { loadRelatedData: true },
|
||||||
|
emit: event => events.push(event),
|
||||||
|
fetchLogs: async () => {
|
||||||
|
events.push('logs:start')
|
||||||
|
await Promise.resolve()
|
||||||
|
events.push('logs:done')
|
||||||
|
},
|
||||||
|
getLists: () => events.push('lists')
|
||||||
|
}
|
||||||
|
const state = new Function(...Object.keys(globals), `${compiled}\nreturn { ${selected.join(', ')} }`)(...Object.values(globals))
|
||||||
|
state.patchUsageFormRef.value = { validate: async () => {}, clearValidate() {} }
|
||||||
|
return { state, calls, events, detailAuxUsage, detailPrescription }
|
||||||
|
}
|
||||||
|
|
||||||
|
test(`${page}: edits auxiliary bags and refreshes the persisted value without changing usage`, async () => {
|
||||||
|
const { state, calls, events, detailAuxUsage } = instance()
|
||||||
|
state.openPatchUsageDialog()
|
||||||
|
assert.equal(state.patchUsageForm.aux_dosage_bag_count, 2)
|
||||||
|
state.patchUsageForm.aux_dosage_bag_count = 4
|
||||||
|
await state.submitPatchUsage()
|
||||||
|
assert.deepEqual(calls, [{
|
||||||
|
id: 42, times_per_day: 3, usage_days: 10, medication_days: 10,
|
||||||
|
dosage_bag_count: 2,
|
||||||
|
aux_times_per_day: 3, aux_usage_days: 10, aux_dosage_bag_count: 4
|
||||||
|
}])
|
||||||
|
assert.equal(state.patchUsageVisible.value, false)
|
||||||
|
assert.equal(detailAuxUsage.value.dosage_bag_count, 4)
|
||||||
|
assert.ok(events.includes('refresh'))
|
||||||
|
assert.ok(events.indexOf('logs:done') > events.indexOf('refresh'))
|
||||||
|
const completedEvent = page.startsWith('components/') ? 'detail-changed' : 'lists'
|
||||||
|
assert.ok(events.indexOf(completedEvent) > events.indexOf('logs:done'))
|
||||||
|
state.openPatchUsageDialog()
|
||||||
|
assert.equal(state.patchUsageForm.aux_dosage_bag_count, 4)
|
||||||
|
})
|
||||||
|
|
||||||
|
test(`${page}: missing historical bag count defaults to one and resets on close`, () => {
|
||||||
|
const { state } = instance({ bagCount: null, mainBagCount: null })
|
||||||
|
state.openPatchUsageDialog()
|
||||||
|
assert.equal(state.patchUsageForm.aux_dosage_bag_count, 1)
|
||||||
|
assert.equal(state.patchUsageForm.dosage_bag_count, 1)
|
||||||
|
state.patchUsageForm.dosage_bag_count = 4
|
||||||
|
state.patchUsageForm.aux_dosage_bag_count = 5
|
||||||
|
state.resetPatchUsageForm()
|
||||||
|
assert.equal(state.patchUsageForm.aux_dosage_bag_count, 1)
|
||||||
|
assert.equal(state.patchUsageForm.dosage_bag_count, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test(`${page}: only water pills with an auxiliary formula submit or require auxiliary bags`, async () => {
|
||||||
|
for (const options of [{ hasAux: false }, { prescriptionType: '饮片' }, { prescriptionType: '颗粒' }]) {
|
||||||
|
const { state, calls } = instance(options)
|
||||||
|
state.openPatchUsageDialog()
|
||||||
|
state.patchUsageForm.aux_dosage_bag_count = 5
|
||||||
|
assert.equal(state.patchUsageRules.value.aux_dosage_bag_count, undefined)
|
||||||
|
await state.submitPatchUsage()
|
||||||
|
assert.ok(!Object.hasOwn(calls[0], 'aux_dosage_bag_count'))
|
||||||
|
assert.equal(Object.hasOwn(calls[0], 'dosage_bag_count'), !options.prescriptionType)
|
||||||
|
}
|
||||||
|
const { state } = instance()
|
||||||
|
assert.equal(state.patchUsageRules.value.aux_dosage_bag_count[0].required, true)
|
||||||
|
assert.equal(state.patchUsageRules.value.dosage_bag_count[0].required, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test(`${page}: main and auxiliary bags can be edited independently in every submitted order status`, async () => {
|
||||||
|
for (let status = 1; status <= 12; status++) {
|
||||||
|
const { state, calls, detailPrescription, detailAuxUsage } = instance({ status })
|
||||||
|
state.openPatchUsageDialog()
|
||||||
|
assert.equal(state.patchUsageForm.dosage_bag_count, 2)
|
||||||
|
state.patchUsageForm.dosage_bag_count = 5
|
||||||
|
state.patchUsageForm.aux_dosage_bag_count = 1
|
||||||
|
await state.submitPatchUsage()
|
||||||
|
assert.equal(calls.length, 1, `status ${status}`)
|
||||||
|
assert.equal(calls[0].dosage_bag_count, 5)
|
||||||
|
assert.equal(calls[0].aux_dosage_bag_count, 1)
|
||||||
|
assert.equal(detailPrescription.value.dosage_bag_count, 5)
|
||||||
|
assert.equal(detailAuxUsage.value.dosage_bag_count, 1)
|
||||||
|
}
|
||||||
|
const { state, calls } = instance({ hasAux: false })
|
||||||
|
state.openPatchUsageDialog()
|
||||||
|
state.patchUsageForm.dosage_bag_count = 4
|
||||||
|
await state.submitPatchUsage()
|
||||||
|
assert.equal(calls[0].dosage_bag_count, 4)
|
||||||
|
assert.ok(!Object.hasOwn(calls[0], 'aux_dosage_bag_count'))
|
||||||
|
})
|
||||||
|
|
||||||
|
test(`${page}: validation blocks requests and a rejected save retains the selected bags`, async () => {
|
||||||
|
const { state, calls, events } = instance({ fail: true })
|
||||||
|
state.openPatchUsageDialog()
|
||||||
|
state.patchUsageForm.aux_dosage_bag_count = 3
|
||||||
|
state.patchUsageForm.dosage_bag_count = 4
|
||||||
|
state.patchUsageFormRef.value.validate = async () => { throw new Error('Required') }
|
||||||
|
await state.submitPatchUsage()
|
||||||
|
assert.equal(calls.length, 0)
|
||||||
|
state.patchUsageFormRef.value.validate = async () => {}
|
||||||
|
await state.submitPatchUsage()
|
||||||
|
assert.equal(state.patchUsageForm.aux_dosage_bag_count, 3)
|
||||||
|
assert.equal(state.patchUsageForm.dosage_bag_count, 4)
|
||||||
|
assert.equal(state.patchUsageVisible.value, true)
|
||||||
|
assert.equal(state.patchUsageSaving.value, false)
|
||||||
|
assert.deepEqual(events, [])
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const path = require('node:path')
|
||||||
|
const test = require('node:test')
|
||||||
|
const ts = require('typescript')
|
||||||
|
const vue = require('vue')
|
||||||
|
const { parse, compileScript, compileTemplate } = require('@vue/compiler-sfc')
|
||||||
|
|
||||||
|
const handlers = [
|
||||||
|
'isDualAuditPassed', 'canQuickTrackRow', 'canShipRow', 'openQuickTrack',
|
||||||
|
'submitQuickTrack', 'openShip', 'openEdit', 'submitEdit', 'resetEditOrderDialog'
|
||||||
|
]
|
||||||
|
const stateNames = [
|
||||||
|
'quickTrackVisible', 'quickTrackSaving', 'quickTrackRowId', 'quickTrackForm',
|
||||||
|
'shipVisible', 'shipRowId', 'shipForm', 'editVisible', 'editDialogLoading',
|
||||||
|
'editSaving', 'editOrderStep', 'editOrderPrescription',
|
||||||
|
'editFormRef', 'editForm', 'editDepositMin'
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const page of ['order_list.vue', 'order_list_h5.vue']) {
|
||||||
|
const filename = path.join(__dirname, '../src/views/consumer/prescription', page)
|
||||||
|
const { descriptor, errors } = parse(fs.readFileSync(filename, 'utf8'), { filename })
|
||||||
|
assert.deepEqual(errors, [])
|
||||||
|
const script = compileScript(descriptor, { id: page })
|
||||||
|
const ast = ts.createSourceFile(filename + '.ts', descriptor.scriptSetup.content, ts.ScriptTarget.Latest, true)
|
||||||
|
// Execute the actual order handlers with only network and unrelated UI dependencies stubbed.
|
||||||
|
const declarations = new Map()
|
||||||
|
for (const node of ast.statements) {
|
||||||
|
if (ts.isFunctionDeclaration(node) && node.name) declarations.set(node.name.text, node.getText(ast))
|
||||||
|
if (ts.isVariableStatement(node)) {
|
||||||
|
for (const declaration of node.declarationList.declarations) {
|
||||||
|
declarations.set(declaration.name.getText(ast), `const ${declaration.getText(ast)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const selected = [...handlers, ...stateNames]
|
||||||
|
for (const name of selected) assert.ok(declarations.has(name), `${page}: missing ${name}`)
|
||||||
|
const compiled = ts.transpileModule(selected.map(name => declarations.get(name)).join('\n'), {
|
||||||
|
compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS }
|
||||||
|
}).outputText
|
||||||
|
|
||||||
|
function instance(detail = {}, saveTracking = async () => {}) {
|
||||||
|
const warnings = []
|
||||||
|
const errors = []
|
||||||
|
const edits = []
|
||||||
|
const tracks = []
|
||||||
|
const globals = {
|
||||||
|
ref: vue.ref, reactive: vue.reactive, nextTick: vue.nextTick,
|
||||||
|
feedback: { msgWarning: message => warnings.push(message), msgError: message => errors.push(message), msgSuccess() {} },
|
||||||
|
prescriptionOrderDetail: async () => ({ data: detail }),
|
||||||
|
prescriptionOrderEdit: async payload => edits.push(payload),
|
||||||
|
prescriptionOrderDdcode: async payload => { tracks.push(payload); await saveTracking(payload) },
|
||||||
|
canEditRow: () => true,
|
||||||
|
parseServicePackageValues: () => [],
|
||||||
|
loadEditPaidOrders: async () => {},
|
||||||
|
resolveShipModeForRow: row => row.ship_mode || 'gancao',
|
||||||
|
editGancaoLogisticsOnlyMode: vue.ref(false),
|
||||||
|
editGancaoDisplayNo: vue.ref(''),
|
||||||
|
detailDrawerRef: vue.ref(null),
|
||||||
|
detailVisible: vue.ref(false),
|
||||||
|
getLists() {}
|
||||||
|
}
|
||||||
|
const state = new Function(...Object.keys(globals), `${compiled}\nreturn { ${selected.join(', ')} }`)(...Object.values(globals))
|
||||||
|
state.editFormRef.value = { validate: async () => {}, clearValidate() {} }
|
||||||
|
return { state, warnings, errors, edits, tracks }
|
||||||
|
}
|
||||||
|
|
||||||
|
test(`${page}: only two approved audits unlock tracking, including legacy string statuses`, () => {
|
||||||
|
const { state } = instance()
|
||||||
|
for (const rx of [undefined, null, 0, 1, 2, '0', '1', '2']) {
|
||||||
|
for (const pay of [undefined, null, 0, 1, 2, '0', '1', '2']) {
|
||||||
|
const row = { prescription_audit_status: rx, payment_slip_audit_status: pay, fulfillment_status: 2 }
|
||||||
|
const allowed = [1, '1'].includes(rx) && [1, '1'].includes(pay)
|
||||||
|
assert.equal(state.canQuickTrackRow(row), allowed, `tracking: ${rx}/${pay}`)
|
||||||
|
assert.equal(state.canShipRow(row), allowed, `shipping: ${rx}/${pay}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const status of [1, 3, 4, 5, 6, 9]) {
|
||||||
|
assert.equal(state.canShipRow({ prescription_audit_status: 1, payment_slip_audit_status: 1, fulfillment_status: status }), false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test(`${page}: direct handler calls cannot open tracking or shipping before both approvals`, () => {
|
||||||
|
for (const [rx, pay] of [[0, 0], [1, 0], [0, 1], [2, 1], [1, 2]]) {
|
||||||
|
const { state, warnings } = instance()
|
||||||
|
const row = { id: 42, prescription_audit_status: rx, payment_slip_audit_status: pay, fulfillment_status: 2 }
|
||||||
|
state.openQuickTrack(row)
|
||||||
|
state.openShip(row)
|
||||||
|
assert.equal(state.quickTrackVisible.value, false)
|
||||||
|
assert.equal(state.shipVisible.value, false)
|
||||||
|
assert.equal(state.quickTrackRowId.value, 0)
|
||||||
|
assert.equal(state.shipRowId.value, 0)
|
||||||
|
assert.equal(warnings.length, 2)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test(`${page}: approved orders can fill or replace the number and preserve the carrier`, async () => {
|
||||||
|
for (const oldNumber of ['', 'SF-OLD']) {
|
||||||
|
const { state, tracks } = instance()
|
||||||
|
const row = { id: 42, prescription_audit_status: 1, payment_slip_audit_status: 1, fulfillment_status: 2, express_company: 'sf', tracking_number: oldNumber }
|
||||||
|
state.openQuickTrack(row)
|
||||||
|
assert.equal(state.quickTrackVisible.value, true)
|
||||||
|
assert.equal(state.quickTrackForm.tracking_number, oldNumber)
|
||||||
|
state.quickTrackForm.tracking_number = ' SF-NEW '
|
||||||
|
await state.submitQuickTrack()
|
||||||
|
assert.deepEqual(tracks, [{ id: 42, express_company: 'sf', tracking_number: 'SF-NEW' }])
|
||||||
|
assert.equal(state.quickTrackVisible.value, false)
|
||||||
|
state.openShip(row)
|
||||||
|
assert.equal(state.shipVisible.value, true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test(`${page}: a server rejection after audit revocation retains the dialog and entered number`, async () => {
|
||||||
|
const { state } = instance({}, async () => { throw new Error('audit revoked') })
|
||||||
|
state.openQuickTrack({ id: 42, prescription_audit_status: 1, payment_slip_audit_status: 1 })
|
||||||
|
state.quickTrackForm.tracking_number = 'SF-NEW'
|
||||||
|
await state.submitQuickTrack()
|
||||||
|
assert.equal(state.quickTrackVisible.value, true)
|
||||||
|
assert.equal(state.quickTrackSaving.value, false)
|
||||||
|
assert.equal(state.quickTrackForm.tracking_number, 'SF-NEW')
|
||||||
|
})
|
||||||
|
|
||||||
|
test(`${page}: editing preserves the existing number while saving other fields before approval`, async () => {
|
||||||
|
const { state, edits, errors } = instance({ id: 42, prescription_audit_status: 1, payment_slip_audit_status: 0, tracking_number: 'SF-EXISTING' })
|
||||||
|
await state.openEdit({ id: 42, prescription_audit_status: 1, payment_slip_audit_status: 1 })
|
||||||
|
assert.deepEqual(errors, [])
|
||||||
|
assert.equal(state.editForm.tracking_number, 'SF-EXISTING')
|
||||||
|
state.editForm.tracking_number = 'FORGED'
|
||||||
|
state.editForm.recipient_name = '修改后的收货人'
|
||||||
|
state.editOrderStep.value = 2
|
||||||
|
await state.submitEdit()
|
||||||
|
assert.equal(edits.length, 1)
|
||||||
|
assert.equal(edits[0].recipient_name, '修改后的收货人')
|
||||||
|
assert.equal(Object.hasOwn(edits[0], 'tracking_number'), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test(`${page}: ordinary editing cannot change tracking while resetting an approved payment audit`, async () => {
|
||||||
|
const { state, edits, errors } = instance({ id: 42, prescription_audit_status: '1', payment_slip_audit_status: '1' })
|
||||||
|
await state.openEdit({ id: 42 })
|
||||||
|
assert.deepEqual(errors, [])
|
||||||
|
state.editForm.tracking_number = 'SF-NEW'
|
||||||
|
state.editOrderStep.value = 2
|
||||||
|
await state.submitEdit()
|
||||||
|
assert.equal(edits.length, 1)
|
||||||
|
assert.equal(Object.hasOwn(edits[0], 'tracking_number'), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test(`${page}: template compiles with tracking controls bound to audit restrictions`, () => {
|
||||||
|
const template = compileTemplate({ source: descriptor.template.content, filename, id: page, compilerOptions: { bindingMetadata: script.bindings } })
|
||||||
|
assert.deepEqual(template.errors, [])
|
||||||
|
const inputs = [...descriptor.template.content.matchAll(/<el-input\b[^>]*v-model="editForm\.tracking_number"[^>]*>/g)]
|
||||||
|
assert.equal(inputs.length, page === 'order_list.vue' ? 2 : 1)
|
||||||
|
for (const [input] of inputs) assert.match(input, /\sdisabled(?:\s|\/?>)/)
|
||||||
|
assert.match(descriptor.template.content, /v-if="canQuickTrackRow\(row\)"/)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -46,6 +46,8 @@ export default defineConfig(({ mode }) => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
base: '/admin/',
|
base: '/admin/',
|
||||||
|
// 临时 UI 预览可能共用 node_modules;主开发服务独立缓存,避免预构建互相覆盖。
|
||||||
|
cacheDir: path.resolve(__dirname, 'node_modules/.vite-admin'),
|
||||||
server: {
|
server: {
|
||||||
port: 5555,
|
port: 5555,
|
||||||
host: '0.0.0.0',
|
host: '0.0.0.0',
|
||||||
|
|||||||
+1
-1
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
Windows 直接在项目根目录双击:
|
Windows 直接在项目根目录双击:
|
||||||
|
|
||||||
- `一键运行_医生工作站.bat`:优先启动现有成品;没有成品时自动使用 `uv` 准备源码环境并运行。
|
- `一键运行_医生工作站.bat`:在源码目录且 `.venv` 可用时运行最新源码;否则运行现有成品;没有成品时使用 `uv` 准备源码环境并运行。
|
||||||
- `一键打包_医生工作站.bat`:自动同步锁定的 Python/Node 依赖,检查冻结 QtWebEngine/QtMultimedia 文件,执行应用与媒体离屏冒烟验证,最后生成 `dist/DoctorWorkstation-Setup-Windows-x64-<版本>.exe` 安装包、便携 ZIP 和 SHA-256 文件。首次使用会自动下载固定版本的 Inno Setup,并校验下载文件的 SHA-256 与数字签名。
|
- `一键打包_医生工作站.bat`:自动同步锁定的 Python/Node 依赖,检查冻结 QtWebEngine/QtMultimedia 文件,执行应用与媒体离屏冒烟验证,最后生成 `dist/DoctorWorkstation-Setup-Windows-x64-<版本>.exe` 安装包、便携 ZIP 和 SHA-256 文件。首次使用会自动下载固定版本的 Inno Setup,并校验下载文件的 SHA-256 与数字签名。
|
||||||
|
|
||||||
英文稳定别名分别是 `Run_DoctorWorkstation.bat` 和 `Build_DoctorWorkstation.bat`。分发 ZIP 解压后,可直接双击其中的 `Start_DoctorWorkstation.bat`。
|
英文稳定别名分别是 `Run_DoctorWorkstation.bat` 和 `Build_DoctorWorkstation.bat`。分发 ZIP 解压后,可直接双击其中的 `Start_DoctorWorkstation.bat`。
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# 已开处方 → AI 界面改造
|
||||||
|
|
||||||
|
## 功能分析
|
||||||
|
|
||||||
|
这是基于已保存资料快照的双模型分析与医生复核工作台。主流程是确认患者和批次、查看模型完成情况、对照原方差异、核查资料缺口、记录所选模型的复核意见。
|
||||||
|
|
||||||
|
保留六个入口:对比总览、完整报告、候选与逐味、资料与缺口、处理进度、历史与趋势。历史批次切换、刷新、重新分析、单模型重试、独立复核草稿及保存继续使用现有服务和权限逻辑。医生原方及支持报告仍可访问。
|
||||||
|
|
||||||
|
## 设计决定
|
||||||
|
|
||||||
|
- 主程序 `shell.py` 将 prescriptions 等业务页列为科技蓝页面。因此继续复用 `reception_style.TECH_BLUE`:主色 #1769E8,背景 #F3F7FD,白色面板,分割线 #DBE5F2。没有采用主程序其他页面的靛蓝令牌,也没有修改全局主题。
|
||||||
|
- 深蓝渐变横幅改为白色标题工具栏。批次状态放在顶行,患者及原方信息单独成行,当前导航用浅蓝背景与蓝色底线标识。
|
||||||
|
- 缩小一致度圆环和数字,保留共同药味、候选药味与药味重合的计算依据;修复指标区样式误把分隔线应用到每个子标签的问题。
|
||||||
|
- 总览由三栏改成剂量差异主区与固定复核侧栏。三方交集和附件覆盖改为可展开的摘要,减少初始屏幕空白和对主图的挤压。
|
||||||
|
- 复核模型和复核状态并排显示,意见输入和保存按钮放在一起。顶部保存动作显示当前模型名,切换时同步更新。
|
||||||
|
- 短窗口收起次要信息,保留剂量主图和复核输入;展开图表时剂量滚动区域可让出高度。列表内容保持最小高度,避免刷新后条目重叠。
|
||||||
|
- 保留未知值与零值区分、不可比/历史状态处理、模型各自失败重试以及现有一致度说明。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
- 处方 AI 五组回归测试覆盖数据处理、权限、异步读取、逐味比较、页面、模型复核与布局。
|
||||||
|
- 新增八种窗口/展开状态组合的控件边界检查,以及展开/收起不改变比较数据和复核清单的检查。
|
||||||
|
- 离线模拟数据预览覆盖 1440×940、1280×860、1024×700、940×640,各内容页面、展开图表、运行中、失败、历史及统计窗口。
|
||||||
|
- 预览输出:`app/artifacts/issued_prescription_redesign/`。模拟数据仅供 UI 验证,不对应真实患者。
|
||||||
|
|
||||||
|
本次改动在现有未提交工作区基础上完成,仅调整桌面呈现与相关回归检查;没有重新打包或发布桌面安装程序。
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
"""Render the prescription comparison window with synthetic, offline data."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from copy import deepcopy
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
|
from doctor_workstation.ui.dialogs import issued_prescription_ai as ai
|
||||||
|
from doctor_workstation.ui.theme import apply_theme
|
||||||
|
|
||||||
|
|
||||||
|
def example_batch() -> dict[str, Any]:
|
||||||
|
"""Three prescriptions that actually differ, so every state of the page has something to draw."""
|
||||||
|
|
||||||
|
doctor = {"生地黄": 16, "天花粉": 15, "干石斛": 20, "醋五味子": 6,
|
||||||
|
"生麦冬": 12, "茯苓": 10, "红参片": 6, "生牡丹皮": 10}
|
||||||
|
candidates = {
|
||||||
|
"qwen": {"生地黄": 15, "干石斛": 12, "醋五味子": 6, "生麦冬": 12, "茯苓": 15, "麸炒白术": 12, "丹参": 15},
|
||||||
|
"openai": {"生地黄": 12, "醋五味子": 6, "生麦冬": 10, "茯苓": 12, "生白芍": 5, "炒酸枣仁": 6},
|
||||||
|
}
|
||||||
|
reports = {
|
||||||
|
"qwen": {"summary": "界面演示数据:对照药味组成、剂量及用法,辅助医生逐项复核。",
|
||||||
|
"diagnosis": "消渴病,气阴两虚兼血热。兼证需结合舌脉资料核实。",
|
||||||
|
"analysis": "阅读药方对比页,查看同名药材的剂量差异、仅医生方药味与仅候选方药味。",
|
||||||
|
"risk_assessment": [{"level": "high", "label": "血压数据缺失,影响补气药安全性评估"},
|
||||||
|
{"level": "medium", "label": "肝肾功能具体指标未提供"}],
|
||||||
|
"missing_information": ["甲状腺功能及眼底检查报告缺失", "舌象与脉诊仅有附件,无文本记录"]},
|
||||||
|
"openai": {"summary": "界面演示数据:两份候选方独立生成,引用编号可回查原始资料。",
|
||||||
|
"diagnosis": "已记录气阴两虚证;辨证依据需补充。",
|
||||||
|
"analysis": "候选方以益气养阴为主,安神药味为本模型新增,需要医师确认。",
|
||||||
|
"risk_assessment": [{"level": "high", "label": "缺少当前用药记录,无法排除配伍风险"}],
|
||||||
|
"missing_information": ["舌象与脉诊仅有附件,无文本记录", "近期体重变化及 BMI 数据缺失"]},
|
||||||
|
}
|
||||||
|
models = {}
|
||||||
|
for key, doses in candidates.items():
|
||||||
|
herbs, rows = [], []
|
||||||
|
for name in sorted(set(doctor) | set(doses), key=lambda item: (item not in doses, item)):
|
||||||
|
common = {"name": name, "unit": "g", "dose_basis": "per_dose", "formula_type": "主方"}
|
||||||
|
left, right = doctor.get(name), doses.get(name)
|
||||||
|
if right is not None:
|
||||||
|
herbs.append({**common, "dosage": right})
|
||||||
|
rows.append({
|
||||||
|
**common, "key": name,
|
||||||
|
"doctor": {**common, "dosage": left} if left is not None else None,
|
||||||
|
"candidate": {**common, "dosage": right, "source_rows": [len(herbs) - 1]} if right is not None else None,
|
||||||
|
"doctor_dosage": left, "candidate_dosage": right,
|
||||||
|
"match_type": "candidate_only" if left is None else "doctor_only" if right is None else "matched",
|
||||||
|
"contribution": min(left, right) / max(left, right) if left and right else None,
|
||||||
|
})
|
||||||
|
matched = sum(row["match_type"] == "matched" for row in rows)
|
||||||
|
denominator = len(doctor) + len(herbs)
|
||||||
|
models[key] = {
|
||||||
|
"status": "success", "report_id": 10 if key == "qwen" else 11,
|
||||||
|
"candidate": {"status": "available_for_review", "prescription_name": "候选药方 · 界面示例",
|
||||||
|
"herbs": herbs, "dose_basis": "per_dose", "prescription_type": "浓缩水丸",
|
||||||
|
"usage_instruction": "服法由医生复核后确认。", "usage_days": 7, "times_per_day": 2,
|
||||||
|
"rationale": "这是用于检查界面排版的模拟药方,不对应真实患者。",
|
||||||
|
"risk_warnings": ["药味与剂量差异需要逐项复核。"]},
|
||||||
|
"comparison": {"status": "comparable",
|
||||||
|
"score": 200 * sum(row["contribution"] or 0 for row in rows) / denominator,
|
||||||
|
"herb_score": 200 * matched / denominator, "matched_count": matched,
|
||||||
|
"doctor_count": len(doctor), "candidate_count": len(herbs), "rows": rows,
|
||||||
|
"reason": "药味与剂量可比;服法与疗程需单独复核。", "usage_differences": []},
|
||||||
|
"report": reports[key],
|
||||||
|
"coverage": {"status": "incomplete", "complete": False,
|
||||||
|
"files": [{"file_id": f"a{index:04d}", "type": "image" if index % 3 else "document",
|
||||||
|
"status": "processed", "transmitted": True, "version_verified": True}
|
||||||
|
for index in range(20 if key == "qwen" else 17)]
|
||||||
|
+ [{"file_id": f"z{index:04d}", "type": "document", "status": "restricted",
|
||||||
|
"transmitted": False, "version_verified": False,
|
||||||
|
"reason": "FILE_UNAVAILABLE_OR_UNSUPPORTED"} for index in range(2)]},
|
||||||
|
"progress": {"stage_label": "处理完成", "elapsed_seconds": 135 if key == "qwen" else 591,
|
||||||
|
"attempt": 1},
|
||||||
|
"usage": {"total_calls": 3, "calls": [
|
||||||
|
{"stage": "text:0", "ok": True, "latency_ms": 3120, "file_count": 0,
|
||||||
|
"usage": {"completion_tokens": 980}, "error_code": ""},
|
||||||
|
{"stage": "files:0", "ok": True, "latency_ms": 22400,
|
||||||
|
"file_count": 20 if key == "qwen" else 17,
|
||||||
|
"usage": {"completion_tokens": 2140}, "error_code": ""},
|
||||||
|
{"stage": "final", "ok": True, "latency_ms": 18800, "file_count": 0,
|
||||||
|
"usage": {"completion_tokens": 5617}, "error_code": ""}]},
|
||||||
|
"review": {"status": "viewed", "comment": ""},
|
||||||
|
"algorithm_version": "prescription-soft-dice-v1.1.0",
|
||||||
|
"prompt_version": "manual-prescription-required-candidate-v4",
|
||||||
|
}
|
||||||
|
return {"id": 40, "prescription_id": 7556, "patient_id": 1391, "diagnosis_id": 1391, "agreement_visible": True,
|
||||||
|
"prescription_revision": 1, "status": "success", "validity": "current",
|
||||||
|
"comparison_type": "non_independent", "models": models, "coverage_status": "partial",
|
||||||
|
"created_at": "2026-09-10 15:29:00", "cutoff_at": "2026-09-10 15:29:00",
|
||||||
|
"source_summary": {"diagnoses_count": 2, "attachment_count": 22, "video_calls_count": 4,
|
||||||
|
"chat_messages_count": 137, "source_record_count": 31},
|
||||||
|
"doctor_snapshot": {"patient": {"name": "张卫君", "gender": 2, "gender_label": "女", "age": 58},
|
||||||
|
"diagnosis": {"clinical_diagnosis": "2型糖尿病 消渴病 · 气阴两虚兼血热",
|
||||||
|
"chief_complaint": "咳嗽反复1月余"},
|
||||||
|
"prescription": {"herbs": [{"name": name, "dosage": dose, "unit": "g",
|
||||||
|
"dose_basis": "per_dose", "formula_type": "主方"}
|
||||||
|
for name, dose in doctor.items()],
|
||||||
|
"prescription_type": "浓缩水丸", "dose_count": 1,
|
||||||
|
"usage_instruction": "每日1剂,水煎分服。"}},
|
||||||
|
"missing": [{"code": "TRANSCRIPT_NOT_VERIFIED_COMPLETE", "critical": True},
|
||||||
|
{"code": "TRANSCRIPT_NOT_VERIFIED_COMPLETE", "critical": True},
|
||||||
|
{"code": "ARCHIVE_SYNC_WATERMARK_UNAVAILABLE", "critical": False}]}
|
||||||
|
|
||||||
|
|
||||||
|
def example_statistics() -> dict[str, Any]:
|
||||||
|
"""Doctor-level shape the statistics window renders; synthetic, but structurally complete."""
|
||||||
|
|
||||||
|
def doctor(identifier: int, name: str, totals: tuple[int, int, int],
|
||||||
|
qwen: tuple[int, float | None], openai: tuple[int, float | None],
|
||||||
|
review: tuple[int, int]) -> dict[str, Any]:
|
||||||
|
total, patients, paired = totals
|
||||||
|
models = {}
|
||||||
|
for key, (eligible, mean) in (("qwen", qwen), ("openai", openai)):
|
||||||
|
share = (0.34, 0.38, 0.18, 0.07, 0.03)
|
||||||
|
models[key] = {"eligible_count": eligible, "mean": mean,
|
||||||
|
"median": None if mean is None else round(mean - 1.4, 1),
|
||||||
|
"excluded_reasons": {"SOURCE_HISTORY_VERSIONS_UNAVAILABLE": max(0, total - eligible - 2),
|
||||||
|
"incomplete_coverage": min(2, max(0, total - eligible))},
|
||||||
|
"distribution": {name: round(eligible * fraction)
|
||||||
|
for name, fraction in zip(
|
||||||
|
("[0,20)", "[20,40)", "[40,60)", "[60,80)", "[80,100]"),
|
||||||
|
share, strict=True)}}
|
||||||
|
evaluated, qualified = review
|
||||||
|
return {"doctor_id": identifier, "doctor_name": name, "total_count": total,
|
||||||
|
"patient_count": patients, "paired_count": paired, "models": models,
|
||||||
|
"review": {"evaluated_count": evaluated, "qualified_count": qualified,
|
||||||
|
"qualified_rate": None if not evaluated else round(100 * qualified / evaluated, 1)}}
|
||||||
|
|
||||||
|
doctors = [doctor(26, "何医生", (18, 14, 9), (12, 21.4), (9, 18.9), (6, 4)),
|
||||||
|
doctor(31, "李医生", (11, 9, 4), (7, 26.8), (5, 24.1), (3, 1)),
|
||||||
|
doctor(44, "王医生", (6, 5, 1), (2, 15.2), (0, None), (0, 0))]
|
||||||
|
return {"total_count": sum(item["total_count"] for item in doctors),
|
||||||
|
"patient_count": sum(item["patient_count"] for item in doctors), "doctors": doctors}
|
||||||
|
|
||||||
|
|
||||||
|
class PreviewRepository:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.batch = example_batch()
|
||||||
|
|
||||||
|
def get_prescription_ai_capabilities(self) -> dict[str, Any]:
|
||||||
|
return {"agreement_visible": True, "can_manage_visibility": False}
|
||||||
|
|
||||||
|
def list_prescription_ai_reports(self, **_params: Any) -> dict[str, Any]:
|
||||||
|
return {"agreement_visible": True, "enabled": True, "lists": deepcopy(self.history()), "count": len(self.history())}
|
||||||
|
|
||||||
|
def history(self) -> list[dict[str, Any]]:
|
||||||
|
"""The current batch plus the earlier ones it supersedes, newest first."""
|
||||||
|
|
||||||
|
older = [
|
||||||
|
{"id": 39, "created_at": "2026-09-10 15:18:00", "status": "success", "validity": "superseded",
|
||||||
|
"comparison_type": "non_independent",
|
||||||
|
"models": {"qwen": {"comparison": {"status": "comparable", "score": 48.9},
|
||||||
|
"algorithm_version": "prescription-soft-dice-v1.0.1", "prompt_version": "v3"},
|
||||||
|
"openai": {"comparison": {"status": "comparable", "score": 41.7},
|
||||||
|
"algorithm_version": "prescription-soft-dice-v1.0.1", "prompt_version": "v3"}}},
|
||||||
|
{"id": 38, "created_at": "2026-09-10 14:05:00", "status": "success", "validity": "superseded",
|
||||||
|
"models": {"qwen": {"comparison": {"status": "comparable", "score": 33.4},
|
||||||
|
"algorithm_version": "prescription-soft-dice-v1.0.1", "prompt_version": "v3"},
|
||||||
|
"openai": {"comparison": {"status": "comparable", "score": 31.1},
|
||||||
|
"algorithm_version": "prescription-soft-dice-v1.0.1", "prompt_version": "v3"}}},
|
||||||
|
{"id": 37, "created_at": "2026-09-10 13:25:00", "status": "failed", "validity": "superseded",
|
||||||
|
"models": {"qwen": {"error_message": "模型返回未通过校验",
|
||||||
|
"algorithm_version": "prescription-soft-dice-v1.0.1"},
|
||||||
|
"openai": {"comparison": {"status": "not_comparable"}}}},
|
||||||
|
{"id": 36, "created_at": "2026-09-10 12:34:00", "status": "success", "validity": "superseded",
|
||||||
|
"models": {"qwen": {"comparison": {"status": "not_comparable"},
|
||||||
|
"algorithm_version": "prescription-soft-dice-v1.0.0"},
|
||||||
|
"openai": {"comparison": {"status": "not_comparable"}}}},
|
||||||
|
]
|
||||||
|
return [{**row, "agreement_visible": True} for row in [self.batch, *older]]
|
||||||
|
|
||||||
|
def get_prescription_ai_report(self, _batch_id: int) -> dict[str, Any]:
|
||||||
|
return deepcopy(self.batch)
|
||||||
|
|
||||||
|
def prescription_ai_statistics(self, *_args: Any, **_params: Any) -> dict[str, Any]:
|
||||||
|
return deepcopy(example_statistics())
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
application = QApplication.instance() or QApplication([])
|
||||||
|
apply_theme(application)
|
||||||
|
output = Path(__file__).resolve().parents[1] / "artifacts" / "issued_prescription_redesign"
|
||||||
|
output.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
def immediate(function: Any, **callbacks: Any) -> None:
|
||||||
|
result = function()
|
||||||
|
if callbacks.get("on_success"):
|
||||||
|
callbacks["on_success"](result)
|
||||||
|
if callbacks.get("on_finished"):
|
||||||
|
callbacks["on_finished"]()
|
||||||
|
|
||||||
|
ai.run_async = immediate
|
||||||
|
repository = PreviewRepository()
|
||||||
|
dialog = ai.IssuedPrescriptionAiDialog(repository, ["*"], prescription_id=7556,
|
||||||
|
current_user={"name": "张医生"})
|
||||||
|
dialog.show()
|
||||||
|
names = {dialog.tabs.tabText(index): index for index in range(dialog.tabs.count())}
|
||||||
|
for filename, width, height, tab in (
|
||||||
|
("prescription-1440.png", 1440, 940, "对比总览"),
|
||||||
|
("prescription-1280.png", 1280, 860, "对比总览"),
|
||||||
|
("prescription-1024.png", 1024, 700, "对比总览"),
|
||||||
|
("prescription-940.png", 940, 640, "对比总览"),
|
||||||
|
("original-1280.png", 1280, 860, "原方记录"),
|
||||||
|
("analysis-1280.png", 1280, 860, "完整报告"),
|
||||||
|
("candidate-1280.png", 1280, 860, "方义与用法"),
|
||||||
|
("per-herb-1440.png", 1440, 940, "候选与逐味"),
|
||||||
|
("sources-1440.png", 1440, 940, "资料与缺口"),
|
||||||
|
("history-1440.png", 1440, 940, "历史与趋势"),
|
||||||
|
("pipeline-1440.png", 1440, 940, "处理进度"),
|
||||||
|
):
|
||||||
|
dialog.resize(width, height)
|
||||||
|
dialog.tabs.setCurrentIndex(names[tab])
|
||||||
|
for _ in range(4):
|
||||||
|
application.processEvents()
|
||||||
|
assert (dialog.width(), dialog.height()) == (width, height), (filename, dialog.size())
|
||||||
|
assert dialog.grab().save(str(output / filename))
|
||||||
|
print(filename, "window", width, height, "workspace", dialog.tabs.width(), dialog.tabs.height())
|
||||||
|
dialog.tabs.setCurrentIndex(names["完整报告"])
|
||||||
|
dialog._set_report_mode("differences")
|
||||||
|
application.processEvents()
|
||||||
|
assert dialog.grab().save(str(output / "report-differences-1440.png"))
|
||||||
|
dialog._set_report_mode("both")
|
||||||
|
dialog.tabs.setCurrentIndex(names["对比总览"])
|
||||||
|
dialog.resize(1280, 860)
|
||||||
|
for _ in range(4):
|
||||||
|
application.processEvents()
|
||||||
|
dialog.review_model.setCurrentIndex(1)
|
||||||
|
application.processEvents()
|
||||||
|
assert dialog.grab().save(str(output / "openai-1280.png"))
|
||||||
|
dialog.review_model.setCurrentIndex(0)
|
||||||
|
repository.batch["status"] = "running"
|
||||||
|
repository.batch["models"]["openai"].update(status="running", candidate=None, comparison=None, report=None)
|
||||||
|
dialog.refresh()
|
||||||
|
application.processEvents()
|
||||||
|
assert dialog.grab().save(str(output / "partial-1280.png"))
|
||||||
|
repository.batch.update(validity="superseded", status="success")
|
||||||
|
dialog.refresh()
|
||||||
|
application.processEvents()
|
||||||
|
assert dialog.grab().save(str(output / "historical-1280.png"))
|
||||||
|
repository.batch.update(validity="current", status="running")
|
||||||
|
repository.batch["models"]["qwen"].update(status="running", candidate=None, comparison=None, report=None)
|
||||||
|
dialog.refresh()
|
||||||
|
application.processEvents()
|
||||||
|
assert dialog.grab().save(str(output / "pending-1280.png"))
|
||||||
|
# A failed model must state the reason and offer its own retry on the card itself.
|
||||||
|
repository.batch.update(validity="current", status="partial")
|
||||||
|
repository.batch["models"]["qwen"].update(
|
||||||
|
status="failed", error_code="upstream_timeout", error_message="上游模型超时,未返回结果",
|
||||||
|
candidate=None, comparison=None, report=None, retry_count=1, max_retry=3)
|
||||||
|
repository.batch["models"]["openai"] = deepcopy(example_batch()["models"]["openai"])
|
||||||
|
dialog.refresh()
|
||||||
|
application.processEvents()
|
||||||
|
assert dialog.grab().save(str(output / "failed-1280.png"))
|
||||||
|
# The light theme is the same window with the other palette; capture it once.
|
||||||
|
dialog.resize(1440, 940)
|
||||||
|
dialog._switch_theme()
|
||||||
|
for _ in range(12):
|
||||||
|
application.processEvents()
|
||||||
|
dialog.repaint()
|
||||||
|
assert dialog.grab().save(str(output / "light-1440.png"))
|
||||||
|
dialog._switch_theme()
|
||||||
|
for _ in range(4):
|
||||||
|
application.processEvents()
|
||||||
|
dialog.close()
|
||||||
|
|
||||||
|
statistics = ai.PrescriptionAiStatisticsDialog(repository, ["*"])
|
||||||
|
statistics.resize(1240, 880)
|
||||||
|
statistics.show()
|
||||||
|
for _ in range(4):
|
||||||
|
application.processEvents()
|
||||||
|
assert statistics.grab().save(str(output / "statistics-1240.png"))
|
||||||
|
statistics.close()
|
||||||
|
print("statistics-1240.png", statistics.panel.doctors.rowCount(), "doctors")
|
||||||
|
print(output)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
"""Capture agreement authorization states using synthetic data and no network."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from copy import deepcopy
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
from render_issued_prescription_ai import PreviewRepository
|
||||||
|
|
||||||
|
from doctor_workstation.services import DemoDoctorRepository
|
||||||
|
from doctor_workstation.ui.dialogs import issued_prescription_ai as ai
|
||||||
|
from doctor_workstation.ui.pages import prescriptions
|
||||||
|
from doctor_workstation.ui.theme import apply_theme
|
||||||
|
|
||||||
|
|
||||||
|
class VisibilityPreviewRepository(DemoDoctorRepository, PreviewRepository):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
DemoDoctorRepository.__init__(self)
|
||||||
|
PreviewRepository.__init__(self)
|
||||||
|
self.visible = True
|
||||||
|
|
||||||
|
def get_prescription_ai_capabilities(self) -> dict[str, Any]:
|
||||||
|
return {"agreement_visible": self.visible, "can_manage_visibility": True}
|
||||||
|
|
||||||
|
def list_prescription_ai_statuses(self, ids: list[int]) -> dict[str, Any]:
|
||||||
|
return {"enabled": True, "agreement_visible": self.visible, "items": [
|
||||||
|
{"prescription_id": value, "agreement_visible": self.visible, "status": "success",
|
||||||
|
"models": {"qwen": {"status": "success", "score": 68.5},
|
||||||
|
"openai": {"status": "success", "score": 55.2}}} for value in ids]}
|
||||||
|
|
||||||
|
def list_prescription_ai_reports(self, **_params: Any) -> dict[str, Any]:
|
||||||
|
rows = [{**row, "agreement_visible": self.visible} for row in self.history()]
|
||||||
|
return {"agreement_visible": self.visible, "enabled": True, "lists": deepcopy(rows), "count": len(rows)}
|
||||||
|
|
||||||
|
def get_prescription_ai_report(self, _batch_id: int) -> dict[str, Any]:
|
||||||
|
return {**deepcopy(self.batch), "agreement_visible": self.visible}
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
application = QApplication.instance() or QApplication([])
|
||||||
|
apply_theme(application)
|
||||||
|
output = Path(__file__).resolve().parents[2] / "artifacts" / "prescription-ai-visibility"
|
||||||
|
output.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
def immediate(function: Any, **callbacks: Any) -> None:
|
||||||
|
try:
|
||||||
|
result = function()
|
||||||
|
except Exception as error:
|
||||||
|
if callbacks.get("on_error"):
|
||||||
|
callbacks["on_error"](error)
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
else:
|
||||||
|
if callbacks.get("on_success"):
|
||||||
|
callbacks["on_success"](result)
|
||||||
|
finally:
|
||||||
|
if callbacks.get("on_finished"):
|
||||||
|
callbacks["on_finished"]()
|
||||||
|
|
||||||
|
ai.run_async = immediate
|
||||||
|
prescriptions.run_async = immediate
|
||||||
|
repository = VisibilityPreviewRepository()
|
||||||
|
|
||||||
|
def capture(widget: Any, filename: str) -> None:
|
||||||
|
for _ in range(4):
|
||||||
|
application.processEvents()
|
||||||
|
assert widget.grab().save(str(output / filename))
|
||||||
|
|
||||||
|
page = prescriptions.PrescriptionsPage(repository, ["*"], {"name": "演示医生", "root": 1})
|
||||||
|
page.resize(1366, 800)
|
||||||
|
page.show()
|
||||||
|
capture(page, "list-authorized.png")
|
||||||
|
repository.visible = False
|
||||||
|
page.refresh()
|
||||||
|
capture(page, "list-restricted.png")
|
||||||
|
page.close()
|
||||||
|
|
||||||
|
repository.visible = True
|
||||||
|
dialog = ai.IssuedPrescriptionAiDialog(repository, ["*"], prescription_id=7556,
|
||||||
|
current_user={"name": "演示医生", "root": 1})
|
||||||
|
dialog.resize(1280, 860)
|
||||||
|
dialog.show()
|
||||||
|
capture(dialog, "report-authorized.png")
|
||||||
|
repository.visible = False
|
||||||
|
repository.batch["status"] = "partial"
|
||||||
|
repository.batch["models"]["openai"].update(
|
||||||
|
status="failed", report=None, candidate=None, comparison=None,
|
||||||
|
error_code="upstream_timeout", error_message="演示:模型连接超时,可重试。")
|
||||||
|
dialog.refresh()
|
||||||
|
dialog._goto("report")
|
||||||
|
capture(dialog, "report-restricted.png")
|
||||||
|
dialog._goto("overview")
|
||||||
|
capture(dialog, "review-restricted.png")
|
||||||
|
dialog._goto("per_herb")
|
||||||
|
capture(dialog, "candidate-restricted.png")
|
||||||
|
dialog.close()
|
||||||
|
statistics = ai.PrescriptionAiStatisticsDialog(repository, ["*"])
|
||||||
|
statistics.resize(1180, 860)
|
||||||
|
statistics.show()
|
||||||
|
capture(statistics, "statistics-restricted.png")
|
||||||
|
statistics.close()
|
||||||
|
print(output)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -9,6 +9,7 @@ $FrozenExecutable = Join-Path $ProjectRoot "dist\DoctorWorkstation\DoctorWorksta
|
|||||||
$ProjectPython = Join-Path $ProjectRoot ".venv\Scripts\python.exe"
|
$ProjectPython = Join-Path $ProjectRoot ".venv\Scripts\python.exe"
|
||||||
$ProjectUvCache = Join-Path $ProjectRoot ".uv-cache"
|
$ProjectUvCache = Join-Path $ProjectRoot ".uv-cache"
|
||||||
$SourceRoot = Join-Path $ProjectRoot "src"
|
$SourceRoot = Join-Path $ProjectRoot "src"
|
||||||
|
$SourceEntry = Join-Path $SourceRoot "doctor_workstation\__main__.py"
|
||||||
|
|
||||||
function Test-ProjectPython {
|
function Test-ProjectPython {
|
||||||
param([Parameter(Mandatory = $true)][string]$Candidate)
|
param([Parameter(Mandatory = $true)][string]$Candidate)
|
||||||
@@ -33,7 +34,13 @@ function Find-Uv {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (Test-Path -LiteralPath $FrozenExecutable -PathType Leaf) {
|
$Python = $null
|
||||||
|
if ((Test-Path -LiteralPath $SourceEntry -PathType Leaf) -and
|
||||||
|
(Test-ProjectPython -Candidate $ProjectPython)) {
|
||||||
|
$Python = $ProjectPython
|
||||||
|
Write-Host "Using current source tree: $SourceRoot"
|
||||||
|
}
|
||||||
|
elseif (Test-Path -LiteralPath $FrozenExecutable -PathType Leaf) {
|
||||||
Write-Host "Using packaged application: $FrozenExecutable"
|
Write-Host "Using packaged application: $FrozenExecutable"
|
||||||
if ($ValidateOnly) {
|
if ($ValidateOnly) {
|
||||||
Write-Host "Windows run entry validation passed."
|
Write-Host "Windows run entry validation passed."
|
||||||
@@ -47,13 +54,7 @@ try {
|
|||||||
}
|
}
|
||||||
exit $ApplicationExitCode
|
exit $ApplicationExitCode
|
||||||
}
|
}
|
||||||
|
elseif (Test-Path -LiteralPath $SourceEntry -PathType Leaf) {
|
||||||
$Python = $null
|
|
||||||
if (Test-ProjectPython -Candidate $ProjectPython) {
|
|
||||||
$Python = $ProjectPython
|
|
||||||
Write-Host "Using existing project environment: $ProjectPython"
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
$Uv = Find-Uv
|
$Uv = Find-Uv
|
||||||
if (-not $Uv) {
|
if (-not $Uv) {
|
||||||
throw "No packaged application or usable .venv was found, and uv is not installed."
|
throw "No packaged application or usable .venv was found, and uv is not installed."
|
||||||
@@ -108,6 +109,9 @@ try {
|
|||||||
}
|
}
|
||||||
$Python = $ProjectPython
|
$Python = $ProjectPython
|
||||||
}
|
}
|
||||||
|
else {
|
||||||
|
throw "No source entry or packaged application was found."
|
||||||
|
}
|
||||||
|
|
||||||
if ($ValidateOnly) {
|
if ($ValidateOnly) {
|
||||||
Write-Host "Windows run entry validation passed."
|
Write-Host "Windows run entry validation passed."
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
__all__ = ["DEBUG_MODE", "ONLINE_API_BASE_URL", "__version__"]
|
__all__ = ["DEBUG_MODE", "ONLINE_API_BASE_URL", "__version__"]
|
||||||
|
|
||||||
# Single source of truth for runtime, package, installer, and executable versions.
|
# Single source of truth for runtime, package, installer, and executable versions.
|
||||||
__version__ = "1.4.2"
|
__version__ = "1.4.5"
|
||||||
|
|
||||||
# 调试模式开启时,登录页显示“演示模式”和“服务器设置”。
|
# 调试模式开启时,登录页显示“演示模式”和“服务器设置”。
|
||||||
# 正式发布请保持 False;此时程序只使用下面配置的线上域名。
|
# 正式发布请保持 False;此时程序只使用下面配置的线上域名。
|
||||||
DEBUG_MODE = True
|
DEBUG_MODE = False
|
||||||
ONLINE_API_BASE_URL = "https://admin.zhenyangtang.com.cn"
|
ONLINE_API_BASE_URL = "https://admin.zhenyangtang.com.cn"
|
||||||
|
|||||||
@@ -1004,6 +1004,8 @@ class ApplicationController(QObject):
|
|||||||
preview_height = min(380, max(320, round(preview_width * 0.66)))
|
preview_height = min(380, max(320, round(preview_width * 0.66)))
|
||||||
margin = 18
|
margin = 18
|
||||||
|
|
||||||
|
if callable(getattr(video_window, "setProperty", None)):
|
||||||
|
video_window.setProperty("doctorSkipOpenGeometry", True)
|
||||||
video_window.showNormal()
|
video_window.showNormal()
|
||||||
video_window.setMinimumSize(440, 300)
|
video_window.setMinimumSize(440, 300)
|
||||||
video_window.setWindowFlag(Qt.WindowType.WindowStaysOnTopHint, True)
|
video_window.setWindowFlag(Qt.WindowType.WindowStaysOnTopHint, True)
|
||||||
@@ -1058,6 +1060,8 @@ class ApplicationController(QObject):
|
|||||||
window.raise_()
|
window.raise_()
|
||||||
if activate:
|
if activate:
|
||||||
window.activateWindow()
|
window.activateWindow()
|
||||||
|
if callable(getattr(window, "setProperty", None)):
|
||||||
|
window.setProperty("doctorSkipOpenGeometry", False)
|
||||||
except (AttributeError, RuntimeError):
|
except (AttributeError, RuntimeError):
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ DEMO_PERMISSIONS: tuple[str, ...] = (
|
|||||||
"tcm.diagnosis/editAiReport",
|
"tcm.diagnosis/editAiReport",
|
||||||
"tcm.diagnosis/aiAnalysis",
|
"tcm.diagnosis/aiAnalysis",
|
||||||
"tcm.diagnosis/aiAssistant",
|
"tcm.diagnosis/aiAssistant",
|
||||||
|
"tcm.diagnosis/aiGeneratePrescription",
|
||||||
"tcm.diagnosis/patientAiReports",
|
"tcm.diagnosis/patientAiReports",
|
||||||
"tcm.diagnosis/generatePatientAiReport",
|
"tcm.diagnosis/generatePatientAiReport",
|
||||||
"tcm.diagnosis/dailyRecord",
|
"tcm.diagnosis/dailyRecord",
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ from doctor_workstation.core.errors import (
|
|||||||
ApiBusinessError,
|
ApiBusinessError,
|
||||||
ApiHttpError,
|
ApiHttpError,
|
||||||
ApiProtocolError,
|
ApiProtocolError,
|
||||||
ApiTimeoutError,
|
|
||||||
ApiTransportError,
|
ApiTransportError,
|
||||||
AuthenticationExpiredError,
|
AuthenticationExpiredError,
|
||||||
)
|
)
|
||||||
@@ -236,6 +235,9 @@ class DoctorRepository(Protocol):
|
|||||||
def get_prescription(self, prescription_id: int) -> Prescription:
|
def get_prescription(self, prescription_id: int) -> Prescription:
|
||||||
"""Return one issued prescription."""
|
"""Return one issued prescription."""
|
||||||
|
|
||||||
|
def get_prescription_ai_capabilities(self) -> dict[str, Any]:
|
||||||
|
"""Read the server's current agreement visibility decision."""
|
||||||
|
|
||||||
def list_prescription_ai_statuses(self, ids: list[int]) -> dict[str, Any]:
|
def list_prescription_ai_statuses(self, ids: list[int]) -> dict[str, Any]:
|
||||||
"""Read cached dual-model states for up to 100 prescriptions."""
|
"""Read cached dual-model states for up to 100 prescriptions."""
|
||||||
|
|
||||||
@@ -1413,11 +1415,11 @@ class RemoteDoctorRepository:
|
|||||||
task: str = "custom",
|
task: str = "custom",
|
||||||
cancelled: Callable[[], bool] | None = None,
|
cancelled: Callable[[], bool] | None = None,
|
||||||
) -> Iterator[dict[str, Any]]:
|
) -> Iterator[dict[str, Any]]:
|
||||||
"""Stream a diagnosis answer, with one legacy fallback before first content."""
|
"""Stream a diagnosis answer; use the legacy route only when streaming is absent."""
|
||||||
|
|
||||||
clean_prompt, clean_task = _diagnosis_ai_request(diagnosis_id, prompt, task)
|
clean_prompt, clean_task = _diagnosis_ai_request(diagnosis_id, prompt, task)
|
||||||
body = {"id": diagnosis_id, "prompt": clean_prompt, "task": clean_task}
|
body = {"id": diagnosis_id, "prompt": clean_prompt, "task": clean_task}
|
||||||
received_delta = False
|
received_event = False
|
||||||
received_done = False
|
received_done = False
|
||||||
try:
|
try:
|
||||||
for raw_event in self.client.post_event_stream(
|
for raw_event in self.client.post_event_stream(
|
||||||
@@ -1428,13 +1430,12 @@ class RemoteDoctorRepository:
|
|||||||
):
|
):
|
||||||
if cancelled is not None and cancelled():
|
if cancelled is not None and cancelled():
|
||||||
return
|
return
|
||||||
|
received_event = True
|
||||||
event = _normalise_diagnosis_ai_event(raw_event)
|
event = _normalise_diagnosis_ai_event(raw_event)
|
||||||
if event is None:
|
if event is None:
|
||||||
continue
|
continue
|
||||||
kind = event["event"]
|
kind = event["event"]
|
||||||
if kind == "delta":
|
if kind == "done":
|
||||||
received_delta = True
|
|
||||||
elif kind == "done":
|
|
||||||
received_done = True
|
received_done = True
|
||||||
yield event
|
yield event
|
||||||
if kind == "done":
|
if kind == "done":
|
||||||
@@ -1443,12 +1444,16 @@ class RemoteDoctorRepository:
|
|||||||
return
|
return
|
||||||
if not received_done:
|
if not received_done:
|
||||||
raise ApiProtocolError("AI assistant stream ended before done")
|
raise ApiProtocolError("AI assistant stream ended before done")
|
||||||
except (ApiHttpError, ApiProtocolError, ApiTimeoutError, ApiTransportError):
|
except ApiHttpError as error:
|
||||||
if received_delta or (cancelled is not None and cancelled()):
|
if (
|
||||||
|
error.status_code not in {404, 405}
|
||||||
|
or received_event
|
||||||
|
or (cancelled is not None and cancelled())
|
||||||
|
):
|
||||||
raise
|
raise
|
||||||
|
|
||||||
# Older deployments do not expose the stream route. Submit exactly one
|
# Only a missing stream route on an older deployment justifies another
|
||||||
# request through the confirmed non-streaming endpoint in that case.
|
# full request. Timeouts, transport failures and malformed streams do not.
|
||||||
result = self.analyze_diagnosis_ai(
|
result = self.analyze_diagnosis_ai(
|
||||||
diagnosis_id,
|
diagnosis_id,
|
||||||
clean_prompt,
|
clean_prompt,
|
||||||
@@ -1609,6 +1614,9 @@ class RemoteDoctorRepository:
|
|||||||
payload = _client_request(self.client, "post" if mutation else "get", endpoint, params, timeout=30.0)
|
payload = _client_request(self.client, "post" if mutation else "get", endpoint, params, timeout=30.0)
|
||||||
return dict(_require_mapping(payload, endpoint))
|
return dict(_require_mapping(payload, endpoint))
|
||||||
|
|
||||||
|
def get_prescription_ai_capabilities(self) -> dict[str, Any]:
|
||||||
|
return self._prescription_ai_request("capabilities", {})
|
||||||
|
|
||||||
def list_prescription_ai_statuses(self, ids: list[int]) -> dict[str, Any]:
|
def list_prescription_ai_statuses(self, ids: list[int]) -> dict[str, Any]:
|
||||||
if len(ids) > 100 or any(int(value) <= 0 for value in ids):
|
if len(ids) > 100 or any(int(value) <= 0 for value in ids):
|
||||||
raise ValueError("statuses requires at most 100 positive prescription IDs")
|
raise ValueError("statuses requires at most 100 positive prescription IDs")
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ from PySide6.QtWidgets import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from ..core.appointment_modes import APPOINTMENT_MODES
|
from ..core.appointment_modes import APPOINTMENT_MODES
|
||||||
|
from .theme import main_window_target_rect
|
||||||
from .widgets import (
|
from .widgets import (
|
||||||
display_text,
|
display_text,
|
||||||
first_value,
|
first_value,
|
||||||
@@ -1766,6 +1767,11 @@ class AppointmentDrawer(QDialog):
|
|||||||
super().mousePressEvent(event)
|
super().mousePressEvent(event)
|
||||||
|
|
||||||
def _sync_to_owner(self) -> None:
|
def _sync_to_owner(self) -> None:
|
||||||
|
target = main_window_target_rect(self)
|
||||||
|
if target is not None:
|
||||||
|
self.setGeometry(target)
|
||||||
|
self._update_drawer_width()
|
||||||
|
return
|
||||||
if self._owner is None:
|
if self._owner is None:
|
||||||
self._update_drawer_width()
|
self._update_drawer_width()
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ admin-table presentation cannot leak into another workstation page.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Iterable, Mapping, Sequence
|
from collections.abc import Callable, Iterable, Mapping, Sequence
|
||||||
from math import ceil
|
from math import ceil
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -75,12 +75,9 @@ TEXT = QColor("#1A1C1F")
|
|||||||
SECONDARY = QColor("#606163")
|
SECONDARY = QColor("#606163")
|
||||||
PLACEHOLDER = QColor("#8E8F90")
|
PLACEHOLDER = QColor("#8E8F90")
|
||||||
_INVALID_INDEX = QModelIndex()
|
_INVALID_INDEX = QModelIndex()
|
||||||
# The last two columns are the frozen right-hand pane. They were 120 + 410,
|
# The last two columns form the compact frozen pane. Video status and ending
|
||||||
# which pinned 532 px of the table. Measured against what they actually hold:
|
# time occupy separate lines; actions keep two inline links plus 更多, with
|
||||||
# the widest video cell is "暂无通话" at 56 px, and the action cell - two inline
|
# the remaining actions in its menu so clinical columns retain more space.
|
||||||
# buttons plus 更多, all two-character labels - reports a 145 px sizeHint. So
|
|
||||||
# 265 px of the action column was permanently blank on every row while the data
|
|
||||||
# columns to its left were horizontally scrolling.
|
|
||||||
_TABLE_COLUMN_WIDTHS = (48, 70, 60, 100, 175, 88, 120, 100, 72, 110, 96, 160)
|
_TABLE_COLUMN_WIDTHS = (48, 70, 60, 100, 175, 88, 120, 100, 72, 110, 96, 160)
|
||||||
_BLUE_COLUMN_WIDTHS = (48, 70, 82, 102, 244, 90, 84, 90, 88, 122, 92, 158)
|
_BLUE_COLUMN_WIDTHS = (48, 70, 82, 102, 244, 90, 84, 90, 88, 122, 92, 158)
|
||||||
_BLUE_WARNING = QColor("#A9691D")
|
_BLUE_WARNING = QColor("#A9691D")
|
||||||
@@ -1609,6 +1606,7 @@ class DiagnosisTableHost(QFrame):
|
|||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
action_policy: Mapping[str, bool] | None = None,
|
action_policy: Mapping[str, bool] | None = None,
|
||||||
|
video_entry_allowed: Callable[[Any], bool] | None = None,
|
||||||
parent: QWidget | None = None,
|
parent: QWidget | None = None,
|
||||||
tech_blue: bool = False,
|
tech_blue: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -1618,6 +1616,7 @@ class DiagnosisTableHost(QFrame):
|
|||||||
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||||||
self.setMinimumHeight(0)
|
self.setMinimumHeight(0)
|
||||||
self.action_policy = dict(action_policy or {})
|
self.action_policy = dict(action_policy or {})
|
||||||
|
self.video_entry_allowed = video_entry_allowed
|
||||||
self.force_open_prescription = False
|
self.force_open_prescription = False
|
||||||
if tech_blue:
|
if tech_blue:
|
||||||
self.LEFT_WIDTHS = _BLUE_COLUMN_WIDTHS[:10]
|
self.LEFT_WIDTHS = _BLUE_COLUMN_WIDTHS[:10]
|
||||||
@@ -1633,6 +1632,9 @@ class DiagnosisTableHost(QFrame):
|
|||||||
self._fixed_height_timer.timeout.connect(self._sync_fixed_height)
|
self._fixed_height_timer.timeout.connect(self._sync_fixed_height)
|
||||||
self.main.setObjectName("DiagnosisMainTable")
|
self.main.setObjectName("DiagnosisMainTable")
|
||||||
self.fixed.setObjectName("DiagnosisFixedTable")
|
self.fixed.setObjectName("DiagnosisFixedTable")
|
||||||
|
# Index widgets supply their own margins. Inherited item padding would
|
||||||
|
# shrink a 92px video cell to 76px and clip its wrapped status text.
|
||||||
|
self.fixed.setStyleSheet("QTableView#DiagnosisFixedTable::item { padding: 0; }")
|
||||||
for view in (self.main, self.fixed):
|
for view in (self.main, self.fixed):
|
||||||
view.setModel(self.model)
|
view.setModel(self.model)
|
||||||
view.setSelectionModel(self.selection)
|
view.setSelectionModel(self.selection)
|
||||||
@@ -1786,11 +1788,11 @@ class DiagnosisTableHost(QFrame):
|
|||||||
host.hovered_row.connect(self._set_hover_row)
|
host.hovered_row.connect(self._set_hover_row)
|
||||||
layout = QHBoxLayout(host)
|
layout = QHBoxLayout(host)
|
||||||
layout.setContentsMargins(3, 2, 3, 2)
|
layout.setContentsMargins(3, 2, 3, 2)
|
||||||
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
||||||
video_capable = self.action_policy.get("video_call", False)
|
video_capable = self.action_policy.get("video_call", False)
|
||||||
|
entry_allowed = self.video_entry_allowed is None or self.video_entry_allowed(record)
|
||||||
call_state = video_call_state(record)
|
call_state = video_call_state(record)
|
||||||
is_text = appointment_type_value(record) == "text"
|
is_text = appointment_type_value(record) == "text"
|
||||||
if video_capable and _appointment_active(record) and (is_text or call_state == "live"):
|
if video_capable and entry_allowed and _appointment_active(record) and (is_text or call_state == "live"):
|
||||||
button = QToolButton(host)
|
button = QToolButton(host)
|
||||||
button.setText("图文沟通" if is_text else "进入视频问诊")
|
button.setText("图文沟通" if is_text else "进入视频问诊")
|
||||||
button.setProperty("rowLink", "primary")
|
button.setProperty("rowLink", "primary")
|
||||||
@@ -1803,10 +1805,17 @@ class DiagnosisTableHost(QFrame):
|
|||||||
button.clicked.connect(
|
button.clicked.connect(
|
||||||
lambda _checked=False, item=record: self.video_requested.emit(item)
|
lambda _checked=False, item=record: self.video_requested.emit(item)
|
||||||
)
|
)
|
||||||
layout.addWidget(button)
|
layout.addWidget(button, 0, Qt.AlignmentFlag.AlignCenter)
|
||||||
else:
|
else:
|
||||||
status = "—"
|
status = "—"
|
||||||
if video_capable:
|
if not entry_allowed:
|
||||||
|
# Match the admin's public status without turning an active
|
||||||
|
# doctor's room into a receiver entry for an unrelated user.
|
||||||
|
if _appointment_active(record) and can_appointment_video(appointment_type_value(record)):
|
||||||
|
status = {"live": "通话中", "pending_room": "接通中", "none": "—"}.get(
|
||||||
|
call_state, first_value(record, "video_call_hint.label", default="—")
|
||||||
|
)
|
||||||
|
elif video_capable:
|
||||||
if call_state == "pending_room":
|
if call_state == "pending_room":
|
||||||
status = "等待接通"
|
status = "等待接通"
|
||||||
elif _appointment_active(record):
|
elif _appointment_active(record):
|
||||||
@@ -1816,10 +1825,17 @@ class DiagnosisTableHost(QFrame):
|
|||||||
_appointment_status(record),
|
_appointment_status(record),
|
||||||
"未挂号" if not _has_appointment(record) else "—",
|
"未挂号" if not _has_appointment(record) else "—",
|
||||||
)
|
)
|
||||||
label = QLabel(display_text(status), host)
|
status_text = display_text(status)
|
||||||
|
label = QLabel(status_text.replace(" · ", "\n", 1), host)
|
||||||
label.setProperty("fixedMuted", True)
|
label.setProperty("fixedMuted", True)
|
||||||
|
label.setTextFormat(Qt.TextFormat.PlainText)
|
||||||
|
label.setToolTip(status_text)
|
||||||
label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
label.setWordWrap(True)
|
label.setWordWrap(True)
|
||||||
|
# Fill the cell instead of centering the label at its sizeHint:
|
||||||
|
# its wrapped height can grow when the available width changes.
|
||||||
|
label.setMinimumWidth(0)
|
||||||
|
label.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Expanding)
|
||||||
layout.addWidget(label)
|
layout.addWidget(label)
|
||||||
return host
|
return host
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from copy import deepcopy
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from html import escape
|
from html import escape
|
||||||
from threading import Event
|
from threading import Event
|
||||||
|
from time import monotonic
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from PySide6.QtCore import QObject, QRunnable, QSize, Qt, QThreadPool, QTimer, Signal, Slot
|
from PySide6.QtCore import QObject, QRunnable, QSize, Qt, QThreadPool, QTimer, Signal, Slot
|
||||||
@@ -54,6 +55,7 @@ from ..diagnosis_terms import (
|
|||||||
TermIndex,
|
TermIndex,
|
||||||
shared_terms,
|
shared_terms,
|
||||||
)
|
)
|
||||||
|
from ..report_provenance import report_provenance_lines
|
||||||
from ..theme import apply_reading_rhythm, mark_business_dialog
|
from ..theme import apply_reading_rhythm, mark_business_dialog
|
||||||
from ..widgets import (
|
from ..widgets import (
|
||||||
display_text,
|
display_text,
|
||||||
@@ -176,8 +178,8 @@ QDialog#AiConsultDialog QTabBar#AiConsultTabs {
|
|||||||
}
|
}
|
||||||
QDialog#AiConsultDialog QTabBar#AiConsultTabs::tab {
|
QDialog#AiConsultDialog QTabBar#AiConsultTabs::tab {
|
||||||
min-height: 36px;
|
min-height: 36px;
|
||||||
padding: 0 16px;
|
padding: 0 7px;
|
||||||
margin-right: 4px;
|
margin-right: 0;
|
||||||
color: #606163;
|
color: #606163;
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
border: 0;
|
border: 0;
|
||||||
@@ -189,6 +191,18 @@ QDialog#AiConsultDialog QTabBar#AiConsultTabs::tab:selected {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
border-bottom: 2px solid #4F63D9;
|
border-bottom: 2px solid #4F63D9;
|
||||||
}
|
}
|
||||||
|
QDialog#AiConsultDialog QPushButton#AiConsultChatFilter {
|
||||||
|
min-height: 30px;
|
||||||
|
padding: 0 12px;
|
||||||
|
color: #4F63D9;
|
||||||
|
background-color: #FFFFFF;
|
||||||
|
border: 1px solid #E1E5F4;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
QDialog#AiConsultDialog QPushButton#AiConsultChatFilter:hover {
|
||||||
|
background-color: #EEF1FA;
|
||||||
|
}
|
||||||
QDialog#AiConsultDialog QFrame#AiConsultChatPane,
|
QDialog#AiConsultDialog QFrame#AiConsultChatPane,
|
||||||
QDialog#AiConsultDialog QFrame#AiConsultSide,
|
QDialog#AiConsultDialog QFrame#AiConsultSide,
|
||||||
QDialog#AiConsultDialog QFrame#AiConsultRecordPane {
|
QDialog#AiConsultDialog QFrame#AiConsultRecordPane {
|
||||||
@@ -483,6 +497,22 @@ QDialog#AiConsultDialog QLabel#AiConsultClinicalDisclaimer {
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
|
QDialog#AiConsultDialog QFrame#AiConsultReportProvenance {
|
||||||
|
background-color: #F7F8FC;
|
||||||
|
border: 1px solid #E4E8F2;
|
||||||
|
border-radius: 9px;
|
||||||
|
}
|
||||||
|
QDialog#AiConsultDialog QLabel#AiConsultReportProvenanceTitle {
|
||||||
|
color: #27336B;
|
||||||
|
background-color: transparent;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
QDialog#AiConsultDialog QLabel#AiConsultReportProvenanceLine {
|
||||||
|
color: #555B68;
|
||||||
|
background-color: transparent;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
QDialog#AiConsultDialog QFrame#AiConsultEventCard {
|
QDialog#AiConsultDialog QFrame#AiConsultEventCard {
|
||||||
background-color: #F7F7F7;
|
background-color: #F7F7F7;
|
||||||
border: 1px solid #EDEDEE;
|
border: 1px solid #EDEDEE;
|
||||||
@@ -637,6 +667,13 @@ QDialog#AiConsultDialog QLabel#AiConsultTag[level="warn"] {
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
QDialog#AiConsultDialog QLabel#AiConsultTag[level="unknown"] {
|
||||||
|
color: #6A6B6D;
|
||||||
|
background-color: #F0F0F0;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
QDialog#AiConsultDialog QPushButton#AiConsultChipButton {
|
QDialog#AiConsultDialog QPushButton#AiConsultChipButton {
|
||||||
min-height: 28px;
|
min-height: 28px;
|
||||||
max-height: 30px;
|
max-height: 30px;
|
||||||
@@ -854,6 +891,11 @@ QUICK_PROMPTS: tuple[tuple[str, str], ...] = (
|
|||||||
("并发症风险评估", "请评估并发症风险并给出随访要点。"),
|
("并发症风险评估", "请评估并发症风险并给出随访要点。"),
|
||||||
("生成问诊问题", "请生成接下来应向患者确认的关键问诊问题。"),
|
("生成问诊问题", "请生成接下来应向患者确认的关键问诊问题。"),
|
||||||
)
|
)
|
||||||
|
AI_PRESCRIPTION_PERMISSION = "tcm.diagnosis/aiGeneratePrescription"
|
||||||
|
PRESCRIPTION_CREATE_PERMISSIONS = (
|
||||||
|
"tcm.diagnosis/chufang",
|
||||||
|
"tcm.diagnosis/kaifang",
|
||||||
|
)
|
||||||
TOOL_ITEMS: tuple[tuple[str, str, str], ...] = (
|
TOOL_ITEMS: tuple[tuple[str, str, str], ...] = (
|
||||||
("chart", "分析病历", "请根据当前病历给出辨证与病情摘要。"),
|
("chart", "分析病历", "请根据当前病历给出辨证与病情摘要。"),
|
||||||
("trend", "血糖趋势", "请评估该患者的血糖控制情况与趋势。"),
|
("trend", "血糖趋势", "请评估该患者的血糖控制情况与趋势。"),
|
||||||
@@ -2104,6 +2146,26 @@ def parse_structured_report(raw: Any) -> tuple[str, tuple[_ReportSection, ...],
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _report_provenance_widget(raw: Any, parent: QWidget) -> QFrame:
|
||||||
|
card = QFrame(parent)
|
||||||
|
card.setObjectName("AiConsultReportProvenance")
|
||||||
|
_style_surface(card)
|
||||||
|
layout = QVBoxLayout(card)
|
||||||
|
layout.setContentsMargins(12, 10, 12, 11)
|
||||||
|
layout.setSpacing(4)
|
||||||
|
title = QLabel("报告出处", card)
|
||||||
|
title.setObjectName("AiConsultReportProvenanceTitle")
|
||||||
|
layout.addWidget(title)
|
||||||
|
for line in report_provenance_lines(raw):
|
||||||
|
label = QLabel(line, card)
|
||||||
|
label.setObjectName("AiConsultReportProvenanceLine")
|
||||||
|
label.setWordWrap(True)
|
||||||
|
label.setTextFormat(Qt.TextFormat.PlainText)
|
||||||
|
label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
|
||||||
|
layout.addWidget(label)
|
||||||
|
return card
|
||||||
|
|
||||||
|
|
||||||
_InsightItem = namedtuple("_InsightItem", "kind marker label text")
|
_InsightItem = namedtuple("_InsightItem", "kind marker label text")
|
||||||
|
|
||||||
_INSIGHT_LIST_RE = re.compile(r"^\s*(\d{1,2})\s*([\.、))]\s*)")
|
_INSIGHT_LIST_RE = re.compile(r"^\s*(\d{1,2})\s*([\.、))]\s*)")
|
||||||
@@ -2277,8 +2339,14 @@ class _RichMessage(QTextBrowser):
|
|||||||
self.document().contentsChanged.connect(self._fit)
|
self.document().contentsChanged.connect(self._fit)
|
||||||
|
|
||||||
def set_payload(self, raw: Any) -> None:
|
def set_payload(self, raw: Any) -> None:
|
||||||
render_chat_payload(self, raw, role=self._role)
|
# Markdown parsing and block formatting both emit contentsChanged. Fit
|
||||||
QTimer.singleShot(0, self._fit)
|
# once after the complete update instead of laying out each mutation.
|
||||||
|
self._fitting = True
|
||||||
|
try:
|
||||||
|
render_chat_payload(self, raw, role=self._role)
|
||||||
|
finally:
|
||||||
|
self._fitting = False
|
||||||
|
self._fit()
|
||||||
|
|
||||||
def resizeEvent(self, event) -> None: # type: ignore[override]
|
def resizeEvent(self, event) -> None: # type: ignore[override]
|
||||||
super().resizeEvent(event)
|
super().resizeEvent(event)
|
||||||
@@ -2409,6 +2477,7 @@ class _StructuredReportPanel(QWidget):
|
|||||||
disclaimer: str,
|
disclaimer: str,
|
||||||
*,
|
*,
|
||||||
time_text: str = "",
|
time_text: str = "",
|
||||||
|
provenance: Any = None,
|
||||||
parent: QWidget | None = None,
|
parent: QWidget | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
@@ -2455,6 +2524,7 @@ class _StructuredReportPanel(QWidget):
|
|||||||
note = _FittedText(_reading_html(disclaimer), parent=self)
|
note = _FittedText(_reading_html(disclaimer), parent=self)
|
||||||
note.setObjectName("AiConsultReportDisclaimer")
|
note.setObjectName("AiConsultReportDisclaimer")
|
||||||
root.addWidget(note)
|
root.addWidget(note)
|
||||||
|
root.addWidget(_report_provenance_widget(provenance, self))
|
||||||
# 富余高度归到底部,否则会被平摊进每张卡片,把报告拉得空空荡荡。
|
# 富余高度归到底部,否则会被平摊进每张卡片,把报告拉得空空荡荡。
|
||||||
root.addStretch(1)
|
root.addStretch(1)
|
||||||
|
|
||||||
@@ -2528,6 +2598,7 @@ class _ClinicalAnalysisPanel(QWidget):
|
|||||||
model: _ClinicalAnalysisModel,
|
model: _ClinicalAnalysisModel,
|
||||||
*,
|
*,
|
||||||
time_text: str = "",
|
time_text: str = "",
|
||||||
|
provenance: Any = None,
|
||||||
parent: QWidget | None = None,
|
parent: QWidget | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
@@ -2621,6 +2692,7 @@ class _ClinicalAnalysisPanel(QWidget):
|
|||||||
disclaimer.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
|
disclaimer.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
|
||||||
_style_surface(disclaimer)
|
_style_surface(disclaimer)
|
||||||
root.addWidget(disclaimer)
|
root.addWidget(disclaimer)
|
||||||
|
root.addWidget(_report_provenance_widget(provenance, self))
|
||||||
QTimer.singleShot(0, self._apply_responsive_layout)
|
QTimer.singleShot(0, self._apply_responsive_layout)
|
||||||
|
|
||||||
def set_time_text(self, time_text: str) -> None:
|
def set_time_text(self, time_text: str) -> None:
|
||||||
@@ -2844,6 +2916,7 @@ class _ChatBubble(QWidget):
|
|||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self._role = role
|
self._role = role
|
||||||
self._raw_payload = text
|
self._raw_payload = text
|
||||||
|
self._provenance: Mapping[str, Any] = {}
|
||||||
self._clinical_panel: _ClinicalAnalysisPanel | None = None
|
self._clinical_panel: _ClinicalAnalysisPanel | None = None
|
||||||
row = QHBoxLayout(self)
|
row = QHBoxLayout(self)
|
||||||
row.setContentsMargins(12, 7, 12, 7)
|
row.setContentsMargins(12, 7, 12, 7)
|
||||||
@@ -2914,6 +2987,9 @@ class _ChatBubble(QWidget):
|
|||||||
if self.body is not None:
|
if self.body is not None:
|
||||||
self.body.set_payload(text)
|
self.body.set_payload(text)
|
||||||
|
|
||||||
|
def set_provenance(self, value: Any) -> None:
|
||||||
|
self._provenance = dict(value) if isinstance(value, Mapping) else {}
|
||||||
|
|
||||||
def set_time_text(self, text: str) -> None:
|
def set_time_text(self, text: str) -> None:
|
||||||
if not text:
|
if not text:
|
||||||
return
|
return
|
||||||
@@ -2934,6 +3010,7 @@ class _ChatBubble(QWidget):
|
|||||||
panel = _ClinicalAnalysisPanel(
|
panel = _ClinicalAnalysisPanel(
|
||||||
model,
|
model,
|
||||||
time_text=self.stamp.text(),
|
time_text=self.stamp.text(),
|
||||||
|
provenance=self._provenance,
|
||||||
parent=self._bubble_frame,
|
parent=self._bubble_frame,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
@@ -2944,6 +3021,7 @@ class _ChatBubble(QWidget):
|
|||||||
panel = _StructuredReportPanel(
|
panel = _StructuredReportPanel(
|
||||||
*report,
|
*report,
|
||||||
time_text=self.stamp.text(),
|
time_text=self.stamp.text(),
|
||||||
|
provenance=self._provenance,
|
||||||
parent=self._bubble_frame,
|
parent=self._bubble_frame,
|
||||||
)
|
)
|
||||||
if panel is None:
|
if panel is None:
|
||||||
@@ -3075,6 +3153,8 @@ class _AiStreamWorker(QRunnable):
|
|||||||
@Slot()
|
@Slot()
|
||||||
def run(self) -> None:
|
def run(self) -> None:
|
||||||
try:
|
try:
|
||||||
|
if self.is_cancelled():
|
||||||
|
return
|
||||||
stream = getattr(self.repository, "stream_diagnosis_ai", None)
|
stream = getattr(self.repository, "stream_diagnosis_ai", None)
|
||||||
if callable(stream):
|
if callable(stream):
|
||||||
events = stream(
|
events = stream(
|
||||||
@@ -3098,10 +3178,14 @@ class _AiStreamWorker(QRunnable):
|
|||||||
{"event": "delta", "text": answer, "fallback": True},
|
{"event": "delta", "text": answer, "fallback": True},
|
||||||
{**payload, "event": "done", "fallback": True},
|
{**payload, "event": "done", "fallback": True},
|
||||||
)
|
)
|
||||||
|
if self.is_cancelled():
|
||||||
|
return
|
||||||
for event in events:
|
for event in events:
|
||||||
if self.is_cancelled():
|
if self.is_cancelled():
|
||||||
return
|
return
|
||||||
self.signals.event.emit(event)
|
self.signals.event.emit(event)
|
||||||
|
if isinstance(event, Mapping) and event.get("event") == "done":
|
||||||
|
return
|
||||||
except Exception as error: # UI boundary: render transport/domain errors in place.
|
except Exception as error: # UI boundary: render transport/domain errors in place.
|
||||||
if not self.is_cancelled():
|
if not self.is_cancelled():
|
||||||
self.signals.error.emit(error)
|
self.signals.error.emit(error)
|
||||||
@@ -3136,13 +3220,18 @@ class _ClickCard(QFrame):
|
|||||||
AI_CONTEXT_MAX_CHARS = 320
|
AI_CONTEXT_MAX_CHARS = 320
|
||||||
AI_PROMPT_LIMIT = 500
|
AI_PROMPT_LIMIT = 500
|
||||||
|
|
||||||
#: 首个增量到达前显示的占位文案,避免留下一块没有任何说明的空白气泡。
|
#: 等待服务端整理诊单资料时显示的占位文案。
|
||||||
AI_STREAM_PENDING_TEXT = "正在生成…"
|
AI_STREAM_PENDING_TEXT = "正在整理诊单资料…"
|
||||||
|
|
||||||
|
#: 服务端确认资料已准备好、尚未收到回答时显示的占位文案。
|
||||||
|
AI_STREAM_WAITING_TEXT = "资料已整理,正在等待 AI 回复…"
|
||||||
|
|
||||||
#: 连接结束但一个字都没收到时的兜底文案。
|
#: 连接结束但一个字都没收到时的兜底文案。
|
||||||
AI_STREAM_SILENT_TEXT = (
|
AI_STREAM_SILENT_TEXT = (
|
||||||
"AI 助手没有返回内容,可能是服务端未响应或连接中断,请稍后重试。"
|
"AI 助手没有返回内容,可能是服务端未响应或连接中断,请稍后重试。"
|
||||||
)
|
)
|
||||||
|
AI_STREAM_TOTAL_TIMEOUT_MS = 180_000
|
||||||
|
AI_STREAM_TIMEOUT_TEXT = "等待 AI 回复超时,已停止本次等待,请稍后重试。"
|
||||||
AI_CONTEXT_SEPARATOR = "\n\n— 医生提问 —\n"
|
AI_CONTEXT_SEPARATOR = "\n\n— 医生提问 —\n"
|
||||||
|
|
||||||
|
|
||||||
@@ -3437,6 +3526,7 @@ class AiConsultDialog(QDialog):
|
|||||||
self._detail: Mapping[str, Any] = {}
|
self._detail: Mapping[str, Any] = {}
|
||||||
self._workspace_sections: dict[str, dict[str, Any]] = {}
|
self._workspace_sections: dict[str, dict[str, Any]] = {}
|
||||||
self._generation = 0
|
self._generation = 0
|
||||||
|
self._session_closed = False
|
||||||
self._image_generation = 0
|
self._image_generation = 0
|
||||||
self._image_preview: QWidget | None = None
|
self._image_preview: QWidget | None = None
|
||||||
# 字典翻译表:内置种子先兜底,加载诊单时再并入服务端实时字典。
|
# 字典翻译表:内置种子先兜底,加载诊单时再并入服务端实时字典。
|
||||||
@@ -3448,8 +3538,12 @@ class AiConsultDialog(QDialog):
|
|||||||
self._stream_worker: _AiStreamWorker | None = None
|
self._stream_worker: _AiStreamWorker | None = None
|
||||||
self._stream_bubble: _ChatBubble | None = None
|
self._stream_bubble: _ChatBubble | None = None
|
||||||
self._stream_text = ""
|
self._stream_text = ""
|
||||||
|
self._stream_prompt = ""
|
||||||
self._pending_chunks: list[str] = []
|
self._pending_chunks: list[str] = []
|
||||||
self._stream_meta: dict[str, Any] = {}
|
self._stream_meta: dict[str, Any] = {}
|
||||||
|
self._stream_started_at: float | None = None
|
||||||
|
self._stream_first_delta_at: float | None = None
|
||||||
|
self._stream_ended_at: float | None = None
|
||||||
self._patient_ai_context: str = ""
|
self._patient_ai_context: str = ""
|
||||||
self._patient_ai_context_labels: list[str] = []
|
self._patient_ai_context_labels: list[str] = []
|
||||||
self._patient_ai_context_bubble: _ChatBubble | None = None
|
self._patient_ai_context_bubble: _ChatBubble | None = None
|
||||||
@@ -3458,10 +3552,17 @@ class AiConsultDialog(QDialog):
|
|||||||
# done 事件是否到达。用于区分“模型没内容”和“连接直接断了”。
|
# done 事件是否到达。用于区分“模型没内容”和“连接直接断了”。
|
||||||
self._stream_completed = False
|
self._stream_completed = False
|
||||||
self._follow_chat = True
|
self._follow_chat = True
|
||||||
|
self._chat_filter = "all"
|
||||||
self._flush_timer = QTimer(self)
|
self._flush_timer = QTimer(self)
|
||||||
self._flush_timer.setSingleShot(True)
|
self._flush_timer.setSingleShot(True)
|
||||||
self._flush_timer.setInterval(40)
|
self._flush_timer.setInterval(40)
|
||||||
self._flush_timer.timeout.connect(self._flush_stream_chunks)
|
self._flush_timer.timeout.connect(self._flush_stream_chunks)
|
||||||
|
self._stream_timeout_timer = QTimer(self)
|
||||||
|
self._stream_timeout_timer.setSingleShot(True)
|
||||||
|
self._stream_timeout_timer.timeout.connect(self._stream_timed_out)
|
||||||
|
self._stream_elapsed_timer = QTimer(self)
|
||||||
|
self._stream_elapsed_timer.setInterval(1000)
|
||||||
|
self._stream_elapsed_timer.timeout.connect(self._update_stream_elapsed)
|
||||||
self.setObjectName("AiConsultDialog")
|
self.setObjectName("AiConsultDialog")
|
||||||
self.setWindowTitle("问诊详情")
|
self.setWindowTitle("问诊详情")
|
||||||
# QDialog 默认只带关闭按钮,医生无法把这个信息密度很高的窗口放大到整屏。
|
# QDialog 默认只带关闭按钮,医生无法把这个信息密度很高的窗口放大到整屏。
|
||||||
@@ -3478,6 +3579,7 @@ class AiConsultDialog(QDialog):
|
|||||||
self.setStyleSheet(AI_CONSULT_QSS)
|
self.setStyleSheet(AI_CONSULT_QSS)
|
||||||
mark_business_dialog(self, "AiConsultDialog")
|
mark_business_dialog(self, "AiConsultDialog")
|
||||||
self._build()
|
self._build()
|
||||||
|
self.finished.connect(self._end_session)
|
||||||
|
|
||||||
def keyPressEvent(self, event: QKeyEvent) -> None: # type: ignore[override]
|
def keyPressEvent(self, event: QKeyEvent) -> None: # type: ignore[override]
|
||||||
# QLineEdit emits returnPressed before the key reaches QDialog. The
|
# QLineEdit emits returnPressed before the key reaches QDialog. The
|
||||||
@@ -3501,6 +3603,7 @@ class AiConsultDialog(QDialog):
|
|||||||
source_title: str = "问诊列表",
|
source_title: str = "问诊列表",
|
||||||
) -> None:
|
) -> None:
|
||||||
self._cancel_stream()
|
self._cancel_stream()
|
||||||
|
self._session_closed = False
|
||||||
self.diagnosis_id = diagnosis_id
|
self.diagnosis_id = diagnosis_id
|
||||||
self.patient_id = _as_int(patient_id)
|
self.patient_id = _as_int(patient_id)
|
||||||
self._seed = dict(seed) if isinstance(seed, Mapping) else {}
|
self._seed = dict(seed) if isinstance(seed, Mapping) else {}
|
||||||
@@ -3508,6 +3611,7 @@ class AiConsultDialog(QDialog):
|
|||||||
self._generation += 1
|
self._generation += 1
|
||||||
self._image_generation += 1
|
self._image_generation += 1
|
||||||
self._clear_chat()
|
self._clear_chat()
|
||||||
|
self._set_chat_filter("all")
|
||||||
# The AI context envelope depends on workspace data that has not yet
|
# The AI context envelope depends on workspace data that has not yet
|
||||||
# been fetched, so reset its cache eagerly instead of letting it
|
# been fetched, so reset its cache eagerly instead of letting it
|
||||||
# linger from a previous patient session.
|
# linger from a previous patient session.
|
||||||
@@ -3540,6 +3644,24 @@ class AiConsultDialog(QDialog):
|
|||||||
layout = QVBoxLayout(shell)
|
layout = QVBoxLayout(shell)
|
||||||
layout.setContentsMargins(18, 14, 18, 14)
|
layout.setContentsMargins(18, 14, 18, 14)
|
||||||
layout.setSpacing(12)
|
layout.setSpacing(12)
|
||||||
|
|
||||||
|
splitter = QSplitter(Qt.Orientation.Horizontal)
|
||||||
|
splitter.setChildrenCollapsible(False)
|
||||||
|
splitter.addWidget(self._build_center())
|
||||||
|
splitter.addWidget(self._build_right_pane())
|
||||||
|
splitter.setStretchFactor(0, 1)
|
||||||
|
splitter.setStretchFactor(1, 0)
|
||||||
|
splitter.setSizes([920, 460])
|
||||||
|
layout.addWidget(splitter, 1)
|
||||||
|
|
||||||
|
def _build_right_pane(self) -> QWidget:
|
||||||
|
host = QWidget()
|
||||||
|
host.setObjectName("AiConsultRightPane")
|
||||||
|
host.setMinimumWidth(360)
|
||||||
|
host.setMaximumWidth(480)
|
||||||
|
layout = QVBoxLayout(host)
|
||||||
|
layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
layout.setSpacing(10)
|
||||||
layout.addLayout(self._build_crumb())
|
layout.addLayout(self._build_crumb())
|
||||||
layout.addWidget(self._build_hero())
|
layout.addWidget(self._build_hero())
|
||||||
self.tabs = QTabBar()
|
self.tabs = QTabBar()
|
||||||
@@ -3548,15 +3670,31 @@ class AiConsultDialog(QDialog):
|
|||||||
self.tabs.addTab(title)
|
self.tabs.addTab(title)
|
||||||
self.tabs.currentChanged.connect(self._tab_changed)
|
self.tabs.currentChanged.connect(self._tab_changed)
|
||||||
layout.addWidget(self.tabs)
|
layout.addWidget(self.tabs)
|
||||||
|
layout.addWidget(self._build_side(), 1)
|
||||||
|
return host
|
||||||
|
|
||||||
splitter = QSplitter(Qt.Orientation.Horizontal)
|
def _build_chat_filter(self) -> QPushButton:
|
||||||
splitter.setChildrenCollapsible(False)
|
self.chat_filter_button = QPushButton("筛选")
|
||||||
splitter.addWidget(self._build_center())
|
self.chat_filter_button.setObjectName("AiConsultChatFilter")
|
||||||
splitter.addWidget(self._build_side())
|
self.chat_filter_button.setAccessibleName("筛选问诊对话")
|
||||||
splitter.setStretchFactor(0, 1)
|
self.chat_filter_button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||||
splitter.setStretchFactor(1, 0)
|
self.chat_filter_menu = QMenu(self.chat_filter_button)
|
||||||
splitter.setSizes([920, 400])
|
for value, label in (
|
||||||
layout.addWidget(splitter, 1)
|
("all", "全部对话"),
|
||||||
|
("patient", "患者消息"),
|
||||||
|
("doctor", "医生消息"),
|
||||||
|
("ai", "AI 助手消息"),
|
||||||
|
("system", "系统事件"),
|
||||||
|
):
|
||||||
|
action = self.chat_filter_menu.addAction(label)
|
||||||
|
action.setData(value)
|
||||||
|
action.setCheckable(True)
|
||||||
|
action.setChecked(value == "all")
|
||||||
|
action.triggered.connect(
|
||||||
|
lambda _checked=False, role=value: self._set_chat_filter(role)
|
||||||
|
)
|
||||||
|
self.chat_filter_button.setMenu(self.chat_filter_menu)
|
||||||
|
return self.chat_filter_button
|
||||||
|
|
||||||
def _build_crumb(self) -> QHBoxLayout:
|
def _build_crumb(self) -> QHBoxLayout:
|
||||||
row = QHBoxLayout()
|
row = QHBoxLayout()
|
||||||
@@ -3572,6 +3710,7 @@ class AiConsultDialog(QDialog):
|
|||||||
current.setObjectName("AiConsultCrumbCurrent")
|
current.setObjectName("AiConsultCrumbCurrent")
|
||||||
row.addWidget(current)
|
row.addWidget(current)
|
||||||
row.addStretch(1)
|
row.addStretch(1)
|
||||||
|
row.addWidget(self._build_chat_filter())
|
||||||
back = QPushButton("返回")
|
back = QPushButton("返回")
|
||||||
back.setObjectName("AiConsultBack")
|
back.setObjectName("AiConsultBack")
|
||||||
back.clicked.connect(self.reject)
|
back.clicked.connect(self.reject)
|
||||||
@@ -3582,9 +3721,11 @@ class AiConsultDialog(QDialog):
|
|||||||
card = QFrame()
|
card = QFrame()
|
||||||
card.setObjectName("AiConsultHero")
|
card.setObjectName("AiConsultHero")
|
||||||
_style_surface(card)
|
_style_surface(card)
|
||||||
row = QHBoxLayout(card)
|
body = QVBoxLayout(card)
|
||||||
row.setContentsMargins(16, 14, 16, 14)
|
body.setContentsMargins(14, 12, 14, 12)
|
||||||
row.setSpacing(14)
|
body.setSpacing(8)
|
||||||
|
row = QHBoxLayout()
|
||||||
|
row.setSpacing(10)
|
||||||
self.avatar = QLabel("患")
|
self.avatar = QLabel("患")
|
||||||
self.avatar.setObjectName("AiConsultAvatar")
|
self.avatar.setObjectName("AiConsultAvatar")
|
||||||
self.avatar.setFixedSize(44, 44)
|
self.avatar.setFixedSize(44, 44)
|
||||||
@@ -3599,13 +3740,14 @@ class AiConsultDialog(QDialog):
|
|||||||
self.identity_label = QLabel("")
|
self.identity_label = QLabel("")
|
||||||
self.identity_label.setObjectName("AiConsultMeta")
|
self.identity_label.setObjectName("AiConsultMeta")
|
||||||
info.addWidget(self.identity_label)
|
info.addWidget(self.identity_label)
|
||||||
self.chip_row = _FlowRow()
|
|
||||||
info.addWidget(self.chip_row)
|
|
||||||
row.addLayout(info, 1)
|
row.addLayout(info, 1)
|
||||||
full = QPushButton("查看完整资料")
|
full = QPushButton("查看完整资料")
|
||||||
full.setObjectName("AiConsultFullRecord")
|
full.setObjectName("AiConsultFullRecord")
|
||||||
full.clicked.connect(self._open_full_record)
|
full.clicked.connect(self._open_full_record)
|
||||||
row.addWidget(full, 0, Qt.AlignmentFlag.AlignTop)
|
row.addWidget(full, 0, Qt.AlignmentFlag.AlignTop)
|
||||||
|
body.addLayout(row)
|
||||||
|
self.chip_row = _FlowRow()
|
||||||
|
body.addWidget(self.chip_row)
|
||||||
return card
|
return card
|
||||||
|
|
||||||
def _build_center(self) -> QWidget:
|
def _build_center(self) -> QWidget:
|
||||||
@@ -3662,12 +3804,14 @@ class AiConsultDialog(QDialog):
|
|||||||
hint.setObjectName("AiConsultComposerLabel")
|
hint.setObjectName("AiConsultComposerLabel")
|
||||||
chips.addWidget(hint)
|
chips.addWidget(hint)
|
||||||
for label, prompt in QUICK_PROMPTS[:3]:
|
for label, prompt in QUICK_PROMPTS[:3]:
|
||||||
|
if label == "AI 生成处方" and not self._can_generate_ai_prescription():
|
||||||
|
continue
|
||||||
button = QPushButton(label)
|
button = QPushButton(label)
|
||||||
button.setObjectName("AiConsultChipButton")
|
button.setObjectName("AiConsultChipButton")
|
||||||
button.setCursor(Qt.CursorShape.PointingHandCursor)
|
button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||||
button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||||||
button.clicked.connect(
|
button.clicked.connect(
|
||||||
lambda _checked=False, text=prompt: self._ask(text)
|
lambda _checked=False, text=prompt: self._ask(text, replace_active=True)
|
||||||
)
|
)
|
||||||
chips.addWidget(button, 1)
|
chips.addWidget(button, 1)
|
||||||
more = QPushButton("更多")
|
more = QPushButton("更多")
|
||||||
@@ -3678,7 +3822,7 @@ class AiConsultDialog(QDialog):
|
|||||||
for label, prompt in QUICK_PROMPTS[3:]:
|
for label, prompt in QUICK_PROMPTS[3:]:
|
||||||
action = more_menu.addAction(label)
|
action = more_menu.addAction(label)
|
||||||
action.triggered.connect(
|
action.triggered.connect(
|
||||||
lambda _checked=False, text=prompt: self._ask(text)
|
lambda _checked=False, text=prompt: self._ask(text, replace_active=True)
|
||||||
)
|
)
|
||||||
more.setMenu(more_menu)
|
more.setMenu(more_menu)
|
||||||
chips.addWidget(more, 1)
|
chips.addWidget(more, 1)
|
||||||
@@ -3808,7 +3952,6 @@ class AiConsultDialog(QDialog):
|
|||||||
side.setObjectName("AiConsultSide")
|
side.setObjectName("AiConsultSide")
|
||||||
_style_surface(side)
|
_style_surface(side)
|
||||||
side.setMinimumWidth(350)
|
side.setMinimumWidth(350)
|
||||||
side.setMaximumWidth(420)
|
|
||||||
outer = QVBoxLayout(side)
|
outer = QVBoxLayout(side)
|
||||||
outer.setContentsMargins(18, 16, 18, 16)
|
outer.setContentsMargins(18, 16, 18, 16)
|
||||||
outer.setSpacing(14)
|
outer.setSpacing(14)
|
||||||
@@ -3872,7 +4015,7 @@ class AiConsultDialog(QDialog):
|
|||||||
for index, (kind, label, prompt) in enumerate(TOOL_ITEMS):
|
for index, (kind, label, prompt) in enumerate(TOOL_ITEMS):
|
||||||
card = _ClickCard()
|
card = _ClickCard()
|
||||||
card.setObjectName("AiConsultToolCard")
|
card.setObjectName("AiConsultToolCard")
|
||||||
card.clicked.connect(lambda text=prompt: self._ask(text))
|
card.clicked.connect(lambda text=prompt: self._ask(text, replace_active=True))
|
||||||
inner = QVBoxLayout(card)
|
inner = QVBoxLayout(card)
|
||||||
inner.setContentsMargins(8, 10, 8, 10)
|
inner.setContentsMargins(8, 10, 8, 10)
|
||||||
inner.setSpacing(6)
|
inner.setSpacing(6)
|
||||||
@@ -3911,6 +4054,32 @@ class AiConsultDialog(QDialog):
|
|||||||
|
|
||||||
def _tab_changed(self, index: int) -> None:
|
def _tab_changed(self, index: int) -> None:
|
||||||
self.center_stack.setCurrentIndex(index)
|
self.center_stack.setCurrentIndex(index)
|
||||||
|
self.chat_filter_button.setVisible(index == 0)
|
||||||
|
|
||||||
|
def _set_chat_filter(self, role: str) -> None:
|
||||||
|
self._chat_filter = role
|
||||||
|
label = "全部对话"
|
||||||
|
for action in self.chat_filter_menu.actions():
|
||||||
|
selected = action.data() == role
|
||||||
|
action.setChecked(selected)
|
||||||
|
if selected:
|
||||||
|
label = action.text()
|
||||||
|
self.chat_filter_button.setText(
|
||||||
|
"筛选" if role == "all" else f"筛选:{label}"
|
||||||
|
)
|
||||||
|
for index in range(self.chat_layout.count() - 1):
|
||||||
|
widget = self.chat_layout.itemAt(index).widget()
|
||||||
|
if widget is not None:
|
||||||
|
widget.setVisible(self._chat_item_matches_filter(widget))
|
||||||
|
|
||||||
|
def _chat_item_matches_filter(self, widget: QWidget) -> bool:
|
||||||
|
if self._chat_filter == "all":
|
||||||
|
return True
|
||||||
|
if isinstance(widget, _ChatEvent):
|
||||||
|
return self._chat_filter == "system"
|
||||||
|
if isinstance(widget, _ChatBubble):
|
||||||
|
return widget._role == self._chat_filter
|
||||||
|
return True
|
||||||
|
|
||||||
def _clear_chat(self) -> None:
|
def _clear_chat(self) -> None:
|
||||||
while self.chat_layout.count() > 1:
|
while self.chat_layout.count() > 1:
|
||||||
@@ -3932,6 +4101,7 @@ class AiConsultDialog(QDialog):
|
|||||||
) -> _ChatBubble:
|
) -> _ChatBubble:
|
||||||
bubble = _ChatBubble(role=role, text=text, time_text=time_text, extra=extra)
|
bubble = _ChatBubble(role=role, text=text, time_text=time_text, extra=extra)
|
||||||
self.chat_layout.insertWidget(self.chat_layout.count() - 1, bubble)
|
self.chat_layout.insertWidget(self.chat_layout.count() - 1, bubble)
|
||||||
|
bubble.setVisible(self._chat_item_matches_filter(bubble))
|
||||||
if self._follow_chat:
|
if self._follow_chat:
|
||||||
QTimer.singleShot(0, self._scroll_chat_to_bottom)
|
QTimer.singleShot(0, self._scroll_chat_to_bottom)
|
||||||
return bubble
|
return bubble
|
||||||
@@ -3953,6 +4123,7 @@ class AiConsultDialog(QDialog):
|
|||||||
marker=marker,
|
marker=marker,
|
||||||
)
|
)
|
||||||
self.chat_layout.insertWidget(self.chat_layout.count() - 1, event)
|
self.chat_layout.insertWidget(self.chat_layout.count() - 1, event)
|
||||||
|
event.setVisible(self._chat_item_matches_filter(event))
|
||||||
if self._follow_chat:
|
if self._follow_chat:
|
||||||
QTimer.singleShot(0, self._scroll_chat_to_bottom)
|
QTimer.singleShot(0, self._scroll_chat_to_bottom)
|
||||||
return event
|
return event
|
||||||
@@ -4550,6 +4721,7 @@ class AiConsultDialog(QDialog):
|
|||||||
time_text=item.time_text,
|
time_text=item.time_text,
|
||||||
)
|
)
|
||||||
if item.role == "ai":
|
if item.role == "ai":
|
||||||
|
bubble.set_provenance(row)
|
||||||
bubble.finalize_clinical_analysis()
|
bubble.finalize_clinical_analysis()
|
||||||
|
|
||||||
def _render_analysis(self, analysis: Mapping[str, Any]) -> None:
|
def _render_analysis(self, analysis: Mapping[str, Any]) -> None:
|
||||||
@@ -4564,51 +4736,25 @@ class AiConsultDialog(QDialog):
|
|||||||
)
|
)
|
||||||
top_risk = risk_rows[0] if risk_rows else {}
|
top_risk = risk_rows[0] if risk_rows else {}
|
||||||
risk_label = display_text(first_value(top_risk, "label"), "")
|
risk_label = display_text(first_value(top_risk, "label"), "")
|
||||||
risk_level = str(first_value(top_risk, "level", default="medium") or "medium")
|
risk_level = str(first_value(top_risk, "level", default="unknown") or "unknown")
|
||||||
if risk_level not in {"high", "medium", "low"}:
|
if risk_level not in {"high", "medium", "low"}:
|
||||||
risk_level = "medium"
|
risk_level = "unknown"
|
||||||
level_text = {"high": "高风险", "medium": "中风险", "low": "低风险"}[risk_level]
|
level_text = {"high": "高风险", "medium": "中风险", "low": "低风险", "unknown": "未提供"}[risk_level]
|
||||||
diagnosis = _as_mapping(get_value(self._detail, "diagnosis", default=self._detail) or self._seed)
|
if not advice and not treatment and not risk_rows:
|
||||||
appointment = _as_mapping(get_value(self._detail, "appointment", default={}))
|
self._set_insights(
|
||||||
clinical = display_text(
|
[("分析状态", "当前诊单暂无可用的 AI 分析报告,请重新分析后查看。", "未生成", "unknown")]
|
||||||
_pick(("clinical_diagnosis", "diagnosis_name"), diagnosis, appointment, self._seed),
|
)
|
||||||
"",
|
return
|
||||||
)
|
markdown = _analysis_markdown(payload)
|
||||||
complaint = display_text(_pick(("chief_complaint",), diagnosis, self._seed), "")
|
|
||||||
glucose = _pick(("fasting_blood_sugar",), diagnosis, appointment, self._seed)
|
|
||||||
glucose_number = _as_float(glucose)
|
|
||||||
if not advice:
|
|
||||||
advice = _join_meta(clinical, f"主诉 {complaint}" if complaint else "") or "结合现有病历评估证候与病情。"
|
|
||||||
if not risk_label:
|
|
||||||
if glucose_number is not None and glucose_number >= 7.0:
|
|
||||||
risk_label, risk_level, level_text = "高血糖风险", "high", "高风险"
|
|
||||||
elif glucose_number is not None and glucose_number >= 6.1:
|
|
||||||
risk_label, risk_level, level_text = "血糖波动风险", "medium", "中风险"
|
|
||||||
else:
|
|
||||||
risk_label, risk_level, level_text = "需持续随访", "low", "低风险"
|
|
||||||
if not treatment:
|
|
||||||
treatment = "结合处方、依从性与最新检验结果评估是否需要调整用药。"
|
|
||||||
markdown = _analysis_markdown(
|
|
||||||
{
|
|
||||||
"diagnosis_advice": advice,
|
|
||||||
"treatment_advice": treatment,
|
|
||||||
"risk_assessment": risk_rows
|
|
||||||
or [{"label": risk_label, "level": risk_level}],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
if markdown:
|
if markdown:
|
||||||
bubble = self._append_bubble("ai", markdown)
|
bubble = self._append_bubble("ai", markdown)
|
||||||
|
bubble.set_provenance(payload)
|
||||||
bubble.finalize_clinical_analysis()
|
bubble.finalize_clinical_analysis()
|
||||||
glucose_tag, glucose_level = (
|
|
||||||
("未达标", "warn")
|
|
||||||
if "高血糖" in risk_label or risk_level == "high"
|
|
||||||
else ("需关注", "low")
|
|
||||||
)
|
|
||||||
self._set_insights(
|
self._set_insights(
|
||||||
[
|
[
|
||||||
("血糖控制评估", advice, glucose_tag, glucose_level),
|
("血糖控制评估", advice or "报告未提供此项分析。", "已分析" if advice else "未提供", "low" if advice else "unknown"),
|
||||||
("并发症风险评估", risk_label, level_text, risk_level),
|
("并发症风险评估", risk_label or "报告未列出风险项。", level_text, risk_level),
|
||||||
("用药合理性评估", treatment, "需关注", "low"),
|
("用药合理性评估", treatment or "报告未提供此项分析。", "已分析" if treatment else "未提供", "low" if treatment else "unknown"),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -5464,6 +5610,13 @@ class AiConsultDialog(QDialog):
|
|||||||
def _submit(self) -> None:
|
def _submit(self) -> None:
|
||||||
self._ask(self.input.text())
|
self._ask(self.input.text())
|
||||||
|
|
||||||
|
def _can_generate_ai_prescription(self) -> bool:
|
||||||
|
return has_permission(
|
||||||
|
self.permissions, AI_PRESCRIPTION_PERMISSION, default=False
|
||||||
|
) and has_permission(
|
||||||
|
self.permissions, PRESCRIPTION_CREATE_PERMISSIONS, default=False
|
||||||
|
)
|
||||||
|
|
||||||
def _handle_local_action(self, text: str) -> bool:
|
def _handle_local_action(self, text: str) -> bool:
|
||||||
if not _is_open_prescription_intent(text):
|
if not _is_open_prescription_intent(text):
|
||||||
return False
|
return False
|
||||||
@@ -5474,9 +5627,8 @@ class AiConsultDialog(QDialog):
|
|||||||
text,
|
text,
|
||||||
time_text=datetime.now().strftime("%H:%M"),
|
time_text=datetime.now().strftime("%H:%M"),
|
||||||
)
|
)
|
||||||
can_prescribe = any(
|
can_prescribe = has_permission(
|
||||||
has_permission(self.permissions, code, default=False)
|
self.permissions, PRESCRIPTION_CREATE_PERMISSIONS, default=False
|
||||||
for code in ("tcm.diagnosis/chufang", "tcm.diagnosis/kaifang")
|
|
||||||
)
|
)
|
||||||
if not can_prescribe:
|
if not can_prescribe:
|
||||||
self._append_bubble(
|
self._append_bubble(
|
||||||
@@ -5485,6 +5637,15 @@ class AiConsultDialog(QDialog):
|
|||||||
time_text="系统",
|
time_text="系统",
|
||||||
)
|
)
|
||||||
return True
|
return True
|
||||||
|
if not has_permission(
|
||||||
|
self.permissions, AI_PRESCRIPTION_PERMISSION, default=False
|
||||||
|
):
|
||||||
|
self._append_bubble(
|
||||||
|
"ai",
|
||||||
|
"当前账号没有 AI 生成处方权限,无法生成处方草稿。",
|
||||||
|
time_text="系统",
|
||||||
|
)
|
||||||
|
return True
|
||||||
if not callable(getattr(self.repository, "create_prescription", None)):
|
if not callable(getattr(self.repository, "create_prescription", None)):
|
||||||
self._append_bubble(
|
self._append_bubble(
|
||||||
"ai",
|
"ai",
|
||||||
@@ -5834,11 +5995,14 @@ class AiConsultDialog(QDialog):
|
|||||||
|
|
||||||
def _use_suggestion(self, question: str) -> None:
|
def _use_suggestion(self, question: str) -> None:
|
||||||
self.input.setText(question)
|
self.input.setText(question)
|
||||||
self._ask(f"请针对该问题给出问诊话术与判断要点:{question}")
|
self._ask(f"请针对该问题给出问诊话术与判断要点:{question}", replace_active=True)
|
||||||
|
|
||||||
def _ask(self, prompt: str) -> None:
|
def _ask(self, prompt: str, *, replace_active: bool = False) -> None:
|
||||||
text = prompt.strip()
|
text = prompt.strip()
|
||||||
if self._asking or not text:
|
if not text:
|
||||||
|
return
|
||||||
|
if self._asking and (not replace_active or text == self._stream_prompt):
|
||||||
|
show_toast(self, "AI 正在生成,请稍候或点击其他快捷工具切换。", "info")
|
||||||
return
|
return
|
||||||
if self.diagnosis_id <= 0:
|
if self.diagnosis_id <= 0:
|
||||||
show_toast(self, "缺少诊单编号,无法发起 AI 对话。", "warning")
|
show_toast(self, "缺少诊单编号,无法发起 AI 对话。", "warning")
|
||||||
@@ -5855,8 +6019,12 @@ class AiConsultDialog(QDialog):
|
|||||||
"warning",
|
"warning",
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
self._cancel_stream()
|
self._cancel_stream(replaced=self._asking)
|
||||||
|
self._stream_started_at = monotonic()
|
||||||
|
self._stream_prompt = text
|
||||||
self._follow_chat = True
|
self._follow_chat = True
|
||||||
|
self.tabs.setCurrentIndex(0)
|
||||||
|
self._set_chat_filter("all")
|
||||||
self.input.clear()
|
self.input.clear()
|
||||||
self._append_bubble(
|
self._append_bubble(
|
||||||
"doctor", text, time_text=datetime.now().strftime("%H:%M")
|
"doctor", text, time_text=datetime.now().strftime("%H:%M")
|
||||||
@@ -5877,6 +6045,8 @@ class AiConsultDialog(QDialog):
|
|||||||
self._pending_chunks.clear()
|
self._pending_chunks.clear()
|
||||||
self._stream_meta.clear()
|
self._stream_meta.clear()
|
||||||
self._asking = True
|
self._asking = True
|
||||||
|
self._update_stream_elapsed()
|
||||||
|
self._stream_elapsed_timer.start()
|
||||||
self.send_button.setEnabled(False)
|
self.send_button.setEnabled(False)
|
||||||
generation = self._generation
|
generation = self._generation
|
||||||
stream_generation = self._stream_generation
|
stream_generation = self._stream_generation
|
||||||
@@ -5898,36 +6068,80 @@ class AiConsultDialog(QDialog):
|
|||||||
worker.signals.finished.connect(
|
worker.signals.finished.connect(
|
||||||
lambda: self._stream_finished(generation, stream_generation, worker)
|
lambda: self._stream_finished(generation, stream_generation, worker)
|
||||||
)
|
)
|
||||||
|
self._stream_timeout_timer.start(AI_STREAM_TOTAL_TIMEOUT_MS)
|
||||||
QThreadPool.globalInstance().start(worker)
|
QThreadPool.globalInstance().start(worker)
|
||||||
QTimer.singleShot(0, self._scroll_chat_to_bottom)
|
QTimer.singleShot(0, self._scroll_chat_to_bottom)
|
||||||
|
|
||||||
def _stream_is_current(self, generation: int, stream_generation: int) -> bool:
|
def _stream_is_current(self, generation: int, stream_generation: int) -> bool:
|
||||||
return generation == self._generation and stream_generation == self._stream_generation
|
return generation == self._generation and stream_generation == self._stream_generation
|
||||||
|
|
||||||
|
def _update_stream_elapsed(self) -> None:
|
||||||
|
if self._stream_bubble is None:
|
||||||
|
return
|
||||||
|
model = display_text(
|
||||||
|
first_value(self._stream_meta, "model_label", "model_key"), ""
|
||||||
|
)
|
||||||
|
if self._stream_started_at is None:
|
||||||
|
self._stream_bubble.set_time_text(_join_meta(model, datetime.now().strftime("%H:%M")))
|
||||||
|
return
|
||||||
|
now = self._stream_ended_at if self._stream_ended_at is not None else monotonic()
|
||||||
|
elapsed = max(0.0, now - self._stream_started_at)
|
||||||
|
if self._stream_ended_at is None:
|
||||||
|
status = (
|
||||||
|
f"生成中 · 已用时 {int(elapsed)} 秒"
|
||||||
|
if self._stream_first_delta_at is not None else f"已等待 {int(elapsed)} 秒"
|
||||||
|
)
|
||||||
|
self._stream_bubble.set_time_text(_join_meta(model, status))
|
||||||
|
return
|
||||||
|
first_delta = (
|
||||||
|
f"首字 {max(0.0, self._stream_first_delta_at - self._stream_started_at):.1f} 秒"
|
||||||
|
if self._stream_first_delta_at is not None else ""
|
||||||
|
)
|
||||||
|
self._stream_bubble.set_time_text(
|
||||||
|
_join_meta(model, first_delta, f"耗时 {elapsed:.1f} 秒", datetime.now().strftime("%H:%M"))
|
||||||
|
)
|
||||||
|
|
||||||
|
def _stop_stream_clock(self) -> None:
|
||||||
|
if self._stream_ended_at is None:
|
||||||
|
self._stream_ended_at = monotonic()
|
||||||
|
self._stream_elapsed_timer.stop()
|
||||||
|
self._update_stream_elapsed()
|
||||||
|
|
||||||
def _stream_event(
|
def _stream_event(
|
||||||
self,
|
self,
|
||||||
generation: int,
|
generation: int,
|
||||||
stream_generation: int,
|
stream_generation: int,
|
||||||
event: Any,
|
event: Any,
|
||||||
) -> None:
|
) -> None:
|
||||||
if not self._stream_is_current(generation, stream_generation):
|
if not self._stream_is_current(generation, stream_generation) or self._stream_ended_at is not None:
|
||||||
return
|
return
|
||||||
payload = dict(event) if isinstance(event, Mapping) else {}
|
payload = dict(event) if isinstance(event, Mapping) else {}
|
||||||
kind = str(payload.get("event") or "").lower()
|
kind = str(payload.get("event") or "").lower()
|
||||||
if kind == "start":
|
if kind == "start":
|
||||||
self._stream_meta.update(payload)
|
self._stream_meta.update(payload)
|
||||||
|
self._update_stream_elapsed()
|
||||||
|
if self._stream_bubble is not None and not self._stream_text and not self._pending_chunks:
|
||||||
|
self._stream_bubble.set_payload(AI_STREAM_WAITING_TEXT)
|
||||||
return
|
return
|
||||||
if kind == "delta":
|
if kind == "delta":
|
||||||
chunk = payload.get("text")
|
chunk = payload.get("text")
|
||||||
if isinstance(chunk, str) and chunk:
|
if isinstance(chunk, str) and chunk:
|
||||||
|
if self._stream_first_delta_at is None:
|
||||||
|
self._stream_first_delta_at = monotonic()
|
||||||
|
self._update_stream_elapsed()
|
||||||
self._pending_chunks.append(chunk)
|
self._pending_chunks.append(chunk)
|
||||||
if not self._flush_timer.isActive():
|
if not self._stream_text:
|
||||||
|
# The first received text replaces the waiting state now.
|
||||||
|
self._flush_stream_chunks()
|
||||||
|
elif not self._flush_timer.isActive():
|
||||||
self._flush_timer.start()
|
self._flush_timer.start()
|
||||||
return
|
return
|
||||||
if kind != "done":
|
if kind != "done":
|
||||||
return
|
return
|
||||||
self._stream_meta.update(payload)
|
self._stream_meta.update(payload)
|
||||||
self._stream_completed = True
|
self._stream_completed = True
|
||||||
|
self._stream_timeout_timer.stop()
|
||||||
|
self._stop_stream_clock()
|
||||||
if not self._stream_text and not self._pending_chunks:
|
if not self._stream_text and not self._pending_chunks:
|
||||||
answer = first_value(payload, "answer", "content", default="")
|
answer = first_value(payload, "answer", "content", default="")
|
||||||
if answer not in (None, ""):
|
if answer not in (None, ""):
|
||||||
@@ -5937,16 +6151,15 @@ class AiConsultDialog(QDialog):
|
|||||||
if not self._stream_text:
|
if not self._stream_text:
|
||||||
self._stream_text = "未返回分析内容。"
|
self._stream_text = "未返回分析内容。"
|
||||||
self._stream_bubble.set_payload(self._stream_text)
|
self._stream_bubble.set_payload(self._stream_text)
|
||||||
model = display_text(
|
self._stream_bubble.set_provenance(self._stream_meta)
|
||||||
first_value(self._stream_meta, "model_label", "model_key"),
|
|
||||||
"",
|
|
||||||
)
|
|
||||||
self._stream_bubble.set_time_text(
|
|
||||||
_join_meta(model, datetime.now().strftime("%H:%M"))
|
|
||||||
)
|
|
||||||
self._stream_bubble.finalize_clinical_analysis()
|
self._stream_bubble.finalize_clinical_analysis()
|
||||||
|
# A terminal event completes the UI request even if transport cleanup
|
||||||
|
# has not returned yet. The next click must not depend on finished.
|
||||||
|
self._asking = False
|
||||||
|
self.send_button.setEnabled(True)
|
||||||
|
|
||||||
def _flush_stream_chunks(self) -> None:
|
def _flush_stream_chunks(self) -> None:
|
||||||
|
self._flush_timer.stop()
|
||||||
if not self._pending_chunks or self._stream_bubble is None:
|
if not self._pending_chunks or self._stream_bubble is None:
|
||||||
return
|
return
|
||||||
self._stream_text += "".join(self._pending_chunks)
|
self._stream_text += "".join(self._pending_chunks)
|
||||||
@@ -5961,8 +6174,10 @@ class AiConsultDialog(QDialog):
|
|||||||
stream_generation: int,
|
stream_generation: int,
|
||||||
error: Exception,
|
error: Exception,
|
||||||
) -> None:
|
) -> None:
|
||||||
if not self._stream_is_current(generation, stream_generation):
|
if not self._stream_is_current(generation, stream_generation) or self._stream_ended_at is not None:
|
||||||
return
|
return
|
||||||
|
self._stream_timeout_timer.stop()
|
||||||
|
self._stop_stream_clock()
|
||||||
self._flush_stream_chunks()
|
self._flush_stream_chunks()
|
||||||
message = friendly_error(error)
|
message = friendly_error(error)
|
||||||
if self._stream_text:
|
if self._stream_text:
|
||||||
@@ -5971,7 +6186,8 @@ class AiConsultDialog(QDialog):
|
|||||||
self._stream_text = message
|
self._stream_text = message
|
||||||
if self._stream_bubble is not None:
|
if self._stream_bubble is not None:
|
||||||
self._stream_bubble.set_payload(self._stream_text)
|
self._stream_bubble.set_payload(self._stream_text)
|
||||||
self._stream_bubble.set_time_text(datetime.now().strftime("%H:%M"))
|
self._asking = False
|
||||||
|
self.send_button.setEnabled(True)
|
||||||
|
|
||||||
def _stream_finished(
|
def _stream_finished(
|
||||||
self,
|
self,
|
||||||
@@ -5981,10 +6197,13 @@ class AiConsultDialog(QDialog):
|
|||||||
) -> None:
|
) -> None:
|
||||||
if not self._stream_is_current(generation, stream_generation):
|
if not self._stream_is_current(generation, stream_generation):
|
||||||
return
|
return
|
||||||
|
self._stream_timeout_timer.stop()
|
||||||
|
self._flush_stream_chunks()
|
||||||
|
self._stop_stream_clock()
|
||||||
if self._stream_worker is worker:
|
if self._stream_worker is worker:
|
||||||
self._stream_worker = None
|
self._stream_worker = None
|
||||||
# 连接结束却既没有 done 也没有报错时(服务端静默断开),占位气泡会永远
|
# 连接结束却既没有 done 也没有报错时(服务端静默断开),
|
||||||
# 停在“正在生成…”。这里补一条明确说明,而不是留一块空白卡片。
|
# 将阶段占位文案替换成明确说明。
|
||||||
if (
|
if (
|
||||||
not self._stream_completed
|
not self._stream_completed
|
||||||
and not self._stream_text
|
and not self._stream_text
|
||||||
@@ -5992,30 +6211,62 @@ class AiConsultDialog(QDialog):
|
|||||||
):
|
):
|
||||||
self._stream_text = AI_STREAM_SILENT_TEXT
|
self._stream_text = AI_STREAM_SILENT_TEXT
|
||||||
self._stream_bubble.set_payload(self._stream_text)
|
self._stream_bubble.set_payload(self._stream_text)
|
||||||
self._stream_bubble.set_time_text(datetime.now().strftime("%H:%M"))
|
|
||||||
self._asking = False
|
self._asking = False
|
||||||
self.send_button.setEnabled(True)
|
self.send_button.setEnabled(True)
|
||||||
|
|
||||||
def _cancel_stream(self) -> None:
|
def _stream_timed_out(self) -> None:
|
||||||
|
if not self._asking or self._stream_completed:
|
||||||
|
return
|
||||||
|
self._flush_stream_chunks()
|
||||||
|
bubble = self._stream_bubble
|
||||||
|
text = self._stream_text
|
||||||
|
self._stop_stream_clock()
|
||||||
|
self._cancel_stream()
|
||||||
|
if bubble is not None:
|
||||||
|
bubble.set_payload(
|
||||||
|
f"{text}\n\n> {AI_STREAM_TIMEOUT_TEXT}" if text else AI_STREAM_TIMEOUT_TEXT
|
||||||
|
)
|
||||||
|
|
||||||
|
def _cancel_stream(self, *, replaced: bool = False) -> None:
|
||||||
|
if replaced and self._asking:
|
||||||
|
self._flush_stream_chunks()
|
||||||
|
self._stop_stream_clock()
|
||||||
|
if self._stream_bubble is not None:
|
||||||
|
notice = "已停止本次生成,已切换到新的请求。"
|
||||||
|
self._stream_bubble.set_payload(
|
||||||
|
f"{self._stream_text}\n\n> {notice}" if self._stream_text else notice
|
||||||
|
)
|
||||||
self._stream_generation += 1
|
self._stream_generation += 1
|
||||||
self._flush_timer.stop()
|
self._flush_timer.stop()
|
||||||
|
self._stream_timeout_timer.stop()
|
||||||
|
self._stream_elapsed_timer.stop()
|
||||||
if self._stream_worker is not None:
|
if self._stream_worker is not None:
|
||||||
self._stream_worker.cancel()
|
self._stream_worker.cancel()
|
||||||
self._stream_worker = None
|
self._stream_worker = None
|
||||||
self._stream_bubble = None
|
self._stream_bubble = None
|
||||||
self._pending_chunks.clear()
|
self._pending_chunks.clear()
|
||||||
self._stream_text = ""
|
self._stream_text = ""
|
||||||
|
self._stream_prompt = ""
|
||||||
self._stream_completed = False
|
self._stream_completed = False
|
||||||
self._stream_meta.clear()
|
self._stream_meta.clear()
|
||||||
|
self._stream_started_at = None
|
||||||
|
self._stream_first_delta_at = None
|
||||||
|
self._stream_ended_at = None
|
||||||
self._asking = False
|
self._asking = False
|
||||||
self.send_button.setEnabled(True)
|
self.send_button.setEnabled(True)
|
||||||
|
|
||||||
def closeEvent(self, event: Any) -> None: # type: ignore[override]
|
def _end_session(self) -> None:
|
||||||
|
if self._session_closed:
|
||||||
|
return
|
||||||
|
self._session_closed = True
|
||||||
self._generation += 1
|
self._generation += 1
|
||||||
self._image_generation += 1
|
self._image_generation += 1
|
||||||
_abort_pending_thumbnails(self)
|
_abort_pending_thumbnails(self)
|
||||||
self._close_image_preview()
|
self._close_image_preview()
|
||||||
self._cancel_stream()
|
self._cancel_stream()
|
||||||
|
|
||||||
|
def closeEvent(self, event: Any) -> None: # type: ignore[override]
|
||||||
|
self._end_session()
|
||||||
super().closeEvent(event)
|
super().closeEvent(event)
|
||||||
|
|
||||||
def _open_full_record(self) -> None:
|
def _open_full_record(self) -> None:
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ from ..diagnosis_media import (
|
|||||||
safe_image_sources,
|
safe_image_sources,
|
||||||
)
|
)
|
||||||
from ..infinite_list import InfiniteList
|
from ..infinite_list import InfiniteList
|
||||||
|
from ..theme import main_window_target_rect
|
||||||
from ..widgets import (
|
from ..widgets import (
|
||||||
display_text,
|
display_text,
|
||||||
first_value,
|
first_value,
|
||||||
@@ -716,6 +717,11 @@ class OrderDetailDrawer(QDialog):
|
|||||||
QTimer.singleShot(0, self.sync_to_owner)
|
QTimer.singleShot(0, self.sync_to_owner)
|
||||||
|
|
||||||
def sync_to_owner(self) -> None:
|
def sync_to_owner(self) -> None:
|
||||||
|
target = main_window_target_rect(self)
|
||||||
|
if target is not None:
|
||||||
|
self.setGeometry(target)
|
||||||
|
self.drawer_panel.setFixedWidth(max(1, round(target.width() * self.drawer_ratio)))
|
||||||
|
return
|
||||||
owner = self._owner
|
owner = self._owner
|
||||||
if owner is None:
|
if owner is None:
|
||||||
self.resize(1152, 720)
|
self.resize(1152, 720)
|
||||||
@@ -1836,6 +1842,10 @@ class DiagnosisDialog(QDialog):
|
|||||||
def _sync_host_geometry(self) -> None:
|
def _sync_host_geometry(self) -> None:
|
||||||
if self._embedded:
|
if self._embedded:
|
||||||
return
|
return
|
||||||
|
target = main_window_target_rect(self)
|
||||||
|
if target is not None:
|
||||||
|
self.setGeometry(target)
|
||||||
|
return
|
||||||
owner = self._owner
|
owner = self._owner
|
||||||
if owner is None:
|
if owner is None:
|
||||||
if self.width() < 760 or self.height() < 520:
|
if self.width() < 760 or self.height() < 520:
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,375 @@
|
|||||||
|
"""Painted charts for the prescription analysis window, in the workstation's tech blue.
|
||||||
|
|
||||||
|
Every widget draws only what the saved report contains: a value that is missing stays visibly
|
||||||
|
absent instead of being drawn as zero, and no chart implies a medical judgement by colour.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from PySide6.QtCore import QPointF, QRectF, QSize, Qt
|
||||||
|
from PySide6.QtGui import QColor, QFont, QPainter, QPaintEvent
|
||||||
|
from PySide6.QtWidgets import QSizePolicy, QWidget
|
||||||
|
|
||||||
|
from .issued_prescription_ai_theme import CONSOLE as TECH_BLUE
|
||||||
|
|
||||||
|
# Colour names resolve through the active palette at paint time, so a theme switch needs no
|
||||||
|
# rebuild here: the next repaint already draws in the new colours.
|
||||||
|
|
||||||
|
|
||||||
|
class _Palette:
|
||||||
|
"""Attribute access into the live palette: ``COLOUR.qwen`` is always the current hue."""
|
||||||
|
|
||||||
|
__slots__ = ("_keys",)
|
||||||
|
|
||||||
|
def __init__(self, **keys: str) -> None:
|
||||||
|
object.__setattr__(self, "_keys", dict(keys))
|
||||||
|
|
||||||
|
def __getattr__(self, name: str) -> str:
|
||||||
|
return TECH_BLUE[self._keys[name]]
|
||||||
|
|
||||||
|
|
||||||
|
COLOUR = _Palette(doctor="muted", qwen="qwen", openai="openai", track="raised",
|
||||||
|
limited="amber", ink="heading", muted="muted")
|
||||||
|
|
||||||
|
|
||||||
|
def _mapping(value: Any) -> dict[str, Any]:
|
||||||
|
return dict(value) if isinstance(value, Mapping) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _count(value: Any) -> int:
|
||||||
|
if value is None or isinstance(value, bool):
|
||||||
|
return 0
|
||||||
|
try:
|
||||||
|
number = int(float(value))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return 0
|
||||||
|
return max(0, number)
|
||||||
|
|
||||||
|
|
||||||
|
class DonutGauge(QWidget):
|
||||||
|
"""A ring reading one percentage. An unavailable value leaves the track empty, never zero."""
|
||||||
|
|
||||||
|
def __init__(self, accent: str, parent: QWidget | None = None, *, diameter: int = 88,
|
||||||
|
thickness: int = 11, track: str = COLOUR.track) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self._accent, self._track = accent, track
|
||||||
|
self._thickness = thickness
|
||||||
|
self._value: float | None = None
|
||||||
|
self.setFixedSize(diameter, diameter)
|
||||||
|
self.setAccessibleName("一致度环形图")
|
||||||
|
self.set_value(None)
|
||||||
|
|
||||||
|
def set_value(self, value: Any) -> None:
|
||||||
|
parsed = None
|
||||||
|
try:
|
||||||
|
parsed = None if value is None or isinstance(value, bool) else float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
parsed = None
|
||||||
|
self._value = None if parsed is None or parsed < 0 or parsed > 100 else parsed
|
||||||
|
self.setAccessibleDescription("暂无可比结果" if self._value is None else f"{self._value:.1f}%")
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
def value(self) -> float | None:
|
||||||
|
return self._value
|
||||||
|
|
||||||
|
def paintEvent(self, event: QPaintEvent) -> None: # noqa: N802 - Qt virtual
|
||||||
|
painter = QPainter(self)
|
||||||
|
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
|
||||||
|
inset = self._thickness / 2 + 1
|
||||||
|
box = QRectF(inset, inset, self.width() - 2 * inset, self.height() - 2 * inset)
|
||||||
|
pen = painter.pen()
|
||||||
|
pen.setWidthF(self._thickness)
|
||||||
|
pen.setCapStyle(Qt.PenCapStyle.FlatCap)
|
||||||
|
pen.setColor(QColor(self._track))
|
||||||
|
painter.setPen(pen)
|
||||||
|
painter.setBrush(Qt.BrushStyle.NoBrush)
|
||||||
|
painter.drawEllipse(box)
|
||||||
|
if self._value:
|
||||||
|
pen.setColor(QColor(self._accent))
|
||||||
|
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
|
||||||
|
painter.setPen(pen)
|
||||||
|
# Qt angles are sixteenths of a degree; start at twelve o'clock and run clockwise.
|
||||||
|
painter.drawArc(box, 90 * 16, -int(360 * 16 * self._value / 100))
|
||||||
|
painter.end()
|
||||||
|
|
||||||
|
|
||||||
|
class VennChart(QWidget):
|
||||||
|
"""Doctor / model-A / model-B herb sets with their real intersection counts."""
|
||||||
|
|
||||||
|
def __init__(self, parent: QWidget | None = None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self._sets: dict[str, int] = {}
|
||||||
|
self._labels = ("医生原方", "千问", "OpenAI")
|
||||||
|
self.setMinimumHeight(96)
|
||||||
|
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||||||
|
|
||||||
|
def set_counts(self, counts: Mapping[str, int], labels: tuple[str, str, str] | None = None) -> None:
|
||||||
|
"""Regions: doctor_only, qwen_only, openai_only, doctor_qwen, doctor_openai, qwen_openai, all."""
|
||||||
|
|
||||||
|
self._sets = {key: _count(value) for key, value in _mapping(counts).items()}
|
||||||
|
if labels:
|
||||||
|
self._labels = labels
|
||||||
|
total = sum(self._sets.values())
|
||||||
|
self.setAccessibleDescription(
|
||||||
|
"三方用药交集:" + " · ".join(f"{key} {value}" for key, value in self._sets.items()) if total
|
||||||
|
else "尚无可比较的候选药方")
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
def has_data(self) -> bool:
|
||||||
|
return any(self._sets.values())
|
||||||
|
|
||||||
|
def paintEvent(self, event: QPaintEvent) -> None: # noqa: N802 - Qt virtual
|
||||||
|
if not self.has_data():
|
||||||
|
return
|
||||||
|
painter = QPainter(self)
|
||||||
|
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
|
||||||
|
# The three set names sit above and below the circles, so their bands are reserved first.
|
||||||
|
top_band, bottom_band = 18.0, 18.0
|
||||||
|
available = max(40.0, self.height() - top_band - bottom_band)
|
||||||
|
side = min(self.width() * 0.62, available / 1.34)
|
||||||
|
radius = side / 2
|
||||||
|
offset = radius * 0.52
|
||||||
|
centre_x = self.width() / 2
|
||||||
|
centre_y = top_band + available / 2 - offset * 0.1
|
||||||
|
circles = (
|
||||||
|
(centre_x - offset, centre_y - offset * 0.55, COLOUR.doctor),
|
||||||
|
(centre_x + offset, centre_y - offset * 0.55, COLOUR.qwen),
|
||||||
|
(centre_x, centre_y + offset * 0.75, COLOUR.openai),
|
||||||
|
)
|
||||||
|
painter.setPen(Qt.PenStyle.NoPen)
|
||||||
|
for x, y, colour in circles:
|
||||||
|
fill = QColor(colour)
|
||||||
|
fill.setAlpha(52)
|
||||||
|
painter.setBrush(fill)
|
||||||
|
painter.drawEllipse(QPointF(x, y), radius, radius)
|
||||||
|
|
||||||
|
font = QFont(self.font())
|
||||||
|
font.setPixelSize(13)
|
||||||
|
font.setBold(True)
|
||||||
|
painter.setFont(font)
|
||||||
|
regions = (
|
||||||
|
("doctor_only", centre_x - offset * 1.5, centre_y - offset * 0.75, COLOUR.doctor),
|
||||||
|
("qwen_only", centre_x + offset * 1.5, centre_y - offset * 0.75, COLOUR.qwen),
|
||||||
|
("openai_only", centre_x, centre_y + offset * 1.45, COLOUR.openai),
|
||||||
|
("doctor_qwen", centre_x, centre_y - offset * 0.95, COLOUR.ink),
|
||||||
|
("doctor_openai", centre_x - offset * 0.85, centre_y + offset * 0.55, COLOUR.ink),
|
||||||
|
("qwen_openai", centre_x + offset * 0.85, centre_y + offset * 0.55, COLOUR.ink),
|
||||||
|
("all", centre_x, centre_y + offset * 0.1, COLOUR.ink),
|
||||||
|
)
|
||||||
|
for key, x, y, colour in regions:
|
||||||
|
value = self._sets.get(key, 0)
|
||||||
|
if not value:
|
||||||
|
continue
|
||||||
|
painter.setPen(QColor(colour))
|
||||||
|
painter.drawText(QRectF(x - 22, y - 10, 44, 20), Qt.AlignmentFlag.AlignCenter, str(value))
|
||||||
|
|
||||||
|
font.setPixelSize(11)
|
||||||
|
font.setBold(False)
|
||||||
|
painter.setFont(font)
|
||||||
|
painter.setPen(QColor(COLOUR.muted))
|
||||||
|
top = centre_y - offset * 0.55 - radius - 17
|
||||||
|
painter.setPen(QColor(COLOUR.doctor))
|
||||||
|
painter.drawText(QRectF(0, top, centre_x - 6, 16),
|
||||||
|
Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, self._labels[0])
|
||||||
|
painter.setPen(QColor(COLOUR.qwen))
|
||||||
|
painter.drawText(QRectF(centre_x + 6, top, centre_x - 6, 16),
|
||||||
|
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter, self._labels[1])
|
||||||
|
painter.setPen(QColor(COLOUR.openai))
|
||||||
|
painter.drawText(QRectF(0, centre_y + offset * 0.75 + radius + 1, self.width(), 16),
|
||||||
|
Qt.AlignmentFlag.AlignCenter, self._labels[2])
|
||||||
|
painter.end()
|
||||||
|
|
||||||
|
def minimumSizeHint(self) -> QSize:
|
||||||
|
return QSize(200, 96)
|
||||||
|
|
||||||
|
|
||||||
|
class DivergingDoses(QWidget):
|
||||||
|
"""Per-herb dose difference against the doctor's prescription, one row per herb."""
|
||||||
|
|
||||||
|
ROW = 30
|
||||||
|
|
||||||
|
def __init__(self, parent: QWidget | None = None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self._rows: list[dict[str, Any]] = []
|
||||||
|
self._span = 1.0
|
||||||
|
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
|
||||||
|
|
||||||
|
def set_rows(self, rows: list[Mapping[str, Any]]) -> None:
|
||||||
|
"""Each row: name, doctor, qwen, openai (floats or None), unit."""
|
||||||
|
|
||||||
|
self._rows = []
|
||||||
|
for row in rows:
|
||||||
|
value = _mapping(row)
|
||||||
|
entry = {"name": str(value.get("name") or ""), "unit": str(value.get("unit") or "")}
|
||||||
|
for key in ("doctor", "qwen", "openai"):
|
||||||
|
raw = value.get(key)
|
||||||
|
entry[key] = None if raw is None or isinstance(raw, bool) else float(raw)
|
||||||
|
self._rows.append(entry)
|
||||||
|
deltas = [abs((row[key] or 0) - (row["doctor"] or 0))
|
||||||
|
for row in self._rows for key in ("qwen", "openai")
|
||||||
|
if row[key] is not None and row["doctor"] is not None]
|
||||||
|
self._span = max(1.0, max(deltas, default=1.0))
|
||||||
|
self.setAccessibleDescription("剂量差异:" + " · ".join(
|
||||||
|
f"{row['name']} 千问 {self._delta_text(row, 'qwen')} OpenAI {self._delta_text(row, 'openai')}"
|
||||||
|
for row in self._rows) if self._rows else "暂无可比药味")
|
||||||
|
self.setMinimumHeight(self.ROW * max(1, len(self._rows)) + 18)
|
||||||
|
self.updateGeometry()
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
def _delta_text(self, row: Mapping[str, Any], key: str) -> str:
|
||||||
|
if row.get(key) is None or row.get("doctor") is None:
|
||||||
|
return "—"
|
||||||
|
delta = row[key] - row["doctor"]
|
||||||
|
return "一致" if abs(delta) < 1e-9 else f"{delta:+g}{row.get('unit') or ''}"
|
||||||
|
|
||||||
|
def paintEvent(self, event: QPaintEvent) -> None: # noqa: N802 - Qt virtual
|
||||||
|
if not self._rows:
|
||||||
|
return
|
||||||
|
painter = QPainter(self)
|
||||||
|
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
|
||||||
|
label_width, value_width = 96, 96
|
||||||
|
left = label_width + 10
|
||||||
|
right = self.width() - value_width - 10
|
||||||
|
middle = (left + right) / 2
|
||||||
|
scale = max(1.0, (right - left) / 2) / self._span
|
||||||
|
font = QFont(self.font())
|
||||||
|
font.setPixelSize(12)
|
||||||
|
painter.setFont(font)
|
||||||
|
for index, row in enumerate(self._rows):
|
||||||
|
top = index * self.ROW + 4
|
||||||
|
painter.setPen(QColor(COLOUR.ink))
|
||||||
|
painter.drawText(QRectF(0, top, label_width, self.ROW - 8),
|
||||||
|
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter, row["name"])
|
||||||
|
painter.setPen(QColor(COLOUR.track))
|
||||||
|
painter.drawLine(QPointF(middle, top), QPointF(middle, top + self.ROW - 10))
|
||||||
|
for offset, key, colour in ((0, "qwen", COLOUR.qwen), (10, "openai", COLOUR.openai)):
|
||||||
|
if row[key] is None or row["doctor"] is None:
|
||||||
|
continue
|
||||||
|
delta = (row[key] - row["doctor"]) * scale
|
||||||
|
bar = QRectF(min(middle, middle + delta), top + offset, max(abs(delta), 2.0), 8)
|
||||||
|
painter.setPen(Qt.PenStyle.NoPen)
|
||||||
|
painter.setBrush(QColor(colour))
|
||||||
|
painter.drawRoundedRect(bar, 3, 3)
|
||||||
|
painter.setPen(QColor(COLOUR.muted))
|
||||||
|
painter.drawText(QRectF(right + 8, top, value_width - 8, self.ROW - 8),
|
||||||
|
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter,
|
||||||
|
f"{self._delta_text(row, 'qwen')} / {self._delta_text(row, 'openai')}")
|
||||||
|
painter.end()
|
||||||
|
|
||||||
|
|
||||||
|
class WaffleCoverage(QWidget):
|
||||||
|
"""One square per attachment: read, limited, or not delivered."""
|
||||||
|
|
||||||
|
def __init__(self, parent: QWidget | None = None, *, columns: int = 11) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self._columns = max(4, columns)
|
||||||
|
self._read = self._limited = self._total = 0
|
||||||
|
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
|
||||||
|
|
||||||
|
def set_counts(self, read: int, limited: int, total: int | None = None) -> None:
|
||||||
|
self._read, self._limited = _count(read), _count(limited)
|
||||||
|
self._total = max(_count(total), self._read + self._limited)
|
||||||
|
self.setAccessibleDescription(
|
||||||
|
f"附件 {self._total} 个:已读 {self._read},受限或不支持 {self._limited}" if self._total else "本次没有附件")
|
||||||
|
rows = max(1, -(-self._total // self._columns)) if self._total else 0
|
||||||
|
self.setMinimumHeight(rows * 16 + max(0, rows - 1) * 4)
|
||||||
|
self.updateGeometry()
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
def has_data(self) -> bool:
|
||||||
|
return self._total > 0
|
||||||
|
|
||||||
|
def paintEvent(self, event: QPaintEvent) -> None: # noqa: N802 - Qt virtual
|
||||||
|
if not self._total:
|
||||||
|
return
|
||||||
|
painter = QPainter(self)
|
||||||
|
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
|
||||||
|
painter.setPen(Qt.PenStyle.NoPen)
|
||||||
|
gap = 4
|
||||||
|
size = max(8.0, min(16.0, (self.width() - gap * (self._columns - 1)) / self._columns))
|
||||||
|
for index in range(self._total):
|
||||||
|
column, row = index % self._columns, index // self._columns
|
||||||
|
colour = COLOUR.qwen if index < self._read else (COLOUR.limited if index < self._read + self._limited else COLOUR.track)
|
||||||
|
painter.setBrush(QColor(colour))
|
||||||
|
painter.drawRoundedRect(QRectF(column * (size + gap), row * (size + gap), size, size), 4, 4)
|
||||||
|
painter.end()
|
||||||
|
|
||||||
|
|
||||||
|
class TrendBars(QWidget):
|
||||||
|
"""Grouped bars per batch: one column per model, oldest batch first."""
|
||||||
|
|
||||||
|
def __init__(self, parent: QWidget | None = None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self._points: list[dict[str, Any]] = []
|
||||||
|
self.setMinimumHeight(170)
|
||||||
|
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
|
||||||
|
|
||||||
|
def set_points(self, points: list[Mapping[str, Any]]) -> None:
|
||||||
|
"""Each point: label plus qwen/openai scores; None keeps the slot visibly empty."""
|
||||||
|
|
||||||
|
self._points = []
|
||||||
|
for point in points:
|
||||||
|
value = _mapping(point)
|
||||||
|
entry = {"label": str(value.get("label") or "")}
|
||||||
|
for key in ("qwen", "openai"):
|
||||||
|
raw = value.get(key)
|
||||||
|
entry[key] = None if raw is None or isinstance(raw, bool) else float(raw)
|
||||||
|
self._points.append(entry)
|
||||||
|
described = [f"{entry['label']} 千问 {self._text(entry['qwen'])} OpenAI {self._text(entry['openai'])}"
|
||||||
|
for entry in self._points]
|
||||||
|
self.setAccessibleDescription("一致度趋势:" + (" · ".join(described) or "暂无批次"))
|
||||||
|
self.setToolTip("\n".join(described) or "暂无批次")
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _text(value: float | None) -> str:
|
||||||
|
return "—" if value is None else f"{value:.1f}%"
|
||||||
|
|
||||||
|
def has_data(self) -> bool:
|
||||||
|
return any(point.get(key) is not None for point in self._points for key in ("qwen", "openai"))
|
||||||
|
|
||||||
|
def paintEvent(self, event: QPaintEvent) -> None: # noqa: N802 - Qt virtual
|
||||||
|
if not self._points:
|
||||||
|
return
|
||||||
|
painter = QPainter(self)
|
||||||
|
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
|
||||||
|
# The top band is left free so each bar can print its own figure above it.
|
||||||
|
left, right, top, bottom = 44.0, self.width() - 10.0, 24.0, self.height() - 24.0
|
||||||
|
values = [point[key] for point in self._points for key in ("qwen", "openai") if point[key] is not None]
|
||||||
|
ceiling = max(10.0, max(values, default=10.0))
|
||||||
|
font = QFont(self.font())
|
||||||
|
font.setPixelSize(10)
|
||||||
|
painter.setFont(font)
|
||||||
|
for fraction in (0.0, 0.5, 1.0):
|
||||||
|
y = bottom - (bottom - top) * fraction
|
||||||
|
painter.setPen(QColor(COLOUR.track))
|
||||||
|
painter.drawLine(QPointF(left, y), QPointF(right, y))
|
||||||
|
painter.setPen(QColor(COLOUR.muted))
|
||||||
|
painter.drawText(QRectF(0, y - 8, left - 6, 16),
|
||||||
|
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter,
|
||||||
|
f"{ceiling * fraction:.0f}%")
|
||||||
|
slot = (right - left) / max(1, len(self._points))
|
||||||
|
width = min(26.0, slot / 3.6)
|
||||||
|
# The two bars of a batch sit side by side, far enough apart for each figure to fit above.
|
||||||
|
gap = width / 2 + 4
|
||||||
|
for index, point in enumerate(self._points):
|
||||||
|
centre = left + slot * (index + 0.5)
|
||||||
|
for offset, key, colour in ((-gap, "qwen", COLOUR.qwen), (gap, "openai", COLOUR.openai)):
|
||||||
|
value = point[key]
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
height = (bottom - top) * min(1.0, value / ceiling)
|
||||||
|
painter.setPen(Qt.PenStyle.NoPen)
|
||||||
|
painter.setBrush(QColor(colour))
|
||||||
|
painter.drawRoundedRect(QRectF(centre + offset - width / 2, bottom - height, width, height), 3, 3)
|
||||||
|
# The design prints each figure above its bar, in that model's own colour.
|
||||||
|
painter.setPen(QColor(colour))
|
||||||
|
painter.drawText(QRectF(centre + offset - 21, bottom - height - 17, 42, 14),
|
||||||
|
Qt.AlignmentFlag.AlignCenter, f"{value:.1f}%")
|
||||||
|
painter.setPen(QColor(COLOUR.muted))
|
||||||
|
painter.drawText(QRectF(centre - slot / 2, bottom + 4, slot, 16),
|
||||||
|
Qt.AlignmentFlag.AlignCenter, point["label"])
|
||||||
|
painter.end()
|
||||||
@@ -0,0 +1,644 @@
|
|||||||
|
"""Native, read-only prescription comparison using saved report snapshots only."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from copy import deepcopy
|
||||||
|
from dataclasses import dataclass, replace
|
||||||
|
from decimal import Decimal, InvalidOperation
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from PySide6.QtCore import QRectF, QSize, Qt
|
||||||
|
from PySide6.QtGui import QColor, QFont, QPainter, QPaintEvent
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QAbstractItemView,
|
||||||
|
QButtonGroup,
|
||||||
|
QFrame,
|
||||||
|
QHBoxLayout,
|
||||||
|
QHeaderView,
|
||||||
|
QLabel,
|
||||||
|
QLineEdit,
|
||||||
|
QPushButton,
|
||||||
|
QScrollArea,
|
||||||
|
QSizePolicy,
|
||||||
|
QTableWidget,
|
||||||
|
QTableWidgetItem,
|
||||||
|
QVBoxLayout,
|
||||||
|
QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .issued_prescription_ai_labels import EXTRA_FIELD_LABELS, SYSTEM_LABELS, system_text
|
||||||
|
from .issued_prescription_ai_progress import ACTIVE_STATES, SUCCESS_STATES
|
||||||
|
|
||||||
|
MODEL_NAMES = {"qwen": "千问", "openai": "OpenAI"}
|
||||||
|
MODEL_COLORS = {"qwen": "#3676C8", "openai": "#268578"}
|
||||||
|
DOCTOR_COLOR = "#526479"
|
||||||
|
INK, MUTED, LINE = "#23384C", "#758395", "#E6EDF3"
|
||||||
|
_UNITS = {
|
||||||
|
"g": "克", "克": "克", "mg": "毫克", "毫克": "毫克", "kg": "千克", "千克": "千克",
|
||||||
|
"ml": "毫升", "毫升": "毫升", "l": "升", "升": "升", "iu": "国际单位", "国际单位": "国际单位",
|
||||||
|
**{unit: unit for unit in ("片", "丸", "粒", "袋", "包", "支", "滴", "枚", "个", "条", "付", "帖", "两", "钱")},
|
||||||
|
}
|
||||||
|
_BASES = {"per_dose": "每剂", "每剂": "每剂", "per_day": "每日", "每日": "每日", "每天": "每日"}
|
||||||
|
_FORMULAS = {"main": "主方", "1": "主方", "主方": "主方", "aux": "辅方", "auxiliary": "辅方", "2": "辅方", "辅方": "辅方"}
|
||||||
|
_STALE = {
|
||||||
|
"stale": "处方已变更", "superseded": "处方已变更", "prescription_changed": "处方已变更",
|
||||||
|
"source_updated": "资料已更新", "invalid": "报告已失效", "voided": "处方已作废",
|
||||||
|
"deleted": "处方已删除", "revoked": "资料权限已变更",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _mapping(value: Any) -> dict[str, Any]:
|
||||||
|
return dict(value) if isinstance(value, Mapping) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _text(value: Any) -> str:
|
||||||
|
"""Never stringify a structured payload, which could expose internal identifiers."""
|
||||||
|
return str(value).strip() if isinstance(value, (str, int, float, Decimal)) and not isinstance(value, bool) else ""
|
||||||
|
|
||||||
|
|
||||||
|
def _reason(value: Any) -> str:
|
||||||
|
if isinstance(value, Mapping):
|
||||||
|
return _reason(value.get("reason") or value.get("message") or value.get("code"))
|
||||||
|
if isinstance(value, list):
|
||||||
|
return ";".join(filter(None, (_reason(item) for item in value)))
|
||||||
|
return system_text(value, SYSTEM_LABELS, EXTRA_FIELD_LABELS, strict=True) if _text(value) else ""
|
||||||
|
|
||||||
|
|
||||||
|
def _number(value: Any) -> Decimal | None:
|
||||||
|
if not _text(value):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
number = Decimal(str(value))
|
||||||
|
except (InvalidOperation, ValueError):
|
||||||
|
return None
|
||||||
|
return number if number.is_finite() and number >= 0 else None
|
||||||
|
|
||||||
|
|
||||||
|
def _formula(value: Any) -> str:
|
||||||
|
return _FORMULAS.get(_text(value), "主辅方未注明")
|
||||||
|
|
||||||
|
|
||||||
|
def _metadata(value: Any) -> str:
|
||||||
|
return system_text(value, SYSTEM_LABELS, EXTRA_FIELD_LABELS, strict=True) if _text(value) else ""
|
||||||
|
|
||||||
|
|
||||||
|
def _join_instructions(values: list[str]) -> str:
|
||||||
|
parts = [part.strip() for value in values for part in value.split(";")]
|
||||||
|
return ";".join(dict.fromkeys(part for part in parts if part and part.lower() not in {"无", "明确无", "none"}))
|
||||||
|
|
||||||
|
|
||||||
|
def _instructions(snapshot: dict[str, Any]) -> str:
|
||||||
|
"""Read both original herb fields and the service's normalized usage snapshot."""
|
||||||
|
fields = ("instructions", "decoction_instruction", "special_usage", "usage_instruction", "usage_time", "usage_way")
|
||||||
|
usage = _mapping(snapshot.get("usage"))
|
||||||
|
values = [_text(owner.get(field)) for owner in (snapshot, usage) for field in fields]
|
||||||
|
if not usage:
|
||||||
|
values.append(_text(snapshot.get("usage")))
|
||||||
|
return _join_instructions(values)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class _Dose:
|
||||||
|
raw: Any
|
||||||
|
unit: str
|
||||||
|
basis: str
|
||||||
|
formula: str
|
||||||
|
processing: str
|
||||||
|
route: str
|
||||||
|
group: str
|
||||||
|
instructions: str = ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def number(self) -> Decimal | None:
|
||||||
|
return _number(self.raw)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def scale(self) -> tuple[str, str] | None:
|
||||||
|
unit, basis = _UNITS.get(self.unit.lower()), _BASES.get(self.basis)
|
||||||
|
return (unit, basis) if unit and basis else None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def label(self) -> str:
|
||||||
|
amount = _text(self.raw)
|
||||||
|
if not amount:
|
||||||
|
return "—"
|
||||||
|
unit = _UNITS.get(self.unit.lower()) or _metadata(self.unit) or "单位未注明"
|
||||||
|
basis = _BASES.get(self.basis) or "基准未确认"
|
||||||
|
return f"{amount} {unit} / {basis}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def identity(self) -> tuple[str, str, str, str]:
|
||||||
|
# Unknown enum values must remain distinct even when their display label is generic.
|
||||||
|
return (_FORMULAS.get(self.formula, self.formula), SYSTEM_LABELS.get(self.processing, self.processing), SYSTEM_LABELS.get(self.route, self.route), self.group)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class _Row:
|
||||||
|
name: str
|
||||||
|
context: str
|
||||||
|
doctor: _Dose | None
|
||||||
|
candidate: _Dose | None
|
||||||
|
match_type: str
|
||||||
|
origin: str = "comparison"
|
||||||
|
source_details: str = ""
|
||||||
|
source_names: tuple[str, ...] = ()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def scale(self) -> tuple[str, str] | None:
|
||||||
|
if self.origin != "comparison":
|
||||||
|
return None
|
||||||
|
doses = [dose for dose in (self.doctor, self.candidate) if dose is not None]
|
||||||
|
if not doses or any(dose.scale is None for dose in doses):
|
||||||
|
return None
|
||||||
|
if len({dose.scale for dose in doses}) != 1 or len({dose.identity for dose in doses}) != 1:
|
||||||
|
return None
|
||||||
|
return doses[0].scale
|
||||||
|
|
||||||
|
@property
|
||||||
|
def incompatibility(self) -> str:
|
||||||
|
if self.origin == "uncompared":
|
||||||
|
return "未纳入对比,仅保留候选原方;医生剂量未知"
|
||||||
|
if self.origin == "original":
|
||||||
|
return "候选原方附列;对应关系未保存,不推断同药"
|
||||||
|
if self.doctor is not None and self.candidate is not None:
|
||||||
|
if self.doctor.identity != self.candidate.identity:
|
||||||
|
return "主辅方、炮制或给药分组不同,不作条形比较"
|
||||||
|
if self.doctor.scale and self.candidate.scale and self.doctor.scale != self.candidate.scale:
|
||||||
|
return "单位或剂量基准不同,不作条形比较"
|
||||||
|
return "单位或每剂/每日基准未明确,不作条形比较"
|
||||||
|
|
||||||
|
def usage_description(self, model_name: str) -> str:
|
||||||
|
return ";".join(filter(None, ("医生:" + self.doctor.instructions if self.doctor and self.doctor.instructions else "", model_name + ":" + self.candidate.instructions if self.candidate and self.candidate.instructions else "")))
|
||||||
|
|
||||||
|
def description(self, model_name: str) -> str:
|
||||||
|
dosage = f"{self.name},{self.context};医生:{self.doctor.label if self.doctor else '—'};{model_name}:{self.candidate.label if self.candidate else '—'}"
|
||||||
|
return ";".join(filter(None, (dosage, self.usage_description(model_name), self.source_details)))
|
||||||
|
|
||||||
|
|
||||||
|
def _dose(row: dict[str, Any], side: str) -> _Dose | None:
|
||||||
|
# An explicit null snapshot takes precedence over contradictory legacy flat fields.
|
||||||
|
if side in row and row[side] is None:
|
||||||
|
return None
|
||||||
|
nested = _mapping(row.get(side))
|
||||||
|
keys = ("doctor_dosage", "doctor_dose") if side == "doctor" else ("candidate_dosage", "candidate_dose", "ai_dose")
|
||||||
|
raw = nested.get("dosage") if "dosage" in nested else next((row[key] for key in keys if key in row), None)
|
||||||
|
if not nested and raw is None:
|
||||||
|
return None
|
||||||
|
values = {key: _text(nested[key] if key in nested else row.get(key)) for key in ("unit", "dose_basis", "formula_type", "processing", "administration_route", "group")}
|
||||||
|
instructions = _instructions(nested) or _instructions(row)
|
||||||
|
return _Dose(raw, values["unit"], values["dose_basis"], values["formula_type"], values["processing"], values["administration_route"], values["group"], instructions)
|
||||||
|
|
||||||
|
|
||||||
|
def _row(value: dict[str, Any]) -> _Row:
|
||||||
|
doctor, candidate = _dose(value, "doctor"), _dose(value, "candidate")
|
||||||
|
details = []
|
||||||
|
for dose in (doctor, candidate):
|
||||||
|
if dose is not None:
|
||||||
|
detail = " · ".join(filter(None, (_formula(dose.formula), _metadata(dose.processing), _metadata(dose.route), _metadata(dose.group))))
|
||||||
|
if detail not in details:
|
||||||
|
details.append(detail)
|
||||||
|
context = " / ".join(details) or _formula(value.get("formula_type"))
|
||||||
|
if len(details) > 1:
|
||||||
|
context = "医生与模型:" + context
|
||||||
|
return _Row(_text(value.get("name") or value.get("canonical_name") or value.get("herb_name")) or "药名未保存", context, doctor, candidate, _text(value.get("match_type")))
|
||||||
|
|
||||||
|
|
||||||
|
def _original_row(herb: Any, *, origin: str) -> _Row:
|
||||||
|
saved = dict(herb) if isinstance(herb, Mapping) else {"name": _text(herb) or "药材记录需核对"}
|
||||||
|
row = _row({"name": saved.get("name"), "doctor": None, "candidate": saved, "match_type": "candidate_only"})
|
||||||
|
note = "未纳入对比" if origin == "uncompared" else "候选原方附列 · 对应关系未保存"
|
||||||
|
return replace(row, origin=origin, context=note + " · " + row.context)
|
||||||
|
|
||||||
|
|
||||||
|
def _saved_rows(candidate: dict[str, Any], comparison: dict[str, Any]) -> list[_Row]:
|
||||||
|
saved = comparison.get("rows")
|
||||||
|
comparison_rows = [dict(value) for value in saved if isinstance(value, Mapping)] if isinstance(saved, list) else []
|
||||||
|
herbs = candidate.get("herbs")
|
||||||
|
herbs = herbs if isinstance(herbs, list) else []
|
||||||
|
result: list[_Row] = []
|
||||||
|
covered: set[int] = set()
|
||||||
|
missing_correspondence = False
|
||||||
|
for value in comparison_rows:
|
||||||
|
row = _row(value)
|
||||||
|
if row.candidate is not None and herbs:
|
||||||
|
indices = _mapping(value.get("candidate")).get("source_rows")
|
||||||
|
# The service uses zero-based array_values indices; never infer correspondence
|
||||||
|
# from names, doses, order, or the number of normalized rows.
|
||||||
|
valid_trace = isinstance(indices, list) and bool(indices) and all(isinstance(index, int) and not isinstance(index, bool) and 0 <= index < len(herbs) for index in indices)
|
||||||
|
if valid_trace:
|
||||||
|
indices = list(dict.fromkeys(indices))
|
||||||
|
covered.update(indices)
|
||||||
|
originals = [_original_row(herbs[index], origin="original") for index in indices]
|
||||||
|
details = ";".join(f"第 {index + 1} 项 {original.name} {original.candidate.label if original.candidate else '—'}" + ("," + original.candidate.instructions if original.candidate and original.candidate.instructions else "") for index, original in zip(indices, originals, strict=True))
|
||||||
|
instructions = _join_instructions([row.candidate.instructions, *[original.candidate.instructions for original in originals if original.candidate]])
|
||||||
|
row = replace(row, candidate=replace(row.candidate, instructions=instructions), source_details="候选原方记录:" + details, source_names=tuple(original.name for original in originals))
|
||||||
|
else:
|
||||||
|
missing_correspondence = True
|
||||||
|
result.append(row)
|
||||||
|
# Normalization can omit unknown names, processing conflicts, or incompatible duplicate
|
||||||
|
# entries. Keep every unaccounted original, but do not claim its doctor counterpart.
|
||||||
|
origin = "original" if missing_correspondence else "uncompared"
|
||||||
|
result.extend(_original_row(herb, origin=origin) for index, herb in enumerate(herbs) if index not in covered)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
class _DoseChart(QWidget):
|
||||||
|
"""A scrollable painted chart; the adjacent table provides native accessibility."""
|
||||||
|
|
||||||
|
ROW_HEIGHT = 80
|
||||||
|
GROUP_HEIGHT = 38
|
||||||
|
|
||||||
|
def __init__(self, parent: QWidget) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setObjectName("PrescriptionDoseChart")
|
||||||
|
self.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred)
|
||||||
|
self.setAccessibleName("药方逐味剂量对比图")
|
||||||
|
self.rows: list[_Row] = []
|
||||||
|
self.groups: list[tuple[tuple[str, str] | None, list[_Row], Decimal]] = []
|
||||||
|
self.model_key = "qwen"
|
||||||
|
self.message = "暂无药方数据"
|
||||||
|
self.bars_enabled = False
|
||||||
|
|
||||||
|
def set_rows(self, rows: list[_Row], model_key: str, *, bars_enabled: bool, message: str) -> None:
|
||||||
|
self.rows, self.model_key, self.bars_enabled, self.message = rows, model_key, bars_enabled, message
|
||||||
|
grouped: dict[tuple[str, str] | None, list[_Row]] = {}
|
||||||
|
for row in rows:
|
||||||
|
grouped.setdefault(row.scale if bars_enabled else None, []).append(row)
|
||||||
|
self.groups = []
|
||||||
|
for scale, members in grouped.items():
|
||||||
|
numbers = [dose.number for row in members for dose in (row.doctor, row.candidate) if dose is not None and dose.number is not None]
|
||||||
|
self.groups.append((scale, members, max(numbers, default=Decimal(0))))
|
||||||
|
color_name = "蓝色" if model_key == "qwen" else "青绿色"
|
||||||
|
descriptions = [message, f"医生为深灰蓝;{MODEL_NAMES[model_key]}为{color_name}。各单位与基准组独立标尺,组间长度不可比较。缺失值为—,不按零计算。"]
|
||||||
|
for row in rows:
|
||||||
|
descriptions.append(row.description(MODEL_NAMES[model_key]))
|
||||||
|
if bars_enabled and row.scale is None:
|
||||||
|
descriptions.append(row.incompatibility)
|
||||||
|
if any(dose is not None and _text(dose.raw) and dose.number is None for dose in (row.doctor, row.candidate)):
|
||||||
|
descriptions.append("非数值、负数及非有限剂量保留原值,未绘制条形。")
|
||||||
|
self.setAccessibleDescription("\n".join(descriptions))
|
||||||
|
height = 16 + sum(self.GROUP_HEIGHT + len(members) * self.ROW_HEIGHT for _, members, _ in self.groups)
|
||||||
|
self.setMinimumHeight(max(120, height))
|
||||||
|
self.updateGeometry()
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
def sizeHint(self) -> QSize:
|
||||||
|
return QSize(440, self.minimumHeight())
|
||||||
|
|
||||||
|
def minimumSizeHint(self) -> QSize:
|
||||||
|
return QSize(0, 0)
|
||||||
|
|
||||||
|
def paintEvent(self, event: QPaintEvent) -> None:
|
||||||
|
painter = QPainter(self)
|
||||||
|
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||||
|
painter.fillRect(self.rect(), QColor("#FFFFFF"))
|
||||||
|
painter.setFont(self.font())
|
||||||
|
if not self.rows:
|
||||||
|
painter.setPen(QColor(MUTED))
|
||||||
|
painter.drawText(self.rect().adjusted(24, 12, -24, -12), Qt.AlignmentFlag.AlignCenter | Qt.TextFlag.TextWordWrap, self.message)
|
||||||
|
return
|
||||||
|
width, y = max(0, self.width() - 32), 8
|
||||||
|
normal = QFont(self.font())
|
||||||
|
bold = QFont(normal)
|
||||||
|
bold.setBold(True)
|
||||||
|
for scale, members, maximum in self.groups:
|
||||||
|
painter.fillRect(QRectF(16, y, width, self.GROUP_HEIGHT - 8), QColor("#F3F6F9"))
|
||||||
|
painter.setFont(bold)
|
||||||
|
painter.setPen(QColor(INK))
|
||||||
|
heading = f"{scale[0]} · {scale[1]} 独立标尺 0–{maximum}" if scale else "原始剂量 · 不作条形比较"
|
||||||
|
painter.drawText(QRectF(24, y, max(0, width - 16), self.GROUP_HEIGHT - 8), Qt.AlignmentFlag.AlignVCenter, painter.fontMetrics().elidedText(heading, Qt.TextElideMode.ElideRight, max(0, width - 16)))
|
||||||
|
y += self.GROUP_HEIGHT
|
||||||
|
for row in members:
|
||||||
|
if y + self.ROW_HEIGHT >= event.rect().top() and y <= event.rect().bottom():
|
||||||
|
painter.setFont(bold)
|
||||||
|
painter.setPen(QColor(INK))
|
||||||
|
title = f"{row.name} · {row.context}"
|
||||||
|
painter.drawText(QRectF(16, y, width, 21), Qt.AlignmentFlag.AlignVCenter, painter.fontMetrics().elidedText(title, Qt.TextElideMode.ElideRight, width))
|
||||||
|
painter.setFont(normal)
|
||||||
|
if scale is None:
|
||||||
|
painter.setPen(QColor(MUTED))
|
||||||
|
detail = ";".join(("医生 " + (row.doctor.label if row.doctor else "—"), MODEL_NAMES[self.model_key] + " " + (row.candidate.label if row.candidate else "—")))
|
||||||
|
painter.drawText(QRectF(16, y + 24, width, 22), Qt.AlignmentFlag.AlignVCenter, painter.fontMetrics().elidedText(detail, Qt.TextElideMode.ElideRight, width))
|
||||||
|
note = row.incompatibility if self.bars_enabled else "按已保存原值列示,详见左侧药材表"
|
||||||
|
painter.drawText(QRectF(16, y + 49, width, 22), Qt.AlignmentFlag.AlignVCenter, painter.fontMetrics().elidedText(note, Qt.TextElideMode.ElideRight, width))
|
||||||
|
else:
|
||||||
|
for index, (dose, color, name) in enumerate(((row.doctor, DOCTOR_COLOR, "医生"), (row.candidate, MODEL_COLORS[self.model_key], MODEL_NAMES[self.model_key]))):
|
||||||
|
bar_y = y + 24 + index * 20
|
||||||
|
painter.setPen(QColor(color))
|
||||||
|
painter.drawText(QRectF(16, bar_y - 5, 54, 22), Qt.AlignmentFlag.AlignVCenter, name)
|
||||||
|
value = dose.label if dose else "—"
|
||||||
|
label_width = min(max(118, painter.fontMetrics().horizontalAdvance(value) + 8), max(118, width // 2))
|
||||||
|
bar_x, bar_width = 76, max(8, width - 66 - label_width - 12)
|
||||||
|
if dose is not None and dose.number is not None:
|
||||||
|
painter.setPen(Qt.PenStyle.NoPen)
|
||||||
|
painter.setBrush(QColor("#F0F3F7"))
|
||||||
|
painter.drawRoundedRect(QRectF(bar_x, bar_y, bar_width, 9), 3, 3)
|
||||||
|
fraction = float(dose.number / maximum) if maximum else 0.0
|
||||||
|
if fraction > 0:
|
||||||
|
painter.setBrush(QColor(color))
|
||||||
|
painter.drawRoundedRect(QRectF(bar_x, bar_y, bar_width * fraction, 9), 3, 3)
|
||||||
|
painter.setPen(QColor(INK))
|
||||||
|
painter.drawText(QRectF(bar_x + bar_width + 10, bar_y - 5, label_width, 22), Qt.AlignmentFlag.AlignVCenter, painter.fontMetrics().elidedText(value, Qt.TextElideMode.ElideRight, int(label_width)))
|
||||||
|
painter.setPen(QColor(LINE))
|
||||||
|
painter.drawLine(16, y + self.ROW_HEIGHT - 6, self.width() - 16, y + self.ROW_HEIGHT - 6)
|
||||||
|
y += self.ROW_HEIGHT
|
||||||
|
|
||||||
|
|
||||||
|
class PrescriptionComparisonPanel(QWidget):
|
||||||
|
"""Switch between saved candidate prescriptions without issuing any requests."""
|
||||||
|
|
||||||
|
def __init__(self, parent: QWidget | None = None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setObjectName("PrescriptionComparisonPanel")
|
||||||
|
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||||||
|
self._batch: dict[str, Any] = {}
|
||||||
|
self._rows: list[_Row] = []
|
||||||
|
self._model_key = "qwen"
|
||||||
|
self._bars_enabled = False
|
||||||
|
self._chart_message = "暂无药方数据"
|
||||||
|
self._candidate_count: int | None = None
|
||||||
|
self._updating_rows = False
|
||||||
|
self.setStyleSheet(f"""
|
||||||
|
QWidget#PrescriptionComparisonPanel {{ background: transparent; }}
|
||||||
|
QFrame#PrescriptionPane {{ background: white; border: 1px solid {LINE}; border-radius: 9px; }}
|
||||||
|
QLabel {{ color: {INK}; background: transparent; }}
|
||||||
|
QLabel#PrescriptionTitle {{ font-size: 20px; font-weight: 700; }}
|
||||||
|
QLabel#PrescriptionCaption {{ color: {MUTED}; font-size: 12px; }}
|
||||||
|
QLabel#PrescriptionStatus {{ color: #5F7287; font-size: 12px; }}
|
||||||
|
QPushButton#PrescriptionModel {{ border: 1px solid #DAE4EE; background: white; color: #607388;
|
||||||
|
border-radius: 6px; padding: 6px 19px; font-weight: 600; }}
|
||||||
|
QPushButton#PrescriptionModel[model="qwen"]:checked {{ background: #EDF4FE; color: #3676C8; border-color: #AAC7EB; }}
|
||||||
|
QPushButton#PrescriptionModel[model="openai"]:checked {{ background: #EDF7F4; color: #268578; border-color: #A5D2C6; }}
|
||||||
|
QLineEdit#PrescriptionSearch {{ background: white; border: 1px solid #DAE4EE; border-radius: 6px; padding: 6px 10px; }}
|
||||||
|
QTableWidget#PrescriptionHerbs {{ background: white; border: 0; color: {INK}; gridline-color: {LINE}; }}
|
||||||
|
QTableWidget#PrescriptionHerbs::item {{ padding: 4px 7px; border-bottom: 1px solid #EDF1F5; }}
|
||||||
|
QTableWidget#PrescriptionHerbs::item:selected {{ background: #EDF4FC; color: {INK}; }}
|
||||||
|
QHeaderView::section {{ background: #F4F7FA; color: #687C91; border: 0; padding: 7px; font-weight: 600; }}
|
||||||
|
QScrollArea {{ background: white; border: 0; }}
|
||||||
|
""")
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
layout.setContentsMargins(10, 8, 10, 8)
|
||||||
|
layout.setSpacing(10)
|
||||||
|
toolbar = QHBoxLayout()
|
||||||
|
toolbar.setSpacing(7)
|
||||||
|
self.model_buttons: dict[str, QPushButton] = {}
|
||||||
|
self.model_group = QButtonGroup(self)
|
||||||
|
for model_key, name in MODEL_NAMES.items():
|
||||||
|
button = QPushButton(name, self)
|
||||||
|
button.setObjectName("PrescriptionModel")
|
||||||
|
button.setProperty("model", model_key)
|
||||||
|
button.setCheckable(True)
|
||||||
|
button.setChecked(model_key == self._model_key)
|
||||||
|
button.setAccessibleName(f"查看{name}候选方与医生方对比")
|
||||||
|
button.clicked.connect(lambda _checked=False, key=model_key: self._select_model(key))
|
||||||
|
self.model_group.addButton(button)
|
||||||
|
self.model_buttons[model_key] = button
|
||||||
|
toolbar.addWidget(button)
|
||||||
|
toolbar.addStretch(1)
|
||||||
|
self.search = QLineEdit(self)
|
||||||
|
self.search.setObjectName("PrescriptionSearch")
|
||||||
|
self.search.setPlaceholderText("搜索药名")
|
||||||
|
self.search.setAccessibleName("搜索药名,同时筛选药材表和图表")
|
||||||
|
self.search.setClearButtonEnabled(True)
|
||||||
|
self.search.setMaximumWidth(240)
|
||||||
|
self.search.textChanged.connect(self._filter_rows)
|
||||||
|
toolbar.addWidget(self.search)
|
||||||
|
layout.addLayout(toolbar)
|
||||||
|
panes = QHBoxLayout()
|
||||||
|
panes.setSpacing(12)
|
||||||
|
left = QFrame(self)
|
||||||
|
left.setObjectName("PrescriptionPane")
|
||||||
|
left.setMinimumWidth(0)
|
||||||
|
left.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Expanding)
|
||||||
|
left_layout = QVBoxLayout(left)
|
||||||
|
left_layout.setContentsMargins(15, 13, 15, 10)
|
||||||
|
left_layout.setSpacing(6)
|
||||||
|
self.caption_label = self._label("千问 · 候选药方", left, "PrescriptionCaption")
|
||||||
|
self.name_label = self._label("暂无候选药方", left, "PrescriptionTitle")
|
||||||
|
self.name_label.setWordWrap(True)
|
||||||
|
self.name_label.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred)
|
||||||
|
self.usage_label = self._label("", left, "PrescriptionCaption")
|
||||||
|
self.usage_label.setWordWrap(True)
|
||||||
|
left_layout.addWidget(self.caption_label)
|
||||||
|
left_layout.addWidget(self.name_label)
|
||||||
|
left_layout.addWidget(self.usage_label)
|
||||||
|
self.herb_table = QTableWidget(0, 3, left)
|
||||||
|
self.herb_table.setObjectName("PrescriptionHerbs")
|
||||||
|
self.herb_table.setAccessibleName("候选药方药材与医生剂量对照表")
|
||||||
|
self.herb_table.setHorizontalHeaderLabels(["药材 / 主辅方", "医生剂量", "千问剂量"])
|
||||||
|
self.herb_table.verticalHeader().hide()
|
||||||
|
self.herb_table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
|
||||||
|
self.herb_table.horizontalHeader().setMinimumSectionSize(36)
|
||||||
|
self.herb_table.setShowGrid(False)
|
||||||
|
self.herb_table.setWordWrap(True)
|
||||||
|
self.herb_table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
|
||||||
|
self.herb_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
||||||
|
self.herb_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
|
||||||
|
self.herb_table.itemSelectionChanged.connect(self._focus_chart_row)
|
||||||
|
self.herb_table.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
|
||||||
|
self.herb_table.setMinimumSize(0, 0)
|
||||||
|
self.herb_table.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Expanding)
|
||||||
|
left_layout.addWidget(self.herb_table, 1)
|
||||||
|
self.empty_label = self._label("选择报告后显示已保存的候选药方", left, "PrescriptionCaption")
|
||||||
|
self.empty_label.setWordWrap(True)
|
||||||
|
left_layout.addWidget(self.empty_label)
|
||||||
|
self.count_label = self._label("", left, "PrescriptionCaption")
|
||||||
|
self.count_label.setWordWrap(True)
|
||||||
|
left_layout.addWidget(self.count_label)
|
||||||
|
panes.addWidget(left, 5)
|
||||||
|
right = QFrame(self)
|
||||||
|
right.setObjectName("PrescriptionPane")
|
||||||
|
right.setMinimumWidth(0)
|
||||||
|
right.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Expanding)
|
||||||
|
right_layout = QVBoxLayout(right)
|
||||||
|
right_layout.setContentsMargins(15, 13, 15, 10)
|
||||||
|
right_layout.setSpacing(6)
|
||||||
|
title = self._label("逐味剂量对比", right)
|
||||||
|
title.setStyleSheet("font-size: 15px; font-weight: 700;")
|
||||||
|
legend_row = QHBoxLayout()
|
||||||
|
legend_row.addWidget(title)
|
||||||
|
legend_row.addStretch()
|
||||||
|
self.legend = self._label("● 医生 ● 千问", right, "PrescriptionCaption")
|
||||||
|
self.legend.setTextFormat(Qt.TextFormat.RichText)
|
||||||
|
legend_row.addWidget(self.legend)
|
||||||
|
right_layout.addLayout(legend_row)
|
||||||
|
self.status_label = self._label("", right, "PrescriptionStatus")
|
||||||
|
self.status_label.setWordWrap(True)
|
||||||
|
self.status_label.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred)
|
||||||
|
right_layout.addWidget(self.status_label)
|
||||||
|
self.chart_scroll = QScrollArea(right)
|
||||||
|
self.chart_scroll.setWidgetResizable(True)
|
||||||
|
self.chart_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||||
|
self.chart_scroll.setMinimumSize(0, 0)
|
||||||
|
self.chart_scroll.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Expanding)
|
||||||
|
self.chart_scroll.setAccessibleName("可滚动查看全部药味的剂量图")
|
||||||
|
self.chart = _DoseChart(self.chart_scroll)
|
||||||
|
self.chart_scroll.setWidget(self.chart)
|
||||||
|
right_layout.addWidget(self.chart_scroll, 1)
|
||||||
|
footnote = self._label("同组同标尺,组间勿比较;剂量差异不代表医疗优劣。", right, "PrescriptionCaption")
|
||||||
|
footnote.setWordWrap(True)
|
||||||
|
right_layout.addWidget(footnote)
|
||||||
|
panes.addWidget(right, 5)
|
||||||
|
layout.addLayout(panes, 1)
|
||||||
|
self._render()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _label(text: str, parent: QWidget, name: str = "") -> QLabel:
|
||||||
|
label = QLabel(text, parent)
|
||||||
|
label.setTextFormat(Qt.TextFormat.PlainText)
|
||||||
|
label.setObjectName(name)
|
||||||
|
return label
|
||||||
|
|
||||||
|
@property
|
||||||
|
def selected_model(self) -> str:
|
||||||
|
return self._model_key
|
||||||
|
|
||||||
|
def minimumSizeHint(self) -> QSize:
|
||||||
|
return QSize(0, 0)
|
||||||
|
|
||||||
|
def set_batch(self, batch: dict) -> None:
|
||||||
|
data = _mapping(batch)
|
||||||
|
if data == self._batch:
|
||||||
|
return
|
||||||
|
self._batch = deepcopy(data)
|
||||||
|
self._render()
|
||||||
|
|
||||||
|
def _select_model(self, model_key: str) -> None:
|
||||||
|
if model_key == self._model_key:
|
||||||
|
return
|
||||||
|
self._model_key = model_key
|
||||||
|
self._render()
|
||||||
|
|
||||||
|
def _state(self, model: dict[str, Any], candidate: dict[str, Any], comparison: dict[str, Any]) -> tuple[bool, str]:
|
||||||
|
if not self._batch:
|
||||||
|
return False, "选择一份报告后,查看已保存的候选药方与剂量对比。"
|
||||||
|
validity = _text(self._batch.get("validity"))
|
||||||
|
if validity and validity not in {"current", "valid"}:
|
||||||
|
return False, (_STALE.get(validity) or "报告有效性未确认") + ";仅查看历史原值,暂停条形比较。"
|
||||||
|
status = _text(model.get("status"))
|
||||||
|
candidate_status = _text(candidate.get("status"))
|
||||||
|
if status in ACTIVE_STATES or candidate_status in ACTIVE_STATES:
|
||||||
|
return False, f"{MODEL_NAMES[self._model_key]}正在生成候选方,完成后显示剂量对比。"
|
||||||
|
if status in _STALE:
|
||||||
|
return False, _STALE[status] + ";仅查看历史原值。"
|
||||||
|
if status in {"failed", "cancelled", "canceled", "blocked"}:
|
||||||
|
return False, "候选方分析未完成;已保存内容仅供查看。"
|
||||||
|
if candidate_status in {"insufficient_data", "withheld_for_risk", "no_medication", "no_medication_recommended"}:
|
||||||
|
headline = {"insufficient_data": "资料不足,暂未提供候选药方", "withheld_for_risk": "因风险暂缓候选用药", "no_medication": "建议暂不使用药物", "no_medication_recommended": "建议暂不使用药物"}[candidate_status]
|
||||||
|
return False, headline + (";" + _reason(candidate.get("reason")) if candidate.get("reason") else "。")
|
||||||
|
if status and status not in SUCCESS_STATES | {"partial"}:
|
||||||
|
return False, "模型状态未确认;仅列示已保存原值。"
|
||||||
|
if candidate_status and candidate_status not in {"available_for_review", "success", "succeeded", "completed"}:
|
||||||
|
return False, "候选方状态未确认;仅列示已保存原值。"
|
||||||
|
if comparison.get("status") == "not_comparable":
|
||||||
|
return False, "本报告不可比:" + (_reason(comparison.get("reason") or comparison.get("reason_code")) or "未通过单位、剂量或资料完整性核验。")
|
||||||
|
saved_rows = comparison.get("rows")
|
||||||
|
if not isinstance(saved_rows, list) or not any(isinstance(row, Mapping) for row in saved_rows):
|
||||||
|
return False, "尚无已保存的逐味对比;医生剂量以—表示,不推算历史处方。"
|
||||||
|
if comparison.get("status") != "comparable":
|
||||||
|
return False, "可比状态未确认;仅列示已保存原值。"
|
||||||
|
prefix = "报告有效性未注明;" if not validity else ""
|
||||||
|
return True, prefix + "按明确单位与每剂/每日基准分组;缺失剂量不按零计算。"
|
||||||
|
|
||||||
|
def _render(self) -> None:
|
||||||
|
model = _mapping(_mapping(self._batch.get("models")).get(self._model_key))
|
||||||
|
candidate, comparison = _mapping(model.get("candidate")), _mapping(model.get("comparison"))
|
||||||
|
self._rows = _saved_rows(candidate, comparison)
|
||||||
|
self._bars_enabled, self._chart_message = self._state(model, candidate, comparison)
|
||||||
|
name = MODEL_NAMES[self._model_key]
|
||||||
|
self.caption_label.setText(f"{name} · 候选药方")
|
||||||
|
candidate_herbs = candidate.get("herbs")
|
||||||
|
self._candidate_count = len(candidate_herbs) if isinstance(candidate_herbs, list) else None
|
||||||
|
has_herbs = isinstance(candidate_herbs, list) and bool(candidate_herbs)
|
||||||
|
if any(row.origin == "original" for row in self._rows):
|
||||||
|
self._chart_message += " 候选原方另行附列;历史对应关系未保存,不推断同药。"
|
||||||
|
elif comparison.get("rows") and any(row.origin == "uncompared" for row in self._rows):
|
||||||
|
self._chart_message += " 未纳入对比的候选药材已补列原值。"
|
||||||
|
empty_title = "候选方生成中" if model.get("status") in ACTIVE_STATES else "暂无候选药方"
|
||||||
|
self.name_label.setText(_text(candidate.get("prescription_name")) or ("已保存候选方" if has_herbs else empty_title))
|
||||||
|
self.name_label.setToolTip(self.name_label.text())
|
||||||
|
usage = []
|
||||||
|
if _text(candidate.get("usage_instruction")):
|
||||||
|
usage.append(_text(candidate["usage_instruction"]))
|
||||||
|
if _number(candidate.get("times_per_day")) is not None:
|
||||||
|
usage.append(f"每日 {candidate['times_per_day']} 次")
|
||||||
|
if _number(candidate.get("usage_days")) is not None:
|
||||||
|
usage.append(f"共 {candidate['usage_days']} 天")
|
||||||
|
usage_text = " · ".join(usage)
|
||||||
|
self.usage_label.setText(usage_text[:100] + ("…" if len(usage_text) > 100 else ""))
|
||||||
|
self.usage_label.setToolTip(usage_text)
|
||||||
|
self.usage_label.setVisible(bool(usage_text))
|
||||||
|
self.status_label.setText(self._chart_message)
|
||||||
|
self.legend.setText(f'<span style="color:{DOCTOR_COLOR}">● 医生</span> <span style="color:{MODEL_COLORS[self._model_key]}">● {name}</span>')
|
||||||
|
self.herb_table.setHorizontalHeaderLabels(["药材 / 主辅方", "医生剂量", f"{name}剂量"])
|
||||||
|
self._filter_rows()
|
||||||
|
|
||||||
|
def _filter_rows(self) -> None:
|
||||||
|
self._updating_rows = True
|
||||||
|
query = self.search.text().strip().casefold()
|
||||||
|
rows = [row for row in self._rows if any(query in name.casefold() for name in (row.name, *row.source_names))]
|
||||||
|
table_scroll = self.herb_table.verticalScrollBar().value()
|
||||||
|
chart_scroll = self.chart_scroll.verticalScrollBar().value()
|
||||||
|
selected_row = self.herb_table.currentRow()
|
||||||
|
selected_name = self.herb_table.item(selected_row, 0).text() if selected_row >= 0 and self.herb_table.item(selected_row, 0) else ""
|
||||||
|
self.herb_table.setRowCount(len(rows))
|
||||||
|
for index, row in enumerate(rows):
|
||||||
|
name_text = row.name + "\n" + row.context
|
||||||
|
instructions = row.usage_description(MODEL_NAMES[self._model_key])
|
||||||
|
if instructions:
|
||||||
|
name_text += "\n" + instructions
|
||||||
|
values = (name_text, row.doctor.label if row.doctor else "—", row.candidate.label if row.candidate else "—")
|
||||||
|
for column, value in enumerate(values):
|
||||||
|
item = QTableWidgetItem(value)
|
||||||
|
item.setToolTip(row.description(MODEL_NAMES[self._model_key]))
|
||||||
|
item.setData(Qt.ItemDataRole.AccessibleTextRole, value)
|
||||||
|
if column == 0:
|
||||||
|
item.setData(Qt.ItemDataRole.UserRole, row)
|
||||||
|
item.setData(Qt.ItemDataRole.AccessibleDescriptionRole, row.description(MODEL_NAMES[self._model_key]))
|
||||||
|
item.setTextAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
|
||||||
|
if column == 1:
|
||||||
|
item.setForeground(QColor(DOCTOR_COLOR))
|
||||||
|
if column == 2:
|
||||||
|
item.setForeground(QColor(MODEL_COLORS[self._model_key]))
|
||||||
|
self.herb_table.setItem(index, column, item)
|
||||||
|
self.herb_table.setRowHeight(index, max(48, self.herb_table.fontMetrics().height() * (3 if instructions else 2) + 12))
|
||||||
|
if name_text == selected_name:
|
||||||
|
self.herb_table.selectRow(index)
|
||||||
|
self.herb_table.verticalScrollBar().setValue(table_scroll)
|
||||||
|
self.herb_table.setVisible(bool(rows))
|
||||||
|
self.empty_label.setText("没有匹配的药名,请调整搜索。" if self._rows and not rows else self._chart_message)
|
||||||
|
self.empty_label.setVisible(not rows)
|
||||||
|
comparison_count = sum(row.origin == "comparison" for row in self._rows)
|
||||||
|
uncompared_count = sum(row.origin == "uncompared" for row in self._rows)
|
||||||
|
original_count = sum(row.origin == "original" for row in self._rows)
|
||||||
|
counts = [f"候选原方 {self._candidate_count} 项" if self._candidate_count is not None else "候选原方未保存", f"对比 {comparison_count} 项"]
|
||||||
|
if uncompared_count:
|
||||||
|
counts.append(f"未纳入 {uncompared_count} 项")
|
||||||
|
if original_count:
|
||||||
|
counts.append(f"原方附列 {original_count} 项")
|
||||||
|
if query:
|
||||||
|
counts.append(f"搜索显示 {len(rows)} 项")
|
||||||
|
self.count_label.setText(" · ".join(counts))
|
||||||
|
self.count_label.setToolTip("原方项数来自候选药材完整清单;对比项数来自已保存的标准化记录。附列药材不推断与对比记录的对应关系;— 表示该侧未保存剂量。")
|
||||||
|
self.count_label.setVisible(bool(self._rows))
|
||||||
|
empty_message = "没有匹配的药名" if self._rows and not rows else self._chart_message
|
||||||
|
self.chart.set_rows(rows, self._model_key, bars_enabled=self._bars_enabled, message=empty_message)
|
||||||
|
self.chart_scroll.verticalScrollBar().setValue(chart_scroll)
|
||||||
|
self._updating_rows = False
|
||||||
|
|
||||||
|
def _focus_chart_row(self) -> None:
|
||||||
|
if self._updating_rows:
|
||||||
|
return
|
||||||
|
item = self.herb_table.item(self.herb_table.currentRow(), 0)
|
||||||
|
if item is None:
|
||||||
|
return
|
||||||
|
target = item.data(Qt.ItemDataRole.UserRole)
|
||||||
|
y = 8
|
||||||
|
for _scale, members, _maximum in self.chart.groups:
|
||||||
|
y += self.chart.GROUP_HEIGHT
|
||||||
|
for row in members:
|
||||||
|
if row is target:
|
||||||
|
self.chart_scroll.verticalScrollBar().setValue(y)
|
||||||
|
return
|
||||||
|
y += self.chart.ROW_HEIGHT
|
||||||
@@ -0,0 +1,590 @@
|
|||||||
|
"""The console chrome: the agreement comparison bar and the numbered step rail.
|
||||||
|
|
||||||
|
Both widgets read only what the saved batch carries. A model without a comparable candidate keeps
|
||||||
|
an empty track instead of a zero-length bar, and the rail's counters stay blank until the batch
|
||||||
|
actually reports them.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from PySide6.QtCore import QPointF, QRectF, QSize, Qt, Signal
|
||||||
|
from PySide6.QtGui import (
|
||||||
|
QBrush,
|
||||||
|
QColor,
|
||||||
|
QFont,
|
||||||
|
QFontMetricsF,
|
||||||
|
QLinearGradient,
|
||||||
|
QPainter,
|
||||||
|
QPainterPath,
|
||||||
|
QPaintEvent,
|
||||||
|
QPalette,
|
||||||
|
)
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QFrame,
|
||||||
|
QHBoxLayout,
|
||||||
|
QLabel,
|
||||||
|
QProgressBar,
|
||||||
|
QPushButton,
|
||||||
|
QSizePolicy,
|
||||||
|
QVBoxLayout,
|
||||||
|
QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .issued_prescription_ai_theme import CONSOLE, MODEL_HUE, MODEL_TEXT, num_font
|
||||||
|
|
||||||
|
MODEL_NAMES = {"qwen": "千问", "openai": "OpenAI"}
|
||||||
|
|
||||||
|
|
||||||
|
def _number(value: Any) -> float | None:
|
||||||
|
if value is None or isinstance(value, bool):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
parsed = float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
def _mapping(value: Any) -> dict[str, Any]:
|
||||||
|
return dict(value) if isinstance(value, Mapping) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _score(model: Mapping[str, Any]) -> float | None:
|
||||||
|
comparison = _mapping(_mapping(model).get("comparison"))
|
||||||
|
status = comparison.get("status") or _mapping(model).get("comparison_status")
|
||||||
|
if status != "comparable":
|
||||||
|
return None
|
||||||
|
value = _number(comparison.get("score", _mapping(model).get("score")))
|
||||||
|
return None if value is None or value < 0 or value > 100 else value
|
||||||
|
|
||||||
|
|
||||||
|
class ScaleTrack(QWidget):
|
||||||
|
"""A 0–100 track: the model's own fill, and a grey mark where the other model stands."""
|
||||||
|
|
||||||
|
# The design's scale: a 12px band for the rival's label, a 13px rail, then the axis row.
|
||||||
|
LABEL_BAND = 13.0
|
||||||
|
RAIL_TOP = 16.0
|
||||||
|
RAIL_HEIGHT = 13.0
|
||||||
|
AXIS_TOP = 31.0
|
||||||
|
|
||||||
|
def __init__(self, model_key: str, parent: QWidget | None = None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self.model_key = model_key
|
||||||
|
self._value: float | None = None
|
||||||
|
self._other: float | None = None
|
||||||
|
self._other_label = ""
|
||||||
|
self.setFixedHeight(44)
|
||||||
|
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||||||
|
|
||||||
|
def set_values(self, value: float | None, other: float | None, other_label: str) -> None:
|
||||||
|
self._value, self._other, self._other_label = value, other, other_label
|
||||||
|
self.setAccessibleDescription("暂无可比结果" if value is None else f"{value:.1f}%")
|
||||||
|
self.setToolTip("" if other is None else f"{other_label} 在 {other:.1f}%")
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
def paintEvent(self, event: QPaintEvent) -> None: # noqa: N802 - Qt virtual
|
||||||
|
painter = QPainter(self)
|
||||||
|
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
|
||||||
|
top, height = self.RAIL_TOP, self.RAIL_HEIGHT
|
||||||
|
radius = height / 2
|
||||||
|
painter.setPen(Qt.PenStyle.NoPen)
|
||||||
|
painter.setBrush(QColor(CONSOLE["raised"]))
|
||||||
|
painter.drawRoundedRect(QRectF(0, top, self.width(), height), radius, radius)
|
||||||
|
if self._value:
|
||||||
|
filled = self.width() * self._value / 100
|
||||||
|
hue = QColor(MODEL_HUE[self.model_key])
|
||||||
|
faded = QColor(hue)
|
||||||
|
faded.setAlphaF(0.45)
|
||||||
|
gradient = QLinearGradient(QPointF(0, top), QPointF(filled, top))
|
||||||
|
gradient.setColorAt(0.0, faded)
|
||||||
|
gradient.setColorAt(1.0, hue)
|
||||||
|
painter.setBrush(QBrush(gradient))
|
||||||
|
painter.drawRoundedRect(QRectF(0, top, filled, height), radius, radius)
|
||||||
|
# Quarter dividers sit on the rail itself, as the design draws them.
|
||||||
|
painter.setPen(QColor(CONSOLE["grid_line"]))
|
||||||
|
for fraction in (0.25, 0.5, 0.75):
|
||||||
|
x = self.width() * fraction
|
||||||
|
painter.drawLine(QPointF(x, top), QPointF(x, top + height))
|
||||||
|
axis = QFont(self.font())
|
||||||
|
axis.setPixelSize(9)
|
||||||
|
painter.setFont(axis)
|
||||||
|
painter.setPen(QColor(CONSOLE["faint"]))
|
||||||
|
for fraction in (0.0, 0.25, 0.5, 0.75, 1.0):
|
||||||
|
label = f"{int(fraction * 100)}%"
|
||||||
|
width = 44.0
|
||||||
|
left = self.width() * fraction - width / 2
|
||||||
|
align = Qt.AlignmentFlag.AlignCenter
|
||||||
|
if fraction == 0.0:
|
||||||
|
left, align = 0.0, Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter
|
||||||
|
elif fraction == 1.0:
|
||||||
|
left, align = self.width() - width, Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter
|
||||||
|
painter.drawText(QRectF(left, self.AXIS_TOP, width, 12), align, label)
|
||||||
|
if self._other is not None:
|
||||||
|
x = self.width() * self._other / 100
|
||||||
|
marker = QColor(CONSOLE["muted"])
|
||||||
|
marker.setAlphaF(0.8)
|
||||||
|
painter.setPen(Qt.PenStyle.NoPen)
|
||||||
|
painter.setBrush(marker)
|
||||||
|
painter.drawRoundedRect(QRectF(x - 1, top - 4, 2, height + 8), 1, 1)
|
||||||
|
painter.setPen(QColor(CONSOLE["faint"]))
|
||||||
|
painter.drawText(QRectF(max(0.0, min(x - 60, self.width() - 120)), 0, 120, self.LABEL_BAND),
|
||||||
|
Qt.AlignmentFlag.AlignCenter, f"{self._other_label} 在此")
|
||||||
|
painter.end()
|
||||||
|
|
||||||
|
|
||||||
|
class ScoreLabel(QLabel):
|
||||||
|
"""The big figure with its unit set small and raised, the way the design prints it.
|
||||||
|
|
||||||
|
``text()`` still returns the whole string, so callers and tests read one plain value.
|
||||||
|
"""
|
||||||
|
|
||||||
|
SIZE = 27
|
||||||
|
UNIT_SIZE = 13
|
||||||
|
|
||||||
|
def _parts(self) -> tuple[str, str]:
|
||||||
|
text = self.text()
|
||||||
|
for unit in ("%", "pt"):
|
||||||
|
if text.endswith(unit) and len(text) > len(unit):
|
||||||
|
return text[: -len(unit)], unit
|
||||||
|
return text, ""
|
||||||
|
|
||||||
|
def sizeHint(self) -> QSize: # noqa: N802 - Qt virtual
|
||||||
|
figure, unit = self._parts()
|
||||||
|
width = QFontMetricsF(num_font(self.SIZE, weight=QFont.Weight.DemiBold)).horizontalAdvance(figure)
|
||||||
|
if unit:
|
||||||
|
width += 2 + QFontMetricsF(num_font(self.UNIT_SIZE)).horizontalAdvance(unit)
|
||||||
|
return QSize(int(width) + 1, int(self.SIZE * 1.1) + 1)
|
||||||
|
|
||||||
|
def minimumSizeHint(self) -> QSize: # noqa: N802 - Qt virtual
|
||||||
|
return self.sizeHint()
|
||||||
|
|
||||||
|
def paintEvent(self, event: QPaintEvent) -> None: # noqa: N802 - Qt virtual
|
||||||
|
figure, unit = self._parts()
|
||||||
|
painter = QPainter(self)
|
||||||
|
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
|
||||||
|
figure_font = num_font(self.SIZE, weight=QFont.Weight.DemiBold)
|
||||||
|
painter.setFont(figure_font)
|
||||||
|
painter.setPen(self.palette().color(QPalette.ColorRole.WindowText))
|
||||||
|
metrics = QFontMetricsF(figure_font)
|
||||||
|
baseline = (self.height() + metrics.capHeight()) / 2
|
||||||
|
painter.drawText(QPointF(0, baseline), figure)
|
||||||
|
if unit:
|
||||||
|
unit_font = num_font(self.UNIT_SIZE)
|
||||||
|
painter.setFont(unit_font)
|
||||||
|
painter.setPen(QColor(CONSOLE["muted"]))
|
||||||
|
painter.drawText(QPointF(metrics.horizontalAdvance(figure) + 2,
|
||||||
|
baseline - metrics.capHeight() + QFontMetricsF(unit_font).capHeight()),
|
||||||
|
unit)
|
||||||
|
painter.end()
|
||||||
|
|
||||||
|
|
||||||
|
class AgreementBar(QFrame):
|
||||||
|
"""Both models' agreement on one scale, with the gap between them stated in points."""
|
||||||
|
|
||||||
|
def __init__(self, parent: QWidget | None = None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setObjectName("AiAgreement")
|
||||||
|
layout = QHBoxLayout(self)
|
||||||
|
layout.setContentsMargins(22, 16, 22, 16)
|
||||||
|
layout.setSpacing(24)
|
||||||
|
self._agreement_visible = True
|
||||||
|
self._metric_chrome: list[QWidget] = []
|
||||||
|
self.blocks: dict[str, dict[str, Any]] = {}
|
||||||
|
for index, key in enumerate(("qwen", "openai")):
|
||||||
|
if index:
|
||||||
|
divider = QFrame(self)
|
||||||
|
divider.setObjectName("AiFactDivider")
|
||||||
|
divider.setFixedWidth(1)
|
||||||
|
layout.addWidget(divider)
|
||||||
|
delta = self._delta_block()
|
||||||
|
layout.addWidget(delta)
|
||||||
|
self._metric_chrome.extend((divider, delta))
|
||||||
|
divider = QFrame(self)
|
||||||
|
divider.setObjectName("AiFactDivider")
|
||||||
|
divider.setFixedWidth(1)
|
||||||
|
layout.addWidget(divider)
|
||||||
|
self._metric_chrome.append(divider)
|
||||||
|
# Both heads line up at the top, so a model that failed does not slide its column down.
|
||||||
|
layout.addWidget(self._model_block(key), 1, Qt.AlignmentFlag.AlignTop)
|
||||||
|
|
||||||
|
def paintEvent(self, event: QPaintEvent) -> None: # noqa: N802 - Qt virtual
|
||||||
|
"""The design runs a blue-to-violet bar down the card's left edge, inside its rounding."""
|
||||||
|
|
||||||
|
super().paintEvent(event)
|
||||||
|
card = QPainterPath()
|
||||||
|
card.addRoundedRect(QRectF(1, 1, self.width() - 2, self.height() - 2), 9, 9)
|
||||||
|
strip = QPainterPath()
|
||||||
|
strip.addRect(QRectF(0, 0, 4, self.height()))
|
||||||
|
gradient = QLinearGradient(QPointF(0, 0), QPointF(0, self.height()))
|
||||||
|
gradient.setColorAt(0.0, QColor(CONSOLE["accent"]))
|
||||||
|
gradient.setColorAt(1.0, QColor(CONSOLE["openai"]))
|
||||||
|
painter = QPainter(self)
|
||||||
|
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
|
||||||
|
painter.setPen(Qt.PenStyle.NoPen)
|
||||||
|
painter.fillPath(card.intersected(strip), QBrush(gradient))
|
||||||
|
painter.end()
|
||||||
|
|
||||||
|
def _model_block(self, key: str) -> QWidget:
|
||||||
|
holder = QWidget(self)
|
||||||
|
column = QVBoxLayout(holder)
|
||||||
|
column.setContentsMargins(0, 0, 0, 0)
|
||||||
|
column.setSpacing(9)
|
||||||
|
head = QHBoxLayout()
|
||||||
|
head.setSpacing(9)
|
||||||
|
dot = QLabel(holder)
|
||||||
|
dot.setFixedSize(9, 9)
|
||||||
|
dot.setStyleSheet(f"background: {MODEL_HUE[key]}; border-radius: 3px;")
|
||||||
|
head.addWidget(dot)
|
||||||
|
name = QLabel(MODEL_NAMES[key], holder)
|
||||||
|
name.setStyleSheet(f"color: {CONSOLE['heading']}; font-size: 13.8px; font-weight: 600;")
|
||||||
|
head.addWidget(name)
|
||||||
|
status = QLabel("尚无报告", holder)
|
||||||
|
status.setTextFormat(Qt.TextFormat.PlainText)
|
||||||
|
head.addWidget(status)
|
||||||
|
coverage = QLabel("", holder)
|
||||||
|
coverage.setTextFormat(Qt.TextFormat.PlainText)
|
||||||
|
coverage.setStyleSheet(
|
||||||
|
f"background: {CONSOLE['amber_dim']}; color: {CONSOLE['amber_text']};"
|
||||||
|
f" border: 1px solid {CONSOLE['amber']}; border-radius: 9px; padding: 1px 8px; font-size: 9.9px;")
|
||||||
|
head.addWidget(coverage)
|
||||||
|
elapsed = QLabel("", holder)
|
||||||
|
elapsed.setTextFormat(Qt.TextFormat.PlainText)
|
||||||
|
elapsed.setStyleSheet(
|
||||||
|
f"background: transparent; color: {CONSOLE['faint']};"
|
||||||
|
f" border: 1px solid {CONSOLE['line']}; border-radius: 9px; padding: 1px 8px; font-size: 9.9px;")
|
||||||
|
head.addWidget(elapsed)
|
||||||
|
head.addStretch(1)
|
||||||
|
column.addLayout(head)
|
||||||
|
score = ScoreLabel("—", holder)
|
||||||
|
score.setObjectName(f"AiAgreementScore{key.capitalize()}")
|
||||||
|
score.setTextFormat(Qt.TextFormat.PlainText)
|
||||||
|
column.addWidget(score)
|
||||||
|
caption = QLabel("药味与剂量一致率", holder)
|
||||||
|
caption.setStyleSheet(f"color: {CONSOLE['faint']}; font-size: 10.5px;")
|
||||||
|
column.addWidget(caption)
|
||||||
|
track = ScaleTrack(key, holder)
|
||||||
|
column.addWidget(track)
|
||||||
|
|
||||||
|
# A failed model states why it stopped and offers its retry on its own block, which is
|
||||||
|
# the only place in the console that belongs to that model alone.
|
||||||
|
failure = QWidget(holder)
|
||||||
|
failure_row = QHBoxLayout(failure)
|
||||||
|
failure_row.setContentsMargins(0, 2, 0, 0)
|
||||||
|
failure_row.setSpacing(8)
|
||||||
|
failure_text = QLabel("", failure)
|
||||||
|
failure_text.setTextFormat(Qt.TextFormat.PlainText)
|
||||||
|
failure_text.setWordWrap(True)
|
||||||
|
failure_text.setStyleSheet(f"color: {CONSOLE['rose_text']}; font-size: 12px;")
|
||||||
|
failure_row.addWidget(failure_text, 1)
|
||||||
|
retry_slot = QHBoxLayout()
|
||||||
|
retry_slot.setContentsMargins(0, 0, 0, 0)
|
||||||
|
retry_slot.setSpacing(6)
|
||||||
|
failure_row.addLayout(retry_slot)
|
||||||
|
failure.setVisible(False)
|
||||||
|
column.addWidget(failure)
|
||||||
|
|
||||||
|
stage = QLabel("等待处理进度", holder)
|
||||||
|
stage.setTextFormat(Qt.TextFormat.PlainText)
|
||||||
|
stage.setWordWrap(True)
|
||||||
|
stage.setStyleSheet(f"color: {MODEL_TEXT[key]}; font-size: 12px;")
|
||||||
|
stage.hide()
|
||||||
|
# The plain bar stays as a value carrier for callers and accessibility tools; the painted
|
||||||
|
# track above is the visual, so it is never added to the layout.
|
||||||
|
carrier = QProgressBar(holder)
|
||||||
|
carrier.setRange(0, 1000)
|
||||||
|
carrier.setTextVisible(False)
|
||||||
|
carrier.setFixedHeight(4)
|
||||||
|
carrier.setAccessibleName(f"{MODEL_NAMES[key]}与医生方的药味及剂量一致度")
|
||||||
|
carrier.setVisible(False)
|
||||||
|
self.blocks[key] = {"score": score, "caption": caption, "coverage": coverage, "elapsed": elapsed, "track": track,
|
||||||
|
"status": status, "failure": failure, "failure_text": failure_text,
|
||||||
|
"retry_slot": retry_slot, "stage": stage, "carrier": carrier}
|
||||||
|
return holder
|
||||||
|
|
||||||
|
def views(self, key: str) -> dict[str, Any]:
|
||||||
|
"""Widget map kept stable for the dialog and its regression tests."""
|
||||||
|
|
||||||
|
block = self.blocks[key]
|
||||||
|
return {"card": self, "model_label": block["score"], "status_chip": block["status"],
|
||||||
|
"coverage_chip": block["coverage"], "score": block["score"], "elapsed": block["elapsed"],
|
||||||
|
"agreement_bar": block["carrier"], "gauge": block["track"], "stage": block["stage"],
|
||||||
|
"failure": block["failure"], "failure_text": block["failure_text"]}
|
||||||
|
|
||||||
|
def attach_action(self, key: str, button: QWidget) -> None:
|
||||||
|
"""Host a dialog-owned action (retry) inside that model's failure row."""
|
||||||
|
|
||||||
|
self.blocks[key]["retry_slot"].addWidget(button)
|
||||||
|
|
||||||
|
def set_agreement_visible(self, visible: bool) -> None:
|
||||||
|
"""Keep model states and retries available when the metric is restricted."""
|
||||||
|
|
||||||
|
self._agreement_visible = visible
|
||||||
|
for widget in self._metric_chrome:
|
||||||
|
widget.setVisible(visible)
|
||||||
|
for key, block in self.blocks.items():
|
||||||
|
for field in ("score", "caption", "track"):
|
||||||
|
block[field].setVisible(visible)
|
||||||
|
if visible:
|
||||||
|
block["carrier"].setAccessibleName(f"{MODEL_NAMES[key]}与医生方的药味及剂量一致度")
|
||||||
|
if not visible:
|
||||||
|
block["score"].clear()
|
||||||
|
block["score"].setToolTip("")
|
||||||
|
block["track"].set_values(None, None, "")
|
||||||
|
block["track"].setAccessibleDescription("")
|
||||||
|
block["carrier"].setValue(0)
|
||||||
|
block["carrier"].setToolTip("")
|
||||||
|
block["carrier"].setAccessibleName("")
|
||||||
|
if not visible:
|
||||||
|
self.delta.clear()
|
||||||
|
self.delta_note.clear()
|
||||||
|
self.overlap.clear()
|
||||||
|
|
||||||
|
def set_failure(self, key: str, message: str, retryable: bool) -> None:
|
||||||
|
block = self.blocks[key]
|
||||||
|
block["failure_text"].setText(message)
|
||||||
|
block["failure"].setVisible(bool(message))
|
||||||
|
slot = block["retry_slot"]
|
||||||
|
for index in range(slot.count()):
|
||||||
|
widget = slot.itemAt(index).widget()
|
||||||
|
if widget is not None:
|
||||||
|
widget.setVisible(retryable)
|
||||||
|
|
||||||
|
def _delta_block(self) -> QWidget:
|
||||||
|
holder = QWidget(self)
|
||||||
|
holder.setFixedWidth(180)
|
||||||
|
column = QVBoxLayout(holder)
|
||||||
|
column.setContentsMargins(0, 6, 0, 0)
|
||||||
|
column.setSpacing(2)
|
||||||
|
column.addStretch(1)
|
||||||
|
self.delta = QLabel("—", holder)
|
||||||
|
self.delta.setTextFormat(Qt.TextFormat.PlainText)
|
||||||
|
self.delta.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
|
self.delta.setFont(num_font(20, weight=QFont.Weight.DemiBold))
|
||||||
|
self.delta.setStyleSheet(f"color: {CONSOLE['accent_text']}; font-size: 19.5px; font-weight: 600;")
|
||||||
|
column.addWidget(self.delta)
|
||||||
|
self.delta_note = QLabel("等待两个模型", holder)
|
||||||
|
self.delta_note.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
|
self.delta_note.setStyleSheet(f"color: {CONSOLE['faint']}; font-size: 10.2px;")
|
||||||
|
column.addWidget(self.delta_note)
|
||||||
|
self.overlap = QLabel("", holder)
|
||||||
|
self.overlap.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
|
self.overlap.setStyleSheet(f"color: {CONSOLE['faint']}; font-size: 10.2px;")
|
||||||
|
column.addWidget(self.overlap)
|
||||||
|
column.addStretch(1)
|
||||||
|
return holder
|
||||||
|
|
||||||
|
def apply(self, batch: Mapping[str, Any] | None, *, coverage: Mapping[str, str] | None = None,
|
||||||
|
elapsed: Mapping[str, str] | None = None) -> None:
|
||||||
|
data = _mapping(batch)
|
||||||
|
models = _mapping(data.get("models"))
|
||||||
|
scores = {key: _score(_mapping(models.get(key))) if self._agreement_visible else None for key in ("qwen", "openai")}
|
||||||
|
for key in ("qwen", "openai"):
|
||||||
|
block = self.blocks[key]
|
||||||
|
value = scores[key]
|
||||||
|
block["score"].setText("—" if value is None else f"{value:.1f}%")
|
||||||
|
other = scores["openai" if key == "qwen" else "qwen"]
|
||||||
|
block["track"].set_values(value, other, MODEL_NAMES["openai" if key == "qwen" else "qwen"])
|
||||||
|
text = _mapping(coverage).get(key, "")
|
||||||
|
block["coverage"].setText(text)
|
||||||
|
block["coverage"].setVisible(bool(text))
|
||||||
|
spent = _mapping(elapsed).get(key, "")
|
||||||
|
block["elapsed"].setText(spent)
|
||||||
|
block["elapsed"].setVisible(bool(spent))
|
||||||
|
if not self._agreement_visible:
|
||||||
|
self.set_agreement_visible(False)
|
||||||
|
return
|
||||||
|
if scores["qwen"] is None or scores["openai"] is None:
|
||||||
|
self.delta.setText("—")
|
||||||
|
self.delta_note.setText("两个模型都可比后才给差值")
|
||||||
|
self.overlap.setText("")
|
||||||
|
return
|
||||||
|
gap = scores["qwen"] - scores["openai"]
|
||||||
|
leader = MODEL_NAMES["qwen"] if gap >= 0 else MODEL_NAMES["openai"]
|
||||||
|
self.delta.setText(f"{'+' if gap >= 0 else '−'}{abs(gap):.1f}pt")
|
||||||
|
self.delta_note.setText(f"{leader}领先" if gap else "两模型持平")
|
||||||
|
overlaps = []
|
||||||
|
for key in ("qwen", "openai"):
|
||||||
|
herb = _number(_mapping(_mapping(models.get(key)).get("comparison")).get("herb_score"))
|
||||||
|
overlaps.append("—" if herb is None else f"{herb:.1f}%")
|
||||||
|
self.overlap.setText("药味重合 " + " / ".join(overlaps))
|
||||||
|
|
||||||
|
|
||||||
|
class _StepButton(QPushButton):
|
||||||
|
"""A step row; the design marks the selected one with a rounded bar down its left edge."""
|
||||||
|
|
||||||
|
BAR_INSET = 9
|
||||||
|
BAR_WIDTH = 3
|
||||||
|
|
||||||
|
def paintEvent(self, event: Any) -> None: # noqa: N802 - Qt virtual
|
||||||
|
super().paintEvent(event)
|
||||||
|
if not self.isChecked():
|
||||||
|
return
|
||||||
|
painter = QPainter(self)
|
||||||
|
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
|
||||||
|
painter.setPen(Qt.PenStyle.NoPen)
|
||||||
|
painter.setBrush(QColor(CONSOLE["accent"]))
|
||||||
|
painter.drawRoundedRect(
|
||||||
|
QRectF(0, self.BAR_INSET, self.BAR_WIDTH, self.height() - self.BAR_INSET * 2), 1.5, 1.5)
|
||||||
|
painter.end()
|
||||||
|
|
||||||
|
|
||||||
|
class StepRail(QFrame):
|
||||||
|
"""The six destinations as numbered steps, with what this batch needs looked at below them."""
|
||||||
|
|
||||||
|
selected = Signal(str)
|
||||||
|
save_requested = Signal()
|
||||||
|
|
||||||
|
FOCUS_ROWS = (("differences", "剂量差异", "项", "amber_text"),
|
||||||
|
("critical", "关键缺口", "项", "rose_text"),
|
||||||
|
("restricted", "附件受限", "个", "text"),
|
||||||
|
("consensus", "三方共识", "味", "accent_text"))
|
||||||
|
|
||||||
|
def __init__(self, steps: tuple[tuple[str, str], ...], parent: QWidget | None = None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setObjectName("AiRail")
|
||||||
|
self.setFixedWidth(232)
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
layout.setSpacing(12)
|
||||||
|
|
||||||
|
nav = QFrame(self)
|
||||||
|
nav.setObjectName("AiPanel")
|
||||||
|
nav_layout = QVBoxLayout(nav)
|
||||||
|
nav_layout.setContentsMargins(6, 6, 6, 6)
|
||||||
|
nav_layout.setSpacing(0)
|
||||||
|
self._tones: dict[str, str] = {}
|
||||||
|
self.buttons: dict[str, QPushButton] = {}
|
||||||
|
self.badges: dict[str, QLabel] = {}
|
||||||
|
self.numbers: dict[str, QLabel] = {}
|
||||||
|
self.names: dict[str, QLabel] = {}
|
||||||
|
for index, (key, text) in enumerate(steps, start=1):
|
||||||
|
button = _StepButton(nav)
|
||||||
|
button.setObjectName("AiStep")
|
||||||
|
button.setCheckable(True)
|
||||||
|
button.setChecked(index == 1)
|
||||||
|
button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||||
|
button.setFixedHeight(42)
|
||||||
|
button.clicked.connect(lambda _checked=False, target=key: self.selected.emit(target))
|
||||||
|
row = QHBoxLayout(button)
|
||||||
|
row.setContentsMargins(11, 0, 11, 0)
|
||||||
|
row.setSpacing(10)
|
||||||
|
number = QLabel(f"{index:02d}", button)
|
||||||
|
number.setObjectName("AiStepNumber")
|
||||||
|
number.setFixedSize(22, 22)
|
||||||
|
number.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
|
number.setFont(num_font(10, weight=QFont.Weight.Bold))
|
||||||
|
row.addWidget(number)
|
||||||
|
name = QLabel(text, button)
|
||||||
|
name.setObjectName("AiStepName")
|
||||||
|
row.addWidget(name, 1)
|
||||||
|
badge = QLabel("", button)
|
||||||
|
badge.setObjectName("AiStepBadge")
|
||||||
|
badge.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
|
badge.setMinimumWidth(20)
|
||||||
|
badge.setFixedHeight(17)
|
||||||
|
badge.setFont(num_font(10))
|
||||||
|
row.addWidget(badge, 0, Qt.AlignmentFlag.AlignVCenter)
|
||||||
|
self.buttons[key] = button
|
||||||
|
self.badges[key] = badge
|
||||||
|
self.numbers[key] = number
|
||||||
|
self.names[key] = name
|
||||||
|
nav_layout.addWidget(button)
|
||||||
|
layout.addWidget(nav)
|
||||||
|
self.set_current(steps[0][0] if steps else "")
|
||||||
|
|
||||||
|
focus = QFrame(self)
|
||||||
|
focus.setObjectName("AiPanel")
|
||||||
|
focus_layout = QVBoxLayout(focus)
|
||||||
|
focus_layout.setContentsMargins(12, 13, 12, 14)
|
||||||
|
focus_layout.setSpacing(7)
|
||||||
|
caption = QLabel("本次关注", focus)
|
||||||
|
caption.setStyleSheet(f"color: {CONSOLE['faint']}; font-size: 10.2px; letter-spacing: 1.4px;"
|
||||||
|
" padding-left: 4px;")
|
||||||
|
focus_layout.addWidget(caption)
|
||||||
|
self.focus_values: dict[str, QLabel] = {}
|
||||||
|
for key, text, unit, tone in self.FOCUS_ROWS:
|
||||||
|
row = QFrame(focus)
|
||||||
|
row.setObjectName("AiFocusRow")
|
||||||
|
row_layout = QHBoxLayout(row)
|
||||||
|
row_layout.setContentsMargins(10, 8, 10, 8)
|
||||||
|
row_layout.setSpacing(8)
|
||||||
|
name = QLabel(text, row)
|
||||||
|
name.setStyleSheet(f"color: {CONSOLE['muted']}; font-size: 11.25px;")
|
||||||
|
row_layout.addWidget(name)
|
||||||
|
row_layout.addStretch(1)
|
||||||
|
value = QLabel(f"— {unit}", row)
|
||||||
|
value.setTextFormat(Qt.TextFormat.PlainText)
|
||||||
|
value.setFont(num_font(14))
|
||||||
|
value.setStyleSheet(f"color: {CONSOLE[tone]};")
|
||||||
|
row_layout.addWidget(value)
|
||||||
|
self.focus_values[key] = value
|
||||||
|
focus_layout.addWidget(row)
|
||||||
|
self.save_button = QPushButton("保存本次复核", focus)
|
||||||
|
self.save_button.setObjectName("AiPrimaryAction")
|
||||||
|
self.save_button.setFixedHeight(34)
|
||||||
|
self.save_button.clicked.connect(self.save_requested.emit)
|
||||||
|
focus_layout.addWidget(self.save_button)
|
||||||
|
layout.addWidget(focus)
|
||||||
|
layout.addStretch(1)
|
||||||
|
|
||||||
|
BADGE_TONES = {"hot": ("rose_dim", "rose_text"), "warn": ("amber_dim", "amber_text")}
|
||||||
|
|
||||||
|
def set_current(self, key: str) -> None:
|
||||||
|
"""The selected step is a tinted row with a blue index chip, not a solid blue button."""
|
||||||
|
|
||||||
|
for target, button in self.buttons.items():
|
||||||
|
selected = target == key
|
||||||
|
button.setChecked(selected)
|
||||||
|
self.numbers[target].setStyleSheet(
|
||||||
|
f"background: {CONSOLE['accent'] if selected else CONSOLE['raised']};"
|
||||||
|
f" color: {'#F2F7FF' if selected else CONSOLE['faint']}; border-radius: 6px;")
|
||||||
|
self.names[target].setStyleSheet(
|
||||||
|
f"color: {CONSOLE['heading'] if selected else CONSOLE['muted']}; font-size: 12.6px;"
|
||||||
|
+ (" font-weight: 600;" if selected else ""))
|
||||||
|
self._paint_badge(target)
|
||||||
|
|
||||||
|
def _paint_badge(self, key: str) -> None:
|
||||||
|
background, colour = self.BADGE_TONES.get(self._tones.get(key, ""), ("raised", "faint"))
|
||||||
|
self.badges[key].setStyleSheet(
|
||||||
|
f"background: {CONSOLE[background]}; color: {CONSOLE[colour]};"
|
||||||
|
" border-radius: 8px; padding: 0 6px;")
|
||||||
|
|
||||||
|
def set_badges(self, counts: Mapping[str, Any], tones: Mapping[str, str] | None = None) -> None:
|
||||||
|
"""Counts, and the severity that decides whether one reads as rose, amber or quiet."""
|
||||||
|
|
||||||
|
self._tones = dict(tones or {})
|
||||||
|
for key, badge in self.badges.items():
|
||||||
|
value = counts.get(key)
|
||||||
|
badge.setText("" if value in (None, "") else str(value))
|
||||||
|
badge.setVisible(bool(badge.text()))
|
||||||
|
self._paint_badge(key)
|
||||||
|
|
||||||
|
def set_focus(self, counts: Mapping[str, Any]) -> None:
|
||||||
|
for key, _text, unit, _tone in self.FOCUS_ROWS:
|
||||||
|
value = counts.get(key)
|
||||||
|
self.focus_values[key].setText(f"{'—' if value is None else value} {unit}")
|
||||||
|
|
||||||
|
|
||||||
|
def rail_qss() -> str:
|
||||||
|
"""The rail and agreement styles for the palette that is active right now."""
|
||||||
|
|
||||||
|
return f"""
|
||||||
|
QFrame#AiRail {{ background: transparent; border: 0; }}
|
||||||
|
QFrame#AiFocusRow {{ background: {CONSOLE['surface_2']}; border: 0; border-radius: 7px; }}
|
||||||
|
QPushButton#AiStep {{ background: transparent; border: 1px solid transparent; border-radius: 7px;
|
||||||
|
text-align: left; }}
|
||||||
|
QPushButton#AiStep:hover {{ background: {CONSOLE['surface_2']}; }}
|
||||||
|
QPushButton#AiStep:checked {{ background: {CONSOLE['selection']};
|
||||||
|
border-color: {CONSOLE['selection_line']}; }}
|
||||||
|
QLabel#AiStepNumber {{ color: {CONSOLE['faint']}; }}
|
||||||
|
QLabel#AiStepName {{ color: {CONSOLE['muted']}; font-size: 12.6px; }}
|
||||||
|
QLabel#AiStepBadge {{ color: {CONSOLE['faint']}; background: {CONSOLE['raised']};
|
||||||
|
border-radius: 8px; padding: 0 6px; }}
|
||||||
|
QFrame#AiAgreement {{ background: {CONSOLE['surface']}; border: 1px solid {CONSOLE['line_soft']};
|
||||||
|
border-radius: 10px; }}
|
||||||
|
QLabel#AiAgreementScoreQwen {{ color: {MODEL_TEXT['qwen']}; font-size: 27px; font-weight: 600; }}
|
||||||
|
QLabel#AiAgreementScoreOpenai {{ color: {MODEL_TEXT['openai']}; font-size: 27px; font-weight: 600; }}
|
||||||
|
"""
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
"""Painted glyphs for the prescription analysis window.
|
||||||
|
|
||||||
|
The window ships no image assets, so every icon in the design is drawn here with QPainter at the
|
||||||
|
size it is used. Each glyph is line art on a transparent background; the colour is supplied by the
|
||||||
|
caller, which keeps a glyph usable on a card, inside a tinted tile, or on the navigation bar.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from PySide6.QtCore import QPointF, QRectF, QSize, Qt
|
||||||
|
from PySide6.QtGui import (
|
||||||
|
QBrush,
|
||||||
|
QColor,
|
||||||
|
QIcon,
|
||||||
|
QLinearGradient,
|
||||||
|
QPainter,
|
||||||
|
QPaintEvent,
|
||||||
|
QPen,
|
||||||
|
QPixmap,
|
||||||
|
QPolygonF,
|
||||||
|
)
|
||||||
|
from PySide6.QtWidgets import QSizePolicy, QWidget
|
||||||
|
|
||||||
|
from .issued_prescription_ai_theme import CONSOLE as TECH_BLUE
|
||||||
|
|
||||||
|
|
||||||
|
def _pen(painter: QPainter, colour: str, width: float) -> QPen:
|
||||||
|
pen = QPen(QColor(colour))
|
||||||
|
pen.setWidthF(width)
|
||||||
|
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
|
||||||
|
pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin)
|
||||||
|
painter.setPen(pen)
|
||||||
|
painter.setBrush(Qt.BrushStyle.NoBrush)
|
||||||
|
return pen
|
||||||
|
|
||||||
|
|
||||||
|
def paint_glyph(painter: QPainter, kind: str, box: QRectF, colour: str) -> None:
|
||||||
|
"""Draw one glyph inside ``box``. Unknown names draw nothing rather than a placeholder."""
|
||||||
|
|
||||||
|
painter.save()
|
||||||
|
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
|
||||||
|
x, y, w, h = box.x(), box.y(), box.width(), box.height()
|
||||||
|
stroke = max(1.2, min(w, h) / 11)
|
||||||
|
if kind == "bars":
|
||||||
|
_pen(painter, colour, stroke)
|
||||||
|
for index, height in enumerate((0.45, 0.75, 0.6)):
|
||||||
|
left = x + w * (0.22 + index * 0.28)
|
||||||
|
painter.drawLine(QPointF(left, y + h * 0.82), QPointF(left, y + h * (0.82 - height)))
|
||||||
|
elif kind == "doc":
|
||||||
|
_pen(painter, colour, stroke)
|
||||||
|
painter.drawRoundedRect(QRectF(x + w * 0.22, y + h * 0.12, w * 0.56, h * 0.76), w * 0.08, w * 0.08)
|
||||||
|
for index in range(2):
|
||||||
|
top = y + h * (0.38 + index * 0.2)
|
||||||
|
painter.drawLine(QPointF(x + w * 0.34, top), QPointF(x + w * 0.66, top))
|
||||||
|
elif kind == "box":
|
||||||
|
_pen(painter, colour, stroke)
|
||||||
|
top, bottom, middle = y + h * 0.2, y + h * 0.8, y + h * 0.5
|
||||||
|
left, right = x + w * 0.16, x + w * 0.84
|
||||||
|
painter.drawPolygon(QPolygonF([QPointF(x + w * 0.5, top), QPointF(right, middle * 0.75 + top * 0.25),
|
||||||
|
QPointF(right, bottom - h * 0.12), QPointF(x + w * 0.5, bottom),
|
||||||
|
QPointF(left, bottom - h * 0.12), QPointF(left, middle * 0.75 + top * 0.25)]))
|
||||||
|
painter.drawLine(QPointF(x + w * 0.5, y + h * 0.5), QPointF(x + w * 0.5, bottom))
|
||||||
|
elif kind == "image":
|
||||||
|
_pen(painter, colour, stroke)
|
||||||
|
painter.drawRoundedRect(QRectF(x + w * 0.16, y + h * 0.22, w * 0.68, h * 0.56), w * 0.08, w * 0.08)
|
||||||
|
painter.drawPolyline(QPolygonF([QPointF(x + w * 0.24, y + h * 0.7), QPointF(x + w * 0.42, y + h * 0.48),
|
||||||
|
QPointF(x + w * 0.58, y + h * 0.66), QPointF(x + w * 0.68, y + h * 0.56)]))
|
||||||
|
painter.setBrush(QColor(colour))
|
||||||
|
painter.drawEllipse(QPointF(x + w * 0.64, y + h * 0.36), stroke * 0.9, stroke * 0.9)
|
||||||
|
elif kind == "clock":
|
||||||
|
_pen(painter, colour, stroke)
|
||||||
|
painter.drawEllipse(QRectF(x + w * 0.16, y + h * 0.16, w * 0.68, h * 0.68))
|
||||||
|
centre = QPointF(x + w * 0.5, y + h * 0.5)
|
||||||
|
painter.drawLine(centre, QPointF(x + w * 0.5, y + h * 0.3))
|
||||||
|
painter.drawLine(centre, QPointF(x + w * 0.66, y + h * 0.58))
|
||||||
|
elif kind == "trend":
|
||||||
|
_pen(painter, colour, stroke)
|
||||||
|
painter.drawPolyline(QPolygonF([QPointF(x + w * 0.18, y + h * 0.68), QPointF(x + w * 0.4, y + h * 0.46),
|
||||||
|
QPointF(x + w * 0.56, y + h * 0.58), QPointF(x + w * 0.82, y + h * 0.28)]))
|
||||||
|
painter.drawPolyline(QPolygonF([QPointF(x + w * 0.62, y + h * 0.28), QPointF(x + w * 0.82, y + h * 0.28),
|
||||||
|
QPointF(x + w * 0.82, y + h * 0.48)]))
|
||||||
|
elif kind == "bell":
|
||||||
|
_pen(painter, colour, stroke)
|
||||||
|
painter.drawPolyline(QPolygonF([
|
||||||
|
QPointF(x + w * 0.24, y + h * 0.68), QPointF(x + w * 0.3, y + h * 0.58),
|
||||||
|
QPointF(x + w * 0.3, y + h * 0.42), QPointF(x + w * 0.5, y + h * 0.2),
|
||||||
|
QPointF(x + w * 0.7, y + h * 0.42), QPointF(x + w * 0.7, y + h * 0.58),
|
||||||
|
QPointF(x + w * 0.76, y + h * 0.68), QPointF(x + w * 0.24, y + h * 0.68)]))
|
||||||
|
painter.drawArc(QRectF(x + w * 0.4, y + h * 0.66, w * 0.2, h * 0.18), 0, -180 * 16)
|
||||||
|
elif kind == "clipboard":
|
||||||
|
_pen(painter, colour, stroke)
|
||||||
|
painter.drawRoundedRect(QRectF(x + w * 0.22, y + h * 0.2, w * 0.56, h * 0.66), w * 0.08, w * 0.08)
|
||||||
|
painter.drawLine(QPointF(x + w * 0.36, y + h * 0.48), QPointF(x + w * 0.64, y + h * 0.48))
|
||||||
|
painter.drawLine(QPointF(x + w * 0.36, y + h * 0.64), QPointF(x + w * 0.56, y + h * 0.64))
|
||||||
|
elif kind == "pencil":
|
||||||
|
_pen(painter, colour, stroke)
|
||||||
|
painter.drawPolyline(QPolygonF([QPointF(x + w * 0.24, y + h * 0.76), QPointF(x + w * 0.28, y + h * 0.6),
|
||||||
|
QPointF(x + w * 0.64, y + h * 0.24), QPointF(x + w * 0.78, y + h * 0.38),
|
||||||
|
QPointF(x + w * 0.42, y + h * 0.74), QPointF(x + w * 0.24, y + h * 0.76)]))
|
||||||
|
elif kind == "bulb":
|
||||||
|
_pen(painter, colour, stroke)
|
||||||
|
painter.drawArc(QRectF(x + w * 0.28, y + h * 0.18, w * 0.44, h * 0.46), 0, 180 * 16)
|
||||||
|
painter.drawLine(QPointF(x + w * 0.28, y + h * 0.41), QPointF(x + w * 0.38, y + h * 0.62))
|
||||||
|
painter.drawLine(QPointF(x + w * 0.72, y + h * 0.41), QPointF(x + w * 0.62, y + h * 0.62))
|
||||||
|
painter.drawLine(QPointF(x + w * 0.38, y + h * 0.66), QPointF(x + w * 0.62, y + h * 0.66))
|
||||||
|
painter.drawLine(QPointF(x + w * 0.42, y + h * 0.78), QPointF(x + w * 0.58, y + h * 0.78))
|
||||||
|
elif kind == "flask":
|
||||||
|
_pen(painter, colour, stroke)
|
||||||
|
painter.drawLine(QPointF(x + w * 0.38, y + h * 0.2), QPointF(x + w * 0.62, y + h * 0.2))
|
||||||
|
painter.drawPolyline(QPolygonF([QPointF(x + w * 0.44, y + h * 0.2), QPointF(x + w * 0.44, y + h * 0.44),
|
||||||
|
QPointF(x + w * 0.24, y + h * 0.78), QPointF(x + w * 0.76, y + h * 0.78),
|
||||||
|
QPointF(x + w * 0.56, y + h * 0.44), QPointF(x + w * 0.56, y + h * 0.2)]))
|
||||||
|
elif kind == "alert":
|
||||||
|
_pen(painter, colour, stroke)
|
||||||
|
painter.drawEllipse(QRectF(x + w * 0.16, y + h * 0.16, w * 0.68, h * 0.68))
|
||||||
|
painter.drawLine(QPointF(x + w * 0.5, y + h * 0.33), QPointF(x + w * 0.5, y + h * 0.56))
|
||||||
|
painter.setBrush(QColor(colour))
|
||||||
|
painter.drawEllipse(QPointF(x + w * 0.5, y + h * 0.68), stroke * 0.7, stroke * 0.7)
|
||||||
|
elif kind == "paperclip":
|
||||||
|
_pen(painter, colour, stroke)
|
||||||
|
painter.drawRoundedRect(QRectF(x + w * 0.18, y + h * 0.22, w * 0.64, h * 0.5), w * 0.1, w * 0.1)
|
||||||
|
painter.drawLine(QPointF(x + w * 0.3, y + h * 0.72), QPointF(x + w * 0.3, y + h * 0.84))
|
||||||
|
elif kind == "info":
|
||||||
|
_pen(painter, colour, stroke)
|
||||||
|
painter.drawEllipse(QRectF(x + w * 0.16, y + h * 0.16, w * 0.68, h * 0.68))
|
||||||
|
painter.drawLine(QPointF(x + w * 0.5, y + h * 0.46), QPointF(x + w * 0.5, y + h * 0.68))
|
||||||
|
painter.setBrush(QColor(colour))
|
||||||
|
painter.drawEllipse(QPointF(x + w * 0.5, y + h * 0.34), stroke * 0.7, stroke * 0.7)
|
||||||
|
elif kind == "refresh":
|
||||||
|
_pen(painter, colour, stroke)
|
||||||
|
painter.drawArc(QRectF(x + w * 0.2, y + h * 0.2, w * 0.6, h * 0.6), 40 * 16, 280 * 16)
|
||||||
|
painter.setBrush(QColor(colour))
|
||||||
|
painter.drawPolygon(QPolygonF([QPointF(x + w * 0.72, y + h * 0.12), QPointF(x + w * 0.86, y + h * 0.34),
|
||||||
|
QPointF(x + w * 0.6, y + h * 0.32)]))
|
||||||
|
elif kind == "save":
|
||||||
|
_pen(painter, colour, stroke)
|
||||||
|
painter.drawRoundedRect(QRectF(x + w * 0.2, y + h * 0.2, w * 0.6, h * 0.6), w * 0.08, w * 0.08)
|
||||||
|
painter.drawLine(QPointF(x + w * 0.36, y + h * 0.2), QPointF(x + w * 0.36, y + h * 0.42))
|
||||||
|
painter.drawLine(QPointF(x + w * 0.36, y + h * 0.42), QPointF(x + w * 0.64, y + h * 0.42))
|
||||||
|
painter.drawLine(QPointF(x + w * 0.64, y + h * 0.42), QPointF(x + w * 0.64, y + h * 0.2))
|
||||||
|
painter.drawRect(QRectF(x + w * 0.36, y + h * 0.56, w * 0.28, h * 0.24))
|
||||||
|
elif kind == "chevron":
|
||||||
|
_pen(painter, colour, stroke)
|
||||||
|
painter.drawPolyline(QPolygonF([QPointF(x + w * 0.4, y + h * 0.28), QPointF(x + w * 0.62, y + h * 0.5),
|
||||||
|
QPointF(x + w * 0.4, y + h * 0.72)]))
|
||||||
|
elif kind == "shield":
|
||||||
|
painter.setPen(Qt.PenStyle.NoPen)
|
||||||
|
painter.setBrush(QColor(colour))
|
||||||
|
painter.drawPolygon(QPolygonF([QPointF(x + w * 0.5, y + h * 0.16), QPointF(x + w * 0.82, y + h * 0.3),
|
||||||
|
QPointF(x + w * 0.82, y + h * 0.56), QPointF(x + w * 0.5, y + h * 0.84),
|
||||||
|
QPointF(x + w * 0.18, y + h * 0.56), QPointF(x + w * 0.18, y + h * 0.3)]))
|
||||||
|
elif kind == "check":
|
||||||
|
_pen(painter, colour, stroke * 1.2)
|
||||||
|
painter.drawPolyline(QPolygonF([QPointF(x + w * 0.32, y + h * 0.52), QPointF(x + w * 0.45, y + h * 0.65),
|
||||||
|
QPointF(x + w * 0.7, y + h * 0.37)]))
|
||||||
|
elif kind == "spark":
|
||||||
|
# The 千问 mark: three crossing strokes forming a six-pointed star.
|
||||||
|
_pen(painter, colour, stroke * 1.1)
|
||||||
|
centre = QPointF(x + w * 0.5, y + h * 0.5)
|
||||||
|
radius = min(w, h) * 0.3
|
||||||
|
for angle in (90, 30, -30):
|
||||||
|
from math import cos, radians, sin
|
||||||
|
dx, dy = cos(radians(angle)) * radius, -sin(radians(angle)) * radius
|
||||||
|
painter.drawLine(QPointF(centre.x() - dx, centre.y() - dy), QPointF(centre.x() + dx, centre.y() + dy))
|
||||||
|
elif kind == "knot":
|
||||||
|
# The OpenAI mark, reduced to the interlocking hexagon it is built from.
|
||||||
|
_pen(painter, colour, stroke)
|
||||||
|
from math import cos, radians, sin
|
||||||
|
centre = QPointF(x + w * 0.5, y + h * 0.5)
|
||||||
|
radius = min(w, h) * 0.3
|
||||||
|
points = [QPointF(centre.x() + cos(radians(angle)) * radius, centre.y() + sin(radians(angle)) * radius)
|
||||||
|
for angle in range(0, 360, 60)]
|
||||||
|
painter.drawPolygon(QPolygonF(points))
|
||||||
|
painter.drawLine(points[0], points[3])
|
||||||
|
painter.restore()
|
||||||
|
|
||||||
|
|
||||||
|
class Glyph(QWidget):
|
||||||
|
"""A single painted icon at a fixed size."""
|
||||||
|
|
||||||
|
def __init__(self, kind: str, colour: str, size: int = 18, parent: QWidget | None = None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self.kind, self.colour = kind, colour
|
||||||
|
self.setFixedSize(size, size)
|
||||||
|
self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)
|
||||||
|
|
||||||
|
def set_colour(self, colour: str) -> None:
|
||||||
|
if colour != self.colour:
|
||||||
|
self.colour = colour
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
def paintEvent(self, event: QPaintEvent) -> None: # noqa: N802 - Qt virtual
|
||||||
|
painter = QPainter(self)
|
||||||
|
paint_glyph(painter, self.kind, QRectF(0, 0, self.width(), self.height()), self.colour)
|
||||||
|
painter.end()
|
||||||
|
|
||||||
|
|
||||||
|
class LogoTile(QWidget):
|
||||||
|
"""A rounded tile with a glyph on it: the window mark and the two model marks."""
|
||||||
|
|
||||||
|
def __init__(self, kind: str, *, start: str, end: str, glyph: str = "#FFFFFF",
|
||||||
|
size: int = 34, radius: float = 10.0, circle: bool = False,
|
||||||
|
parent: QWidget | None = None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self.kind, self.start, self.end, self.glyph_colour = kind, start, end, glyph
|
||||||
|
self.radius, self.circle = radius, circle
|
||||||
|
self.setFixedSize(size, size)
|
||||||
|
self.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
|
||||||
|
self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)
|
||||||
|
|
||||||
|
def paintEvent(self, event: QPaintEvent) -> None: # noqa: N802 - Qt virtual
|
||||||
|
painter = QPainter(self)
|
||||||
|
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
|
||||||
|
box = QRectF(0, 0, self.width(), self.height())
|
||||||
|
gradient = QLinearGradient(box.topLeft(), box.bottomRight())
|
||||||
|
gradient.setColorAt(0.0, QColor(self.start))
|
||||||
|
gradient.setColorAt(1.0, QColor(self.end))
|
||||||
|
painter.setPen(Qt.PenStyle.NoPen)
|
||||||
|
painter.setBrush(QBrush(gradient))
|
||||||
|
if self.circle:
|
||||||
|
painter.drawEllipse(box)
|
||||||
|
else:
|
||||||
|
painter.drawRoundedRect(box, self.radius, self.radius)
|
||||||
|
paint_glyph(painter, self.kind, box, self.glyph_colour)
|
||||||
|
painter.end()
|
||||||
|
|
||||||
|
def minimumSizeHint(self) -> QSize:
|
||||||
|
return QSize(self.width(), self.height())
|
||||||
|
|
||||||
|
|
||||||
|
def window_mark(parent: QWidget | None = None, size: int = 38) -> LogoTile:
|
||||||
|
tile = LogoTile("check", start=TECH_BLUE["accent"], end=TECH_BLUE["accent_pressed"],
|
||||||
|
size=size, radius=11, parent=parent)
|
||||||
|
tile.setAccessibleName("诊断与药方对照")
|
||||||
|
return tile
|
||||||
|
|
||||||
|
|
||||||
|
def model_mark(model_key: str, parent: QWidget | None = None, size: int = 30) -> LogoTile:
|
||||||
|
if model_key == "openai":
|
||||||
|
return LogoTile("knot", start=TECH_BLUE["openai_dim"], end=TECH_BLUE["surface_2"],
|
||||||
|
glyph=TECH_BLUE["openai_text"], size=size, circle=True, parent=parent)
|
||||||
|
return LogoTile("spark", start=TECH_BLUE["qwen_dim"], end=TECH_BLUE["surface_2"],
|
||||||
|
glyph=TECH_BLUE["qwen_text"], size=size, radius=9, parent=parent)
|
||||||
|
|
||||||
|
|
||||||
|
def glyph_icon(kind: str, colour: str, size: int = 16) -> QIcon:
|
||||||
|
"""The same line art as a QIcon, for buttons that place their own icon and label."""
|
||||||
|
|
||||||
|
pixmap = QPixmap(size, size)
|
||||||
|
pixmap.fill(Qt.GlobalColor.transparent)
|
||||||
|
painter = QPainter(pixmap)
|
||||||
|
paint_glyph(painter, kind, QRectF(0, 0, size, size), colour)
|
||||||
|
painter.end()
|
||||||
|
return QIcon(pixmap)
|
||||||
@@ -283,3 +283,61 @@ def value_text(value: Any, field: str, labels: Mapping[str, str], fields: Mappin
|
|||||||
if field and field not in fields:
|
if field and field not in fields:
|
||||||
return system_text(value, labels, fields)
|
return system_text(value, labels, fields)
|
||||||
return plain_text(value)
|
return plain_text(value)
|
||||||
|
|
||||||
|
|
||||||
|
STATE_LABELS = {
|
||||||
|
"blank": "尚未开方", "not_generated": "尚无分析记录", "unavailable": "暂无可比结果",
|
||||||
|
"not_applicable": "尚未开方", "not_started": "尚未分析", "pending": "待分析",
|
||||||
|
"preparing": "准备资料", "waiting_sources": "等待转写/资料", "retry_wait": "等待重试", "blocked": "需完善资料关联",
|
||||||
|
"queued": "待分析", "waiting": "等待资料", "waiting_transcript": "等待转写",
|
||||||
|
"waiting_transcription": "等待转写", "running": "分析中", "processing": "分析中",
|
||||||
|
"retrying": "重试中", "succeeded": "已完成", "completed": "已完成", "success": "已完成",
|
||||||
|
"partial": "部分完成", "failed": "需重试", "cancelled": "已取消", "canceled": "已取消",
|
||||||
|
"stale": "处方已变更", "superseded": "处方已变更", "invalid": "已失效",
|
||||||
|
"prescription_changed": "处方已变更", "source_updated": "资料已更新", "voided": "处方已作废", "deleted": "处方已删除", "revoked": "权限已撤销",
|
||||||
|
"current": "当前版本", "valid": "当前有效", "complete": "资料清单完整",
|
||||||
|
"incomplete": "资料不全", "missing": "资料缺失", "unknown": "未确认",
|
||||||
|
"needs_patient_link": "需完善患者关联", "patient_unlinked": "需完善患者关联",
|
||||||
|
"independent_baseline": "独立基线", "baseline": "独立基线",
|
||||||
|
"latest_context": "最新资料对照", "supplemental": "最新资料对照",
|
||||||
|
"assisted_revision": "AI 辅助后修订", "ai_assisted": "AI 辅助后修订",
|
||||||
|
"non_independent": "非独立对照", "auxiliary": "辅助复核",
|
||||||
|
"comparable": "可比", "not_comparable": "不可比",
|
||||||
|
"available_for_review": "供医生复核", "insufficient_data": "资料不足,暂不提供候选用药",
|
||||||
|
"withheld_for_risk": "因风险暂缓候选用药", "viewed": "已查看", "needs_information": "需补充资料",
|
||||||
|
"not_adopted": "不采纳", "reviewed": "已复核",
|
||||||
|
"per_dose": "每剂", "per_day": "每日", "matched": "共同药味", "doctor_only": "仅医生方", "candidate_only": "仅模型方",
|
||||||
|
"insufficient_sample": "样本不足", "descriptive_only": "仅作描述性统计",
|
||||||
|
}
|
||||||
|
FIELD_LABELS = {
|
||||||
|
"summary": "概要", "timeline": "病程", "analysis": "综合分析", "tcm_analysis": "中医辨证",
|
||||||
|
"diagnosis": "辨证分析", "treatment_advice": "治疗与随访建议", "risk_assessment": "需复核风险",
|
||||||
|
"evidence_references": "证据来源编号", "missing_information": "待补充资料", "level": "风险等级", "label": "说明",
|
||||||
|
"risk_warnings": "需复核风险", "risks": "风险", "follow_up": "随访建议", "evidence": "依据",
|
||||||
|
"sources": "来源", "manifest": "来源清单", "missing": "资料缺口", "status": "状态",
|
||||||
|
"reason": "原因", "name": "药名", "herb_name": "规范药名", "canonical_name": "规范药名",
|
||||||
|
"processing": "炮制", "dosage": "剂量", "dose": "剂量", "unit": "单位", "dose_basis": "剂量基准",
|
||||||
|
"formula_type": "主辅方", "doctor_dosage": "医生剂量", "candidate_dosage": "模型剂量",
|
||||||
|
"doctor_dose": "医生剂量", "candidate_dose": "模型剂量", "ai_dose": "模型剂量",
|
||||||
|
"contribution": "匹配贡献", "ratio": "匹配贡献", "match_ratio": "匹配贡献", "match": "匹配情况",
|
||||||
|
"prescription_name": "候选方名称", "prescription_type": "剂型", "herbs": "药味",
|
||||||
|
"dose_count": "剂数", "usage_days": "疗程(天)", "times_per_day": "每日服次",
|
||||||
|
"usage_instruction": "服法", "usage_time": "服药时间", "usage_way": "给药途径",
|
||||||
|
"rationale": "方义与依据", "usage_differences": "用法、疗程与风险差异", "normalization": "规范化记录",
|
||||||
|
"algorithm_version": "算法版本", "dictionary_version": "药材字典版本", "model_version": "模型版本",
|
||||||
|
"prompt_version": "提示词版本", "doctor_count": "医生药项数", "candidate_count": "候选药项数",
|
||||||
|
"matched_count": "共同药项数", "coverage": "模型资料覆盖", "source_summary": "来源汇总",
|
||||||
|
"cutoff_at": "资料截止时间", "generated_at": "报告生成时间", "comment": "复核意见",
|
||||||
|
"match_type": "增减药项", "administration_route": "给药途径", "group": "用药组",
|
||||||
|
"delivered": "已送达", "unreadable": "不可读", "unsupported": "不支持", "parsed": "已解析",
|
||||||
|
"diagnosis_count": "病历数", "prescription_count": "历史处方数", "chat_count": "聊天记录数",
|
||||||
|
"daily_record_count": "日常记录数", "transcript_count": "转写数", "attachment_count": "附件数",
|
||||||
|
"files": "附件处理清单", "source_ids": "已读取来源编号", "source_id": "来源编号", "file_id": "附件编号",
|
||||||
|
"complete": "资料清单完整", "source_complete": "文字来源齐全", "transmitted": "附件已送达", "critical": "关键资料缺口",
|
||||||
|
"baseline_eligible": "独立基线统计资格", "baseline_exclusion_reasons": "基线排除原因", "instructions": "特殊煎服说明",
|
||||||
|
"versions": "版本信息", "strata": "按版本分层", "count": "样本数", "mean": "均值", "median": "中位数",
|
||||||
|
"distribution": "一致度分布", "sample_status": "样本说明",
|
||||||
|
}
|
||||||
|
STATE_LABELS.update(SYSTEM_LABELS)
|
||||||
|
FIELD_LABELS.update(EXTRA_FIELD_LABELS)
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,210 @@
|
|||||||
|
"""The analysis console's palette, and the switch between its dark and light themes.
|
||||||
|
|
||||||
|
The prescription analysis window has its own theme, separate from the workstation's light chrome:
|
||||||
|
tech blue for the interface itself, and two model hues (cyan for 千问, violet for OpenAI) that stay
|
||||||
|
apart from the interface colour and from each other. Severity keeps amber and rose, so a reading
|
||||||
|
never depends on hue alone.
|
||||||
|
|
||||||
|
``CONSOLE`` is the active palette and is mutated in place by :func:`use_theme`, so every module
|
||||||
|
that imported it sees the new values. Anything derived from it — a stylesheet string, a colour
|
||||||
|
constant — must be rebuilt in a callback registered through :func:`on_theme_changed`.
|
||||||
|
|
||||||
|
Key names mirror ``reception_style.TECH_BLUE`` so a widget can take either palette.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ctypes
|
||||||
|
import sys
|
||||||
|
from collections.abc import Callable
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from PySide6.QtGui import QFont
|
||||||
|
|
||||||
|
DARK: dict[str, str] = {
|
||||||
|
# ground and cards
|
||||||
|
"canvas": "#080D18",
|
||||||
|
"canvas_soft": "#0C1322",
|
||||||
|
"surface": "#0F1726",
|
||||||
|
"surface_2": "#131D2E",
|
||||||
|
"raised": "#1A2637",
|
||||||
|
"line": "#22304A",
|
||||||
|
"line_soft": "#18233A",
|
||||||
|
# type
|
||||||
|
"heading": "#E8EFFB",
|
||||||
|
"text": "#B6C5DA",
|
||||||
|
"muted": "#8195AF",
|
||||||
|
"faint": "#6A7F99",
|
||||||
|
"selected_text": "#E8EFFB",
|
||||||
|
# interface colour
|
||||||
|
"accent": "#2E7BF6",
|
||||||
|
"accent_text": "#6BA5FF",
|
||||||
|
"accent_pressed": "#1A5FD0",
|
||||||
|
"selection": "#142645",
|
||||||
|
"selection_line": "#1B3E77",
|
||||||
|
# model hues
|
||||||
|
"qwen": "#17BFDD",
|
||||||
|
"qwen_text": "#55D8EE",
|
||||||
|
"qwen_dim": "#10303C",
|
||||||
|
"openai": "#9B7BFF",
|
||||||
|
"openai_text": "#B69FFF",
|
||||||
|
"openai_dim": "#241F48",
|
||||||
|
# severity
|
||||||
|
"amber": "#F5B942",
|
||||||
|
"amber_text": "#F8C661",
|
||||||
|
"amber_dim": "#2C2415",
|
||||||
|
"rose": "#F4697A",
|
||||||
|
"rose_text": "#F88694",
|
||||||
|
"rose_dim": "#2C1620",
|
||||||
|
"ok": "#2E7BF6",
|
||||||
|
"zebra": "#0E1626",
|
||||||
|
"grid_line": "#191F2C",
|
||||||
|
}
|
||||||
|
|
||||||
|
LIGHT: dict[str, str] = {
|
||||||
|
"canvas": "#F1F5FC",
|
||||||
|
"canvas_soft": "#E7EEFA",
|
||||||
|
"surface": "#FFFFFF",
|
||||||
|
"surface_2": "#F5F8FE",
|
||||||
|
"raised": "#E8EFFA",
|
||||||
|
"line": "#D5E1F2",
|
||||||
|
"line_soft": "#E3EBF8",
|
||||||
|
"heading": "#0A182E",
|
||||||
|
"text": "#2A3C56",
|
||||||
|
"muted": "#54697F",
|
||||||
|
"faint": "#7A8DA3",
|
||||||
|
"selected_text": "#0A182E",
|
||||||
|
"accent": "#1A5FD0",
|
||||||
|
"accent_text": "#124DAE",
|
||||||
|
"accent_pressed": "#0E3F90",
|
||||||
|
"selection": "#E8EFFA",
|
||||||
|
"selection_line": "#AEC7EE",
|
||||||
|
"qwen": "#0C89AC",
|
||||||
|
"qwen_text": "#086C88",
|
||||||
|
"qwen_dim": "#E2F5FA",
|
||||||
|
"openai": "#6B4AE0",
|
||||||
|
"openai_text": "#5537C6",
|
||||||
|
"openai_dim": "#EDE8FE",
|
||||||
|
"amber": "#B4791A",
|
||||||
|
"amber_text": "#8F5F0B",
|
||||||
|
"amber_dim": "#FCF2DF",
|
||||||
|
"rose": "#D0435A",
|
||||||
|
"rose_text": "#AE3149",
|
||||||
|
"rose_dim": "#FCE9EC",
|
||||||
|
"ok": "#1A5FD0",
|
||||||
|
"zebra": "#FAFBFE",
|
||||||
|
"grid_line": "#DDE4EE",
|
||||||
|
}
|
||||||
|
|
||||||
|
CONSOLE: dict[str, str] = dict(DARK)
|
||||||
|
_THEME = "dark"
|
||||||
|
_LISTENERS: list[Callable[[], None]] = []
|
||||||
|
|
||||||
|
|
||||||
|
def current_theme() -> str:
|
||||||
|
return _THEME
|
||||||
|
|
||||||
|
|
||||||
|
def on_theme_changed(callback: Callable[[], None]) -> Callable[[], None]:
|
||||||
|
"""Register a rebuild for anything derived from the palette; returns the callback."""
|
||||||
|
|
||||||
|
_LISTENERS.append(callback)
|
||||||
|
return callback
|
||||||
|
|
||||||
|
|
||||||
|
def use_theme(name: str) -> str:
|
||||||
|
"""Switch the active palette in place and let every derived value rebuild itself."""
|
||||||
|
|
||||||
|
global _THEME
|
||||||
|
palette = LIGHT if name == "light" else DARK
|
||||||
|
_THEME = "light" if name == "light" else "dark"
|
||||||
|
CONSOLE.clear()
|
||||||
|
CONSOLE.update(palette)
|
||||||
|
_rebuild()
|
||||||
|
return _THEME
|
||||||
|
|
||||||
|
|
||||||
|
def toggle_theme() -> str:
|
||||||
|
return use_theme("light" if _THEME == "dark" else "dark")
|
||||||
|
|
||||||
|
|
||||||
|
def _rebuild() -> None:
|
||||||
|
for callback in list(_LISTENERS):
|
||||||
|
callback()
|
||||||
|
|
||||||
|
|
||||||
|
MODEL_HUE = {"qwen": CONSOLE["qwen"], "openai": CONSOLE["openai"]}
|
||||||
|
MODEL_TEXT = {"qwen": CONSOLE["qwen_text"], "openai": CONSOLE["openai_text"]}
|
||||||
|
MODEL_DIM = {"qwen": CONSOLE["qwen_dim"], "openai": CONSOLE["openai_dim"]}
|
||||||
|
|
||||||
|
|
||||||
|
@on_theme_changed
|
||||||
|
def _rebuild_model_hues() -> None:
|
||||||
|
MODEL_HUE.update({"qwen": CONSOLE["qwen"], "openai": CONSOLE["openai"]})
|
||||||
|
MODEL_TEXT.update({"qwen": CONSOLE["qwen_text"], "openai": CONSOLE["openai_text"]})
|
||||||
|
MODEL_DIM.update({"qwen": CONSOLE["qwen_dim"], "openai": CONSOLE["openai_dim"]})
|
||||||
|
|
||||||
|
|
||||||
|
RADIUS = 10
|
||||||
|
RADIUS_SM = 7
|
||||||
|
|
||||||
|
# The design sets numbers in a condensed face so columns of figures line up; the fallbacks keep
|
||||||
|
# the same tabular behaviour when Bahnschrift is missing.
|
||||||
|
NUM_FAMILIES = ("Bahnschrift", "Segoe UI", "Microsoft YaHei UI", "Microsoft YaHei")
|
||||||
|
|
||||||
|
|
||||||
|
def num_font(size: int, *, weight: QFont.Weight | None = None) -> QFont:
|
||||||
|
"""A tabular face for figures, at the pixel size the design states."""
|
||||||
|
|
||||||
|
font = QFont()
|
||||||
|
font.setFamilies(list(NUM_FAMILIES))
|
||||||
|
font.setPixelSize(size)
|
||||||
|
if weight is not None:
|
||||||
|
font.setWeight(weight)
|
||||||
|
font.setStyleStrategy(QFont.StyleStrategy.PreferAntialias)
|
||||||
|
return font
|
||||||
|
|
||||||
|
|
||||||
|
# Windows draws the title bar itself, so the console's dark ground stops at the frame unless the
|
||||||
|
# window asks DWM for a matching caption. Windows 11 (build 22000+) honours these attributes;
|
||||||
|
# anywhere else the call fails and the native bar is left as it is.
|
||||||
|
_DWMWA_USE_IMMERSIVE_DARK_MODE = 20
|
||||||
|
_DWMWA_BORDER_COLOR = 34
|
||||||
|
_DWMWA_CAPTION_COLOR = 35
|
||||||
|
_DWMWA_TEXT_COLOR = 36
|
||||||
|
|
||||||
|
|
||||||
|
def _colorref(value: str) -> int:
|
||||||
|
"""A ``#RRGGBB`` string as the ``0x00BBGGRR`` integer DWM expects."""
|
||||||
|
|
||||||
|
colour = value.lstrip("#")
|
||||||
|
red, green, blue = (int(colour[index:index + 2], 16) for index in (0, 2, 4))
|
||||||
|
return (blue << 16) | (green << 8) | red
|
||||||
|
|
||||||
|
|
||||||
|
def apply_window_chrome(widget: Any) -> bool:
|
||||||
|
"""Paint the native title bar in the palette that is active right now.
|
||||||
|
|
||||||
|
Returns whether DWM accepted the change, so a caller can tell "not Windows 11" from "done".
|
||||||
|
"""
|
||||||
|
|
||||||
|
if sys.platform != "win32":
|
||||||
|
return False
|
||||||
|
handle = int(widget.winId())
|
||||||
|
if not handle:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
dwm = ctypes.windll.dwmapi
|
||||||
|
except (AttributeError, OSError): # pragma: no cover - not Windows
|
||||||
|
return False
|
||||||
|
dark = ctypes.c_int(1 if _THEME == "dark" else 0)
|
||||||
|
caption = ctypes.c_uint(_colorref(CONSOLE["canvas"]))
|
||||||
|
text = ctypes.c_uint(_colorref(CONSOLE["heading"]))
|
||||||
|
border = ctypes.c_uint(_colorref(CONSOLE["line"]))
|
||||||
|
applied = False
|
||||||
|
for attribute, value in ((_DWMWA_USE_IMMERSIVE_DARK_MODE, dark), (_DWMWA_CAPTION_COLOR, caption),
|
||||||
|
(_DWMWA_TEXT_COLOR, text), (_DWMWA_BORDER_COLOR, border)):
|
||||||
|
result = dwm.DwmSetWindowAttribute(ctypes.c_void_p(handle), ctypes.c_int(attribute),
|
||||||
|
ctypes.byref(value), ctypes.sizeof(value))
|
||||||
|
applied = applied or result == 0
|
||||||
|
return applied
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -93,6 +93,7 @@ from PySide6.QtWidgets import (
|
|||||||
from .. import icons
|
from .. import icons
|
||||||
from ..diagnosis_terms import shared_terms
|
from ..diagnosis_terms import shared_terms
|
||||||
from ..infinite_list import InfiniteList
|
from ..infinite_list import InfiniteList
|
||||||
|
from ..theme import main_window_target_rect
|
||||||
from ..widgets import (
|
from ..widgets import (
|
||||||
MessageBanner,
|
MessageBanner,
|
||||||
display_text,
|
display_text,
|
||||||
@@ -3306,6 +3307,17 @@ class PrescriptionEditorDialog(QDialog):
|
|||||||
self._fit_drawer_geometry()
|
self._fit_drawer_geometry()
|
||||||
|
|
||||||
def _fit_drawer_geometry(self) -> None:
|
def _fit_drawer_geometry(self) -> None:
|
||||||
|
target = main_window_target_rect(self)
|
||||||
|
if target is not None:
|
||||||
|
width = target.width()
|
||||||
|
self.drawer_surface.setMaximumWidth(max(self.DRAWER_WIDTH, width))
|
||||||
|
self.setGeometry(target)
|
||||||
|
if self.diagnosis_host.isVisible():
|
||||||
|
editor_width = min(self.DRAWER_WIDTH, max(520, round(width * 0.55)))
|
||||||
|
self.workspace_splitter.setSizes([max(300, width - editor_width - 1), editor_width])
|
||||||
|
else:
|
||||||
|
self.workspace_splitter.setSizes([0, width])
|
||||||
|
return
|
||||||
parent = self.parentWidget()
|
parent = self.parentWidget()
|
||||||
if parent is None:
|
if parent is None:
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ from PySide6.QtWidgets import (
|
|||||||
QWidget,
|
QWidget,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from ..report_provenance import report_provenance_lines
|
||||||
from ..theme import apply_reading_rhythm
|
from ..theme import apply_reading_rhythm
|
||||||
from ..widgets import (
|
from ..widgets import (
|
||||||
EmptyState,
|
EmptyState,
|
||||||
@@ -1413,8 +1414,22 @@ class PrescriptionAiReportDialog(QDialog):
|
|||||||
or "仅供专业人员辅助审方,不替代辨证、诊断和处方审核。"
|
or "仅供专业人员辅助审方,不替代辨证、诊断和处方审核。"
|
||||||
)
|
)
|
||||||
self._add_label(disclaimer, "PrescriptionAiMuted")
|
self._add_label(disclaimer, "PrescriptionAiMuted")
|
||||||
|
self._add_report_provenance(data)
|
||||||
return
|
return
|
||||||
self._section("报告正文", str(data.get("content") or "暂无报告正文"))
|
self._section("报告正文", str(data.get("content") or "暂无报告正文"))
|
||||||
|
self._add_report_provenance(data)
|
||||||
|
|
||||||
|
def _add_report_provenance(self, data: dict[str, Any]) -> None:
|
||||||
|
if self.kind is not DIAGNOSIS_AI_KIND:
|
||||||
|
return
|
||||||
|
lines = [f"关联诊单:#{self._entity_id()}", *report_provenance_lines(data)]
|
||||||
|
label = QLabel("报告出处\n" + "\n".join(lines))
|
||||||
|
label.setObjectName("DiagnosisAiReportProvenance")
|
||||||
|
label.setTextFormat(Qt.TextFormat.PlainText)
|
||||||
|
label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
|
||||||
|
label.setWordWrap(True)
|
||||||
|
label.setStyleSheet("color: #555B68; font-size: 12px; padding: 10px 14px; background: #F7F8FC; border: 1px solid #E4E8F2; border-radius: 8px;")
|
||||||
|
self.host_layout.addWidget(label)
|
||||||
|
|
||||||
def reject(self) -> None:
|
def reject(self) -> None:
|
||||||
self.dialog_generation += 1
|
self.dialog_generation += 1
|
||||||
@@ -1464,6 +1479,13 @@ class DiagnosisAiAssistantDialog(QDialog):
|
|||||||
self.answer_label = _AiAnswerBrowser()
|
self.answer_label = _AiAnswerBrowser()
|
||||||
self.answer_scroll = self.answer_label
|
self.answer_scroll = self.answer_label
|
||||||
root.addWidget(self.answer_scroll, 1)
|
root.addWidget(self.answer_scroll, 1)
|
||||||
|
self.source_label = QLabel("")
|
||||||
|
self.source_label.setObjectName("DiagnosisAiAssistantProvenance")
|
||||||
|
self.source_label.setTextFormat(Qt.TextFormat.PlainText)
|
||||||
|
self.source_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
|
||||||
|
self.source_label.setWordWrap(True)
|
||||||
|
self.source_label.setVisible(False)
|
||||||
|
root.addWidget(self.source_label)
|
||||||
actions = QHBoxLayout()
|
actions = QHBoxLayout()
|
||||||
actions.addStretch(1)
|
actions.addStretch(1)
|
||||||
self.retry_button = QPushButton("重试")
|
self.retry_button = QPushButton("重试")
|
||||||
@@ -1481,6 +1503,8 @@ class DiagnosisAiAssistantDialog(QDialog):
|
|||||||
self.question_label.setText(f"问题:{self.prompt}")
|
self.question_label.setText(f"问题:{self.prompt}")
|
||||||
self.answer_label.clear()
|
self.answer_label.clear()
|
||||||
self.model_label.clear()
|
self.model_label.clear()
|
||||||
|
self.source_label.clear()
|
||||||
|
self.source_label.hide()
|
||||||
self._ask()
|
self._ask()
|
||||||
|
|
||||||
def _ask(self) -> None:
|
def _ask(self) -> None:
|
||||||
@@ -1519,6 +1543,8 @@ class DiagnosisAiAssistantDialog(QDialog):
|
|||||||
)
|
)
|
||||||
self.model_label.setText(f"本次模型:{model}")
|
self.model_label.setText(f"本次模型:{model}")
|
||||||
self.answer_label.set_answer(answer)
|
self.answer_label.set_answer(answer)
|
||||||
|
self.source_label.setText("分析出处:" + ";".join(report_provenance_lines(payload)))
|
||||||
|
self.source_label.show()
|
||||||
self.status_banner.show_message("分析完成", "success")
|
self.status_banner.show_message("分析完成", "success")
|
||||||
self.loading = False
|
self.loading = False
|
||||||
self.retry_button.setEnabled(True)
|
self.retry_button.setEnabled(True)
|
||||||
@@ -1527,6 +1553,7 @@ class DiagnosisAiAssistantDialog(QDialog):
|
|||||||
if generation != self.request_generation:
|
if generation != self.request_generation:
|
||||||
return
|
return
|
||||||
self.status_banner.show_message(error_message(error), "danger")
|
self.status_banner.show_message(error_message(error), "danger")
|
||||||
|
self.source_label.hide()
|
||||||
self.loading = False
|
self.loading = False
|
||||||
self.retry_button.setEnabled(True)
|
self.retry_button.setEnabled(True)
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ from PySide6.QtWidgets import (
|
|||||||
QWidget,
|
QWidget,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from ...core import UserProfile
|
||||||
from ...core.appointment_modes import appointment_type_value, can_appointment_video
|
from ...core.appointment_modes import appointment_type_value, can_appointment_video
|
||||||
from .. import icons
|
from .. import icons
|
||||||
from ..consultations_style import consultations_stylesheet
|
from ..consultations_style import consultations_stylesheet
|
||||||
@@ -1493,6 +1494,7 @@ class ConsultationsPage(QWidget):
|
|||||||
table_wrap_layout.setSpacing(0)
|
table_wrap_layout.setSpacing(0)
|
||||||
self.table_host = DiagnosisTableHost(
|
self.table_host = DiagnosisTableHost(
|
||||||
tech_blue=True,
|
tech_blue=True,
|
||||||
|
video_entry_allowed=self._can_enter_list_call,
|
||||||
action_policy={
|
action_policy={
|
||||||
"view": _canonical_allowed(permissions, "tcm.diagnosis/readonlyDetail"),
|
"view": _canonical_allowed(permissions, "tcm.diagnosis/readonlyDetail"),
|
||||||
"edit": _canonical_allowed(permissions, "tcm.diagnosis/edit"),
|
"edit": _canonical_allowed(permissions, "tcm.diagnosis/edit"),
|
||||||
@@ -3053,8 +3055,11 @@ class ConsultationsPage(QWidget):
|
|||||||
_as_int(payload.get(key), 0) > 0
|
_as_int(payload.get(key), 0) > 0
|
||||||
for key in ("appointment_id", "patient_id", "diagnosis_id")
|
for key in ("appointment_id", "patient_id", "diagnosis_id")
|
||||||
)
|
)
|
||||||
|
entry_allowed = has_record and self._can_enter_list_call(record)
|
||||||
|
self.video_button.setVisible(entry_allowed)
|
||||||
|
self.call_toolbar_button.setVisible(entry_allowed)
|
||||||
self.video_button.setEnabled(
|
self.video_button.setEnabled(
|
||||||
has_record
|
entry_allowed
|
||||||
and is_video_available(record)
|
and is_video_available(record)
|
||||||
and (appointment_type_value(record) == "text" or video_call_is_live(record))
|
and (appointment_type_value(record) == "text" or video_call_is_live(record))
|
||||||
and valid_ids
|
and valid_ids
|
||||||
@@ -3496,6 +3501,28 @@ class ConsultationsPage(QWidget):
|
|||||||
self._checked_changed(len(self.table_host.selected_records()))
|
self._checked_changed(len(self.table_host.selected_records()))
|
||||||
self._selection_changed()
|
self._selection_changed()
|
||||||
|
|
||||||
|
def _can_enter_list_call(self, record: Any) -> bool:
|
||||||
|
if not self._native_video_capable or record is None:
|
||||||
|
return False
|
||||||
|
# Text appointments retain their IM entry; this restriction concerns
|
||||||
|
# receiving an existing video call from the diagnosis list.
|
||||||
|
if appointment_type_value(record) == "text":
|
||||||
|
return True
|
||||||
|
if not can_appointment_video(appointment_type_value(record)):
|
||||||
|
return False
|
||||||
|
roles = UserProfile.from_dict({
|
||||||
|
"role_ids": first_value(self.current_user, "role_ids", "role_id", "roles", default=[])
|
||||||
|
}).role_ids
|
||||||
|
if 1 in roles:
|
||||||
|
return False
|
||||||
|
user_id = _as_int(first_value(self.current_user, "id", "user_id", default=0))
|
||||||
|
assistant_id = _as_int(first_value(record, "assistant_id", "assistant", default=0))
|
||||||
|
return bool(
|
||||||
|
user_id > 0
|
||||||
|
and assistant_id == user_id
|
||||||
|
and _canonical_allowed(self.permissions, "tcm.diagnosis/watchCall")
|
||||||
|
)
|
||||||
|
|
||||||
def _request_video(self) -> None:
|
def _request_video(self) -> None:
|
||||||
record = self.table.current_data()
|
record = self.table.current_data()
|
||||||
if not self._native_video_capable:
|
if not self._native_video_capable:
|
||||||
@@ -3504,6 +3531,9 @@ class ConsultationsPage(QWidget):
|
|||||||
if record is None or not is_video_available(record):
|
if record is None or not is_video_available(record):
|
||||||
self.banner.show_message("仅当前“已预约”的挂号可进入视频问诊。", "warning")
|
self.banner.show_message("仅当前“已预约”的挂号可进入视频问诊。", "warning")
|
||||||
return
|
return
|
||||||
|
if not self._can_enter_list_call(record):
|
||||||
|
self.banner.show_message("仅本诊单指派且具有旁观权限的医助可进入视频会话。", "warning")
|
||||||
|
return
|
||||||
if appointment_type_value(record) != "text" and not video_call_is_live(record):
|
if appointment_type_value(record) != "text" and not video_call_is_live(record):
|
||||||
self.banner.show_message("医生尚未发起视频会话,请等待会话开始。", "warning")
|
self.banner.show_message("医生尚未发起视频会话,请等待会话开始。", "warning")
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ from PySide6.QtWidgets import (
|
|||||||
from .. import icons, motion
|
from .. import icons, motion
|
||||||
from ..dialogs.issued_prescription_ai import (
|
from ..dialogs.issued_prescription_ai import (
|
||||||
PrescriptionAiStatisticsDialog,
|
PrescriptionAiStatisticsDialog,
|
||||||
|
agreement_safe_batch,
|
||||||
agreement_text,
|
agreement_text,
|
||||||
batch_running,
|
batch_running,
|
||||||
can_open_issued_ai,
|
can_open_issued_ai,
|
||||||
@@ -445,6 +446,21 @@ def _formula(value: Any) -> str:
|
|||||||
return "辅方" if text in {"2", "aux", "auxiliary", "辅方"} else "主方"
|
return "辅方" if text in {"2", "aux", "auxiliary", "辅方"} else "主方"
|
||||||
|
|
||||||
|
|
||||||
|
def _is_blank_prescription(row: Any) -> bool:
|
||||||
|
if row is None or _truthy(get_value(row, "is_system_auto")):
|
||||||
|
return True
|
||||||
|
raw = getattr(row, "raw", None)
|
||||||
|
source = raw if isinstance(raw, Mapping) and raw else row
|
||||||
|
missing = object()
|
||||||
|
herbs = get_value(source, "herbs", missing)
|
||||||
|
# Compact historical rows may omit herbs; omission is not an empty prescription.
|
||||||
|
if herbs is missing:
|
||||||
|
return False
|
||||||
|
return not isinstance(herbs, (list, tuple)) or not any(
|
||||||
|
str(get_value(herb, "name", "") or "").strip() for herb in herbs
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _order_warnings(row: Any) -> list[str]:
|
def _order_warnings(row: Any) -> list[str]:
|
||||||
"""Match the PC list's linked-order checks, including blank herb rows."""
|
"""Match the PC list's linked-order checks, including blank herb rows."""
|
||||||
|
|
||||||
@@ -992,6 +1008,10 @@ class PrescriptionsPage(QWidget):
|
|||||||
self._mutation_pending = False
|
self._mutation_pending = False
|
||||||
self._ai_statuses: dict[int, dict[str, Any]] = {}
|
self._ai_statuses: dict[int, dict[str, Any]] = {}
|
||||||
self._ai_enabled = False
|
self._ai_enabled = False
|
||||||
|
self._agreement_visible = False
|
||||||
|
self._capability_agreement_visible = False
|
||||||
|
self._ai_capability_token = 0
|
||||||
|
self._ai_capability_pending = False
|
||||||
self._ai_request_token = 0
|
self._ai_request_token = 0
|
||||||
self._ai_pending = False
|
self._ai_pending = False
|
||||||
self._ai_timer = QTimer(self)
|
self._ai_timer = QTimer(self)
|
||||||
@@ -1033,7 +1053,7 @@ class PrescriptionsPage(QWidget):
|
|||||||
self.orders_button.clicked.connect(lambda: self._open_orders())
|
self.orders_button.clicked.connect(lambda: self._open_orders())
|
||||||
header.add_action(self.orders_button)
|
header.add_action(self.orders_button)
|
||||||
self.ai_statistics_button = QPushButton("AI 一致度统计", header)
|
self.ai_statistics_button = QPushButton("AI 一致度统计", header)
|
||||||
self.ai_statistics_button.setVisible(has_permission(permissions, "tcm.prescriptionAi/statistics", default=False) and callable(getattr(repository, "prescription_ai_statistics", None)))
|
self.ai_statistics_button.hide()
|
||||||
self.ai_statistics_button.clicked.connect(self._open_ai_statistics)
|
self.ai_statistics_button.clicked.connect(self._open_ai_statistics)
|
||||||
header.add_action(self.ai_statistics_button)
|
header.add_action(self.ai_statistics_button)
|
||||||
self.add_button = QPushButton("新增处方", header)
|
self.add_button = QPushButton("新增处方", header)
|
||||||
@@ -1183,7 +1203,7 @@ class PrescriptionsPage(QWidget):
|
|||||||
self.view_button = self._action_button("查看", "cf.prescription/read", self._view_selected)
|
self.view_button = self._action_button("查看", "cf.prescription/read", self._view_selected)
|
||||||
toolbar.addWidget(self.view_button)
|
toolbar.addWidget(self.view_button)
|
||||||
self.ai_report_button = self._action_button("AI 报告", "tcm.prescriptionAi/reports", self._open_ai_report)
|
self.ai_report_button = self._action_button("AI 报告", "tcm.prescriptionAi/reports", self._open_ai_report)
|
||||||
self.ai_report_button.setVisible(can_open_issued_ai(self.permissions) and callable(getattr(self.repository, "list_prescription_ai_reports", None)))
|
self.ai_report_button.hide()
|
||||||
toolbar.addWidget(self.ai_report_button)
|
toolbar.addWidget(self.ai_report_button)
|
||||||
self.patch_button = self._action_button(
|
self.patch_button = self._action_button(
|
||||||
"修改患者", "tcm.prescription/patchPatient", self._patch_selected
|
"修改患者", "tcm.prescription/patchPatient", self._patch_selected
|
||||||
@@ -1422,6 +1442,7 @@ class PrescriptionsPage(QWidget):
|
|||||||
self._refresh_pending = False
|
self._refresh_pending = False
|
||||||
self._generation += 1
|
self._generation += 1
|
||||||
generation = self._generation
|
generation = self._generation
|
||||||
|
self._load_ai_capabilities()
|
||||||
page_size = self._page_size
|
page_size = self._page_size
|
||||||
start_time, end_time = self._date_filters()
|
start_time, end_time = self._date_filters()
|
||||||
filters: dict[str, Any] = {
|
filters: dict[str, Any] = {
|
||||||
@@ -1565,7 +1586,7 @@ class PrescriptionsPage(QWidget):
|
|||||||
actions_host,
|
actions_host,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if self._ai_enabled and can_open_issued_ai(self.permissions):
|
if self._ai_enabled and can_open_issued_ai(self.permissions) and not _is_blank_prescription(row):
|
||||||
actions.addWidget(
|
actions.addWidget(
|
||||||
_row_action_button(
|
_row_action_button(
|
||||||
"eye", "AI 报告",
|
"eye", "AI 报告",
|
||||||
@@ -1618,6 +1639,8 @@ class PrescriptionsPage(QWidget):
|
|||||||
if item is None or top + self.table.rowHeight(index) <= 0 or top >= viewport.height():
|
if item is None or top + self.table.rowHeight(index) <= 0 or top >= viewport.height():
|
||||||
continue
|
continue
|
||||||
row = item.data(Qt.ItemDataRole.UserRole)
|
row = item.data(Qt.ItemDataRole.UserRole)
|
||||||
|
if _is_blank_prescription(row):
|
||||||
|
continue
|
||||||
value = _int(first_value(row, "id", "prescription_id"), 0)
|
value = _int(first_value(row, "id", "prescription_id"), 0)
|
||||||
if value > 0:
|
if value > 0:
|
||||||
ids.append(value)
|
ids.append(value)
|
||||||
@@ -1628,8 +1651,18 @@ class PrescriptionsPage(QWidget):
|
|||||||
self._ai_scroll_timer.start()
|
self._ai_scroll_timer.start()
|
||||||
|
|
||||||
def _load_ai_statuses(self) -> None:
|
def _load_ai_statuses(self) -> None:
|
||||||
|
only_blank = self.table.rowCount() > 0 and all(
|
||||||
|
_is_blank_prescription(self.table.item(index, 0).data(Qt.ItemDataRole.UserRole))
|
||||||
|
for index in range(self.table.rowCount())
|
||||||
|
)
|
||||||
|
self.ai_status_notice.setVisible(not only_blank)
|
||||||
|
if only_blank:
|
||||||
|
self._ai_timer.stop()
|
||||||
|
self._set_ai_columns(False)
|
||||||
|
return
|
||||||
method = getattr(self.repository, "list_prescription_ai_statuses", None)
|
method = getattr(self.repository, "list_prescription_ai_statuses", None)
|
||||||
if not has_permission(self.permissions, "tcm.prescriptionAi/statuses", default=False):
|
if not has_permission(self.permissions, "tcm.prescriptionAi/statuses", default=False):
|
||||||
|
self._set_ai_columns(False)
|
||||||
self.ai_status_notice.setText("AI 分析:当前账号没有查看分析状态的权限,请联系管理员授权。")
|
self.ai_status_notice.setText("AI 分析:当前账号没有查看分析状态的权限,请联系管理员授权。")
|
||||||
return
|
return
|
||||||
if not callable(method):
|
if not callable(method):
|
||||||
@@ -1661,8 +1694,8 @@ class PrescriptionsPage(QWidget):
|
|||||||
def _set_ai_columns(self, enabled: bool) -> None:
|
def _set_ai_columns(self, enabled: bool) -> None:
|
||||||
changed = enabled != self._ai_enabled
|
changed = enabled != self._ai_enabled
|
||||||
self._ai_enabled = enabled
|
self._ai_enabled = enabled
|
||||||
for column in (11, 12):
|
self.table.setColumnHidden(11, not enabled)
|
||||||
self.table.setColumnHidden(column, not enabled)
|
self.table.setColumnHidden(12, not (enabled and self._agreement_visible))
|
||||||
if enabled:
|
if enabled:
|
||||||
header = self.table.horizontalHeader()
|
header = self.table.horizontalHeader()
|
||||||
header.moveSection(header.visualIndex(11), header.visualIndex(4) + 1)
|
header.moveSection(header.visualIndex(11), header.visualIndex(4) + 1)
|
||||||
@@ -1673,10 +1706,54 @@ class PrescriptionsPage(QWidget):
|
|||||||
if changed:
|
if changed:
|
||||||
self._decorate_rows()
|
self._decorate_rows()
|
||||||
|
|
||||||
|
def _set_agreement_visible(self, visible: bool) -> None:
|
||||||
|
self._agreement_visible = visible
|
||||||
|
self.table.setColumnHidden(12, not (self._ai_enabled and visible))
|
||||||
|
self.ai_statistics_button.setVisible(
|
||||||
|
visible and has_permission(self.permissions, "tcm.prescriptionAi/statistics", default=False)
|
||||||
|
and callable(getattr(self.repository, "prescription_ai_statistics", None)))
|
||||||
|
if not visible:
|
||||||
|
self._ai_statuses = {key: agreement_safe_batch(row, False) for key, row in self._ai_statuses.items()}
|
||||||
|
for index in range(self.table.rowCount()):
|
||||||
|
item = self.table.item(index, 12)
|
||||||
|
if item is not None:
|
||||||
|
item.setText("")
|
||||||
|
item.setToolTip("")
|
||||||
|
item.setData(Qt.ItemDataRole.AccessibleTextRole, "")
|
||||||
|
|
||||||
|
def _load_ai_capabilities(self) -> None:
|
||||||
|
self._ai_capability_token += 1
|
||||||
|
token, generation = self._ai_capability_token, self._generation
|
||||||
|
self._ai_request_token += 1
|
||||||
|
self._ai_pending = False
|
||||||
|
self._capability_agreement_visible = False
|
||||||
|
self._set_agreement_visible(False)
|
||||||
|
method = getattr(self.repository, "get_prescription_ai_capabilities", None)
|
||||||
|
self._ai_capability_pending = callable(method)
|
||||||
|
if not callable(method):
|
||||||
|
return
|
||||||
|
run_async(method,
|
||||||
|
on_success=lambda result: self._ai_capabilities_ready(result, token, generation),
|
||||||
|
on_error=lambda _error: self._ai_capabilities_ready({}, token, generation))
|
||||||
|
|
||||||
|
def _ai_capabilities_ready(self, result: Any, token: int, generation: int) -> None:
|
||||||
|
if token != self._ai_capability_token or generation != self._generation:
|
||||||
|
return
|
||||||
|
self._ai_capability_pending = False
|
||||||
|
self._capability_agreement_visible = _row_mapping(result).get("agreement_visible") is True
|
||||||
|
self._set_agreement_visible(self._capability_agreement_visible)
|
||||||
|
if self._capability_agreement_visible:
|
||||||
|
# A previous deny discarded scores, including completed cached batches.
|
||||||
|
# Fetch them again after a fresh grant rather than displaying that cache.
|
||||||
|
self._ai_statuses.clear()
|
||||||
|
if self.isVisible() and not self._loading:
|
||||||
|
self._load_ai_statuses()
|
||||||
|
|
||||||
def _ai_statuses_ready(self, result: Any, ids: list[int], token: int, generation: int) -> None:
|
def _ai_statuses_ready(self, result: Any, ids: list[int], token: int, generation: int) -> None:
|
||||||
if token != self._ai_request_token or generation != self._generation or not self.isVisible():
|
if token != self._ai_request_token or generation != self._generation or not self.isVisible():
|
||||||
return
|
return
|
||||||
data = _row_mapping(result)
|
data = _row_mapping(result)
|
||||||
|
self._set_agreement_visible(self._capability_agreement_visible and data.get("agreement_visible") is True)
|
||||||
self._set_ai_columns(data.get("enabled") is True)
|
self._set_ai_columns(data.get("enabled") is True)
|
||||||
if not self._ai_enabled:
|
if not self._ai_enabled:
|
||||||
self._ai_timer.stop()
|
self._ai_timer.stop()
|
||||||
@@ -1687,15 +1764,17 @@ class PrescriptionsPage(QWidget):
|
|||||||
self.ai_status_notice.setToolTip("")
|
self.ai_status_notice.setToolTip("")
|
||||||
self.ai_report_button.setToolTip("自动分析未启用;仍可查看已保存的历史报告。")
|
self.ai_report_button.setToolTip("自动分析未启用;仍可查看已保存的历史报告。")
|
||||||
return
|
return
|
||||||
self.ai_status_notice.setText("AI 分析已启用:保存手工处方后自动生成两份报告,并显示药味与剂量一致度。")
|
self.ai_status_notice.setText("AI 分析已启用:保存手工处方后自动生成两份报告。"
|
||||||
|
+ ("可查看药味与剂量一致度。" if self._agreement_visible else ""))
|
||||||
self.ai_status_notice.setToolTip("")
|
self.ai_status_notice.setToolTip("")
|
||||||
self.ai_report_button.setToolTip("查看两份 AI 报告、候选处方和逐味对照。")
|
self.ai_report_button.setToolTip("查看两份 AI 报告和候选处方。"
|
||||||
|
+ ("可查看逐味对照。" if self._agreement_visible else ""))
|
||||||
for value in ids:
|
for value in ids:
|
||||||
self._ai_statuses[value] = {}
|
self._ai_statuses[value] = {}
|
||||||
for batch in data.get("items") or []:
|
for batch in data.get("items") or []:
|
||||||
value = _int(get_value(batch, "prescription_id"), 0)
|
value = _int(get_value(batch, "prescription_id"), 0)
|
||||||
if value in ids:
|
if value in ids:
|
||||||
self._ai_statuses[value] = _row_mapping(batch)
|
self._ai_statuses[value] = agreement_safe_batch(batch, self._agreement_visible and _row_mapping(batch).get("agreement_visible") is True)
|
||||||
self._render_ai_statuses()
|
self._render_ai_statuses()
|
||||||
if any(batch_running(self._ai_statuses.get(value)) for value in self._visible_ai_ids()):
|
if any(batch_running(self._ai_statuses.get(value)) for value in self._visible_ai_ids()):
|
||||||
self._ai_timer.start()
|
self._ai_timer.start()
|
||||||
@@ -1711,15 +1790,17 @@ class PrescriptionsPage(QWidget):
|
|||||||
row = self.table.item(index, 0).data(Qt.ItemDataRole.UserRole)
|
row = self.table.item(index, 0).data(Qt.ItemDataRole.UserRole)
|
||||||
value = _int(first_value(row, "id", "prescription_id"), 0)
|
value = _int(first_value(row, "id", "prescription_id"), 0)
|
||||||
batch = self._ai_statuses.get(value)
|
batch = self._ai_statuses.get(value)
|
||||||
if batch is None:
|
blank = _is_blank_prescription(row)
|
||||||
|
if batch is None and not blank:
|
||||||
continue
|
continue
|
||||||
state = state_text(batch) if batch else ("尚未开方" if _truthy(get_value(row, "is_system_auto")) else "尚无分析记录")
|
state = "" if blank else (state_text(batch) if batch else "尚无分析记录")
|
||||||
for column, text in ((11, state), (12, agreement_text(batch))):
|
agreement = "" if blank or not self._agreement_visible else agreement_text(batch)
|
||||||
|
for column, text in ((11, state), (12, agreement)):
|
||||||
item = self.table.item(index, column)
|
item = self.table.item(index, column)
|
||||||
if item.text() != text:
|
if item.text() != text:
|
||||||
item.setText(text)
|
item.setText(text)
|
||||||
changed = True
|
changed = True
|
||||||
item.setToolTip(status_tooltip(batch))
|
item.setToolTip("" if blank or column == 12 and not self._agreement_visible else status_tooltip(batch))
|
||||||
item.setData(Qt.ItemDataRole.AccessibleTextRole, text)
|
item.setData(Qt.ItemDataRole.AccessibleTextRole, text)
|
||||||
if changed:
|
if changed:
|
||||||
self.table.resizeRowsToContents()
|
self.table.resizeRowsToContents()
|
||||||
@@ -1729,6 +1810,7 @@ class PrescriptionsPage(QWidget):
|
|||||||
def _ai_statuses_error(self, error: Exception, token: int, generation: int) -> None:
|
def _ai_statuses_error(self, error: Exception, token: int, generation: int) -> None:
|
||||||
if token == self._ai_request_token and generation == self._generation:
|
if token == self._ai_request_token and generation == self._generation:
|
||||||
self._ai_timer.stop()
|
self._ai_timer.stop()
|
||||||
|
self._set_agreement_visible(False)
|
||||||
self._set_ai_columns(False)
|
self._set_ai_columns(False)
|
||||||
self.ai_status_notice.setText("AI 分析暂不可用:请稍后刷新;持续无法使用时,请联系管理员检查服务。")
|
self.ai_status_notice.setText("AI 分析暂不可用:请稍后刷新;持续无法使用时,请联系管理员检查服务。")
|
||||||
self.ai_status_notice.setToolTip(friendly_error(error))
|
self.ai_status_notice.setToolTip(friendly_error(error))
|
||||||
@@ -1736,6 +1818,8 @@ class PrescriptionsPage(QWidget):
|
|||||||
|
|
||||||
def _open_ai_report(self) -> None:
|
def _open_ai_report(self) -> None:
|
||||||
row = self._selected()
|
row = self._selected()
|
||||||
|
if _is_blank_prescription(row):
|
||||||
|
return
|
||||||
value = _int(first_value(row, "id", "prescription_id"), 0)
|
value = _int(first_value(row, "id", "prescription_id"), 0)
|
||||||
if value > 0:
|
if value > 0:
|
||||||
present_issued_prescription_ai(self.repository, self.permissions, self, prescription_id=value)
|
present_issued_prescription_ai(self.repository, self.permissions, self, prescription_id=value)
|
||||||
@@ -1748,14 +1832,16 @@ class PrescriptionsPage(QWidget):
|
|||||||
return
|
return
|
||||||
self.table.selectRow(item.row())
|
self.table.selectRow(item.row())
|
||||||
row = self._selected()
|
row = self._selected()
|
||||||
|
if _is_blank_prescription(row):
|
||||||
|
return
|
||||||
menu = QMenu(self.table)
|
menu = QMenu(self.table)
|
||||||
action = menu.addAction("AI 报告 / 逐味对照")
|
action = menu.addAction("AI 报告 / 逐味对照" if self._agreement_visible else "AI 报告")
|
||||||
action.triggered.connect(lambda: self._run_row_action(row, self._open_ai_report))
|
action.triggered.connect(lambda: self._run_row_action(row, self._open_ai_report))
|
||||||
self._ai_context_menu = menu
|
self._ai_context_menu = menu
|
||||||
menu.popup(self.table.viewport().mapToGlobal(position))
|
menu.popup(self.table.viewport().mapToGlobal(position))
|
||||||
|
|
||||||
def _open_ai_statistics(self) -> None:
|
def _open_ai_statistics(self) -> None:
|
||||||
if not has_permission(self.permissions, "tcm.prescriptionAi/statistics", default=False):
|
if not self._agreement_visible or not has_permission(self.permissions, "tcm.prescriptionAi/statistics", default=False):
|
||||||
return
|
return
|
||||||
dialog = PrescriptionAiStatisticsDialog(self.repository, self.permissions, self)
|
dialog = PrescriptionAiStatisticsDialog(self.repository, self.permissions, self)
|
||||||
dialog.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
|
dialog.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
|
||||||
@@ -1795,7 +1881,13 @@ class PrescriptionsPage(QWidget):
|
|||||||
row = self.table.current_data()
|
row = self.table.current_data()
|
||||||
active = not self._mutation_pending
|
active = not self._mutation_pending
|
||||||
self.view_button.setEnabled(active and row is not None)
|
self.view_button.setEnabled(active and row is not None)
|
||||||
self.ai_report_button.setEnabled(row is not None)
|
ai_available = (
|
||||||
|
not _is_blank_prescription(row)
|
||||||
|
and can_open_issued_ai(self.permissions)
|
||||||
|
and callable(getattr(self.repository, "list_prescription_ai_reports", None))
|
||||||
|
)
|
||||||
|
self.ai_report_button.setVisible(ai_available)
|
||||||
|
self.ai_report_button.setEnabled(ai_available)
|
||||||
self.patch_button.setEnabled(active and can_patch_patient(row))
|
self.patch_button.setEnabled(active and can_patch_patient(row))
|
||||||
self.create_order_button.setEnabled(active and can_create_order(row))
|
self.create_order_button.setEnabled(active and can_create_order(row))
|
||||||
self.edit_button.setEnabled(active and can_edit_or_delete(row))
|
self.edit_button.setEnabled(active and can_edit_or_delete(row))
|
||||||
@@ -2169,12 +2261,14 @@ class PrescriptionsPage(QWidget):
|
|||||||
if self.table.rowCount() == 0 and not self._loading:
|
if self.table.rowCount() == 0 and not self._loading:
|
||||||
self.refresh()
|
self.refresh()
|
||||||
elif not self._loading:
|
elif not self._loading:
|
||||||
self._load_ai_statuses()
|
self._load_ai_capabilities()
|
||||||
|
|
||||||
def hideEvent(self, event: Any) -> None:
|
def hideEvent(self, event: Any) -> None:
|
||||||
self._ai_timer.stop()
|
self._ai_timer.stop()
|
||||||
self._ai_scroll_timer.stop()
|
self._ai_scroll_timer.stop()
|
||||||
self._ai_request_token += 1
|
self._ai_request_token += 1
|
||||||
|
self._ai_capability_token += 1
|
||||||
|
self._ai_capability_pending = False
|
||||||
self._ai_pending = False
|
self._ai_pending = False
|
||||||
super().hideEvent(event)
|
super().hideEvent(event)
|
||||||
|
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ from ..dialogs.prescription_ai import (
|
|||||||
)
|
)
|
||||||
from ..filter_disclosure import FilterDisclosure
|
from ..filter_disclosure import FilterDisclosure
|
||||||
from ..reception_style import body_family, heading_family, number_family
|
from ..reception_style import body_family, heading_family, number_family
|
||||||
|
from ..report_provenance import report_provenance_lines
|
||||||
from ..theme import mark_business_dialog
|
from ..theme import mark_business_dialog
|
||||||
from ..widgets import (
|
from ..widgets import (
|
||||||
EmptyState,
|
EmptyState,
|
||||||
@@ -320,6 +321,11 @@ QWidget#ReceptionPage QLabel#ReceptionPatientMeta {
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
|
QWidget#ReceptionPage QLabel#ReceptionCurrentAssistant {
|
||||||
|
color: #273244;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
QWidget#ReceptionPage QLabel#StatusBadge {
|
QWidget#ReceptionPage QLabel#StatusBadge {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
@@ -3335,6 +3341,14 @@ class _ReceptionAiAnalysisDialog(QDialog):
|
|||||||
"",
|
"",
|
||||||
"ReceptionAiAnalysisDialogTreatmentText",
|
"ReceptionAiAnalysisDialogTreatmentText",
|
||||||
)
|
)
|
||||||
|
source_label = QLabel("", body)
|
||||||
|
source_label.setObjectName("ReceptionAiAnalysisDialogSources")
|
||||||
|
source_label.setTextFormat(Qt.TextFormat.PlainText)
|
||||||
|
source_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
|
||||||
|
source_label.setWordWrap(True)
|
||||||
|
source_label.setStyleSheet("color: #555B68; font-size: 12px; padding: 10px 14px; background: #F7F8FC; border: 1px solid #E4E8F2; border-radius: 8px;")
|
||||||
|
body_layout.addWidget(source_label)
|
||||||
|
self.source_label = source_label
|
||||||
body_layout.addStretch(1)
|
body_layout.addStretch(1)
|
||||||
scroll.setWidget(body)
|
scroll.setWidget(body)
|
||||||
surface_layout.addWidget(scroll, 1)
|
surface_layout.addWidget(scroll, 1)
|
||||||
@@ -3525,6 +3539,7 @@ class _ReceptionAiAnalysisDialog(QDialog):
|
|||||||
" | ".join(part for part in (model, f"第 {version} 版", generated_at) if part)
|
" | ".join(part for part in (model, f"第 {version} 版", generated_at) if part)
|
||||||
)
|
)
|
||||||
self.meta_label.setVisible(bool(self.meta_label.text()))
|
self.meta_label.setVisible(bool(self.meta_label.text()))
|
||||||
|
self.source_label.setText("报告出处\n" + "\n".join(report_provenance_lines(payload)))
|
||||||
self.scroll_area.verticalScrollBar().setValue(0)
|
self.scroll_area.verticalScrollBar().setValue(0)
|
||||||
|
|
||||||
|
|
||||||
@@ -4384,8 +4399,17 @@ class ReceptionPage(QWidget):
|
|||||||
actions.setContentsMargins(0, 0, 0, 0)
|
actions.setContentsMargins(0, 0, 0, 0)
|
||||||
actions.setSpacing(14)
|
actions.setSpacing(14)
|
||||||
actions.addStretch(1)
|
actions.addStretch(1)
|
||||||
patient_head.addLayout(actions)
|
action_group = QVBoxLayout()
|
||||||
patient_head.setAlignment(actions, Qt.AlignmentFlag.AlignBottom)
|
action_group.setContentsMargins(0, 0, 0, 0)
|
||||||
|
action_group.setSpacing(6)
|
||||||
|
self.current_assistant_label = QLabel("当前医助:—", hero)
|
||||||
|
self.current_assistant_label.setObjectName("ReceptionCurrentAssistant")
|
||||||
|
self.current_assistant_label.setTextFormat(Qt.TextFormat.PlainText)
|
||||||
|
self.current_assistant_label.setWordWrap(True)
|
||||||
|
action_group.addWidget(self.current_assistant_label)
|
||||||
|
action_group.addLayout(actions)
|
||||||
|
patient_head.addLayout(action_group)
|
||||||
|
patient_head.setAlignment(action_group, Qt.AlignmentFlag.AlignBottom)
|
||||||
|
|
||||||
self.complete_button = _ReceptionCompleteButton(hero)
|
self.complete_button = _ReceptionCompleteButton(hero)
|
||||||
self.complete_button.clicked.connect(self._complete_appointment)
|
self.complete_button.clicked.connect(self._complete_appointment)
|
||||||
@@ -5198,6 +5222,13 @@ class ReceptionPage(QWidget):
|
|||||||
state_layout.addStretch(1)
|
state_layout.addStretch(1)
|
||||||
self.ai_analysis_stack.addWidget(analysis_state)
|
self.ai_analysis_stack.addWidget(analysis_state)
|
||||||
analysis_layout.addWidget(self.ai_analysis_stack)
|
analysis_layout.addWidget(self.ai_analysis_stack)
|
||||||
|
self.ai_source_label = QLabel("", analysis_card)
|
||||||
|
self.ai_source_label.setObjectName("ReceptionAiSourceSummary")
|
||||||
|
self.ai_source_label.setTextFormat(Qt.TextFormat.PlainText)
|
||||||
|
self.ai_source_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
|
||||||
|
self.ai_source_label.setWordWrap(True)
|
||||||
|
self.ai_source_label.setStyleSheet("color: #5B6474; font-size: 11px;")
|
||||||
|
analysis_layout.addWidget(self.ai_source_label)
|
||||||
|
|
||||||
self.ai_detail_actions_host = QWidget(analysis_card)
|
self.ai_detail_actions_host = QWidget(analysis_card)
|
||||||
report_footer = QHBoxLayout(self.ai_detail_actions_host)
|
report_footer = QHBoxLayout(self.ai_detail_actions_host)
|
||||||
@@ -5432,6 +5463,7 @@ class ReceptionPage(QWidget):
|
|||||||
self.ai_analysis_stack.setCurrentWidget(
|
self.ai_analysis_stack.setCurrentWidget(
|
||||||
self.ai_analysis_content_page if success else self.ai_analysis_state_page
|
self.ai_analysis_content_page if success else self.ai_analysis_state_page
|
||||||
)
|
)
|
||||||
|
self.ai_source_label.setVisible(success)
|
||||||
if success and payload is not None:
|
if success and payload is not None:
|
||||||
if (
|
if (
|
||||||
self._ai_analysis_rendered_model != model
|
self._ai_analysis_rendered_model != model
|
||||||
@@ -5559,6 +5591,9 @@ class ReceptionPage(QWidget):
|
|||||||
)
|
)
|
||||||
self.ai_analysis_snapshot_meta.setVisible(bool(meta_text))
|
self.ai_analysis_snapshot_meta.setVisible(bool(meta_text))
|
||||||
self.ai_analysis_card.setToolTip("")
|
self.ai_analysis_card.setToolTip("")
|
||||||
|
source_lines = report_provenance_lines(payload)
|
||||||
|
self.ai_source_label.setText("报告出处:" + ";".join(source_lines[:2]))
|
||||||
|
self.ai_source_label.setToolTip("\n".join(source_lines))
|
||||||
|
|
||||||
def _render_ai_risk_chips(self, risks: Sequence[Mapping[str, Any]]) -> None:
|
def _render_ai_risk_chips(self, risks: Sequence[Mapping[str, Any]]) -> None:
|
||||||
_clear_ai_layout(self.ai_risk_chip_layout)
|
_clear_ai_layout(self.ai_risk_chip_layout)
|
||||||
@@ -7864,9 +7899,14 @@ class ReceptionPage(QWidget):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
self.appointment_labels["assistant"].setText(
|
# reception normalises these fields from the diagnosis's current
|
||||||
display_text(first_value(appointment, "assistant_name"))
|
# assistant, matching the recipient of the notification action.
|
||||||
)
|
assistant_name = display_text(first_value(appointment, "assistant_name"), "")
|
||||||
|
assistant_id = _as_int(first_value(appointment, "assistant_id"), None)
|
||||||
|
missing_name = "姓名暂缺" if assistant_id else "未分配" if assistant_id == 0 else "—"
|
||||||
|
self.current_assistant_label.setText(f"当前医助:{assistant_name or missing_name}")
|
||||||
|
self.current_assistant_label.setToolTip(self.current_assistant_label.text())
|
||||||
|
self.appointment_labels["assistant"].setText(assistant_name or "—")
|
||||||
self.appointment_labels["type"].setText(
|
self.appointment_labels["type"].setText(
|
||||||
appointment_type_description(appointment_type_value(appointment))
|
appointment_type_description(appointment_type_value(appointment))
|
||||||
)
|
)
|
||||||
@@ -8800,6 +8840,8 @@ class ReceptionPage(QWidget):
|
|||||||
)
|
)
|
||||||
self.patient_meta_label.setText("正在核对患者、诊单与预约信息…" if seed else "—")
|
self.patient_meta_label.setText("正在核对患者、诊单与预约信息…" if seed else "—")
|
||||||
self.patient_meta_label.setToolTip("")
|
self.patient_meta_label.setToolTip("")
|
||||||
|
self.current_assistant_label.setText("当前医助:正在加载…" if seed else "当前医助:—")
|
||||||
|
self.current_assistant_label.setToolTip("")
|
||||||
self.detail_status.set_status("—", "neutral")
|
self.detail_status.set_status("—", "neutral")
|
||||||
for label in getattr(self, "appointment_labels", {}).values():
|
for label in getattr(self, "appointment_labels", {}).values():
|
||||||
label.setText("—")
|
label.setText("—")
|
||||||
@@ -8865,6 +8907,8 @@ class ReceptionPage(QWidget):
|
|||||||
return
|
return
|
||||||
self.patient_meta_label.setText(message)
|
self.patient_meta_label.setText(message)
|
||||||
self.patient_meta_label.setToolTip("")
|
self.patient_meta_label.setToolTip("")
|
||||||
|
self.current_assistant_label.setText("当前医助:—")
|
||||||
|
self.current_assistant_label.setToolTip("")
|
||||||
self._set_case_placeholder(message)
|
self._set_case_placeholder(message)
|
||||||
self.prescription_hint.setText("处方:患者详情加载失败")
|
self.prescription_hint.setText("处方:患者详情加载失败")
|
||||||
self.prescription_hint.setProperty("casePrescriptionState", "unknown")
|
self.prescription_hint.setProperty("casePrescriptionState", "unknown")
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
"""Human-readable, server-recorded source scope for clinical AI reports."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from typing import Any
|
||||||
|
from urllib.parse import unquote
|
||||||
|
|
||||||
|
_REPORT_SOURCE_COUNTS = (
|
||||||
|
("diagnosis_count", "诊单"),
|
||||||
|
("doctor_note_count", "医生备注"),
|
||||||
|
("tracking_note_count", "随访记录"),
|
||||||
|
("blood_record_count", "血糖血压记录"),
|
||||||
|
("diet_record_count", "饮食记录"),
|
||||||
|
("exercise_record_count", "运动记录"),
|
||||||
|
("prescription_count", "历史处方"),
|
||||||
|
("im_message_count", "IM 消息"),
|
||||||
|
("wechat_message_count", "企微消息"),
|
||||||
|
("call_record_count", "视频问诊"),
|
||||||
|
("transcript_segment_count", "视频转写片段"),
|
||||||
|
)
|
||||||
|
_NIHAIXIA_SOURCE_URL = "https://github.com/coinweth/nihaixia-skill"
|
||||||
|
_NIHAIXIA_COMMIT = "270e94e637c4249dc411e54ac6715f21247de3cb"
|
||||||
|
_ANCIENT_BOOKS_COMMIT = "db0155dc7c42b9c6b3736896661f317c7110038f"
|
||||||
|
_ANCIENT_BOOKS_URL = "https://github.com/xiaopangxia/TCM-Ancient-Books"
|
||||||
|
|
||||||
|
|
||||||
|
def _source_text(value: Any, limit: int) -> str:
|
||||||
|
"""Keep displayed source metadata brief and free of repository links."""
|
||||||
|
text = re.sub(r"https://github\.com(?:/[^\s,。;、」)》\]]*)?", "", str(value or ""))
|
||||||
|
return " ".join(text.split())[:limit]
|
||||||
|
|
||||||
|
|
||||||
|
def report_provenance_lines(raw: Any) -> tuple[str, ...]:
|
||||||
|
"""Describe recorded inputs; never turn model prose into a claimed citation."""
|
||||||
|
report = raw if isinstance(raw, Mapping) else {}
|
||||||
|
lines: list[str] = []
|
||||||
|
ids = report.get("source_diagnosis_ids")
|
||||||
|
diagnosis_ids = [
|
||||||
|
value for value in ids if type(value) is int and value > 0
|
||||||
|
] if isinstance(ids, (list, tuple)) else []
|
||||||
|
if diagnosis_ids:
|
||||||
|
shown = "、".join(f"#{value}" for value in diagnosis_ids[:12])
|
||||||
|
lines.append("来源诊单:" + shown + (" 等" if len(diagnosis_ids) > 12 else ""))
|
||||||
|
|
||||||
|
summary = report.get("source_summary")
|
||||||
|
counts = []
|
||||||
|
if isinstance(summary, Mapping):
|
||||||
|
for key, label in _REPORT_SOURCE_COUNTS:
|
||||||
|
value = summary.get(key)
|
||||||
|
if type(value) is int and value > 0:
|
||||||
|
counts.append(f"{label} {value} 条")
|
||||||
|
if counts:
|
||||||
|
lines.append("患者资料范围:" + "、".join(counts))
|
||||||
|
elif isinstance(summary, Mapping) and type(summary.get("source_record_count")) is int and summary["source_record_count"] > 0:
|
||||||
|
lines.append(f"患者资料范围:记录 {summary['source_record_count']} 条,分类未记录")
|
||||||
|
elif not diagnosis_ids:
|
||||||
|
lines.append("患者资料范围:此报告未附可核查的来源清单")
|
||||||
|
|
||||||
|
knowledge = report.get("knowledge_source")
|
||||||
|
url = knowledge.get("url") if isinstance(knowledge, Mapping) else None
|
||||||
|
legacy_source_match = re.fullmatch(
|
||||||
|
re.escape(_NIHAIXIA_SOURCE_URL) + r"/blob/([0-9a-f]{40})/SKILL\.md",
|
||||||
|
url,
|
||||||
|
) if isinstance(url, str) else None
|
||||||
|
valid_knowledge = isinstance(knowledge, Mapping) and knowledge.get("commit") == _NIHAIXIA_COMMIT and (
|
||||||
|
(url in (None, "") and knowledge.get("path") == "SKILL.md")
|
||||||
|
or (
|
||||||
|
legacy_source_match is not None
|
||||||
|
and legacy_source_match.group(1) == _NIHAIXIA_COMMIT
|
||||||
|
and knowledge.get("path") in (None, "SKILL.md")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if valid_knowledge:
|
||||||
|
lines.append(
|
||||||
|
"知识参考:倪海厦临床参考 Skill;本地资料:"
|
||||||
|
"server/knowledge/nihaixia-skill/SKILL.md"
|
||||||
|
+ f"(版本 {_NIHAIXIA_COMMIT[:12]})"
|
||||||
|
)
|
||||||
|
sections = knowledge.get("sections")
|
||||||
|
allowed_sections = {"六经速查表", "六经辨证诊断公式", "七步走辨证思维模式"}
|
||||||
|
if isinstance(sections, (list, tuple)) and sections and all(
|
||||||
|
isinstance(item, str) and item in allowed_sections for item in sections
|
||||||
|
):
|
||||||
|
lines.append("参考章节:" + "、".join(sections))
|
||||||
|
else:
|
||||||
|
lines.append("知识参考:此报告未记录所用知识资料版本")
|
||||||
|
|
||||||
|
ancient_sources = report.get("ancient_book_sources")
|
||||||
|
shown_sources: set[tuple[str, int]] = set()
|
||||||
|
if isinstance(ancient_sources, (list, tuple)):
|
||||||
|
for source in ancient_sources[:12]:
|
||||||
|
if not isinstance(source, Mapping) or source.get("commit") != _ANCIENT_BOOKS_COMMIT:
|
||||||
|
continue
|
||||||
|
ancient_url = source.get("url")
|
||||||
|
filename = source.get("path")
|
||||||
|
line_number = source.get("line")
|
||||||
|
if ancient_url not in (None, ""):
|
||||||
|
legacy_match = re.fullmatch(
|
||||||
|
re.escape(_ANCIENT_BOOKS_URL)
|
||||||
|
+ r"/blob/"
|
||||||
|
+ _ANCIENT_BOOKS_COMMIT
|
||||||
|
+ r"/([^/#?]+\.txt)#L([1-9]\d{0,8})",
|
||||||
|
ancient_url,
|
||||||
|
) if isinstance(ancient_url, str) else None
|
||||||
|
if legacy_match is None:
|
||||||
|
continue
|
||||||
|
legacy_filename = unquote(legacy_match.group(1))
|
||||||
|
legacy_line = int(legacy_match.group(2))
|
||||||
|
if filename not in (None, legacy_filename) or line_number not in (None, legacy_line):
|
||||||
|
continue
|
||||||
|
filename, line_number = legacy_filename, legacy_line
|
||||||
|
if not (
|
||||||
|
isinstance(filename, str)
|
||||||
|
and re.fullmatch(r"[^/\\\x00-\x1f]{1,255}\.txt", filename)
|
||||||
|
and type(line_number) is int
|
||||||
|
and 0 < line_number < 1_000_000_000
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
source_key = (filename, line_number)
|
||||||
|
if source_key in shown_sources:
|
||||||
|
continue
|
||||||
|
shown_sources.add(source_key)
|
||||||
|
title = _source_text(source.get("title"), 60) or "古籍原文"
|
||||||
|
chapter = _source_text(source.get("chapter"), 60)
|
||||||
|
excerpt = _source_text(source.get("excerpt"), 120)
|
||||||
|
lines.append(
|
||||||
|
"古籍参考:《" + title + "》"
|
||||||
|
+ ("「" + chapter + "」" if chapter else "")
|
||||||
|
+ f";本地资料:server/knowledge/tcm-ancient-books/{filename}"
|
||||||
|
+ f" 第{line_number}行(版本 {_ANCIENT_BOOKS_COMMIT[:12]})"
|
||||||
|
+ (";摘录:" + excerpt if excerpt else "")
|
||||||
|
)
|
||||||
|
if not shown_sources and "-ancient-" in str(
|
||||||
|
report.get("prompt_version") or report.get("context_version") or ""
|
||||||
|
):
|
||||||
|
lines.append("古籍参考:本次未记录可核查的古籍片段")
|
||||||
|
if report.get("is_edited") is True:
|
||||||
|
lines.append("人工编辑:报告生成后已修改,来源范围仅对应原始生成版本。")
|
||||||
|
lines.append("以上列出本次输入范围与知识参考,不代表每条结论均有逐项引用。")
|
||||||
|
return tuple(lines)
|
||||||
@@ -885,6 +885,7 @@ class ShellWindow(QMainWindow):
|
|||||||
parent: QWidget | None = None,
|
parent: QWidget | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
|
self.setProperty("doctorMainWindow", True)
|
||||||
self.setWindowTitle("甄养堂 · 问诊中心")
|
self.setWindowTitle("甄养堂 · 问诊中心")
|
||||||
self.setWindowFlag(Qt.WindowType.FramelessWindowHint, True)
|
self.setWindowFlag(Qt.WindowType.FramelessWindowHint, True)
|
||||||
# The ground paints its own rounded corner, so everything outside that
|
# The ground paints its own rounded corner, so everything outside that
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from pathlib import Path
|
|||||||
from string import Template
|
from string import Template
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from PySide6.QtCore import QEvent, QObject, QPointF, Qt
|
from PySide6.QtCore import QEvent, QObject, QPointF, QRect, Qt
|
||||||
from PySide6.QtGui import (
|
from PySide6.QtGui import (
|
||||||
QColor,
|
QColor,
|
||||||
QFont,
|
QFont,
|
||||||
@@ -33,6 +33,7 @@ from PySide6.QtWidgets import (
|
|||||||
QScrollArea,
|
QScrollArea,
|
||||||
QTextBrowser,
|
QTextBrowser,
|
||||||
QTextEdit,
|
QTextEdit,
|
||||||
|
QWidget,
|
||||||
)
|
)
|
||||||
|
|
||||||
from doctor_workstation.resources import resource_path
|
from doctor_workstation.resources import resource_path
|
||||||
@@ -1450,6 +1451,73 @@ def mark_business_dialog(dialog: QDialog, object_name: str | None = None) -> Non
|
|||||||
_refresh_widget_style(dialog)
|
_refresh_widget_style(dialog)
|
||||||
|
|
||||||
|
|
||||||
|
def main_window_target_rect(window: QWidget) -> QRect | None:
|
||||||
|
"""Return a centered rectangle covering 85% of the visible workstation."""
|
||||||
|
|
||||||
|
app = QApplication.instance()
|
||||||
|
if app is None:
|
||||||
|
return None
|
||||||
|
main = next(
|
||||||
|
(
|
||||||
|
candidate
|
||||||
|
for candidate in app.topLevelWidgets()
|
||||||
|
if candidate is not window
|
||||||
|
and candidate.property("doctorMainWindow")
|
||||||
|
and candidate.isVisible()
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if main is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
main_frame = main.frameGeometry()
|
||||||
|
target_width = max(1, round(main_frame.width() * 0.85))
|
||||||
|
target_height = max(1, round(main_frame.height() * 0.85))
|
||||||
|
return QRect(
|
||||||
|
main_frame.center().x() - target_width // 2,
|
||||||
|
main_frame.center().y() - target_height // 2,
|
||||||
|
target_width,
|
||||||
|
target_height,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _size_new_window_to_main(window: QWidget) -> None:
|
||||||
|
"""Open a business window at 85% of the visible workstation, centered."""
|
||||||
|
|
||||||
|
if not window.isWindow() or window.property("doctorMainWindow"):
|
||||||
|
return
|
||||||
|
if window.property("doctorSkipOpenGeometry"):
|
||||||
|
return
|
||||||
|
if (
|
||||||
|
isinstance(window, (QMessageBox, QInputDialog, QFileDialog))
|
||||||
|
or window.metaObject().className() == "QPrintDialog"
|
||||||
|
):
|
||||||
|
return
|
||||||
|
if window.windowFlags() & Qt.WindowType.FramelessWindowHint:
|
||||||
|
# Drawers have their own owner-sync routines and use the same target rect.
|
||||||
|
return
|
||||||
|
target = main_window_target_rect(window)
|
||||||
|
if target is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
target_width = target.width()
|
||||||
|
target_height = target.height()
|
||||||
|
if window.minimumWidth() > target_width:
|
||||||
|
window.setMinimumWidth(target_width)
|
||||||
|
if window.minimumHeight() > target_height:
|
||||||
|
window.setMinimumHeight(target_height)
|
||||||
|
if window.maximumWidth() < target_width:
|
||||||
|
window.setMaximumWidth(target_width)
|
||||||
|
if window.maximumHeight() < target_height:
|
||||||
|
window.setMaximumHeight(target_height)
|
||||||
|
window.resize(target_width, target_height)
|
||||||
|
frame = window.frameGeometry()
|
||||||
|
window.move(
|
||||||
|
target.center().x() - frame.width() // 2,
|
||||||
|
target.center().y() - frame.height() // 2,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class _BusinessDialogStyleFilter(QObject):
|
class _BusinessDialogStyleFilter(QObject):
|
||||||
"""Style dynamically-created Qt dialogs just before they become visible."""
|
"""Style dynamically-created Qt dialogs just before they become visible."""
|
||||||
|
|
||||||
@@ -1490,6 +1558,7 @@ class _BusinessDialogStyleFilter(QObject):
|
|||||||
if not self._is_native_or_overlay(watched):
|
if not self._is_native_or_overlay(watched):
|
||||||
allow_dialog_resize(watched)
|
allow_dialog_resize(watched)
|
||||||
elif isinstance(watched, QDialog) and event_type == QEvent.Type.Show:
|
elif isinstance(watched, QDialog) and event_type == QEvent.Type.Show:
|
||||||
|
_size_new_window_to_main(watched)
|
||||||
for button_box in watched.findChildren(QDialogButtonBox):
|
for button_box in watched.findChildren(QDialogButtonBox):
|
||||||
_polish_dialog_buttons(button_box)
|
_polish_dialog_buttons(button_box)
|
||||||
if not self._is_native_or_overlay(watched):
|
if not self._is_native_or_overlay(watched):
|
||||||
@@ -1505,6 +1574,13 @@ class _BusinessDialogStyleFilter(QObject):
|
|||||||
watched.setMinimumWidth(max(watched.minimumWidth(), 460))
|
watched.setMinimumWidth(max(watched.minimumWidth(), 460))
|
||||||
elif isinstance(watched, QInputDialog):
|
elif isinstance(watched, QInputDialog):
|
||||||
watched.setMinimumWidth(max(watched.minimumWidth(), 480))
|
watched.setMinimumWidth(max(watched.minimumWidth(), 480))
|
||||||
|
elif (
|
||||||
|
isinstance(watched, QWidget)
|
||||||
|
and event_type == QEvent.Type.Show
|
||||||
|
and watched.isWindow()
|
||||||
|
and watched.windowType() == Qt.WindowType.Window
|
||||||
|
):
|
||||||
|
_size_new_window_to_main(watched)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
@@ -1600,4 +1676,5 @@ __all__ = [
|
|||||||
"apply_theme",
|
"apply_theme",
|
||||||
"crisp_pixmap",
|
"crisp_pixmap",
|
||||||
"mark_business_dialog",
|
"mark_business_dialog",
|
||||||
|
"main_window_target_rect",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -2,13 +2,16 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
from threading import Event
|
||||||
|
from time import monotonic
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
from PySide6.QtCore import Qt
|
from PySide6.QtCore import QPoint, Qt, QThread
|
||||||
from PySide6.QtTest import QTest
|
from PySide6.QtTest import QTest
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QApplication,
|
QApplication,
|
||||||
@@ -21,7 +24,7 @@ from PySide6.QtWidgets import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from doctor_workstation.core import PermissionSet
|
from doctor_workstation.core import PermissionSet
|
||||||
from doctor_workstation.services import DemoDoctorRepository
|
from doctor_workstation.services import ApiClient, DemoDoctorRepository, RemoteDoctorRepository
|
||||||
from doctor_workstation.ui.dialogs import ai_consult as ai_consult_module
|
from doctor_workstation.ui.dialogs import ai_consult as ai_consult_module
|
||||||
from doctor_workstation.ui.dialogs.ai_consult import (
|
from doctor_workstation.ui.dialogs.ai_consult import (
|
||||||
AiConsultDialog,
|
AiConsultDialog,
|
||||||
@@ -125,6 +128,56 @@ def test_ai_consult_dialog_matches_workspace_chrome(
|
|||||||
dialog.close()
|
dialog.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_header_and_chat_filter_move_to_right_pane_and_filter_message_roles(
|
||||||
|
application: QApplication,
|
||||||
|
) -> None:
|
||||||
|
dialog = AiConsultDialog(
|
||||||
|
DemoDoctorRepository(), PermissionSet(["tcm.diagnosis/aiAssistant"])
|
||||||
|
)
|
||||||
|
patient = dialog._append_bubble("patient", "患者提问")
|
||||||
|
doctor = dialog._append_bubble("doctor", "医生回复")
|
||||||
|
ai = dialog._append_bubble("ai", "智能分析")
|
||||||
|
system = dialog._append_event(title="视频开始")
|
||||||
|
dialog.show()
|
||||||
|
application.processEvents()
|
||||||
|
|
||||||
|
right_pane = dialog.findChild(QWidget, "AiConsultRightPane")
|
||||||
|
chat_pane = dialog.findChild(QWidget, "AiConsultChatPane")
|
||||||
|
assert right_pane is not None and chat_pane is not None
|
||||||
|
assert right_pane.mapTo(dialog, QPoint(0, 0)).x() >= chat_pane.mapTo(
|
||||||
|
dialog, QPoint(chat_pane.width(), 0)
|
||||||
|
).x()
|
||||||
|
assert dialog.tabs.parentWidget() is right_pane
|
||||||
|
assert dialog.chat_filter_button.parentWidget() is right_pane
|
||||||
|
assert right_pane.findChild(QWidget, "AiConsultHero") is not None
|
||||||
|
assert all(item.isVisible() for item in (patient, doctor, ai, system))
|
||||||
|
|
||||||
|
actions = {action.data(): action for action in dialog.chat_filter_menu.actions()}
|
||||||
|
actions["doctor"].trigger()
|
||||||
|
application.processEvents()
|
||||||
|
assert doctor.isVisible()
|
||||||
|
assert not patient.isVisible()
|
||||||
|
assert not ai.isVisible()
|
||||||
|
assert not system.isVisible()
|
||||||
|
|
||||||
|
new_doctor = dialog._append_bubble("doctor", "新的医生回复")
|
||||||
|
new_patient = dialog._append_bubble("patient", "新的患者提问")
|
||||||
|
application.processEvents()
|
||||||
|
assert new_doctor.isVisible()
|
||||||
|
assert not new_patient.isVisible()
|
||||||
|
|
||||||
|
actions["all"].trigger()
|
||||||
|
application.processEvents()
|
||||||
|
assert all(item.isVisible() for item in (patient, doctor, ai, system, new_doctor, new_patient))
|
||||||
|
dialog.tabs.setCurrentIndex(1)
|
||||||
|
application.processEvents()
|
||||||
|
assert not dialog.chat_filter_button.isVisible()
|
||||||
|
dialog.tabs.setCurrentIndex(0)
|
||||||
|
application.processEvents()
|
||||||
|
assert dialog.chat_filter_button.isVisible()
|
||||||
|
dialog.close()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("key", [Qt.Key.Key_Return, Qt.Key.Key_Enter])
|
@pytest.mark.parametrize("key", [Qt.Key.Key_Return, Qt.Key.Key_Enter])
|
||||||
def test_input_enter_submits_once_without_closing_dialog(
|
def test_input_enter_submits_once_without_closing_dialog(
|
||||||
application: QApplication,
|
application: QApplication,
|
||||||
@@ -197,6 +250,52 @@ def test_prescription_action_without_permission_does_not_call_ai_or_repository(
|
|||||||
dialog.close()
|
dialog.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("grants", "visible"),
|
||||||
|
[
|
||||||
|
(["tcm.diagnosis/aiAssistant"], False),
|
||||||
|
(["tcm.diagnosis/aiAssistant", "tcm.diagnosis/chufang"], False),
|
||||||
|
(["tcm.diagnosis/aiAssistant", "tcm.diagnosis/aiGeneratePrescription"], False),
|
||||||
|
(["tcm.diagnosis/aiAssistant", "tcm.diagnosis/chufang", "tcm.diagnosis/aiGeneratePrescription"], True),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_ai_prescription_quick_prompt_requires_both_permissions(
|
||||||
|
application: QApplication,
|
||||||
|
grants: list[str],
|
||||||
|
visible: bool,
|
||||||
|
) -> None:
|
||||||
|
dialog = AiConsultDialog(DemoDoctorRepository(), PermissionSet(grants))
|
||||||
|
buttons = dialog.findChildren(QPushButton, "AiConsultChipButton")
|
||||||
|
assert ("AI 生成处方" in [button.text() for button in buttons]) is visible
|
||||||
|
dialog.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_prescription_action_with_write_permission_but_without_ai_grant_is_blocked(
|
||||||
|
application: QApplication,
|
||||||
|
) -> None:
|
||||||
|
class Repository:
|
||||||
|
def create_prescription(self, _payload: Any) -> None:
|
||||||
|
raise AssertionError("must not create without AI prescription permission")
|
||||||
|
|
||||||
|
def generate_ai_prescription(self, _diagnosis_id: int) -> None:
|
||||||
|
raise AssertionError("must not generate without AI prescription permission")
|
||||||
|
|
||||||
|
dialog = AiConsultDialog(
|
||||||
|
Repository(),
|
||||||
|
PermissionSet(["tcm.diagnosis/aiAssistant", "tcm.diagnosis/chufang"]),
|
||||||
|
)
|
||||||
|
dialog.diagnosis_id = 501
|
||||||
|
dialog._ask("开个处方")
|
||||||
|
application.processEvents()
|
||||||
|
bodies = [
|
||||||
|
browser.toPlainText()
|
||||||
|
for browser in dialog.findChildren(QTextBrowser, "AiConsultBubbleText")
|
||||||
|
]
|
||||||
|
assert any("没有 AI 生成处方权限" in body for body in bodies)
|
||||||
|
assert dialog._stream_worker is None
|
||||||
|
dialog.close()
|
||||||
|
|
||||||
|
|
||||||
def test_prescription_action_opens_editor_and_forces_current_diagnosis_ownership(
|
def test_prescription_action_opens_editor_and_forces_current_diagnosis_ownership(
|
||||||
application: QApplication,
|
application: QApplication,
|
||||||
immediate_async: None,
|
immediate_async: None,
|
||||||
@@ -298,7 +397,7 @@ def test_prescription_action_opens_editor_and_forces_current_diagnosis_ownership
|
|||||||
dialog = AiConsultDialog(
|
dialog = AiConsultDialog(
|
||||||
Repository(),
|
Repository(),
|
||||||
PermissionSet(
|
PermissionSet(
|
||||||
["tcm.diagnosis/aiAssistant", "tcm.diagnosis/chufang"]
|
["tcm.diagnosis/aiAssistant", "tcm.diagnosis/chufang", "tcm.diagnosis/aiGeneratePrescription"]
|
||||||
),
|
),
|
||||||
parent=host,
|
parent=host,
|
||||||
)
|
)
|
||||||
@@ -366,7 +465,7 @@ def test_prescription_action_rejects_mismatched_patient_detail(
|
|||||||
dialog = AiConsultDialog(
|
dialog = AiConsultDialog(
|
||||||
Repository(),
|
Repository(),
|
||||||
PermissionSet(
|
PermissionSet(
|
||||||
["tcm.diagnosis/aiAssistant", "tcm.diagnosis/chufang"]
|
["tcm.diagnosis/aiAssistant", "tcm.diagnosis/chufang", "tcm.diagnosis/aiGeneratePrescription"]
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
dialog.diagnosis_id = 501
|
dialog.diagnosis_id = 501
|
||||||
@@ -617,6 +716,29 @@ def test_ai_consult_sidebar_loads_patient_facts_and_reports(
|
|||||||
dialog.close()
|
dialog.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_server_analysis_is_not_presented_as_ai_diagnosis(
|
||||||
|
application: QApplication,
|
||||||
|
) -> None:
|
||||||
|
dialog = AiConsultDialog(
|
||||||
|
DemoDoctorRepository(), PermissionSet(["tcm.diagnosis/aiAssistant"])
|
||||||
|
)
|
||||||
|
dialog._seed = {
|
||||||
|
"clinical_diagnosis": "糖尿病",
|
||||||
|
"fasting_blood_sugar": 11.2,
|
||||||
|
}
|
||||||
|
dialog._render_analysis({})
|
||||||
|
dialog.show()
|
||||||
|
application.processEvents()
|
||||||
|
|
||||||
|
titles = [
|
||||||
|
widget.text()
|
||||||
|
for widget in dialog.findChildren(QLabel, "AiConsultRecordTitle")
|
||||||
|
]
|
||||||
|
assert titles == ["分析状态"]
|
||||||
|
assert not dialog.findChildren(QTextBrowser, "AiConsultBubbleText")
|
||||||
|
dialog.close()
|
||||||
|
|
||||||
|
|
||||||
def test_ai_consult_sidebar_survives_chat_archive_errors(
|
def test_ai_consult_sidebar_survives_chat_archive_errors(
|
||||||
application: QApplication,
|
application: QApplication,
|
||||||
immediate_async: None,
|
immediate_async: None,
|
||||||
@@ -1372,6 +1494,55 @@ def test_pending_and_silently_closed_streams_never_show_a_blank_bubble(
|
|||||||
dialog.close()
|
dialog.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_stalled_stream_exits_pending_state_and_ignores_late_events(
|
||||||
|
application: QApplication,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
dialog = _silent_dialog(monkeypatch)
|
||||||
|
dialog.show()
|
||||||
|
application.processEvents()
|
||||||
|
|
||||||
|
dialog._ask("请总结当前病情")
|
||||||
|
generation, stream_generation = dialog._generation, dialog._stream_generation
|
||||||
|
bubble = dialog._stream_bubble
|
||||||
|
worker = dialog._stream_worker
|
||||||
|
assert bubble is not None and worker is not None
|
||||||
|
assert dialog._stream_timeout_timer.isActive()
|
||||||
|
|
||||||
|
dialog._stream_timed_out()
|
||||||
|
assert worker.is_cancelled()
|
||||||
|
assert not dialog._stream_timeout_timer.isActive()
|
||||||
|
assert not dialog._asking
|
||||||
|
assert dialog.send_button.isEnabled()
|
||||||
|
assert bubble._raw_payload == ai_consult_module.AI_STREAM_TIMEOUT_TEXT
|
||||||
|
|
||||||
|
calls: list[int] = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
dialog.repository,
|
||||||
|
"stream_diagnosis_ai",
|
||||||
|
lambda *args, **kwargs: calls.append(1),
|
||||||
|
raising=False,
|
||||||
|
)
|
||||||
|
worker.run()
|
||||||
|
assert calls == []
|
||||||
|
dialog._stream_event(generation, stream_generation, {"event": "delta", "text": "迟到回复"})
|
||||||
|
dialog._stream_finished(generation, stream_generation, worker)
|
||||||
|
assert bubble._raw_payload == ai_consult_module.AI_STREAM_TIMEOUT_TEXT
|
||||||
|
|
||||||
|
dialog._ask("请补充说明")
|
||||||
|
second_bubble = dialog._stream_bubble
|
||||||
|
assert second_bubble is not None
|
||||||
|
dialog._stream_event(
|
||||||
|
dialog._generation,
|
||||||
|
dialog._stream_generation,
|
||||||
|
{"event": "delta", "text": "已收到部分分析"},
|
||||||
|
)
|
||||||
|
dialog._stream_timed_out()
|
||||||
|
assert second_bubble._raw_payload.startswith("已收到部分分析\n\n>")
|
||||||
|
assert ai_consult_module.AI_STREAM_TIMEOUT_TEXT in second_bubble._raw_payload
|
||||||
|
dialog.close()
|
||||||
|
|
||||||
|
|
||||||
def test_answered_stream_replaces_the_pending_placeholder(
|
def test_answered_stream_replaces_the_pending_placeholder(
|
||||||
application: QApplication,
|
application: QApplication,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
@@ -1382,19 +1553,383 @@ def test_answered_stream_replaces_the_pending_placeholder(
|
|||||||
|
|
||||||
dialog._ask("请总结当前病情")
|
dialog._ask("请总结当前病情")
|
||||||
generation, stream_generation = dialog._generation, dialog._stream_generation
|
generation, stream_generation = dialog._generation, dialog._stream_generation
|
||||||
|
assert dialog._stream_bubble is not None
|
||||||
|
assert dialog._stream_bubble._raw_payload == ai_consult_module.AI_STREAM_PENDING_TEXT
|
||||||
|
dialog._stream_event(
|
||||||
|
generation,
|
||||||
|
stream_generation,
|
||||||
|
{"event": "start", "source_summary": {"patient_name": "敏感姓名"}},
|
||||||
|
)
|
||||||
|
assert dialog._stream_bubble._raw_payload == ai_consult_module.AI_STREAM_WAITING_TEXT
|
||||||
|
assert "敏感姓名" not in dialog._stream_bubble._raw_payload
|
||||||
dialog._stream_event(generation, stream_generation, {"event": "delta", "text": "证候:"})
|
dialog._stream_event(generation, stream_generation, {"event": "delta", "text": "证候:"})
|
||||||
|
dialog._flush_timer.stop()
|
||||||
|
dialog._flush_stream_chunks()
|
||||||
|
assert dialog._stream_bubble._raw_payload == "证候:"
|
||||||
dialog._stream_event(generation, stream_generation, {"event": "delta", "text": "脾肾两虚"})
|
dialog._stream_event(generation, stream_generation, {"event": "delta", "text": "脾肾两虚"})
|
||||||
dialog._stream_event(generation, stream_generation, {"event": "done", "model_label": "千问"})
|
dialog._stream_event(generation, stream_generation, {"event": "done", "model_label": "千问"})
|
||||||
dialog._stream_finished(generation, stream_generation, dialog._stream_worker)
|
dialog._stream_finished(generation, stream_generation, dialog._stream_worker)
|
||||||
application.processEvents()
|
application.processEvents()
|
||||||
|
|
||||||
assert dialog._stream_text == "证候:脾肾两虚"
|
assert dialog._stream_text == "证候:脾肾两虚"
|
||||||
|
assert not dialog._stream_timeout_timer.isActive()
|
||||||
texts = _bubble_texts(dialog)
|
texts = _bubble_texts(dialog)
|
||||||
assert not any(ai_consult_module.AI_STREAM_PENDING_TEXT in text for text in texts)
|
assert not any(ai_consult_module.AI_STREAM_PENDING_TEXT in text for text in texts)
|
||||||
|
assert not any(ai_consult_module.AI_STREAM_WAITING_TEXT in text for text in texts)
|
||||||
assert not any(ai_consult_module.AI_STREAM_SILENT_TEXT in text for text in texts)
|
assert not any(ai_consult_module.AI_STREAM_SILENT_TEXT in text for text in texts)
|
||||||
dialog.close()
|
dialog.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_stream_clock_covers_preparation_first_text_and_completion(
|
||||||
|
application: QApplication,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
dialog = _silent_dialog(monkeypatch)
|
||||||
|
now = [100.0]
|
||||||
|
monkeypatch.setattr(ai_consult_module, "monotonic", lambda: now[0])
|
||||||
|
dialog._ask("请生成问诊小结")
|
||||||
|
bubble = dialog._stream_bubble
|
||||||
|
assert bubble is not None
|
||||||
|
generation, stream_generation = dialog._generation, dialog._stream_generation
|
||||||
|
assert "已等待 0 秒" in bubble.stamp.text()
|
||||||
|
assert dialog._stream_elapsed_timer.isActive()
|
||||||
|
|
||||||
|
now[0] = 103.0
|
||||||
|
dialog._stream_elapsed_timer.timeout.emit()
|
||||||
|
assert "已等待 3 秒" in bubble.stamp.text()
|
||||||
|
dialog._stream_event(generation, stream_generation, {"event": "start"})
|
||||||
|
assert "已等待 3 秒" in bubble.stamp.text()
|
||||||
|
|
||||||
|
now[0] = 104.2
|
||||||
|
dialog._stream_event(generation, stream_generation, {"event": "delta", "text": "问诊小结"})
|
||||||
|
# No timer flush or done event is needed to display the first text.
|
||||||
|
assert bubble._raw_payload == "问诊小结"
|
||||||
|
assert "生成中" in bubble.stamp.text()
|
||||||
|
assert "已用时 4 秒" in bubble.stamp.text()
|
||||||
|
dialog._stream_event(generation, stream_generation, {"event": "delta", "text": ":请补充舌脉。"})
|
||||||
|
assert dialog._flush_timer.isActive()
|
||||||
|
|
||||||
|
now[0] = 108.7
|
||||||
|
dialog._stream_event(
|
||||||
|
generation, stream_generation, {"event": "done", "model_label": "千问"}
|
||||||
|
)
|
||||||
|
assert bubble._raw_payload == "问诊小结:请补充舌脉。"
|
||||||
|
assert "千问" in bubble.stamp.text()
|
||||||
|
assert "首字 4.2 秒" in bubble.stamp.text()
|
||||||
|
assert "耗时 8.7 秒" in bubble.stamp.text()
|
||||||
|
assert not dialog._stream_elapsed_timer.isActive()
|
||||||
|
assert not dialog._flush_timer.isActive()
|
||||||
|
stamp = bubble.stamp.text()
|
||||||
|
now[0] = 130.0
|
||||||
|
dialog._stream_finished(generation, stream_generation, dialog._stream_worker)
|
||||||
|
dialog._update_stream_elapsed()
|
||||||
|
dialog._stream_event(generation, stream_generation, {"event": "delta", "text": "迟到内容"})
|
||||||
|
assert bubble.stamp.text() == stamp
|
||||||
|
assert bubble._raw_payload == "问诊小结:请补充舌脉。"
|
||||||
|
assert dialog.send_button.isEnabled()
|
||||||
|
dialog.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("ending", ["error", "timeout", "silent", "switch", "close"])
|
||||||
|
def test_stream_clock_stops_on_every_exit(
|
||||||
|
application: QApplication,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
ending: str,
|
||||||
|
) -> None:
|
||||||
|
dialog = _silent_dialog(monkeypatch)
|
||||||
|
now = [100.0]
|
||||||
|
monkeypatch.setattr(ai_consult_module, "monotonic", lambda: now[0])
|
||||||
|
dialog._ask("请总结病情")
|
||||||
|
bubble = dialog._stream_bubble
|
||||||
|
assert bubble is not None
|
||||||
|
now[0] = 112.4
|
||||||
|
generation, stream_generation = dialog._generation, dialog._stream_generation
|
||||||
|
worker = dialog._stream_worker
|
||||||
|
if ending == "error":
|
||||||
|
dialog._stream_failed(generation, stream_generation, RuntimeError("模型繁忙"))
|
||||||
|
dialog._stream_finished(generation, stream_generation, worker)
|
||||||
|
elif ending == "timeout":
|
||||||
|
dialog._stream_timed_out()
|
||||||
|
elif ending == "silent":
|
||||||
|
dialog._stream_finished(generation, stream_generation, worker)
|
||||||
|
elif ending == "switch":
|
||||||
|
dialog.open_for(diagnosis_id=502, patient_id=302)
|
||||||
|
else:
|
||||||
|
dialog.close()
|
||||||
|
assert not dialog._stream_elapsed_timer.isActive()
|
||||||
|
assert not dialog._stream_timeout_timer.isActive()
|
||||||
|
assert not dialog._asking
|
||||||
|
assert dialog.send_button.isEnabled()
|
||||||
|
if ending in {"error", "timeout", "silent"}:
|
||||||
|
assert "耗时 12.4 秒" in bubble.stamp.text()
|
||||||
|
stamp = bubble.stamp.text()
|
||||||
|
now[0] = 140.0
|
||||||
|
dialog._update_stream_elapsed()
|
||||||
|
assert bubble.stamp.text() == stamp
|
||||||
|
if ending == "switch":
|
||||||
|
dialog._ask("新患者的问题")
|
||||||
|
assert dialog._stream_bubble is not None
|
||||||
|
assert "已等待 0 秒" in dialog._stream_bubble.stamp.text()
|
||||||
|
assert dialog._stream_first_delta_at is None
|
||||||
|
dialog.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_http_stream_paints_before_upstream_finishes_on_the_ui_thread(
|
||||||
|
application: QApplication,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""Exercise HTTP parsing, repository, real worker and Qt delivery together."""
|
||||||
|
release = Event()
|
||||||
|
waiting_for_end = Event()
|
||||||
|
|
||||||
|
class DelayedStream(httpx.SyncByteStream):
|
||||||
|
def __iter__(self):
|
||||||
|
first = (
|
||||||
|
'event: start\r\ndata: {"model_label":"千问"}\r\n\r\n'
|
||||||
|
'event: delta\r\ndata: {"text":"先收到的内容"}\r\n\r\n'
|
||||||
|
).encode()
|
||||||
|
# A network chunk may end partway through a Chinese character.
|
||||||
|
split = first.index("先".encode()) + 1
|
||||||
|
yield first[:split]
|
||||||
|
yield first[split:]
|
||||||
|
waiting_for_end.set()
|
||||||
|
if not release.wait(5):
|
||||||
|
raise AssertionError("UI did not receive the first streamed text")
|
||||||
|
yield (
|
||||||
|
'event: delta\r\ndata: {"text":",然后完成。"}\r\n\r\n'
|
||||||
|
'event: done\r\ndata: {"model_label":"千问"}\r\n\r\n'
|
||||||
|
).encode()
|
||||||
|
|
||||||
|
client = ApiClient(
|
||||||
|
"https://example.test",
|
||||||
|
transport=httpx.MockTransport(
|
||||||
|
lambda request: httpx.Response(
|
||||||
|
200, headers={"content-type": "text/event-stream"}, stream=DelayedStream()
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
dialog = AiConsultDialog(RemoteDoctorRepository(client))
|
||||||
|
dialog.diagnosis_id = 501
|
||||||
|
dialog._context_notice_shown = True
|
||||||
|
delivered_on = []
|
||||||
|
original_event = dialog._stream_event
|
||||||
|
|
||||||
|
def record_event(*args: Any) -> None:
|
||||||
|
delivered_on.append(QThread.currentThread())
|
||||||
|
original_event(*args)
|
||||||
|
|
||||||
|
monkeypatch.setattr(dialog, "_stream_event", record_event)
|
||||||
|
dialog.show()
|
||||||
|
try:
|
||||||
|
dialog._ask("请生成问诊小结")
|
||||||
|
deadline = monotonic() + 3
|
||||||
|
while (not dialog._stream_text or not waiting_for_end.is_set()) and monotonic() < deadline:
|
||||||
|
QTest.qWait(10)
|
||||||
|
bubble = dialog._stream_bubble
|
||||||
|
assert bubble is not None and bubble.body is not None
|
||||||
|
assert waiting_for_end.is_set()
|
||||||
|
assert bubble.body.toPlainText() == "先收到的内容"
|
||||||
|
assert not dialog._stream_completed
|
||||||
|
assert dialog._asking
|
||||||
|
assert "生成中" in bubble.stamp.text()
|
||||||
|
release.set()
|
||||||
|
deadline = monotonic() + 3
|
||||||
|
while dialog._asking and monotonic() < deadline:
|
||||||
|
QTest.qWait(10)
|
||||||
|
assert dialog._stream_completed
|
||||||
|
assert not dialog._asking
|
||||||
|
assert bubble.body.toPlainText() == "先收到的内容,然后完成。"
|
||||||
|
assert delivered_on and all(thread == application.thread() for thread in delivered_on)
|
||||||
|
assert not dialog._stream_elapsed_timer.isActive()
|
||||||
|
finally:
|
||||||
|
release.set()
|
||||||
|
dialog.close()
|
||||||
|
client.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("terminal", ["done", "error"])
|
||||||
|
def test_every_quick_tool_can_start_after_terminal_event_before_worker_exit(
|
||||||
|
application: QApplication,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
terminal: str,
|
||||||
|
) -> None:
|
||||||
|
dialog = _silent_dialog(monkeypatch)
|
||||||
|
dialog.show()
|
||||||
|
application.processEvents()
|
||||||
|
cards = dialog.findChildren(ai_consult_module._ClickCard, "AiConsultToolCard")
|
||||||
|
assert len(cards) == len(ai_consult_module.TOOL_ITEMS) == 6
|
||||||
|
previous = []
|
||||||
|
for card, (_kind, label, prompt) in zip(cards, ai_consult_module.TOOL_ITEMS, strict=True):
|
||||||
|
# Exercise the text surface the doctor clicks, including event propagation.
|
||||||
|
target = next(child for child in card.findChildren(QLabel) if child.text() == label)
|
||||||
|
QTest.mouseClick(target, Qt.MouseButton.LeftButton)
|
||||||
|
worker = dialog._stream_worker
|
||||||
|
assert worker is not None and worker.prompt == prompt
|
||||||
|
assert dialog._asking
|
||||||
|
generation, stream_generation = dialog._generation, dialog._stream_generation
|
||||||
|
for old_generation, old_stream_generation, old_worker in previous:
|
||||||
|
dialog._stream_finished(old_generation, old_stream_generation, old_worker)
|
||||||
|
assert dialog._stream_worker is worker
|
||||||
|
assert dialog._asking
|
||||||
|
previous.append((generation, stream_generation, worker))
|
||||||
|
if terminal == "done":
|
||||||
|
dialog._stream_event(generation, stream_generation, {"event": "done", "answer": "已完成"})
|
||||||
|
else:
|
||||||
|
dialog._stream_failed(generation, stream_generation, RuntimeError("模型繁忙"))
|
||||||
|
# The transport may take longer to close; it must not keep the UI locked.
|
||||||
|
assert not dialog._asking
|
||||||
|
assert dialog.send_button.isEnabled()
|
||||||
|
dialog.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_clicking_another_quick_tool_switches_the_active_request(
|
||||||
|
application: QApplication,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
dialog = _silent_dialog(monkeypatch)
|
||||||
|
notices = []
|
||||||
|
monkeypatch.setattr(ai_consult_module, "show_toast", lambda *args: notices.append(args[1]))
|
||||||
|
dialog.show()
|
||||||
|
application.processEvents()
|
||||||
|
cards = dialog.findChildren(ai_consult_module._ClickCard, "AiConsultToolCard")
|
||||||
|
QTest.mouseClick(cards[0], Qt.MouseButton.LeftButton)
|
||||||
|
old_worker, old_bubble = dialog._stream_worker, dialog._stream_bubble
|
||||||
|
old_generation, old_stream_generation = dialog._generation, dialog._stream_generation
|
||||||
|
assert old_worker is not None and old_bubble is not None
|
||||||
|
dialog._stream_event(old_generation, old_stream_generation, {"event": "delta", "text": "已收到的分析"})
|
||||||
|
|
||||||
|
QTest.mouseClick(cards[1], Qt.MouseButton.LeftButton)
|
||||||
|
worker, bubble = dialog._stream_worker, dialog._stream_bubble
|
||||||
|
assert worker is not None and worker is not old_worker
|
||||||
|
assert worker.prompt == ai_consult_module.TOOL_ITEMS[1][2]
|
||||||
|
assert old_worker.is_cancelled()
|
||||||
|
assert "已收到的分析" in old_bubble._raw_payload
|
||||||
|
assert "已停止" in old_bubble._raw_payload
|
||||||
|
assert bubble is not None and bubble is not old_bubble
|
||||||
|
assert bubble._raw_payload == ai_consult_module.AI_STREAM_PENDING_TEXT
|
||||||
|
assert "已等待 0 秒" in bubble.stamp.text()
|
||||||
|
assert dialog._asking
|
||||||
|
|
||||||
|
dialog._stream_event(old_generation, old_stream_generation, {"event": "done", "answer": "旧结果"})
|
||||||
|
dialog._stream_failed(old_generation, old_stream_generation, RuntimeError("旧错误"))
|
||||||
|
dialog._stream_finished(old_generation, old_stream_generation, old_worker)
|
||||||
|
assert dialog._stream_worker is worker
|
||||||
|
assert dialog._asking
|
||||||
|
assert bubble._raw_payload == ai_consult_module.AI_STREAM_PENDING_TEXT
|
||||||
|
|
||||||
|
# Repeated clicks on the same running tool do not submit duplicate requests.
|
||||||
|
QTest.mouseClick(cards[1], Qt.MouseButton.LeftButton)
|
||||||
|
assert dialog._stream_worker is worker
|
||||||
|
assert not worker.is_cancelled()
|
||||||
|
assert notices and "正在生成" in notices[-1]
|
||||||
|
dialog.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_stream_worker_does_not_wait_for_another_event_after_done() -> None:
|
||||||
|
continued_after_done = []
|
||||||
|
|
||||||
|
class DoneThenIdleRepository:
|
||||||
|
def stream_diagnosis_ai(self, *args: Any, **kwargs: Any):
|
||||||
|
yield {"event": "done", "answer": "已完成"}
|
||||||
|
continued_after_done.append(True)
|
||||||
|
raise AssertionError("the terminal event must end iteration")
|
||||||
|
|
||||||
|
worker = ai_consult_module._AiStreamWorker(
|
||||||
|
DoneThenIdleRepository(), diagnosis_id=501, prompt="请分析", task="custom"
|
||||||
|
)
|
||||||
|
events, errors, finished = [], [], []
|
||||||
|
worker.signals.event.connect(events.append)
|
||||||
|
worker.signals.error.connect(errors.append)
|
||||||
|
worker.signals.finished.connect(lambda: finished.append(True))
|
||||||
|
worker.run()
|
||||||
|
assert [event["event"] for event in events] == ["done"]
|
||||||
|
assert continued_after_done == []
|
||||||
|
assert errors == []
|
||||||
|
assert finished == [True]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("entry", ["tool", "chip", "menu", "suggestion"])
|
||||||
|
def test_quick_actions_switch_requests_and_reveal_the_chat(
|
||||||
|
application: QApplication,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
entry: str,
|
||||||
|
) -> None:
|
||||||
|
dialog = _silent_dialog(monkeypatch)
|
||||||
|
dialog.show()
|
||||||
|
application.processEvents()
|
||||||
|
dialog._ask("之前的请求")
|
||||||
|
old_worker = dialog._stream_worker
|
||||||
|
assert old_worker is not None
|
||||||
|
dialog.tabs.setCurrentIndex(1)
|
||||||
|
dialog._set_chat_filter("doctor")
|
||||||
|
if entry == "tool":
|
||||||
|
target = dialog.findChildren(ai_consult_module._ClickCard, "AiConsultToolCard")[0]
|
||||||
|
prompt = ai_consult_module.TOOL_ITEMS[0][2]
|
||||||
|
QTest.mouseClick(target, Qt.MouseButton.LeftButton)
|
||||||
|
elif entry == "chip":
|
||||||
|
target = next(button for button in dialog.findChildren(QPushButton) if button.text() == "总结当前病情")
|
||||||
|
prompt = ai_consult_module.QUICK_PROMPTS[0][1]
|
||||||
|
target.click()
|
||||||
|
elif entry == "menu":
|
||||||
|
more = next(button for button in dialog.findChildren(QPushButton) if button.text() == "更多")
|
||||||
|
prompt = ai_consult_module.QUICK_PROMPTS[3][1]
|
||||||
|
more.menu().actions()[0].trigger()
|
||||||
|
else:
|
||||||
|
target = dialog.findChildren(ai_consult_module._ClickCard, "AiConsultSuggestRow")[0]
|
||||||
|
prompt = f"请针对该问题给出问诊话术与判断要点:{ai_consult_module.SUGGESTIONS[0]}"
|
||||||
|
QTest.mouseClick(target, Qt.MouseButton.LeftButton)
|
||||||
|
assert old_worker.is_cancelled()
|
||||||
|
assert dialog._stream_worker is not None and dialog._stream_worker.prompt == prompt
|
||||||
|
assert dialog.tabs.currentIndex() == dialog.center_stack.currentIndex() == 0
|
||||||
|
assert dialog._chat_filter == "all"
|
||||||
|
assert dialog._stream_bubble is not None and dialog._stream_bubble.isVisible()
|
||||||
|
dialog.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("exit_action", ["back", "side", "escape", "accept", "reject", "close"])
|
||||||
|
def test_all_dialog_exit_paths_cancel_stream_and_allow_a_fresh_session(
|
||||||
|
application: QApplication,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
exit_action: str,
|
||||||
|
) -> None:
|
||||||
|
dialog = _silent_dialog(monkeypatch)
|
||||||
|
dialog.show()
|
||||||
|
application.processEvents()
|
||||||
|
dialog._ask("请分析病情")
|
||||||
|
worker = dialog._stream_worker
|
||||||
|
assert worker is not None
|
||||||
|
generation, stream_generation = dialog._generation, dialog._stream_generation
|
||||||
|
if exit_action == "back":
|
||||||
|
next(button for button in dialog.findChildren(QPushButton) if button.text() == "返回").click()
|
||||||
|
elif exit_action == "side":
|
||||||
|
dialog.findChild(QPushButton, "AiConsultSideClose").click()
|
||||||
|
elif exit_action == "escape":
|
||||||
|
QTest.keyClick(dialog, Qt.Key.Key_Escape)
|
||||||
|
else:
|
||||||
|
getattr(dialog, exit_action)()
|
||||||
|
assert not dialog.isVisible()
|
||||||
|
assert worker.is_cancelled()
|
||||||
|
assert not dialog._asking
|
||||||
|
assert not dialog._stream_timeout_timer.isActive()
|
||||||
|
assert not dialog._stream_elapsed_timer.isActive()
|
||||||
|
closed_generation = dialog._generation
|
||||||
|
assert closed_generation > generation
|
||||||
|
dialog.close()
|
||||||
|
assert dialog._generation == closed_generation
|
||||||
|
|
||||||
|
dialog.open_for(diagnosis_id=502, patient_id=302)
|
||||||
|
dialog.show()
|
||||||
|
dialog._ask("新患者的问题")
|
||||||
|
new_worker, bubble = dialog._stream_worker, dialog._stream_bubble
|
||||||
|
assert new_worker is not None and not new_worker.is_cancelled()
|
||||||
|
dialog._stream_event(generation, stream_generation, {"event": "done", "answer": "旧患者结果"})
|
||||||
|
dialog._stream_finished(generation, stream_generation, worker)
|
||||||
|
assert dialog._stream_worker is new_worker
|
||||||
|
assert dialog._asking
|
||||||
|
assert bubble is not None and bubble._raw_payload == ai_consult_module.AI_STREAM_PENDING_TEXT
|
||||||
|
dialog.close()
|
||||||
|
|
||||||
|
|
||||||
def test_ai_consult_window_can_be_maximized(
|
def test_ai_consult_window_can_be_maximized(
|
||||||
application: QApplication,
|
application: QApplication,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
@@ -1522,6 +2057,49 @@ def test_sectioned_reply_becomes_a_structured_report(application: QApplication)
|
|||||||
bubble.deleteLater()
|
bubble.deleteLater()
|
||||||
|
|
||||||
|
|
||||||
|
def test_structured_report_shows_recorded_patient_and_skill_sources(
|
||||||
|
application: QApplication,
|
||||||
|
) -> None:
|
||||||
|
reply = (
|
||||||
|
"以下根据当前病历及历史随访资料作初步梳理,请医师结合原始记录复核所有结论,并确认记录时间与患者本人相符。\n"
|
||||||
|
"1. 病情判断\n- 当前资料尚不足以确认具体证型,仍需补充舌脉与检查资料。\n"
|
||||||
|
"2. 建议下一步\n- 请核对既往病历、血糖血压记录及随访记录,再决定后续检查与处置。\n"
|
||||||
|
)
|
||||||
|
commit = "270e94e637c4249dc411e54ac6715f21247de3cb"
|
||||||
|
bubble = ai_consult_module._ChatBubble(role="ai", text=reply)
|
||||||
|
bubble.set_provenance({
|
||||||
|
"source_diagnosis_ids": [501, 502],
|
||||||
|
"source_summary": {"diagnosis_count": 2, "blood_record_count": 3},
|
||||||
|
"knowledge_source": {
|
||||||
|
"title": "倪海厦 skill",
|
||||||
|
"url": f"https://github.com/coinweth/nihaixia-skill/blob/{commit}/SKILL.md",
|
||||||
|
"commit": commit,
|
||||||
|
"sections": ["六经速查表"],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
assert bubble.finalize_clinical_analysis() is True
|
||||||
|
card = bubble.findChild(ai_consult_module.QFrame, "AiConsultReportProvenance")
|
||||||
|
assert card is not None
|
||||||
|
lines = [label.text() for label in card.findChildren(QLabel)]
|
||||||
|
assert any("#501" in line and "#502" in line for line in lines)
|
||||||
|
assert any("诊单 2 条" in line and "血糖血压记录 3 条" in line for line in lines)
|
||||||
|
assert any("server/knowledge/nihaixia-skill/SKILL.md" in line and "版本 270e94e637c4" in line for line in lines)
|
||||||
|
assert all("https://github.com" not in line for line in lines)
|
||||||
|
bubble.deleteLater()
|
||||||
|
|
||||||
|
|
||||||
|
def test_clinical_report_marks_unrecorded_provenance(application: QApplication) -> None:
|
||||||
|
bubble = ai_consult_module._ChatBubble(role="ai", text=LONG_CLINICAL_REPLY)
|
||||||
|
assert bubble.finalize_clinical_analysis() is True
|
||||||
|
card = bubble.findChild(ai_consult_module.QFrame, "AiConsultReportProvenance")
|
||||||
|
assert card is not None
|
||||||
|
lines = [label.text() for label in card.findChildren(QLabel)]
|
||||||
|
assert any("未附可核查的来源清单" in line for line in lines)
|
||||||
|
assert any("未记录所用知识资料版本" in line for line in lines)
|
||||||
|
bubble.deleteLater()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"reply",
|
"reply",
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from typing import Any
|
|||||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from PySide6.QtCore import QDate, Signal
|
from PySide6.QtCore import QDate, QEvent, Signal
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QApplication,
|
QApplication,
|
||||||
QDialog,
|
QDialog,
|
||||||
@@ -19,7 +19,7 @@ from PySide6.QtWidgets import (
|
|||||||
QWidget,
|
QWidget,
|
||||||
)
|
)
|
||||||
|
|
||||||
from doctor_workstation.core import PermissionSet
|
from doctor_workstation.core import PermissionSet, UserProfile
|
||||||
from doctor_workstation.ui.dialogs import diagnosis as diagnosis_module
|
from doctor_workstation.ui.dialogs import diagnosis as diagnosis_module
|
||||||
from doctor_workstation.ui.pages import consultations as consultations_module
|
from doctor_workstation.ui.pages import consultations as consultations_module
|
||||||
from doctor_workstation.ui.pages.consultations import (
|
from doctor_workstation.ui.pages.consultations import (
|
||||||
@@ -669,7 +669,7 @@ def test_switching_rows_invalidates_prescription_worker_and_clears_busy(
|
|||||||
application.processEvents()
|
application.processEvents()
|
||||||
|
|
||||||
|
|
||||||
def test_native_call_does_not_reuse_video_qr_permission(
|
def test_list_video_entry_uses_watch_permission_not_video_qr_permission(
|
||||||
application: QApplication,
|
application: QApplication,
|
||||||
) -> None:
|
) -> None:
|
||||||
class VideoRepository:
|
class VideoRepository:
|
||||||
@@ -686,13 +686,17 @@ def test_native_call_does_not_reuse_video_qr_permission(
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
live_row = _row(
|
live_row = _row(
|
||||||
|
assistant_id=66,
|
||||||
video_call_hint={
|
video_call_hint={
|
||||||
"state": "live",
|
"state": "live",
|
||||||
"label": "视频通话进行中",
|
"label": "视频通话进行中",
|
||||||
"start_time": 1787102100,
|
"start_time": 1787102100,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
page = ConsultationsPage(VideoRepository(), permissions=PermissionSet([]))
|
page = ConsultationsPage(
|
||||||
|
VideoRepository(), permissions=PermissionSet(["tcm.diagnosis/watchCall"]),
|
||||||
|
current_user={"id": 66, "role_id": 2},
|
||||||
|
)
|
||||||
emitted: list[dict[str, Any]] = []
|
emitted: list[dict[str, Any]] = []
|
||||||
page.video_requested.connect(emitted.append)
|
page.video_requested.connect(emitted.append)
|
||||||
page.table.set_rows([live_row])
|
page.table.set_rows([live_row])
|
||||||
@@ -738,10 +742,13 @@ def test_video_join_action_is_hidden_until_doctor_session_is_live(
|
|||||||
def end_call(self, diagnosis_id: int) -> None:
|
def end_call(self, diagnosis_id: int) -> None:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
page = ConsultationsPage(VideoRepository(), permissions=PermissionSet([]))
|
page = ConsultationsPage(
|
||||||
|
VideoRepository(), permissions=PermissionSet(["tcm.diagnosis/watchCall"]),
|
||||||
|
current_user={"id": 66, "role_id": 2},
|
||||||
|
)
|
||||||
emitted: list[dict[str, Any]] = []
|
emitted: list[dict[str, Any]] = []
|
||||||
page.video_requested.connect(emitted.append)
|
page.video_requested.connect(emitted.append)
|
||||||
page.table.set_rows([_row(video_call_hint=hint)])
|
page.table.set_rows([_row(assistant_id=66, video_call_hint=hint)])
|
||||||
page.table.selectRow(0)
|
page.table.selectRow(0)
|
||||||
application.processEvents()
|
application.processEvents()
|
||||||
|
|
||||||
@@ -754,6 +761,92 @@ def test_video_join_action_is_hidden_until_doctor_session_is_live(
|
|||||||
application.processEvents()
|
application.processEvents()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("user", [
|
||||||
|
{"id": 66, "role_id": 1},
|
||||||
|
{"id": 66, "role_id": "1"},
|
||||||
|
{"id": 66, "role_ids": [1, 2]},
|
||||||
|
{"id": 66, "role_id": "1,2"},
|
||||||
|
{"id": 66, "roles": [{"id": 1}]},
|
||||||
|
{"id": 66, "role_id": 1, "root": True},
|
||||||
|
UserProfile.from_dict({"id": 66, "role_id": [1, 2]}),
|
||||||
|
])
|
||||||
|
@pytest.mark.parametrize("state,status", [
|
||||||
|
("live", "通话中"), ("pending_room", "接通中"), ("none", "—"),
|
||||||
|
])
|
||||||
|
def test_doctor_video_column_is_readonly_even_with_full_permission(
|
||||||
|
application: QApplication,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
user: Any,
|
||||||
|
state: str,
|
||||||
|
status: str,
|
||||||
|
) -> None:
|
||||||
|
from doctor_workstation.services import DemoDoctorRepository
|
||||||
|
|
||||||
|
monkeypatch.setattr(consultations_module, "run_async", lambda *_a, **_kw: None)
|
||||||
|
# Even a mixed-role doctor assigned to this row must not get the receiver entry.
|
||||||
|
row = _row(assistant_id=66, video_call_hint={"state": state})
|
||||||
|
page = ConsultationsPage(DemoDoctorRepository(), PermissionSet(["*"]), current_user=user)
|
||||||
|
emitted = []
|
||||||
|
page.video_requested.connect(emitted.append)
|
||||||
|
try:
|
||||||
|
page.table_host.set_rows([row])
|
||||||
|
page.table.selectRow(0)
|
||||||
|
page._selection_changed()
|
||||||
|
cell = page.table_host.fixed.indexWidget(page.table_host.model.index(0, 10))
|
||||||
|
assert cell.findChildren(QToolButton) == []
|
||||||
|
assert status in [label.text() for label in cell.findChildren(QLabel)]
|
||||||
|
assert page.call_toolbar_button.isHidden()
|
||||||
|
assert not page.video_button.isEnabled()
|
||||||
|
page._row_video(row)
|
||||||
|
page.video_button.setEnabled(True)
|
||||||
|
page.video_button.click()
|
||||||
|
assert emitted == []
|
||||||
|
finally:
|
||||||
|
page.close()
|
||||||
|
page.deleteLater()
|
||||||
|
application.sendPostedEvents(None, QEvent.Type.DeferredDelete)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("user,permissions,assistant_id,allowed", [
|
||||||
|
({"id": 66, "role_id": 2}, ["tcm.diagnosis/watchCall"], 66, True),
|
||||||
|
({"id": 66, "role_id": 2}, ["tcm.diagnosis/videoQr"], 66, False),
|
||||||
|
({"id": 66, "role_id": 2}, [], 66, False),
|
||||||
|
({"id": 66, "role_id": 2}, ["*"], 77, False),
|
||||||
|
({"id": 66, "role_id": 0, "root": True}, ["*"], 77, False),
|
||||||
|
({"id": 66, "role_id": 0, "root": True}, ["*"], 66, True),
|
||||||
|
({"id": 0, "role_id": 2}, ["*"], 0, False),
|
||||||
|
(None, ["*"], 66, False),
|
||||||
|
])
|
||||||
|
def test_list_video_entry_requires_the_assigned_user_and_watch_permission(
|
||||||
|
application: QApplication,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
user: Any,
|
||||||
|
permissions: list[str],
|
||||||
|
assistant_id: int,
|
||||||
|
allowed: bool,
|
||||||
|
) -> None:
|
||||||
|
from doctor_workstation.services import DemoDoctorRepository
|
||||||
|
|
||||||
|
monkeypatch.setattr(consultations_module, "run_async", lambda *_a, **_kw: None)
|
||||||
|
row = _row(assistant_id=assistant_id, video_call_hint={"state": "live"})
|
||||||
|
page = ConsultationsPage(DemoDoctorRepository(), PermissionSet(permissions), current_user=user)
|
||||||
|
emitted = []
|
||||||
|
page.video_requested.connect(emitted.append)
|
||||||
|
try:
|
||||||
|
page.table_host.set_rows([row])
|
||||||
|
page.table.selectRow(0)
|
||||||
|
page._selection_changed()
|
||||||
|
cell = page.table_host.fixed.indexWidget(page.table_host.model.index(0, 10))
|
||||||
|
assert bool(cell.findChildren(QToolButton)) is allowed
|
||||||
|
assert page.video_button.isEnabled() is allowed
|
||||||
|
page._request_video()
|
||||||
|
assert bool(emitted) is allowed
|
||||||
|
finally:
|
||||||
|
page.close()
|
||||||
|
page.deleteLater()
|
||||||
|
application.sendPostedEvents(None, QEvent.Type.DeferredDelete)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"value",
|
"value",
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -1121,6 +1121,76 @@ def test_appointment_history_phone_is_fail_closed_without_phone_plain(
|
|||||||
dialog.close()
|
dialog.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("locked", [False, True])
|
||||||
|
def test_doctor_can_save_clinical_fields_without_resubmitting_masked_identity(
|
||||||
|
application: QApplication,
|
||||||
|
locked: bool,
|
||||||
|
) -> None:
|
||||||
|
class MaskedRepository(VisualRepository):
|
||||||
|
def check_diagnosis_phone(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
raise AssertionError("a masked phone must not be checked as plaintext")
|
||||||
|
|
||||||
|
def check_diagnosis_id_card(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
raise AssertionError("a masked identity must not be checked as plaintext")
|
||||||
|
|
||||||
|
repository = MaskedRepository(locked=locked)
|
||||||
|
for source in (repository.detail["diagnosis"], repository.detail["patient"]):
|
||||||
|
source["phone"] = "156****7862"
|
||||||
|
source["id_card"] = "1101**********1234"
|
||||||
|
dialog = _open_dialog(
|
||||||
|
application, (1024, 640), mode="edit", repository=repository,
|
||||||
|
permissions=PermissionSet(["tcm.diagnosis/detail", "tcm.diagnosis/edit"]),
|
||||||
|
)
|
||||||
|
saved = []
|
||||||
|
dialog.saved.connect(lambda: saved.append(True))
|
||||||
|
try:
|
||||||
|
assert dialog.edit_fields["phone"].isReadOnly()
|
||||||
|
assert dialog.edit_fields["phone"].toPlainText() == "156****7862"
|
||||||
|
assert dialog.edit_fields["id_card"].isReadOnly()
|
||||||
|
dialog.edit_fields["chief_complaint"].setPlainText("口渴较前改善")
|
||||||
|
dialog._save()
|
||||||
|
assert saved == [True]
|
||||||
|
assert len(repository.updates) == 1
|
||||||
|
payload = repository.updates[0]
|
||||||
|
assert payload["chief_complaint"] == "口渴较前改善"
|
||||||
|
assert "phone" not in payload and "id_card" not in payload
|
||||||
|
assert "156****7862" not in str(payload)
|
||||||
|
assert "1101**********1234" not in str(payload)
|
||||||
|
finally:
|
||||||
|
dialog.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_plain_phone_permission_keeps_validation_and_duplicate_checks(
|
||||||
|
application: QApplication,
|
||||||
|
) -> None:
|
||||||
|
class PlainRepository(VisualRepository):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.phone_checks = []
|
||||||
|
|
||||||
|
def check_diagnosis_phone(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
self.phone_checks.append(dict(payload))
|
||||||
|
return {"exists": False}
|
||||||
|
|
||||||
|
repository = PlainRepository()
|
||||||
|
dialog = _open_dialog(
|
||||||
|
application, (1024, 640), mode="edit", repository=repository,
|
||||||
|
permissions=PermissionSet(["tcm.diagnosis/edit", "tcm.diagnosis/phonePlain"]),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
assert not dialog.edit_fields["phone"].isReadOnly()
|
||||||
|
dialog.edit_fields["phone"].setPlainText("156****7862")
|
||||||
|
dialog._save()
|
||||||
|
assert not repository.phone_checks
|
||||||
|
assert not repository.updates
|
||||||
|
dialog.edit_fields["phone"].setPlainText("15600007862")
|
||||||
|
dialog._save()
|
||||||
|
assert repository.phone_checks == [{"id": 501, "phone": "15600007862"}]
|
||||||
|
assert repository.updates[-1]["phone"] == "15600007862"
|
||||||
|
finally:
|
||||||
|
dialog.close()
|
||||||
|
|
||||||
|
|
||||||
def test_order_detail_action_requires_exact_permission(application: QApplication) -> None:
|
def test_order_detail_action_requires_exact_permission(application: QApplication) -> None:
|
||||||
repository = VisualRepository()
|
repository = VisualRepository()
|
||||||
dialog = _open_dialog(
|
dialog = _open_dialog(
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PySide6.QtCore import QEvent
|
||||||
|
from PySide6.QtGui import QFont
|
||||||
|
from PySide6.QtWidgets import QApplication, QLabel, QToolButton, QVBoxLayout, QWidget
|
||||||
|
|
||||||
|
from doctor_workstation.ui.diagnosis_index_widgets import DiagnosisTableHost
|
||||||
|
from doctor_workstation.ui.reception_style import body_family
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def application() -> QApplication:
|
||||||
|
return QApplication.instance() or QApplication([])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("tech_blue", [False, True])
|
||||||
|
@pytest.mark.parametrize("size", [(1024, 640), (1710, 920)])
|
||||||
|
@pytest.mark.parametrize("with_channel", [False, True])
|
||||||
|
def test_public_video_status_fits_frozen_cell_with_inherited_item_padding(
|
||||||
|
application: QApplication, tech_blue: bool, size: tuple[int, int],
|
||||||
|
with_channel: bool,
|
||||||
|
) -> None:
|
||||||
|
root = QWidget()
|
||||||
|
font = QFont(body_family())
|
||||||
|
font.setPixelSize(13)
|
||||||
|
root.setFont(font)
|
||||||
|
# Match GLOBAL_QSS without changing the application's theme between tests.
|
||||||
|
root.setStyleSheet("QTableView::item { padding: 6px 8px; border-bottom: 1px solid #E1E9F4; }")
|
||||||
|
layout = QVBoxLayout(root)
|
||||||
|
host = DiagnosisTableHost(
|
||||||
|
parent=root, tech_blue=tech_blue,
|
||||||
|
action_policy={"video_call": True, "view": True, "edit": True},
|
||||||
|
video_entry_allowed=lambda _record: False,
|
||||||
|
)
|
||||||
|
layout.addWidget(host)
|
||||||
|
statuses = [
|
||||||
|
("ended", "视频已结束 · 10-17 14:19"),
|
||||||
|
("ended", "视频已结束 · 10-17 14:27"),
|
||||||
|
("ended", "视频已结束"),
|
||||||
|
("live", "通话中"),
|
||||||
|
("pending_room", "接通中"),
|
||||||
|
("missed", "上次通话未接听"),
|
||||||
|
("cancelled", "上次通话已取消"),
|
||||||
|
("none", "—"),
|
||||||
|
]
|
||||||
|
rows = [
|
||||||
|
{
|
||||||
|
"id": index + 1, "patient_id": index + 100, "patient_name": "测试患者",
|
||||||
|
"gender": 1, "age": 50, "has_appointment": 1,
|
||||||
|
"appointment_id": index + 200, "appointment_status": 1,
|
||||||
|
"appointment_type": "video",
|
||||||
|
"latest_appointment_channel_text": "自媒体4H" if with_channel else "",
|
||||||
|
"appointments": [{"id": index + 200, "status": 1, "doctor_name": "测试医生",
|
||||||
|
"time_text": "2026-10-17 14:15", "appointment_type": "video"}],
|
||||||
|
"video_call_hint": {"state": state, "label": text},
|
||||||
|
}
|
||||||
|
for index, (state, text) in enumerate(statuses * 4)
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
host.set_rows(rows)
|
||||||
|
root.resize(*size)
|
||||||
|
root.show()
|
||||||
|
for _ in range(4):
|
||||||
|
application.processEvents()
|
||||||
|
for index, (_state, text) in enumerate(statuses):
|
||||||
|
cell = host.fixed.indexWidget(host.model.index(index, 10))
|
||||||
|
assert not cell.findChildren(QToolButton)
|
||||||
|
label = cell.findChild(QLabel)
|
||||||
|
assert label is not None
|
||||||
|
assert label.height() >= label.heightForWidth(label.width())
|
||||||
|
assert cell.rect().contains(label.geometry())
|
||||||
|
assert abs(label.geometry().center().y() - cell.rect().center().y()) <= 1
|
||||||
|
assert label.toolTip() == text
|
||||||
|
if " · " in text:
|
||||||
|
assert label.text() == text.replace(" · ", "\n")
|
||||||
|
assert label.fontMetrics().horizontalAdvance("10-17 14:19") <= label.width()
|
||||||
|
assert cell.width() >= host.fixed.columnWidth(10) - 2
|
||||||
|
host.main.horizontalScrollBar().setValue(host.main.horizontalScrollBar().maximum())
|
||||||
|
for fraction in (2, 3):
|
||||||
|
host.main.verticalScrollBar().setValue(host.main.verticalScrollBar().maximum() // fraction)
|
||||||
|
application.processEvents()
|
||||||
|
assert host.fixed.verticalScrollBar().value() == host.main.verticalScrollBar().value()
|
||||||
|
row = host.main.indexAt(host.main.viewport().rect().center()).row()
|
||||||
|
assert row >= 0
|
||||||
|
assert host.main.visualRect(host.model.index(row, 0)).top() == host.fixed.visualRect(
|
||||||
|
host.model.index(row, 10)
|
||||||
|
).top()
|
||||||
|
finally:
|
||||||
|
root.close()
|
||||||
|
root.deleteLater()
|
||||||
|
application.sendPostedEvents(None, QEvent.Type.DeferredDelete)
|
||||||
@@ -11,8 +11,8 @@ from uuid import UUID
|
|||||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from PySide6.QtCore import Qt
|
from PySide6.QtCore import QEvent, QObject, QRect, Qt
|
||||||
from PySide6.QtWidgets import QApplication, QPushButton
|
from PySide6.QtWidgets import QApplication, QPushButton, QWidget
|
||||||
|
|
||||||
from doctor_workstation.services import DemoDoctorRepository
|
from doctor_workstation.services import DemoDoctorRepository
|
||||||
from doctor_workstation.services.repository import RemoteDoctorRepository
|
from doctor_workstation.services.repository import RemoteDoctorRepository
|
||||||
@@ -23,7 +23,7 @@ from doctor_workstation.ui.pages import prescriptions as page_module
|
|||||||
|
|
||||||
def batch(batch_id: int = 40, prescription_id: int = 801, status: str = "running") -> dict[str, Any]:
|
def batch(batch_id: int = 40, prescription_id: int = 801, status: str = "running") -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
"id": batch_id, "prescription_id": prescription_id, "prescription_revision": 2,
|
"id": batch_id, "prescription_id": prescription_id, "prescription_revision": 2, "agreement_visible": True,
|
||||||
"patient_id": 301, "diagnosis_id": 501, "status": status,
|
"patient_id": 301, "diagnosis_id": 501, "status": status,
|
||||||
"validity": "current", "comparison_type": "independent_baseline",
|
"validity": "current", "comparison_type": "independent_baseline",
|
||||||
"source_summary": {"diagnosis_count": 2, "attachment_count": 4},
|
"source_summary": {"diagnosis_count": 2, "attachment_count": 4},
|
||||||
@@ -48,10 +48,14 @@ class Repository(DemoDoctorRepository):
|
|||||||
self.calls: list[tuple[str, Any]] = []
|
self.calls: list[tuple[str, Any]] = []
|
||||||
self.batches = [batch()]
|
self.batches = [batch()]
|
||||||
self.enabled = True
|
self.enabled = True
|
||||||
|
self.agreement_visible = True
|
||||||
|
|
||||||
|
def get_prescription_ai_capabilities(self) -> dict[str, Any]:
|
||||||
|
return {"agreement_visible": self.agreement_visible, "can_manage_visibility": False}
|
||||||
|
|
||||||
def list_prescription_ai_reports(self, **params: Any) -> dict[str, Any]:
|
def list_prescription_ai_reports(self, **params: Any) -> dict[str, Any]:
|
||||||
self.calls.append(("reports", params))
|
self.calls.append(("reports", params))
|
||||||
return {"enabled": self.enabled, "lists": deepcopy(self.batches), "count": len(self.batches), "page_no": 1, "page_size": 20}
|
return {"agreement_visible": self.agreement_visible, "enabled": self.enabled, "lists": deepcopy(self.batches), "count": len(self.batches), "page_no": 1, "page_size": 20}
|
||||||
|
|
||||||
def get_prescription_ai_report(self, batch_id: int) -> dict[str, Any]:
|
def get_prescription_ai_report(self, batch_id: int) -> dict[str, Any]:
|
||||||
self.calls.append(("detail", batch_id))
|
self.calls.append(("detail", batch_id))
|
||||||
@@ -71,7 +75,7 @@ class Repository(DemoDoctorRepository):
|
|||||||
|
|
||||||
def list_prescription_ai_statuses(self, ids: list[int]) -> dict[str, Any]:
|
def list_prescription_ai_statuses(self, ids: list[int]) -> dict[str, Any]:
|
||||||
self.calls.append(("statuses", list(ids)))
|
self.calls.append(("statuses", list(ids)))
|
||||||
return {"enabled": self.enabled, "items": [{"prescription_id": value, "batch_id": 40, "status": "running", "validity": "current", "comparison_type": "independent_baseline", "models": {"qwen": {"status": "succeeded", "score": 0, "comparison_status": "comparable"}, "openai": {"status": "running", "score": None}}} for value in ids] if self.enabled else []}
|
return {"agreement_visible": self.agreement_visible, "enabled": self.enabled, "items": [{"agreement_visible": self.agreement_visible, "prescription_id": value, "batch_id": 40, "status": "running", "validity": "current", "comparison_type": "independent_baseline", "models": {"qwen": {"status": "succeeded", "score": 0, "comparison_status": "comparable"}, "openai": {"status": "running", "score": None}}} for value in ids] if self.enabled else []}
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="module")
|
@pytest.fixture(scope="module")
|
||||||
@@ -138,6 +142,215 @@ def test_shared_report_reads_only_and_renders_each_model_independently(applicati
|
|||||||
assert repository.calls == calls
|
assert repository.calls == calls
|
||||||
|
|
||||||
|
|
||||||
|
def test_report_open_and_poll_never_show_auxiliary_windows(application: QApplication, immediate: None) -> None:
|
||||||
|
shown_windows = []
|
||||||
|
|
||||||
|
class WindowObserver(QObject):
|
||||||
|
def eventFilter(self, watched: QObject, event: QEvent) -> bool:
|
||||||
|
if event.type() == QEvent.Type.Show and isinstance(watched, QWidget) and watched.isWindow():
|
||||||
|
shown_windows.append((watched.metaObject().className(), watched.windowTitle()))
|
||||||
|
return False
|
||||||
|
|
||||||
|
observer = WindowObserver()
|
||||||
|
application.installEventFilter(observer)
|
||||||
|
dialog = None
|
||||||
|
try:
|
||||||
|
repository = Repository()
|
||||||
|
dialog = ai.IssuedPrescriptionAiDialog(repository, ["*"], prescription_id=801)
|
||||||
|
dialog.show()
|
||||||
|
application.processEvents()
|
||||||
|
initial_windows = list(shown_windows)
|
||||||
|
shown_windows.clear()
|
||||||
|
dialog.tabs.setCurrentIndex(3)
|
||||||
|
dialog.model_views["qwen"]["comment"].setPlainText("正在填写的复核意见")
|
||||||
|
for _ in range(3):
|
||||||
|
dialog._timer.timeout.emit()
|
||||||
|
dialog._progress_timer.timeout.emit()
|
||||||
|
application.processEvents()
|
||||||
|
assert len([call for call in repository.calls if call[0] == "detail"]) == 4
|
||||||
|
assert shown_windows == [], "Polling must not show even transient top-level widgets"
|
||||||
|
assert initial_windows == [("IssuedPrescriptionAiDialog", dialog.windowTitle())]
|
||||||
|
assert dialog.tabs.currentIndex() == 3
|
||||||
|
assert dialog.model_views["qwen"]["comment"].toPlainText() == "正在填写的复核意见"
|
||||||
|
assert dialog.chip_row.count() == 2
|
||||||
|
assert all(dialog.chip_row.itemAt(index).widget().isVisible() for index in range(2))
|
||||||
|
assert dialog._timer.isActive() and dialog._progress_timer.isActive()
|
||||||
|
finally:
|
||||||
|
application.removeEventFilter(observer)
|
||||||
|
if dialog is not None:
|
||||||
|
dialog.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_navigation_names_every_destination_once(application: QApplication, immediate: None) -> None:
|
||||||
|
"""A duplicated tab name makes the navigation unreadable; the live progress lives in the one tab."""
|
||||||
|
dialog = ai.IssuedPrescriptionAiDialog(Repository(), ["*"], prescription_id=801)
|
||||||
|
dialog.show()
|
||||||
|
application.processEvents()
|
||||||
|
names = [dialog.tabs.tabText(index) for index in range(dialog.tabs.count())]
|
||||||
|
assert len(names) == len(set(names)), names
|
||||||
|
assert names.count("处理进度") == 1
|
||||||
|
# Those pages are hosted in a scroll area, so the tab holds the host, not the page itself.
|
||||||
|
for key in ("per_herb", "gaps", "history"):
|
||||||
|
assert dialog.tabs.indexOf(dialog.tab_pages[key]) >= 0
|
||||||
|
bar = dialog.model_views["qwen"]["progress_bar"]
|
||||||
|
assert dialog.pipeline_page.isAncestorOf(bar)
|
||||||
|
assert dialog.pipeline_page.stage_labels["qwen"].isHidden()
|
||||||
|
dialog.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(("width", "height"), [(1280, 860), (1024, 700), (940, 640)])
|
||||||
|
def test_prescription_workspace_is_the_primary_view(application: QApplication, immediate: None, width: int, height: int) -> None:
|
||||||
|
dialog = ai.IssuedPrescriptionAiDialog(Repository(), ["*"], prescription_id=801)
|
||||||
|
dialog.resize(width, height)
|
||||||
|
dialog.show()
|
||||||
|
application.processEvents()
|
||||||
|
assert (dialog.width(), dialog.height()) == (width, height)
|
||||||
|
assert dialog.tabs.currentWidget() is dialog.comparison_panel
|
||||||
|
assert dialog.tabs.tabText(0) == "对比总览"
|
||||||
|
assert dialog.comparison_panel.isVisible()
|
||||||
|
assert dialog.tabs.height() > height * 0.28
|
||||||
|
assert dialog.model_views["qwen"]["comment"].isVisible()
|
||||||
|
# The chart strip is on the first screen but must never outgrow its budget, and a model
|
||||||
|
# without a comparable score shows no filled bar.
|
||||||
|
assert dialog.model_views["qwen"]["agreement_bar"].value() == 0
|
||||||
|
assert dialog.model_views["openai"]["agreement_bar"].isHidden()
|
||||||
|
assert dialog.model_views["qwen"]["gauge"].accessibleDescription() == "0.0%"
|
||||||
|
assert dialog.model_views["openai"]["gauge"].accessibleDescription() == "暂无可比结果"
|
||||||
|
dialog.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(("width", "height"), [(1440, 940), (1280, 860), (1024, 700), (940, 640)])
|
||||||
|
def test_review_controls_remain_inside_the_sidebar(application: QApplication, immediate: None,
|
||||||
|
width: int, height: int) -> None:
|
||||||
|
repository = Repository()
|
||||||
|
model = repository.batches[0]["models"]["qwen"]
|
||||||
|
herb = model["candidate"]["herbs"][0]
|
||||||
|
model["comparison"]["rows"][0].update(key="黄芪", doctor={**herb, "dosage": 16}, candidate=herb)
|
||||||
|
dialog = ai.IssuedPrescriptionAiDialog(repository, ["*"], prescription_id=801)
|
||||||
|
dialog.resize(width, height)
|
||||||
|
dialog.show()
|
||||||
|
panel = dialog.comparison_panel
|
||||||
|
for _ in range(4):
|
||||||
|
application.processEvents()
|
||||||
|
assert dialog.nav_buttons["overview"].isChecked()
|
||||||
|
for key in ai.MODELS:
|
||||||
|
dialog.review_model.setCurrentIndex(0 if key == "qwen" else 1)
|
||||||
|
application.processEvents()
|
||||||
|
views = dialog.model_views[key]
|
||||||
|
for control in (dialog.review_model, views["review_state"], views["comment"], views["save"]):
|
||||||
|
assert control.isVisible()
|
||||||
|
bounds = QRect(control.mapTo(panel.checklist, control.rect().topLeft()), control.size())
|
||||||
|
assert panel.checklist.rect().contains(bounds), (key, control.accessibleName(), bounds)
|
||||||
|
assert ai.MODELS[key] in dialog.save_review_button.toolTip()
|
||||||
|
assert panel.checklist.isVisible()
|
||||||
|
dialog.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_hero_band_carries_the_frozen_identity_and_yields_on_short_windows(application: QApplication, immediate: None) -> None:
|
||||||
|
repository = Repository()
|
||||||
|
repository.batches[0]["doctor_snapshot"] = {
|
||||||
|
"patient": {"name": "测试患者", "gender_label": "女", "age": 58},
|
||||||
|
"diagnosis": {"clinical_diagnosis": "消渴病 气阴两虚"},
|
||||||
|
"prescription": {"prescription_type": "浓缩水丸", "dose_count": 1, "dose_unit": "剂"},
|
||||||
|
}
|
||||||
|
dialog = ai.IssuedPrescriptionAiDialog(repository, ["*"], prescription_id=801)
|
||||||
|
dialog.resize(1280, 860)
|
||||||
|
dialog.show()
|
||||||
|
application.processEvents()
|
||||||
|
assert "测试患者 · 女 · 58 岁" in dialog.identity.text()
|
||||||
|
assert "消渴病 气阴两虚" in dialog.identity.text()
|
||||||
|
assert "浓缩水丸 · 1 剂" in dialog.identity.text()
|
||||||
|
assert dialog.fact_values["basis"].text() == "同单位 · 每剂"
|
||||||
|
assert dialog.fact_values["prescription"].text() == "801 / 501"
|
||||||
|
dialog.resize(1024, 700)
|
||||||
|
application.processEvents()
|
||||||
|
assert dialog.fact_values["batch"].text() == "#40"
|
||||||
|
dialog.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_hero_band_shows_dashes_when_the_snapshot_is_not_deployed(application: QApplication, immediate: None) -> None:
|
||||||
|
repository = Repository()
|
||||||
|
repository.batches[0].pop("doctor_snapshot", None)
|
||||||
|
dialog = ai.IssuedPrescriptionAiDialog(repository, ["*"], prescription_id=801)
|
||||||
|
dialog.resize(1280, 860)
|
||||||
|
dialog.show()
|
||||||
|
application.processEvents()
|
||||||
|
assert dialog.identity.text() == "尚无冻结快照"
|
||||||
|
assert dialog.fact_values["prescription"].text().startswith("801")
|
||||||
|
dialog.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_failed_model_states_the_reason_and_offers_its_own_retry(application: QApplication, immediate: None) -> None:
|
||||||
|
repository = Repository()
|
||||||
|
repository.batches[0]["models"]["openai"].update(status="failed", error_code="INVALID_REPORT_OUTPUT",
|
||||||
|
error_message="INVALID_REPORT_OUTPUT")
|
||||||
|
dialog = ai.IssuedPrescriptionAiDialog(repository, ["*"], prescription_id=801)
|
||||||
|
dialog.show()
|
||||||
|
application.processEvents()
|
||||||
|
assert dialog.model_views["openai"]["failure"].isVisible()
|
||||||
|
text = dialog.model_views["openai"]["failure_text"].text()
|
||||||
|
assert "OpenAI 失败" in text and "格式校验" in text and "原资料快照" in text
|
||||||
|
assert "INVALID_REPORT_OUTPUT" not in text
|
||||||
|
assert dialog.model_views["openai"]["retry"].isEnabled()
|
||||||
|
assert dialog.model_views["openai"]["retry"].isVisible()
|
||||||
|
# A model that is still running never shows a failure row.
|
||||||
|
dialog.model_views["openai"]["retry"].click()
|
||||||
|
application.processEvents()
|
||||||
|
assert ("retry", (40, "openai")) in repository.calls
|
||||||
|
dialog.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_exhausted_manual_retries_point_at_regeneration_instead_of_retry(application: QApplication, immediate: None) -> None:
|
||||||
|
repository = Repository()
|
||||||
|
repository.batches[0]["models"]["openai"].update(status="failed", error_code="UPSTREAM_TIMEOUT",
|
||||||
|
error_message="UPSTREAM_TIMEOUT", manual_retries=2)
|
||||||
|
dialog = ai.IssuedPrescriptionAiDialog(repository, ["*"], prescription_id=801)
|
||||||
|
dialog.show()
|
||||||
|
application.processEvents()
|
||||||
|
text = dialog.model_views["openai"]["failure_text"].text()
|
||||||
|
assert "手动重试次数已用完" in text and "重新分析" in text
|
||||||
|
assert not dialog.model_views["openai"]["retry"].isVisible()
|
||||||
|
dialog.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_inline_review_keeps_model_drafts_and_saves_selected_model(application: QApplication, immediate: None) -> None:
|
||||||
|
repository = Repository()
|
||||||
|
repository.batches[0]["models"]["openai"].update(report_id=92, report={"summary": "已保存报告"})
|
||||||
|
dialog = ai.IssuedPrescriptionAiDialog(repository, ["*"], prescription_id=801)
|
||||||
|
dialog.show()
|
||||||
|
application.processEvents()
|
||||||
|
dialog.model_views["qwen"]["comment"].setPlainText("千问复核草稿")
|
||||||
|
dialog.review_model.setCurrentIndex(1)
|
||||||
|
dialog.model_views["openai"]["comment"].setPlainText("OpenAI复核草稿")
|
||||||
|
dialog.model_views["openai"]["review_state"].setCurrentIndex(3)
|
||||||
|
dialog._poll()
|
||||||
|
application.processEvents()
|
||||||
|
assert dialog.review_model.currentData() == "openai"
|
||||||
|
assert dialog.model_views["qwen"]["comment"].toPlainText() == "千问复核草稿"
|
||||||
|
assert dialog.model_views["openai"]["comment"].toPlainText() == "OpenAI复核草稿"
|
||||||
|
dialog.save_review_button.click()
|
||||||
|
assert ("review", (40, "openai", "reviewed", "OpenAI复核草稿")) in repository.calls
|
||||||
|
assert not any(call[0] == "review" and call[1][1] == "qwen" for call in repository.calls)
|
||||||
|
dialog.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_workspace_links_reach_existing_supporting_reports(application: QApplication, immediate: None) -> None:
|
||||||
|
dialog = ai.IssuedPrescriptionAiDialog(Repository(), ["*"], prescription_id=801)
|
||||||
|
dialog.show()
|
||||||
|
# Each link opens the composed page that answers it, not the raw saved text.
|
||||||
|
for field, key in (("report", "report"), ("candidate", "candidate"), ("comparison", "per_herb"),
|
||||||
|
("sources", "gaps"), ("progress", "pipeline"), ("doctor", "original")):
|
||||||
|
dialog.comparison_panel.open_report.emit(field)
|
||||||
|
assert dialog.tabs.currentWidget() is dialog.tab_pages[key], field
|
||||||
|
dialog.nav_buttons["overview"].click()
|
||||||
|
assert dialog.tabs.currentWidget() is dialog.comparison_panel
|
||||||
|
assert dialog.nav_buttons["overview"].isChecked()
|
||||||
|
assert not dialog.nav_buttons["gaps"].isChecked()
|
||||||
|
dialog.nav_buttons["per_herb"].click()
|
||||||
|
assert dialog.tabs.currentWidget() is dialog.tab_pages["per_herb"]
|
||||||
|
dialog.close()
|
||||||
|
|
||||||
|
|
||||||
def test_retry_and_review_target_only_selected_model(application: QApplication, immediate: None) -> None:
|
def test_retry_and_review_target_only_selected_model(application: QApplication, immediate: None) -> None:
|
||||||
repository = Repository()
|
repository = Repository()
|
||||||
repository.batches[0]["status"] = "partial"
|
repository.batches[0]["status"] = "partial"
|
||||||
@@ -250,6 +463,69 @@ def test_list_batches_visible_ids_and_stops_on_hide(application: QApplication, i
|
|||||||
assert repository.calls == calls
|
assert repository.calls == calls
|
||||||
|
|
||||||
|
|
||||||
|
def test_blank_list_row_has_no_ai_display_or_entry_points(application: QApplication, immediate: None, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
repository = Repository()
|
||||||
|
opened = []
|
||||||
|
monkeypatch.setattr(page_module, "present_issued_prescription_ai", lambda *args, **kwargs: opened.append(kwargs))
|
||||||
|
page = page_module.PrescriptionsPage(repository, ["*"])
|
||||||
|
page.resize(1366, 800)
|
||||||
|
page.show()
|
||||||
|
application.processEvents()
|
||||||
|
# Demo row 801 contains herbs; row 802 is an empty manual prescription.
|
||||||
|
assert all(call[1] == [801] for call in repository.calls if call[0] == "statuses")
|
||||||
|
assert not page.table.isColumnHidden(11) and not page.table.isColumnHidden(12)
|
||||||
|
page._ai_statuses[802] = batch(prescription_id=802)
|
||||||
|
page._render_ai_statuses()
|
||||||
|
for column in (11, 12):
|
||||||
|
item = page.table.item(1, column)
|
||||||
|
assert item.text() == item.toolTip() == item.data(Qt.ItemDataRole.AccessibleTextRole) == ""
|
||||||
|
assert not any(button.accessibleName() == "AI 报告" for button in page.table.cellWidget(1, 2).findChildren(QPushButton))
|
||||||
|
page.table.selectRow(1)
|
||||||
|
assert page.ai_report_button.isHidden() and not page.ai_report_button.isEnabled()
|
||||||
|
page._open_ai_report()
|
||||||
|
page.table.itemClicked.emit(page.table.item(1, 11))
|
||||||
|
page.table.itemClicked.emit(page.table.item(1, 12))
|
||||||
|
page._open_ai_context_menu(page.table.visualItemRect(page.table.item(1, 1)).center())
|
||||||
|
assert not hasattr(page, "_ai_context_menu")
|
||||||
|
assert opened == []
|
||||||
|
page.table.selectRow(0)
|
||||||
|
assert page.ai_report_button.isVisible() and page.ai_report_button.isEnabled()
|
||||||
|
page.ai_report_button.click()
|
||||||
|
assert opened == [{"prescription_id": 801}]
|
||||||
|
page.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("blank_fields", [
|
||||||
|
{"is_system_auto": "1", "herbs": []},
|
||||||
|
{"is_system_auto": True, "herbs": [{"name": "黄芪"}]},
|
||||||
|
{"is_system_auto": 0, "herbs": [{}, {"name": " "}]},
|
||||||
|
{"is_system_auto": 0, "herbs": None},
|
||||||
|
])
|
||||||
|
def test_all_blank_list_hides_ai_until_prescription_is_saved(application: QApplication, immediate: None, monkeypatch: pytest.MonkeyPatch, blank_fields: dict[str, Any]) -> None:
|
||||||
|
repository = Repository()
|
||||||
|
row = {"id": 803, "sn": "RX-BLANK", "patient_name": "测试患者", **blank_fields}
|
||||||
|
monkeypatch.setattr(repository, "list_prescriptions", lambda **_filters: {"lists": [row], "count": 1})
|
||||||
|
page = page_module.PrescriptionsPage(repository, ["*"])
|
||||||
|
page.resize(1366, 800)
|
||||||
|
page.show()
|
||||||
|
application.processEvents()
|
||||||
|
assert page.table.isColumnHidden(11) and page.table.isColumnHidden(12)
|
||||||
|
assert page.ai_report_button.isHidden() and page.ai_status_notice.isHidden()
|
||||||
|
assert not page._ai_timer.isActive()
|
||||||
|
assert not any(call[0] == "statuses" for call in repository.calls)
|
||||||
|
assert not any(button.accessibleName() == "AI 报告" for button in page.table.cellWidget(0, 2).findChildren(QPushButton))
|
||||||
|
|
||||||
|
row.update(is_system_auto=0, herbs=[{"name": "黄芪", "dosage": 12}])
|
||||||
|
page.refresh()
|
||||||
|
application.processEvents()
|
||||||
|
assert ("statuses", [803]) in repository.calls
|
||||||
|
assert not page.table.isColumnHidden(11) and not page.table.isColumnHidden(12)
|
||||||
|
assert page.ai_report_button.isVisible() and page.ai_status_notice.isVisible()
|
||||||
|
assert "千问 0%" in page.table.item(0, 12).text()
|
||||||
|
assert any(button.accessibleName() == "AI 报告" for button in page.table.cellWidget(0, 2).findChildren(QPushButton))
|
||||||
|
page.close()
|
||||||
|
|
||||||
|
|
||||||
def test_list_disabled_explains_availability_and_keeps_history(application: QApplication, immediate: None) -> None:
|
def test_list_disabled_explains_availability_and_keeps_history(application: QApplication, immediate: None) -> None:
|
||||||
repository = Repository()
|
repository = Repository()
|
||||||
repository.enabled = False
|
repository.enabled = False
|
||||||
@@ -323,6 +599,9 @@ def test_list_ignores_response_after_filter_generation_changes(application: QApp
|
|||||||
|
|
||||||
def test_statistics_nulls_and_absent_review_samples(application: QApplication, immediate: None) -> None:
|
def test_statistics_nulls_and_absent_review_samples(application: QApplication, immediate: None) -> None:
|
||||||
class StatisticsRepository:
|
class StatisticsRepository:
|
||||||
|
def get_prescription_ai_capabilities(self) -> dict[str, Any]:
|
||||||
|
return {"agreement_visible": True}
|
||||||
|
|
||||||
def prescription_ai_statistics(self, *_args: Any) -> dict[str, Any]:
|
def prescription_ai_statistics(self, *_args: Any) -> dict[str, Any]:
|
||||||
return {"total_count": 3, "patient_count": 2, "doctors": [{"doctor_name": "张医生", "total_count": 3, "patient_count": 2, "models": {"qwen": {"eligible_count": 0, "coverage_rate": 0, "mean": None, "median": None, "excluded_reasons": {"non_independent": 3}}, "openai": {"eligible_count": 1, "coverage_rate": 33.3, "mean": 0, "median": 0}}, "review": {"evaluated_count": 0, "qualified_count": 0, "qualified_rate": None}}]}
|
return {"total_count": 3, "patient_count": 2, "doctors": [{"doctor_name": "张医生", "total_count": 3, "patient_count": 2, "models": {"qwen": {"eligible_count": 0, "coverage_rate": 0, "mean": None, "median": None, "excluded_reasons": {"non_independent": 3}}, "openai": {"eligible_count": 1, "coverage_rate": 33.3, "mean": 0, "median": 0}}, "review": {"evaluated_count": 0, "qualified_count": 0, "qualified_rate": None}}]}
|
||||||
dialog = ai.PrescriptionAiStatisticsDialog(StatisticsRepository(), ["*"])
|
dialog = ai.PrescriptionAiStatisticsDialog(StatisticsRepository(), ["*"])
|
||||||
@@ -363,6 +642,7 @@ def test_repository_exact_read_and_mutation_contracts() -> None:
|
|||||||
return {"saved": True}
|
return {"saved": True}
|
||||||
client = Client()
|
client = Client()
|
||||||
repository = RemoteDoctorRepository(client)
|
repository = RemoteDoctorRepository(client)
|
||||||
|
repository.get_prescription_ai_capabilities()
|
||||||
assert repository.list_prescription_ai_statuses([801, 802, 801]) == {"enabled": False, "items": []}
|
assert repository.list_prescription_ai_statuses([801, 802, 801]) == {"enabled": False, "items": []}
|
||||||
repository.list_prescription_ai_reports(diagnosis_id=501, page_no=2, page_size=20)
|
repository.list_prescription_ai_reports(diagnosis_id=501, page_no=2, page_size=20)
|
||||||
repository.get_prescription_ai_report(40)
|
repository.get_prescription_ai_report(40)
|
||||||
@@ -372,6 +652,7 @@ def test_repository_exact_read_and_mutation_contracts() -> None:
|
|||||||
repository.review_prescription_ai(40, "qwen", "reviewed", "核对完成")
|
repository.review_prescription_ai(40, "qwen", "reviewed", "核对完成")
|
||||||
repository.prescription_ai_statistics("2026-09-01", "2026-09-09", 7)
|
repository.prescription_ai_statistics("2026-09-01", "2026-09-09", 7)
|
||||||
assert client.calls == [
|
assert client.calls == [
|
||||||
|
("GET", "tcm.prescriptionAi/capabilities", {}),
|
||||||
("GET", "tcm.prescriptionAi/statuses", {"ids": "801,802"}),
|
("GET", "tcm.prescriptionAi/statuses", {"ids": "801,802"}),
|
||||||
("GET", "tcm.prescriptionAi/reports", {"diagnosis_id": 501, "page_no": 2, "page_size": 20}),
|
("GET", "tcm.prescriptionAi/reports", {"diagnosis_id": 501, "page_no": 2, "page_size": 20}),
|
||||||
("GET", "tcm.prescriptionAi/detail", {"batch_id": 40}),
|
("GET", "tcm.prescriptionAi/detail", {"batch_id": 40}),
|
||||||
@@ -388,6 +669,217 @@ def test_repository_exact_read_and_mutation_contracts() -> None:
|
|||||||
repository.retry_prescription_ai(40, "other")
|
repository.retry_prescription_ai(40, "other")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("flag", [False, None, 1, "true"])
|
||||||
|
def test_agreement_denied_or_missing_keeps_reports_review_and_retry(
|
||||||
|
application: QApplication, immediate: None, flag: Any,
|
||||||
|
) -> None:
|
||||||
|
repository = Repository()
|
||||||
|
repository.agreement_visible = flag
|
||||||
|
value = repository.batches[0]
|
||||||
|
value["agreement_visible"] = flag
|
||||||
|
value["models"]["openai"].update(status="failed", error_message="上游超时")
|
||||||
|
dialog = ai.IssuedPrescriptionAiDialog(repository, ["*"], diagnosis_id=501,
|
||||||
|
current_user={"root": 1, "role_ids": [1]})
|
||||||
|
dialog.show()
|
||||||
|
application.processEvents()
|
||||||
|
assert not dialog._agreement_visible
|
||||||
|
assert "千问分析" in dialog.model_views["qwen"]["report"].toPlainText()
|
||||||
|
assert dialog.model_views["qwen"]["comment"].isEnabled()
|
||||||
|
assert dialog.model_views["qwen"]["save"].isVisible()
|
||||||
|
assert dialog.model_views["openai"]["retry"].isVisible()
|
||||||
|
assert dialog.model_views["openai"]["retry"].isEnabled()
|
||||||
|
assert dialog.history.count() == 1
|
||||||
|
assert dialog.comparison_panel.checklist.isVisible()
|
||||||
|
assert dialog.comparison_panel.conclusions.isHidden()
|
||||||
|
assert not dialog.comparison_panel.rows
|
||||||
|
assert not dialog.comparison_panel.doses.rows
|
||||||
|
assert dialog.nav_buttons["history"].isHidden()
|
||||||
|
assert dialog.agreement_note.isHidden()
|
||||||
|
for key, views in dialog.model_views.items():
|
||||||
|
assert not views["score"].text() and not views["score"].toolTip()
|
||||||
|
assert not views["agreement_bar"].toolTip() and not views["agreement_bar"].accessibleName()
|
||||||
|
assert views["gauge"]._value is None and not views["gauge"].accessibleDescription()
|
||||||
|
assert not views["comparison"].toPlainText()
|
||||||
|
assert "comparison" not in dialog._batch["models"][key]
|
||||||
|
assert not dialog.agreement.delta.text() and not dialog.agreement.overlap.text()
|
||||||
|
for key in ("differences", "consensus"):
|
||||||
|
assert not dialog.rail.focus_values[key].text()
|
||||||
|
assert dialog.rail.focus_values[key].parentWidget().isHidden()
|
||||||
|
dialog._goto("per_herb")
|
||||||
|
assert dialog.tabs.currentWidget() is dialog.tab_pages["candidate"]
|
||||||
|
assert "黄芪" in dialog.model_views["qwen"]["candidate"].toPlainText()
|
||||||
|
dialog._goto("comparison_raw")
|
||||||
|
assert dialog.tabs.currentWidget() is dialog.tab_pages["report"]
|
||||||
|
dialog._goto("history")
|
||||||
|
assert dialog.tabs.currentWidget() is dialog.tab_pages["report"]
|
||||||
|
dialog.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("missing", ["reports", "history_batch", "detail"])
|
||||||
|
def test_each_report_visibility_flag_is_required(application: QApplication, immediate: None, monkeypatch: pytest.MonkeyPatch, missing: str) -> None:
|
||||||
|
repository = Repository()
|
||||||
|
reports, detail = repository.list_prescription_ai_reports, repository.get_prescription_ai_report
|
||||||
|
def read_reports(**params: Any) -> dict[str, Any]:
|
||||||
|
data = reports(**params)
|
||||||
|
if missing == "reports":
|
||||||
|
data.pop("agreement_visible")
|
||||||
|
if missing == "history_batch":
|
||||||
|
data["lists"][0].pop("agreement_visible")
|
||||||
|
return data
|
||||||
|
def read_detail(batch_id: int) -> dict[str, Any]:
|
||||||
|
data = detail(batch_id)
|
||||||
|
if missing == "detail":
|
||||||
|
data.pop("agreement_visible")
|
||||||
|
return data
|
||||||
|
monkeypatch.setattr(repository, "list_prescription_ai_reports", read_reports)
|
||||||
|
monkeypatch.setattr(repository, "get_prescription_ai_report", read_detail)
|
||||||
|
dialog = ai.IssuedPrescriptionAiDialog(repository, ["*"], prescription_id=801)
|
||||||
|
dialog.show()
|
||||||
|
assert not dialog._agreement_visible
|
||||||
|
assert not dialog.model_views["qwen"]["score"].text()
|
||||||
|
assert "千问分析" in dialog.model_views["qwen"]["report"].toPlainText()
|
||||||
|
dialog.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_agreement_revocation_and_failed_read_clear_cached_views_and_survive_theme(
|
||||||
|
application: QApplication, immediate: None,
|
||||||
|
) -> None:
|
||||||
|
repository = Repository()
|
||||||
|
dialog = ai.IssuedPrescriptionAiDialog(repository, ["*"], prescription_id=801)
|
||||||
|
dialog.show()
|
||||||
|
assert dialog.model_views["qwen"]["score"].text() == "0.0%"
|
||||||
|
old_batch, old_generation = deepcopy(dialog._batch), dialog._generation
|
||||||
|
dialog.model_views["qwen"]["comment"].setPlainText("保留复核草稿")
|
||||||
|
repository.agreement_visible = False
|
||||||
|
dialog.refresh()
|
||||||
|
dialog._detail_ready(old_batch, old_generation, 40)
|
||||||
|
assert not dialog._agreement_visible
|
||||||
|
assert not dialog.model_views["qwen"]["score"].text()
|
||||||
|
assert dialog.history_page.table.rowCount() == 0
|
||||||
|
assert not dialog.history_page.chart._points
|
||||||
|
assert dialog.model_views["qwen"]["comment"].toPlainText() == "保留复核草稿"
|
||||||
|
dialog._switch_theme()
|
||||||
|
application.processEvents()
|
||||||
|
assert not dialog._agreement_visible and not dialog.model_views["qwen"]["score"].text()
|
||||||
|
assert dialog.model_views["qwen"]["comment"].toPlainText() == "保留复核草稿"
|
||||||
|
dialog._switch_theme()
|
||||||
|
repository.agreement_visible = True
|
||||||
|
dialog.refresh()
|
||||||
|
assert dialog._agreement_visible
|
||||||
|
dialog._detail_error(RuntimeError("offline"), dialog._generation)
|
||||||
|
assert not dialog._agreement_visible
|
||||||
|
assert not dialog.model_views["qwen"]["score"].text()
|
||||||
|
assert "comparison" not in dialog._batch["models"]["qwen"]
|
||||||
|
assert "千问分析" in dialog.model_views["qwen"]["report"].toPlainText()
|
||||||
|
dialog._detail_ready(old_batch, dialog._generation, 40)
|
||||||
|
assert not dialog._agreement_visible
|
||||||
|
dialog.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_revoked_agreement_cache_discards_flat_comparison_metadata() -> None:
|
||||||
|
value = batch()
|
||||||
|
sentinel = "previous-private-comparison"
|
||||||
|
value["baseline_exclusions_json"] = sentinel
|
||||||
|
value["models"]["qwen"].update({
|
||||||
|
"reason": sentinel, "comparison_reason_code": sentinel,
|
||||||
|
"dictionary_version": sentinel, "error_code": "",
|
||||||
|
})
|
||||||
|
value["models"]["openai"].update({
|
||||||
|
"error_code": "UPSTREAM_TIMEOUT", "reason": "模型请求超时",
|
||||||
|
})
|
||||||
|
sanitized = ai.agreement_safe_batch(value, False)
|
||||||
|
assert sentinel not in str(sanitized)
|
||||||
|
assert "千问分析" in str(sanitized["models"]["qwen"]["report"])
|
||||||
|
assert sanitized["models"]["openai"]["reason"] == "模型请求超时"
|
||||||
|
assert value["models"]["qwen"]["reason"] == sentinel
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_capability_failure_revocation_and_late_status_cannot_restore_scores(
|
||||||
|
application: QApplication, immediate: None, monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
repository = Repository()
|
||||||
|
page = page_module.PrescriptionsPage(repository, ["*"], {"root": 1})
|
||||||
|
page.resize(1366, 800)
|
||||||
|
page.show()
|
||||||
|
application.processEvents()
|
||||||
|
assert not page.table.isColumnHidden(12)
|
||||||
|
old_statuses = repository.list_prescription_ai_statuses([801, 802])
|
||||||
|
old_token = page._ai_request_token
|
||||||
|
old_capability_token = page._ai_capability_token
|
||||||
|
monkeypatch.setattr(repository, "get_prescription_ai_capabilities", lambda: (_ for _ in ()).throw(RuntimeError("offline")))
|
||||||
|
page._load_ai_capabilities()
|
||||||
|
page._ai_statuses_ready(old_statuses, [801, 802], old_token, page._generation)
|
||||||
|
page._ai_capabilities_ready({"agreement_visible": True}, old_capability_token, page._generation)
|
||||||
|
assert page.table.isColumnHidden(12) and not page.table.isColumnHidden(11)
|
||||||
|
assert page.ai_statistics_button.isHidden()
|
||||||
|
assert page.ai_report_button.isVisible()
|
||||||
|
assert page._ai_statuses
|
||||||
|
for index in range(page.table.rowCount()):
|
||||||
|
item = page.table.item(index, 12)
|
||||||
|
assert not item.text() and not item.toolTip() and not item.data(Qt.ItemDataRole.AccessibleTextRole)
|
||||||
|
assert all("score" not in model for row in page._ai_statuses.values() for model in row.get("models", {}).values())
|
||||||
|
page.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_rechecks_completed_scores_after_hide_and_show(application: QApplication, immediate: None, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
repository = Repository()
|
||||||
|
original = repository.list_prescription_ai_statuses
|
||||||
|
def statuses(ids: list[int]) -> dict[str, Any]:
|
||||||
|
data = original(ids)
|
||||||
|
for row in data["items"]:
|
||||||
|
row["status"] = "success"
|
||||||
|
for model in row["models"].values():
|
||||||
|
model["status"] = "success"
|
||||||
|
return data
|
||||||
|
monkeypatch.setattr(repository, "list_prescription_ai_statuses", statuses)
|
||||||
|
page = page_module.PrescriptionsPage(repository, ["*"])
|
||||||
|
page.resize(1366, 800)
|
||||||
|
page.show()
|
||||||
|
application.processEvents()
|
||||||
|
assert "0%" in page.table.item(0, 12).text()
|
||||||
|
before = len([call for call in repository.calls if call[0] == "statuses"])
|
||||||
|
page.hide()
|
||||||
|
page.show()
|
||||||
|
application.processEvents()
|
||||||
|
assert len([call for call in repository.calls if call[0] == "statuses"]) > before
|
||||||
|
assert "0%" in page.table.item(0, 12).text()
|
||||||
|
page.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("flag", [False, None, 1, "true"])
|
||||||
|
def test_direct_statistics_requires_strict_server_capability(application: QApplication, immediate: None, flag: Any) -> None:
|
||||||
|
class StatisticsRepository:
|
||||||
|
calls = 0
|
||||||
|
def get_prescription_ai_capabilities(self) -> dict[str, Any]:
|
||||||
|
return {"agreement_visible": flag, "can_manage_visibility": True}
|
||||||
|
def prescription_ai_statistics(self, *_args: Any) -> dict[str, Any]:
|
||||||
|
self.calls += 1
|
||||||
|
return {}
|
||||||
|
repository = StatisticsRepository()
|
||||||
|
dialog = ai.PrescriptionAiStatisticsDialog(repository, ["*"])
|
||||||
|
dialog.show()
|
||||||
|
assert repository.calls == 0
|
||||||
|
assert dialog.panel.isHidden() and not dialog.report.toPlainText()
|
||||||
|
dialog.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_statistics_failed_capability_clears_old_charts_and_ignores_late_result(
|
||||||
|
application: QApplication, immediate: None, monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
repository = ChineseStatisticsRepository()
|
||||||
|
dialog = ai.PrescriptionAiStatisticsDialog(repository, ["*"])
|
||||||
|
dialog.show()
|
||||||
|
assert dialog.panel.isVisible() and dialog.report.toPlainText()
|
||||||
|
old_generation = dialog._generation
|
||||||
|
monkeypatch.setattr(repository, "get_prescription_ai_capabilities", lambda: (_ for _ in ()).throw(RuntimeError("offline")))
|
||||||
|
dialog.refresh()
|
||||||
|
dialog._ready(repository.prescription_ai_statistics(), old_generation)
|
||||||
|
assert dialog.panel.isHidden() and not dialog.report.toPlainText()
|
||||||
|
assert not dialog.panel.distribution.has_data()
|
||||||
|
assert dialog.panel.doctors.rowCount() == 0
|
||||||
|
dialog.close()
|
||||||
|
|
||||||
|
|
||||||
def test_stale_list_score_is_not_attached_to_current_prescription() -> None:
|
def test_stale_list_score_is_not_attached_to_current_prescription() -> None:
|
||||||
value = {"validity": "superseded", "models": {"qwen": {"score": 100}, "openai": {"score": 90}}}
|
value = {"validity": "superseded", "models": {"qwen": {"score": 100}, "openai": {"score": 90}}}
|
||||||
assert ai.agreement_text(value) == "千问 —\nOpenAI —"
|
assert ai.agreement_text(value) == "千问 —\nOpenAI —"
|
||||||
@@ -447,8 +939,9 @@ def test_context_menu_targets_clicked_prescription(application: QApplication, im
|
|||||||
page.resize(1366, 800)
|
page.resize(1366, 800)
|
||||||
page.show()
|
page.show()
|
||||||
application.processEvents()
|
application.processEvents()
|
||||||
item = page.table.item(1, 1)
|
page.table.selectRow(1)
|
||||||
expected_id = page.table.item(1, 0).data(Qt.ItemDataRole.UserRole).id
|
item = page.table.item(0, 1)
|
||||||
|
expected_id = page.table.item(0, 0).data(Qt.ItemDataRole.UserRole).id
|
||||||
page._open_ai_context_menu(page.table.visualItemRect(item).center())
|
page._open_ai_context_menu(page.table.visualItemRect(item).center())
|
||||||
page._ai_context_menu.actions()[0].trigger()
|
page._ai_context_menu.actions()[0].trigger()
|
||||||
assert opened == [{"prescription_id": expected_id}]
|
assert opened == [{"prescription_id": expected_id}]
|
||||||
@@ -500,7 +993,7 @@ def test_actual_history_pagination_contract(application: QApplication, immediate
|
|||||||
def reports(**params: Any) -> dict[str, Any]:
|
def reports(**params: Any) -> dict[str, Any]:
|
||||||
repository.calls.append(("reports", params))
|
repository.calls.append(("reports", params))
|
||||||
page = params["page_no"]
|
page = params["page_no"]
|
||||||
return {"lists": repository.batches[(page - 1) * 20:page * 20], "count": 21, "page_no": page, "page_size": 20}
|
return {"agreement_visible": True, "lists": repository.batches[(page - 1) * 20:page * 20], "count": 21, "page_no": page, "page_size": 20}
|
||||||
monkeypatch.setattr(repository, "list_prescription_ai_reports", reports)
|
monkeypatch.setattr(repository, "list_prescription_ai_reports", reports)
|
||||||
dialog = ai.IssuedPrescriptionAiDialog(repository, ["*"], prescription_id=801)
|
dialog = ai.IssuedPrescriptionAiDialog(repository, ["*"], prescription_id=801)
|
||||||
dialog.show()
|
dialog.show()
|
||||||
@@ -522,6 +1015,9 @@ def test_server_empty_batch_statuses_are_not_fake_scores(status: str, expected:
|
|||||||
|
|
||||||
def test_statistics_displays_actual_version_strata_without_filling_null_mean(application: QApplication, immediate: None) -> None:
|
def test_statistics_displays_actual_version_strata_without_filling_null_mean(application: QApplication, immediate: None) -> None:
|
||||||
class StatisticsRepository:
|
class StatisticsRepository:
|
||||||
|
def get_prescription_ai_capabilities(self) -> dict[str, Any]:
|
||||||
|
return {"agreement_visible": True}
|
||||||
|
|
||||||
def prescription_ai_statistics(self, *_args: Any) -> dict[str, Any]:
|
def prescription_ai_statistics(self, *_args: Any) -> dict[str, Any]:
|
||||||
strata = [{"versions": {"model_version": version, "algorithm_version": "v1", "prompt_version": "p1", "dictionary_version": "d1"}, "count": 1, "mean": mean, "median": mean, "distribution": {"[0,20)": 1 if mean == 0 else 0}, "sample_status": "insufficient_sample"} for version, mean in (("model-1", 0), ("model-2", 80))]
|
strata = [{"versions": {"model_version": version, "algorithm_version": "v1", "prompt_version": "p1", "dictionary_version": "d1"}, "count": 1, "mean": mean, "median": mean, "distribution": {"[0,20)": 1 if mean == 0 else 0}, "sample_status": "insufficient_sample"} for version, mean in (("model-1", 0), ("model-2", 80))]
|
||||||
return {"total_count": 2, "patient_count": 1, "doctors": [{"doctor_id": 7, "doctor_name": "张医生", "total_count": 2, "patient_count": 1, "paired_count": 0, "models": {"qwen": {"eligible_count": 2, "coverage_rate": 100, "mean": None, "median": None, "excluded_reasons": {}, "strata": strata}}, "review": {"evaluated_count": 0, "qualified_count": 0, "qualified_rate": None}}]}
|
return {"total_count": 2, "patient_count": 1, "doctors": [{"doctor_id": 7, "doctor_name": "张医生", "total_count": 2, "patient_count": 1, "paired_count": 0, "models": {"qwen": {"eligible_count": 2, "coverage_rate": 100, "mean": None, "median": None, "excluded_reasons": {}, "strata": strata}}, "review": {"evaluated_count": 0, "qualified_count": 0, "qualified_rate": None}}]}
|
||||||
@@ -617,6 +1113,25 @@ def test_metadata_and_prose_remain_html_escaped(application: QApplication) -> No
|
|||||||
browser.close()
|
browser.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_candidate_reading_view_keeps_full_medicine_instructions(application: QApplication) -> None:
|
||||||
|
candidate = {
|
||||||
|
"prescription_name": "测试候选方", "dose_basis": "per_dose", "unit": "g",
|
||||||
|
"herbs": [{"name": "测试药材", "dosage": 12, "formula_type": "主方",
|
||||||
|
"instructions": "先煎,具体时长由医生复核", "processing": "生品",
|
||||||
|
"evidence_references": ["diagnoses:501"]}],
|
||||||
|
"rationale": "保留完整方义", "risk_warnings": ["复核提示 <script>bad()</script>"],
|
||||||
|
}
|
||||||
|
html = ai._candidate_html(candidate)
|
||||||
|
assert html.index("测试药材") < html.index("保留完整方义")
|
||||||
|
assert "<script>" not in html
|
||||||
|
browser = ai._browser()
|
||||||
|
browser.setHtml(html)
|
||||||
|
text = browser.toPlainText()
|
||||||
|
for expected in ("测试药材", "12", "克", "每剂", "主方", "先煎", "生品", "诊单(编号:501)", "保留完整方义"):
|
||||||
|
assert expected in text
|
||||||
|
browser.close()
|
||||||
|
|
||||||
|
|
||||||
def chinese_history_batch() -> dict[str, Any]:
|
def chinese_history_batch() -> dict[str, Any]:
|
||||||
"""Synthetic fixture; no patient or network data is used in tests or visual QA."""
|
"""Synthetic fixture; no patient or network data is used in tests or visual QA."""
|
||||||
value = batch(39, status="success")
|
value = batch(39, status="success")
|
||||||
@@ -673,6 +1188,9 @@ def test_internal_version_labels_keep_revision_numbers(version: str, expected: s
|
|||||||
|
|
||||||
|
|
||||||
class ChineseStatisticsRepository:
|
class ChineseStatisticsRepository:
|
||||||
|
def get_prescription_ai_capabilities(self) -> dict[str, Any]:
|
||||||
|
return {"agreement_visible": True}
|
||||||
|
|
||||||
def prescription_ai_statistics(self, *_args: Any) -> dict[str, Any]:
|
def prescription_ai_statistics(self, *_args: Any) -> dict[str, Any]:
|
||||||
exclusions = {"SOURCE_HISTORY_VERSIONS_UNAVAILABLE": 3, "transcript_not_final": 1,
|
exclusions = {"SOURCE_HISTORY_VERSIONS_UNAVAILABLE": 3, "transcript_not_final": 1,
|
||||||
"ATTACHMENT_TARGET_PLAN_LEAKAGE_UNVERIFIED": 2, "future_reason_a": 4, "future_reason_b": 5}
|
"ATTACHMENT_TARGET_PLAN_LEAKAGE_UNVERIFIED": 2, "future_reason_a": 4, "future_reason_b": 5}
|
||||||
@@ -907,6 +1425,7 @@ def test_progress_updates_preserve_report_document_scroll_and_selection(applicat
|
|||||||
repository.batches = [value]
|
repository.batches = [value]
|
||||||
dialog = ai.IssuedPrescriptionAiDialog(repository, ["*"], prescription_id=801)
|
dialog = ai.IssuedPrescriptionAiDialog(repository, ["*"], prescription_id=801)
|
||||||
dialog.show()
|
dialog.show()
|
||||||
|
dialog.tabs.setCurrentWidget(dialog.report_pages["report"])
|
||||||
application.processEvents()
|
application.processEvents()
|
||||||
report = dialog.model_views["qwen"]["report"]
|
report = dialog.model_views["qwen"]["report"]
|
||||||
report.verticalScrollBar().setValue(300)
|
report.verticalScrollBar().setValue(300)
|
||||||
@@ -1035,3 +1554,93 @@ def test_retry_wait_freezes_attempt_duration_while_countdown_advances_and_poll_r
|
|||||||
def test_unknown_or_invalid_attempt_count_is_not_invented(attempt: Any) -> None:
|
def test_unknown_or_invalid_attempt_count_is_not_invented(attempt: Any) -> None:
|
||||||
view = ai.progress_view({"status": "retry_wait", "progress": {"stage": "retry_wait", "attempt": attempt}})
|
view = ai.progress_view({"status": "retry_wait", "progress": {"stage": "retry_wait", "attempt": attempt}})
|
||||||
assert "次尝试" not in view.detail
|
assert "次尝试" not in view.detail
|
||||||
|
|
||||||
|
|
||||||
|
def test_theme_switch_keeps_the_page_the_drafts_and_every_step(application: QApplication,
|
||||||
|
immediate: None) -> None:
|
||||||
|
"""The palette swaps in place; nothing the doctor typed or opened is lost."""
|
||||||
|
|
||||||
|
from doctor_workstation.ui.dialogs import issued_prescription_ai_theme as theme
|
||||||
|
|
||||||
|
dialog = ai.IssuedPrescriptionAiDialog(Repository(), ["*"], prescription_id=801)
|
||||||
|
dialog.resize(1280, 860)
|
||||||
|
dialog.show()
|
||||||
|
application.processEvents()
|
||||||
|
dialog._goto("gaps")
|
||||||
|
dialog.model_views["qwen"]["comment"].setPlainText("切换前写的复核意见")
|
||||||
|
dark = theme.CONSOLE["canvas"]
|
||||||
|
try:
|
||||||
|
dialog._switch_theme()
|
||||||
|
application.processEvents()
|
||||||
|
assert theme.CONSOLE["canvas"] != dark
|
||||||
|
assert theme.current_theme() == "light"
|
||||||
|
assert dialog.model_views["qwen"]["comment"].toPlainText() == "切换前写的复核意见"
|
||||||
|
assert dialog.tabs.currentWidget() is dialog.tab_pages["gaps"]
|
||||||
|
assert len(dialog.rail.buttons) == len(ai.NAV_PRIMARY)
|
||||||
|
assert all(button.isVisible() for button in dialog.rail.buttons.values())
|
||||||
|
finally:
|
||||||
|
dialog._switch_theme()
|
||||||
|
application.processEvents()
|
||||||
|
assert theme.CONSOLE["canvas"] == dark
|
||||||
|
dialog.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_step_stays_readable_in_both_palettes(application: QApplication, immediate: None) -> None:
|
||||||
|
"""A selected step paints its own labels: a descendant :checked rule would not reach them."""
|
||||||
|
|
||||||
|
from doctor_workstation.ui.dialogs import issued_prescription_ai_theme as theme
|
||||||
|
|
||||||
|
dialog = ai.IssuedPrescriptionAiDialog(Repository(), ["*"], prescription_id=801)
|
||||||
|
dialog.show()
|
||||||
|
application.processEvents()
|
||||||
|
rail = dialog.rail
|
||||||
|
assert theme.CONSOLE["heading"] in rail.names["overview"].styleSheet()
|
||||||
|
assert theme.CONSOLE["muted"] in rail.names["report"].styleSheet()
|
||||||
|
rail.set_current("report")
|
||||||
|
assert theme.CONSOLE["heading"] in rail.names["report"].styleSheet()
|
||||||
|
assert theme.CONSOLE["muted"] in rail.names["overview"].styleSheet()
|
||||||
|
dialog.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_native_title_bar_takes_the_console_palette() -> None:
|
||||||
|
"""The frame Windows draws follows the theme, so the dark body does not stop at the caption."""
|
||||||
|
|
||||||
|
from doctor_workstation.ui.dialogs import issued_prescription_ai_theme as theme
|
||||||
|
|
||||||
|
# DWM wants 0x00BBGGRR, not the #RRGGBB the palette is written in.
|
||||||
|
assert theme._colorref("#080D18") == 0x180D08
|
||||||
|
assert theme._colorref("#FFFFFF") == 0xFFFFFF
|
||||||
|
|
||||||
|
class Handleless:
|
||||||
|
def winId(self) -> int:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
assert theme.apply_window_chrome(Handleless()) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_report_rail_lists_the_saved_sections_and_filters_to_differences(
|
||||||
|
application: QApplication, immediate: None) -> None:
|
||||||
|
repository = Repository()
|
||||||
|
repository.batches[0]["models"]["qwen"]["report"] = {"summary": "两侧一致的概要", "analysis": "千问的分析"}
|
||||||
|
repository.batches[0]["models"]["openai"]["report"] = {"summary": "两侧一致的概要", "analysis": "OpenAI 的分析"}
|
||||||
|
dialog = ai.IssuedPrescriptionAiDialog(repository, ["*"], prescription_id=801)
|
||||||
|
dialog.show()
|
||||||
|
dialog._goto("report")
|
||||||
|
application.processEvents()
|
||||||
|
titles = [dialog.report_toc.itemAt(index).widget().text()
|
||||||
|
for index in range(dialog.report_toc.count())]
|
||||||
|
assert "概要" in titles and "综合分析" in titles
|
||||||
|
both = dialog.model_views["qwen"]["report"].toPlainText()
|
||||||
|
assert "两侧一致的概要" in both and "千问的分析" in both
|
||||||
|
dialog._set_report_mode("differences")
|
||||||
|
application.processEvents()
|
||||||
|
filtered = dialog.model_views["qwen"]["report"].toPlainText()
|
||||||
|
assert "千问的分析" in filtered
|
||||||
|
assert "两侧一致的概要" not in filtered # 两侧完全相同的段落不算差异
|
||||||
|
dialog._set_report_mode("qwen")
|
||||||
|
application.processEvents()
|
||||||
|
assert not dialog.report_columns["openai"].isVisible()
|
||||||
|
dialog._set_report_mode("both")
|
||||||
|
application.processEvents()
|
||||||
|
assert dialog.report_columns["openai"].isVisible()
|
||||||
|
dialog.close()
|
||||||
|
|||||||
@@ -0,0 +1,399 @@
|
|||||||
|
"""Offline rendering and data-integrity checks for the prescription workspace."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
from copy import deepcopy
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PySide6.QtCore import QEvent, QObject, Qt
|
||||||
|
from PySide6.QtTest import QTest
|
||||||
|
from PySide6.QtWidgets import QApplication, QVBoxLayout, QWidget
|
||||||
|
|
||||||
|
from doctor_workstation.ui.dialogs.issued_prescription_ai_comparison import (
|
||||||
|
PrescriptionComparisonPanel,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def saved_row(name: str = "黄芪", *, doctor: Any = 30, candidate: Any = 15, unit: str = "g", basis: str = "per_dose", formula: str = "主方", processing: str = "生品") -> dict[str, Any]:
|
||||||
|
identity = {"name": name, "processing": processing, "formula_type": formula, "administration_route": "口服", "group": "", "unit": unit, "dose_basis": basis}
|
||||||
|
return {**identity, "key": "a1b2c3d4" * 8, "doctor": {**identity, "dosage": doctor}, "candidate": {**identity, "dosage": candidate}, "doctor_dosage": doctor, "candidate_dosage": candidate, "match_type": "matched"}
|
||||||
|
|
||||||
|
|
||||||
|
def saved_batch(rows: list[dict[str, Any]] | None = None) -> dict[str, Any]:
|
||||||
|
rows = rows if rows is not None else [saved_row(), saved_row("白术", doctor=9, candidate=12)]
|
||||||
|
herbs = []
|
||||||
|
for row in rows:
|
||||||
|
if row.get("candidate"):
|
||||||
|
original = deepcopy(row["candidate"])
|
||||||
|
original.pop("source_rows", None)
|
||||||
|
row["candidate"].setdefault("source_rows", [len(herbs)])
|
||||||
|
herbs.append(original)
|
||||||
|
candidate = {"status": "available_for_review", "prescription_name": "补中益气汤加减", "herbs": herbs, "usage_instruction": "水煎温服", "times_per_day": 2, "usage_days": 7}
|
||||||
|
qwen = {"status": "succeeded", "candidate": candidate, "comparison": {"status": "comparable", "score": 68.5, "herb_score": 100, "rows": rows}}
|
||||||
|
openai = deepcopy(qwen)
|
||||||
|
openai["candidate"]["prescription_name"] = "益气健脾方"
|
||||||
|
return {"id": 44, "validity": "current", "status": "completed", "models": {"qwen": qwen, "openai": openai}}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def application() -> QApplication:
|
||||||
|
return QApplication.instance() or QApplication([])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def no_network(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
def denied(*_args: Any, **_kwargs: Any) -> None:
|
||||||
|
pytest.fail("The comparison panel must never contact external systems")
|
||||||
|
monkeypatch.setattr(socket.socket, "connect", denied)
|
||||||
|
monkeypatch.setattr(socket.socket, "connect_ex", denied)
|
||||||
|
monkeypatch.setattr(socket, "create_connection", denied)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def panel(application: QApplication):
|
||||||
|
host = QWidget()
|
||||||
|
host.resize(1120, 400)
|
||||||
|
layout = QVBoxLayout(host)
|
||||||
|
layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
widget = PrescriptionComparisonPanel(host)
|
||||||
|
layout.addWidget(widget)
|
||||||
|
host.show()
|
||||||
|
application.processEvents()
|
||||||
|
yield widget
|
||||||
|
host.close()
|
||||||
|
host.deleteLater()
|
||||||
|
application.processEvents()
|
||||||
|
|
||||||
|
|
||||||
|
def test_saved_snapshot_is_readable_and_model_switch_is_explicit(panel: PrescriptionComparisonPanel, application: QApplication) -> None:
|
||||||
|
data = saved_batch()
|
||||||
|
before = deepcopy(data)
|
||||||
|
panel.set_batch(data)
|
||||||
|
application.processEvents()
|
||||||
|
assert data == before
|
||||||
|
assert panel.selected_model == "qwen"
|
||||||
|
assert panel.model_buttons["qwen"].isChecked()
|
||||||
|
assert panel.name_label.text() == "补中益气汤加减"
|
||||||
|
assert panel.herb_table.rowCount() == 2
|
||||||
|
assert panel.herb_table.item(0, 1).text() == "30 克 / 每剂"
|
||||||
|
assert panel.herb_table.item(0, 2).text() == "15 克 / 每剂"
|
||||||
|
assert "主方" in panel.herb_table.item(0, 0).text()
|
||||||
|
assert "a1b2c3d4" not in panel.chart.accessibleDescription()
|
||||||
|
assert "药味剂量一致度" not in panel.chart.accessibleDescription()
|
||||||
|
assert "水煎温服" in panel.usage_label.text()
|
||||||
|
assert panel.chart.bars_enabled
|
||||||
|
assert panel.chart.groups[0][0] == ("克", "每剂")
|
||||||
|
panel.model_buttons["openai"].click()
|
||||||
|
assert panel.selected_model == "openai"
|
||||||
|
assert panel.name_label.text() == "益气健脾方"
|
||||||
|
assert panel.herb_table.horizontalHeaderItem(2).text() == "OpenAI剂量"
|
||||||
|
assert panel.chart.model_key == "openai"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("candidate_key", ["candidate_dose", "candidate_dosage", "ai_dose"])
|
||||||
|
def test_legacy_flat_doses_remain_supported(panel: PrescriptionComparisonPanel, candidate_key: str) -> None:
|
||||||
|
data = saved_batch()
|
||||||
|
data["models"]["qwen"]["comparison"]["rows"] = [{"name": "黄芪", "doctor_dose": 0, candidate_key: "12.50", "unit": "g", "dose_basis": "每剂", "formula_type": "主方"}]
|
||||||
|
panel.set_batch(data)
|
||||||
|
assert panel.herb_table.item(0, 1).text() == "0 克 / 每剂"
|
||||||
|
assert panel.herb_table.item(0, 2).text() == "12.50 克 / 每剂"
|
||||||
|
assert panel.chart.rows[0].doctor.number == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_absent_historical_doctor_is_never_replaced_with_current_prescription(panel: PrescriptionComparisonPanel) -> None:
|
||||||
|
data = saved_batch()
|
||||||
|
data["models"]["qwen"]["comparison"]["rows"] = []
|
||||||
|
data["prescription"] = {"herbs": [{"name": "黄芪", "dosage": 99, "unit": "g", "dose_basis": "per_dose"}]}
|
||||||
|
panel.set_batch(data)
|
||||||
|
assert panel.herb_table.rowCount() == 2
|
||||||
|
assert panel.herb_table.item(0, 1).text() == "—"
|
||||||
|
assert panel.herb_table.item(0, 2).text() == "15 克 / 每剂"
|
||||||
|
assert not panel.chart.bars_enabled
|
||||||
|
assert "历史处方" in panel.status_label.text()
|
||||||
|
assert "99" not in panel.chart.accessibleDescription()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("rows", [None, {}, "unavailable"])
|
||||||
|
def test_malformed_comparison_keeps_candidate_list_without_claiming_a_snapshot(panel: PrescriptionComparisonPanel, rows: Any) -> None:
|
||||||
|
data = saved_batch()
|
||||||
|
data["models"]["qwen"]["comparison"]["rows"] = rows
|
||||||
|
panel.set_batch(data)
|
||||||
|
assert panel.herb_table.rowCount() == 2
|
||||||
|
assert panel.herb_table.item(0, 1).text() == "—"
|
||||||
|
assert not panel.chart.bars_enabled
|
||||||
|
|
||||||
|
|
||||||
|
def test_explicit_missing_snapshot_and_nested_unknown_unit_override_flat_values(panel: PrescriptionComparisonPanel) -> None:
|
||||||
|
row = saved_row()
|
||||||
|
row["doctor"] = None
|
||||||
|
row["candidate"]["unit"] = None
|
||||||
|
panel.set_batch(saved_batch([row]))
|
||||||
|
assert panel.herb_table.item(0, 1).text() == "—"
|
||||||
|
assert "单位未注明" in panel.herb_table.item(0, 2).text()
|
||||||
|
assert panel.chart.groups[0][0] is None
|
||||||
|
assert "未明确" in panel.chart.accessibleDescription()
|
||||||
|
|
||||||
|
|
||||||
|
def test_incomparable_report_retains_original_doses_without_bars(panel: PrescriptionComparisonPanel) -> None:
|
||||||
|
data = saved_batch()
|
||||||
|
data["models"]["qwen"]["comparison"].update(status="not_comparable", score=99, reason="dose_basis_mismatch")
|
||||||
|
panel.set_batch(data)
|
||||||
|
assert "剂量基准不同" in panel.status_label.text()
|
||||||
|
assert not panel.chart.bars_enabled
|
||||||
|
assert panel.herb_table.item(0, 1).text() == "30 克 / 每剂"
|
||||||
|
assert all(scale is None for scale, _rows, _maximum in panel.chart.groups)
|
||||||
|
assert "99" not in panel.chart.accessibleDescription()
|
||||||
|
|
||||||
|
|
||||||
|
def test_units_and_bases_have_independent_scales_and_mismatched_rows_are_excluded(panel: PrescriptionComparisonPanel) -> None:
|
||||||
|
rows = [saved_row("黄芪"), saved_row("白术", unit="mg", doctor=1000), saved_row("茯苓", basis="per_day", doctor=60), saved_row("甘草")]
|
||||||
|
rows[-1]["candidate"]["dose_basis"] = "per_day"
|
||||||
|
panel.set_batch(saved_batch(rows))
|
||||||
|
assert [scale for scale, _members, _maximum in panel.chart.groups] == [("克", "每剂"), ("毫克", "每剂"), ("克", "每日"), None]
|
||||||
|
assert panel.chart.groups[0][2] == 30
|
||||||
|
assert panel.chart.groups[1][2] == 1000
|
||||||
|
assert "单位或剂量基准不同" in panel.chart.accessibleDescription()
|
||||||
|
assert "每日" in panel.herb_table.item(3, 2).text()
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_auxiliary_and_processing_rows_never_merge(panel: PrescriptionComparisonPanel) -> None:
|
||||||
|
rows = [saved_row("甘草", formula="主方"), saved_row("甘草", formula="辅方"), saved_row("甘草", processing="炙品")]
|
||||||
|
rows[2]["candidate"]["processing"] = "生品"
|
||||||
|
panel.set_batch(saved_batch(rows))
|
||||||
|
assert panel.herb_table.rowCount() == 3
|
||||||
|
assert "主方" in panel.herb_table.item(0, 0).text()
|
||||||
|
assert "辅方" in panel.herb_table.item(1, 0).text()
|
||||||
|
assert panel.chart.rows[2].scale is None
|
||||||
|
assert "炮制" in panel.chart.accessibleDescription()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("skipped_index", [0, 1])
|
||||||
|
def test_zero_based_trace_preserves_candidate_herbs_skipped_during_normalization(panel: PrescriptionComparisonPanel, skipped_index: int) -> None:
|
||||||
|
normalized = saved_row("黄芪")
|
||||||
|
normalized["candidate"]["source_rows"] = [1 - skipped_index]
|
||||||
|
data = saved_batch([normalized])
|
||||||
|
original = deepcopy(data["models"]["qwen"]["candidate"]["herbs"][0])
|
||||||
|
excluded = {**original, "name": "黄芪", "processing": "炮制待核对", "dosage": 8, "instructions": "先煎 30 分钟"}
|
||||||
|
herbs = [original]
|
||||||
|
herbs.insert(skipped_index, excluded)
|
||||||
|
data["models"]["qwen"]["candidate"]["herbs"] = herbs
|
||||||
|
# Even a contradictory comparable status must not grant the omitted raw herb a bar.
|
||||||
|
panel.set_batch(data)
|
||||||
|
assert panel.herb_table.rowCount() == 2
|
||||||
|
assert panel.herb_table.item(0, 2).text() == "15 克 / 每剂"
|
||||||
|
assert "未纳入对比" in panel.herb_table.item(1, 0).text()
|
||||||
|
assert "炮制待核对" in panel.herb_table.item(1, 0).text()
|
||||||
|
assert panel.herb_table.item(1, 1).text() == "—"
|
||||||
|
assert panel.herb_table.item(1, 2).text() == "8 克 / 每剂"
|
||||||
|
assert panel.chart.rows[1].scale is None
|
||||||
|
assert "先煎 30 分钟" in panel.chart.accessibleDescription()
|
||||||
|
assert "原方 2 项" in panel.count_label.text() and "未纳入 1 项" in panel.count_label.text()
|
||||||
|
|
||||||
|
|
||||||
|
def test_merged_source_rows_cover_each_original_once_and_keep_original_doses(panel: PrescriptionComparisonPanel) -> None:
|
||||||
|
merged = saved_row("黄芪", candidate=10)
|
||||||
|
merged["candidate"]["source_rows"] = [0, 1]
|
||||||
|
data = saved_batch([merged])
|
||||||
|
original = data["models"]["qwen"]["candidate"]["herbs"][0]
|
||||||
|
data["models"]["qwen"]["candidate"]["herbs"] = [{**original, "name": "北芪", "dosage": 4}, {**original, "dosage": 6}, {**original, "name": "未识别药材", "dosage": 5}]
|
||||||
|
panel.set_batch(data)
|
||||||
|
assert panel.herb_table.rowCount() == 2
|
||||||
|
assert panel.herb_table.item(0, 2).text() == "10 克 / 每剂"
|
||||||
|
tooltip = panel.herb_table.item(0, 0).toolTip()
|
||||||
|
assert "第 1 项 北芪 4 克 / 每剂" in tooltip
|
||||||
|
assert "第 2 项 黄芪 6 克 / 每剂" in tooltip
|
||||||
|
assert "未识别药材" in panel.herb_table.item(1, 0).text()
|
||||||
|
assert "未纳入对比" in panel.herb_table.item(1, 0).text()
|
||||||
|
assert "原方 3 项" in panel.count_label.text() and "对比 1 项" in panel.count_label.text()
|
||||||
|
panel.search.setText("北芪")
|
||||||
|
assert panel.herb_table.rowCount() == 1
|
||||||
|
assert "黄芪" in panel.herb_table.item(0, 0).text()
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_missing_trace_retains_full_original_separately_without_name_matching(panel: PrescriptionComparisonPanel) -> None:
|
||||||
|
data = saved_batch([saved_row("黄芪")])
|
||||||
|
model = data["models"]["qwen"]
|
||||||
|
del model["comparison"]["rows"][0]["candidate"]["source_rows"]
|
||||||
|
original = model["candidate"]["herbs"][0]
|
||||||
|
model["candidate"]["herbs"] = [{**original, "dosage": 4}, {**original, "name": "炮制待核对药材", "dosage": 6}]
|
||||||
|
panel.set_batch(data)
|
||||||
|
assert panel.herb_table.rowCount() == 3
|
||||||
|
assert panel.herb_table.item(0, 2).text() == "15 克 / 每剂"
|
||||||
|
for index, dose in ((1, "4 克 / 每剂"), (2, "6 克 / 每剂")):
|
||||||
|
assert "原方附列" in panel.herb_table.item(index, 0).text()
|
||||||
|
assert panel.herb_table.item(index, 1).text() == "—"
|
||||||
|
assert panel.herb_table.item(index, 2).text() == dose
|
||||||
|
assert panel.chart.rows[index].scale is None
|
||||||
|
assert "对应关系未保存" in panel.status_label.text()
|
||||||
|
assert "原方附列 2 项" in panel.count_label.text()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("trace", [[True], [1], [-1], ["0"], []])
|
||||||
|
def test_invalid_source_trace_never_hides_an_original_herb(panel: PrescriptionComparisonPanel, trace: list[Any]) -> None:
|
||||||
|
data = saved_batch([saved_row("黄芪")])
|
||||||
|
data["models"]["qwen"]["comparison"]["rows"][0]["candidate"]["source_rows"] = trace
|
||||||
|
panel.set_batch(data)
|
||||||
|
assert panel.herb_table.rowCount() == 2
|
||||||
|
assert "原方附列" in panel.herb_table.item(1, 0).text()
|
||||||
|
assert panel.chart.rows[1].scale is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_real_normalized_usage_keeps_all_special_instructions_visible_and_accessible(panel: PrescriptionComparisonPanel) -> None:
|
||||||
|
row = saved_row("石膏")
|
||||||
|
row["doctor"]["usage"] = {"decoction_instruction": "先煎 30 分钟", "special_usage": "布包煎", "usage_time": "饭后", "usage_way": "温服"}
|
||||||
|
row["candidate"]["instructions"] = "后下 5 分钟"
|
||||||
|
row["candidate"]["usage"] = {"instructions": "后下 5 分钟", "decoction_instruction": "另煎", "special_usage": "分次兑服", "usage_instruction": "服前摇匀", "usage_time": "睡前", "usage_way": "温服"}
|
||||||
|
panel.set_batch(saved_batch([row]))
|
||||||
|
name_item = panel.herb_table.item(0, 0)
|
||||||
|
for instruction in ("先煎 30 分钟", "布包煎", "饭后", "温服", "后下 5 分钟", "另煎", "分次兑服", "服前摇匀", "睡前"):
|
||||||
|
assert instruction in name_item.toolTip()
|
||||||
|
assert instruction in name_item.data(Qt.ItemDataRole.AccessibleDescriptionRole)
|
||||||
|
assert instruction in panel.chart.accessibleDescription()
|
||||||
|
assert "先煎 30 分钟" in name_item.text() and "后下 5 分钟" in name_item.text()
|
||||||
|
assert panel.chart.rows[0].candidate.instructions.count("后下 5 分钟") == 1
|
||||||
|
assert panel.herb_table.rowHeight(0) >= panel.herb_table.fontMetrics().height() * 3 + 12
|
||||||
|
|
||||||
|
|
||||||
|
def test_clicking_a_table_row_scrolls_to_its_chart_group(panel: PrescriptionComparisonPanel, application: QApplication) -> None:
|
||||||
|
rows = [saved_row(f"药材{index}", unit="g" if index % 2 == 0 else "mg") for index in range(20)]
|
||||||
|
panel.set_batch(saved_batch(rows))
|
||||||
|
application.processEvents()
|
||||||
|
item = panel.herb_table.item(11, 0)
|
||||||
|
panel.herb_table.scrollToItem(item)
|
||||||
|
application.processEvents()
|
||||||
|
panel.chart_scroll.verticalScrollBar().setValue(0)
|
||||||
|
QTest.mouseClick(panel.herb_table.viewport(), Qt.MouseButton.LeftButton, pos=panel.herb_table.visualItemRect(item).center())
|
||||||
|
application.processEvents()
|
||||||
|
assert panel.herb_table.currentRow() == 11
|
||||||
|
# Ten gram rows precede the milligram group; the clicked row is its sixth member.
|
||||||
|
expected_y = 8 + panel.chart.GROUP_HEIGHT * 2 + panel.chart.ROW_HEIGHT * 15
|
||||||
|
assert panel.chart_scroll.verticalScrollBar().value() == expected_y
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("field", ["processing", "formula_type", "administration_route", "group"])
|
||||||
|
def test_unrecognized_identity_values_do_not_become_equal_after_localization(panel: PrescriptionComparisonPanel, field: str) -> None:
|
||||||
|
row = saved_row()
|
||||||
|
row["doctor"][field] = "unknown_first"
|
||||||
|
row["candidate"][field] = "unknown_second"
|
||||||
|
panel.set_batch(saved_batch([row]))
|
||||||
|
assert panel.chart.rows[0].scale is None
|
||||||
|
assert "unknown_first" not in panel.chart.accessibleDescription()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("value", [None, "适量", -3, float("nan"), float("inf"), True])
|
||||||
|
def test_invalid_or_missing_doses_never_produce_numeric_bars(panel: PrescriptionComparisonPanel, application: QApplication, value: Any) -> None:
|
||||||
|
panel.set_batch(saved_batch([saved_row(doctor=value)]))
|
||||||
|
assert panel.chart.rows[0].doctor.number is None
|
||||||
|
assert panel.chart.groups[0][2] == 15
|
||||||
|
application.processEvents()
|
||||||
|
assert not panel.chart.grab().isNull()
|
||||||
|
if value is None or value is True:
|
||||||
|
assert panel.herb_table.item(0, 1).text() == "—"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(("status", "expected"), [("running", "正在生成"), ("failed", "未完成"), ("future_state", "状态未确认")])
|
||||||
|
def test_processing_failed_and_unknown_model_states_are_explicit(panel: PrescriptionComparisonPanel, status: str, expected: str) -> None:
|
||||||
|
data = saved_batch()
|
||||||
|
data["models"]["qwen"].update(status=status, candidate=None, comparison=None)
|
||||||
|
panel.set_batch(data)
|
||||||
|
assert expected in panel.status_label.text()
|
||||||
|
assert panel.empty_label.isVisible()
|
||||||
|
assert panel.herb_table.rowCount() == 0
|
||||||
|
assert not panel.chart.bars_enabled
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("validity", ["stale", "source_updated", "future_validity"])
|
||||||
|
def test_outdated_and_unknown_validity_only_show_saved_original_values(panel: PrescriptionComparisonPanel, validity: str) -> None:
|
||||||
|
data = saved_batch()
|
||||||
|
data["validity"] = validity
|
||||||
|
panel.set_batch(data)
|
||||||
|
assert not panel.chart.bars_enabled
|
||||||
|
assert "历史原值" in panel.status_label.text()
|
||||||
|
assert panel.herb_table.rowCount() == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_refresh_preserves_search_model_selection_and_both_scroll_positions(panel: PrescriptionComparisonPanel, application: QApplication) -> None:
|
||||||
|
data = saved_batch([saved_row(f"黄芪{index}") for index in range(40)])
|
||||||
|
panel.set_batch(data)
|
||||||
|
panel.model_buttons["openai"].click()
|
||||||
|
panel.search.setText("黄芪")
|
||||||
|
panel.herb_table.selectRow(10)
|
||||||
|
application.processEvents()
|
||||||
|
panel.herb_table.verticalScrollBar().setValue(180)
|
||||||
|
panel.chart_scroll.verticalScrollBar().setValue(250)
|
||||||
|
before = (panel.herb_table.verticalScrollBar().value(), panel.chart_scroll.verticalScrollBar().value())
|
||||||
|
for _ in range(3):
|
||||||
|
panel.set_batch(deepcopy(data))
|
||||||
|
application.processEvents()
|
||||||
|
assert panel.search.text() == "黄芪"
|
||||||
|
assert panel.selected_model == "openai"
|
||||||
|
assert panel.herb_table.currentRow() == 10
|
||||||
|
assert before == (panel.herb_table.verticalScrollBar().value(), panel.chart_scroll.verticalScrollBar().value())
|
||||||
|
changed = deepcopy(data)
|
||||||
|
changed["models"]["qwen"]["progress"] = {"stage": "completed"}
|
||||||
|
panel.set_batch(changed)
|
||||||
|
application.processEvents()
|
||||||
|
assert panel.search.text() == "黄芪" and panel.selected_model == "openai"
|
||||||
|
assert before == (panel.herb_table.verticalScrollBar().value(), panel.chart_scroll.verticalScrollBar().value())
|
||||||
|
panel.search.setText("黄芪39")
|
||||||
|
assert panel.herb_table.rowCount() == 1
|
||||||
|
assert len(panel.chart.rows) == 1
|
||||||
|
panel.search.setText("未匹配")
|
||||||
|
assert "没有匹配" in panel.empty_label.text()
|
||||||
|
|
||||||
|
|
||||||
|
def test_parent_owned_controls_do_not_flash_windows_on_update(application: QApplication) -> None:
|
||||||
|
shown_windows = []
|
||||||
|
|
||||||
|
class WindowObserver(QObject):
|
||||||
|
def eventFilter(self, watched: QObject, event: QEvent) -> bool:
|
||||||
|
if event.type() == QEvent.Type.Show and isinstance(watched, QWidget) and watched.isWindow():
|
||||||
|
shown_windows.append(watched)
|
||||||
|
return False
|
||||||
|
|
||||||
|
observer = WindowObserver()
|
||||||
|
application.installEventFilter(observer)
|
||||||
|
host = QWidget()
|
||||||
|
try:
|
||||||
|
layout = QVBoxLayout(host)
|
||||||
|
widget = PrescriptionComparisonPanel(host)
|
||||||
|
layout.addWidget(widget)
|
||||||
|
widget.set_batch(saved_batch())
|
||||||
|
host.show()
|
||||||
|
application.processEvents()
|
||||||
|
assert shown_windows == [host]
|
||||||
|
shown_windows.clear()
|
||||||
|
widget.set_batch(saved_batch([saved_row("当归")]))
|
||||||
|
widget.model_buttons["openai"].click()
|
||||||
|
widget.set_batch({})
|
||||||
|
application.processEvents()
|
||||||
|
assert shown_windows == []
|
||||||
|
assert all(child.parentWidget() is not None for child in widget.findChildren(QWidget))
|
||||||
|
assert widget.herb_table.rowCount() == 0
|
||||||
|
assert widget.chart.rows == []
|
||||||
|
finally:
|
||||||
|
application.removeEventFilter(observer)
|
||||||
|
host.close()
|
||||||
|
host.deleteLater()
|
||||||
|
application.processEvents()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("size", [(1120, 400), (940, 340)])
|
||||||
|
def test_compact_sizes_keep_table_and_chart_scrollable(panel: PrescriptionComparisonPanel, application: QApplication, size: tuple[int, int]) -> None:
|
||||||
|
panel.parentWidget().resize(*size)
|
||||||
|
panel.set_batch(saved_batch([saved_row(f"药材{index}") for index in range(60)]))
|
||||||
|
application.processEvents()
|
||||||
|
assert panel.width() <= size[0] and panel.height() <= size[1]
|
||||||
|
assert panel.herb_table.viewport().height() >= 65
|
||||||
|
assert panel.herb_table.viewport().width() >= 320
|
||||||
|
assert panel.chart_scroll.viewport().height() >= 90
|
||||||
|
assert panel.herb_table.verticalScrollBar().maximum() > 0
|
||||||
|
assert panel.chart_scroll.verticalScrollBar().maximum() > 0
|
||||||
|
assert not panel.grab().isNull()
|
||||||
|
assert panel.herb_table.item(0, 2).data(Qt.ItemDataRole.AccessibleTextRole) == "15 克 / 每剂"
|
||||||
|
assert "药材59" in panel.chart.accessibleDescription()
|
||||||
@@ -0,0 +1,478 @@
|
|||||||
|
"""The composed pages must show the saved batch exactly, including what is missing from it."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PySide6.QtWidgets import QApplication, QLabel
|
||||||
|
|
||||||
|
from doctor_workstation.ui.dialogs import issued_prescription_ai_pages as pages
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def application() -> QApplication:
|
||||||
|
return QApplication.instance() or QApplication([])
|
||||||
|
|
||||||
|
|
||||||
|
def comparison_rows(model: str) -> list[dict[str, Any]]:
|
||||||
|
doses = {"qwen": {"生地黄": 15, "生麦冬": 12, "麸炒白术": 12}, "openai": {"生地黄": 12, "茯苓": 8}}[model]
|
||||||
|
contributions = {"qwen": {"生地黄": 0.94, "生麦冬": 1.0}, "openai": {"生地黄": 0.75}}[model]
|
||||||
|
doctor = {"生地黄": 16, "生麦冬": 12, "红参片": 6, "茯苓": 10}
|
||||||
|
rows = []
|
||||||
|
for name in sorted(set(doses) | set(doctor)):
|
||||||
|
rows.append({
|
||||||
|
"name": name, "unit": "g",
|
||||||
|
"doctor_dosage": doctor.get(name),
|
||||||
|
"candidate_dosage": doses.get(name),
|
||||||
|
"contribution": contributions.get(name),
|
||||||
|
})
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def batch(**overrides: Any) -> dict[str, Any]:
|
||||||
|
value = {
|
||||||
|
"id": 13, "status": "success", "coverage_status": "partial", "comparison_type": "latest_context",
|
||||||
|
"source_summary": {"attachment_count": 4, "diagnoses_count": 1, "video_calls_count": 3,
|
||||||
|
"source_record_count": 9},
|
||||||
|
"missing": [{"code": "TRANSCRIPT_NOT_VERIFIED_COMPLETE", "critical": True},
|
||||||
|
{"code": "TRANSCRIPT_NOT_VERIFIED_COMPLETE", "critical": True},
|
||||||
|
{"code": "ARCHIVE_SYNC_WATERMARK_UNAVAILABLE", "critical": False}],
|
||||||
|
"models": {
|
||||||
|
"qwen": {"status": "success", "algorithm_version": "prescription-soft-dice-v1.1.0",
|
||||||
|
"prompt_version": "manual-prescription-required-candidate-v4",
|
||||||
|
"comparison": {"status": "comparable", "rows": comparison_rows("qwen")},
|
||||||
|
"coverage": {"files": [{"status": "processed"}, {"status": "processed"},
|
||||||
|
{"status": "processed"}, {"status": "restricted"}]},
|
||||||
|
"progress": {"stage_label": "处理完成", "elapsed_seconds": 135, "attempt": 1},
|
||||||
|
"usage": {"total_calls": 2, "calls": [
|
||||||
|
{"stage": "text:0", "ok": True, "latency_ms": 3400, "file_count": 0,
|
||||||
|
"usage": {"completion_tokens": 1020}, "error_code": ""},
|
||||||
|
{"stage": "final", "ok": False, "latency_ms": 14400, "file_count": 0,
|
||||||
|
"usage": {"completion_tokens": 5617}, "error_code": "INVALID_REPORT_OUTPUT"}]}},
|
||||||
|
"openai": {"status": "success", "comparison": {"status": "comparable", "rows": comparison_rows("openai")},
|
||||||
|
"progress": {"stage_label": "处理完成", "elapsed_seconds": 593, "attempt": 1},
|
||||||
|
"usage": {"calls": []}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
value.update(overrides)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- candidates page
|
||||||
|
@pytest.fixture
|
||||||
|
def per_herb(application: QApplication) -> pages.CandidatesPage:
|
||||||
|
widget = pages.CandidatesPage()
|
||||||
|
widget.resize(1200, 700)
|
||||||
|
widget.show()
|
||||||
|
application.processEvents()
|
||||||
|
yield widget
|
||||||
|
widget.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _dose_cells(page: pages.CandidatesPage, row: int) -> tuple[str, str]:
|
||||||
|
return (page.table.cellWidget(row, 1).dose, page.table.cellWidget(row, 2).dose)
|
||||||
|
|
||||||
|
|
||||||
|
def _verdict(page: pages.CandidatesPage, row: int) -> str:
|
||||||
|
return page.table.cellWidget(row, 4).findChildren(QLabel)[0].text()
|
||||||
|
|
||||||
|
|
||||||
|
def test_per_herb_merges_both_models_into_one_row(per_herb: pages.CandidatesPage) -> None:
|
||||||
|
per_herb.set_batch(batch())
|
||||||
|
names = [per_herb.table.item(row, 0).text() for row in range(per_herb.table.rowCount())]
|
||||||
|
assert names.count("生地黄") == 1
|
||||||
|
row = names.index("生地黄")
|
||||||
|
assert _dose_cells(per_herb, row) == ("15 g", "12 g")
|
||||||
|
assert per_herb.table.item(row, 3).text() == "16 g"
|
||||||
|
assert per_herb.table.cellWidget(row, 1).contribution == 0.94
|
||||||
|
assert per_herb.table.cellWidget(row, 2).contribution == 0.75
|
||||||
|
|
||||||
|
|
||||||
|
def test_per_herb_marks_absence_without_inventing_a_dose(per_herb: pages.CandidatesPage) -> None:
|
||||||
|
per_herb.set_batch(batch())
|
||||||
|
names = [per_herb.table.item(row, 0).text() for row in range(per_herb.table.rowCount())]
|
||||||
|
only_doctor = names.index("红参片")
|
||||||
|
assert _dose_cells(per_herb, only_doctor) == ("—", "—")
|
||||||
|
assert per_herb.table.cellWidget(only_doctor, 1).contribution is None
|
||||||
|
assert _verdict(per_herb, only_doctor) == "仅医方使用"
|
||||||
|
added = names.index("麸炒白术")
|
||||||
|
assert per_herb.table.item(added, 3).text() == "未收录"
|
||||||
|
assert _verdict(per_herb, added) == "仅 千问 收录"
|
||||||
|
|
||||||
|
|
||||||
|
def test_per_herb_conclusion_states_a_dose_gap_over_the_threshold(per_herb: pages.CandidatesPage) -> None:
|
||||||
|
per_herb.set_batch(batch())
|
||||||
|
names = [per_herb.table.item(row, 0).text() for row in range(per_herb.table.rowCount())]
|
||||||
|
row = names.index("生地黄")
|
||||||
|
assert _verdict(per_herb, row) == "两模型均收录" # 医方 16,两侧差 1 与 4,未过 5 克阈值
|
||||||
|
source = batch()
|
||||||
|
for model in source["models"].values():
|
||||||
|
for entry in model["comparison"]["rows"]:
|
||||||
|
if entry["name"] == "生地黄":
|
||||||
|
entry["candidate_dosage"] = 9
|
||||||
|
per_herb.set_batch(source)
|
||||||
|
names = [per_herb.table.item(index, 0).text() for index in range(per_herb.table.rowCount())]
|
||||||
|
assert _verdict(per_herb, names.index("生地黄")) == "剂量分歧 7 g"
|
||||||
|
|
||||||
|
|
||||||
|
def test_per_herb_filters_count_and_narrow_the_table(
|
||||||
|
per_herb: pages.CandidatesPage, application: QApplication) -> None:
|
||||||
|
per_herb.set_batch(batch())
|
||||||
|
total = per_herb.table.rowCount()
|
||||||
|
assert per_herb.filter_buttons["all"].text() == f"全部 {total}"
|
||||||
|
per_herb.search.setText("生地黄")
|
||||||
|
application.processEvents()
|
||||||
|
assert per_herb.table.rowCount() == 1
|
||||||
|
per_herb.search.clear()
|
||||||
|
per_herb.filter_buttons["doctor"].click()
|
||||||
|
application.processEvents()
|
||||||
|
assert 0 < per_herb.table.rowCount() < total
|
||||||
|
assert all(_verdict(per_herb, row) in {"仅医方使用", "两模型均未收录"}
|
||||||
|
for row in range(per_herb.table.rowCount()))
|
||||||
|
per_herb.filter_buttons["single"].click()
|
||||||
|
application.processEvents()
|
||||||
|
assert all("仅 " in _verdict(per_herb, row) for row in range(per_herb.table.rowCount()))
|
||||||
|
|
||||||
|
|
||||||
|
def test_per_herb_cards_show_each_saved_prescription(per_herb: pages.CandidatesPage) -> None:
|
||||||
|
per_herb.set_batch(batch(doctor_snapshot={"prescription": {
|
||||||
|
"herbs": [{"name": "生地黄", "dosage": 16, "unit": "g"}, {"name": "红参片", "dosage": 6, "unit": "g"}],
|
||||||
|
"prescription_type": "浓缩水丸", "dose_count": 1, "usage_instruction": "每日1剂,水煎分服。"}}))
|
||||||
|
doctor = per_herb.cards["doctor"]
|
||||||
|
assert doctor.summary.text() == "2 味 · 浓缩水丸"
|
||||||
|
assert "生地黄" in doctor.herbs.text() and "16 g" in doctor.herbs.text()
|
||||||
|
assert "每日1剂" in doctor.usage.text()
|
||||||
|
assert per_herb.cards["qwen"].summary.text() == "尚无候选方"
|
||||||
|
|
||||||
|
|
||||||
|
def test_per_herb_card_grows_for_a_long_usage_note(per_herb: pages.CandidatesPage,
|
||||||
|
application: QApplication) -> None:
|
||||||
|
"""A card must not cap itself and cut the herb list or the 方义 in half."""
|
||||||
|
|
||||||
|
note = "方中生黄芪益气固表,生地黄、生麦冬滋阴清热。" * 8
|
||||||
|
per_herb.set_batch(batch(doctor_snapshot={"prescription": {
|
||||||
|
"herbs": [{"name": f"药{index}", "dosage": 15, "unit": "g"} for index in range(20)],
|
||||||
|
"prescription_type": "浓缩水丸", "dose_count": 1, "usage_instruction": note}}))
|
||||||
|
per_herb.resize(1100, 700)
|
||||||
|
application.processEvents()
|
||||||
|
card = per_herb.cards["doctor"]
|
||||||
|
assert "另有 15 味" in card.herbs.text()
|
||||||
|
assert card.usage.height() >= card.usage.heightForWidth(card.usage.width())
|
||||||
|
assert card.height() >= card.herbs.height() + card.usage.height()
|
||||||
|
|
||||||
|
|
||||||
|
def test_per_herb_says_when_no_prescription_was_saved(per_herb: pages.CandidatesPage) -> None:
|
||||||
|
per_herb.set_batch({})
|
||||||
|
assert per_herb.cards["doctor"].summary.text() == "未保存原方"
|
||||||
|
assert per_herb.cards["doctor"].herbs.text() == "尚未保存药味"
|
||||||
|
assert per_herb.table.rowCount() == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- sources page
|
||||||
|
@pytest.fixture
|
||||||
|
def sources(application: QApplication) -> pages.SourcesPage:
|
||||||
|
widget = pages.SourcesPage()
|
||||||
|
widget.resize(900, 600)
|
||||||
|
widget.show()
|
||||||
|
application.processEvents()
|
||||||
|
yield widget
|
||||||
|
widget.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_sources_groups_gaps_by_type_and_keeps_criticality(sources: pages.SourcesPage) -> None:
|
||||||
|
sources.set_batch(batch())
|
||||||
|
rows = {sources.gaps.item(row, 0).text(): (sources.gaps.item(row, 1).text(), sources.gaps.item(row, 2).text())
|
||||||
|
for row in range(sources.gaps.rowCount())}
|
||||||
|
transcript = next(key for key in rows if "转写" in key)
|
||||||
|
assert rows[transcript] == ("关键", "2")
|
||||||
|
archive = next(key for key in rows if "归档" in key)
|
||||||
|
assert rows[archive][0] == "一般"
|
||||||
|
|
||||||
|
|
||||||
|
def _composition(sources: pages.SourcesPage) -> dict[str, str]:
|
||||||
|
rows = {}
|
||||||
|
for index in range(sources.composition_layout.count()):
|
||||||
|
widget = sources.composition_layout.itemAt(index).widget()
|
||||||
|
labels = widget.findChildren(QLabel)
|
||||||
|
rows[labels[0].text()] = labels[-1].text()
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def test_sources_reports_attachment_reading_and_composition(sources: pages.SourcesPage) -> None:
|
||||||
|
sources.set_batch(batch())
|
||||||
|
assert "模型实际读取 3 个" in sources.attachment_note.text()
|
||||||
|
assert sources.waffle.accessibleDescription() == "附件 4 个:已读 3,受限或不支持 1"
|
||||||
|
assert _composition(sources)["问诊通话"] == "3"
|
||||||
|
assert "soft-dice" in sources.meta.text()
|
||||||
|
|
||||||
|
|
||||||
|
def test_sources_reports_what_each_model_managed_to_read(sources: pages.SourcesPage) -> None:
|
||||||
|
sources.set_batch(batch())
|
||||||
|
rows = {sources.per_model.item(row, 0).text(): (sources.per_model.item(row, 1).text(),
|
||||||
|
sources.per_model.item(row, 3).text())
|
||||||
|
for row in range(sources.per_model.rowCount())}
|
||||||
|
assert rows["千问"] == ("3", "75%")
|
||||||
|
assert "已读取 3" in sources.attachment_legend.text()
|
||||||
|
|
||||||
|
|
||||||
|
def test_sources_meta_states_the_cutoff_and_reads_codes_in_chinese(sources: pages.SourcesPage) -> None:
|
||||||
|
sources.set_batch(batch(cutoff_at="2026-09-10 15:29"))
|
||||||
|
text = sources.meta.text()
|
||||||
|
assert "资料截止:2026-09-10 15:29" in text
|
||||||
|
assert "覆盖状态:部分资料缺失" in text # partial 在覆盖语境里说的是资料,不是进度
|
||||||
|
assert "对照类型:最新资料对照" in text
|
||||||
|
sources.set_batch(batch())
|
||||||
|
assert "资料截止:—" in sources.meta.text()
|
||||||
|
|
||||||
|
|
||||||
|
def test_sources_stays_empty_without_a_batch(sources: pages.SourcesPage) -> None:
|
||||||
|
sources.set_batch({})
|
||||||
|
assert _composition(sources) == {}
|
||||||
|
assert sources.gaps.rowCount() == 0
|
||||||
|
assert sources.attachment_note.text() == "本次没有附件"
|
||||||
|
assert not sources.gap_note.isVisible()
|
||||||
|
|
||||||
|
|
||||||
|
def test_sources_puts_critical_gaps_first_and_counts_them(sources: pages.SourcesPage) -> None:
|
||||||
|
sources.set_batch(batch())
|
||||||
|
assert "关键" in sources.gaps.item(0, 1).text()
|
||||||
|
assert "3 项 · 关键 2" in sources.gap_title.findChildren(QLabel)[-1].text()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- progress page
|
||||||
|
@pytest.fixture
|
||||||
|
def progress(application: QApplication) -> pages.ProgressPage:
|
||||||
|
widget = pages.ProgressPage()
|
||||||
|
widget.resize(900, 600)
|
||||||
|
widget.show()
|
||||||
|
application.processEvents()
|
||||||
|
yield widget
|
||||||
|
widget.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_progress_lists_every_call_with_its_outcome(progress: pages.ProgressPage) -> None:
|
||||||
|
progress.set_batch(batch())
|
||||||
|
assert progress.calls.rowCount() == 2
|
||||||
|
assert progress.calls.item(0, 0).text() == "千问"
|
||||||
|
assert progress.calls.item(0, 1).text() == "文字资料 1" # 阶段键不直接露出
|
||||||
|
assert progress.calls.item(1, 1).text() == "生成候选与报告"
|
||||||
|
assert progress.calls.item(0, 3).text() == "3.4 s"
|
||||||
|
assert progress.calls.item(0, 6).text() == "通过"
|
||||||
|
assert progress.calls.item(1, 5).text() == "5617"
|
||||||
|
# 最慢的一次调用占满耗时分布条,其余按比例
|
||||||
|
assert progress.calls.cellWidget(1, 2)._fraction == 1.0
|
||||||
|
assert progress.calls.cellWidget(0, 2)._fraction < 0.3
|
||||||
|
assert "INVALID_REPORT_OUTPUT" not in progress.calls.item(1, 5).text()
|
||||||
|
|
||||||
|
|
||||||
|
def test_progress_shows_each_model_stage_and_attempt(progress: pages.ProgressPage) -> None:
|
||||||
|
progress.set_batch(batch())
|
||||||
|
assert "处理完成" in progress.stage_labels["qwen"].text()
|
||||||
|
assert "第 1 次尝试" in progress.stage_labels["qwen"].text()
|
||||||
|
assert "已用时 2 分 15 秒" in progress.stage_labels["qwen"].text()
|
||||||
|
assert "已用时 9 分 53 秒" in progress.stage_labels["openai"].text()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- history page
|
||||||
|
@pytest.fixture
|
||||||
|
def history(application: QApplication) -> pages.HistoryPage:
|
||||||
|
widget = pages.HistoryPage()
|
||||||
|
widget.resize(900, 600)
|
||||||
|
widget.show()
|
||||||
|
application.processEvents()
|
||||||
|
yield widget
|
||||||
|
widget.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_history_orders_by_time_and_keeps_uncomparable_slots_empty(history: pages.HistoryPage) -> None:
|
||||||
|
history.set_history([
|
||||||
|
{"id": 13, "created_at": 300, "status": "success", "comparison_type": "latest_context",
|
||||||
|
"models": {"qwen": {"score": 18.9, "comparison_status": "comparable",
|
||||||
|
"algorithm_version": "prescription-soft-dice-v1.1.0"},
|
||||||
|
"openai": {"score": 16.7, "comparison_status": "comparable"}}},
|
||||||
|
{"id": 11, "created_at": 100, "status": "failed",
|
||||||
|
"models": {"qwen": {"score": None, "comparison_status": "not_comparable"},
|
||||||
|
"openai": {"score": None, "comparison_status": "not_comparable"}}},
|
||||||
|
])
|
||||||
|
description = history.chart.accessibleDescription()
|
||||||
|
assert description.index("11") < description.index("13") # oldest first on the chart
|
||||||
|
assert "11 千问 — OpenAI —" in description
|
||||||
|
assert history.table.item(0, 0).text() == "#13" # newest first in the list
|
||||||
|
assert history.table.item(0, 3).text() == "18.9%"
|
||||||
|
assert history.table.item(1, 3).text() == "—"
|
||||||
|
assert history.table.item(0, 5).text() == "v1.1.0"
|
||||||
|
assert history.chart.has_data()
|
||||||
|
|
||||||
|
|
||||||
|
def test_history_reads_the_score_from_either_payload_shape(history: pages.HistoryPage) -> None:
|
||||||
|
"""列表接口把分数摊平,详情接口留在 comparison 里,两种都要认。"""
|
||||||
|
history.set_history([
|
||||||
|
{"id": 40, "created_at": "2026-09-10 15:29:00", "status": "success", "comparison_type": "non_independent",
|
||||||
|
"models": {"qwen": {"comparison": {"status": "comparable", "score": 94.44}},
|
||||||
|
"openai": {"comparison": {"status": "not_comparable", "score": 93.3}}}},
|
||||||
|
{"id": 39, "created_at": "2026-09-09 09:00:00", "status": "success",
|
||||||
|
"models": {"qwen": {"score": 42.9, "comparison_status": "comparable"}}},
|
||||||
|
])
|
||||||
|
assert history.table.item(0, 0).text() == "#40"
|
||||||
|
assert history.table.item(0, 3).text() == "94.4%"
|
||||||
|
assert history.table.item(0, 4).text() == "—" # 不可比就不给分,哪怕载荷里带着数字
|
||||||
|
assert "分层" in history.strata.text() or "同一套" in history.strata.text()
|
||||||
|
assert history.table.item(1, 3).text() == "42.9%"
|
||||||
|
description = history.chart.accessibleDescription()
|
||||||
|
assert description.index("39") < description.index("40")
|
||||||
|
|
||||||
|
|
||||||
|
def test_history_without_any_comparable_batch_draws_nothing(history: pages.HistoryPage) -> None:
|
||||||
|
history.set_history([{"id": 1, "created_at": 1, "models": {"qwen": {"comparison_status": "not_comparable"}}}])
|
||||||
|
assert not history.chart.has_data()
|
||||||
|
assert history.table.rowCount() == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- statistics panel
|
||||||
|
@pytest.fixture
|
||||||
|
def statistics(application: QApplication) -> pages.StatisticsPanel:
|
||||||
|
widget = pages.StatisticsPanel()
|
||||||
|
widget.resize(1000, 600)
|
||||||
|
widget.show()
|
||||||
|
application.processEvents()
|
||||||
|
yield widget
|
||||||
|
widget.close()
|
||||||
|
|
||||||
|
|
||||||
|
def statistics_payload() -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"total_count": 10, "patient_count": 8,
|
||||||
|
"doctors": [
|
||||||
|
{"doctor_id": 26, "doctor_name": "何医生", "total_count": 6, "patient_count": 5, "paired_count": 3,
|
||||||
|
"models": {"qwen": {"eligible_count": 4, "mean": 18.4, "median": 16.9,
|
||||||
|
"excluded_reasons": {"SOURCE_HISTORY_VERSIONS_UNAVAILABLE": 2}},
|
||||||
|
"openai": {"eligible_count": 3, "mean": 21.0, "median": 19.6,
|
||||||
|
"excluded_reasons": {"SOURCE_HISTORY_VERSIONS_UNAVAILABLE": 3}}},
|
||||||
|
"review": {"evaluated_count": 0, "qualified_count": 0, "qualified_rate": None}},
|
||||||
|
{"doctor_id": 31, "doctor_name": "李医生", "total_count": 4, "patient_count": 3, "paired_count": 1,
|
||||||
|
"models": {"qwen": {"eligible_count": 2, "mean": 25.0, "excluded_reasons": {"incomplete_coverage": 2}},
|
||||||
|
"openai": {"eligible_count": 0, "mean": None, "excluded_reasons": {"incomplete_coverage": 4}}},
|
||||||
|
"review": {"evaluated_count": 2, "qualified_count": 1, "qualified_rate": 50.0}},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_statistics_headline_counts_and_coverage(statistics: pages.StatisticsPanel) -> None:
|
||||||
|
statistics.set_statistics(statistics_payload())
|
||||||
|
assert statistics.kpi_values["events"].text() == "10"
|
||||||
|
assert "涉及患者 8 人" in statistics.kpi_notes["events"].text()
|
||||||
|
assert statistics.kpi_values["qwen"].text() == "6"
|
||||||
|
assert "覆盖率 60.0%" in statistics.kpi_notes["qwen"].text()
|
||||||
|
assert statistics.kpi_values["openai"].text() == "3"
|
||||||
|
|
||||||
|
|
||||||
|
def test_statistics_review_rate_needs_a_review_sample(statistics: pages.StatisticsPanel) -> None:
|
||||||
|
payload = statistics_payload()
|
||||||
|
for doctor in payload["doctors"]:
|
||||||
|
doctor["review"] = {"evaluated_count": 0, "qualified_count": 0, "qualified_rate": None}
|
||||||
|
statistics.set_statistics(payload)
|
||||||
|
assert statistics.kpi_values["review"].text() == "—"
|
||||||
|
assert "尚未建立复核样本" in statistics.kpi_notes["review"].text()
|
||||||
|
statistics.set_statistics(statistics_payload())
|
||||||
|
assert statistics.kpi_values["review"].text() == "50.0%"
|
||||||
|
|
||||||
|
|
||||||
|
def _bars(layout) -> list[str]:
|
||||||
|
return [layout.itemAt(index).widget().accessibleDescription() for index in range(layout.count())]
|
||||||
|
|
||||||
|
|
||||||
|
def test_statistics_funnel_and_exclusions_are_aggregated(statistics: pages.StatisticsPanel) -> None:
|
||||||
|
statistics.set_statistics(statistics_payload())
|
||||||
|
funnel = _bars(statistics.funnel_layout)
|
||||||
|
assert "范围内开方事件 10" in funnel
|
||||||
|
assert "千问 有效基线比较 6" in funnel
|
||||||
|
assert "两模型配对共同样本 4" in funnel
|
||||||
|
reasons = _bars(statistics.exclusion_layout)
|
||||||
|
assert any(text.endswith(" 5") for text in reasons) # 来源历史版本无法重建 2 + 3
|
||||||
|
assert all("SOURCE_HISTORY" not in text for text in reasons)
|
||||||
|
|
||||||
|
|
||||||
|
def test_statistics_distribution_sums_the_saved_bins(statistics: pages.StatisticsPanel) -> None:
|
||||||
|
payload = statistics_payload()
|
||||||
|
payload["doctors"][0]["models"]["qwen"]["distribution"] = {"[0,20)": 3, "[20,40)": 1}
|
||||||
|
payload["doctors"][1]["models"]["qwen"]["distribution"] = {"[0,20)": 2}
|
||||||
|
statistics.set_statistics(payload)
|
||||||
|
assert statistics.distribution.has_data()
|
||||||
|
assert "千问 5/1/0/0/0" in statistics.distribution.accessibleDescription()
|
||||||
|
assert statistics.distribution.isVisible()
|
||||||
|
|
||||||
|
|
||||||
|
def test_statistics_draws_no_distribution_without_samples(statistics: pages.StatisticsPanel) -> None:
|
||||||
|
statistics.set_statistics(statistics_payload())
|
||||||
|
assert not statistics.distribution.has_data()
|
||||||
|
assert statistics.chart_empty.isVisible()
|
||||||
|
|
||||||
|
|
||||||
|
def test_statistics_summary_row_pairs_mean_with_median(statistics: pages.StatisticsPanel) -> None:
|
||||||
|
statistics.set_statistics(statistics_payload())
|
||||||
|
assert statistics.summary_values["qwen"].text() == "21.7% / 16.9%"
|
||||||
|
assert statistics.summary_values["paired"].text() == "4 例"
|
||||||
|
|
||||||
|
|
||||||
|
def test_statistics_lists_each_doctor_without_ranking(statistics: pages.StatisticsPanel) -> None:
|
||||||
|
statistics.set_statistics(statistics_payload())
|
||||||
|
assert statistics.doctors.rowCount() == 2
|
||||||
|
assert statistics.doctors.item(0, 0).text() == "何医生"
|
||||||
|
assert statistics.doctors.item(0, 3).text() == "4 / 18.4%"
|
||||||
|
assert statistics.doctors.item(1, 4).text() == "0 / —"
|
||||||
|
assert statistics.doctors.item(1, 6).text() == "50.0%"
|
||||||
|
assert "不是医生准确率" in statistics.footnote.text()
|
||||||
|
|
||||||
|
|
||||||
|
def test_progress_counts_the_batch_in_the_stat_row(progress: pages.ProgressPage) -> None:
|
||||||
|
progress.set_batch(batch())
|
||||||
|
assert progress.stat_values["calls"].text() == "2 次"
|
||||||
|
assert progress.stat_values["failures"].text() == "1 次"
|
||||||
|
assert progress.stat_values["repairs"].text() == "0 次"
|
||||||
|
assert progress.stat_values["elapsed"].text() == "9 分 53 秒"
|
||||||
|
|
||||||
|
|
||||||
|
def test_progress_derives_its_stages_from_the_saved_calls(progress: pages.ProgressPage) -> None:
|
||||||
|
progress.set_batch(batch())
|
||||||
|
names = []
|
||||||
|
layout = progress.stage_lists["qwen"]
|
||||||
|
for index in range(layout.count()):
|
||||||
|
widget = layout.itemAt(index).widget()
|
||||||
|
names.append(widget.findChildren(QLabel)[1].text())
|
||||||
|
assert names == ["文字资料分析", "生成候选与报告"]
|
||||||
|
assert progress.stage_lists["openai"].count() == 0 # 没有调用记录就不编造阶段
|
||||||
|
|
||||||
|
|
||||||
|
def test_history_names_the_version_change_between_two_batches(history: pages.HistoryPage) -> None:
|
||||||
|
history.set_history([
|
||||||
|
{"id": 5, "created_at": "2026-09-10 15:29:00", "status": "success",
|
||||||
|
"models": {"qwen": {"comparison": {"status": "comparable", "score": 15.3},
|
||||||
|
"algorithm_version": "prescription-soft-dice-v1.1.0",
|
||||||
|
"prompt_version": "v4"}}},
|
||||||
|
{"id": 4, "created_at": "2026-09-10 15:18:00", "status": "success",
|
||||||
|
"models": {"qwen": {"comparison": {"status": "comparable", "score": 18.9},
|
||||||
|
"algorithm_version": "prescription-soft-dice-v1.0.1",
|
||||||
|
"prompt_version": "v3"}}},
|
||||||
|
])
|
||||||
|
text = history.strata.text()
|
||||||
|
assert "比较算法 v1.0.1 → v1.1.0" in text
|
||||||
|
assert "提示词 v3 → v4" in text
|
||||||
|
assert "不能直接相减" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_history_says_when_every_batch_shares_one_version(history: pages.HistoryPage) -> None:
|
||||||
|
history.set_history([
|
||||||
|
{"id": 2, "created_at": "2026-09-10 15:29:00",
|
||||||
|
"models": {"qwen": {"algorithm_version": "prescription-soft-dice-v1.1.0"}}},
|
||||||
|
{"id": 1, "created_at": "2026-09-10 14:29:00",
|
||||||
|
"models": {"qwen": {"algorithm_version": "prescription-soft-dice-v1.1.0"}}},
|
||||||
|
])
|
||||||
|
assert "全部批次使用同一套算法" in history.strata.text()
|
||||||
|
|
||||||
|
|
||||||
|
def test_history_shows_why_a_batch_has_no_score(history: pages.HistoryPage) -> None:
|
||||||
|
history.set_history([{"id": 2, "created_at": "2026-09-10 13:25:00", "status": "failed",
|
||||||
|
"models": {"qwen": {"error_message": "模型返回未通过校验"}}}])
|
||||||
|
assert "模型返回未通过校验" in history.table.item(0, 2).text()
|
||||||
|
assert history.table.item(0, 3).text() == "—"
|
||||||
@@ -0,0 +1,337 @@
|
|||||||
|
"""Data provenance and native UI checks for the prescription overview page."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from copy import deepcopy
|
||||||
|
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PySide6.QtCore import Qt
|
||||||
|
from PySide6.QtWidgets import QApplication, QLabel, QVBoxLayout, QWidget
|
||||||
|
|
||||||
|
from doctor_workstation.ui.dialogs.issued_prescription_ai_workspace import (
|
||||||
|
PrescriptionReviewWorkspace,
|
||||||
|
checklist_items,
|
||||||
|
diagnosis_text,
|
||||||
|
dose_deltas,
|
||||||
|
gap_counts,
|
||||||
|
review_rows,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def batch(count: int = 3) -> dict:
|
||||||
|
rows = []
|
||||||
|
herbs = []
|
||||||
|
for index in range(count):
|
||||||
|
herb = {"name": f"药材{index}", "dosage": "15.00", "unit": "g", "dose_basis": "per_dose", "formula_type": "主方", "processing": "生品"}
|
||||||
|
rows.append({"key": f"saved-identity-{index}", "name": herb["name"], "doctor": {**herb, "dosage": "30.00"}, "candidate": {**herb, "source_rows": [index]}, "match_type": "matched"})
|
||||||
|
herbs.append(herb)
|
||||||
|
model = {"status": "succeeded", "report": {"diagnosis": "已保存的辨证意见", "summary": "不能当作诊断的摘要", "missing_information": ["缺少舌脉记录"]}, "candidate": {"status": "available_for_review", "herbs": herbs}, "comparison": {"status": "comparable", "rows": rows}}
|
||||||
|
return {"id": 4, "validity": "current", "models": {"qwen": deepcopy(model), "openai": deepcopy(model)}, "doctor_snapshot": {"patient": {"name": "测试患者", "gender": "male", "age": 50}, "diagnosis": {"western_diagnosis": "已记录西医诊断", "tcm_diagnosis": "已记录中医诊断", "syndrome": "已记录证候"}, "prescription": {"herbs": deepcopy(herbs), "usage_instruction": "水煎服", "usage_days": 7, "times_per_day": 2}}}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def application():
|
||||||
|
return QApplication.instance() or QApplication([])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def workspace(application):
|
||||||
|
host = QWidget()
|
||||||
|
host.resize(1024, 760)
|
||||||
|
layout = QVBoxLayout(host)
|
||||||
|
layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
widget = PrescriptionReviewWorkspace(host)
|
||||||
|
layout.addWidget(widget)
|
||||||
|
host.show()
|
||||||
|
application.processEvents()
|
||||||
|
yield widget
|
||||||
|
host.close()
|
||||||
|
host.deleteLater()
|
||||||
|
application.processEvents()
|
||||||
|
|
||||||
|
|
||||||
|
def test_three_series_need_saved_unique_identity_and_same_baseline():
|
||||||
|
source = batch()
|
||||||
|
before = deepcopy(source)
|
||||||
|
rows, _ = review_rows(source)
|
||||||
|
assert source == before
|
||||||
|
assert len(rows) == 3
|
||||||
|
assert all(set(row.doses) == {"doctor", "qwen", "openai"} for row in rows)
|
||||||
|
assert rows[0].scale == ("克", "每剂")
|
||||||
|
assert rows[0].changed
|
||||||
|
assert "15.00 克 / 每剂" in rows[0].description
|
||||||
|
assert "候选原方记录:第 1 项 药材0" in rows[0].description
|
||||||
|
assert "saved-identity" not in rows[0].description
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("mutation", ["no_keys", "different_baseline", "duplicate_keys", "different_units", "different_identity"])
|
||||||
|
def test_no_guessed_cross_model_join(mutation):
|
||||||
|
source = batch(1)
|
||||||
|
target = source["models"]["openai"]["comparison"]["rows"][0]
|
||||||
|
if mutation == "no_keys":
|
||||||
|
for model in source["models"].values():
|
||||||
|
model["comparison"]["rows"][0].pop("key")
|
||||||
|
elif mutation == "different_baseline":
|
||||||
|
target["doctor"]["dosage"] = 31
|
||||||
|
elif mutation == "duplicate_keys":
|
||||||
|
source["models"]["openai"]["comparison"]["rows"].append(deepcopy(target))
|
||||||
|
elif mutation == "different_units":
|
||||||
|
target["candidate"]["unit"] = "mg"
|
||||||
|
else:
|
||||||
|
target["candidate"]["processing"] = "炙品"
|
||||||
|
rows, _ = review_rows(source)
|
||||||
|
assert len(rows) >= 2
|
||||||
|
assert all(len(row.entries) == 1 for row in rows)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("value", ["nan", "-3", "1/2", "Infinity", None])
|
||||||
|
def test_invalid_numbers_and_missing_are_never_zero(value):
|
||||||
|
source = batch(1)
|
||||||
|
for model in source["models"].values():
|
||||||
|
model["comparison"]["rows"][0]["candidate"]["dosage"] = value
|
||||||
|
rows, _ = review_rows(source)
|
||||||
|
assert rows[0].scale is None
|
||||||
|
assert not rows[0].changed
|
||||||
|
if value is None:
|
||||||
|
assert rows[0].entries["qwen"].candidate.label == "—"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("field,value", [("validity", "stale"), ("validity", "source_updated"), ("validity", None), ("status", "running"), ("status", "failed"), ("comparison", "not_comparable"), ("candidate", "withheld_for_risk")])
|
||||||
|
def test_pending_historical_and_error_are_text_only(field, value):
|
||||||
|
source = batch()
|
||||||
|
if field == "validity":
|
||||||
|
source[field] = value
|
||||||
|
else:
|
||||||
|
for model in source["models"].values():
|
||||||
|
if field == "status":
|
||||||
|
model[field] = value
|
||||||
|
else:
|
||||||
|
model[field]["status"] = value
|
||||||
|
rows, _ = review_rows(source)
|
||||||
|
assert rows
|
||||||
|
assert all(row.scale is None for row in rows)
|
||||||
|
assert all(not row.changed for row in rows)
|
||||||
|
|
||||||
|
|
||||||
|
def test_unaccounted_candidates_and_missing_trace_survive():
|
||||||
|
source = batch(1)
|
||||||
|
for model in source["models"].values():
|
||||||
|
model["candidate"]["herbs"].append({"name": "未规范炮制药", "dosage": "2.5", "unit": "g"})
|
||||||
|
model["comparison"]["rows"][0]["candidate"].pop("source_rows")
|
||||||
|
rows, _ = review_rows(source)
|
||||||
|
assert len(rows) == 5
|
||||||
|
originals = [row for row in rows if next(iter(row.entries.values())).origin != "comparison"]
|
||||||
|
assert len(originals) == 4
|
||||||
|
assert all(row.scale is None for row in originals)
|
||||||
|
assert sum(row.name == "未规范炮制药" for row in originals) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_diagnosis_never_fabricated_from_summary():
|
||||||
|
assert diagnosis_text({"report": {"summary": "摘要疾病"}}) == "诊断意见未保存"
|
||||||
|
assert diagnosis_text({"report": {"diagnosis": "辨证原文"}}) == "辨证原文"
|
||||||
|
assert diagnosis_text({"report": {"diagnosis": {"western_diagnosis": "诊断原值"}}}) == "西医诊断:诊断原值"
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_side_is_not_a_zero_dose_difference():
|
||||||
|
source = batch(1)
|
||||||
|
for model in source["models"].values():
|
||||||
|
model["comparison"]["rows"][0]["doctor"] = None
|
||||||
|
rows, _ = review_rows(source)
|
||||||
|
assert rows[0].doctor is None
|
||||||
|
assert not rows[0].changed
|
||||||
|
|
||||||
|
|
||||||
|
def test_historical_report_states_itself_instead_of_reporting_zero_differences(workspace):
|
||||||
|
source = batch()
|
||||||
|
source["validity"] = "stale"
|
||||||
|
workspace.set_batch(source)
|
||||||
|
assert "历史或失效报告" in workspace.summary_text
|
||||||
|
assert workspace.doses.rows == [] # 不可比就不画,不是画成 0
|
||||||
|
assert workspace.doses.empty.isVisible()
|
||||||
|
|
||||||
|
|
||||||
|
def test_每味药与原方的距离按共同标尺画出(workspace):
|
||||||
|
workspace.set_batch(batch())
|
||||||
|
assert [delta.name for delta in workspace.doses.rows] == ["药材0", "药材1", "药材2"]
|
||||||
|
assert all(delta.deltas["qwen"] == -15 for delta in workspace.doses.rows)
|
||||||
|
assert "3 项剂量差异" in workspace.summary_text
|
||||||
|
axis = workspace.doses.body.findChildren(QWidget)
|
||||||
|
described = [widget.accessibleDescription() for widget in axis if widget.accessibleName() == "剂量差异条"]
|
||||||
|
assert "药材0 千问 −15克 OpenAI −15克" in described
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_herb_one_model_never_listed_is_marked_absent_not_zero(workspace):
|
||||||
|
source = batch(1)
|
||||||
|
source["models"]["openai"]["comparison"]["rows"][0]["candidate"] = None
|
||||||
|
source["models"]["openai"]["candidate"]["herbs"] = []
|
||||||
|
workspace.set_batch(source)
|
||||||
|
delta = workspace.doses.rows[0]
|
||||||
|
assert delta.deltas["openai"] is None
|
||||||
|
assert delta.badge == "OpenAI 未收录"
|
||||||
|
assert delta.deltas["qwen"] == -15
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_unreadable_saved_dose_takes_the_whole_row_off_the_chart(workspace):
|
||||||
|
source = batch(1)
|
||||||
|
source["models"]["openai"]["comparison"]["rows"][0]["candidate"]["dosage"] = "nan"
|
||||||
|
workspace.set_batch(source)
|
||||||
|
assert workspace.doses.rows == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_incomparable_basis_keeps_the_row_off_the_chart(workspace):
|
||||||
|
source = batch(1)
|
||||||
|
for model in source["models"].values():
|
||||||
|
model["comparison"]["rows"][0]["doctor"]["dose_basis"] = "per_day"
|
||||||
|
workspace.set_batch(source)
|
||||||
|
assert workspace.doses.rows == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_pending_model_adds_no_row_and_no_difference(workspace):
|
||||||
|
source = batch(1)
|
||||||
|
source["models"]["openai"] = {"status": "running"}
|
||||||
|
workspace.set_batch(source)
|
||||||
|
assert len(workspace.doses.rows) == 1
|
||||||
|
assert workspace.doses.rows[0].deltas["openai"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_new_herbs_carry_their_full_dose_and_say_which_model_added_them(workspace):
|
||||||
|
source = batch(1)
|
||||||
|
for model in source["models"].values():
|
||||||
|
model["comparison"]["rows"][0]["doctor"] = None
|
||||||
|
workspace.set_batch(source)
|
||||||
|
delta = workspace.doses.rows[0]
|
||||||
|
assert delta.doctor is None
|
||||||
|
assert delta.deltas["qwen"] == 15
|
||||||
|
|
||||||
|
|
||||||
|
def test_gap_counts_split_the_saved_gaps_into_three_buckets() -> None:
|
||||||
|
source = batch(1)
|
||||||
|
source["missing"] = [{"code": "TRANSCRIPT_NOT_VERIFIED_COMPLETE", "critical": True},
|
||||||
|
{"code": "ATTACHMENT_STORAGE_RESTRICTED"},
|
||||||
|
{"code": "ARCHIVE_SYNC_WATERMARK_UNAVAILABLE"}]
|
||||||
|
assert gap_counts(source) == {"critical": 1, "attachment": 1, "other": 1}
|
||||||
|
assert gap_counts(batch(1)) == {"critical": 0, "attachment": 0, "other": 0}
|
||||||
|
|
||||||
|
|
||||||
|
def _conclusion_texts(workspace) -> list[str]:
|
||||||
|
"""The numbered sentences, skipping the hairlines the design puts between them."""
|
||||||
|
|
||||||
|
texts = []
|
||||||
|
for index in range(workspace.conclusions.body_layout.count()):
|
||||||
|
widget = workspace.conclusions.body_layout.itemAt(index).widget()
|
||||||
|
labels = widget.findChildren(QLabel) if widget is not None else []
|
||||||
|
if labels:
|
||||||
|
texts.append(labels[-1].text())
|
||||||
|
return texts
|
||||||
|
|
||||||
|
|
||||||
|
def test_conclusions_state_the_counts_they_were_built_from(workspace) -> None:
|
||||||
|
workspace.set_batch(batch(3))
|
||||||
|
texts = _conclusion_texts(workspace)
|
||||||
|
assert any("两个模型都给出了候选方" in text for text in texts)
|
||||||
|
assert any("剂量偏差共" in text for text in texts)
|
||||||
|
assert any("没有记录资料缺口" in text for text in texts)
|
||||||
|
|
||||||
|
|
||||||
|
def test_conclusions_never_invent_a_score_comparison(workspace) -> None:
|
||||||
|
source = batch(1)
|
||||||
|
source["models"]["openai"]["comparison"]["status"] = "not_comparable"
|
||||||
|
workspace.set_batch(source)
|
||||||
|
texts = _conclusion_texts(workspace)
|
||||||
|
assert any("只有可比的一侧有分数" in text for text in texts)
|
||||||
|
|
||||||
|
|
||||||
|
def test_attribution_groups_every_herb_exactly_once(workspace) -> None:
|
||||||
|
source = batch(2)
|
||||||
|
source["models"]["openai"]["candidate"]["herbs"] = [{"name": "药材0", "dosage": "15.00", "unit": "g"}]
|
||||||
|
workspace.set_batch(source)
|
||||||
|
assert workspace.attribution.rows["all"]["count"].text() == "1 味"
|
||||||
|
assert workspace.attribution.rows["doctor_only"]["count"].text() == "0 味"
|
||||||
|
assert workspace.attribution.rows["openai_only"]["count"].text() == "0 味"
|
||||||
|
# 药材1 只有医方与千问共用,四个分组都不含它,标题必须说明这一点
|
||||||
|
assert "合计 2 味" in workspace.attribution.total_note.text()
|
||||||
|
assert "另 1 味为医方与单一模型共用" in workspace.attribution.total_note.text()
|
||||||
|
|
||||||
|
|
||||||
|
def test_risk_panel_flags_only_large_changes(workspace) -> None:
|
||||||
|
small = batch(1)
|
||||||
|
for model in small["models"].values():
|
||||||
|
model["comparison"]["rows"][0]["candidate"]["dosage"] = "29.00"
|
||||||
|
workspace.set_batch(small)
|
||||||
|
assert workspace.risks.empty.isVisible() # 差 1 克不进清单
|
||||||
|
large = batch(1)
|
||||||
|
for model in large["models"].values():
|
||||||
|
model["comparison"]["rows"][0]["candidate"]["dosage"] = "12.00"
|
||||||
|
workspace.set_batch(large)
|
||||||
|
assert workspace.risks.body.isVisible()
|
||||||
|
assert "2 项" in workspace.risks.hint.text()
|
||||||
|
|
||||||
|
|
||||||
|
def test_checklist_merges_the_models_and_keeps_the_severe_items_first():
|
||||||
|
source = batch(1)
|
||||||
|
source["models"]["qwen"]["report"]["risk_assessment"] = [{"level": "high", "label": "血压数据缺失"}]
|
||||||
|
source["missing"] = [{"code": "TRANSCRIPT_NOT_VERIFIED_COMPLETE", "critical": True}]
|
||||||
|
items = checklist_items(source)
|
||||||
|
assert items[0][0] == "血压数据缺失"
|
||||||
|
assert items[0][1] == "千问 关键"
|
||||||
|
shared = next(item for item in items if item[0] == "缺少舌脉记录")
|
||||||
|
assert shared[1] == "千问、OpenAI"
|
||||||
|
assert any("转写" in item[0] for item in items)
|
||||||
|
|
||||||
|
|
||||||
|
def test_checklist_shows_the_saved_items_and_says_so_when_empty(workspace):
|
||||||
|
workspace.set_batch(batch())
|
||||||
|
assert "1 条 · 按严重度排序" in workspace.checklist.count_note.text()
|
||||||
|
assert not workspace.checklist.empty.isVisible()
|
||||||
|
workspace.set_batch({"id": 9, "validity": "current", "models": {}})
|
||||||
|
assert workspace.checklist.empty.isVisible()
|
||||||
|
assert workspace.checklist.count_note.text() == "暂无待确认项"
|
||||||
|
|
||||||
|
|
||||||
|
def test_rerendering_leaves_no_stale_row_widgets(workspace, application):
|
||||||
|
workspace.set_batch(batch(20))
|
||||||
|
application.processEvents()
|
||||||
|
assert len(_axes(workspace)) == 20
|
||||||
|
workspace.set_batch(batch(3))
|
||||||
|
application.processEvents()
|
||||||
|
assert len(_axes(workspace)) == 3
|
||||||
|
|
||||||
|
|
||||||
|
def _axes(workspace):
|
||||||
|
return [widget for widget in workspace.doses.body.findChildren(QWidget)
|
||||||
|
if widget.accessibleName() == "剂量差异条" and widget.parentWidget() is not None]
|
||||||
|
|
||||||
|
|
||||||
|
def test_page_fits_a_1024_window_without_horizontal_scrolling(workspace, application):
|
||||||
|
workspace.set_batch(batch())
|
||||||
|
application.processEvents()
|
||||||
|
assert workspace.minimumSizeHint().width() <= 900
|
||||||
|
assert workspace.width() == 1024
|
||||||
|
assert workspace.doses.scroll.horizontalScrollBarPolicy() == Qt.ScrollBarPolicy.ScrollBarAlwaysOff
|
||||||
|
assert workspace.checklist.scroll.horizontalScrollBarPolicy() == Qt.ScrollBarPolicy.ScrollBarAlwaysOff
|
||||||
|
|
||||||
|
|
||||||
|
def test_dose_deltas_ignore_rows_without_a_shared_scale():
|
||||||
|
source = batch(1)
|
||||||
|
for model in source["models"].values():
|
||||||
|
model["comparison"]["rows"][0]["candidate"]["unit"] = "mg"
|
||||||
|
rows, _states = review_rows(source)
|
||||||
|
assert dose_deltas(rows) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_review_slot_has_no_visible_parentless_widget(workspace, application):
|
||||||
|
before = {widget for widget in application.topLevelWidgets() if widget.isVisible()}
|
||||||
|
first = QLabel("第一模型复核", workspace)
|
||||||
|
second = QLabel("双模型复核", workspace)
|
||||||
|
workspace.set_review_widget(first)
|
||||||
|
workspace.set_review_widget(second)
|
||||||
|
application.processEvents()
|
||||||
|
assert second.parentWidget() is workspace.checklist.review_slot
|
||||||
|
assert not first.isVisible()
|
||||||
|
assert first.parentWidget() is workspace.checklist.review_slot
|
||||||
|
assert {widget for widget in application.topLevelWidgets() if widget.isVisible()} == before
|
||||||
|
|
||||||
|
|
||||||
@@ -53,6 +53,7 @@ def test_windows_one_click_entrypoints_and_release_pipeline() -> None:
|
|||||||
release_launcher = read("packaging/windows/start_release.bat")
|
release_launcher = read("packaging/windows/start_release.bat")
|
||||||
|
|
||||||
assert run_script.index("$FrozenExecutable") < run_script.index("Find-Uv")
|
assert run_script.index("$FrozenExecutable") < run_script.index("Find-Uv")
|
||||||
|
assert run_script.index('Write-Host "Using current source tree:') < run_script.index('Write-Host "Using packaged application:')
|
||||||
assert "& $Uv sync --frozen" in run_script
|
assert "& $Uv sync --frozen" in run_script
|
||||||
assert '"UV_CACHE_DIR"' in run_script
|
assert '"UV_CACHE_DIR"' in run_script
|
||||||
assert "$ProjectUvCache" in run_script
|
assert "$ProjectUvCache" in run_script
|
||||||
|
|||||||
@@ -1335,6 +1335,34 @@ def test_patient_report_dialog_uses_one_scroll_owner_and_wrapped_risk_flow(
|
|||||||
application.processEvents()
|
application.processEvents()
|
||||||
|
|
||||||
|
|
||||||
|
def test_reception_report_displays_recorded_sources(application: QApplication) -> None:
|
||||||
|
commit = "270e94e637c4249dc411e54ac6715f21247de3cb"
|
||||||
|
payload = _snapshot("qwen", 2, "2026-10-08 15:00:00")
|
||||||
|
payload.update({
|
||||||
|
"source_diagnosis_ids": [501],
|
||||||
|
"source_summary": {"diagnosis_count": 1, "prescription_count": 2},
|
||||||
|
"knowledge_source": {
|
||||||
|
"url": f"https://github.com/coinweth/nihaixia-skill/blob/{commit}/SKILL.md",
|
||||||
|
"commit": commit,
|
||||||
|
"sections": ["六经速查表"],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
dialog = _ReceptionAiAnalysisDialog({"qwen": [payload]})
|
||||||
|
assert "#501" in dialog.source_label.text()
|
||||||
|
assert "历史处方 2 条" in dialog.source_label.text()
|
||||||
|
assert "六经速查表" in dialog.source_label.text()
|
||||||
|
assert "server/knowledge/nihaixia-skill/SKILL.md" in dialog.source_label.text()
|
||||||
|
assert "https://github.com" not in dialog.source_label.text()
|
||||||
|
|
||||||
|
page = ReceptionPage(object(), PermissionSet([]))
|
||||||
|
page._render_ai_analysis_payload(payload, "qwen")
|
||||||
|
assert "报告出处" in page.ai_source_label.text()
|
||||||
|
assert "#501" in page.ai_source_label.text()
|
||||||
|
assert "https://github.com" not in page.ai_source_label.text()
|
||||||
|
dialog.close()
|
||||||
|
page.close()
|
||||||
|
|
||||||
|
|
||||||
def test_reception_ai_card_is_compact_preview_without_nested_scroll(
|
def test_reception_ai_card_is_compact_preview_without_nested_scroll(
|
||||||
application: QApplication,
|
application: QApplication,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
@@ -308,6 +308,7 @@ def test_diagnosis_dialog_renders_saved_case_report(
|
|||||||
assert dialog.snapshot_caption.text() == "完整病历"
|
assert dialog.snapshot_caption.text() == "完整病历"
|
||||||
assert "肝郁脾虚证" in dialog.snapshot_body.text()
|
assert "肝郁脾虚证" in dialog.snapshot_body.text()
|
||||||
assert any("核心判断" in text for text in labels)
|
assert any("核心判断" in text for text in labels)
|
||||||
|
assert any("报告出处" in text and "关联诊单:#501" in text for text in labels)
|
||||||
assert dialog.can_refresh is True
|
assert dialog.can_refresh is True
|
||||||
assert not dialog.generate_button.isHidden()
|
assert not dialog.generate_button.isHidden()
|
||||||
dialog.close()
|
dialog.close()
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ 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 import prescriptions as prescriptions_module
|
||||||
from doctor_workstation.ui.pages.prescription_library import PrescriptionLibraryPage
|
from doctor_workstation.ui.pages.prescription_library import PrescriptionLibraryPage
|
||||||
from doctor_workstation.ui.pages.prescriptions import PrescriptionsPage
|
from doctor_workstation.ui.pages.prescriptions import PrescriptionsPage
|
||||||
from doctor_workstation.ui.widgets import BusinessPager
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="module")
|
@pytest.fixture(scope="module")
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from doctor_workstation.ui.report_provenance import report_provenance_lines
|
||||||
|
|
||||||
|
_COMMIT = "db0155dc7c42b9c6b3736896661f317c7110038f"
|
||||||
|
_URL = (
|
||||||
|
"https://github.com/xiaopangxia/TCM-Ancient-Books/blob/"
|
||||||
|
f"{_COMMIT}/123-%E6%B5%8B%E8%AF%95%E5%8F%A4%E7%B1%8D.txt#L42"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_recorded_ancient_book_reference_has_title_chapter_and_pinned_line() -> None:
|
||||||
|
lines = report_provenance_lines(
|
||||||
|
{
|
||||||
|
"prompt_version": "patient-longitudinal-report-nihaixia-ancient-v4",
|
||||||
|
"ancient_book_sources": [
|
||||||
|
{
|
||||||
|
"title": "测试古籍",
|
||||||
|
"chapter": "消渴篇",
|
||||||
|
"path": "123-测试古籍.txt",
|
||||||
|
"line": 42,
|
||||||
|
"excerpt": "消渴者,当辨其证。",
|
||||||
|
"url": _URL,
|
||||||
|
"commit": _COMMIT,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert any(
|
||||||
|
"《测试古籍》「消渴篇」" in line
|
||||||
|
and "server/knowledge/tcm-ancient-books/123-测试古籍.txt 第42行" in line
|
||||||
|
and "版本 db0155dc7c42" in line
|
||||||
|
and "摘录:消渴者,当辨其证。" in line
|
||||||
|
for line in lines
|
||||||
|
)
|
||||||
|
assert "https://github.com" not in "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def test_locally_bundled_sources_render_without_external_urls() -> None:
|
||||||
|
lines = report_provenance_lines({
|
||||||
|
"prompt_version": "patient-longitudinal-report-nihaixia-ancient-v4",
|
||||||
|
"knowledge_source": {
|
||||||
|
"commit": "270e94e637c4249dc411e54ac6715f21247de3cb",
|
||||||
|
"path": "SKILL.md",
|
||||||
|
"url": "",
|
||||||
|
},
|
||||||
|
"ancient_book_sources": [{
|
||||||
|
"title": "测试古籍",
|
||||||
|
"chapter": "消渴篇",
|
||||||
|
"path": "123-测试古籍.txt",
|
||||||
|
"line": 42,
|
||||||
|
"excerpt": "消渴者,当辨其证。",
|
||||||
|
"commit": _COMMIT,
|
||||||
|
"url": "",
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
assert any("server/knowledge/nihaixia-skill/SKILL.md" in line for line in lines)
|
||||||
|
assert any("server/knowledge/tcm-ancient-books/123-测试古籍.txt 第42行" in line for line in lines)
|
||||||
|
assert "https://github.com" not in "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ancient_book_reference_rejects_conflicting_local_metadata() -> None:
|
||||||
|
lines = report_provenance_lines({
|
||||||
|
"ancient_book_sources": [
|
||||||
|
{"title": "测试古籍", "path": "别的书.txt", "line": 42, "url": _URL, "commit": _COMMIT},
|
||||||
|
{"title": "测试古籍", "path": "123-测试古籍.txt", "line": 43, "url": _URL, "commit": _COMMIT},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
assert all("古籍参考:《" not in line for line in lines)
|
||||||
|
|
||||||
|
|
||||||
|
def test_old_or_unverified_report_does_not_gain_ancient_citation() -> None:
|
||||||
|
lines = report_provenance_lines(
|
||||||
|
{
|
||||||
|
"prompt_version": "patient-longitudinal-report-nihaixia-v3",
|
||||||
|
"ancient_book_sources": [
|
||||||
|
{"title": "伪造", "url": _URL.replace(_COMMIT, "0" * 40), "commit": _COMMIT}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert all("古籍参考:《" not in line for line in lines)
|
||||||
@@ -8,7 +8,13 @@ from typing import Any
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from doctor_workstation.core.errors import ApiBusinessError, ApiHttpError, ApiProtocolError
|
from doctor_workstation.core.errors import (
|
||||||
|
ApiBusinessError,
|
||||||
|
ApiHttpError,
|
||||||
|
ApiProtocolError,
|
||||||
|
ApiTimeoutError,
|
||||||
|
ApiTransportError,
|
||||||
|
)
|
||||||
from doctor_workstation.core.models import Appointment, Consultation, PageResult, Prescription
|
from doctor_workstation.core.models import Appointment, Consultation, PageResult, Prescription
|
||||||
from doctor_workstation.services.mock_repository import DemoDoctorRepository
|
from doctor_workstation.services.mock_repository import DemoDoctorRepository
|
||||||
from doctor_workstation.services.repository import (
|
from doctor_workstation.services.repository import (
|
||||||
@@ -774,10 +780,11 @@ def test_remote_diagnosis_ai_stream_normalises_chunks_in_order() -> None:
|
|||||||
assert client.post_calls == []
|
assert client.post_calls == []
|
||||||
|
|
||||||
|
|
||||||
def test_remote_diagnosis_ai_stream_falls_back_once_but_not_for_error_event() -> None:
|
@pytest.mark.parametrize("status_code", [404, 405])
|
||||||
|
def test_remote_diagnosis_ai_stream_falls_back_for_missing_route(status_code: int) -> None:
|
||||||
class MissingStreamClient(RecordingClient):
|
class MissingStreamClient(RecordingClient):
|
||||||
def post_event_stream(self, *args: Any, **kwargs: Any):
|
def post_event_stream(self, *args: Any, **kwargs: Any):
|
||||||
raise ApiHttpError("missing", status_code=404)
|
raise ApiHttpError("missing", status_code=status_code)
|
||||||
|
|
||||||
missing_client = MissingStreamClient()
|
missing_client = MissingStreamClient()
|
||||||
events = list(
|
events = list(
|
||||||
@@ -789,6 +796,57 @@ def test_remote_diagnosis_ai_stream_falls_back_once_but_not_for_error_event() ->
|
|||||||
"tcm.diagnosis/aiAssistant"
|
"tcm.diagnosis/aiAssistant"
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"stream_error",
|
||||||
|
[
|
||||||
|
ApiHttpError("server error", status_code=500),
|
||||||
|
ApiTimeoutError("timed out"),
|
||||||
|
ApiTransportError("disconnected"),
|
||||||
|
ApiProtocolError("invalid event stream"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_remote_diagnosis_ai_stream_failures_do_not_issue_full_request(
|
||||||
|
stream_error: Exception,
|
||||||
|
) -> None:
|
||||||
|
class FailedStreamClient(RecordingClient):
|
||||||
|
def post_event_stream(self, *args: Any, **kwargs: Any):
|
||||||
|
raise stream_error
|
||||||
|
|
||||||
|
client = FailedStreamClient()
|
||||||
|
with pytest.raises(type(stream_error)):
|
||||||
|
list(RemoteDoctorRepository(client).stream_diagnosis_ai(501, "请分析"))
|
||||||
|
assert client.post_calls == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("after_start", [False, True])
|
||||||
|
def test_remote_diagnosis_ai_stream_incomplete_response_does_not_fall_back(
|
||||||
|
after_start: bool,
|
||||||
|
) -> None:
|
||||||
|
class IncompleteStreamClient(RecordingClient):
|
||||||
|
def post_event_stream(self, *args: Any, **kwargs: Any):
|
||||||
|
if after_start:
|
||||||
|
yield {"event": "start", "data": {"model_key": "qwen"}}
|
||||||
|
|
||||||
|
client = IncompleteStreamClient()
|
||||||
|
with pytest.raises(ApiProtocolError, match="ended before done"):
|
||||||
|
list(RemoteDoctorRepository(client).stream_diagnosis_ai(501, "请分析"))
|
||||||
|
assert client.post_calls == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_remote_diagnosis_ai_stream_does_not_fall_back_after_start() -> None:
|
||||||
|
class FailedAfterStartClient(RecordingClient):
|
||||||
|
def post_event_stream(self, *args: Any, **kwargs: Any):
|
||||||
|
yield {"event": "start", "data": {"model_key": "qwen"}}
|
||||||
|
raise ApiHttpError("missing", status_code=404)
|
||||||
|
|
||||||
|
client = FailedAfterStartClient()
|
||||||
|
with pytest.raises(ApiHttpError):
|
||||||
|
list(RemoteDoctorRepository(client).stream_diagnosis_ai(501, "请分析"))
|
||||||
|
assert client.post_calls == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_remote_diagnosis_ai_stream_error_event_does_not_fall_back() -> None:
|
||||||
class ErrorStreamClient(RecordingClient):
|
class ErrorStreamClient(RecordingClient):
|
||||||
def post_event_stream(self, *args: Any, **kwargs: Any):
|
def post_event_stream(self, *args: Any, **kwargs: Any):
|
||||||
yield {"event": "error", "data": {"message": "模型繁忙"}}
|
yield {"event": "error", "data": {"message": "模型繁忙"}}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import pytest
|
|||||||
from PySide6.QtCore import Qt
|
from PySide6.QtCore import Qt
|
||||||
from PySide6.QtWidgets import QApplication, QLabel, QPushButton, QToolButton
|
from PySide6.QtWidgets import QApplication, QLabel, QPushButton, QToolButton
|
||||||
|
|
||||||
from doctor_workstation.core import PermissionSet
|
from doctor_workstation.core import PermissionSet, UserProfile
|
||||||
from doctor_workstation.core.appointment_modes import (
|
from doctor_workstation.core.appointment_modes import (
|
||||||
appointment_type_description,
|
appointment_type_description,
|
||||||
appointment_type_value,
|
appointment_type_value,
|
||||||
@@ -76,13 +76,18 @@ def test_appointment_text_chat_carries_exact_ids_and_blocks_video_qr(application
|
|||||||
page.deleteLater()
|
page.deleteLater()
|
||||||
|
|
||||||
|
|
||||||
def test_consultation_text_entry_does_not_wait_for_live_video(application, monkeypatch):
|
@pytest.mark.parametrize("current_user", [
|
||||||
|
None,
|
||||||
|
{"id": 66, "role_id": 1},
|
||||||
|
UserProfile.from_dict({"id": 66, "role_id": [1, 2]}),
|
||||||
|
])
|
||||||
|
def test_consultation_text_entry_does_not_wait_for_live_video(application, monkeypatch, current_user):
|
||||||
monkeypatch.setattr(consultations, "run_async", lambda *_a, **_kw: None)
|
monkeypatch.setattr(consultations, "run_async", lambda *_a, **_kw: None)
|
||||||
row = {"id": 501, "patient_id": 901, "patient_name": "图文患者", "has_appointment": 1,
|
row = {"id": 501, "patient_id": 901, "patient_name": "图文患者", "has_appointment": 1,
|
||||||
"appointment_status": 1, "latest_appointment_id": 71,
|
"appointment_status": 1, "latest_appointment_id": 71,
|
||||||
"appointments": [{"id": 71, "status": 1, "appointment_type": "text"}],
|
"appointments": [{"id": 71, "status": 1, "appointment_type": "text"}],
|
||||||
"video_call_hint": {"state": "none"}}
|
"video_call_hint": {"state": "none"}}
|
||||||
page = consultations.ConsultationsPage(DemoDoctorRepository(), PermissionSet(["*"]))
|
page = consultations.ConsultationsPage(DemoDoctorRepository(), PermissionSet(["*"]), current_user=current_user)
|
||||||
try:
|
try:
|
||||||
page.table_host.set_rows([row])
|
page.table_host.set_rows([row])
|
||||||
page.table.selectRow(0)
|
page.table.selectRow(0)
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
|
from PySide6.QtCore import Qt, QTimer
|
||||||
|
from PySide6.QtWidgets import QApplication, QDialog, QMainWindow
|
||||||
|
|
||||||
|
from doctor_workstation.core import PermissionSet
|
||||||
|
from doctor_workstation.services import DemoDoctorRepository
|
||||||
|
from doctor_workstation.ui.appointment_drawer import AppointmentDrawer
|
||||||
|
from doctor_workstation.ui.dialogs.ai_consult_picker import AiConsultTargetDialog
|
||||||
|
from doctor_workstation.ui.dialogs.diagnosis import DiagnosisDialog, OrderDetailDrawer
|
||||||
|
from doctor_workstation.ui.dialogs.prescription import PrescriptionEditorDialog
|
||||||
|
from doctor_workstation.ui.shell import ShellWindow
|
||||||
|
from doctor_workstation.ui.theme import _install_business_dialog_styling
|
||||||
|
|
||||||
|
|
||||||
|
def test_new_dialog_and_im_window_use_85_percent_of_main_window() -> None:
|
||||||
|
app = QApplication.instance() or QApplication([])
|
||||||
|
_install_business_dialog_styling(app)
|
||||||
|
main = QMainWindow()
|
||||||
|
main.setProperty("doctorMainWindow", True)
|
||||||
|
main.setGeometry(40, 60, 1200, 800)
|
||||||
|
main.show()
|
||||||
|
app.processEvents()
|
||||||
|
|
||||||
|
dialog = QDialog(main)
|
||||||
|
dialog.setMinimumSize(1100, 700)
|
||||||
|
im_window = QMainWindow()
|
||||||
|
im_window.resize(1120, 760)
|
||||||
|
try:
|
||||||
|
for window in (dialog, im_window):
|
||||||
|
window.show()
|
||||||
|
app.processEvents()
|
||||||
|
assert window.size().width() == round(main.frameGeometry().width() * 0.85)
|
||||||
|
assert window.size().height() == round(main.frameGeometry().height() * 0.85)
|
||||||
|
assert abs(window.frameGeometry().center().x() - main.frameGeometry().center().x()) <= 1
|
||||||
|
assert abs(window.frameGeometry().center().y() - main.frameGeometry().center().y()) <= 1
|
||||||
|
window.hide()
|
||||||
|
main.resize(1400, 900)
|
||||||
|
app.processEvents()
|
||||||
|
for window in (dialog, im_window):
|
||||||
|
window.show()
|
||||||
|
app.processEvents()
|
||||||
|
assert window.width() == round(main.frameGeometry().width() * 0.85)
|
||||||
|
assert window.height() == round(main.frameGeometry().height() * 0.85)
|
||||||
|
finally:
|
||||||
|
im_window.close()
|
||||||
|
dialog.close()
|
||||||
|
main.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_frameless_drawer_keeps_its_owner_layout() -> None:
|
||||||
|
app = QApplication.instance() or QApplication([])
|
||||||
|
_install_business_dialog_styling(app)
|
||||||
|
main = QMainWindow()
|
||||||
|
main.setProperty("doctorMainWindow", True)
|
||||||
|
main.resize(1200, 800)
|
||||||
|
main.show()
|
||||||
|
app.processEvents()
|
||||||
|
drawer = QDialog(main)
|
||||||
|
drawer.setWindowFlag(Qt.WindowType.FramelessWindowHint, True)
|
||||||
|
drawer.resize(500, 400)
|
||||||
|
try:
|
||||||
|
drawer.show()
|
||||||
|
app.processEvents()
|
||||||
|
assert drawer.size().width() == 500
|
||||||
|
assert drawer.size().height() == 400
|
||||||
|
finally:
|
||||||
|
drawer.close()
|
||||||
|
main.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_modal_exec_uses_main_window_geometry() -> None:
|
||||||
|
app = QApplication.instance() or QApplication([])
|
||||||
|
_install_business_dialog_styling(app)
|
||||||
|
main = QMainWindow()
|
||||||
|
main.setProperty("doctorMainWindow", True)
|
||||||
|
main.setGeometry(30, 50, 1200, 800)
|
||||||
|
main.show()
|
||||||
|
app.processEvents()
|
||||||
|
dialog = QDialog(main)
|
||||||
|
try:
|
||||||
|
QTimer.singleShot(0, dialog.accept)
|
||||||
|
dialog.exec()
|
||||||
|
assert dialog.width() == round(main.frameGeometry().width() * 0.85)
|
||||||
|
assert dialog.height() == round(main.frameGeometry().height() * 0.85)
|
||||||
|
finally:
|
||||||
|
dialog.close()
|
||||||
|
main.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_owner_synchronized_business_drawers_use_main_window_85_percent() -> None:
|
||||||
|
app = QApplication.instance() or QApplication([])
|
||||||
|
_install_business_dialog_styling(app)
|
||||||
|
main = QMainWindow()
|
||||||
|
main.setProperty("doctorMainWindow", True)
|
||||||
|
main.setGeometry(30, 50, 1400, 900)
|
||||||
|
main.show()
|
||||||
|
app.processEvents()
|
||||||
|
repository = DemoDoctorRepository()
|
||||||
|
dialogs = [
|
||||||
|
AppointmentDrawer({"id": 501, "diagnosis_id": 501}, repository, main, autoload=False),
|
||||||
|
DiagnosisDialog(repository, main),
|
||||||
|
OrderDetailDrawer(main),
|
||||||
|
PrescriptionEditorDialog(repository, parent=main),
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
for dialog in dialogs:
|
||||||
|
dialog.show()
|
||||||
|
app.processEvents()
|
||||||
|
app.processEvents()
|
||||||
|
assert dialog.width() == round(main.frameGeometry().width() * 0.85)
|
||||||
|
assert dialog.height() == round(main.frameGeometry().height() * 0.85)
|
||||||
|
dialog.hide()
|
||||||
|
finally:
|
||||||
|
for dialog in dialogs:
|
||||||
|
dialog.close()
|
||||||
|
main.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_maximized_workstation_opens_patient_picker_at_85_percent() -> None:
|
||||||
|
app = QApplication.instance() or QApplication([])
|
||||||
|
_install_business_dialog_styling(app)
|
||||||
|
repository = DemoDoctorRepository()
|
||||||
|
permissions = PermissionSet(["*"])
|
||||||
|
main = ShellWindow(
|
||||||
|
repository,
|
||||||
|
{"user": {"name": "测试医生"}, "demo_mode": True},
|
||||||
|
permissions,
|
||||||
|
)
|
||||||
|
main.showMaximized()
|
||||||
|
app.processEvents()
|
||||||
|
picker = AiConsultTargetDialog(repository, permissions, main)
|
||||||
|
try:
|
||||||
|
picker.show()
|
||||||
|
app.processEvents()
|
||||||
|
assert main.isMaximized()
|
||||||
|
assert picker.width() == round(main.frameGeometry().width() * 0.85)
|
||||||
|
assert picker.height() == round(main.frameGeometry().height() * 0.85)
|
||||||
|
finally:
|
||||||
|
picker.close()
|
||||||
|
main.close()
|
||||||
@@ -1,495 +0,0 @@
|
|||||||
vite v6.4.2 building for production...
|
|
||||||
|
|
||||||
WARN
|
|
||||||
(!) outDir D:\web\zyt\artifacts\appointment-modes\admin-build is not inside project root and will not be emptied.
|
|
||||||
Use --emptyOutDir to override.
|
|
||||||
|
|
||||||
|
|
||||||
transforming...
|
|
||||||
✓ 4061 modules transformed.
|
|
||||||
|
|
||||||
WARN node_modules/.pnpm/tcplayer.js@5.3.4-beta.32/node_modules/tcplayer.js/dist/tcplayer.v5.3.4.min.js (3199:26): Use of eval in "node_modules/.pnpm/tcplayer.js@5.3.4-beta.32/node_modules/tcplayer.js/dist/tcplayer.v5.3.4.min.js" is strongly discouraged as it poses security risks and may cause issues with minification.
|
|
||||||
|
|
||||||
rendering chunks...
|
|
||||||
computing gzip size...
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/default_avatar-C6VB7PGm.png 6.09 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/no_perms-jDxcYpYC.png 14.62 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/index.html 31.87 kB │ gzip: 16.16 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/login_bg-BkIjQ0FB.png 59.27 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/red3-NOuWP8DK.png 105.00 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/pink3-BxZ4Y6CS.png 108.36 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/blue3-D3K9OqGO.png 108.98 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/yellow3-C_qd9cqN.png 109.27 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/green3-CPorIQiC.png 109.99 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/purple3-BGd0LxTa.png 110.15 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/my_topbg-BiU0PleK.png 142.47 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/red2-Dw8p71sP.png 750.50 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/purple2-C0tQkldV.png 752.33 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/green2-C-VRKLSN.png 756.86 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/yellow2-B-WtqITJ.png 762.86 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/pink2-BpEp33zy.png 806.48 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/blue2-CRQPdLZd.png 806.81 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/red1-C6Y3UuNB.png 1,660.63 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/yellow1-Ebw0T5sw.png 1,662.23 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/pink1-BWNZrP7C.png 1,668.56 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/blue1-gLOo1H0w.png 1,676.54 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/purple1-BpMq9FWz.png 1,680.01 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/green1-h1zqes95.png 1,688.20 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/index-CmFE2aZQ.css 0.04 kB │ gzip: 0.06 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/index-CjSHFu-R.css 0.04 kB │ gzip: 0.06 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/index-Oppei429.css 0.04 kB │ gzip: 0.06 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/CaseRecordList-Cl-c3q7S.css 0.05 kB │ gzip: 0.07 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/DietRecordList-Cc-xUr2B.css 0.05 kB │ gzip: 0.07 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/roster-CqNlv_rj.css 0.05 kB │ gzip: 0.07 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/BloodRecordList-DhVK-Y_e.css 0.05 kB │ gzip: 0.07 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/index-B3imPWFk.css 0.05 kB │ gzip: 0.07 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/list-BluNBZln.css 0.05 kB │ gzip: 0.07 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/ExerciseRecordList-DGtgtzou.css 0.05 kB │ gzip: 0.07 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/edit-CObVTcPU.css 0.09 kB │ gzip: 0.10 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/edit-D-D8bVPM.css 0.09 kB │ gzip: 0.10 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/pc_details-nlJo1_D0.css 0.09 kB │ gzip: 0.10 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/AppointmentRecordPanel-DC22GDgn.css 0.09 kB │ gzip: 0.10 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/theme-picker-BsELUxM9.css 0.11 kB │ gzip: 0.09 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/content-Cl-9UIip.css 0.13 kB │ gzip: 0.13 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/AssignLogPanel-DkQMoEkX.css 0.13 kB │ gzip: 0.13 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/content-DXAsZ7EV.css 0.13 kB │ gzip: 0.13 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/content-DaxRy47P.css 0.14 kB │ gzip: 0.14 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/edit-CAJZpU7c.css 0.14 kB │ gzip: 0.12 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/content-DxNvUNZR.css 0.15 kB │ gzip: 0.15 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/useListTimeFilter-DI6SIumd.css 0.16 kB │ gzip: 0.14 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/edit-C1NtFi7N.css 0.16 kB │ gzip: 0.11 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/content-Bd3rHJ5J.css 0.18 kB │ gzip: 0.15 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/change-password-DRfsLJ26.css 0.19 kB │ gzip: 0.16 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/content-Dy_alH9u.css 0.19 kB │ gzip: 0.16 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/decoration-img-C5XvHl9_.css 0.19 kB │ gzip: 0.16 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/account_cost-fbCWUO9k.css 0.20 kB │ gzip: 0.16 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/index-57RdOFNo.css 0.23 kB │ gzip: 0.16 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/error-Cz3CexuM.css 0.24 kB │ gzip: 0.18 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/edit-D4atl_7z.css 0.25 kB │ gzip: 0.17 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/attr-lNortPsv.css 0.27 kB │ gzip: 0.19 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/setting-CxqqGetv.css 0.27 kB │ gzip: 0.17 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/index-DX96drV3.css 0.28 kB │ gzip: 0.17 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/menu-DYFusokT.css 0.29 kB │ gzip: 0.16 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/OrderActionHost-DiqybThz.css 0.31 kB │ gzip: 0.22 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/index-Bk2rMrqw.css 0.32 kB │ gzip: 0.20 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/index-D0f1Mn00.css 0.33 kB │ gzip: 0.22 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/prescription-drawer-BjwiqvPc.css 0.35 kB │ gzip: 0.16 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/bind-work-wechat-BX1gqwq1.css 0.35 kB │ gzip: 0.22 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/index-DhR2yeiO.css 0.39 kB │ gzip: 0.19 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/index-DoFDMZdP.css 0.40 kB │ gzip: 0.23 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/index-DBNvK3ZK.css 0.45 kB │ gzip: 0.21 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/PatientOrderList-D8-uh2iu.css 0.45 kB │ gzip: 0.25 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/DiagnosisTodoList-D0SBmTHG.css 0.46 kB │ gzip: 0.23 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/CallRecordPanel-TsjZtTO8.css 0.47 kB │ gzip: 0.23 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/index-C_LxAEkS.css 0.48 kB │ gzip: 0.27 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/login-Bt4SvQsz.css 0.52 kB │ gzip: 0.28 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/content-sMbqkta2.css 0.55 kB │ gzip: 0.29 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/RecordingPlaybackBlock-B3KYFgvg.css 0.58 kB │ gzip: 0.26 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/medicine-DO6x6zrS.css 0.58 kB │ gzip: 0.29 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/preview-pc-BRQDo0AR.css 0.67 kB │ gzip: 0.36 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/index-BzrSkGWL.css 0.67 kB │ gzip: 0.27 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/tabbar-DJsOahKR.css 0.69 kB │ gzip: 0.31 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/qywx-Dym8iFe0.css 0.74 kB │ gzip: 0.37 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/AssistantWatchCallDialog-BIPZRgaH.css 0.76 kB │ gzip: 0.38 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/picker-JBYDNsl5.css 0.84 kB │ gzip: 0.32 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/oa-phone-CctIyraX.css 0.94 kB │ gzip: 0.34 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/preview-C7oaKmYo.css 0.98 kB │ gzip: 0.41 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/index-Bidrbn2Z.css 1.16 kB │ gzip: 0.51 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/TrackingNoteTimeline-B9T8pFso.css 1.18 kB │ gzip: 0.47 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/RecordingVideoPlayer-4DegHAwm.css 1.19 kB │ gzip: 0.53 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/index-55HtuLpn.css 1.21 kB │ gzip: 0.55 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/PatientInfoCard-n6_UPo9e.css 1.32 kB │ gzip: 0.55 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/TrackingMatrix-zyxtXYPb.css 1.32 kB │ gzip: 0.44 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/SendPanel-DaGPWlQG.css 1.39 kB │ gzip: 0.50 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/picker-XZbUgFct.css 1.57 kB │ gzip: 0.50 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/PrescriptionOrderDetailDrawer-B4rggqa1.css 1.73 kB │ gzip: 0.71 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/ImChatRecordPanel-aX8zpWRQ.css 1.74 kB │ gzip: 0.59 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/index-BYOmK9R5.css 1.93 kB │ gzip: 0.58 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/index-OgnT0gUo.css 1.98 kB │ gzip: 0.56 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/MessageBubble-BwtdPM6K.css 2.04 kB │ gzip: 0.60 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/index-BmDW9vcs.css 2.44 kB │ gzip: 0.86 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/PatientCaseCard-C3Mob6yG.css 2.49 kB │ gzip: 0.81 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/tongji-C0VnzFxy.css 2.49 kB │ gzip: 0.77 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/dept-tongji-ky7rOYoi.css 2.67 kB │ gzip: 0.72 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/readonly-CdP3eZN0.css 2.69 kB │ gzip: 0.92 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/NoteTimeline-I2FSxX4s.css 2.74 kB │ gzip: 0.84 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/patient-call-k8V9hV2G.css 2.85 kB │ gzip: 0.91 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/DailyMatrix-DCZnxI8i.css 2.87 kB │ gzip: 0.85 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/PaibanPanel-aqm8azGc.css 3.35 kB │ gzip: 0.88 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/paiban-BTUHWpUu.css 3.35 kB │ gzip: 0.88 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/appointment-g8BrHfhi.css 3.43 kB │ gzip: 0.93 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/PrescriptionAiBatchGenerateDialog-CKkykmdr.css 3.45 kB │ gzip: 0.86 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/h5-BSFcFSny.css 3.52 kB │ gzip: 0.99 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/index-Bk7b_sL2.css 3.57 kB │ gzip: 0.96 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/add-ByXRFm8M.css 3.60 kB │ gzip: 0.87 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/PromotionAutomationForm-BMhNCJa-.css 3.64 kB │ gzip: 1.03 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/mubiao-B6IOi2bJ.css 3.78 kB │ gzip: 1.08 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/WelcomeMessageEditor-DD5mXWP2.css 4.41 kB │ gzip: 1.23 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/PrescriptionAiReportDialog-CV6AuHV3.css 4.46 kB │ gzip: 1.11 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/index-CMsGEvq_.css 5.19 kB │ gzip: 1.18 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/mubiao-dept-node-ChUPkaxq.css 5.47 kB │ gzip: 1.29 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/OrderPanel-BzElZPEq.css 5.67 kB │ gzip: 1.46 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/index-Co57tWbg.css 6.11 kB │ gzip: 1.70 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/list-D5BdlpZJ.css 6.51 kB │ gzip: 1.45 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/index-DtDaTXOB.css 7.01 kB │ gzip: 1.69 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/mubiao-dept-card-naMqyghz.css 7.09 kB │ gzip: 1.53 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/index-AnyUcdTT.css 7.21 kB │ gzip: 1.86 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/index-Bw7BJ7QR.css 8.03 kB │ gzip: 1.99 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/index-DONCMk_U.css 8.04 kB │ gzip: 2.32 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/progress-99NT9ysL.css 8.14 kB │ gzip: 1.91 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/index-Cmf8QBsF.css 8.75 kB │ gzip: 1.78 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/index-CSIcxqFk.css 8.85 kB │ gzip: 2.21 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/index-B_6Taw4k.css 8.97 kB │ gzip: 1.91 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/edit-DOd7Lhrw.css 9.47 kB │ gzip: 2.05 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/index-Dnn9ddtX.css 9.49 kB │ gzip: 2.20 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/index-D9Td5Y2a.css 9.52 kB │ gzip: 2.10 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/ProgressPanel-C_aSqDJh.css 10.10 kB │ gzip: 2.10 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/index-I_vRP8ff.css 10.57 kB │ gzip: 2.04 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/index-DV5JwFnL.css 11.95 kB │ gzip: 2.61 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/index-cz2HBbSE.css 13.65 kB │ gzip: 3.05 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/commission-settlement-Gy2UsUPr.css 15.55 kB │ gzip: 3.19 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/list_h5-Cs3mUmOa.css 17.70 kB │ gzip: 3.23 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/order_list-CMCIYVRT.css 18.15 kB │ gzip: 3.76 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/index-Dgjf9Hxv.css 18.86 kB │ gzip: 3.83 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/WecomFloatingWidgetBuilder-DRWt4rvh.css 19.20 kB │ gzip: 4.00 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/index_h5-6234yJkC.css 19.63 kB │ gzip: 3.53 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/yeji-7-gSVFmd.css 25.24 kB │ gzip: 4.60 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/index-BSycZlsS.css 45.91 kB │ gzip: 9.91 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/order_list_h5-DB3qJv9G.css 48.13 kB │ gzip: 7.68 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/.pnpm-B3v8nGpq.css 723.50 kB │ gzip: 100.49 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/getExposeType-BhVtb25-.js 0.07 kB │ gzip: 0.09 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/attr-qqeREw9s.js 0.13 kB │ gzip: 0.13 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/attr-DcOELR-K.js 0.13 kB │ gzip: 0.13 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/attr-tHefvLXa.js 0.13 kB │ gzip: 0.13 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/MediaSourceSelect-8A82adC4.js 0.14 kB │ gzip: 0.14 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/code-preview-DebWNacj.js 0.16 kB │ gzip: 0.15 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/edit-B5lAmlRD.js 0.18 kB │ gzip: 0.16 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/refund-log-Dte4TYSk.js 0.19 kB │ gzip: 0.17 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/account-adjust-B00EjgEk.js 0.19 kB │ gzip: 0.17 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/content-o13C0dUX.js 0.19 kB │ gzip: 0.17 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/content-Bw34X5yl.js 0.19 kB │ gzip: 0.17 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/content-fJdDXmQk.js 0.19 kB │ gzip: 0.17 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/content-ZSwm2OnL.js 0.19 kB │ gzip: 0.17 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/perm-CxGp7d9E.js 0.20 kB │ gzip: 0.18 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/diabetes-discovery-display-B_wmGXQJ.js 0.20 kB │ gzip: 0.17 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/PrescriptionOrderTimeDialog-DhGQ2qc5.js 0.20 kB │ gzip: 0.18 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/weapp-AW0lTawR.js 0.20 kB │ gzip: 0.15 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/GancaoSubmissionReconcileButton-BUP3IK_K.js 0.21 kB │ gzip: 0.18 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/content-n8Jap8KU.js 0.21 kB │ gzip: 0.19 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/edit-DobNH3vu.js 0.21 kB │ gzip: 0.17 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/edit-B6XQI8aF.js 0.21 kB │ gzip: 0.17 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/edit-DyTWIq8-.js 0.21 kB │ gzip: 0.17 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/edit-DcP285OM.js 0.21 kB │ gzip: 0.17 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/edit-CpN5NTKb.js 0.21 kB │ gzip: 0.17 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/edit-CscKSbQv.js 0.21 kB │ gzip: 0.17 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/attr.vue_vue_type_script_setup_true_lang-DG0CrN4I.js 0.22 kB │ gzip: 0.18 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/attr.vue_vue_type_script_setup_true_lang-awcvFaxy.js 0.22 kB │ gzip: 0.18 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/attr.vue_vue_type_script_setup_true_lang-DvEPH3sF.js 0.22 kB │ gzip: 0.18 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/oa-menu-form-DpRW2C_C.js 0.22 kB │ gzip: 0.18 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/useLockFn-DZaCbVGv.js 0.22 kB │ gzip: 0.19 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/appointment-type-DJT6wPaT.js 0.23 kB │ gzip: 0.17 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/edit-D9QQ1l2O.js 0.23 kB │ gzip: 0.19 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/auth-C5D7rPvf.js 0.24 kB │ gzip: 0.18 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/edit-CBO2zICf.js 0.25 kB │ gzip: 0.19 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/relations-add-D1VlWUUa.js 0.25 kB │ gzip: 0.19 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/mobile-style-DwsiSMiK.js 0.26 kB │ gzip: 0.19 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/edit-CHvGoI1A.js 0.27 kB │ gzip: 0.18 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/cost-edit-Bg-Uyd2c.js 0.30 kB │ gzip: 0.21 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/yeji-edit-C9uvAsWe.js 0.30 kB │ gzip: 0.21 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/404-3rkYd7tU.js 0.31 kB │ gzip: 0.29 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/data-table-SmfBi9Sg.js 0.31 kB │ gzip: 0.20 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/oa-menu-form-edit-C7hYj3ER.js 0.33 kB │ gzip: 0.21 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/medicine-Dd2rx3Ot.js 0.35 kB │ gzip: 0.18 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/consumer-B3GKjsdE.js 0.35 kB │ gzip: 0.19 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/user-Ca4bJD6d.js 0.37 kB │ gzip: 0.18 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/role-DTJVNPTP.js 0.39 kB │ gzip: 0.18 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/useDictOptions-Dn9M7zwp.js 0.42 kB │ gzip: 0.32 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/index-BzeCW0Uz.js 0.42 kB │ gzip: 0.32 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/admin-BOSvnXa7.js 0.42 kB │ gzip: 0.21 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/content-s8eO9hBN.js 0.44 kB │ gzip: 0.36 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/pay-CYh6c1Jc.js 0.45 kB │ gzip: 0.21 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/department-jIolW8HD.js 0.46 kB │ gzip: 0.19 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/menu-DxVtimm9.js 0.46 kB │ gzip: 0.19 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/content-COxSBtU5.js 0.47 kB │ gzip: 0.37 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/attr-C-UmNM7-.js 0.48 kB │ gzip: 0.25 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/post-BM66vrTr.js 0.49 kB │ gzip: 0.21 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/overflow-CvDxI8pV.js 0.49 kB │ gzip: 0.37 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/decoration-DcKIqlQW.js 0.50 kB │ gzip: 0.22 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/message-NjmKbrE0.js 0.50 kB │ gzip: 0.21 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/403-Bd7P9v6E.js 0.52 kB │ gzip: 0.44 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/footer.vue_vue_type_script_setup_true_lang-CwusgJ4Z.js 0.53 kB │ gzip: 0.38 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/attr-C-oRPuSk.js 0.54 kB │ gzip: 0.27 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/attr-CY72vprk.js 0.54 kB │ gzip: 0.27 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/add-nav-BdWkHCFM.js 0.54 kB │ gzip: 0.27 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/content-DnglhtO0.js 0.54 kB │ gzip: 0.27 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/menu-set-O2cSQVkP.js 0.54 kB │ gzip: 0.28 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/attr-D3WqzAia.js 0.55 kB │ gzip: 0.26 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/patient-vjBAK2HA.js 0.56 kB │ gzip: 0.22 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/fans-eQIJEPPO.js 0.60 kB │ gzip: 0.21 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/attr-DZ31aYPg.js 0.60 kB │ gzip: 0.28 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/attr-lqO8LPXd.js 0.61 kB │ gzip: 0.29 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/attr-LZa4samX.js 0.61 kB │ gzip: 0.29 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/diag-display-DCz_VAqj.js 0.61 kB │ gzip: 0.39 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/content.vue_vue_type_script_setup_true_lang-0Lb4ZZ78.js 0.61 kB │ gzip: 0.41 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/index-C9CTu6xY.js 0.63 kB │ gzip: 0.43 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/edit-CFowjcfc.js 0.63 kB │ gzip: 0.32 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/link-DGRRT5eg.js 0.64 kB │ gzip: 0.44 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/asset-D92fftiF.js 0.67 kB │ gzip: 0.22 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/content.vue_vue_type_script_setup_true_lang-Bt_MGGD6.js 0.69 kB │ gzip: 0.44 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/useMediaSourceOptions-B0L3IlgH.js 0.70 kB │ gzip: 0.42 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/content.vue_vue_type_script_setup_true_lang-4NVOwZND.js 0.70 kB │ gzip: 0.45 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/website-CBS8EOwn.js 0.74 kB │ gzip: 0.24 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/menu-C47G34h8.js 0.76 kB │ gzip: 0.49 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/usePaging-oaNM6A9T.js 0.77 kB │ gzip: 0.47 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/self_input_stats-BMoDYPed.js 0.79 kB │ gzip: 0.27 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/decoration-img-ChFP1uE8.js 0.81 kB │ gzip: 0.50 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/dict-DqKNj29A.js 0.81 kB │ gzip: 0.25 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/code-D879PDoD.js 0.82 kB │ gzip: 0.25 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/blood-thresholds-CJ1fqMjh.js 0.83 kB │ gzip: 0.34 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/content-NopGY6qR.js 0.84 kB │ gzip: 0.55 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/content.vue_vue_type_script_setup_true_lang-1ur9wwmZ.js 0.86 kB │ gzip: 0.55 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/index.vue_vue_type_script_setup_true_lang-kAZgL5aN.js 0.86 kB │ gzip: 0.52 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/index.vue_vue_type_script_setup_true_lang-CDLrQsa9.js 0.88 kB │ gzip: 0.51 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/error-Bn-Kyayl.js 0.89 kB │ gzip: 0.60 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/theme-picker-BiL-qD2M.js 0.92 kB │ gzip: 0.59 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/wecomOauthPostMessage-C_e6VGrO.js 0.95 kB │ gzip: 0.53 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/index.vue_vue_type_script_setup_true_lang-Cmgm_Z5P.js 0.98 kB │ gzip: 0.53 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/wx_oa-B5L52qTa.js 1.04 kB │ gzip: 0.29 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/article-hs8A9Jdu.js 1.07 kB │ gzip: 0.26 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/attr-setting.vue_vue_type_script_setup_true_lang-ClYX-hV1.js 1.08 kB │ gzip: 0.61 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/rich_text-Dk0jS3Pc.js 1.10 kB │ gzip: 0.58 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/cache-CWFtbfTm.js 1.13 kB │ gzip: 0.69 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/index-DCmzItce.js 1.21 kB │ gzip: 0.70 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/popover_input-pPM8UqwF.js 1.25 kB │ gzip: 0.59 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/finance-DA6_SyDw.js 1.30 kB │ gzip: 0.37 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/pc-DQbWYqv7.js 1.31 kB │ gzip: 0.78 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/doctor-DyZGuUZC.js 1.32 kB │ gzip: 0.37 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/oa-menu-form-edit.vue_vue_type_script_setup_true_lang-C21u_UoH.js 1.33 kB │ gzip: 0.78 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/content-BAht1aZ2.js 1.33 kB │ gzip: 0.71 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/content-CeY2TUv-.js 1.37 kB │ gzip: 0.70 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/content-BJGtt-xN.js 1.38 kB │ gzip: 0.79 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/file-BTnyaqZX.js 1.41 kB │ gzip: 0.64 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/upload-DHrBQCOO.js 1.41 kB │ gzip: 0.64 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/content-NSr7rO7a.js 1.42 kB │ gzip: 0.79 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/code-preview.vue_vue_type_script_setup_true_lang-C4bf87lU.js 1.44 kB │ gzip: 0.85 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/index-DLNXfvlb.js 1.44 kB │ gzip: 0.85 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/index.vue_vue_type_style_index_0_lang-mxcCSl8u.js 1.46 kB │ gzip: 0.79 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/oa-phone-DVZEALSU.js 1.47 kB │ gzip: 0.80 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/attr-BePIgx8X.js 1.50 kB │ gzip: 0.76 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/menu-BxpNhh7J.js 1.55 kB │ gzip: 0.88 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/MediaSourceSelect.vue_vue_type_script_setup_true_lang-xuHF5G6T.js 1.58 kB │ gzip: 0.80 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/PatientInfoCard-oWJA6Uib.js 1.60 kB │ gzip: 0.82 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/attr.vue_vue_type_script_setup_true_lang-BACvjW0Z.js 1.62 kB │ gzip: 0.71 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/style-DHri-b9V.js 1.62 kB │ gzip: 0.88 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/refund-log.vue_vue_type_script_setup_true_lang-C9XoZuna.js 1.66 kB │ gzip: 0.87 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/statistics-B3M41WAU.js 1.69 kB │ gzip: 1.02 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/attr.vue_vue_type_script_setup_true_lang-DNKk0KAC.js 1.71 kB │ gzip: 0.81 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/order-D2TrFdV8.js 1.74 kB │ gzip: 0.44 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/environment-C74pQttO.js 1.74 kB │ gzip: 0.74 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/setup-Dz_APrpo.js 1.77 kB │ gzip: 1.00 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/useMenuOa-DFdWMJUR.js 1.82 kB │ gzip: 0.85 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/attr-setting-D_7bJ_WP.js 1.88 kB │ gzip: 0.53 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/index-BzDASedl.js 1.90 kB │ gzip: 0.99 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/edit.vue_vue_type_script_setup_true_name_articleColumnEdit_lang-E0vpVDJX.js 1.94 kB │ gzip: 1.07 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/index-B9qvdXe9.js 2.03 kB │ gzip: 0.98 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/index-B3x_V6St.js 2.04 kB │ gzip: 1.14 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/TrackingNoteTimeline-B7ZehsXP.js 2.08 kB │ gzip: 1.17 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/index-DMVXI7dX.js 2.09 kB │ gzip: 1.17 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/attr.vue_vue_type_script_setup_true_lang-CFyAuY4i.js 2.09 kB │ gzip: 0.95 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/open_setting-521tiVc_.js 2.10 kB │ gzip: 1.12 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/index-0BnrgRc3.js 2.12 kB │ gzip: 1.23 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/account-adjust.vue_vue_type_script_setup_true_lang-CpPfqdLb.js 2.16 kB │ gzip: 1.12 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/filing-Cqmunz7R.js 2.17 kB │ gzip: 1.17 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/index-ClW9LZFF.js 2.18 kB │ gzip: 1.28 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/icon-qQvuPtJo.js 2.23 kB │ gzip: 0.82 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/content.vue_vue_type_script_setup_true_lang-Dur4SMQq.js 2.24 kB │ gzip: 1.16 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/edit.vue_vue_type_script_setup_true_lang-vpDrHDku.js 2.28 kB │ gzip: 1.16 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/edit.vue_vue_type_script_setup_true_lang-C366kQIF.js 2.29 kB │ gzip: 1.09 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/index.vue_vue_type_script_setup_true_lang-BBhGStE3.js 2.37 kB │ gzip: 1.15 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/RecordingPlaybackBlock-BOTDmjKh.js 2.40 kB │ gzip: 1.19 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/auth.vue_vue_type_script_setup_true_lang-CWND1gXs.js 2.40 kB │ gzip: 1.33 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/stats-BZP8tLtW.js 2.45 kB │ gzip: 0.58 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/data-table.vue_vue_type_script_setup_true_lang-DS49GzuV.js 2.51 kB │ gzip: 1.28 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/oa-attr-BzWXYYXP.js 2.51 kB │ gzip: 1.25 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/edit.vue_vue_type_script_setup_true_lang-Dhx2yo7b.js 2.52 kB │ gzip: 1.20 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/h5-p3Zf_YQP.js 2.56 kB │ gzip: 1.22 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/change-password-YIODZgIx.js 2.57 kB │ gzip: 1.35 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/im-business-message-parse-kN-fWnvR.js 2.60 kB │ gzip: 1.28 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/add-nav.vue_vue_type_script_setup_true_lang-DMG92gvp.js 2.61 kB │ gzip: 1.27 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/tabbar-C4RTjgc5.js 2.66 kB │ gzip: 1.33 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/file-CT3DBwWs.js 2.73 kB │ gzip: 1.06 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/edit.vue_vue_type_script_setup_true_lang-uPRl_lL4.js 2.74 kB │ gzip: 1.25 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/PrescriptionOrderTimeDialog.vue_vue_type_script_setup_true_lang-VQL80FaN.js 2.77 kB │ gzip: 1.53 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/oa-menu-form.vue_vue_type_script_setup_true_lang-BeAQ08_1.js 2.77 kB │ gzip: 1.08 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/CaseRecordList-LSZveKRb.js 2.80 kB │ gzip: 1.53 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/attr.vue_vue_type_script_setup_true_lang-CkZpjrdt.js 2.81 kB │ gzip: 1.06 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/index-C659Qo0h.js 2.85 kB │ gzip: 1.34 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/follow_reply-DVMErqzs.js 2.85 kB │ gzip: 1.49 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/index-D1k3lHzy.js 2.86 kB │ gzip: 1.44 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/cost-edit.vue_vue_type_script_setup_true_lang-CVc0n4pV.js 2.86 kB │ gzip: 1.35 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/attr.vue_vue_type_script_setup_true_lang-lmr2i5Bj.js 2.89 kB │ gzip: 1.41 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/default_reply-CpyjKv3V.js 2.91 kB │ gzip: 1.54 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/edit.vue_vue_type_script_setup_true_lang-gdH1LeQj.js 2.96 kB │ gzip: 1.14 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/protocol-D4wxifpO.js 2.98 kB │ gzip: 1.10 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/attr.vue_vue_type_script_setup_true_lang-PJQQwYnV.js 2.99 kB │ gzip: 1.45 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/mubiao-dept-node-Drt73fzr.js 3.03 kB │ gzip: 1.25 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/relations-add.vue_vue_type_script_setup_true_lang-C8d76FGV.js 3.04 kB │ gzip: 1.26 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/picker.vue_vue_type_script_setup_true_lang-BIYyttaB.js 3.05 kB │ gzip: 1.50 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/AssignLogPanel-D2r9RPoC.js 3.06 kB │ gzip: 1.48 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/keyword_reply-DBgqHDjZ.js 3.11 kB │ gzip: 1.60 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/edit.vue_vue_type_script_setup_true_lang-DweAr7Ym.js 3.12 kB │ gzip: 1.52 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/index-BUsM_LI5.js 3.21 kB │ gzip: 1.52 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/edit-CnIVKgZ-.js 3.25 kB │ gzip: 1.47 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/menu-set.vue_vue_type_script_setup_true_lang-BRrEuHV-.js 3.26 kB │ gzip: 1.40 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/pc_details-B_6wZRHo.js 3.39 kB │ gzip: 1.49 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/index-DqGrHNcO.js 3.39 kB │ gzip: 1.45 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/index-phcPclpA.js 3.42 kB │ gzip: 1.53 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/index-CUge-qSO.js 3.43 kB │ gzip: 1.64 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/GancaoSubmissionReconcileButton.vue_vue_type_script_setup_true_lang-BkwnyzI2.js 3.44 kB │ gzip: 1.70 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/balance_details-Ds1LzmaH.js 3.48 kB │ gzip: 1.63 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/index-DtdgFl7O.js 3.48 kB │ gzip: 1.64 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/index.vue_vue_type_script_setup_true_lang-cQgN4IT9.js 3.52 kB │ gzip: 1.56 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/journal-CY6ZpS6p.js 3.73 kB │ gzip: 1.46 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/edit-DKvnMaof.js 3.73 kB │ gzip: 1.57 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/detail-BV7O2HIL.js 3.76 kB │ gzip: 1.55 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/AssistantWatchCallDialog-BuTdyKef.js 3.78 kB │ gzip: 1.85 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/CallRecordPanel-BSTysfqz.js 3.78 kB │ gzip: 1.77 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/edit-bI1lUcbW.js 3.91 kB │ gzip: 1.71 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/preview-pc-BY7HFTA8.js 3.98 kB │ gzip: 1.64 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/AppointmentRecordPanel-BbOdj1-7.js 4.02 kB │ gzip: 1.73 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/edit.vue_vue_type_script_setup_true_lang-C8TwWqpc.js 4.11 kB │ gzip: 1.65 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/index-CxWRNLqZ.js 4.11 kB │ gzip: 1.79 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/first_visit-CvA69-XM.js 4.13 kB │ gzip: 0.89 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/detail-BRablUBf.js 4.15 kB │ gzip: 1.56 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/MessageBubble-DgY38P4R.js 4.17 kB │ gzip: 1.79 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/index-Bh5w94E3.js 4.22 kB │ gzip: 1.92 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/useListTimeFilter-COfv9A2s.js 4.26 kB │ gzip: 1.52 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/index-B2NSVhqr.js 4.37 kB │ gzip: 2.14 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/bind-work-wechat-DP4Gin1a.js 4.40 kB │ gzip: 2.27 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/recharge_record-BzIkkNm9.js 4.43 kB │ gzip: 1.87 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/index-B4J-ZHfW.js 4.45 kB │ gzip: 1.79 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/index-BwbCBYM9.js 4.47 kB │ gzip: 1.91 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/index-exKfRfkT.js 4.49 kB │ gzip: 1.96 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/index-Bs0vH486.js 4.49 kB │ gzip: 1.96 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/preview-Dh1zljh0.js 4.49 kB │ gzip: 1.70 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/edit.vue_vue_type_script_setup_true_lang-CvCnQtvN.js 4.51 kB │ gzip: 1.75 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/RecordingVideoPlayer-JNdaKBs5.js 4.53 kB │ gzip: 2.26 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/ImChatRecordPanel-6cheoyXp.js 4.54 kB │ gzip: 2.40 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/index-BLgjrOkJ.js 4.55 kB │ gzip: 1.91 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/paiban-idrEqd0C.js 4.56 kB │ gzip: 2.13 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/index-DAvdgv_l.js 4.57 kB │ gzip: 1.33 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/login_register-BcBecvF5.js 4.59 kB │ gzip: 2.01 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/dept-tongji-CwUw39uH.js 4.66 kB │ gzip: 2.04 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/index-C0B1ZpBc.js 4.76 kB │ gzip: 2.05 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/attr.vue_vue_type_script_setup_true_lang-Dozjlzsq.js 4.92 kB │ gzip: 2.04 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/edit-BBC7JIUD.js 5.04 kB │ gzip: 1.99 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/edit.vue_vue_type_script_setup_true_lang-CeStvPlb.js 5.18 kB │ gzip: 2.03 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/PaibanPanel-CwUhJWnn.js 5.28 kB │ gzip: 2.43 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/index-gh639-1m.js 5.32 kB │ gzip: 2.20 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/index-rDSQ6cWW.js 5.53 kB │ gzip: 2.20 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/mubiao-CfJ7Ipck.js 5.55 kB │ gzip: 2.56 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/yeji-edit.vue_vue_type_script_setup_true_lang-Dw9aZRHs.js 5.56 kB │ gzip: 1.81 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/mobile-style.vue_vue_type_script_setup_true_lang-BGsSkFG1.js 5.59 kB │ gzip: 1.86 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/ExerciseRecordList-oXdkNbAH.js 5.62 kB │ gzip: 2.15 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/attr-853P--U5.js 5.62 kB │ gzip: 2.24 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/tongji-BTDH_24t.js 5.66 kB │ gzip: 2.31 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/PatientOrderList-Cy0KYlPt.js 5.82 kB │ gzip: 2.84 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/DietRecordList-BeSIO_4H.js 5.84 kB │ gzip: 2.02 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/mubiao-dept-card-C1gPiZEq.js 5.91 kB │ gzip: 2.38 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/login-DJgbVJrh.js 6.03 kB │ gzip: 2.72 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/refund_record-MA_WT5dD.js 6.27 kB │ gzip: 2.33 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/DiagnosisTodoList-CIc3FdV9.js 6.34 kB │ gzip: 2.89 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/index-D4UoI3lC.js 6.35 kB │ gzip: 3.20 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/information-BZXrACCh.js 6.36 kB │ gzip: 1.81 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/NoteTimeline-B64KEfoD.js 6.48 kB │ gzip: 2.60 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/account_cost-DdPYY7oD.js 6.56 kB │ gzip: 2.86 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/PatientCaseCard-QLnHkNyv.js 6.61 kB │ gzip: 2.34 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/index-hHC-HY2w.js 6.61 kB │ gzip: 2.62 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/index-6aflqrhb.js 6.77 kB │ gzip: 2.89 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/readonly-Bc7S11g_.js 6.98 kB │ gzip: 2.69 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/picker-BA72xnJ0.js 7.32 kB │ gzip: 3.18 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/edit.vue_vue_type_script_setup_true_lang-CKaLV_7V.js 7.35 kB │ gzip: 2.66 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/setting-DK-S6SHN.js 7.35 kB │ gzip: 2.93 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/weapp-BaIevkiG.js 7.39 kB │ gzip: 2.13 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/prescription-order-utils-CXfs2Bra.js 7.62 kB │ gzip: 3.11 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/index-RSmge3tZ.js 7.65 kB │ gzip: 3.13 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/config-h4uBlWvN.js 7.77 kB │ gzip: 2.59 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/TrackingMatrix-Bq97RGBJ.js 7.84 kB │ gzip: 3.07 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/patient-call-CzyPR-gp.js 7.85 kB │ gzip: 3.24 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/dayjs-CF5xpNFg.js 8.07 kB │ gzip: 3.45 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/tcm-DVMUVofm.js 8.99 kB │ gzip: 1.57 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/edit-vUD1ND3r.js 9.00 kB │ gzip: 2.83 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/tim-upload-plugin-B7yxlBgj.js 9.47 kB │ gzip: 3.61 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/index-BsQ-IFkz.js 9.49 kB │ gzip: 3.84 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/BloodRecordList-lzEwKV5j.js 9.58 kB │ gzip: 3.33 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/medicine-D_iyqZcd.js 9.74 kB │ gzip: 3.55 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/edit-Chzff2sc.js 9.99 kB │ gzip: 3.55 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/index-CUd56fl0.js 10.06 kB │ gzip: 3.98 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/index-DfXvRPp3.js 10.33 kB │ gzip: 3.89 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/PrescriptionAiBatchGenerateDialog-DD3meqcJ.js 10.36 kB │ gzip: 4.29 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/SendPanel-DDd8ec_S.js 10.37 kB │ gzip: 3.57 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/progress-DuXbNWsm.js 11.18 kB │ gzip: 4.59 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/list-Cw7NQEcD.js 11.18 kB │ gzip: 4.03 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/index-DGvtXd9k.js 11.30 kB │ gzip: 4.22 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/appointment-Cm3Hfa5N.js 11.34 kB │ gzip: 4.24 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/edit-BDOteF3G.js 11.44 kB │ gzip: 3.76 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/OrderPanel-Blnuj7Qu.js 11.74 kB │ gzip: 4.22 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/picker-I1e_QZKi.js 12.15 kB │ gzip: 4.49 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/ProgressPanel-2qbsbc6G.js 12.58 kB │ gzip: 4.48 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/prescription-drawer-Dql8RbMo.js 12.76 kB │ gzip: 3.88 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/index-CcMovsjP.js 13.00 kB │ gzip: 4.23 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/index-B_Kh6a1u.js 13.11 kB │ gzip: 3.91 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/h5-BpUP9yLV.js 13.83 kB │ gzip: 4.40 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/index-CWnyJh4D.js 14.26 kB │ gzip: 5.90 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/guahao-DEn7doZO.js 15.06 kB │ gzip: 4.44 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/WecomFloatingWidgetBuilder-CUAXb84X.js 15.22 kB │ gzip: 6.02 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/index-BrOS-AAf.js 15.62 kB │ gzip: 6.44 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/index-BRhh-vvk.js 15.90 kB │ gzip: 5.20 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/edit-b7xryZ1R.js 15.90 kB │ gzip: 4.70 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/index-CMnX8dfi.js 16.34 kB │ gzip: 6.06 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/index-NRre3yrk.js 16.35 kB │ gzip: 5.11 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/index-CRhDpVNm.js 16.60 kB │ gzip: 6.07 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/PrescriptionAiReportDialog-DAmQQSVZ.js 17.15 kB │ gzip: 6.46 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/index-DQb8OBRa.js 17.84 kB │ gzip: 6.02 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/roster-BVq4roST.js 18.09 kB │ gzip: 5.71 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/index-DXdrxgPI.js 18.19 kB │ gzip: 6.88 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/PromotionAutomationForm-kf-hJGbx.js 18.23 kB │ gzip: 6.37 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/index-CVeeMncf.js 18.60 kB │ gzip: 5.23 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/WelcomeMessageEditor-D6ufstYr.js 19.11 kB │ gzip: 6.95 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/OrderActionHost-CdrbUm1n.js 21.16 kB │ gzip: 6.02 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/index-CS6MIRqR.js 21.91 kB │ gzip: 7.87 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/add-C1r7FTAR.js 22.29 kB │ gzip: 5.36 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/index-BpYt_Hee.js 22.96 kB │ gzip: 8.02 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/DailyMatrix-BPhRn5lY.js 24.97 kB │ gzip: 7.09 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/list_h5-cNTYH_iO.js 27.86 kB │ gzip: 9.43 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/qywx-CJq7NVl4.js 30.35 kB │ gzip: 10.01 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/list-DSAjNXOT.js 30.35 kB │ gzip: 9.74 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/commission-settlement-B1m1GnhE.js 35.83 kB │ gzip: 9.75 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/edit-C-_be8Ci.js 35.86 kB │ gzip: 9.02 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/index-CmQWbBsG.js 36.70 kB │ gzip: 11.81 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/index_h5-BSNbLohA.js 37.92 kB │ gzip: 11.61 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/index-CnqPWEF7.js 37.98 kB │ gzip: 13.55 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/index-CAiJ9mjS.js 40.10 kB │ gzip: 11.87 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/PrescriptionOrderDetailDrawer-BwYuljL9.js 48.77 kB │ gzip: 13.48 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/index-Am7Jc2zt.js 51.46 kB │ gzip: 15.16 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/index-QkQBeLjo.js 55.03 kB │ gzip: 15.89 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/tim-profanity-filter-plugin-BJ7z5puq.js 55.90 kB │ gzip: 21.04 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/index-DpMMQbZW.js 69.54 kB │ gzip: 21.09 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/rtc-detect-CAvkmauD.js 74.80 kB │ gzip: 25.93 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/yeji-D8HJelzr.js 87.40 kB │ gzip: 23.27 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/index-C4cvelAi.js 88.42 kB │ gzip: 25.65 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/order_list-D_i5USUp.js 114.89 kB │ gzip: 31.80 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/order_list_h5-B8Us1G-6.js 139.58 kB │ gzip: 36.82 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/@tencentcloud/chat-uikit-engine-BJz4IWsw.js 162.84 kB │ gzip: 41.72 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/index-C1oKTAGC.js 290.22 kB │ gzip: 92.00 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/@tencentcloud/chat-Bg29VzHQ.js 725.88 kB │ gzip: 178.89 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/@tencentcloud/call-engine-js-CRx4G1jN.js 2,290.42 kB │ gzip: 749.64 kB
|
|
||||||
../artifacts/appointment-modes/admin-build/assets/.pnpm-BHOjf1ZS.js 18,525.72 kB │ gzip: 5,519.46 kB
|
|
||||||
|
|
||||||
WARN
|
|
||||||
(!) Some chunks are larger than 500 kB after minification. Consider:
|
|
||||||
- Using dynamic import() to code-split the application
|
|
||||||
- Use build.rollupOptions.output.manualChunks to improve chunking: https://rollupjs.org/configuration-options/#output-manualchunks
|
|
||||||
- Adjust chunk size limit for this warning via build.chunkSizeWarningLimit.
|
|
||||||
|
|
||||||
✓ built in 1m 53s
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
src/components/chat-dialog/ChatMessageItem.vue(29,7): error TS7022: 'attrs' implicitly has type 'any' because it does not have a type annotation and is referenced directly or indirectly in its own initializer.
|
|
||||||
src/components/editor/index.vue(29,44): error TS7016: Could not find a declaration file for module '@wangeditor/editor-for-vue'. 'D:/web/zyt/admin/node_modules/.pnpm/@wangeditor+editor-for-vue@_d49ef1161b4f4b880c450fdbfe3a0001/node_modules/@wangeditor/editor-for-vue/dist/index.esm.js' implicitly has an 'any' type.
|
|
||||||
There are types at 'D:/web/zyt/admin/node_modules/@wangeditor/editor-for-vue/dist/src/index.d.ts', but this result could not be resolved when respecting package.json "exports". The '@wangeditor/editor-for-vue' library may need to update its package.json or typings.
|
|
||||||
src/components/link/mini-program.vue(11,30): error TS7006: Parameter 'value' implicitly has an 'any' type.
|
|
||||||
src/components/link/mini-program.vue(22,30): error TS7006: Parameter 'value' implicitly has an 'any' type.
|
|
||||||
src/components/link/mini-program.vue(33,30): error TS7006: Parameter 'value' implicitly has an 'any' type.
|
|
||||||
src/components/link/mini-program.vue(48,31): error TS7006: Parameter 'value' implicitly has an 'any' type.
|
|
||||||
src/utils/call-local-recorder.ts(335,26): error TS2339: Property 'captureStream' does not exist on type 'HTMLVideoElement'.
|
|
||||||
src/views/asset/user/index.vue(230,20): error TS2339: Property 'remark' does not exist on type 'never'.
|
|
||||||
src/views/consumer/prescription/index.vue(1952,9): error TS2322: Type '{ name: any; code: any; children: any; }[]' is not assignable to type 'never[]'.
|
|
||||||
Type '{ name: any; code: any; children: any; }' is not assignable to type 'never'.
|
|
||||||
src/views/consumer/prescription/index.vue(1958,13): error TS2322: Type '{ name: string; children: { name: string; children: { name: string; }[]; }[]; }' is not assignable to type 'never'.
|
|
||||||
src/views/consumer/prescription/index.vue(1986,13): error TS2322: Type '{ name: string; children: { name: string; children: { name: string; }[]; }[]; }' is not assignable to type 'never'.
|
|
||||||
src/views/consumer/prescription/index.vue(2001,13): error TS2322: Type '{ name: string; children: { name: string; children: { name: string; }[]; }[]; }' is not assignable to type 'never'.
|
|
||||||
src/views/consumer/prescription/index.vue(2016,13): error TS2322: Type '{ name: string; children: { name: string; children: { name: string; }[]; }[]; }' is not assignable to type 'never'.
|
|
||||||
src/views/consumer/prescription/index.vue(2256,32): error TS2304: Cannot find name 'searchPatientsAPI'.
|
|
||||||
src/views/consumer/prescription/order_list.vue(1939,68): error TS7006: Parameter 'r' implicitly has an 'any' type.
|
|
||||||
src/views/consumer/prescription/order_list.vue(2379,9): error TS2322: Type '{ value: any; label: any; code: any; children: any; }[]' is not assignable to type 'never[]'.
|
|
||||||
Type '{ value: any; label: any; code: any; children: any; }' is not assignable to type 'never'.
|
|
||||||
src/views/consumer/prescription/order_list.vue(3775,13): error TS2322: Type 'string | number | undefined' is not assignable to type 'number | undefined'.
|
|
||||||
Type 'string' is not assignable to type 'number'.
|
|
||||||
src/views/consumer/prescription/order_list_h5.vue(497,96): error TS7006: Parameter 'cmd' implicitly has an 'any' type.
|
|
||||||
src/views/consumer/prescription/order_list_h5.vue(566,47): error TS2345: Argument of type 'Record<string, any>' is not assignable to parameter of type '{ id: number; }'.
|
|
||||||
Property 'id' is missing in type 'Record<string, any>' but required in type '{ id: number; }'.
|
|
||||||
src/views/consumer/prescription/order_list_h5.vue(576,58): error TS2345: Argument of type 'Record<string, any>' is not assignable to parameter of type '{ id: number; }'.
|
|
||||||
Property 'id' is missing in type 'Record<string, any>' but required in type '{ id: number; }'.
|
|
||||||
src/views/consumer/prescription/order_list_h5.vue(586,47): error TS2345: Argument of type 'Record<string, any>' is not assignable to parameter of type '{ id: number; }'.
|
|
||||||
Property 'id' is missing in type 'Record<string, any>' but required in type '{ id: number; }'.
|
|
||||||
src/views/consumer/prescription/order_list_h5.vue(596,59): error TS2345: Argument of type 'Record<string, any>' is not assignable to parameter of type '{ id: number; }'.
|
|
||||||
Property 'id' is missing in type 'Record<string, any>' but required in type '{ id: number; }'.
|
|
||||||
src/views/consumer/prescription/order_list_h5.vue(611,46): error TS2345: Argument of type 'Record<string, any>' is not assignable to parameter of type '{ id: number; express_company?: unknown; tracking_number?: unknown; ship_mode?: unknown; }'.
|
|
||||||
Property 'id' is missing in type 'Record<string, any>' but required in type '{ id: number; express_company?: unknown; tracking_number?: unknown; ship_mode?: unknown; }'.
|
|
||||||
src/views/consumer/prescription/order_list_h5.vue(621,53): error TS2345: Argument of type 'Record<string, any>' is not assignable to parameter of type '{ id: number; diagnosis_id?: number | undefined; pay_order_ids?: number[] | undefined; }'.
|
|
||||||
Property 'id' is missing in type 'Record<string, any>' but required in type '{ id: number; diagnosis_id?: number | undefined; pay_order_ids?: number[] | undefined; }'.
|
|
||||||
src/views/consumer/prescription/order_list_h5.vue(631,53): error TS2345: Argument of type 'Record<string, any>' is not assignable to parameter of type '{ id: number; }'.
|
|
||||||
Property 'id' is missing in type 'Record<string, any>' but required in type '{ id: number; }'.
|
|
||||||
src/views/consumer/prescription/order_list_h5.vue(641,53): error TS2345: Argument of type 'Record<string, any>' is not assignable to parameter of type '{ id: number; }'.
|
|
||||||
Property 'id' is missing in type 'Record<string, any>' but required in type '{ id: number; }'.
|
|
||||||
src/views/consumer/prescription/order_list_h5.vue(2509,68): error TS7006: Parameter 'r' implicitly has an 'any' type.
|
|
||||||
src/views/consumer/prescription/order_list_h5.vue(2848,9): error TS2322: Type '{ value: any; label: any; code: any; children: any; }[]' is not assignable to type 'never[]'.
|
|
||||||
Type '{ value: any; label: any; code: any; children: any; }' is not assignable to type 'never'.
|
|
||||||
src/views/consumer/prescription/order_list_h5.vue(4694,13): error TS2322: Type 'string | number | undefined' is not assignable to type 'number | undefined'.
|
|
||||||
Type 'string' is not assignable to type 'number'.
|
|
||||||
src/views/decoration/component/tabbar/pc/attr.vue(10,18): error TS2345: Argument of type '{ modelValue: any; }' is not assignable to parameter of type '{ readonly modelValue: any[]; readonly max?: number | undefined; readonly min?: number | undefined; readonly itemData: any; readonly "onUpdate:modelValue"?: ((value: any) => any) | undefined; } & VNodeProps & AllowedComponentProps & ComponentCustomProps & Record<...>'.
|
|
||||||
Property 'itemData' is missing in type '{ modelValue: any; }' but required in type '{ readonly modelValue: any[]; readonly max?: number | undefined; readonly min?: number | undefined; readonly itemData: any; readonly "onUpdate:modelValue"?: ((value: any) => any) | undefined; }'.
|
|
||||||
src/views/decoration/component/tabbar/pc/attr.vue(13,18): error TS2345: Argument of type '{ modelValue: any; }' is not assignable to parameter of type '{ readonly modelValue: any[]; readonly max?: number | undefined; readonly min?: number | undefined; readonly itemData: any; readonly "onUpdate:modelValue"?: ((value: any) => any) | undefined; } & VNodeProps & AllowedComponentProps & ComponentCustomProps & Record<...>'.
|
|
||||||
Property 'itemData' is missing in type '{ modelValue: any; }' but required in type '{ readonly modelValue: any[]; readonly max?: number | undefined; readonly min?: number | undefined; readonly itemData: any; readonly "onUpdate:modelValue"?: ((value: any) => any) | undefined; }'.
|
|
||||||
src/views/decoration/component/widgets/middle-banner/content.vue(6,33): error TS2339: Property 'height' does not exist on type '{}'.
|
|
||||||
src/views/doctor/dept-tongji.vue(131,31): error TS7006: Parameter 'depts' implicitly has an 'any' type.
|
|
||||||
src/views/doctor/dept-tongji.vue(132,17): error TS7034: Variable 'result' implicitly has type 'any[]' in some locations where its type cannot be determined.
|
|
||||||
src/views/doctor/dept-tongji.vue(133,27): error TS7006: Parameter 'dept' implicitly has an 'any' type.
|
|
||||||
src/views/doctor/dept-tongji.vue(136,30): error TS7005: Variable 'result' implicitly has an 'any[]' type.
|
|
||||||
src/views/doctor/dept-tongji.vue(139,20): error TS7005: Variable 'result' implicitly has an 'any[]' type.
|
|
||||||
src/views/doctor/tongji.vue(230,48): error TS2769: No overload matches this call.
|
|
||||||
Overload 1 of 3, '(callbackfn: (previousValue: unknown, currentValue: unknown, currentIndex: number, array: unknown[]) => unknown, initialValue: unknown): unknown', gave the following error.
|
|
||||||
Argument of type '(total: number, count: number) => number' is not assignable to parameter of type '(previousValue: unknown, currentValue: unknown, currentIndex: number, array: unknown[]) => unknown'.
|
|
||||||
Types of parameters 'total' and 'previousValue' are incompatible.
|
|
||||||
Type 'unknown' is not assignable to type 'number'.
|
|
||||||
Overload 2 of 3, '(callbackfn: (previousValue: number, currentValue: unknown, currentIndex: number, array: unknown[]) => number, initialValue: number): number', gave the following error.
|
|
||||||
Argument of type '(total: number, count: number) => number' is not assignable to parameter of type '(previousValue: number, currentValue: unknown, currentIndex: number, array: unknown[]) => number'.
|
|
||||||
Types of parameters 'count' and 'currentValue' are incompatible.
|
|
||||||
Type 'unknown' is not assignable to type 'number'.
|
|
||||||
src/views/doctor/tongji.vue(236,51): error TS2769: No overload matches this call.
|
|
||||||
Overload 1 of 3, '(callbackfn: (previousValue: unknown, currentValue: unknown, currentIndex: number, array: unknown[]) => unknown, initialValue: unknown): unknown', gave the following error.
|
|
||||||
Argument of type '(total: number, count: number) => number' is not assignable to parameter of type '(previousValue: unknown, currentValue: unknown, currentIndex: number, array: unknown[]) => unknown'.
|
|
||||||
Types of parameters 'total' and 'previousValue' are incompatible.
|
|
||||||
Type 'unknown' is not assignable to type 'number'.
|
|
||||||
Overload 2 of 3, '(callbackfn: (previousValue: number, currentValue: unknown, currentIndex: number, array: unknown[]) => number, initialValue: number): number', gave the following error.
|
|
||||||
Argument of type '(total: number, count: number) => number' is not assignable to parameter of type '(previousValue: number, currentValue: unknown, currentIndex: number, array: unknown[]) => number'.
|
|
||||||
Types of parameters 'count' and 'currentValue' are incompatible.
|
|
||||||
Type 'unknown' is not assignable to type 'number'.
|
|
||||||
src/views/first_visit/my_patients/components/OrderActionHost.vue(13,28): error TS7006: Parameter 'command' implicitly has an 'any' type.
|
|
||||||
src/views/first_visit/my_patients/components/OrderPanel.vue(202,40): error TS7006: Parameter 'command' implicitly has an 'any' type.
|
|
||||||
src/views/first_visit/wecom_promotion/index.vue(121,43): error TS7006: Parameter 'checked' implicitly has an 'any' type.
|
|
||||||
src/views/first_visit/wecom_promotion/index.vue(217,226): error TS7006: Parameter 'value' implicitly has an 'any' type.
|
|
||||||
src/views/order/index.vue(446,33): error TS2367: This comparison appears to be unintentional because the types '"supplement"' and '"normal"' have no overlap.
|
|
||||||
src/views/order/index.vue(1542,13): error TS2367: This comparison appears to be unintentional because the types '"supplement"' and '"normal"' have no overlap.
|
|
||||||
src/views/tcm/appointment/list.vue(266,69): error TS7006: Parameter 'cmd' implicitly has an 'any' type.
|
|
||||||
src/views/tcm/diagnosis/add.vue(744,49): error TS2349: This expression is not callable.
|
|
||||||
Type 'String' has no call signatures.
|
|
||||||
src/views/tcm/diagnosis/add.vue(772,45): error TS2349: This expression is not callable.
|
|
||||||
Type 'String' has no call signatures.
|
|
||||||
src/views/tcm/diagnosis/components/BloodRecordList.vue(416,5): error TS2322: Type 'string' is not assignable to type 'number'.
|
|
||||||
src/views/tcm/diagnosis/components/RecordingVideoPlayer.vue(145,54): error TS2554: Expected 0 arguments, but got 1.
|
|
||||||
src/views/tcm/diagnosis/components/RecordingVideoPlayer.vue(146,50): error TS2554: Expected 0 arguments, but got 1.
|
|
||||||
src/views/tcm/diagnosis/components/RecordingVideoPlayer.vue(147,47): error TS2554: Expected 0 arguments, but got 1.
|
|
||||||
src/views/tcm/diagnosis/components/RecordingVideoPlayer.vue(148,47): error TS2554: Expected 0 arguments, but got 1.
|
|
||||||
src/views/tcm/diagnosis/components/RecordingVideoPlayer.vue(154,26): error TS2554: Expected 0 arguments, but got 1.
|
|
||||||
src/views/tcm/diagnosis/index.vue(392,69): error TS7006: Parameter 'cmd' implicitly has an 'any' type.
|
|
||||||
src/views/tcm/follow/index.vue(239,69): error TS7006: Parameter 'cmd' implicitly has an 'any' type.
|
|
||||||
src/views/workbench/index.vue(541,18): error TS7006: Parameter 'd' implicitly has an 'any' type.
|
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
# 业务订单辅方袋数:后端只读调查
|
||||||
|
|
||||||
|
调查日期:2026-10-17。范围为业务订单详情的服用参数修改链路;未修改业务代码、未读取业务数据库或配置中的敏感信息。仓库 `.trellis/` 不存在,已按现有代码约定调查。
|
||||||
|
|
||||||
|
## 结论
|
||||||
|
|
||||||
|
辅方「5g · 1袋」中的袋数已经持久化在 `zyt_tcm_prescription.aux_usage.dosage_bag_count`,无需新增数据库字段。业务订单的修改入口是 `POST /tcm.prescriptionOrder/patchPrescriptionUsage`,当前只更新辅方每天次数和开立天数,没有接受或保存辅方袋数。建议沿用此入口增加可选参数 `aux_dosage_bag_count`,限制为 1~5 的整数;未传时保留旧袋数,兼容现有调用方。
|
||||||
|
|
||||||
|
## 字段与读写链路
|
||||||
|
|
||||||
|
- `server/sql/1.9.20260520/add_prescription_aux_usage.sql`:处方表的 `aux_usage` 是已有 JSON 列。
|
||||||
|
- `server/app/common/model/tcm/Prescription.php:21`:`aux_usage` 配置为 JSON,关联数组读写。主方袋数为处方独立字段 `dosage_bag_count`;辅方袋数位于 JSON 中。`bags_per_dose` 是另一个字段,不应用来保存本次截图的袋数。
|
||||||
|
- `server/app/adminapi/logic/tcm/PrescriptionLogic.php:183`:`normalizeAuxUsage()` 维护 `dosage_amount`、`dosage_bag_count`、`need_decoction`、`bags_per_dose`、`times_per_day`、`usage_days`、`prescription_name`。`normalizeDosageBagCount()` 将袋数约束到 1~5,默认 1。
|
||||||
|
- `server/app/adminapi/validate/tcm/PrescriptionValidate.php:15`:通用处方主方袋数采用 `integer|between:1,5`,可复用该业务口径。
|
||||||
|
- `server/app/adminapi/controller/tcm/PrescriptionOrderController.php:240`:调用 `post()->goCheck('patchPrescriptionUsage')`,再传入管理员身份调用逻辑层。
|
||||||
|
- `server/app/adminapi/logic/tcm/PrescriptionOrderLogic.php:5770`:公开方法进入受保护事务,内部 `patchPrescriptionUsageLocked()` 加载订单与关联处方。当前合并旧 `aux_usage` 后仅改 `times_per_day`、`usage_days`,其余键保持不变。
|
||||||
|
- 同文件 `detail():1212` 复用处方详情,导出函数 `formatUsageSegmentForExport():3756` 和 `normalizeAuxUsageForExport():3807` 已读取辅方 `dosage_bag_count`。保存正确 JSON 键后,无需另建展示或导出字段。
|
||||||
|
- 辅方存在性以 `herbs[].formula_type === '辅方'` 判定;残留 `aux_usage` 不代表仍有辅方。
|
||||||
|
|
||||||
|
## 校验现状与必须注意的问题
|
||||||
|
|
||||||
|
当前业务逻辑强制主方次数 1~6、主方开立天数 1~999、订单服用天数 1~999;存在辅方时要求辅方次数及开立天数,并分别检查 1~6、1~999。
|
||||||
|
|
||||||
|
发现既有验证器场景命名问题:`PrescriptionOrderValidate.php:136` 定义的是 `patchPrescriptionUsage()`,而非 `scenePatchPrescriptionUsage()`。ThinkPHP `vendor/topthink/framework/src/think/Validate.php:1673` 的 `getScene()` 只调用 `scene` 前缀方法。因此控制器当前场景校验不会执行该方法中的 `append()`;静态 `$scene` 虽然列了用量字段,但这些字段没有基础 `$rule`,实际主要只校验已有的 `id` 规则。逻辑层先 `(int)` 转换再判断范围,也会把部分非整数输入截断为整数。
|
||||||
|
|
||||||
|
已用只加载 Composer 和内存验证器的 PHP 探针复现:同一组非法次数/天数参数,`scene('patchPrescriptionUsage')->check(...)` 返回 `true`,手动先调用旧 `patchPrescriptionUsage()->check(...)` 返回 `false`。探针不初始化业务应用、不连接数据库。
|
||||||
|
|
||||||
|
最小修复应正确使用 `scenePatchPrescriptionUsage()`,把新参数加进场景并追加 `integer|between:1,5`。业务逻辑还应在保存前验证袋数确实为整数并在范围内,避免直接调用逻辑方法时发生小数截断。无需改变其他接口的验证场景。
|
||||||
|
|
||||||
|
## 权限、状态与事务
|
||||||
|
|
||||||
|
- 现有接口权限为 `tcm.prescriptionOrder/patchPrescriptionUsage`,菜单迁移在 `server/sql/1.9.20260708/add_prescription_order_patch_usage_menu.sql`;新增袋数应沿用此权限。
|
||||||
|
- `AuthMiddleware` 根据菜单 URI 校验管理员权限;现有框架对未注册在菜单表的普通 URI 存在历史放行逻辑,因此部署应确保已有菜单迁移已执行。本次无需新增权限或迁移。
|
||||||
|
- 逻辑层先检查订单存在、`canAccessOrder()`、药房快照可变、订单未取消、已关联有效处方,以及 `PrescriptionLogic::canViewPrescription()`。
|
||||||
|
- `canAccessOrder()` 沿用当前全量角色、处方订单菜单访问、订单创建人、开方人授权或数据域可见创建人等口径;处方可见性另检查超管、创建人、医助、共享、可见角色及现有相关权限等。
|
||||||
|
- `LockedPharmacySnapshotMutation::execute()` 在 `Db::transaction()` 中锁订单行及药房提交声明行;内部返回 `false` 会导致回滚。新增字段必须留在此现有事务内部。
|
||||||
|
- `PharmacyRemoteSnapshotPolicy` 在已有药房远端单号、提交时间或声明状态为 `PENDING`、`UNKNOWN`、`PENDING_RECONCILE`、`SUCCESS` 时锁定修改。新增袋数不可绕开该限制。
|
||||||
|
|
||||||
|
## 日志
|
||||||
|
|
||||||
|
当前写入 `zyt_tcm_prescription_order_log`,`action = patch_rx_usage`,摘要为主方次数/天数、辅方次数/天数及订单天数的前后值。建议在原辅方段落加上「袋数 旧值 → 新值」,沿用当前日志动作,确保能回溯这次更改。
|
||||||
|
|
||||||
|
`writeLog():5723` 默认是非严格日志,日志保存失败会吞掉异常;本接口沿用该默认行为。是否改成严格日志属于额外行为变更,不是实现辅方袋数所必需。
|
||||||
|
|
||||||
|
## 最小修改建议
|
||||||
|
|
||||||
|
1. 修改 `PrescriptionOrderValidate` 的正确场景方法,仅对新增可选 `aux_dosage_bag_count` 检查整数及 1~5 范围。
|
||||||
|
2. 在 `patchPrescriptionUsageLocked()` 中,存在辅方且新参数已提供时才更新 `aux_usage['dosage_bag_count']`;继续复制旧 JSON,保留用量、名称、代煎等所有其他键。不修改主方 `dosage_bag_count`。
|
||||||
|
3. 缺少新参数时保留旧袋数;无辅方时沿用现有忽略辅方修改的逻辑。不因编辑次数或天数而把旧袋数重置为默认值。
|
||||||
|
4. 扩展 `patch_rx_usage` 摘要记录袋数前后值。
|
||||||
|
5. 验证有效 1、5,非法 0、6、小数、非数字,省略字段兼容,JSON 其他键保留,无辅方不产生新辅方用法,权限/取消/药房锁定失败,以及保存后回读与日志。参数校验至少要通过真实 `scene('patchPrescriptionUsage')` 路径测试,不能仅直接调用场景方法。
|
||||||
|
|
||||||
|
## 现有测试与本次验证
|
||||||
|
|
||||||
|
- 未发现针对辅方袋数修改或 `patch_rx_usage` 摘要的既有功能测试。
|
||||||
|
- `server/tests/pharmacy/run.php:809` 对本接口的受保护事务、错误清理及回滚信息有源码契约检查;它不验证真实辅方袋数持久化。
|
||||||
|
- `server/tests/pharmacy/prescription_order_export_name_regression.php` 覆盖辅方存在性及名称回退,本次运行 **7 项通过**。
|
||||||
|
- `php tests/pharmacy/run.php` 本次基线运行在 `run.php:870` 失败,提示 `tracking correction must append a strict operation log containing old and new logistics values`。这是快递修改日志的源码文本断言,调查时未修改任何后端业务代码,应与本次新增功能分开判断。
|
||||||
|
- 真实 ORM/事务测试可参考 `PrescriptionOrderReleaseAndTimeTest.php` 或 `PrescriptionOrderTrackingAuditTest.php` 的临时数据库夹具;它们要求显式指定隔离 MySQL 端口,不能直接用业务数据库。目前未运行这些数据库测试。
|
||||||
|
|
||||||
|
推荐从仓库根目录执行:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
php -l server/app/adminapi/validate/tcm/PrescriptionOrderValidate.php
|
||||||
|
php -l server/app/adminapi/logic/tcm/PrescriptionOrderLogic.php
|
||||||
|
php server/tests/pharmacy/run.php
|
||||||
|
php server/tests/pharmacy/prescription_order_export_name_regression.php
|
||||||
|
```
|
||||||
|
|
||||||
|
若补充独立辅方袋数行为测试,应使用可控临时数据库或现有内存替身,覆盖上述范围、合并保存及权限/状态拒绝;测试命令随新测试实际路径确定。已有真实 ORM 测试仅在已准备隔离 MySQL 时运行,并按对应文件头说明设置环境变量。
|
||||||
|
|
||||||
|
## 实施结果(2026-10-17)
|
||||||
|
|
||||||
|
调查完成后,按授权实现了最小后端扩展:
|
||||||
|
|
||||||
|
- `PrescriptionOrderValidate` 已改为正确的 `scenePatchPrescriptionUsage()`,新增可选 `aux_dosage_bag_count`。使用场景闭包严格验证 1~5 整数/整数字符串;缺少字段可通过,但显式空值、布尔值、小数或其他类型会拒绝,避免框架普通可选规则跳过空值。
|
||||||
|
- `PrescriptionOrderLogic` 仅在处方含辅方且 `prescription_type` 为「浓缩水丸」时处理新袋数,逻辑层独立重复严格校验;合并原 JSON 保存该键,省略字段保留原值,其他处方类型和无辅方处方忽略袋数变更。原有主方、次数/天数、权限、取消限制、药房锁及事务边界继续沿用。
|
||||||
|
- 在 `patch_rx_usage` 原日志中增加「辅方袋数 X袋 → Y袋」;只在实际提供并处理袋数字段时记录,不改变原日志失败容错行为。
|
||||||
|
- 控制器仅更新注释。未新增数据库迁移,未修改本分工之外的前端代码。
|
||||||
|
|
||||||
|
新增 `server/tests/PrescriptionOrderAuxBagCountTest.php`,直接运行真实场景验证器和公开业务逻辑入口,使用独立本地 MySQL 随机临时库,结束后自动删除该库;不加载业务数据库配置。覆盖整数边界、非法输入、JSON/主方字段保留、保存后数据库及模型回读、旧请求省略字段、旧 JSON 无袋数字段、非浓缩水丸、无辅方及残留 JSON、日志、权限/取消/药房快照拒绝,以及处方已保存后订单更新失败的真实事务回滚。
|
||||||
|
|
||||||
|
验证结果:
|
||||||
|
|
||||||
|
- 新增真实验证器/ORM/事务测试:**83 项断言通过**。
|
||||||
|
- 相关 4 个 PHP 文件语法检查通过。
|
||||||
|
- 辅方导出名称回归:**7 项通过**。
|
||||||
|
- 3 个修改过的现有 PHP 文件 `git -c core.whitespace=cr-at-eol diff --check` 通过。
|
||||||
|
- 上文记录的药房套件快递日志文本断言基线失败未扩大修改。
|
||||||
|
|
||||||
|
在已准备隔离 MySQL 实例后,可从仓库根目录重跑:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:ZYT_AUX_BAG_TEST_MYSQL_PORT='13483'
|
||||||
|
php server/tests/PrescriptionOrderAuxBagCountTest.php
|
||||||
|
php server/tests/pharmacy/prescription_order_export_name_regression.php
|
||||||
|
```
|
||||||
|
|
||||||
|
本次测试使用 `.codex-tasks/aux-bag-count-mysql/data` 的专用实例,绑定本机回环地址与 13483 端口。测试会拒绝未指定端口或默认 3306 端口。
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# Desktop AI quick-tools lifecycle review
|
||||||
|
|
||||||
|
审查时间:2026-10-17。范围:`app/src/doctor_workstation/ui/dialogs/ai_consult.py` 的工具绑定、流状态与关闭路径;辅助读取 repository / API SSE 实现和现有 UI 测试。审查时业务文件及测试已有未提交改动,未编辑这些文件,也未调用生产或任何外部服务。
|
||||||
|
|
||||||
|
## 结论
|
||||||
|
|
||||||
|
### 1. 已确认:done 后仍由 worker.finished 持有忙碌门控
|
||||||
|
|
||||||
|
- 六个工具在 `ai_consult.py:4009-4012` 用 `_ClickCard.clicked = Signal()` 绑定各自的默认参数 `prompt`。信号不携带 bool 参数,因此未发现常见的 lambda 参数覆盖或循环变量晚绑定问题。
|
||||||
|
- `_ask()` 在 `5996` 遇到 `_asking` 就无提示返回;卡片本身仍保留可点击外观,忙碌期间的工具选择被直接丢弃。
|
||||||
|
- `_stream_event(done)` 在 `6130-6143` 标记完成、停止总超时与耗时计时、渲染最终内容,但不清除 `_asking`,也不启用发送按钮。
|
||||||
|
- `_asking` 只在 `_stream_finished()` 的 `6196-6197` 恢复。`_AiStreamWorker.run()` 在 `3185-3188` 对 done 仍然继续迭代,直到底层迭代器退出才会进入 `finally` 发出 finished。
|
||||||
|
- 因而“已显示最终答案”和“允许再次点击工具”不是同一个时点。done 后迭代器或连接清理阻塞时,工具无反应可以持续;同时总超时已经停止,手工调用 `_stream_timed_out()` 也会被 `_stream_completed` 门控直接返回。
|
||||||
|
- 当前 `RemoteDoctorRepository.stream_diagnosis_ai()` 在 yield done 后有 `return`(`repository.py:1438-1442`),通常会很快触发 finished,所以不能仅凭该缺陷断言真实部署每次都会永久卡住。确定的客户端缺陷是 UI 终态仍依赖传输退出,并且在此期间没有超时恢复。
|
||||||
|
|
||||||
|
### 2. 已确认:返回、侧栏 ×、Escape 的 reject 路径绕过流清理
|
||||||
|
|
||||||
|
- 返回与侧栏 × 绑定 `self.reject`(`3710`、`3966`),但唯一关闭清理在 `closeEvent()`(`6231-6237`);没有覆盖 `done()` / `reject()` 或连接 `finished` 作统一清理。
|
||||||
|
- Qt 的 `QDialog.reject()` 隐藏对话框并结束 exec,不触发此处的 closeEvent 清理。离线调用已证实:reject 后 `_asking=True`、worker 未取消、两个流计时器仍运行、generation 未变化。
|
||||||
|
- `open_for()` 会先 `_cancel_stream()` 并递增会话 generation(`3601-3608`),所以复用同一对话框且按标准 open_for 重开时能够隔离旧回调。`present_ai_consult()` 每次新建一个对话框,旧对话框带父对象时隐藏后仍可能被保留,造成后台 worker / timer 继续存在。
|
||||||
|
- 这是生命周期缺陷;它本身不能证明同一已显示窗口的其他工具永久失效,但应与本次门控修复一起覆盖。
|
||||||
|
|
||||||
|
### 3. 其他退出路径
|
||||||
|
|
||||||
|
- `_stream_failed()` 与 done 相同,停止计时并显示错误,却只靠稍后的 finished 解锁。实际 worker 的 except 紧接 finally,常规错误路径间隙很短;仍建议终态入口统一恢复 UI 状态。
|
||||||
|
- `_stream_finished()` 对 generation / stream_generation 作检查;`_cancel_stream()` 与 `open_for()` 的 generation 隔离正确,迟到旧事件不会覆盖新流。
|
||||||
|
- 取消仅设置线程 Event,无法中断正在阻塞的 next()/网络读取;因此 UI 必须先解除对旧 worker 的依赖,再由 worker 完成清理。
|
||||||
|
- 未发现六个 prompt 绑定错误。连续点击无反馈的直接入口是 `_ask()` 的无提示忙碌返回,而非按钮失去信号连接。
|
||||||
|
|
||||||
|
## 离线复现
|
||||||
|
|
||||||
|
使用 `app/.venv/Scripts/python.exe`、`QT_QPA_PLATFORM=offscreen`,以 stdin 临时脚本运行,未新增或修改业务 / 测试文件。
|
||||||
|
|
||||||
|
1. 将线程池 start 替换成仅记录 worker 的函数,点击第一张工具卡,直接投递 done,不投递 finished,再点第二张卡。
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"case":"done_before_finished","asking":true,"send_enabled":false,"timeout_active":false,"completed":true,"second_click_started_worker":false}
|
||||||
|
{"case":"forced_timeout_after_done","asking":true}
|
||||||
|
```
|
||||||
|
|
||||||
|
补发 finished 后再次点第二张卡,第二个 worker 正常创建。
|
||||||
|
|
||||||
|
2. 使用真实 QThreadPool / QRunnable 与完全本地的假 repository。假生成器 yield done 后在 Event 上等待,模拟终态数据已到但迭代器未退出。UI 事件循环实际收到 done 后立即发起第二问:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"case":"real_qrunnable_done_then_open_iterator","done_delivered":true,"iterator_resumed_after_done":true,"asking":true,"timeout_active":false,"prompts":["first"]}
|
||||||
|
{"case":"iterator_released","asking":false,"send_enabled":true}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. fake pool 下 show 对话框并开始一问,调用实际 `dialog.reject()`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"case":"reject","visible":false,"asking":true,"worker_cancelled":false,"generation_unchanged":true,"stream_generation_unchanged":true,"elapsed_timer_active":true,"timeout_active":true}
|
||||||
|
{"case":"open_for_after_reject","worker_cancelled":true,"asking":false,"generation_changed":true}
|
||||||
|
```
|
||||||
|
|
||||||
|
所有复现脚本 exit code 0,未访问 API。
|
||||||
|
|
||||||
|
## 建议的最小修复
|
||||||
|
|
||||||
|
1. 将 UI 终态处理集中为幂等 helper,确保 done、error、silent finished 都能停止计时、冲刷待显示文本、清除 `_asking` 并恢复发送按钮;done 不等待网络或 worker 退出。保留已完成气泡 / 元数据 / 最终耗时,避免用会清空这些字段的 `_cancel_stream()` 直接处理 done。
|
||||||
|
2. worker 将有效 done 视为终止事件,发出后不再拉取下一项;在 finally 中安全关闭拥有 close() 的迭代器。即使底层关闭有延迟,UI 在步骤 1 已恢复。
|
||||||
|
3. 选定忙碌时点击工具的明确策略:若允许切换,则工具入口取消当前流并发起新的工具请求;若保持串行,则提供可见的忙碌反馈或禁用状态。当前静默吞点击不利于判断应用是否失效。不要误将旧 finished 用于解锁新请求,继续依赖 stream_generation 隔离。
|
||||||
|
4. 在 `done(result)` 或其他统一的对话框结束入口中执行 generation 失效与 `_cancel_stream()`,覆盖 reject / accept / Escape / 窗口关闭;确保和 closeEvent 重入时幂等。
|
||||||
|
|
||||||
|
## 建议回归情境
|
||||||
|
|
||||||
|
- 按截图六个工具依次真实点击(包含卡片文字和图标区域),每个 done 到达即点下一个,不提前补发 finished;每次 prompt 与 worker 都应正确创建。
|
||||||
|
- done 后迭代器保持打开 / cleanup 延迟:UI 必须允许下一问,旧 worker 的迟到 delta/error/finished 不得改动新气泡或错误解锁新请求。
|
||||||
|
- 正在首帧等待或生成中点击另一工具,验证所选忙碌策略且用户可理解当前状态。
|
||||||
|
- error 到达但 finished 延迟、无 done 的静默结束、总超时、切换患者后迟到信号,均应恢复交互且不串患者。
|
||||||
|
- 对返回按钮、侧栏 ×、Escape、原生窗口关闭分别验证 worker 已取消、计时器停止、旧 generation 无效;重开后新请求正常。
|
||||||
|
- 保留首字即时显示、40ms 后续合并、完成后结构化渲染、首字/总耗时固定、一次性全量上下文提示的现有行为。
|
||||||
|
|
||||||
|
现有 `test_answered_stream_replaces_the_pending_placeholder` 与耗时完成测试在 done 后立即手工调用 finished,掩盖终态间隙;现有关闭耗时测试用 `dialog.close()`,未覆盖 reject 路径。
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# 桌面端 AI 助手:服务端流式链路审查
|
||||||
|
|
||||||
|
日期:2026-10-17
|
||||||
|
|
||||||
|
## 结论
|
||||||
|
|
||||||
|
- 正常链路已经逐帧转发:`DifyChatService::sendStreamRequest()` 在 `CURLOPT_WRITEFUNCTION` 内解析 SSE 并立即调用 `onDelta`;`DiagnosisController::runAssistantSse()` 对每个 delta `echo` 后执行 `ob_flush()` / `flush()`,没有等模型全文完成才发送。
|
||||||
|
- 控制器清理 PHP 输出缓冲、关闭输出压缩、设置 `X-Accel-Buffering: no` 和 `Cache-Control: no-cache, no-transform`,并先发送 2048 字节注释和 start 事件。
|
||||||
|
- 首字之前仍有真实耗时:`prepareAssistant()` 在 SSE headers 前完成权限、DataScope、病例聚合和模型预检。病例正文超过 120,000 字节时,`fitContextForPrompt()` 同步调用 `PatientAiReportLogic::compactSourceForPrompt()`;后者依次通过 blocking 请求读取全部分片并可能归并摘要。保留这一流程以保持全量临床来源覆盖,不能用截断病例来换速度。
|
||||||
|
- 兼容重试也可能增加首字等待:通用 `/v1` 地址存在协议回退;`streamPreparedAssistant()` 在尚未交付任何 delta 且流失败为 `UPSTREAM_REJECTED` / `INCOMPLETE_RESPONSE` / `EMPTY_RESPONSE` 时,会额外做一次 blocking 兼容尝试。已经交付文本后不会重复生成。
|
||||||
|
- 因此前端需要在用户提交时立即反馈并开始计秒,不能等 SSE start 才开始计时。`AssistantSseTiming` 已提供不含患者正文的 prepare、first_delta、upstream_done 等日志,可定位实际延迟阶段。
|
||||||
|
|
||||||
|
## 修复
|
||||||
|
|
||||||
|
发现 SSE 分帧正则 `(?:\r\n|\r|\n){2}` 允许回溯,把一个 CRLF 拆成两个换行;仅收到一行 data 时就会提前解析。合法的多行 CRLF data 事件会被拆坏并丢失文本,可能进一步触发无 delta 的兼容重试。
|
||||||
|
|
||||||
|
将换行匹配改为原子组 `(?>\r\n|\r|\n){2}`,使 CRLF 始终作为一个换行。事件到达空行即转发,不等待 message_end 或网络 EOF。接口、权限、事件格式、错误码和凭据保护保持兼容。
|
||||||
|
|
||||||
|
修改文件:
|
||||||
|
|
||||||
|
- `server/app/common/service/DifyChatService.php`
|
||||||
|
- `server/tests/DifyChatStreamContractTest.php`
|
||||||
|
- `server/tests/DifyChatStreamDeliveryTest.php`(新增)
|
||||||
|
|
||||||
|
新增行为覆盖:
|
||||||
|
|
||||||
|
- data 行尚未结束事件时不解析;完整首帧在终止事件及 EOF 前立即交付。
|
||||||
|
- 多行 CRLF data 在 1 字节、2 字节及整段传输切块下保持为同一事件。
|
||||||
|
- 离线模拟实际 cURL header/write 回调:每帧在下一次网络读取之前、`curl_exec()` 返回之前到达消费者;Dify / OpenAI 均覆盖。
|
||||||
|
- HTTP 错误正文不进入 delta;消费者拒绝后停止继续读取和交付。
|
||||||
|
|
||||||
|
新增回归在修复前实际失败:`FAIL: a data line alone is not a complete SSE event`;应用修复后通过。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
以下命令全部退出码 0:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
php server/tests/DifyChatStreamContractTest.php
|
||||||
|
php server/tests/DifyChatStreamDeliveryTest.php
|
||||||
|
php server/tests/DifyChatAncientBooksReuseContractTest.php
|
||||||
|
php server/tests/DiagnosisAiAssistantStreamContractTest.php
|
||||||
|
php server/tests/DiagnosisAiAssistantTimingTest.php
|
||||||
|
php server/tests/DiagnosisAiAssistantContractTest.php
|
||||||
|
php server/tests/DiagnosisAiPrescriptionGeneratePermissionTest.php
|
||||||
|
php server/tests/DiagnosisAiAnalysisSecurityTest.php
|
||||||
|
php server/tests/DiagnosisAiAnalysisContractTest.php
|
||||||
|
php server/tests/PrescriptionAiUpstreamContractTest.php
|
||||||
|
php -l server/app/common/service/DifyChatService.php
|
||||||
|
php -l server/tests/DifyChatStreamContractTest.php
|
||||||
|
php -l server/tests/DifyChatStreamDeliveryTest.php
|
||||||
|
git diff --check -- server/app/common/service/DifyChatService.php server/tests/DifyChatStreamContractTest.php server/tests/DifyChatStreamDeliveryTest.php
|
||||||
|
```
|
||||||
|
|
||||||
|
验证均离线进行,没有调用生产模型、读取凭据或测试生产代理配置;因此不提供生产首字时延数值。
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 136 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 141 KiB |
@@ -0,0 +1,53 @@
|
|||||||
|
# 桌面 AI 对话即时反馈与秒数验证
|
||||||
|
|
||||||
|
日期:2026-10-17
|
||||||
|
|
||||||
|
## 行为
|
||||||
|
|
||||||
|
- 提交问题即显示“已等待 0 秒”,资料准备与模型等待期间每秒更新。
|
||||||
|
- 第一段有效文本到达时立即替换等待占位;后续文本按 40 ms 合并刷新,保留顺序。
|
||||||
|
- 生成期间显示“生成中 · 已用时 N 秒”;完成后显示模型、首字耗时、总耗时和时间。
|
||||||
|
- 完成、错误、超时、静默断连、切换患者、关闭窗口均停止秒数计时;新问题重新计时。迟到事件不会覆盖已结束的结果。
|
||||||
|
- 富文本解析和格式调整完成后统一测量高度,减少一次更新内的重复布局。
|
||||||
|
- 服务端修复 CRLF 分帧错误,详见 [后端审查](desktop-ai-stream-backend-review.md)。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
在 `app/` 下执行:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.venv/Scripts/python.exe -m pytest -o addopts= tests/test_ai_consult_ui.py tests/test_ai_consult_workspace_ui.py tests/test_api_client.py tests/test_repository_parity.py
|
||||||
|
.venv/Scripts/python.exe -m ruff check src/doctor_workstation/ui/dialogs/ai_consult.py tests/test_ai_consult_ui.py
|
||||||
|
```
|
||||||
|
|
||||||
|
结果:156 passed in 4.75s;Ruff 通过。
|
||||||
|
|
||||||
|
新增端到端回归使用真实 httpx 解析器、仓库、线程池 worker 和 Qt 事件交付,暂停上游结束事件,确认首段中文文本已出现在界面;覆盖跨网络块的 UTF-8 字符、后续文本顺序、结束状态以及回调运行在 UI 主线程。未调用生产模型。
|
||||||
|
|
||||||
|
服务端 10 项相关契约/行为测试与 3 项 PHP 语法检查通过,命令及结果见后端审查。
|
||||||
|
|
||||||
|
1308 × 806 离屏检查三个状态,完成状态元信息没有截断:
|
||||||
|
|
||||||
|
- [等待](desktop-ai-stream-waiting.png)
|
||||||
|
- [生成中](desktop-ai-stream-generating.png)
|
||||||
|
- [完成](desktop-ai-stream-done.png)
|
||||||
|
|
||||||
|
本地一次富文本更新对比:同一离屏控件连续 20 次更新,原逻辑 157.9 ms,新逻辑 129.6 ms。该数值仅为本机布局检查,不代表生产模型首字时延。
|
||||||
|
|
||||||
|
## 扩展检查边界
|
||||||
|
|
||||||
|
额外运行 `test_dialog_detail_layout.py`、`test_list_detail_geometry.py`、`test_diagnosis_detail_contract.py`、`test_ui_detail_refinements.py`:34 通过,3 失败,分别为挂号列表横向溢出、患者状态按钮宽度、问诊筛选控件高度。这些模块未在本次任务中修改。将 AI 模块恢复为 Git HEAD 源码后再次运行同一组,仍是同样的 3 项失败(34 passed in 12.22s),确认不是本次修改引入。
|
||||||
|
|
||||||
|
AI 用例与其他布局用例连跑时,在后者 fixture 的 `apply_theme()` / `app.setStyle("Fusion")` 处停滞;独立运行布局组能完成。将 AI 模块恢复为 Git HEAD 源码后,该组合也会停滞,已停止诊断进程;没有修改全局主题或相应布局。
|
||||||
|
|
||||||
|
## 生效范围
|
||||||
|
|
||||||
|
已修改仓库源码,未生成安装包或部署生产服务。源码运行的桌面端重启后加载;已安装的打包版本需重新构建发布。服务端 CRLF 修复需部署。病例聚合及模型首字前的真实耗时仍然存在,当前以实时秒数明确展示。
|
||||||
|
|
||||||
|
## 追加:快捷工具后续点击无反应
|
||||||
|
|
||||||
|
同日修复后续反馈:完成/报错到达后立即解除忙碌状态,不依赖后台线程退出;worker 收到 done 后不再拉取下一个事件。生成中切换其他快捷工具、快捷指令或对话建议会停止当前请求,保留已有文本和耗时并明确标注停止;重复点击相同的进行中工具只提示,不重复提交。新请求自动展示问诊对话并恢复全部对话筛选,避免结果被其他资料页或筛选隐藏。
|
||||||
|
|
||||||
|
统一窗口结束清理,覆盖返回、侧栏 ×、Escape、accept/reject 和原生关闭;旧请求的 delta/error/finished 回调无法影响新请求。审查依据见 [快捷工具生命周期审查](desktop-ai-quick-tools-review.md)。
|
||||||
|
|
||||||
|
新增 4 项核心回归在修复前均实际失败,修复后通过;另覆盖 4 个快捷入口和 6 种窗口退出方式。相同桌面端测试命令最终结果为 **170 passed in 5.84s**,Ruff 与 `git diff --check` 通过。未修改服务端、打包或重启正在使用的桌面端进程。
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user