Compare commits
50
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb28d93cec | ||
|
|
70ddc1a70a | ||
|
|
fc91908cf9 | ||
|
|
b4acad8510 | ||
|
|
f015b26ae1 | ||
|
|
91aa1cb595 | ||
|
|
0349ec0fbd | ||
|
|
68b412495d | ||
|
|
55979da912 | ||
|
|
228d93ac3b | ||
|
|
4704b32807 | ||
|
|
73be0ecedf | ||
|
|
6ccef86dd0 | ||
|
|
e762a367da | ||
|
|
91f56931fd | ||
|
|
9185c6db04 | ||
|
|
5884e000ff | ||
|
|
4d0af7f3f4 | ||
|
|
27772bec61 | ||
|
|
5706e9a6a5 | ||
|
|
51d8fd409e | ||
|
|
88b94c2fc6 | ||
|
|
637c2f1ff2 | ||
|
|
19ea2dadeb | ||
|
|
4dac39c4c2 | ||
|
|
a20d3565af | ||
|
|
67c374d899 | ||
|
|
13948f5982 | ||
|
|
1c9b093e08 | ||
|
|
8bb4bd07ae | ||
|
|
fea33285e8 | ||
|
|
5543f45850 | ||
|
|
70be2fc70f | ||
|
|
3cdc812f2b | ||
|
|
71304f356d | ||
|
|
694c83dc7d | ||
|
|
735b607f80 | ||
|
|
4ef3cef6a0 | ||
|
|
49fc974421 | ||
|
|
88ce52620a | ||
|
|
0a17f071fc | ||
|
|
8e2b4fc763 | ||
|
|
b7e91f5fe3 | ||
|
|
68fc86a167 | ||
|
|
bd22e5f476 | ||
|
|
dbf474ddd7 | ||
|
|
bbe3e870a1 | ||
|
|
caab2cabe3 | ||
|
|
3c7b88bb15 | ||
|
|
36975c6c1b |
@@ -0,0 +1,3 @@
|
||||
# The bundled GB18030 source files must retain their original bytes.
|
||||
server/knowledge/tcm-ancient-books/*.txt -text -diff
|
||||
server/knowledge/tcm-ancient-books/*.baiduyun.downloading* -text -diff
|
||||
@@ -28,6 +28,9 @@ app/.test-tmp-stream/
|
||||
/.codex
|
||||
/.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
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const vm = require('node:vm')
|
||||
const ts = require('typescript')
|
||||
const vue = require('vue')
|
||||
const { parse, compileScript, compileTemplate, compileStyleAsync } = require('@vue/compiler-sfc')
|
||||
|
||||
const sourceFlag = process.argv.indexOf('--source')
|
||||
const filename = sourceFlag >= 0 ? path.resolve(process.argv[sourceFlag + 1]) : path.join(__dirname, '../src/views/tcm/diagnosis/components/DailyMatrix.vue')
|
||||
const source = fs.readFileSync(filename, 'utf8')
|
||||
const { descriptor, errors } = parse(source, { filename })
|
||||
assert.deepEqual(errors, [], 'actual SFC parses')
|
||||
const script = compileScript(descriptor, { id: 'daily-record-test' })
|
||||
const template = compileTemplate({ source: descriptor.template.content, filename, id: 'daily-record-test', compilerOptions: { bindingMetadata: script.bindings } })
|
||||
assert.deepEqual(template.errors, [], 'actual template compiles')
|
||||
const calls = []
|
||||
const pending = { window: null, notes: null, bloodEdit: null }
|
||||
const api = new Proxy({}, { get(_target, name) {
|
||||
return (...args) => {
|
||||
calls.push({ name, args: JSON.parse(JSON.stringify(args)) })
|
||||
if (name === 'diagnosisTrackingWindow') return pending.window ? pending.window(...args) : Promise.resolve({})
|
||||
if (name === 'diagnosisTrackingNotes') return pending.notes ? pending.notes(...args) : Promise.resolve([])
|
||||
if (name === 'bloodRecordEdit' && pending.bloodEdit) return pending.bloodEdit(...args)
|
||||
return Promise.resolve(true)
|
||||
}
|
||||
} })
|
||||
const thresholdSource = fs.readFileSync(path.join(__dirname, '../src/utils/blood-thresholds.ts'), 'utf8')
|
||||
const thresholdExports = {}
|
||||
vm.runInNewContext(ts.transpileModule(thresholdSource, { compilerOptions: { module: ts.ModuleKind.CommonJS } }).outputText, { exports: thresholdExports })
|
||||
const moduleObject = { exports: {} }
|
||||
const lifecycle = { mounted: [], unmounted: [] }
|
||||
const runtime = { ...vue, onMounted: fn => lifecycle.mounted.push(fn), onUnmounted: fn => lifecycle.unmounted.push(fn) }
|
||||
vm.runInNewContext(ts.transpileModule(script.content, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText, {
|
||||
exports: moduleObject.exports, module: moduleObject, console, Date, Map, window: { addEventListener() {}, removeEventListener() {} },
|
||||
require(name) {
|
||||
if (name === 'vue') return runtime
|
||||
if (name === '@/api/tcm') return api
|
||||
if (name === '@/utils/perm') return { hasPermission: () => true }
|
||||
if (name === '@/utils/blood-thresholds') return thresholdExports
|
||||
if (name === '@/utils/feedback') return { default: { msgWarning: message => calls.push({ name: 'warning', message }) } }
|
||||
if (name === 'vue-echarts' || name.endsWith('.vue')) return { default: {} }
|
||||
throw new Error(`Unexpected import: ${name}`)
|
||||
}
|
||||
}, { filename })
|
||||
const props = vue.reactive({ diagnosisId: 7, patientId: 70, age: 50, readOnly: false, patientName: '合成测试' })
|
||||
let exposed
|
||||
const scope = vue.effectScope()
|
||||
const state = scope.run(() => moduleObject.exports.default.setup(props, { expose(value) { exposed = value } }))
|
||||
const date = '2026-09-20'
|
||||
const blood = [
|
||||
{ id: 103, diagnosis_id: 7, record_date: date, record_time: '19:30', fasting_blood_sugar: 7.1, record_time_estimated: 1, record_time_period: 'evening', record_time_text: '昨晚', followup_audio_task_id: 80, remark: '晚间原始行' },
|
||||
{ id: 102, diagnosis_id: 7, record_date: date, record_time: '08:30', fasting_blood_sugar: 7.1, source: 1, remark: '早间原始行' },
|
||||
{ id: 101, diagnosis_id: 7, record_date: date, record_time: '07:00', postprandial_blood_sugar: 8.4, remark: '独立原始行' }
|
||||
]
|
||||
const diet = [ { id: 201, record_date: date, breakfast_foods: '鸡蛋', breakfast_images: ['fixture-a.png'] }, { id: 202, record_date: date, lunch_foods: '豆腐', lunch_images: ['fixture-b.png'] } ]
|
||||
const exercise = [ { id: 301, record_date: date, exercise_type: '散步', duration: 20, intensity: 1, images: ['fixture-c.png'] }, { id: 302, record_date: date, exercise_type: '散步', duration: 20, intensity: 1 } ]
|
||||
function fixtures() {
|
||||
state.bloodRecords.value = structuredClone(blood)
|
||||
state.dietRecords.value = structuredClone(diet)
|
||||
state.exerciseRecords.value = structuredClone(exercise)
|
||||
}
|
||||
function rowCount(map) { const rows = map.value.get(date); return Array.isArray(rows) ? rows.length : rows ? 1 : 0 }
|
||||
function observe() {
|
||||
fixtures()
|
||||
state.handleCellClick('fasting', date)
|
||||
return { bloodRows: rowCount(state.bloodByDate), dietRows: rowCount(state.dietByDate), exerciseRows: rowCount(state.exerciseByDate), dayDetailOpen: !!state.dayDetailVisible?.value, implicitBloodEditId: state.bloodForm.value.id || null, fastingCell: state.getCell('fasting', date).value }
|
||||
}
|
||||
async function main() {
|
||||
for (const style of descriptor.styles) {
|
||||
const result = await compileStyleAsync({ source: style.content, filename, id: 'daily-record-test', scoped: style.scoped, preprocessLang: style.lang })
|
||||
assert.deepEqual(result.errors, [], 'actual styles compile')
|
||||
}
|
||||
const canonicalFlag = process.argv.indexOf('--canonical-fixture')
|
||||
if (canonicalFlag >= 0) {
|
||||
const fixture = JSON.parse(fs.readFileSync(process.argv[canonicalFlag + 1], 'utf8'))
|
||||
assert.equal(fixture.synthetic, true)
|
||||
assert.equal(fixture.source, 'real-disposable-mysql-apply')
|
||||
for (const alias of ['breakfast', 'lunch', 'dinner']) assert.equal(fixture.diet_columns.includes(alias), false)
|
||||
const actual = { ...fixture.diet_model, record_date: new Intl.DateTimeFormat('sv-SE', { timeZone: 'Asia/Shanghai' }).format(new Date(Number(fixture.diet_model.record_date) * 1000)) }
|
||||
state.dietRecords.value = [actual]
|
||||
state.openDietRecord(actual, actual.record_date)
|
||||
for (const meal of ['breakfast', 'lunch', 'dinner']) {
|
||||
assert.equal(state.dietForm.value[`${meal}_foods`], actual[`${meal}_foods`], 'real adopted meal opens in existing edit input')
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(state.dietForm.value[`${meal}_images`])), actual[`${meal}_images`])
|
||||
assert.equal(state.getCell(meal, actual.record_date).value, '✓')
|
||||
}
|
||||
state.dietForm.value.note = 'only synthetic note changed'
|
||||
await state.submitDiet()
|
||||
const payload = calls.findLast(x => x.name === 'dietRecordEdit').args[0]
|
||||
for (const meal of ['breakfast', 'lunch', 'dinner']) {
|
||||
assert.equal(payload[`${meal}_foods`], actual[`${meal}_foods`], 'submit preserves untouched canonical meals')
|
||||
assert.deepEqual(payload[`${meal}_images`], actual[`${meal}_images`], 'submit preserves images')
|
||||
assert.equal(meal in payload, false, 'no extraction alias leaks to existing edit API')
|
||||
}
|
||||
console.log('DAILY_RECORDS_CANONICAL_MYSQL PASS actual_model=1 edit_inputs=1 canonical_payload=1 preserved_images=1')
|
||||
}
|
||||
if (process.argv.includes('--observe')) { console.log(JSON.stringify(observe())); scope.stop(); return }
|
||||
assert.deepEqual(observe(), { bloodRows: 3, dietRows: 2, exerciseRows: 2, dayDetailOpen: true, implicitBloodEditId: null, fastingCell: '7.1 / 7.1' })
|
||||
assert.equal(state.dayBloodRecords.value.length, 3, 'all same-day blood rows present')
|
||||
assert.equal(state.dayDietRecords.value.length, 2, 'all diet rows present')
|
||||
assert.equal(state.dayExerciseRecords.value.length, 2, 'identical exercise rows remain separate')
|
||||
assert.equal(state.recordTimeLabel(blood[0]), '19:30')
|
||||
assert.equal(state.recordTimePeriodLabel(blood[0]), '晚上')
|
||||
assert.equal(state.recordTimeLabel(diet[0]), '未记录具体时间', 'do not invent times for old diet rows')
|
||||
assert.match(descriptor.template.content, /v-for="record in dayBloodRecords"/)
|
||||
assert.match(descriptor.template.content, /v-for="record in dayDietRecords"/)
|
||||
assert.match(descriptor.template.content, /v-for="record in dayExerciseRecords"/)
|
||||
assert.match(descriptor.template.content, /估计时间/)
|
||||
assert.match(descriptor.template.content, /record\.record_time_text/)
|
||||
|
||||
state.openBloodRecord(blood[0], date)
|
||||
assert.equal(state.bloodForm.value.id, 103)
|
||||
assert.equal(state.bloodForm.value.postprandial_blood_sugar, 0, 'no field from another row merged into edit')
|
||||
state.bloodForm.value.remark = '只改备注'
|
||||
await state.submitBlood()
|
||||
let sent = calls.findLast(x => x.name === 'bloodRecordEdit').args[0]
|
||||
assert.equal(sent.id, 103)
|
||||
assert.equal(sent.record_time_estimated, 1, 'unmodified time retains estimate')
|
||||
assert.equal(sent.record_time_text, '昨晚')
|
||||
assert.equal(sent.followup_audio_task_id, 80, 'keep provenance')
|
||||
state.openBloodRecord(blood[0], date)
|
||||
state.bloodForm.value.record_time = '20:05'
|
||||
await state.submitBlood()
|
||||
sent = calls.findLast(x => x.name === 'bloodRecordEdit').args[0]
|
||||
assert.equal(sent.record_time_estimated, 0, 'manual time correction clears estimate')
|
||||
assert.equal(sent.record_time_period, '')
|
||||
assert.equal(sent.record_time_text, '')
|
||||
assert.equal(sent.followup_audio_task_id, 80)
|
||||
state.openBloodRecord({ ...blood[0], record_time: '19:30:25' }, date)
|
||||
state.bloodForm.value.record_time = '19:30'
|
||||
await state.submitBlood()
|
||||
sent = calls.findLast(x => x.name === 'bloodRecordEdit').args[0]
|
||||
assert.equal(sent.record_time_estimated, 0, 'seconds correction must not retain estimated time')
|
||||
assert.equal(state.recordTimeLabel({ record_time: '19:30:25' }), '19:30:25', 'precise original seconds remain visible')
|
||||
state.openBloodRecord(undefined, date)
|
||||
await state.submitBlood()
|
||||
sent = calls.findLast(x => x.name === 'bloodRecordAdd').args[0]
|
||||
assert.equal(sent.id, '')
|
||||
assert.equal('record_time_estimated' in sent, false, 'legacy manual add does not require migration columns')
|
||||
state.openDietRecord(diet[1], date)
|
||||
assert.equal(state.dietForm.value.id, 202)
|
||||
state.dietForm.value.lunch_images.push('fixture-new.png')
|
||||
assert.equal(diet[1].lunch_images.length, 1, 'edit image arrays cloned')
|
||||
await state.submitDiet()
|
||||
assert.equal(calls.findLast(x => x.name === 'dietRecordEdit').args[0].id, 202)
|
||||
state.openExerciseRecord(exercise[1], date)
|
||||
await state.submitExercise()
|
||||
assert.equal(calls.findLast(x => x.name === 'exerciseRecordEdit').args[0].id, 302)
|
||||
fixtures()
|
||||
props.readOnly = true
|
||||
state.dayDetailVisible.value = false
|
||||
state.handleCellClick('exercise', date)
|
||||
assert.equal(state.dayDetailVisible.value, true, 'readonly viewers can inspect actual rows')
|
||||
state.bloodDialogVisible.value = false
|
||||
state.openBloodRecord(blood[0], date)
|
||||
assert.equal(state.bloodDialogVisible.value, false, 'readonly cannot edit')
|
||||
props.readOnly = false
|
||||
|
||||
const windows = []
|
||||
pending.window = params => new Promise(resolve => windows.push({ params, resolve }))
|
||||
const oldFetch = state.fetchTracking()
|
||||
props.diagnosisId = 8
|
||||
await vue.nextTick()
|
||||
assert.equal(state.dayDetailVisible.value, false, 'patient switch closes prior details')
|
||||
assert.equal(state.bloodRecords.value.length, 0, 'patient switch immediately clears old rows')
|
||||
assert.equal(windows.length, 2)
|
||||
assert.equal(windows[0].params.id, 7)
|
||||
assert.equal(windows[1].params.id, 8)
|
||||
windows[1].resolve({ blood_records: [{ id: 888, record_date: date }] })
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
windows[0].resolve({ blood_records: blood })
|
||||
await oldFetch
|
||||
assert.equal(state.bloodRecords.value[0].id, 888, 'late old-patient fetch cannot overwrite new data')
|
||||
assert.deepEqual(calls.filter(x => x.name === 'diagnosisTrackingNotes').slice(-2).map(x => x.args[0].diagnosis_id), [8, 7], 'both sequential requests use their own bound identity')
|
||||
|
||||
let releaseEdit
|
||||
pending.bloodEdit = () => new Promise(resolve => { releaseEdit = resolve })
|
||||
pending.window = null
|
||||
state.openBloodRecord({ ...blood[0], diagnosis_id: 8 }, date)
|
||||
const oldSubmit = state.submitBlood()
|
||||
props.diagnosisId = 9
|
||||
await vue.nextTick()
|
||||
state.bloodDialogVisible.value = true
|
||||
releaseEdit(true)
|
||||
await oldSubmit
|
||||
assert.equal(state.bloodDialogVisible.value, true, 'late old-patient submit cannot close new dialog')
|
||||
|
||||
// Single-flight guard prevents duplicate submissions of the same manual form.
|
||||
state.bloodSubmitting.value = true
|
||||
const callCount = calls.length
|
||||
await state.submitBlood()
|
||||
assert.equal(calls.length, callCount)
|
||||
state.bloodSubmitting.value = false
|
||||
const unmountedWindows = []
|
||||
pending.window = () => new Promise(resolve => unmountedWindows.push(resolve))
|
||||
const unmountedFetch = state.fetchTracking()
|
||||
for (const unmount of lifecycle.unmounted) unmount()
|
||||
unmountedWindows[0]({ blood_records: blood })
|
||||
await unmountedFetch
|
||||
assert.notEqual(state.bloodRecords.value[0]?.id, 103, 'unmounted request cannot repopulate component')
|
||||
scope.stop()
|
||||
assert.equal(typeof exposed.refresh, 'function')
|
||||
console.log('DailyMatrix actual SFC setup/template/style: 3 blood + 2 diet + 2 exercise rows, exact edit IDs, time metadata, readonly detail, stale isolation: PASS')
|
||||
}
|
||||
main().catch(error => { scope.stop(); console.error(error); process.exitCode = 1 })
|
||||
@@ -9,6 +9,7 @@ import useAppStore from './stores/modules/app'
|
||||
import useSettingStore from './stores/modules/setting'
|
||||
import 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>
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
/** AI 助手(MCP)后台接口:挂在 /mcp/admin 下,沿用后台登录令牌 */
|
||||
const opts = { urlPrefix: 'mcp' }
|
||||
|
||||
/** AI 授权列表(有 ai.grant/lists 看全部,否则只看自己的) */
|
||||
export function aiGrantLists(params: any) {
|
||||
return request.get({ url: '/admin/grants', params }, opts)
|
||||
}
|
||||
|
||||
/** 撤销 AI 授权 */
|
||||
export function aiGrantRevoke(params: { id: number }) {
|
||||
return request.post({ url: '/admin/revoke', params }, opts)
|
||||
}
|
||||
|
||||
/** AI 访问日志 */
|
||||
export function aiAccessLogLists(params: any) {
|
||||
return request.get({ url: '/admin/logs', params }, opts)
|
||||
}
|
||||
|
||||
/** AI 数据目录与覆盖情况 */
|
||||
export function aiCatalogLists(params: any) {
|
||||
return request.get({ url: '/admin/catalog', params }, opts)
|
||||
}
|
||||
@@ -0,0 +1,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
|
||||
}
|
||||
@@ -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
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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
@@ -0,0 +1,403 @@
|
||||
/**
|
||||
* 复诊节点(revisit_due)前端纯函数集合
|
||||
*
|
||||
* 数据契约与后端 `RevisitDueService::buildListPayload` 输出一致(方案 docs/plans/assistant-revisit-reach-reminder-2026-10-12.md 契约 C):
|
||||
* 所有日期为 'Y-m-d' 字符串或 '';无快照且无法计算时后端返回 state='none' 的对象,前端显示「—」。
|
||||
*
|
||||
* 本文件只做 state → 文案 / 颜色 class / el-tag type 的映射与日期、天数文案拼装,不含任何请求或响应式逻辑,
|
||||
* 供 PC 列表、H5 卡片、只读页三处共用(RevisitNodeCell.vue + readonly.vue)。
|
||||
*/
|
||||
|
||||
export type RevisitDueState =
|
||||
| 'visited'
|
||||
| 'booked'
|
||||
| 'missed'
|
||||
| 'contacted'
|
||||
| 'overdue'
|
||||
| 'warning'
|
||||
| 'pending'
|
||||
| 'no_days'
|
||||
| 'none'
|
||||
|
||||
/** 列表行 `revisit_due` 对象(契约 C) */
|
||||
export interface RevisitDue {
|
||||
current_slot: number
|
||||
next_slot: number
|
||||
valid_order_count: number
|
||||
medication_days: number
|
||||
/** 0=缺失 1=订单 2=处方 */
|
||||
days_src: 0 | 1 | 2
|
||||
/** 14/21/28/30 或 0=其它 */
|
||||
rule_days: number
|
||||
/** 0=无 1=签收 2=发货 3=下单 */
|
||||
anchor_src: 0 | 1 | 2 | 3
|
||||
anchor_date: string
|
||||
order_date: string
|
||||
due_date: string
|
||||
days_left: number | null
|
||||
overdue_days: number | null
|
||||
state: RevisitDueState | string
|
||||
state_text: string
|
||||
severe: 0 | 1
|
||||
/** 0=无 1=已约 3=已面诊 4=过号 */
|
||||
appointment_state: number
|
||||
appointment_date: string
|
||||
snooze_count: number
|
||||
escalation_level: number
|
||||
promised_date: string
|
||||
missed_count: number
|
||||
first_touch_date: string
|
||||
in_care_days: number | null
|
||||
total_medication_days: number
|
||||
closed_state: number
|
||||
}
|
||||
|
||||
/** 「复诊状态」筛选下拉选项(请求参数 revisit_state;due = overdue ∪ missed) */
|
||||
export const REVISIT_STATE_FILTER_OPTIONS: Array<{ label: string; value: string }> = [
|
||||
{ label: '到期未挂号 / 过号', value: 'due' },
|
||||
{ label: '即将到期', value: 'warning' },
|
||||
{ label: '已约待面诊', value: 'booked' },
|
||||
{ label: '已面诊', value: 'visited' },
|
||||
{ label: '已联系', value: 'contacted' },
|
||||
{ label: '缺开药天数', value: 'no_days' },
|
||||
{ label: '未到期', value: 'pending' }
|
||||
]
|
||||
|
||||
/** 「开药天数档」筛选下拉选项(请求参数 revisit_days_bucket) */
|
||||
export const REVISIT_DAYS_BUCKET_OPTIONS: Array<{ label: string; value: string }> = [
|
||||
{ label: '14 天', value: '14' },
|
||||
{ label: '21 天', value: '21' },
|
||||
{ label: '28 天', value: '28' },
|
||||
{ label: '30 天', value: '30' },
|
||||
{ label: '其它天数', value: 'other' }
|
||||
]
|
||||
|
||||
/** state → 默认文案(后端 state_text 缺失时兜底) */
|
||||
const STATE_LABEL: Record<string, string> = {
|
||||
visited: '已面诊',
|
||||
booked: '已约',
|
||||
missed: '过号',
|
||||
contacted: '已联系',
|
||||
overdue: '超期',
|
||||
warning: '即将到期',
|
||||
pending: '未到期',
|
||||
no_days: '缺开药天数',
|
||||
none: '—'
|
||||
}
|
||||
|
||||
const toInt = (v: unknown, fallback = 0): number => {
|
||||
if (v === null || v === undefined || v === '') return fallback
|
||||
const n = Number(v)
|
||||
return Number.isFinite(n) ? Math.trunc(n) : fallback
|
||||
}
|
||||
|
||||
const toNullableInt = (v: unknown): number | null => {
|
||||
if (v === null || v === undefined || v === '') return null
|
||||
const n = Number(v)
|
||||
return Number.isFinite(n) ? Math.trunc(n) : null
|
||||
}
|
||||
|
||||
const toDateStr = (v: unknown): string => {
|
||||
if (v === null || v === undefined) return ''
|
||||
const s = String(v).trim()
|
||||
if (s === '' || s === '0000-00-00') return ''
|
||||
return s
|
||||
}
|
||||
|
||||
/**
|
||||
* 把列表行 / 详情里的 `revisit_due` 原始值规整为 RevisitDue;
|
||||
* 字段缺失(旧接口)或非对象时返回 null,调用方据此不渲染。
|
||||
*/
|
||||
export function normalizeRevisitDue(raw: unknown): RevisitDue | null {
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null
|
||||
const r = raw as Record<string, unknown>
|
||||
const state = String(r.state ?? 'none').trim() || 'none'
|
||||
const daysSrc = toInt(r.days_src)
|
||||
const anchorSrc = toInt(r.anchor_src)
|
||||
return {
|
||||
current_slot: toInt(r.current_slot),
|
||||
next_slot: toInt(r.next_slot),
|
||||
valid_order_count: toInt(r.valid_order_count),
|
||||
medication_days: toInt(r.medication_days),
|
||||
days_src: (daysSrc === 1 || daysSrc === 2 ? daysSrc : 0) as 0 | 1 | 2,
|
||||
rule_days: toInt(r.rule_days),
|
||||
anchor_src: (anchorSrc >= 1 && anchorSrc <= 3 ? anchorSrc : 0) as 0 | 1 | 2 | 3,
|
||||
anchor_date: toDateStr(r.anchor_date),
|
||||
order_date: toDateStr(r.order_date),
|
||||
due_date: toDateStr(r.due_date),
|
||||
days_left: toNullableInt(r.days_left),
|
||||
overdue_days: toNullableInt(r.overdue_days),
|
||||
state,
|
||||
state_text: String(r.state_text ?? '').trim(),
|
||||
severe: toInt(r.severe) === 1 ? 1 : 0,
|
||||
appointment_state: toInt(r.appointment_state),
|
||||
appointment_date: toDateStr(r.appointment_date),
|
||||
snooze_count: toInt(r.snooze_count),
|
||||
escalation_level: toInt(r.escalation_level),
|
||||
promised_date: toDateStr(r.promised_date),
|
||||
missed_count: toInt(r.missed_count),
|
||||
first_touch_date: toDateStr(r.first_touch_date),
|
||||
in_care_days: toNullableInt(r.in_care_days),
|
||||
total_medication_days: toInt(r.total_medication_days),
|
||||
closed_state: toInt(r.closed_state)
|
||||
}
|
||||
}
|
||||
|
||||
/** 是否有可展示的复诊节点(state !== 'none') */
|
||||
export function hasRevisitDue(due: RevisitDue | null | undefined): due is RevisitDue {
|
||||
return !!due && due.state !== 'none' && due.state !== ''
|
||||
}
|
||||
|
||||
/** 行级标红:到期未挂号 / 过号未面诊;人工关闭或已自动关闭(closed_state!==0)的行不标红,与「复诊到期」chip / 筛选口径一致 */
|
||||
export function isRevisitDueHighlight(due: RevisitDue | null | undefined): boolean {
|
||||
return (
|
||||
hasRevisitDue(due) &&
|
||||
toInt(due.closed_state) === 0 &&
|
||||
(due.state === 'overdue' || due.state === 'missed')
|
||||
)
|
||||
}
|
||||
|
||||
/** 深红:超期达到 severe_overdue_days(后端已算好 severe=1) */
|
||||
export function isRevisitDueSevere(due: RevisitDue | null | undefined): boolean {
|
||||
return isRevisitDueHighlight(due) && Number(due?.severe) === 1
|
||||
}
|
||||
|
||||
/**
|
||||
* el-table :row-class-name 片段:
|
||||
* '' | 'row-revisit-overdue' | 'row-revisit-overdue row-revisit-overdue-deep'
|
||||
*/
|
||||
export function revisitRowClassName(due: RevisitDue | null | undefined): string {
|
||||
if (!isRevisitDueHighlight(due)) return ''
|
||||
return isRevisitDueSevere(due) ? 'row-revisit-overdue row-revisit-overdue-deep' : 'row-revisit-overdue'
|
||||
}
|
||||
|
||||
/** 'YYYY-MM-DD' → 'MM-DD';非法/空值返回 '' */
|
||||
export function formatRevisitMonthDay(date: string | null | undefined): string {
|
||||
const s = toDateStr(date)
|
||||
if (!s) return ''
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(s)
|
||||
if (m) return `${m[2]}-${m[3]}`
|
||||
return s
|
||||
}
|
||||
|
||||
/** 用药起算锚点来源文案(行 2 末尾) */
|
||||
export function revisitAnchorSrcText(src: number | null | undefined): string {
|
||||
const n = toInt(src)
|
||||
if (n === 1) return '签收起'
|
||||
if (n === 2) return '发货起'
|
||||
if (n === 3) return '下单起'
|
||||
return ''
|
||||
}
|
||||
|
||||
/** 开药天数来源文案(tooltip 用) */
|
||||
export function revisitDaysSrcText(src: number | null | undefined): string {
|
||||
const n = toInt(src)
|
||||
if (n === 1) return '订单'
|
||||
if (n === 2) return '处方'
|
||||
return '缺失'
|
||||
}
|
||||
|
||||
/** 是否缺开药天数(days_src=0 或 medication_days<=0) */
|
||||
export function isRevisitDaysMissing(due: RevisitDue | null | undefined): boolean {
|
||||
if (!hasRevisitDue(due)) return false
|
||||
return toInt(due.days_src) === 0 || toInt(due.medication_days) <= 0
|
||||
}
|
||||
|
||||
/** 「第 N 诊」文案 */
|
||||
export function revisitSlotText(due: RevisitDue | null | undefined): string {
|
||||
if (!hasRevisitDue(due)) return ''
|
||||
const slot = toInt(due.current_slot)
|
||||
return slot > 0 ? `第 ${slot} 诊` : '未成交'
|
||||
}
|
||||
|
||||
/** 「开药 X 天」或「缺开药天数」 */
|
||||
export function revisitMedicationDaysText(due: RevisitDue | null | undefined): string {
|
||||
if (!hasRevisitDue(due)) return ''
|
||||
if (isRevisitDaysMissing(due)) return '缺开药天数'
|
||||
return `开药 ${toInt(due.medication_days)} 天`
|
||||
}
|
||||
|
||||
/** 「药尽 MM-DD」;缺天数无 due_date 时返回 '' */
|
||||
export function revisitDueDateText(due: RevisitDue | null | undefined): string {
|
||||
if (!hasRevisitDue(due)) return ''
|
||||
const md = formatRevisitMonthDay(due.due_date)
|
||||
return md ? `药尽 ${md}` : ''
|
||||
}
|
||||
|
||||
/** state 兜底文案(后端 state_text 为空时使用) */
|
||||
export function revisitStateLabel(state: string | null | undefined): string {
|
||||
const key = String(state ?? '').trim()
|
||||
return STATE_LABEL[key] ?? (key || '—')
|
||||
}
|
||||
|
||||
/**
|
||||
* 状态 chip 文案:优先后端 state_text;为空时按 state + 数字自行拼装,
|
||||
* 保证旧接口 / 字段缺失时也可读。
|
||||
*/
|
||||
export function revisitStateChipText(due: RevisitDue | null | undefined): string {
|
||||
if (!hasRevisitDue(due)) return ''
|
||||
if (due.state_text) return due.state_text
|
||||
const state = String(due.state)
|
||||
switch (state) {
|
||||
case 'overdue': {
|
||||
const n = toInt(due.overdue_days)
|
||||
return n <= 0 ? '今日应挂号' : `超期 ${n} 天`
|
||||
}
|
||||
case 'missed': {
|
||||
const md = formatRevisitMonthDay(due.appointment_date)
|
||||
return md ? `过号 ${md}` : '过号'
|
||||
}
|
||||
case 'booked': {
|
||||
const md = formatRevisitMonthDay(due.appointment_date)
|
||||
return md ? `已约 ${md}` : '已约'
|
||||
}
|
||||
case 'visited': {
|
||||
const md = formatRevisitMonthDay(due.appointment_date)
|
||||
return md ? `已面诊 ${md}` : '已面诊'
|
||||
}
|
||||
case 'contacted': {
|
||||
const md = formatRevisitMonthDay(due.promised_date)
|
||||
return md ? `已联系·约 ${md}` : '已联系'
|
||||
}
|
||||
case 'warning':
|
||||
case 'pending': {
|
||||
const n = due.days_left
|
||||
return n === null ? revisitStateLabel(state) : `剩 ${n} 天`
|
||||
}
|
||||
default:
|
||||
return revisitStateLabel(state)
|
||||
}
|
||||
}
|
||||
|
||||
/** 状态 chip 的 el-tag type */
|
||||
export function revisitStateTagType(
|
||||
state: string | null | undefined
|
||||
): 'danger' | 'warning' | 'primary' | 'success' | 'info' {
|
||||
switch (String(state ?? '')) {
|
||||
case 'overdue':
|
||||
case 'missed':
|
||||
return 'danger'
|
||||
case 'warning':
|
||||
case 'no_days':
|
||||
return 'warning'
|
||||
case 'booked':
|
||||
case 'contacted':
|
||||
return 'primary'
|
||||
case 'visited':
|
||||
return 'success'
|
||||
default:
|
||||
return 'info'
|
||||
}
|
||||
}
|
||||
|
||||
/** 状态 chip 的 el-tag effect:红为 dark;描边态(已联系 / 缺天数)为 plain;其余 light */
|
||||
export function revisitStateTagEffect(state: string | null | undefined): 'dark' | 'light' | 'plain' {
|
||||
switch (String(state ?? '')) {
|
||||
case 'overdue':
|
||||
case 'missed':
|
||||
return 'dark'
|
||||
case 'contacted':
|
||||
case 'no_days':
|
||||
return 'plain'
|
||||
default:
|
||||
return 'light'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 状态 → 徽标颜色 class(复用列表 / 只读页既有的 unserved-* 色板):
|
||||
* overdue/missed → unserved-red;warning/no_days → unserved-orange;
|
||||
* visited → unserved-green;booked/contacted → unserved-blue;其余 → unserved-muted
|
||||
*/
|
||||
export function revisitStateColorClass(
|
||||
state: string | null | undefined
|
||||
): 'unserved-red' | 'unserved-orange' | 'unserved-green' | 'unserved-blue' | 'unserved-muted' {
|
||||
switch (String(state ?? '')) {
|
||||
case 'overdue':
|
||||
case 'missed':
|
||||
return 'unserved-red'
|
||||
case 'warning':
|
||||
case 'no_days':
|
||||
return 'unserved-orange'
|
||||
case 'visited':
|
||||
return 'unserved-green'
|
||||
case 'booked':
|
||||
case 'contacted':
|
||||
return 'unserved-blue'
|
||||
default:
|
||||
return 'unserved-muted'
|
||||
}
|
||||
}
|
||||
|
||||
/** 状态 chip 附加 class(组件内样式钩子):rv-chip rv-chip--<state> [rv-chip--severe] */
|
||||
export function revisitStateChipClass(due: RevisitDue | null | undefined): string {
|
||||
if (!hasRevisitDue(due)) return 'rv-chip'
|
||||
const cls = ['rv-chip', `rv-chip--${String(due.state)}`]
|
||||
if (isRevisitDueSevere(due)) cls.push('rv-chip--severe')
|
||||
return cls.join(' ')
|
||||
}
|
||||
|
||||
/** 行 4:「已延迟 N 次」 */
|
||||
export function revisitSnoozeText(due: RevisitDue | null | undefined): string {
|
||||
if (!hasRevisitDue(due)) return ''
|
||||
const n = toInt(due.snooze_count)
|
||||
return n > 0 ? `已延迟 ${n} 次` : ''
|
||||
}
|
||||
|
||||
/** 行 4:「已联系 约 MM-DD」 */
|
||||
export function revisitContactText(due: RevisitDue | null | undefined): string {
|
||||
if (!hasRevisitDue(due)) return ''
|
||||
const md = formatRevisitMonthDay(due.promised_date)
|
||||
return md ? `已联系 约 ${md}` : ''
|
||||
}
|
||||
|
||||
/** 行 4 合并文案:仅 snooze_count>0 或 promised_date 有值时非空 */
|
||||
export function revisitFollowLineText(due: RevisitDue | null | undefined): string {
|
||||
const parts = [revisitSnoozeText(due), revisitContactText(due)].filter((s) => s !== '')
|
||||
return parts.join(' · ')
|
||||
}
|
||||
|
||||
/** 行 2:「MM-DD 开药 · 开药 X 天 · 签收起」 */
|
||||
export function revisitOrderLineText(due: RevisitDue | null | undefined): string {
|
||||
if (!hasRevisitDue(due)) return ''
|
||||
const parts: string[] = []
|
||||
const md = formatRevisitMonthDay(due.order_date)
|
||||
if (md) parts.push(`${md} 开药`)
|
||||
parts.push(revisitMedicationDaysText(due))
|
||||
if (!isRevisitDaysMissing(due)) {
|
||||
const anchor = revisitAnchorSrcText(due.anchor_src)
|
||||
if (anchor) parts.push(anchor)
|
||||
}
|
||||
return parts.filter((s) => s !== '').join(' · ')
|
||||
}
|
||||
|
||||
/** 诊次 tag tooltip 行 */
|
||||
export function revisitTooltipLines(due: RevisitDue | null | undefined): string[] {
|
||||
if (!hasRevisitDue(due)) return []
|
||||
const lines: string[] = []
|
||||
const offset = Math.max(0, toInt(due.current_slot) - toInt(due.valid_order_count))
|
||||
lines.push(`有效订单 ${toInt(due.valid_order_count)} 笔 · 诊次偏移 +${offset}`)
|
||||
if (toInt(due.next_slot) > 0) lines.push(`待复诊:第 ${toInt(due.next_slot)} 诊`)
|
||||
const first = formatRevisitMonthDay(due.first_touch_date)
|
||||
if (first) {
|
||||
const care = due.in_care_days
|
||||
lines.push(care === null ? `首次触达 ${first}` : `首次触达 ${first} · 在管 ${care} 天`)
|
||||
}
|
||||
if (toInt(due.total_medication_days) > 0) lines.push(`累计开药 ${toInt(due.total_medication_days)} 天`)
|
||||
const anchorMd = formatRevisitMonthDay(due.anchor_date)
|
||||
const anchorSrc = revisitAnchorSrcText(due.anchor_src)
|
||||
if (anchorMd && anchorSrc) lines.push(`用药起算 ${anchorMd}(${anchorSrc.replace(/起$/, '')})`)
|
||||
if (!isRevisitDaysMissing(due)) lines.push(`开药天数来源:${revisitDaysSrcText(due.days_src)}`)
|
||||
if (toInt(due.missed_count) > 0) lines.push(`曾过号 ${toInt(due.missed_count)} 次`)
|
||||
return lines
|
||||
}
|
||||
|
||||
/**
|
||||
* 只读页徽标文案:「第 N 诊 · 药尽 MM-DD · 超期 N 天」
|
||||
* 无 due_date 时省略中段;尾段为状态 chip 文案。
|
||||
*/
|
||||
export function revisitBadgeText(due: RevisitDue | null | undefined): string {
|
||||
if (!hasRevisitDue(due)) return ''
|
||||
const parts = [revisitSlotText(due), revisitDueDateText(due), revisitStateChipText(due)]
|
||||
return parts.filter((s) => s !== '').join(' · ')
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
<template>
|
||||
<div class="ai-access-log-page">
|
||||
<el-card class="!border-none" shadow="never">
|
||||
<el-form :inline="true">
|
||||
<el-form-item label="时间">
|
||||
<el-date-picker
|
||||
v-model="timeRange"
|
||||
type="datetimerange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始时间"
|
||||
end-placeholder="结束时间"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
clearable
|
||||
@change="resetPage"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="结果">
|
||||
<el-select v-model="queryParams.status" clearable placeholder="全部" class="w-[120px]" @change="resetPage">
|
||||
<el-option v-for="(label, value) in STATUS" :key="value" :label="label" :value="value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="数据资源">
|
||||
<el-input v-model="queryParams.resource" placeholder="如 tcm.diagnosis" clearable class="w-[180px]" @keyup.enter="resetPage" @clear="resetPage" />
|
||||
</el-form-item>
|
||||
<el-form-item label="记录ID">
|
||||
<el-input v-model="queryParams.record_id" placeholder="如诊单ID" clearable class="w-[130px]" @keyup.enter="resetPage" @clear="resetPage" />
|
||||
</el-form-item>
|
||||
<el-form-item label="任务号">
|
||||
<el-input v-model="queryParams.client_task_id" placeholder="行知任务号" clearable class="w-[200px]" @keyup.enter="resetPage" @clear="resetPage" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="resetPage">查询</el-button>
|
||||
<el-button @click="handleReset">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div class="page-hint">
|
||||
记录每一次 AI 查询:谁、通过哪个客户端任务、查了哪个数据资源、返回了哪些记录。按“记录ID”可以查某个诊单被哪些账号通过 AI 看过。
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card class="!border-none mt-3" shadow="never">
|
||||
<el-table :data="pager.lists" v-loading="pager.loading" size="default" stripe>
|
||||
<el-table-column label="时间" width="165" prop="create_time_text" />
|
||||
<el-table-column label="账号" min-width="120">
|
||||
<template #default="{ row }">
|
||||
<div>{{ row.admin_name || (row.admin_id ? '#' + row.admin_id : '—') }}</div>
|
||||
<div class="cell-sub">{{ row.ip }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="工具" width="170" prop="tool" />
|
||||
<el-table-column label="数据资源" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<div>{{ row.resource_name || row.resource || '—' }}</div>
|
||||
<div class="cell-sub">{{ row.resource }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="结果" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 'ok' ? 'success' : 'warning'" size="small">{{ STATUS[row.status] || row.status }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="条数" width="70" align="right" prop="result_rows" />
|
||||
<el-table-column label="记录ID / 原因" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<el-tooltip v-if="row.record_ids" :content="row.record_ids" placement="top">
|
||||
<span class="ellipsis">{{ row.record_ids }}</span>
|
||||
</el-tooltip>
|
||||
<span v-else class="cell-sub">{{ row.message }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="参数" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<el-tooltip v-if="row.arguments" :content="row.arguments" placement="top">
|
||||
<span class="ellipsis">{{ row.arguments }}</span>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="耗时" width="80" align="right">
|
||||
<template #default="{ row }">{{ row.duration_ms }}ms</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="任务号" min-width="160" prop="client_task_id" />
|
||||
</el-table>
|
||||
<div class="flex justify-end mt-4">
|
||||
<pagination v-model="pager" @change="getLists" />
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="aiMcpAccessLog">
|
||||
import { aiAccessLogLists } from '@/api/ai_mcp'
|
||||
import { usePaging } from '@/hooks/usePaging'
|
||||
import { onMounted, reactive, ref, watch } from 'vue'
|
||||
|
||||
const STATUS: Record<string, string> = { ok: '成功', denied: '拒绝', invalid: '参数错误', limited: '超限', error: '失败' }
|
||||
const timeRange = ref<[string, string] | null>(null)
|
||||
const queryParams = reactive({ start_time: '', end_time: '', status: '', resource: '', record_id: '', client_task_id: '' })
|
||||
|
||||
watch(timeRange, (val) => {
|
||||
queryParams.start_time = val?.[0] ?? ''
|
||||
queryParams.end_time = val?.[1] ?? ''
|
||||
})
|
||||
|
||||
const { pager, getLists, resetPage, resetParams } = usePaging({
|
||||
fetchFun: aiAccessLogLists,
|
||||
params: queryParams
|
||||
})
|
||||
|
||||
const handleReset = () => {
|
||||
timeRange.value = null
|
||||
resetParams()
|
||||
}
|
||||
|
||||
onMounted(() => getLists())
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-hint {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.cell-sub {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
.ellipsis {
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,123 @@
|
||||
<template>
|
||||
<div class="ai-catalog-page">
|
||||
<el-card class="!border-none" shadow="never">
|
||||
<div class="summary">
|
||||
<div class="summary-item">
|
||||
<div class="summary-value success">{{ counts.open ?? 0 }}</div>
|
||||
<div class="summary-label">已开放</div>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<div class="summary-value warning">{{ counts.pending ?? 0 }}</div>
|
||||
<div class="summary-label">待整改</div>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<div class="summary-value">{{ counts.excluded ?? 0 }}</div>
|
||||
<div class="summary-label">不开放</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-form :inline="true" class="mt-3">
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="queryParams.status" clearable placeholder="全部" class="w-[120px]" @change="resetPage">
|
||||
<el-option label="已开放" value="open" />
|
||||
<el-option label="待整改" value="pending" />
|
||||
<el-option label="不开放" value="excluded" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="业务分组">
|
||||
<el-select v-model="queryParams.domain" clearable filterable placeholder="全部" class="w-[180px]" @change="resetPage">
|
||||
<el-option v-for="d in domains" :key="d" :label="d" :value="d" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="关键词">
|
||||
<el-input v-model="queryParams.keyword" placeholder="名称或资源标识" clearable class="w-[200px]" @keyup.enter="resetPage" @clear="resetPage" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="resetPage">查询</el-button>
|
||||
<el-button @click="resetParams">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div class="page-hint">
|
||||
后台每个只读接口都是一个“数据资源”。已开放的资源由 AI 以调用账号自己的权限和数据范围执行原有接口;
|
||||
待整改的资源写明了原因(如缺少逐条权限校验、会调用外部接口、权限点未登记),整改或审核通过后开放。
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card class="!border-none mt-3" shadow="never">
|
||||
<el-table :data="pager.lists" v-loading="pager.loading" size="default" stripe>
|
||||
<el-table-column label="资源" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<div>{{ row.name }}</div>
|
||||
<div class="cell-sub">{{ row.resource }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="业务分组" width="150" prop="domain" />
|
||||
<el-table-column label="类型" width="80">
|
||||
<template #default="{ row }">{{ KIND[row.kind] || row.kind }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="STATUS[row.status]?.type" size="small">{{ STATUS[row.status]?.label || row.status }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="原因" min-width="260" prop="reason" />
|
||||
<el-table-column label="已审核" width="80" align="center">
|
||||
<template #default="{ row }">{{ row.reviewed ? '是' : '自动' }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="flex justify-end mt-4">
|
||||
<pagination v-model="pager" @change="getLists" />
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="aiMcpCatalog">
|
||||
import { aiCatalogLists } from '@/api/ai_mcp'
|
||||
import { usePaging } from '@/hooks/usePaging'
|
||||
import { computed, onMounted, reactive } from 'vue'
|
||||
|
||||
const KIND: Record<string, string> = { list: '列表', detail: '详情', report: '统计', other: '其他', write: '写操作' }
|
||||
const STATUS: Record<string, { label: string; type: 'success' | 'warning' | 'info' }> = {
|
||||
open: { label: '已开放', type: 'success' },
|
||||
pending: { label: '待整改', type: 'warning' },
|
||||
excluded: { label: '不开放', type: 'info' }
|
||||
}
|
||||
const queryParams = reactive({ status: '', domain: '', keyword: '' })
|
||||
|
||||
const { pager, getLists, resetPage, resetParams } = usePaging({
|
||||
fetchFun: aiCatalogLists,
|
||||
params: queryParams,
|
||||
size: 50
|
||||
})
|
||||
|
||||
const counts = computed<Record<string, number>>(() => pager.extend?.counts || {})
|
||||
const domains = computed<string[]>(() => pager.extend?.domains || [])
|
||||
|
||||
onMounted(() => getLists())
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.summary {
|
||||
display: flex;
|
||||
gap: 48px;
|
||||
}
|
||||
.summary-value {
|
||||
font-size: 26px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.summary-value.success {
|
||||
color: var(--el-color-success);
|
||||
}
|
||||
.summary-value.warning {
|
||||
color: var(--el-color-warning);
|
||||
}
|
||||
.summary-label,
|
||||
.page-hint,
|
||||
.cell-sub {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
.page-hint {
|
||||
line-height: 1.6;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,135 @@
|
||||
<template>
|
||||
<div class="ai-grant-page">
|
||||
<el-card class="!border-none" shadow="never">
|
||||
<el-alert
|
||||
v-if="pager.extend && pager.extend.enabled === false"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="mb-3"
|
||||
title="AI 助手接口未启用:服务器 .env 的 [AI_MCP] ENABLED 为 false,客户端暂时无法绑定和查询。"
|
||||
/>
|
||||
<el-form :inline="true">
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="queryParams.status" clearable placeholder="全部" class="w-[120px]" @change="resetPage">
|
||||
<el-option label="有效" value="1" />
|
||||
<el-option label="已撤销" value="2" />
|
||||
<el-option label="已过期" value="3" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="关键词">
|
||||
<el-input
|
||||
v-model="queryParams.keyword"
|
||||
placeholder="姓名 / 账号 / 备注"
|
||||
clearable
|
||||
class="w-[220px]"
|
||||
@keyup.enter="resetPage"
|
||||
@clear="resetPage"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="resetPage">查询</el-button>
|
||||
<el-button @click="resetParams">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div class="page-hint">
|
||||
员工在行知等 AI 助手里用甄养堂账号密码绑定后,会在这里生成一条只读授权。AI 只能按该账号自己的权限和数据范围查询;
|
||||
撤销后立即失效。账号改密、停用、失去“允许 AI 助手查询”权限时,授权也会自动失效。
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card class="!border-none mt-3" shadow="never">
|
||||
<el-table :data="pager.lists" v-loading="pager.loading" size="default" stripe>
|
||||
<el-table-column label="账号" min-width="140">
|
||||
<template #default="{ row }">
|
||||
<div>{{ row.admin_name || '—' }}</div>
|
||||
<div class="cell-sub">{{ row.admin_account }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="客户端" min-width="150">
|
||||
<template #default="{ row }">
|
||||
<div>{{ row.client }}<span v-if="row.client_instance"> · {{ row.client_instance }}</span></div>
|
||||
<div class="cell-sub">{{ row.label }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="令牌" width="130" prop="token_prefix" />
|
||||
<el-table-column label="状态" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status_text === '有效' ? 'success' : 'info'" size="small">{{ row.status_text }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="最近使用" min-width="150">
|
||||
<template #default="{ row }">
|
||||
<div>{{ row.last_used_time_text || '—' }}</div>
|
||||
<div class="cell-sub">{{ row.last_used_ip }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="到期时间" width="150" prop="expire_time_text" />
|
||||
<el-table-column label="签发时间" width="150" prop="create_time_text" />
|
||||
<el-table-column label="撤销" min-width="140">
|
||||
<template #default="{ row }">
|
||||
<template v-if="row.revoke_time_text">
|
||||
<div>{{ row.revoke_time_text }}</div>
|
||||
<div class="cell-sub">{{ reasonText(row.revoke_reason) }}</div>
|
||||
</template>
|
||||
<span v-else>—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="90" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="row.can_revoke" type="danger" link @click="handleRevoke(row)">撤销</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="flex justify-end mt-4">
|
||||
<pagination v-model="pager" @change="getLists" />
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="aiMcpGrant">
|
||||
import { aiGrantLists, aiGrantRevoke } from '@/api/ai_mcp'
|
||||
import { usePaging } from '@/hooks/usePaging'
|
||||
import feedback from '@/utils/feedback'
|
||||
import { onMounted, reactive } from 'vue'
|
||||
|
||||
const queryParams = reactive({ status: '', keyword: '' })
|
||||
|
||||
const { pager, getLists, resetPage, resetParams } = usePaging({
|
||||
fetchFun: aiGrantLists,
|
||||
params: queryParams
|
||||
})
|
||||
|
||||
const REASONS: Record<string, string> = {
|
||||
client_revoke: '客户端解绑',
|
||||
admin_revoke: '后台撤销',
|
||||
rebind: '重新绑定',
|
||||
expired: '到期/闲置失效',
|
||||
password_changed: '账号改密',
|
||||
admin_disabled: '账号停用',
|
||||
admin_deleted: '账号删除'
|
||||
}
|
||||
const reasonText = (reason: string) => REASONS[reason] || reason || ''
|
||||
|
||||
const handleRevoke = async (row: any) => {
|
||||
await feedback.confirm(`确认撤销 ${row.admin_name || row.admin_account} 的这条 AI 授权?撤销后该客户端需要重新绑定。`)
|
||||
await aiGrantRevoke({ id: row.id })
|
||||
feedback.msgSuccess('已撤销')
|
||||
getLists()
|
||||
}
|
||||
|
||||
onMounted(() => getLists())
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-hint {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.cell-sub {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -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">
|
||||
由每日定时任务「待分配诊单自动指派」写入:按上月二诊复诊接诊率分档轮询分配(>70% 每人3条/日,60%~70%
|
||||
每人2条/日,50%~60% 每人1条/日,<50% 不分)。「回退」可将已分配医助撤回到自动分配前的原医助(通常为一中心),并写入指派日志。
|
||||
每人2条/日,50%~60% 每人1条/日,<50% 不分)。「回退」可将已分配医助撤回到自动分配前的原医助(通常为一中心),并写入指派日志(原医助重新接手按「继承」记录)。
|
||||
<br />
|
||||
口径版本:<b>v1 旧口径</b> = 分母仅新指派(visit2_rate_new_assign);<b>v2 新口径</b> = 每列独立分母,继承按继承时诊次计入(visit2_rate)。
|
||||
每条日志同时记录两套率并标注本批次分档所用口径(配置 project.revisit_rate.auto_assign_tier_field);2026-10 前的历史行仅有旧口径率。
|
||||
</div>
|
||||
</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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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 : '请求失败;当前审核内容已保留,请刷新任务状态后确认'
|
||||
}
|
||||
@@ -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: '',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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()
|
||||
@@ -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."
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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> <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)
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 绘制标签,不再为每行每列
|
||||
|
||||
@@ -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)
|
||||
@@ -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": "模型繁忙"}}
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
from PySide6.QtCore import Qt, QTimer
|
||||
from PySide6.QtWidgets import QApplication, QDialog, QMainWindow
|
||||
|
||||
from doctor_workstation.core import PermissionSet
|
||||
from doctor_workstation.services import DemoDoctorRepository
|
||||
from doctor_workstation.ui.appointment_drawer import AppointmentDrawer
|
||||
from doctor_workstation.ui.dialogs.ai_consult_picker import AiConsultTargetDialog
|
||||
from doctor_workstation.ui.dialogs.diagnosis import DiagnosisDialog, OrderDetailDrawer
|
||||
from doctor_workstation.ui.dialogs.prescription import PrescriptionEditorDialog
|
||||
from doctor_workstation.ui.shell import ShellWindow
|
||||
from doctor_workstation.ui.theme import _install_business_dialog_styling
|
||||
|
||||
|
||||
def test_new_dialog_and_im_window_use_85_percent_of_main_window() -> None:
|
||||
app = QApplication.instance() or QApplication([])
|
||||
_install_business_dialog_styling(app)
|
||||
main = QMainWindow()
|
||||
main.setProperty("doctorMainWindow", True)
|
||||
main.setGeometry(40, 60, 1200, 800)
|
||||
main.show()
|
||||
app.processEvents()
|
||||
|
||||
dialog = QDialog(main)
|
||||
dialog.setMinimumSize(1100, 700)
|
||||
im_window = QMainWindow()
|
||||
im_window.resize(1120, 760)
|
||||
try:
|
||||
for window in (dialog, im_window):
|
||||
window.show()
|
||||
app.processEvents()
|
||||
assert window.size().width() == round(main.frameGeometry().width() * 0.85)
|
||||
assert window.size().height() == round(main.frameGeometry().height() * 0.85)
|
||||
assert abs(window.frameGeometry().center().x() - main.frameGeometry().center().x()) <= 1
|
||||
assert abs(window.frameGeometry().center().y() - main.frameGeometry().center().y()) <= 1
|
||||
window.hide()
|
||||
main.resize(1400, 900)
|
||||
app.processEvents()
|
||||
for window in (dialog, im_window):
|
||||
window.show()
|
||||
app.processEvents()
|
||||
assert window.width() == round(main.frameGeometry().width() * 0.85)
|
||||
assert window.height() == round(main.frameGeometry().height() * 0.85)
|
||||
finally:
|
||||
im_window.close()
|
||||
dialog.close()
|
||||
main.close()
|
||||
|
||||
|
||||
def test_frameless_drawer_keeps_its_owner_layout() -> None:
|
||||
app = QApplication.instance() or QApplication([])
|
||||
_install_business_dialog_styling(app)
|
||||
main = QMainWindow()
|
||||
main.setProperty("doctorMainWindow", True)
|
||||
main.resize(1200, 800)
|
||||
main.show()
|
||||
app.processEvents()
|
||||
drawer = QDialog(main)
|
||||
drawer.setWindowFlag(Qt.WindowType.FramelessWindowHint, True)
|
||||
drawer.resize(500, 400)
|
||||
try:
|
||||
drawer.show()
|
||||
app.processEvents()
|
||||
assert drawer.size().width() == 500
|
||||
assert drawer.size().height() == 400
|
||||
finally:
|
||||
drawer.close()
|
||||
main.close()
|
||||
|
||||
|
||||
def test_modal_exec_uses_main_window_geometry() -> None:
|
||||
app = QApplication.instance() or QApplication([])
|
||||
_install_business_dialog_styling(app)
|
||||
main = QMainWindow()
|
||||
main.setProperty("doctorMainWindow", True)
|
||||
main.setGeometry(30, 50, 1200, 800)
|
||||
main.show()
|
||||
app.processEvents()
|
||||
dialog = QDialog(main)
|
||||
try:
|
||||
QTimer.singleShot(0, dialog.accept)
|
||||
dialog.exec()
|
||||
assert dialog.width() == round(main.frameGeometry().width() * 0.85)
|
||||
assert dialog.height() == round(main.frameGeometry().height() * 0.85)
|
||||
finally:
|
||||
dialog.close()
|
||||
main.close()
|
||||
|
||||
|
||||
def test_owner_synchronized_business_drawers_use_main_window_85_percent() -> None:
|
||||
app = QApplication.instance() or QApplication([])
|
||||
_install_business_dialog_styling(app)
|
||||
main = QMainWindow()
|
||||
main.setProperty("doctorMainWindow", True)
|
||||
main.setGeometry(30, 50, 1400, 900)
|
||||
main.show()
|
||||
app.processEvents()
|
||||
repository = DemoDoctorRepository()
|
||||
dialogs = [
|
||||
AppointmentDrawer({"id": 501, "diagnosis_id": 501}, repository, main, autoload=False),
|
||||
DiagnosisDialog(repository, main),
|
||||
OrderDetailDrawer(main),
|
||||
PrescriptionEditorDialog(repository, parent=main),
|
||||
]
|
||||
try:
|
||||
for dialog in dialogs:
|
||||
dialog.show()
|
||||
app.processEvents()
|
||||
app.processEvents()
|
||||
assert dialog.width() == round(main.frameGeometry().width() * 0.85)
|
||||
assert dialog.height() == round(main.frameGeometry().height() * 0.85)
|
||||
dialog.hide()
|
||||
finally:
|
||||
for dialog in dialogs:
|
||||
dialog.close()
|
||||
main.close()
|
||||
|
||||
|
||||
def test_maximized_workstation_opens_patient_picker_at_85_percent() -> None:
|
||||
app = QApplication.instance() or QApplication([])
|
||||
_install_business_dialog_styling(app)
|
||||
repository = DemoDoctorRepository()
|
||||
permissions = PermissionSet(["*"])
|
||||
main = ShellWindow(
|
||||
repository,
|
||||
{"user": {"name": "测试医生"}, "demo_mode": True},
|
||||
permissions,
|
||||
)
|
||||
main.showMaximized()
|
||||
app.processEvents()
|
||||
picker = AiConsultTargetDialog(repository, permissions, main)
|
||||
try:
|
||||
picker.show()
|
||||
app.processEvents()
|
||||
assert main.isMaximized()
|
||||
assert picker.width() == round(main.frameGeometry().width() * 0.85)
|
||||
assert picker.height() == round(main.frameGeometry().height() * 0.85)
|
||||
finally:
|
||||
picker.close()
|
||||
main.close()
|
||||
@@ -1,495 +0,0 @@
|
||||
vite v6.4.2 building for production...
|
||||
|
||||
WARN
|
||||
(!) outDir D:\web\zyt\artifacts\appointment-modes\admin-build is not inside project root and will not be emptied.
|
||||
Use --emptyOutDir to override.
|
||||
|
||||
|
||||
transforming...
|
||||
✓ 4061 modules transformed.
|
||||
|
||||
WARN node_modules/.pnpm/tcplayer.js@5.3.4-beta.32/node_modules/tcplayer.js/dist/tcplayer.v5.3.4.min.js (3199:26): Use of eval in "node_modules/.pnpm/tcplayer.js@5.3.4-beta.32/node_modules/tcplayer.js/dist/tcplayer.v5.3.4.min.js" is strongly discouraged as it poses security risks and may cause issues with minification.
|
||||
|
||||
rendering chunks...
|
||||
computing gzip size...
|
||||
../artifacts/appointment-modes/admin-build/assets/default_avatar-C6VB7PGm.png 6.09 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/no_perms-jDxcYpYC.png 14.62 kB
|
||||
../artifacts/appointment-modes/admin-build/index.html 31.87 kB │ gzip: 16.16 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/login_bg-BkIjQ0FB.png 59.27 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/red3-NOuWP8DK.png 105.00 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/pink3-BxZ4Y6CS.png 108.36 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/blue3-D3K9OqGO.png 108.98 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/yellow3-C_qd9cqN.png 109.27 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/green3-CPorIQiC.png 109.99 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/purple3-BGd0LxTa.png 110.15 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/my_topbg-BiU0PleK.png 142.47 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/red2-Dw8p71sP.png 750.50 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/purple2-C0tQkldV.png 752.33 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/green2-C-VRKLSN.png 756.86 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/yellow2-B-WtqITJ.png 762.86 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/pink2-BpEp33zy.png 806.48 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/blue2-CRQPdLZd.png 806.81 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/red1-C6Y3UuNB.png 1,660.63 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/yellow1-Ebw0T5sw.png 1,662.23 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/pink1-BWNZrP7C.png 1,668.56 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/blue1-gLOo1H0w.png 1,676.54 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/purple1-BpMq9FWz.png 1,680.01 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/green1-h1zqes95.png 1,688.20 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-CmFE2aZQ.css 0.04 kB │ gzip: 0.06 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-CjSHFu-R.css 0.04 kB │ gzip: 0.06 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-Oppei429.css 0.04 kB │ gzip: 0.06 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/CaseRecordList-Cl-c3q7S.css 0.05 kB │ gzip: 0.07 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/DietRecordList-Cc-xUr2B.css 0.05 kB │ gzip: 0.07 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/roster-CqNlv_rj.css 0.05 kB │ gzip: 0.07 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/BloodRecordList-DhVK-Y_e.css 0.05 kB │ gzip: 0.07 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-B3imPWFk.css 0.05 kB │ gzip: 0.07 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/list-BluNBZln.css 0.05 kB │ gzip: 0.07 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/ExerciseRecordList-DGtgtzou.css 0.05 kB │ gzip: 0.07 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/edit-CObVTcPU.css 0.09 kB │ gzip: 0.10 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/edit-D-D8bVPM.css 0.09 kB │ gzip: 0.10 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/pc_details-nlJo1_D0.css 0.09 kB │ gzip: 0.10 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/AppointmentRecordPanel-DC22GDgn.css 0.09 kB │ gzip: 0.10 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/theme-picker-BsELUxM9.css 0.11 kB │ gzip: 0.09 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/content-Cl-9UIip.css 0.13 kB │ gzip: 0.13 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/AssignLogPanel-DkQMoEkX.css 0.13 kB │ gzip: 0.13 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/content-DXAsZ7EV.css 0.13 kB │ gzip: 0.13 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/content-DaxRy47P.css 0.14 kB │ gzip: 0.14 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/edit-CAJZpU7c.css 0.14 kB │ gzip: 0.12 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/content-DxNvUNZR.css 0.15 kB │ gzip: 0.15 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/useListTimeFilter-DI6SIumd.css 0.16 kB │ gzip: 0.14 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/edit-C1NtFi7N.css 0.16 kB │ gzip: 0.11 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/content-Bd3rHJ5J.css 0.18 kB │ gzip: 0.15 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/change-password-DRfsLJ26.css 0.19 kB │ gzip: 0.16 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/content-Dy_alH9u.css 0.19 kB │ gzip: 0.16 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/decoration-img-C5XvHl9_.css 0.19 kB │ gzip: 0.16 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/account_cost-fbCWUO9k.css 0.20 kB │ gzip: 0.16 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-57RdOFNo.css 0.23 kB │ gzip: 0.16 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/error-Cz3CexuM.css 0.24 kB │ gzip: 0.18 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/edit-D4atl_7z.css 0.25 kB │ gzip: 0.17 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/attr-lNortPsv.css 0.27 kB │ gzip: 0.19 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/setting-CxqqGetv.css 0.27 kB │ gzip: 0.17 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-DX96drV3.css 0.28 kB │ gzip: 0.17 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/menu-DYFusokT.css 0.29 kB │ gzip: 0.16 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/OrderActionHost-DiqybThz.css 0.31 kB │ gzip: 0.22 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-Bk2rMrqw.css 0.32 kB │ gzip: 0.20 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-D0f1Mn00.css 0.33 kB │ gzip: 0.22 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/prescription-drawer-BjwiqvPc.css 0.35 kB │ gzip: 0.16 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/bind-work-wechat-BX1gqwq1.css 0.35 kB │ gzip: 0.22 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-DhR2yeiO.css 0.39 kB │ gzip: 0.19 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-DoFDMZdP.css 0.40 kB │ gzip: 0.23 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-DBNvK3ZK.css 0.45 kB │ gzip: 0.21 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/PatientOrderList-D8-uh2iu.css 0.45 kB │ gzip: 0.25 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/DiagnosisTodoList-D0SBmTHG.css 0.46 kB │ gzip: 0.23 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/CallRecordPanel-TsjZtTO8.css 0.47 kB │ gzip: 0.23 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-C_LxAEkS.css 0.48 kB │ gzip: 0.27 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/login-Bt4SvQsz.css 0.52 kB │ gzip: 0.28 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/content-sMbqkta2.css 0.55 kB │ gzip: 0.29 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/RecordingPlaybackBlock-B3KYFgvg.css 0.58 kB │ gzip: 0.26 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/medicine-DO6x6zrS.css 0.58 kB │ gzip: 0.29 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/preview-pc-BRQDo0AR.css 0.67 kB │ gzip: 0.36 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-BzrSkGWL.css 0.67 kB │ gzip: 0.27 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/tabbar-DJsOahKR.css 0.69 kB │ gzip: 0.31 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/qywx-Dym8iFe0.css 0.74 kB │ gzip: 0.37 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/AssistantWatchCallDialog-BIPZRgaH.css 0.76 kB │ gzip: 0.38 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/picker-JBYDNsl5.css 0.84 kB │ gzip: 0.32 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/oa-phone-CctIyraX.css 0.94 kB │ gzip: 0.34 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/preview-C7oaKmYo.css 0.98 kB │ gzip: 0.41 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-Bidrbn2Z.css 1.16 kB │ gzip: 0.51 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/TrackingNoteTimeline-B9T8pFso.css 1.18 kB │ gzip: 0.47 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/RecordingVideoPlayer-4DegHAwm.css 1.19 kB │ gzip: 0.53 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-55HtuLpn.css 1.21 kB │ gzip: 0.55 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/PatientInfoCard-n6_UPo9e.css 1.32 kB │ gzip: 0.55 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/TrackingMatrix-zyxtXYPb.css 1.32 kB │ gzip: 0.44 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/SendPanel-DaGPWlQG.css 1.39 kB │ gzip: 0.50 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/picker-XZbUgFct.css 1.57 kB │ gzip: 0.50 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/PrescriptionOrderDetailDrawer-B4rggqa1.css 1.73 kB │ gzip: 0.71 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/ImChatRecordPanel-aX8zpWRQ.css 1.74 kB │ gzip: 0.59 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-BYOmK9R5.css 1.93 kB │ gzip: 0.58 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-OgnT0gUo.css 1.98 kB │ gzip: 0.56 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/MessageBubble-BwtdPM6K.css 2.04 kB │ gzip: 0.60 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-BmDW9vcs.css 2.44 kB │ gzip: 0.86 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/PatientCaseCard-C3Mob6yG.css 2.49 kB │ gzip: 0.81 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/tongji-C0VnzFxy.css 2.49 kB │ gzip: 0.77 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/dept-tongji-ky7rOYoi.css 2.67 kB │ gzip: 0.72 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/readonly-CdP3eZN0.css 2.69 kB │ gzip: 0.92 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/NoteTimeline-I2FSxX4s.css 2.74 kB │ gzip: 0.84 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/patient-call-k8V9hV2G.css 2.85 kB │ gzip: 0.91 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/DailyMatrix-DCZnxI8i.css 2.87 kB │ gzip: 0.85 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/PaibanPanel-aqm8azGc.css 3.35 kB │ gzip: 0.88 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/paiban-BTUHWpUu.css 3.35 kB │ gzip: 0.88 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/appointment-g8BrHfhi.css 3.43 kB │ gzip: 0.93 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/PrescriptionAiBatchGenerateDialog-CKkykmdr.css 3.45 kB │ gzip: 0.86 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/h5-BSFcFSny.css 3.52 kB │ gzip: 0.99 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-Bk7b_sL2.css 3.57 kB │ gzip: 0.96 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/add-ByXRFm8M.css 3.60 kB │ gzip: 0.87 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/PromotionAutomationForm-BMhNCJa-.css 3.64 kB │ gzip: 1.03 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/mubiao-B6IOi2bJ.css 3.78 kB │ gzip: 1.08 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/WelcomeMessageEditor-DD5mXWP2.css 4.41 kB │ gzip: 1.23 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/PrescriptionAiReportDialog-CV6AuHV3.css 4.46 kB │ gzip: 1.11 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-CMsGEvq_.css 5.19 kB │ gzip: 1.18 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/mubiao-dept-node-ChUPkaxq.css 5.47 kB │ gzip: 1.29 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/OrderPanel-BzElZPEq.css 5.67 kB │ gzip: 1.46 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-Co57tWbg.css 6.11 kB │ gzip: 1.70 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/list-D5BdlpZJ.css 6.51 kB │ gzip: 1.45 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-DtDaTXOB.css 7.01 kB │ gzip: 1.69 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/mubiao-dept-card-naMqyghz.css 7.09 kB │ gzip: 1.53 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-AnyUcdTT.css 7.21 kB │ gzip: 1.86 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-Bw7BJ7QR.css 8.03 kB │ gzip: 1.99 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-DONCMk_U.css 8.04 kB │ gzip: 2.32 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/progress-99NT9ysL.css 8.14 kB │ gzip: 1.91 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-Cmf8QBsF.css 8.75 kB │ gzip: 1.78 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-CSIcxqFk.css 8.85 kB │ gzip: 2.21 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-B_6Taw4k.css 8.97 kB │ gzip: 1.91 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/edit-DOd7Lhrw.css 9.47 kB │ gzip: 2.05 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-Dnn9ddtX.css 9.49 kB │ gzip: 2.20 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-D9Td5Y2a.css 9.52 kB │ gzip: 2.10 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/ProgressPanel-C_aSqDJh.css 10.10 kB │ gzip: 2.10 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-I_vRP8ff.css 10.57 kB │ gzip: 2.04 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-DV5JwFnL.css 11.95 kB │ gzip: 2.61 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-cz2HBbSE.css 13.65 kB │ gzip: 3.05 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/commission-settlement-Gy2UsUPr.css 15.55 kB │ gzip: 3.19 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/list_h5-Cs3mUmOa.css 17.70 kB │ gzip: 3.23 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/order_list-CMCIYVRT.css 18.15 kB │ gzip: 3.76 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-Dgjf9Hxv.css 18.86 kB │ gzip: 3.83 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/WecomFloatingWidgetBuilder-DRWt4rvh.css 19.20 kB │ gzip: 4.00 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index_h5-6234yJkC.css 19.63 kB │ gzip: 3.53 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/yeji-7-gSVFmd.css 25.24 kB │ gzip: 4.60 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-BSycZlsS.css 45.91 kB │ gzip: 9.91 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/order_list_h5-DB3qJv9G.css 48.13 kB │ gzip: 7.68 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/.pnpm-B3v8nGpq.css 723.50 kB │ gzip: 100.49 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/getExposeType-BhVtb25-.js 0.07 kB │ gzip: 0.09 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/attr-qqeREw9s.js 0.13 kB │ gzip: 0.13 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/attr-DcOELR-K.js 0.13 kB │ gzip: 0.13 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/attr-tHefvLXa.js 0.13 kB │ gzip: 0.13 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/MediaSourceSelect-8A82adC4.js 0.14 kB │ gzip: 0.14 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/code-preview-DebWNacj.js 0.16 kB │ gzip: 0.15 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/edit-B5lAmlRD.js 0.18 kB │ gzip: 0.16 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/refund-log-Dte4TYSk.js 0.19 kB │ gzip: 0.17 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/account-adjust-B00EjgEk.js 0.19 kB │ gzip: 0.17 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/content-o13C0dUX.js 0.19 kB │ gzip: 0.17 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/content-Bw34X5yl.js 0.19 kB │ gzip: 0.17 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/content-fJdDXmQk.js 0.19 kB │ gzip: 0.17 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/content-ZSwm2OnL.js 0.19 kB │ gzip: 0.17 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/perm-CxGp7d9E.js 0.20 kB │ gzip: 0.18 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/diabetes-discovery-display-B_wmGXQJ.js 0.20 kB │ gzip: 0.17 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/PrescriptionOrderTimeDialog-DhGQ2qc5.js 0.20 kB │ gzip: 0.18 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/weapp-AW0lTawR.js 0.20 kB │ gzip: 0.15 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/GancaoSubmissionReconcileButton-BUP3IK_K.js 0.21 kB │ gzip: 0.18 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/content-n8Jap8KU.js 0.21 kB │ gzip: 0.19 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/edit-DobNH3vu.js 0.21 kB │ gzip: 0.17 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/edit-B6XQI8aF.js 0.21 kB │ gzip: 0.17 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/edit-DyTWIq8-.js 0.21 kB │ gzip: 0.17 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/edit-DcP285OM.js 0.21 kB │ gzip: 0.17 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/edit-CpN5NTKb.js 0.21 kB │ gzip: 0.17 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/edit-CscKSbQv.js 0.21 kB │ gzip: 0.17 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/attr.vue_vue_type_script_setup_true_lang-DG0CrN4I.js 0.22 kB │ gzip: 0.18 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/attr.vue_vue_type_script_setup_true_lang-awcvFaxy.js 0.22 kB │ gzip: 0.18 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/attr.vue_vue_type_script_setup_true_lang-DvEPH3sF.js 0.22 kB │ gzip: 0.18 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/oa-menu-form-DpRW2C_C.js 0.22 kB │ gzip: 0.18 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/useLockFn-DZaCbVGv.js 0.22 kB │ gzip: 0.19 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/appointment-type-DJT6wPaT.js 0.23 kB │ gzip: 0.17 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/edit-D9QQ1l2O.js 0.23 kB │ gzip: 0.19 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/auth-C5D7rPvf.js 0.24 kB │ gzip: 0.18 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/edit-CBO2zICf.js 0.25 kB │ gzip: 0.19 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/relations-add-D1VlWUUa.js 0.25 kB │ gzip: 0.19 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/mobile-style-DwsiSMiK.js 0.26 kB │ gzip: 0.19 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/edit-CHvGoI1A.js 0.27 kB │ gzip: 0.18 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/cost-edit-Bg-Uyd2c.js 0.30 kB │ gzip: 0.21 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/yeji-edit-C9uvAsWe.js 0.30 kB │ gzip: 0.21 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/404-3rkYd7tU.js 0.31 kB │ gzip: 0.29 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/data-table-SmfBi9Sg.js 0.31 kB │ gzip: 0.20 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/oa-menu-form-edit-C7hYj3ER.js 0.33 kB │ gzip: 0.21 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/medicine-Dd2rx3Ot.js 0.35 kB │ gzip: 0.18 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/consumer-B3GKjsdE.js 0.35 kB │ gzip: 0.19 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/user-Ca4bJD6d.js 0.37 kB │ gzip: 0.18 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/role-DTJVNPTP.js 0.39 kB │ gzip: 0.18 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/useDictOptions-Dn9M7zwp.js 0.42 kB │ gzip: 0.32 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-BzeCW0Uz.js 0.42 kB │ gzip: 0.32 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/admin-BOSvnXa7.js 0.42 kB │ gzip: 0.21 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/content-s8eO9hBN.js 0.44 kB │ gzip: 0.36 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/pay-CYh6c1Jc.js 0.45 kB │ gzip: 0.21 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/department-jIolW8HD.js 0.46 kB │ gzip: 0.19 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/menu-DxVtimm9.js 0.46 kB │ gzip: 0.19 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/content-COxSBtU5.js 0.47 kB │ gzip: 0.37 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/attr-C-UmNM7-.js 0.48 kB │ gzip: 0.25 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/post-BM66vrTr.js 0.49 kB │ gzip: 0.21 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/overflow-CvDxI8pV.js 0.49 kB │ gzip: 0.37 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/decoration-DcKIqlQW.js 0.50 kB │ gzip: 0.22 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/message-NjmKbrE0.js 0.50 kB │ gzip: 0.21 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/403-Bd7P9v6E.js 0.52 kB │ gzip: 0.44 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/footer.vue_vue_type_script_setup_true_lang-CwusgJ4Z.js 0.53 kB │ gzip: 0.38 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/attr-C-oRPuSk.js 0.54 kB │ gzip: 0.27 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/attr-CY72vprk.js 0.54 kB │ gzip: 0.27 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/add-nav-BdWkHCFM.js 0.54 kB │ gzip: 0.27 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/content-DnglhtO0.js 0.54 kB │ gzip: 0.27 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/menu-set-O2cSQVkP.js 0.54 kB │ gzip: 0.28 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/attr-D3WqzAia.js 0.55 kB │ gzip: 0.26 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/patient-vjBAK2HA.js 0.56 kB │ gzip: 0.22 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/fans-eQIJEPPO.js 0.60 kB │ gzip: 0.21 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/attr-DZ31aYPg.js 0.60 kB │ gzip: 0.28 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/attr-lqO8LPXd.js 0.61 kB │ gzip: 0.29 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/attr-LZa4samX.js 0.61 kB │ gzip: 0.29 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/diag-display-DCz_VAqj.js 0.61 kB │ gzip: 0.39 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/content.vue_vue_type_script_setup_true_lang-0Lb4ZZ78.js 0.61 kB │ gzip: 0.41 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-C9CTu6xY.js 0.63 kB │ gzip: 0.43 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/edit-CFowjcfc.js 0.63 kB │ gzip: 0.32 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/link-DGRRT5eg.js 0.64 kB │ gzip: 0.44 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/asset-D92fftiF.js 0.67 kB │ gzip: 0.22 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/content.vue_vue_type_script_setup_true_lang-Bt_MGGD6.js 0.69 kB │ gzip: 0.44 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/useMediaSourceOptions-B0L3IlgH.js 0.70 kB │ gzip: 0.42 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/content.vue_vue_type_script_setup_true_lang-4NVOwZND.js 0.70 kB │ gzip: 0.45 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/website-CBS8EOwn.js 0.74 kB │ gzip: 0.24 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/menu-C47G34h8.js 0.76 kB │ gzip: 0.49 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/usePaging-oaNM6A9T.js 0.77 kB │ gzip: 0.47 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/self_input_stats-BMoDYPed.js 0.79 kB │ gzip: 0.27 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/decoration-img-ChFP1uE8.js 0.81 kB │ gzip: 0.50 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/dict-DqKNj29A.js 0.81 kB │ gzip: 0.25 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/code-D879PDoD.js 0.82 kB │ gzip: 0.25 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/blood-thresholds-CJ1fqMjh.js 0.83 kB │ gzip: 0.34 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/content-NopGY6qR.js 0.84 kB │ gzip: 0.55 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/content.vue_vue_type_script_setup_true_lang-1ur9wwmZ.js 0.86 kB │ gzip: 0.55 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index.vue_vue_type_script_setup_true_lang-kAZgL5aN.js 0.86 kB │ gzip: 0.52 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index.vue_vue_type_script_setup_true_lang-CDLrQsa9.js 0.88 kB │ gzip: 0.51 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/error-Bn-Kyayl.js 0.89 kB │ gzip: 0.60 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/theme-picker-BiL-qD2M.js 0.92 kB │ gzip: 0.59 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/wecomOauthPostMessage-C_e6VGrO.js 0.95 kB │ gzip: 0.53 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index.vue_vue_type_script_setup_true_lang-Cmgm_Z5P.js 0.98 kB │ gzip: 0.53 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/wx_oa-B5L52qTa.js 1.04 kB │ gzip: 0.29 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/article-hs8A9Jdu.js 1.07 kB │ gzip: 0.26 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/attr-setting.vue_vue_type_script_setup_true_lang-ClYX-hV1.js 1.08 kB │ gzip: 0.61 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/rich_text-Dk0jS3Pc.js 1.10 kB │ gzip: 0.58 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/cache-CWFtbfTm.js 1.13 kB │ gzip: 0.69 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-DCmzItce.js 1.21 kB │ gzip: 0.70 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/popover_input-pPM8UqwF.js 1.25 kB │ gzip: 0.59 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/finance-DA6_SyDw.js 1.30 kB │ gzip: 0.37 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/pc-DQbWYqv7.js 1.31 kB │ gzip: 0.78 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/doctor-DyZGuUZC.js 1.32 kB │ gzip: 0.37 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/oa-menu-form-edit.vue_vue_type_script_setup_true_lang-C21u_UoH.js 1.33 kB │ gzip: 0.78 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/content-BAht1aZ2.js 1.33 kB │ gzip: 0.71 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/content-CeY2TUv-.js 1.37 kB │ gzip: 0.70 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/content-BJGtt-xN.js 1.38 kB │ gzip: 0.79 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/file-BTnyaqZX.js 1.41 kB │ gzip: 0.64 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/upload-DHrBQCOO.js 1.41 kB │ gzip: 0.64 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/content-NSr7rO7a.js 1.42 kB │ gzip: 0.79 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/code-preview.vue_vue_type_script_setup_true_lang-C4bf87lU.js 1.44 kB │ gzip: 0.85 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-DLNXfvlb.js 1.44 kB │ gzip: 0.85 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index.vue_vue_type_style_index_0_lang-mxcCSl8u.js 1.46 kB │ gzip: 0.79 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/oa-phone-DVZEALSU.js 1.47 kB │ gzip: 0.80 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/attr-BePIgx8X.js 1.50 kB │ gzip: 0.76 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/menu-BxpNhh7J.js 1.55 kB │ gzip: 0.88 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/MediaSourceSelect.vue_vue_type_script_setup_true_lang-xuHF5G6T.js 1.58 kB │ gzip: 0.80 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/PatientInfoCard-oWJA6Uib.js 1.60 kB │ gzip: 0.82 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/attr.vue_vue_type_script_setup_true_lang-BACvjW0Z.js 1.62 kB │ gzip: 0.71 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/style-DHri-b9V.js 1.62 kB │ gzip: 0.88 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/refund-log.vue_vue_type_script_setup_true_lang-C9XoZuna.js 1.66 kB │ gzip: 0.87 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/statistics-B3M41WAU.js 1.69 kB │ gzip: 1.02 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/attr.vue_vue_type_script_setup_true_lang-DNKk0KAC.js 1.71 kB │ gzip: 0.81 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/order-D2TrFdV8.js 1.74 kB │ gzip: 0.44 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/environment-C74pQttO.js 1.74 kB │ gzip: 0.74 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/setup-Dz_APrpo.js 1.77 kB │ gzip: 1.00 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/useMenuOa-DFdWMJUR.js 1.82 kB │ gzip: 0.85 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/attr-setting-D_7bJ_WP.js 1.88 kB │ gzip: 0.53 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-BzDASedl.js 1.90 kB │ gzip: 0.99 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/edit.vue_vue_type_script_setup_true_name_articleColumnEdit_lang-E0vpVDJX.js 1.94 kB │ gzip: 1.07 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-B9qvdXe9.js 2.03 kB │ gzip: 0.98 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-B3x_V6St.js 2.04 kB │ gzip: 1.14 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/TrackingNoteTimeline-B7ZehsXP.js 2.08 kB │ gzip: 1.17 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-DMVXI7dX.js 2.09 kB │ gzip: 1.17 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/attr.vue_vue_type_script_setup_true_lang-CFyAuY4i.js 2.09 kB │ gzip: 0.95 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/open_setting-521tiVc_.js 2.10 kB │ gzip: 1.12 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-0BnrgRc3.js 2.12 kB │ gzip: 1.23 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/account-adjust.vue_vue_type_script_setup_true_lang-CpPfqdLb.js 2.16 kB │ gzip: 1.12 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/filing-Cqmunz7R.js 2.17 kB │ gzip: 1.17 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-ClW9LZFF.js 2.18 kB │ gzip: 1.28 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/icon-qQvuPtJo.js 2.23 kB │ gzip: 0.82 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/content.vue_vue_type_script_setup_true_lang-Dur4SMQq.js 2.24 kB │ gzip: 1.16 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/edit.vue_vue_type_script_setup_true_lang-vpDrHDku.js 2.28 kB │ gzip: 1.16 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/edit.vue_vue_type_script_setup_true_lang-C366kQIF.js 2.29 kB │ gzip: 1.09 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index.vue_vue_type_script_setup_true_lang-BBhGStE3.js 2.37 kB │ gzip: 1.15 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/RecordingPlaybackBlock-BOTDmjKh.js 2.40 kB │ gzip: 1.19 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/auth.vue_vue_type_script_setup_true_lang-CWND1gXs.js 2.40 kB │ gzip: 1.33 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/stats-BZP8tLtW.js 2.45 kB │ gzip: 0.58 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/data-table.vue_vue_type_script_setup_true_lang-DS49GzuV.js 2.51 kB │ gzip: 1.28 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/oa-attr-BzWXYYXP.js 2.51 kB │ gzip: 1.25 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/edit.vue_vue_type_script_setup_true_lang-Dhx2yo7b.js 2.52 kB │ gzip: 1.20 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/h5-p3Zf_YQP.js 2.56 kB │ gzip: 1.22 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/change-password-YIODZgIx.js 2.57 kB │ gzip: 1.35 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/im-business-message-parse-kN-fWnvR.js 2.60 kB │ gzip: 1.28 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/add-nav.vue_vue_type_script_setup_true_lang-DMG92gvp.js 2.61 kB │ gzip: 1.27 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/tabbar-C4RTjgc5.js 2.66 kB │ gzip: 1.33 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/file-CT3DBwWs.js 2.73 kB │ gzip: 1.06 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/edit.vue_vue_type_script_setup_true_lang-uPRl_lL4.js 2.74 kB │ gzip: 1.25 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/PrescriptionOrderTimeDialog.vue_vue_type_script_setup_true_lang-VQL80FaN.js 2.77 kB │ gzip: 1.53 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/oa-menu-form.vue_vue_type_script_setup_true_lang-BeAQ08_1.js 2.77 kB │ gzip: 1.08 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/CaseRecordList-LSZveKRb.js 2.80 kB │ gzip: 1.53 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/attr.vue_vue_type_script_setup_true_lang-CkZpjrdt.js 2.81 kB │ gzip: 1.06 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-C659Qo0h.js 2.85 kB │ gzip: 1.34 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/follow_reply-DVMErqzs.js 2.85 kB │ gzip: 1.49 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-D1k3lHzy.js 2.86 kB │ gzip: 1.44 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/cost-edit.vue_vue_type_script_setup_true_lang-CVc0n4pV.js 2.86 kB │ gzip: 1.35 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/attr.vue_vue_type_script_setup_true_lang-lmr2i5Bj.js 2.89 kB │ gzip: 1.41 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/default_reply-CpyjKv3V.js 2.91 kB │ gzip: 1.54 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/edit.vue_vue_type_script_setup_true_lang-gdH1LeQj.js 2.96 kB │ gzip: 1.14 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/protocol-D4wxifpO.js 2.98 kB │ gzip: 1.10 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/attr.vue_vue_type_script_setup_true_lang-PJQQwYnV.js 2.99 kB │ gzip: 1.45 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/mubiao-dept-node-Drt73fzr.js 3.03 kB │ gzip: 1.25 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/relations-add.vue_vue_type_script_setup_true_lang-C8d76FGV.js 3.04 kB │ gzip: 1.26 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/picker.vue_vue_type_script_setup_true_lang-BIYyttaB.js 3.05 kB │ gzip: 1.50 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/AssignLogPanel-D2r9RPoC.js 3.06 kB │ gzip: 1.48 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/keyword_reply-DBgqHDjZ.js 3.11 kB │ gzip: 1.60 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/edit.vue_vue_type_script_setup_true_lang-DweAr7Ym.js 3.12 kB │ gzip: 1.52 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-BUsM_LI5.js 3.21 kB │ gzip: 1.52 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/edit-CnIVKgZ-.js 3.25 kB │ gzip: 1.47 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/menu-set.vue_vue_type_script_setup_true_lang-BRrEuHV-.js 3.26 kB │ gzip: 1.40 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/pc_details-B_6wZRHo.js 3.39 kB │ gzip: 1.49 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-DqGrHNcO.js 3.39 kB │ gzip: 1.45 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-phcPclpA.js 3.42 kB │ gzip: 1.53 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-CUge-qSO.js 3.43 kB │ gzip: 1.64 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/GancaoSubmissionReconcileButton.vue_vue_type_script_setup_true_lang-BkwnyzI2.js 3.44 kB │ gzip: 1.70 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/balance_details-Ds1LzmaH.js 3.48 kB │ gzip: 1.63 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-DtdgFl7O.js 3.48 kB │ gzip: 1.64 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index.vue_vue_type_script_setup_true_lang-cQgN4IT9.js 3.52 kB │ gzip: 1.56 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/journal-CY6ZpS6p.js 3.73 kB │ gzip: 1.46 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/edit-DKvnMaof.js 3.73 kB │ gzip: 1.57 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/detail-BV7O2HIL.js 3.76 kB │ gzip: 1.55 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/AssistantWatchCallDialog-BuTdyKef.js 3.78 kB │ gzip: 1.85 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/CallRecordPanel-BSTysfqz.js 3.78 kB │ gzip: 1.77 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/edit-bI1lUcbW.js 3.91 kB │ gzip: 1.71 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/preview-pc-BY7HFTA8.js 3.98 kB │ gzip: 1.64 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/AppointmentRecordPanel-BbOdj1-7.js 4.02 kB │ gzip: 1.73 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/edit.vue_vue_type_script_setup_true_lang-C8TwWqpc.js 4.11 kB │ gzip: 1.65 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-CxWRNLqZ.js 4.11 kB │ gzip: 1.79 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/first_visit-CvA69-XM.js 4.13 kB │ gzip: 0.89 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/detail-BRablUBf.js 4.15 kB │ gzip: 1.56 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/MessageBubble-DgY38P4R.js 4.17 kB │ gzip: 1.79 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-Bh5w94E3.js 4.22 kB │ gzip: 1.92 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/useListTimeFilter-COfv9A2s.js 4.26 kB │ gzip: 1.52 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-B2NSVhqr.js 4.37 kB │ gzip: 2.14 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/bind-work-wechat-DP4Gin1a.js 4.40 kB │ gzip: 2.27 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/recharge_record-BzIkkNm9.js 4.43 kB │ gzip: 1.87 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-B4J-ZHfW.js 4.45 kB │ gzip: 1.79 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-BwbCBYM9.js 4.47 kB │ gzip: 1.91 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-exKfRfkT.js 4.49 kB │ gzip: 1.96 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-Bs0vH486.js 4.49 kB │ gzip: 1.96 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/preview-Dh1zljh0.js 4.49 kB │ gzip: 1.70 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/edit.vue_vue_type_script_setup_true_lang-CvCnQtvN.js 4.51 kB │ gzip: 1.75 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/RecordingVideoPlayer-JNdaKBs5.js 4.53 kB │ gzip: 2.26 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/ImChatRecordPanel-6cheoyXp.js 4.54 kB │ gzip: 2.40 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-BLgjrOkJ.js 4.55 kB │ gzip: 1.91 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/paiban-idrEqd0C.js 4.56 kB │ gzip: 2.13 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-DAvdgv_l.js 4.57 kB │ gzip: 1.33 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/login_register-BcBecvF5.js 4.59 kB │ gzip: 2.01 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/dept-tongji-CwUw39uH.js 4.66 kB │ gzip: 2.04 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-C0B1ZpBc.js 4.76 kB │ gzip: 2.05 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/attr.vue_vue_type_script_setup_true_lang-Dozjlzsq.js 4.92 kB │ gzip: 2.04 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/edit-BBC7JIUD.js 5.04 kB │ gzip: 1.99 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/edit.vue_vue_type_script_setup_true_lang-CeStvPlb.js 5.18 kB │ gzip: 2.03 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/PaibanPanel-CwUhJWnn.js 5.28 kB │ gzip: 2.43 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-gh639-1m.js 5.32 kB │ gzip: 2.20 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-rDSQ6cWW.js 5.53 kB │ gzip: 2.20 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/mubiao-CfJ7Ipck.js 5.55 kB │ gzip: 2.56 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/yeji-edit.vue_vue_type_script_setup_true_lang-Dw9aZRHs.js 5.56 kB │ gzip: 1.81 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/mobile-style.vue_vue_type_script_setup_true_lang-BGsSkFG1.js 5.59 kB │ gzip: 1.86 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/ExerciseRecordList-oXdkNbAH.js 5.62 kB │ gzip: 2.15 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/attr-853P--U5.js 5.62 kB │ gzip: 2.24 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/tongji-BTDH_24t.js 5.66 kB │ gzip: 2.31 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/PatientOrderList-Cy0KYlPt.js 5.82 kB │ gzip: 2.84 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/DietRecordList-BeSIO_4H.js 5.84 kB │ gzip: 2.02 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/mubiao-dept-card-C1gPiZEq.js 5.91 kB │ gzip: 2.38 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/login-DJgbVJrh.js 6.03 kB │ gzip: 2.72 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/refund_record-MA_WT5dD.js 6.27 kB │ gzip: 2.33 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/DiagnosisTodoList-CIc3FdV9.js 6.34 kB │ gzip: 2.89 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-D4UoI3lC.js 6.35 kB │ gzip: 3.20 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/information-BZXrACCh.js 6.36 kB │ gzip: 1.81 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/NoteTimeline-B64KEfoD.js 6.48 kB │ gzip: 2.60 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/account_cost-DdPYY7oD.js 6.56 kB │ gzip: 2.86 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/PatientCaseCard-QLnHkNyv.js 6.61 kB │ gzip: 2.34 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-hHC-HY2w.js 6.61 kB │ gzip: 2.62 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-6aflqrhb.js 6.77 kB │ gzip: 2.89 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/readonly-Bc7S11g_.js 6.98 kB │ gzip: 2.69 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/picker-BA72xnJ0.js 7.32 kB │ gzip: 3.18 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/edit.vue_vue_type_script_setup_true_lang-CKaLV_7V.js 7.35 kB │ gzip: 2.66 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/setting-DK-S6SHN.js 7.35 kB │ gzip: 2.93 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/weapp-BaIevkiG.js 7.39 kB │ gzip: 2.13 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/prescription-order-utils-CXfs2Bra.js 7.62 kB │ gzip: 3.11 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-RSmge3tZ.js 7.65 kB │ gzip: 3.13 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/config-h4uBlWvN.js 7.77 kB │ gzip: 2.59 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/TrackingMatrix-Bq97RGBJ.js 7.84 kB │ gzip: 3.07 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/patient-call-CzyPR-gp.js 7.85 kB │ gzip: 3.24 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/dayjs-CF5xpNFg.js 8.07 kB │ gzip: 3.45 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/tcm-DVMUVofm.js 8.99 kB │ gzip: 1.57 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/edit-vUD1ND3r.js 9.00 kB │ gzip: 2.83 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/tim-upload-plugin-B7yxlBgj.js 9.47 kB │ gzip: 3.61 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-BsQ-IFkz.js 9.49 kB │ gzip: 3.84 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/BloodRecordList-lzEwKV5j.js 9.58 kB │ gzip: 3.33 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/medicine-D_iyqZcd.js 9.74 kB │ gzip: 3.55 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/edit-Chzff2sc.js 9.99 kB │ gzip: 3.55 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-CUd56fl0.js 10.06 kB │ gzip: 3.98 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-DfXvRPp3.js 10.33 kB │ gzip: 3.89 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/PrescriptionAiBatchGenerateDialog-DD3meqcJ.js 10.36 kB │ gzip: 4.29 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/SendPanel-DDd8ec_S.js 10.37 kB │ gzip: 3.57 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/progress-DuXbNWsm.js 11.18 kB │ gzip: 4.59 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/list-Cw7NQEcD.js 11.18 kB │ gzip: 4.03 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-DGvtXd9k.js 11.30 kB │ gzip: 4.22 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/appointment-Cm3Hfa5N.js 11.34 kB │ gzip: 4.24 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/edit-BDOteF3G.js 11.44 kB │ gzip: 3.76 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/OrderPanel-Blnuj7Qu.js 11.74 kB │ gzip: 4.22 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/picker-I1e_QZKi.js 12.15 kB │ gzip: 4.49 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/ProgressPanel-2qbsbc6G.js 12.58 kB │ gzip: 4.48 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/prescription-drawer-Dql8RbMo.js 12.76 kB │ gzip: 3.88 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-CcMovsjP.js 13.00 kB │ gzip: 4.23 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-B_Kh6a1u.js 13.11 kB │ gzip: 3.91 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/h5-BpUP9yLV.js 13.83 kB │ gzip: 4.40 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-CWnyJh4D.js 14.26 kB │ gzip: 5.90 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/guahao-DEn7doZO.js 15.06 kB │ gzip: 4.44 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/WecomFloatingWidgetBuilder-CUAXb84X.js 15.22 kB │ gzip: 6.02 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-BrOS-AAf.js 15.62 kB │ gzip: 6.44 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-BRhh-vvk.js 15.90 kB │ gzip: 5.20 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/edit-b7xryZ1R.js 15.90 kB │ gzip: 4.70 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-CMnX8dfi.js 16.34 kB │ gzip: 6.06 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-NRre3yrk.js 16.35 kB │ gzip: 5.11 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-CRhDpVNm.js 16.60 kB │ gzip: 6.07 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/PrescriptionAiReportDialog-DAmQQSVZ.js 17.15 kB │ gzip: 6.46 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-DQb8OBRa.js 17.84 kB │ gzip: 6.02 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/roster-BVq4roST.js 18.09 kB │ gzip: 5.71 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-DXdrxgPI.js 18.19 kB │ gzip: 6.88 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/PromotionAutomationForm-kf-hJGbx.js 18.23 kB │ gzip: 6.37 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-CVeeMncf.js 18.60 kB │ gzip: 5.23 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/WelcomeMessageEditor-D6ufstYr.js 19.11 kB │ gzip: 6.95 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/OrderActionHost-CdrbUm1n.js 21.16 kB │ gzip: 6.02 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-CS6MIRqR.js 21.91 kB │ gzip: 7.87 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/add-C1r7FTAR.js 22.29 kB │ gzip: 5.36 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-BpYt_Hee.js 22.96 kB │ gzip: 8.02 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/DailyMatrix-BPhRn5lY.js 24.97 kB │ gzip: 7.09 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/list_h5-cNTYH_iO.js 27.86 kB │ gzip: 9.43 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/qywx-CJq7NVl4.js 30.35 kB │ gzip: 10.01 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/list-DSAjNXOT.js 30.35 kB │ gzip: 9.74 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/commission-settlement-B1m1GnhE.js 35.83 kB │ gzip: 9.75 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/edit-C-_be8Ci.js 35.86 kB │ gzip: 9.02 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-CmQWbBsG.js 36.70 kB │ gzip: 11.81 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index_h5-BSNbLohA.js 37.92 kB │ gzip: 11.61 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-CnqPWEF7.js 37.98 kB │ gzip: 13.55 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-CAiJ9mjS.js 40.10 kB │ gzip: 11.87 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/PrescriptionOrderDetailDrawer-BwYuljL9.js 48.77 kB │ gzip: 13.48 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-Am7Jc2zt.js 51.46 kB │ gzip: 15.16 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-QkQBeLjo.js 55.03 kB │ gzip: 15.89 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/tim-profanity-filter-plugin-BJ7z5puq.js 55.90 kB │ gzip: 21.04 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-DpMMQbZW.js 69.54 kB │ gzip: 21.09 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/rtc-detect-CAvkmauD.js 74.80 kB │ gzip: 25.93 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/yeji-D8HJelzr.js 87.40 kB │ gzip: 23.27 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-C4cvelAi.js 88.42 kB │ gzip: 25.65 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/order_list-D_i5USUp.js 114.89 kB │ gzip: 31.80 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/order_list_h5-B8Us1G-6.js 139.58 kB │ gzip: 36.82 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/@tencentcloud/chat-uikit-engine-BJz4IWsw.js 162.84 kB │ gzip: 41.72 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/index-C1oKTAGC.js 290.22 kB │ gzip: 92.00 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/@tencentcloud/chat-Bg29VzHQ.js 725.88 kB │ gzip: 178.89 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/@tencentcloud/call-engine-js-CRx4G1jN.js 2,290.42 kB │ gzip: 749.64 kB
|
||||
../artifacts/appointment-modes/admin-build/assets/.pnpm-BHOjf1ZS.js 18,525.72 kB │ gzip: 5,519.46 kB
|
||||
|
||||
WARN
|
||||
(!) Some chunks are larger than 500 kB after minification. Consider:
|
||||
- Using dynamic import() to code-split the application
|
||||
- Use build.rollupOptions.output.manualChunks to improve chunking: https://rollupjs.org/configuration-options/#output-manualchunks
|
||||
- Adjust chunk size limit for this warning via build.chunkSizeWarningLimit.
|
||||
|
||||
✓ built in 1m 53s
|
||||
@@ -1,91 +0,0 @@
|
||||
src/components/chat-dialog/ChatMessageItem.vue(29,7): error TS7022: 'attrs' implicitly has type 'any' because it does not have a type annotation and is referenced directly or indirectly in its own initializer.
|
||||
src/components/editor/index.vue(29,44): error TS7016: Could not find a declaration file for module '@wangeditor/editor-for-vue'. 'D:/web/zyt/admin/node_modules/.pnpm/@wangeditor+editor-for-vue@_d49ef1161b4f4b880c450fdbfe3a0001/node_modules/@wangeditor/editor-for-vue/dist/index.esm.js' implicitly has an 'any' type.
|
||||
There are types at 'D:/web/zyt/admin/node_modules/@wangeditor/editor-for-vue/dist/src/index.d.ts', but this result could not be resolved when respecting package.json "exports". The '@wangeditor/editor-for-vue' library may need to update its package.json or typings.
|
||||
src/components/link/mini-program.vue(11,30): error TS7006: Parameter 'value' implicitly has an 'any' type.
|
||||
src/components/link/mini-program.vue(22,30): error TS7006: Parameter 'value' implicitly has an 'any' type.
|
||||
src/components/link/mini-program.vue(33,30): error TS7006: Parameter 'value' implicitly has an 'any' type.
|
||||
src/components/link/mini-program.vue(48,31): error TS7006: Parameter 'value' implicitly has an 'any' type.
|
||||
src/utils/call-local-recorder.ts(335,26): error TS2339: Property 'captureStream' does not exist on type 'HTMLVideoElement'.
|
||||
src/views/asset/user/index.vue(230,20): error TS2339: Property 'remark' does not exist on type 'never'.
|
||||
src/views/consumer/prescription/index.vue(1952,9): error TS2322: Type '{ name: any; code: any; children: any; }[]' is not assignable to type 'never[]'.
|
||||
Type '{ name: any; code: any; children: any; }' is not assignable to type 'never'.
|
||||
src/views/consumer/prescription/index.vue(1958,13): error TS2322: Type '{ name: string; children: { name: string; children: { name: string; }[]; }[]; }' is not assignable to type 'never'.
|
||||
src/views/consumer/prescription/index.vue(1986,13): error TS2322: Type '{ name: string; children: { name: string; children: { name: string; }[]; }[]; }' is not assignable to type 'never'.
|
||||
src/views/consumer/prescription/index.vue(2001,13): error TS2322: Type '{ name: string; children: { name: string; children: { name: string; }[]; }[]; }' is not assignable to type 'never'.
|
||||
src/views/consumer/prescription/index.vue(2016,13): error TS2322: Type '{ name: string; children: { name: string; children: { name: string; }[]; }[]; }' is not assignable to type 'never'.
|
||||
src/views/consumer/prescription/index.vue(2256,32): error TS2304: Cannot find name 'searchPatientsAPI'.
|
||||
src/views/consumer/prescription/order_list.vue(1939,68): error TS7006: Parameter 'r' implicitly has an 'any' type.
|
||||
src/views/consumer/prescription/order_list.vue(2379,9): error TS2322: Type '{ value: any; label: any; code: any; children: any; }[]' is not assignable to type 'never[]'.
|
||||
Type '{ value: any; label: any; code: any; children: any; }' is not assignable to type 'never'.
|
||||
src/views/consumer/prescription/order_list.vue(3775,13): error TS2322: Type 'string | number | undefined' is not assignable to type 'number | undefined'.
|
||||
Type 'string' is not assignable to type 'number'.
|
||||
src/views/consumer/prescription/order_list_h5.vue(497,96): error TS7006: Parameter 'cmd' implicitly has an 'any' type.
|
||||
src/views/consumer/prescription/order_list_h5.vue(566,47): error TS2345: Argument of type 'Record<string, any>' is not assignable to parameter of type '{ id: number; }'.
|
||||
Property 'id' is missing in type 'Record<string, any>' but required in type '{ id: number; }'.
|
||||
src/views/consumer/prescription/order_list_h5.vue(576,58): error TS2345: Argument of type 'Record<string, any>' is not assignable to parameter of type '{ id: number; }'.
|
||||
Property 'id' is missing in type 'Record<string, any>' but required in type '{ id: number; }'.
|
||||
src/views/consumer/prescription/order_list_h5.vue(586,47): error TS2345: Argument of type 'Record<string, any>' is not assignable to parameter of type '{ id: number; }'.
|
||||
Property 'id' is missing in type 'Record<string, any>' but required in type '{ id: number; }'.
|
||||
src/views/consumer/prescription/order_list_h5.vue(596,59): error TS2345: Argument of type 'Record<string, any>' is not assignable to parameter of type '{ id: number; }'.
|
||||
Property 'id' is missing in type 'Record<string, any>' but required in type '{ id: number; }'.
|
||||
src/views/consumer/prescription/order_list_h5.vue(611,46): error TS2345: Argument of type 'Record<string, any>' is not assignable to parameter of type '{ id: number; express_company?: unknown; tracking_number?: unknown; ship_mode?: unknown; }'.
|
||||
Property 'id' is missing in type 'Record<string, any>' but required in type '{ id: number; express_company?: unknown; tracking_number?: unknown; ship_mode?: unknown; }'.
|
||||
src/views/consumer/prescription/order_list_h5.vue(621,53): error TS2345: Argument of type 'Record<string, any>' is not assignable to parameter of type '{ id: number; diagnosis_id?: number | undefined; pay_order_ids?: number[] | undefined; }'.
|
||||
Property 'id' is missing in type 'Record<string, any>' but required in type '{ id: number; diagnosis_id?: number | undefined; pay_order_ids?: number[] | undefined; }'.
|
||||
src/views/consumer/prescription/order_list_h5.vue(631,53): error TS2345: Argument of type 'Record<string, any>' is not assignable to parameter of type '{ id: number; }'.
|
||||
Property 'id' is missing in type 'Record<string, any>' but required in type '{ id: number; }'.
|
||||
src/views/consumer/prescription/order_list_h5.vue(641,53): error TS2345: Argument of type 'Record<string, any>' is not assignable to parameter of type '{ id: number; }'.
|
||||
Property 'id' is missing in type 'Record<string, any>' but required in type '{ id: number; }'.
|
||||
src/views/consumer/prescription/order_list_h5.vue(2509,68): error TS7006: Parameter 'r' implicitly has an 'any' type.
|
||||
src/views/consumer/prescription/order_list_h5.vue(2848,9): error TS2322: Type '{ value: any; label: any; code: any; children: any; }[]' is not assignable to type 'never[]'.
|
||||
Type '{ value: any; label: any; code: any; children: any; }' is not assignable to type 'never'.
|
||||
src/views/consumer/prescription/order_list_h5.vue(4694,13): error TS2322: Type 'string | number | undefined' is not assignable to type 'number | undefined'.
|
||||
Type 'string' is not assignable to type 'number'.
|
||||
src/views/decoration/component/tabbar/pc/attr.vue(10,18): error TS2345: Argument of type '{ modelValue: any; }' is not assignable to parameter of type '{ readonly modelValue: any[]; readonly max?: number | undefined; readonly min?: number | undefined; readonly itemData: any; readonly "onUpdate:modelValue"?: ((value: any) => any) | undefined; } & VNodeProps & AllowedComponentProps & ComponentCustomProps & Record<...>'.
|
||||
Property 'itemData' is missing in type '{ modelValue: any; }' but required in type '{ readonly modelValue: any[]; readonly max?: number | undefined; readonly min?: number | undefined; readonly itemData: any; readonly "onUpdate:modelValue"?: ((value: any) => any) | undefined; }'.
|
||||
src/views/decoration/component/tabbar/pc/attr.vue(13,18): error TS2345: Argument of type '{ modelValue: any; }' is not assignable to parameter of type '{ readonly modelValue: any[]; readonly max?: number | undefined; readonly min?: number | undefined; readonly itemData: any; readonly "onUpdate:modelValue"?: ((value: any) => any) | undefined; } & VNodeProps & AllowedComponentProps & ComponentCustomProps & Record<...>'.
|
||||
Property 'itemData' is missing in type '{ modelValue: any; }' but required in type '{ readonly modelValue: any[]; readonly max?: number | undefined; readonly min?: number | undefined; readonly itemData: any; readonly "onUpdate:modelValue"?: ((value: any) => any) | undefined; }'.
|
||||
src/views/decoration/component/widgets/middle-banner/content.vue(6,33): error TS2339: Property 'height' does not exist on type '{}'.
|
||||
src/views/doctor/dept-tongji.vue(131,31): error TS7006: Parameter 'depts' implicitly has an 'any' type.
|
||||
src/views/doctor/dept-tongji.vue(132,17): error TS7034: Variable 'result' implicitly has type 'any[]' in some locations where its type cannot be determined.
|
||||
src/views/doctor/dept-tongji.vue(133,27): error TS7006: Parameter 'dept' implicitly has an 'any' type.
|
||||
src/views/doctor/dept-tongji.vue(136,30): error TS7005: Variable 'result' implicitly has an 'any[]' type.
|
||||
src/views/doctor/dept-tongji.vue(139,20): error TS7005: Variable 'result' implicitly has an 'any[]' type.
|
||||
src/views/doctor/tongji.vue(230,48): error TS2769: No overload matches this call.
|
||||
Overload 1 of 3, '(callbackfn: (previousValue: unknown, currentValue: unknown, currentIndex: number, array: unknown[]) => unknown, initialValue: unknown): unknown', gave the following error.
|
||||
Argument of type '(total: number, count: number) => number' is not assignable to parameter of type '(previousValue: unknown, currentValue: unknown, currentIndex: number, array: unknown[]) => unknown'.
|
||||
Types of parameters 'total' and 'previousValue' are incompatible.
|
||||
Type 'unknown' is not assignable to type 'number'.
|
||||
Overload 2 of 3, '(callbackfn: (previousValue: number, currentValue: unknown, currentIndex: number, array: unknown[]) => number, initialValue: number): number', gave the following error.
|
||||
Argument of type '(total: number, count: number) => number' is not assignable to parameter of type '(previousValue: number, currentValue: unknown, currentIndex: number, array: unknown[]) => number'.
|
||||
Types of parameters 'count' and 'currentValue' are incompatible.
|
||||
Type 'unknown' is not assignable to type 'number'.
|
||||
src/views/doctor/tongji.vue(236,51): error TS2769: No overload matches this call.
|
||||
Overload 1 of 3, '(callbackfn: (previousValue: unknown, currentValue: unknown, currentIndex: number, array: unknown[]) => unknown, initialValue: unknown): unknown', gave the following error.
|
||||
Argument of type '(total: number, count: number) => number' is not assignable to parameter of type '(previousValue: unknown, currentValue: unknown, currentIndex: number, array: unknown[]) => unknown'.
|
||||
Types of parameters 'total' and 'previousValue' are incompatible.
|
||||
Type 'unknown' is not assignable to type 'number'.
|
||||
Overload 2 of 3, '(callbackfn: (previousValue: number, currentValue: unknown, currentIndex: number, array: unknown[]) => number, initialValue: number): number', gave the following error.
|
||||
Argument of type '(total: number, count: number) => number' is not assignable to parameter of type '(previousValue: number, currentValue: unknown, currentIndex: number, array: unknown[]) => number'.
|
||||
Types of parameters 'count' and 'currentValue' are incompatible.
|
||||
Type 'unknown' is not assignable to type 'number'.
|
||||
src/views/first_visit/my_patients/components/OrderActionHost.vue(13,28): error TS7006: Parameter 'command' implicitly has an 'any' type.
|
||||
src/views/first_visit/my_patients/components/OrderPanel.vue(202,40): error TS7006: Parameter 'command' implicitly has an 'any' type.
|
||||
src/views/first_visit/wecom_promotion/index.vue(121,43): error TS7006: Parameter 'checked' implicitly has an 'any' type.
|
||||
src/views/first_visit/wecom_promotion/index.vue(217,226): error TS7006: Parameter 'value' implicitly has an 'any' type.
|
||||
src/views/order/index.vue(446,33): error TS2367: This comparison appears to be unintentional because the types '"supplement"' and '"normal"' have no overlap.
|
||||
src/views/order/index.vue(1542,13): error TS2367: This comparison appears to be unintentional because the types '"supplement"' and '"normal"' have no overlap.
|
||||
src/views/tcm/appointment/list.vue(266,69): error TS7006: Parameter 'cmd' implicitly has an 'any' type.
|
||||
src/views/tcm/diagnosis/add.vue(744,49): error TS2349: This expression is not callable.
|
||||
Type 'String' has no call signatures.
|
||||
src/views/tcm/diagnosis/add.vue(772,45): error TS2349: This expression is not callable.
|
||||
Type 'String' has no call signatures.
|
||||
src/views/tcm/diagnosis/components/BloodRecordList.vue(416,5): error TS2322: Type 'string' is not assignable to type 'number'.
|
||||
src/views/tcm/diagnosis/components/RecordingVideoPlayer.vue(145,54): error TS2554: Expected 0 arguments, but got 1.
|
||||
src/views/tcm/diagnosis/components/RecordingVideoPlayer.vue(146,50): error TS2554: Expected 0 arguments, but got 1.
|
||||
src/views/tcm/diagnosis/components/RecordingVideoPlayer.vue(147,47): error TS2554: Expected 0 arguments, but got 1.
|
||||
src/views/tcm/diagnosis/components/RecordingVideoPlayer.vue(148,47): error TS2554: Expected 0 arguments, but got 1.
|
||||
src/views/tcm/diagnosis/components/RecordingVideoPlayer.vue(154,26): error TS2554: Expected 0 arguments, but got 1.
|
||||
src/views/tcm/diagnosis/index.vue(392,69): error TS7006: Parameter 'cmd' implicitly has an 'any' type.
|
||||
src/views/tcm/follow/index.vue(239,69): error TS7006: Parameter 'cmd' implicitly has an 'any' type.
|
||||
src/views/workbench/index.vue(541,18): error TS7006: Parameter 'd' implicitly has an 'any' type.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
Reference in New Issue
Block a user