Compare commits

...
Author SHA1 Message Date
gr bb28d93cec 更新 2026-10-15 11:03:54 +08:00
long 70ddc1a70a feat(followup-audio): inline review with serialized draft autosave 2026-10-12 09:30:43 +08:00
long fc91908cf9 fix(followup-audio): preserve roles chosen before first audio file 2026-10-10 15:03:06 +08:00
long b4acad8510 feat(followup-audio): review full dialogue and bind roles before extraction 2026-10-10 14:25:03 +08:00
long f015b26ae1 Merge human-reviewed followup business workflow 2026-10-10 11:42:08 +08:00
long 91aa1cb595 feat: enable explicitly authorized human-reviewed clinical adoption 2026-10-10 11:42:08 +08:00
gr 0349ec0fbd 和不满 2026-10-10 10:58:56 +08:00
gr 68b412495d 更新 2026-10-10 10:55:32 +08:00
long 55979da912 Merge explicit GPT text channel integration 2026-10-10 10:28:29 +08:00
long 228d93ac3b feat: bind explicitly authorized HTTP text extraction channel 2026-10-10 10:28:28 +08:00
long 4704b32807 Merge followup extraction provider preference fix 2026-10-09 18:09:13 +08:00
long 73be0ecedf fix: honor configured followup extraction model preference 2026-10-09 18:09:12 +08:00
long 6ccef86dd0 Merge followup audio flow review fixes 2026-10-09 17:46:25 +08:00
long e762a367da fix: preserve confirmed followup snapshots and expand flow checks 2026-10-09 17:46:25 +08:00
long 91f56931fd Merge followup audio draft request fix 2026-10-09 17:13:28 +08:00
long 9185c6db04 fix: preserve typed followup audio review request payloads 2026-10-09 17:13:21 +08:00
long 5884e000ff Merge follow-up audio preview with isolated Dify ASR 2026-10-09 16:12:11 +08:00
long 4d0af7f3f4 feat: add scoped read-only audio preview and isolated Dify transport 2026-10-09 16:10:59 +08:00
long 27772bec61 Merge commit '5706e9a6a51227ebf798df46fabcbabf83f91ab2' into codex/followup-audio 2026-10-09 14:51:44 +08:00
Your Name 5706e9a6a5 更新 2026-10-09 14:13:57 +08:00
Your Name 51d8fd409e 更新 2026-10-09 09:36:17 +08:00
Your Name 88b94c2fc6 修复古籍索引安装脚本兼容旧版 Git 2026-10-09 09:16:03 +08:00
Your Name 637c2f1ff2 更新 2026-10-09 09:09:54 +08:00
Your Name 19ea2dadeb 更新 2026-10-08 18:03:11 +08:00
Your Name 4dac39c4c2 更新 2026-10-08 16:32:50 +08:00
long a20d3565af feat: preserve stereo channels and require reviewed speaker roles 2026-10-08 16:15:41 +08:00
Your Name 67c374d899 更新 2026-10-08 15:49:53 +08:00
long 13948f5982 feat: add durable ASR-to-patient extraction and guarded review 2026-10-08 15:08:42 +08:00
Your Name 1c9b093e08 更新 2026-10-08 14:12:44 +08:00
long 8bb4bd07ae ops: add isolated ASR deployment and safe GPU test handoff 2026-10-08 12:42:01 +08:00
long fea33285e8 feat: configure audio providers and add local sample acceptance 2026-10-07 18:15:32 +08:00
gr 5543f45850 更新 2026-09-30 14:35:46 +08:00
long 70be2fc70f feat: add reviewed follow-up audio patient enrichment 2026-09-29 15:44:30 +08:00
gr 3cdc812f2b ;rgb:0000/0000/0000
更新
2026-09-28 14:53:47 +08:00
gr 71304f356d i
Merge branch 'master' into 9-28
2026-09-28 14:20:46 +08:00
gr 694c83dc7d 更新 2026-09-28 14:18:12 +08:00
Your Name 735b607f80 更新 2026-09-28 14:13:15 +08:00
Your Name 4ef3cef6a0 更新 2026-09-28 11:32:28 +08:00
gr 49fc974421 Merge branch 'master' into mcp 2026-09-28 10:05:13 +08:00
gr 88ce52620a i更新 2026-09-28 10:04:24 +08:00
gr 0a17f071fc i更新 2026-09-28 10:03:20 +08:00
gr 8e2b4fc763 更新 2026-09-24 16:18:52 +08:00
gr b7e91f5fe3 更新 2026-09-24 11:48:20 +08:00
gr 68fc86a167 ;rgb:0000/0000/0000
gengx
2026-09-24 11:03:44 +08:00
gr bd22e5f476 更新 2026-09-24 09:45:44 +08:00
Your Name dbf474ddd7 更新 2026-09-21 10:26:14 +08:00
Your Name bbe3e870a1 更新 2026-09-10 15:33:21 +08:00
Your Name caab2cabe3 更新 2026-09-10 15:23:03 +08:00
Your Name 3c7b88bb15 i
`Merge branch 'master' into chufang-9-9
2026-09-10 15:20:45 +08:00
Your Name 36975c6c1b 更新 2026-09-10 15:19:17 +08:00
2900 changed files with 3883101 additions and 9025 deletions
+3
View File
@@ -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
+3
View File
@@ -28,6 +28,9 @@ app/.test-tmp-stream/
/.codex
/.agents
/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
/.spool
TUICallKit-Vue3/.env
+206
View File
@@ -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 })
+3
View File
@@ -9,6 +9,7 @@ import useAppStore from './stores/modules/app'
import useSettingStore from './stores/modules/setting'
import useUserStore from './stores/modules/user'
import ChatNotifyToast from './components/chat-notify-toast/index.vue'
import RevisitDueToast from './components/revisit-due-toast/index.vue'
const appStore = useAppStore()
const settingStore = useSettingStore()
@@ -58,6 +59,8 @@ watch(
<el-config-provider :locale="elConfig.locale" :z-index="elConfig.zIndex">
<router-view />
<ChatNotifyToast v-if="showChatNotifyToast" />
<!-- 复诊到期弹窗(右下角):与会话通知并列挂载,复用同一条件避免未绑企微时无意义轮询 -->
<RevisitDueToast v-if="showChatNotifyToast" />
</el-config-provider>
</template>
+24
View File
@@ -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)
}
+143
View File
@@ -0,0 +1,143 @@
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_retry: boolean
audio_available: boolean
preview_only?: boolean
transcript_available?: boolean
pipeline_progress?: { transcribed: number; total: number }
}
export interface FollowupTaskDetail extends FollowupTaskSummary {
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 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
}
+195
View File
@@ -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 })
}
+50 -4
View File
@@ -204,8 +204,26 @@ export function commissionSettlementConfirmRevoke(params: Record<string, any>) {
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 })
}
@@ -214,23 +232,29 @@ export function revisitRateDeptOptions() {
return request.get({ url: '/stats.revisitRate/deptOptions' })
}
/** 复诊接诊率:「被指派数」点击下钻(按诊单聚合明细) */
/**
* 复诊接诊率:「新指派数 / 继承数 / 被指派合计」点击下钻(按诊单聚合明细)。
* 行含 assign_type(new|inherit)、inherit_visit_slot(继承时诊次 k)、entry_slot(起算诊次)
*/
export function revisitRateAssignLines(params: {
month?: string
dept_ids?: string
assistant_id?: number
dept_id?: number
inherit_mode?: RevisitRateInheritMode
kind?: RevisitRateAssignKind
}) {
return request.get({ url: '/stats.revisitRate/assignLines', params })
}
/** 复诊接诊率:「N 诊单数」点击下钻(具体订单明细) */
/** 复诊接诊率:「N 诊单数」点击下钻(具体订单明细);slot=0 为全部 N≥2(复诊合计单数),行含 visit_slot */
export function revisitRateVisitOrderLines(params: {
month?: string
slot: number
dept_ids?: string
assistant_id?: number
dept_id?: number
inherit_mode?: RevisitRateInheritMode
}) {
return request.get({ url: '/stats.revisitRate/visitOrderLines', params })
}
@@ -253,3 +277,25 @@ export function assistantPerformanceOverview(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 })
}
+1 -1
View File
@@ -467,7 +467,7 @@ export function prescriptionOrderEditTime(params: { id: number; create_time: str
return request.post({ url: '/tcm.prescriptionOrder/editTime', params })
}
/** 仅修改业务订单的承运商与快递单号;所有履约状态均可使用 */
/** 仅修改业务订单的承运商与快递单号;处方和支付单均审核通过后,所有履约状态均可使用 */
export function prescriptionOrderDdcode(params: {
id: number
express_company: string
File diff suppressed because it is too large Load Diff
+403
View File
@@ -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(' · ')
}
+135
View File
@@ -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>
+123
View File
@@ -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>
+135
View File
@@ -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>
@@ -871,8 +871,17 @@
<el-option label="京东快递" value="jd" />
<el-option label="极兔速递" value="jt" />
</el-select>
<el-input v-model="editForm.tracking_number" maxlength="80" placeholder="快递单号" class="flex-1 min-w-0" />
</div>
<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>
</el-form-item>
</el-col>
</el-row>
@@ -1067,8 +1076,17 @@
<el-option label="京东快递" value="jd" />
<el-option label="极兔速递" value="jt" />
</el-select>
<el-input v-model="editForm.tracking_number" maxlength="80" placeholder="快递单号" class="flex-1 min-w-0" />
</div>
<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>
</el-form-item>
</el-col>
<el-col :span="24">
@@ -2975,9 +2993,13 @@ function canWithdrawRow(row: { fulfillment_status?: number }) {
return Number(row.fulfillment_status) === 1
}
function canShipRow(row: { fulfillment_status?: number }) {
// 履约中(2) 可发货
return Number(row.fulfillment_status) === 2
function canShipRow(row: {
fulfillment_status?: number
prescription_audit_status?: number
payment_slip_audit_status?: number
}) {
// 履约中(2) 且处方、支付单均审核通过才可发货
return Number(row.fulfillment_status) === 2 && isDualAuditPassed(row)
}
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
}
function canQuickTrackRow(_row: { fulfillment_status?: number }) {
return true
function canQuickTrackRow(row: {
prescription_audit_status?: number
payment_slip_audit_status?: number
}) {
return isDualAuditPassed(row)
}
function canUploadPharmacyRow(row: {
@@ -3270,7 +3295,7 @@ const editSaving = ref(false)
/** 编辑订单分步:0 收货 / 1 服务与支付单 / 2 金额与确认(与处方列表「创建业务订单」一致) */
const editOrderStep = ref(0)
/** 顶部「关联处方」卡片数据(来自业务订单详情中的 prescription) */
const editOrderPrescription = ref<Record<string, any> | null>(null)
const editOrderPrescription = ref<Record<string, any> | null>(null)
/** 甘草 SCM 已提交:弹窗仅展示并提交快递单号与承运商 */
const editGancaoLogisticsOnlyMode = ref(false)
/** 用于提示文案展示甘草处方单号 */
@@ -3455,7 +3480,7 @@ const editRules = computed<FormRules>(() => {
return rules
})
function resetEditOrderDialog() {
function resetEditOrderDialog() {
editOrderStep.value = 0
editOrderPrescription.value = null
editGancaoLogisticsOnlyMode.value = false
@@ -3507,8 +3532,8 @@ async function openEdit(row: {
}
editOrderStep.value = 0
editOrderPrescription.value = null
editVisible.value = true
editDialogLoading.value = true
editVisible.value = true
editDialogLoading.value = true
try {
const res: any = await prescriptionOrderDetail({ id: row.id })
const d = res?.data ?? res
@@ -3531,7 +3556,7 @@ async function openEdit(row: {
editOrderPrescription.value = null
}
editForm.id = d.id
editForm.id = d.id
editForm.prescription_id = Number(d.prescription_id) || 0
editForm.diagnosis_id = Number(d.diagnosis_id) || 0
editForm.assistant_id = Number(d.assistant_id) || 0
@@ -3627,7 +3652,6 @@ async function submitEdit() {
service_channel: editForm.service_channel || '',
service_package: Array.isArray(editForm.service_package) ? editForm.service_package.join(',') : '',
express_company: editForm.express_company || 'auto',
tracking_number: editForm.tracking_number || '',
fee_type: editForm.fee_type,
amount: editForm.amount,
remark_extra: editForm.remark_extra || '',
@@ -3915,7 +3939,17 @@ const quickTrackForm = reactive({
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
quickTrackForm.express_company = String(row.express_company || 'auto') || 'auto'
quickTrackForm.tracking_number = String(row.tracking_number || '')
@@ -3963,7 +3997,19 @@ function resolveShipModeForRow(row: { id: number; ship_mode?: unknown }) {
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)
shipForm.ship_mode = resolveShipModeForRow(row)
shipForm.express_company = String(row.express_company || 'auto') || 'auto'
@@ -1624,8 +1624,17 @@
<el-option label="京东快递" value="jd" />
<el-option label="极兔速递" value="jt" />
</el-select>
<el-input v-model="editForm.tracking_number" maxlength="80" placeholder="快递单号" class="flex-1 min-w-0" />
</div>
<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>
</el-form-item>
</el-col>
<el-col :span="24">
@@ -3422,9 +3431,13 @@ function canWithdrawRow(row: { fulfillment_status?: number }) {
return Number(row.fulfillment_status) === 1
}
function canShipRow(row: { fulfillment_status?: number }) {
// 履约中(2) 可发货
return Number(row.fulfillment_status) === 2
function canShipRow(row: {
fulfillment_status?: number
prescription_audit_status?: number
payment_slip_audit_status?: number
}) {
// 履约中(2) 且处方、支付单均审核通过才可发货
return Number(row.fulfillment_status) === 2 && isDualAuditPassed(row)
}
function canAddPayOrderRow(row: { fulfillment_status?: number }) {
@@ -3444,8 +3457,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
}
function canQuickTrackRow(_row: { fulfillment_status?: number }) {
return true
function canQuickTrackRow(row: {
prescription_audit_status?: number
payment_slip_audit_status?: number
}) {
return isDualAuditPassed(row)
}
function canUploadPharmacyRow(row: {
@@ -4181,7 +4197,7 @@ const editSaving = ref(false)
/** 编辑订单分步:0 收货 / 1 服务与支付单 / 2 金额与确认(与处方列表「创建业务订单」一致) */
const editOrderStep = ref(0)
/** 顶部「关联处方」卡片数据(来自业务订单详情中的 prescription) */
const editOrderPrescription = ref<Record<string, any> | null>(null)
const editOrderPrescription = ref<Record<string, any> | null>(null)
const editOrderStepLead = computed(() => {
const texts = [
@@ -4381,7 +4397,7 @@ const editRules = computed<FormRules>(() => {
return rules
})
function resetEditOrderDialog() {
function resetEditOrderDialog() {
editOrderStep.value = 0
editOrderPrescription.value = null
editFormRef.value?.clearValidate()
@@ -4431,8 +4447,8 @@ async function openEdit(row: {
}
editOrderStep.value = 0
editOrderPrescription.value = null
editVisible.value = true
editDialogLoading.value = true
editVisible.value = true
editDialogLoading.value = true
try {
const res: any = await prescriptionOrderDetail({ id: row.id })
const d = res?.data ?? res
@@ -4455,7 +4471,7 @@ async function openEdit(row: {
editOrderPrescription.value = null
}
editForm.id = d.id
editForm.id = d.id
editForm.prescription_id = Number(d.prescription_id) || 0
editForm.diagnosis_id = Number(d.diagnosis_id) || 0
editForm.assistant_id = Number(d.assistant_id) || 0
@@ -4540,7 +4556,6 @@ async function submitEdit() {
service_channel: editForm.service_channel || '',
service_package: Array.isArray(editForm.service_package) ? editForm.service_package.join(',') : '',
express_company: editForm.express_company || 'auto',
tracking_number: editForm.tracking_number || '',
fee_type: editForm.fee_type,
amount: editForm.amount,
remark_extra: editForm.remark_extra || '',
@@ -4788,7 +4803,17 @@ const quickTrackForm = reactive({
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
quickTrackForm.express_company = String(row.express_company || 'auto') || 'auto'
quickTrackForm.tracking_number = String(row.tracking_number || '')
@@ -4840,7 +4865,19 @@ const shipDialogModeDisplay = computed(() =>
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
shipForm.ship_mode = String(row.ship_mode || 'gancao') || 'gancao'
shipForm.express_company = String(row.express_company || 'auto') || 'auto'
@@ -154,7 +154,7 @@
<div class="panel-heading panel-heading--table">
<div>
<h2>明细数据列表</h2>
<p>展开部门可查看人员明细;加粉=总进线=区间新增“员工+客户”组合(同一员工的同一客户只计一次,同一客户进入不同员工分别计数;包含区间内添加后已删客户,剔除继承客户、扫一扫/搜手机号/名片分享添加及区间前已存在的相同组合)<template v-if="canViewDeletedFans">,(-N)表示加粉组合中已删除</template>;挂号=已支付且实收低于 10 元的订单,预约=有效预约记录;开口率=开口/加粉,挂号率=挂号/加粉,面诊率=面诊/挂号(看挂号后流失),预约率=面诊/预约(看预约后未面诊),面诊接诊率=接诊诊单/面诊,接诊率=接诊诊单/总进线</p>
<p>展开部门可查看人员明细;加粉=总进线=区间新增“员工+客户”组合(同一员工的同一客户只计一次,同一客户进入不同员工分别计数;包含区间内添加后已删客户,剔除继承客户、扫一扫/搜手机号/名片分享添加及区间前已存在的相同组合)<template v-if="canViewDeletedFans">,(-N)表示加粉组合中已删除</template>;开口=获客客户首次发起会话事件,按开口时间计入,同一员工的同一客户只计一次;挂号=已支付且实收低于 10 元的订单,预约=有效预约记录;开口率=开口/加粉,挂号率=挂号/加粉,面诊率=面诊/挂号(看挂号后流失),预约率=面诊/预约(看预约后未面诊),面诊接诊率=接诊诊单/面诊,接诊率=接诊诊单/总进线</p>
</div>
<span>{{ dashboard.rows.length }} 个顶层节点</span>
</div>
@@ -449,7 +449,7 @@ const timeOptions = [
]
const metricCards: Array<{ key: string; label: string; type: MetricType; hint: string }> = [
{ key: 'add_fans_count', label: '加粉数', type: 'count', hint: '区间新增员工+客户组合(不同员工分别计数,含已删除)' },
{ key: 'total_open_count', label: '开口数', type: 'count', hint: '来源于个人业绩录入' },
{ key: 'total_open_count', label: '开口数', type: 'count', hint: '获客客户首次发起会话;按开口时间统计,同一员工的同一客户只计一次' },
{ key: 'interview_count', label: '面诊', type: 'count', hint: '已完成预约' },
{ key: 'completed_order_count', label: '接诊诊单', type: 'count', hint: '业务订单,按创建人归属并过滤无效单' },
{ key: 'completed_order_amount', label: '诊单金额', type: 'money', hint: '排除取消、拒收、退款及已退款订单' },
@@ -56,6 +56,70 @@
/>
<el-empty v-else description="暂无数据" />
</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>
</template>
@@ -63,7 +127,12 @@
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
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 dateRange = ref<string[]>([])
@@ -191,8 +260,78 @@ const handleReset = () => {
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(() => {
fetchData()
fetchRevisitRate()
})
</script>
@@ -270,4 +409,57 @@ onMounted(() => {
.stats-chart {
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>
@@ -67,7 +67,10 @@
</el-form>
<div class="log-hint">
由每日定时任务「待分配诊单自动指派」写入:按上月二诊复诊接诊率分档轮询分配(&gt;70% 每人3条/日,60%~70%
每人2条/日,50%~60% 每人1条/日,&lt;50% 不分)。「回退」可将已分配医助撤回到自动分配前的原医助(通常为一中心),并写入指派日志。
每人2条/日,50%~60% 每人1条/日,&lt;50% 不分)。「回退」可将已分配医助撤回到自动分配前的原医助(通常为一中心),并写入指派日志(原医助重新接手按「继承」记录)。
<br />
口径版本:<b>v1 旧口径</b> = 分母仅新指派(visit2_rate_new_assign);<b>v2 新口径</b> = 每列独立分母,继承按继承时诊次计入(visit2_rate)。
每条日志同时记录两套率并标注本批次分档所用口径(配置 project.revisit_rate.auto_assign_tier_field);2026-10 前的历史行仅有旧口径率。
</div>
</el-card>
@@ -137,7 +140,7 @@
<template #default="{ row }">
<template v-if="Number(row.assistant_id) > 0">
<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>
<span v-else>—</span>
</template>
@@ -145,6 +148,29 @@
<el-table-column label="档位" width="100" align="center">
<template #default="{ row }">{{ row.tier_text || '—' }}</template>
</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">
<template #default="{ row }">
{{ Number(row.round_no) > 0 ? '第' + row.round_no + '轮' : '—' }}
@@ -186,6 +212,7 @@
import { autoAssignLogLists, autoAssignLogRollback } from '@/api/stats'
import { usePaging } from '@/hooks/usePaging'
import feedback from '@/utils/feedback'
import { InfoFilled } from '@element-plus/icons-vue'
import { onMounted, reactive, ref, watch } from 'vue'
const dateRange = ref<[string, string] | null>(null)
@@ -241,6 +268,14 @@ const handleReset = () => {
const formatRate = (rate: number | null | undefined) =>
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 onSelectionChange = (rows: any[]) => {
@@ -315,5 +350,12 @@ onMounted(() => getLists())
font-size: 12px;
color: var(--el-text-color-secondary);
}
.col-hint {
margin-left: 2px;
vertical-align: -2px;
color: var(--el-text-color-secondary);
cursor: help;
}
}
</style>
+863
View File
@@ -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>
+285 -31
View File
@@ -29,12 +29,18 @@
@change="loadData"
/>
</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-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="500" trigger="hover">
<el-popover placement="bottom-start" :width="560" trigger="hover">
<template #reference>
<span class="rate-caliber-trigger">
<el-icon><InfoFilled /></el-icon>
@@ -43,16 +49,29 @@
</template>
<div class="rate-caliber">
<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>
<b>诊次(第 N 次下单)</b>:患者(诊单)名下计入业绩的业务订单(剔除已取消 / 拒收 / 退款)按下单时间升序编号为「实单序号」,<b>统计诊次 = 实单序号 + 诊单偏移</b>(默认偏移 0 → 第 1 笔实单为一诊;偏移 1 → 第 1 笔实单为二诊;偏移 2 → 第 1 笔实单为三诊,5 笔实单等价七诊)。诊次<b>跨月累计不重置</b>。诊单可在「业务订单」tab 配置偏移量。
</p>
<p>
<b>当月 N 诊单数</b>:当月内下单且诊次为 N 的订单数,归属下单时点<b>持有该患者的医助</b>(指派可在往月;释放后不再归属;「继承」指派会转移持有人但不计被指派数)。
</p>
<p>
<b>当月 N 诊接诊率</b> = 当月 N 诊单数 ÷ 当月被指派总数。往月指派、当月成交会推高分子,比率可能超过 100%;医助当月无新指派但旗下有成交时,被指派数为 0、比率显示「—」。
<b>当月 N 诊单数</b>:当月内下单且诊次为 N 的订单数,归属下单时点<b>持有该患者的医助</b>(指派可在往月;释放后不再归属;继承会转移持有人)。往月指派、当月成交会推高分子,比率可能超过 100%。
</p>
<p>
医助按人事部门归组;<b>仅统计「二中心」及其组织下级</b>;部门下拉与未选时的默认范围均限定在该子树,选定部门时含其组织下级。
@@ -81,7 +100,9 @@
<template #header>
<div class="rate-card-header">
<span>部门 · 医助明细({{ monthText }})</span>
<span class="rate-card-hint">点击「被指派数 / N诊单数」可查看具体诊单与订单;底栏合计的被指派数按诊单去重</span>
<span class="rate-card-hint">
点击「新指派数 / 继承数 / 被指派合计 / N诊单数 / 复诊合计单数」可查看具体诊单与订单;接诊率下方灰字为「单数 / 分母」;底栏合计按诊单去重
</span>
</div>
</template>
<el-table
@@ -104,19 +125,88 @@
<span v-else>{{ row.assistant_name }}</span>
</template>
</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 }">
<el-link
v-if="canViewAssignLines && row.assigned_count > 0"
type="primary"
:underline="false"
@click="openAssignDetail(row)"
@click="openAssignDetail(row, 'new')"
>
{{ row.assigned_count }}
</el-link>
<span v-else>{{ row.assigned_count }}</span>
</template>
</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">
<el-table-column
:prop="`visit${slot}_count`"
@@ -149,12 +239,64 @@
<el-table-column
:prop="`visit${slot}_rate`"
:label="`${slotLabel(slot)}接诊率`"
min-width="96"
min-width="104"
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>
</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-card>
@@ -162,7 +304,7 @@
<el-dialog
v-model="detailVisible"
:title="detailTitle"
width="980px"
width="1080px"
top="6vh"
destroy-on-close
>
@@ -193,6 +335,33 @@
<template #default="{ row }">{{ row.patient_phone || '—' }}</template>
</el-table-column>
<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="last_assign_time_text" label="最近指派时间" min-width="160" />
</template>
@@ -210,6 +379,9 @@
<span v-else>{{ row.order_no || `#${row.order_id}` }}</span>
</template>
</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="patient_name" label="患者" min-width="100">
<template #default="{ row }">
@@ -251,7 +423,9 @@ import {
revisitRateAssignLines,
revisitRateDeptOptions,
revisitRateOverview,
revisitRateVisitOrderLines
revisitRateVisitOrderLines,
type RevisitRateAssignKind,
type RevisitRateInheritMode
} from '@/api/stats'
import { hasPermission } from '@/utils/perm'
@@ -263,7 +437,14 @@ const OrderDetailDrawer = defineAsyncComponent(
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
visit2_rate_new_assign?: number | null
}
interface StatsRow extends MetricPack {
@@ -289,11 +470,14 @@ const canViewOrderLines = hasPermission(['stats.revisitRate/visitOrderLines'])
const canViewOrderDetail = hasPermission(['tcm.prescriptionOrder/detail'])
const canViewPatientDetail = hasPermission(['tcm.diagnosis/detail'])
/** 接口动态返回的诊次分档(最少 2~4,按数据扩展到五诊、六诊…) */
/** 接口动态返回的诊次分档(最少 2~4,按分子最大诊次 / 最大起算诊次扩展到五诊、六诊…) */
const visitSlots = ref<number[]>([2, 3, 4])
const cnDigits = ['零', '一', '二', '三', '四', '五', '六', '七', '八', '九', '十']
const slotLabel = (slot: number) => {
if (slot === 0) {
return '复诊合计'
}
if (slot >= 2 && slot <= 10) {
return `${cnDigits[slot]}诊`
}
@@ -307,6 +491,8 @@ const initNow = new Date()
const currentMonth = `${initNow.getFullYear()}-${String(initNow.getMonth() + 1).padStart(2, '0')}`
const month = ref<string>(currentMonth)
const deptId = ref<number | undefined>(undefined)
/** 口径:首次加载不传、取后端默认(config default_inherit_mode),之后以用户选择为准 */
const inheritMode = ref<RevisitRateInheritMode | ''>('')
const loading = ref(false)
const rows = ref<StatsRow[]>([])
const total = ref<MetricPack | null>(null)
@@ -314,6 +500,8 @@ const total = ref<MetricPack | null>(null)
const deptTreeOptions = ref<DeptTreeNode[]>([])
const deptTreeProps = { value: 'id', label: 'name', children: 'children' }
const isSlotMode = computed(() => inheritMode.value !== 'exclude')
const monthText = computed(() => {
const [y, m] = month.value.split('-')
return y && m ? `${y}年${Number(m)}月` : month.value
@@ -322,9 +510,30 @@ const monthText = computed(() => {
const formatRate = (rate: number | null | undefined) =>
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 = () => ({
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 {
@@ -341,11 +550,11 @@ const summaryCards = computed<SummaryCard[]>(() => {
const cards: SummaryCard[] = [
{
key: 'assigned',
label: '当月被指派总数',
label: isSlotMode.value ? '当月新指派数' : '当月被指派数',
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),
onClick: () => openAssignDetail(null)
onClick: () => openAssignDetail(null, 'new')
}
]
for (const slot of visitSlots.value) {
@@ -355,11 +564,19 @@ const summaryCards = computed<SummaryCard[]>(() => {
key: `visit${slot}`,
label: `当月${slotLabel(slot)}接诊率`,
value: t ? formatRate(rate) : '—',
sub: t ? `${slotLabel(slot)}下单 ${cnt} 单` : '',
sub: t ? `${slotLabel(slot)}下单 ${cnt} 单 / 分母 ${t[`visit${slot}_den`] ?? '—'}` : '',
clickable: !!(canViewOrderLines && cnt > 0),
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
})
@@ -374,15 +591,16 @@ const getSummaries = ({ columns }: { columns: any[] }) => {
return ''
}
const prop = String(col.property || '')
if (prop === 'assigned_count') {
return String(t.assigned_count)
if (prop === 'assigned_count' || prop === 'inherit_count' || prop === 'assigned_total_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] ?? '')
}
if (/^visit\d+_rate$/.test(prop)) {
return formatRate(t[prop])
}
return ''
})
}
@@ -404,6 +622,9 @@ const loadData = async () => {
if (res?.month) {
month.value = res.month
}
if (res?.inherit_mode === 'slot' || res?.inherit_mode === 'exclude') {
inheritMode.value = res.inherit_mode
}
} finally {
loading.value = false
}
@@ -449,6 +670,8 @@ const detailType = ref<'assign' | 'orders'>('assign')
const detailTitle = ref('')
const detailLoading = ref(false)
const detailRows = ref<any[]>([])
/** 订单明细的诊次(0 = 复诊合计,表格多显示「诊次」列) */
const detailSlot = ref(2)
/** row 为 null 时表示合计(当前部门筛选范围) */
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 || '' }
}
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) {
return
}
const scope = rowScope(row)
detailType.value = 'assign'
detailTitle.value = `${scope.label} · 被指派明细(${monthText.value})`
detailTitle.value = `${scope.label} · ${assignKindLabel[kind]}(${monthText.value})`
detailVisible.value = true
detailLoading.value = true
detailRows.value = []
@@ -475,7 +704,8 @@ const openAssignDetail = async (row: StatsRow | null) => {
const res = await revisitRateAssignLines({
...baseQuery(),
assistant_id: scope.assistant_id,
dept_id: scope.dept_id
dept_id: scope.dept_id,
kind
})
detailRows.value = res?.rows ?? []
} finally {
@@ -483,12 +713,14 @@ const openAssignDetail = async (row: StatsRow | null) => {
}
}
/** slot=0 表示全部诊次 ≥ 2(复诊合计单数) */
const openOrderDetail = async (slot: number, row: StatsRow | null) => {
if (!canViewOrderLines) {
return
}
const scope = rowScope(row)
detailType.value = 'orders'
detailSlot.value = slot
detailTitle.value = `${scope.label} · ${slotLabel(slot)}订单明细(${monthText.value})`
detailVisible.value = true
detailLoading.value = true
@@ -529,6 +761,7 @@ const openPatientDetail = (diagnosisId: number) => {
const handleReset = () => {
month.value = currentMonth
deptId.value = undefined
inheritMode.value = ''
loadData()
}
@@ -623,6 +856,22 @@ onMounted(() => {
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-footer {
@@ -640,5 +889,10 @@ onMounted(() => {
p + p {
margin-top: 6px;
}
.rate-caliber-note {
color: var(--el-text-color-secondary);
font-size: 12px;
}
}
</style>
@@ -12,9 +12,21 @@
{{ assistantLogCellText(row, 'to') }}
</template>
</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 }">
<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>
</template>
</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())}`
}
/** 对应日志表 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 {
const nameKey = side === 'from' ? 'from_assistant_name' : 'to_assistant_name'
const idKey = side === 'from' ? 'from_assistant_id' : 'to_assistant_id'
@@ -132,4 +158,11 @@ defineExpose({ refresh: load })
color: var(--el-text-color-secondary);
cursor: help;
}
.assign-log-inherit-slot {
margin-top: 2px;
font-size: 12px;
line-height: 1.3;
color: var(--el-text-color-secondary);
}
</style>
@@ -68,7 +68,11 @@
'is-high': getCell(row.key, date).isHigh,
'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)"
@keydown.enter="handleCellClick(row.key, date)"
@keydown.space.prevent="handleCellClick(row.key, date)"
>
<template v-if="row.key === 'tracking' && getTrackingContent(date)">
<el-tooltip placement="top" effect="light" :show-after="150">
@@ -90,6 +94,9 @@
</template>
<template v-else-if="getCell(row.key, date).value">
<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>
<el-tooltip
v-if="getCell(row.key, date).isPatientSelf"
@@ -100,7 +107,10 @@
<span class="daily-matrix__cell-patient">自录</span>
</el-tooltip>
</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>
</template>
</el-table-column>
@@ -109,6 +119,7 @@
<div class="daily-matrix__chart">
<div class="daily-matrix__section-title">血糖趋势</div>
<p class="daily-matrix__hint">按日展示最新有效血糖值;点击表格查看当天全部原始记录。</p>
<v-charts
v-if="bloodTrendHasData"
class="daily-matrix__chart-canvas"
@@ -128,6 +139,84 @@
/>
</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 ?? '—' }} 分钟</dd>
<dt>强度</dt><dd>{{ record.intensity_text || 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-form :model="bloodForm" label-width="120px">
<el-form-item label="记录日期" required>
@@ -148,6 +237,12 @@
class="w-full"
/>
</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-input-number v-model="bloodForm.fasting_blood_sugar" :precision="2" :step="0.1" :min="0" :max="50" />
<span class="ml-2">mmol/L</span>
@@ -353,6 +448,10 @@ interface BloodRecord {
patient_id?: number
record_date?: 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
fasting_blood_sugar?: any
postprandial_blood_sugar?: any
@@ -364,12 +463,11 @@ interface BloodRecord {
remark?: string
/** 来源:0-医生录入 1-患者自录(小程序日常记录页) */
source?: number
/** 合并后该日是否含有「患者自录」记录 */
has_patient_self?: boolean
[key: string]: any
}
interface DietRecord {
record_time?: string
id?: number | string
diagnosis_id?: number
patient_id?: number
@@ -388,6 +486,7 @@ interface DietRecord {
}
interface ExerciseRecord {
record_time?: string
id?: number | string
diagnosis_id?: number
patient_id?: number
@@ -437,6 +536,9 @@ const bloodDialogVisible = ref(false)
const dietDialogVisible = ref(false)
const exerciseDialogVisible = ref(false)
const trackingDialogVisible = ref(false)
const dayDetailVisible = ref(false)
const detailDate = ref('')
const originalBloodTime = ref('')
const bloodSubmitting = ref(false)
const dietSubmitting = ref(false)
const exerciseSubmitting = ref(false)
@@ -455,7 +557,7 @@ const bloodDialogTitle = computed(() => (bloodForm.value.id ? '编辑血糖血
const dietDialogTitle = computed(() => (dietForm.value.id ? '编辑饮食记录' : '新增饮食记录'))
const exerciseDialogTitle = computed(() => (exerciseForm.value.id ? '编辑运动记录' : '新增运动记录'))
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 tableWrapRef = ref<HTMLElement | null>(null)
const tableWrapWidth = ref(0)
@@ -583,70 +685,63 @@ const hasPatientSelfRecord = computed(() => {
return bloodRecords.value.some((r) => Number((r as any).source) === 1)
})
const bloodByDate = computed(() => {
const map = new Map<string, BloodRecord>()
for (const record of bloodRecords.value) {
// Group actual rows, never a synthetic record bearing the first row's id.
function groupRecordsByDate<T extends { record_date?: string }>(records: T[]): Map<string, T[]> {
const map = new Map<string, T[]>()
for (const record of records) {
const date = String(record.record_date || '')
if (!date) continue
if (!map.has(date)) {
map.set(date, {
id: record.id,
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
}
const rows = map.get(date) || []
rows.push(record)
map.set(date, rows)
}
return map
})
}
const dietByDate = computed(() => {
const map = new Map<string, DietRecord>()
for (const record of dietRecords.value) {
const date = String(record.record_date || '')
if (!date || map.has(date)) continue
map.set(date, record)
}
return map
})
const bloodByDate = computed(() => groupRecordsByDate(bloodRecords.value))
const dietByDate = computed(() => groupRecordsByDate(dietRecords.value))
const exerciseByDate = computed(() => groupRecordsByDate(exerciseRecords.value))
const dayBloodRecords = computed(() => bloodByDate.value.get(detailDate.value) || [])
const dayDietRecords = computed(() => dietByDate.value.get(detailDate.value) || [])
const dayExerciseRecords = computed(() => exerciseByDate.value.get(detailDate.value) || [])
const mealFields = [{ key: 'breakfast', label: '早餐' }, { key: 'lunch', label: '午餐' }, { key: 'dinner', label: '晚餐' }]
const exerciseByDate = computed(() => {
const map = new Map<string, ExerciseRecord>()
for (const record of exerciseRecords.value) {
const date = String(record.record_date || '')
if (!date || map.has(date)) continue
map.set(date, record)
function recordTimeLabel(record: { record_time?: string }) {
return record.record_time ? String(record.record_time) : '未记录具体时间'
}
function recordTimePeriodLabel(record: BloodRecord) {
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) {
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 map = new Map<string, TrackingNoteRecord>()
@@ -663,9 +758,10 @@ const chartDates = computed(() => [...visibleDates.value].reverse())
const bloodTrendSeries = computed(() => {
return chartDates.value.reduce(
(acc, date) => {
const blood = bloodByDate.value.get(date)
acc.fasting.push(parseTrendNumber(blood?.fasting_blood_sugar))
acc.postprandial.push(parseTrendNumber(blood?.postprandial_blood_sugar))
const records = bloodByDate.value.get(date) || []
const latestValue = (field: keyof BloodRecord) => records.map((record) => parseTrendNumber(record[field])).find((value) => value !== null) ?? null
acc.fasting.push(latestValue('fasting_blood_sugar'))
acc.postprandial.push(latestValue('postprandial_blood_sugar'))
return acc
},
{
@@ -754,89 +850,49 @@ function parseTrendNumber(value: unknown): number | null {
}
function getCell(metric: string, date: string): { value: string; isHigh: boolean; hasRecord: boolean; isPatientSelf?: boolean } {
const blood = bloodByDate.value.get(date)
const diet = dietByDate.value.get(date)
const exercise = exerciseByDate.value.get(date)
const blood = bloodByDate.value.get(date) || []
const diet = dietByDate.value.get(date) || []
const exercise = exerciseByDate.value.get(date) || []
const tracking = trackingByDate.value.get(date)
const isPatientSelf = !!blood?.has_patient_self
switch (metric) {
case 'fasting':
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 bloodFields: Record<string, keyof BloodRecord> = {
fasting: 'fasting_blood_sugar', postprandial: 'postprandial_blood_sugar',
other: 'other_blood_sugar', western: 'western_medicine', insulin: 'insulin'
}
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 ? `${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() {
if (!diagnosisId.value) {
const seq = ++requestSeq
const requestDiagnosisId = diagnosisId.value
if (!requestDiagnosisId) {
loading.value = false
bloodRecords.value = []
dietRecords.value = []
exerciseRecords.value = []
@@ -845,23 +901,23 @@ async function fetchTracking() {
}
const { start, end } = currentRange.value
if (rangeMode.value === 'custom' && (!start || !end)) return
const seq = ++requestSeq
const requestDates = [...visibleDates.value]
loading.value = true
try {
const data: any = await diagnosisTrackingWindow({
id: diagnosisId.value,
id: requestDiagnosisId,
start_date: start,
end_date: end
})
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 : []
dietRecords.value = Array.isArray(data?.diet_records) ? data.diet_records : []
exerciseRecords.value = Array.isArray(data?.exercise_records) ? data.exercise_records : []
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 {
if (seq === requestSeq) {
@@ -890,76 +946,83 @@ 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, images: [...(source?.images || [])] }
exerciseDialogVisible.value = true
}
function openQuickAdd(kind: DialogKind) {
if (!canEditDailyRecord.value) return
const date = visibleDates.value[0] || formatDate(new Date())
if (kind === 'blood') {
bloodForm.value = createBloodForm(date)
bloodDialogVisible.value = true
return
}
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
if (kind === 'blood') return openBloodRecord(undefined, date)
if (kind === 'diet') return openDietRecord(undefined, date)
if (kind === 'exercise') return openExerciseRecord(undefined, date)
handleCellClick('tracking', todayDate.value)
}
function handleCellClick(metric: string, date: string) {
if (!canEditDailyRecord.value) 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 (!isCellClickable(metric, date)) return
if (metric === 'tracking') {
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
return
}
const source = exerciseByDate.value.get(date)
exerciseForm.value = source ? { ...createExerciseForm(date), ...source } : createExerciseForm(date)
exerciseDialogVisible.value = true
const count = (bloodByDate.value.get(date)?.length || 0) + (dietByDate.value.get(date)?.length || 0) + (exerciseByDate.value.get(date)?.length || 0)
if (count) {
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() {
if (!canEditDailyRecord.value || bloodSubmitting.value) return
if (!bloodForm.value.record_date) {
feedback.msgWarning('请选择记录日期')
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
try {
if (bloodForm.value.id) {
await bloodRecordEdit(bloodForm.value)
if (payload.id) {
await bloodRecordEdit(payload)
} else {
await bloodRecordAdd(bloodForm.value)
await bloodRecordAdd(payload)
}
if (targetDiagnosisId !== diagnosisId.value) return
bloodDialogVisible.value = false
await fetchTracking()
} finally {
@@ -968,17 +1031,21 @@ async function submitBlood() {
}
async function submitDiet() {
if (!canEditDailyRecord.value || dietSubmitting.value) return
if (!dietForm.value.record_date) {
feedback.msgWarning('请选择记录日期')
return
}
const targetDiagnosisId = diagnosisId.value
const payload = { ...dietForm.value }
dietSubmitting.value = true
try {
if (dietForm.value.id) {
await dietRecordEdit(dietForm.value)
if (payload.id) {
await dietRecordEdit(payload)
} else {
await dietRecordAdd(dietForm.value)
await dietRecordAdd(payload)
}
if (targetDiagnosisId !== diagnosisId.value) return
dietDialogVisible.value = false
await fetchTracking()
} finally {
@@ -987,6 +1054,7 @@ async function submitDiet() {
}
async function submitExercise() {
if (!canEditDailyRecord.value || exerciseSubmitting.value) return
if (!exerciseForm.value.record_date) {
feedback.msgWarning('请选择记录日期')
return
@@ -999,13 +1067,16 @@ async function submitExercise() {
feedback.msgWarning('请输入运动时长')
return
}
const targetDiagnosisId = diagnosisId.value
const payload = { ...exerciseForm.value }
exerciseSubmitting.value = true
try {
if (exerciseForm.value.id) {
await exerciseRecordEdit(exerciseForm.value)
if (payload.id) {
await exerciseRecordEdit(payload)
} else {
await exerciseRecordAdd(exerciseForm.value)
await exerciseRecordAdd(payload)
}
if (targetDiagnosisId !== diagnosisId.value) return
exerciseDialogVisible.value = false
await fetchTracking()
} finally {
@@ -1014,6 +1085,7 @@ async function submitExercise() {
}
async function submitTracking() {
if (trackingSubmitting.value) return
if (!trackingForm.value.note_date) {
feedback.msgWarning('请选择记录日期')
return
@@ -1027,12 +1099,14 @@ async function submitTracking() {
feedback.msgWarning('请输入跟踪备注')
return
}
const targetDiagnosisId = diagnosisId.value
trackingSubmitting.value = true
try {
await diagnosisAddTrackingNote({
diagnosis_id: diagnosisId.value,
diagnosis_id: targetDiagnosisId,
tracking_content: content
})
if (targetDiagnosisId !== diagnosisId.value) return
trackingDialogVisible.value = false
await fetchTracking()
} finally {
@@ -1057,17 +1131,23 @@ function getTrackingPreview(content: string) {
}
function isCellClickable(metric: string, date: string) {
if (!canEditDailyRecord.value) return false
if (metric !== 'tracking') return true
return date === todayDate.value || !!getTrackingContent(date)
if (metric === 'tracking') return !!getTrackingContent(date) || (canEditDailyRecord.value && date === todayDate.value)
return canEditDailyRecord.value || getDayRecordCount(metric, date) > 0
}
watch(
() => props.diagnosisId,
(value) => {
if (value) {
fetchTracking()
}
() => {
dayDetailVisible.value = false
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)
}
)
@@ -1081,6 +1161,7 @@ onMounted(() => {
})
onUnmounted(() => {
requestSeq++
window.removeEventListener('resize', updateTableWrapWidth)
})
@@ -1116,6 +1197,29 @@ defineExpose({ refresh: fetchTracking })
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 {
margin-top: 16px;
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,628 @@
<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="65" align="right">
<template #default="{ row }"><el-button link type="primary" :disabled="busy" @click="openTask(row.id)">查看</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>
<el-tag size="small" :type="statusType(detail.status)">{{ followupTaskStatus(detail) }}</el-tag>
</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>
<section v-if="hasStereo" 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>
<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, 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, 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' | ''>('')
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 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 || [])
? '写入目标或记录日期已更改,请先保存审核草稿取得新快照,再核对并采纳'
: 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
}
async function readDetail(id: number, preserve = false) {
const ticket = scope.capture()
const fresh = await followupAudioDetail(id, ticket.signal)
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)
}
} 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()
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() } }
}
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; }
.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' && 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,180 @@
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))
}
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 : '请求失败;当前审核内容已保留,请刷新任务状态后确认'
}
+103 -6
View File
@@ -52,7 +52,7 @@
</p>
</div>
</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-form
@@ -660,6 +660,7 @@
lazy
>
<daily-matrix
ref="dailyMatrixRef"
v-if="formData.id"
:diagnosis-id="Number(formData.id)"
:patient-id="Number(formData.patient_id)"
@@ -670,6 +671,21 @@
<el-empty v-else description="请先保存诊单后查看" />
</el-tab-pane>
<el-tab-pane v-if="followupCapabilities?.enabled && formData.id" label="回访录音" name="followupAudio" lazy>
<FollowupAudioPanel
v-if="visible && formData.id && followupCapabilities"
: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-tab-pane>
<!-- 病历记录标签页(开方后自动显示) -->
<el-tab-pane label="处方" name="bingli" :disabled="!formData.id" v-if="hasPermission(['tcm.diagnosis/chufang'])">
<div v-if="formData.id" class="bingli-tab-content">
@@ -785,6 +801,7 @@
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { tcmDiagnosisAdd, tcmDiagnosisEdit, tcmDiagnosisDetail, checkPhone, checkIdCard, diagnosisTrackingNotes } from '@/api/tcm'
import { getDictData } from '@/api/app'
import feedback from '@/utils/feedback'
@@ -794,6 +811,8 @@ import useAppStore from '@/stores/modules/app'
import { ElMessage } from 'element-plus'
import { QuestionFilled } from '@element-plus/icons-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 CallRecordPanel from './components/CallRecordPanel.vue'
import ImChatRecordPanel from './components/ImChatRecordPanel.vue'
@@ -811,6 +830,12 @@ const getImageUrl = (url: string) => (url?.indexOf?.('http') === 0 ? url : appSt
const caseRecordListRef = ref()
const callRecordPanelRef = ref<{ refresh?: () => void } | null>(null)
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 basicSnapshot = ref('')
let diagnosisGeneration = 0
let capabilitiesRequest: AbortController | undefined
const visible = ref(false)
/** 只读查看:与 consumers 处方等处「查看诊单」一致,全部 Tab 仅展示不可保存 */
@@ -970,6 +995,45 @@ const formData = ref({
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('录音已采纳,但病历或日常记录刷新失败,请手动刷新查看')
}
}
watch([visible, () => formData.value.id], async ([isVisible, id]) => {
capabilitiesRequest?.abort()
followupCapabilities.value = null
if (!isVisible || !id) return
const request = new AbortController()
capabilitiesRequest = request
try {
const result = await followupAudioCapabilities(Number(id), request.signal)
if (!request.signal.aborted && visible.value && String(formData.value.id) === String(id)) followupCapabilities.value = result
} catch { /* Fail closed: an unavailable capability endpoint must not expose this feature. */ }
})
onBeforeUnmount(() => { diagnosisGeneration++; capabilitiesRequest?.abort() })
/** 就诊卡创建来源(与 zyt_tcm_diagnosis.create_source 一致) */
const createSourceOptions = [
{ value: '', label: '未设置' },
@@ -1260,8 +1324,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 })
if (generation !== diagnosisGeneration || !visible.value) return
if (preserveDirty && basicDirty.value) {
feedback.msgWarning('录音已采纳,病历仍保留你的未保存修改;请核对后再保存,未用刷新覆盖输入')
return
}
originalPhone.value = data.phone || ''
originalIdCard.value = data.id_card || ''
@@ -1277,6 +1347,7 @@ async function loadDiagnosisDetailIntoForm(id: number): Promise<void> {
formData.value.show_card = Number(data.show_card) === 0 ? 0 : 1
const y = data.diabetes_discovery_year
formData.value.diabetes_discovery_year = y == null || y === '' ? '' : String(y)
basicSnapshot.value = basicFingerprint()
}
const resetPatientBasicLockState = () => {
@@ -1286,6 +1357,11 @@ const resetPatientBasicLockState = () => {
}
const open = async (type: string, id?: number) => {
const generation = ++diagnosisGeneration
submitting.value = false
basicSnapshot.value = ''
followupCapabilities.value = null
formData.value.id = ''
viewOnly.value = false
mode.value = type
visible.value = true
@@ -1296,6 +1372,7 @@ const open = async (type: string, id?: number) => {
// 加载字典数据
await getDictOptions()
if (generation !== diagnosisGeneration || !visible.value) return
if (type === 'edit' && id) {
await loadDiagnosisDetailIntoForm(id)
@@ -1310,6 +1387,11 @@ const open = async (type: string, id?: number) => {
/** 只读打开:与编辑同一套界面与全部 Tab,不可保存 */
const openViewOnly = async (id: number) => {
if (!id) return
const generation = ++diagnosisGeneration
submitting.value = false
basicSnapshot.value = ''
followupCapabilities.value = null
formData.value.id = ''
viewOnly.value = true
mode.value = 'edit'
visible.value = true
@@ -1318,13 +1400,17 @@ const openViewOnly = async (id: number) => {
idCardRevealUnlocked.value = false
resetPatientBasicLockState()
await getDictOptions()
if (generation !== diagnosisGeneration || !visible.value) return
await loadDiagnosisDetailIntoForm(id)
}
const handleSubmit = async () => {
if (viewOnly.value) return
if (viewOnly.value || submitting.value) return
const generation = diagnosisGeneration
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
try {
// 准备提交数据,使用原始完整数据
@@ -1336,6 +1422,7 @@ const handleSubmit = async () => {
if (mode.value === 'add') {
const result = await tcmDiagnosisAdd(submitData)
if (generation !== diagnosisGeneration || !visible.value) return
// 如果是新增,保存成功后切换到编辑模式,这样可以添加血糖血压记录
if (result && result.id) {
@@ -1343,11 +1430,14 @@ const handleSubmit = async () => {
phoneRevealUnlocked.value = false
idCardRevealUnlocked.value = false
formData.value.id = result.id
submittedForm.id = result.id
// 重新获取详情,确保patient_id等字段正确
try {
const detail = await tcmDiagnosisDetail({ id: result.id })
if (generation !== diagnosisGeneration || !visible.value) return
formData.value.patient_id = detail.patient_id
submittedForm.patient_id = detail.patient_id
// 更新原始数据
originalPhone.value = detail.phone || ''
@@ -1367,11 +1457,13 @@ const handleSubmit = async () => {
await tcmDiagnosisEdit(submitData)
}
if (generation !== diagnosisGeneration || !visible.value) return
basicSnapshot.value = JSON.stringify(submittedForm)
emit('success')
} catch (error) {
console.error('提交失败:', error)
} finally {
submitting.value = false
if (generation === diagnosisGeneration) submitting.value = false
}
}
@@ -1395,7 +1487,12 @@ const handleOpenPrescription = () => {
prescriptionRef.value?.open(data)
}
const handleClose = () => {
const handleClose = async () => {
if (followupAudioRef.value && !await followupAudioRef.value.confirmLeave()) return
diagnosisGeneration++
capabilitiesRequest?.abort()
followupCapabilities.value = null
basicSnapshot.value = ''
formRef.value?.resetFields()
formData.value = {
id: '',
+286 -81
View File
@@ -14,6 +14,7 @@
formData.pending_assign !== '1' &&
formData.pending_booking !== '1' &&
formData.completed_appointment !== '1' &&
formData.revisit_state === '' &&
!hasLatestAppointmentFilter() &&
formData.appointment_date === tab.value
}
@@ -30,6 +31,14 @@
待预约
<em v-if="pendingBookingCount !== undefined">{{ pendingBookingCount }}</em>
</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
:class="['chip', 'chip-completed-visit', { active: formData.completed_appointment === '1' }]"
@click="handleCompletedVisitTabClick"
@@ -147,6 +156,36 @@
:value="String(item.value)"
/>
</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>
</div>
</el-card>
@@ -278,22 +317,20 @@
<span v-else class="status-unconfirmed">未确认</span>
</template>
</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 }">
<div v-if="row.has_prescription" class="followup-cell">
<div class="followup-time">{{ row.followup_time_text || '—' }}</div>
<div class="followup-doctor">{{ row.followup_doctor_name || '—' }}</div>
<el-tag
v-if="row.followup_rx_voided"
type="info"
size="small"
effect="plain"
class="followup-void-tag"
>
处方已作废
</el-tag>
</div>
<span v-else class="followup-none">无</span>
<revisit-node-cell
:due="revisitDueOf(row)"
:has-prescription="!!row.has_prescription"
:followup-doctor-name="row.followup_doctor_name || ''"
:prescribed-text="prescribedTextOf(row)"
/>
</template>
</el-table-column>
<el-table-column label="助理" width="100" show-overflow-tooltip>
@@ -301,12 +338,6 @@
<span class="assistant-cell">{{ getAssistantName(row.assistant_id || row.assistant) }}</span>
</template>
</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
label="未服务天数"
prop="unserved_days"
@@ -519,7 +550,6 @@
</el-form-item>
<el-form-item label="继承">
<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 v-if="assignMode === 'batch'" label="已选择">
<div class="text-gray-500">{{ batchAssignIds.length }} 条诊单记录</div>
@@ -737,7 +767,7 @@
</template>
<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 { cancelAppointment } from '@/api/doctor'
import { usePaging } from '@/hooks/usePaging'
@@ -752,6 +782,15 @@ import { useRoute, useRouter } from 'vue-router'
import { computed, defineAsyncComponent, onMounted, onUnmounted, watch } from 'vue'
import dayjs from 'dayjs'
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 DetailPopup = defineAsyncComponent(() => import('./detail.vue'))
@@ -798,6 +837,12 @@ const formData = reactive({
latest_assign_end_date: '' as string,
/** 未服务天数排序:desc=天数多到少 asc=少到多 */
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',
appointment_date: '' as string,
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_channel_source: formData.latest_appointment_channel_source,
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> {
return {
page_no: 1,
@@ -915,6 +962,8 @@ function buildDateCountRequestPayload(overrides: Record<string, unknown> = {}):
pending_assign_order_month: '',
pending_assign_keyword: '',
sort_unserved_days: '',
revisit_state: '',
sort_revisit_due: '',
...overrides
}
}
@@ -960,6 +1009,9 @@ function clearSecondaryFiltersWhenPendingAssignWideSearch() {
formData.latest_assign_start_date = ''
formData.latest_assign_end_date = ''
formData.sort_unserved_days = ''
formData.revisit_state = ''
formData.revisit_days_bucket = ''
formData.sort_revisit_due = ''
formData.pending_assign_order_month = ''
activeTab.value = 'all'
if (kw1 !== '') {
@@ -996,6 +1048,7 @@ const handleDateTabClick = async (value: string) => {
formData.pending_booking = ''
formData.completed_appointment = ''
formData.has_appointment = ''
clearRevisitTabFilters()
clearLatestAppointmentFilters()
formData.appointment_date = value
pager.page = 1
@@ -1012,6 +1065,7 @@ const handlePendingBookingTabClick = async () => {
formData.completed_appointment = ''
formData.appointment_date = ''
formData.has_appointment = '0'
clearRevisitTabFilters()
clearLatestAppointmentFilters()
pager.page = 1
await getLists()
@@ -1027,6 +1081,7 @@ const handleCompletedVisitTabClick = async () => {
formData.completed_appointment = '1'
formData.has_appointment = ''
formData.appointment_date = ''
clearRevisitTabFilters()
clearLatestAppointmentFilters()
pager.page = 1
await getLists()
@@ -1039,6 +1094,7 @@ const handlePendingAssignTabClick = async () => {
formData.completed_appointment = ''
formData.has_appointment = ''
formData.appointment_date = ''
clearRevisitTabFilters()
clearLatestAppointmentFilters()
if (!formData.pending_assign_order_month) {
formData.pending_assign_order_month = dayjs().format('YYYY-MM')
@@ -1057,10 +1113,66 @@ const onPendingAssignOrderMonthChange = async (val: string | null) => {
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 () => {
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(
buildDateCountRequestPayload({ appointment_date: yesterdayStr.value, has_appointment: '' }) as any
),
@@ -1081,7 +1193,10 @@ const fetchDateCounts = async () => {
tcmDiagnosisLists(
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 = {
[yesterdayStr.value]: yesterday?.count ?? 0,
@@ -1094,6 +1209,7 @@ const fetchDateCounts = async () => {
pendingBookingCount.value = noApt?.count ?? 0
completedVisitCount.value = doneVisit?.count ?? 0
pendingAssignCount.value = pending?.count ?? 0
revisitDueCount.value = revisitDue?.count ?? 0
} catch (e) {
console.error('获取日期数量失败', e)
}
@@ -1252,12 +1368,11 @@ const handleTableSortChange = ({
prop: string
order: 'ascending' | 'descending' | null
}) => {
if (prop === 'unserved_days') {
formData.sort_unserved_days =
order === 'ascending' ? 'asc' : order === 'descending' ? 'desc' : ''
} else {
formData.sort_unserved_days = ''
}
const dir: '' | 'asc' | 'desc' =
order === 'ascending' ? 'asc' : order === 'descending' ? 'desc' : ''
// 按列 prop 分派;两种排序后端互斥,切换列时清掉另一列
formData.sort_unserved_days = prop === 'unserved_days' ? dir : ''
formData.sort_revisit_due = prop === 'revisit_due_date' ? dir : ''
pager.page = 1
getLists()
}
@@ -1311,6 +1426,9 @@ const handleReset = () => {
formData.latest_assign_start_date = ''
formData.latest_assign_end_date = ''
formData.sort_unserved_days = ''
formData.revisit_state = ''
formData.revisit_days_bucket = ''
formData.sort_revisit_due = ''
formData.diagnosis_confirmed = ''
formData.appointment_date = ''
formData.has_appointment = ''
@@ -1343,8 +1461,10 @@ const unservedDaysClass = (n: unknown) => {
return 'unserved-green'
}
// 行高亮:未确认诊单优先,其次未挂号
// 行高亮优先级(方案 D3-c 默认 overdue_first):复诊超期/过号 > 未确认 > 未挂号
const getRowClassName = ({ row }: { row: any }) => {
const revisitCls = revisitRowClassName(revisitDueOf(row))
if (revisitCls) return revisitCls
if (!isDiagnosisConfirmed(row)) return 'row-unconfirmed'
if (!row.has_appointment) return 'row-no-appointment'
return ''
@@ -1356,6 +1476,8 @@ const prescriptionRef = ref()
const appointmentRef = ref()
/** 从路由带 id 进入时,诊单弹窗为异步组件,需在 ref 就绪后再 open */
const pendingRouteDiagnosisId = ref<number | null>(null)
/** 路由 ?id=&action=appointment 深链(复诊到期弹窗「去挂号」):挂号弹窗同为异步组件,先暂存行再 open */
const pendingRouteAppointmentRow = ref<Record<string, any> | null>(null)
const userStore = useUserStore()
watch(
@@ -1370,6 +1492,53 @@ watch(
{ 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 watchCallDiagnosisId = ref(0)
@@ -1614,13 +1783,13 @@ const handleConfirmAssign = async () => {
feedback.msgWarning('请选择医助')
return
}
assignLoading.value = true
try {
if (assignMode.value === 'single') {
// 单独指派
await tcmDiagnosisAssign({
id: currentRow.value.id,
await tcmDiagnosisAssign({
id: currentRow.value.id,
assistant_id: assignForm.value.assistant_id,
is_inherit: assignForm.value.is_inherit ? 1 : 0
})
@@ -2220,22 +2389,57 @@ const goReadonly = (row: any) => {
}
onMounted(() => {
// 默认展示当天挂号,突出重点
formData.appointment_date = todayStr.value
// 字典与列表并行,避免 await 字典阻塞首屏列表(LCP)
getDictOptions()
getLists()
scheduleDeferredDateCounts()
if (String(route.query.revisit_state ?? '') === 'due') {
// 复诊到期弹窗「查看全部」深链:直接激活「复诊到期」chip(内部会 getLists + fetchDateCounts)
void handleRevisitDueTabClick()
} else {
// 默认展示当天挂号,突出重点
formData.appointment_date = todayStr.value
getLists()
scheduleDeferredDateCounts()
}
silentListPollTimer = setInterval(silentRefreshListsAndDateCounts, SILENT_LIST_POLL_MS)
const raw = route.query.id
if (raw != null && 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(() => {
if (silentListPollTimer != null) {
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 {
margin-left: 8px;
color: var(--el-color-success);
@@ -2451,6 +2670,14 @@ onUnmounted(() => {
.latest-appointment-channel {
width: 170px;
}
.revisit-state-select {
width: 170px;
}
.revisit-days-bucket-select {
width: 130px;
}
}
.list-card {
@@ -2641,17 +2868,6 @@ onUnmounted(() => {
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=绿 */
.unserved-days {
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 {
font-size: 13px;
color: var(--el-text-color-secondary);
@@ -2782,6 +2971,22 @@ onUnmounted(() => {
: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;
}
/* 复诊到期未挂号 / 过号未面诊:红色左边条(同 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 {
+70 -9
View File
@@ -25,6 +25,7 @@
formData.pending_assign !== '1' &&
formData.pending_booking !== '1' &&
formData.completed_appointment !== '1' &&
formData.revisit_state === '' &&
formData.appointment_date === tab.value
}
]"
@@ -40,6 +41,13 @@
待预约
<em v-if="pendingBookingCount !== undefined">{{ pendingBookingCount }}</em>
</span>
<span
:class="['dh5-chip', 'dh5-chip--revisit', { active: formData.revisit_state === 'due' }]"
@click="handleRevisitDueTabClick"
>
复诊到期
<em v-if="revisitDueCount !== undefined">{{ revisitDueCount }}</em>
</span>
<span
:class="['dh5-chip', 'dh5-chip--done', { active: formData.completed_appointment === '1' }]"
@click="handleCompletedVisitTabClick"
@@ -170,13 +178,17 @@
</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>
<div class="dh5-row-val">
<span>{{ row.followup_time_text || '—' }}</span>
<span class="dh5-meta-doctor">{{ row.followup_doctor_name || '—' }}</span>
<span v-if="row.followup_rx_voided" class="dh5-tag dh5-tag--gray">处方作废</span>
<div class="dh5-row-val dh5-row-val--revisit">
<revisit-node-cell
compact
:due="revisitDueOf(row)"
:has-prescription="!!row.has_prescription"
:followup-doctor-name="row.followup_doctor_name || ''"
:prescribed-text="row.followup_rx_voided ? '处方作废' : ''"
/>
</div>
</div>
@@ -625,6 +637,13 @@ import {
import useUserStore from '@/stores/modules/user'
import { defineAsyncComponent, computed, reactive, ref, onMounted, onUnmounted } from 'vue'
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 DetailPopup = defineAsyncComponent(() => import('./detail.vue'))
@@ -685,7 +704,9 @@ const formData = reactive({
has_appointment: '' as '' | '0' | '1',
pending_assign: '' 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)
@@ -740,6 +761,7 @@ const handleReset = () => {
formData.pending_assign = ''
formData.pending_booking = ''
formData.completed_appointment = ''
formData.revisit_state = ''
pager.page = 1
getLists()
fetchDateCounts()
@@ -767,6 +789,7 @@ const handleDateTabClick = async (value: string) => {
formData.pending_assign = ''
formData.pending_booking = ''
formData.completed_appointment = ''
formData.revisit_state = ''
formData.has_appointment = ''
formData.appointment_date = value
pager.page = 1
@@ -778,6 +801,7 @@ const handlePendingBookingTabClick = async () => {
formData.pending_assign = ''
formData.pending_booking = '1'
formData.completed_appointment = ''
formData.revisit_state = ''
formData.appointment_date = ''
formData.has_appointment = '0'
pager.page = 1
@@ -789,6 +813,7 @@ const handleCompletedVisitTabClick = async () => {
formData.pending_assign = ''
formData.pending_booking = ''
formData.completed_appointment = '1'
formData.revisit_state = ''
formData.has_appointment = ''
formData.appointment_date = ''
pager.page = 1
@@ -800,6 +825,7 @@ const handlePendingAssignTabClick = async () => {
formData.pending_assign = '1'
formData.pending_booking = ''
formData.completed_appointment = ''
formData.revisit_state = ''
formData.has_appointment = ''
formData.appointment_date = ''
pager.page = 1
@@ -807,9 +833,27 @@ const handlePendingAssignTabClick = async () => {
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 () => {
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: dayBeforeStr.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({ has_appointment: 0, 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 = {
[yesterdayStr.value]: yesterday?.count ?? 0,
@@ -831,6 +876,7 @@ const fetchDateCounts = async () => {
pendingBookingCount.value = noApt?.count ?? 0
completedVisitCount.value = doneVisit?.count ?? 0
pendingAssignCount.value = pending?.count ?? 0
revisitDueCount.value = revisitDue?.count ?? 0
} catch (e) {
console.error('获取日期数量失败', e)
}
@@ -898,7 +944,9 @@ const isDiagnosisConfirmed = (row: any) => {
return records.length > 0 && records.some((r: any) => r.is_confirmed == 1)
}
// 卡片左边条优先级(同 PC 行高亮):复诊超期/过号 > 未确认 > 未挂号
const getCardClass = (row: any) => {
if (isRevisitDueHighlight(revisitDueOf(row))) return 'dh5-card--overdue'
if (!isDiagnosisConfirmed(row)) return 'dh5-card--unconfirmed'
if (!row.has_appointment) return 'dh5-card--noapt'
return ''
@@ -1582,6 +1630,8 @@ $dh5-card-bg: #ffffff;
.dh5-chip--booking.active { background: $dh5-warn; }
.dh5-chip--done.active { background: $dh5-success; }
.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 {
display: flex;
@@ -1675,6 +1725,12 @@ $dh5-card-bg: #ffffff;
.dh5-card--unconfirmed::before { background: $dh5-danger; }
.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 {
display: flex;
@@ -1800,6 +1856,11 @@ $dh5-card-bg: #ffffff;
font-size: 12px;
}
/* 复诊节点块:单元格组件自带多行布局,取消外层 flex 换行间距 */
.dh5-row-val--revisit {
display: block;
}
/* 挂号块 - 状态色 */
.dh5-row--apt {
background: #fafbfc;
@@ -25,6 +25,17 @@
</span>
</el-tooltip>
<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>
@@ -156,6 +167,12 @@ import { diagnosisReadonlyDetail } from '@/api/tcm'
import { getDoctorNotes } from '@/api/patient'
import { formatGender } from '@/utils/diag-display'
import { hasPermission } from '@/utils/perm'
import {
normalizeRevisitDue,
revisitBadgeText,
revisitStateColorClass,
revisitTooltipLines
} from '@/utils/revisitDue'
import PatientInfoCard from './components/PatientInfoCard.vue'
import PatientCaseCard from './components/PatientCaseCard.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 || '')
/** 复诊节点: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 列表一致):
* null/undefined → 灰;>=7 → 红;3-6 → 橙;<=2 → 绿
@@ -352,6 +380,13 @@ onMounted(fetchDetail)
border-color: #fecaca;
background: rgba(220, 38, 38, 0.08);
}
/* 复诊节点徽标专用:已约 / 已联系 */
&.unserved-blue {
color: #4a5dff;
border-color: #c7d2fe;
background: rgba(74, 93, 255, 0.08);
}
}
.empty-state {
@@ -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,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\)"/)
})
}
+1 -1
View File
@@ -8,7 +8,7 @@
Windows 直接在项目根目录双击:
- `一键运行_医生工作站.bat`:优先启动现有成品;没有成品时自动使用 `uv` 准备源码环境并运行。
- `一键运行_医生工作站.bat`:在源码目录且 `.venv` 可用时运行最新源码;否则运行现有成品;没有成品时使用 `uv` 准备源码环境并运行。
- `一键打包_医生工作站.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`。
@@ -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,281 @@
"""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,
"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 list_prescription_ai_reports(self, **_params: Any) -> dict[str, Any]:
return {"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 [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()
+12 -8
View File
@@ -9,6 +9,7 @@ $FrozenExecutable = Join-Path $ProjectRoot "dist\DoctorWorkstation\DoctorWorksta
$ProjectPython = Join-Path $ProjectRoot ".venv\Scripts\python.exe"
$ProjectUvCache = Join-Path $ProjectRoot ".uv-cache"
$SourceRoot = Join-Path $ProjectRoot "src"
$SourceEntry = Join-Path $SourceRoot "doctor_workstation\__main__.py"
function Test-ProjectPython {
param([Parameter(Mandatory = $true)][string]$Candidate)
@@ -33,7 +34,13 @@ function Find-Uv {
}
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"
if ($ValidateOnly) {
Write-Host "Windows run entry validation passed."
@@ -47,13 +54,7 @@ try {
}
exit $ApplicationExitCode
}
$Python = $null
if (Test-ProjectPython -Candidate $ProjectPython) {
$Python = $ProjectPython
Write-Host "Using existing project environment: $ProjectPython"
}
else {
elseif (Test-Path -LiteralPath $SourceEntry -PathType Leaf) {
$Uv = Find-Uv
if (-not $Uv) {
throw "No packaged application or usable .venv was found, and uv is not installed."
@@ -108,6 +109,9 @@ try {
}
$Python = $ProjectPython
}
else {
throw "No source entry or packaged application was found."
}
if ($ValidateOnly) {
Write-Host "Windows run entry validation passed."
+2 -2
View File
@@ -3,9 +3,9 @@
__all__ = ["DEBUG_MODE", "ONLINE_API_BASE_URL", "__version__"]
# Single source of truth for runtime, package, installer, and executable versions.
__version__ = "1.4.2"
__version__ = "1.4.5"
# 调试模式开启时,登录页显示“演示模式”和“服务器设置”。
# 正式发布请保持 False;此时程序只使用下面配置的线上域名。
DEBUG_MODE = True
DEBUG_MODE = False
ONLINE_API_BASE_URL = "https://admin.zhenyangtang.com.cn"
+4
View File
@@ -1004,6 +1004,8 @@ class ApplicationController(QObject):
preview_height = min(380, max(320, round(preview_width * 0.66)))
margin = 18
if callable(getattr(video_window, "setProperty", None)):
video_window.setProperty("doctorSkipOpenGeometry", True)
video_window.showNormal()
video_window.setMinimumSize(440, 300)
video_window.setWindowFlag(Qt.WindowType.WindowStaysOnTopHint, True)
@@ -1058,6 +1060,8 @@ class ApplicationController(QObject):
window.raise_()
if activate:
window.activateWindow()
if callable(getattr(window, "setProperty", None)):
window.setProperty("doctorSkipOpenGeometry", False)
except (AttributeError, RuntimeError):
return
@@ -64,6 +64,7 @@ DEMO_PERMISSIONS: tuple[str, ...] = (
"tcm.diagnosis/editAiReport",
"tcm.diagnosis/aiAnalysis",
"tcm.diagnosis/aiAssistant",
"tcm.diagnosis/aiGeneratePrescription",
"tcm.diagnosis/patientAiReports",
"tcm.diagnosis/generatePatientAiReport",
"tcm.diagnosis/dailyRecord",
@@ -19,7 +19,6 @@ from doctor_workstation.core.errors import (
ApiBusinessError,
ApiHttpError,
ApiProtocolError,
ApiTimeoutError,
ApiTransportError,
AuthenticationExpiredError,
)
@@ -233,8 +232,29 @@ class DoctorRepository(Protocol):
) -> dict[str, Any]:
"""Append one model-specific AI diagnosis snapshot for one patient."""
def get_prescription(self, prescription_id: int) -> Prescription:
"""Return one issued prescription."""
def get_prescription(self, prescription_id: int) -> Prescription:
"""Return one issued prescription."""
def list_prescription_ai_statuses(self, ids: list[int]) -> dict[str, Any]:
"""Read cached dual-model states for up to 100 prescriptions."""
def list_prescription_ai_reports(self, *, prescription_id: int = 0, diagnosis_id: int = 0, page_no: int = 1, page_size: int = 20) -> dict[str, Any]:
"""Read immutable analysis batch history in the authorized scope."""
def get_prescription_ai_report(self, batch_id: int) -> dict[str, Any]:
"""Read one analysis batch without starting model work."""
def regenerate_prescription_ai(self, prescription_id: int, reason: str) -> dict[str, Any]:
"""Explicitly request a new analysis batch with a reason."""
def retry_prescription_ai(self, batch_id: int, model_key: str) -> dict[str, Any]:
"""Retry only the requested failed model."""
def review_prescription_ai(self, batch_id: int, model_key: str, status: str, comment: str) -> dict[str, Any]:
"""Save a doctor's review separately from immutable AI output."""
def prescription_ai_statistics(self, date_from: str, date_to: str, doctor_id: int | None = None) -> dict[str, Any]:
"""Read baseline agreement statistics, never diagnostic accuracy."""
def create_prescription(
self,
@@ -1392,11 +1412,11 @@ class RemoteDoctorRepository:
task: str = "custom",
cancelled: Callable[[], bool] | None = None,
) -> 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)
body = {"id": diagnosis_id, "prompt": clean_prompt, "task": clean_task}
received_delta = False
received_event = False
received_done = False
try:
for raw_event in self.client.post_event_stream(
@@ -1407,13 +1427,12 @@ class RemoteDoctorRepository:
):
if cancelled is not None and cancelled():
return
received_event = True
event = _normalise_diagnosis_ai_event(raw_event)
if event is None:
continue
kind = event["event"]
if kind == "delta":
received_delta = True
elif kind == "done":
if kind == "done":
received_done = True
yield event
if kind == "done":
@@ -1422,12 +1441,16 @@ class RemoteDoctorRepository:
return
if not received_done:
raise ApiProtocolError("AI assistant stream ended before done")
except (ApiHttpError, ApiProtocolError, ApiTimeoutError, ApiTransportError):
if received_delta or (cancelled is not None and cancelled()):
except ApiHttpError as error:
if (
error.status_code not in {404, 405}
or received_event
or (cancelled is not None and cancelled())
):
raise
# Older deployments do not expose the stream route. Submit exactly one
# request through the confirmed non-streaming endpoint in that case.
# Only a missing stream route on an older deployment justifies another
# full request. Timeouts, transport failures and malformed streams do not.
result = self.analyze_diagnosis_ai(
diagnosis_id,
clean_prompt,
@@ -1574,14 +1597,55 @@ class RemoteDoctorRepository:
payload, Prescription.from_dict, page_no=page_no, page_size=page_size
)
def get_prescription(self, prescription_id: int) -> Prescription:
"""Load one issued prescription using ``tcm.prescription/detail``."""
def get_prescription(self, prescription_id: int) -> Prescription:
"""Load one issued prescription using ``tcm.prescription/detail``."""
result = _require_mapping(
self.client.get("tcm.prescription/detail", {"id": prescription_id}),
"tcm.prescription/detail",
)
return Prescription.from_dict(result)
return Prescription.from_dict(result)
def _prescription_ai_request(self, action: str, params: dict[str, Any], *, mutation: bool = False) -> dict[str, Any]:
endpoint = f"tcm.prescriptionAi/{action}"
payload = _client_request(self.client, "post" if mutation else "get", endpoint, params, timeout=30.0)
return dict(_require_mapping(payload, endpoint))
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):
raise ValueError("statuses requires at most 100 positive prescription IDs")
return self._prescription_ai_request("statuses", {"ids": ",".join(str(int(value)) for value in dict.fromkeys(ids))})
def list_prescription_ai_reports(self, *, prescription_id: int = 0, diagnosis_id: int = 0, page_no: int = 1, page_size: int = 20) -> dict[str, Any]:
if bool(prescription_id) == bool(diagnosis_id):
raise ValueError("Exactly one prescription_id or diagnosis_id is required")
params = {"prescription_id": prescription_id} if prescription_id else {"diagnosis_id": diagnosis_id}
params.update(page_no=page_no, page_size=page_size)
return self._prescription_ai_request("reports", params)
def get_prescription_ai_report(self, batch_id: int) -> dict[str, Any]:
return self._prescription_ai_request("detail", {"batch_id": batch_id})
def regenerate_prescription_ai(self, prescription_id: int, reason: str) -> dict[str, Any]:
if not reason.strip():
raise ValueError("重新分析需要填写原因")
return self._prescription_ai_request("regenerate", {"prescription_id": prescription_id, "reason": reason.strip()}, mutation=True)
def retry_prescription_ai(self, batch_id: int, model_key: str) -> dict[str, Any]:
if model_key not in {"qwen", "openai"}:
raise ValueError("Unknown model_key")
return self._prescription_ai_request("retry", {"batch_id": batch_id, "model_key": model_key}, mutation=True)
def review_prescription_ai(self, batch_id: int, model_key: str, status: str, comment: str) -> dict[str, Any]:
if model_key not in {"qwen", "openai"} or status not in {"viewed", "needs_information", "not_adopted", "reviewed"}:
raise ValueError("Invalid review state")
return self._prescription_ai_request("review", {"batch_id": batch_id, "model_key": model_key, "status": status, "comment": comment}, mutation=True)
def prescription_ai_statistics(self, date_from: str, date_to: str, doctor_id: int | None = None) -> dict[str, Any]:
params: dict[str, Any] = {"date_from": date_from, "date_to": date_to}
if doctor_id is not None:
params["doctor_id"] = doctor_id
return self._prescription_ai_request("statistics", params)
def create_prescription(
self,
@@ -43,6 +43,7 @@ from PySide6.QtWidgets import (
)
from ..core.appointment_modes import APPOINTMENT_MODES
from .theme import main_window_target_rect
from .widgets import (
display_text,
first_value,
@@ -1766,6 +1767,11 @@ class AppointmentDrawer(QDialog):
super().mousePressEvent(event)
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:
self._update_drawer_width()
return
@@ -54,6 +54,7 @@ from ..diagnosis_terms import (
TermIndex,
shared_terms,
)
from ..report_provenance import report_provenance_lines
from ..theme import apply_reading_rhythm, mark_business_dialog
from ..widgets import (
display_text,
@@ -176,8 +177,8 @@ QDialog#AiConsultDialog QTabBar#AiConsultTabs {
}
QDialog#AiConsultDialog QTabBar#AiConsultTabs::tab {
min-height: 36px;
padding: 0 16px;
margin-right: 4px;
padding: 0 7px;
margin-right: 0;
color: #606163;
background-color: transparent;
border: 0;
@@ -189,6 +190,18 @@ QDialog#AiConsultDialog QTabBar#AiConsultTabs::tab:selected {
font-weight: 600;
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#AiConsultSide,
QDialog#AiConsultDialog QFrame#AiConsultRecordPane {
@@ -483,6 +496,22 @@ QDialog#AiConsultDialog QLabel#AiConsultClinicalDisclaimer {
font-size: 13px;
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 {
background-color: #F7F7F7;
border: 1px solid #EDEDEE;
@@ -637,6 +666,13 @@ QDialog#AiConsultDialog QLabel#AiConsultTag[level="warn"] {
font-size: 13px;
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 {
min-height: 28px;
max-height: 30px;
@@ -854,6 +890,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], ...] = (
("chart", "分析病历", "请根据当前病历给出辨证与病情摘要。"),
("trend", "血糖趋势", "请评估该患者的血糖控制情况与趋势。"),
@@ -2104,6 +2145,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")
_INSIGHT_LIST_RE = re.compile(r"^\s*(\d{1,2})\s*([\.、))]\s*)")
@@ -2409,6 +2470,7 @@ class _StructuredReportPanel(QWidget):
disclaimer: str,
*,
time_text: str = "",
provenance: Any = None,
parent: QWidget | None = None,
) -> None:
super().__init__(parent)
@@ -2455,6 +2517,7 @@ class _StructuredReportPanel(QWidget):
note = _FittedText(_reading_html(disclaimer), parent=self)
note.setObjectName("AiConsultReportDisclaimer")
root.addWidget(note)
root.addWidget(_report_provenance_widget(provenance, self))
# 富余高度归到底部,否则会被平摊进每张卡片,把报告拉得空空荡荡。
root.addStretch(1)
@@ -2528,6 +2591,7 @@ class _ClinicalAnalysisPanel(QWidget):
model: _ClinicalAnalysisModel,
*,
time_text: str = "",
provenance: Any = None,
parent: QWidget | None = None,
) -> None:
super().__init__(parent)
@@ -2621,6 +2685,7 @@ class _ClinicalAnalysisPanel(QWidget):
disclaimer.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
_style_surface(disclaimer)
root.addWidget(disclaimer)
root.addWidget(_report_provenance_widget(provenance, self))
QTimer.singleShot(0, self._apply_responsive_layout)
def set_time_text(self, time_text: str) -> None:
@@ -2844,6 +2909,7 @@ class _ChatBubble(QWidget):
super().__init__(parent)
self._role = role
self._raw_payload = text
self._provenance: Mapping[str, Any] = {}
self._clinical_panel: _ClinicalAnalysisPanel | None = None
row = QHBoxLayout(self)
row.setContentsMargins(12, 7, 12, 7)
@@ -2914,6 +2980,9 @@ class _ChatBubble(QWidget):
if self.body is not None:
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:
if not text:
return
@@ -2934,6 +3003,7 @@ class _ChatBubble(QWidget):
panel = _ClinicalAnalysisPanel(
model,
time_text=self.stamp.text(),
provenance=self._provenance,
parent=self._bubble_frame,
)
else:
@@ -2944,6 +3014,7 @@ class _ChatBubble(QWidget):
panel = _StructuredReportPanel(
*report,
time_text=self.stamp.text(),
provenance=self._provenance,
parent=self._bubble_frame,
)
if panel is None:
@@ -3075,6 +3146,8 @@ class _AiStreamWorker(QRunnable):
@Slot()
def run(self) -> None:
try:
if self.is_cancelled():
return
stream = getattr(self.repository, "stream_diagnosis_ai", None)
if callable(stream):
events = stream(
@@ -3098,6 +3171,8 @@ class _AiStreamWorker(QRunnable):
{"event": "delta", "text": answer, "fallback": True},
{**payload, "event": "done", "fallback": True},
)
if self.is_cancelled():
return
for event in events:
if self.is_cancelled():
return
@@ -3136,13 +3211,18 @@ class _ClickCard(QFrame):
AI_CONTEXT_MAX_CHARS = 320
AI_PROMPT_LIMIT = 500
#: 首个增量到达前显示的占位文案,避免留下一块没有任何说明的空白气泡。
AI_STREAM_PENDING_TEXT = "正在生成…"
#: 等待服务端整理诊单资料时显示的占位文案。
AI_STREAM_PENDING_TEXT = "正在整理诊单资料…"
#: 服务端确认资料已准备好、尚未收到回答时显示的占位文案。
AI_STREAM_WAITING_TEXT = "资料已整理,正在等待 AI 回复…"
#: 连接结束但一个字都没收到时的兜底文案。
AI_STREAM_SILENT_TEXT = (
"AI 助手没有返回内容,可能是服务端未响应或连接中断,请稍后重试。"
)
AI_STREAM_TOTAL_TIMEOUT_MS = 180_000
AI_STREAM_TIMEOUT_TEXT = "等待 AI 回复超时,已停止本次等待,请稍后重试。"
AI_CONTEXT_SEPARATOR = "\n\n— 医生提问 —\n"
@@ -3458,10 +3538,14 @@ class AiConsultDialog(QDialog):
# done 事件是否到达。用于区分“模型没内容”和“连接直接断了”。
self._stream_completed = False
self._follow_chat = True
self._chat_filter = "all"
self._flush_timer = QTimer(self)
self._flush_timer.setSingleShot(True)
self._flush_timer.setInterval(40)
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.setObjectName("AiConsultDialog")
self.setWindowTitle("问诊详情")
# QDialog 默认只带关闭按钮,医生无法把这个信息密度很高的窗口放大到整屏。
@@ -3508,6 +3592,7 @@ class AiConsultDialog(QDialog):
self._generation += 1
self._image_generation += 1
self._clear_chat()
self._set_chat_filter("all")
# The AI context envelope depends on workspace data that has not yet
# been fetched, so reset its cache eagerly instead of letting it
# linger from a previous patient session.
@@ -3540,6 +3625,24 @@ class AiConsultDialog(QDialog):
layout = QVBoxLayout(shell)
layout.setContentsMargins(18, 14, 18, 14)
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.addWidget(self._build_hero())
self.tabs = QTabBar()
@@ -3548,15 +3651,31 @@ class AiConsultDialog(QDialog):
self.tabs.addTab(title)
self.tabs.currentChanged.connect(self._tab_changed)
layout.addWidget(self.tabs)
layout.addWidget(self._build_side(), 1)
return host
splitter = QSplitter(Qt.Orientation.Horizontal)
splitter.setChildrenCollapsible(False)
splitter.addWidget(self._build_center())
splitter.addWidget(self._build_side())
splitter.setStretchFactor(0, 1)
splitter.setStretchFactor(1, 0)
splitter.setSizes([920, 400])
layout.addWidget(splitter, 1)
def _build_chat_filter(self) -> QPushButton:
self.chat_filter_button = QPushButton("筛选")
self.chat_filter_button.setObjectName("AiConsultChatFilter")
self.chat_filter_button.setAccessibleName("筛选问诊对话")
self.chat_filter_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.chat_filter_menu = QMenu(self.chat_filter_button)
for value, label in (
("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:
row = QHBoxLayout()
@@ -3572,6 +3691,7 @@ class AiConsultDialog(QDialog):
current.setObjectName("AiConsultCrumbCurrent")
row.addWidget(current)
row.addStretch(1)
row.addWidget(self._build_chat_filter())
back = QPushButton("返回")
back.setObjectName("AiConsultBack")
back.clicked.connect(self.reject)
@@ -3582,9 +3702,11 @@ class AiConsultDialog(QDialog):
card = QFrame()
card.setObjectName("AiConsultHero")
_style_surface(card)
row = QHBoxLayout(card)
row.setContentsMargins(16, 14, 16, 14)
row.setSpacing(14)
body = QVBoxLayout(card)
body.setContentsMargins(14, 12, 14, 12)
body.setSpacing(8)
row = QHBoxLayout()
row.setSpacing(10)
self.avatar = QLabel("患")
self.avatar.setObjectName("AiConsultAvatar")
self.avatar.setFixedSize(44, 44)
@@ -3599,13 +3721,14 @@ class AiConsultDialog(QDialog):
self.identity_label = QLabel("")
self.identity_label.setObjectName("AiConsultMeta")
info.addWidget(self.identity_label)
self.chip_row = _FlowRow()
info.addWidget(self.chip_row)
row.addLayout(info, 1)
full = QPushButton("查看完整资料")
full.setObjectName("AiConsultFullRecord")
full.clicked.connect(self._open_full_record)
row.addWidget(full, 0, Qt.AlignmentFlag.AlignTop)
body.addLayout(row)
self.chip_row = _FlowRow()
body.addWidget(self.chip_row)
return card
def _build_center(self) -> QWidget:
@@ -3662,6 +3785,8 @@ class AiConsultDialog(QDialog):
hint.setObjectName("AiConsultComposerLabel")
chips.addWidget(hint)
for label, prompt in QUICK_PROMPTS[:3]:
if label == "AI 生成处方" and not self._can_generate_ai_prescription():
continue
button = QPushButton(label)
button.setObjectName("AiConsultChipButton")
button.setCursor(Qt.CursorShape.PointingHandCursor)
@@ -3808,7 +3933,6 @@ class AiConsultDialog(QDialog):
side.setObjectName("AiConsultSide")
_style_surface(side)
side.setMinimumWidth(350)
side.setMaximumWidth(420)
outer = QVBoxLayout(side)
outer.setContentsMargins(18, 16, 18, 16)
outer.setSpacing(14)
@@ -3911,6 +4035,32 @@ class AiConsultDialog(QDialog):
def _tab_changed(self, index: int) -> None:
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:
while self.chat_layout.count() > 1:
@@ -3932,6 +4082,7 @@ class AiConsultDialog(QDialog):
) -> _ChatBubble:
bubble = _ChatBubble(role=role, text=text, time_text=time_text, extra=extra)
self.chat_layout.insertWidget(self.chat_layout.count() - 1, bubble)
bubble.setVisible(self._chat_item_matches_filter(bubble))
if self._follow_chat:
QTimer.singleShot(0, self._scroll_chat_to_bottom)
return bubble
@@ -3953,6 +4104,7 @@ class AiConsultDialog(QDialog):
marker=marker,
)
self.chat_layout.insertWidget(self.chat_layout.count() - 1, event)
event.setVisible(self._chat_item_matches_filter(event))
if self._follow_chat:
QTimer.singleShot(0, self._scroll_chat_to_bottom)
return event
@@ -4550,6 +4702,7 @@ class AiConsultDialog(QDialog):
time_text=item.time_text,
)
if item.role == "ai":
bubble.set_provenance(row)
bubble.finalize_clinical_analysis()
def _render_analysis(self, analysis: Mapping[str, Any]) -> None:
@@ -4564,51 +4717,25 @@ class AiConsultDialog(QDialog):
)
top_risk = risk_rows[0] if risk_rows else {}
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"}:
risk_level = "medium"
level_text = {"high": "高风险", "medium": "中风险", "low": "低风险"}[risk_level]
diagnosis = _as_mapping(get_value(self._detail, "diagnosis", default=self._detail) or self._seed)
appointment = _as_mapping(get_value(self._detail, "appointment", default={}))
clinical = display_text(
_pick(("clinical_diagnosis", "diagnosis_name"), diagnosis, appointment, self._seed),
"",
)
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}],
}
)
risk_level = "unknown"
level_text = {"high": "高风险", "medium": "中风险", "low": "低风险", "unknown": "未提供"}[risk_level]
if not advice and not treatment and not risk_rows:
self._set_insights(
[("分析状态", "当前诊单暂无可用的 AI 分析报告,请重新分析后查看。", "未生成", "unknown")]
)
return
markdown = _analysis_markdown(payload)
if markdown:
bubble = self._append_bubble("ai", markdown)
bubble.set_provenance(payload)
bubble.finalize_clinical_analysis()
glucose_tag, glucose_level = (
("未达标", "warn")
if "高血糖" in risk_label or risk_level == "high"
else ("需关注", "low")
)
self._set_insights(
[
("血糖控制评估", advice, glucose_tag, glucose_level),
("并发症风险评估", risk_label, level_text, risk_level),
("用药合理性评估", treatment, "需关注", "low"),
("血糖控制评估", advice or "报告未提供此项分析。", "已分析" if advice else "未提供", "low" if advice else "unknown"),
("并发症风险评估", risk_label or "报告未列出风险项。", level_text, risk_level),
("用药合理性评估", treatment or "报告未提供此项分析。", "已分析" if treatment else "未提供", "low" if treatment else "unknown"),
]
)
@@ -5464,6 +5591,13 @@ class AiConsultDialog(QDialog):
def _submit(self) -> None:
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:
if not _is_open_prescription_intent(text):
return False
@@ -5474,9 +5608,8 @@ class AiConsultDialog(QDialog):
text,
time_text=datetime.now().strftime("%H:%M"),
)
can_prescribe = any(
has_permission(self.permissions, code, default=False)
for code in ("tcm.diagnosis/chufang", "tcm.diagnosis/kaifang")
can_prescribe = has_permission(
self.permissions, PRESCRIPTION_CREATE_PERMISSIONS, default=False
)
if not can_prescribe:
self._append_bubble(
@@ -5485,6 +5618,15 @@ class AiConsultDialog(QDialog):
time_text="系统",
)
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)):
self._append_bubble(
"ai",
@@ -5729,6 +5871,7 @@ class AiConsultDialog(QDialog):
"usage_notes",
}
seed.update({key: deepcopy(value) for key, value in draft.items() if key in allowed})
seed["ai_assisted"] = True
dialog = PrescriptionEditorDialog(
self.repository,
seed,
@@ -5897,6 +6040,7 @@ class AiConsultDialog(QDialog):
worker.signals.finished.connect(
lambda: self._stream_finished(generation, stream_generation, worker)
)
self._stream_timeout_timer.start(AI_STREAM_TOTAL_TIMEOUT_MS)
QThreadPool.globalInstance().start(worker)
QTimer.singleShot(0, self._scroll_chat_to_bottom)
@@ -5915,6 +6059,8 @@ class AiConsultDialog(QDialog):
kind = str(payload.get("event") or "").lower()
if kind == "start":
self._stream_meta.update(payload)
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
if kind == "delta":
chunk = payload.get("text")
@@ -5927,6 +6073,7 @@ class AiConsultDialog(QDialog):
return
self._stream_meta.update(payload)
self._stream_completed = True
self._stream_timeout_timer.stop()
if not self._stream_text and not self._pending_chunks:
answer = first_value(payload, "answer", "content", default="")
if answer not in (None, ""):
@@ -5943,6 +6090,7 @@ class AiConsultDialog(QDialog):
self._stream_bubble.set_time_text(
_join_meta(model, datetime.now().strftime("%H:%M"))
)
self._stream_bubble.set_provenance(self._stream_meta)
self._stream_bubble.finalize_clinical_analysis()
def _flush_stream_chunks(self) -> None:
@@ -5962,6 +6110,7 @@ class AiConsultDialog(QDialog):
) -> None:
if not self._stream_is_current(generation, stream_generation):
return
self._stream_timeout_timer.stop()
self._flush_stream_chunks()
message = friendly_error(error)
if self._stream_text:
@@ -5980,10 +6129,11 @@ class AiConsultDialog(QDialog):
) -> None:
if not self._stream_is_current(generation, stream_generation):
return
self._stream_timeout_timer.stop()
if self._stream_worker is worker:
self._stream_worker = None
# 连接结束却既没有 done 也没有报错时(服务端静默断开),占位气泡会永远
# 停在“正在生成…”。这里补一条明确说明,而不是留一块空白卡片。
# 连接结束却既没有 done 也没有报错时(服务端静默断开),
# 将阶段占位文案替换成明确说明。
if (
not self._stream_completed
and not self._stream_text
@@ -5995,9 +6145,23 @@ class AiConsultDialog(QDialog):
self._asking = False
self.send_button.setEnabled(True)
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._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
)
bubble.set_time_text(datetime.now().strftime("%H:%M"))
def _cancel_stream(self) -> None:
self._stream_generation += 1
self._flush_timer.stop()
self._stream_timeout_timer.stop()
if self._stream_worker is not None:
self._stream_worker.cancel()
self._stream_worker = None
@@ -67,6 +67,7 @@ from ..diagnosis_media import (
safe_image_sources,
)
from ..infinite_list import InfiniteList
from ..theme import main_window_target_rect
from ..widgets import (
display_text,
first_value,
@@ -78,6 +79,7 @@ from ..widgets import (
page_total,
run_async,
)
from .issued_prescription_ai import can_open_issued_ai, present_issued_prescription_ai
from .local_audio_queue import LocalAudioQueueDialog
from .prescription_ai import can_open_diagnosis_ai_report, present_diagnosis_ai_report
@@ -715,6 +717,11 @@ class OrderDetailDrawer(QDialog):
QTimer.singleShot(0, self.sync_to_owner)
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
if owner is None:
self.resize(1152, 720)
@@ -1067,6 +1074,12 @@ class DiagnosisDialog(QDialog):
self.readonly_ai_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.readonly_ai_button.clicked.connect(self._open_ai_report)
heading_row.addWidget(self.readonly_ai_button, 0)
self.readonly_prescription_ai_button = QPushButton("处方 AI 对照", card)
self.readonly_prescription_ai_button.setProperty("variant", "secondary")
self.readonly_prescription_ai_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.readonly_prescription_ai_button.setVisible(can_open_issued_ai(self.permissions) and callable(getattr(self.repository, "list_prescription_ai_reports", None)))
self.readonly_prescription_ai_button.clicked.connect(self._open_prescription_ai_report)
heading_row.addWidget(self.readonly_prescription_ai_button, 0)
layout.addLayout(heading_row)
patient_hero = QFrame()
patient_hero.setObjectName("DiagnosisReadonlyPatientHero")
@@ -1709,6 +1722,9 @@ class DiagnosisDialog(QDialog):
if hasattr(self, "readonly_ai_button"):
self.readonly_ai_button.setVisible(can_open_diagnosis_ai_report(self.permissions))
self.readonly_ai_button.setEnabled(self._diagnosis_id > 0)
if hasattr(self, "readonly_prescription_ai_button"):
self.readonly_prescription_ai_button.setVisible(can_open_issued_ai(self.permissions) and callable(getattr(self.repository, "list_prescription_ai_reports", None)))
self.readonly_prescription_ai_button.setEnabled(self._diagnosis_id > 0)
previous_key = self._current_tab_key()
allowed_tabs = [
(key, label) for key, label, codes in _TAB_DEFINITIONS if self._tab_allowed(codes)
@@ -1826,6 +1842,10 @@ class DiagnosisDialog(QDialog):
def _sync_host_geometry(self) -> None:
if self._embedded:
return
target = main_window_target_rect(self)
if target is not None:
self.setGeometry(target)
return
owner = self._owner
if owner is None:
if self.width() < 760 or self.height() < 520:
@@ -1960,6 +1980,10 @@ class DiagnosisDialog(QDialog):
)
present_diagnosis_ai_report(self.repository, self.permissions, self, row)
def _open_prescription_ai_report(self) -> None:
if self._diagnosis_id > 0:
present_issued_prescription_ai(self.repository, self.permissions, self, diagnosis_id=self._diagnosis_id)
def open_view_only(
self,
diagnosis_id: int,
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>&nbsp;&nbsp;<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,558 @@
"""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.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)
layout.addWidget(self._delta_block())
divider = QFrame(self)
divider.setObjectName("AiFactDivider")
divider.setFixedWidth(1)
layout.addWidget(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, "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_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))) 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 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)
@@ -0,0 +1,343 @@
"""Presentation-only Chinese labels for saved prescription analysis payloads.
Keep source identifiers, enum keys and statistics untouched in the repository data.
Only explicitly structured metadata is localized; clinical prose is not translated.
"""
from __future__ import annotations
import re
from collections.abc import Mapping
from typing import Any
SOURCE_LABELS = {
"diagnoses": "诊单", "doctor_notes": "医生笔记", "tracking_notes": "随访记录",
"prescriptions": "历史处方", "call_records": "问诊通话", "video_calls": "视频问诊",
"transcript_segments": "转写片段", "chat_records": "聊天记录", "daily_records": "日常记录",
"blood_records": "血糖血压记录", "blood_glucose_pressure": "血糖血压记录",
"diet_records": "饮食记录", "diet": "饮食记录", "exercise_records": "运动记录", "exercise": "运动记录",
"im_messages": "即时聊天记录", "tencent_im": "即时聊天记录",
"wechat_messages": "企业微信聊天记录", "wechat_work": "企业微信聊天记录",
"target_plan": "本次处方方案", "clinical": "临床资料", "file": "附件",
"tongue": "舌象", "tongue_image": "舌象图片", "tongue_images": "舌象图片",
"clinical_attachment": "临床附件", "patient": "患者资料",
}
SYSTEM_LABELS = {
"SOURCE_HISTORY_VERSIONS_UNAVAILABLE": "来源历史版本无法核验",
"ARCHIVE_SYNC_WATERMARK_UNAVAILABLE": "归档同步完整性尚未核验",
"TRANSCRIPT_NOT_VERIFIED_COMPLETE": "问诊转写完整性尚未核验",
"TRANSCRIPT_NOT_FINAL": "问诊转写尚未完整归档",
"TRANSCRIPT_PARTIAL": "问诊转写仅部分完成", "TRANSCRIPT_FAILED": "问诊转写失败",
"TRANSCRIPT_RUNNING": "问诊转写进行中", "TRANSCRIPT_PENDING": "等待问诊转写",
"SOURCE_AUTHORIZATION_LINK_UNAVAILABLE": "来源缺少可核验的授权关联",
"SOURCE_PATIENT_CONFLICT": "来源的患者关联存在冲突",
"SOURCE_ACCESS_RESTRICTED": "来源访问受限",
"UNLINKED_SOURCE_REQUIRES_AUTHORIZATION": "未关联来源需核验访问权限",
"SOURCE_READ_UNAVAILABLE": "来源暂时无法读取",
"ATTACHMENT_TARGET_PLAN_LEAKAGE_UNVERIFIED": "附件可能包含本次处方,独立性未核验",
"UNSTRUCTURED_TARGET_PLAN_LEAKAGE_UNVERIFIED": "非结构化资料可能包含本次处方,独立性未核验",
"TARGET_PLAN_COPY_ISOLATED": "已隔离资料中复制的本次处方内容",
"CRITICAL_CLINICAL_FACT_MISSING": "关键临床资料缺失",
"FILE_STORAGE_AUTHORIZATION_UNVERIFIED": "附件存储访问权限尚未核验",
"FILE_CONTENT_VERSION_UNVERIFIED": "附件内容版本尚未核验",
"PATIENT_BINDING_REQUIRED": "需完善患者与诊单关联",
"PATIENT_BINDING_OR_PERMISSION_REQUIRED": "需核验患者关联与资料访问权限",
"ACCESS_REVOKED": "资料访问权限已变更", "SOURCE_CHANGED": "资料或处方已更新,请查看新版本",
"BUDGET_PAUSED": "已达到分析预算,等待额度恢复",
"CONFIG_INVALID": "模型配置无效,请联系管理员", "CONFIG_DISABLED": "模型分析尚未启用",
"UPSTREAM_AUTH_FAILED": "模型服务认证失败,请联系管理员",
"UPSTREAM_TIMEOUT": "模型响应超时,可稍后重试", "UPSTREAM_BUSY": "模型服务繁忙,可稍后重试",
"UPSTREAM_UNAVAILABLE": "模型服务暂不可用", "UPSTREAM_REJECTED": "模型服务未接受本次请求",
"UPSTREAM_FAILED": "模型服务处理失败", "EMPTY_RESPONSE": "模型未返回内容",
"INCOMPLETE_RESPONSE": "模型返回内容不完整", "INVALID_RESPONSE": "模型返回内容未通过校验",
"RESPONSE_INVALID": "模型返回内容未通过校验", "INVALID_REPORT_OUTPUT": "模型报告未通过格式校验",
"INVALID_EVIDENCE_OUTPUT": "模型证据来源未通过校验",
"INVALID_FILE_EVIDENCE_OUTPUT": "模型附件证据未通过校验",
"CONTEXT_TOO_LARGE": "资料超过本次处理预算",
"INPUT_TOKEN_BUDGET_EXCEEDED": "输入资料超过本次处理预算",
"SYNTHESIS_BUDGET_EXCEEDED": "资料汇总超过本次处理预算",
"FINAL_CONTEXT_EXCEEDS_BUDGET": "来源与缺口说明超过汇总预算,请联系管理员",
"TOTAL_CALL_BUDGET_EXCEEDED": "模型调用次数达到本次上限",
"SOURCE_UNIT_EXCEEDS_BUDGET": "单条来源资料超过处理预算",
"RESPONSE_SIZE_EXCEEDED": "模型返回内容超过长度上限",
"LEASE_EXPIRED": "工作进程中断,任务等待恢复",
"SOURCE_PREPARATION_FAILED": "来源资料准备失败", "INTERNAL_ERROR": "分析处理异常,可稍后重试",
"GENERATION_FAILED": "报告生成失败,可稍后重试", "CHECKPOINT_REJECTED": "分析进度保存未通过校验",
"INVALID_PROFILE": "模型配置未通过校验", "INVALID_FROZEN_CONTEXT": "冻结资料未通过校验",
"INVALID_FILE_MANIFEST": "附件清单未通过校验", "SOURCE_GAP": "来源资料存在缺口",
"FILE_CAPABILITY_DISABLED": "模型附件处理能力尚未启用",
"FILE_UNAVAILABLE_OR_UNSUPPORTED": "附件不可用或格式不受支持",
"FILE_TYPE_UNSUPPORTED": "附件格式不受支持",
"STRICT_FILES_INVALID_OR_LIMIT": "附件校验未通过或超过处理上限",
"FILE_DELIVERY_UNVERIFIED": "附件送达情况尚未核验",
"MODEL_REPORTED_UNREADABLE": "模型无法读取附件", "MODEL_REPORTED_UNSUPPORTED": "模型不支持此附件",
"MODEL_FILE_OUTPUT_INVALID": "模型未能正确解析这组附件",
"AI_ANALYSIS_CIPHER_INVALID": "报告加密数据无法读取",
"AI_ANALYSIS_ENCRYPTION_FAILED": "报告加密保存失败",
"AI_ANALYSIS_KEY_INVALID": "报告加密配置无效", "AI_ANALYSIS_KEY_UNAVAILABLE": "报告加密配置不可用",
"formulation_mismatch": "剂型不同,未提供经确认的换算规则",
"dose_basis_mismatch": "剂量基准不同", "unit_mismatch": "剂量单位不同",
"unknown_formulation": "剂型缺失或不受支持", "empty_prescription": "处方为空或药味结构无效",
"catalog_unavailable": "缺少可用的药材字典", "invalid_herb": "药味结构无效",
"ambiguous_herb_name": "药名存在多种字典匹配", "unknown_herb_name": "药名未匹配药材字典",
"doctor_identity_mismatch": "医生药材编号与规范药名不一致",
"processing_conflict": "炮制信息与药材字典冲突或无效",
"ambiguous_herb_role": "主辅方、给药途径或分组不明确",
"missing_or_unknown_unit": "剂量单位缺失或不受支持",
"missing_or_unknown_dose_basis": "每剂或每日剂量基准不明确",
"invalid_dosage": "剂量数值无效", "invalid_herb_usage": "药味煎服说明格式无效",
"duplicate_semantics_conflict": "重复药项的单位、剂量基准或煎服说明不一致",
"no_medication": "建议暂不使用药物", "no_medication_recommended": "建议暂不使用药物",
"baseline_ineligible": "不符合独立基线统计条件", "incomplete_coverage": "资料覆盖不完整",
"missing_result": "缺少模型结果", "invalid_score": "一致度分值无效",
"missing_algorithm_version": "缺少算法版本", "transcript_not_final": "问诊转写尚未完整归档",
"event_patient_conflict": "开方事件的患者关联冲突", "duplicate_baseline_conflict": "重复基线结果存在冲突",
"review_conflict": "复核记录存在冲突", "review_not_completed": "复核尚未完成",
"review_not_independent": "复核不具独立性", "review_disputed": "复核存在争议",
"review_not_evaluable": "复核不可评价", "invalid_review_outcome": "复核结论无效",
"unknown_review_sampling": "复核抽样方式未确认", "start_at_required": "需设置分析起始时间",
"processed": "已处理", "restricted": "访问受限", "error": "处理失败", "timeout": "处理超时",
"delivered": "已送达", "unreadable": "不可读", "unsupported": "不支持", "parsed": "已解析",
"ok": "可比条件已通过", "doctor": "医生方", "candidate": "候选方", "both": "双方",
"low": "低风险", "medium": "中风险", "high": "高风险", "none": "无",
"main": "主方", "oral": "口服", "external": "外用",
"raw": "生品", "image": "图片", "document": "文档", "remote_url": "远程附件",
"stratified_versions": "按版本分层统计", "single_version": "单一版本", "no_valid_samples": "无有效样本",
"no_samples": "未建立复核样本", "recorded": "已记录", "conflict": "存在冲突",
"not_evaluable": "不可评价", "qualified": "合格", "needs_revision": "需修订", "unqualified": "不合格",
"random": "随机抽样", "stratified": "分层抽样", "risk_directed": "按风险抽样",
"stratified_sampling": "按抽样方式分层统计",
"spelling_aliases_only_no_quantity_conversion": "仅规范单位写法,不进行剂量换算",
"g": "克", "mg": "毫克", "kg": "千克", "ml": "毫升", "mL": "毫升", "l": "升", "L": "升",
"qwen": "千问", "openai": "OpenAI",
}
EXTRA_FIELD_LABELS = {
"source": "来源", "code": "原因说明", "reason_code": "原因说明", "error_code": "错误原因",
"error_message": "错误说明", "message": "说明", "detail": "详情", "details": "详细记录",
"reason_message": "原因说明", "field": "字段", "value": "记录值", "side": "所属处方",
"row": "药项序号", "rows": "逐味记录", "key": "药项标识", "id": "编号", "medicine_id": "药材编号",
"prescription_id": "处方编号", "diagnosis_id": "诊单编号", "patient_id": "患者编号",
"doctor_id": "医生编号", "doctor_name": "医生姓名", "model_key": "模型", "model_name": "模型名称",
"configured_model_name": "配置的模型名称", "comparison_status": "可比状态", "coverage_status": "资料覆盖状态",
"comparison_type": "比较类型", "validity": "报告有效性", "version_verified": "内容版本已核验",
"source_record_count": "来源记录总数", "missing_count": "资料缺口数", "snapshot_complete": "资料快照完整",
"may_be_truncated": "资料可能截断", "history_versioning": "历史版本核验", "archive_sync_verified": "归档同步已核验",
"file_ids": "附件编号", "evidence_file_ids": "证据附件编号", "covered_source_ids": "已覆盖来源编号",
"source_kind": "来源类型", "kind": "来源类型", "type": "类型", "purpose": "用途",
"transfer_method": "附件传递方式", "content_hash": "内容指纹", "dictionary_hash": "药材字典指纹",
"source_hash": "来源指纹", "url": "附件地址", "uri": "附件地址", "path": "附件路径",
"age": "年龄", "gender": "性别", "gender_label": "性别说明", "allergy_history": "过敏史",
"pregnancy_history": "妊娠与哺乳情况", "current_medications": "当前用药",
"allergy_history_text": "过敏史正文", "allergy_history_desc": "过敏史说明",
"pregnancy_history_text": "妊娠与哺乳正文", "pregnancy_history_desc": "妊娠与哺乳说明",
"current_medicine": "当前用药", "current_medication": "当前用药",
"prescription": "处方", "prescription_opinion": "处方意见", "prescription_advice": "处方建议",
"treatment_principle": "治则", "doctor_advice": "医嘱", "prescription_date": "开方日期",
"issues": "需核对问题", "formulation": "剂型", "items": "药项", "bases": "剂量基准",
"merges": "重复药项合并", "defaults": "默认值记录", "identity_complete": "药项身份核验完整",
"raw_herb_count": "原始药项数", "source_rows": "原始药项序号", "source_names": "原始药名",
"original_dosages": "原始剂量", "usage": "煎服说明", "doctor": "医生方", "candidate": "候选方",
"dictionary_versions": "药材字典版本", "unit_policy": "单位处理规则", "denominator": "一致度计算分母",
"matched_contribution_sum": "共同药项贡献合计", "special_usage": "特殊用法",
"decoction_instruction": "煎药说明", "usage_note": "服法备注", "before": "处理前", "after": "处理后",
"dosage_amount": "每次用量", "dosage_unit": "每次用量单位", "dosage_bag_count": "每次袋数", "aux_usage": "辅助用法",
"score": "药味剂量一致度", "herb_score": "纯药味重合度", "model": "模型",
"schema_version": "资料格式版本", "decision_at": "处方决策时间", "created_at": "建立时间", "updated_at": "更新时间",
"source_diagnosis_ids": "来源诊单编号", "redaction_manifest": "处方内容隔离清单",
"total_count": "开方事件数", "total_events": "开方事件数", "patient_count": "患者数",
"eligible_count": "有效比较数", "valid_count": "有效比较数", "excluded_count": "排除样本数",
"coverage_rate": "覆盖率", "coverage_percent": "覆盖率", "paired_count": "双模型共同有效样本数",
"excluded_reasons": "排除原因及数量", "exclusion_reasons": "排除原因及数量", "exclusion_reason": "排除原因",
"aggregation_status": "统计汇总方式", "paired_strata": "双模型版本分层", "models": "模型统计",
"review": "专家复核", "reviews": "专家复核", "evaluated_count": "可评价样本数", "evaluable_count": "可评价样本数",
"qualified_count": "合格样本数", "qualified_rate": "合格率", "qualification_rate": "合格率",
"reviewed_events": "已复核事件数", "unreviewed_events": "未复核事件数", "sampling_method": "抽样方式",
"sampling_groups": "抽样分组", "sampling_coverage_percent": "抽样覆盖率", "outcomes": "复核结论分布",
"outcome": "复核结论", "independent": "独立复核", "disputed": "存在争议",
"confidence_interval": "置信区间", "confidence_interval_reason": "置信区间说明",
"unknown_patient_events": "患者身份未确认事件数", "repeated_patient_events": "重复患者事件数",
"invalid_row_count": "无效记录数", "metric": "统计指标",
}
ENUM_FIELDS = {
"status", "comparison_status", "coverage_status", "comparison_type", "validity", "dose_basis", "bases",
"match_type", "match", "side", "level", "kind", "source_kind", "type", "purpose", "transfer_method",
"sample_status", "aggregation_status", "history_versioning", "sampling_method", "outcome", "unit_policy",
"model_key", "model", "unit", "formula_type",
}
REASON_FIELDS = {
"reason", "reason_message", "reason_code", "error_code", "error_message", "code", "message",
"missing", "missing_information", "baseline_exclusion_reasons", "excluded_reasons", "exclusion_reasons", "exclusion_reason",
}
SOURCE_FIELDS = {
"source", "source_id", "source_ids", "file_id", "file_ids", "evidence_file_ids", "covered_source_ids",
"evidence_references", "redaction_manifest",
}
_MACHINE_KEY = re.compile(r"[A-Za-z][A-Za-z0-9]*(?:_[A-Za-z0-9]+)+\Z")
_UPPER_CODE = re.compile(r"(?<![A-Za-z0-9_])[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+(?![A-Za-z0-9_])")
_SOURCE_ID = re.compile(r"([a-z][a-z0-9_]*)[::]([^\s,,;;:<>]+)\Z")
_KNOWN_SOURCE_IN_TEXT = re.compile(r"(?<![A-Za-z0-9_])(" + "|".join(SOURCE_LABELS) + r")[::]([A-Za-z0-9]+)(?![A-Za-z0-9_])")
def plain_text(value: Any) -> str:
if value is None or value == "":
return "—"
if isinstance(value, bool):
return "是" if value else "否"
return str(value)
def source_text(value: Any, fields: Mapping[str, str]) -> str:
"""Localize a source prefix, preserving its entire numeric or opaque ID."""
text = plain_text(value)
if text in SOURCE_LABELS:
return SOURCE_LABELS[text]
if text.startswith("clinical."):
field = text.removeprefix("clinical.")
return "临床资料 · " + fields.get(field, "待核对项目")
match = _SOURCE_ID.fullmatch(text)
if match:
prefix, identifier = match.groups()
# Redaction manifests append a field name after the numeric source ID.
if ":" in identifier:
identifier, field = identifier.split(":", 1)
return f"{SOURCE_LABELS.get(prefix, '其他来源')}(编号:{identifier}) · {fields.get(field, '待核对字段')}"
return f"{SOURCE_LABELS.get(prefix, '其他来源')}(编号:{identifier})"
if _MACHINE_KEY.fullmatch(text) or re.fullmatch(r"[A-Za-z][A-Za-z0-9_-]*", text):
return "来源类型待核对"
return _KNOWN_SOURCE_IN_TEXT.sub(lambda match: source_text(f"{match[1]}:{match[2]}", fields), text)
def system_text(value: Any, labels: Mapping[str, str], fields: Mapping[str, str], *, strict: bool = False) -> str:
"""Translate metadata, including historical ``CODE: source:id`` gap strings."""
text = plain_text(value)
if "\n" in text:
return "\n".join(system_text(line, labels, fields, strict=strict) for line in text.split("\n"))
if text in labels:
return labels[text]
if text in SOURCE_LABELS:
return SOURCE_LABELS[text]
if text in fields:
return fields[text]
# Only a technical prefix permits parsing the rest as a source identifier.
compound = re.fullmatch(r"([A-Za-z][A-Za-z0-9_]+)\s*[::]\s*(.*)", text, re.DOTALL)
if compound and (compound[1] in labels or _MACHINE_KEY.fullmatch(compound[1])):
prefix = labels.get(compound[1], "未识别的系统原因,请联系管理员核对")
return prefix + ":" + source_text(compound[2], fields)
if _MACHINE_KEY.fullmatch(text):
return "未识别的系统标识,请联系管理员核对"
# Historic reports can include a known source/code inside a Chinese gap explanation.
text = _UPPER_CODE.sub(lambda match: labels.get(match[0], "未识别的系统原因,请联系管理员核对"), text)
text = _KNOWN_SOURCE_IN_TEXT.sub(lambda match: source_text(f"{match[1]}:{match[2]}", fields), text)
if strict and re.fullmatch(r"[A-Za-z][A-Za-z0-9 ._-]*", text):
return "未识别的系统状态,请联系管理员核对"
return text
def field_text(key: Any, fields: Mapping[str, str], labels: Mapping[str, str]) -> str:
text = str(key)
if text in fields:
return fields[text]
if text.endswith("_count") and text.removesuffix("_count") in SOURCE_LABELS:
return SOURCE_LABELS[text.removesuffix("_count")] + "数"
translated = system_text(text, labels, fields)
if translated != text:
return translated
if text in SOURCE_LABELS or text.startswith("clinical.") or _SOURCE_ID.fullmatch(text):
return source_text(text, fields)
return "其他字段(待核对)" if re.search(r"[A-Za-z]", text) and not re.search(r"[\u4e00-\u9fff]", text) else text
def value_text(value: Any, field: str, labels: Mapping[str, str], fields: Mapping[str, str]) -> str:
if field in SOURCE_FIELDS:
return source_text(value, fields)
if field == "field":
return field_text(value, fields, labels)
if field == "coverage_status":
return {"partial": "资料不全", "pending": "资料待核对", "unavailable": "暂无覆盖信息"}.get(str(value)) or system_text(value, labels, fields, strict=True)
if field == "history_versioning" and value == "unavailable":
return "历史版本无法核验"
if field == "formula_type" and value == "auxiliary":
return "辅方"
if field.endswith("version") or field.endswith("versions"):
text = plain_text(value)
for prefix, name in (
("manual-prescription-independent-v", "手动处方独立分析"),
("manual-prescription-available-evidence-v", "手动处方已读资料分析"),
("prescription-soft-dice-v", "处方药味剂量一致度算法"),
("prescription-evidence-v", "处方证据资料格式"),
("prescription-source-access-v", "处方来源权限格式"),
):
if text.startswith(prefix) and re.fullmatch(r"\d+(?:\.\d+)*", text.removeprefix(prefix)):
return f"{name} · 第 {text.removeprefix(prefix)} 版"
return text
if field in ENUM_FIELDS:
return system_text(value, labels, fields, strict=True)
if field in REASON_FIELDS:
return system_text(value, labels, fields, strict=field in {"code", "error_code", "reason_code"})
if field.endswith(("_status", "_state", "_code")):
return system_text(value, labels, fields, strict=True)
if field and field not in fields:
return system_text(value, labels, fields)
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,135 @@
"""Honest presentation of server checkpoints, without fabricated totals or ETA."""
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass
from typing import Any
ACTIVE_STATES = {"pending", "preparing", "queued", "waiting", "waiting_sources", "waiting_transcript", "waiting_transcription", "running", "processing", "retrying", "retry_wait"}
SUCCESS_STATES = {"succeeded", "completed", "success"}
TERMINAL_STATES = SUCCESS_STATES | {"partial", "failed", "cancelled", "canceled", "blocked", "stale", "superseded", "invalid", "revoked", "deleted", "voided"}
STAGES = {
"preparing": "准备资料", "waiting_sources": "等待转写与资料", "queued": "排队等待处理",
"text": "分析文字资料", "files": "分析附件", "reduce": "汇总资料要点",
"final": "生成完整报告", "validating": "校验报告", "comparing": "计算用药对照",
"completed": "已完成", "retry_wait": "等待重试", "failed": "处理失败", "cancelled": "已取消",
"unknown": "等待阶段详情",
}
COMPACT_STAGES = {"preparing": "准备资料", "waiting_sources": "等待资料", "queued": "排队中", "text": "文字",
"files": "附件", "reduce": "汇总", "final": "生成报告", "validating": "校验", "comparing": "用药对照",
"completed": "已完成", "retry_wait": "待重试", "failed": "失败", "cancelled": "已取消", "unknown": "处理中"}
STATUS_STAGE = {"pending": "queued", "waiting": "waiting_sources", "waiting_transcript": "waiting_sources",
"waiting_transcription": "waiting_sources", "retrying": "retry_wait",
**{state: "completed" for state in SUCCESS_STATES}, "canceled": "cancelled", "partial": "completed", "blocked": "failed"}
def _mapping(value: Any) -> dict[str, Any]:
return dict(value) if isinstance(value, Mapping) else {}
def _integer(value: Any) -> int | None:
# Counts/timestamps are integer contract fields, not arbitrary numeric text.
return value if isinstance(value, int) and not isinstance(value, bool) and value >= 0 else None
def duration(seconds: int) -> str:
seconds = max(0, seconds)
if seconds < 60:
return f"{seconds} 秒"
if seconds < 3600:
return f"{seconds // 60} 分 {seconds % 60:02d} 秒"
return f"{seconds // 3600} 小时 {seconds % 3600 // 60:02d} 分"
@dataclass(frozen=True)
class ProgressView:
stage: str
headline: str
detail: str
completed: int | None = None
total: int | None = None
busy: bool = False
def progress_view(owner: Any, *, fallback_status: str = "", seconds: int = 0, live: bool = True) -> ProgressView:
data = _mapping(owner)
progress = _mapping(data.get("progress"))
status = str(data.get("status") or fallback_status)
stage = str(progress.get("stage") or STATUS_STAGE.get(status, status))
if stage not in STAGES:
stage = "unknown"
terminal = status in TERMINAL_STATES
if terminal:
stage = STATUS_STAGE.get(status, status)
if stage not in STAGES:
stage = "unknown"
active = status in ACTIVE_STATES and not terminal and stage not in {"completed", "failed", "cancelled"}
advance = max(0, seconds) if active else 0
headline = STAGES[stage]
if status == "partial":
headline = "部分模型已完成"
if stage == "unknown":
headline = "正在处理,等待阶段详情" if active else "暂无处理进度"
completed = _integer(progress.get("completed_units"))
total = _integer(progress.get("total_units"))
if stage not in {"text", "files", "reduce"} or total is None or not 0 < total <= 1_000_000 or completed is None or completed > total:
completed = total = None
if total is not None:
headline += f" · 本阶段 {completed}/{total} 组"
details = []
attempt = _integer(progress.get("attempt"))
if attempt is not None and 0 < attempt <= 1_000_000:
details.append(f"第 {attempt} 次尝试")
elapsed = _integer(progress.get("elapsed_seconds"))
stage_elapsed = _integer(progress.get("stage_elapsed_seconds"))
if elapsed is not None:
# Retry metadata freezes the previous attempt's duration. Only its
# scheduling countdown and update age continue between server polls.
details.append("已用时 " + duration(elapsed + (0 if stage == "retry_wait" else advance)))
if stage_elapsed is not None and active and stage != "retry_wait":
details.append("本阶段 " + duration(stage_elapsed + advance))
remaining = _integer(progress.get("wait_remaining_seconds"))
if stage in {"preparing", "waiting_sources", "retry_wait"} and remaining is not None and active:
remaining = max(0, remaining - advance)
details.append("资料等待窗口剩余 " + duration(remaining) if remaining else "等待窗口已到,等待服务端确认")
if stage == "retry_wait":
details[-1] = "距下次重试 " + duration(remaining) if remaining else "重试时间已到,等待服务端确认"
server_time = _integer(progress.get("server_time"))
updated_at = _integer(progress.get("updated_at"))
if active and server_time is not None and updated_at is not None and updated_at > 0:
details.append("阶段更新于 " + duration(max(0, server_time - updated_at) + advance) + "前")
if not progress and active:
details.append("服务端暂未提供分阶段进度")
notice = progress.get("notice")
terminal_notice = stage in {"failed", "cancelled"} and progress.get("stage") == stage and progress.get("phase") == "failed"
if isinstance(notice, str) and notice.strip() and (not terminal or terminal_notice):
details.append(notice.strip())
elif stage in {"final", "text", "files", "reduce"} and active:
details.append("等待模型响应;耗时取决于资料量与模型服务")
return ProgressView(stage, headline, " · ".join(details), completed, total, live and active)
def flow_text(batch: Any) -> str:
data = _mapping(batch)
status = data.get("status")
if not data:
return "准备资料 → 双模型分析 → 用药对照 → 完成"
if data.get("validity") not in (None, "", "current", "valid"):
return "此批次已失效 · 以下为最后保存的处理记录"
models = [_mapping(_mapping(data.get("models")).get(key)) for key in ("qwen", "openai")]
complete = sum(model.get("status") in SUCCESS_STATES for model in models)
failed = sum(model.get("status") == "failed" for model in models)
stages = [progress_view(model).stage for model in models]
batch_stage = progress_view(data).stage
if status in {"failed", "cancelled", "canceled", "blocked"}:
return f"处理已停止 · {complete}/2 个模型完成" + (f" · {failed} 个模型失败" if failed else "")
if batch_stage in {"preparing", "waiting_sources"}:
return "准备资料:进行中 → 双模型分析:待开始 → 用药对照:待开始 → 完成:待处理"
prepared = "已完成" if any(models) else "待确认"
comparisons = sum(model.get("status") in SUCCESS_STATES and bool(model.get("comparison")) for model in models)
compare_state = f"{comparisons}/2 已处理" if comparisons else "进行中" if "comparing" in stages else "待处理"
analysis = f"{complete}/2 完成" + (f",{failed} 个失败" if failed else ",进行中" if status not in TERMINAL_STATES else "")
end = "已完成" if complete == 2 else "部分完成" if status == "partial" else "待处理"
return f"准备资料:{prepared} → 双模型分析:{analysis} → 用药对照:{compare_state} → 完成:{end}"
@@ -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
@@ -20,6 +20,7 @@ from datetime import date
from pathlib import Path
from time import monotonic
from typing import Any
from uuid import uuid4
from PySide6.QtCore import (
QBuffer,
@@ -92,6 +93,7 @@ from PySide6.QtWidgets import (
from .. import icons
from ..diagnosis_terms import shared_terms
from ..infinite_list import InfiniteList
from ..theme import main_window_target_rect
from ..widgets import (
MessageBanner,
display_text,
@@ -2658,6 +2660,8 @@ class PrescriptionEditorDialog(QDialog):
else None
)
self._source = _mapping(prescription)
self._save_fingerprint = ""
self._save_request_key = ""
self._loading_data = False
self._linked_order_generation = 0
self._diagnosis_view: _StructuredDiagnosisDialog | None = None
@@ -3303,6 +3307,17 @@ class PrescriptionEditorDialog(QDialog):
self._fit_drawer_geometry()
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()
if parent is None:
return
@@ -3786,6 +3801,7 @@ class PrescriptionEditorDialog(QDialog):
"audit_remark",
"business_prescription_audit_rejected",
"business_prescription_audit_remark",
"ai_assisted",
)
result = {key: self._source.get(key) for key in hidden_keys if key in self._source}
if self.mode == "add":
@@ -3838,6 +3854,20 @@ class PrescriptionEditorDialog(QDialog):
result["dosage_amount"] = dosage_amount
else:
result.pop("dosage_amount", None)
# This editor has no independent-assistance attestation control. Only
# retain positive evidence of AI exposure; a seed's false is not a new
# doctor-confirmed statement that this submission was unassisted.
if result.get("ai_assisted") in (True, 1, "1", "true"):
result["ai_assisted"] = True
else:
result.pop("ai_assisted", None)
# A repeated submit of unchanged editor content keeps its idempotency key.
# Unknown AI exposure is omitted, rather than falsely asserted as False.
fingerprint = json.dumps(result, ensure_ascii=False, sort_keys=True, default=str)
if fingerprint != self._save_fingerprint:
self._save_fingerprint = fingerprint
self._save_request_key = str(uuid4())
result["request_key"] = self._save_request_key
return result
def _herb_validation_label(self, global_index: int) -> str:
@@ -26,6 +26,7 @@ from PySide6.QtWidgets import (
QWidget,
)
from ..report_provenance import report_provenance_lines
from ..theme import apply_reading_rhythm
from ..widgets import (
EmptyState,
@@ -1413,8 +1414,22 @@ class PrescriptionAiReportDialog(QDialog):
or "仅供专业人员辅助审方,不替代辨证、诊断和处方审核。"
)
self._add_label(disclaimer, "PrescriptionAiMuted")
self._add_report_provenance(data)
return
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:
self.dialog_generation += 1
@@ -1464,6 +1479,13 @@ class DiagnosisAiAssistantDialog(QDialog):
self.answer_label = _AiAnswerBrowser()
self.answer_scroll = self.answer_label
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.addStretch(1)
self.retry_button = QPushButton("重试")
@@ -1481,6 +1503,8 @@ class DiagnosisAiAssistantDialog(QDialog):
self.question_label.setText(f"问题:{self.prompt}")
self.answer_label.clear()
self.model_label.clear()
self.source_label.clear()
self.source_label.hide()
self._ask()
def _ask(self) -> None:
@@ -1519,6 +1543,8 @@ class DiagnosisAiAssistantDialog(QDialog):
)
self.model_label.setText(f"本次模型:{model}")
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.loading = False
self.retry_button.setEnabled(True)
@@ -1527,6 +1553,7 @@ class DiagnosisAiAssistantDialog(QDialog):
if generation != self.request_generation:
return
self.status_banner.show_message(error_message(error), "danger")
self.source_label.hide()
self.loading = False
self.retry_button.setEnabled(True)
@@ -8,7 +8,7 @@ from html import escape
from math import ceil
from typing import Any
from PySide6.QtCore import QDateTime, QModelIndex, QRectF, QSize, Qt
from PySide6.QtCore import QDateTime, QEvent, QModelIndex, QRectF, QSize, Qt, QTimer
from PySide6.QtGui import (
QColor,
QFont,
@@ -33,7 +33,8 @@ from PySide6.QtWidgets import (
QLabel,
QLineEdit,
QListWidget,
QListWidgetItem,
QListWidgetItem,
QMenu,
QMessageBox,
QPushButton,
QScrollArea,
@@ -48,7 +49,16 @@ from PySide6.QtWidgets import (
QWidget,
)
from .. import icons, motion
from .. import icons, motion
from ..dialogs.issued_prescription_ai import (
PrescriptionAiStatisticsDialog,
agreement_text,
batch_running,
can_open_issued_ai,
present_issued_prescription_ai,
state_text,
status_tooltip,
)
from ..dialogs.prescription import (
AuditPrescriptionDialog,
DiagnosisDetailDialog,
@@ -435,7 +445,22 @@ def _formula(value: Any) -> str:
return "辅方" if text in {"2", "aux", "auxiliary", "辅方"} else "主方"
def _order_warnings(row: Any) -> list[str]:
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]:
"""Match the PC list's linked-order checks, including blank herb rows."""
raw = getattr(row, "raw", None)
@@ -877,7 +902,7 @@ class _PrescriptionInfoDelegate(QStyledItemDelegate):
painter.setPen(QColor(foreground))
painter.drawText(pill, Qt.AlignmentFlag.AlignCenter, label)
elif column != 2:
lines = text.rsplit(" · ", 2) if column == 5 else text.rsplit(" ", 1) if column == 10 else [text]
lines = text.rsplit(" · ", 2) if column == 5 else text.rsplit(" ", 1) if column == 10 else text.splitlines() if column == 12 else [text]
if len(lines) > 1:
primary, secondary = lines[0], " · ".join(lines[1:]) if column == 5 else lines[1]
if column == 5:
@@ -979,7 +1004,18 @@ class PrescriptionsPage(QWidget):
self._detail_target = 0
self._diagnosis_detail_generation = 0
self._diagnosis_detail_target = 0
self._mutation_pending = False
self._mutation_pending = False
self._ai_statuses: dict[int, dict[str, Any]] = {}
self._ai_enabled = False
self._ai_request_token = 0
self._ai_pending = False
self._ai_timer = QTimer(self)
self._ai_timer.setInterval(5000)
self._ai_timer.timeout.connect(self._load_ai_statuses)
self._ai_scroll_timer = QTimer(self)
self._ai_scroll_timer.setSingleShot(True)
self._ai_scroll_timer.setInterval(180)
self._ai_scroll_timer.timeout.connect(self._load_ai_statuses)
outer = QVBoxLayout(self)
outer.setContentsMargins(28, 16, 26, 8)
@@ -1010,7 +1046,11 @@ class PrescriptionsPage(QWidget):
self.orders_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.orders_button.setVisible(has_permission(permissions, "tcm.prescriptionOrder/lists"))
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.setVisible(has_permission(permissions, "tcm.prescriptionAi/statistics", default=False) and callable(getattr(repository, "prescription_ai_statistics", None)))
self.ai_statistics_button.clicked.connect(self._open_ai_statistics)
header.add_action(self.ai_statistics_button)
self.add_button = QPushButton("新增处方", header)
self.add_button.setObjectName("PrescriptionAddButton")
self.add_button.setMinimumWidth(122)
@@ -1156,7 +1196,10 @@ class PrescriptionsPage(QWidget):
toolbar.addWidget(self.count_badge)
toolbar.addStretch(1)
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.hide()
toolbar.addWidget(self.ai_report_button)
self.patch_button = self._action_button(
"修改患者", "tcm.prescription/patchPatient", self._patch_selected
)
@@ -1181,9 +1224,17 @@ class PrescriptionsPage(QWidget):
refresh.setIcon(_blue_prescription_icon("refresh", "#5D6B80"))
refresh.setIconSize(QSize(15, 15))
refresh.clicked.connect(self.refresh)
toolbar.addWidget(refresh)
layout.addWidget(toolbar_scroll)
self.stack = QStackedWidget()
toolbar.addWidget(refresh)
layout.addWidget(toolbar_scroll)
self.ai_status_notice = QLabel("AI 分析:正在检查服务状态。", card)
self.ai_status_notice.setObjectName("PrescriptionAiStatusNotice")
self.ai_status_notice.setProperty("role", "muted")
self.ai_status_notice.setTextFormat(Qt.TextFormat.PlainText)
self.ai_status_notice.setWordWrap(True)
self.ai_status_notice.setMargin(12)
self.ai_status_notice.setAccessibleName("AI 分析状态")
layout.addWidget(self.ai_status_notice)
self.stack = QStackedWidget()
table_host = QWidget()
table_layout = QVBoxLayout(table_host)
table_layout.setContentsMargins(0, 0, 0, 0)
@@ -1200,7 +1251,9 @@ class PrescriptionsPage(QWidget):
TableColumn("void_status", "作废", 72, _void_cell),
TableColumn("doctor_name", "医生信息", 180, _doctor_cell),
TableColumn("assistant_name", "医助", 125),
TableColumn("create_time", "创建时间", 180, _create_time_cell),
TableColumn("create_time", "创建时间", 180, _create_time_cell),
TableColumn("__ai_status__", "AI 分析", 144, lambda _value, _row: "—"),
TableColumn("__ai_agreement__", "与 AI 一致度", 130, lambda _value, _row: "—"),
]
)
self.table.setObjectName("PrescriptionTable")
@@ -1213,8 +1266,13 @@ class PrescriptionsPage(QWidget):
self.table.horizontalHeader().setDefaultAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
self.table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Interactive)
self.table.horizontalHeader().moveSection(2, 10)
for column, width in enumerate((42, 192, 144, 108, 94, 144, 108, 80, 137, 83, 128)):
self.table.setColumnWidth(column, width)
for column, width in enumerate((42, 192, 144, 108, 94, 144, 108, 80, 137, 83, 128)):
self.table.setColumnWidth(column, width)
self.table.setColumnHidden(11, True)
self.table.setColumnHidden(12, True)
self.table.horizontalHeaderItem(12).setToolTip("分别显示千问、OpenAI 的药味与剂量一致度;点击查看逐味贡献。")
self.table.horizontalHeader().viewport().installEventFilter(self)
self.table.verticalScrollBar().valueChanged.connect(lambda _value: self._schedule_ai_statuses())
self.table.setWordWrap(False)
# Rows are not uniform: the number column's delegate grows a row that
# carries an order warning so the warning text stays readable without a
@@ -1227,7 +1285,10 @@ class PrescriptionsPage(QWidget):
self.table.horizontalHeaderItem(0).setIcon(_blue_prescription_icon("checkbox", "#8A97A9", 14))
self.table.horizontalHeaderItem(0).setTextAlignment(Qt.AlignmentFlag.AlignCenter)
self.table.itemSelectionChanged.connect(self._selection_changed)
self.table.itemDoubleClicked.connect(lambda _item: self._view_selected())
self.table.itemDoubleClicked.connect(lambda _item: self._view_selected())
self.table.itemClicked.connect(lambda item: self._open_ai_report() if item.column() in {11, 12} else None)
self.table.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
self.table.customContextMenuRequested.connect(self._open_ai_context_menu)
table_layout.addWidget(self.table, 1)
self.pager = InfiniteList(self._page_size)
self.pager.bind(self.table)
@@ -1271,9 +1332,22 @@ class PrescriptionsPage(QWidget):
self.filter_grid.setColumnStretch(column, stretch)
self.filter_card.setFixedHeight(200 if compact else 144)
def _number_column_resized(self, column: int, _old_size: int, _new_size: int) -> None:
def _number_column_resized(self, column: int, _old_size: int, _new_size: int) -> None:
if column == 1:
self.table.resizeRowsToContents()
self.table.resizeRowsToContents()
def eventFilter(self, watched: Any, event: Any) -> bool:
if hasattr(self, "table") and watched is self.table.horizontalHeader().viewport() and event.type() in {QEvent.Type.MouseButtonPress, QEvent.Type.MouseButtonRelease, QEvent.Type.MouseButtonDblClick}:
header = self.table.horizontalHeader()
position = event.position().toPoint()
column = header.logicalIndexAt(position)
if column in {11, 12} and event.button() == Qt.MouseButton.LeftButton:
relative = position.x() - header.sectionViewportPosition(column)
# Keep native column resizing, but never rank mixed-model display
# text or treat unavailable values as numeric zero through sorting.
if 5 < relative < header.sectionSize(column) - 5:
return True
return super().eventFilter(watched, event)
def _action_button(
self,
@@ -1354,7 +1428,11 @@ class PrescriptionsPage(QWidget):
self.doctor_filter.clear()
self._search()
def refresh(self) -> None:
def refresh(self) -> None:
self._ai_timer.stop()
self._ai_request_token += 1
self._ai_pending = False
self._ai_statuses.clear()
self._loading = True
self._refresh_pending = False
self._generation += 1
@@ -1392,8 +1470,9 @@ class PrescriptionsPage(QWidget):
if generation != self._generation:
return
rows = page_items(result)
self.table.set_rows(rows)
self._decorate_rows()
self.table.set_rows(rows)
self._decorate_rows()
self._render_ai_statuses()
total = page_total(result, len(rows))
self.pager.update_state(requested_page, total)
self.count_badge.setText(f"共 {total} 条")
@@ -1415,7 +1494,8 @@ class PrescriptionsPage(QWidget):
self.banner.clear()
if rows and self.table.currentRow() < 0:
self.table.selectRow(0)
self._selection_changed()
self._selection_changed()
self._load_ai_statuses()
def _decorate_rows(self) -> None:
"""Apply the reference table's tags, checkbox, avatar, and row actions."""
@@ -1489,7 +1569,7 @@ class PrescriptionsPage(QWidget):
actions.setContentsMargins(3, 0, 3, 0)
actions.setSpacing(3)
actions.addStretch(1)
if has_permission(self.permissions, "cf.prescription/read"):
if has_permission(self.permissions, "cf.prescription/read"):
actions.addWidget(
_row_action_button(
"eye",
@@ -1499,7 +1579,15 @@ class PrescriptionsPage(QWidget):
),
actions_host,
)
)
)
if self._ai_enabled and can_open_issued_ai(self.permissions) and not _is_blank_prescription(row):
actions.addWidget(
_row_action_button(
"eye", "AI 报告",
lambda _checked=False, target=row: self._run_row_action(target, self._open_ai_report),
actions_host, label="AI",
)
)
if has_permission(self.permissions, "cf.prescription/edit"):
actions.addWidget(
_row_action_button(
@@ -1531,7 +1619,179 @@ class PrescriptionsPage(QWidget):
actions.addStretch(1)
self.table.setCellWidget(row_index, 2, actions_host)
self._sync_row_mutation_actions()
self.table.resizeRowsToContents()
self.table.resizeRowsToContents()
def _visible_ai_ids(self) -> list[int]:
if not self.isVisible() or not self.table.isVisible():
return []
ids = []
viewport = self.table.viewport().rect()
for index in range(self.table.rowCount()):
item = self.table.item(index, 0)
# Column 0 may be horizontally off-screen; use row geometry only.
top = self.table.rowViewportPosition(index)
if item is None or top + self.table.rowHeight(index) <= 0 or top >= viewport.height():
continue
row = item.data(Qt.ItemDataRole.UserRole)
if _is_blank_prescription(row):
continue
value = _int(first_value(row, "id", "prescription_id"), 0)
if value > 0:
ids.append(value)
return list(dict.fromkeys(ids))[:100]
def _schedule_ai_statuses(self) -> None:
if self.isVisible():
self._ai_scroll_timer.start()
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)
if not has_permission(self.permissions, "tcm.prescriptionAi/statuses", default=False):
self.ai_status_notice.setText("AI 分析:当前账号没有查看分析状态的权限,请联系管理员授权。")
return
if not callable(method):
self.ai_status_notice.setText("AI 分析:当前数据模式暂不支持此功能。")
return
if self._ai_pending:
return
ids = self._visible_ai_ids()
ids = [value for value in ids if value not in self._ai_statuses or batch_running(self._ai_statuses[value])]
if not ids:
self._ai_timer.stop()
if self.table.rowCount() == 0:
self.ai_status_notice.setText("AI 分析:当前列表没有处方,暂无可查看的分析结果。")
return
self._ai_pending = True
self._ai_request_token += 1
token, generation = self._ai_request_token, self._generation
run_async(
lambda: method(ids),
on_success=lambda result: self._ai_statuses_ready(result, ids, token, generation),
on_error=lambda error: self._ai_statuses_error(error, token, generation),
on_finished=lambda: self._ai_statuses_finished(token),
)
def _ai_statuses_finished(self, token: int) -> None:
if token == self._ai_request_token:
self._ai_pending = False
def _set_ai_columns(self, enabled: bool) -> None:
changed = enabled != self._ai_enabled
self._ai_enabled = enabled
for column in (11, 12):
self.table.setColumnHidden(column, not enabled)
if enabled:
header = self.table.horizontalHeader()
header.moveSection(header.visualIndex(11), header.visualIndex(4) + 1)
header.moveSection(header.visualIndex(12), header.visualIndex(11) + 1)
self.table.setColumnWidth(11, 144)
self.table.setColumnWidth(12, 130)
self.table.setColumnWidth(2, 178)
if changed:
self._decorate_rows()
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():
return
data = _row_mapping(result)
self._set_ai_columns(data.get("enabled") is True)
if not self._ai_enabled:
self._ai_timer.stop()
notice = "AI 分析未启用:暂不生成报告或一致度,请联系管理员启用。"
if can_open_issued_ai(self.permissions):
notice += "已有报告仍可从“AI 报告”查看。"
self.ai_status_notice.setText(notice)
self.ai_status_notice.setToolTip("")
self.ai_report_button.setToolTip("自动分析未启用;仍可查看已保存的历史报告。")
return
self.ai_status_notice.setText("AI 分析已启用:保存手工处方后自动生成两份报告,并显示药味与剂量一致度。")
self.ai_status_notice.setToolTip("")
self.ai_report_button.setToolTip("查看两份 AI 报告、候选处方和逐味对照。")
for value in ids:
self._ai_statuses[value] = {}
for batch in data.get("items") or []:
value = _int(get_value(batch, "prescription_id"), 0)
if value in ids:
self._ai_statuses[value] = _row_mapping(batch)
self._render_ai_statuses()
if any(batch_running(self._ai_statuses.get(value)) for value in self._visible_ai_ids()):
self._ai_timer.start()
else:
self._ai_timer.stop()
def _render_ai_statuses(self) -> None:
sorting = self.table.isSortingEnabled()
self.table.setSortingEnabled(False)
changed = False
try:
for index in range(self.table.rowCount()):
row = self.table.item(index, 0).data(Qt.ItemDataRole.UserRole)
value = _int(first_value(row, "id", "prescription_id"), 0)
batch = self._ai_statuses.get(value)
blank = _is_blank_prescription(row)
if batch is None and not blank:
continue
state = "" if blank else (state_text(batch) if batch else "尚无分析记录")
agreement = "" if blank else agreement_text(batch)
for column, text in ((11, state), (12, agreement)):
item = self.table.item(index, column)
if item.text() != text:
item.setText(text)
changed = True
item.setToolTip("" if blank else status_tooltip(batch))
item.setData(Qt.ItemDataRole.AccessibleTextRole, text)
if changed:
self.table.resizeRowsToContents()
finally:
self.table.setSortingEnabled(sorting)
def _ai_statuses_error(self, error: Exception, token: int, generation: int) -> None:
if token == self._ai_request_token and generation == self._generation:
self._ai_timer.stop()
self._set_ai_columns(False)
self.ai_status_notice.setText("AI 分析暂不可用:请稍后刷新;持续无法使用时,请联系管理员检查服务。")
self.ai_status_notice.setToolTip(friendly_error(error))
self.ai_report_button.setToolTip("AI 状态暂不可用:" + friendly_error(error))
def _open_ai_report(self) -> None:
row = self._selected()
if _is_blank_prescription(row):
return
value = _int(first_value(row, "id", "prescription_id"), 0)
if value > 0:
present_issued_prescription_ai(self.repository, self.permissions, self, prescription_id=value)
def _open_ai_context_menu(self, position: Any) -> None:
if not can_open_issued_ai(self.permissions) or not callable(getattr(self.repository, "list_prescription_ai_reports", None)):
return
item = self.table.itemAt(position)
if item is None:
return
self.table.selectRow(item.row())
row = self._selected()
if _is_blank_prescription(row):
return
menu = QMenu(self.table)
action = menu.addAction("AI 报告 / 逐味对照")
action.triggered.connect(lambda: self._run_row_action(row, self._open_ai_report))
self._ai_context_menu = menu
menu.popup(self.table.viewport().mapToGlobal(position))
def _open_ai_statistics(self) -> None:
if not has_permission(self.permissions, "tcm.prescriptionAi/statistics", default=False):
return
dialog = PrescriptionAiStatisticsDialog(self.repository, self.permissions, self)
dialog.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
dialog.show()
def _run_row_action(self, row: Any, callback: Callable[[], None]) -> None:
target_id = _int(first_value(row, "id", "prescription_id", default=None), 0)
@@ -1566,7 +1826,14 @@ class PrescriptionsPage(QWidget):
def _selection_changed(self) -> None:
row = self.table.current_data()
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)
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.create_order_button.setEnabled(active and can_create_order(row))
self.edit_button.setEnabled(active and can_edit_or_delete(row))
@@ -1935,10 +2202,19 @@ class PrescriptionsPage(QWidget):
self.banner.show_message(message, "danger")
show_toast(self, message, "danger", 5000)
def showEvent(self, event: Any) -> None:
def showEvent(self, event: Any) -> None:
super().showEvent(event)
if self.table.rowCount() == 0 and not self._loading:
self.refresh()
if self.table.rowCount() == 0 and not self._loading:
self.refresh()
elif not self._loading:
self._load_ai_statuses()
def hideEvent(self, event: Any) -> None:
self._ai_timer.stop()
self._ai_scroll_timer.stop()
self._ai_request_token += 1
self._ai_pending = False
super().hideEvent(event)
__all__ = [
@@ -89,6 +89,7 @@ from ..dialogs.prescription_ai import (
)
from ..filter_disclosure import FilterDisclosure
from ..reception_style import body_family, heading_family, number_family
from ..report_provenance import report_provenance_lines
from ..theme import mark_business_dialog
from ..widgets import (
EmptyState,
@@ -3335,6 +3336,14 @@ class _ReceptionAiAnalysisDialog(QDialog):
"",
"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)
scroll.setWidget(body)
surface_layout.addWidget(scroll, 1)
@@ -3525,6 +3534,7 @@ class _ReceptionAiAnalysisDialog(QDialog):
" | ".join(part for part in (model, f"第 {version} 版", generated_at) if part)
)
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)
@@ -5198,6 +5208,13 @@ class ReceptionPage(QWidget):
state_layout.addStretch(1)
self.ai_analysis_stack.addWidget(analysis_state)
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)
report_footer = QHBoxLayout(self.ai_detail_actions_host)
@@ -5432,6 +5449,7 @@ class ReceptionPage(QWidget):
self.ai_analysis_stack.setCurrentWidget(
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 (
self._ai_analysis_rendered_model != model
@@ -5559,6 +5577,9 @@ class ReceptionPage(QWidget):
)
self.ai_analysis_snapshot_meta.setVisible(bool(meta_text))
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:
_clear_ai_layout(self.ai_risk_chip_layout)
@@ -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)
+1
View File
@@ -885,6 +885,7 @@ class ShellWindow(QMainWindow):
parent: QWidget | None = None,
) -> None:
super().__init__(parent)
self.setProperty("doctorMainWindow", True)
self.setWindowTitle("甄养堂 · 问诊中心")
self.setWindowFlag(Qt.WindowType.FramelessWindowHint, True)
# The ground paints its own rounded corner, so everything outside that
+78 -1
View File
@@ -8,7 +8,7 @@ from pathlib import Path
from string import Template
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 (
QColor,
QFont,
@@ -33,6 +33,7 @@ from PySide6.QtWidgets import (
QScrollArea,
QTextBrowser,
QTextEdit,
QWidget,
)
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)
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):
"""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):
allow_dialog_resize(watched)
elif isinstance(watched, QDialog) and event_type == QEvent.Type.Show:
_size_new_window_to_main(watched)
for button_box in watched.findChildren(QDialogButtonBox):
_polish_dialog_buttons(button_box)
if not self._is_native_or_overlay(watched):
@@ -1505,6 +1574,13 @@ class _BusinessDialogStyleFilter(QObject):
watched.setMinimumWidth(max(watched.minimumWidth(), 460))
elif isinstance(watched, QInputDialog):
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
@@ -1600,4 +1676,5 @@ __all__ = [
"apply_theme",
"crisp_pixmap",
"mark_business_dialog",
"main_window_target_rect",
]
+7
View File
@@ -127,6 +127,13 @@ def format_record_time(value: Any, default: str = "—") -> str:
raw = str(value).strip()
if not raw:
return default
# The API sends 0 for "not set yet" (a snapshot cutoff that has not been frozen, an
# unfinished task); rendering it as a bare 0 or as 1970 would read as a real time.
try:
if float(raw) <= 0:
return default
except ValueError:
pass
if _UNIX_TIMESTAMP_RE.fullmatch(raw):
stamp = float(raw)
if stamp >= 10_000_000_000:
+228 -3
View File
@@ -8,7 +8,7 @@ from typing import Any
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from PySide6.QtCore import Qt
from PySide6.QtCore import QPoint, Qt
from PySide6.QtTest import QTest
from PySide6.QtWidgets import (
QApplication,
@@ -125,6 +125,56 @@ def test_ai_consult_dialog_matches_workspace_chrome(
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])
def test_input_enter_submits_once_without_closing_dialog(
application: QApplication,
@@ -197,6 +247,52 @@ def test_prescription_action_without_permission_does_not_call_ai_or_repository(
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(
application: QApplication,
immediate_async: None,
@@ -298,7 +394,7 @@ def test_prescription_action_opens_editor_and_forces_current_diagnosis_ownership
dialog = AiConsultDialog(
Repository(),
PermissionSet(
["tcm.diagnosis/aiAssistant", "tcm.diagnosis/chufang"]
["tcm.diagnosis/aiAssistant", "tcm.diagnosis/chufang", "tcm.diagnosis/aiGeneratePrescription"]
),
parent=host,
)
@@ -366,7 +462,7 @@ def test_prescription_action_rejects_mismatched_patient_detail(
dialog = AiConsultDialog(
Repository(),
PermissionSet(
["tcm.diagnosis/aiAssistant", "tcm.diagnosis/chufang"]
["tcm.diagnosis/aiAssistant", "tcm.diagnosis/chufang", "tcm.diagnosis/aiGeneratePrescription"]
),
)
dialog.diagnosis_id = 501
@@ -617,6 +713,29 @@ def test_ai_consult_sidebar_loads_patient_facts_and_reports(
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(
application: QApplication,
immediate_async: None,
@@ -1372,6 +1491,55 @@ def test_pending_and_silently_closed_streams_never_show_a_blank_bubble(
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(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
@@ -1382,15 +1550,29 @@ def test_answered_stream_replaces_the_pending_placeholder(
dialog._ask("请总结当前病情")
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._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": "done", "model_label": "千问"})
dialog._stream_finished(generation, stream_generation, dialog._stream_worker)
application.processEvents()
assert dialog._stream_text == "证候:脾肾两虚"
assert not dialog._stream_timeout_timer.isActive()
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_WAITING_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()
@@ -1522,6 +1704,49 @@ def test_sectioned_reply_becomes_a_structured_report(application: QApplication)
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(
"reply",
[
File diff suppressed because it is too large Load Diff
@@ -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
+1
View File
@@ -53,6 +53,7 @@ def test_windows_one_click_entrypoints_and_release_pipeline() -> None:
release_launcher = read("packaging/windows/start_release.bat")
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_CACHE_DIR"' 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()
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(
application: QApplication,
) -> None:
+1
View File
@@ -308,6 +308,7 @@ def test_diagnosis_dialog_renders_saved_case_report(
assert dialog.snapshot_caption.text() == "完整病历"
assert "肝郁脾虚证" in dialog.snapshot_body.text()
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 not dialog.generate_button.isHidden()
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.prescription_library import PrescriptionLibraryPage
from doctor_workstation.ui.pages.prescriptions import PrescriptionsPage
from doctor_workstation.ui.widgets import BusinessPager
@pytest.fixture(scope="module")
+2 -1
View File
@@ -303,7 +303,8 @@ def test_issued_page_sends_exact_filter_dto_and_row_guards(
page.table.selectRow(0)
page._selection_changed()
assert page.table.columnCount() == 11
assert page.table.columnCount() == 13
assert page.table.isColumnHidden(11) and page.table.isColumnHidden(12)
assert page.table.horizontalHeaderItem(2).text() == "操作"
assert page.table.cellWidget(0, 2) is not None
# 处方类型与审核状态由 _RowDecorationDelegate 绘制标签,不再为每行每列
+2 -1
View File
@@ -121,7 +121,8 @@ def test_shell_preserves_all_columns_filters_and_reachable_pager(
table = page.table
assert [column.key for column in table.columns] == [
"__selected__", "sn", "__actions__", "prescription_type", "is_system_auto",
"patient_name", "audit_status", "void_status", "doctor_name", "assistant_name", "create_time",
"patient_name", "audit_status", "void_status", "doctor_name", "assistant_name", "create_time",
"__ai_status__", "__ai_agreement__",
]
assert [table.horizontalHeader().logicalIndex(index) for index in range(11)] == [
0, 1, 3, 4, 5, 6, 7, 8, 9, 10, 2,
@@ -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)
+61 -3
View File
@@ -8,7 +8,13 @@ from typing import Any
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.services.mock_repository import DemoDoctorRepository
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 == []
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):
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()
events = list(
@@ -789,6 +796,57 @@ def test_remote_diagnosis_ai_stream_falls_back_once_but_not_for_error_event() ->
"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):
def post_event_stream(self, *args: Any, **kwargs: Any):
yield {"event": "error", "data": {"message": "模型繁忙"}}
+9
View File
@@ -46,6 +46,15 @@ def test_format_record_time_with_datetime_returns_minute_precision() -> None:
assert format_record_time(date(2026, 8, 20)) == "2026-08-20"
def test_format_record_time_with_unset_epoch_returns_default() -> None:
# The API sends 0 (and occasionally "0") for a snapshot cutoff or finish time that does
# not exist yet; it must not be rendered as a bare 0 or as 1970.
assert format_record_time(0) == "—"
assert format_record_time("0") == "—"
assert format_record_time(-1) == "—"
assert format_record_time(0, default="未开始") == "未开始"
def test_format_record_time_with_garbage_returns_raw_value() -> None:
assert format_record_time("not-a-timestamp") == "not-a-timestamp"
+145
View File
@@ -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()
-495
View File
@@ -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
-91
View File
@@ -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.
-495
View File
@@ -1,495 +0,0 @@
vite v6.4.2 building for production...
WARN
(!) outDir D:\web\zyt\artifacts\im-account-filter\admin-build is not inside project root and will not be emptied.
Use --emptyOutDir to override.
transforming...
✓ 4063 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/im-account-filter/admin-build/assets/default_avatar-C6VB7PGm.png 6.09 kB
../artifacts/im-account-filter/admin-build/assets/no_perms-jDxcYpYC.png 14.62 kB
../artifacts/im-account-filter/admin-build/index.html 31.87 kB │ gzip: 16.16 kB
../artifacts/im-account-filter/admin-build/assets/login_bg-BkIjQ0FB.png 59.27 kB
../artifacts/im-account-filter/admin-build/assets/red3-NOuWP8DK.png 105.00 kB
../artifacts/im-account-filter/admin-build/assets/pink3-BxZ4Y6CS.png 108.36 kB
../artifacts/im-account-filter/admin-build/assets/blue3-D3K9OqGO.png 108.98 kB
../artifacts/im-account-filter/admin-build/assets/yellow3-C_qd9cqN.png 109.27 kB
../artifacts/im-account-filter/admin-build/assets/green3-CPorIQiC.png 109.99 kB
../artifacts/im-account-filter/admin-build/assets/purple3-BGd0LxTa.png 110.15 kB
../artifacts/im-account-filter/admin-build/assets/my_topbg-BiU0PleK.png 142.47 kB
../artifacts/im-account-filter/admin-build/assets/red2-Dw8p71sP.png 750.50 kB
../artifacts/im-account-filter/admin-build/assets/purple2-C0tQkldV.png 752.33 kB
../artifacts/im-account-filter/admin-build/assets/green2-C-VRKLSN.png 756.86 kB
../artifacts/im-account-filter/admin-build/assets/yellow2-B-WtqITJ.png 762.86 kB
../artifacts/im-account-filter/admin-build/assets/pink2-BpEp33zy.png 806.48 kB
../artifacts/im-account-filter/admin-build/assets/blue2-CRQPdLZd.png 806.81 kB
../artifacts/im-account-filter/admin-build/assets/red1-C6Y3UuNB.png 1,660.63 kB
../artifacts/im-account-filter/admin-build/assets/yellow1-Ebw0T5sw.png 1,662.23 kB
../artifacts/im-account-filter/admin-build/assets/pink1-BWNZrP7C.png 1,668.56 kB
../artifacts/im-account-filter/admin-build/assets/blue1-gLOo1H0w.png 1,676.54 kB
../artifacts/im-account-filter/admin-build/assets/purple1-BpMq9FWz.png 1,680.01 kB
../artifacts/im-account-filter/admin-build/assets/green1-h1zqes95.png 1,688.20 kB
../artifacts/im-account-filter/admin-build/assets/index-CmFE2aZQ.css 0.04 kB │ gzip: 0.06 kB
../artifacts/im-account-filter/admin-build/assets/index-CjSHFu-R.css 0.04 kB │ gzip: 0.06 kB
../artifacts/im-account-filter/admin-build/assets/index-Oppei429.css 0.04 kB │ gzip: 0.06 kB
../artifacts/im-account-filter/admin-build/assets/CaseRecordList-Cl-c3q7S.css 0.05 kB │ gzip: 0.07 kB
../artifacts/im-account-filter/admin-build/assets/DietRecordList-Cc-xUr2B.css 0.05 kB │ gzip: 0.07 kB
../artifacts/im-account-filter/admin-build/assets/roster-CqNlv_rj.css 0.05 kB │ gzip: 0.07 kB
../artifacts/im-account-filter/admin-build/assets/BloodRecordList-DhVK-Y_e.css 0.05 kB │ gzip: 0.07 kB
../artifacts/im-account-filter/admin-build/assets/index-B3imPWFk.css 0.05 kB │ gzip: 0.07 kB
../artifacts/im-account-filter/admin-build/assets/list-BluNBZln.css 0.05 kB │ gzip: 0.07 kB
../artifacts/im-account-filter/admin-build/assets/ExerciseRecordList-DGtgtzou.css 0.05 kB │ gzip: 0.07 kB
../artifacts/im-account-filter/admin-build/assets/edit-CObVTcPU.css 0.09 kB │ gzip: 0.10 kB
../artifacts/im-account-filter/admin-build/assets/edit-D-D8bVPM.css 0.09 kB │ gzip: 0.10 kB
../artifacts/im-account-filter/admin-build/assets/pc_details-nlJo1_D0.css 0.09 kB │ gzip: 0.10 kB
../artifacts/im-account-filter/admin-build/assets/AppointmentRecordPanel-DC22GDgn.css 0.09 kB │ gzip: 0.10 kB
../artifacts/im-account-filter/admin-build/assets/theme-picker-BsELUxM9.css 0.11 kB │ gzip: 0.09 kB
../artifacts/im-account-filter/admin-build/assets/content-Cl-9UIip.css 0.13 kB │ gzip: 0.13 kB
../artifacts/im-account-filter/admin-build/assets/AssignLogPanel-DkQMoEkX.css 0.13 kB │ gzip: 0.13 kB
../artifacts/im-account-filter/admin-build/assets/content-DXAsZ7EV.css 0.13 kB │ gzip: 0.13 kB
../artifacts/im-account-filter/admin-build/assets/content-DaxRy47P.css 0.14 kB │ gzip: 0.14 kB
../artifacts/im-account-filter/admin-build/assets/edit-CAJZpU7c.css 0.14 kB │ gzip: 0.12 kB
../artifacts/im-account-filter/admin-build/assets/content-DxNvUNZR.css 0.15 kB │ gzip: 0.15 kB
../artifacts/im-account-filter/admin-build/assets/useListTimeFilter-DI6SIumd.css 0.16 kB │ gzip: 0.14 kB
../artifacts/im-account-filter/admin-build/assets/edit-C1NtFi7N.css 0.16 kB │ gzip: 0.11 kB
../artifacts/im-account-filter/admin-build/assets/content-Bd3rHJ5J.css 0.18 kB │ gzip: 0.15 kB
../artifacts/im-account-filter/admin-build/assets/change-password-DRfsLJ26.css 0.19 kB │ gzip: 0.16 kB
../artifacts/im-account-filter/admin-build/assets/content-Dy_alH9u.css 0.19 kB │ gzip: 0.16 kB
../artifacts/im-account-filter/admin-build/assets/decoration-img-C5XvHl9_.css 0.19 kB │ gzip: 0.16 kB
../artifacts/im-account-filter/admin-build/assets/account_cost-fbCWUO9k.css 0.20 kB │ gzip: 0.16 kB
../artifacts/im-account-filter/admin-build/assets/index-57RdOFNo.css 0.23 kB │ gzip: 0.16 kB
../artifacts/im-account-filter/admin-build/assets/error-Cz3CexuM.css 0.24 kB │ gzip: 0.18 kB
../artifacts/im-account-filter/admin-build/assets/edit-D4atl_7z.css 0.25 kB │ gzip: 0.17 kB
../artifacts/im-account-filter/admin-build/assets/attr-lNortPsv.css 0.27 kB │ gzip: 0.19 kB
../artifacts/im-account-filter/admin-build/assets/setting-CxqqGetv.css 0.27 kB │ gzip: 0.17 kB
../artifacts/im-account-filter/admin-build/assets/index-DX96drV3.css 0.28 kB │ gzip: 0.17 kB
../artifacts/im-account-filter/admin-build/assets/menu-DYFusokT.css 0.29 kB │ gzip: 0.16 kB
../artifacts/im-account-filter/admin-build/assets/OrderActionHost-DiqybThz.css 0.31 kB │ gzip: 0.22 kB
../artifacts/im-account-filter/admin-build/assets/index-Bk2rMrqw.css 0.32 kB │ gzip: 0.20 kB
../artifacts/im-account-filter/admin-build/assets/index-D0f1Mn00.css 0.33 kB │ gzip: 0.22 kB
../artifacts/im-account-filter/admin-build/assets/prescription-drawer-BjwiqvPc.css 0.35 kB │ gzip: 0.16 kB
../artifacts/im-account-filter/admin-build/assets/bind-work-wechat-BX1gqwq1.css 0.35 kB │ gzip: 0.22 kB
../artifacts/im-account-filter/admin-build/assets/index-DhR2yeiO.css 0.39 kB │ gzip: 0.19 kB
../artifacts/im-account-filter/admin-build/assets/index-DoFDMZdP.css 0.40 kB │ gzip: 0.23 kB
../artifacts/im-account-filter/admin-build/assets/index-DBNvK3ZK.css 0.45 kB │ gzip: 0.21 kB
../artifacts/im-account-filter/admin-build/assets/PatientOrderList-D8-uh2iu.css 0.45 kB │ gzip: 0.25 kB
../artifacts/im-account-filter/admin-build/assets/DiagnosisTodoList-D0SBmTHG.css 0.46 kB │ gzip: 0.23 kB
../artifacts/im-account-filter/admin-build/assets/CallRecordPanel-TsjZtTO8.css 0.47 kB │ gzip: 0.23 kB
../artifacts/im-account-filter/admin-build/assets/index-C_LxAEkS.css 0.48 kB │ gzip: 0.27 kB
../artifacts/im-account-filter/admin-build/assets/login-Bt4SvQsz.css 0.52 kB │ gzip: 0.28 kB
../artifacts/im-account-filter/admin-build/assets/content-sMbqkta2.css 0.55 kB │ gzip: 0.29 kB
../artifacts/im-account-filter/admin-build/assets/RecordingPlaybackBlock-B3KYFgvg.css 0.58 kB │ gzip: 0.26 kB
../artifacts/im-account-filter/admin-build/assets/medicine-DO6x6zrS.css 0.58 kB │ gzip: 0.29 kB
../artifacts/im-account-filter/admin-build/assets/preview-pc-BRQDo0AR.css 0.67 kB │ gzip: 0.36 kB
../artifacts/im-account-filter/admin-build/assets/index-BzrSkGWL.css 0.67 kB │ gzip: 0.27 kB
../artifacts/im-account-filter/admin-build/assets/tabbar-DJsOahKR.css 0.69 kB │ gzip: 0.31 kB
../artifacts/im-account-filter/admin-build/assets/qywx-Dym8iFe0.css 0.74 kB │ gzip: 0.37 kB
../artifacts/im-account-filter/admin-build/assets/AssistantWatchCallDialog-BIPZRgaH.css 0.76 kB │ gzip: 0.38 kB
../artifacts/im-account-filter/admin-build/assets/picker-JBYDNsl5.css 0.84 kB │ gzip: 0.32 kB
../artifacts/im-account-filter/admin-build/assets/oa-phone-CctIyraX.css 0.94 kB │ gzip: 0.34 kB
../artifacts/im-account-filter/admin-build/assets/preview-C7oaKmYo.css 0.98 kB │ gzip: 0.41 kB
../artifacts/im-account-filter/admin-build/assets/index-Bidrbn2Z.css 1.16 kB │ gzip: 0.51 kB
../artifacts/im-account-filter/admin-build/assets/TrackingNoteTimeline-B9T8pFso.css 1.18 kB │ gzip: 0.47 kB
../artifacts/im-account-filter/admin-build/assets/RecordingVideoPlayer-4DegHAwm.css 1.19 kB │ gzip: 0.53 kB
../artifacts/im-account-filter/admin-build/assets/index-55HtuLpn.css 1.21 kB │ gzip: 0.55 kB
../artifacts/im-account-filter/admin-build/assets/PatientInfoCard-n6_UPo9e.css 1.32 kB │ gzip: 0.55 kB
../artifacts/im-account-filter/admin-build/assets/TrackingMatrix-zyxtXYPb.css 1.32 kB │ gzip: 0.44 kB
../artifacts/im-account-filter/admin-build/assets/SendPanel-DaGPWlQG.css 1.39 kB │ gzip: 0.50 kB
../artifacts/im-account-filter/admin-build/assets/picker-XZbUgFct.css 1.57 kB │ gzip: 0.50 kB
../artifacts/im-account-filter/admin-build/assets/PrescriptionOrderDetailDrawer-B4rggqa1.css 1.73 kB │ gzip: 0.71 kB
../artifacts/im-account-filter/admin-build/assets/ImChatRecordPanel-CNRZQxnD.css 1.85 kB │ gzip: 0.61 kB
../artifacts/im-account-filter/admin-build/assets/index-BYOmK9R5.css 1.93 kB │ gzip: 0.58 kB
../artifacts/im-account-filter/admin-build/assets/index-OgnT0gUo.css 1.98 kB │ gzip: 0.56 kB
../artifacts/im-account-filter/admin-build/assets/MessageBubble-BwtdPM6K.css 2.04 kB │ gzip: 0.60 kB
../artifacts/im-account-filter/admin-build/assets/index-BmDW9vcs.css 2.44 kB │ gzip: 0.86 kB
../artifacts/im-account-filter/admin-build/assets/PatientCaseCard-C3Mob6yG.css 2.49 kB │ gzip: 0.81 kB
../artifacts/im-account-filter/admin-build/assets/tongji-C0VnzFxy.css 2.49 kB │ gzip: 0.77 kB
../artifacts/im-account-filter/admin-build/assets/dept-tongji-ky7rOYoi.css 2.67 kB │ gzip: 0.72 kB
../artifacts/im-account-filter/admin-build/assets/readonly-CdP3eZN0.css 2.69 kB │ gzip: 0.92 kB
../artifacts/im-account-filter/admin-build/assets/NoteTimeline-I2FSxX4s.css 2.74 kB │ gzip: 0.84 kB
../artifacts/im-account-filter/admin-build/assets/patient-call-k8V9hV2G.css 2.85 kB │ gzip: 0.91 kB
../artifacts/im-account-filter/admin-build/assets/DailyMatrix-DCZnxI8i.css 2.87 kB │ gzip: 0.85 kB
../artifacts/im-account-filter/admin-build/assets/PaibanPanel-aqm8azGc.css 3.35 kB │ gzip: 0.88 kB
../artifacts/im-account-filter/admin-build/assets/paiban-BTUHWpUu.css 3.35 kB │ gzip: 0.88 kB
../artifacts/im-account-filter/admin-build/assets/appointment-g8BrHfhi.css 3.43 kB │ gzip: 0.93 kB
../artifacts/im-account-filter/admin-build/assets/PrescriptionAiBatchGenerateDialog-CKkykmdr.css 3.45 kB │ gzip: 0.86 kB
../artifacts/im-account-filter/admin-build/assets/h5-BSFcFSny.css 3.52 kB │ gzip: 0.99 kB
../artifacts/im-account-filter/admin-build/assets/index-Bk7b_sL2.css 3.57 kB │ gzip: 0.96 kB
../artifacts/im-account-filter/admin-build/assets/add-ByXRFm8M.css 3.60 kB │ gzip: 0.87 kB
../artifacts/im-account-filter/admin-build/assets/PromotionAutomationForm-BMhNCJa-.css 3.64 kB │ gzip: 1.03 kB
../artifacts/im-account-filter/admin-build/assets/mubiao-B6IOi2bJ.css 3.78 kB │ gzip: 1.08 kB
../artifacts/im-account-filter/admin-build/assets/WelcomeMessageEditor-DD5mXWP2.css 4.41 kB │ gzip: 1.23 kB
../artifacts/im-account-filter/admin-build/assets/PrescriptionAiReportDialog-CV6AuHV3.css 4.46 kB │ gzip: 1.11 kB
../artifacts/im-account-filter/admin-build/assets/index-CMsGEvq_.css 5.19 kB │ gzip: 1.18 kB
../artifacts/im-account-filter/admin-build/assets/mubiao-dept-node-ChUPkaxq.css 5.47 kB │ gzip: 1.29 kB
../artifacts/im-account-filter/admin-build/assets/OrderPanel-BzElZPEq.css 5.67 kB │ gzip: 1.46 kB
../artifacts/im-account-filter/admin-build/assets/index-Co57tWbg.css 6.11 kB │ gzip: 1.70 kB
../artifacts/im-account-filter/admin-build/assets/list-D5BdlpZJ.css 6.51 kB │ gzip: 1.45 kB
../artifacts/im-account-filter/admin-build/assets/index-DtDaTXOB.css 7.01 kB │ gzip: 1.69 kB
../artifacts/im-account-filter/admin-build/assets/mubiao-dept-card-naMqyghz.css 7.09 kB │ gzip: 1.53 kB
../artifacts/im-account-filter/admin-build/assets/index-AnyUcdTT.css 7.21 kB │ gzip: 1.86 kB
../artifacts/im-account-filter/admin-build/assets/index-Bw7BJ7QR.css 8.03 kB │ gzip: 1.99 kB
../artifacts/im-account-filter/admin-build/assets/index-ma9pGSsz.css 8.04 kB │ gzip: 2.32 kB
../artifacts/im-account-filter/admin-build/assets/progress-99NT9ysL.css 8.14 kB │ gzip: 1.91 kB
../artifacts/im-account-filter/admin-build/assets/index-Cmf8QBsF.css 8.75 kB │ gzip: 1.78 kB
../artifacts/im-account-filter/admin-build/assets/index-CSIcxqFk.css 8.85 kB │ gzip: 2.21 kB
../artifacts/im-account-filter/admin-build/assets/index-B_6Taw4k.css 8.97 kB │ gzip: 1.91 kB
../artifacts/im-account-filter/admin-build/assets/edit-DOd7Lhrw.css 9.47 kB │ gzip: 2.05 kB
../artifacts/im-account-filter/admin-build/assets/index-Dnn9ddtX.css 9.49 kB │ gzip: 2.20 kB
../artifacts/im-account-filter/admin-build/assets/index-D9Td5Y2a.css 9.52 kB │ gzip: 2.10 kB
../artifacts/im-account-filter/admin-build/assets/ProgressPanel-C_aSqDJh.css 10.10 kB │ gzip: 2.10 kB
../artifacts/im-account-filter/admin-build/assets/index-I_vRP8ff.css 10.57 kB │ gzip: 2.04 kB
../artifacts/im-account-filter/admin-build/assets/index-DV5JwFnL.css 11.95 kB │ gzip: 2.61 kB
../artifacts/im-account-filter/admin-build/assets/index-cz2HBbSE.css 13.65 kB │ gzip: 3.05 kB
../artifacts/im-account-filter/admin-build/assets/commission-settlement-Gy2UsUPr.css 15.55 kB │ gzip: 3.19 kB
../artifacts/im-account-filter/admin-build/assets/list_h5-Cs3mUmOa.css 17.70 kB │ gzip: 3.23 kB
../artifacts/im-account-filter/admin-build/assets/order_list-CMCIYVRT.css 18.15 kB │ gzip: 3.76 kB
../artifacts/im-account-filter/admin-build/assets/index-Dgjf9Hxv.css 18.86 kB │ gzip: 3.83 kB
../artifacts/im-account-filter/admin-build/assets/WecomFloatingWidgetBuilder-DRWt4rvh.css 19.20 kB │ gzip: 4.00 kB
../artifacts/im-account-filter/admin-build/assets/index_h5-6234yJkC.css 19.63 kB │ gzip: 3.53 kB
../artifacts/im-account-filter/admin-build/assets/yeji-7-gSVFmd.css 25.24 kB │ gzip: 4.60 kB
../artifacts/im-account-filter/admin-build/assets/index-BSycZlsS.css 45.91 kB │ gzip: 9.91 kB
../artifacts/im-account-filter/admin-build/assets/order_list_h5-DB3qJv9G.css 48.13 kB │ gzip: 7.68 kB
../artifacts/im-account-filter/admin-build/assets/.pnpm-B3v8nGpq.css 723.50 kB │ gzip: 100.49 kB
../artifacts/im-account-filter/admin-build/assets/getExposeType-BhVtb25-.js 0.07 kB │ gzip: 0.09 kB
../artifacts/im-account-filter/admin-build/assets/attr-qqeREw9s.js 0.13 kB │ gzip: 0.13 kB
../artifacts/im-account-filter/admin-build/assets/attr-DcOELR-K.js 0.13 kB │ gzip: 0.13 kB
../artifacts/im-account-filter/admin-build/assets/attr-tHefvLXa.js 0.13 kB │ gzip: 0.13 kB
../artifacts/im-account-filter/admin-build/assets/MediaSourceSelect-8A82adC4.js 0.14 kB │ gzip: 0.14 kB
../artifacts/im-account-filter/admin-build/assets/code-preview-DBRXIZ5s.js 0.16 kB │ gzip: 0.15 kB
../artifacts/im-account-filter/admin-build/assets/edit-BQzWQW42.js 0.18 kB │ gzip: 0.16 kB
../artifacts/im-account-filter/admin-build/assets/refund-log-BKxGpbZJ.js 0.19 kB │ gzip: 0.17 kB
../artifacts/im-account-filter/admin-build/assets/account-adjust-B9vE51aJ.js 0.19 kB │ gzip: 0.17 kB
../artifacts/im-account-filter/admin-build/assets/content-BKAOFNqu.js 0.19 kB │ gzip: 0.17 kB
../artifacts/im-account-filter/admin-build/assets/content-BRF9xP1V.js 0.19 kB │ gzip: 0.17 kB
../artifacts/im-account-filter/admin-build/assets/content-C0v_tOz5.js 0.19 kB │ gzip: 0.17 kB
../artifacts/im-account-filter/admin-build/assets/content-CU8EgS8j.js 0.19 kB │ gzip: 0.17 kB
../artifacts/im-account-filter/admin-build/assets/perm-ReZ1LYgU.js 0.20 kB │ gzip: 0.18 kB
../artifacts/im-account-filter/admin-build/assets/diabetes-discovery-display-B_wmGXQJ.js 0.20 kB │ gzip: 0.17 kB
../artifacts/im-account-filter/admin-build/assets/PrescriptionOrderTimeDialog-CO8lEk6t.js 0.20 kB │ gzip: 0.18 kB
../artifacts/im-account-filter/admin-build/assets/weapp-CSMt578I.js 0.20 kB │ gzip: 0.15 kB
../artifacts/im-account-filter/admin-build/assets/GancaoSubmissionReconcileButton-DaOrcVD6.js 0.21 kB │ gzip: 0.18 kB
../artifacts/im-account-filter/admin-build/assets/content-B9L9tFty.js 0.21 kB │ gzip: 0.19 kB
../artifacts/im-account-filter/admin-build/assets/edit-Cnhi669r.js 0.21 kB │ gzip: 0.17 kB
../artifacts/im-account-filter/admin-build/assets/edit-RsdNi0sG.js 0.21 kB │ gzip: 0.17 kB
../artifacts/im-account-filter/admin-build/assets/edit-BIVwVbdj.js 0.21 kB │ gzip: 0.17 kB
../artifacts/im-account-filter/admin-build/assets/edit-CS1FpnKo.js 0.21 kB │ gzip: 0.17 kB
../artifacts/im-account-filter/admin-build/assets/edit-CsLOq8cA.js 0.21 kB │ gzip: 0.17 kB
../artifacts/im-account-filter/admin-build/assets/edit-DFJuQUt4.js 0.21 kB │ gzip: 0.17 kB
../artifacts/im-account-filter/admin-build/assets/attr.vue_vue_type_script_setup_true_lang-DG0CrN4I.js 0.22 kB │ gzip: 0.18 kB
../artifacts/im-account-filter/admin-build/assets/attr.vue_vue_type_script_setup_true_lang-awcvFaxy.js 0.22 kB │ gzip: 0.18 kB
../artifacts/im-account-filter/admin-build/assets/attr.vue_vue_type_script_setup_true_lang-DvEPH3sF.js 0.22 kB │ gzip: 0.18 kB
../artifacts/im-account-filter/admin-build/assets/oa-menu-form-C0NXOJbg.js 0.22 kB │ gzip: 0.18 kB
../artifacts/im-account-filter/admin-build/assets/useLockFn-DZaCbVGv.js 0.22 kB │ gzip: 0.19 kB
../artifacts/im-account-filter/admin-build/assets/appointment-type-DJT6wPaT.js 0.23 kB │ gzip: 0.17 kB
../artifacts/im-account-filter/admin-build/assets/edit-DsY37x6w.js 0.23 kB │ gzip: 0.18 kB
../artifacts/im-account-filter/admin-build/assets/auth-CQMXVsq4.js 0.24 kB │ gzip: 0.18 kB
../artifacts/im-account-filter/admin-build/assets/edit-DVCS2-tX.js 0.25 kB │ gzip: 0.19 kB
../artifacts/im-account-filter/admin-build/assets/relations-add-TgfEUFsn.js 0.25 kB │ gzip: 0.19 kB
../artifacts/im-account-filter/admin-build/assets/mobile-style-A6-Ytfdg.js 0.26 kB │ gzip: 0.19 kB
../artifacts/im-account-filter/admin-build/assets/edit-C7l-AQkg.js 0.27 kB │ gzip: 0.19 kB
../artifacts/im-account-filter/admin-build/assets/cost-edit-FBwF0u_p.js 0.30 kB │ gzip: 0.21 kB
../artifacts/im-account-filter/admin-build/assets/yeji-edit-DZBTmpJN.js 0.30 kB │ gzip: 0.21 kB
../artifacts/im-account-filter/admin-build/assets/404-CHX28Ct_.js 0.31 kB │ gzip: 0.29 kB
../artifacts/im-account-filter/admin-build/assets/data-table-B2Ely9V2.js 0.31 kB │ gzip: 0.20 kB
../artifacts/im-account-filter/admin-build/assets/oa-menu-form-edit-CuxIy3Cx.js 0.33 kB │ gzip: 0.21 kB
../artifacts/im-account-filter/admin-build/assets/medicine-BKSr1vD6.js 0.35 kB │ gzip: 0.18 kB
../artifacts/im-account-filter/admin-build/assets/consumer-CUp4ANbV.js 0.35 kB │ gzip: 0.20 kB
../artifacts/im-account-filter/admin-build/assets/user-Bf1uVga9.js 0.37 kB │ gzip: 0.18 kB
../artifacts/im-account-filter/admin-build/assets/role-BF3CqZnw.js 0.39 kB │ gzip: 0.18 kB
../artifacts/im-account-filter/admin-build/assets/useDictOptions-Dm28dNNo.js 0.42 kB │ gzip: 0.32 kB
../artifacts/im-account-filter/admin-build/assets/index-CxDFxkgf.js 0.42 kB │ gzip: 0.32 kB
../artifacts/im-account-filter/admin-build/assets/admin-C8Vnlftq.js 0.42 kB │ gzip: 0.21 kB
../artifacts/im-account-filter/admin-build/assets/content-DT1XKlpO.js 0.44 kB │ gzip: 0.36 kB
../artifacts/im-account-filter/admin-build/assets/pay-DcNRfvav.js 0.45 kB │ gzip: 0.20 kB
../artifacts/im-account-filter/admin-build/assets/department-CpbHoJWl.js 0.46 kB │ gzip: 0.19 kB
../artifacts/im-account-filter/admin-build/assets/menu-fRjuc9D6.js 0.46 kB │ gzip: 0.19 kB
../artifacts/im-account-filter/admin-build/assets/content-lHNz_rVU.js 0.47 kB │ gzip: 0.37 kB
../artifacts/im-account-filter/admin-build/assets/attr-CqNjJZSp.js 0.48 kB │ gzip: 0.25 kB
../artifacts/im-account-filter/admin-build/assets/post-CwbznGRQ.js 0.49 kB │ gzip: 0.22 kB
../artifacts/im-account-filter/admin-build/assets/overflow-B3CCpSAq.js 0.49 kB │ gzip: 0.37 kB
../artifacts/im-account-filter/admin-build/assets/decoration-aYJgFVdU.js 0.50 kB │ gzip: 0.22 kB
../artifacts/im-account-filter/admin-build/assets/message-CFGRf72h.js 0.50 kB │ gzip: 0.21 kB
../artifacts/im-account-filter/admin-build/assets/403-CmUBOX6U.js 0.52 kB │ gzip: 0.44 kB
../artifacts/im-account-filter/admin-build/assets/footer.vue_vue_type_script_setup_true_lang-BQ_JwEY8.js 0.53 kB │ gzip: 0.38 kB
../artifacts/im-account-filter/admin-build/assets/attr-CzKaNt79.js 0.54 kB │ gzip: 0.27 kB
../artifacts/im-account-filter/admin-build/assets/attr-CkhITkGu.js 0.54 kB │ gzip: 0.28 kB
../artifacts/im-account-filter/admin-build/assets/add-nav-BSigI979.js 0.54 kB │ gzip: 0.28 kB
../artifacts/im-account-filter/admin-build/assets/content-DgGA7umv.js 0.54 kB │ gzip: 0.28 kB
../artifacts/im-account-filter/admin-build/assets/menu-set-BEKLkStG.js 0.54 kB │ gzip: 0.28 kB
../artifacts/im-account-filter/admin-build/assets/attr-DED_RFBQ.js 0.55 kB │ gzip: 0.26 kB
../artifacts/im-account-filter/admin-build/assets/patient-10cOiqNf.js 0.56 kB │ gzip: 0.22 kB
../artifacts/im-account-filter/admin-build/assets/fans-BuQxLAMK.js 0.60 kB │ gzip: 0.21 kB
../artifacts/im-account-filter/admin-build/assets/attr-B9aKQy30.js 0.60 kB │ gzip: 0.28 kB
../artifacts/im-account-filter/admin-build/assets/attr-6r7TVAax.js 0.61 kB │ gzip: 0.29 kB
../artifacts/im-account-filter/admin-build/assets/attr-AkWNVJlI.js 0.61 kB │ gzip: 0.29 kB
../artifacts/im-account-filter/admin-build/assets/diag-display-DCz_VAqj.js 0.61 kB │ gzip: 0.39 kB
../artifacts/im-account-filter/admin-build/assets/content.vue_vue_type_script_setup_true_lang-Cb2RlgSQ.js 0.61 kB │ gzip: 0.41 kB
../artifacts/im-account-filter/admin-build/assets/index-hADFb-Qb.js 0.63 kB │ gzip: 0.43 kB
../artifacts/im-account-filter/admin-build/assets/edit-CnTFyK4a.js 0.63 kB │ gzip: 0.32 kB
../artifacts/im-account-filter/admin-build/assets/link-BSeCb4pE.js 0.64 kB │ gzip: 0.45 kB
../artifacts/im-account-filter/admin-build/assets/asset-CzzFecaT.js 0.67 kB │ gzip: 0.22 kB
../artifacts/im-account-filter/admin-build/assets/content.vue_vue_type_script_setup_true_lang-DygM2blB.js 0.69 kB │ gzip: 0.44 kB
../artifacts/im-account-filter/admin-build/assets/useMediaSourceOptions-BbeqKRaz.js 0.70 kB │ gzip: 0.42 kB
../artifacts/im-account-filter/admin-build/assets/content.vue_vue_type_script_setup_true_lang-BybcODyl.js 0.70 kB │ gzip: 0.45 kB
../artifacts/im-account-filter/admin-build/assets/website-CnBMCdv4.js 0.74 kB │ gzip: 0.24 kB
../artifacts/im-account-filter/admin-build/assets/menu-ChKBqme9.js 0.76 kB │ gzip: 0.49 kB
../artifacts/im-account-filter/admin-build/assets/usePaging-oaNM6A9T.js 0.77 kB │ gzip: 0.47 kB
../artifacts/im-account-filter/admin-build/assets/self_input_stats-D9U_aByK.js 0.79 kB │ gzip: 0.27 kB
../artifacts/im-account-filter/admin-build/assets/decoration-img-C3rZgZnn.js 0.81 kB │ gzip: 0.50 kB
../artifacts/im-account-filter/admin-build/assets/dict-Cpp_Mw13.js 0.81 kB │ gzip: 0.25 kB
../artifacts/im-account-filter/admin-build/assets/code-SnwCPhUk.js 0.82 kB │ gzip: 0.26 kB
../artifacts/im-account-filter/admin-build/assets/blood-thresholds-CJ1fqMjh.js 0.83 kB │ gzip: 0.34 kB
../artifacts/im-account-filter/admin-build/assets/content-vVE2p2bn.js 0.84 kB │ gzip: 0.55 kB
../artifacts/im-account-filter/admin-build/assets/content.vue_vue_type_script_setup_true_lang-Cis0yx5N.js 0.86 kB │ gzip: 0.55 kB
../artifacts/im-account-filter/admin-build/assets/index.vue_vue_type_script_setup_true_lang-kAZgL5aN.js 0.86 kB │ gzip: 0.52 kB
../artifacts/im-account-filter/admin-build/assets/index.vue_vue_type_script_setup_true_lang-CDLrQsa9.js 0.88 kB │ gzip: 0.51 kB
../artifacts/im-account-filter/admin-build/assets/error-Dv5eyRBh.js 0.89 kB │ gzip: 0.60 kB
../artifacts/im-account-filter/admin-build/assets/theme-picker-DOZzD0eg.js 0.92 kB │ gzip: 0.59 kB
../artifacts/im-account-filter/admin-build/assets/wecomOauthPostMessage-C_e6VGrO.js 0.95 kB │ gzip: 0.53 kB
../artifacts/im-account-filter/admin-build/assets/index.vue_vue_type_script_setup_true_lang-Cmgm_Z5P.js 0.98 kB │ gzip: 0.53 kB
../artifacts/im-account-filter/admin-build/assets/wx_oa-BpHzQG4c.js 1.04 kB │ gzip: 0.29 kB
../artifacts/im-account-filter/admin-build/assets/article--pPmZn0N.js 1.07 kB │ gzip: 0.27 kB
../artifacts/im-account-filter/admin-build/assets/attr-setting.vue_vue_type_script_setup_true_lang-IVCzoaL5.js 1.08 kB │ gzip: 0.61 kB
../artifacts/im-account-filter/admin-build/assets/rich_text-CsKSftnY.js 1.10 kB │ gzip: 0.58 kB
../artifacts/im-account-filter/admin-build/assets/cache-DgX09o9S.js 1.13 kB │ gzip: 0.69 kB
../artifacts/im-account-filter/admin-build/assets/index-CUJ5A7oy.js 1.21 kB │ gzip: 0.70 kB
../artifacts/im-account-filter/admin-build/assets/popover_input-pPM8UqwF.js 1.25 kB │ gzip: 0.59 kB
../artifacts/im-account-filter/admin-build/assets/finance-DuFx0ydf.js 1.30 kB │ gzip: 0.37 kB
../artifacts/im-account-filter/admin-build/assets/pc-DkNtz_df.js 1.31 kB │ gzip: 0.78 kB
../artifacts/im-account-filter/admin-build/assets/doctor-D9Ckz5qF.js 1.32 kB │ gzip: 0.37 kB
../artifacts/im-account-filter/admin-build/assets/oa-menu-form-edit.vue_vue_type_script_setup_true_lang-3C4xDi6c.js 1.33 kB │ gzip: 0.78 kB
../artifacts/im-account-filter/admin-build/assets/content-DTJHaQ97.js 1.33 kB │ gzip: 0.71 kB
../artifacts/im-account-filter/admin-build/assets/content-BBtVqQrt.js 1.37 kB │ gzip: 0.70 kB
../artifacts/im-account-filter/admin-build/assets/content-zXgrFnfv.js 1.38 kB │ gzip: 0.79 kB
../artifacts/im-account-filter/admin-build/assets/file-DgUwoHFQ.js 1.41 kB │ gzip: 0.64 kB
../artifacts/im-account-filter/admin-build/assets/upload-zCiopoTX.js 1.41 kB │ gzip: 0.64 kB
../artifacts/im-account-filter/admin-build/assets/content-B0Nx2Xp7.js 1.42 kB │ gzip: 0.79 kB
../artifacts/im-account-filter/admin-build/assets/code-preview.vue_vue_type_script_setup_true_lang-D9DFyk1p.js 1.44 kB │ gzip: 0.85 kB
../artifacts/im-account-filter/admin-build/assets/index-Bj-CUIgW.js 1.44 kB │ gzip: 0.86 kB
../artifacts/im-account-filter/admin-build/assets/index.vue_vue_type_style_index_0_lang-DBzsCZdN.js 1.46 kB │ gzip: 0.79 kB
../artifacts/im-account-filter/admin-build/assets/oa-phone-zZyXZcXj.js 1.47 kB │ gzip: 0.80 kB
../artifacts/im-account-filter/admin-build/assets/attr-DpFiCsnZ.js 1.50 kB │ gzip: 0.76 kB
../artifacts/im-account-filter/admin-build/assets/menu-CO308tu7.js 1.55 kB │ gzip: 0.87 kB
../artifacts/im-account-filter/admin-build/assets/MediaSourceSelect.vue_vue_type_script_setup_true_lang-xuHF5G6T.js 1.58 kB │ gzip: 0.80 kB
../artifacts/im-account-filter/admin-build/assets/PatientInfoCard-CGWScS-V.js 1.60 kB │ gzip: 0.82 kB
../artifacts/im-account-filter/admin-build/assets/attr.vue_vue_type_script_setup_true_lang-D1E0COys.js 1.62 kB │ gzip: 0.71 kB
../artifacts/im-account-filter/admin-build/assets/style-CLznS8nw.js 1.62 kB │ gzip: 0.88 kB
../artifacts/im-account-filter/admin-build/assets/refund-log.vue_vue_type_script_setup_true_lang-B_lDKpjw.js 1.66 kB │ gzip: 0.87 kB
../artifacts/im-account-filter/admin-build/assets/statistics-f_KiXaUV.js 1.69 kB │ gzip: 1.02 kB
../artifacts/im-account-filter/admin-build/assets/attr.vue_vue_type_script_setup_true_lang-Dd6cWsZ6.js 1.71 kB │ gzip: 0.81 kB
../artifacts/im-account-filter/admin-build/assets/order-D_FCirhb.js 1.74 kB │ gzip: 0.44 kB
../artifacts/im-account-filter/admin-build/assets/environment-B_XTqIWk.js 1.74 kB │ gzip: 0.74 kB
../artifacts/im-account-filter/admin-build/assets/setup-DgUzoDWf.js 1.77 kB │ gzip: 1.00 kB
../artifacts/im-account-filter/admin-build/assets/useMenuOa-DLMZQo_g.js 1.82 kB │ gzip: 0.85 kB
../artifacts/im-account-filter/admin-build/assets/attr-setting-Cs0gMOw0.js 1.88 kB │ gzip: 0.53 kB
../artifacts/im-account-filter/admin-build/assets/index-Cy7DHPGZ.js 1.90 kB │ gzip: 0.99 kB
../artifacts/im-account-filter/admin-build/assets/edit.vue_vue_type_script_setup_true_name_articleColumnEdit_lang-CkPUppPd.js 1.94 kB │ gzip: 1.07 kB
../artifacts/im-account-filter/admin-build/assets/index-Bp595dP4.js 2.03 kB │ gzip: 0.98 kB
../artifacts/im-account-filter/admin-build/assets/index-7aWdYRG3.js 2.04 kB │ gzip: 1.14 kB
../artifacts/im-account-filter/admin-build/assets/TrackingNoteTimeline-CrRi6X48.js 2.08 kB │ gzip: 1.17 kB
../artifacts/im-account-filter/admin-build/assets/index-CdAj-Aii.js 2.09 kB │ gzip: 1.17 kB
../artifacts/im-account-filter/admin-build/assets/attr.vue_vue_type_script_setup_true_lang-Ms8VOaHv.js 2.09 kB │ gzip: 0.95 kB
../artifacts/im-account-filter/admin-build/assets/open_setting-Dxyl8ZOh.js 2.10 kB │ gzip: 1.12 kB
../artifacts/im-account-filter/admin-build/assets/index-BNJ0ZVyH.js 2.12 kB │ gzip: 1.23 kB
../artifacts/im-account-filter/admin-build/assets/account-adjust.vue_vue_type_script_setup_true_lang-DTPZREhE.js 2.16 kB │ gzip: 1.12 kB
../artifacts/im-account-filter/admin-build/assets/filing-D4NTcggm.js 2.17 kB │ gzip: 1.17 kB
../artifacts/im-account-filter/admin-build/assets/index-BucNbXmG.js 2.18 kB │ gzip: 1.28 kB
../artifacts/im-account-filter/admin-build/assets/icon-Do497UAk.js 2.23 kB │ gzip: 0.82 kB
../artifacts/im-account-filter/admin-build/assets/content.vue_vue_type_script_setup_true_lang-C0NsmCvz.js 2.24 kB │ gzip: 1.16 kB
../artifacts/im-account-filter/admin-build/assets/edit.vue_vue_type_script_setup_true_lang-BnPsXfZU.js 2.28 kB │ gzip: 1.16 kB
../artifacts/im-account-filter/admin-build/assets/edit.vue_vue_type_script_setup_true_lang-CymXj7F7.js 2.29 kB │ gzip: 1.09 kB
../artifacts/im-account-filter/admin-build/assets/index.vue_vue_type_script_setup_true_lang-BBhGStE3.js 2.37 kB │ gzip: 1.15 kB
../artifacts/im-account-filter/admin-build/assets/RecordingPlaybackBlock-AeIjtk_8.js 2.40 kB │ gzip: 1.19 kB
../artifacts/im-account-filter/admin-build/assets/auth.vue_vue_type_script_setup_true_lang-DmZ0-3eO.js 2.40 kB │ gzip: 1.34 kB
../artifacts/im-account-filter/admin-build/assets/stats-nEmR8lop.js 2.45 kB │ gzip: 0.58 kB
../artifacts/im-account-filter/admin-build/assets/data-table.vue_vue_type_script_setup_true_lang-2DOwK9aa.js 2.51 kB │ gzip: 1.28 kB
../artifacts/im-account-filter/admin-build/assets/oa-attr-B1hCdPcf.js 2.51 kB │ gzip: 1.25 kB
../artifacts/im-account-filter/admin-build/assets/edit.vue_vue_type_script_setup_true_lang-DIEkzadS.js 2.52 kB │ gzip: 1.20 kB
../artifacts/im-account-filter/admin-build/assets/h5-BuxdT1Ge.js 2.56 kB │ gzip: 1.22 kB
../artifacts/im-account-filter/admin-build/assets/change-password-CTHR5iAx.js 2.57 kB │ gzip: 1.35 kB
../artifacts/im-account-filter/admin-build/assets/im-business-message-parse-kN-fWnvR.js 2.60 kB │ gzip: 1.28 kB
../artifacts/im-account-filter/admin-build/assets/add-nav.vue_vue_type_script_setup_true_lang-B_WQB6lM.js 2.61 kB │ gzip: 1.27 kB
../artifacts/im-account-filter/admin-build/assets/tabbar-C1-HGN3_.js 2.66 kB │ gzip: 1.33 kB
../artifacts/im-account-filter/admin-build/assets/file-Ctv52jTJ.js 2.73 kB │ gzip: 1.06 kB
../artifacts/im-account-filter/admin-build/assets/edit.vue_vue_type_script_setup_true_lang-CQlLYE6x.js 2.74 kB │ gzip: 1.25 kB
../artifacts/im-account-filter/admin-build/assets/PrescriptionOrderTimeDialog.vue_vue_type_script_setup_true_lang-CVFb32gJ.js 2.77 kB │ gzip: 1.53 kB
../artifacts/im-account-filter/admin-build/assets/oa-menu-form.vue_vue_type_script_setup_true_lang-iYtuB3lb.js 2.77 kB │ gzip: 1.08 kB
../artifacts/im-account-filter/admin-build/assets/CaseRecordList-tMfop0Ax.js 2.80 kB │ gzip: 1.52 kB
../artifacts/im-account-filter/admin-build/assets/attr.vue_vue_type_script_setup_true_lang-DPF_SVr9.js 2.81 kB │ gzip: 1.06 kB
../artifacts/im-account-filter/admin-build/assets/index-Bs1CWyht.js 2.85 kB │ gzip: 1.34 kB
../artifacts/im-account-filter/admin-build/assets/follow_reply-CLLbFSdU.js 2.85 kB │ gzip: 1.49 kB
../artifacts/im-account-filter/admin-build/assets/index-DatyJB_Z.js 2.86 kB │ gzip: 1.44 kB
../artifacts/im-account-filter/admin-build/assets/cost-edit.vue_vue_type_script_setup_true_lang-C4-qihtL.js 2.86 kB │ gzip: 1.35 kB
../artifacts/im-account-filter/admin-build/assets/attr.vue_vue_type_script_setup_true_lang-DCzKMVHe.js 2.89 kB │ gzip: 1.41 kB
../artifacts/im-account-filter/admin-build/assets/default_reply-CCbjFiSS.js 2.91 kB │ gzip: 1.54 kB
../artifacts/im-account-filter/admin-build/assets/edit.vue_vue_type_script_setup_true_lang-YlyRN4kl.js 2.96 kB │ gzip: 1.14 kB
../artifacts/im-account-filter/admin-build/assets/protocol-BzB8ADIy.js 2.98 kB │ gzip: 1.10 kB
../artifacts/im-account-filter/admin-build/assets/attr.vue_vue_type_script_setup_true_lang-C14Yv8Im.js 2.99 kB │ gzip: 1.45 kB
../artifacts/im-account-filter/admin-build/assets/mubiao-dept-node-B0ePzrLt.js 3.03 kB │ gzip: 1.25 kB
../artifacts/im-account-filter/admin-build/assets/relations-add.vue_vue_type_script_setup_true_lang-p4eU2-OM.js 3.04 kB │ gzip: 1.27 kB
../artifacts/im-account-filter/admin-build/assets/picker.vue_vue_type_script_setup_true_lang-vT5soSYR.js 3.05 kB │ gzip: 1.50 kB
../artifacts/im-account-filter/admin-build/assets/AssignLogPanel-CFZgXVh2.js 3.06 kB │ gzip: 1.48 kB
../artifacts/im-account-filter/admin-build/assets/keyword_reply-BdymY522.js 3.11 kB │ gzip: 1.60 kB
../artifacts/im-account-filter/admin-build/assets/edit.vue_vue_type_script_setup_true_lang-CC56Yug7.js 3.12 kB │ gzip: 1.52 kB
../artifacts/im-account-filter/admin-build/assets/index-BiWT4KtO.js 3.21 kB │ gzip: 1.53 kB
../artifacts/im-account-filter/admin-build/assets/edit-SMNg0bD8.js 3.25 kB │ gzip: 1.47 kB
../artifacts/im-account-filter/admin-build/assets/menu-set.vue_vue_type_script_setup_true_lang-C_CMPELM.js 3.26 kB │ gzip: 1.40 kB
../artifacts/im-account-filter/admin-build/assets/pc_details-Dz3ryfVw.js 3.39 kB │ gzip: 1.49 kB
../artifacts/im-account-filter/admin-build/assets/index-DzhB4nYz.js 3.39 kB │ gzip: 1.45 kB
../artifacts/im-account-filter/admin-build/assets/index-DaO3Dr3X.js 3.42 kB │ gzip: 1.53 kB
../artifacts/im-account-filter/admin-build/assets/index-D9uB9rBL.js 3.43 kB │ gzip: 1.64 kB
../artifacts/im-account-filter/admin-build/assets/GancaoSubmissionReconcileButton.vue_vue_type_script_setup_true_lang-Bosm-6EE.js 3.44 kB │ gzip: 1.70 kB
../artifacts/im-account-filter/admin-build/assets/balance_details-C4aiXjPv.js 3.48 kB │ gzip: 1.63 kB
../artifacts/im-account-filter/admin-build/assets/index-ClmX7Grr.js 3.48 kB │ gzip: 1.64 kB
../artifacts/im-account-filter/admin-build/assets/index.vue_vue_type_script_setup_true_lang-CPBIijoV.js 3.52 kB │ gzip: 1.56 kB
../artifacts/im-account-filter/admin-build/assets/journal-D8rzfTjM.js 3.73 kB │ gzip: 1.46 kB
../artifacts/im-account-filter/admin-build/assets/edit-CZ-8SnyI.js 3.73 kB │ gzip: 1.57 kB
../artifacts/im-account-filter/admin-build/assets/detail-BtrR1i5l.js 3.76 kB │ gzip: 1.55 kB
../artifacts/im-account-filter/admin-build/assets/AssistantWatchCallDialog-WFQy_w1O.js 3.78 kB │ gzip: 1.85 kB
../artifacts/im-account-filter/admin-build/assets/CallRecordPanel-C6S8lX6p.js 3.78 kB │ gzip: 1.77 kB
../artifacts/im-account-filter/admin-build/assets/edit-BqH5-oDz.js 3.91 kB │ gzip: 1.71 kB
../artifacts/im-account-filter/admin-build/assets/preview-pc-Xf5C6ZTV.js 3.98 kB │ gzip: 1.64 kB
../artifacts/im-account-filter/admin-build/assets/AppointmentRecordPanel-Dz-s2ev7.js 4.02 kB │ gzip: 1.73 kB
../artifacts/im-account-filter/admin-build/assets/edit.vue_vue_type_script_setup_true_lang-C_uAPVti.js 4.11 kB │ gzip: 1.65 kB
../artifacts/im-account-filter/admin-build/assets/index-CfYI3w0V.js 4.11 kB │ gzip: 1.79 kB
../artifacts/im-account-filter/admin-build/assets/first_visit-CL3LsNli.js 4.13 kB │ gzip: 0.89 kB
../artifacts/im-account-filter/admin-build/assets/detail-BKl3pxSr.js 4.15 kB │ gzip: 1.56 kB
../artifacts/im-account-filter/admin-build/assets/MessageBubble-Bdhrgfj-.js 4.17 kB │ gzip: 1.79 kB
../artifacts/im-account-filter/admin-build/assets/index-LDzctaXO.js 4.22 kB │ gzip: 1.92 kB
../artifacts/im-account-filter/admin-build/assets/useListTimeFilter-CH6UBG2R.js 4.26 kB │ gzip: 1.52 kB
../artifacts/im-account-filter/admin-build/assets/index-DRzquyS9.js 4.37 kB │ gzip: 2.13 kB
../artifacts/im-account-filter/admin-build/assets/bind-work-wechat-CROd0N9y.js 4.40 kB │ gzip: 2.27 kB
../artifacts/im-account-filter/admin-build/assets/recharge_record-enX6gukh.js 4.43 kB │ gzip: 1.87 kB
../artifacts/im-account-filter/admin-build/assets/index-rTwl4NKT.js 4.45 kB │ gzip: 1.79 kB
../artifacts/im-account-filter/admin-build/assets/index-DfAjN1ed.js 4.47 kB │ gzip: 1.91 kB
../artifacts/im-account-filter/admin-build/assets/index-DztYvhgb.js 4.49 kB │ gzip: 1.96 kB
../artifacts/im-account-filter/admin-build/assets/index-Crm6rZwf.js 4.49 kB │ gzip: 1.96 kB
../artifacts/im-account-filter/admin-build/assets/preview-B82ShBae.js 4.49 kB │ gzip: 1.70 kB
../artifacts/im-account-filter/admin-build/assets/edit.vue_vue_type_script_setup_true_lang-CNaaHiYx.js 4.51 kB │ gzip: 1.74 kB
../artifacts/im-account-filter/admin-build/assets/RecordingVideoPlayer-DjZKFdeH.js 4.53 kB │ gzip: 2.26 kB
../artifacts/im-account-filter/admin-build/assets/index-CGc_5of6.js 4.55 kB │ gzip: 1.91 kB
../artifacts/im-account-filter/admin-build/assets/paiban-PtPXBv3F.js 4.56 kB │ gzip: 2.13 kB
../artifacts/im-account-filter/admin-build/assets/index-DD7jgAVQ.js 4.57 kB │ gzip: 1.33 kB
../artifacts/im-account-filter/admin-build/assets/login_register-BNOOfIjY.js 4.59 kB │ gzip: 2.01 kB
../artifacts/im-account-filter/admin-build/assets/dept-tongji-Dinbag9H.js 4.66 kB │ gzip: 2.04 kB
../artifacts/im-account-filter/admin-build/assets/index-DqeWJjfV.js 4.76 kB │ gzip: 2.05 kB
../artifacts/im-account-filter/admin-build/assets/attr.vue_vue_type_script_setup_true_lang-CHccJy1G.js 4.92 kB │ gzip: 2.04 kB
../artifacts/im-account-filter/admin-build/assets/edit-CzQiz2K9.js 5.04 kB │ gzip: 1.99 kB
../artifacts/im-account-filter/admin-build/assets/edit.vue_vue_type_script_setup_true_lang-Ddc6tfnt.js 5.18 kB │ gzip: 2.03 kB
../artifacts/im-account-filter/admin-build/assets/PaibanPanel-CrkOl1_i.js 5.28 kB │ gzip: 2.43 kB
../artifacts/im-account-filter/admin-build/assets/index-gYY-Ihui.js 5.32 kB │ gzip: 2.20 kB
../artifacts/im-account-filter/admin-build/assets/index-BQCUmdVA.js 5.53 kB │ gzip: 2.20 kB
../artifacts/im-account-filter/admin-build/assets/mubiao-ClqsT4Lq.js 5.55 kB │ gzip: 2.56 kB
../artifacts/im-account-filter/admin-build/assets/yeji-edit.vue_vue_type_script_setup_true_lang-C_B8uAfu.js 5.56 kB │ gzip: 1.81 kB
../artifacts/im-account-filter/admin-build/assets/mobile-style.vue_vue_type_script_setup_true_lang-BmffPg5D.js 5.59 kB │ gzip: 1.86 kB
../artifacts/im-account-filter/admin-build/assets/ExerciseRecordList-BObPeBus.js 5.62 kB │ gzip: 2.16 kB
../artifacts/im-account-filter/admin-build/assets/attr-Dzwjk7L0.js 5.62 kB │ gzip: 2.24 kB
../artifacts/im-account-filter/admin-build/assets/tongji-CX_AZXQC.js 5.66 kB │ gzip: 2.31 kB
../artifacts/im-account-filter/admin-build/assets/PatientOrderList-BHvkaD04.js 5.82 kB │ gzip: 2.83 kB
../artifacts/im-account-filter/admin-build/assets/DietRecordList-COIYJ7tp.js 5.84 kB │ gzip: 2.02 kB
../artifacts/im-account-filter/admin-build/assets/mubiao-dept-card-C2KWHSmH.js 5.91 kB │ gzip: 2.38 kB
../artifacts/im-account-filter/admin-build/assets/login-BQTyEEgd.js 6.03 kB │ gzip: 2.72 kB
../artifacts/im-account-filter/admin-build/assets/refund_record-B1htcVBe.js 6.27 kB │ gzip: 2.33 kB
../artifacts/im-account-filter/admin-build/assets/DiagnosisTodoList-CIiI2vXk.js 6.34 kB │ gzip: 2.89 kB
../artifacts/im-account-filter/admin-build/assets/index-DBgVsArC.js 6.35 kB │ gzip: 3.20 kB
../artifacts/im-account-filter/admin-build/assets/information-H4cElfMD.js 6.36 kB │ gzip: 1.81 kB
../artifacts/im-account-filter/admin-build/assets/NoteTimeline-D2Cn-t49.js 6.48 kB │ gzip: 2.60 kB
../artifacts/im-account-filter/admin-build/assets/account_cost-D_MeM5Ko.js 6.56 kB │ gzip: 2.86 kB
../artifacts/im-account-filter/admin-build/assets/PatientCaseCard-DKT_4Y7L.js 6.61 kB │ gzip: 2.34 kB
../artifacts/im-account-filter/admin-build/assets/index-Cz1l9OWu.js 6.61 kB │ gzip: 2.62 kB
../artifacts/im-account-filter/admin-build/assets/index-CHjcZeLZ.js 6.77 kB │ gzip: 2.89 kB
../artifacts/im-account-filter/admin-build/assets/readonly-DUZeV_w4.js 6.98 kB │ gzip: 2.69 kB
../artifacts/im-account-filter/admin-build/assets/picker-BmZLoYfG.js 7.32 kB │ gzip: 3.18 kB
../artifacts/im-account-filter/admin-build/assets/edit.vue_vue_type_script_setup_true_lang-BLYfnDac.js 7.35 kB │ gzip: 2.66 kB
../artifacts/im-account-filter/admin-build/assets/setting-rhpIbXGO.js 7.35 kB │ gzip: 2.93 kB
../artifacts/im-account-filter/admin-build/assets/weapp-9bAJJTz2.js 7.39 kB │ gzip: 2.13 kB
../artifacts/im-account-filter/admin-build/assets/prescription-order-utils-CXfs2Bra.js 7.62 kB │ gzip: 3.11 kB
../artifacts/im-account-filter/admin-build/assets/index-kR5rYZYe.js 7.65 kB │ gzip: 3.13 kB
../artifacts/im-account-filter/admin-build/assets/config-3N1TQJj9.js 7.77 kB │ gzip: 2.59 kB
../artifacts/im-account-filter/admin-build/assets/TrackingMatrix-B607GGU9.js 7.84 kB │ gzip: 3.07 kB
../artifacts/im-account-filter/admin-build/assets/patient-call-XoEtFLU4.js 7.85 kB │ gzip: 3.24 kB
../artifacts/im-account-filter/admin-build/assets/dayjs-CF5xpNFg.js 8.07 kB │ gzip: 3.45 kB
../artifacts/im-account-filter/admin-build/assets/edit-BxkGCzTr.js 9.00 kB │ gzip: 2.83 kB
../artifacts/im-account-filter/admin-build/assets/tcm-ChtuqNhm.js 9.44 kB │ gzip: 1.79 kB
../artifacts/im-account-filter/admin-build/assets/tim-upload-plugin-B7yxlBgj.js 9.47 kB │ gzip: 3.61 kB
../artifacts/im-account-filter/admin-build/assets/index-Fl6nmxEe.js 9.49 kB │ gzip: 3.85 kB
../artifacts/im-account-filter/admin-build/assets/BloodRecordList-BIMdWKO9.js 9.58 kB │ gzip: 3.33 kB
../artifacts/im-account-filter/admin-build/assets/medicine-tIfSC35q.js 9.74 kB │ gzip: 3.56 kB
../artifacts/im-account-filter/admin-build/assets/edit-DA50wNyU.js 9.99 kB │ gzip: 3.55 kB
../artifacts/im-account-filter/admin-build/assets/index-C26qtC74.js 10.06 kB │ gzip: 3.98 kB
../artifacts/im-account-filter/admin-build/assets/index-eQPLcJEP.js 10.33 kB │ gzip: 3.89 kB
../artifacts/im-account-filter/admin-build/assets/PrescriptionAiBatchGenerateDialog-Bh8cBZ2U.js 10.36 kB │ gzip: 4.29 kB
../artifacts/im-account-filter/admin-build/assets/SendPanel-Nh2wEP27.js 10.37 kB │ gzip: 3.57 kB
../artifacts/im-account-filter/admin-build/assets/ImChatRecordPanel-CY_60n0E.js 10.42 kB │ gzip: 4.54 kB
../artifacts/im-account-filter/admin-build/assets/progress-DuOm10jY.js 11.18 kB │ gzip: 4.59 kB
../artifacts/im-account-filter/admin-build/assets/list-CVyT8pTR.js 11.18 kB │ gzip: 4.03 kB
../artifacts/im-account-filter/admin-build/assets/index-BbWzj1v3.js 11.30 kB │ gzip: 4.22 kB
../artifacts/im-account-filter/admin-build/assets/appointment-Cs4RxO0A.js 11.34 kB │ gzip: 4.24 kB
../artifacts/im-account-filter/admin-build/assets/edit-BLc8zvwf.js 11.44 kB │ gzip: 3.76 kB
../artifacts/im-account-filter/admin-build/assets/OrderPanel-BrM0G-6H.js 11.74 kB │ gzip: 4.22 kB
../artifacts/im-account-filter/admin-build/assets/picker-MDXg6BK8.js 12.15 kB │ gzip: 4.49 kB
../artifacts/im-account-filter/admin-build/assets/ProgressPanel-BdB2pJX6.js 12.58 kB │ gzip: 4.48 kB
../artifacts/im-account-filter/admin-build/assets/prescription-drawer-BWDDYnw4.js 12.76 kB │ gzip: 3.87 kB
../artifacts/im-account-filter/admin-build/assets/index-BbU9WSws.js 13.00 kB │ gzip: 4.23 kB
../artifacts/im-account-filter/admin-build/assets/index-DCXj6gvc.js 13.11 kB │ gzip: 3.91 kB
../artifacts/im-account-filter/admin-build/assets/h5-CaqOq4yE.js 13.83 kB │ gzip: 4.41 kB
../artifacts/im-account-filter/admin-build/assets/index-BCAQC4rg.js 14.26 kB │ gzip: 5.90 kB
../artifacts/im-account-filter/admin-build/assets/WecomFloatingWidgetBuilder-DMflyfHq.js 15.22 kB │ gzip: 6.02 kB
../artifacts/im-account-filter/admin-build/assets/guahao-BYCsWxhO.js 15.46 kB │ gzip: 4.47 kB
../artifacts/im-account-filter/admin-build/assets/index-B9EPXS4Q.js 15.62 kB │ gzip: 6.44 kB
../artifacts/im-account-filter/admin-build/assets/index-1kRmD1tW.js 15.90 kB │ gzip: 5.20 kB
../artifacts/im-account-filter/admin-build/assets/edit-CltFqeY9.js 15.90 kB │ gzip: 4.70 kB
../artifacts/im-account-filter/admin-build/assets/index-DhIFawvT.js 16.34 kB │ gzip: 6.06 kB
../artifacts/im-account-filter/admin-build/assets/index-Bg-fEUBq.js 16.35 kB │ gzip: 5.11 kB
../artifacts/im-account-filter/admin-build/assets/index-D_XrhTnj.js 16.60 kB │ gzip: 6.07 kB
../artifacts/im-account-filter/admin-build/assets/PrescriptionAiReportDialog-DFaqRfkq.js 17.15 kB │ gzip: 6.46 kB
../artifacts/im-account-filter/admin-build/assets/index--Gnpl1A-.js 17.84 kB │ gzip: 6.02 kB
../artifacts/im-account-filter/admin-build/assets/roster-swtnbZqH.js 18.09 kB │ gzip: 5.72 kB
../artifacts/im-account-filter/admin-build/assets/index-txlKB4-2.js 18.19 kB │ gzip: 6.88 kB
../artifacts/im-account-filter/admin-build/assets/PromotionAutomationForm-7RvW6GYj.js 18.23 kB │ gzip: 6.36 kB
../artifacts/im-account-filter/admin-build/assets/index-DM2LKeNd.js 18.60 kB │ gzip: 5.23 kB
../artifacts/im-account-filter/admin-build/assets/WelcomeMessageEditor-s-2890d7.js 19.11 kB │ gzip: 6.95 kB
../artifacts/im-account-filter/admin-build/assets/OrderActionHost-DRYzGZA4.js 21.16 kB │ gzip: 6.02 kB
../artifacts/im-account-filter/admin-build/assets/index-DXrt0yLp.js 21.91 kB │ gzip: 7.87 kB
../artifacts/im-account-filter/admin-build/assets/add-Dtdqf7rg.js 22.29 kB │ gzip: 5.36 kB
../artifacts/im-account-filter/admin-build/assets/index-IWiFfZkH.js 22.96 kB │ gzip: 8.02 kB
../artifacts/im-account-filter/admin-build/assets/DailyMatrix-Cs-u0Y_n.js 24.97 kB │ gzip: 7.09 kB
../artifacts/im-account-filter/admin-build/assets/list_h5-Dd9_81WR.js 27.86 kB │ gzip: 9.43 kB
../artifacts/im-account-filter/admin-build/assets/qywx-CMKsxFl1.js 30.35 kB │ gzip: 10.01 kB
../artifacts/im-account-filter/admin-build/assets/list-BSx_1xK9.js 30.35 kB │ gzip: 9.74 kB
../artifacts/im-account-filter/admin-build/assets/commission-settlement-Bo-HY-oY.js 35.83 kB │ gzip: 9.75 kB
../artifacts/im-account-filter/admin-build/assets/edit-Bar3Z5T5.js 35.86 kB │ gzip: 9.03 kB
../artifacts/im-account-filter/admin-build/assets/index-B0VJhQYg.js 36.70 kB │ gzip: 11.81 kB
../artifacts/im-account-filter/admin-build/assets/index_h5-n5_UHvfv.js 37.92 kB │ gzip: 11.61 kB
../artifacts/im-account-filter/admin-build/assets/index-BJi-R8ym.js 39.27 kB │ gzip: 14.01 kB
../artifacts/im-account-filter/admin-build/assets/index-DzMN_XCD.js 40.10 kB │ gzip: 11.87 kB
../artifacts/im-account-filter/admin-build/assets/PrescriptionOrderDetailDrawer-xamaMnut.js 48.77 kB │ gzip: 13.48 kB
../artifacts/im-account-filter/admin-build/assets/index-E1I45ddz.js 51.46 kB │ gzip: 15.17 kB
../artifacts/im-account-filter/admin-build/assets/index-C0h-ySsY.js 55.03 kB │ gzip: 15.89 kB
../artifacts/im-account-filter/admin-build/assets/tim-profanity-filter-plugin-BJ7z5puq.js 55.90 kB │ gzip: 21.04 kB
../artifacts/im-account-filter/admin-build/assets/index-B_S7y1Ml.js 69.54 kB │ gzip: 21.09 kB
../artifacts/im-account-filter/admin-build/assets/rtc-detect-CAvkmauD.js 74.80 kB │ gzip: 25.93 kB
../artifacts/im-account-filter/admin-build/assets/yeji-DL63xmk5.js 87.40 kB │ gzip: 23.27 kB
../artifacts/im-account-filter/admin-build/assets/index-DqAUwhoT.js 88.42 kB │ gzip: 25.66 kB
../artifacts/im-account-filter/admin-build/assets/order_list-hscOFw30.js 114.89 kB │ gzip: 31.81 kB
../artifacts/im-account-filter/admin-build/assets/order_list_h5-ArM4QkBN.js 139.58 kB │ gzip: 36.82 kB
../artifacts/im-account-filter/admin-build/assets/@tencentcloud/chat-uikit-engine-BJz4IWsw.js 162.84 kB │ gzip: 41.72 kB
../artifacts/im-account-filter/admin-build/assets/index-FUvp6yIk.js 290.22 kB │ gzip: 92.03 kB
../artifacts/im-account-filter/admin-build/assets/@tencentcloud/chat-Bg29VzHQ.js 725.88 kB │ gzip: 178.89 kB
../artifacts/im-account-filter/admin-build/assets/@tencentcloud/call-engine-js-CRx4G1jN.js 2,290.42 kB │ gzip: 749.64 kB
../artifacts/im-account-filter/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 50s
-495
View File
@@ -1,495 +0,0 @@
vite v6.4.2 building for production...
WARN
(!) outDir D:\web\zyt\artifacts\im-chat-history\admin-build is not inside project root and will not be emptied.
Use --emptyOutDir to override.
transforming...
✓ 4063 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/im-chat-history/admin-build/assets/default_avatar-C6VB7PGm.png 6.09 kB
../artifacts/im-chat-history/admin-build/assets/no_perms-jDxcYpYC.png 14.62 kB
../artifacts/im-chat-history/admin-build/index.html 31.87 kB │ gzip: 16.16 kB
../artifacts/im-chat-history/admin-build/assets/login_bg-BkIjQ0FB.png 59.27 kB
../artifacts/im-chat-history/admin-build/assets/red3-NOuWP8DK.png 105.00 kB
../artifacts/im-chat-history/admin-build/assets/pink3-BxZ4Y6CS.png 108.36 kB
../artifacts/im-chat-history/admin-build/assets/blue3-D3K9OqGO.png 108.98 kB
../artifacts/im-chat-history/admin-build/assets/yellow3-C_qd9cqN.png 109.27 kB
../artifacts/im-chat-history/admin-build/assets/green3-CPorIQiC.png 109.99 kB
../artifacts/im-chat-history/admin-build/assets/purple3-BGd0LxTa.png 110.15 kB
../artifacts/im-chat-history/admin-build/assets/my_topbg-BiU0PleK.png 142.47 kB
../artifacts/im-chat-history/admin-build/assets/red2-Dw8p71sP.png 750.50 kB
../artifacts/im-chat-history/admin-build/assets/purple2-C0tQkldV.png 752.33 kB
../artifacts/im-chat-history/admin-build/assets/green2-C-VRKLSN.png 756.86 kB
../artifacts/im-chat-history/admin-build/assets/yellow2-B-WtqITJ.png 762.86 kB
../artifacts/im-chat-history/admin-build/assets/pink2-BpEp33zy.png 806.48 kB
../artifacts/im-chat-history/admin-build/assets/blue2-CRQPdLZd.png 806.81 kB
../artifacts/im-chat-history/admin-build/assets/red1-C6Y3UuNB.png 1,660.63 kB
../artifacts/im-chat-history/admin-build/assets/yellow1-Ebw0T5sw.png 1,662.23 kB
../artifacts/im-chat-history/admin-build/assets/pink1-BWNZrP7C.png 1,668.56 kB
../artifacts/im-chat-history/admin-build/assets/blue1-gLOo1H0w.png 1,676.54 kB
../artifacts/im-chat-history/admin-build/assets/purple1-BpMq9FWz.png 1,680.01 kB
../artifacts/im-chat-history/admin-build/assets/green1-h1zqes95.png 1,688.20 kB
../artifacts/im-chat-history/admin-build/assets/index-CmFE2aZQ.css 0.04 kB │ gzip: 0.06 kB
../artifacts/im-chat-history/admin-build/assets/index-CjSHFu-R.css 0.04 kB │ gzip: 0.06 kB
../artifacts/im-chat-history/admin-build/assets/index-Oppei429.css 0.04 kB │ gzip: 0.06 kB
../artifacts/im-chat-history/admin-build/assets/CaseRecordList-Cl-c3q7S.css 0.05 kB │ gzip: 0.07 kB
../artifacts/im-chat-history/admin-build/assets/DietRecordList-Cc-xUr2B.css 0.05 kB │ gzip: 0.07 kB
../artifacts/im-chat-history/admin-build/assets/roster-CqNlv_rj.css 0.05 kB │ gzip: 0.07 kB
../artifacts/im-chat-history/admin-build/assets/BloodRecordList-DhVK-Y_e.css 0.05 kB │ gzip: 0.07 kB
../artifacts/im-chat-history/admin-build/assets/index-B3imPWFk.css 0.05 kB │ gzip: 0.07 kB
../artifacts/im-chat-history/admin-build/assets/list-BluNBZln.css 0.05 kB │ gzip: 0.07 kB
../artifacts/im-chat-history/admin-build/assets/ExerciseRecordList-DGtgtzou.css 0.05 kB │ gzip: 0.07 kB
../artifacts/im-chat-history/admin-build/assets/edit-CObVTcPU.css 0.09 kB │ gzip: 0.10 kB
../artifacts/im-chat-history/admin-build/assets/edit-D-D8bVPM.css 0.09 kB │ gzip: 0.10 kB
../artifacts/im-chat-history/admin-build/assets/pc_details-nlJo1_D0.css 0.09 kB │ gzip: 0.10 kB
../artifacts/im-chat-history/admin-build/assets/AppointmentRecordPanel-DC22GDgn.css 0.09 kB │ gzip: 0.10 kB
../artifacts/im-chat-history/admin-build/assets/theme-picker-BsELUxM9.css 0.11 kB │ gzip: 0.09 kB
../artifacts/im-chat-history/admin-build/assets/content-Cl-9UIip.css 0.13 kB │ gzip: 0.13 kB
../artifacts/im-chat-history/admin-build/assets/AssignLogPanel-DkQMoEkX.css 0.13 kB │ gzip: 0.13 kB
../artifacts/im-chat-history/admin-build/assets/content-DXAsZ7EV.css 0.13 kB │ gzip: 0.13 kB
../artifacts/im-chat-history/admin-build/assets/content-DaxRy47P.css 0.14 kB │ gzip: 0.14 kB
../artifacts/im-chat-history/admin-build/assets/edit-CAJZpU7c.css 0.14 kB │ gzip: 0.12 kB
../artifacts/im-chat-history/admin-build/assets/content-DxNvUNZR.css 0.15 kB │ gzip: 0.15 kB
../artifacts/im-chat-history/admin-build/assets/useListTimeFilter-DI6SIumd.css 0.16 kB │ gzip: 0.14 kB
../artifacts/im-chat-history/admin-build/assets/edit-C1NtFi7N.css 0.16 kB │ gzip: 0.11 kB
../artifacts/im-chat-history/admin-build/assets/content-Bd3rHJ5J.css 0.18 kB │ gzip: 0.15 kB
../artifacts/im-chat-history/admin-build/assets/change-password-DRfsLJ26.css 0.19 kB │ gzip: 0.16 kB
../artifacts/im-chat-history/admin-build/assets/content-Dy_alH9u.css 0.19 kB │ gzip: 0.16 kB
../artifacts/im-chat-history/admin-build/assets/decoration-img-C5XvHl9_.css 0.19 kB │ gzip: 0.16 kB
../artifacts/im-chat-history/admin-build/assets/account_cost-fbCWUO9k.css 0.20 kB │ gzip: 0.16 kB
../artifacts/im-chat-history/admin-build/assets/index-57RdOFNo.css 0.23 kB │ gzip: 0.16 kB
../artifacts/im-chat-history/admin-build/assets/error-Cz3CexuM.css 0.24 kB │ gzip: 0.18 kB
../artifacts/im-chat-history/admin-build/assets/edit-D4atl_7z.css 0.25 kB │ gzip: 0.17 kB
../artifacts/im-chat-history/admin-build/assets/attr-lNortPsv.css 0.27 kB │ gzip: 0.19 kB
../artifacts/im-chat-history/admin-build/assets/setting-CxqqGetv.css 0.27 kB │ gzip: 0.17 kB
../artifacts/im-chat-history/admin-build/assets/index-DX96drV3.css 0.28 kB │ gzip: 0.17 kB
../artifacts/im-chat-history/admin-build/assets/menu-DYFusokT.css 0.29 kB │ gzip: 0.16 kB
../artifacts/im-chat-history/admin-build/assets/OrderActionHost-DiqybThz.css 0.31 kB │ gzip: 0.22 kB
../artifacts/im-chat-history/admin-build/assets/index-Bk2rMrqw.css 0.32 kB │ gzip: 0.20 kB
../artifacts/im-chat-history/admin-build/assets/index-D0f1Mn00.css 0.33 kB │ gzip: 0.22 kB
../artifacts/im-chat-history/admin-build/assets/prescription-drawer-BjwiqvPc.css 0.35 kB │ gzip: 0.16 kB
../artifacts/im-chat-history/admin-build/assets/bind-work-wechat-BX1gqwq1.css 0.35 kB │ gzip: 0.22 kB
../artifacts/im-chat-history/admin-build/assets/index-DhR2yeiO.css 0.39 kB │ gzip: 0.19 kB
../artifacts/im-chat-history/admin-build/assets/index-DoFDMZdP.css 0.40 kB │ gzip: 0.23 kB
../artifacts/im-chat-history/admin-build/assets/index-DBNvK3ZK.css 0.45 kB │ gzip: 0.21 kB
../artifacts/im-chat-history/admin-build/assets/PatientOrderList-D8-uh2iu.css 0.45 kB │ gzip: 0.25 kB
../artifacts/im-chat-history/admin-build/assets/DiagnosisTodoList-D0SBmTHG.css 0.46 kB │ gzip: 0.23 kB
../artifacts/im-chat-history/admin-build/assets/CallRecordPanel-TsjZtTO8.css 0.47 kB │ gzip: 0.23 kB
../artifacts/im-chat-history/admin-build/assets/index-C_LxAEkS.css 0.48 kB │ gzip: 0.27 kB
../artifacts/im-chat-history/admin-build/assets/login-Bt4SvQsz.css 0.52 kB │ gzip: 0.28 kB
../artifacts/im-chat-history/admin-build/assets/content-sMbqkta2.css 0.55 kB │ gzip: 0.29 kB
../artifacts/im-chat-history/admin-build/assets/RecordingPlaybackBlock-B3KYFgvg.css 0.58 kB │ gzip: 0.26 kB
../artifacts/im-chat-history/admin-build/assets/medicine-DO6x6zrS.css 0.58 kB │ gzip: 0.29 kB
../artifacts/im-chat-history/admin-build/assets/preview-pc-BRQDo0AR.css 0.67 kB │ gzip: 0.36 kB
../artifacts/im-chat-history/admin-build/assets/index-BzrSkGWL.css 0.67 kB │ gzip: 0.27 kB
../artifacts/im-chat-history/admin-build/assets/tabbar-DJsOahKR.css 0.69 kB │ gzip: 0.31 kB
../artifacts/im-chat-history/admin-build/assets/qywx-Dym8iFe0.css 0.74 kB │ gzip: 0.37 kB
../artifacts/im-chat-history/admin-build/assets/AssistantWatchCallDialog-BIPZRgaH.css 0.76 kB │ gzip: 0.38 kB
../artifacts/im-chat-history/admin-build/assets/picker-JBYDNsl5.css 0.84 kB │ gzip: 0.32 kB
../artifacts/im-chat-history/admin-build/assets/oa-phone-CctIyraX.css 0.94 kB │ gzip: 0.34 kB
../artifacts/im-chat-history/admin-build/assets/preview-C7oaKmYo.css 0.98 kB │ gzip: 0.41 kB
../artifacts/im-chat-history/admin-build/assets/index-Bidrbn2Z.css 1.16 kB │ gzip: 0.51 kB
../artifacts/im-chat-history/admin-build/assets/TrackingNoteTimeline-B9T8pFso.css 1.18 kB │ gzip: 0.47 kB
../artifacts/im-chat-history/admin-build/assets/RecordingVideoPlayer-4DegHAwm.css 1.19 kB │ gzip: 0.53 kB
../artifacts/im-chat-history/admin-build/assets/index-55HtuLpn.css 1.21 kB │ gzip: 0.55 kB
../artifacts/im-chat-history/admin-build/assets/PatientInfoCard-n6_UPo9e.css 1.32 kB │ gzip: 0.55 kB
../artifacts/im-chat-history/admin-build/assets/TrackingMatrix-zyxtXYPb.css 1.32 kB │ gzip: 0.44 kB
../artifacts/im-chat-history/admin-build/assets/SendPanel-DaGPWlQG.css 1.39 kB │ gzip: 0.50 kB
../artifacts/im-chat-history/admin-build/assets/picker-XZbUgFct.css 1.57 kB │ gzip: 0.50 kB
../artifacts/im-chat-history/admin-build/assets/PrescriptionOrderDetailDrawer-B4rggqa1.css 1.73 kB │ gzip: 0.71 kB
../artifacts/im-chat-history/admin-build/assets/ImChatRecordPanel-DUX4tYwU.css 1.85 kB │ gzip: 0.61 kB
../artifacts/im-chat-history/admin-build/assets/index-BYOmK9R5.css 1.93 kB │ gzip: 0.58 kB
../artifacts/im-chat-history/admin-build/assets/index-OgnT0gUo.css 1.98 kB │ gzip: 0.56 kB
../artifacts/im-chat-history/admin-build/assets/MessageBubble-BwtdPM6K.css 2.04 kB │ gzip: 0.60 kB
../artifacts/im-chat-history/admin-build/assets/index-BmDW9vcs.css 2.44 kB │ gzip: 0.86 kB
../artifacts/im-chat-history/admin-build/assets/PatientCaseCard-C3Mob6yG.css 2.49 kB │ gzip: 0.81 kB
../artifacts/im-chat-history/admin-build/assets/tongji-C0VnzFxy.css 2.49 kB │ gzip: 0.77 kB
../artifacts/im-chat-history/admin-build/assets/dept-tongji-ky7rOYoi.css 2.67 kB │ gzip: 0.72 kB
../artifacts/im-chat-history/admin-build/assets/readonly-CdP3eZN0.css 2.69 kB │ gzip: 0.92 kB
../artifacts/im-chat-history/admin-build/assets/NoteTimeline-I2FSxX4s.css 2.74 kB │ gzip: 0.84 kB
../artifacts/im-chat-history/admin-build/assets/patient-call-k8V9hV2G.css 2.85 kB │ gzip: 0.91 kB
../artifacts/im-chat-history/admin-build/assets/DailyMatrix-DCZnxI8i.css 2.87 kB │ gzip: 0.85 kB
../artifacts/im-chat-history/admin-build/assets/PaibanPanel-aqm8azGc.css 3.35 kB │ gzip: 0.88 kB
../artifacts/im-chat-history/admin-build/assets/paiban-BTUHWpUu.css 3.35 kB │ gzip: 0.88 kB
../artifacts/im-chat-history/admin-build/assets/appointment-g8BrHfhi.css 3.43 kB │ gzip: 0.93 kB
../artifacts/im-chat-history/admin-build/assets/PrescriptionAiBatchGenerateDialog-CKkykmdr.css 3.45 kB │ gzip: 0.86 kB
../artifacts/im-chat-history/admin-build/assets/h5-BSFcFSny.css 3.52 kB │ gzip: 0.99 kB
../artifacts/im-chat-history/admin-build/assets/index-Bk7b_sL2.css 3.57 kB │ gzip: 0.96 kB
../artifacts/im-chat-history/admin-build/assets/add-ByXRFm8M.css 3.60 kB │ gzip: 0.87 kB
../artifacts/im-chat-history/admin-build/assets/PromotionAutomationForm-BMhNCJa-.css 3.64 kB │ gzip: 1.03 kB
../artifacts/im-chat-history/admin-build/assets/mubiao-B6IOi2bJ.css 3.78 kB │ gzip: 1.08 kB
../artifacts/im-chat-history/admin-build/assets/WelcomeMessageEditor-DD5mXWP2.css 4.41 kB │ gzip: 1.23 kB
../artifacts/im-chat-history/admin-build/assets/PrescriptionAiReportDialog-CV6AuHV3.css 4.46 kB │ gzip: 1.11 kB
../artifacts/im-chat-history/admin-build/assets/index-CMsGEvq_.css 5.19 kB │ gzip: 1.18 kB
../artifacts/im-chat-history/admin-build/assets/mubiao-dept-node-ChUPkaxq.css 5.47 kB │ gzip: 1.29 kB
../artifacts/im-chat-history/admin-build/assets/OrderPanel-BzElZPEq.css 5.67 kB │ gzip: 1.46 kB
../artifacts/im-chat-history/admin-build/assets/index-Co57tWbg.css 6.11 kB │ gzip: 1.70 kB
../artifacts/im-chat-history/admin-build/assets/list-D5BdlpZJ.css 6.51 kB │ gzip: 1.45 kB
../artifacts/im-chat-history/admin-build/assets/index-DtDaTXOB.css 7.01 kB │ gzip: 1.69 kB
../artifacts/im-chat-history/admin-build/assets/mubiao-dept-card-naMqyghz.css 7.09 kB │ gzip: 1.53 kB
../artifacts/im-chat-history/admin-build/assets/index-AnyUcdTT.css 7.21 kB │ gzip: 1.86 kB
../artifacts/im-chat-history/admin-build/assets/index-Bw7BJ7QR.css 8.03 kB │ gzip: 1.99 kB
../artifacts/im-chat-history/admin-build/assets/index-D4TKAF49.css 8.04 kB │ gzip: 2.32 kB
../artifacts/im-chat-history/admin-build/assets/progress-99NT9ysL.css 8.14 kB │ gzip: 1.91 kB
../artifacts/im-chat-history/admin-build/assets/index-Cmf8QBsF.css 8.75 kB │ gzip: 1.78 kB
../artifacts/im-chat-history/admin-build/assets/index-CSIcxqFk.css 8.85 kB │ gzip: 2.21 kB
../artifacts/im-chat-history/admin-build/assets/index-B_6Taw4k.css 8.97 kB │ gzip: 1.91 kB
../artifacts/im-chat-history/admin-build/assets/edit-DOd7Lhrw.css 9.47 kB │ gzip: 2.05 kB
../artifacts/im-chat-history/admin-build/assets/index-Dnn9ddtX.css 9.49 kB │ gzip: 2.20 kB
../artifacts/im-chat-history/admin-build/assets/index-D9Td5Y2a.css 9.52 kB │ gzip: 2.10 kB
../artifacts/im-chat-history/admin-build/assets/ProgressPanel-C_aSqDJh.css 10.10 kB │ gzip: 2.10 kB
../artifacts/im-chat-history/admin-build/assets/index-I_vRP8ff.css 10.57 kB │ gzip: 2.04 kB
../artifacts/im-chat-history/admin-build/assets/index-DV5JwFnL.css 11.95 kB │ gzip: 2.61 kB
../artifacts/im-chat-history/admin-build/assets/index-cz2HBbSE.css 13.65 kB │ gzip: 3.05 kB
../artifacts/im-chat-history/admin-build/assets/commission-settlement-Gy2UsUPr.css 15.55 kB │ gzip: 3.19 kB
../artifacts/im-chat-history/admin-build/assets/list_h5-Cs3mUmOa.css 17.70 kB │ gzip: 3.23 kB
../artifacts/im-chat-history/admin-build/assets/order_list-CMCIYVRT.css 18.15 kB │ gzip: 3.76 kB
../artifacts/im-chat-history/admin-build/assets/index-Dgjf9Hxv.css 18.86 kB │ gzip: 3.83 kB
../artifacts/im-chat-history/admin-build/assets/WecomFloatingWidgetBuilder-DRWt4rvh.css 19.20 kB │ gzip: 4.00 kB
../artifacts/im-chat-history/admin-build/assets/index_h5-6234yJkC.css 19.63 kB │ gzip: 3.53 kB
../artifacts/im-chat-history/admin-build/assets/yeji-7-gSVFmd.css 25.24 kB │ gzip: 4.60 kB
../artifacts/im-chat-history/admin-build/assets/index-BSycZlsS.css 45.91 kB │ gzip: 9.91 kB
../artifacts/im-chat-history/admin-build/assets/order_list_h5-DB3qJv9G.css 48.13 kB │ gzip: 7.68 kB
../artifacts/im-chat-history/admin-build/assets/.pnpm-B3v8nGpq.css 723.50 kB │ gzip: 100.49 kB
../artifacts/im-chat-history/admin-build/assets/getExposeType-BhVtb25-.js 0.07 kB │ gzip: 0.09 kB
../artifacts/im-chat-history/admin-build/assets/attr-qqeREw9s.js 0.13 kB │ gzip: 0.13 kB
../artifacts/im-chat-history/admin-build/assets/attr-DcOELR-K.js 0.13 kB │ gzip: 0.13 kB
../artifacts/im-chat-history/admin-build/assets/attr-tHefvLXa.js 0.13 kB │ gzip: 0.13 kB
../artifacts/im-chat-history/admin-build/assets/MediaSourceSelect-8A82adC4.js 0.14 kB │ gzip: 0.14 kB
../artifacts/im-chat-history/admin-build/assets/code-preview-CRYwsie5.js 0.16 kB │ gzip: 0.15 kB
../artifacts/im-chat-history/admin-build/assets/edit-CnxBj2N-.js 0.18 kB │ gzip: 0.16 kB
../artifacts/im-chat-history/admin-build/assets/refund-log-D0z9hzwY.js 0.19 kB │ gzip: 0.17 kB
../artifacts/im-chat-history/admin-build/assets/account-adjust-D_iiZRxf.js 0.19 kB │ gzip: 0.17 kB
../artifacts/im-chat-history/admin-build/assets/content-GwXp2MfM.js 0.19 kB │ gzip: 0.17 kB
../artifacts/im-chat-history/admin-build/assets/content-V0f1_vdW.js 0.19 kB │ gzip: 0.17 kB
../artifacts/im-chat-history/admin-build/assets/content-DtM3yPIw.js 0.19 kB │ gzip: 0.17 kB
../artifacts/im-chat-history/admin-build/assets/content-Ba0l8P4X.js 0.19 kB │ gzip: 0.17 kB
../artifacts/im-chat-history/admin-build/assets/perm-CiI_vrea.js 0.20 kB │ gzip: 0.17 kB
../artifacts/im-chat-history/admin-build/assets/diabetes-discovery-display-B_wmGXQJ.js 0.20 kB │ gzip: 0.17 kB
../artifacts/im-chat-history/admin-build/assets/PrescriptionOrderTimeDialog-BHoWMYDV.js 0.20 kB │ gzip: 0.17 kB
../artifacts/im-chat-history/admin-build/assets/weapp-Iyi9vyFf.js 0.20 kB │ gzip: 0.15 kB
../artifacts/im-chat-history/admin-build/assets/GancaoSubmissionReconcileButton-CCqEzOaM.js 0.21 kB │ gzip: 0.18 kB
../artifacts/im-chat-history/admin-build/assets/content-CNCHtrEi.js 0.21 kB │ gzip: 0.19 kB
../artifacts/im-chat-history/admin-build/assets/edit-B0tfK27i.js 0.21 kB │ gzip: 0.17 kB
../artifacts/im-chat-history/admin-build/assets/edit-zzzTd8tZ.js 0.21 kB │ gzip: 0.17 kB
../artifacts/im-chat-history/admin-build/assets/edit-cLpVnAEl.js 0.21 kB │ gzip: 0.17 kB
../artifacts/im-chat-history/admin-build/assets/edit-BFh3g8dC.js 0.21 kB │ gzip: 0.17 kB
../artifacts/im-chat-history/admin-build/assets/edit-CBF3zoKz.js 0.21 kB │ gzip: 0.17 kB
../artifacts/im-chat-history/admin-build/assets/edit-BwLHbRaK.js 0.21 kB │ gzip: 0.17 kB
../artifacts/im-chat-history/admin-build/assets/attr.vue_vue_type_script_setup_true_lang-DG0CrN4I.js 0.22 kB │ gzip: 0.18 kB
../artifacts/im-chat-history/admin-build/assets/attr.vue_vue_type_script_setup_true_lang-awcvFaxy.js 0.22 kB │ gzip: 0.18 kB
../artifacts/im-chat-history/admin-build/assets/attr.vue_vue_type_script_setup_true_lang-DvEPH3sF.js 0.22 kB │ gzip: 0.18 kB
../artifacts/im-chat-history/admin-build/assets/oa-menu-form-r5keepdo.js 0.22 kB │ gzip: 0.18 kB
../artifacts/im-chat-history/admin-build/assets/useLockFn-DZaCbVGv.js 0.22 kB │ gzip: 0.19 kB
../artifacts/im-chat-history/admin-build/assets/appointment-type-DJT6wPaT.js 0.23 kB │ gzip: 0.17 kB
../artifacts/im-chat-history/admin-build/assets/edit-DTpkRNnx.js 0.23 kB │ gzip: 0.19 kB
../artifacts/im-chat-history/admin-build/assets/auth-D69rZ0QF.js 0.24 kB │ gzip: 0.18 kB
../artifacts/im-chat-history/admin-build/assets/edit-DKHY8c-x.js 0.25 kB │ gzip: 0.19 kB
../artifacts/im-chat-history/admin-build/assets/relations-add-eMBobvC6.js 0.25 kB │ gzip: 0.19 kB
../artifacts/im-chat-history/admin-build/assets/mobile-style-M0fJyJft.js 0.26 kB │ gzip: 0.19 kB
../artifacts/im-chat-history/admin-build/assets/edit-Dl6ZYHd6.js 0.27 kB │ gzip: 0.19 kB
../artifacts/im-chat-history/admin-build/assets/cost-edit-HlyKgBPk.js 0.30 kB │ gzip: 0.20 kB
../artifacts/im-chat-history/admin-build/assets/yeji-edit-DiKvabrc.js 0.30 kB │ gzip: 0.21 kB
../artifacts/im-chat-history/admin-build/assets/404-Bx67M9_i.js 0.31 kB │ gzip: 0.29 kB
../artifacts/im-chat-history/admin-build/assets/data-table-DiwFgvTm.js 0.31 kB │ gzip: 0.20 kB
../artifacts/im-chat-history/admin-build/assets/oa-menu-form-edit-BJ1TaJrJ.js 0.33 kB │ gzip: 0.21 kB
../artifacts/im-chat-history/admin-build/assets/medicine-ClhF6dhx.js 0.35 kB │ gzip: 0.18 kB
../artifacts/im-chat-history/admin-build/assets/consumer-DvvPd1hy.js 0.35 kB │ gzip: 0.19 kB
../artifacts/im-chat-history/admin-build/assets/user-BoFAWLZw.js 0.37 kB │ gzip: 0.18 kB
../artifacts/im-chat-history/admin-build/assets/role-DursS8TF.js 0.39 kB │ gzip: 0.18 kB
../artifacts/im-chat-history/admin-build/assets/useDictOptions-DGLBVObI.js 0.42 kB │ gzip: 0.32 kB
../artifacts/im-chat-history/admin-build/assets/index-offYjbU6.js 0.42 kB │ gzip: 0.32 kB
../artifacts/im-chat-history/admin-build/assets/admin-Dswuerz6.js 0.42 kB │ gzip: 0.20 kB
../artifacts/im-chat-history/admin-build/assets/content-CMc73Fka.js 0.44 kB │ gzip: 0.35 kB
../artifacts/im-chat-history/admin-build/assets/pay-XwSNNoxK.js 0.45 kB │ gzip: 0.20 kB
../artifacts/im-chat-history/admin-build/assets/department-BG0AgOKP.js 0.46 kB │ gzip: 0.19 kB
../artifacts/im-chat-history/admin-build/assets/menu-B2wXzt_G.js 0.46 kB │ gzip: 0.19 kB
../artifacts/im-chat-history/admin-build/assets/content-BkJ1RaU_.js 0.47 kB │ gzip: 0.37 kB
../artifacts/im-chat-history/admin-build/assets/attr-Cy0U3F6F.js 0.48 kB │ gzip: 0.25 kB
../artifacts/im-chat-history/admin-build/assets/post-CQN2ASdU.js 0.49 kB │ gzip: 0.21 kB
../artifacts/im-chat-history/admin-build/assets/overflow-Ccm0BASr.js 0.49 kB │ gzip: 0.37 kB
../artifacts/im-chat-history/admin-build/assets/decoration-qD9r-XC7.js 0.50 kB │ gzip: 0.22 kB
../artifacts/im-chat-history/admin-build/assets/message-4zgGHQuK.js 0.50 kB │ gzip: 0.21 kB
../artifacts/im-chat-history/admin-build/assets/403-NUKlpGTJ.js 0.52 kB │ gzip: 0.44 kB
../artifacts/im-chat-history/admin-build/assets/footer.vue_vue_type_script_setup_true_lang-DSCEzizm.js 0.53 kB │ gzip: 0.38 kB
../artifacts/im-chat-history/admin-build/assets/attr-IKnWwccM.js 0.54 kB │ gzip: 0.27 kB
../artifacts/im-chat-history/admin-build/assets/attr-CbwBYdrP.js 0.54 kB │ gzip: 0.27 kB
../artifacts/im-chat-history/admin-build/assets/add-nav-CKob9pi_.js 0.54 kB │ gzip: 0.28 kB
../artifacts/im-chat-history/admin-build/assets/content-BPe9H2z1.js 0.54 kB │ gzip: 0.28 kB
../artifacts/im-chat-history/admin-build/assets/menu-set-Cdq4oYg2.js 0.54 kB │ gzip: 0.28 kB
../artifacts/im-chat-history/admin-build/assets/attr-B0hrD1LU.js 0.55 kB │ gzip: 0.26 kB
../artifacts/im-chat-history/admin-build/assets/patient-x1uj0g5K.js 0.56 kB │ gzip: 0.22 kB
../artifacts/im-chat-history/admin-build/assets/fans-Bko_8S6a.js 0.60 kB │ gzip: 0.21 kB
../artifacts/im-chat-history/admin-build/assets/attr-DlaxhiVa.js 0.60 kB │ gzip: 0.28 kB
../artifacts/im-chat-history/admin-build/assets/attr-S1Kh6jUC.js 0.61 kB │ gzip: 0.29 kB
../artifacts/im-chat-history/admin-build/assets/attr-BqqVaNlV.js 0.61 kB │ gzip: 0.29 kB
../artifacts/im-chat-history/admin-build/assets/diag-display-DCz_VAqj.js 0.61 kB │ gzip: 0.39 kB
../artifacts/im-chat-history/admin-build/assets/content.vue_vue_type_script_setup_true_lang-DMxWOKx5.js 0.61 kB │ gzip: 0.41 kB
../artifacts/im-chat-history/admin-build/assets/index-BhE11Nq_.js 0.63 kB │ gzip: 0.43 kB
../artifacts/im-chat-history/admin-build/assets/edit-DIWWWv4S.js 0.63 kB │ gzip: 0.32 kB
../artifacts/im-chat-history/admin-build/assets/link-gNOTfPzX.js 0.64 kB │ gzip: 0.45 kB
../artifacts/im-chat-history/admin-build/assets/asset-lYonyybX.js 0.67 kB │ gzip: 0.22 kB
../artifacts/im-chat-history/admin-build/assets/content.vue_vue_type_script_setup_true_lang-D1LuuMC-.js 0.69 kB │ gzip: 0.44 kB
../artifacts/im-chat-history/admin-build/assets/useMediaSourceOptions-fw385QtK.js 0.70 kB │ gzip: 0.42 kB
../artifacts/im-chat-history/admin-build/assets/content.vue_vue_type_script_setup_true_lang-BfBIkn5E.js 0.70 kB │ gzip: 0.45 kB
../artifacts/im-chat-history/admin-build/assets/website-BKsM8bHj.js 0.74 kB │ gzip: 0.24 kB
../artifacts/im-chat-history/admin-build/assets/menu-BNR0wsyZ.js 0.76 kB │ gzip: 0.49 kB
../artifacts/im-chat-history/admin-build/assets/usePaging-oaNM6A9T.js 0.77 kB │ gzip: 0.47 kB
../artifacts/im-chat-history/admin-build/assets/self_input_stats-Bh9Ivy5F.js 0.79 kB │ gzip: 0.27 kB
../artifacts/im-chat-history/admin-build/assets/decoration-img-CHn5E-6-.js 0.81 kB │ gzip: 0.50 kB
../artifacts/im-chat-history/admin-build/assets/dict-C49DwoYN.js 0.81 kB │ gzip: 0.25 kB
../artifacts/im-chat-history/admin-build/assets/code-DKMUKFzD.js 0.82 kB │ gzip: 0.25 kB
../artifacts/im-chat-history/admin-build/assets/blood-thresholds-CJ1fqMjh.js 0.83 kB │ gzip: 0.34 kB
../artifacts/im-chat-history/admin-build/assets/content-BdQq_NlD.js 0.84 kB │ gzip: 0.55 kB
../artifacts/im-chat-history/admin-build/assets/content.vue_vue_type_script_setup_true_lang-CFOyeTvK.js 0.86 kB │ gzip: 0.55 kB
../artifacts/im-chat-history/admin-build/assets/index.vue_vue_type_script_setup_true_lang-kAZgL5aN.js 0.86 kB │ gzip: 0.52 kB
../artifacts/im-chat-history/admin-build/assets/index.vue_vue_type_script_setup_true_lang-CDLrQsa9.js 0.88 kB │ gzip: 0.51 kB
../artifacts/im-chat-history/admin-build/assets/error-4ClSi6LT.js 0.89 kB │ gzip: 0.60 kB
../artifacts/im-chat-history/admin-build/assets/theme-picker-M1AQ2XQj.js 0.92 kB │ gzip: 0.59 kB
../artifacts/im-chat-history/admin-build/assets/wecomOauthPostMessage-C_e6VGrO.js 0.95 kB │ gzip: 0.53 kB
../artifacts/im-chat-history/admin-build/assets/index.vue_vue_type_script_setup_true_lang-Cmgm_Z5P.js 0.98 kB │ gzip: 0.53 kB
../artifacts/im-chat-history/admin-build/assets/wx_oa-CsUXz3M_.js 1.04 kB │ gzip: 0.29 kB
../artifacts/im-chat-history/admin-build/assets/article-B_VIFIGv.js 1.07 kB │ gzip: 0.26 kB
../artifacts/im-chat-history/admin-build/assets/attr-setting.vue_vue_type_script_setup_true_lang-Cn7UpFyE.js 1.08 kB │ gzip: 0.62 kB
../artifacts/im-chat-history/admin-build/assets/rich_text-C8iWxnva.js 1.10 kB │ gzip: 0.58 kB
../artifacts/im-chat-history/admin-build/assets/cache-BZx4Ny8O.js 1.13 kB │ gzip: 0.69 kB
../artifacts/im-chat-history/admin-build/assets/index-lM29fXUc.js 1.21 kB │ gzip: 0.70 kB
../artifacts/im-chat-history/admin-build/assets/popover_input-pPM8UqwF.js 1.25 kB │ gzip: 0.59 kB
../artifacts/im-chat-history/admin-build/assets/finance-B4gS023c.js 1.30 kB │ gzip: 0.37 kB
../artifacts/im-chat-history/admin-build/assets/pc-BVO2bNRL.js 1.31 kB │ gzip: 0.78 kB
../artifacts/im-chat-history/admin-build/assets/doctor-3GXATh2M.js 1.32 kB │ gzip: 0.37 kB
../artifacts/im-chat-history/admin-build/assets/oa-menu-form-edit.vue_vue_type_script_setup_true_lang-C3xkO5fZ.js 1.33 kB │ gzip: 0.78 kB
../artifacts/im-chat-history/admin-build/assets/content-Db4P_VPl.js 1.33 kB │ gzip: 0.71 kB
../artifacts/im-chat-history/admin-build/assets/content-gqAvW-3z.js 1.37 kB │ gzip: 0.70 kB
../artifacts/im-chat-history/admin-build/assets/content-DlEk_FSe.js 1.38 kB │ gzip: 0.79 kB
../artifacts/im-chat-history/admin-build/assets/file-BOrBUJT2.js 1.41 kB │ gzip: 0.63 kB
../artifacts/im-chat-history/admin-build/assets/upload-DooW1Wwa.js 1.41 kB │ gzip: 0.64 kB
../artifacts/im-chat-history/admin-build/assets/content-CLc2dzGE.js 1.42 kB │ gzip: 0.79 kB
../artifacts/im-chat-history/admin-build/assets/code-preview.vue_vue_type_script_setup_true_lang-BN1CyAsN.js 1.44 kB │ gzip: 0.85 kB
../artifacts/im-chat-history/admin-build/assets/index-CSekGh8y.js 1.44 kB │ gzip: 0.86 kB
../artifacts/im-chat-history/admin-build/assets/index.vue_vue_type_style_index_0_lang-BR-rP4B6.js 1.46 kB │ gzip: 0.79 kB
../artifacts/im-chat-history/admin-build/assets/oa-phone-B-GLqF9L.js 1.47 kB │ gzip: 0.79 kB
../artifacts/im-chat-history/admin-build/assets/attr-Ch8kaUiR.js 1.50 kB │ gzip: 0.76 kB
../artifacts/im-chat-history/admin-build/assets/menu-BwPV9PE0.js 1.55 kB │ gzip: 0.87 kB
../artifacts/im-chat-history/admin-build/assets/MediaSourceSelect.vue_vue_type_script_setup_true_lang-xuHF5G6T.js 1.58 kB │ gzip: 0.80 kB
../artifacts/im-chat-history/admin-build/assets/PatientInfoCard-BwfsUM2s.js 1.60 kB │ gzip: 0.82 kB
../artifacts/im-chat-history/admin-build/assets/attr.vue_vue_type_script_setup_true_lang-D7iG4611.js 1.62 kB │ gzip: 0.71 kB
../artifacts/im-chat-history/admin-build/assets/style-BSaMeNII.js 1.62 kB │ gzip: 0.88 kB
../artifacts/im-chat-history/admin-build/assets/refund-log.vue_vue_type_script_setup_true_lang-BNZvq8OU.js 1.66 kB │ gzip: 0.87 kB
../artifacts/im-chat-history/admin-build/assets/statistics-D4bqVAIO.js 1.69 kB │ gzip: 1.02 kB
../artifacts/im-chat-history/admin-build/assets/attr.vue_vue_type_script_setup_true_lang-BucqEaGC.js 1.71 kB │ gzip: 0.81 kB
../artifacts/im-chat-history/admin-build/assets/order-D6xPdSYq.js 1.74 kB │ gzip: 0.44 kB
../artifacts/im-chat-history/admin-build/assets/environment-FBaSCNXw.js 1.74 kB │ gzip: 0.74 kB
../artifacts/im-chat-history/admin-build/assets/setup-xqfHpnLl.js 1.77 kB │ gzip: 1.00 kB
../artifacts/im-chat-history/admin-build/assets/useMenuOa-C9nb3qNM.js 1.82 kB │ gzip: 0.85 kB
../artifacts/im-chat-history/admin-build/assets/attr-setting-CSC9RHvg.js 1.88 kB │ gzip: 0.53 kB
../artifacts/im-chat-history/admin-build/assets/index-CmqXvX0o.js 1.90 kB │ gzip: 0.99 kB
../artifacts/im-chat-history/admin-build/assets/edit.vue_vue_type_script_setup_true_name_articleColumnEdit_lang-Dj2cjLjD.js 1.94 kB │ gzip: 1.07 kB
../artifacts/im-chat-history/admin-build/assets/index-dsbqhbDF.js 2.03 kB │ gzip: 0.98 kB
../artifacts/im-chat-history/admin-build/assets/index-e3LZNL22.js 2.04 kB │ gzip: 1.14 kB
../artifacts/im-chat-history/admin-build/assets/TrackingNoteTimeline-B5IIeBTZ.js 2.08 kB │ gzip: 1.17 kB
../artifacts/im-chat-history/admin-build/assets/index-irEiZ2to.js 2.09 kB │ gzip: 1.17 kB
../artifacts/im-chat-history/admin-build/assets/attr.vue_vue_type_script_setup_true_lang-BTdpdc9s.js 2.09 kB │ gzip: 0.95 kB
../artifacts/im-chat-history/admin-build/assets/open_setting-CbyEUjYD.js 2.10 kB │ gzip: 1.12 kB
../artifacts/im-chat-history/admin-build/assets/index-D-oRNzvN.js 2.12 kB │ gzip: 1.23 kB
../artifacts/im-chat-history/admin-build/assets/account-adjust.vue_vue_type_script_setup_true_lang-CIazW94a.js 2.16 kB │ gzip: 1.12 kB
../artifacts/im-chat-history/admin-build/assets/filing-BBoLgS0g.js 2.17 kB │ gzip: 1.17 kB
../artifacts/im-chat-history/admin-build/assets/index-Dy_DI6fL.js 2.18 kB │ gzip: 1.28 kB
../artifacts/im-chat-history/admin-build/assets/icon-JBSUrucJ.js 2.23 kB │ gzip: 0.82 kB
../artifacts/im-chat-history/admin-build/assets/content.vue_vue_type_script_setup_true_lang-ef0Do_pT.js 2.24 kB │ gzip: 1.16 kB
../artifacts/im-chat-history/admin-build/assets/edit.vue_vue_type_script_setup_true_lang-B04f-PF6.js 2.28 kB │ gzip: 1.15 kB
../artifacts/im-chat-history/admin-build/assets/edit.vue_vue_type_script_setup_true_lang-CKpR0wBb.js 2.29 kB │ gzip: 1.09 kB
../artifacts/im-chat-history/admin-build/assets/index.vue_vue_type_script_setup_true_lang-BBhGStE3.js 2.37 kB │ gzip: 1.15 kB
../artifacts/im-chat-history/admin-build/assets/RecordingPlaybackBlock-Cvp4VJaq.js 2.40 kB │ gzip: 1.18 kB
../artifacts/im-chat-history/admin-build/assets/auth.vue_vue_type_script_setup_true_lang-CSADBu_h.js 2.40 kB │ gzip: 1.33 kB
../artifacts/im-chat-history/admin-build/assets/stats-BwkMZNKj.js 2.45 kB │ gzip: 0.58 kB
../artifacts/im-chat-history/admin-build/assets/data-table.vue_vue_type_script_setup_true_lang-BdfIrilq.js 2.51 kB │ gzip: 1.28 kB
../artifacts/im-chat-history/admin-build/assets/oa-attr-I62kgtSB.js 2.51 kB │ gzip: 1.25 kB
../artifacts/im-chat-history/admin-build/assets/edit.vue_vue_type_script_setup_true_lang-D1CNqt95.js 2.52 kB │ gzip: 1.20 kB
../artifacts/im-chat-history/admin-build/assets/h5-DUfSYLLB.js 2.56 kB │ gzip: 1.22 kB
../artifacts/im-chat-history/admin-build/assets/change-password-BHBSZ_1x.js 2.57 kB │ gzip: 1.35 kB
../artifacts/im-chat-history/admin-build/assets/im-business-message-parse-kN-fWnvR.js 2.60 kB │ gzip: 1.28 kB
../artifacts/im-chat-history/admin-build/assets/add-nav.vue_vue_type_script_setup_true_lang-TLGujWib.js 2.61 kB │ gzip: 1.26 kB
../artifacts/im-chat-history/admin-build/assets/tabbar-BGzVO5aw.js 2.66 kB │ gzip: 1.33 kB
../artifacts/im-chat-history/admin-build/assets/file-CIbk4Ntr.js 2.73 kB │ gzip: 1.06 kB
../artifacts/im-chat-history/admin-build/assets/edit.vue_vue_type_script_setup_true_lang-CRrevJfY.js 2.74 kB │ gzip: 1.25 kB
../artifacts/im-chat-history/admin-build/assets/PrescriptionOrderTimeDialog.vue_vue_type_script_setup_true_lang-CLtuslQR.js 2.77 kB │ gzip: 1.53 kB
../artifacts/im-chat-history/admin-build/assets/oa-menu-form.vue_vue_type_script_setup_true_lang-B49nVddb.js 2.77 kB │ gzip: 1.08 kB
../artifacts/im-chat-history/admin-build/assets/CaseRecordList-CoSK0NHb.js 2.80 kB │ gzip: 1.52 kB
../artifacts/im-chat-history/admin-build/assets/attr.vue_vue_type_script_setup_true_lang-CoDIyiGp.js 2.81 kB │ gzip: 1.06 kB
../artifacts/im-chat-history/admin-build/assets/index-CsWG06Br.js 2.85 kB │ gzip: 1.34 kB
../artifacts/im-chat-history/admin-build/assets/follow_reply-rMkYpNN5.js 2.85 kB │ gzip: 1.49 kB
../artifacts/im-chat-history/admin-build/assets/index-BIkjSt8G.js 2.86 kB │ gzip: 1.44 kB
../artifacts/im-chat-history/admin-build/assets/cost-edit.vue_vue_type_script_setup_true_lang-B8oLeJca.js 2.86 kB │ gzip: 1.35 kB
../artifacts/im-chat-history/admin-build/assets/attr.vue_vue_type_script_setup_true_lang-By9ydUIZ.js 2.89 kB │ gzip: 1.41 kB
../artifacts/im-chat-history/admin-build/assets/default_reply-D6ouDPpq.js 2.91 kB │ gzip: 1.54 kB
../artifacts/im-chat-history/admin-build/assets/edit.vue_vue_type_script_setup_true_lang-Deve6q4X.js 2.96 kB │ gzip: 1.14 kB
../artifacts/im-chat-history/admin-build/assets/protocol-BFlJmc-k.js 2.98 kB │ gzip: 1.10 kB
../artifacts/im-chat-history/admin-build/assets/attr.vue_vue_type_script_setup_true_lang-DCtCwWvi.js 2.99 kB │ gzip: 1.45 kB
../artifacts/im-chat-history/admin-build/assets/mubiao-dept-node-BaFPJoMU.js 3.03 kB │ gzip: 1.25 kB
../artifacts/im-chat-history/admin-build/assets/relations-add.vue_vue_type_script_setup_true_lang-B0HRNpRz.js 3.04 kB │ gzip: 1.27 kB
../artifacts/im-chat-history/admin-build/assets/picker.vue_vue_type_script_setup_true_lang-CusCTJJ4.js 3.05 kB │ gzip: 1.50 kB
../artifacts/im-chat-history/admin-build/assets/AssignLogPanel-DRsSJqIX.js 3.06 kB │ gzip: 1.48 kB
../artifacts/im-chat-history/admin-build/assets/keyword_reply-7EuDiKuz.js 3.11 kB │ gzip: 1.60 kB
../artifacts/im-chat-history/admin-build/assets/edit.vue_vue_type_script_setup_true_lang-DJE0T4Q1.js 3.12 kB │ gzip: 1.51 kB
../artifacts/im-chat-history/admin-build/assets/index-CeqsxLC-.js 3.21 kB │ gzip: 1.52 kB
../artifacts/im-chat-history/admin-build/assets/edit-DYd7907D.js 3.25 kB │ gzip: 1.47 kB
../artifacts/im-chat-history/admin-build/assets/menu-set.vue_vue_type_script_setup_true_lang-GLaRZwMy.js 3.26 kB │ gzip: 1.40 kB
../artifacts/im-chat-history/admin-build/assets/pc_details-Bw7VUj2g.js 3.39 kB │ gzip: 1.49 kB
../artifacts/im-chat-history/admin-build/assets/index-CHQBJpbo.js 3.39 kB │ gzip: 1.45 kB
../artifacts/im-chat-history/admin-build/assets/index-Dj5Yh3rP.js 3.42 kB │ gzip: 1.53 kB
../artifacts/im-chat-history/admin-build/assets/index-B19ngLYq.js 3.43 kB │ gzip: 1.64 kB
../artifacts/im-chat-history/admin-build/assets/GancaoSubmissionReconcileButton.vue_vue_type_script_setup_true_lang-B20SThVv.js 3.44 kB │ gzip: 1.70 kB
../artifacts/im-chat-history/admin-build/assets/balance_details-D_KfXFYa.js 3.48 kB │ gzip: 1.63 kB
../artifacts/im-chat-history/admin-build/assets/index-D7yxqlrs.js 3.48 kB │ gzip: 1.64 kB
../artifacts/im-chat-history/admin-build/assets/index.vue_vue_type_script_setup_true_lang-BKPP98Vt.js 3.52 kB │ gzip: 1.56 kB
../artifacts/im-chat-history/admin-build/assets/journal-BQXRw-rc.js 3.73 kB │ gzip: 1.46 kB
../artifacts/im-chat-history/admin-build/assets/edit-wbPWradd.js 3.73 kB │ gzip: 1.57 kB
../artifacts/im-chat-history/admin-build/assets/detail-DPEZNO-R.js 3.76 kB │ gzip: 1.55 kB
../artifacts/im-chat-history/admin-build/assets/AssistantWatchCallDialog-Do4wtgqb.js 3.78 kB │ gzip: 1.85 kB
../artifacts/im-chat-history/admin-build/assets/CallRecordPanel-0Um7IKOD.js 3.78 kB │ gzip: 1.77 kB
../artifacts/im-chat-history/admin-build/assets/edit-Bv_AGp7e.js 3.91 kB │ gzip: 1.71 kB
../artifacts/im-chat-history/admin-build/assets/preview-pc-vQFYLbHC.js 3.98 kB │ gzip: 1.63 kB
../artifacts/im-chat-history/admin-build/assets/AppointmentRecordPanel-C_BICu-f.js 4.02 kB │ gzip: 1.73 kB
../artifacts/im-chat-history/admin-build/assets/edit.vue_vue_type_script_setup_true_lang-g-evO6wb.js 4.11 kB │ gzip: 1.65 kB
../artifacts/im-chat-history/admin-build/assets/index-fFppMfCb.js 4.11 kB │ gzip: 1.79 kB
../artifacts/im-chat-history/admin-build/assets/first_visit-DrLCEbkH.js 4.13 kB │ gzip: 0.89 kB
../artifacts/im-chat-history/admin-build/assets/detail-DdD6RcHV.js 4.15 kB │ gzip: 1.56 kB
../artifacts/im-chat-history/admin-build/assets/MessageBubble-DEoXDLTv.js 4.17 kB │ gzip: 1.79 kB
../artifacts/im-chat-history/admin-build/assets/index-F345h9xq.js 4.22 kB │ gzip: 1.92 kB
../artifacts/im-chat-history/admin-build/assets/useListTimeFilter-i-LKEU1K.js 4.26 kB │ gzip: 1.52 kB
../artifacts/im-chat-history/admin-build/assets/index-CWKqi4D6.js 4.37 kB │ gzip: 2.14 kB
../artifacts/im-chat-history/admin-build/assets/bind-work-wechat-BV_lfAa8.js 4.40 kB │ gzip: 2.27 kB
../artifacts/im-chat-history/admin-build/assets/recharge_record-saR6x18Z.js 4.43 kB │ gzip: 1.87 kB
../artifacts/im-chat-history/admin-build/assets/index-DuwLBHge.js 4.45 kB │ gzip: 1.79 kB
../artifacts/im-chat-history/admin-build/assets/index-Dv70S3A7.js 4.47 kB │ gzip: 1.91 kB
../artifacts/im-chat-history/admin-build/assets/index-DJa1-cVO.js 4.49 kB │ gzip: 1.96 kB
../artifacts/im-chat-history/admin-build/assets/index-Tejy5EwF.js 4.49 kB │ gzip: 1.96 kB
../artifacts/im-chat-history/admin-build/assets/preview-zbOEK628.js 4.49 kB │ gzip: 1.70 kB
../artifacts/im-chat-history/admin-build/assets/edit.vue_vue_type_script_setup_true_lang-Ba54tb4y.js 4.51 kB │ gzip: 1.75 kB
../artifacts/im-chat-history/admin-build/assets/RecordingVideoPlayer-B-dcy3dw.js 4.53 kB │ gzip: 2.26 kB
../artifacts/im-chat-history/admin-build/assets/index-DRTC0exf.js 4.55 kB │ gzip: 1.91 kB
../artifacts/im-chat-history/admin-build/assets/paiban-pudpsS38.js 4.56 kB │ gzip: 2.13 kB
../artifacts/im-chat-history/admin-build/assets/index-C7KZ_Ik4.js 4.57 kB │ gzip: 1.33 kB
../artifacts/im-chat-history/admin-build/assets/login_register-CGqb90Lj.js 4.59 kB │ gzip: 2.01 kB
../artifacts/im-chat-history/admin-build/assets/dept-tongji-BOgCLB2J.js 4.66 kB │ gzip: 2.04 kB
../artifacts/im-chat-history/admin-build/assets/index-BpKwfCQA.js 4.76 kB │ gzip: 2.05 kB
../artifacts/im-chat-history/admin-build/assets/attr.vue_vue_type_script_setup_true_lang-CNcDtJ9x.js 4.92 kB │ gzip: 2.04 kB
../artifacts/im-chat-history/admin-build/assets/edit-C1amYZ9l.js 5.04 kB │ gzip: 1.99 kB
../artifacts/im-chat-history/admin-build/assets/edit.vue_vue_type_script_setup_true_lang-CAgpj-eS.js 5.18 kB │ gzip: 2.03 kB
../artifacts/im-chat-history/admin-build/assets/PaibanPanel-DZNODK98.js 5.28 kB │ gzip: 2.43 kB
../artifacts/im-chat-history/admin-build/assets/index-DXb87qjA.js 5.32 kB │ gzip: 2.20 kB
../artifacts/im-chat-history/admin-build/assets/index-DkaxoKzQ.js 5.53 kB │ gzip: 2.20 kB
../artifacts/im-chat-history/admin-build/assets/mubiao-CHwIUtxK.js 5.55 kB │ gzip: 2.56 kB
../artifacts/im-chat-history/admin-build/assets/yeji-edit.vue_vue_type_script_setup_true_lang-Bu0RgavZ.js 5.56 kB │ gzip: 1.81 kB
../artifacts/im-chat-history/admin-build/assets/mobile-style.vue_vue_type_script_setup_true_lang-B-q3QQVT.js 5.59 kB │ gzip: 1.86 kB
../artifacts/im-chat-history/admin-build/assets/ExerciseRecordList-DZ-SHSRX.js 5.62 kB │ gzip: 2.16 kB
../artifacts/im-chat-history/admin-build/assets/attr-CRSc5FiL.js 5.62 kB │ gzip: 2.24 kB
../artifacts/im-chat-history/admin-build/assets/tongji-CQw3u6Fc.js 5.66 kB │ gzip: 2.31 kB
../artifacts/im-chat-history/admin-build/assets/PatientOrderList-DPhs-MJN.js 5.82 kB │ gzip: 2.84 kB
../artifacts/im-chat-history/admin-build/assets/DietRecordList-DK7RU2Fo.js 5.84 kB │ gzip: 2.01 kB
../artifacts/im-chat-history/admin-build/assets/mubiao-dept-card-XFpSnNvn.js 5.91 kB │ gzip: 2.38 kB
../artifacts/im-chat-history/admin-build/assets/login-mU_6coFj.js 6.03 kB │ gzip: 2.72 kB
../artifacts/im-chat-history/admin-build/assets/refund_record-WGOu0QAJ.js 6.27 kB │ gzip: 2.33 kB
../artifacts/im-chat-history/admin-build/assets/DiagnosisTodoList-DFhubbCI.js 6.34 kB │ gzip: 2.88 kB
../artifacts/im-chat-history/admin-build/assets/index-BDHQ8kpu.js 6.35 kB │ gzip: 3.20 kB
../artifacts/im-chat-history/admin-build/assets/information-cQxR_gym.js 6.36 kB │ gzip: 1.81 kB
../artifacts/im-chat-history/admin-build/assets/NoteTimeline-C_UaNOKv.js 6.48 kB │ gzip: 2.60 kB
../artifacts/im-chat-history/admin-build/assets/account_cost-Bo5VIFr8.js 6.56 kB │ gzip: 2.86 kB
../artifacts/im-chat-history/admin-build/assets/PatientCaseCard-B7o30YLL.js 6.61 kB │ gzip: 2.34 kB
../artifacts/im-chat-history/admin-build/assets/index-DcDTRBFW.js 6.61 kB │ gzip: 2.62 kB
../artifacts/im-chat-history/admin-build/assets/index-CVeY18TE.js 6.77 kB │ gzip: 2.89 kB
../artifacts/im-chat-history/admin-build/assets/readonly-Bt9YZALs.js 6.98 kB │ gzip: 2.69 kB
../artifacts/im-chat-history/admin-build/assets/picker-BAPV7c7C.js 7.32 kB │ gzip: 3.18 kB
../artifacts/im-chat-history/admin-build/assets/edit.vue_vue_type_script_setup_true_lang-Dz5fUrfr.js 7.35 kB │ gzip: 2.66 kB
../artifacts/im-chat-history/admin-build/assets/setting-Hgl4zct0.js 7.35 kB │ gzip: 2.93 kB
../artifacts/im-chat-history/admin-build/assets/weapp-BvXTEjc6.js 7.39 kB │ gzip: 2.13 kB
../artifacts/im-chat-history/admin-build/assets/prescription-order-utils-CXfs2Bra.js 7.62 kB │ gzip: 3.11 kB
../artifacts/im-chat-history/admin-build/assets/index-BBTpOFL8.js 7.65 kB │ gzip: 3.13 kB
../artifacts/im-chat-history/admin-build/assets/config-CV0P9oRu.js 7.77 kB │ gzip: 2.59 kB
../artifacts/im-chat-history/admin-build/assets/TrackingMatrix-Av_7LhBk.js 7.84 kB │ gzip: 3.07 kB
../artifacts/im-chat-history/admin-build/assets/patient-call-Cg9Vaj8C.js 7.85 kB │ gzip: 3.24 kB
../artifacts/im-chat-history/admin-build/assets/dayjs-CF5xpNFg.js 8.07 kB │ gzip: 3.45 kB
../artifacts/im-chat-history/admin-build/assets/edit-da9KFXx_.js 9.00 kB │ gzip: 2.83 kB
../artifacts/im-chat-history/admin-build/assets/ImChatRecordPanel-CsmMG9cI.js 9.30 kB │ gzip: 4.21 kB
../artifacts/im-chat-history/admin-build/assets/tcm-BuqVT7wS.js 9.44 kB │ gzip: 1.79 kB
../artifacts/im-chat-history/admin-build/assets/tim-upload-plugin-B7yxlBgj.js 9.47 kB │ gzip: 3.61 kB
../artifacts/im-chat-history/admin-build/assets/index-DALWbhiS.js 9.49 kB │ gzip: 3.85 kB
../artifacts/im-chat-history/admin-build/assets/BloodRecordList-_QjjAMCZ.js 9.58 kB │ gzip: 3.33 kB
../artifacts/im-chat-history/admin-build/assets/medicine-jP_YfcAn.js 9.74 kB │ gzip: 3.55 kB
../artifacts/im-chat-history/admin-build/assets/edit-pzxY3-e8.js 9.99 kB │ gzip: 3.55 kB
../artifacts/im-chat-history/admin-build/assets/index-DP7qunZa.js 10.06 kB │ gzip: 3.98 kB
../artifacts/im-chat-history/admin-build/assets/index-hTQz2IcE.js 10.33 kB │ gzip: 3.89 kB
../artifacts/im-chat-history/admin-build/assets/PrescriptionAiBatchGenerateDialog-CZ1Wvfc7.js 10.36 kB │ gzip: 4.29 kB
../artifacts/im-chat-history/admin-build/assets/SendPanel-CabEk305.js 10.37 kB │ gzip: 3.57 kB
../artifacts/im-chat-history/admin-build/assets/progress-BsQKnwsp.js 11.18 kB │ gzip: 4.59 kB
../artifacts/im-chat-history/admin-build/assets/list-BHd-eM8G.js 11.18 kB │ gzip: 4.03 kB
../artifacts/im-chat-history/admin-build/assets/index--2yxCiVu.js 11.30 kB │ gzip: 4.22 kB
../artifacts/im-chat-history/admin-build/assets/appointment-ByorQLwa.js 11.34 kB │ gzip: 4.24 kB
../artifacts/im-chat-history/admin-build/assets/edit-Qv0aOhsU.js 11.44 kB │ gzip: 3.76 kB
../artifacts/im-chat-history/admin-build/assets/OrderPanel-B9ccVKPw.js 11.74 kB │ gzip: 4.22 kB
../artifacts/im-chat-history/admin-build/assets/picker-BwMDQMg3.js 12.15 kB │ gzip: 4.49 kB
../artifacts/im-chat-history/admin-build/assets/ProgressPanel-D3QNGTTf.js 12.58 kB │ gzip: 4.48 kB
../artifacts/im-chat-history/admin-build/assets/prescription-drawer-Dpvk2MZD.js 12.76 kB │ gzip: 3.87 kB
../artifacts/im-chat-history/admin-build/assets/index-CgZP_eau.js 13.00 kB │ gzip: 4.23 kB
../artifacts/im-chat-history/admin-build/assets/index-gQ8YYUNH.js 13.11 kB │ gzip: 3.91 kB
../artifacts/im-chat-history/admin-build/assets/h5-DlI3J5NN.js 13.83 kB │ gzip: 4.40 kB
../artifacts/im-chat-history/admin-build/assets/index-_UZVnWu_.js 14.26 kB │ gzip: 5.90 kB
../artifacts/im-chat-history/admin-build/assets/guahao-DlzdlKLt.js 15.06 kB │ gzip: 4.44 kB
../artifacts/im-chat-history/admin-build/assets/WecomFloatingWidgetBuilder-BUV-hVOm.js 15.22 kB │ gzip: 6.02 kB
../artifacts/im-chat-history/admin-build/assets/index-DYfZFMZH.js 15.62 kB │ gzip: 6.43 kB
../artifacts/im-chat-history/admin-build/assets/index-yIo8VvbW.js 15.90 kB │ gzip: 5.20 kB
../artifacts/im-chat-history/admin-build/assets/edit-DyWYmoYJ.js 15.90 kB │ gzip: 4.71 kB
../artifacts/im-chat-history/admin-build/assets/index-nZMrOmT0.js 16.34 kB │ gzip: 6.06 kB
../artifacts/im-chat-history/admin-build/assets/index-COnlKS9k.js 16.35 kB │ gzip: 5.11 kB
../artifacts/im-chat-history/admin-build/assets/index-DdycTSXR.js 16.60 kB │ gzip: 6.07 kB
../artifacts/im-chat-history/admin-build/assets/PrescriptionAiReportDialog-CRUja6D5.js 17.15 kB │ gzip: 6.47 kB
../artifacts/im-chat-history/admin-build/assets/index-9rPpXRqm.js 17.84 kB │ gzip: 6.02 kB
../artifacts/im-chat-history/admin-build/assets/roster-5q1rfTpI.js 18.09 kB │ gzip: 5.72 kB
../artifacts/im-chat-history/admin-build/assets/index-Dw8UNAuL.js 18.19 kB │ gzip: 6.88 kB
../artifacts/im-chat-history/admin-build/assets/PromotionAutomationForm-BA0NIzNP.js 18.23 kB │ gzip: 6.37 kB
../artifacts/im-chat-history/admin-build/assets/index-DL3Rz2aL.js 18.60 kB │ gzip: 5.23 kB
../artifacts/im-chat-history/admin-build/assets/WelcomeMessageEditor-CRBBUPRj.js 19.11 kB │ gzip: 6.95 kB
../artifacts/im-chat-history/admin-build/assets/OrderActionHost-B-jmhVnH.js 21.16 kB │ gzip: 6.01 kB
../artifacts/im-chat-history/admin-build/assets/index-C_so5ska.js 21.91 kB │ gzip: 7.87 kB
../artifacts/im-chat-history/admin-build/assets/add-EcOqBIiP.js 22.29 kB │ gzip: 5.36 kB
../artifacts/im-chat-history/admin-build/assets/index-PgI4nM1G.js 22.96 kB │ gzip: 8.02 kB
../artifacts/im-chat-history/admin-build/assets/DailyMatrix-HANRgYOy.js 24.97 kB │ gzip: 7.09 kB
../artifacts/im-chat-history/admin-build/assets/list_h5-DoSV8WSE.js 27.86 kB │ gzip: 9.42 kB
../artifacts/im-chat-history/admin-build/assets/qywx-DffvrfUX.js 30.35 kB │ gzip: 10.01 kB
../artifacts/im-chat-history/admin-build/assets/list-QcqwAhrg.js 30.35 kB │ gzip: 9.73 kB
../artifacts/im-chat-history/admin-build/assets/commission-settlement-C2o50eF8.js 35.83 kB │ gzip: 9.75 kB
../artifacts/im-chat-history/admin-build/assets/edit-Drj7L2C8.js 35.86 kB │ gzip: 9.02 kB
../artifacts/im-chat-history/admin-build/assets/index-DUf7A4Dd.js 36.70 kB │ gzip: 11.81 kB
../artifacts/im-chat-history/admin-build/assets/index_h5-B07JsZ1O.js 37.92 kB │ gzip: 11.61 kB
../artifacts/im-chat-history/admin-build/assets/index-BrMxtLph.js 39.24 kB │ gzip: 14.01 kB
../artifacts/im-chat-history/admin-build/assets/index-CFzaCYqK.js 40.10 kB │ gzip: 11.87 kB
../artifacts/im-chat-history/admin-build/assets/PrescriptionOrderDetailDrawer-D-NYIIFH.js 48.77 kB │ gzip: 13.48 kB
../artifacts/im-chat-history/admin-build/assets/index-CX4B8xwM.js 51.46 kB │ gzip: 15.16 kB
../artifacts/im-chat-history/admin-build/assets/index-Cu5qMzfF.js 55.03 kB │ gzip: 15.89 kB
../artifacts/im-chat-history/admin-build/assets/tim-profanity-filter-plugin-BJ7z5puq.js 55.90 kB │ gzip: 21.04 kB
../artifacts/im-chat-history/admin-build/assets/index-gt2MSDnW.js 69.54 kB │ gzip: 21.09 kB
../artifacts/im-chat-history/admin-build/assets/rtc-detect-CAvkmauD.js 74.80 kB │ gzip: 25.93 kB
../artifacts/im-chat-history/admin-build/assets/yeji-Cw0Su2A3.js 87.40 kB │ gzip: 23.27 kB
../artifacts/im-chat-history/admin-build/assets/index-Dz7niwME.js 88.42 kB │ gzip: 25.66 kB
../artifacts/im-chat-history/admin-build/assets/order_list-D_zw0l1b.js 114.89 kB │ gzip: 31.80 kB
../artifacts/im-chat-history/admin-build/assets/order_list_h5-CCe4ZA_s.js 139.58 kB │ gzip: 36.82 kB
../artifacts/im-chat-history/admin-build/assets/@tencentcloud/chat-uikit-engine-BJz4IWsw.js 162.84 kB │ gzip: 41.72 kB
../artifacts/im-chat-history/admin-build/assets/index-Cs3PThsC.js 290.22 kB │ gzip: 92.04 kB
../artifacts/im-chat-history/admin-build/assets/@tencentcloud/chat-Bg29VzHQ.js 725.88 kB │ gzip: 178.89 kB
../artifacts/im-chat-history/admin-build/assets/@tencentcloud/call-engine-js-CRx4G1jN.js 2,290.42 kB │ gzip: 749.64 kB
../artifacts/im-chat-history/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 55s
@@ -1,15 +0,0 @@
✔ type labels and video capability preserve legacy video while rejecting text and unknown (1.042ms)
✔ rendered real chat template keeps text tools while excluding call controls in text mode (201.7263ms)
✔ text mode hides all live call entries and refuses direct group invocation (52.6597ms)
✔ all SDK outgoing paths stop before touching SDK when current appointment is text (50.1941ms)
✔ video preflight uses actual appointment and prevents a server-side change to text (44.9134ms)
✔ historical phone is audio-only; missing server confirmation never enables video (45.8624ms)
✔ singleton call guard switches to current chat and cancels a pending old context (2.7073ms)
ℹ tests 7
ℹ suites 0
ℹ pass 7
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 1045.4102
-109
View File
@@ -1,109 +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/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(1150,42): error TS2322: Type '(value: string[]) => void' is not assignable to type '(value: CascaderValue | null | undefined) => any'.
Types of parameters 'value' and 'value' are incompatible.
Type 'CascaderValue | null | undefined' is not assignable to type 'string[]'.
Type 'undefined' is not assignable to type 'string[]'.
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(253,26): error TS2322: Type 'unknown[]' is not assignable to type 'TreeNodeData[]'.
Type 'unknown' is not assignable to type 'TreeNodeData'.
src/views/consumer/prescription/order_list.vue(983,45): error TS2322: Type 'string | number | undefined' is not assignable to type 'number | null | undefined'.
Type 'string' is not assignable to type 'number'.
src/views/consumer/prescription/order_list.vue(1181,41): error TS2322: Type 'string | number | undefined' is not assignable to type 'number | null | undefined'.
Type 'string' is not assignable to type 'number'.
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(271,26): error TS2322: Type 'unknown[]' is not assignable to type 'TreeNodeData[]'.
Type 'unknown' is not assignable to type 'TreeNodeData'.
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(1550,45): error TS2322: Type 'string | number | undefined' is not assignable to type 'number | null | undefined'.
Type 'string' is not assignable to type 'number'.
src/views/consumer/prescription/order_list_h5.vue(1738,41): error TS2322: Type 'string | number | undefined' is not assignable to type 'number | null | undefined'.
Type 'string' is not assignable to type '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(94,58): error TS2322: Type 'string' is not assignable to type '"primary" | "success" | "warning" | "danger" | "info" | undefined'.
src/views/doctor/tongji.vue(125,58): error TS2322: Type 'string' is not assignable to type '"primary" | "success" | "warning" | "danger" | "info" | undefined'.
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/fans/h5.vue(43,47): error TS2322: Type '"" | "danger" | "info"' is not assignable to type '"primary" | "success" | "warning" | "danger" | "info" | undefined'.
Type '""' is not assignable to type '"primary" | "success" | "warning" | "danger" | "info" | undefined'.
src/views/fans/h5.vue(167,51): error TS2322: Type 'string' is not assignable to type '"primary" | "success" | "warning" | "danger" | "info" | undefined'.
src/views/fans/index.vue(158,34): error TS2322: Type 'string' is not assignable to type '"primary" | "success" | "warning" | "danger" | "info" | undefined'.
src/views/message/notice/index.vue(12,44): error TS2322: Type '(opts?: { silent?: boolean; }) => Promise<any>' is not assignable to type '(name: TabPaneName) => any'.
Types of parameters 'opts' and 'name' are incompatible.
Type 'TabPaneName' is not assignable to type '{ silent?: boolean | undefined; } | undefined'.
Type 'string' has no properties in common with type '{ silent?: boolean | undefined; }'.
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/organization/department/edit.vue(20,29): error TS2353: Object literal may only specify known properties, and 'value' does not exist in type 'TreeOptionProps'.
src/views/permission/admin/edit.vue(44,29): error TS2353: Object literal may only specify known properties, and 'value' does not exist in type 'TreeOptionProps'.
src/views/tcm/appointment/list.vue(90,26): error TS2322: Type 'unknown[]' is not assignable to type 'TreeNodeData[]'.
Type 'unknown' is not assignable to type 'TreeNodeData'.
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/workbench/index.vue(541,18): error TS7006: Parameter 'd' implicitly has an 'any' type.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 28 KiB

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
// Local-only additive schema upgrade. Never prints connection secrets or patient content.
$root = dirname(__DIR__, 2) . '/server/';
chdir($root);
require $root . 'vendor/autoload.php';
$app = new \think\App($root);
$app->initialize();
use think\facade\Db;
try {
$connection = (string) config('database.default');
$cfg = (array) config('database.connections.' . $connection);
if (!in_array((string) ($cfg['hostname'] ?? ''), ['127.0.0.1', 'localhost', '::1'], true)
|| ($cfg['prefix'] ?? '') !== 'zyt_') {
throw new RuntimeException('Local database/prefix guard rejected');
}
$active = Db::name('prescription_ai_task')->whereIn('status', ['queued', 'running', 'retry_wait'])->count()
+ Db::name('prescription_ai_batch')->where('validity', 'current')->whereIn('status', ['preparing', 'waiting_sources', 'queued', 'running', 'retry_wait'])->count();
if ($active > 0) { throw new RuntimeException('Tasks must finish before this local upgrade'); }
$snapshot = static function (): array {
return [
'batches' => Db::name('prescription_ai_batch')->count(),
'results' => Db::name('prescription_ai_result')->count(),
'attempts' => Db::name('prescription_ai_attempt')->count(),
'tasks_hash' => hash('sha256', json_encode(Db::name('prescription_ai_task')->field('id,status,attempts,total_attempts,manual_retries,result_id')->order('id')->select()->toArray())),
];
};
$before = $snapshot();
$present = isset(Db::name('prescription_ai_task')->getFields()['progress_json']);
$sqlFile = $root . 'database/migrations/2026_09_10_prescription_ai_progress.sql';
$result = ['time' => date('c'), 'local_database' => true, 'column_previously_present' => $present,
'migration_sha256' => hash_file('sha256', $sqlFile), 'applied' => false];
if (in_array('--apply', $argv, true)) {
Db::execute('SET SESSION lock_wait_timeout = 5');
foreach (explode(';', preg_replace('/^--.*$/m', '', file_get_contents($sqlFile))) as $statement) {
if (trim($statement) !== '') { Db::execute($statement); }
}
$columns = Db::query("SHOW COLUMNS FROM `zyt_prescription_ai_task` LIKE 'progress_json'");
if (count($columns) !== 1 || $columns[0]['Type'] !== 'varchar(2048)' || $columns[0]['Null'] !== 'YES') {
throw new RuntimeException('Unexpected progress column definition');
}
$result['applied'] = true;
$result['business_state_unchanged'] = $before === $snapshot();
if (!$result['business_state_unchanged']) { throw new RuntimeException('Business task state changed during upgrade'); }
}
echo json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE), PHP_EOL;
} catch (Throwable $e) {
echo json_encode(['error' => get_class($e), 'code' => $e->getCode()]), PHP_EOL;
exit(1);
}
@@ -0,0 +1,18 @@
[
{
"lane": "qwen",
"old_pid": 36592,
"new_pid": 7512,
"started_at": "2026-09-10T09:56:47.4495394+08:00",
"policy": "manual-prescription-available-evidence-v2",
"historical_tasks_retried": false
},
{
"lane": "openai",
"old_pid": 4796,
"new_pid": 9292,
"started_at": "2026-09-10T09:56:47.8478717+08:00",
"policy": "manual-prescription-available-evidence-v2",
"historical_tasks_retried": false
}
]
@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
// Read-only comparison diagnostic. Prints dosage units and structural fields only,
// never herb names, report text, patient data, URLs or credentials.
$serverRoot = dirname(__DIR__, 2) . DIRECTORY_SEPARATOR . 'server' . DIRECTORY_SEPARATOR;
chdir($serverRoot);
require $serverRoot . 'vendor/autoload.php';
$app = new \think\App($serverRoot);
$app->initialize();
use app\common\service\prescriptionai\PrescriptionAiCipher;
use app\common\service\prescriptionai\PrescriptionAiPolicy;
use think\facade\Db;
$batchId = (int) ($argv[1] ?? 5);
$cipher = new PrescriptionAiCipher();
$batch = Db::name('prescription_ai_batch')->where('id', $batchId)->find();
$doctor = $cipher->decrypt((string) $batch['prescription_cipher'], 'prescription');
$herbs = PrescriptionAiPolicy::decode($doctor['herbs'] ?? []);
$shape = static function (array $herb): array {
return [
'has_name' => trim((string) ($herb['name'] ?? '')) !== '',
'dosage' => $herb['dosage'] ?? null,
'unit' => $herb['unit'] ?? null,
'dose_basis' => $herb['dose_basis'] ?? null,
'processing' => $herb['processing'] ?? null,
'formula_type' => $herb['formula_type'] ?? null,
'keys' => array_keys($herb),
];
};
$out = ['batch_id' => $batchId, 'doctor' => [
'dose_unit' => $doctor['dose_unit'] ?? null,
'dose_basis' => $doctor['dose_basis'] ?? null,
'prescription_type' => $doctor['prescription_type'] ?? null,
'herb_count' => count($herbs),
'herbs' => array_map($shape, array_slice($herbs, 0, 5)),
]];
foreach (Db::name('prescription_ai_result')->where('batch_id', $batchId)->select()->toArray() as $row) {
$body = $cipher->decrypt((string) $row['body_cipher'], 'result:' . $row['batch_id'] . ':' . $row['model_key']);
$candidate = (array) ($body['candidate'] ?? []);
$candidateHerbs = (array) ($candidate['herbs'] ?? []);
$out['results'][] = [
'result_id' => (int) $row['id'], 'model_key' => $row['model_key'],
'candidate_status' => $candidate['status'] ?? null,
'dose_basis' => $candidate['dose_basis'] ?? null,
'prescription_type' => $candidate['prescription_type'] ?? null,
'times_per_day' => $candidate['times_per_day'] ?? null,
'usage_days' => $candidate['usage_days'] ?? null,
'herb_count' => count($candidateHerbs),
'herbs' => array_map($shape, array_slice($candidateHerbs, 0, 5)),
'comparison_status' => $row['comparison_status'], 'comparison_reason_code' => $row['comparison_reason_code'],
];
}
echo json_encode($out, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), "\n";
@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
$serverRoot = dirname(__DIR__, 2) . DIRECTORY_SEPARATOR . 'server' . DIRECTORY_SEPARATOR;
chdir($serverRoot);
require $serverRoot . 'vendor/autoload.php';
$app = new \think\App($serverRoot);
$app->initialize();
try {
$secret = (string) config('prescription_analysis.encryption_key', '');
if ($secret === '') { $secret = trim((string) file_get_contents(root_path('runtime') . 'prescription_ai_private/snapshot.key')); }
$cipher = new \app\common\service\prescriptionai\PrescriptionAiCipher($secret);
$out = [];
foreach (\think\facade\Db::name('prescription_ai_task')->where('batch_id', (int) \think\facade\Db::name('prescription_ai_subject')->where('prescription_id', 7556)->value('latest_batch_id'))->select()->toArray() as $task) {
if (empty($task['progress_cipher'])) {
$out[] = ['model' => $task['model_key'], 'status' => $task['status'], 'result_id' => $task['result_id']];
continue;
}
$p = $cipher->decrypt($task['progress_cipher'], 'progress:' . $task['id']);
$out[] = ['model' => $task['model_key'], 'status' => $task['status'], 'attempts' => $task['attempts'], 'manual_retries' => $task['manual_retries'],
'error_code' => $task['error_code'], 'stage' => $p['stage'], 'calls' => $p['usage']['total_calls'],
'next_run' => date('c', (int) $task['next_run_at']), 'updated' => date('c', (int) $task['updated_at']),
'steps' => array_keys($p['steps']),
'recent_calls' => array_map(static fn (array $call): array => array_intersect_key($call, array_flip(['stage', 'latency_ms', 'ok', 'file_count', 'error_code'])), array_slice($p['usage']['calls'], -4))];
}
echo json_encode(['time' => date('c'), 'tasks' => $out], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE), PHP_EOL;
} catch (Throwable $e) { echo json_encode(['error' => get_class($e), 'code' => $e->getCode(), 'line' => $e->getLine(), 'file' => basename($e->getFile())]), PHP_EOL; exit(1); }
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
// Read-only API validation; clinical bodies remain in memory and are never printed.
$serverRoot = dirname(__DIR__, 2) . DIRECTORY_SEPARATOR . 'server' . DIRECTORY_SEPARATOR;
chdir($serverRoot);
require $serverRoot . 'vendor/autoload.php';
$app = new \think\App($serverRoot);
$app->initialize();
try {
$batch = \think\facade\Db::name('prescription_ai_batch')->where('id', 1)->where('prescription_id', 7556)->find();
$actor = (int) $batch['actor_id'];
$info = \app\common\service\prescriptionai\PrescriptionAiAccess::actor($actor);
$detail = \app\adminapi\logic\tcm\PrescriptionAiLogic::detail(1, $actor, $info);
$models = [];
foreach ($detail['models'] ?? [] as $key => $model) {
$coverage = $model['coverage'] ?? [];
$fileStatuses = [];
$fileReasons = [];
foreach ($coverage['files'] ?? [] as $file) {
$status = (string) ($file['status'] ?? 'unknown');
$fileStatuses[$status] = ($fileStatuses[$status] ?? 0) + 1;
$reason = (string) ($file['reason'] ?? '');
if ($reason !== '') { $fileReasons[$reason] = ($fileReasons[$reason] ?? 0) + 1; }
}
$models[$key] = ['status' => $model['status'] ?? null,
'report_id' => $model['report_id'] ?? null, 'has_report' => !empty($model['report']),
'candidate_status' => $model['candidate']['status'] ?? null,
'coverage_complete' => $coverage['complete'] ?? null,
'files' => $fileStatuses, 'file_reasons' => $fileReasons,
'comparison_status' => $model['comparison']['status'] ?? null,
'error_code' => $model['error_code'] ?? null];
}
$clinicalMissing = array_values(array_map(static fn (array $gap): string => (string) $gap['source_id'],
array_filter($detail['missing'] ?? [], static fn (array $gap): bool => ($gap['code'] ?? '') === 'CRITICAL_CLINICAL_FACT_MISSING')));
echo json_encode(['time' => date('c'), 'batch_id' => $detail['id'] ?? null, 'status' => $detail['status'] ?? null,
'clinical_missing_fields' => $clinicalMissing, 'models' => $models], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE), PHP_EOL;
} catch (Throwable $e) {
echo json_encode(['error' => get_class($e), 'code' => $e->getCode()]), PHP_EOL;
exit(1);
}
@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
require __DIR__ . '/check_queue.php';
use app\common\service\prescriptionai\PrescriptionAiCipher;
use app\common\service\prescriptionai\PrescriptionAiGenerator;
use think\facade\Db;
$secret = (string) config('prescription_analysis.encryption_key', '');
if ($secret === '') {
$secret = trim((string) file_get_contents(root_path('runtime') . 'prescription_ai_private/snapshot.key'));
}
$cipher = new PrescriptionAiCipher($secret);
$batch = Db::name('prescription_ai_batch')->where('prescription_id', 7556)->order('id', 'desc')->find();
$context = $cipher->decrypt((string) $batch['context_cipher'], 'context');
$budget = max(6000, min(200000, (int) config('prescription_ai.manual_analysis.input_token_budget', 24000))) - 3500;
$units = (new ReflectionMethod(PrescriptionAiGenerator::class, 'sourceUnits'))->invoke(null, $context['source']['records'], $budget);
$chunks = (new ReflectionMethod(PrescriptionAiGenerator::class, 'pack'))->invoke(null, $units, $budget);
$textPromptBytes = [];
foreach ($chunks as $chunk) {
$ids = array_values(array_unique(array_column($chunk, 'source_id')));
$prompt = (new ReflectionMethod(PrescriptionAiGenerator::class, 'evidencePrompt'))->invoke(null, 'text', $ids,
['patient' => $context['source']['patient'] ?? [], 'clinical_field_semantics' => $context['source']['clinical_field_semantics'] ?? [], 'records' => $chunk]);
$textPromptBytes[] = strlen($prompt);
}
$attachmentStatuses = array_count_values(array_column($context['files'] ?? [], 'status'));
$result = [];
foreach (Db::name('prescription_ai_task')->where('batch_id', $batch['id'])->select()->toArray() as $task) {
if (empty($task['progress_cipher'])) { continue; }
$progress = $cipher->decrypt($task['progress_cipher'], 'progress:' . $task['id']);
$item = ['model' => $task['model_key'], 'stage' => $progress['stage'], 'calls' => $progress['usage']['total_calls'], 'steps' => []];
foreach ($progress['steps'] ?? [] as $key => $step) {
$content = (string) ($step['value']['content'] ?? '');
$decoded = json_decode(trim($content), true);
$jsonError = json_last_error_msg();
$fenced = preg_match('/\A```(?:json)?\s*(.*?)\s*```\z/s', trim($content), $match) === 1;
$wrapped = $fenced ? json_decode($match[1], true) : null;
$summary = ['step' => $key, 'bytes' => strlen($content), 'json_error' => $jsonError,
'has_think_tag' => str_contains($content, '<think>'), 'whole_json_fence' => $fenced,
'json_keys' => is_array($decoded) ? array_keys($decoded) : null,
'fenced_json_keys' => is_array($wrapped) ? array_keys($wrapped) : null];
if (str_starts_with($key, 'text:')) {
$expected = array_values(array_unique(array_column($chunks[(int) substr($key, 5)], 'source_id')));
$summary['expected_source_count'] = count($expected);
$summary['raw_valid'] = (new ReflectionMethod(PrescriptionAiGenerator::class, 'parseEvidence'))->invoke(null, $content, $expected) !== null;
$summary['fenced_valid'] = $fenced && (new ReflectionMethod(PrescriptionAiGenerator::class, 'parseEvidence'))->invoke(null, $match[1], $expected) !== null;
$value = is_array($decoded) ? $decoded : (is_array($wrapped) ? $wrapped : []);
$summary['field_types'] = array_map('get_debug_type', $value);
if (is_array($value['covered_source_ids'] ?? null)) {
$summary['covered_source_count'] = count($value['covered_source_ids']);
$summary['missing_source_count'] = count(array_diff($expected, $value['covered_source_ids']));
$summary['extra_source_count'] = count(array_diff($value['covered_source_ids'], $expected));
}
}
if (str_starts_with($key, 'files:')) {
$sendable = array_values(array_filter($context['files'], static fn (array $f): bool => $f['status'] !== 'restricted' && !empty($f['url'])));
$fileBatch = array_chunk($sendable, 3)[(int) substr($key, 6)];
$known = array_values(array_unique(array_merge(array_column($context['source']['records'], 'source_id'), array_column($context['files'], 'file_id'))));
$value = (new ReflectionMethod(PrescriptionAiGenerator::class, 'object'))->invoke(null, $content);
$summary['file_output_valid'] = (new ReflectionMethod(PrescriptionAiGenerator::class, 'parseFiles'))->invoke(null, $content, $fileBatch, $known) !== null;
$summary['returned_count'] = is_array($value['files'] ?? null) ? count($value['files']) : null;
$summary['expected_count'] = count($fileBatch);
$summary['file_shapes'] = [];
foreach ($value['files'] ?? [] as $file) {
$refs = $file['evidence_references'] ?? null;
$summary['file_shapes'][] = ['keys' => array_keys($file), 'types' => array_map('get_debug_type', $file),
'status' => in_array($file['status'] ?? '', ['processed', 'unreadable', 'unsupported'], true) ? $file['status'] : 'INVALID',
'expected_id' => in_array($file['file_id'] ?? null, array_column($fileBatch, 'file_id'), true),
'findings_bytes' => is_string($file['findings'] ?? null) ? strlen($file['findings']) : null,
'refs_count' => is_array($refs) ? count($refs) : null,
'unknown_refs_count' => is_array($refs) && count(array_filter($refs, 'is_string')) === count($refs) ? count(array_diff($refs, $known)) : null];
}
}
$item['steps'][] = $summary;
}
$result[] = $item;
}
echo json_encode(['text_chunks' => count($chunks), 'text_prompt_bytes' => $textPromptBytes, 'input_budget' => $budget + 3500,
'attachments' => count($context['files'] ?? []), 'attachment_statuses' => $attachmentStatuses,
'progress_shape_only' => $result], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE), PHP_EOL;
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
// Read-only: exercise actual permission-aware endpoints, print only public progress metadata.
$root = dirname(__DIR__, 2) . '/server/';
chdir($root);
require $root . 'vendor/autoload.php';
$app = new \think\App($root);
$app->initialize();
use think\facade\Db;
use app\adminapi\logic\tcm\PrescriptionAiLogic as Api;
use app\common\service\prescriptionai\PrescriptionAiAccess as Access;
try {
$batchId = (int) Db::name('prescription_ai_subject')->where('prescription_id', 7556)->value('latest_batch_id');
$batch = Db::name('prescription_ai_batch')->where('id', $batchId)->find();
$actor = (int) $batch['actor_id'];
$info = Access::actor($actor);
$started = microtime(true);
$statuses = Api::statuses([7556], $actor, $info);
$statusMs = round((microtime(true) - $started) * 1000, 1);
$started = microtime(true);
$detail = Api::detail($batchId, $actor, $info);
$detailMs = round((microtime(true) - $started) * 1000, 1);
$models = [];
foreach ($detail['models'] ?? [] as $key => $model) {
$models[$key] = ['status' => $model['status'], 'error_code' => $model['error_code'], 'progress' => $model['progress'] ?? null];
}
$out = ['time' => date('c'), 'batch_id' => $batchId, 'batch_status' => $detail['status'], 'models' => $models,
'list_has_progress' => isset($statuses['items'][0]['progress']),
'list_milliseconds' => $statusMs, 'detail_milliseconds' => $detailMs];
echo json_encode($out, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE), PHP_EOL;
} catch (Throwable $e) {
echo json_encode(['error' => get_class($e), 'code' => $e->getCode()]), PHP_EOL;
exit(1);
}
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
// Read-only provider parameters. Does not submit patient data or generate a report.
$serverRoot = dirname(__DIR__, 2) . DIRECTORY_SEPARATOR . 'server' . DIRECTORY_SEPARATOR;
chdir($serverRoot);
require $serverRoot . 'vendor/autoload.php';
$app = new \think\App($serverRoot);
$app->initialize();
$cfg = (array) config('prescription_ai');
$endpoint = (new ReflectionMethod(\app\common\service\DifyChatService::class, 'buildEndpoint'))->invoke(null, $cfg['base_url'], 'parameters');
$out = [];
foreach (['qwen', 'openai'] as $model) {
$curl = curl_init($endpoint);
curl_setopt_array($curl, [CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 15, CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $cfg['models'][$model]['api_key']]]);
$body = curl_exec($curl);
$status = curl_getinfo($curl, CURLINFO_HTTP_CODE);
$decoded = is_string($body) ? json_decode($body, true) : [];
$files = (array) ($decoded['file_upload'] ?? []);
$image = (array) ($files['image'] ?? []);
$out[$model] = ['http_code' => $status, 'curl_errno' => curl_errno($curl),
'files' => array_intersect_key($files, array_flip(['enabled', 'number_limits', 'allowed_file_types', 'allowed_file_extensions', 'allowed_file_upload_methods'])),
'image' => array_intersect_key($image, array_flip(['enabled', 'number_limits', 'transfer_methods']))];
curl_close($curl);
}
echo json_encode($out, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE), PHP_EOL;
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
// Read-only queue metadata diagnostic. Never print credentials or clinical content.
$serverRoot = dirname(__DIR__, 2) . DIRECTORY_SEPARATOR . 'server' . DIRECTORY_SEPARATOR;
chdir($serverRoot);
require $serverRoot . 'vendor/autoload.php';
$app = new \think\App($serverRoot);
$app->initialize();
try {
$out = [
'php_now' => time(), 'php_local_time' => date('c'),
'enabled' => (bool) config('prescription_analysis.enabled', false),
'start_at' => (int) config('prescription_analysis.start_at', 0),
'configured_key_present' => (string) config('prescription_analysis.encryption_key', '') !== '',
'local_key_present' => is_file(root_path('runtime') . 'prescription_ai_private/snapshot.key'),
'db_now' => \think\facade\Db::query('SELECT UNIX_TIMESTAMP() AS now')[0]['now'],
];
$fields = 'id,prescription_id,status,validity,trigger_type,wait_until,next_run_at,lock_until,prepare_attempts,error_code,cutoff_at,created_at,updated_at';
$out['prescription_batches'] = \think\facade\Db::name('prescription_ai_batch')
->where('prescription_id', 7556)->field($fields)->order('id', 'desc')->limit(5)->select()->toArray();
$ids = array_column($out['prescription_batches'], 'id');
$out['prescription_tasks'] = $ids ? \think\facade\Db::name('prescription_ai_task')->whereIn('batch_id', $ids)
->field('id,batch_id,model_key,status,attempts,total_attempts,next_run_at,lock_until,error_code,result_id,started_at,finished_at,updated_at')
->select()->toArray() : [];
$out['batch_counts'] = \think\facade\Db::name('prescription_ai_batch')
->field('status,COUNT(*) AS total,MIN(id) AS first_id,MAX(id) AS last_id')->group('status')->select()->toArray();
$out['model_counts'] = \think\facade\Db::name('prescription_ai_task')
->field('model_key,status,COUNT(*) AS total')->group('model_key,status')->select()->toArray();
$out['active_preparation'] = \think\facade\Db::name('prescription_ai_batch')
->where('lock_until', '>', time())->field($fields)->limit(10)->select()->toArray();
echo json_encode($out, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE), PHP_EOL;
} catch (\Throwable $error) {
echo json_encode(['diagnostic_error' => get_class($error), 'code' => $error->getCode()]), PHP_EOL;
exit(1);
}
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
require __DIR__ . '/check_queue.php';
$secret = (string) config('prescription_analysis.encryption_key', '');
if ($secret === '') {
$secret = trim((string) file_get_contents(root_path('runtime') . 'prescription_ai_private/snapshot.key'));
}
$cipher = new \app\common\service\prescriptionai\PrescriptionAiCipher($secret);
$batch = \think\facade\Db::name('prescription_ai_batch')->where('id', 1)->where('prescription_id', 7556)->find();
$context = $cipher->decrypt($batch['context_cipher'], 'context');
$types = $hosts = $missing = [];
$sendable = [];
foreach ($context['files'] as $file) {
$type = (string) ($file['type'] ?? 'unknown');
$types[$type] = ($types[$type] ?? 0) + 1;
$host = (string) (parse_url((string) ($file['url'] ?? ''), PHP_URL_HOST) ?: '(none)');
$hosts[$host] = ($hosts[$host] ?? 0) + 1;
if ($file['status'] !== 'restricted' && !empty($file['url'])) { $sendable[] = $file; }
}
$normalization = [];
foreach (array_chunk($sendable, 3) as $files) {
$wire = array_map(static fn (array $f): array => ['type' => $f['type'], 'url' => $f['url'], 'transfer_method' => 'remote_url'], $files);
$value = (new ReflectionMethod(\app\common\service\DifyChatService::class, 'normalizeFiles'))->invoke(null, $wire, 3);
$normalization[] = ['input' => count($wire), 'kept' => count($value['kept']), 'over_limit' => count($value['dropped'])];
}
foreach ($context['missing'] ?? [] as $item) {
$code = (string) ($item['code'] ?? 'unknown');
$missing[$code] = ($missing[$code] ?? 0) + 1;
}
echo json_encode(['source_shape_only' => [
'attachment_types' => $types, 'attachment_hosts' => $hosts, 'missing_codes' => $missing,
'attachment_batch_normalization' => $normalization,
'max_files' => config('prescription_ai.max_files'), 'timeout' => config('prescription_ai.timeout'),
]], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE), PHP_EOL;
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
$serverRoot = dirname(__DIR__, 2) . DIRECTORY_SEPARATOR . 'server' . DIRECTORY_SEPARATOR;
chdir($serverRoot);
require $serverRoot . 'vendor/autoload.php';
$app = new \think\App($serverRoot);
$app->initialize();
try {
$secret = (string) config('prescription_analysis.encryption_key', '');
if ($secret === '') { $secret = trim((string) file_get_contents(root_path('runtime') . 'prescription_ai_private/snapshot.key')); }
$cipher = new \app\common\service\prescriptionai\PrescriptionAiCipher($secret);
$out = [];
foreach (\think\facade\Db::name('prescription_ai_task')->where('batch_id', 1)->select()->toArray() as $task) {
if (empty($task['progress_cipher'])) {
$out[] = ['model' => $task['model_key'], 'status' => $task['status'], 'result_id' => $task['result_id']];
continue;
}
$p = $cipher->decrypt($task['progress_cipher'], 'progress:' . $task['id']);
$out[] = ['model' => $task['model_key'], 'status' => $task['status'], 'attempts' => $task['attempts'], 'manual_retries' => $task['manual_retries'],
'error_code' => $task['error_code'], 'stage' => $p['stage'], 'calls' => $p['usage']['total_calls'],
'next_run' => date('c', (int) $task['next_run_at']), 'updated' => date('c', (int) $task['updated_at']),
'steps' => array_keys($p['steps']),
'recent_calls' => array_map(static fn (array $call): array => array_intersect_key($call, array_flip(['stage', 'latency_ms', 'ok', 'file_count', 'error_code'])), array_slice($p['usage']['calls'], -4))];
}
echo json_encode(['time' => date('c'), 'tasks' => $out], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE), PHP_EOL;
} catch (Throwable $e) { echo json_encode(['error' => get_class($e), 'code' => $e->getCode(), 'line' => $e->getLine(), 'file' => basename($e->getFile())]), PHP_EOL; exit(1); }
@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
// Read-only diagnostic for preparation wait and model timing. Metadata and counts only;
// never prints clinical content, attachment URLs, prompts or credentials.
$serverRoot = dirname(__DIR__, 2) . DIRECTORY_SEPARATOR . 'server' . DIRECTORY_SEPARATOR;
chdir($serverRoot);
require $serverRoot . 'vendor/autoload.php';
$app = new \think\App($serverRoot);
$app->initialize();
use think\facade\Db;
$rxId = (int) ($argv[1] ?? 7556);
$out = ['now' => time(), 'prescription_id' => $rxId];
$rx = Db::name('tcm_prescription')->where('id', $rxId)->field('id,diagnosis_id,appointment_id,patient_id,is_system_auto,update_time')->find();
$out['prescription'] = $rx;
$diagnosisId = (int) ($rx['diagnosis_id'] ?? 0);
$out['call_records'] = Db::name('tcm_call_record')->where('diagnosis_id', $diagnosisId)
->field('id,diagnosis_id,status,transcription_status,transcription_session_id,transcription_segment_count,start_time,end_time,create_time,update_time,transcription_started_at,transcription_finished_at')
->order('id', 'desc')->limit(10)->select()->toArray();
foreach ($out['call_records'] as &$call) {
$call['segment_total'] = (int) Db::name('tcm_call_transcript_segment')->where('call_record_id', $call['id'])->count();
$call['segment_current_session'] = (string) $call['transcription_session_id'] !== ''
? (int) Db::name('tcm_call_transcript_segment')->where('call_record_id', $call['id'])
->where('transcription_session_id', $call['transcription_session_id'])->count()
: 0;
}
unset($call);
$batches = Db::name('prescription_ai_batch')->where('prescription_id', $rxId)
->field('id,status,validity,cutoff_at,decision_at,wait_until,next_run_at,created_at,updated_at,coverage_status,source_summary_json,missing_json,error_code')
->order('id', 'desc')->limit(5)->select()->toArray();
foreach ($batches as &$batch) {
$batch['prepare_seconds'] = (int) $batch['cutoff_at'] > 0 ? (int) $batch['cutoff_at'] - (int) $batch['created_at'] : null;
$batch['missing_codes'] = array_count_values(array_column(json_decode((string) $batch['missing_json'], true) ?: [], 'code'));
unset($batch['missing_json']);
}
unset($batch);
$out['batches'] = $batches;
$ids = array_column($batches, 'id');
$out['tasks'] = $ids ? Db::name('prescription_ai_task')->whereIn('batch_id', $ids)
->field('id,batch_id,model_key,status,attempts,total_attempts,error_code,started_at,finished_at,updated_at')
->order('id')->select()->toArray() : [];
foreach ($out['tasks'] as &$task) {
$task['run_seconds'] = (int) $task['finished_at'] > 0 && (int) $task['started_at'] > 0
? (int) $task['finished_at'] - (int) $task['started_at'] : null;
}
unset($task);
$taskIds = array_column($out['tasks'], 'id');
$out['attempts'] = $taskIds ? Db::name('prescription_ai_attempt')->whereIn('task_id', $taskIds)
->field('id,task_id,attempt_no,status,error_code,started_at,finished_at')->order('id')->limit(40)->select()->toArray() : [];
echo json_encode($out, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), "\n";

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