Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bbe10f647e |
@@ -1,206 +0,0 @@
|
||||
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,7 +9,6 @@ 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()
|
||||
@@ -59,8 +58,6 @@ 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>
|
||||
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
export type FollowupKind = 'diagnosis' | 'blood' | 'diet' | 'exercise' | 'tracking_note'
|
||||
export type FollowupValue = string | number | boolean | null | Array<string | number>
|
||||
export type FollowupValues = Record<string, FollowupValue>
|
||||
export interface FollowupField {
|
||||
key: string
|
||||
label: string
|
||||
type: 'text' | 'number' | 'select' | 'multiselect' | 'date'
|
||||
options?: Array<{ label: string; value: string | number }>
|
||||
readonly?: boolean
|
||||
}
|
||||
export type FollowupFields = Record<FollowupKind, FollowupField[]>
|
||||
export type FollowupAudioChannel = 0 | 1
|
||||
export type FollowupChannelRoles = 'unconfirmed' | 'left_service' | 'right_service'
|
||||
export interface FollowupTranscriptSegment {
|
||||
id: string
|
||||
start_ms: number
|
||||
end_ms: number
|
||||
text: string
|
||||
channel?: FollowupAudioChannel
|
||||
}
|
||||
export interface FollowupCapabilities {
|
||||
enabled: boolean
|
||||
audio_verified: boolean
|
||||
preview_only?: boolean
|
||||
preview_ready?: boolean
|
||||
manual_review_enabled?: boolean
|
||||
manual_review_ready?: boolean
|
||||
can_review?: boolean
|
||||
test_scope_allowed?: boolean
|
||||
can_upload: boolean
|
||||
can_apply: boolean
|
||||
can_daily: boolean
|
||||
models: Array<{ value: string; label: string }>
|
||||
limits: { max_bytes: number; max_seconds: number; chunk_bytes: number }
|
||||
fields: FollowupFields
|
||||
}
|
||||
export interface FollowupReviewItem {
|
||||
id: string
|
||||
kind: FollowupKind
|
||||
values: FollowupValues
|
||||
record_date: string | null
|
||||
record_time: string | null
|
||||
time_period: string | null
|
||||
time_estimated: boolean
|
||||
time_text: string
|
||||
date_text: string
|
||||
evidence: Array<{ text: string; start_ms?: number; end_ms?: number; segment_id?: string; position_type?: 'segment'; channel?: FollowupAudioChannel }>
|
||||
needs_review: boolean
|
||||
selected: boolean
|
||||
target_id: number | null
|
||||
current_values: FollowupValues
|
||||
expected_hash: string
|
||||
conflict: boolean
|
||||
possible_duplicate: boolean
|
||||
candidates?: Array<{
|
||||
id: number
|
||||
values: FollowupValues
|
||||
record_time: string | null
|
||||
time_estimated: boolean
|
||||
time_period: string | null
|
||||
delete_time?: number | null
|
||||
}>
|
||||
}
|
||||
export interface FollowupTaskSummary {
|
||||
id: number
|
||||
diagnosis_id: number
|
||||
file_name: string
|
||||
recorded_at: string
|
||||
status: string
|
||||
stage: string
|
||||
error_code: string
|
||||
error_message: string
|
||||
version: number
|
||||
created_at: number | string
|
||||
expires_at: number | string
|
||||
can_delete?: boolean
|
||||
delete_block_reason?: string
|
||||
can_retry: boolean
|
||||
audio_available: boolean
|
||||
preview_only?: boolean
|
||||
transcript_available?: boolean
|
||||
pipeline_progress?: { transcribed: number; total: number }
|
||||
}
|
||||
export interface FollowupTaskDetail extends FollowupTaskSummary {
|
||||
extraction_revision?: number
|
||||
extraction_channel_roles?: FollowupChannelRoles
|
||||
can_reextract?: boolean
|
||||
reextract_block_reason?: string
|
||||
role_check?: {
|
||||
conflict: boolean
|
||||
suggested_roles: FollowupChannelRoles
|
||||
message: string
|
||||
evidence: Array<{ channel: FollowupAudioChannel; start_ms: number; end_ms: number; text: string }>
|
||||
}
|
||||
summary: string
|
||||
transcript: string
|
||||
transcript_segments?: FollowupTranscriptSegment[]
|
||||
channel_roles?: FollowupChannelRoles
|
||||
uncertainties: unknown[]
|
||||
items: FollowupReviewItem[]
|
||||
fields?: FollowupFields
|
||||
applied_items: unknown[]
|
||||
review_refreshed?: boolean
|
||||
}
|
||||
interface Envelope<T> { code: number; msg?: string; data: T }
|
||||
export class FollowupAudioError extends Error {
|
||||
constructor(message: string, public readonly code = '') { super(message); this.name = 'FollowupAudioError' }
|
||||
}
|
||||
const options = { isTransformResponse: false, ignoreCancelToken: true, isOpenRetry: false, retryCount: 0 }
|
||||
const base = '/tcm.followupAudio/'
|
||||
function unwrap<T>(response: Envelope<T>): T {
|
||||
if (response.code !== 1) {
|
||||
const data = response.data
|
||||
const code = data && typeof data === 'object' && 'code' in data ? String(data.code) : ''
|
||||
throw new FollowupAudioError(response.msg || '回访录音请求失败,请刷新任务状态后确认', code)
|
||||
}
|
||||
return response.data
|
||||
}
|
||||
async function get<T>(name: string, params: Record<string, number>, signal?: AbortSignal): Promise<T> {
|
||||
return unwrap(await request.get<Envelope<T>>({ url: base + name, params, signal }, options))
|
||||
}
|
||||
async function post<T>(name: string, data: unknown, signal?: AbortSignal): Promise<T> {
|
||||
return unwrap(await request.post<Envelope<T>>({ url: base + name, data, signal, timeout: 120000 }, options))
|
||||
}
|
||||
export const followupAudioCapabilities = (diagnosisId: number, signal?: AbortSignal) => get<FollowupCapabilities>('capabilities', { diagnosis_id: diagnosisId }, signal)
|
||||
export const followupAudioLists = (diagnosisId: number, signal?: AbortSignal) => get<{ items: FollowupTaskSummary[] }>('lists', { diagnosis_id: diagnosisId }, signal)
|
||||
export const followupAudioDetail = (id: number, signal?: AbortSignal) => get<FollowupTaskDetail>('detail', { id }, signal)
|
||||
export const followupAudioUploadSession = (data: { diagnosis_id: number; file_name: string; total_bytes: number }, signal?: AbortSignal) => post<{ upload_id: string; chunk_bytes: number }>('uploadSession', data, signal)
|
||||
export async function followupAudioUploadChunk(uploadId: string, index: number, file: Blob, signal?: AbortSignal) {
|
||||
const data = new FormData()
|
||||
data.append('upload_id', uploadId)
|
||||
data.append('index', String(index))
|
||||
data.append('file', file, `chunk-${index}`)
|
||||
return unwrap(await request.post<Envelope<{ index: number; received: boolean }>>({
|
||||
url: base + 'uploadChunk', data, signal, timeout: 120000,
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
}, options))
|
||||
}
|
||||
export const followupAudioUploadComplete = (uploadId: string, signal?: AbortSignal) => post<{ upload_id: string; duration_seconds: number; sha256: string }>('uploadComplete', { upload_id: uploadId }, signal)
|
||||
export const followupAudioCreate = (data: { diagnosis_id: number; upload_id: string; recorded_at: string; model_key: string; channel_roles?: FollowupChannelRoles }, signal?: AbortSignal) => post<{ task_id: number; status: string; reused?: boolean; reuse_message?: string }>('create', data, signal)
|
||||
export const followupAudioSaveDraft = (data: { id: number; version: number; items: FollowupReviewItem[]; channel_roles?: FollowupChannelRoles }, signal?: AbortSignal) => post<FollowupTaskDetail>('saveDraft', data, signal)
|
||||
export const followupAudioApply = (data: { id: number; version: number; items: FollowupReviewItem[] }, signal?: AbortSignal) => post<{ id: number; status: 'applied'; applied_items: unknown[] }>('apply', data, signal)
|
||||
export const followupAudioReextract = (data: { id: number; version: number; channel_roles: Exclude<FollowupChannelRoles, 'unconfirmed'> }, signal?: AbortSignal) => post<FollowupTaskDetail>('reextract', data, signal)
|
||||
export const followupAudioDelete = (data: { id: number; version: number }, signal?: AbortSignal) => post<{ id: number; status: 'deleted'; version: number }>('delete', data, signal)
|
||||
export const followupAudioRetry = (id: number, signal?: AbortSignal) => post<{ id: number; status: string }>('retry', { id }, signal)
|
||||
/** The existing request interceptor puts the token in a header, never in the audio URL. */
|
||||
export async function followupAudioBlob(id: number, signal?: AbortSignal): Promise<Blob> {
|
||||
const blob = await request.get<Blob>({ url: base + 'audio', params: { id }, responseType: 'blob', signal, timeout: 120000 }, options)
|
||||
if (blob.type.includes('json')) {
|
||||
const response: Envelope<unknown> = JSON.parse(await blob.text())
|
||||
unwrap(response)
|
||||
throw new FollowupAudioError('录音响应不是音频文件')
|
||||
}
|
||||
return blob
|
||||
}
|
||||
@@ -1,195 +0,0 @@
|
||||
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 })
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
export interface PrescriptionAiVisibilityConfig {
|
||||
role_ids: number[]
|
||||
admin_ids: number[]
|
||||
}
|
||||
|
||||
export interface PrescriptionAiVisibilityOptions {
|
||||
roles: { id: number; name: string }[]
|
||||
admins: { id: number; name: string; account: string }[]
|
||||
}
|
||||
|
||||
export interface PrescriptionAiCapabilities {
|
||||
agreement_visible: boolean
|
||||
can_manage_visibility: boolean
|
||||
}
|
||||
|
||||
// A failed or malformed read must never become an empty policy that can be saved.
|
||||
function readVisibilityConfig(value: unknown): PrescriptionAiVisibilityConfig {
|
||||
const config = value as Partial<PrescriptionAiVisibilityConfig> | null
|
||||
const validIds = (ids: unknown): ids is number[] =>
|
||||
Array.isArray(ids) && ids.every((id) => Number.isSafeInteger(id) && id > 0)
|
||||
if (!config || !validIds(config.role_ids) || !validIds(config.admin_ids)) {
|
||||
throw new Error('可见范围配置响应格式不正确')
|
||||
}
|
||||
return { role_ids: [...config.role_ids], admin_ids: [...config.admin_ids] }
|
||||
}
|
||||
|
||||
export async function getPrescriptionAiVisibilityConfig() {
|
||||
const data = await request.get({ url: '/tcm.prescriptionAi/getVisibilityConfig' })
|
||||
return readVisibilityConfig(data)
|
||||
}
|
||||
|
||||
export async function setPrescriptionAiVisibilityConfig(params: PrescriptionAiVisibilityConfig) {
|
||||
const data = await request.post({ url: '/tcm.prescriptionAi/setVisibilityConfig', params })
|
||||
return readVisibilityConfig(data)
|
||||
}
|
||||
|
||||
export function getPrescriptionAiVisibilityOptions() {
|
||||
return request.get({
|
||||
url: '/tcm.prescriptionAi/visibilityOptions'
|
||||
}) as Promise<PrescriptionAiVisibilityOptions>
|
||||
}
|
||||
|
||||
export function getPrescriptionAiCapabilities() {
|
||||
return request.get({
|
||||
url: '/tcm.prescriptionAi/capabilities'
|
||||
}) as Promise<PrescriptionAiCapabilities>
|
||||
}
|
||||
@@ -204,26 +204,8 @@ export function commissionSettlementConfirmRevoke(params: Record<string, any>) {
|
||||
return request.post({ url: '/stats.commissionSettlement/confirmRevoke', params })
|
||||
}
|
||||
|
||||
/**
|
||||
* 复诊接诊率口径:
|
||||
* - 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
|
||||
}) {
|
||||
/** 复诊接诊率(按月):当月 N 诊接诊率,部门 → 医助分组 + 合计;剔除「继承」指派 */
|
||||
export function revisitRateOverview(params: { month?: string; dept_ids?: string }) {
|
||||
return request.get({ url: '/stats.revisitRate/overview', params })
|
||||
}
|
||||
|
||||
@@ -232,29 +214,23 @@ 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 诊单数」点击下钻(具体订单明细);slot=0 为全部 N≥2(复诊合计单数),行含 visit_slot */
|
||||
/** 复诊接诊率:「N 诊单数」点击下钻(具体订单明细) */
|
||||
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 })
|
||||
}
|
||||
@@ -277,63 +253,3 @@ 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 })
|
||||
}
|
||||
|
||||
|
||||
/** 独立指派周期统计,与原 revisitRate 按月报表分别调用。 */
|
||||
export type RevisitRateCohortType = 'new' | 'inherit'
|
||||
|
||||
export interface RevisitRateQuery {
|
||||
start_date: string
|
||||
end_date: string
|
||||
dept_ids?: string
|
||||
assistant_id?: number
|
||||
}
|
||||
|
||||
export interface RevisitRateDetailQuery extends RevisitRateQuery {
|
||||
scope_assistant_id?: number
|
||||
dept_id?: number
|
||||
}
|
||||
|
||||
const revisitCohortPrefix = (cohortType: RevisitRateCohortType) =>
|
||||
cohortType === 'inherit' ? '/stats.revisitInheritRate' : '/stats.revisitAssignRate'
|
||||
|
||||
export function revisitCohortOverview(params: RevisitRateQuery, cohortType: RevisitRateCohortType) {
|
||||
return request.get({ url: `${revisitCohortPrefix(cohortType)}/overview`, params })
|
||||
}
|
||||
|
||||
export function revisitCohortDeptOptions(cohortType: RevisitRateCohortType) {
|
||||
return request.get({ url: `${revisitCohortPrefix(cohortType)}/deptOptions` })
|
||||
}
|
||||
|
||||
export function revisitCohortAssignLines(params: RevisitRateDetailQuery, cohortType: RevisitRateCohortType) {
|
||||
return request.get({ url: `${revisitCohortPrefix(cohortType)}/assignLines`, params })
|
||||
}
|
||||
|
||||
export function revisitCohortVisitOrderLines(
|
||||
params: RevisitRateDetailQuery & { slot: number },
|
||||
cohortType: RevisitRateCohortType
|
||||
) {
|
||||
return request.get({ url: `${revisitCohortPrefix(cohortType)}/visitOrderLines`, params })
|
||||
}
|
||||
|
||||
@@ -490,10 +490,8 @@ export function prescriptionOrderPatchPrescriptionUsage(params: {
|
||||
times_per_day: number
|
||||
usage_days: number
|
||||
medication_days: number
|
||||
dosage_bag_count?: number
|
||||
aux_times_per_day?: number
|
||||
aux_usage_days?: number
|
||||
aux_dosage_bag_count?: number
|
||||
}) {
|
||||
return request.post({ url: '/tcm.prescriptionOrder/patchPrescriptionUsage', params })
|
||||
}
|
||||
|
||||
@@ -1,403 +0,0 @@
|
||||
/**
|
||||
* 复诊节点(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(' · ')
|
||||
}
|
||||
@@ -312,17 +312,6 @@
|
||||
{{ detailPrescription.dosage_amount }}{{ detailPrescription.dosage_unit || 'g' }}
|
||||
<template v-if="detailPrescription.prescription_type === '浓缩水丸'">
|
||||
· {{ Number(detailPrescription.dosage_bag_count) > 0 ? Number(detailPrescription.dosage_bag_count) : 1 }}袋
|
||||
<el-button
|
||||
v-if="!readonly && detailData.prescription_id && !String(detailData.prescription_detail_error || '').trim()"
|
||||
v-perms="['tcm.prescriptionOrder/patchPrescriptionUsage']"
|
||||
type="primary"
|
||||
size="small"
|
||||
link
|
||||
class="ml-2"
|
||||
@click="openPatchUsageDialog"
|
||||
>
|
||||
修改
|
||||
</el-button>
|
||||
</template>
|
||||
<span v-if="detailPrescription.prescription_type === '饮片' && detailPrescription.need_decoction !== null" class="ml-2 text-gray-500">
|
||||
({{ detailPrescription.need_decoction ? '代煎' : '不代煎' }})
|
||||
@@ -336,17 +325,6 @@
|
||||
{{ detailAuxUsage.dosage_amount }}{{ detailPrescription.dosage_unit || 'g' }}
|
||||
<template v-if="detailPrescription.prescription_type === '浓缩水丸'">
|
||||
· {{ Number(detailAuxUsage.dosage_bag_count) > 0 ? Number(detailAuxUsage.dosage_bag_count) : 1 }}袋
|
||||
<el-button
|
||||
v-if="!readonly && detailData.prescription_id && !String(detailData.prescription_detail_error || '').trim()"
|
||||
v-perms="['tcm.prescriptionOrder/patchPrescriptionUsage']"
|
||||
type="primary"
|
||||
size="small"
|
||||
link
|
||||
class="ml-2"
|
||||
@click="openPatchUsageDialog"
|
||||
>
|
||||
修改
|
||||
</el-button>
|
||||
</template>
|
||||
<span v-if="detailPrescription.prescription_type === '饮片'" class="ml-2 text-gray-500">
|
||||
({{ detailAuxUsage.need_decoction ? '代煎' : '不代煎' }})
|
||||
@@ -965,15 +943,6 @@
|
||||
label-width="108px"
|
||||
>
|
||||
<div v-if="detailHasAuxHerbs" class="text-xs font-medium text-gray-500 mb-3">主方</div>
|
||||
<el-form-item
|
||||
v-if="detailPrescription?.prescription_type === '浓缩水丸'"
|
||||
label="每次袋数"
|
||||
prop="dosage_bag_count"
|
||||
>
|
||||
<el-select v-model="patchUsageForm.dosage_bag_count" class="w-full">
|
||||
<el-option v-for="count in 5" :key="count" :label="`${count}袋`" :value="count" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="每天次数" prop="times_per_day">
|
||||
<el-input-number
|
||||
v-model="patchUsageForm.times_per_day"
|
||||
@@ -999,15 +968,6 @@
|
||||
</el-form-item>
|
||||
<template v-if="detailHasAuxHerbs">
|
||||
<div class="text-xs font-medium text-gray-500 mb-3 mt-2 pt-2 border-t border-gray-100">辅方</div>
|
||||
<el-form-item
|
||||
v-if="detailPrescription?.prescription_type === '浓缩水丸'"
|
||||
label="每次袋数"
|
||||
prop="aux_dosage_bag_count"
|
||||
>
|
||||
<el-select v-model="patchUsageForm.aux_dosage_bag_count" class="w-full">
|
||||
<el-option v-for="count in 5" :key="count" :label="`${count}袋`" :value="count" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="每天次数" prop="aux_times_per_day">
|
||||
<el-input-number
|
||||
v-model="patchUsageForm.aux_times_per_day"
|
||||
@@ -1472,12 +1432,10 @@ const patchUsageVisible = ref(false)
|
||||
const patchUsageSaving = ref(false)
|
||||
const patchUsageFormRef = ref<FormInstance>()
|
||||
const patchUsageForm = reactive({
|
||||
dosage_bag_count: 1 as number | undefined,
|
||||
times_per_day: 3 as number | undefined,
|
||||
usage_days: 7 as number | undefined,
|
||||
aux_times_per_day: 3 as number | undefined,
|
||||
aux_usage_days: 7 as number | undefined,
|
||||
aux_dosage_bag_count: 1 as number | undefined,
|
||||
medication_days: undefined as number | undefined
|
||||
})
|
||||
const patchUsageRules = computed<FormRules>(() => {
|
||||
@@ -1486,15 +1444,9 @@ const patchUsageRules = computed<FormRules>(() => {
|
||||
usage_days: [{ required: true, message: '请填写主方开立天数', trigger: 'change' }],
|
||||
medication_days: [{ required: true, message: '请填写订单服用天数', trigger: 'change' }]
|
||||
}
|
||||
if (detailPrescription.value?.prescription_type === '浓缩水丸') {
|
||||
rules.dosage_bag_count = [{ required: true, message: '请选择主方每次袋数', trigger: 'change' }]
|
||||
}
|
||||
if (detailHasAuxHerbs.value) {
|
||||
rules.aux_times_per_day = [{ required: true, message: '请填写辅方每天次数', trigger: 'change' }]
|
||||
rules.aux_usage_days = [{ required: true, message: '请填写辅方开立天数', trigger: 'change' }]
|
||||
if (detailPrescription.value?.prescription_type === '浓缩水丸') {
|
||||
rules.aux_dosage_bag_count = [{ required: true, message: '请选择辅方每次袋数', trigger: 'change' }]
|
||||
}
|
||||
}
|
||||
return rules
|
||||
})
|
||||
@@ -1507,7 +1459,6 @@ function openPatchUsageDialog() {
|
||||
return
|
||||
}
|
||||
const aux = detailAuxUsage.value
|
||||
patchUsageForm.dosage_bag_count = Number(rx.dosage_bag_count) > 0 ? Number(rx.dosage_bag_count) : 1
|
||||
patchUsageForm.times_per_day =
|
||||
Number(rx.times_per_day) > 0 ? Number(rx.times_per_day) : 3
|
||||
patchUsageForm.usage_days =
|
||||
@@ -1517,20 +1468,16 @@ function openPatchUsageDialog() {
|
||||
patchUsageForm.aux_usage_days =
|
||||
aux && Number(aux.usage_days) > 0 ? Number(aux.usage_days) : 7
|
||||
const md = Number(ord.medication_days)
|
||||
patchUsageForm.aux_dosage_bag_count =
|
||||
aux && Number(aux.dosage_bag_count) > 0 ? Number(aux.dosage_bag_count) : 1
|
||||
patchUsageForm.medication_days = md > 0 ? md : Number(rx.usage_days) > 0 ? Number(rx.usage_days) : 7
|
||||
patchUsageVisible.value = true
|
||||
nextTick(() => patchUsageFormRef.value?.clearValidate())
|
||||
}
|
||||
|
||||
function resetPatchUsageForm() {
|
||||
patchUsageForm.dosage_bag_count = 1
|
||||
patchUsageForm.times_per_day = 3
|
||||
patchUsageForm.usage_days = 7
|
||||
patchUsageForm.aux_times_per_day = 3
|
||||
patchUsageForm.aux_usage_days = 7
|
||||
patchUsageForm.aux_dosage_bag_count = 1
|
||||
patchUsageForm.medication_days = undefined
|
||||
}
|
||||
|
||||
@@ -1551,25 +1498,17 @@ async function submitPatchUsage() {
|
||||
times_per_day: number
|
||||
usage_days: number
|
||||
medication_days: number
|
||||
dosage_bag_count?: number
|
||||
aux_times_per_day?: number
|
||||
aux_usage_days?: number
|
||||
aux_dosage_bag_count?: number
|
||||
} = {
|
||||
id: ordId,
|
||||
times_per_day: Number(patchUsageForm.times_per_day),
|
||||
usage_days: Number(patchUsageForm.usage_days),
|
||||
medication_days: Number(patchUsageForm.medication_days)
|
||||
}
|
||||
if (detailPrescription.value?.prescription_type === '浓缩水丸') {
|
||||
payload.dosage_bag_count = Number(patchUsageForm.dosage_bag_count)
|
||||
}
|
||||
if (detailHasAuxHerbs.value) {
|
||||
payload.aux_times_per_day = Number(patchUsageForm.aux_times_per_day)
|
||||
payload.aux_usage_days = Number(patchUsageForm.aux_usage_days)
|
||||
if (detailPrescription.value?.prescription_type === '浓缩水丸') {
|
||||
payload.aux_dosage_bag_count = Number(patchUsageForm.aux_dosage_bag_count)
|
||||
}
|
||||
}
|
||||
await prescriptionOrderPatchPrescriptionUsage(payload)
|
||||
feedback.msgSuccess('保存成功')
|
||||
@@ -1824,7 +1763,7 @@ async function refresh() {
|
||||
detailLogisticsExpress.value = String(d.express_company || 'auto') || 'auto'
|
||||
void fetchLogisticsTrace()
|
||||
}
|
||||
if (props.loadRelatedData) await fetchLogs(id)
|
||||
if (props.loadRelatedData) void fetchLogs(id)
|
||||
const diagId = d.diagnosis_id
|
||||
if (props.loadRelatedData && diagId) {
|
||||
void loadDetailUnlinkedPayOrders(diagId, id, d.pay_order_ids || [])
|
||||
|
||||
@@ -883,17 +883,6 @@
|
||||
{{ detailPrescription.dosage_amount }}{{ detailPrescription.dosage_unit || 'g' }}
|
||||
<template v-if="detailPrescription.prescription_type === '浓缩水丸'">
|
||||
· {{ Number(detailPrescription.dosage_bag_count) > 0 ? Number(detailPrescription.dosage_bag_count) : 1 }}袋
|
||||
<el-button
|
||||
v-if="detailData.prescription_id && !String(detailData.prescription_detail_error || '').trim()"
|
||||
v-perms="['tcm.prescriptionOrder/patchPrescriptionUsage']"
|
||||
type="primary"
|
||||
size="small"
|
||||
link
|
||||
class="ml-2"
|
||||
@click="openPatchUsageDialog"
|
||||
>
|
||||
修改
|
||||
</el-button>
|
||||
</template>
|
||||
<span v-if="detailPrescription.prescription_type === '饮片' && detailPrescription.need_decoction !== null" class="ml-2 text-gray-500">
|
||||
({{ detailPrescription.need_decoction ? '代煎' : '不代煎' }})
|
||||
@@ -907,17 +896,6 @@
|
||||
{{ detailAuxUsage.dosage_amount }}{{ detailPrescription.dosage_unit || 'g' }}
|
||||
<template v-if="detailPrescription.prescription_type === '浓缩水丸'">
|
||||
· {{ Number(detailAuxUsage.dosage_bag_count) > 0 ? Number(detailAuxUsage.dosage_bag_count) : 1 }}袋
|
||||
<el-button
|
||||
v-if="detailData.prescription_id && !String(detailData.prescription_detail_error || '').trim()"
|
||||
v-perms="['tcm.prescriptionOrder/patchPrescriptionUsage']"
|
||||
type="primary"
|
||||
size="small"
|
||||
link
|
||||
class="ml-2"
|
||||
@click="openPatchUsageDialog"
|
||||
>
|
||||
修改
|
||||
</el-button>
|
||||
</template>
|
||||
<span v-if="detailPrescription.prescription_type === '饮片'" class="ml-2 text-gray-500">
|
||||
({{ detailAuxUsage.need_decoction ? '代煎' : '不代煎' }})
|
||||
@@ -2283,15 +2261,6 @@
|
||||
label-width="96px"
|
||||
>
|
||||
<div v-if="detailHasAuxHerbs" class="text-xs font-medium text-gray-500 mb-3">主方</div>
|
||||
<el-form-item
|
||||
v-if="detailPrescription?.prescription_type === '浓缩水丸'"
|
||||
label="每次袋数"
|
||||
prop="dosage_bag_count"
|
||||
>
|
||||
<el-select v-model="patchUsageForm.dosage_bag_count" class="w-full">
|
||||
<el-option v-for="count in 5" :key="count" :label="`${count}袋`" :value="count" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="每天次数" prop="times_per_day">
|
||||
<el-input-number
|
||||
v-model="patchUsageForm.times_per_day"
|
||||
@@ -2317,15 +2286,6 @@
|
||||
</el-form-item>
|
||||
<template v-if="detailHasAuxHerbs">
|
||||
<div class="text-xs font-medium text-gray-500 mb-3 mt-2 pt-2 border-t border-gray-100">辅方</div>
|
||||
<el-form-item
|
||||
v-if="detailPrescription?.prescription_type === '浓缩水丸'"
|
||||
label="每次袋数"
|
||||
prop="aux_dosage_bag_count"
|
||||
>
|
||||
<el-select v-model="patchUsageForm.aux_dosage_bag_count" class="w-full">
|
||||
<el-option v-for="count in 5" :key="count" :label="`${count}袋`" :value="count" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="每天次数" prop="aux_times_per_day">
|
||||
<el-input-number
|
||||
v-model="patchUsageForm.aux_times_per_day"
|
||||
@@ -3994,12 +3954,10 @@ const patchUsageVisible = ref(false)
|
||||
const patchUsageSaving = ref(false)
|
||||
const patchUsageFormRef = ref<FormInstance>()
|
||||
const patchUsageForm = reactive({
|
||||
dosage_bag_count: 1 as number | undefined,
|
||||
times_per_day: 3 as number | undefined,
|
||||
usage_days: 7 as number | undefined,
|
||||
aux_times_per_day: 3 as number | undefined,
|
||||
aux_usage_days: 7 as number | undefined,
|
||||
aux_dosage_bag_count: 1 as number | undefined,
|
||||
medication_days: undefined as number | undefined
|
||||
})
|
||||
const patchUsageRules = computed<FormRules>(() => {
|
||||
@@ -4008,15 +3966,9 @@ const patchUsageRules = computed<FormRules>(() => {
|
||||
usage_days: [{ required: true, message: '请填写主方开立天数', trigger: 'change' }],
|
||||
medication_days: [{ required: true, message: '请填写订单服用天数', trigger: 'change' }]
|
||||
}
|
||||
if (detailPrescription.value?.prescription_type === '浓缩水丸') {
|
||||
rules.dosage_bag_count = [{ required: true, message: '请选择主方每次袋数', trigger: 'change' }]
|
||||
}
|
||||
if (detailHasAuxHerbs.value) {
|
||||
rules.aux_times_per_day = [{ required: true, message: '请填写辅方每天次数', trigger: 'change' }]
|
||||
rules.aux_usage_days = [{ required: true, message: '请填写辅方开立天数', trigger: 'change' }]
|
||||
if (detailPrescription.value?.prescription_type === '浓缩水丸') {
|
||||
rules.aux_dosage_bag_count = [{ required: true, message: '请选择辅方每次袋数', trigger: 'change' }]
|
||||
}
|
||||
}
|
||||
return rules
|
||||
})
|
||||
@@ -4029,7 +3981,6 @@ function openPatchUsageDialog() {
|
||||
return
|
||||
}
|
||||
const aux = detailAuxUsage.value
|
||||
patchUsageForm.dosage_bag_count = Number(rx.dosage_bag_count) > 0 ? Number(rx.dosage_bag_count) : 1
|
||||
patchUsageForm.times_per_day =
|
||||
Number(rx.times_per_day) > 0 ? Number(rx.times_per_day) : 3
|
||||
patchUsageForm.usage_days =
|
||||
@@ -4039,20 +3990,16 @@ function openPatchUsageDialog() {
|
||||
patchUsageForm.aux_usage_days =
|
||||
aux && Number(aux.usage_days) > 0 ? Number(aux.usage_days) : 7
|
||||
const md = Number(ord.medication_days)
|
||||
patchUsageForm.aux_dosage_bag_count =
|
||||
aux && Number(aux.dosage_bag_count) > 0 ? Number(aux.dosage_bag_count) : 1
|
||||
patchUsageForm.medication_days = md > 0 ? md : Number(rx.usage_days) > 0 ? Number(rx.usage_days) : 7
|
||||
patchUsageVisible.value = true
|
||||
nextTick(() => patchUsageFormRef.value?.clearValidate())
|
||||
}
|
||||
|
||||
function resetPatchUsageForm() {
|
||||
patchUsageForm.dosage_bag_count = 1
|
||||
patchUsageForm.times_per_day = 3
|
||||
patchUsageForm.usage_days = 7
|
||||
patchUsageForm.aux_times_per_day = 3
|
||||
patchUsageForm.aux_usage_days = 7
|
||||
patchUsageForm.aux_dosage_bag_count = 1
|
||||
patchUsageForm.medication_days = undefined
|
||||
}
|
||||
|
||||
@@ -4073,25 +4020,17 @@ async function submitPatchUsage() {
|
||||
times_per_day: number
|
||||
usage_days: number
|
||||
medication_days: number
|
||||
dosage_bag_count?: number
|
||||
aux_times_per_day?: number
|
||||
aux_usage_days?: number
|
||||
aux_dosage_bag_count?: number
|
||||
} = {
|
||||
id: ordId,
|
||||
times_per_day: Number(patchUsageForm.times_per_day),
|
||||
usage_days: Number(patchUsageForm.usage_days),
|
||||
medication_days: Number(patchUsageForm.medication_days)
|
||||
}
|
||||
if (detailPrescription.value?.prescription_type === '浓缩水丸') {
|
||||
payload.dosage_bag_count = Number(patchUsageForm.dosage_bag_count)
|
||||
}
|
||||
if (detailHasAuxHerbs.value) {
|
||||
payload.aux_times_per_day = Number(patchUsageForm.aux_times_per_day)
|
||||
payload.aux_usage_days = Number(patchUsageForm.aux_usage_days)
|
||||
if (detailPrescription.value?.prescription_type === '浓缩水丸') {
|
||||
payload.aux_dosage_bag_count = Number(patchUsageForm.aux_dosage_bag_count)
|
||||
}
|
||||
}
|
||||
await prescriptionOrderPatchPrescriptionUsage(payload)
|
||||
feedback.msgSuccess('保存成功')
|
||||
|
||||
@@ -1,227 +0,0 @@
|
||||
<template>
|
||||
<div class="prescription-ai-visibility">
|
||||
<el-card v-loading="loading" shadow="never" class="!border-none">
|
||||
<div class="text-xl font-medium mb-[20px]">处方 AI 一致度可见范围</div>
|
||||
<el-alert type="info" :closable="false" show-icon class="mb-5">
|
||||
<template #title>指定用户组或用户可查看一致度</template>
|
||||
<div class="leading-6">
|
||||
用户命中任一指定系统角色,或被单独指定,即可查看桌面端“与 AI
|
||||
一致度”、逐味对照和一致度统计。AI
|
||||
报告继续按原有权限开放,已有报告不受影响,患者与处方的数据范围保持原有规则。
|
||||
</div>
|
||||
</el-alert>
|
||||
|
||||
<el-alert
|
||||
v-if="loadError"
|
||||
:title="loadError"
|
||||
type="error"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="mb-5"
|
||||
/>
|
||||
<div v-if="loading" role="status" class="mb-5">正在读取当前可见范围…</div>
|
||||
|
||||
<el-form
|
||||
v-if="hasLoaded"
|
||||
class="ls-form"
|
||||
:model="formData"
|
||||
:disabled="!ready || loading || saving"
|
||||
label-width="140px"
|
||||
>
|
||||
<el-form-item label="指定用户组" prop="role_ids">
|
||||
<div class="w-full max-w-[640px]">
|
||||
<el-select
|
||||
v-model="formData.role_ids"
|
||||
class="w-full"
|
||||
multiple
|
||||
filterable
|
||||
clearable
|
||||
placeholder="请选择系统角色,可搜索角色名称"
|
||||
>
|
||||
<el-option
|
||||
v-for="role in roleOptions"
|
||||
:key="role.id"
|
||||
:label="role.name"
|
||||
:value="role.id"
|
||||
/>
|
||||
</el-select>
|
||||
<div class="form-tips">账号拥有任一指定角色即可命中。</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="指定用户" prop="admin_ids">
|
||||
<div class="w-full max-w-[640px]">
|
||||
<el-select
|
||||
v-model="formData.admin_ids"
|
||||
class="w-full"
|
||||
multiple
|
||||
filterable
|
||||
clearable
|
||||
placeholder="请选择用户,可搜索姓名或账号"
|
||||
>
|
||||
<el-option
|
||||
v-for="admin in adminOptions"
|
||||
:key="admin.id"
|
||||
:label="admin.label"
|
||||
:value="admin.id"
|
||||
/>
|
||||
</el-select>
|
||||
<div class="form-tips">
|
||||
单独指定的用户无需同时命中用户组。当前不在可选列表中的已配置 ID
|
||||
会保留,移除后保存才会取消该项授权。
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="生效规则">
|
||||
<div>
|
||||
<div class="leading-6">指定用户组与指定用户按“或”生效。</div>
|
||||
<div class="form-tips">
|
||||
两项均为空时,所有账号(包括超级管理员)均隐藏一致度、对照与统计。
|
||||
拥有本页管理权限不自动获得一致度查看权,超级管理员也需命中上述名单。
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-alert
|
||||
v-if="ready && !formData.role_ids.length && !formData.admin_ids.length"
|
||||
title="当前选择为空:保存后所有账号将隐藏一致度、对照与统计,AI 报告仍保留。"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="mb-5"
|
||||
/>
|
||||
<el-alert
|
||||
v-if="saveError"
|
||||
:title="saveError"
|
||||
type="error"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="mb-5"
|
||||
/>
|
||||
<el-button :disabled="loading || saving" @click="loadConfig">
|
||||
{{ loadError ? '重试加载' : '重新加载' }}
|
||||
</el-button>
|
||||
</el-card>
|
||||
|
||||
<footer-btns v-if="canManage">
|
||||
<el-button
|
||||
type="primary"
|
||||
:loading="saving"
|
||||
:disabled="!ready || loading || saving"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
{{ saving ? '正在保存' : '保存' }}
|
||||
</el-button>
|
||||
</footer-btns>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="prescriptionAiVisibility">
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
|
||||
import type {
|
||||
PrescriptionAiVisibilityConfig,
|
||||
PrescriptionAiVisibilityOptions
|
||||
} from '@/api/setting/prescription_ai_visibility'
|
||||
import {
|
||||
getPrescriptionAiCapabilities,
|
||||
getPrescriptionAiVisibilityConfig,
|
||||
getPrescriptionAiVisibilityOptions,
|
||||
setPrescriptionAiVisibilityConfig
|
||||
} from '@/api/setting/prescription_ai_visibility'
|
||||
import feedback from '@/utils/feedback'
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const ready = ref(false)
|
||||
const hasLoaded = ref(false)
|
||||
const canManage = ref(false)
|
||||
const loadError = ref('')
|
||||
const saveError = ref('')
|
||||
const formData = reactive<PrescriptionAiVisibilityConfig>({ role_ids: [], admin_ids: [] })
|
||||
const optionsData = reactive<PrescriptionAiVisibilityOptions>({ roles: [], admins: [] })
|
||||
|
||||
const roleOptions = computed(() => {
|
||||
const options = new Map(optionsData.roles.map((role) => [role.id, role]))
|
||||
for (const id of formData.role_ids) {
|
||||
if (!options.has(id)) {
|
||||
options.set(id, { id, name: `已配置角色 #${id}(当前不在可选列表)` })
|
||||
}
|
||||
}
|
||||
return [...options.values()]
|
||||
})
|
||||
|
||||
const adminOptions = computed(() => {
|
||||
const options = new Map(
|
||||
optionsData.admins.map((admin) => [
|
||||
admin.id,
|
||||
{ id: admin.id, label: `${admin.name || `用户 #${admin.id}`}(${admin.account})` }
|
||||
])
|
||||
)
|
||||
for (const id of formData.admin_ids) {
|
||||
if (!options.has(id)) {
|
||||
options.set(id, { id, label: `已配置用户 #${id}(当前不在可选列表)` })
|
||||
}
|
||||
}
|
||||
return [...options.values()]
|
||||
})
|
||||
|
||||
const assignConfig = (config: PrescriptionAiVisibilityConfig) => {
|
||||
formData.role_ids = [...config.role_ids]
|
||||
formData.admin_ids = [...config.admin_ids]
|
||||
}
|
||||
|
||||
const loadConfig = async () => {
|
||||
if (loading.value || saving.value) return
|
||||
loading.value = true
|
||||
ready.value = false
|
||||
canManage.value = false
|
||||
loadError.value = ''
|
||||
saveError.value = ''
|
||||
try {
|
||||
const capabilities = await getPrescriptionAiCapabilities()
|
||||
if (capabilities.can_manage_visibility !== true) {
|
||||
loadError.value = '当前账号无权管理处方 AI 一致度可见范围。'
|
||||
return
|
||||
}
|
||||
canManage.value = true
|
||||
const [config, options] = await Promise.all([
|
||||
getPrescriptionAiVisibilityConfig(),
|
||||
getPrescriptionAiVisibilityOptions()
|
||||
])
|
||||
if (!Array.isArray(options?.roles) || !Array.isArray(options?.admins)) {
|
||||
throw new Error('可选用户组或用户响应格式不正确')
|
||||
}
|
||||
// Apply both responses together; an incomplete load cannot overwrite the current policy.
|
||||
assignConfig(config)
|
||||
optionsData.roles = options.roles
|
||||
optionsData.admins = options.admins
|
||||
hasLoaded.value = true
|
||||
ready.value = true
|
||||
} catch {
|
||||
loadError.value = '可见范围加载失败,尚未读取完整配置。请重试加载后再保存。'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!ready.value || !canManage.value || loading.value || saving.value) return
|
||||
saving.value = true
|
||||
saveError.value = ''
|
||||
try {
|
||||
const config = await setPrescriptionAiVisibilityConfig({
|
||||
role_ids: [...formData.role_ids],
|
||||
admin_ids: [...formData.admin_ids]
|
||||
})
|
||||
assignConfig(config)
|
||||
feedback.msgSuccess('可见范围已保存')
|
||||
} catch {
|
||||
saveError.value = '保存结果未确认,当前选择已保留。请重试保存,或重新加载以确认服务端策略。'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
void loadConfig()
|
||||
</script>
|
||||
@@ -56,70 +56,6 @@
|
||||
/>
|
||||
<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>
|
||||
|
||||
@@ -127,12 +63,7 @@
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import vCharts from 'vue-echarts'
|
||||
import {
|
||||
assistantPerformanceOverview,
|
||||
assistantPerformanceRevisitRate,
|
||||
type AssistantRevisitRateResult
|
||||
} from '@/api/stats'
|
||||
import { hasPermission } from '@/utils/perm'
|
||||
import { assistantPerformanceOverview } from '@/api/stats'
|
||||
|
||||
const loading = ref(false)
|
||||
const dateRange = ref<string[]>([])
|
||||
@@ -260,78 +191,8 @@ 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>
|
||||
|
||||
@@ -409,57 +270,4 @@ 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,10 +67,7 @@
|
||||
</el-form>
|
||||
<div class="log-hint">
|
||||
由每日定时任务「待分配诊单自动指派」写入:按上月二诊复诊接诊率分档轮询分配(>70% 每人3条/日,60%~70%
|
||||
每人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 前的历史行仅有旧口径率。
|
||||
每人2条/日,50%~60% 每人1条/日,<50% 不分)。「回退」可将已分配医助撤回到自动分配前的原医助(通常为一中心),并写入指派日志。
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
@@ -140,7 +137,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(tierRate(row)) }}</div>
|
||||
<div class="cell-sub">上月二诊接诊率 {{ formatRate(row.visit2_rate) }}</div>
|
||||
</template>
|
||||
<span v-else>—</span>
|
||||
</template>
|
||||
@@ -148,29 +145,6 @@
|
||||
<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 + '轮' : '—' }}
|
||||
@@ -212,7 +186,6 @@
|
||||
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)
|
||||
@@ -268,14 +241,6 @@ 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[]) => {
|
||||
@@ -350,12 +315,5 @@ 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>
|
||||
|
||||
@@ -1,901 +0,0 @@
|
||||
<template>
|
||||
<div class="revisit-rate-page">
|
||||
<el-card class="!border-none" shadow="never">
|
||||
<el-form :inline="true" class="rate-filter-form">
|
||||
<el-form-item :label="dateLabel">
|
||||
<el-date-picker
|
||||
v-model="dateRange"
|
||||
type="daterange"
|
||||
value-format="YYYY-MM-DD"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
range-separator="至"
|
||||
:clearable="false"
|
||||
style="width: 280px"
|
||||
@change="loadData"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="部门">
|
||||
<el-tree-select
|
||||
v-model="deptId"
|
||||
:data="deptTreeOptions"
|
||||
placeholder="二中心(全部)"
|
||||
clearable
|
||||
filterable
|
||||
check-strictly
|
||||
node-key="id"
|
||||
:default-expand-all="true"
|
||||
:props="deptTreeProps"
|
||||
style="width: 210px"
|
||||
@change="handleDeptChange"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="医助">
|
||||
<el-select
|
||||
v-model="assistantId"
|
||||
placeholder="全部医助"
|
||||
filterable
|
||||
clearable
|
||||
:loading="optionsLoading"
|
||||
style="width: 180px"
|
||||
@change="loadData"
|
||||
>
|
||||
<el-option
|
||||
v-for="assistant in assistantOptions"
|
||||
:key="assistant.id"
|
||||
:label="assistant.name"
|
||||
:value="assistant.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="loading" @click="loadData">查询</el-button>
|
||||
<el-button :disabled="loading" @click="handleReset">重置</el-button>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-popover placement="bottom-start" :width="540" trigger="hover">
|
||||
<template #reference>
|
||||
<span class="rate-caliber-trigger"
|
||||
><el-icon><InfoFilled /></el-icon>统计口径</span
|
||||
>
|
||||
</template>
|
||||
<div class="rate-caliber">
|
||||
<p>
|
||||
<b>{{ cohortLabel }}</b
|
||||
>:按所选{{
|
||||
dateLabel
|
||||
}}范围确定患者,订单从对应指派后累计至查询当天;结束日期允许选择未来日期,但未来订单不会提前计入。
|
||||
</p>
|
||||
<p v-if="isInherit">
|
||||
<b>继承诊次重新起算</b>:继承后的第 1 笔有效订单算二诊,第 2
|
||||
笔算三诊,依次递增;不使用继承前的全局诊次,也不叠加诊次偏移。只统计继承患者,不混入新指派患者。
|
||||
</p>
|
||||
<p v-else>
|
||||
只统计新指派患者,不混入继承患者。继承患者在独立的「继承复诊接诊率」页面统计。
|
||||
</p>
|
||||
<p>
|
||||
<b>N 诊接诊率</b> = N 诊单数 ÷ 本页{{ cohortLabel }};分母为 0
|
||||
显示「—」。<b>复诊合计接诊率</b> = 复诊合计单数 ÷ 本页{{
|
||||
cohortLabel
|
||||
}},表示人均复诊成交单数,可超过 100%。
|
||||
</p>
|
||||
<p>
|
||||
订单仅计入有效业务订单,剔除已取消 / 拒收 /
|
||||
退款;归属按服务端的患者持有记录计算。医助按人事部门归组;仅统计「二中心」及其组织下级,选定部门时包含其下级。
|
||||
</p>
|
||||
<p>
|
||||
部门行和合计行按诊单去重,不能直接将医助行相加。灰字为「单数 /
|
||||
分母」。
|
||||
</p>
|
||||
</div>
|
||||
</el-popover>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<el-alert class="mt-3" type="info" :closable="false" show-icon :title="reportHint" />
|
||||
<el-alert
|
||||
v-if="optionsError"
|
||||
class="mt-3"
|
||||
type="warning"
|
||||
:title="optionsError"
|
||||
:closable="false"
|
||||
/>
|
||||
<el-alert
|
||||
v-if="queryError"
|
||||
class="mt-3"
|
||||
type="error"
|
||||
:title="queryError"
|
||||
:closable="false"
|
||||
/>
|
||||
</el-card>
|
||||
|
||||
<div class="rate-kpi-grid">
|
||||
<div
|
||||
v-for="card in summaryCards"
|
||||
:key="card.key"
|
||||
class="rate-kpi-card"
|
||||
:class="{ 'is-clickable': card.clickable }"
|
||||
@click="card.clickable && card.onClick()"
|
||||
>
|
||||
<div class="rate-kpi-label">{{ card.label }}</div>
|
||||
<div class="rate-kpi-value">{{ card.value }}</div>
|
||||
<div v-if="card.sub" class="rate-kpi-sub">{{ card.sub }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card class="!border-none mt-4" shadow="never">
|
||||
<template #header>
|
||||
<div class="rate-card-header">
|
||||
<span>{{ reportTitle }} · 部门 / 医助明细</span>
|
||||
<span class="rate-card-hint">{{ dateLabel }}:{{ rangeText }}</span>
|
||||
<span v-if="observedUntil" class="rate-card-hint"
|
||||
>订单统计截至:{{ observedUntil }}</span
|
||||
>
|
||||
</div>
|
||||
<div class="rate-card-hint mt-2">
|
||||
点击{{ cohortLabel }}、各诊次单数可查看患者与订单;底栏合计按诊单去重。
|
||||
</div>
|
||||
</template>
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
:data="rows"
|
||||
size="small"
|
||||
border
|
||||
row-key="row_key"
|
||||
:tree-props="{ children: 'children' }"
|
||||
:default-expand-all="true"
|
||||
show-summary
|
||||
:summary-method="getSummaries"
|
||||
>
|
||||
<el-table-column label="部门 / 医助" min-width="200" fixed>
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.is_dept" class="rate-dept-cell"
|
||||
>{{ row.dept_name
|
||||
}}<span class="rate-dept-count"
|
||||
>{{ row.assistant_count }} 人</span
|
||||
></span
|
||||
>
|
||||
<span v-else>{{ row.assistant_name }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
:prop="cohortCountKey"
|
||||
:label="cohortLabel"
|
||||
min-width="100"
|
||||
align="right"
|
||||
>
|
||||
<template #header>
|
||||
<span>{{ cohortLabel }}</span>
|
||||
<el-tooltip
|
||||
effect="dark"
|
||||
placement="top"
|
||||
:content="`所选${dateLabel}范围内的${cohortLabel},部门和合计按诊单去重`"
|
||||
><el-icon class="rate-th-hint"><InfoFilled /></el-icon
|
||||
></el-tooltip>
|
||||
</template>
|
||||
<template #default="{ row }">
|
||||
<el-link
|
||||
v-if="canViewAssignLines && row[cohortCountKey] > 0"
|
||||
type="primary"
|
||||
:underline="false"
|
||||
@click="openAssignDetail(row)"
|
||||
>{{ row[cohortCountKey] }}</el-link
|
||||
>
|
||||
<span v-else>{{ row[cohortCountKey] ?? 0 }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template v-for="slot in visitSlots" :key="slot">
|
||||
<el-table-column
|
||||
:prop="`visit${slot}_count`"
|
||||
:label="`${slotLabel(slot)}单数`"
|
||||
min-width="96"
|
||||
align="right"
|
||||
>
|
||||
<template #header>
|
||||
<span>{{ slotLabel(slot) }}单数</span>
|
||||
<el-tooltip effect="dark" placement="top" :content="slotCountTip(slot)"
|
||||
><el-icon class="rate-th-hint"><InfoFilled /></el-icon
|
||||
></el-tooltip>
|
||||
</template>
|
||||
<template #default="{ row }">
|
||||
<el-link
|
||||
v-if="canViewOrderLines && row[`visit${slot}_count`] > 0"
|
||||
type="primary"
|
||||
:underline="false"
|
||||
@click="openOrderDetail(slot, row)"
|
||||
>{{ row[`visit${slot}_count`] }}</el-link
|
||||
>
|
||||
<span v-else>{{ row[`visit${slot}_count`] ?? 0 }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
:prop="`visit${slot}_rate`"
|
||||
:label="`${slotLabel(slot)}接诊率`"
|
||||
min-width="110"
|
||||
align="right"
|
||||
>
|
||||
<template #header>
|
||||
<span>{{ slotLabel(slot) }}接诊率</span>
|
||||
<el-tooltip
|
||||
effect="dark"
|
||||
placement="top"
|
||||
:content="`${slotLabel(slot)}单数 ÷ ${cohortLabel};分母为 0 显示「—」`"
|
||||
><el-icon class="rate-th-hint"><InfoFilled /></el-icon
|
||||
></el-tooltip>
|
||||
</template>
|
||||
<template #default="{ row }">
|
||||
<div class="rate-cell">
|
||||
<span class="rate-cell-main">{{
|
||||
formatRate(row[`visit${slot}_rate`])
|
||||
}}</span
|
||||
><span class="rate-cell-sub">{{
|
||||
denText(row, `visit${slot}`)
|
||||
}}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</template>
|
||||
<el-table-column
|
||||
prop="visit_all_count"
|
||||
label="复诊合计单数"
|
||||
min-width="110"
|
||||
align="right"
|
||||
>
|
||||
<template #header>
|
||||
<span>复诊合计单数</span>
|
||||
<el-tooltip
|
||||
effect="dark"
|
||||
placement="top"
|
||||
content="所选患者从对应指派后累计至查询当天的全部复诊订单数"
|
||||
><el-icon class="rate-th-hint"><InfoFilled /></el-icon
|
||||
></el-tooltip>
|
||||
</template>
|
||||
<template #default="{ row }">
|
||||
<el-link
|
||||
v-if="canViewOrderLines && row.visit_all_count > 0"
|
||||
type="primary"
|
||||
:underline="false"
|
||||
@click="openOrderDetail(0, row)"
|
||||
>{{ row.visit_all_count }}</el-link
|
||||
>
|
||||
<span v-else>{{ row.visit_all_count ?? 0 }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
prop="visit_all_rate"
|
||||
label="复诊合计接诊率"
|
||||
min-width="128"
|
||||
align="right"
|
||||
>
|
||||
<template #header>
|
||||
<span>复诊合计接诊率</span>
|
||||
<el-tooltip
|
||||
effect="dark"
|
||||
placement="top"
|
||||
:content="`复诊合计单数 ÷ ${cohortLabel};表示人均复诊成交单数,可超过 100%`"
|
||||
><el-icon class="rate-th-hint"><InfoFilled /></el-icon
|
||||
></el-tooltip>
|
||||
</template>
|
||||
<template #default="{ row }">
|
||||
<div class="rate-cell">
|
||||
<span class="rate-cell-main">{{ formatRate(row.visit_all_rate) }}</span
|
||||
><span class="rate-cell-sub">{{ denText(row, 'visit_all') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<el-dialog
|
||||
v-model="detailVisible"
|
||||
:title="detailTitle"
|
||||
width="1080px"
|
||||
top="6vh"
|
||||
destroy-on-close
|
||||
@close="invalidateDetail"
|
||||
>
|
||||
<p class="rate-detail-hint">{{ detailHint }}</p>
|
||||
<el-alert
|
||||
v-if="detailError"
|
||||
class="mb-3"
|
||||
type="error"
|
||||
:title="detailError"
|
||||
:closable="false"
|
||||
/>
|
||||
<el-table
|
||||
v-loading="detailLoading"
|
||||
:data="detailRows"
|
||||
size="small"
|
||||
border
|
||||
stripe
|
||||
max-height="60vh"
|
||||
>
|
||||
<template v-if="detailType === 'assign'">
|
||||
<el-table-column prop="diagnosis_id" label="诊单ID" width="90" />
|
||||
<el-table-column prop="patient_name" label="患者" min-width="100">
|
||||
<template #default="{ row }">
|
||||
<el-link
|
||||
v-if="canViewPatientDetail && row.diagnosis_id"
|
||||
type="primary"
|
||||
:underline="false"
|
||||
@click="openPatientDetail(row.diagnosis_id)"
|
||||
>{{ row.patient_name || `诊单#${row.diagnosis_id}` }}</el-link
|
||||
>
|
||||
<span v-else>{{ row.patient_name || '—' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="patient_phone" label="联系电话" min-width="120"
|
||||
><template #default="{ row }">{{
|
||||
row.patient_phone || '—'
|
||||
}}</template></el-table-column
|
||||
>
|
||||
<el-table-column
|
||||
prop="assistant_names"
|
||||
:label="isInherit ? '继承医助' : '被指派医助'"
|
||||
min-width="140"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column
|
||||
prop="assign_count"
|
||||
:label="isInherit ? '范围内继承次数' : '范围内指派次数'"
|
||||
width="120"
|
||||
align="right"
|
||||
/>
|
||||
<el-table-column
|
||||
prop="first_assign_time_text"
|
||||
:label="isInherit ? '首次继承时间' : '首次指派时间'"
|
||||
min-width="160"
|
||||
/>
|
||||
<el-table-column
|
||||
prop="last_assign_time_text"
|
||||
:label="isInherit ? '最近继承时间' : '最近指派时间'"
|
||||
min-width="160"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-table-column
|
||||
prop="order_no"
|
||||
label="订单号"
|
||||
min-width="180"
|
||||
show-overflow-tooltip
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<el-link
|
||||
v-if="canViewOrderDetail && row.order_id"
|
||||
type="primary"
|
||||
:underline="false"
|
||||
@click="openOrderInfo(row.order_id)"
|
||||
>{{ row.order_no || `#${row.order_id}` }}</el-link
|
||||
>
|
||||
<span v-else>{{ row.order_no || `#${row.order_id}` }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
v-if="detailSlot === 0 || isInherit"
|
||||
prop="visit_slot"
|
||||
:label="isInherit ? '继承后诊次' : '诊次'"
|
||||
width="100"
|
||||
align="center"
|
||||
><template #default="{ row }">{{
|
||||
slotLabel(Number(row.visit_slot))
|
||||
}}</template></el-table-column
|
||||
>
|
||||
<el-table-column
|
||||
v-if="isInherit"
|
||||
prop="global_visit_slot"
|
||||
label="全局诊次(参考)"
|
||||
width="130"
|
||||
align="center"
|
||||
><template #default="{ row }">{{
|
||||
row.global_visit_slot ? slotLabel(Number(row.global_visit_slot)) : '—'
|
||||
}}</template></el-table-column
|
||||
>
|
||||
<el-table-column prop="diagnosis_id" label="诊单ID" width="90" />
|
||||
<el-table-column prop="patient_name" label="患者" min-width="100">
|
||||
<template #default="{ row }">
|
||||
<el-link
|
||||
v-if="canViewPatientDetail && row.diagnosis_id"
|
||||
type="primary"
|
||||
:underline="false"
|
||||
@click="openPatientDetail(row.diagnosis_id)"
|
||||
>{{ row.patient_name || `诊单#${row.diagnosis_id}` }}</el-link
|
||||
>
|
||||
<span v-else>{{ row.patient_name || '—' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="patient_phone" label="联系电话" min-width="120"
|
||||
><template #default="{ row }">{{
|
||||
row.patient_phone || '—'
|
||||
}}</template></el-table-column
|
||||
>
|
||||
<el-table-column prop="amount" label="金额" width="100" align="right"
|
||||
><template #default="{ row }">¥ {{ row.amount }}</template></el-table-column
|
||||
>
|
||||
<el-table-column
|
||||
prop="cohort_assign_time_text"
|
||||
:label="isInherit ? '继承时间' : '指派时间'"
|
||||
min-width="160"
|
||||
/>
|
||||
<el-table-column prop="create_time_text" label="下单时间" min-width="160" />
|
||||
<el-table-column prop="assistant_name" label="持有医助" min-width="100" />
|
||||
<el-table-column prop="creator_name" label="订单创建人" min-width="100" />
|
||||
</template>
|
||||
</el-table>
|
||||
<div class="rate-detail-footer">共 {{ detailCount }} 条</div>
|
||||
</el-dialog>
|
||||
<order-detail-drawer ref="orderDetailRef" readonly append-to-body />
|
||||
<diagnosis-edit-popup ref="diagnosisEditRef" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, defineAsyncComponent, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { InfoFilled } from '@element-plus/icons-vue'
|
||||
import {
|
||||
revisitCohortAssignLines,
|
||||
revisitCohortDeptOptions,
|
||||
revisitCohortOverview,
|
||||
revisitCohortVisitOrderLines,
|
||||
type RevisitRateCohortType,
|
||||
type RevisitRateDetailQuery,
|
||||
type RevisitRateQuery
|
||||
} from '@/api/stats'
|
||||
import { hasPermission } from '@/utils/perm'
|
||||
|
||||
const props = defineProps<{ cohortType: RevisitRateCohortType }>()
|
||||
const OrderDetailDrawer = defineAsyncComponent(
|
||||
() => import('@/views/consumer/prescription/components/PrescriptionOrderDetailDrawer.vue')
|
||||
)
|
||||
const DiagnosisEditPopup = defineAsyncComponent(() => import('@/views/tcm/diagnosis/edit.vue'))
|
||||
|
||||
interface MetricPack extends Record<string, any> {
|
||||
assigned_count: number
|
||||
inherit_count: number
|
||||
assigned_total_count: number
|
||||
visit_all_count: number
|
||||
visit_all_den: number
|
||||
visit_all_rate: number | null
|
||||
}
|
||||
interface StatsRow extends MetricPack {
|
||||
row_key: string
|
||||
is_dept: 0 | 1
|
||||
dept_id: number
|
||||
dept_name: string
|
||||
assistant_id?: number
|
||||
assistant_name?: string
|
||||
assistant_count?: number
|
||||
children?: StatsRow[]
|
||||
}
|
||||
interface DeptOption {
|
||||
id: number
|
||||
pid: number
|
||||
name: string
|
||||
}
|
||||
interface DeptTreeNode extends DeptOption {
|
||||
children?: DeptTreeNode[]
|
||||
}
|
||||
interface AssistantOption {
|
||||
id: number
|
||||
name: string
|
||||
dept_id: number
|
||||
}
|
||||
interface SummaryCard {
|
||||
key: string
|
||||
label: string
|
||||
value: string
|
||||
sub: string
|
||||
clickable: boolean
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
const isInherit = computed(() => props.cohortType === 'inherit')
|
||||
const cohortLabel = computed(() => (isInherit.value ? '继承数' : '新指派数'))
|
||||
const cohortCountKey = computed(() => (isInherit.value ? 'inherit_count' : 'assigned_count'))
|
||||
const dateLabel = computed(() => (isInherit.value ? '继承日期' : '指派日期'))
|
||||
const reportTitle = computed(() => (isInherit.value ? '继承复诊接诊率' : '按指派周期复诊率'))
|
||||
const reportHint = computed(() =>
|
||||
isInherit.value
|
||||
? '按继承日期范围选择患者,订单累计至查询当天。继承后第 1 笔有效订单算二诊,第 2 笔算三诊;忽略继承前全局诊次及偏移,新指派患者另页统计。'
|
||||
: '按新指派日期范围选择患者,订单累计至查询当天,跨月继续累计。继承患者在独立的「继承复诊接诊率」页面统计。'
|
||||
)
|
||||
const permissionPrefix = computed(() =>
|
||||
isInherit.value ? 'stats.revisitInheritRate' : 'stats.revisitAssignRate'
|
||||
)
|
||||
const canViewAssignLines = computed(() => hasPermission([`${permissionPrefix.value}/assignLines`]))
|
||||
const canViewOrderLines = computed(() =>
|
||||
hasPermission([`${permissionPrefix.value}/visitOrderLines`])
|
||||
)
|
||||
const canViewOrderDetail = hasPermission(['tcm.prescriptionOrder/detail'])
|
||||
const canViewPatientDetail = hasPermission(['tcm.diagnosis/detail'])
|
||||
|
||||
const formatDate = (date: Date) =>
|
||||
`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
|
||||
const defaultDateRange = (): [string, string] => {
|
||||
const now = new Date()
|
||||
return [formatDate(new Date(now.getFullYear(), now.getMonth(), 1)), formatDate(now)]
|
||||
}
|
||||
const dateRange = ref<[string, string]>(defaultDateRange())
|
||||
const deptId = ref<number | undefined>()
|
||||
const assistantId = ref<number | undefined>()
|
||||
const loading = ref(false)
|
||||
const queryError = ref('')
|
||||
const rows = ref<StatsRow[]>([])
|
||||
const total = ref<MetricPack | null>(null)
|
||||
const visitSlots = ref<number[]>([2, 3, 4])
|
||||
const observedUntil = ref('')
|
||||
/** 下钻只复用已展示报表的查询快照,避免读取正在编辑的筛选。 */
|
||||
const loadedQuery = ref<RevisitRateQuery | null>(null)
|
||||
let overviewRequestId = 0
|
||||
let disposed = false
|
||||
const rangeText = computed(() =>
|
||||
loadedQuery.value
|
||||
? `${loadedQuery.value.start_date} 至 ${loadedQuery.value.end_date}`
|
||||
: `${dateRange.value[0]} 至 ${dateRange.value[1]}`
|
||||
)
|
||||
const currentQuery = (): RevisitRateQuery => ({
|
||||
start_date: dateRange.value[0],
|
||||
end_date: dateRange.value[1],
|
||||
dept_ids: deptId.value ? String(deptId.value) : '',
|
||||
assistant_id: assistantId.value || undefined
|
||||
})
|
||||
|
||||
const deptOptions = ref<DeptOption[]>([])
|
||||
const allAssistants = ref<AssistantOption[]>([])
|
||||
const optionsLoading = ref(false)
|
||||
const optionsError = ref('')
|
||||
const deptTreeProps = { value: 'id', label: 'name', children: 'children' }
|
||||
const deptTreeOptions = computed<DeptTreeNode[]>(() => {
|
||||
const nodes = new Map<number, DeptTreeNode>()
|
||||
deptOptions.value.forEach((item) => nodes.set(item.id, { ...item, children: [] }))
|
||||
const roots: DeptTreeNode[] = []
|
||||
nodes.forEach((node) => {
|
||||
const parent = nodes.get(node.pid)
|
||||
if (parent && parent !== node) parent.children!.push(node)
|
||||
else roots.push(node)
|
||||
})
|
||||
const stripEmptyChildren = (node: DeptTreeNode) => {
|
||||
if (node.children?.length) node.children.forEach(stripEmptyChildren)
|
||||
else delete node.children
|
||||
}
|
||||
roots.forEach(stripEmptyChildren)
|
||||
return roots
|
||||
})
|
||||
const selectedDeptIds = computed(() => {
|
||||
if (!deptId.value) return null
|
||||
const ids = new Set<number>([Number(deptId.value)])
|
||||
let expanded = true
|
||||
while (expanded) {
|
||||
expanded = false
|
||||
deptOptions.value.forEach((item) => {
|
||||
if (ids.has(item.pid) && !ids.has(item.id)) {
|
||||
ids.add(item.id)
|
||||
expanded = true
|
||||
}
|
||||
})
|
||||
}
|
||||
return ids
|
||||
})
|
||||
const assistantOptions = computed(() =>
|
||||
allAssistants.value.filter(
|
||||
(item) => !selectedDeptIds.value || selectedDeptIds.value.has(item.dept_id)
|
||||
)
|
||||
)
|
||||
const handleDeptChange = () => {
|
||||
if (assistantId.value && !assistantOptions.value.some((item) => item.id === assistantId.value))
|
||||
assistantId.value = undefined
|
||||
void loadData()
|
||||
}
|
||||
const loadDeptOptions = async () => {
|
||||
optionsLoading.value = true
|
||||
optionsError.value = ''
|
||||
try {
|
||||
const res = await revisitCohortDeptOptions(props.cohortType)
|
||||
if (disposed) return
|
||||
deptOptions.value = (res?.rows ?? []).map((item: DeptOption) => ({
|
||||
...item,
|
||||
id: Number(item.id),
|
||||
pid: Number(item.pid)
|
||||
}))
|
||||
allAssistants.value = (res?.assistants ?? []).map((item: AssistantOption) => ({
|
||||
...item,
|
||||
id: Number(item.id),
|
||||
dept_id: Number(item.dept_id)
|
||||
}))
|
||||
} catch {
|
||||
if (!disposed) optionsError.value = '部门及医助选项加载失败,请刷新页面重试。'
|
||||
} finally {
|
||||
if (!disposed) optionsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const cnDigits = ['零', '一', '二', '三', '四', '五', '六', '七', '八', '九', '十']
|
||||
const slotLabel = (slot: number) => {
|
||||
if (slot === 0) return '复诊合计'
|
||||
if (slot >= 1 && slot <= 10) return `${cnDigits[slot]}诊`
|
||||
if (slot > 10 && slot < 20) return `十${cnDigits[slot - 10]}诊`
|
||||
return `${slot}诊`
|
||||
}
|
||||
const slotCountTip = (slot: number) =>
|
||||
isInherit.value
|
||||
? `所选继承患者在继承后的第 ${slot - 1} 笔有效订单数,累计至查询当天;不使用继承前全局诊次和偏移`
|
||||
: `所选新指派患者的${slotLabel(slot)}有效订单数,从对应指派后累计至查询当天`
|
||||
const formatRate = (rate: number | null | undefined) =>
|
||||
rate === null || rate === undefined ? '—' : `${rate}%`
|
||||
const denText = (row: Record<string, any>, prefix: string) =>
|
||||
`${Number(row[`${prefix}_count`] ?? 0)} / ${row[`${prefix}_den`] ?? '—'}`
|
||||
const summaryCards = computed<SummaryCard[]>(() => {
|
||||
const t = total.value
|
||||
const cards: SummaryCard[] = [
|
||||
{
|
||||
key: 'cohort',
|
||||
label: cohortLabel.value,
|
||||
value: t ? String(t[cohortCountKey.value] ?? 0) : '—',
|
||||
sub: `按${dateLabel.value}范围选择患者`,
|
||||
clickable: !!(canViewAssignLines.value && t && t[cohortCountKey.value] > 0),
|
||||
onClick: () => void openAssignDetail(null)
|
||||
}
|
||||
]
|
||||
visitSlots.value.forEach((slot) =>
|
||||
cards.push({
|
||||
key: `visit${slot}`,
|
||||
label: `${slotLabel(slot)}接诊率`,
|
||||
value: t ? formatRate(t[`visit${slot}_rate`]) : '—',
|
||||
sub: t
|
||||
? `${slotLabel(slot)}下单 ${t[`visit${slot}_count`] ?? 0} 单 / 分母 ${t[`visit${slot}_den`] ?? '—'}`
|
||||
: '',
|
||||
clickable: !!(canViewOrderLines.value && t && t[`visit${slot}_count`] > 0),
|
||||
onClick: () => void openOrderDetail(slot, null)
|
||||
})
|
||||
)
|
||||
cards.push({
|
||||
key: 'visit_all',
|
||||
label: '复诊合计接诊率',
|
||||
value: t ? formatRate(t.visit_all_rate) : '—',
|
||||
sub: t ? `复诊下单 ${t.visit_all_count ?? 0} 单 / 分母 ${t.visit_all_den ?? '—'}` : '',
|
||||
clickable: !!(canViewOrderLines.value && t && t.visit_all_count > 0),
|
||||
onClick: () => void openOrderDetail(0, null)
|
||||
})
|
||||
return cards
|
||||
})
|
||||
/** 服务端已按诊单去重,合计不能由各医助行相加。 */
|
||||
const getSummaries = ({ columns }: { columns: Array<{ property?: string }> }) =>
|
||||
columns.map((column, index) => {
|
||||
if (index === 0) return '合计'
|
||||
const t = total.value
|
||||
if (!t) return ''
|
||||
const prop = column.property || ''
|
||||
if (prop === cohortCountKey.value) return String(t[prop] ?? 0)
|
||||
const match = /^visit(\d+|_all)_(count|rate)$/.exec(prop)
|
||||
if (!match) return ''
|
||||
return match[2] === 'rate'
|
||||
? `${formatRate(t[prop])}(${denText(t, `visit${match[1]}`)})`
|
||||
: String(t[prop] ?? 0)
|
||||
})
|
||||
|
||||
const loadData = async () => {
|
||||
const requestId = ++overviewRequestId
|
||||
invalidateDetail()
|
||||
detailVisible.value = false
|
||||
loadedQuery.value = null
|
||||
rows.value = []
|
||||
total.value = null
|
||||
observedUntil.value = ''
|
||||
queryError.value = ''
|
||||
loading.value = true
|
||||
const query = currentQuery()
|
||||
try {
|
||||
const res = await revisitCohortOverview(query, props.cohortType)
|
||||
if (disposed || requestId !== overviewRequestId) return
|
||||
rows.value = res?.rows ?? []
|
||||
total.value = res?.total ?? null
|
||||
observedUntil.value = res?.observed_until ?? ''
|
||||
const slots: number[] = Array.isArray(res?.slots) ? res.slots.map(Number) : []
|
||||
visitSlots.value = slots.length
|
||||
? [
|
||||
...new Set(
|
||||
slots.filter((slot) => Number.isInteger(slot) && slot >= 2 && slot <= 50)
|
||||
)
|
||||
].sort((a, b) => a - b)
|
||||
: [2, 3, 4]
|
||||
loadedQuery.value = { ...query }
|
||||
} catch {
|
||||
if (!disposed && requestId === overviewRequestId)
|
||||
queryError.value = '报表加载失败,请重新查询。'
|
||||
} finally {
|
||||
if (!disposed && requestId === overviewRequestId) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const detailVisible = ref(false)
|
||||
const detailType = ref<'assign' | 'orders'>('assign')
|
||||
const detailTitle = ref('')
|
||||
const detailHint = ref('')
|
||||
const detailLoading = ref(false)
|
||||
const detailError = ref('')
|
||||
const detailRows = ref<any[]>([])
|
||||
const detailCount = ref(0)
|
||||
const detailSlot = ref(2)
|
||||
let detailRequestId = 0
|
||||
const invalidateDetail = () => {
|
||||
++detailRequestId
|
||||
detailLoading.value = false
|
||||
detailRows.value = []
|
||||
detailCount.value = 0
|
||||
detailError.value = ''
|
||||
}
|
||||
const rowScope = (
|
||||
row: StatsRow | null
|
||||
): Pick<RevisitRateDetailQuery, 'scope_assistant_id' | 'dept_id'> & { label: string } => {
|
||||
if (!row) return { label: '合计' }
|
||||
return row.is_dept
|
||||
? { dept_id: row.dept_id, label: row.dept_name }
|
||||
: { scope_assistant_id: row.assistant_id, label: row.assistant_name || '' }
|
||||
}
|
||||
const openDetail = async (type: 'assign' | 'orders', slot: number, row: StatsRow | null) => {
|
||||
if (loading.value || !loadedQuery.value) return
|
||||
if (type === 'assign' ? !canViewAssignLines.value : !canViewOrderLines.value) return
|
||||
const query = { ...loadedQuery.value }
|
||||
const { label, ...scope } = rowScope(row)
|
||||
const requestId = ++detailRequestId
|
||||
detailType.value = type
|
||||
detailSlot.value = slot
|
||||
detailTitle.value = `${label} · ${type === 'assign' ? `${cohortLabel.value}明细` : `${slotLabel(slot)}订单明细`}(${query.start_date} 至 ${query.end_date})`
|
||||
detailHint.value = `${dateLabel.value}:${query.start_date} 至 ${query.end_date};订单统计截至:${observedUntil.value || '查询当天'}${isInherit.value && type === 'orders' ? '。继承后首笔有效订单算二诊,全局诊次仅供参考。' : '。'}`
|
||||
detailRows.value = []
|
||||
detailCount.value = 0
|
||||
detailError.value = ''
|
||||
detailLoading.value = true
|
||||
detailVisible.value = true
|
||||
try {
|
||||
// assistant_id 保留顶层筛选,scope_assistant_id 独立约束医助行。
|
||||
const params: RevisitRateDetailQuery = { ...query, ...scope }
|
||||
const res =
|
||||
type === 'assign'
|
||||
? await revisitCohortAssignLines(params, props.cohortType)
|
||||
: await revisitCohortVisitOrderLines({ ...params, slot }, props.cohortType)
|
||||
if (disposed || requestId !== detailRequestId || !detailVisible.value) return
|
||||
detailRows.value = res?.rows ?? []
|
||||
detailCount.value = Number(res?.count ?? detailRows.value.length)
|
||||
} catch {
|
||||
if (!disposed && requestId === detailRequestId)
|
||||
detailError.value = '明细加载失败,请关闭后重试。'
|
||||
} finally {
|
||||
if (!disposed && requestId === detailRequestId) detailLoading.value = false
|
||||
}
|
||||
}
|
||||
const openAssignDetail = (row: StatsRow | null) => openDetail('assign', 0, row)
|
||||
const openOrderDetail = (slot: number, row: StatsRow | null) => openDetail('orders', slot, row)
|
||||
const orderDetailRef = ref<{ open: (id: number) => void } | null>(null)
|
||||
const openOrderInfo = (orderId: number) => {
|
||||
if (canViewOrderDetail && orderId) orderDetailRef.value?.open(orderId)
|
||||
}
|
||||
const diagnosisEditRef = ref<{ openViewOnly: (id: number) => void } | null>(null)
|
||||
const openPatientDetail = (diagnosisId: number) => {
|
||||
if (canViewPatientDetail && diagnosisId) diagnosisEditRef.value?.openViewOnly(diagnosisId)
|
||||
}
|
||||
const handleReset = () => {
|
||||
dateRange.value = defaultDateRange()
|
||||
deptId.value = undefined
|
||||
assistantId.value = undefined
|
||||
void loadData()
|
||||
}
|
||||
onMounted(() => {
|
||||
void loadDeptOptions()
|
||||
void loadData()
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
disposed = true
|
||||
++overviewRequestId
|
||||
++detailRequestId
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.revisit-rate-page {
|
||||
.rate-filter-form {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px 0;
|
||||
:deep(.el-form-item) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
.rate-caliber-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 13px;
|
||||
cursor: help;
|
||||
}
|
||||
.rate-kpi-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.rate-kpi-card {
|
||||
background: var(--el-bg-color);
|
||||
border-radius: 8px;
|
||||
padding: 16px 20px;
|
||||
&.is-clickable {
|
||||
cursor: pointer;
|
||||
transition: box-shadow 0.15s ease;
|
||||
&:hover {
|
||||
box-shadow: var(--el-box-shadow-light);
|
||||
}
|
||||
}
|
||||
.rate-kpi-label {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
.rate-kpi-value {
|
||||
margin-top: 6px;
|
||||
font-size: 26px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
.rate-kpi-sub {
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
}
|
||||
.rate-card-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
}
|
||||
.rate-card-hint {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
.rate-dept-cell {
|
||||
font-weight: 600;
|
||||
.rate-dept-count {
|
||||
margin-left: 6px;
|
||||
font-weight: 400;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
}
|
||||
.rate-th-hint {
|
||||
margin-left: 2px;
|
||||
vertical-align: -2px;
|
||||
color: var(--el-text-color-secondary);
|
||||
cursor: help;
|
||||
}
|
||||
.rate-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
line-height: 1.3;
|
||||
.rate-cell-main {
|
||||
font-weight: 500;
|
||||
}
|
||||
.rate-cell-sub {
|
||||
font-size: 11px;
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
}
|
||||
}
|
||||
.rate-detail-hint {
|
||||
margin: 0 0 12px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
.rate-detail-footer {
|
||||
margin-top: 10px;
|
||||
text-align: right;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
.rate-caliber {
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
color: var(--el-text-color-regular);
|
||||
p + p {
|
||||
margin-top: 6px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +0,0 @@
|
||||
<template>
|
||||
<cohort-report cohort-type="new" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="revisitAssignRateStatsPage">
|
||||
import CohortReport from './components/CohortReport.vue'
|
||||
</script>
|
||||
@@ -1,863 +0,0 @@
|
||||
<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>
|
||||
@@ -1,7 +0,0 @@
|
||||
<template>
|
||||
<cohort-report cohort-type="inherit" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="revisitInheritRateStatsPage">
|
||||
import CohortReport from '../revisit_assign_rate/components/CohortReport.vue'
|
||||
</script>
|
||||
@@ -29,18 +29,12 @@
|
||||
@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="560" trigger="hover">
|
||||
<el-popover placement="bottom-start" :width="500" trigger="hover">
|
||||
<template #reference>
|
||||
<span class="rate-caliber-trigger">
|
||||
<el-icon><InfoFilled /></el-icon>
|
||||
@@ -49,29 +43,16 @@
|
||||
</template>
|
||||
<div class="rate-caliber">
|
||||
<p>
|
||||
<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 配置偏移量。
|
||||
<b>当月被指派总数</b>:当月内诊单被指派给医助(按指派操作时间落月,<b>剔除勾选「继承」的指派</b>)的诊单数,按「医助 × 诊单」去重;部门行 / 合计行按诊单去重;<b>再剔除</b>名下存在履约「拒收 / 退款」业务订单的诊单。
|
||||
</p>
|
||||
<p>
|
||||
<b>当月 N 诊单数</b>:当月内下单且诊次为 N 的订单数,归属下单时点<b>持有该患者的医助</b>(指派可在往月;释放后不再归属;继承会转移持有人)。往月指派、当月成交会推高分子,比率可能超过 100%。
|
||||
<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、比率显示「—」。
|
||||
</p>
|
||||
<p>
|
||||
医助按人事部门归组;<b>仅统计「二中心」及其组织下级</b>;部门下拉与未选时的默认范围均限定在该子树,选定部门时含其组织下级。
|
||||
@@ -100,9 +81,7 @@
|
||||
<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
|
||||
@@ -125,88 +104,19 @@
|
||||
<span v-else>{{ row.assistant_name }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<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>
|
||||
<el-table-column prop="assigned_count" label="当月被指派数" min-width="110" align="right">
|
||||
<template #default="{ row }">
|
||||
<el-link
|
||||
v-if="canViewAssignLines && row.assigned_count > 0"
|
||||
type="primary"
|
||||
:underline="false"
|
||||
@click="openAssignDetail(row, 'new')"
|
||||
@click="openAssignDetail(row)"
|
||||
>
|
||||
{{ 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`"
|
||||
@@ -239,64 +149,12 @@
|
||||
<el-table-column
|
||||
:prop="`visit${slot}_rate`"
|
||||
:label="`${slotLabel(slot)}接诊率`"
|
||||
min-width="104"
|
||||
min-width="96"
|
||||
align="right"
|
||||
>
|
||||
<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>
|
||||
<template #default="{ row }">{{ formatRate(row[`visit${slot}_rate`]) }}</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>
|
||||
|
||||
@@ -304,7 +162,7 @@
|
||||
<el-dialog
|
||||
v-model="detailVisible"
|
||||
:title="detailTitle"
|
||||
width="1080px"
|
||||
width="980px"
|
||||
top="6vh"
|
||||
destroy-on-close
|
||||
>
|
||||
@@ -335,33 +193,6 @@
|
||||
<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>
|
||||
@@ -379,9 +210,6 @@
|
||||
<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 }">
|
||||
@@ -423,9 +251,7 @@ import {
|
||||
revisitRateAssignLines,
|
||||
revisitRateDeptOptions,
|
||||
revisitRateOverview,
|
||||
revisitRateVisitOrderLines,
|
||||
type RevisitRateAssignKind,
|
||||
type RevisitRateInheritMode
|
||||
revisitRateVisitOrderLines
|
||||
} from '@/api/stats'
|
||||
import { hasPermission } from '@/utils/perm'
|
||||
|
||||
@@ -437,14 +263,7 @@ 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 {
|
||||
@@ -470,14 +289,11 @@ 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]}诊`
|
||||
}
|
||||
@@ -491,8 +307,6 @@ 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)
|
||||
@@ -500,8 +314,6 @@ 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
|
||||
@@ -510,30 +322,9 @@ 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) : '',
|
||||
inherit_mode: (inheritMode.value || undefined) as RevisitRateInheritMode | undefined
|
||||
dept_ids: deptId.value ? String(deptId.value) : ''
|
||||
})
|
||||
|
||||
interface SummaryCard {
|
||||
@@ -550,11 +341,11 @@ const summaryCards = computed<SummaryCard[]>(() => {
|
||||
const cards: SummaryCard[] = [
|
||||
{
|
||||
key: 'assigned',
|
||||
label: isSlotMode.value ? '当月新指派数' : '当月被指派数',
|
||||
label: '当月被指派总数',
|
||||
value: t ? String(t.assigned_count) : '—',
|
||||
sub: t && isSlotMode.value ? `继承 ${t.inherit_count ?? 0} · 被指派合计 ${t.assigned_total_count ?? 0}` : '',
|
||||
sub: '',
|
||||
clickable: !!(canViewAssignLines && t && t.assigned_count > 0),
|
||||
onClick: () => openAssignDetail(null, 'new')
|
||||
onClick: () => openAssignDetail(null)
|
||||
}
|
||||
]
|
||||
for (const slot of visitSlots.value) {
|
||||
@@ -564,19 +355,11 @@ const summaryCards = computed<SummaryCard[]>(() => {
|
||||
key: `visit${slot}`,
|
||||
label: `当月${slotLabel(slot)}接诊率`,
|
||||
value: t ? formatRate(rate) : '—',
|
||||
sub: t ? `${slotLabel(slot)}下单 ${cnt} 单 / 分母 ${t[`visit${slot}_den`] ?? '—'}` : '',
|
||||
sub: t ? `${slotLabel(slot)}下单 ${cnt} 单` : '',
|
||||
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
|
||||
})
|
||||
|
||||
@@ -591,16 +374,15 @@ const getSummaries = ({ columns }: { columns: any[] }) => {
|
||||
return ''
|
||||
}
|
||||
const prop = String(col.property || '')
|
||||
if (prop === 'assigned_count' || prop === 'inherit_count' || prop === 'assigned_total_count') {
|
||||
return String(t[prop] ?? 0)
|
||||
if (prop === 'assigned_count') {
|
||||
return String(t.assigned_count)
|
||||
}
|
||||
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]}`)})`
|
||||
}
|
||||
if (/^visit\d+_count$/.test(prop)) {
|
||||
return String(t[prop] ?? '')
|
||||
}
|
||||
if (/^visit\d+_rate$/.test(prop)) {
|
||||
return formatRate(t[prop])
|
||||
}
|
||||
return ''
|
||||
})
|
||||
}
|
||||
@@ -622,9 +404,6 @@ 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
|
||||
}
|
||||
@@ -670,8 +449,6 @@ 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 } => {
|
||||
@@ -684,19 +461,13 @@ const rowScope = (row: StatsRow | null): { assistant_id?: number; dept_id?: numb
|
||||
return { assistant_id: row.assistant_id, label: row.assistant_name || '' }
|
||||
}
|
||||
|
||||
const assignKindLabel: Record<RevisitRateAssignKind, string> = {
|
||||
new: '新指派明细',
|
||||
inherit: '继承明细',
|
||||
all: '被指派合计明细'
|
||||
}
|
||||
|
||||
const openAssignDetail = async (row: StatsRow | null, kind: RevisitRateAssignKind = 'all') => {
|
||||
const openAssignDetail = async (row: StatsRow | null) => {
|
||||
if (!canViewAssignLines) {
|
||||
return
|
||||
}
|
||||
const scope = rowScope(row)
|
||||
detailType.value = 'assign'
|
||||
detailTitle.value = `${scope.label} · ${assignKindLabel[kind]}(${monthText.value})`
|
||||
detailTitle.value = `${scope.label} · 被指派明细(${monthText.value})`
|
||||
detailVisible.value = true
|
||||
detailLoading.value = true
|
||||
detailRows.value = []
|
||||
@@ -704,8 +475,7 @@ const openAssignDetail = async (row: StatsRow | null, kind: RevisitRateAssignKin
|
||||
const res = await revisitRateAssignLines({
|
||||
...baseQuery(),
|
||||
assistant_id: scope.assistant_id,
|
||||
dept_id: scope.dept_id,
|
||||
kind
|
||||
dept_id: scope.dept_id
|
||||
})
|
||||
detailRows.value = res?.rows ?? []
|
||||
} finally {
|
||||
@@ -713,14 +483,12 @@ const openAssignDetail = async (row: StatsRow | null, kind: RevisitRateAssignKin
|
||||
}
|
||||
}
|
||||
|
||||
/** 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
|
||||
@@ -761,7 +529,6 @@ const openPatientDetail = (diagnosisId: number) => {
|
||||
const handleReset = () => {
|
||||
month.value = currentMonth
|
||||
deptId.value = undefined
|
||||
inheritMode.value = ''
|
||||
loadData()
|
||||
}
|
||||
|
||||
@@ -856,22 +623,6 @@ 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 {
|
||||
@@ -889,10 +640,5 @@ onMounted(() => {
|
||||
p + p {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.rate-caliber-note {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -12,21 +12,9 @@
|
||||
{{ assistantLogCellText(row, 'to') }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<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>
|
||||
<el-table-column label="继承" width="72" align="center">
|
||||
<template #default="{ row }">
|
||||
<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>
|
||||
<el-tag v-if="Number(row.is_inherit) === 1" type="success" size="small">是</el-tag>
|
||||
<span v-else class="text-gray-400">否</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@@ -97,20 +85,6 @@ 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'
|
||||
@@ -158,11 +132,4 @@ 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,11 +68,7 @@
|
||||
'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">
|
||||
@@ -94,9 +90,6 @@
|
||||
</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"
|
||||
@@ -107,10 +100,7 @@
|
||||
<span class="daily-matrix__cell-patient">自录</span>
|
||||
</el-tooltip>
|
||||
</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>
|
||||
<template v-else>—</template>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@@ -119,7 +109,6 @@
|
||||
|
||||
<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"
|
||||
@@ -139,84 +128,6 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<el-drawer v-model="dayDetailVisible" :title="`${detailDate} 日常记录明细`" size="min(640px, 100vw)">
|
||||
<p class="daily-matrix__hint">每张卡片是一条独立记录;相同数值也不会合并。请在对应记录上点击编辑。</p>
|
||||
<section class="daily-matrix__detail-section">
|
||||
<div class="daily-matrix__detail-heading">
|
||||
<h3>血糖血压 · {{ dayBloodRecords.length }}条</h3>
|
||||
<el-button v-if="canEditDailyRecord" link type="primary" @click="openBloodRecord(undefined, detailDate)">新增一条</el-button>
|
||||
</div>
|
||||
<el-empty v-if="!dayBloodRecords.length" description="当天暂无血糖血压记录" :image-size="48" />
|
||||
<article v-for="record in dayBloodRecords" :key="record.id" class="daily-matrix__detail-record">
|
||||
<header class="daily-matrix__detail-heading">
|
||||
<strong>{{ recordTimeLabel(record) }} <small>#{{ record.id }}</small></strong>
|
||||
<el-button v-if="canEditDailyRecord" link type="primary" @click="openBloodRecord(record, detailDate)">编辑此条</el-button>
|
||||
</header>
|
||||
<div class="daily-matrix__detail-tags">
|
||||
<el-tag v-if="Number(record.record_time_estimated) === 1" type="warning" size="small">估计时间</el-tag>
|
||||
<el-tag v-if="record.record_time_period" type="info" size="small">时段:{{ recordTimePeriodLabel(record) }}</el-tag>
|
||||
<el-tag v-if="record.followup_audio_task_id" size="small">随访录音</el-tag>
|
||||
<span v-if="Number(record.source) === 1" class="daily-matrix__cell-patient">患者自录</span>
|
||||
</div>
|
||||
<p v-if="record.record_time_text" class="daily-matrix__time-evidence">时间原文:{{ record.record_time_text }}</p>
|
||||
<dl class="daily-matrix__detail-fields">
|
||||
<template v-for="field in bloodDetailFields(record)" :key="field.label">
|
||||
<dt>{{ field.label }}</dt><dd>{{ field.value }}</dd>
|
||||
</template>
|
||||
</dl>
|
||||
</article>
|
||||
</section>
|
||||
<section class="daily-matrix__detail-section">
|
||||
<div class="daily-matrix__detail-heading">
|
||||
<h3>饮食 · {{ dayDietRecords.length }}条</h3>
|
||||
<el-button v-if="canEditDailyRecord" link type="primary" @click="openDietRecord(undefined, detailDate)">新增一条</el-button>
|
||||
</div>
|
||||
<el-empty v-if="!dayDietRecords.length" description="当天暂无饮食记录" :image-size="48" />
|
||||
<article v-for="record in dayDietRecords" :key="record.id" class="daily-matrix__detail-record">
|
||||
<header class="daily-matrix__detail-heading">
|
||||
<strong>{{ recordTimeLabel(record) }} <small>#{{ record.id }}</small></strong>
|
||||
<el-button v-if="canEditDailyRecord" link type="primary" @click="openDietRecord(record, detailDate)">编辑此条</el-button>
|
||||
</header>
|
||||
<dl class="daily-matrix__detail-fields">
|
||||
<template v-for="meal in mealFields" :key="meal.key">
|
||||
<dt>{{ meal.label }}</dt>
|
||||
<dd>
|
||||
{{ record[`${meal.key}_foods`] || record[meal.key] || '—' }}
|
||||
<div v-if="record[`${meal.key}_images`]?.length" class="daily-matrix__detail-images">
|
||||
<el-image v-for="(url, index) in record[`${meal.key}_images`]" :key="`${url}-${index}`"
|
||||
:src="url" :preview-src-list="record[`${meal.key}_images`]" :initial-index="index" preview-teleported fit="cover" />
|
||||
</div>
|
||||
</dd>
|
||||
</template>
|
||||
<dt>备注</dt><dd>{{ record.note || '—' }}</dd>
|
||||
</dl>
|
||||
</article>
|
||||
</section>
|
||||
<section class="daily-matrix__detail-section">
|
||||
<div class="daily-matrix__detail-heading">
|
||||
<h3>运动 · {{ dayExerciseRecords.length }}条</h3>
|
||||
<el-button v-if="canEditDailyRecord" link type="primary" @click="openExerciseRecord(undefined, detailDate)">新增一条</el-button>
|
||||
</div>
|
||||
<el-empty v-if="!dayExerciseRecords.length" description="当天暂无运动记录" :image-size="48" />
|
||||
<article v-for="record in dayExerciseRecords" :key="record.id" class="daily-matrix__detail-record">
|
||||
<header class="daily-matrix__detail-heading">
|
||||
<strong>{{ recordTimeLabel(record) }} <small>#{{ record.id }}</small></strong>
|
||||
<el-button v-if="canEditDailyRecord" link type="primary" @click="openExerciseRecord(record, detailDate)">编辑此条</el-button>
|
||||
</header>
|
||||
<dl class="daily-matrix__detail-fields">
|
||||
<dt>运动</dt><dd>{{ record.exercise_type || '—' }}</dd>
|
||||
<dt>时长</dt><dd>{{ record.duration == null ? '未记录' : `${record.duration} 分钟` }}</dd>
|
||||
<dt>强度</dt><dd>{{ intensityLabel(record.intensity) }}</dd>
|
||||
<dt>备注</dt><dd>{{ record.note || '—' }}</dd>
|
||||
</dl>
|
||||
<div v-if="record.images?.length" class="daily-matrix__detail-images">
|
||||
<el-image v-for="(url, index) in record.images" :key="`${url}-${index}`" :src="url"
|
||||
:preview-src-list="record.images" :initial-index="index" preview-teleported fit="cover" />
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
</el-drawer>
|
||||
|
||||
<el-dialog v-model="bloodDialogVisible" :title="bloodDialogTitle" width="600px" :close-on-click-modal="false">
|
||||
<el-form :model="bloodForm" label-width="120px">
|
||||
<el-form-item label="记录日期" required>
|
||||
@@ -237,12 +148,6 @@
|
||||
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>
|
||||
@@ -343,13 +248,11 @@
|
||||
<el-option label="其他" value="其他" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="运动时长(分钟)" :required="!exerciseForm.id">
|
||||
<el-input-number v-model="exerciseForm.duration" :min="0" :max="300" placeholder="未记录" />
|
||||
<span v-if="exerciseForm.id" class="ml-2 text-info">留空表示未记录</span>
|
||||
<el-form-item label="运动时长(分钟)" required>
|
||||
<el-input-number v-model="exerciseForm.duration" :min="0" :max="300" />
|
||||
</el-form-item>
|
||||
<el-form-item label="运动强度">
|
||||
<el-select v-model="exerciseIntensity" placeholder="未记录" class="w-full">
|
||||
<el-option label="未记录" value="" />
|
||||
<el-form-item label="运动强度" required>
|
||||
<el-select v-model="exerciseForm.intensity" placeholder="请选择运动强度" class="w-full">
|
||||
<el-option label="低强度" :value="1" />
|
||||
<el-option label="中强度" :value="2" />
|
||||
<el-option label="高强度" :value="3" />
|
||||
@@ -450,10 +353,6 @@ 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
|
||||
@@ -465,11 +364,12 @@ 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
|
||||
@@ -488,14 +388,13 @@ interface DietRecord {
|
||||
}
|
||||
|
||||
interface ExerciseRecord {
|
||||
record_time?: string
|
||||
id?: number | string
|
||||
diagnosis_id?: number
|
||||
patient_id?: number
|
||||
record_date?: string
|
||||
exercise_type?: string
|
||||
duration?: number | null
|
||||
intensity?: number | null
|
||||
duration?: number
|
||||
intensity?: number
|
||||
intensity_text?: string
|
||||
images?: string[]
|
||||
note?: string
|
||||
@@ -538,9 +437,6 @@ 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)
|
||||
@@ -549,11 +445,6 @@ const trackingSubmitting = ref(false)
|
||||
const bloodForm = ref<BloodRecord>(createBloodForm())
|
||||
const dietForm = ref<DietRecord>(createDietForm())
|
||||
const exerciseForm = ref<ExerciseRecord>(createExerciseForm())
|
||||
// el-option uses a string sentinel; persisted unknowns remain SQL/JSON null.
|
||||
const exerciseIntensity = computed<number | ''>({
|
||||
get: () => exerciseForm.value.intensity ?? '',
|
||||
set: (value) => { exerciseForm.value.intensity = value === '' ? null : value }
|
||||
})
|
||||
const trackingForm = ref({
|
||||
note_date: '',
|
||||
existing_content: '',
|
||||
@@ -564,7 +455,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(() => canEditDailyRecord.value && trackingForm.value.note_date === todayDate.value)
|
||||
const trackingCanSubmit = computed(() => !readOnly.value && trackingForm.value.note_date === todayDate.value)
|
||||
const trackingDialogTitle = computed(() => (trackingCanSubmit.value ? '新增/追加跟踪备注' : '查看跟踪备注'))
|
||||
const tableWrapRef = ref<HTMLElement | null>(null)
|
||||
const tableWrapWidth = ref(0)
|
||||
@@ -692,63 +583,70 @@ const hasPatientSelfRecord = computed(() => {
|
||||
return bloodRecords.value.some((r) => Number((r as any).source) === 1)
|
||||
})
|
||||
|
||||
// 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 bloodByDate = computed(() => {
|
||||
const map = new Map<string, BloodRecord>()
|
||||
for (const record of bloodRecords.value) {
|
||||
const date = String(record.record_date || '')
|
||||
if (!date) continue
|
||||
const rows = map.get(date) || []
|
||||
rows.push(record)
|
||||
map.set(date, rows)
|
||||
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
|
||||
}
|
||||
}
|
||||
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: '晚餐' }]
|
||||
|
||||
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: '睡前'
|
||||
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)
|
||||
}
|
||||
const period = String(record.record_time_period || '')
|
||||
return labels[period] || period || '未提供时段'
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
function intensityLabel(value?: number | null) {
|
||||
return ({ 1: '低强度', 2: '中强度', 3: '高强度' } as Record<number, string>)[Number(value)] || '未记录'
|
||||
}
|
||||
|
||||
function bloodDetailFields(record: BloodRecord) {
|
||||
const show = (value: unknown) => value == null || value === '' ? '—' : String(value)
|
||||
return [
|
||||
{ label: '空腹血糖', value: `${show(record.fasting_blood_sugar)} mmol/L` },
|
||||
{ label: '餐后2h血糖', value: `${show(record.postprandial_blood_sugar)} mmol/L` },
|
||||
{ label: '其他血糖', value: `${show(record.other_blood_sugar)} mmol/L` },
|
||||
{ label: '血压', value: record.systolic_pressure || record.diastolic_pressure ? `${formatBp(record)} mmHg` : '—' },
|
||||
{ label: '西药', value: show(record.western_medicine) },
|
||||
{ label: '胰岛素', value: show(record.insulin) },
|
||||
{ label: '备注', value: show(record.remark) }
|
||||
]
|
||||
}
|
||||
|
||||
function getDayRecordCount(metric: string, date: string) {
|
||||
if (['breakfast', 'lunch', 'dinner'].includes(metric)) return dietByDate.value.get(date)?.length || 0
|
||||
if (metric === 'exercise') return exerciseByDate.value.get(date)?.length || 0
|
||||
if (metric === 'tracking') return 0
|
||||
return bloodByDate.value.get(date)?.length || 0
|
||||
}
|
||||
const 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)
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
const trackingByDate = computed(() => {
|
||||
const map = new Map<string, TrackingNoteRecord>()
|
||||
@@ -765,10 +663,9 @@ const chartDates = computed(() => [...visibleDates.value].reverse())
|
||||
const bloodTrendSeries = computed(() => {
|
||||
return chartDates.value.reduce(
|
||||
(acc, date) => {
|
||||
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'))
|
||||
const blood = bloodByDate.value.get(date)
|
||||
acc.fasting.push(parseTrendNumber(blood?.fasting_blood_sugar))
|
||||
acc.postprandial.push(parseTrendNumber(blood?.postprandial_blood_sugar))
|
||||
return acc
|
||||
},
|
||||
{
|
||||
@@ -857,49 +754,89 @@ 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 bloodFields: Record<string, keyof BloodRecord> = {
|
||||
fasting: 'fasting_blood_sugar', postprandial: 'postprandial_blood_sugar',
|
||||
other: 'other_blood_sugar', western: 'western_medicine', insulin: 'insulin'
|
||||
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 field = bloodFields[metric]
|
||||
if (field || metric === 'bp') {
|
||||
const rows = blood.filter((record) => metric === 'bp'
|
||||
? !!(record.systolic_pressure || record.diastolic_pressure)
|
||||
: record[field] != null && record[field] !== '')
|
||||
const isHigh = (record: BloodRecord) => {
|
||||
if (metric === 'fasting') return isHighFastingBloodSugar(record.fasting_blood_sugar, props.age)
|
||||
if (metric === 'postprandial') return isHighPostprandialBloodSugar(record.postprandial_blood_sugar, props.age)
|
||||
if (metric === 'other') return isHighOtherBloodSugar(record.other_blood_sugar, props.age)
|
||||
return metric === 'bp' && isHighBloodPressure(record)
|
||||
}
|
||||
return {
|
||||
value: rows.map((record) => metric === 'bp' ? formatBp(record) : String(record[field])).join(' / '),
|
||||
isHigh: rows.some(isHigh), hasRecord: rows.length > 0,
|
||||
isPatientSelf: rows.some((record) => Number(record.source) === 1)
|
||||
}
|
||||
}
|
||||
if (['breakfast', 'lunch', 'dinner'].includes(metric)) {
|
||||
const count = diet.filter((record) => record[metric] || record[`${metric}_foods`] || record[`${metric}_images`]?.length).length
|
||||
return { value: count ? '✓' : '', isHigh: false, hasRecord: count > 0 }
|
||||
}
|
||||
if (metric === 'exercise') {
|
||||
return { value: exercise.map((record) => record.duration != null ? `${record.duration}min` : record.exercise_type || '已记录').join(' / '), isHigh: false, hasRecord: exercise.length > 0 }
|
||||
}
|
||||
if (metric === 'tracking') {
|
||||
return { value: tracking?.content ? summarizeTracking(tracking.content) : '', isHigh: false, hasRecord: !!tracking?.content }
|
||||
}
|
||||
return { value: '', isHigh: false, hasRecord: false }
|
||||
}
|
||||
|
||||
async function fetchTracking() {
|
||||
const seq = ++requestSeq
|
||||
const requestDiagnosisId = diagnosisId.value
|
||||
if (!requestDiagnosisId) {
|
||||
loading.value = false
|
||||
if (!diagnosisId.value) {
|
||||
bloodRecords.value = []
|
||||
dietRecords.value = []
|
||||
exerciseRecords.value = []
|
||||
@@ -908,23 +845,23 @@ async function fetchTracking() {
|
||||
}
|
||||
const { start, end } = currentRange.value
|
||||
if (rangeMode.value === 'custom' && (!start || !end)) return
|
||||
const requestDates = [...visibleDates.value]
|
||||
const seq = ++requestSeq
|
||||
loading.value = true
|
||||
try {
|
||||
const data: any = await diagnosisTrackingWindow({
|
||||
id: requestDiagnosisId,
|
||||
id: diagnosisId.value,
|
||||
start_date: start,
|
||||
end_date: end
|
||||
})
|
||||
const noteData: any = await diagnosisTrackingNotes({
|
||||
diagnosis_id: requestDiagnosisId
|
||||
diagnosis_id: diagnosisId.value
|
||||
})
|
||||
if (seq !== requestSeq || requestDiagnosisId !== diagnosisId.value) return
|
||||
if (seq !== requestSeq) 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) => requestDates.includes(String(item?.note_date || '')))
|
||||
? noteData.filter((item: any) => visibleDates.value.includes(String(item?.note_date || '')))
|
||||
: []
|
||||
} catch {
|
||||
if (seq === requestSeq) {
|
||||
@@ -953,88 +890,76 @@ function handleCustomRangeChange() {
|
||||
}
|
||||
}
|
||||
|
||||
function recordTimeKey(value: unknown) {
|
||||
const time = String(value || '')
|
||||
return /^\d{2}:\d{2}$/.test(time) ? `${time}:00` : time
|
||||
}
|
||||
|
||||
function openBloodRecord(source: BloodRecord | undefined, date: string) {
|
||||
if (!canEditDailyRecord.value) return
|
||||
bloodForm.value = { ...createBloodForm(date), ...source }
|
||||
originalBloodTime.value = recordTimeKey(source?.record_time)
|
||||
bloodDialogVisible.value = true
|
||||
}
|
||||
|
||||
function openDietRecord(source: DietRecord | undefined, date: string) {
|
||||
if (!canEditDailyRecord.value) return
|
||||
dietForm.value = {
|
||||
...createDietForm(date), ...source,
|
||||
breakfast_images: [...(source?.breakfast_images || [])],
|
||||
lunch_images: [...(source?.lunch_images || [])], dinner_images: [...(source?.dinner_images || [])]
|
||||
}
|
||||
dietDialogVisible.value = true
|
||||
}
|
||||
|
||||
function openExerciseRecord(source: ExerciseRecord | undefined, date: string) {
|
||||
if (!canEditDailyRecord.value) return
|
||||
exerciseForm.value = {
|
||||
...createExerciseForm(date), ...source,
|
||||
// Existing omissions must not inherit the defaults used only for manual new records.
|
||||
...(source ? { duration: source.duration ?? null, intensity: source.intensity ?? null } : {}),
|
||||
images: [...(source?.images || [])]
|
||||
}
|
||||
exerciseDialogVisible.value = true
|
||||
}
|
||||
|
||||
function openQuickAdd(kind: DialogKind) {
|
||||
if (!canEditDailyRecord.value) return
|
||||
const date = visibleDates.value[0] || formatDate(new Date())
|
||||
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 (!isCellClickable(metric, date)) return
|
||||
if (metric === 'tracking') {
|
||||
const source = trackingByDate.value.get(date)
|
||||
trackingForm.value = { note_date: date, existing_content: String(source?.content || ''), content: '' }
|
||||
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
|
||||
}
|
||||
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
|
||||
exerciseForm.value = createExerciseForm(date)
|
||||
exerciseDialogVisible.value = true
|
||||
}
|
||||
|
||||
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)) return openDietRecord(undefined, date)
|
||||
if (metric === 'exercise') return openExerciseRecord(undefined, date)
|
||||
openBloodRecord(undefined, date)
|
||||
if (['breakfast', 'lunch', 'dinner'].includes(metric)) {
|
||||
const source = dietByDate.value.get(date)
|
||||
dietForm.value = source ? { ...createDietForm(date), ...source } : createDietForm(date)
|
||||
dietDialogVisible.value = true
|
||||
return
|
||||
}
|
||||
if (metric === 'tracking') {
|
||||
const source = trackingByDate.value.get(date)
|
||||
if (!source && date !== todayDate.value) return
|
||||
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
|
||||
}
|
||||
|
||||
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 (payload.id) {
|
||||
await bloodRecordEdit(payload)
|
||||
if (bloodForm.value.id) {
|
||||
await bloodRecordEdit(bloodForm.value)
|
||||
} else {
|
||||
await bloodRecordAdd(payload)
|
||||
await bloodRecordAdd(bloodForm.value)
|
||||
}
|
||||
if (targetDiagnosisId !== diagnosisId.value) return
|
||||
bloodDialogVisible.value = false
|
||||
await fetchTracking()
|
||||
} finally {
|
||||
@@ -1043,21 +968,17 @@ 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 (payload.id) {
|
||||
await dietRecordEdit(payload)
|
||||
if (dietForm.value.id) {
|
||||
await dietRecordEdit(dietForm.value)
|
||||
} else {
|
||||
await dietRecordAdd(payload)
|
||||
await dietRecordAdd(dietForm.value)
|
||||
}
|
||||
if (targetDiagnosisId !== diagnosisId.value) return
|
||||
dietDialogVisible.value = false
|
||||
await fetchTracking()
|
||||
} finally {
|
||||
@@ -1066,7 +987,6 @@ async function submitDiet() {
|
||||
}
|
||||
|
||||
async function submitExercise() {
|
||||
if (!canEditDailyRecord.value || exerciseSubmitting.value) return
|
||||
if (!exerciseForm.value.record_date) {
|
||||
feedback.msgWarning('请选择记录日期')
|
||||
return
|
||||
@@ -1075,20 +995,17 @@ async function submitExercise() {
|
||||
feedback.msgWarning('请选择运动类型')
|
||||
return
|
||||
}
|
||||
if (!exerciseForm.value.id && !exerciseForm.value.duration) {
|
||||
if (!exerciseForm.value.duration) {
|
||||
feedback.msgWarning('请输入运动时长')
|
||||
return
|
||||
}
|
||||
const targetDiagnosisId = diagnosisId.value
|
||||
const payload = { ...exerciseForm.value, duration: exerciseForm.value.duration ?? null, intensity: exerciseForm.value.intensity ?? null }
|
||||
exerciseSubmitting.value = true
|
||||
try {
|
||||
if (payload.id) {
|
||||
await exerciseRecordEdit(payload)
|
||||
if (exerciseForm.value.id) {
|
||||
await exerciseRecordEdit(exerciseForm.value)
|
||||
} else {
|
||||
await exerciseRecordAdd(payload)
|
||||
await exerciseRecordAdd(exerciseForm.value)
|
||||
}
|
||||
if (targetDiagnosisId !== diagnosisId.value) return
|
||||
exerciseDialogVisible.value = false
|
||||
await fetchTracking()
|
||||
} finally {
|
||||
@@ -1097,7 +1014,6 @@ async function submitExercise() {
|
||||
}
|
||||
|
||||
async function submitTracking() {
|
||||
if (trackingSubmitting.value) return
|
||||
if (!trackingForm.value.note_date) {
|
||||
feedback.msgWarning('请选择记录日期')
|
||||
return
|
||||
@@ -1111,14 +1027,12 @@ async function submitTracking() {
|
||||
feedback.msgWarning('请输入跟踪备注')
|
||||
return
|
||||
}
|
||||
const targetDiagnosisId = diagnosisId.value
|
||||
trackingSubmitting.value = true
|
||||
try {
|
||||
await diagnosisAddTrackingNote({
|
||||
diagnosis_id: targetDiagnosisId,
|
||||
diagnosis_id: diagnosisId.value,
|
||||
tracking_content: content
|
||||
})
|
||||
if (targetDiagnosisId !== diagnosisId.value) return
|
||||
trackingDialogVisible.value = false
|
||||
await fetchTracking()
|
||||
} finally {
|
||||
@@ -1143,23 +1057,17 @@ function getTrackingPreview(content: string) {
|
||||
}
|
||||
|
||||
function isCellClickable(metric: string, date: string) {
|
||||
if (metric === 'tracking') return !!getTrackingContent(date) || (canEditDailyRecord.value && date === todayDate.value)
|
||||
return canEditDailyRecord.value || getDayRecordCount(metric, date) > 0
|
||||
if (!canEditDailyRecord.value) return false
|
||||
if (metric !== 'tracking') return true
|
||||
return date === todayDate.value || !!getTrackingContent(date)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.diagnosisId,
|
||||
() => {
|
||||
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()
|
||||
(value) => {
|
||||
if (value) {
|
||||
fetchTracking()
|
||||
}
|
||||
void nextTick(updateTableWrapWidth)
|
||||
}
|
||||
)
|
||||
@@ -1173,7 +1081,6 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
requestSeq++
|
||||
window.removeEventListener('resize', updateTableWrapWidth)
|
||||
})
|
||||
|
||||
@@ -1209,29 +1116,6 @@ 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;
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
<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>
|
||||
@@ -1,722 +0,0 @@
|
||||
<template>
|
||||
<section v-if="featureEnabled" class="followup-panel" aria-label="回访录音">
|
||||
<div class="panel-toolbar">
|
||||
<div class="toolbar-title">
|
||||
<el-button v-if="selectedId" link type="primary" :disabled="busy" @click="backToList">返回列表</el-button>
|
||||
<h3 v-else>回访录音 <span>{{ tasks.length }}</span></h3>
|
||||
</div>
|
||||
<div class="toolbar-actions">
|
||||
<el-button link :aria-expanded="helpVisible" @click="helpVisible = !helpVisible">使用说明</el-button>
|
||||
<el-button :loading="loading" :disabled="busy" @click="refresh">刷新</el-button>
|
||||
<el-button v-if="!selectedId && !viewOnly && capabilities.can_upload" type="primary" :disabled="!canUpload || busy || createUncertain" @click="openUpload">上传录音</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="helpVisible" class="help-box">
|
||||
<p v-if="previewOnly">{{ capabilities.preview_only ? '当前仅开放测试识别与提炼,可核对并保存审核草稿;测试预览任务不提供确认写入。' : '这是历史测试任务,不写入病例;新上传的真实录音可按正常业务流程审核后确认入单。' }}</p>
|
||||
<p v-else>录音只生成建议,不会自动写入。逐项核对后选择采纳,再确认写入;日常记录按日期和时段分别保留。</p>
|
||||
<p>仅当前可见标签页每 5 秒刷新;已编辑内容不会被覆盖。后台分析不受关闭页面影响,未知结果请先核对任务或账单,勿重复上传。</p>
|
||||
<p v-if="detail">任务 #{{ detail.id }} · 版本 {{ detail.version }} · 保留至 {{ formatExpiry(detail.expires_at) }}</p>
|
||||
</div>
|
||||
<el-alert v-if="previewOnly" type="warning" :closable="false" :title="capabilities.preview_only ? '仅测试识别与提炼,不写入病例' : '历史测试任务,仅供查看和审阅'" description="此任务保留测试用途。新业务录音请新建任务,人工核对后确认入单。" show-icon />
|
||||
<el-alert v-if="!audioReady" type="warning" :closable="false" :title="capabilities.preview_only ? '测试预览尚未就绪,暂不可上传或重试。已有任务可查看。' : '当前环境尚未通过音频能力验证,暂不可上传或重试。已有任务可查看。'" />
|
||||
<el-alert v-else-if="capabilities.can_upload && !capabilities.models.length" type="warning" :closable="false" title="暂无可用的分析模型,暂不可上传。已有任务可查看。" />
|
||||
<el-alert v-if="basicDirty" type="warning" :closable="false" :title="previewOnly ? '病历有未保存修改,测试预览不会覆盖这些修改。' : '病历有未保存修改,请先保存病历,再写入录音建议。'" />
|
||||
<el-alert v-if="createUncertain" type="warning" :closable="false" title="创建结果未确认,已停止重复提交。请刷新列表并联系管理员核对,不要重新上传同一录音。" />
|
||||
<el-alert v-if="errorMessage" type="error" :closable="false" :title="errorMessage" show-icon />
|
||||
<div v-if="!selectedId" class="task-list">
|
||||
<el-table :data="tasks" max-height="440" size="small" table-layout="fixed" empty-text="暂无录音,上传后可提炼回访资料" aria-label="录音任务列表">
|
||||
<el-table-column label="录音" min-width="210">
|
||||
<template #default="{ row }">
|
||||
<el-button class="filename-button" link type="primary" :disabled="busy" @click="openTask(row.id)">{{ row.file_name }}</el-button>
|
||||
<div class="task-time">{{ row.recorded_at }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="145">
|
||||
<template #default="{ row }"><el-tag size="small" :type="statusType(row.status)">{{ followupTaskStatus(row) }}</el-tag><div v-if="row.transcript_available && (row.status === 'failed' || row.status === 'needs_reconciliation')" class="task-time">转写已保留</div></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="112" align="right">
|
||||
<template #default="{ row }"><el-button link type="primary" :disabled="busy" @click="openTask(row.id)">查看</el-button><el-button v-if="!viewOnly && row.can_delete !== undefined" link type="danger" :disabled="busy || !canDeleteTask(row)" :title="row.delete_block_reason || '删除未入单录音任务'" @click="deleteRecording(row)">删除</el-button></template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
<div v-else-if="!detail" class="detail-loading" role="status">正在加载录音任务…</div>
|
||||
<section v-if="selectedId && detail" class="task-detail" aria-label="录音任务详情">
|
||||
<div class="detail-heading">
|
||||
<div class="recording-title"><h3>{{ detail.file_name }}</h3><span>通话时间 {{ detail.recorded_at }}</span></div>
|
||||
<div class="detail-actions"><el-tag size="small" :type="statusType(detail.status)">{{ followupTaskStatus(detail) }}</el-tag><el-button v-if="!viewOnly && detail.can_delete !== undefined" link type="danger" :disabled="busy || !canDeleteTask(detail)" :title="detail.delete_block_reason || '删除未入单录音任务'" @click="deleteRecording(detail)">删除录音</el-button></div>
|
||||
</div>
|
||||
<p v-if="detail.status === 'running' || detail.status === 'queued'" class="processing-state" role="status">{{ followupProgressText(detail) }}</p>
|
||||
<el-alert v-if="detail.error_message || detail.status === 'failed'" :type="detail.status === 'failed' ? 'error' : 'warning'" :closable="false" :title="detail.error_message || '任务处理失败,不能写入;请先核对失败原因。'" />
|
||||
<el-alert v-if="retainedTranscriptNotice(detail)" type="warning" :closable="false" :title="retainedTranscriptNotice(detail)" />
|
||||
<el-alert v-if="detail.status === 'needs_reconciliation'" type="error" :closable="false" title="上游结果待核对,禁止重发。请管理员先确认上游任务或账单。" />
|
||||
<el-alert v-if="detail.status === 'expired'" type="warning" :closable="false" title="录音和转录已过保留期,不能继续写入;已采纳的业务记录保留。" />
|
||||
<el-button v-if="detail.can_retry && detail.status === 'failed' && !viewOnly" class="retry-button" :disabled="!canUpload || busy" @click="retryTask">重试已确认失败的任务</el-button>
|
||||
<div class="summary-box">
|
||||
<div class="muted">回访摘要 · AI未核实草稿,不代表已确认病情</div>
|
||||
<p>{{ detail.summary || '暂无回访摘要' }}</p>
|
||||
<div class="summary-counts"><span>{{ detail.status === 'applied' ? '已写入' : '已选' }} <strong>{{ selectedCount }}</strong> 项</span><span>待核实 <strong>{{ pendingCount }}</strong> 项</span></div>
|
||||
</div>
|
||||
<el-alert v-if="detail.status === 'review' && !reviewItems.length" type="warning" :closable="false" title="未生成可写入信息" :description="detail.uncertainties.length ? `本次提炼保留了 ${detail.uncertainties.length} 条待核实原因,已展示在下方。请结合原文回听核对,尤其注意声道角色是否相符。` : '请查看回访摘要并回听录音;本次没有可供确认写入的结构化候选。'" show-icon />
|
||||
<section v-if="hasStereo && !['queued', 'running'].includes(detail.status)" class="channel-role-box" aria-label="声道角色人工标注">
|
||||
<div v-if="savedChannelRoles !== 'unconfirmed' && !editingRoles && !roleDirty" class="role-summary"><span>{{ savedChannelRoles === 'left_service' ? '左客服端 / 右患者或家属端' : '右客服端 / 左患者或家属端' }} · 已确认</span><el-button v-if="canEditReview" link type="primary" @click="editingRoles = true">修改</el-button></div>
|
||||
<label v-else>声道角色(人工确认)<el-select :model-value="channelRoles" :disabled="!canEditReview || (busy && mutation !== 'save')" @update:model-value="setChannelRoles"><el-option label="角色待确认" value="unconfirmed" /><el-option label="左客服端 / 右患者或家属端" value="left_service" /><el-option label="右客服端 / 左患者或家属端" value="right_service" /></el-select></label>
|
||||
<p v-if="savedChannelRoles === 'unconfirmed' || editingRoles || roleDirty" class="muted">声道不等于已核实的说话人身份。角色标注不自动重新提炼;变更后保存草稿会取消全部采纳,须重新核对。</p>
|
||||
<p v-if="savedChannelRoles !== 'unconfirmed' && !roleDirty" class="muted">当前角色已确认,无需再次选择;只有左右标错时才需修改。</p>
|
||||
<p v-if="roleDirty" class="review-error" role="status">角色标注正在等待保存;保存后请逐项重新核对。</p>
|
||||
</section>
|
||||
<section v-if="detail.role_check?.conflict || (detail.can_reextract !== undefined && savedChannelRoles !== extractionRoles && savedChannelRoles !== 'unconfirmed')" class="role-reextract" aria-label="角色纠错与重新提炼">
|
||||
<strong>{{ detail.role_check?.conflict ? '声道角色与转写内容可能不一致' : '审核角色已修改,原提炼结果尚未更新' }}</strong>
|
||||
<p>{{ detail.role_check?.message || '修改角色标注不会自动改变已生成的建议,可按核实后的角色重新提炼。' }}</p>
|
||||
<blockquote v-for="(evidence, index) in detail.role_check?.evidence || []" :key="index"><span>{{ evidence.channel === 0 ? '左声道' : '右声道' }} · {{ audioTimestamp(evidence.start_ms) }}:{{ evidence.text }}</span><el-button v-if="detail.audio_available" link type="primary" @click="loadDialogue(evidence.start_ms)">回听依据</el-button></blockquote>
|
||||
<p>当前提炼依据:{{ extractionRoleLabel(extractionRoles) }}。{{ reextractTarget !== 'unconfirmed' ? `待确认的新角色:${extractionRoleLabel(reextractTarget)}。` : '请先回听并修改上方角色标注。' }}建议仅供核对,系统不会自动交换角色。</p>
|
||||
<el-button type="primary" plain :disabled="!canReextract || busy || dirty || reextractTarget === 'unconfirmed' || reextractTarget === extractionRoles" @click="reextractWithRoles">确认角色并重新提炼</el-button>
|
||||
<p v-if="dirty">请先完成当前单项核对或取消编辑,等待审核草稿保存后再重新提炼。</p>
|
||||
<p v-else-if="detail.reextract_block_reason">{{ detail.reextract_block_reason }}</p>
|
||||
<p class="muted">复用已完成的转写,不重复语音识别;旧结果保留,重新提炼不自动写入病例。</p>
|
||||
</section>
|
||||
<p v-if="(detail.extraction_revision || 0) > 0" class="muted">第 {{ (detail.extraction_revision || 0) + 1 }} 版提炼 · {{ extractionRoleLabel(extractionRoles) }} · 历史结果已保留</p>
|
||||
<FollowupAudioReview :key="selectedId" ref="reviewRef" v-model:items="reviewItems" :fields="detail.fields || capabilities.fields" :disabled="!canEditReview || uploading || (mutation !== '' && mutation !== 'save')" :can-daily="capabilities.can_daily" :applied="detail.status === 'applied'" :uncertainties="detail.uncertainties" :snapshot-items="detail.items" :channel-roles="savedChannelRoles" :save-state="saveState" :persisted-revision="persistedRevision" @saved-next="saveDraft(true)" @seek="loadDialogue">
|
||||
<template #player>
|
||||
<div class="audio-player">
|
||||
<el-button v-if="detail.audio_available" size="small" :loading="audioLoading" @click="reloadAudio">{{ audioUrl ? '重新加载录音' : '加载录音播放' }}</el-button>
|
||||
<span v-else class="muted">录音不可播放或已清理</span>
|
||||
<audio v-if="audioUrl" :key="audioUrl" ref="audioElement" :src="audioUrl" controls preload="metadata" />
|
||||
</div>
|
||||
<div v-if="hasStereo && detail.audio_available" class="channel-playback" aria-label="声道回听">
|
||||
<el-button size="small" :type="playbackChannel === 'original' ? 'primary' : 'default'" :aria-pressed="playbackChannel === 'original'" :disabled="audioLoading || channelLoading" @click="playChannel('original')">完整对话</el-button>
|
||||
<el-button v-for="channel in ([0, 1] as const)" :key="channel" size="small" :type="playbackChannel === channel ? 'primary' : 'default'" :aria-pressed="playbackChannel === channel" :disabled="audioLoading || channelLoading" @click="playChannel(channel)">{{ channelPlaybackLabel(channel, savedChannelRoles) }}</el-button>
|
||||
<span class="muted" role="status">{{ channelLoading ? '正在切换声道…' : !audioUrl ? '尚未加载录音' : playbackChannel === 'original' ? '回听模式:双方对话' : `回听模式:仅${playbackChannel === 0 ? '左' : '右'}声道` }}</span>
|
||||
</div>
|
||||
<p v-if="playbackError" class="review-error" role="alert">{{ playbackError }}</p>
|
||||
<p v-if="detail.audio_available" class="muted">回听对话默认从该区间前 3 秒开始,连续播放双方声音;可随时切换为只听一方。</p>
|
||||
</template>
|
||||
</FollowupAudioReview>
|
||||
<el-collapse v-model="detailSections" class="original-collapse">
|
||||
<el-collapse-item title="完整对话转写" name="original">
|
||||
<div v-if="transcriptSegments.length" class="transcript-segments">
|
||||
<p class="muted">双方转写按同一时间区间并列展示,不表示发言先后;重叠发言与具体问答请结合录音核对。</p>
|
||||
<section v-for="window in conversationWindows" :key="window.id" class="transcript-segment">
|
||||
<div class="segment-heading"><span>{{ audioTimestamp(window.start_ms) }}–{{ audioTimestamp(window.end_ms) }}</span><el-button v-if="detail.audio_available" link type="primary" @click="loadDialogue(window.start_ms)">回听双方对话</el-button></div>
|
||||
<div class="dialogue-sides">
|
||||
<div v-for="segment in window.segments" :key="segment.id" class="dialogue-side">
|
||||
<strong>{{ followupChannelLabel(segment.channel, savedChannelRoles) }}</strong>
|
||||
<pre>{{ segment.text || '(该区间无转写文本,不代表没有说话)' }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<pre v-else class="transcript-text">{{ detail.transcript || '暂无转录' }}</pre>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
<div v-if="detail.status === 'review'" class="review-actions">
|
||||
<div class="selection-summary"><strong>已选 {{ selectedCount }} 项</strong><span>{{ saveStatusText }}</span></div>
|
||||
<div v-if="canEditReview" class="action-buttons">
|
||||
<el-button link :disabled="busy" :loading="mutation === 'save'" @click="saveDraft()">{{ saveState === 'error' || saveState === 'conflict' ? '重试保存草稿' : '立即保存草稿' }}</el-button>
|
||||
<el-button v-if="canApplyReview" type="primary" :disabled="busy || basicDirty || !selectedCount || !!applyIssue" :loading="mutation === 'apply'" @click="applySelected">确认写入</el-button>
|
||||
</div>
|
||||
<p v-if="applyIssue" class="review-error" role="alert">{{ applyIssue }}</p>
|
||||
</div>
|
||||
<p v-if="detail.status === 'applied'" class="applied-note">{{ previewOnly ? '该任务已有历史采纳记录;当前为测试预览,不允许再次写入。' : '本次审核已完成,所选项已写入诊单或日常记录,不能重复写入。' }}</p>
|
||||
</section>
|
||||
<el-dialog v-model="uploadVisible" title="上传回访录音" width="min(520px, calc(100vw - 32px))" append-to-body :close-on-click-modal="false" :close-on-press-escape="!uploading" :show-close="!uploading" :before-close="closeUpload">
|
||||
<el-form label-position="top" class="upload-form">
|
||||
<el-form-item label="录音文件">
|
||||
<input ref="fileInput" type="file" accept="audio/mpeg,audio/mp4,audio/wav,audio/amr,.mp3,.m4a,.wav,.amr" :disabled="!canUpload || busy || createUncertain" aria-label="录音文件" @change="chooseFile" />
|
||||
<small>支持 MP3 / M4A / WAV / AMR,最多 {{ Math.floor(capabilities.limits.max_bytes / 1024 / 1024) }} MB / {{ Math.floor(capabilities.limits.max_seconds / 60) }} 分钟。</small>
|
||||
</el-form-item>
|
||||
<el-form-item label="实际通话时间(北京时间)">
|
||||
<el-date-picker v-model="recordedAt" type="datetime" value-format="YYYY-MM-DD HH:mm:ss" placeholder="用于识别昨天、前天等相对日期" :disabled="!canUpload || busy || createUncertain" />
|
||||
</el-form-item>
|
||||
<el-form-item label="本次录音的声道角色">
|
||||
<el-select v-model="uploadChannelRoles" aria-label="本次录音的声道角色" :disabled="!canUpload || busy || createUncertain">
|
||||
<el-option label="暂不确定 / 单声道" value="unconfirmed" />
|
||||
<el-option label="左客服 / 右患者或家属" value="left_service" />
|
||||
<el-option label="右客服 / 左患者或家属" value="right_service" />
|
||||
</el-select>
|
||||
<small>已知角色会在提炼前提供给模型;不确定请勿猜测。单声道不按左右区分身份。</small>
|
||||
</el-form-item>
|
||||
<el-collapse class="advanced-settings">
|
||||
<el-collapse-item title="高级设置 · 分析模型" name="model">
|
||||
<el-form-item label="分析模型">
|
||||
<el-select v-model="modelKey" :disabled="!canUpload || busy || createUncertain" placeholder="选择已配置的模型"><el-option v-for="model in capabilities.models" :key="model.value" :label="model.label" :value="model.value" /></el-select>
|
||||
</el-form-item>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
</el-form>
|
||||
<el-alert v-if="errorMessage" type="error" :closable="false" :title="errorMessage" show-icon />
|
||||
<el-alert v-if="createUncertain && !uploading" type="warning" :closable="false" title="创建结果未确认,请关闭弹窗后刷新任务列表并联系管理员核对,不要重新上传。" />
|
||||
<el-progress v-if="uploading" :percentage="uploadProgress" />
|
||||
<p v-if="uploadStage" class="upload-stage" role="status">{{ uploadStage }}</p>
|
||||
<p class="muted">{{ previewOnly ? '本次仅测试识别与提炼,可保存审核草稿,不允许写入病历。' : '分析仅生成建议,人工确认后才会写入。' }}</p>
|
||||
<template #footer><el-button :disabled="uploading" @click="closeUpload()">取消</el-button><el-button type="primary" :disabled="!canUpload || busy || createUncertain" :loading="uploading" @click="submitUpload">上传并分析</el-button></template>
|
||||
</el-dialog>
|
||||
</section>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
FollowupAudioError, followupAudioReextract, followupAudioDelete, followupAudioApply, followupAudioBlob, followupAudioCreate,
|
||||
followupAudioDetail, followupAudioLists, followupAudioRetry, followupAudioSaveDraft,
|
||||
followupAudioUploadChunk, followupAudioUploadComplete, followupAudioUploadSession,
|
||||
type FollowupAudioChannel, type FollowupCapabilities, type FollowupChannelRoles, type FollowupReviewItem, type FollowupTaskDetail, type FollowupTaskSummary
|
||||
} from '@/api/followupAudio'
|
||||
import FollowupAudioReview from './FollowupAudioReview.vue'
|
||||
import { channelPlaybackLabel, dialogueWindows, dialoguePlaybackStart, audioTimestamp, changedReviewTarget, cloneReviewItems, createFollowupScope, followupAudioReady, followupCanApply, followupCanReview, followupChannelLabel, followupError, followupFeatureEnabled, followupProgressText, followupTaskStatus, hasStereoTranscript, normalizedChannelRoles, rebaseReviewItems, retainedTranscriptNotice, reviewBatchIssue, reviewFieldIssue, reviewIssue, reviewPending, uploadChunks, uploadIssue } from './followupAudioState'
|
||||
import { createFollowupAudioPlayback, type FollowupPlaybackChannel } from './followupAudioPlayback'
|
||||
const props = withDefaults(defineProps<{
|
||||
diagnosisId: number; viewOnly?: boolean; active?: boolean; capabilities: FollowupCapabilities;
|
||||
basicDirty?: boolean; beforeApply?: () => boolean
|
||||
}>(), { viewOnly: false, active: true, basicDirty: false })
|
||||
const emit = defineEmits<{ (event: 'applied', diagnosisId: number): void }>()
|
||||
const tasks = ref<FollowupTaskSummary[]>([])
|
||||
const detail = ref<FollowupTaskDetail | null>(null)
|
||||
const reviewItems = ref<FollowupReviewItem[]>([])
|
||||
const selectedId = ref(0)
|
||||
const snapshot = ref('[]')
|
||||
const channelRoles = ref<FollowupChannelRoles>('unconfirmed')
|
||||
const roleSnapshot = ref<FollowupChannelRoles>('unconfirmed')
|
||||
const editingRoles = ref(false)
|
||||
const saveState = ref<'idle' | 'pending' | 'saving' | 'saved' | 'error' | 'conflict'>('idle')
|
||||
const persistedRevision = ref(0)
|
||||
const committedDirty = computed(() => roleDirty.value || JSON.stringify(reviewItems.value) !== snapshot.value)
|
||||
const saveStatusText = computed(() => saveState.value === 'saving' ? '正在自动保存…'
|
||||
: saveState.value === 'error' ? '保存失败,修改仍保留;请重试保存'
|
||||
: saveState.value === 'conflict' ? '自动保存已暂停;请重新核对后重试保存'
|
||||
: committedDirty.value ? '等待自动保存…'
|
||||
: reviewRef.value?.hasUnsavedChanges() ? '当前单项尚未完成核对'
|
||||
: saveState.value === 'saved' ? '审核草稿已自动保存' : '草稿已同步')
|
||||
const roleDirty = computed(() => channelRoles.value !== roleSnapshot.value)
|
||||
const savedChannelRoles = computed(() => normalizedChannelRoles(detail.value?.channel_roles))
|
||||
const transcriptSegments = computed(() => [...(detail.value?.transcript_segments || [])].sort((a, b) => a.start_ms - b.start_ms || (a.channel ?? -1) - (b.channel ?? -1)))
|
||||
const conversationWindows = computed(() => dialogueWindows(transcriptSegments.value))
|
||||
const hasStereo = computed(() => hasStereoTranscript(transcriptSegments.value))
|
||||
const reviewRef = ref<{ hasUnsavedChanges: () => boolean } | null>(null)
|
||||
const dirty = computed(() => roleDirty.value || JSON.stringify(reviewItems.value) !== snapshot.value || !!reviewRef.value?.hasUnsavedChanges())
|
||||
const uploadVisible = ref(false)
|
||||
const helpVisible = ref(false)
|
||||
const detailSections = ref<string[]>([])
|
||||
const file = ref<File | null>(null)
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
const recordedAt = ref('')
|
||||
const uploadChannelRoles = ref<FollowupChannelRoles>('unconfirmed')
|
||||
const modelKey = ref(props.capabilities.models[0]?.value || '')
|
||||
const loading = ref(false)
|
||||
const uploading = ref(false)
|
||||
const uploadProgress = ref(0)
|
||||
const uploadStage = ref('')
|
||||
const createUncertain = ref(false)
|
||||
const mutation = ref<'save' | 'apply' | 'retry' | 'delete' | 'reextract' | ''>('')
|
||||
const errorMessage = ref('')
|
||||
const audioUrl = ref('')
|
||||
const audioElement = ref<HTMLAudioElement | null>(null)
|
||||
const audioLoading = ref(false)
|
||||
const playbackChannel = ref<FollowupPlaybackChannel>('original')
|
||||
const channelLoading = ref(false)
|
||||
const playbackError = ref('')
|
||||
const documentVisible = ref(typeof document === 'undefined' || document.visibilityState !== 'hidden')
|
||||
const busy = computed(() => uploading.value || !!mutation.value)
|
||||
const featureEnabled = computed(() => followupFeatureEnabled(props.capabilities))
|
||||
const previewOnly = computed(() => !!props.capabilities.preview_only || !!detail.value?.preview_only)
|
||||
const audioReady = computed(() => followupAudioReady(props.capabilities))
|
||||
const canUpload = computed(() => !props.viewOnly && featureEnabled.value && audioReady.value && props.capabilities.can_upload && props.capabilities.models.length > 0)
|
||||
const canEditReview = computed(() => !props.viewOnly && followupCanReview(props.capabilities, !!detail.value?.preview_only) && detail.value?.status === 'review')
|
||||
const canApplyReview = computed(() => !props.viewOnly && followupCanApply(props.capabilities, !!detail.value?.preview_only) && detail.value?.status === 'review')
|
||||
const extractionRoles = computed(() => normalizedChannelRoles(detail.value?.extraction_channel_roles))
|
||||
const reextractTarget = computed<FollowupChannelRoles>(() => {
|
||||
if (savedChannelRoles.value !== 'unconfirmed' && savedChannelRoles.value !== extractionRoles.value) return savedChannelRoles.value
|
||||
return normalizedChannelRoles(detail.value?.role_check?.suggested_roles)
|
||||
})
|
||||
const canReextract = computed(() => !props.viewOnly && featureEnabled.value && detail.value?.status === 'review' && detail.value?.can_reextract === true)
|
||||
const selectedCount = computed(() => reviewItems.value.filter(item => item.selected).length)
|
||||
const pendingCount = computed(() => reviewItems.value.filter(item => reviewPending(item, detail.value?.fields || props.capabilities.fields, props.capabilities.can_daily)).length + (detail.value?.uncertainties.length || 0))
|
||||
const applyIssue = computed(() => previewOnly.value ? '测试预览仅保存审核草稿,不允许确认写入病历'
|
||||
: saveState.value === 'conflict' ? '记录版本已变化,请重新核对后重试保存草稿'
|
||||
: saveState.value === 'error' ? '草稿保存失败,请重试保存成功后再确认写入'
|
||||
: saveState.value === 'saving' || committedDirty.value ? '审核草稿尚未保存完成,请稍候再确认写入'
|
||||
: roleDirty.value ? '声道角色标注尚未保存,请先保存草稿并重新核对建议'
|
||||
: hasStereo.value && savedChannelRoles.value === 'unconfirmed' ? '请先回听并确认声道角色,保存草稿后重新核对建议'
|
||||
: changedReviewTarget(reviewItems.value, detail.value?.items || [])
|
||||
? '写入目标或记录日期已更改,请先保存审核草稿取得新快照,再核对并采纳'
|
||||
: reviewBatchIssue(reviewItems.value, detail.value?.fields || props.capabilities.fields) || reviewItems.value.map(item => reviewFieldIssue(item, detail.value?.fields || props.capabilities.fields) || reviewIssue(item, props.capabilities.can_daily)).find(Boolean) || '')
|
||||
const effectiveActive = computed(() => props.active && featureEnabled.value && documentVisible.value)
|
||||
const scope = createFollowupScope()
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
let autosaveTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let hydrating = false
|
||||
let savePromise: Promise<void> | null = null
|
||||
let pollRunning = false
|
||||
let audioRequest = 0
|
||||
let playbackRequest = 0
|
||||
let playback: ReturnType<typeof createFollowupAudioPlayback> | null = null
|
||||
let playbackElement: HTMLAudioElement | null = null
|
||||
let lastDiagnosis = 0
|
||||
function clearTimer() { if (timer !== undefined) clearTimeout(timer); timer = undefined }
|
||||
function releaseAudio() {
|
||||
audioRequest++; playbackRequest++
|
||||
playback?.dispose(); playback = null; playbackElement = null
|
||||
audioElement.value?.pause()
|
||||
if (audioUrl.value) URL.revokeObjectURL(audioUrl.value)
|
||||
audioUrl.value = ''; audioLoading.value = false; channelLoading.value = false; playbackChannel.value = 'original'; playbackError.value = ''
|
||||
}
|
||||
function stop() {
|
||||
scope.invalidate(); clearTimer(); clearAutosave(); releaseAudio()
|
||||
savePromise = null
|
||||
if (saveState.value === 'saving') { saveState.value = 'error'; errorMessage.value = '保存结果尚未确认;修改仍保留,请刷新核对版本后重试保存。' }
|
||||
loading.value = false; uploading.value = false; mutation.value = ''; pollRunning = false
|
||||
}
|
||||
function installDetail(fresh: FollowupTaskDetail, preserve: boolean, preserveRoles = preserve) {
|
||||
// Completed tasks use the server's confirmed snapshot, never an unsaved local draft.
|
||||
preserve = preserve && fresh.status === 'review'
|
||||
const keepRoles = preserveRoles && roleDirty.value && fresh.status === 'review'
|
||||
if (!fresh.audio_available) releaseAudio()
|
||||
if (retainedTranscriptNotice(fresh) && (!detail.value || detail.value.status !== fresh.status || !detail.value.transcript_available)) {
|
||||
if (!detailSections.value.includes('original')) detailSections.value = [...detailSections.value, 'original']
|
||||
}
|
||||
hydrating = true
|
||||
try {
|
||||
detail.value = fresh
|
||||
const nextItems = preserve ? rebaseReviewItems(reviewItems.value, fresh.items) : cloneReviewItems(fresh.items)
|
||||
if (JSON.stringify(nextItems) !== JSON.stringify(reviewItems.value)) reviewItems.value = nextItems
|
||||
snapshot.value = JSON.stringify(fresh.items)
|
||||
roleSnapshot.value = normalizedChannelRoles(fresh.channel_roles)
|
||||
if (!keepRoles) channelRoles.value = roleSnapshot.value
|
||||
} finally { hydrating = false }
|
||||
}
|
||||
function resetRoles() { hydrating = true; channelRoles.value = 'unconfirmed'; roleSnapshot.value = 'unconfirmed'; hydrating = false; editingRoles.value = false; saveState.value = 'idle'; persistedRevision.value = 0 }
|
||||
function setChannelRoles(value: unknown) {
|
||||
if (!canEditReview.value || (busy.value && mutation.value !== 'save') || !hasStereo.value || !['unconfirmed', 'left_service', 'right_service'].includes(String(value))) return
|
||||
channelRoles.value = value as FollowupChannelRoles
|
||||
}
|
||||
function taskUnavailable(error: unknown): boolean {
|
||||
return error instanceof FollowupAudioError && (error.code === 'FOLLOWUP_AUDIO_TASK_UNAVAILABLE' || error.message.includes('FOLLOWUP_AUDIO_TASK_UNAVAILABLE'))
|
||||
}
|
||||
function forgetUnavailableTask(id: number) {
|
||||
if (selectedId.value !== id) return
|
||||
stop(); resetRoles(); selectedId.value = 0; detail.value = null; reviewItems.value = []; snapshot.value = '[]'; detailSections.value = []
|
||||
tasks.value = tasks.value.filter(row => row.id !== id)
|
||||
errorMessage.value = '录音已删除或不再可访问,已关闭本页缓存与播放;未写入病例。'
|
||||
void refresh()
|
||||
}
|
||||
async function readDetail(id: number, preserve = false, verifyOnly = false) {
|
||||
const ticket = scope.capture()
|
||||
let fresh: FollowupTaskDetail
|
||||
try { fresh = await followupAudioDetail(id, ticket.signal) }
|
||||
catch (error) {
|
||||
if (scope.current(ticket) && id === selectedId.value && taskUnavailable(error)) { forgetUnavailableTask(id); return }
|
||||
throw error
|
||||
}
|
||||
if (verifyOnly && detail.value && fresh.version === detail.value.version && fresh.status === detail.value.status) return
|
||||
if (!scope.current(ticket) || id !== selectedId.value || (detail.value && fresh.version < detail.value.version)) return
|
||||
preserve = preserve || (!!detail.value && dirty.value)
|
||||
const newlyApplied = detail.value?.status === 'review' && fresh.status === 'applied'
|
||||
installDetail(fresh, preserve)
|
||||
if (newlyApplied && !previewOnly.value) emit('applied', props.diagnosisId)
|
||||
if (preserve && fresh.status === 'review') { clearAutosave(); saveState.value = 'conflict'; errorMessage.value = '服务端记录或版本已变化;已保留你的字段与日期修改,并取消所有选择。自动保存已暂停,请重新核对后重试保存草稿。' }
|
||||
else if (preserve && newlyApplied) errorMessage.value = '任务已由其他人确认,已显示实际写入结果;本页未保存的修改未写入。'
|
||||
}
|
||||
async function refresh() {
|
||||
if (!effectiveActive.value || !props.diagnosisId || pollRunning || busy.value) return
|
||||
const ticket = scope.capture()
|
||||
pollRunning = true; loading.value = true; clearTimer()
|
||||
try {
|
||||
const response = await followupAudioLists(props.diagnosisId, ticket.signal)
|
||||
if (!scope.current(ticket)) return
|
||||
tasks.value = response.items
|
||||
const updated = tasks.value.find(task => task.id === selectedId.value)
|
||||
if (updated && (!detail.value || updated.version > detail.value.version || (updated.version >= detail.value.version && updated.status !== detail.value.status) || updated.status === 'running')) {
|
||||
await readDetail(updated.id, dirty.value)
|
||||
} else if (!updated && selectedId.value && detail.value) {
|
||||
// A limited list can omit older tasks; only an authoritative detail denial revokes them.
|
||||
await readDetail(selectedId.value, false, true)
|
||||
}
|
||||
} catch (error) { if (scope.current(ticket)) errorMessage.value = followupError(error) }
|
||||
finally {
|
||||
if (scope.current(ticket)) {
|
||||
pollRunning = false; loading.value = false
|
||||
if (effectiveActive.value) timer = setTimeout(() => { void refresh() }, 5000)
|
||||
}
|
||||
}
|
||||
}
|
||||
async function confirmLeave(): Promise<boolean> {
|
||||
if (busy.value) { ElMessage.warning('正在上传或提交审核,请等待本次操作结束'); return false }
|
||||
if (!dirty.value) return true
|
||||
try {
|
||||
await ElMessageBox.confirm('审核修改尚未保存。关闭诊单或切换录音将丢失这些修改,是否继续?', '保留审核修改', { type: 'warning', confirmButtonText: '继续离开', cancelButtonText: '返回保存' })
|
||||
return true
|
||||
} catch { return false }
|
||||
}
|
||||
async function openTask(id: number) {
|
||||
if (!featureEnabled.value || id === selectedId.value || busy.value) return
|
||||
const previous = scope.capture()
|
||||
if (!await confirmLeave() || !scope.current(previous)) return
|
||||
stop(); resetRoles(); selectedId.value = id; detail.value = null; reviewItems.value = []; snapshot.value = '[]'; errorMessage.value = ''; detailSections.value = []
|
||||
const ticket = scope.capture()
|
||||
try { await readDetail(id) } catch (error) { if (scope.current(ticket)) errorMessage.value = followupError(error) }
|
||||
if (scope.current(ticket)) await refresh()
|
||||
}
|
||||
async function backToList() {
|
||||
const ticket = scope.capture()
|
||||
if (!await confirmLeave() || !scope.current(ticket)) return
|
||||
stop(); resetRoles(); selectedId.value = 0; detail.value = null; reviewItems.value = []; snapshot.value = '[]'; detailSections.value = []; errorMessage.value = ''
|
||||
await refresh()
|
||||
}
|
||||
function openUpload() {
|
||||
if (!canUpload.value || busy.value || createUncertain.value) return
|
||||
if (!file.value) uploadChannelRoles.value = 'unconfirmed'
|
||||
uploadVisible.value = true
|
||||
}
|
||||
function closeUpload(done?: () => void) { if (uploading.value) return; uploadVisible.value = false; done?.() }
|
||||
function statusType(status: string) {
|
||||
if (status === 'failed' || status === 'needs_reconciliation') return 'danger'
|
||||
if (status === 'applied') return 'success'
|
||||
if (status === 'review') return 'warning'
|
||||
return 'info'
|
||||
}
|
||||
function chooseFile(event: Event) {
|
||||
const nextFile = (event.target as HTMLInputElement).files?.[0] || null
|
||||
// Cancelling the picker or selecting the first file must not discard a role chosen earlier.
|
||||
if (!nextFile) return
|
||||
if (file.value && nextFile !== file.value) {
|
||||
uploadChannelRoles.value = 'unconfirmed'
|
||||
ElMessage.warning('录音已更换,请重新核对本次录音的声道角色')
|
||||
}
|
||||
file.value = nextFile; uploadProgress.value = 0; uploadStage.value = ''
|
||||
}
|
||||
async function submitUpload() {
|
||||
if (busy.value || createUncertain.value) return
|
||||
const problem = uploadIssue(file.value, recordedAt.value, modelKey.value, props.capabilities, props.viewOnly)
|
||||
if (problem) { ElMessage.warning(problem); return }
|
||||
const recording = file.value
|
||||
if (!recording) return
|
||||
const ticket = scope.capture()
|
||||
uploading.value = true; uploadProgress.value = 0; errorMessage.value = ''; clearTimer()
|
||||
let creating = false
|
||||
try {
|
||||
uploadStage.value = '正在创建私有上传会话'
|
||||
const session = await followupAudioUploadSession({ diagnosis_id: props.diagnosisId, file_name: recording.name, total_bytes: recording.size }, ticket.signal)
|
||||
if (!scope.current(ticket) || !canUpload.value) return
|
||||
uploadStage.value = '正在上传录音分片'
|
||||
const finished = await uploadChunks(recording, session.chunk_bytes,
|
||||
(index, chunk) => followupAudioUploadChunk(session.upload_id, index, chunk, ticket.signal),
|
||||
() => scope.current(ticket) && canUpload.value, percent => { uploadProgress.value = percent })
|
||||
if (!finished) return
|
||||
uploadStage.value = '正在校验录音时长和完整性'
|
||||
await followupAudioUploadComplete(session.upload_id, ticket.signal)
|
||||
if (!scope.current(ticket) || !canUpload.value) return
|
||||
uploadStage.value = '正在创建分析任务'; creating = true; createUncertain.value = true
|
||||
const result = await followupAudioCreate({ diagnosis_id: props.diagnosisId, upload_id: session.upload_id, recorded_at: recordedAt.value, model_key: modelKey.value, channel_roles: uploadChannelRoles.value }, ticket.signal)
|
||||
if (!scope.current(ticket)) return
|
||||
createUncertain.value = false; uploading.value = false; uploadVisible.value = false; file.value = null; if (fileInput.value) fileInput.value.value = ''
|
||||
uploadStage.value = result.reused
|
||||
? result.reuse_message || '同一录音已有任务,已打开原任务,不会重复调用模型'
|
||||
: props.capabilities.preview_only ? '测试任务已创建,后台将识别与提炼;此任务不允许写入业务记录' : '任务已创建,后台将进行分析;审核前不会写入业务记录'
|
||||
if (result.reused) ElMessage.warning(uploadStage.value)
|
||||
await openTask(result.task_id)
|
||||
} catch (error) {
|
||||
if (scope.current(ticket)) { errorMessage.value = followupError(error); createUncertain.value = creating && !(error instanceof FollowupAudioError); uploadStage.value = '本次操作已停止,未自动重试' }
|
||||
} finally { if (scope.current(ticket)) { uploading.value = false; await refresh() } }
|
||||
}
|
||||
async function handleReviewError(error: unknown) {
|
||||
const ticket = scope.capture()
|
||||
if (taskUnavailable(error) && detail.value) { forgetUnavailableTask(detail.value.id); return }
|
||||
errorMessage.value = followupError(error)
|
||||
const stale = error instanceof FollowupAudioError && error.code === 'FOLLOWUP_AUDIO_STALE_REVIEW'
|
||||
if (stale || /STALE_REVIEW|版本|快照|已变化/.test(errorMessage.value)) {
|
||||
clearAutosave(); saveState.value = 'conflict'
|
||||
try { await readDetail(selectedId.value, true) } catch { if (scope.current(ticket)) errorMessage.value += ';刷新失败,当前编辑仍已保留,请稍后刷新。' }
|
||||
}
|
||||
}
|
||||
function clearAutosave() { if (autosaveTimer !== undefined) clearTimeout(autosaveTimer); autosaveTimer = undefined }
|
||||
function scheduleAutosave() {
|
||||
clearAutosave()
|
||||
if (hydrating || !effectiveActive.value || !canEditReview.value || !committedDirty.value || ['error', 'conflict'].includes(saveState.value)) return
|
||||
if (mutation.value === 'save') return
|
||||
saveState.value = 'pending'
|
||||
autosaveTimer = setTimeout(() => { autosaveTimer = undefined; void saveDraft(true) }, 650)
|
||||
}
|
||||
// Merge only changes made after the request began. Server-owned evidence and hashes always win.
|
||||
function mergeSaveResponse(sent: FollowupReviewItem[], saved: FollowupReviewItem[], local: FollowupReviewItem[]) {
|
||||
const before = new Map(sent.map(item => [item.id, item]))
|
||||
const latest = new Map(local.map(item => [item.id, item]))
|
||||
return cloneReviewItems(saved).map(item => {
|
||||
const previous = before.get(item.id), edit = latest.get(item.id)
|
||||
if (!previous || !edit) return item
|
||||
const serverRequiresReview = (item.needs_review && !previous.needs_review) || (previous.selected && !item.selected)
|
||||
for (const key of ['values', 'record_date', 'record_time', 'time_period', 'target_id', 'selected', 'needs_review'] as const) {
|
||||
if (serverRequiresReview && (key === 'selected' || key === 'needs_review')) continue
|
||||
if (JSON.stringify(previous[key]) !== JSON.stringify(edit[key])) Object.assign(item, { [key]: edit[key] })
|
||||
}
|
||||
return item
|
||||
})
|
||||
}
|
||||
async function saveDraft(automatic = false): Promise<void> {
|
||||
if (savePromise) return savePromise
|
||||
if (!canEditReview.value || !detail.value || busy.value || !effectiveActive.value) return
|
||||
if (!automatic && reviewRef.value?.hasUnsavedChanges()) { ElMessage.warning('请先保存或取消正在核对的单项修改'); return }
|
||||
if (automatic && (!committedDirty.value || ['error', 'conflict'].includes(saveState.value))) return
|
||||
clearAutosave()
|
||||
const ticket = scope.capture(), taskId = detail.value.id, diagnosisId = props.diagnosisId
|
||||
const current = () => scope.current(ticket) && selectedId.value === taskId && props.diagnosisId === diagnosisId
|
||||
const changedRoles = roleDirty.value, sentRoles = channelRoles.value
|
||||
const sentItems = cloneReviewItems(reviewItems.value), version = detail.value.version
|
||||
mutation.value = 'save'; saveState.value = 'saving'; errorMessage.value = ''; clearTimer()
|
||||
const operation = (async () => {
|
||||
try {
|
||||
const saved = await followupAudioSaveDraft({ id: taskId, version, items: sentItems, ...(changedRoles && hasStereo.value ? { channel_roles: sentRoles } : {}) }, ticket.signal)
|
||||
if (!current()) return
|
||||
if (detail.value && saved.version < detail.value.version) {
|
||||
saveState.value = 'conflict'; errorMessage.value = '服务端已有更新版本;自动保存已暂停,请核对当前记录后重试保存。'; return
|
||||
}
|
||||
if (saved.review_refreshed) {
|
||||
const latestRoles = channelRoles.value
|
||||
installDetail(saved, true, false)
|
||||
if (latestRoles !== sentRoles) { hydrating = true; channelRoles.value = latestRoles; hydrating = false }
|
||||
saveState.value = 'conflict'
|
||||
errorMessage.value = '业务记录已变化,版本已刷新;当前编辑已保留,全部选择已取消。自动保存已暂停,请重新核对后重试保存草稿。'
|
||||
} else {
|
||||
const latestRoles = channelRoles.value
|
||||
const merged = changedRoles ? rebaseReviewItems(reviewItems.value, saved.items) : mergeSaveResponse(sentItems, saved.items, reviewItems.value)
|
||||
hydrating = true
|
||||
try {
|
||||
detail.value = saved; snapshot.value = JSON.stringify(saved.items)
|
||||
roleSnapshot.value = normalizedChannelRoles(saved.channel_roles)
|
||||
channelRoles.value = latestRoles === sentRoles ? roleSnapshot.value : latestRoles
|
||||
if (JSON.stringify(merged) !== JSON.stringify(reviewItems.value)) reviewItems.value = merged
|
||||
} finally { hydrating = false }
|
||||
editingRoles.value = roleDirty.value
|
||||
saveState.value = committedDirty.value ? 'pending' : 'saved'
|
||||
if (!committedDirty.value) persistedRevision.value++
|
||||
if (!automatic) ElMessage.success(changedRoles ? '角色标注已保存,全部建议须重新核对;不会自动重新提炼' : previewOnly.value ? '测试审核草稿已保存,不会写入病历' : '审核草稿已保存,尚未写入病历')
|
||||
}
|
||||
} catch (error) {
|
||||
if (current()) {
|
||||
saveState.value = 'error'
|
||||
await handleReviewError(error)
|
||||
if (current() && !['conflict'].includes(saveState.value)) errorMessage.value += ';自动保存已暂停,修改仍保留,请重试保存草稿。'
|
||||
}
|
||||
} finally {
|
||||
if (current()) {
|
||||
mutation.value = ''; savePromise = null
|
||||
scheduleAutosave()
|
||||
await refresh()
|
||||
}
|
||||
}
|
||||
})()
|
||||
savePromise = operation
|
||||
return operation
|
||||
}
|
||||
async function applySelected() {
|
||||
if (!canApplyReview.value || !detail.value || busy.value || !selectedCount.value) return
|
||||
if (reviewRef.value?.hasUnsavedChanges()) { ElMessage.warning('请先保存或取消正在核对的单项修改'); return }
|
||||
if (props.basicDirty || (props.beforeApply && !props.beforeApply())) { ElMessage.warning('请先保存病历表单,当前修改不会被覆盖'); return }
|
||||
if (applyIssue.value) { ElMessage.warning(applyIssue.value); return }
|
||||
const ticket = scope.capture()
|
||||
try { await ElMessageBox.confirm(`将一次性采纳 ${selectedCount.value} 项;任一项冲突则整批不写入。确认已核对原话、日期和时间?`, '确认采纳', { type: 'warning', confirmButtonText: '全部采纳', cancelButtonText: '返回核对' }) } catch { return }
|
||||
if (!scope.current(ticket) || !canApplyReview.value || !selectedCount.value || props.basicDirty || (props.beforeApply && !props.beforeApply()) || !detail.value) return
|
||||
if (applyIssue.value) { ElMessage.warning(applyIssue.value); return }
|
||||
const diagnosisId = props.diagnosisId
|
||||
mutation.value = 'apply'; errorMessage.value = ''; clearTimer()
|
||||
try {
|
||||
await followupAudioApply({ id: detail.value.id, version: detail.value.version, items: cloneReviewItems(reviewItems.value) }, ticket.signal)
|
||||
if (!scope.current(ticket) || !canApplyReview.value) return
|
||||
detail.value = { ...detail.value, status: 'applied' }; snapshot.value = JSON.stringify(reviewItems.value)
|
||||
ElMessage.success('所选记录已全部采纳'); emit('applied', diagnosisId)
|
||||
await readDetail(selectedId.value)
|
||||
} catch (error) { if (scope.current(ticket)) await handleReviewError(error) }
|
||||
finally { if (scope.current(ticket)) { mutation.value = ''; await refresh() } }
|
||||
}
|
||||
function extractionRoleLabel(roles: FollowupChannelRoles): string {
|
||||
return roles === 'left_service' ? '左客服 / 右患者或家属' : roles === 'right_service' ? '右客服 / 左患者或家属' : '角色未确认'
|
||||
}
|
||||
async function reextractWithRoles() {
|
||||
const roles = reextractTarget.value
|
||||
if (!canReextract.value || !detail.value || busy.value || roles === 'unconfirmed' || roles === extractionRoles.value) return
|
||||
if (dirty.value) { ElMessage.warning('请先完成或取消当前审核修改,等待草稿保存后再重新提炼'); return }
|
||||
const ticket = scope.capture(), id = detail.value.id, version = detail.value.version, diagnosisId = props.diagnosisId
|
||||
const current = () => scope.current(ticket) && diagnosisId === props.diagnosisId && selectedId.value === id
|
||||
clearAutosave(); clearTimer(); mutation.value = 'reextract'; errorMessage.value = ''
|
||||
let confirmed = false
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认已回听并核实角色为“${extractionRoleLabel(roles)}”?将保留旧版结果,复用已完成的转写重新提炼,不重复语音识别;新建议须重新审核,不自动写入病例。`, '确认角色并重新提炼', { type: 'warning', confirmButtonText: '角色正确,开始重新提炼', cancelButtonText: '返回回听' })
|
||||
if (!current() || !canReextract.value || dirty.value) return
|
||||
confirmed = true
|
||||
const fresh = await followupAudioReextract({ id, version, channel_roles: roles }, ticket.signal)
|
||||
if (!current()) return
|
||||
installDetail(fresh, false); saveState.value = 'idle'; editingRoles.value = false
|
||||
ElMessage.success('已提交重新提炼,将复用原转写;新结果仍需审核,不自动写入病例')
|
||||
} catch (error) { if (confirmed && current()) await handleReviewError(error) }
|
||||
finally { if (current()) { mutation.value = ''; scheduleAutosave(); await refresh() } }
|
||||
}
|
||||
function canDeleteTask(task: FollowupTaskSummary): boolean {
|
||||
return !props.viewOnly && featureEnabled.value && task.can_delete === true && ['review', 'failed'].includes(task.status)
|
||||
}
|
||||
async function deleteRecording(task: FollowupTaskSummary) {
|
||||
if (busy.value || !canDeleteTask(task)) return
|
||||
const ticket = scope.capture(), diagnosisId = props.diagnosisId, id = task.id, version = task.version
|
||||
const current = () => scope.current(ticket) && diagnosisId === props.diagnosisId
|
||||
clearAutosave(); clearTimer(); mutation.value = 'delete'; errorMessage.value = ''
|
||||
let confirmed = false
|
||||
try {
|
||||
await ElMessageBox.confirm('删除后,该录音任务及审核草稿将从列表移除,未保存修改会丢弃;不删除或回滚病例。原录音和转写仍按原保留期限清理。重新上传同一文件将创建新的分析任务。确认删除?', '删除录音任务', { type: 'warning', confirmButtonText: '确认删除', cancelButtonText: '保留录音' })
|
||||
if (!current() || !canDeleteTask(task)) return
|
||||
confirmed = true
|
||||
await followupAudioDelete({ id, version }, ticket.signal)
|
||||
if (!current()) return
|
||||
// Fence pending playback, autosaves and reads; deletion never revives a stale draft.
|
||||
if (selectedId.value === id) {
|
||||
stop(); resetRoles(); selectedId.value = 0; detail.value = null; reviewItems.value = []; snapshot.value = '[]'; detailSections.value = []
|
||||
}
|
||||
tasks.value = tasks.value.filter(row => row.id !== id)
|
||||
errorMessage.value = ''
|
||||
ElMessage.success('录音任务已删除,未修改病例。原资料按保留策略清理。')
|
||||
await refresh()
|
||||
} catch (error) {
|
||||
if (confirmed && current()) errorMessage.value = followupError(error)
|
||||
} finally {
|
||||
if (current()) { mutation.value = ''; scheduleAutosave(); await refresh() }
|
||||
}
|
||||
}
|
||||
async function retryTask() {
|
||||
if (!detail.value?.can_retry || detail.value.status !== 'failed' || !canUpload.value || busy.value) return
|
||||
const ticket = scope.capture()
|
||||
try { await ElMessageBox.confirm('仅对服务端已确认失败、允许重试的任务重新分析。确认继续?', '确认重试', { type: 'warning' }) } catch { return }
|
||||
if (!scope.current(ticket) || !detail.value?.can_retry || detail.value.status !== 'failed' || !canUpload.value) return
|
||||
mutation.value = 'retry'; clearTimer()
|
||||
try { await followupAudioRetry(detail.value.id, ticket.signal); if (scope.current(ticket)) await readDetail(selectedId.value) }
|
||||
catch (error) { if (scope.current(ticket)) errorMessage.value = followupError(error) }
|
||||
finally { if (scope.current(ticket)) { mutation.value = ''; await refresh() } }
|
||||
}
|
||||
async function loadDialogue(milliseconds: number) { await loadAudio(dialoguePlaybackStart(milliseconds)) }
|
||||
async function reloadAudio() { releaseAudio(); await loadAudio() }
|
||||
async function playChannel(channel: FollowupPlaybackChannel) { await loadAudio((audioElement.value?.currentTime || 0) * 1000, channel === 'original' ? undefined : channel) }
|
||||
async function loadAudio(milliseconds?: number, channel?: FollowupAudioChannel) {
|
||||
if (!featureEnabled.value || !detail.value?.audio_available) return
|
||||
const operation = ++playbackRequest
|
||||
playbackError.value = ''
|
||||
if (channel !== undefined && (!hasStereo.value || (channel !== 0 && channel !== 1))) {
|
||||
releaseAudio(); playbackError.value = '未取得可验证的双声道片段,无法隔离声道;请使用原音播放。'; return
|
||||
}
|
||||
const ticket = scope.capture(); const id = detail.value.id
|
||||
if (!audioUrl.value) {
|
||||
const requestId = ++audioRequest; audioLoading.value = true
|
||||
try {
|
||||
const blob = await followupAudioBlob(id, ticket.signal)
|
||||
if (!scope.current(ticket) || requestId !== audioRequest || id !== selectedId.value) return
|
||||
audioUrl.value = URL.createObjectURL(blob)
|
||||
} catch (error) { if (scope.current(ticket) && requestId === audioRequest) errorMessage.value = followupError(error) }
|
||||
finally { if (scope.current(ticket) && requestId === audioRequest) audioLoading.value = false }
|
||||
}
|
||||
await nextTick()
|
||||
if (!scope.current(ticket) || operation !== playbackRequest || id !== selectedId.value || !audioUrl.value || !audioElement.value) return
|
||||
const element = audioElement.value
|
||||
if (playbackElement !== element) { playback?.dispose(); playback = createFollowupAudioPlayback(element); playbackElement = element }
|
||||
const routing = playback
|
||||
channelLoading.value = true
|
||||
try {
|
||||
if (!routing || !await routing.select(channel ?? 'original')) return
|
||||
if (!scope.current(ticket) || operation !== playbackRequest || id !== selectedId.value) return
|
||||
playbackChannel.value = channel ?? 'original'
|
||||
if (typeof milliseconds !== 'number' || !Number.isFinite(milliseconds)) return
|
||||
element.currentTime = Math.max(0, milliseconds / 1000)
|
||||
try { await element.play() } catch { if (scope.current(ticket) && operation === playbackRequest) playbackError.value = '播放未启动,请使用播放器控件继续。' }
|
||||
} catch (error) {
|
||||
if (scope.current(ticket) && operation === playbackRequest) { playbackChannel.value = 'original'; playbackError.value = followupError(error) }
|
||||
} finally { if (scope.current(ticket) && operation === playbackRequest) channelLoading.value = false }
|
||||
}
|
||||
function formatExpiry(value: number | string) {
|
||||
if (!value) return '由保留策略控制'
|
||||
const date = typeof value === 'number' || /^\d+$/.test(value) ? new Date(Number(value) * 1000) : new Date(value)
|
||||
return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })
|
||||
}
|
||||
function onVisibility() { documentVisible.value = document.visibilityState !== 'hidden' }
|
||||
watch([() => props.diagnosisId, effectiveActive, () => !!props.capabilities.preview_only], ([id, active]) => {
|
||||
stop()
|
||||
if (id !== lastDiagnosis) {
|
||||
resetRoles(); lastDiagnosis = id; selectedId.value = 0; detail.value = null; reviewItems.value = []; snapshot.value = '[]'
|
||||
tasks.value = []; file.value = null; recordedAt.value = ''; uploadChannelRoles.value = 'unconfirmed'; uploadVisible.value = false; detailSections.value = []; helpVisible.value = false; errorMessage.value = ''; uploadStage.value = ''; createUncertain.value = false
|
||||
}
|
||||
if (active) { void refresh(); scheduleAutosave() }
|
||||
}, { immediate: true })
|
||||
watch([() => JSON.stringify(reviewItems.value), channelRoles], () => { if (!hydrating) scheduleAutosave() }, { flush: 'sync' })
|
||||
watch(() => props.capabilities.models, models => {
|
||||
if (!models.some(model => model.value === modelKey.value)) modelKey.value = models[0]?.value || ''
|
||||
})
|
||||
onMounted(() => document.addEventListener('visibilitychange', onVisibility))
|
||||
onBeforeUnmount(() => { stop(); document.removeEventListener('visibilitychange', onVisibility) })
|
||||
defineExpose({ confirmLeave, refresh, hasUnsavedChanges: () => dirty.value })
|
||||
</script>
|
||||
<style scoped>
|
||||
.followup-panel { display: grid; gap: 12px; min-width: 0; color: var(--el-text-color-primary); font-size: 14px; }
|
||||
.panel-toolbar, .toolbar-actions, .detail-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; min-width: 0; }
|
||||
.toolbar-actions { justify-content: flex-end; gap: 8px; flex-wrap: wrap; }
|
||||
.toolbar-actions :deep(.el-button + .el-button) { margin-left: 0; }
|
||||
h3 { margin: 0; font-size: 14px; font-weight: 600; }
|
||||
.toolbar-title h3 span { margin-left: 5px; font-weight: 400; color: var(--el-text-color-secondary); }
|
||||
.help-box { background: #fff; border: 1px solid #e2e8f0; border-radius: 10px; padding: 10px 14px; color: var(--el-text-color-secondary); font-size: 12px; line-height: 1.7; }
|
||||
.help-box p { margin: 3px 0; }
|
||||
.task-list, .task-detail { min-width: 0; max-width: 100%; border: 1px solid #e2e8f0; border-radius: 10px; background: #fff; overflow: hidden; }
|
||||
.task-list { padding: 4px 12px; }
|
||||
.task-list :deep(.el-table) { --el-table-header-bg-color: #f8fafc; }
|
||||
.task-list :deep(.el-table .cell) { line-height: 1.5; }
|
||||
.filename-button { max-width: 100%; height: auto; text-align: left; white-space: normal; word-break: break-word; line-height: 1.6; }
|
||||
.filename-button :deep(span) { overflow-wrap: anywhere; }
|
||||
.task-time, .recording-title > span { display: block; color: var(--el-text-color-secondary); font-size: 12px; margin-top: 3px; }
|
||||
.task-detail { padding: 16px; display: grid; gap: 14px; overflow: visible; }
|
||||
.detail-heading { align-items: flex-start; }
|
||||
.recording-title { min-width: 0; }
|
||||
.recording-title h3 { overflow-wrap: anywhere; line-height: 1.6; }
|
||||
.role-reextract { padding: 12px; border: 1px solid var(--el-color-warning-light-5); background: var(--el-color-warning-light-9); border-radius: 6px; display: grid; gap: 8px; }
|
||||
.role-reextract p { margin: 0; line-height: 1.6; }
|
||||
.role-reextract blockquote { margin: 0; padding: 8px; background: var(--el-bg-color); white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.role-reextract :deep(.el-button) { justify-self: start; }
|
||||
.detail-actions { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
|
||||
.detail-heading :deep(.el-tag) { flex-shrink: 0; }
|
||||
.detail-loading { padding: 32px; text-align: center; color: var(--el-text-color-secondary); }
|
||||
.processing-state, .applied-note { margin: 0; color: var(--el-text-color-secondary); font-size: 13px; }
|
||||
.retry-button { justify-self: start; }
|
||||
.summary-box { padding-bottom: 12px; border-bottom: 1px solid #e2e8f0; }
|
||||
.summary-box > p { margin: 0 0 10px; line-height: 1.7; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.summary-counts { display: flex; gap: 20px; color: var(--el-text-color-secondary); font-size: 12px; }
|
||||
.summary-counts strong { color: var(--el-text-color-primary); font-weight: 600; }
|
||||
.channel-role-box { display: grid; gap: 6px; font-size: 12px; }
|
||||
.role-summary { display: flex; align-items: center; gap: 12px; }
|
||||
.channel-role-box label { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; }
|
||||
.channel-role-box :deep(.el-select) { width: 260px; max-width: 100%; }
|
||||
.channel-playback { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; margin: 8px 0; }
|
||||
.channel-playback :deep(.el-button + .el-button) { margin-left: 0; }
|
||||
.transcript-segments { max-height: 320px; overflow-y: auto; margin-top: 12px; }
|
||||
.transcript-segment { padding: 10px 0; border-bottom: 1px solid #e2e8f0; }
|
||||
.segment-heading { display: flex; align-items: center; justify-content: space-between; gap: 8px; color: var(--el-text-color-secondary); font-size: 12px; }
|
||||
.transcript-segment pre { margin: 5px 0 0; white-space: pre-wrap; overflow-wrap: anywhere; font-family: inherit; }
|
||||
.dialogue-sides { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 240px), 1fr)); gap: 10px; margin-top: 8px; }
|
||||
.dialogue-side { padding: 10px; background: var(--el-fill-color-light); border-radius: 6px; min-width: 0; }
|
||||
.dialogue-side strong { font-size: 12px; color: var(--el-text-color-secondary); }
|
||||
.original-collapse { --el-collapse-border-color: #e2e8f0; border-top: 0; }
|
||||
.original-collapse :deep(.el-collapse-item__header) { height: 38px; font-size: 13px; color: var(--el-text-color-regular); }
|
||||
.original-collapse :deep(.el-collapse-item__content) { padding-bottom: 10px; }
|
||||
.audio-player { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
|
||||
.audio-player audio { width: 100%; max-width: 440px; height: 36px; }
|
||||
.transcript-text { max-height: 280px; overflow-y: auto; margin: 12px 0 0; padding: 12px; background: #f8fafc; border-radius: 6px; white-space: pre-wrap; overflow-wrap: anywhere; font-family: inherit; }
|
||||
.review-actions { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 12px; align-items: center; padding-top: 2px; }
|
||||
.selection-summary { display: grid; gap: 3px; font-size: 13px; }
|
||||
.selection-summary span { color: var(--el-text-color-secondary); font-size: 12px; }
|
||||
.action-buttons { display: flex; gap: 8px; }
|
||||
.action-buttons :deep(.el-button + .el-button) { margin-left: 0; }
|
||||
.review-error { color: var(--el-color-danger); width: 100%; margin: 0; font-size: 12px; }
|
||||
.muted, .upload-stage { color: var(--el-text-color-secondary); font-size: 12px; line-height: 1.6; margin: 0; }
|
||||
.upload-stage { margin: 10px 0; color: var(--el-text-color-regular); }
|
||||
.upload-form { min-width: 0; }
|
||||
.upload-form input { max-width: 100%; }
|
||||
.upload-form small { display: block; color: var(--el-text-color-secondary); line-height: 1.5; margin-top: 6px; }
|
||||
.upload-form :deep(.el-date-editor), .upload-form :deep(.el-select) { width: 100%; }
|
||||
@media (max-width: 640px) {
|
||||
.panel-toolbar { align-items: flex-start; flex-wrap: wrap; gap: 8px; }
|
||||
.toolbar-actions { gap: 6px; }
|
||||
.task-list { padding: 0 4px; }
|
||||
.task-detail { padding: 12px; gap: 12px; }
|
||||
.detail-heading { flex-wrap: wrap; gap: 6px; }
|
||||
.review-actions { align-items: flex-start; }
|
||||
.action-buttons { margin-left: auto; }
|
||||
}
|
||||
</style>
|
||||
@@ -1,273 +0,0 @@
|
||||
<template>
|
||||
<section class="followup-review" aria-label="回访录音人工审核">
|
||||
<el-tabs v-model="group" class="review-tabs" aria-label="提炼结果分类">
|
||||
<el-tab-pane :label="`全部 ${counts.all}`" name="all" />
|
||||
<el-tab-pane :label="`诊单资料 ${counts.diagnosis}`" name="diagnosis" />
|
||||
<el-tab-pane :label="`日常记录 ${counts.daily}`" name="daily" />
|
||||
<el-tab-pane :label="`待核实 ${counts.pending + (uncertainties?.length || 0)}`" name="pending" />
|
||||
</el-tabs>
|
||||
<div class="review-caption"><span>{{ applied ? '审核结果' : '提炼结果' }} · {{ visibleItems.length }} 项</span><span v-if="!applied">跨分类已选 {{ counts.selected }} 项</span></div>
|
||||
<p v-if="!canDaily && counts.daily" class="permission-note">日常记录仅供查看,当前账号没有编辑权限。</p>
|
||||
<div class="review-table">
|
||||
<el-table :data="visibleItems" max-height="360" row-key="id" size="small" table-layout="fixed" :empty-text="items.length ? '此分类暂无内容' : '暂无结构化结果,可查看录音原文'" aria-label="回访提炼结果">
|
||||
<el-table-column v-if="!applied" label="采纳" width="50" align="center">
|
||||
<template #default="{ row }"><el-checkbox :model-value="row.selected" :disabled="disabled || (!row.selected && selectionBlocked(row))" :aria-label="`采纳${itemTitle(row)}`" @update:model-value="(value: boolean | string | number) => toggleSelection(row, !!value)" /></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="字段 / 内容" min-width="210">
|
||||
<template #default="{ row }">
|
||||
<div v-for="field in itemFields(row).slice(0, 2)" :key="field.key" class="result-line"><span>{{ field.label }}:</span>{{ fieldText(row.values[field.key], field) }}</div>
|
||||
<span v-if="!itemFields(row).length" class="muted">暂无有效字段</span>
|
||||
<div class="item-meta">{{ itemKindLabel(row) }}<span v-if="itemFields(row).length > 2"> · 共 {{ itemFields(row).length }} 个字段</span><span v-if="row.target_id"> · 更新已有记录</span></div>
|
||||
<div v-if="row.kind !== 'diagnosis'" class="item-meta mobile-event-time">{{ row.record_date || '日期待核实' }} · {{ reviewTimeValue(row.record_time) || row.time_period || '时间待核实' }}{{ row.time_estimated ? '(推定)' : '' }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="日期 / 时段" width="130">
|
||||
<template #default="{ row }"><span>{{ row.kind === 'diagnosis' ? '—' : row.record_date || '待补充日期' }}</span><div v-if="row.kind !== 'diagnosis'" class="item-meta">{{ reviewTimeValue(row.record_time) || row.time_period || '未提及时间' }}{{ row.time_estimated ? '(推定)' : '' }}</div></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="90">
|
||||
<template #default="{ row }"><el-tag size="small" :type="itemStatus(row).type">{{ itemStatus(row).label }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="60" align="right" fixed="right">
|
||||
<template #default="{ row }"><el-button link type="primary" @click="openReview(row.id)">{{ applied || disabled ? '查看' : '核对' }}</el-button></template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
<section v-if="(group === 'pending' || !items.length) && uncertainties?.length" class="uncertainty-list" aria-label="原文待核实信息">
|
||||
<strong>本次未能确认的信息与原因</strong>
|
||||
<ul><li v-for="(value, index) in uncertainties" :key="index">{{ uncertaintyText(value) }}</li></ul>
|
||||
</section>
|
||||
<section ref="reviewWorkspace" class="review-workspace" tabindex="-1" aria-label="同页核对与回听">
|
||||
<div class="review-player"><slot name="player" /></div>
|
||||
<p v-if="navigationNotice" class="editor-notice" role="status">{{ navigationNotice }}</p>
|
||||
<div v-if="editor" class="inline-editor-heading"><strong>{{ applied || disabled ? '查看审核结果' : '核对提炼内容' }} · {{ editorPosition }} / {{ items.length }}</strong><el-button link @click="cancelReview()">{{ editorDirty ? '取消本项修改' : '收起核对' }}</el-button></div>
|
||||
<section v-if="editor" class="review-editor">
|
||||
<div class="editor-heading"><strong>{{ followupKindLabels[editor.kind] }}</strong><el-tag size="small" :type="itemStatus(editor).type">{{ itemStatus(editor).label }}</el-tag></div>
|
||||
<p v-if="editorNotice" class="editor-notice" role="status">{{ editorNotice }}</p>
|
||||
<div class="evidence">
|
||||
<div class="evidence-title">录音原话</div>
|
||||
<blockquote v-for="(quote, index) in editor.evidence" :key="index"><div v-if="quote.channel === 0 || quote.channel === 1" class="muted">{{ followupChannelLabel(quote.channel, channelRoles) }}</div><span>{{ quote.text }}</span><el-button v-if="evidencePlaybackLabel(quote)" link type="primary" @click="seekEvidence(quote)">{{ evidencePlaybackLabel(quote) }}</el-button></blockquote>
|
||||
<p v-if="editor.evidence.some(quote => quote.position_type === 'segment')" class="muted">回听位置为转写片段范围,不是原话的逐字时间戳;默认回听双方对话,请结合原始录音核对。</p>
|
||||
<p v-if="!editor.evidence.length" class="muted">未提供原话证据,请人工核实。</p>
|
||||
<p v-if="editor.date_text || editor.time_text" class="muted">时间原话:{{ editor.date_text || '未提及日期' }} · {{ editor.time_text || '未提及时间' }}</p>
|
||||
</div>
|
||||
<div v-if="editor.conflict || editor.possible_duplicate || editor.time_estimated" class="review-flags"><span v-if="editor.conflict">与已有值冲突</span><span v-if="editor.possible_duplicate">可能存在重复记录</span><span v-if="editor.time_estimated">时间由时段推定</span></div>
|
||||
<template v-if="editor.kind !== 'diagnosis'">
|
||||
<div v-if="disabled || applied" class="readonly-event"><span>{{ editor.record_date || '未记录日期' }} · {{ reviewTimeValue(editor.record_time) || editor.time_period || '未记录时间' }}</span><span>{{ editor.target_id ? `更新记录 #${editor.target_id}` : '新增独立记录' }}</span></div>
|
||||
<div v-else class="event-time">
|
||||
<label class="target-field">写入目标<el-select :model-value="editor.target_id || 0" @update:model-value="(value: number) => chooseTarget(value)"><el-option label="新增独立记录(不覆盖已有行)" :value="0" /><el-option v-for="candidate in editorCandidates" :key="candidate.id" :value="candidate.id" :disabled="!!candidate.delete_time" :label="candidateLabel(editor, candidate)" /></el-select></label>
|
||||
<label>记录日期<el-date-picker :model-value="editor.record_date" type="date" value-format="YYYY-MM-DD" placeholder="请核实日期" @update:model-value="(value: string | null) => patchEditor({ record_date: value || null, target_id: null })" /></label>
|
||||
<label>记录时间<el-time-picker :model-value="reviewTimeValue(editor.record_time)" value-format="HH:mm" format="HH:mm" placeholder="未提及可留空" @update:model-value="(value: string | null) => patchEditor({ record_time: value || null })" /></label>
|
||||
<label>原始时段<el-input :model-value="editor.time_period || ''" placeholder="如早晨、餐后" @update:model-value="(value: string) => patchEditor({ time_period: value || null })" /></label>
|
||||
</div>
|
||||
<p v-if="targetChanged && !applied" class="target-hint" role="status">日期或目标已更改。保存本项后将自动同步草稿并刷新目标当前值,请依据新值重新核对。</p>
|
||||
</template>
|
||||
<div class="field-grid field-grid--header"><span>字段</span><span>{{ applied ? '写入前值' : targetChanged ? '旧目标值(待刷新)' : '当前值' }}</span><span>{{ applied || disabled ? '审核值' : '拟采纳值' }}</span></div>
|
||||
<div v-for="field in itemFields(editor)" :key="field.key" class="field-grid">
|
||||
<div class="field-label">{{ field.label }}<small v-if="field.readonly">只读</small></div>
|
||||
<div class="current-value">{{ fieldText(editor.current_values[field.key], field) }}</div>
|
||||
<div class="suggested-value"><FollowupAudioField :field="field" :model-value="editor.values[field.key]" :readonly="disabled || applied || field.readonly" @update:model-value="value => setValue(field.key, value)" /><el-button v-if="!disabled && !applied" link type="danger" :disabled="Object.keys(editor.values).length <= 1" @click="removeField(field.key)">移除</el-button></div>
|
||||
</div>
|
||||
<p v-if="!disabled && !applied" class="muted">仅可更正或移除本项提议字段,不采纳整项请取消勾选;新增内容请返回原诊单编辑人工补录。</p>
|
||||
<el-checkbox v-if="!disabled && !applied" :model-value="!editor.needs_review" class="review-confirm" @update:model-value="(value: boolean | string | number) => confirmReview(!!value)">我已核对原话、冲突 / 重复及日期时间,确认本项内容</el-checkbox>
|
||||
<p v-if="editorIssue && !disabled && !applied" class="review-error" role="status">{{ editorIssue }}</p>
|
||||
</section>
|
||||
<div v-if="editor && !disabled && !applied" class="editor-footer">
|
||||
<span class="draft-hint" role="status">{{ saveState === 'saving' || saveState === 'pending' ? '审核草稿同步中,不写入病例' : saveState === 'error' || saveState === 'conflict' ? '草稿同步需处理,请查看下方提示' : '核对结果自动保存为草稿,最终确认后才写入病例' }}</span>
|
||||
<el-button @click="skipAndNext">暂不采纳并下一项</el-button>
|
||||
<el-button :disabled="!editorDirty" @click="saveReview(false)">保存本项修改</el-button>
|
||||
<el-button type="primary" :disabled="!!editorIssue || targetChanged" @click="saveReview(true, true)">{{ nextReviewId ? '采纳并下一项' : '完成核对并选中' }}</el-button>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import type { FollowupAudioChannel, FollowupChannelRoles, FollowupField, FollowupFields, FollowupReviewItem, FollowupValue } from '@/api/followupAudio'
|
||||
import FollowupAudioField from './FollowupAudioField.vue'
|
||||
import { cloneReviewItems, evidencePlaybackLabel, fieldText, followupChannelLabel, followupKindLabels, hasEditableReviewFields, reviewCounts, reviewFieldIssue, reviewIssue, reviewPending, reviewTimeValue } from './followupAudioState'
|
||||
const props = defineProps<{ items: FollowupReviewItem[]; fields: FollowupFields; disabled: boolean; canDaily: boolean; applied?: boolean; uncertainties?: unknown[]; snapshotItems?: FollowupReviewItem[]; channelRoles?: FollowupChannelRoles; saveState?: string; persistedRevision?: number }>()
|
||||
const emit = defineEmits<{ (event: 'update:items', items: FollowupReviewItem[]): void; (event: 'seek', milliseconds: number, channel?: FollowupAudioChannel): void; (event: 'saved-next'): void }>()
|
||||
type Group = 'all' | 'diagnosis' | 'daily' | 'pending'
|
||||
type Editable = Partial<Pick<FollowupReviewItem, 'values' | 'record_date' | 'record_time' | 'time_period' | 'selected' | 'target_id' | 'needs_review'>>
|
||||
const group = ref<Group>('all')
|
||||
const editor = ref<FollowupReviewItem | null>(null)
|
||||
const editorSource = ref('')
|
||||
const editorNotice = ref('')
|
||||
const navigationNotice = ref('')
|
||||
const reviewWorkspace = ref<HTMLElement | null>(null)
|
||||
const editorPosition = computed(() => props.items.findIndex(item => item.id === editor.value?.id) + 1)
|
||||
const nextReviewId = computed(() => {
|
||||
const index = visibleItems.value.findIndex(item => item.id === editor.value?.id)
|
||||
return index < 0 ? null : visibleItems.value.slice(index + 1).find(item => !item.selected && !selectionBlocked(item))?.id || null
|
||||
})
|
||||
const counts = computed(() => reviewCounts(props.items, props.fields, props.canDaily))
|
||||
const visibleItems = computed(() => props.items.filter(item => group.value === 'all' || (group.value === 'diagnosis' && item.kind === 'diagnosis') || (group.value === 'daily' && item.kind !== 'diagnosis') || (group.value === 'pending' && reviewPending(item, props.fields, props.canDaily))))
|
||||
const editorDirty = computed(() => !!editor.value && JSON.stringify(editor.value) !== editorSource.value)
|
||||
const targetChanged = computed(() => {
|
||||
if (!editor.value || !editorSource.value) return false
|
||||
const source: FollowupReviewItem = props.snapshotItems?.find(item => item.id === editor.value?.id) || JSON.parse(editorSource.value)
|
||||
return editor.value.target_id !== source.target_id || editor.value.record_date !== source.record_date
|
||||
})
|
||||
const editorCandidates = computed(() => {
|
||||
if (!editor.value || !editorSource.value) return []
|
||||
const source: FollowupReviewItem = props.snapshotItems?.find(item => item.id === editor.value?.id) || JSON.parse(editorSource.value)
|
||||
return editor.value.record_date === source.record_date ? editor.value.candidates || [] : []
|
||||
})
|
||||
const editorIssue = computed(() => editor.value ? reviewFieldIssue({ ...editor.value, selected: true }, props.fields) || reviewIssue({ ...editor.value, selected: true }, props.canDaily) : '')
|
||||
function patch(id: string, update: Editable) { emit('update:items', props.items.map(item => item.id === id ? { ...item, ...update } : item)) }
|
||||
function itemFields(item: FollowupReviewItem): FollowupField[] { return Object.keys(item.values).map(key => (props.fields[item.kind] || []).find(field => field.key === key) || { key, label: key, type: 'text', readonly: true }) }
|
||||
function itemKindLabel(item: FollowupReviewItem) { return followupKindLabels[item.kind] }
|
||||
function itemTitle(item: FollowupReviewItem) { return itemFields(item).map(field => field.label).join('、') || followupKindLabels[item.kind] }
|
||||
function selectionBlocked(item: FollowupReviewItem) { return props.disabled || !!props.applied || !hasEditableReviewFields(item, props.fields) || (item.kind !== 'diagnosis' && !props.canDaily) || !!reviewFieldIssue({ ...item, selected: true }, props.fields) }
|
||||
function toggleSelection(item: FollowupReviewItem, selected: boolean) {
|
||||
if (props.disabled || props.applied) return
|
||||
if (!selected) { patch(item.id, { selected: false }); return }
|
||||
if (selectionBlocked(item)) return
|
||||
if (reviewPending(item, props.fields, props.canDaily)) { openReview(item.id); return }
|
||||
patch(item.id, { selected: true })
|
||||
}
|
||||
function itemStatus(item: FollowupReviewItem): { label: string; type: 'success' | 'info' | 'warning' } {
|
||||
if (props.applied) return { label: item.selected ? '已写入' : '未采纳', type: item.selected ? 'success' : 'info' }
|
||||
if (!hasEditableReviewFields(item, props.fields)) return { label: '只读字段', type: 'info' }
|
||||
if (reviewPending(item, props.fields, props.canDaily)) return { label: '待核实', type: 'warning' }
|
||||
return { label: item.selected ? '已选择' : '已核对', type: item.selected ? 'success' : 'info' }
|
||||
}
|
||||
function openReview(id: string) {
|
||||
if (editor.value?.id === id && editorDirty.value && !props.applied) {
|
||||
void nextTick(() => { reviewWorkspace.value?.scrollIntoView({ block: 'start' }); reviewWorkspace.value?.focus({ preventScroll: true }) })
|
||||
return
|
||||
}
|
||||
if (editor.value && editor.value.id !== id && editorDirty.value && !props.applied) {
|
||||
editorNotice.value = '当前项有未保存修改,请先保存或取消本项修改,再核对其他项。'
|
||||
return
|
||||
}
|
||||
const item = props.items.find(candidate => candidate.id === id)
|
||||
if (!item) return
|
||||
editor.value = cloneReviewItems([item])[0]; editorSource.value = JSON.stringify(editor.value); editorNotice.value = ''; navigationNotice.value = ''
|
||||
void nextTick(() => { reviewWorkspace.value?.scrollIntoView({ block: 'start' }); reviewWorkspace.value?.focus({ preventScroll: true }) })
|
||||
}
|
||||
function cancelReview(done?: () => void) { editor.value = null; editorSource.value = ''; editorNotice.value = ''; done?.() }
|
||||
function patchEditor(update: Editable) {
|
||||
if (!editor.value || props.disabled || props.applied) return
|
||||
// Detail/snapshot items are editable drafts, not the server-owned original proposal.
|
||||
if (update.values && Object.keys(update.values).some(key => !Object.prototype.hasOwnProperty.call(editor.value?.values, key))) return
|
||||
editor.value = { ...editor.value, ...update, selected: false, needs_review: true }
|
||||
}
|
||||
function setValue(key: string, value: FollowupValue) {
|
||||
if (!editor.value || !Object.prototype.hasOwnProperty.call(editor.value.values, key)
|
||||
|| !(props.fields[editor.value.kind] || []).some(field => field.key === key && !field.readonly)) return
|
||||
patchEditor({ values: { ...editor.value.values, [key]: value } })
|
||||
}
|
||||
function removeField(key: string) { if (!editor.value || Object.keys(editor.value.values).length <= 1) return; const values = { ...editor.value.values }; delete values[key]; patchEditor({ values }) }
|
||||
function chooseTarget(id: number) {
|
||||
if (!editor.value || (id && !editorCandidates.value.some(candidate => candidate.id === id && !candidate.delete_time))) return
|
||||
patchEditor({ target_id: id || null })
|
||||
}
|
||||
function confirmReview(confirmed: boolean) {
|
||||
if (!editor.value || props.disabled || props.applied) return
|
||||
editor.value = { ...editor.value, needs_review: !confirmed, selected: confirmed ? editor.value.selected : false }
|
||||
}
|
||||
function saveReview(select: boolean, advance = false) {
|
||||
if (!editor.value || props.disabled || props.applied || (select && (editorIssue.value || targetChanged.value))) return
|
||||
const item = editor.value
|
||||
const nextId = advance ? nextReviewId.value : null
|
||||
patch(item.id, { values: { ...item.values }, record_date: item.record_date, record_time: item.record_time, time_period: item.time_period, target_id: item.target_id, selected: select || (!editorIssue.value && !targetChanged.value && item.selected), needs_review: targetChanged.value || item.needs_review })
|
||||
cancelReview()
|
||||
emit('saved-next')
|
||||
if (nextId) openReview(nextId)
|
||||
else if (advance) navigationNotice.value = '当前列表已核对至末项;可切换分类继续,或确认写入已选内容。'
|
||||
}
|
||||
function skipAndNext() {
|
||||
if (!editor.value || props.disabled || props.applied) return
|
||||
if (editorDirty.value) { editorNotice.value = '当前项有未保存修改,请先保存或取消修改后再跳过。'; return }
|
||||
const nextId = nextReviewId.value
|
||||
patch(editor.value.id, { selected: false })
|
||||
cancelReview(); emit('saved-next')
|
||||
if (nextId) openReview(nextId)
|
||||
else navigationNotice.value = '本项暂不采纳,未写入病例;可切换分类继续核对。'
|
||||
}
|
||||
function candidateLabel(item: FollowupReviewItem, candidate: NonNullable<FollowupReviewItem['candidates']>[number]) {
|
||||
const preview = (props.fields[item.kind] || []).filter(field => Object.prototype.hasOwnProperty.call(candidate.values, field.key)).slice(0, 2).map(field => `${field.label} ${fieldText(candidate.values[field.key], field)}`).join(',')
|
||||
return `#${candidate.id} · ${candidate.record_time || candidate.time_period || '时间未记录'}${candidate.time_estimated ? '(推定)' : ''}${preview ? ` · ${preview}` : ''}`
|
||||
}
|
||||
function seekEvidence(quote: FollowupReviewItem['evidence'][number]) {
|
||||
if (!evidencePlaybackLabel(quote) || typeof quote.start_ms !== 'number') return
|
||||
emit('seek', quote.start_ms)
|
||||
}
|
||||
function uncertaintyText(value: unknown) { return typeof value === 'string' ? value : JSON.stringify(value) }
|
||||
watch(() => props.items.find(item => item.id === editor.value?.id), fresh => {
|
||||
if (!editor.value) return
|
||||
if (!fresh) { cancelReview(); return }
|
||||
const current = JSON.stringify(fresh)
|
||||
if (current === editorSource.value) return
|
||||
const edited = editor.value
|
||||
editor.value = editorDirty.value && !props.applied ? { ...cloneReviewItems([fresh])[0], values: { ...edited.values }, record_date: edited.record_date, record_time: edited.record_time, time_period: edited.time_period, target_id: edited.target_id, selected: false, needs_review: true } : cloneReviewItems([fresh])[0]
|
||||
editorSource.value = current
|
||||
editorNotice.value = '任务数据已更新,当前编辑已保留;请重新核对后选择。'
|
||||
}, { deep: true })
|
||||
watch(() => props.applied, applied => { if (applied && editor.value) openReview(editor.value.id) })
|
||||
defineExpose({ hasUnsavedChanges: () => editorDirty.value })
|
||||
</script>
|
||||
<style scoped>
|
||||
.followup-review, .review-table { min-width: 0; max-width: 100%; }
|
||||
.review-tabs :deep(.el-tabs__header) { margin: 0 0 8px; }
|
||||
.review-tabs :deep(.el-tabs__item) { padding: 0 14px; font-size: 13px; height: 36px; }
|
||||
.review-tabs :deep(.el-tabs__nav-wrap::after) { height: 1px; background: #e2e8f0; }
|
||||
.review-caption { display: flex; gap: 12px; justify-content: space-between; margin: 0 0 8px; color: var(--el-text-color-secondary); font-size: 12px; }
|
||||
.review-table { overflow: hidden; }
|
||||
.review-table :deep(.el-table) { --el-table-header-bg-color: #f8fafc; }
|
||||
.review-table :deep(.el-table .cell) { line-height: 1.6; }
|
||||
.result-line { white-space: nowrap; text-overflow: ellipsis; overflow: hidden; }
|
||||
.result-line > span { color: #64748b; }
|
||||
.item-meta, .muted, .permission-note { color: #64748b; font-size: 12px; }
|
||||
.item-meta { margin-top: 2px; }
|
||||
.mobile-event-time { display: none; }
|
||||
.permission-note { margin: 0 0 8px; }
|
||||
.uncertainty-list { margin-top: 12px; padding: 10px 12px; background: #f8fafc; border-radius: 6px; font-size: 13px; line-height: 1.6; overflow-wrap: anywhere; }
|
||||
.uncertainty-list strong { font-size: 12px; font-weight: 500; color: var(--el-text-color-secondary); }
|
||||
.uncertainty-list ul { padding-left: 18px; margin: 6px 0 0; }
|
||||
.review-editor { display: grid; gap: 12px; min-width: 0; max-height: 65vh; overflow-y: auto; padding-right: 4px; }
|
||||
.editor-heading { display: flex; align-items: center; gap: 10px; }
|
||||
.editor-heading strong { font-size: 14px; }
|
||||
.evidence { padding: 10px 12px; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px; overflow-wrap: anywhere; }
|
||||
.evidence-title { color: var(--el-text-color-secondary); font-size: 12px; }
|
||||
.evidence blockquote { margin: 6px 0; white-space: pre-wrap; line-height: 1.6; }
|
||||
.evidence blockquote :deep(.el-button) { margin-left: 8px; }
|
||||
.evidence p { margin: 6px 0 0; }
|
||||
.review-flags { display: flex; flex-wrap: wrap; gap: 6px 14px; color: var(--el-color-warning-dark-2); font-size: 12px; }
|
||||
.event-time { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
|
||||
.event-time label { display: grid; gap: 5px; font-size: 12px; color: var(--el-text-color-regular); }
|
||||
.event-time .target-field { grid-column: 1 / -1; }
|
||||
.event-time :deep(.el-date-editor), .event-time :deep(.el-select), .suggested-value :deep(.el-input-number), .suggested-value :deep(.el-select), .suggested-value :deep(.el-date-editor) { width: 100%; }
|
||||
.readonly-event { display: flex; flex-wrap: wrap; gap: 8px 20px; font-size: 13px; }
|
||||
.target-hint, .editor-notice { margin: 0; color: var(--el-color-warning-dark-2); font-size: 12px; line-height: 1.6; }
|
||||
.field-grid { display: grid; grid-template-columns: 1fr 1fr 1.6fr; align-items: start; gap: 12px; padding-bottom: 10px; border-bottom: 1px solid #e2e8f0; font-size: 13px; }
|
||||
.field-grid--header { color: var(--el-text-color-secondary); font-size: 12px; padding-bottom: 6px; }
|
||||
.field-label small { display: block; color: var(--el-text-color-secondary); margin-top: 3px; }
|
||||
.current-value { white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.suggested-value { min-width: 0; display: grid; gap: 4px; }
|
||||
.suggested-value > :deep(.el-button) { justify-self: end; font-size: 12px; }
|
||||
.review-confirm { white-space: normal; height: auto; min-height: 32px; align-items: flex-start; }
|
||||
.review-confirm :deep(.el-checkbox__input) { margin-top: 4px; }
|
||||
.review-confirm :deep(.el-checkbox__label) { white-space: normal; line-height: 1.6; font-size: 13px; }
|
||||
.review-workspace { margin-top: 14px; scroll-margin-top: 12px; outline: none; }
|
||||
.review-workspace:focus-visible { outline: 2px solid var(--el-color-primary); outline-offset: 4px; }
|
||||
.review-player { position: sticky; top: 0; z-index: 2; background: var(--el-bg-color, #fff); padding: 10px 0; border-bottom: 1px solid var(--el-border-color-lighter); }
|
||||
.inline-editor-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin: 14px 0; }
|
||||
.draft-hint { flex: 1 1 100%; font-size: 12px; color: var(--el-text-color-secondary); }
|
||||
.review-error { color: var(--el-color-danger); font-size: 12px; margin: 0; }
|
||||
.editor-footer { display: flex; justify-content: flex-end; flex-wrap: wrap; gap: 8px; }
|
||||
.editor-footer :deep(.el-button + .el-button) { margin-left: 0; }
|
||||
@media (max-width: 640px) {
|
||||
.mobile-event-time { display: block; }
|
||||
.review-tabs :deep(.el-tabs__item) { padding: 0 10px; }
|
||||
.review-caption { gap: 6px; flex-wrap: wrap; }
|
||||
.event-time { grid-template-columns: 1fr; }
|
||||
.field-grid { grid-template-columns: 1fr; gap: 6px; }
|
||||
.field-grid--header { display: none; }
|
||||
.field-label { font-weight: 600; }
|
||||
.current-value::before { content: '当前值:'; color: var(--el-text-color-secondary); }
|
||||
.suggested-value::before { content: '审核值:'; color: var(--el-text-color-secondary); }
|
||||
.editor-footer { gap: 6px; }
|
||||
}
|
||||
</style>
|
||||
@@ -1,303 +0,0 @@
|
||||
<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>
|
||||
@@ -1,59 +0,0 @@
|
||||
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. */ })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,196 +0,0 @@
|
||||
import type { FollowupAudioChannel, FollowupCapabilities, FollowupChannelRoles, FollowupField, FollowupFields, FollowupReviewItem, FollowupTaskDetail, FollowupTaskSummary, FollowupTranscriptSegment, FollowupValue } from '@/api/followupAudio'
|
||||
|
||||
export const FOLLOWUP_CHUNK_BYTES = 2097152
|
||||
export const followupKindLabels = { diagnosis: '诊单资料', blood: '血糖 / 血压', diet: '饮食', exercise: '运动', tracking_note: '跟踪备注' }
|
||||
export const followupStatusLabels: Record<string, string> = {
|
||||
queued: '等待处理', running: '正在分析', review: '待人工审核', applied: '已采纳',
|
||||
failed: '处理失败', needs_reconciliation: '结果待核对', expired: '已过保留期'
|
||||
}
|
||||
const followupStageLabels: Record<string, string> = { transcribing: '正在转写', extracting: '正在提炼', validating: '正在校验', review: '待人工审核' }
|
||||
export function followupTaskStatus(task: Pick<FollowupTaskSummary, 'status' | 'stage'>): string {
|
||||
return (task.status === 'running' && followupStageLabels[task.stage]) || followupStatusLabels[task.status] || task.status
|
||||
}
|
||||
export function followupProgressText(task: FollowupTaskSummary): string {
|
||||
const progress = task.pipeline_progress
|
||||
const valid = progress && Number.isSafeInteger(progress.transcribed) && Number.isSafeInteger(progress.total)
|
||||
&& progress.total > 0 && progress.transcribed >= 0 && progress.transcribed <= progress.total
|
||||
return `${followupTaskStatus(task)}${valid ? ` · 转写片段 ${progress.transcribed} / ${progress.total}` : ''};人工审核前不会写入业务记录。`
|
||||
}
|
||||
export function retainedTranscriptNotice(task: FollowupTaskDetail): string {
|
||||
if (!task.transcript_available || !task.transcript.trim()) return ''
|
||||
if (task.status === 'failed') return '处理失败,已完成片段的转写仍可查看。保留转写不代表提炼成功,不能直接写入病历。'
|
||||
if (task.status === 'needs_reconciliation') return '结果待核对,已完成片段的转写仍可查看。请先核对上游结果,不要重复发送。'
|
||||
return ''
|
||||
}
|
||||
export function evidencePlaybackLabel(quote: FollowupReviewItem['evidence'][number]): string {
|
||||
if (typeof quote.start_ms !== 'number' || !Number.isFinite(quote.start_ms) || quote.start_ms < 0) return ''
|
||||
if (quote.channel !== undefined && quote.channel !== 0 && quote.channel !== 1) return ''
|
||||
if (quote.position_type === 'segment') {
|
||||
if (!quote.segment_id || typeof quote.end_ms !== 'number' || !Number.isFinite(quote.end_ms) || quote.end_ms <= quote.start_ms) return ''
|
||||
return `回听对话 ${audioTimestamp(quote.start_ms)}–${audioTimestamp(quote.end_ms)}`
|
||||
}
|
||||
return `播放 ${audioTimestamp(quote.start_ms)}`
|
||||
}
|
||||
export function audioTimestamp(ms: number): string { const seconds = Math.max(0, Math.floor(ms / 1000)); return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}` }
|
||||
export function normalizedChannelRoles(value: unknown): FollowupChannelRoles { return value === 'left_service' || value === 'right_service' ? value : 'unconfirmed' }
|
||||
export function hasStereoTranscript(segments: FollowupTranscriptSegment[] = []): boolean { return segments.some(segment => segment.channel === 0) && segments.some(segment => segment.channel === 1) }
|
||||
export function followupChannelLabel(channel?: FollowupAudioChannel, roles: FollowupChannelRoles = 'unconfirmed'): string {
|
||||
if (channel !== 0 && channel !== 1) return '未标注声道'
|
||||
const side = channel === 0 ? '左声道' : '右声道'
|
||||
const confirmed = normalizedChannelRoles(roles)
|
||||
if (confirmed === 'unconfirmed') return `${side} · 角色待确认`
|
||||
const service = confirmed === 'left_service' ? channel === 0 : channel === 1
|
||||
return `${side} · ${service ? '客服端' : '患者/家属端'}(人工标注)`
|
||||
}
|
||||
/** Preserve coarse ASR windows; never invent speaker turn order or word-level timestamps. */
|
||||
export function dialogueWindows(segments: FollowupTranscriptSegment[]) {
|
||||
const windows = new Map<string, { id: string; start_ms: number; end_ms: number; segments: FollowupTranscriptSegment[] }>()
|
||||
for (const segment of segments) {
|
||||
const id = `${segment.start_ms}:${segment.end_ms}`
|
||||
if (!windows.has(id)) windows.set(id, { id, start_ms: segment.start_ms, end_ms: segment.end_ms, segments: [] })
|
||||
windows.get(id)!.segments.push(segment)
|
||||
}
|
||||
return [...windows.values()].sort((a, b) => a.start_ms - b.start_ms || a.end_ms - b.end_ms)
|
||||
.map(window => ({ ...window, segments: [...window.segments].sort((a, b) => (a.channel ?? -1) - (b.channel ?? -1)) }))
|
||||
}
|
||||
export function dialoguePlaybackStart(milliseconds: number): number {
|
||||
return Number.isFinite(milliseconds) ? Math.max(0, milliseconds - 3000) : 0
|
||||
}
|
||||
export function channelPlaybackLabel(channel: FollowupAudioChannel, roles: FollowupChannelRoles): string {
|
||||
if (roles === 'unconfirmed') return channel === 0 ? '只听左声道' : '只听右声道'
|
||||
const service = roles === 'left_service' ? channel === 0 : channel === 1
|
||||
return service ? '只听客服' : '只听患者/家属'
|
||||
}
|
||||
export const cloneReviewItems = (items: FollowupReviewItem[]): FollowupReviewItem[] => JSON.parse(JSON.stringify(items))
|
||||
export function fieldText(value: FollowupValue | undefined, field: FollowupField): string {
|
||||
if (value === undefined || value === null || value === '' || (Array.isArray(value) && !value.length)) return '未记录'
|
||||
const display = (part: string | number | boolean) => field.options?.find(option => String(option.value) === String(part))?.label || String(part)
|
||||
return Array.isArray(value) ? value.map(display).join('、') : display(value)
|
||||
}
|
||||
export function hasEditableReviewFields(item: FollowupReviewItem, fields: FollowupFields): boolean {
|
||||
return Object.keys(item.values).some(key => fields[item.kind]?.some(field => field.key === key && !field.readonly))
|
||||
}
|
||||
/** Prevent multiple selected writes to the same diagnosis field; never silently pick a last value. */
|
||||
export function reviewBatchIssue(items: FollowupReviewItem[], fields: FollowupFields): string {
|
||||
const touched = new Set<string>()
|
||||
for (const item of items.filter(item => item.selected)) {
|
||||
if (item.kind === 'diagnosis') {
|
||||
for (const key of Object.keys(item.values)) {
|
||||
if (touched.has(key)) {
|
||||
const label = fields.diagnosis?.find(field => field.key === key)?.label || key
|
||||
return `${label}存在多条选中建议,请合并内容到一项或仅保留一项后再确认写入`
|
||||
}
|
||||
touched.add(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
export function reviewFieldIssue(item: FollowupReviewItem, fields: FollowupFields): string {
|
||||
if (!item.selected) return ''
|
||||
for (const key of Object.keys(item.values)) {
|
||||
const field = fields[item.kind]?.find(candidate => candidate.key === key)
|
||||
if (!field || field.readonly) return `${field?.label || key} 当前不可编辑,请先取消采纳该字段`
|
||||
}
|
||||
return ''
|
||||
}
|
||||
export function reviewIssue(item: FollowupReviewItem, canDaily: boolean): string {
|
||||
if (!item.selected) return ''
|
||||
if (item.needs_review) return '请明确核对不确定、冲突或重复内容'
|
||||
if (item.kind !== 'diagnosis' && !canDaily) return '没有日常记录编辑权限'
|
||||
if (!Object.keys(item.values).length) return '至少填写一个有效字段'
|
||||
if (item.kind !== 'diagnosis' && !validDate(item.record_date)) return '请核对并填写有效的记录日期'
|
||||
if (item.record_time && !/^([01]\d|2[0-3]):[0-5]\d(?::[0-5]\d)?$/.test(item.record_time)) return '请填写有效的记录时间'
|
||||
return ''
|
||||
}
|
||||
/** Pending is independent of selection so filtering never hides unresolved items. */
|
||||
export function reviewPending(item: FollowupReviewItem, fields: FollowupFields, canDaily: boolean): boolean {
|
||||
return item.needs_review || !!reviewFieldIssue({ ...item, selected: true }, fields) || !!reviewIssue({ ...item, selected: true }, canDaily)
|
||||
}
|
||||
export function reviewCounts(items: FollowupReviewItem[], fields: FollowupFields, canDaily: boolean) {
|
||||
return {
|
||||
all: items.length,
|
||||
diagnosis: items.filter(item => item.kind === 'diagnosis').length,
|
||||
daily: items.filter(item => item.kind !== 'diagnosis').length,
|
||||
pending: items.filter(item => reviewPending(item, fields, canDaily)).length,
|
||||
selected: items.filter(item => item.selected).length
|
||||
}
|
||||
}
|
||||
export function changedReviewTarget(items: FollowupReviewItem[], snapshot: FollowupReviewItem[]): boolean {
|
||||
const previous = new Map(snapshot.map(item => [item.id, item]))
|
||||
return items.some(item => {
|
||||
const original = previous.get(item.id)
|
||||
return item.selected && original && (item.target_id !== original.target_id || item.record_date !== original.record_date)
|
||||
})
|
||||
}
|
||||
/** The API accepts legacy seconds, while the review editor exposes minute precision. */
|
||||
export function reviewTimeValue(value: string | null): string | null {
|
||||
return value && /^([01]\d|2[0-3]):[0-5]\d(?::[0-5]\d)?$/.test(value) ? value.slice(0, 5) : null
|
||||
}
|
||||
export function validDate(value: string | null): boolean {
|
||||
if (!value || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return false
|
||||
const [year, month, day] = value.split('-').map(Number)
|
||||
const date = new Date(Date.UTC(year, month - 1, day))
|
||||
return date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day
|
||||
}
|
||||
/** A stale snapshot refresh never discards user values or reselects an item for them. */
|
||||
export function rebaseReviewItems(local: FollowupReviewItem[], fresh: FollowupReviewItem[]): FollowupReviewItem[] {
|
||||
const previous = new Map(local.map(item => [item.id, item]))
|
||||
return cloneReviewItems(fresh).map(item => {
|
||||
const edit = previous.get(item.id)
|
||||
return edit ? { ...item, values: { ...edit.values }, record_date: edit.record_date, record_time: edit.record_time,
|
||||
time_period: edit.time_period, target_id: edit.target_id, selected: false, needs_review: true } : item
|
||||
})
|
||||
}
|
||||
/** Missing preview readiness/scope fails closed; normal deployments retain their full-verification rules. */
|
||||
export function followupFeatureEnabled(caps: FollowupCapabilities | null): boolean {
|
||||
return !!caps?.enabled && (!caps.preview_only || caps.test_scope_allowed === true)
|
||||
}
|
||||
export function followupAudioReady(caps: FollowupCapabilities | null): boolean {
|
||||
return !!caps && (caps.preview_only ? caps.preview_ready === true && caps.test_scope_allowed === true : caps.audio_verified || (caps.manual_review_enabled === true && caps.manual_review_ready === true))
|
||||
}
|
||||
export function followupCanReview(caps: FollowupCapabilities | null, previewTask = false): boolean {
|
||||
return followupFeatureEnabled(caps) && !!caps && (caps.preview_only || previewTask ? caps.can_review === true : caps.can_apply)
|
||||
}
|
||||
export function followupCanApply(caps: FollowupCapabilities | null, previewTask = false): boolean {
|
||||
return followupFeatureEnabled(caps) && followupAudioReady(caps) && !!caps && !caps.preview_only && !previewTask && caps.can_apply
|
||||
}
|
||||
export function uploadIssue(file: Pick<File, 'size' | 'name'> | null, recordedAt: string, model: string, caps: FollowupCapabilities | null, viewOnly: boolean): string {
|
||||
if (viewOnly || !caps || !followupFeatureEnabled(caps) || !followupAudioReady(caps) || !caps.can_upload) return '当前不可上传录音'
|
||||
if (!file || !file.size) return '请选择非空录音文件'
|
||||
if (file.size > caps.limits.max_bytes) return '录音文件超过大小限制'
|
||||
if (!/\.(mp3|wav|m4a|amr)$/i.test(file.name)) return '请选择支持的录音格式'
|
||||
if (!/^\d{4}-\d{2}-\d{2} ([01]\d|2[0-3]):[0-5]\d:[0-5]\d$/.test(recordedAt) || !validDate(recordedAt.slice(0, 10))) return '请填写实际录音时间(北京时间)'
|
||||
if (!caps.models.some(option => option.value === model)) return '请选择可用的分析模型'
|
||||
return ''
|
||||
}
|
||||
/** All async operations capture one generation; invalidate on patient switch, hide or dispose. */
|
||||
export function createFollowupScope() {
|
||||
let generation = 0
|
||||
let controller = new AbortController()
|
||||
return {
|
||||
capture: () => ({ generation, signal: controller.signal }),
|
||||
current: (ticket: { generation: number; signal: AbortSignal }) => ticket.generation === generation && !ticket.signal.aborted,
|
||||
invalidate() { controller.abort(); generation++; controller = new AbortController() }
|
||||
}
|
||||
}
|
||||
export async function uploadChunks(
|
||||
file: Blob,
|
||||
chunkBytes: number,
|
||||
send: (index: number, chunk: Blob) => Promise<{ received: boolean }>,
|
||||
isCurrent: () => boolean,
|
||||
progress: (percent: number) => void
|
||||
): Promise<boolean> {
|
||||
if (!Number.isSafeInteger(chunkBytes) || chunkBytes < 65536 || chunkBytes > FOLLOWUP_CHUNK_BYTES) throw new Error('服务端分片配置不匹配,请联系管理员')
|
||||
for (let start = 0, index = 0; start < file.size; start += chunkBytes, index++) {
|
||||
if (!isCurrent()) return false
|
||||
const result = await send(index, file.slice(start, start + chunkBytes))
|
||||
if (!isCurrent()) return false
|
||||
if (!result.received) throw new Error('录音分片未被服务端确认,上传已停止')
|
||||
progress(Math.round(Math.min(start + chunkBytes, file.size) / file.size * 100))
|
||||
}
|
||||
return isCurrent()
|
||||
}
|
||||
export function followupError(error: unknown): string {
|
||||
return error instanceof Error ? error.message : '请求失败;当前审核内容已保留,请刷新任务状态后确认'
|
||||
}
|
||||
@@ -52,7 +52,7 @@
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
<el-tabs v-model="activeTab" :before-leave="beforeTabLeave" class="diagnosis-tabs tcm-diagnosis-tabs">
|
||||
<el-tabs v-model="activeTab" class="diagnosis-tabs tcm-diagnosis-tabs">
|
||||
<!-- 基本信息标签页 -->
|
||||
<el-tab-pane label="病历" name="basic">
|
||||
<el-form
|
||||
@@ -660,7 +660,6 @@
|
||||
lazy
|
||||
>
|
||||
<daily-matrix
|
||||
ref="dailyMatrixRef"
|
||||
v-if="formData.id"
|
||||
:diagnosis-id="Number(formData.id)"
|
||||
:patient-id="Number(formData.patient_id)"
|
||||
@@ -671,33 +670,6 @@
|
||||
<el-empty v-else description="请先保存诊单后查看" />
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane v-if="formData.id && hasFollowupAudioPermission" label="回访录音" name="followupAudio" lazy>
|
||||
<el-alert v-if="followupCapabilitiesLoading" title="正在检查回访录音状态…" type="info" :closable="false" show-icon />
|
||||
<div v-else-if="followupCapabilitiesError" role="alert">
|
||||
<el-alert :title="followupCapabilitiesError" type="error" :closable="false" show-icon />
|
||||
<el-button class="mt-3" @click="loadFollowupCapabilities">重新加载</el-button>
|
||||
</div>
|
||||
<FollowupAudioPanel
|
||||
v-else-if="visible && followupCapabilities?.enabled && followupCapabilitiesDiagnosisId === Number(formData.id)"
|
||||
:key="Number(formData.id)"
|
||||
ref="followupAudioRef"
|
||||
:diagnosis-id="Number(formData.id)"
|
||||
:view-only="viewOnly"
|
||||
:active="activeTab === 'followupAudio'"
|
||||
:capabilities="followupCapabilities"
|
||||
:basic-dirty="basicDirty"
|
||||
:before-apply="canApplyFollowup"
|
||||
@applied="handleFollowupApplied"
|
||||
/>
|
||||
<el-alert
|
||||
v-else
|
||||
:title="followupCapabilities?.test_scope_allowed === false ? '当前诊单未开放回访录音' : '当前未启用回访录音,请联系管理员确认'"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
/>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 病历记录标签页(开方后自动显示) -->
|
||||
<el-tab-pane label="处方" name="bingli" :disabled="!formData.id" v-if="hasPermission(['tcm.diagnosis/chufang'])">
|
||||
<div v-if="formData.id" class="bingli-tab-content">
|
||||
@@ -813,7 +785,6 @@
|
||||
</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'
|
||||
@@ -823,8 +794,6 @@ 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'
|
||||
@@ -842,15 +811,6 @@ 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 followupCapabilitiesLoading = ref(false)
|
||||
const followupCapabilitiesError = ref('')
|
||||
const followupCapabilitiesDiagnosisId = ref<number | null>(null)
|
||||
const basicSnapshot = ref('')
|
||||
let diagnosisGeneration = 0
|
||||
let capabilitiesRequest: AbortController | undefined
|
||||
|
||||
const visible = ref(false)
|
||||
/** 只读查看:与 consumers 处方等处「查看诊单」一致,全部 Tab 仅展示不可保存 */
|
||||
@@ -862,7 +822,6 @@ const submitting = ref(false)
|
||||
/** 拥有后手机号可正常编辑,失焦不再强制脱敏 */
|
||||
const hasPhonePlainPermission = computed(() => hasPermission(['tcm.diagnosis/phonePlain']))
|
||||
const hasDailyRecordPermission = computed(() => hasPermission(['tcm.diagnosis/dailyRecord']))
|
||||
const hasFollowupAudioPermission = computed(() => hasPermission(['tcm.diagnosis/followupAudio']))
|
||||
const patientBasicLocked = ref(false)
|
||||
const canEditPatientBasicFromApi = ref(true)
|
||||
const latestPrescriptionOrder = ref<{
|
||||
@@ -1011,77 +970,6 @@ 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('录音已采纳,但病历或日常记录刷新失败,请手动刷新查看')
|
||||
}
|
||||
}
|
||||
function resetFollowupCapabilities() {
|
||||
capabilitiesRequest?.abort()
|
||||
capabilitiesRequest = undefined
|
||||
followupCapabilities.value = null
|
||||
followupCapabilitiesDiagnosisId.value = null
|
||||
followupCapabilitiesLoading.value = false
|
||||
followupCapabilitiesError.value = ''
|
||||
}
|
||||
async function loadFollowupCapabilities(): Promise<void> {
|
||||
// A rapid second click must not create another request for the same context.
|
||||
if (followupCapabilitiesLoading.value) return
|
||||
resetFollowupCapabilities()
|
||||
const id = Number(formData.value.id)
|
||||
if (!visible.value || !id || !hasFollowupAudioPermission.value) return
|
||||
const generation = diagnosisGeneration
|
||||
const request = new AbortController()
|
||||
capabilitiesRequest = request
|
||||
followupCapabilitiesLoading.value = true
|
||||
const isCurrent = () => capabilitiesRequest === request && !request.signal.aborted
|
||||
&& generation === diagnosisGeneration && visible.value && Number(formData.value.id) === id
|
||||
try {
|
||||
const result = await followupAudioCapabilities(id, request.signal)
|
||||
if (!isCurrent()) return
|
||||
if (typeof result?.enabled !== 'boolean') throw new Error('Invalid capability response')
|
||||
followupCapabilities.value = result
|
||||
followupCapabilitiesDiagnosisId.value = id
|
||||
} catch {
|
||||
if (!isCurrent()) return
|
||||
// No endpoint failure (including permission denial) may mount the panel.
|
||||
followupCapabilitiesError.value = '回访录音状态加载失败,请重新加载;若仍失败,请联系管理员检查权限或服务状态'
|
||||
} finally {
|
||||
if (isCurrent()) {
|
||||
followupCapabilitiesLoading.value = false
|
||||
capabilitiesRequest = undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
watch([visible, () => formData.value.id, hasFollowupAudioPermission], () => {
|
||||
// Invalidate synchronously on patient switches and permission changes.
|
||||
if (!hasFollowupAudioPermission.value && activeTab.value === 'followupAudio') activeTab.value = 'basic'
|
||||
resetFollowupCapabilities()
|
||||
void loadFollowupCapabilities()
|
||||
}, { flush: 'sync' })
|
||||
onBeforeUnmount(() => { diagnosisGeneration++; resetFollowupCapabilities() })
|
||||
|
||||
/** 就诊卡创建来源(与 zyt_tcm_diagnosis.create_source 一致) */
|
||||
const createSourceOptions = [
|
||||
{ value: '', label: '未设置' },
|
||||
@@ -1372,14 +1260,8 @@ const getDictOptions = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDiagnosisDetailIntoForm(id: number, preserveDirty = false): Promise<void> {
|
||||
const generation = diagnosisGeneration
|
||||
async function loadDiagnosisDetailIntoForm(id: number): Promise<void> {
|
||||
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 || ''
|
||||
@@ -1395,7 +1277,6 @@ async function loadDiagnosisDetailIntoForm(id: number, preserveDirty = false): P
|
||||
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 = () => {
|
||||
@@ -1405,11 +1286,6 @@ const resetPatientBasicLockState = () => {
|
||||
}
|
||||
|
||||
const open = async (type: string, id?: number) => {
|
||||
const generation = ++diagnosisGeneration
|
||||
submitting.value = false
|
||||
basicSnapshot.value = ''
|
||||
resetFollowupCapabilities()
|
||||
formData.value.id = ''
|
||||
viewOnly.value = false
|
||||
mode.value = type
|
||||
visible.value = true
|
||||
@@ -1420,7 +1296,6 @@ const open = async (type: string, id?: number) => {
|
||||
|
||||
// 加载字典数据
|
||||
await getDictOptions()
|
||||
if (generation !== diagnosisGeneration || !visible.value) return
|
||||
|
||||
if (type === 'edit' && id) {
|
||||
await loadDiagnosisDetailIntoForm(id)
|
||||
@@ -1435,11 +1310,6 @@ const open = async (type: string, id?: number) => {
|
||||
/** 只读打开:与编辑同一套界面与全部 Tab,不可保存 */
|
||||
const openViewOnly = async (id: number) => {
|
||||
if (!id) return
|
||||
const generation = ++diagnosisGeneration
|
||||
submitting.value = false
|
||||
basicSnapshot.value = ''
|
||||
resetFollowupCapabilities()
|
||||
formData.value.id = ''
|
||||
viewOnly.value = true
|
||||
mode.value = 'edit'
|
||||
visible.value = true
|
||||
@@ -1448,17 +1318,13 @@ 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 || submitting.value) return
|
||||
const generation = diagnosisGeneration
|
||||
if (viewOnly.value) return
|
||||
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 {
|
||||
// 准备提交数据,使用原始完整数据
|
||||
@@ -1470,7 +1336,6 @@ const handleSubmit = async () => {
|
||||
|
||||
if (mode.value === 'add') {
|
||||
const result = await tcmDiagnosisAdd(submitData)
|
||||
if (generation !== diagnosisGeneration || !visible.value) return
|
||||
|
||||
// 如果是新增,保存成功后切换到编辑模式,这样可以添加血糖血压记录
|
||||
if (result && result.id) {
|
||||
@@ -1478,14 +1343,11 @@ 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 || ''
|
||||
@@ -1505,13 +1367,11 @@ 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 {
|
||||
if (generation === diagnosisGeneration) submitting.value = false
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1535,11 +1395,7 @@ const handleOpenPrescription = () => {
|
||||
prescriptionRef.value?.open(data)
|
||||
}
|
||||
|
||||
const handleClose = async () => {
|
||||
if (followupAudioRef.value && !await followupAudioRef.value.confirmLeave()) return
|
||||
diagnosisGeneration++
|
||||
resetFollowupCapabilities()
|
||||
basicSnapshot.value = ''
|
||||
const handleClose = () => {
|
||||
formRef.value?.resetFields()
|
||||
formData.value = {
|
||||
id: '',
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
formData.pending_assign !== '1' &&
|
||||
formData.pending_booking !== '1' &&
|
||||
formData.completed_appointment !== '1' &&
|
||||
formData.revisit_state === '' &&
|
||||
!hasLatestAppointmentFilter() &&
|
||||
formData.appointment_date === tab.value
|
||||
}
|
||||
@@ -31,14 +30,6 @@
|
||||
待预约
|
||||
<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"
|
||||
@@ -156,36 +147,6 @@
|
||||
: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>
|
||||
@@ -317,20 +278,22 @@
|
||||
<span v-else class="status-unconfirmed">未确认</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<!-- 复诊节点:吞掉原「复诊」「开方」两列;排序走后端 sort_revisit_due(药尽日,NULL 置后) -->
|
||||
<el-table-column
|
||||
label="复诊节点"
|
||||
prop="revisit_due_date"
|
||||
min-width="180"
|
||||
sortable="custom"
|
||||
>
|
||||
<el-table-column label="复诊" min-width="120">
|
||||
<template #default="{ row }">
|
||||
<revisit-node-cell
|
||||
:due="revisitDueOf(row)"
|
||||
:has-prescription="!!row.has_prescription"
|
||||
:followup-doctor-name="row.followup_doctor_name || ''"
|
||||
:prescribed-text="prescribedTextOf(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>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="助理" width="100" show-overflow-tooltip>
|
||||
@@ -338,6 +301,12 @@
|
||||
<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"
|
||||
@@ -550,6 +519,7 @@
|
||||
</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>
|
||||
@@ -767,7 +737,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="tcmDiagnosis">
|
||||
import { tcmDiagnosisLists, tcmDiagnosisDelete, tcmDiagnosisAssign, tcmDiagnosisGuahaoLogList, getAssistants, generateMiniProgramQrcode, generateOrderQrcode, getWechatChatRecords, addWechatChatRecord, deleteWechatChatRecord, getWechatExternalContact, fillIdCard, diagnosisReadonlyDetail } from '@/api/tcm'
|
||||
import { tcmDiagnosisLists, tcmDiagnosisDelete, tcmDiagnosisAssign, tcmDiagnosisGuahaoLogList, getAssistants, generateMiniProgramQrcode, generateOrderQrcode, getWechatChatRecords, addWechatChatRecord, deleteWechatChatRecord, getWechatExternalContact, fillIdCard } from '@/api/tcm'
|
||||
import { orderCreate } from '@/api/order'
|
||||
import { cancelAppointment } from '@/api/doctor'
|
||||
import { usePaging } from '@/hooks/usePaging'
|
||||
@@ -782,15 +752,6 @@ 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'))
|
||||
@@ -837,12 +798,6 @@ 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',
|
||||
@@ -943,13 +898,11 @@ 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,
|
||||
revisit_state: formData.revisit_state,
|
||||
revisit_days_bucket: formData.revisit_days_bucket
|
||||
latest_assign_end_date: formData.latest_assign_end_date
|
||||
}
|
||||
}
|
||||
|
||||
/** 顶部 Tab 角标 count 请求:带上共用筛选,再叠加各 Tab 专属条件(revisit_state 视为 Tab 条件,角标间互不影响) */
|
||||
/** 顶部 Tab 角标 count 请求:带上共用筛选,再叠加各 Tab 专属条件 */
|
||||
function buildDateCountRequestPayload(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
page_no: 1,
|
||||
@@ -962,8 +915,6 @@ function buildDateCountRequestPayload(overrides: Record<string, unknown> = {}):
|
||||
pending_assign_order_month: '',
|
||||
pending_assign_keyword: '',
|
||||
sort_unserved_days: '',
|
||||
revisit_state: '',
|
||||
sort_revisit_due: '',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
@@ -1009,9 +960,6 @@ 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 !== '') {
|
||||
@@ -1048,7 +996,6 @@ const handleDateTabClick = async (value: string) => {
|
||||
formData.pending_booking = ''
|
||||
formData.completed_appointment = ''
|
||||
formData.has_appointment = ''
|
||||
clearRevisitTabFilters()
|
||||
clearLatestAppointmentFilters()
|
||||
formData.appointment_date = value
|
||||
pager.page = 1
|
||||
@@ -1065,7 +1012,6 @@ const handlePendingBookingTabClick = async () => {
|
||||
formData.completed_appointment = ''
|
||||
formData.appointment_date = ''
|
||||
formData.has_appointment = '0'
|
||||
clearRevisitTabFilters()
|
||||
clearLatestAppointmentFilters()
|
||||
pager.page = 1
|
||||
await getLists()
|
||||
@@ -1081,7 +1027,6 @@ const handleCompletedVisitTabClick = async () => {
|
||||
formData.completed_appointment = '1'
|
||||
formData.has_appointment = ''
|
||||
formData.appointment_date = ''
|
||||
clearRevisitTabFilters()
|
||||
clearLatestAppointmentFilters()
|
||||
pager.page = 1
|
||||
await getLists()
|
||||
@@ -1094,7 +1039,6 @@ 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')
|
||||
@@ -1113,66 +1057,10 @@ 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, revisitDue] = await Promise.all([
|
||||
const [yesterday, dayBefore, today, tomorrow, dayAfter, all, noApt, doneVisit, pending] = await Promise.all([
|
||||
tcmDiagnosisLists(
|
||||
buildDateCountRequestPayload({ appointment_date: yesterdayStr.value, has_appointment: '' }) as any
|
||||
),
|
||||
@@ -1193,10 +1081,7 @@ const fetchDateCounts = async () => {
|
||||
tcmDiagnosisLists(
|
||||
buildDateCountRequestPayload({ completed_appointment: 1, has_appointment: '' }) as any
|
||||
),
|
||||
tcmDiagnosisLists(buildPendingAssignCountPayload() as any),
|
||||
tcmDiagnosisLists(
|
||||
buildDateCountRequestPayload({ revisit_state: 'due', has_appointment: '' }) as any
|
||||
)
|
||||
tcmDiagnosisLists(buildPendingAssignCountPayload() as any)
|
||||
])
|
||||
dateCounts.value = {
|
||||
[yesterdayStr.value]: yesterday?.count ?? 0,
|
||||
@@ -1209,7 +1094,6 @@ 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)
|
||||
}
|
||||
@@ -1368,11 +1252,12 @@ const handleTableSortChange = ({
|
||||
prop: string
|
||||
order: 'ascending' | 'descending' | null
|
||||
}) => {
|
||||
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 : ''
|
||||
if (prop === 'unserved_days') {
|
||||
formData.sort_unserved_days =
|
||||
order === 'ascending' ? 'asc' : order === 'descending' ? 'desc' : ''
|
||||
} else {
|
||||
formData.sort_unserved_days = ''
|
||||
}
|
||||
pager.page = 1
|
||||
getLists()
|
||||
}
|
||||
@@ -1426,9 +1311,6 @@ 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 = ''
|
||||
@@ -1461,10 +1343,8 @@ 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 ''
|
||||
@@ -1476,8 +1356,6 @@ 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(
|
||||
@@ -1492,53 +1370,6 @@ 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)
|
||||
|
||||
@@ -1783,13 +1614,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
|
||||
})
|
||||
@@ -2389,57 +2220,22 @@ const goReadonly = (row: any) => {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 默认展示当天挂号,突出重点
|
||||
formData.appointment_date = todayStr.value
|
||||
// 字典与列表并行,避免 await 字典阻塞首屏列表(LCP)
|
||||
getDictOptions()
|
||||
if (String(route.query.revisit_state ?? '') === 'due') {
|
||||
// 复诊到期弹窗「查看全部」深链:直接激活「复诊到期」chip(内部会 getLists + fetchDateCounts)
|
||||
void handleRevisitDueTabClick()
|
||||
} else {
|
||||
// 默认展示当天挂号,突出重点
|
||||
formData.appointment_date = todayStr.value
|
||||
getLists()
|
||||
scheduleDeferredDateCounts()
|
||||
}
|
||||
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) && num > 0) {
|
||||
if (String(route.query.action ?? '') === 'appointment') {
|
||||
// 复诊到期弹窗「去挂号」深链:等首屏列表回来后再找行,找不到走详情接口
|
||||
void getListsPromiseForRoute().then(() => openAppointmentFromRoute(num))
|
||||
} else {
|
||||
pendingRouteDiagnosisId.value = num
|
||||
}
|
||||
}
|
||||
if (!Number.isNaN(num)) 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)
|
||||
@@ -2517,21 +2313,6 @@ 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);
|
||||
@@ -2670,14 +2451,6 @@ onUnmounted(() => {
|
||||
.latest-appointment-channel {
|
||||
width: 170px;
|
||||
}
|
||||
|
||||
.revisit-state-select {
|
||||
width: 170px;
|
||||
}
|
||||
|
||||
.revisit-days-bucket-select {
|
||||
width: 130px;
|
||||
}
|
||||
}
|
||||
|
||||
.list-card {
|
||||
@@ -2868,6 +2641,17 @@ 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;
|
||||
@@ -2912,6 +2696,33 @@ 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);
|
||||
@@ -2971,22 +2782,6 @@ 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,7 +25,6 @@
|
||||
formData.pending_assign !== '1' &&
|
||||
formData.pending_booking !== '1' &&
|
||||
formData.completed_appointment !== '1' &&
|
||||
formData.revisit_state === '' &&
|
||||
formData.appointment_date === tab.value
|
||||
}
|
||||
]"
|
||||
@@ -41,13 +40,6 @@
|
||||
待预约
|
||||
<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"
|
||||
@@ -178,17 +170,13 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 复诊节点块(已开方或有复诊节点数据时显示;卡片头已有「已开方」标签,此处隐藏开方文案) -->
|
||||
<div v-if="row.has_prescription || hasRevisitDue(revisitDueOf(row))" class="dh5-row dh5-row--followup">
|
||||
<!-- 复诊块(仅已开方时显示) -->
|
||||
<div v-if="row.has_prescription" class="dh5-row dh5-row--followup">
|
||||
<span class="dh5-row-key">复诊</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 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>
|
||||
</div>
|
||||
|
||||
@@ -637,13 +625,6 @@ 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'))
|
||||
@@ -704,9 +685,7 @@ const formData = reactive({
|
||||
has_appointment: '' as '' | '0' | '1',
|
||||
pending_assign: '' as '' | '1',
|
||||
pending_booking: '' as '' | '1',
|
||||
completed_appointment: '' as '' | '1',
|
||||
/** 顶部「复诊到期」chip:due = 到期未挂号 ∪ 过号未面诊(后端 revisit_state) */
|
||||
revisit_state: '' as '' | 'due'
|
||||
completed_appointment: '' as '' | '1'
|
||||
})
|
||||
|
||||
const showMoreFilter = ref(false)
|
||||
@@ -761,7 +740,6 @@ const handleReset = () => {
|
||||
formData.pending_assign = ''
|
||||
formData.pending_booking = ''
|
||||
formData.completed_appointment = ''
|
||||
formData.revisit_state = ''
|
||||
pager.page = 1
|
||||
getLists()
|
||||
fetchDateCounts()
|
||||
@@ -789,7 +767,6 @@ 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
|
||||
@@ -801,7 +778,6 @@ 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
|
||||
@@ -813,7 +789,6 @@ 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
|
||||
@@ -825,7 +800,6 @@ 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
|
||||
@@ -833,27 +807,9 @@ 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, revisitDue] = await Promise.all([
|
||||
const [yesterday, dayBefore, today, tomorrow, dayAfter, all, noApt, doneVisit, pending] = 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 }),
|
||||
@@ -862,8 +818,7 @@ 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({ revisit_state: 'due', page_no: 1, page_size: 1 })
|
||||
tcmDiagnosisLists({ pending_assign: 1, page_no: 1, page_size: 1 })
|
||||
])
|
||||
dateCounts.value = {
|
||||
[yesterdayStr.value]: yesterday?.count ?? 0,
|
||||
@@ -876,7 +831,6 @@ 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)
|
||||
}
|
||||
@@ -944,9 +898,7 @@ 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 ''
|
||||
@@ -1630,8 +1582,6 @@ $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;
|
||||
@@ -1725,12 +1675,6 @@ $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;
|
||||
@@ -1856,11 +1800,6 @@ $dh5-card-bg: #ffffff;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 复诊节点块:单元格组件自带多行布局,取消外层 flex 换行间距 */
|
||||
.dh5-row-val--revisit {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* 挂号块 - 状态色 */
|
||||
.dh5-row--apt {
|
||||
background: #fafbfc;
|
||||
|
||||
@@ -25,17 +25,6 @@
|
||||
</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>
|
||||
|
||||
@@ -167,12 +156,6 @@ 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'
|
||||
@@ -210,17 +193,6 @@ 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 → 绿
|
||||
@@ -380,13 +352,6 @@ 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 {
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const test = require('node:test')
|
||||
const ts = require('typescript')
|
||||
const vue = require('vue')
|
||||
const { parse, compileScript, compileTemplate } = require('@vue/compiler-sfc')
|
||||
const file = process.env.FOLLOWUP_EXERCISE_MATRIX_SOURCE || path.resolve(__dirname, '../src/views/tcm/diagnosis/components/DailyMatrix.vue')
|
||||
const source = fs.readFileSync(file, 'utf8')
|
||||
function setup() {
|
||||
const descriptor = parse(source, {filename:file}).descriptor
|
||||
const compiled = compileScript(descriptor, {id:'exercise-null'})
|
||||
const output = ts.transpileModule(compiled.content, {compilerOptions:{target:ts.ScriptTarget.ES2022,module:ts.ModuleKind.CommonJS}}).outputText
|
||||
const calls=[]; const warnings=[]; const module={exports:{}}
|
||||
const api={diagnosisTrackingWindow:async()=>({}),diagnosisTrackingNotes:async()=>[],exerciseRecordEdit:async p=>calls.push(p),exerciseRecordAdd:async p=>calls.push(p)}
|
||||
new Function('require','module','exports',output)(name=>name==='vue'?{...vue,onMounted(){},onUnmounted(){}}:name==='@/utils/perm'?{hasPermission:()=>true}:name==='@/api/tcm'?api:name==='@/utils/feedback'?{default:{msgWarning:m=>warnings.push(m)}}:{default:{}},module,module.exports)
|
||||
const scope=vue.effectScope()
|
||||
const matrix=scope.run(()=>module.exports.default.setup({diagnosisId:1,patientId:101,readOnly:false},{expose(){}}))
|
||||
return {matrix,calls,warnings,dispose:()=>scope.stop()}
|
||||
}
|
||||
test('real DailyMatrix edit and submit preserve null duration/intensity',async()=>{
|
||||
const f=setup(); f.matrix.openExerciseRecord({id:1,record_date:'2026-09-29',exercise_type:'散步',duration:null,intensity:null},'2026-09-29')
|
||||
assert.equal(f.matrix.exerciseForm.value.duration,null);assert.equal(f.matrix.exerciseForm.value.intensity,null)
|
||||
f.matrix.exerciseForm.value.note='仅改备注';await f.matrix.submitExercise()
|
||||
assert.equal(f.calls.length,1);assert.equal(f.calls[0].duration,null);assert.equal(f.calls[0].intensity,null);assert.deepEqual(f.warnings,[]);f.dispose()
|
||||
})
|
||||
test('missing keys in existing row do not inherit manual-entry defaults',()=>{
|
||||
const f=setup();f.matrix.openExerciseRecord({id:2,exercise_type:'散步'},'2026-09-29')
|
||||
assert.equal(f.matrix.exerciseForm.value.duration,null);assert.equal(f.matrix.exerciseForm.value.intensity,null);f.dispose()
|
||||
})
|
||||
test('explicit known and zero values remain unchanged; cleared duration submits null',async()=>{
|
||||
const f=setup();f.matrix.openExerciseRecord({id:2,exercise_type:'散步',duration:0,intensity:1},'2026-09-29')
|
||||
await f.matrix.submitExercise();assert.equal(f.calls[0].duration,0);assert.equal(f.calls[0].intensity,1)
|
||||
f.matrix.exerciseForm.value.duration=undefined;await f.matrix.submitExercise();assert.equal(f.calls[1].duration,null);f.dispose()
|
||||
})
|
||||
test('unknown intensity option maps to null without conflating known low intensity',()=>{
|
||||
const f=setup();f.matrix.openExerciseRecord({id:1,exercise_type:'散步',duration:null,intensity:null},'2026-09-29')
|
||||
assert.equal(f.matrix.exerciseIntensity.value,'');f.matrix.exerciseIntensity.value=1;assert.equal(f.matrix.exerciseForm.value.intensity,1)
|
||||
f.matrix.exerciseIntensity.value='';assert.equal(f.matrix.exerciseForm.value.intensity,null)
|
||||
assert.equal(f.matrix.intensityLabel(null),'未记录');assert.equal(f.matrix.intensityLabel(1),'低强度');f.dispose()
|
||||
})
|
||||
test('matrix compiles and detail rendering explicitly distinguishes unknown from zero',()=>{
|
||||
const descriptor=parse(source,{filename:file}).descriptor;const script=compileScript(descriptor,{id:'exercise-compile'})
|
||||
const render=compileTemplate({source:descriptor.template.content,filename:file,id:'exercise-compile',compilerOptions:{bindingMetadata:script.bindings}})
|
||||
assert.deepEqual(render.errors,[]);assert.ok(/<el-option label="未记录" value=""/.test(source), "explicit unknown option exists")
|
||||
assert.ok(/record\.duration == null \? '未记录'/.test(source), 'null duration renders unknown')
|
||||
})
|
||||
@@ -1,64 +0,0 @@
|
||||
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()
|
||||
})
|
||||
@@ -1,253 +0,0 @@
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const test = require('node:test')
|
||||
const ts = require('typescript')
|
||||
const vue = require('vue')
|
||||
const { parse, compileScript, compileTemplate } = require('@vue/compiler-sfc')
|
||||
|
||||
const root = path.resolve(__dirname, '..')
|
||||
const apiPath = path.join(root, 'src/api/setting/prescription_ai_visibility.ts')
|
||||
const pagePath = path.join(root, 'src/views/setting/prescription_ai_visibility/index.vue')
|
||||
const { descriptor, errors } = parse(fs.readFileSync(pagePath, 'utf8'), { filename: pagePath })
|
||||
assert.deepEqual(errors, [])
|
||||
const script = compileScript(descriptor, { id: 'prescription-ai-visibility' })
|
||||
|
||||
function moduleFrom(source, dependencies) {
|
||||
const compiled = ts.transpileModule(source, {
|
||||
compilerOptions: {
|
||||
target: ts.ScriptTarget.ES2022,
|
||||
module: ts.ModuleKind.CommonJS,
|
||||
esModuleInterop: true
|
||||
}
|
||||
}).outputText
|
||||
const module = { exports: {} }
|
||||
new Function('require', 'module', 'exports', compiled)(dependencies, module, module.exports)
|
||||
return module.exports
|
||||
}
|
||||
|
||||
const makeApi = (request) =>
|
||||
moduleFrom(fs.readFileSync(apiPath, 'utf8'), (name) => {
|
||||
assert.equal(name, '@/utils/request')
|
||||
return request
|
||||
})
|
||||
|
||||
const tick = () => new Promise((resolve) => setImmediate(resolve))
|
||||
const deferred = () => {
|
||||
let resolve
|
||||
let reject
|
||||
const promise = new Promise((yes, no) => {
|
||||
resolve = yes
|
||||
reject = no
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
function makePage(overrides = {}) {
|
||||
const saved = []
|
||||
const messages = []
|
||||
const calls = []
|
||||
const api = {
|
||||
getPrescriptionAiCapabilities: async () => {
|
||||
calls.push('capabilities')
|
||||
return { agreement_visible: false, can_manage_visibility: true }
|
||||
},
|
||||
getPrescriptionAiVisibilityConfig: async () => {
|
||||
calls.push('config')
|
||||
return { role_ids: [2, 90], admin_ids: [3, 91] }
|
||||
},
|
||||
getPrescriptionAiVisibilityOptions: async () => {
|
||||
calls.push('options')
|
||||
return {
|
||||
roles: [{ id: 2, name: '医生' }],
|
||||
admins: [{ id: 3, name: '张医生', account: 'zhang.doctor' }]
|
||||
}
|
||||
},
|
||||
setPrescriptionAiVisibilityConfig: async (params) => {
|
||||
saved.push(params)
|
||||
return params
|
||||
},
|
||||
...overrides
|
||||
}
|
||||
const component = moduleFrom(script.content, (name) => {
|
||||
if (name === 'vue') return vue
|
||||
if (name === '@/api/setting/prescription_ai_visibility') return api
|
||||
if (name === '@/utils/feedback') {
|
||||
return { msgSuccess: (message) => messages.push(message) }
|
||||
}
|
||||
throw new Error(`Unexpected import: ${name}`)
|
||||
}).default
|
||||
const page = component.setup({}, { expose() {} })
|
||||
return { page, api, saved, messages, calls }
|
||||
}
|
||||
|
||||
test('API uses the dedicated routes and rejects malformed policy responses', async () => {
|
||||
const calls = []
|
||||
let response = { role_ids: [2], admin_ids: [3] }
|
||||
const api = makeApi({
|
||||
get: async (params) => {
|
||||
calls.push(['get', params])
|
||||
return response
|
||||
},
|
||||
post: async (params) => {
|
||||
calls.push(['post', params])
|
||||
return response
|
||||
}
|
||||
})
|
||||
const data = await api.getPrescriptionAiVisibilityConfig()
|
||||
data.role_ids.push(5)
|
||||
assert.deepEqual(response.role_ids, [2])
|
||||
await api.setPrescriptionAiVisibilityConfig({ role_ids: [], admin_ids: [] })
|
||||
await api.getPrescriptionAiVisibilityOptions()
|
||||
await api.getPrescriptionAiCapabilities()
|
||||
assert.deepEqual(calls, [
|
||||
['get', { url: '/tcm.prescriptionAi/getVisibilityConfig' }],
|
||||
[
|
||||
'post',
|
||||
{
|
||||
url: '/tcm.prescriptionAi/setVisibilityConfig',
|
||||
params: { role_ids: [], admin_ids: [] }
|
||||
}
|
||||
],
|
||||
['get', { url: '/tcm.prescriptionAi/visibilityOptions' }],
|
||||
['get', { url: '/tcm.prescriptionAi/capabilities' }]
|
||||
])
|
||||
for (response of [
|
||||
null,
|
||||
{},
|
||||
{ role_ids: [], admin_ids: ['3'] },
|
||||
{ role_ids: [-1], admin_ids: [] }
|
||||
]) {
|
||||
await assert.rejects(api.getPrescriptionAiVisibilityConfig(), /响应格式不正确/)
|
||||
await assert.rejects(
|
||||
api.setPrescriptionAiVisibilityConfig({ role_ids: [], admin_ids: [] }),
|
||||
/响应格式不正确/
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
test('initial loading is pending and cannot submit before the real policy is read', async () => {
|
||||
const pending = deferred()
|
||||
const { page, saved } = makePage({ getPrescriptionAiVisibilityConfig: () => pending.promise })
|
||||
assert.equal(page.loading.value, true)
|
||||
assert.equal(page.ready.value, false)
|
||||
assert.equal(page.hasLoaded.value, false)
|
||||
await page.handleSubmit()
|
||||
assert.deepEqual(saved, [])
|
||||
pending.reject(new Error('offline'))
|
||||
await tick()
|
||||
assert.equal(page.loading.value, false)
|
||||
assert.equal(page.ready.value, false)
|
||||
assert.match(page.loadError.value, /加载失败/)
|
||||
await page.handleSubmit()
|
||||
assert.deepEqual(saved, [])
|
||||
})
|
||||
|
||||
test('a failed reload cannot apply a partial policy or overwrite saved selections', async () => {
|
||||
const { page, api, saved } = makePage()
|
||||
await tick()
|
||||
api.getPrescriptionAiVisibilityConfig = async () => ({ role_ids: [], admin_ids: [] })
|
||||
api.getPrescriptionAiVisibilityOptions = async () => {
|
||||
throw new Error('options failed')
|
||||
}
|
||||
await page.loadConfig()
|
||||
assert.deepEqual(page.formData.role_ids, [2, 90])
|
||||
assert.deepEqual(page.formData.admin_ids, [3, 91])
|
||||
assert.equal(page.hasLoaded.value, true)
|
||||
assert.equal(page.ready.value, false)
|
||||
await page.handleSubmit()
|
||||
assert.deepEqual(saved, [])
|
||||
api.getPrescriptionAiVisibilityOptions = async () => ({ roles: [], admins: [] })
|
||||
await page.loadConfig()
|
||||
assert.equal(page.ready.value, true)
|
||||
assert.equal(page.loadError.value, '')
|
||||
assert.deepEqual(page.formData.role_ids, [])
|
||||
})
|
||||
|
||||
test('missing selected IDs stay visible and user labels support both name and account search', async () => {
|
||||
const { page, saved } = makePage()
|
||||
await tick()
|
||||
assert.equal(page.canManage.value, true)
|
||||
assert.equal(page.ready.value, true)
|
||||
assert.deepEqual(
|
||||
page.roleOptions.value.map((row) => row.id),
|
||||
[2, 90]
|
||||
)
|
||||
assert.match(page.roleOptions.value[1].name, /#90.*当前不在可选列表/)
|
||||
assert.deepEqual(
|
||||
page.adminOptions.value.map((row) => row.id),
|
||||
[3, 91]
|
||||
)
|
||||
assert.match(page.adminOptions.value[0].label, /张医生.*zhang\.doctor/)
|
||||
assert.match(page.adminOptions.value[1].label, /#91.*当前不在可选列表/)
|
||||
await page.handleSubmit()
|
||||
assert.deepEqual(saved, [{ role_ids: [2, 90], admin_ids: [3, 91] }])
|
||||
})
|
||||
|
||||
test('explicitly clearing both selectors saves the empty policy and applies normalized response', async () => {
|
||||
const { page, saved, messages, api } = makePage()
|
||||
await tick()
|
||||
page.formData.role_ids = []
|
||||
page.formData.admin_ids = []
|
||||
await page.handleSubmit()
|
||||
assert.deepEqual(saved, [{ role_ids: [], admin_ids: [] }])
|
||||
assert.equal(messages.length, 1)
|
||||
page.formData.role_ids = [90, 2, 90]
|
||||
api.setPrescriptionAiVisibilityConfig = async () => ({ role_ids: [2, 90], admin_ids: [] })
|
||||
await page.handleSubmit()
|
||||
assert.deepEqual(page.formData.role_ids, [2, 90])
|
||||
assert.equal(page.saving.value, false)
|
||||
})
|
||||
|
||||
test('pending save blocks duplicate writes and reload; failed write keeps the user selection', async () => {
|
||||
const pending = deferred()
|
||||
const saved = []
|
||||
const { page, calls } = makePage({
|
||||
setPrescriptionAiVisibilityConfig: (params) => {
|
||||
saved.push(params)
|
||||
return pending.promise
|
||||
}
|
||||
})
|
||||
await tick()
|
||||
page.formData.role_ids = [90]
|
||||
const first = page.handleSubmit()
|
||||
assert.equal(page.saving.value, true)
|
||||
await page.handleSubmit()
|
||||
await page.loadConfig()
|
||||
assert.equal(saved.length, 1)
|
||||
assert.equal(calls.filter((name) => name === 'capabilities').length, 1)
|
||||
pending.reject(new Error('offline'))
|
||||
await first
|
||||
assert.equal(page.saving.value, false)
|
||||
assert.equal(page.ready.value, true)
|
||||
assert.deepEqual(page.formData.role_ids, [90])
|
||||
assert.deepEqual(page.formData.admin_ids, [3, 91])
|
||||
assert.match(page.saveError.value, /当前选择已保留/)
|
||||
})
|
||||
|
||||
test('management capability is independent from viewing and denies policy reads when absent', async () => {
|
||||
const { page, calls, saved } = makePage({
|
||||
getPrescriptionAiCapabilities: async () => ({
|
||||
agreement_visible: true,
|
||||
can_manage_visibility: false
|
||||
})
|
||||
})
|
||||
await tick()
|
||||
assert.deepEqual(calls, [])
|
||||
assert.equal(page.canManage.value, false)
|
||||
assert.equal(page.hasLoaded.value, false)
|
||||
assert.equal(page.ready.value, false)
|
||||
assert.match(page.loadError.value, /无权管理/)
|
||||
await page.handleSubmit()
|
||||
assert.deepEqual(saved, [])
|
||||
})
|
||||
|
||||
test('actual settings template compiles with its script bindings', () => {
|
||||
const compiled = compileTemplate({
|
||||
source: descriptor.template.content,
|
||||
filename: pagePath,
|
||||
id: 'prescription-ai-visibility',
|
||||
compilerOptions: { bindingMetadata: script.bindings }
|
||||
})
|
||||
assert.deepEqual(compiled.errors, [])
|
||||
})
|
||||
@@ -1,167 +0,0 @@
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const test = require('node:test')
|
||||
const ts = require('typescript')
|
||||
const vue = require('vue')
|
||||
const { parse, compileScript, compileTemplate } = require('@vue/compiler-sfc')
|
||||
|
||||
const selected = [
|
||||
'patchUsageVisible', 'patchUsageSaving', 'patchUsageFormRef', 'patchUsageForm',
|
||||
'patchUsageRules', 'openPatchUsageDialog', 'resetPatchUsageForm', 'submitPatchUsage'
|
||||
]
|
||||
|
||||
for (const page of ['components/PrescriptionOrderDetailDrawer.vue', 'order_list_h5.vue']) {
|
||||
const filename = path.join(__dirname, '../src/views/consumer/prescription', page)
|
||||
const { descriptor, errors } = parse(fs.readFileSync(filename, 'utf8'), { filename })
|
||||
assert.deepEqual(errors, [])
|
||||
const script = compileScript(descriptor, { id: page })
|
||||
const template = compileTemplate({
|
||||
source: descriptor.template.content, filename, id: page,
|
||||
compilerOptions: { bindingMetadata: script.bindings }
|
||||
})
|
||||
assert.deepEqual(template.errors, [])
|
||||
|
||||
// Run the real handlers; replace the network and the rest of the order page.
|
||||
const ast = ts.createSourceFile(filename + '.ts', descriptor.scriptSetup.content, ts.ScriptTarget.Latest, true)
|
||||
const declarations = new Map()
|
||||
for (const node of ast.statements) {
|
||||
if (ts.isFunctionDeclaration(node) && node.name) declarations.set(node.name.text, node.getText(ast))
|
||||
if (ts.isVariableStatement(node)) {
|
||||
for (const declaration of node.declarationList.declarations) {
|
||||
declarations.set(declaration.name.getText(ast), `const ${declaration.getText(ast)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
const executed = page.startsWith('components/') ? [...selected, 'refresh'] : selected
|
||||
for (const name of executed) assert.ok(declarations.has(name), `${page}: missing ${name}`)
|
||||
const compiled = ts.transpileModule(executed.map(name => declarations.get(name)).join('\n'), {
|
||||
compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS }
|
||||
}).outputText
|
||||
|
||||
function instance({ hasAux = true, prescriptionType = '浓缩水丸', bagCount = 2, mainBagCount = 2, status = 2, fail = false } = {}) {
|
||||
const calls = []
|
||||
const events = []
|
||||
const aux = { times_per_day: 3, usage_days: 10, dosage_bag_count: bagCount }
|
||||
const detailPrescription = vue.ref({ prescription_type: prescriptionType, times_per_day: 3, usage_days: 10, dosage_bag_count: mainBagCount })
|
||||
const detailAuxUsage = vue.ref(hasAux ? aux : null)
|
||||
const detailData = vue.ref({
|
||||
id: 42, prescription_id: 17, medication_days: 10, fulfillment_status: status,
|
||||
gancao_reciperl_order_no: 'TEST-SUBMITTED', ej_pharmacy_order_no: 'TEST-EJ-SUBMITTED'
|
||||
})
|
||||
const refresh = async () => {
|
||||
if (calls[0]?.dosage_bag_count) detailPrescription.value.dosage_bag_count = calls[0].dosage_bag_count
|
||||
if (calls[0]?.aux_dosage_bag_count) detailAuxUsage.value.dosage_bag_count = calls[0].aux_dosage_bag_count
|
||||
events.push('refresh')
|
||||
return { ...detailData.value }
|
||||
}
|
||||
const globals = {
|
||||
ref: vue.ref, reactive: vue.reactive, computed: vue.computed, nextTick: vue.nextTick,
|
||||
detailPrescription, detailAuxUsage, detailData, detailHasAuxHerbs: vue.ref(hasAux),
|
||||
feedback: { msgWarning() {}, msgSuccess() {} },
|
||||
prescriptionOrderPatchPrescriptionUsage: async payload => {
|
||||
calls.push(payload)
|
||||
if (fail) throw new Error('Save failed')
|
||||
},
|
||||
requestDetail: refresh, refreshCurrentPrescriptionOrderDetail: refresh,
|
||||
props: { loadRelatedData: true },
|
||||
emit: event => events.push(event),
|
||||
fetchLogs: async () => {
|
||||
events.push('logs:start')
|
||||
await Promise.resolve()
|
||||
events.push('logs:done')
|
||||
},
|
||||
getLists: () => events.push('lists')
|
||||
}
|
||||
const state = new Function(...Object.keys(globals), `${compiled}\nreturn { ${selected.join(', ')} }`)(...Object.values(globals))
|
||||
state.patchUsageFormRef.value = { validate: async () => {}, clearValidate() {} }
|
||||
return { state, calls, events, detailAuxUsage, detailPrescription }
|
||||
}
|
||||
|
||||
test(`${page}: edits auxiliary bags and refreshes the persisted value without changing usage`, async () => {
|
||||
const { state, calls, events, detailAuxUsage } = instance()
|
||||
state.openPatchUsageDialog()
|
||||
assert.equal(state.patchUsageForm.aux_dosage_bag_count, 2)
|
||||
state.patchUsageForm.aux_dosage_bag_count = 4
|
||||
await state.submitPatchUsage()
|
||||
assert.deepEqual(calls, [{
|
||||
id: 42, times_per_day: 3, usage_days: 10, medication_days: 10,
|
||||
dosage_bag_count: 2,
|
||||
aux_times_per_day: 3, aux_usage_days: 10, aux_dosage_bag_count: 4
|
||||
}])
|
||||
assert.equal(state.patchUsageVisible.value, false)
|
||||
assert.equal(detailAuxUsage.value.dosage_bag_count, 4)
|
||||
assert.ok(events.includes('refresh'))
|
||||
assert.ok(events.indexOf('logs:done') > events.indexOf('refresh'))
|
||||
const completedEvent = page.startsWith('components/') ? 'detail-changed' : 'lists'
|
||||
assert.ok(events.indexOf(completedEvent) > events.indexOf('logs:done'))
|
||||
state.openPatchUsageDialog()
|
||||
assert.equal(state.patchUsageForm.aux_dosage_bag_count, 4)
|
||||
})
|
||||
|
||||
test(`${page}: missing historical bag count defaults to one and resets on close`, () => {
|
||||
const { state } = instance({ bagCount: null, mainBagCount: null })
|
||||
state.openPatchUsageDialog()
|
||||
assert.equal(state.patchUsageForm.aux_dosage_bag_count, 1)
|
||||
assert.equal(state.patchUsageForm.dosage_bag_count, 1)
|
||||
state.patchUsageForm.dosage_bag_count = 4
|
||||
state.patchUsageForm.aux_dosage_bag_count = 5
|
||||
state.resetPatchUsageForm()
|
||||
assert.equal(state.patchUsageForm.aux_dosage_bag_count, 1)
|
||||
assert.equal(state.patchUsageForm.dosage_bag_count, 1)
|
||||
})
|
||||
|
||||
test(`${page}: only water pills with an auxiliary formula submit or require auxiliary bags`, async () => {
|
||||
for (const options of [{ hasAux: false }, { prescriptionType: '饮片' }, { prescriptionType: '颗粒' }]) {
|
||||
const { state, calls } = instance(options)
|
||||
state.openPatchUsageDialog()
|
||||
state.patchUsageForm.aux_dosage_bag_count = 5
|
||||
assert.equal(state.patchUsageRules.value.aux_dosage_bag_count, undefined)
|
||||
await state.submitPatchUsage()
|
||||
assert.ok(!Object.hasOwn(calls[0], 'aux_dosage_bag_count'))
|
||||
assert.equal(Object.hasOwn(calls[0], 'dosage_bag_count'), !options.prescriptionType)
|
||||
}
|
||||
const { state } = instance()
|
||||
assert.equal(state.patchUsageRules.value.aux_dosage_bag_count[0].required, true)
|
||||
assert.equal(state.patchUsageRules.value.dosage_bag_count[0].required, true)
|
||||
})
|
||||
|
||||
test(`${page}: main and auxiliary bags can be edited independently in every submitted order status`, async () => {
|
||||
for (let status = 1; status <= 12; status++) {
|
||||
const { state, calls, detailPrescription, detailAuxUsage } = instance({ status })
|
||||
state.openPatchUsageDialog()
|
||||
assert.equal(state.patchUsageForm.dosage_bag_count, 2)
|
||||
state.patchUsageForm.dosage_bag_count = 5
|
||||
state.patchUsageForm.aux_dosage_bag_count = 1
|
||||
await state.submitPatchUsage()
|
||||
assert.equal(calls.length, 1, `status ${status}`)
|
||||
assert.equal(calls[0].dosage_bag_count, 5)
|
||||
assert.equal(calls[0].aux_dosage_bag_count, 1)
|
||||
assert.equal(detailPrescription.value.dosage_bag_count, 5)
|
||||
assert.equal(detailAuxUsage.value.dosage_bag_count, 1)
|
||||
}
|
||||
const { state, calls } = instance({ hasAux: false })
|
||||
state.openPatchUsageDialog()
|
||||
state.patchUsageForm.dosage_bag_count = 4
|
||||
await state.submitPatchUsage()
|
||||
assert.equal(calls[0].dosage_bag_count, 4)
|
||||
assert.ok(!Object.hasOwn(calls[0], 'aux_dosage_bag_count'))
|
||||
})
|
||||
|
||||
test(`${page}: validation blocks requests and a rejected save retains the selected bags`, async () => {
|
||||
const { state, calls, events } = instance({ fail: true })
|
||||
state.openPatchUsageDialog()
|
||||
state.patchUsageForm.aux_dosage_bag_count = 3
|
||||
state.patchUsageForm.dosage_bag_count = 4
|
||||
state.patchUsageFormRef.value.validate = async () => { throw new Error('Required') }
|
||||
await state.submitPatchUsage()
|
||||
assert.equal(calls.length, 0)
|
||||
state.patchUsageFormRef.value.validate = async () => {}
|
||||
await state.submitPatchUsage()
|
||||
assert.equal(state.patchUsageForm.aux_dosage_bag_count, 3)
|
||||
assert.equal(state.patchUsageForm.dosage_bag_count, 4)
|
||||
assert.equal(state.patchUsageVisible.value, true)
|
||||
assert.equal(state.patchUsageSaving.value, false)
|
||||
assert.deepEqual(events, [])
|
||||
})
|
||||
}
|
||||
@@ -46,8 +46,6 @@ export default defineConfig(({ mode }) => {
|
||||
|
||||
return {
|
||||
base: '/admin/',
|
||||
// 临时 UI 预览可能共用 node_modules;主开发服务独立缓存,避免预构建互相覆盖。
|
||||
cacheDir: path.resolve(__dirname, 'node_modules/.vite-admin'),
|
||||
server: {
|
||||
port: 5555,
|
||||
host: '0.0.0.0',
|
||||
|
||||
@@ -89,7 +89,7 @@ def example_batch() -> dict[str, Any]:
|
||||
"algorithm_version": "prescription-soft-dice-v1.1.0",
|
||||
"prompt_version": "manual-prescription-required-candidate-v4",
|
||||
}
|
||||
return {"id": 40, "prescription_id": 7556, "patient_id": 1391, "diagnosis_id": 1391, "agreement_visible": True,
|
||||
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",
|
||||
@@ -143,11 +143,8 @@ class PreviewRepository:
|
||||
def __init__(self) -> None:
|
||||
self.batch = example_batch()
|
||||
|
||||
def get_prescription_ai_capabilities(self) -> dict[str, Any]:
|
||||
return {"agreement_visible": True, "can_manage_visibility": False}
|
||||
|
||||
def list_prescription_ai_reports(self, **_params: Any) -> dict[str, Any]:
|
||||
return {"agreement_visible": True, "enabled": True, "lists": deepcopy(self.history()), "count": len(self.history())}
|
||||
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."""
|
||||
@@ -173,7 +170,7 @@ class PreviewRepository:
|
||||
"algorithm_version": "prescription-soft-dice-v1.0.0"},
|
||||
"openai": {"comparison": {"status": "not_comparable"}}}},
|
||||
]
|
||||
return [{**row, "agreement_visible": True} for row in [self.batch, *older]]
|
||||
return [self.batch, *older]
|
||||
|
||||
def get_prescription_ai_report(self, _batch_id: int) -> dict[str, Any]:
|
||||
return deepcopy(self.batch)
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
"""Capture agreement authorization states using synthetic data and no network."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
from PySide6.QtWidgets import QApplication
|
||||
from render_issued_prescription_ai import PreviewRepository
|
||||
|
||||
from doctor_workstation.services import DemoDoctorRepository
|
||||
from doctor_workstation.ui.dialogs import issued_prescription_ai as ai
|
||||
from doctor_workstation.ui.pages import prescriptions
|
||||
from doctor_workstation.ui.theme import apply_theme
|
||||
|
||||
|
||||
class VisibilityPreviewRepository(DemoDoctorRepository, PreviewRepository):
|
||||
def __init__(self) -> None:
|
||||
DemoDoctorRepository.__init__(self)
|
||||
PreviewRepository.__init__(self)
|
||||
self.visible = True
|
||||
|
||||
def get_prescription_ai_capabilities(self) -> dict[str, Any]:
|
||||
return {"agreement_visible": self.visible, "can_manage_visibility": True}
|
||||
|
||||
def list_prescription_ai_statuses(self, ids: list[int]) -> dict[str, Any]:
|
||||
return {"enabled": True, "agreement_visible": self.visible, "items": [
|
||||
{"prescription_id": value, "agreement_visible": self.visible, "status": "success",
|
||||
"models": {"qwen": {"status": "success", "score": 68.5},
|
||||
"openai": {"status": "success", "score": 55.2}}} for value in ids]}
|
||||
|
||||
def list_prescription_ai_reports(self, **_params: Any) -> dict[str, Any]:
|
||||
rows = [{**row, "agreement_visible": self.visible} for row in self.history()]
|
||||
return {"agreement_visible": self.visible, "enabled": True, "lists": deepcopy(rows), "count": len(rows)}
|
||||
|
||||
def get_prescription_ai_report(self, _batch_id: int) -> dict[str, Any]:
|
||||
return {**deepcopy(self.batch), "agreement_visible": self.visible}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
application = QApplication.instance() or QApplication([])
|
||||
apply_theme(application)
|
||||
output = Path(__file__).resolve().parents[2] / "artifacts" / "prescription-ai-visibility"
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def immediate(function: Any, **callbacks: Any) -> None:
|
||||
try:
|
||||
result = function()
|
||||
except Exception as error:
|
||||
if callbacks.get("on_error"):
|
||||
callbacks["on_error"](error)
|
||||
else:
|
||||
raise
|
||||
else:
|
||||
if callbacks.get("on_success"):
|
||||
callbacks["on_success"](result)
|
||||
finally:
|
||||
if callbacks.get("on_finished"):
|
||||
callbacks["on_finished"]()
|
||||
|
||||
ai.run_async = immediate
|
||||
prescriptions.run_async = immediate
|
||||
repository = VisibilityPreviewRepository()
|
||||
|
||||
def capture(widget: Any, filename: str) -> None:
|
||||
for _ in range(4):
|
||||
application.processEvents()
|
||||
assert widget.grab().save(str(output / filename))
|
||||
|
||||
page = prescriptions.PrescriptionsPage(repository, ["*"], {"name": "演示医生", "root": 1})
|
||||
page.resize(1366, 800)
|
||||
page.show()
|
||||
capture(page, "list-authorized.png")
|
||||
repository.visible = False
|
||||
page.refresh()
|
||||
capture(page, "list-restricted.png")
|
||||
page.close()
|
||||
|
||||
repository.visible = True
|
||||
dialog = ai.IssuedPrescriptionAiDialog(repository, ["*"], prescription_id=7556,
|
||||
current_user={"name": "演示医生", "root": 1})
|
||||
dialog.resize(1280, 860)
|
||||
dialog.show()
|
||||
capture(dialog, "report-authorized.png")
|
||||
repository.visible = False
|
||||
repository.batch["status"] = "partial"
|
||||
repository.batch["models"]["openai"].update(
|
||||
status="failed", report=None, candidate=None, comparison=None,
|
||||
error_code="upstream_timeout", error_message="演示:模型连接超时,可重试。")
|
||||
dialog.refresh()
|
||||
dialog._goto("report")
|
||||
capture(dialog, "report-restricted.png")
|
||||
dialog._goto("overview")
|
||||
capture(dialog, "review-restricted.png")
|
||||
dialog._goto("per_herb")
|
||||
capture(dialog, "candidate-restricted.png")
|
||||
dialog.close()
|
||||
statistics = ai.PrescriptionAiStatisticsDialog(repository, ["*"])
|
||||
statistics.resize(1180, 860)
|
||||
statistics.show()
|
||||
capture(statistics, "statistics-restricted.png")
|
||||
statistics.close()
|
||||
print(output)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -3,7 +3,7 @@
|
||||
__all__ = ["DEBUG_MODE", "ONLINE_API_BASE_URL", "__version__"]
|
||||
|
||||
# Single source of truth for runtime, package, installer, and executable versions.
|
||||
__version__ = "1.4.5"
|
||||
__version__ = "1.4.6"
|
||||
|
||||
# 调试模式开启时,登录页显示“演示模式”和“服务器设置”。
|
||||
# 正式发布请保持 False;此时程序只使用下面配置的线上域名。
|
||||
|
||||
@@ -235,9 +235,6 @@ class DoctorRepository(Protocol):
|
||||
def get_prescription(self, prescription_id: int) -> Prescription:
|
||||
"""Return one issued prescription."""
|
||||
|
||||
def get_prescription_ai_capabilities(self) -> dict[str, Any]:
|
||||
"""Read the server's current agreement visibility decision."""
|
||||
|
||||
def list_prescription_ai_statuses(self, ids: list[int]) -> dict[str, Any]:
|
||||
"""Read cached dual-model states for up to 100 prescriptions."""
|
||||
|
||||
@@ -1614,9 +1611,6 @@ class RemoteDoctorRepository:
|
||||
payload = _client_request(self.client, "post" if mutation else "get", endpoint, params, timeout=30.0)
|
||||
return dict(_require_mapping(payload, endpoint))
|
||||
|
||||
def get_prescription_ai_capabilities(self) -> dict[str, Any]:
|
||||
return self._prescription_ai_request("capabilities", {})
|
||||
|
||||
def list_prescription_ai_statuses(self, ids: list[int]) -> dict[str, Any]:
|
||||
if len(ids) > 100 or any(int(value) <= 0 for value in ids):
|
||||
raise ValueError("statuses requires at most 100 positive prescription IDs")
|
||||
|
||||
@@ -7,7 +7,7 @@ admin-table presentation cannot leak into another workstation page.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Iterable, Mapping, Sequence
|
||||
from collections.abc import Iterable, Mapping, Sequence
|
||||
from math import ceil
|
||||
from typing import Any
|
||||
|
||||
@@ -75,9 +75,12 @@ TEXT = QColor("#1A1C1F")
|
||||
SECONDARY = QColor("#606163")
|
||||
PLACEHOLDER = QColor("#8E8F90")
|
||||
_INVALID_INDEX = QModelIndex()
|
||||
# The last two columns form the compact frozen pane. Video status and ending
|
||||
# time occupy separate lines; actions keep two inline links plus 更多, with
|
||||
# the remaining actions in its menu so clinical columns retain more space.
|
||||
# The last two columns are the frozen right-hand pane. They were 120 + 410,
|
||||
# which pinned 532 px of the table. Measured against what they actually hold:
|
||||
# the widest video cell is "暂无通话" at 56 px, and the action cell - two inline
|
||||
# buttons plus 更多, all two-character labels - reports a 145 px sizeHint. So
|
||||
# 265 px of the action column was permanently blank on every row while the data
|
||||
# columns to its left were horizontally scrolling.
|
||||
_TABLE_COLUMN_WIDTHS = (48, 70, 60, 100, 175, 88, 120, 100, 72, 110, 96, 160)
|
||||
_BLUE_COLUMN_WIDTHS = (48, 70, 82, 102, 244, 90, 84, 90, 88, 122, 92, 158)
|
||||
_BLUE_WARNING = QColor("#A9691D")
|
||||
@@ -1606,7 +1609,6 @@ class DiagnosisTableHost(QFrame):
|
||||
self,
|
||||
*,
|
||||
action_policy: Mapping[str, bool] | None = None,
|
||||
video_entry_allowed: Callable[[Any], bool] | None = None,
|
||||
parent: QWidget | None = None,
|
||||
tech_blue: bool = False,
|
||||
) -> None:
|
||||
@@ -1616,7 +1618,6 @@ class DiagnosisTableHost(QFrame):
|
||||
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||||
self.setMinimumHeight(0)
|
||||
self.action_policy = dict(action_policy or {})
|
||||
self.video_entry_allowed = video_entry_allowed
|
||||
self.force_open_prescription = False
|
||||
if tech_blue:
|
||||
self.LEFT_WIDTHS = _BLUE_COLUMN_WIDTHS[:10]
|
||||
@@ -1632,9 +1633,6 @@ class DiagnosisTableHost(QFrame):
|
||||
self._fixed_height_timer.timeout.connect(self._sync_fixed_height)
|
||||
self.main.setObjectName("DiagnosisMainTable")
|
||||
self.fixed.setObjectName("DiagnosisFixedTable")
|
||||
# Index widgets supply their own margins. Inherited item padding would
|
||||
# shrink a 92px video cell to 76px and clip its wrapped status text.
|
||||
self.fixed.setStyleSheet("QTableView#DiagnosisFixedTable::item { padding: 0; }")
|
||||
for view in (self.main, self.fixed):
|
||||
view.setModel(self.model)
|
||||
view.setSelectionModel(self.selection)
|
||||
@@ -1788,11 +1786,11 @@ class DiagnosisTableHost(QFrame):
|
||||
host.hovered_row.connect(self._set_hover_row)
|
||||
layout = QHBoxLayout(host)
|
||||
layout.setContentsMargins(3, 2, 3, 2)
|
||||
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
video_capable = self.action_policy.get("video_call", False)
|
||||
entry_allowed = self.video_entry_allowed is None or self.video_entry_allowed(record)
|
||||
call_state = video_call_state(record)
|
||||
is_text = appointment_type_value(record) == "text"
|
||||
if video_capable and entry_allowed and _appointment_active(record) and (is_text or call_state == "live"):
|
||||
if video_capable and _appointment_active(record) and (is_text or call_state == "live"):
|
||||
button = QToolButton(host)
|
||||
button.setText("图文沟通" if is_text else "进入视频问诊")
|
||||
button.setProperty("rowLink", "primary")
|
||||
@@ -1805,17 +1803,10 @@ class DiagnosisTableHost(QFrame):
|
||||
button.clicked.connect(
|
||||
lambda _checked=False, item=record: self.video_requested.emit(item)
|
||||
)
|
||||
layout.addWidget(button, 0, Qt.AlignmentFlag.AlignCenter)
|
||||
layout.addWidget(button)
|
||||
else:
|
||||
status = "—"
|
||||
if not entry_allowed:
|
||||
# Match the admin's public status without turning an active
|
||||
# doctor's room into a receiver entry for an unrelated user.
|
||||
if _appointment_active(record) and can_appointment_video(appointment_type_value(record)):
|
||||
status = {"live": "通话中", "pending_room": "接通中", "none": "—"}.get(
|
||||
call_state, first_value(record, "video_call_hint.label", default="—")
|
||||
)
|
||||
elif video_capable:
|
||||
if video_capable:
|
||||
if call_state == "pending_room":
|
||||
status = "等待接通"
|
||||
elif _appointment_active(record):
|
||||
@@ -1825,17 +1816,10 @@ class DiagnosisTableHost(QFrame):
|
||||
_appointment_status(record),
|
||||
"未挂号" if not _has_appointment(record) else "—",
|
||||
)
|
||||
status_text = display_text(status)
|
||||
label = QLabel(status_text.replace(" · ", "\n", 1), host)
|
||||
label = QLabel(display_text(status), host)
|
||||
label.setProperty("fixedMuted", True)
|
||||
label.setTextFormat(Qt.TextFormat.PlainText)
|
||||
label.setToolTip(status_text)
|
||||
label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
label.setWordWrap(True)
|
||||
# Fill the cell instead of centering the label at its sizeHint:
|
||||
# its wrapped height can grow when the available width changes.
|
||||
label.setMinimumWidth(0)
|
||||
label.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Expanding)
|
||||
layout.addWidget(label)
|
||||
return host
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ from copy import deepcopy
|
||||
from datetime import datetime
|
||||
from html import escape
|
||||
from threading import Event
|
||||
from time import monotonic
|
||||
from typing import Any
|
||||
|
||||
from PySide6.QtCore import QObject, QRunnable, QSize, Qt, QThreadPool, QTimer, Signal, Slot
|
||||
@@ -2339,14 +2338,8 @@ class _RichMessage(QTextBrowser):
|
||||
self.document().contentsChanged.connect(self._fit)
|
||||
|
||||
def set_payload(self, raw: Any) -> None:
|
||||
# Markdown parsing and block formatting both emit contentsChanged. Fit
|
||||
# once after the complete update instead of laying out each mutation.
|
||||
self._fitting = True
|
||||
try:
|
||||
render_chat_payload(self, raw, role=self._role)
|
||||
finally:
|
||||
self._fitting = False
|
||||
self._fit()
|
||||
render_chat_payload(self, raw, role=self._role)
|
||||
QTimer.singleShot(0, self._fit)
|
||||
|
||||
def resizeEvent(self, event) -> None: # type: ignore[override]
|
||||
super().resizeEvent(event)
|
||||
@@ -3184,8 +3177,6 @@ class _AiStreamWorker(QRunnable):
|
||||
if self.is_cancelled():
|
||||
return
|
||||
self.signals.event.emit(event)
|
||||
if isinstance(event, Mapping) and event.get("event") == "done":
|
||||
return
|
||||
except Exception as error: # UI boundary: render transport/domain errors in place.
|
||||
if not self.is_cancelled():
|
||||
self.signals.error.emit(error)
|
||||
@@ -3526,7 +3517,6 @@ class AiConsultDialog(QDialog):
|
||||
self._detail: Mapping[str, Any] = {}
|
||||
self._workspace_sections: dict[str, dict[str, Any]] = {}
|
||||
self._generation = 0
|
||||
self._session_closed = False
|
||||
self._image_generation = 0
|
||||
self._image_preview: QWidget | None = None
|
||||
# 字典翻译表:内置种子先兜底,加载诊单时再并入服务端实时字典。
|
||||
@@ -3538,12 +3528,8 @@ class AiConsultDialog(QDialog):
|
||||
self._stream_worker: _AiStreamWorker | None = None
|
||||
self._stream_bubble: _ChatBubble | None = None
|
||||
self._stream_text = ""
|
||||
self._stream_prompt = ""
|
||||
self._pending_chunks: list[str] = []
|
||||
self._stream_meta: dict[str, Any] = {}
|
||||
self._stream_started_at: float | None = None
|
||||
self._stream_first_delta_at: float | None = None
|
||||
self._stream_ended_at: float | None = None
|
||||
self._patient_ai_context: str = ""
|
||||
self._patient_ai_context_labels: list[str] = []
|
||||
self._patient_ai_context_bubble: _ChatBubble | None = None
|
||||
@@ -3560,9 +3546,6 @@ class AiConsultDialog(QDialog):
|
||||
self._stream_timeout_timer = QTimer(self)
|
||||
self._stream_timeout_timer.setSingleShot(True)
|
||||
self._stream_timeout_timer.timeout.connect(self._stream_timed_out)
|
||||
self._stream_elapsed_timer = QTimer(self)
|
||||
self._stream_elapsed_timer.setInterval(1000)
|
||||
self._stream_elapsed_timer.timeout.connect(self._update_stream_elapsed)
|
||||
self.setObjectName("AiConsultDialog")
|
||||
self.setWindowTitle("问诊详情")
|
||||
# QDialog 默认只带关闭按钮,医生无法把这个信息密度很高的窗口放大到整屏。
|
||||
@@ -3579,7 +3562,6 @@ class AiConsultDialog(QDialog):
|
||||
self.setStyleSheet(AI_CONSULT_QSS)
|
||||
mark_business_dialog(self, "AiConsultDialog")
|
||||
self._build()
|
||||
self.finished.connect(self._end_session)
|
||||
|
||||
def keyPressEvent(self, event: QKeyEvent) -> None: # type: ignore[override]
|
||||
# QLineEdit emits returnPressed before the key reaches QDialog. The
|
||||
@@ -3603,7 +3585,6 @@ class AiConsultDialog(QDialog):
|
||||
source_title: str = "问诊列表",
|
||||
) -> None:
|
||||
self._cancel_stream()
|
||||
self._session_closed = False
|
||||
self.diagnosis_id = diagnosis_id
|
||||
self.patient_id = _as_int(patient_id)
|
||||
self._seed = dict(seed) if isinstance(seed, Mapping) else {}
|
||||
@@ -3811,7 +3792,7 @@ class AiConsultDialog(QDialog):
|
||||
button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||||
button.clicked.connect(
|
||||
lambda _checked=False, text=prompt: self._ask(text, replace_active=True)
|
||||
lambda _checked=False, text=prompt: self._ask(text)
|
||||
)
|
||||
chips.addWidget(button, 1)
|
||||
more = QPushButton("更多")
|
||||
@@ -3822,7 +3803,7 @@ class AiConsultDialog(QDialog):
|
||||
for label, prompt in QUICK_PROMPTS[3:]:
|
||||
action = more_menu.addAction(label)
|
||||
action.triggered.connect(
|
||||
lambda _checked=False, text=prompt: self._ask(text, replace_active=True)
|
||||
lambda _checked=False, text=prompt: self._ask(text)
|
||||
)
|
||||
more.setMenu(more_menu)
|
||||
chips.addWidget(more, 1)
|
||||
@@ -4015,7 +3996,7 @@ class AiConsultDialog(QDialog):
|
||||
for index, (kind, label, prompt) in enumerate(TOOL_ITEMS):
|
||||
card = _ClickCard()
|
||||
card.setObjectName("AiConsultToolCard")
|
||||
card.clicked.connect(lambda text=prompt: self._ask(text, replace_active=True))
|
||||
card.clicked.connect(lambda text=prompt: self._ask(text))
|
||||
inner = QVBoxLayout(card)
|
||||
inner.setContentsMargins(8, 10, 8, 10)
|
||||
inner.setSpacing(6)
|
||||
@@ -5995,14 +5976,11 @@ class AiConsultDialog(QDialog):
|
||||
|
||||
def _use_suggestion(self, question: str) -> None:
|
||||
self.input.setText(question)
|
||||
self._ask(f"请针对该问题给出问诊话术与判断要点:{question}", replace_active=True)
|
||||
self._ask(f"请针对该问题给出问诊话术与判断要点:{question}")
|
||||
|
||||
def _ask(self, prompt: str, *, replace_active: bool = False) -> None:
|
||||
def _ask(self, prompt: str) -> None:
|
||||
text = prompt.strip()
|
||||
if not text:
|
||||
return
|
||||
if self._asking and (not replace_active or text == self._stream_prompt):
|
||||
show_toast(self, "AI 正在生成,请稍候或点击其他快捷工具切换。", "info")
|
||||
if self._asking or not text:
|
||||
return
|
||||
if self.diagnosis_id <= 0:
|
||||
show_toast(self, "缺少诊单编号,无法发起 AI 对话。", "warning")
|
||||
@@ -6019,12 +5997,8 @@ class AiConsultDialog(QDialog):
|
||||
"warning",
|
||||
)
|
||||
return
|
||||
self._cancel_stream(replaced=self._asking)
|
||||
self._stream_started_at = monotonic()
|
||||
self._stream_prompt = text
|
||||
self._cancel_stream()
|
||||
self._follow_chat = True
|
||||
self.tabs.setCurrentIndex(0)
|
||||
self._set_chat_filter("all")
|
||||
self.input.clear()
|
||||
self._append_bubble(
|
||||
"doctor", text, time_text=datetime.now().strftime("%H:%M")
|
||||
@@ -6045,8 +6019,6 @@ class AiConsultDialog(QDialog):
|
||||
self._pending_chunks.clear()
|
||||
self._stream_meta.clear()
|
||||
self._asking = True
|
||||
self._update_stream_elapsed()
|
||||
self._stream_elapsed_timer.start()
|
||||
self.send_button.setEnabled(False)
|
||||
generation = self._generation
|
||||
stream_generation = self._stream_generation
|
||||
@@ -6075,65 +6047,26 @@ class AiConsultDialog(QDialog):
|
||||
def _stream_is_current(self, generation: int, stream_generation: int) -> bool:
|
||||
return generation == self._generation and stream_generation == self._stream_generation
|
||||
|
||||
def _update_stream_elapsed(self) -> None:
|
||||
if self._stream_bubble is None:
|
||||
return
|
||||
model = display_text(
|
||||
first_value(self._stream_meta, "model_label", "model_key"), ""
|
||||
)
|
||||
if self._stream_started_at is None:
|
||||
self._stream_bubble.set_time_text(_join_meta(model, datetime.now().strftime("%H:%M")))
|
||||
return
|
||||
now = self._stream_ended_at if self._stream_ended_at is not None else monotonic()
|
||||
elapsed = max(0.0, now - self._stream_started_at)
|
||||
if self._stream_ended_at is None:
|
||||
status = (
|
||||
f"生成中 · 已用时 {int(elapsed)} 秒"
|
||||
if self._stream_first_delta_at is not None else f"已等待 {int(elapsed)} 秒"
|
||||
)
|
||||
self._stream_bubble.set_time_text(_join_meta(model, status))
|
||||
return
|
||||
first_delta = (
|
||||
f"首字 {max(0.0, self._stream_first_delta_at - self._stream_started_at):.1f} 秒"
|
||||
if self._stream_first_delta_at is not None else ""
|
||||
)
|
||||
self._stream_bubble.set_time_text(
|
||||
_join_meta(model, first_delta, f"耗时 {elapsed:.1f} 秒", datetime.now().strftime("%H:%M"))
|
||||
)
|
||||
|
||||
def _stop_stream_clock(self) -> None:
|
||||
if self._stream_ended_at is None:
|
||||
self._stream_ended_at = monotonic()
|
||||
self._stream_elapsed_timer.stop()
|
||||
self._update_stream_elapsed()
|
||||
|
||||
def _stream_event(
|
||||
self,
|
||||
generation: int,
|
||||
stream_generation: int,
|
||||
event: Any,
|
||||
) -> None:
|
||||
if not self._stream_is_current(generation, stream_generation) or self._stream_ended_at is not None:
|
||||
if not self._stream_is_current(generation, stream_generation):
|
||||
return
|
||||
payload = dict(event) if isinstance(event, Mapping) else {}
|
||||
kind = str(payload.get("event") or "").lower()
|
||||
if kind == "start":
|
||||
self._stream_meta.update(payload)
|
||||
self._update_stream_elapsed()
|
||||
if self._stream_bubble is not None and not self._stream_text and not self._pending_chunks:
|
||||
self._stream_bubble.set_payload(AI_STREAM_WAITING_TEXT)
|
||||
return
|
||||
if kind == "delta":
|
||||
chunk = payload.get("text")
|
||||
if isinstance(chunk, str) and chunk:
|
||||
if self._stream_first_delta_at is None:
|
||||
self._stream_first_delta_at = monotonic()
|
||||
self._update_stream_elapsed()
|
||||
self._pending_chunks.append(chunk)
|
||||
if not self._stream_text:
|
||||
# The first received text replaces the waiting state now.
|
||||
self._flush_stream_chunks()
|
||||
elif not self._flush_timer.isActive():
|
||||
if not self._flush_timer.isActive():
|
||||
self._flush_timer.start()
|
||||
return
|
||||
if kind != "done":
|
||||
@@ -6141,7 +6074,6 @@ class AiConsultDialog(QDialog):
|
||||
self._stream_meta.update(payload)
|
||||
self._stream_completed = True
|
||||
self._stream_timeout_timer.stop()
|
||||
self._stop_stream_clock()
|
||||
if not self._stream_text and not self._pending_chunks:
|
||||
answer = first_value(payload, "answer", "content", default="")
|
||||
if answer not in (None, ""):
|
||||
@@ -6151,15 +6083,17 @@ class AiConsultDialog(QDialog):
|
||||
if not self._stream_text:
|
||||
self._stream_text = "未返回分析内容。"
|
||||
self._stream_bubble.set_payload(self._stream_text)
|
||||
model = display_text(
|
||||
first_value(self._stream_meta, "model_label", "model_key"),
|
||||
"",
|
||||
)
|
||||
self._stream_bubble.set_time_text(
|
||||
_join_meta(model, datetime.now().strftime("%H:%M"))
|
||||
)
|
||||
self._stream_bubble.set_provenance(self._stream_meta)
|
||||
self._stream_bubble.finalize_clinical_analysis()
|
||||
# A terminal event completes the UI request even if transport cleanup
|
||||
# has not returned yet. The next click must not depend on finished.
|
||||
self._asking = False
|
||||
self.send_button.setEnabled(True)
|
||||
|
||||
def _flush_stream_chunks(self) -> None:
|
||||
self._flush_timer.stop()
|
||||
if not self._pending_chunks or self._stream_bubble is None:
|
||||
return
|
||||
self._stream_text += "".join(self._pending_chunks)
|
||||
@@ -6174,10 +6108,9 @@ class AiConsultDialog(QDialog):
|
||||
stream_generation: int,
|
||||
error: Exception,
|
||||
) -> None:
|
||||
if not self._stream_is_current(generation, stream_generation) or self._stream_ended_at is not None:
|
||||
if not self._stream_is_current(generation, stream_generation):
|
||||
return
|
||||
self._stream_timeout_timer.stop()
|
||||
self._stop_stream_clock()
|
||||
self._flush_stream_chunks()
|
||||
message = friendly_error(error)
|
||||
if self._stream_text:
|
||||
@@ -6186,8 +6119,7 @@ class AiConsultDialog(QDialog):
|
||||
self._stream_text = message
|
||||
if self._stream_bubble is not None:
|
||||
self._stream_bubble.set_payload(self._stream_text)
|
||||
self._asking = False
|
||||
self.send_button.setEnabled(True)
|
||||
self._stream_bubble.set_time_text(datetime.now().strftime("%H:%M"))
|
||||
|
||||
def _stream_finished(
|
||||
self,
|
||||
@@ -6198,8 +6130,6 @@ class AiConsultDialog(QDialog):
|
||||
if not self._stream_is_current(generation, stream_generation):
|
||||
return
|
||||
self._stream_timeout_timer.stop()
|
||||
self._flush_stream_chunks()
|
||||
self._stop_stream_clock()
|
||||
if self._stream_worker is worker:
|
||||
self._stream_worker = None
|
||||
# 连接结束却既没有 done 也没有报错时(服务端静默断开),
|
||||
@@ -6211,6 +6141,7 @@ class AiConsultDialog(QDialog):
|
||||
):
|
||||
self._stream_text = AI_STREAM_SILENT_TEXT
|
||||
self._stream_bubble.set_payload(self._stream_text)
|
||||
self._stream_bubble.set_time_text(datetime.now().strftime("%H:%M"))
|
||||
self._asking = False
|
||||
self.send_button.setEnabled(True)
|
||||
|
||||
@@ -6220,53 +6151,34 @@ class AiConsultDialog(QDialog):
|
||||
self._flush_stream_chunks()
|
||||
bubble = self._stream_bubble
|
||||
text = self._stream_text
|
||||
self._stop_stream_clock()
|
||||
self._cancel_stream()
|
||||
if bubble is not None:
|
||||
bubble.set_payload(
|
||||
f"{text}\n\n> {AI_STREAM_TIMEOUT_TEXT}" if text else AI_STREAM_TIMEOUT_TEXT
|
||||
)
|
||||
bubble.set_time_text(datetime.now().strftime("%H:%M"))
|
||||
|
||||
def _cancel_stream(self, *, replaced: bool = False) -> None:
|
||||
if replaced and self._asking:
|
||||
self._flush_stream_chunks()
|
||||
self._stop_stream_clock()
|
||||
if self._stream_bubble is not None:
|
||||
notice = "已停止本次生成,已切换到新的请求。"
|
||||
self._stream_bubble.set_payload(
|
||||
f"{self._stream_text}\n\n> {notice}" if self._stream_text else notice
|
||||
)
|
||||
def _cancel_stream(self) -> None:
|
||||
self._stream_generation += 1
|
||||
self._flush_timer.stop()
|
||||
self._stream_timeout_timer.stop()
|
||||
self._stream_elapsed_timer.stop()
|
||||
if self._stream_worker is not None:
|
||||
self._stream_worker.cancel()
|
||||
self._stream_worker = None
|
||||
self._stream_bubble = None
|
||||
self._pending_chunks.clear()
|
||||
self._stream_text = ""
|
||||
self._stream_prompt = ""
|
||||
self._stream_completed = False
|
||||
self._stream_meta.clear()
|
||||
self._stream_started_at = None
|
||||
self._stream_first_delta_at = None
|
||||
self._stream_ended_at = None
|
||||
self._asking = False
|
||||
self.send_button.setEnabled(True)
|
||||
|
||||
def _end_session(self) -> None:
|
||||
if self._session_closed:
|
||||
return
|
||||
self._session_closed = True
|
||||
def closeEvent(self, event: Any) -> None: # type: ignore[override]
|
||||
self._generation += 1
|
||||
self._image_generation += 1
|
||||
_abort_pending_thumbnails(self)
|
||||
self._close_image_preview()
|
||||
self._cancel_stream()
|
||||
|
||||
def closeEvent(self, event: Any) -> None: # type: ignore[override]
|
||||
self._end_session()
|
||||
super().closeEvent(event)
|
||||
|
||||
def _open_full_record(self) -> None:
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from copy import deepcopy
|
||||
from html import escape
|
||||
from math import isfinite
|
||||
from time import monotonic
|
||||
@@ -403,28 +402,6 @@ def can_open_issued_ai(permissions: Any) -> bool:
|
||||
return has_permission(permissions, "tcm.prescriptionAi/reports", default=False) and has_permission(permissions, "tcm.prescriptionAi/detail", default=False)
|
||||
|
||||
|
||||
def agreement_safe_batch(value: Any, visible: bool) -> dict[str, Any]:
|
||||
"""Discard restricted structured comparisons before retaining any UI cache."""
|
||||
|
||||
data = deepcopy(mapping(value))
|
||||
if visible:
|
||||
return data
|
||||
fields = {"comparison", "score", "herb_score", "comparison_status", "comparison_reason_code", "comparison_type",
|
||||
"algorithm_version", "normalization_version", "denominator", "dictionary_version", "dictionary_versions",
|
||||
"baseline_eligible", "baseline_exclusion_reasons", "baseline_exclusions_json"}
|
||||
for item in (data, *mapping(data.get("models")).values()):
|
||||
if isinstance(item, dict):
|
||||
for field in fields:
|
||||
item.pop(field, None)
|
||||
for model in mapping(data.get("models")).values():
|
||||
if isinstance(model, dict) and not model.get("error_code"):
|
||||
# Successful model reasons describe the comparison, while failed
|
||||
# task reasons explain generation and remain useful with reports.
|
||||
model.pop("reason", None)
|
||||
data["agreement_visible"] = False
|
||||
return data
|
||||
|
||||
|
||||
def _html(value: Any, field: str = "") -> str:
|
||||
"""Escape all stored/model content, including source URLs, before rendering."""
|
||||
if isinstance(value, Mapping):
|
||||
@@ -559,8 +536,6 @@ class IssuedPrescriptionAiDialog(QDialog):
|
||||
self._progress_frozen_seconds = 0
|
||||
self._progress_stale = False
|
||||
self._read_access_denied = False
|
||||
self._agreement_visible = False
|
||||
self._history_agreement_visible = False
|
||||
self.setWindowTitle("诊断与药方 · AI 分析报告")
|
||||
self.resize(1440, 940)
|
||||
self.setMinimumSize(940, 640)
|
||||
@@ -600,7 +575,7 @@ class IssuedPrescriptionAiDialog(QDialog):
|
||||
identity_block.addWidget(self.breadcrumb)
|
||||
title_row = QHBoxLayout()
|
||||
title_row.setSpacing(10)
|
||||
heading = self.heading = QLabel("诊断与药方对照", top)
|
||||
heading = QLabel("诊断与药方对照", top)
|
||||
heading.setObjectName("AiHeroTitle")
|
||||
title_row.addWidget(heading)
|
||||
self.chip_row = QHBoxLayout()
|
||||
@@ -928,7 +903,7 @@ class IssuedPrescriptionAiDialog(QDialog):
|
||||
footer_text = QVBoxLayout()
|
||||
footer_text.setSpacing(4)
|
||||
footer_text.addWidget(self.message)
|
||||
note = self.disclaimer = QLabel(DISCLAIMER)
|
||||
note = QLabel(DISCLAIMER)
|
||||
note.setObjectName("AiFootnote")
|
||||
note.setWordWrap(True)
|
||||
footer_text.addWidget(note)
|
||||
@@ -946,7 +921,6 @@ class IssuedPrescriptionAiDialog(QDialog):
|
||||
self._progress_timer.timeout.connect(self._render_progress)
|
||||
self._sync_actions()
|
||||
self._navigation_changed(self.tabs.currentIndex())
|
||||
self._apply_agreement_visibility(self._agreement_visible)
|
||||
|
||||
REPORT_MODES = (("both", "并排对照"), ("qwen", "仅千问"), ("openai", "仅 OpenAI"),
|
||||
("differences", "只看不同段落"))
|
||||
@@ -1089,8 +1063,6 @@ class IssuedPrescriptionAiDialog(QDialog):
|
||||
return host
|
||||
|
||||
def _goto(self, key: str) -> None:
|
||||
if not self._agreement_visible:
|
||||
key = "candidate" if key == "per_herb" else "report" if key in {"comparison_raw", "history"} else key
|
||||
page = self.tab_pages.get(key)
|
||||
if page is not None:
|
||||
self.tabs.setCurrentWidget(page)
|
||||
@@ -1098,10 +1070,7 @@ class IssuedPrescriptionAiDialog(QDialog):
|
||||
def _navigation_changed(self, index: int) -> None:
|
||||
widget = self.tabs.widget(index)
|
||||
current = next((key for key, page in self.tab_pages.items() if page is widget), "")
|
||||
if not self._agreement_visible and current in {"per_herb", "comparison_raw", "history"}:
|
||||
self._goto("report")
|
||||
return
|
||||
self.rail.set_current("per_herb" if current == "candidate" and not self._agreement_visible else current)
|
||||
self.rail.set_current(current)
|
||||
self._current_page = current
|
||||
self._apply_density()
|
||||
|
||||
@@ -1132,36 +1101,6 @@ class IssuedPrescriptionAiDialog(QDialog):
|
||||
if field in MODELS:
|
||||
self.model_views[field]["report"].setFocus()
|
||||
|
||||
def _apply_agreement_visibility(self, visible: bool) -> None:
|
||||
"""One server decision controls every comparison surface, without losing review."""
|
||||
|
||||
self._agreement_visible = visible
|
||||
self.agreement.set_agreement_visible(visible)
|
||||
self.agreement_note.setVisible(visible)
|
||||
self.heading.setText("诊断与药方对照" if visible else "诊断与药方报告")
|
||||
self.disclaimer.setText(DISCLAIMER if visible else "候选建议仅供医生复核。")
|
||||
self.comparison_panel.set_agreement_visible(visible)
|
||||
self.rail.names["overview"].setText("对比总览" if visible else "报告复核")
|
||||
self.rail.names["per_herb"].setText("候选与逐味" if visible else "AI 候选处方")
|
||||
self.nav_buttons["history"].setVisible(visible)
|
||||
for key in ("differences", "consensus"):
|
||||
self.rail.focus_values[key].parentWidget().setVisible(visible)
|
||||
if not visible:
|
||||
self._batch = agreement_safe_batch(self._batch, False) if self._batch else {}
|
||||
self._history_scope = {key: agreement_safe_batch(row, False) for key, row in self._history_scope.items()}
|
||||
self.history_page.set_history([])
|
||||
self.candidates_page.set_batch({})
|
||||
for views in self.model_views.values():
|
||||
views["comparison"].clear()
|
||||
self.comparison_panel.set_batch(self._batch)
|
||||
self.rail.set_focus({})
|
||||
self.rail.badges["overview"].clear()
|
||||
self.rail.badges["per_herb"].clear()
|
||||
self.rail.badges["overview"].hide()
|
||||
self.rail.badges["per_herb"].hide()
|
||||
if self._current_page in {"per_herb", "comparison_raw", "history"}:
|
||||
self._goto("report")
|
||||
|
||||
@property
|
||||
def save_review_button(self) -> QPushButton:
|
||||
"""The review card's own save action for the model currently being annotated."""
|
||||
@@ -1267,12 +1206,9 @@ class IssuedPrescriptionAiDialog(QDialog):
|
||||
return
|
||||
self._read_access_denied = False
|
||||
data = mapping(result)
|
||||
self._history_agreement_visible = data.get("agreement_visible") is True
|
||||
self._apply_agreement_visibility(self._history_agreement_visible)
|
||||
self._feature_enabled = data.get("enabled", True) is not False
|
||||
rows = [agreement_safe_batch(row, self._history_agreement_visible and mapping(row).get("agreement_visible") is True)
|
||||
for row in data.get("lists") or []]
|
||||
self.history_page.set_history(rows if self._agreement_visible else [])
|
||||
rows = data.get("lists") or []
|
||||
self.history_page.set_history([mapping(row) for row in rows])
|
||||
self.history.blockSignals(True)
|
||||
self.history.clear()
|
||||
self._history_scope = {}
|
||||
@@ -1385,8 +1321,6 @@ class IssuedPrescriptionAiDialog(QDialog):
|
||||
|
||||
def _read_failed(self, error: Exception, prefix: str) -> None:
|
||||
self._timer.stop()
|
||||
self._history_agreement_visible = False
|
||||
self._apply_agreement_visibility(False)
|
||||
text = str(error).lower()
|
||||
restricted = isinstance(error, (ValueError, AuthenticationExpiredError)) or getattr(error, "status_code", None) in {401, 403, 404} or any(
|
||||
marker in text for marker in ("forbidden", "unauthorized", "permission", "access_revoked", "not found", "权限", "无权", "登录", "归属", "不存在")
|
||||
@@ -1420,10 +1354,6 @@ class IssuedPrescriptionAiDialog(QDialog):
|
||||
if int(batch.get("id") or 0) != batch_id or not identity_matches or (self.prescription_id and int(batch.get("prescription_id") or 0) != self.prescription_id):
|
||||
self._detail_error(ValueError("报告归属与当前处方或诊单不一致"), generation)
|
||||
return
|
||||
visible = (self._history_agreement_visible and batch.get("agreement_visible") is True
|
||||
and mapping(expected).get("agreement_visible") is True)
|
||||
self._apply_agreement_visibility(visible)
|
||||
batch = agreement_safe_batch(batch, visible)
|
||||
first_load = not self._batch
|
||||
self._batch = batch
|
||||
self._read_access_denied = False
|
||||
@@ -1438,7 +1368,7 @@ class IssuedPrescriptionAiDialog(QDialog):
|
||||
self.identity.setText(f"处方 {batch.get('prescription_id')} · 诊单 {batch.get('diagnosis_id')} · 版本 {batch.get('prescription_revision', '—')}")
|
||||
self._set_chips([
|
||||
(label(batch.get("validity")), "info" if current_batch(batch) else "warn"),
|
||||
(label(batch.get("comparison_type")) if self._agreement_visible else "", "neutral"),
|
||||
(label(batch.get("comparison_type")), "neutral"),
|
||||
])
|
||||
self.batch_summary.setText(f"{compact_state} {_reason(batch.get('error_message') or batch.get('error_code'))}\n资料截止:{format_record_time(batch.get('cutoff_at'))} · 批次建立:{format_record_time(batch.get('created_at'))}")
|
||||
self._render_facts(batch)
|
||||
@@ -1481,11 +1411,9 @@ class IssuedPrescriptionAiDialog(QDialog):
|
||||
"risk_assessment": report.get("risk_assessment"),
|
||||
}
|
||||
_set_html(views["candidate"], _candidate_html(candidate_content))
|
||||
comparison_html = ""
|
||||
if self._agreement_visible:
|
||||
comparison_html = f"<p><b>药味与剂量一致度:{percentage(score, 1)} 纯药味重合度:{percentage(comparison.get('herb_score'), 1)}</b></p>"
|
||||
comparison_html += "<p class=sec>逐味贡献</p>" + _html(_comparison_rows(comparison.get("rows")) or "没有可展示的逐味对照。")
|
||||
comparison_html += _html({field: value for field, value in comparison.items() if field not in {"score", "herb_score", "rows"}})
|
||||
comparison_html = f"<p><b>药味与剂量一致度:{percentage(score, 1)} 纯药味重合度:{percentage(comparison.get('herb_score'), 1)}</b></p>"
|
||||
comparison_html += "<p class=sec>逐味贡献</p>" + _html(_comparison_rows(comparison.get("rows")) or "没有可展示的逐味对照。")
|
||||
comparison_html += _html({field: value for field, value in comparison.items() if field not in {"score", "herb_score", "rows"}})
|
||||
_set_html(views["comparison"], comparison_html)
|
||||
source_html = _html({"source_summary": batch.get("source_summary"), "missing": batch.get("missing"), "coverage": model.get("coverage"), "baseline_eligible": batch.get("baseline_eligible"), "baseline_exclusion_reasons": batch.get("baseline_exclusion_reasons"), "cutoff_at": format_record_time(batch.get("cutoff_at")), "generated_at": format_record_time(model.get("generated_at"))})
|
||||
reference_lines = [line for line in report_provenance_lines(model)
|
||||
@@ -1500,7 +1428,7 @@ class IssuedPrescriptionAiDialog(QDialog):
|
||||
views["comment"].setPlainText(str(review.get("comment") or ""))
|
||||
index = views["review_state"].findData(review.get("status"))
|
||||
views["review_state"].setCurrentIndex(max(0, index))
|
||||
self.candidates_page.set_batch(batch if self._agreement_visible else {})
|
||||
self.candidates_page.set_batch(batch)
|
||||
self.sources_page.set_batch(batch)
|
||||
self.pipeline_page.set_batch(batch)
|
||||
self._sync_failures(batch)
|
||||
@@ -1560,12 +1488,6 @@ class IssuedPrescriptionAiDialog(QDialog):
|
||||
"restricted": panel.restricted_count(data) if hasattr(panel, "restricted_count") else None,
|
||||
"consensus": panel.consensus_count() if hasattr(panel, "consensus_count") else None,
|
||||
})
|
||||
if not self._agreement_visible:
|
||||
for key in ("differences", "consensus"):
|
||||
self.rail.focus_values[key].clear()
|
||||
for key in ("overview", "per_herb"):
|
||||
self.rail.badges[key].clear()
|
||||
self.rail.badges[key].hide()
|
||||
|
||||
def _render_facts(self, batch: Any) -> None:
|
||||
snapshot = mapping(mapping(batch).get("doctor_snapshot"))
|
||||
@@ -1785,7 +1707,6 @@ class PrescriptionAiStatisticsDialog(QDialog):
|
||||
super().__init__(parent)
|
||||
self.repository, self.permissions = repository, permissions
|
||||
self._generation = 0
|
||||
self._agreement_visible = False
|
||||
self.setWindowTitle("医生处方 AI 一致度统计")
|
||||
self.setStyleSheet(dialog_style() + rail_qss())
|
||||
self.resize(1180, 860)
|
||||
@@ -1821,7 +1742,6 @@ class PrescriptionAiStatisticsDialog(QDialog):
|
||||
self.report.document().setDefaultStyleSheet(_report_css())
|
||||
self.report.setMinimumHeight(130)
|
||||
root.addWidget(self.report, 1)
|
||||
self.panel.hide()
|
||||
|
||||
def showEvent(self, event: Any) -> None:
|
||||
super().showEvent(event)
|
||||
@@ -1830,19 +1750,9 @@ class PrescriptionAiStatisticsDialog(QDialog):
|
||||
|
||||
def hideEvent(self, event: Any) -> None:
|
||||
self._generation += 1
|
||||
self._clear_statistics()
|
||||
super().hideEvent(event)
|
||||
|
||||
def _clear_statistics(self) -> None:
|
||||
self._agreement_visible = False
|
||||
self.panel.set_statistics({})
|
||||
self.panel.hide()
|
||||
self.report.clear()
|
||||
|
||||
def refresh(self) -> None:
|
||||
self._generation += 1
|
||||
generation = self._generation
|
||||
self._clear_statistics()
|
||||
if not has_permission(self.permissions, "tcm.prescriptionAi/statistics", default=False):
|
||||
self.summary.setText("没有查看医生统计的权限。")
|
||||
return
|
||||
@@ -1853,33 +1763,20 @@ class PrescriptionAiStatisticsDialog(QDialog):
|
||||
if doctor and (not doctor.isdecimal() or int(doctor) <= 0):
|
||||
self.summary.setText("请填写有效的医生编号。")
|
||||
return
|
||||
self.summary.setText("正在核验一致度查看权限…")
|
||||
method = getattr(self.repository, "get_prescription_ai_capabilities", None)
|
||||
if not callable(method):
|
||||
self.summary.setText("当前账号无权查看一致度统计。")
|
||||
return
|
||||
date_from, date_to = self.date_from.date().toString("yyyy-MM-dd"), self.date_to.date().toString("yyyy-MM-dd")
|
||||
run_async(method, on_success=lambda result: self._capabilities_ready(result, generation, date_from, date_to, int(doctor) if doctor else None),
|
||||
on_error=lambda error: self._error(error, generation))
|
||||
|
||||
def _capabilities_ready(self, result: Any, generation: int, date_from: str, date_to: str, doctor: int | None) -> None:
|
||||
if generation != self._generation:
|
||||
return
|
||||
if mapping(result).get("agreement_visible") is not True or not has_permission(self.permissions, "tcm.prescriptionAi/statistics", default=False):
|
||||
self._clear_statistics()
|
||||
self.summary.setText("当前账号无权查看一致度统计。")
|
||||
return
|
||||
self._agreement_visible = True
|
||||
self._generation += 1
|
||||
generation = self._generation
|
||||
self.summary.setText("正在读取统计…")
|
||||
run_async(lambda: self.repository.prescription_ai_statistics(date_from, date_to, doctor), on_success=lambda result: self._ready(result, generation), on_error=lambda error: self._error(error, generation))
|
||||
self.report.clear()
|
||||
date_from, date_to = self.date_from.date().toString("yyyy-MM-dd"), self.date_to.date().toString("yyyy-MM-dd")
|
||||
run_async(lambda: self.repository.prescription_ai_statistics(date_from, date_to, int(doctor) if doctor else None), on_success=lambda result: self._ready(result, generation), on_error=lambda error: self._error(error, generation))
|
||||
|
||||
def _error(self, error: Exception, generation: int) -> None:
|
||||
if generation == self._generation:
|
||||
self.summary.setText("统计暂不可用:" + _error_text(error))
|
||||
self._clear_statistics()
|
||||
self.report.clear()
|
||||
|
||||
def _ready(self, result: Any, generation: int) -> None:
|
||||
if generation != self._generation or not self._agreement_visible:
|
||||
if generation != self._generation:
|
||||
return
|
||||
data = mapping(result)
|
||||
if data.get("enabled") is False:
|
||||
@@ -1889,7 +1786,6 @@ class PrescriptionAiStatisticsDialog(QDialog):
|
||||
return
|
||||
self.summary.setText(f"范围内开方事件:{label(data.get('total_count'))} · 患者数:{label(data.get('patient_count'))}。小样本应谨慎解读,未按分数排名。")
|
||||
self.panel.set_statistics(data)
|
||||
self.panel.show()
|
||||
sections = []
|
||||
for row in data.get("doctors") or []:
|
||||
doctor = mapping(row)
|
||||
|
||||
@@ -190,8 +190,6 @@ class AgreementBar(QFrame):
|
||||
layout = QHBoxLayout(self)
|
||||
layout.setContentsMargins(22, 16, 22, 16)
|
||||
layout.setSpacing(24)
|
||||
self._agreement_visible = True
|
||||
self._metric_chrome: list[QWidget] = []
|
||||
self.blocks: dict[str, dict[str, Any]] = {}
|
||||
for index, key in enumerate(("qwen", "openai")):
|
||||
if index:
|
||||
@@ -199,14 +197,11 @@ class AgreementBar(QFrame):
|
||||
divider.setObjectName("AiFactDivider")
|
||||
divider.setFixedWidth(1)
|
||||
layout.addWidget(divider)
|
||||
delta = self._delta_block()
|
||||
layout.addWidget(delta)
|
||||
self._metric_chrome.extend((divider, delta))
|
||||
layout.addWidget(self._delta_block())
|
||||
divider = QFrame(self)
|
||||
divider.setObjectName("AiFactDivider")
|
||||
divider.setFixedWidth(1)
|
||||
layout.addWidget(divider)
|
||||
self._metric_chrome.append(divider)
|
||||
# Both heads line up at the top, so a model that failed does not slide its column down.
|
||||
layout.addWidget(self._model_block(key), 1, Qt.AlignmentFlag.AlignTop)
|
||||
|
||||
@@ -299,7 +294,7 @@ class AgreementBar(QFrame):
|
||||
carrier.setFixedHeight(4)
|
||||
carrier.setAccessibleName(f"{MODEL_NAMES[key]}与医生方的药味及剂量一致度")
|
||||
carrier.setVisible(False)
|
||||
self.blocks[key] = {"score": score, "caption": caption, "coverage": coverage, "elapsed": elapsed, "track": track,
|
||||
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
|
||||
@@ -318,30 +313,6 @@ class AgreementBar(QFrame):
|
||||
|
||||
self.blocks[key]["retry_slot"].addWidget(button)
|
||||
|
||||
def set_agreement_visible(self, visible: bool) -> None:
|
||||
"""Keep model states and retries available when the metric is restricted."""
|
||||
|
||||
self._agreement_visible = visible
|
||||
for widget in self._metric_chrome:
|
||||
widget.setVisible(visible)
|
||||
for key, block in self.blocks.items():
|
||||
for field in ("score", "caption", "track"):
|
||||
block[field].setVisible(visible)
|
||||
if visible:
|
||||
block["carrier"].setAccessibleName(f"{MODEL_NAMES[key]}与医生方的药味及剂量一致度")
|
||||
if not visible:
|
||||
block["score"].clear()
|
||||
block["score"].setToolTip("")
|
||||
block["track"].set_values(None, None, "")
|
||||
block["track"].setAccessibleDescription("")
|
||||
block["carrier"].setValue(0)
|
||||
block["carrier"].setToolTip("")
|
||||
block["carrier"].setAccessibleName("")
|
||||
if not visible:
|
||||
self.delta.clear()
|
||||
self.delta_note.clear()
|
||||
self.overlap.clear()
|
||||
|
||||
def set_failure(self, key: str, message: str, retryable: bool) -> None:
|
||||
block = self.blocks[key]
|
||||
block["failure_text"].setText(message)
|
||||
@@ -380,7 +351,7 @@ class AgreementBar(QFrame):
|
||||
elapsed: Mapping[str, str] | None = None) -> None:
|
||||
data = _mapping(batch)
|
||||
models = _mapping(data.get("models"))
|
||||
scores = {key: _score(_mapping(models.get(key))) if self._agreement_visible else None for key in ("qwen", "openai")}
|
||||
scores = {key: _score(_mapping(models.get(key))) for key in ("qwen", "openai")}
|
||||
for key in ("qwen", "openai"):
|
||||
block = self.blocks[key]
|
||||
value = scores[key]
|
||||
@@ -393,9 +364,6 @@ class AgreementBar(QFrame):
|
||||
spent = _mapping(elapsed).get(key, "")
|
||||
block["elapsed"].setText(spent)
|
||||
block["elapsed"].setVisible(bool(spent))
|
||||
if not self._agreement_visible:
|
||||
self.set_agreement_visible(False)
|
||||
return
|
||||
if scores["qwen"] is None or scores["openai"] is None:
|
||||
self.delta.setText("—")
|
||||
self.delta_note.setText("两个模型都可比后才给差值")
|
||||
|
||||
@@ -537,9 +537,6 @@ class DoseDiffPanel(QFrame):
|
||||
item = self.body_layout.takeAt(0)
|
||||
widget = item.widget()
|
||||
if widget is not None:
|
||||
widget.hide()
|
||||
for label in widget.findChildren(QLabel):
|
||||
label.clear()
|
||||
widget.setParent(None)
|
||||
widget.deleteLater()
|
||||
extent = _axis_extent(deltas)
|
||||
@@ -623,9 +620,6 @@ class ConclusionsPanel(QFrame):
|
||||
entry = self.body_layout.takeAt(0)
|
||||
widget = entry.widget()
|
||||
if widget is not None:
|
||||
widget.hide()
|
||||
for label in widget.findChildren(QLabel):
|
||||
label.clear()
|
||||
widget.setParent(None)
|
||||
widget.deleteLater()
|
||||
for index, text in enumerate(items, start=1):
|
||||
@@ -850,9 +844,6 @@ class RiskPanel(QFrame):
|
||||
entry = self.body_layout.takeAt(0)
|
||||
widget = entry.widget()
|
||||
if widget is not None:
|
||||
widget.hide()
|
||||
for label in widget.findChildren(QLabel):
|
||||
label.clear()
|
||||
widget.setParent(None)
|
||||
widget.deleteLater()
|
||||
flagged = []
|
||||
@@ -1024,9 +1015,6 @@ class ChecklistPanel(QFrame):
|
||||
entry = self.body_layout.takeAt(0)
|
||||
widget = entry.widget()
|
||||
if widget is not None:
|
||||
widget.hide()
|
||||
for label in widget.findChildren(QLabel):
|
||||
label.clear()
|
||||
widget.setParent(None)
|
||||
widget.deleteLater()
|
||||
grouped: dict[str, tuple[str, str, int, str]] = {}
|
||||
@@ -1144,7 +1132,6 @@ class PrescriptionReviewWorkspace(QWidget):
|
||||
self._batch: dict[str, Any] = {}
|
||||
self.rows: list[ReviewDoseRow] = []
|
||||
self._review_widget: QWidget | None = None
|
||||
self._agreement_visible = True
|
||||
self._summary = "选择报告后查看诊断与药方"
|
||||
self.setStyleSheet(f"""
|
||||
QWidget#PrescriptionReviewWorkspace {{ background: transparent; }}
|
||||
@@ -1163,7 +1150,7 @@ class PrescriptionReviewWorkspace(QWidget):
|
||||
page = QWidget(self.page_scroll)
|
||||
self.page_scroll.setWidget(page)
|
||||
outer.addWidget(self.page_scroll)
|
||||
root = self._workspace_layout = QVBoxLayout(page)
|
||||
root = QVBoxLayout(page)
|
||||
root.setContentsMargins(0, 0, 6, 0)
|
||||
root.setSpacing(14)
|
||||
|
||||
@@ -1214,10 +1201,6 @@ class PrescriptionReviewWorkspace(QWidget):
|
||||
layout.takeAt(0)
|
||||
for index in range(layout.columnCount()):
|
||||
layout.setColumnStretch(index, 0)
|
||||
if not self._agreement_visible:
|
||||
self.first.addWidget(self.checklist, 0, 0)
|
||||
self.first.setColumnStretch(0, 1)
|
||||
return
|
||||
cards = (self.conclusions, self.attribution, self.checklist)
|
||||
if columns == 1:
|
||||
for row, card in enumerate(cards):
|
||||
@@ -1306,31 +1289,7 @@ class PrescriptionReviewWorkspace(QWidget):
|
||||
self._batch = deepcopy(data)
|
||||
self._render()
|
||||
|
||||
def set_agreement_visible(self, visible: bool) -> None:
|
||||
"""Use the existing checklist as a report-only review workspace."""
|
||||
|
||||
if visible == self._agreement_visible:
|
||||
return
|
||||
self._agreement_visible = visible
|
||||
for widget in (self.conclusions, self.attribution, self.doses, self.risks, self.links["comparison"]):
|
||||
widget.setVisible(visible)
|
||||
self._workspace_layout.setStretch(0, 5 if visible else 1)
|
||||
self._workspace_layout.setStretch(1, 4 if visible else 0)
|
||||
self._columns = 0
|
||||
self._reflow(1 if self.width() < self.STACK_BELOW else 3)
|
||||
self._render()
|
||||
|
||||
def _render(self) -> None:
|
||||
if not self._agreement_visible:
|
||||
self.rows = []
|
||||
self.doses.set_rows([])
|
||||
self.risks.set_rows([])
|
||||
self.attribution.apply({})
|
||||
self.conclusions.set_items([])
|
||||
self.checklist.set_items(checklist_items(self._batch))
|
||||
self._summary = "请结合报告与资料缺口记录复核意见。"
|
||||
self.summary_changed.emit(self._summary)
|
||||
return
|
||||
self.rows, _states = review_rows(self._batch)
|
||||
deltas = dose_deltas(self.rows)
|
||||
self.doses.set_rows(deltas)
|
||||
|
||||
@@ -38,7 +38,6 @@ from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from ...core import UserProfile
|
||||
from ...core.appointment_modes import appointment_type_value, can_appointment_video
|
||||
from .. import icons
|
||||
from ..consultations_style import consultations_stylesheet
|
||||
@@ -1494,7 +1493,6 @@ class ConsultationsPage(QWidget):
|
||||
table_wrap_layout.setSpacing(0)
|
||||
self.table_host = DiagnosisTableHost(
|
||||
tech_blue=True,
|
||||
video_entry_allowed=self._can_enter_list_call,
|
||||
action_policy={
|
||||
"view": _canonical_allowed(permissions, "tcm.diagnosis/readonlyDetail"),
|
||||
"edit": _canonical_allowed(permissions, "tcm.diagnosis/edit"),
|
||||
@@ -3055,11 +3053,8 @@ class ConsultationsPage(QWidget):
|
||||
_as_int(payload.get(key), 0) > 0
|
||||
for key in ("appointment_id", "patient_id", "diagnosis_id")
|
||||
)
|
||||
entry_allowed = has_record and self._can_enter_list_call(record)
|
||||
self.video_button.setVisible(entry_allowed)
|
||||
self.call_toolbar_button.setVisible(entry_allowed)
|
||||
self.video_button.setEnabled(
|
||||
entry_allowed
|
||||
has_record
|
||||
and is_video_available(record)
|
||||
and (appointment_type_value(record) == "text" or video_call_is_live(record))
|
||||
and valid_ids
|
||||
@@ -3501,28 +3496,6 @@ class ConsultationsPage(QWidget):
|
||||
self._checked_changed(len(self.table_host.selected_records()))
|
||||
self._selection_changed()
|
||||
|
||||
def _can_enter_list_call(self, record: Any) -> bool:
|
||||
if not self._native_video_capable or record is None:
|
||||
return False
|
||||
# Text appointments retain their IM entry; this restriction concerns
|
||||
# receiving an existing video call from the diagnosis list.
|
||||
if appointment_type_value(record) == "text":
|
||||
return True
|
||||
if not can_appointment_video(appointment_type_value(record)):
|
||||
return False
|
||||
roles = UserProfile.from_dict({
|
||||
"role_ids": first_value(self.current_user, "role_ids", "role_id", "roles", default=[])
|
||||
}).role_ids
|
||||
if 1 in roles:
|
||||
return False
|
||||
user_id = _as_int(first_value(self.current_user, "id", "user_id", default=0))
|
||||
assistant_id = _as_int(first_value(record, "assistant_id", "assistant", default=0))
|
||||
return bool(
|
||||
user_id > 0
|
||||
and assistant_id == user_id
|
||||
and _canonical_allowed(self.permissions, "tcm.diagnosis/watchCall")
|
||||
)
|
||||
|
||||
def _request_video(self) -> None:
|
||||
record = self.table.current_data()
|
||||
if not self._native_video_capable:
|
||||
@@ -3530,9 +3503,6 @@ class ConsultationsPage(QWidget):
|
||||
return
|
||||
if record is None or not is_video_available(record):
|
||||
self.banner.show_message("仅当前“已预约”的挂号可进入视频问诊。", "warning")
|
||||
return
|
||||
if not self._can_enter_list_call(record):
|
||||
self.banner.show_message("仅本诊单指派且具有旁观权限的医助可进入视频会话。", "warning")
|
||||
return
|
||||
if appointment_type_value(record) != "text" and not video_call_is_live(record):
|
||||
self.banner.show_message("医生尚未发起视频会话,请等待会话开始。", "warning")
|
||||
|
||||
@@ -52,7 +52,6 @@ from PySide6.QtWidgets import (
|
||||
from .. import icons, motion
|
||||
from ..dialogs.issued_prescription_ai import (
|
||||
PrescriptionAiStatisticsDialog,
|
||||
agreement_safe_batch,
|
||||
agreement_text,
|
||||
batch_running,
|
||||
can_open_issued_ai,
|
||||
@@ -1008,10 +1007,6 @@ class PrescriptionsPage(QWidget):
|
||||
self._mutation_pending = False
|
||||
self._ai_statuses: dict[int, dict[str, Any]] = {}
|
||||
self._ai_enabled = False
|
||||
self._agreement_visible = False
|
||||
self._capability_agreement_visible = False
|
||||
self._ai_capability_token = 0
|
||||
self._ai_capability_pending = False
|
||||
self._ai_request_token = 0
|
||||
self._ai_pending = False
|
||||
self._ai_timer = QTimer(self)
|
||||
@@ -1053,7 +1048,7 @@ class PrescriptionsPage(QWidget):
|
||||
self.orders_button.clicked.connect(lambda: self._open_orders())
|
||||
header.add_action(self.orders_button)
|
||||
self.ai_statistics_button = QPushButton("AI 一致度统计", header)
|
||||
self.ai_statistics_button.hide()
|
||||
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)
|
||||
@@ -1442,7 +1437,6 @@ class PrescriptionsPage(QWidget):
|
||||
self._refresh_pending = False
|
||||
self._generation += 1
|
||||
generation = self._generation
|
||||
self._load_ai_capabilities()
|
||||
page_size = self._page_size
|
||||
start_time, end_time = self._date_filters()
|
||||
filters: dict[str, Any] = {
|
||||
@@ -1662,7 +1656,6 @@ class PrescriptionsPage(QWidget):
|
||||
return
|
||||
method = getattr(self.repository, "list_prescription_ai_statuses", None)
|
||||
if not has_permission(self.permissions, "tcm.prescriptionAi/statuses", default=False):
|
||||
self._set_ai_columns(False)
|
||||
self.ai_status_notice.setText("AI 分析:当前账号没有查看分析状态的权限,请联系管理员授权。")
|
||||
return
|
||||
if not callable(method):
|
||||
@@ -1694,8 +1687,8 @@ class PrescriptionsPage(QWidget):
|
||||
def _set_ai_columns(self, enabled: bool) -> None:
|
||||
changed = enabled != self._ai_enabled
|
||||
self._ai_enabled = enabled
|
||||
self.table.setColumnHidden(11, not enabled)
|
||||
self.table.setColumnHidden(12, not (enabled and self._agreement_visible))
|
||||
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)
|
||||
@@ -1706,54 +1699,10 @@ class PrescriptionsPage(QWidget):
|
||||
if changed:
|
||||
self._decorate_rows()
|
||||
|
||||
def _set_agreement_visible(self, visible: bool) -> None:
|
||||
self._agreement_visible = visible
|
||||
self.table.setColumnHidden(12, not (self._ai_enabled and visible))
|
||||
self.ai_statistics_button.setVisible(
|
||||
visible and has_permission(self.permissions, "tcm.prescriptionAi/statistics", default=False)
|
||||
and callable(getattr(self.repository, "prescription_ai_statistics", None)))
|
||||
if not visible:
|
||||
self._ai_statuses = {key: agreement_safe_batch(row, False) for key, row in self._ai_statuses.items()}
|
||||
for index in range(self.table.rowCount()):
|
||||
item = self.table.item(index, 12)
|
||||
if item is not None:
|
||||
item.setText("")
|
||||
item.setToolTip("")
|
||||
item.setData(Qt.ItemDataRole.AccessibleTextRole, "")
|
||||
|
||||
def _load_ai_capabilities(self) -> None:
|
||||
self._ai_capability_token += 1
|
||||
token, generation = self._ai_capability_token, self._generation
|
||||
self._ai_request_token += 1
|
||||
self._ai_pending = False
|
||||
self._capability_agreement_visible = False
|
||||
self._set_agreement_visible(False)
|
||||
method = getattr(self.repository, "get_prescription_ai_capabilities", None)
|
||||
self._ai_capability_pending = callable(method)
|
||||
if not callable(method):
|
||||
return
|
||||
run_async(method,
|
||||
on_success=lambda result: self._ai_capabilities_ready(result, token, generation),
|
||||
on_error=lambda _error: self._ai_capabilities_ready({}, token, generation))
|
||||
|
||||
def _ai_capabilities_ready(self, result: Any, token: int, generation: int) -> None:
|
||||
if token != self._ai_capability_token or generation != self._generation:
|
||||
return
|
||||
self._ai_capability_pending = False
|
||||
self._capability_agreement_visible = _row_mapping(result).get("agreement_visible") is True
|
||||
self._set_agreement_visible(self._capability_agreement_visible)
|
||||
if self._capability_agreement_visible:
|
||||
# A previous deny discarded scores, including completed cached batches.
|
||||
# Fetch them again after a fresh grant rather than displaying that cache.
|
||||
self._ai_statuses.clear()
|
||||
if self.isVisible() and not self._loading:
|
||||
self._load_ai_statuses()
|
||||
|
||||
def _ai_statuses_ready(self, result: Any, ids: list[int], token: int, generation: int) -> None:
|
||||
if token != self._ai_request_token or generation != self._generation or not self.isVisible():
|
||||
return
|
||||
data = _row_mapping(result)
|
||||
self._set_agreement_visible(self._capability_agreement_visible and data.get("agreement_visible") is True)
|
||||
self._set_ai_columns(data.get("enabled") is True)
|
||||
if not self._ai_enabled:
|
||||
self._ai_timer.stop()
|
||||
@@ -1764,17 +1713,15 @@ class PrescriptionsPage(QWidget):
|
||||
self.ai_status_notice.setToolTip("")
|
||||
self.ai_report_button.setToolTip("自动分析未启用;仍可查看已保存的历史报告。")
|
||||
return
|
||||
self.ai_status_notice.setText("AI 分析已启用:保存手工处方后自动生成两份报告。"
|
||||
+ ("可查看药味与剂量一致度。" if self._agreement_visible else ""))
|
||||
self.ai_status_notice.setText("AI 分析已启用:保存手工处方后自动生成两份报告,并显示药味与剂量一致度。")
|
||||
self.ai_status_notice.setToolTip("")
|
||||
self.ai_report_button.setToolTip("查看两份 AI 报告和候选处方。"
|
||||
+ ("可查看逐味对照。" if self._agreement_visible else ""))
|
||||
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] = agreement_safe_batch(batch, self._agreement_visible and _row_mapping(batch).get("agreement_visible") is True)
|
||||
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()
|
||||
@@ -1794,13 +1741,13 @@ class PrescriptionsPage(QWidget):
|
||||
if batch is None and not blank:
|
||||
continue
|
||||
state = "" if blank else (state_text(batch) if batch else "尚无分析记录")
|
||||
agreement = "" if blank or not self._agreement_visible else agreement_text(batch)
|
||||
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 or column == 12 and not self._agreement_visible else status_tooltip(batch))
|
||||
item.setToolTip("" if blank else status_tooltip(batch))
|
||||
item.setData(Qt.ItemDataRole.AccessibleTextRole, text)
|
||||
if changed:
|
||||
self.table.resizeRowsToContents()
|
||||
@@ -1810,7 +1757,6 @@ class PrescriptionsPage(QWidget):
|
||||
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_agreement_visible(False)
|
||||
self._set_ai_columns(False)
|
||||
self.ai_status_notice.setText("AI 分析暂不可用:请稍后刷新;持续无法使用时,请联系管理员检查服务。")
|
||||
self.ai_status_notice.setToolTip(friendly_error(error))
|
||||
@@ -1835,13 +1781,13 @@ class PrescriptionsPage(QWidget):
|
||||
if _is_blank_prescription(row):
|
||||
return
|
||||
menu = QMenu(self.table)
|
||||
action = menu.addAction("AI 报告 / 逐味对照" if self._agreement_visible else "AI 报告")
|
||||
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 self._agreement_visible or not has_permission(self.permissions, "tcm.prescriptionAi/statistics", default=False):
|
||||
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)
|
||||
@@ -2261,14 +2207,12 @@ class PrescriptionsPage(QWidget):
|
||||
if self.table.rowCount() == 0 and not self._loading:
|
||||
self.refresh()
|
||||
elif not self._loading:
|
||||
self._load_ai_capabilities()
|
||||
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_capability_token += 1
|
||||
self._ai_capability_pending = False
|
||||
self._ai_pending = False
|
||||
super().hideEvent(event)
|
||||
|
||||
|
||||
@@ -321,11 +321,6 @@ QWidget#ReceptionPage QLabel#ReceptionPatientMeta {
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
}
|
||||
QWidget#ReceptionPage QLabel#ReceptionCurrentAssistant {
|
||||
color: #273244;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
}
|
||||
QWidget#ReceptionPage QLabel#StatusBadge {
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
@@ -4399,17 +4394,8 @@ class ReceptionPage(QWidget):
|
||||
actions.setContentsMargins(0, 0, 0, 0)
|
||||
actions.setSpacing(14)
|
||||
actions.addStretch(1)
|
||||
action_group = QVBoxLayout()
|
||||
action_group.setContentsMargins(0, 0, 0, 0)
|
||||
action_group.setSpacing(6)
|
||||
self.current_assistant_label = QLabel("当前医助:—", hero)
|
||||
self.current_assistant_label.setObjectName("ReceptionCurrentAssistant")
|
||||
self.current_assistant_label.setTextFormat(Qt.TextFormat.PlainText)
|
||||
self.current_assistant_label.setWordWrap(True)
|
||||
action_group.addWidget(self.current_assistant_label)
|
||||
action_group.addLayout(actions)
|
||||
patient_head.addLayout(action_group)
|
||||
patient_head.setAlignment(action_group, Qt.AlignmentFlag.AlignBottom)
|
||||
patient_head.addLayout(actions)
|
||||
patient_head.setAlignment(actions, Qt.AlignmentFlag.AlignBottom)
|
||||
|
||||
self.complete_button = _ReceptionCompleteButton(hero)
|
||||
self.complete_button.clicked.connect(self._complete_appointment)
|
||||
@@ -7899,14 +7885,9 @@ class ReceptionPage(QWidget):
|
||||
)
|
||||
)
|
||||
)
|
||||
# reception normalises these fields from the diagnosis's current
|
||||
# assistant, matching the recipient of the notification action.
|
||||
assistant_name = display_text(first_value(appointment, "assistant_name"), "")
|
||||
assistant_id = _as_int(first_value(appointment, "assistant_id"), None)
|
||||
missing_name = "姓名暂缺" if assistant_id else "未分配" if assistant_id == 0 else "—"
|
||||
self.current_assistant_label.setText(f"当前医助:{assistant_name or missing_name}")
|
||||
self.current_assistant_label.setToolTip(self.current_assistant_label.text())
|
||||
self.appointment_labels["assistant"].setText(assistant_name or "—")
|
||||
self.appointment_labels["assistant"].setText(
|
||||
display_text(first_value(appointment, "assistant_name"))
|
||||
)
|
||||
self.appointment_labels["type"].setText(
|
||||
appointment_type_description(appointment_type_value(appointment))
|
||||
)
|
||||
@@ -8840,8 +8821,6 @@ class ReceptionPage(QWidget):
|
||||
)
|
||||
self.patient_meta_label.setText("正在核对患者、诊单与预约信息…" if seed else "—")
|
||||
self.patient_meta_label.setToolTip("")
|
||||
self.current_assistant_label.setText("当前医助:正在加载…" if seed else "当前医助:—")
|
||||
self.current_assistant_label.setToolTip("")
|
||||
self.detail_status.set_status("—", "neutral")
|
||||
for label in getattr(self, "appointment_labels", {}).values():
|
||||
label.setText("—")
|
||||
@@ -8907,8 +8886,6 @@ class ReceptionPage(QWidget):
|
||||
return
|
||||
self.patient_meta_label.setText(message)
|
||||
self.patient_meta_label.setToolTip("")
|
||||
self.current_assistant_label.setText("当前医助:—")
|
||||
self.current_assistant_label.setToolTip("")
|
||||
self._set_case_placeholder(message)
|
||||
self.prescription_hint.setText("处方:患者详情加载失败")
|
||||
self.prescription_hint.setProperty("casePrescriptionState", "unknown")
|
||||
|
||||
@@ -2,16 +2,13 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from threading import Event
|
||||
from time import monotonic
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from PySide6.QtCore import QPoint, Qt, QThread
|
||||
from PySide6.QtCore import QPoint, Qt
|
||||
from PySide6.QtTest import QTest
|
||||
from PySide6.QtWidgets import (
|
||||
QApplication,
|
||||
@@ -24,7 +21,7 @@ from PySide6.QtWidgets import (
|
||||
)
|
||||
|
||||
from doctor_workstation.core import PermissionSet
|
||||
from doctor_workstation.services import ApiClient, DemoDoctorRepository, RemoteDoctorRepository
|
||||
from doctor_workstation.services import DemoDoctorRepository
|
||||
from doctor_workstation.ui.dialogs import ai_consult as ai_consult_module
|
||||
from doctor_workstation.ui.dialogs.ai_consult import (
|
||||
AiConsultDialog,
|
||||
@@ -1580,356 +1577,6 @@ def test_answered_stream_replaces_the_pending_placeholder(
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_stream_clock_covers_preparation_first_text_and_completion(
|
||||
application: QApplication,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
dialog = _silent_dialog(monkeypatch)
|
||||
now = [100.0]
|
||||
monkeypatch.setattr(ai_consult_module, "monotonic", lambda: now[0])
|
||||
dialog._ask("请生成问诊小结")
|
||||
bubble = dialog._stream_bubble
|
||||
assert bubble is not None
|
||||
generation, stream_generation = dialog._generation, dialog._stream_generation
|
||||
assert "已等待 0 秒" in bubble.stamp.text()
|
||||
assert dialog._stream_elapsed_timer.isActive()
|
||||
|
||||
now[0] = 103.0
|
||||
dialog._stream_elapsed_timer.timeout.emit()
|
||||
assert "已等待 3 秒" in bubble.stamp.text()
|
||||
dialog._stream_event(generation, stream_generation, {"event": "start"})
|
||||
assert "已等待 3 秒" in bubble.stamp.text()
|
||||
|
||||
now[0] = 104.2
|
||||
dialog._stream_event(generation, stream_generation, {"event": "delta", "text": "问诊小结"})
|
||||
# No timer flush or done event is needed to display the first text.
|
||||
assert bubble._raw_payload == "问诊小结"
|
||||
assert "生成中" in bubble.stamp.text()
|
||||
assert "已用时 4 秒" in bubble.stamp.text()
|
||||
dialog._stream_event(generation, stream_generation, {"event": "delta", "text": ":请补充舌脉。"})
|
||||
assert dialog._flush_timer.isActive()
|
||||
|
||||
now[0] = 108.7
|
||||
dialog._stream_event(
|
||||
generation, stream_generation, {"event": "done", "model_label": "千问"}
|
||||
)
|
||||
assert bubble._raw_payload == "问诊小结:请补充舌脉。"
|
||||
assert "千问" in bubble.stamp.text()
|
||||
assert "首字 4.2 秒" in bubble.stamp.text()
|
||||
assert "耗时 8.7 秒" in bubble.stamp.text()
|
||||
assert not dialog._stream_elapsed_timer.isActive()
|
||||
assert not dialog._flush_timer.isActive()
|
||||
stamp = bubble.stamp.text()
|
||||
now[0] = 130.0
|
||||
dialog._stream_finished(generation, stream_generation, dialog._stream_worker)
|
||||
dialog._update_stream_elapsed()
|
||||
dialog._stream_event(generation, stream_generation, {"event": "delta", "text": "迟到内容"})
|
||||
assert bubble.stamp.text() == stamp
|
||||
assert bubble._raw_payload == "问诊小结:请补充舌脉。"
|
||||
assert dialog.send_button.isEnabled()
|
||||
dialog.close()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("ending", ["error", "timeout", "silent", "switch", "close"])
|
||||
def test_stream_clock_stops_on_every_exit(
|
||||
application: QApplication,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
ending: str,
|
||||
) -> None:
|
||||
dialog = _silent_dialog(monkeypatch)
|
||||
now = [100.0]
|
||||
monkeypatch.setattr(ai_consult_module, "monotonic", lambda: now[0])
|
||||
dialog._ask("请总结病情")
|
||||
bubble = dialog._stream_bubble
|
||||
assert bubble is not None
|
||||
now[0] = 112.4
|
||||
generation, stream_generation = dialog._generation, dialog._stream_generation
|
||||
worker = dialog._stream_worker
|
||||
if ending == "error":
|
||||
dialog._stream_failed(generation, stream_generation, RuntimeError("模型繁忙"))
|
||||
dialog._stream_finished(generation, stream_generation, worker)
|
||||
elif ending == "timeout":
|
||||
dialog._stream_timed_out()
|
||||
elif ending == "silent":
|
||||
dialog._stream_finished(generation, stream_generation, worker)
|
||||
elif ending == "switch":
|
||||
dialog.open_for(diagnosis_id=502, patient_id=302)
|
||||
else:
|
||||
dialog.close()
|
||||
assert not dialog._stream_elapsed_timer.isActive()
|
||||
assert not dialog._stream_timeout_timer.isActive()
|
||||
assert not dialog._asking
|
||||
assert dialog.send_button.isEnabled()
|
||||
if ending in {"error", "timeout", "silent"}:
|
||||
assert "耗时 12.4 秒" in bubble.stamp.text()
|
||||
stamp = bubble.stamp.text()
|
||||
now[0] = 140.0
|
||||
dialog._update_stream_elapsed()
|
||||
assert bubble.stamp.text() == stamp
|
||||
if ending == "switch":
|
||||
dialog._ask("新患者的问题")
|
||||
assert dialog._stream_bubble is not None
|
||||
assert "已等待 0 秒" in dialog._stream_bubble.stamp.text()
|
||||
assert dialog._stream_first_delta_at is None
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_http_stream_paints_before_upstream_finishes_on_the_ui_thread(
|
||||
application: QApplication,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Exercise HTTP parsing, repository, real worker and Qt delivery together."""
|
||||
release = Event()
|
||||
waiting_for_end = Event()
|
||||
|
||||
class DelayedStream(httpx.SyncByteStream):
|
||||
def __iter__(self):
|
||||
first = (
|
||||
'event: start\r\ndata: {"model_label":"千问"}\r\n\r\n'
|
||||
'event: delta\r\ndata: {"text":"先收到的内容"}\r\n\r\n'
|
||||
).encode()
|
||||
# A network chunk may end partway through a Chinese character.
|
||||
split = first.index("先".encode()) + 1
|
||||
yield first[:split]
|
||||
yield first[split:]
|
||||
waiting_for_end.set()
|
||||
if not release.wait(5):
|
||||
raise AssertionError("UI did not receive the first streamed text")
|
||||
yield (
|
||||
'event: delta\r\ndata: {"text":",然后完成。"}\r\n\r\n'
|
||||
'event: done\r\ndata: {"model_label":"千问"}\r\n\r\n'
|
||||
).encode()
|
||||
|
||||
client = ApiClient(
|
||||
"https://example.test",
|
||||
transport=httpx.MockTransport(
|
||||
lambda request: httpx.Response(
|
||||
200, headers={"content-type": "text/event-stream"}, stream=DelayedStream()
|
||||
)
|
||||
),
|
||||
)
|
||||
dialog = AiConsultDialog(RemoteDoctorRepository(client))
|
||||
dialog.diagnosis_id = 501
|
||||
dialog._context_notice_shown = True
|
||||
delivered_on = []
|
||||
original_event = dialog._stream_event
|
||||
|
||||
def record_event(*args: Any) -> None:
|
||||
delivered_on.append(QThread.currentThread())
|
||||
original_event(*args)
|
||||
|
||||
monkeypatch.setattr(dialog, "_stream_event", record_event)
|
||||
dialog.show()
|
||||
try:
|
||||
dialog._ask("请生成问诊小结")
|
||||
deadline = monotonic() + 3
|
||||
while (not dialog._stream_text or not waiting_for_end.is_set()) and monotonic() < deadline:
|
||||
QTest.qWait(10)
|
||||
bubble = dialog._stream_bubble
|
||||
assert bubble is not None and bubble.body is not None
|
||||
assert waiting_for_end.is_set()
|
||||
assert bubble.body.toPlainText() == "先收到的内容"
|
||||
assert not dialog._stream_completed
|
||||
assert dialog._asking
|
||||
assert "生成中" in bubble.stamp.text()
|
||||
release.set()
|
||||
deadline = monotonic() + 3
|
||||
while dialog._asking and monotonic() < deadline:
|
||||
QTest.qWait(10)
|
||||
assert dialog._stream_completed
|
||||
assert not dialog._asking
|
||||
assert bubble.body.toPlainText() == "先收到的内容,然后完成。"
|
||||
assert delivered_on and all(thread == application.thread() for thread in delivered_on)
|
||||
assert not dialog._stream_elapsed_timer.isActive()
|
||||
finally:
|
||||
release.set()
|
||||
dialog.close()
|
||||
client.close()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("terminal", ["done", "error"])
|
||||
def test_every_quick_tool_can_start_after_terminal_event_before_worker_exit(
|
||||
application: QApplication,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
terminal: str,
|
||||
) -> None:
|
||||
dialog = _silent_dialog(monkeypatch)
|
||||
dialog.show()
|
||||
application.processEvents()
|
||||
cards = dialog.findChildren(ai_consult_module._ClickCard, "AiConsultToolCard")
|
||||
assert len(cards) == len(ai_consult_module.TOOL_ITEMS) == 6
|
||||
previous = []
|
||||
for card, (_kind, label, prompt) in zip(cards, ai_consult_module.TOOL_ITEMS, strict=True):
|
||||
# Exercise the text surface the doctor clicks, including event propagation.
|
||||
target = next(child for child in card.findChildren(QLabel) if child.text() == label)
|
||||
QTest.mouseClick(target, Qt.MouseButton.LeftButton)
|
||||
worker = dialog._stream_worker
|
||||
assert worker is not None and worker.prompt == prompt
|
||||
assert dialog._asking
|
||||
generation, stream_generation = dialog._generation, dialog._stream_generation
|
||||
for old_generation, old_stream_generation, old_worker in previous:
|
||||
dialog._stream_finished(old_generation, old_stream_generation, old_worker)
|
||||
assert dialog._stream_worker is worker
|
||||
assert dialog._asking
|
||||
previous.append((generation, stream_generation, worker))
|
||||
if terminal == "done":
|
||||
dialog._stream_event(generation, stream_generation, {"event": "done", "answer": "已完成"})
|
||||
else:
|
||||
dialog._stream_failed(generation, stream_generation, RuntimeError("模型繁忙"))
|
||||
# The transport may take longer to close; it must not keep the UI locked.
|
||||
assert not dialog._asking
|
||||
assert dialog.send_button.isEnabled()
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_clicking_another_quick_tool_switches_the_active_request(
|
||||
application: QApplication,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
dialog = _silent_dialog(monkeypatch)
|
||||
notices = []
|
||||
monkeypatch.setattr(ai_consult_module, "show_toast", lambda *args: notices.append(args[1]))
|
||||
dialog.show()
|
||||
application.processEvents()
|
||||
cards = dialog.findChildren(ai_consult_module._ClickCard, "AiConsultToolCard")
|
||||
QTest.mouseClick(cards[0], Qt.MouseButton.LeftButton)
|
||||
old_worker, old_bubble = dialog._stream_worker, dialog._stream_bubble
|
||||
old_generation, old_stream_generation = dialog._generation, dialog._stream_generation
|
||||
assert old_worker is not None and old_bubble is not None
|
||||
dialog._stream_event(old_generation, old_stream_generation, {"event": "delta", "text": "已收到的分析"})
|
||||
|
||||
QTest.mouseClick(cards[1], Qt.MouseButton.LeftButton)
|
||||
worker, bubble = dialog._stream_worker, dialog._stream_bubble
|
||||
assert worker is not None and worker is not old_worker
|
||||
assert worker.prompt == ai_consult_module.TOOL_ITEMS[1][2]
|
||||
assert old_worker.is_cancelled()
|
||||
assert "已收到的分析" in old_bubble._raw_payload
|
||||
assert "已停止" in old_bubble._raw_payload
|
||||
assert bubble is not None and bubble is not old_bubble
|
||||
assert bubble._raw_payload == ai_consult_module.AI_STREAM_PENDING_TEXT
|
||||
assert "已等待 0 秒" in bubble.stamp.text()
|
||||
assert dialog._asking
|
||||
|
||||
dialog._stream_event(old_generation, old_stream_generation, {"event": "done", "answer": "旧结果"})
|
||||
dialog._stream_failed(old_generation, old_stream_generation, RuntimeError("旧错误"))
|
||||
dialog._stream_finished(old_generation, old_stream_generation, old_worker)
|
||||
assert dialog._stream_worker is worker
|
||||
assert dialog._asking
|
||||
assert bubble._raw_payload == ai_consult_module.AI_STREAM_PENDING_TEXT
|
||||
|
||||
# Repeated clicks on the same running tool do not submit duplicate requests.
|
||||
QTest.mouseClick(cards[1], Qt.MouseButton.LeftButton)
|
||||
assert dialog._stream_worker is worker
|
||||
assert not worker.is_cancelled()
|
||||
assert notices and "正在生成" in notices[-1]
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_stream_worker_does_not_wait_for_another_event_after_done() -> None:
|
||||
continued_after_done = []
|
||||
|
||||
class DoneThenIdleRepository:
|
||||
def stream_diagnosis_ai(self, *args: Any, **kwargs: Any):
|
||||
yield {"event": "done", "answer": "已完成"}
|
||||
continued_after_done.append(True)
|
||||
raise AssertionError("the terminal event must end iteration")
|
||||
|
||||
worker = ai_consult_module._AiStreamWorker(
|
||||
DoneThenIdleRepository(), diagnosis_id=501, prompt="请分析", task="custom"
|
||||
)
|
||||
events, errors, finished = [], [], []
|
||||
worker.signals.event.connect(events.append)
|
||||
worker.signals.error.connect(errors.append)
|
||||
worker.signals.finished.connect(lambda: finished.append(True))
|
||||
worker.run()
|
||||
assert [event["event"] for event in events] == ["done"]
|
||||
assert continued_after_done == []
|
||||
assert errors == []
|
||||
assert finished == [True]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("entry", ["tool", "chip", "menu", "suggestion"])
|
||||
def test_quick_actions_switch_requests_and_reveal_the_chat(
|
||||
application: QApplication,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
entry: str,
|
||||
) -> None:
|
||||
dialog = _silent_dialog(monkeypatch)
|
||||
dialog.show()
|
||||
application.processEvents()
|
||||
dialog._ask("之前的请求")
|
||||
old_worker = dialog._stream_worker
|
||||
assert old_worker is not None
|
||||
dialog.tabs.setCurrentIndex(1)
|
||||
dialog._set_chat_filter("doctor")
|
||||
if entry == "tool":
|
||||
target = dialog.findChildren(ai_consult_module._ClickCard, "AiConsultToolCard")[0]
|
||||
prompt = ai_consult_module.TOOL_ITEMS[0][2]
|
||||
QTest.mouseClick(target, Qt.MouseButton.LeftButton)
|
||||
elif entry == "chip":
|
||||
target = next(button for button in dialog.findChildren(QPushButton) if button.text() == "总结当前病情")
|
||||
prompt = ai_consult_module.QUICK_PROMPTS[0][1]
|
||||
target.click()
|
||||
elif entry == "menu":
|
||||
more = next(button for button in dialog.findChildren(QPushButton) if button.text() == "更多")
|
||||
prompt = ai_consult_module.QUICK_PROMPTS[3][1]
|
||||
more.menu().actions()[0].trigger()
|
||||
else:
|
||||
target = dialog.findChildren(ai_consult_module._ClickCard, "AiConsultSuggestRow")[0]
|
||||
prompt = f"请针对该问题给出问诊话术与判断要点:{ai_consult_module.SUGGESTIONS[0]}"
|
||||
QTest.mouseClick(target, Qt.MouseButton.LeftButton)
|
||||
assert old_worker.is_cancelled()
|
||||
assert dialog._stream_worker is not None and dialog._stream_worker.prompt == prompt
|
||||
assert dialog.tabs.currentIndex() == dialog.center_stack.currentIndex() == 0
|
||||
assert dialog._chat_filter == "all"
|
||||
assert dialog._stream_bubble is not None and dialog._stream_bubble.isVisible()
|
||||
dialog.close()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exit_action", ["back", "side", "escape", "accept", "reject", "close"])
|
||||
def test_all_dialog_exit_paths_cancel_stream_and_allow_a_fresh_session(
|
||||
application: QApplication,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
exit_action: str,
|
||||
) -> None:
|
||||
dialog = _silent_dialog(monkeypatch)
|
||||
dialog.show()
|
||||
application.processEvents()
|
||||
dialog._ask("请分析病情")
|
||||
worker = dialog._stream_worker
|
||||
assert worker is not None
|
||||
generation, stream_generation = dialog._generation, dialog._stream_generation
|
||||
if exit_action == "back":
|
||||
next(button for button in dialog.findChildren(QPushButton) if button.text() == "返回").click()
|
||||
elif exit_action == "side":
|
||||
dialog.findChild(QPushButton, "AiConsultSideClose").click()
|
||||
elif exit_action == "escape":
|
||||
QTest.keyClick(dialog, Qt.Key.Key_Escape)
|
||||
else:
|
||||
getattr(dialog, exit_action)()
|
||||
assert not dialog.isVisible()
|
||||
assert worker.is_cancelled()
|
||||
assert not dialog._asking
|
||||
assert not dialog._stream_timeout_timer.isActive()
|
||||
assert not dialog._stream_elapsed_timer.isActive()
|
||||
closed_generation = dialog._generation
|
||||
assert closed_generation > generation
|
||||
dialog.close()
|
||||
assert dialog._generation == closed_generation
|
||||
|
||||
dialog.open_for(diagnosis_id=502, patient_id=302)
|
||||
dialog.show()
|
||||
dialog._ask("新患者的问题")
|
||||
new_worker, bubble = dialog._stream_worker, dialog._stream_bubble
|
||||
assert new_worker is not None and not new_worker.is_cancelled()
|
||||
dialog._stream_event(generation, stream_generation, {"event": "done", "answer": "旧患者结果"})
|
||||
dialog._stream_finished(generation, stream_generation, worker)
|
||||
assert dialog._stream_worker is new_worker
|
||||
assert dialog._asking
|
||||
assert bubble is not None and bubble._raw_payload == ai_consult_module.AI_STREAM_PENDING_TEXT
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_ai_consult_window_can_be_maximized(
|
||||
application: QApplication,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
|
||||
@@ -7,7 +7,7 @@ from typing import Any
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import QDate, QEvent, Signal
|
||||
from PySide6.QtCore import QDate, Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QApplication,
|
||||
QDialog,
|
||||
@@ -19,7 +19,7 @@ from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from doctor_workstation.core import PermissionSet, UserProfile
|
||||
from doctor_workstation.core import PermissionSet
|
||||
from doctor_workstation.ui.dialogs import diagnosis as diagnosis_module
|
||||
from doctor_workstation.ui.pages import consultations as consultations_module
|
||||
from doctor_workstation.ui.pages.consultations import (
|
||||
@@ -669,7 +669,7 @@ def test_switching_rows_invalidates_prescription_worker_and_clears_busy(
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_list_video_entry_uses_watch_permission_not_video_qr_permission(
|
||||
def test_native_call_does_not_reuse_video_qr_permission(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
class VideoRepository:
|
||||
@@ -686,17 +686,13 @@ def test_list_video_entry_uses_watch_permission_not_video_qr_permission(
|
||||
pass
|
||||
|
||||
live_row = _row(
|
||||
assistant_id=66,
|
||||
video_call_hint={
|
||||
"state": "live",
|
||||
"label": "视频通话进行中",
|
||||
"start_time": 1787102100,
|
||||
}
|
||||
)
|
||||
page = ConsultationsPage(
|
||||
VideoRepository(), permissions=PermissionSet(["tcm.diagnosis/watchCall"]),
|
||||
current_user={"id": 66, "role_id": 2},
|
||||
)
|
||||
page = ConsultationsPage(VideoRepository(), permissions=PermissionSet([]))
|
||||
emitted: list[dict[str, Any]] = []
|
||||
page.video_requested.connect(emitted.append)
|
||||
page.table.set_rows([live_row])
|
||||
@@ -742,13 +738,10 @@ def test_video_join_action_is_hidden_until_doctor_session_is_live(
|
||||
def end_call(self, diagnosis_id: int) -> None:
|
||||
pass
|
||||
|
||||
page = ConsultationsPage(
|
||||
VideoRepository(), permissions=PermissionSet(["tcm.diagnosis/watchCall"]),
|
||||
current_user={"id": 66, "role_id": 2},
|
||||
)
|
||||
page = ConsultationsPage(VideoRepository(), permissions=PermissionSet([]))
|
||||
emitted: list[dict[str, Any]] = []
|
||||
page.video_requested.connect(emitted.append)
|
||||
page.table.set_rows([_row(assistant_id=66, video_call_hint=hint)])
|
||||
page.table.set_rows([_row(video_call_hint=hint)])
|
||||
page.table.selectRow(0)
|
||||
application.processEvents()
|
||||
|
||||
@@ -759,92 +752,6 @@ def test_video_join_action_is_hidden_until_doctor_session_is_live(
|
||||
assert emitted == []
|
||||
page.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("user", [
|
||||
{"id": 66, "role_id": 1},
|
||||
{"id": 66, "role_id": "1"},
|
||||
{"id": 66, "role_ids": [1, 2]},
|
||||
{"id": 66, "role_id": "1,2"},
|
||||
{"id": 66, "roles": [{"id": 1}]},
|
||||
{"id": 66, "role_id": 1, "root": True},
|
||||
UserProfile.from_dict({"id": 66, "role_id": [1, 2]}),
|
||||
])
|
||||
@pytest.mark.parametrize("state,status", [
|
||||
("live", "通话中"), ("pending_room", "接通中"), ("none", "—"),
|
||||
])
|
||||
def test_doctor_video_column_is_readonly_even_with_full_permission(
|
||||
application: QApplication,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
user: Any,
|
||||
state: str,
|
||||
status: str,
|
||||
) -> None:
|
||||
from doctor_workstation.services import DemoDoctorRepository
|
||||
|
||||
monkeypatch.setattr(consultations_module, "run_async", lambda *_a, **_kw: None)
|
||||
# Even a mixed-role doctor assigned to this row must not get the receiver entry.
|
||||
row = _row(assistant_id=66, video_call_hint={"state": state})
|
||||
page = ConsultationsPage(DemoDoctorRepository(), PermissionSet(["*"]), current_user=user)
|
||||
emitted = []
|
||||
page.video_requested.connect(emitted.append)
|
||||
try:
|
||||
page.table_host.set_rows([row])
|
||||
page.table.selectRow(0)
|
||||
page._selection_changed()
|
||||
cell = page.table_host.fixed.indexWidget(page.table_host.model.index(0, 10))
|
||||
assert cell.findChildren(QToolButton) == []
|
||||
assert status in [label.text() for label in cell.findChildren(QLabel)]
|
||||
assert page.call_toolbar_button.isHidden()
|
||||
assert not page.video_button.isEnabled()
|
||||
page._row_video(row)
|
||||
page.video_button.setEnabled(True)
|
||||
page.video_button.click()
|
||||
assert emitted == []
|
||||
finally:
|
||||
page.close()
|
||||
page.deleteLater()
|
||||
application.sendPostedEvents(None, QEvent.Type.DeferredDelete)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("user,permissions,assistant_id,allowed", [
|
||||
({"id": 66, "role_id": 2}, ["tcm.diagnosis/watchCall"], 66, True),
|
||||
({"id": 66, "role_id": 2}, ["tcm.diagnosis/videoQr"], 66, False),
|
||||
({"id": 66, "role_id": 2}, [], 66, False),
|
||||
({"id": 66, "role_id": 2}, ["*"], 77, False),
|
||||
({"id": 66, "role_id": 0, "root": True}, ["*"], 77, False),
|
||||
({"id": 66, "role_id": 0, "root": True}, ["*"], 66, True),
|
||||
({"id": 0, "role_id": 2}, ["*"], 0, False),
|
||||
(None, ["*"], 66, False),
|
||||
])
|
||||
def test_list_video_entry_requires_the_assigned_user_and_watch_permission(
|
||||
application: QApplication,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
user: Any,
|
||||
permissions: list[str],
|
||||
assistant_id: int,
|
||||
allowed: bool,
|
||||
) -> None:
|
||||
from doctor_workstation.services import DemoDoctorRepository
|
||||
|
||||
monkeypatch.setattr(consultations_module, "run_async", lambda *_a, **_kw: None)
|
||||
row = _row(assistant_id=assistant_id, video_call_hint={"state": "live"})
|
||||
page = ConsultationsPage(DemoDoctorRepository(), PermissionSet(permissions), current_user=user)
|
||||
emitted = []
|
||||
page.video_requested.connect(emitted.append)
|
||||
try:
|
||||
page.table_host.set_rows([row])
|
||||
page.table.selectRow(0)
|
||||
page._selection_changed()
|
||||
cell = page.table_host.fixed.indexWidget(page.table_host.model.index(0, 10))
|
||||
assert bool(cell.findChildren(QToolButton)) is allowed
|
||||
assert page.video_button.isEnabled() is allowed
|
||||
page._request_video()
|
||||
assert bool(emitted) is allowed
|
||||
finally:
|
||||
page.close()
|
||||
page.deleteLater()
|
||||
application.sendPostedEvents(None, QEvent.Type.DeferredDelete)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
|
||||
@@ -1121,76 +1121,6 @@ def test_appointment_history_phone_is_fail_closed_without_phone_plain(
|
||||
dialog.close()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("locked", [False, True])
|
||||
def test_doctor_can_save_clinical_fields_without_resubmitting_masked_identity(
|
||||
application: QApplication,
|
||||
locked: bool,
|
||||
) -> None:
|
||||
class MaskedRepository(VisualRepository):
|
||||
def check_diagnosis_phone(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
raise AssertionError("a masked phone must not be checked as plaintext")
|
||||
|
||||
def check_diagnosis_id_card(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
raise AssertionError("a masked identity must not be checked as plaintext")
|
||||
|
||||
repository = MaskedRepository(locked=locked)
|
||||
for source in (repository.detail["diagnosis"], repository.detail["patient"]):
|
||||
source["phone"] = "156****7862"
|
||||
source["id_card"] = "1101**********1234"
|
||||
dialog = _open_dialog(
|
||||
application, (1024, 640), mode="edit", repository=repository,
|
||||
permissions=PermissionSet(["tcm.diagnosis/detail", "tcm.diagnosis/edit"]),
|
||||
)
|
||||
saved = []
|
||||
dialog.saved.connect(lambda: saved.append(True))
|
||||
try:
|
||||
assert dialog.edit_fields["phone"].isReadOnly()
|
||||
assert dialog.edit_fields["phone"].toPlainText() == "156****7862"
|
||||
assert dialog.edit_fields["id_card"].isReadOnly()
|
||||
dialog.edit_fields["chief_complaint"].setPlainText("口渴较前改善")
|
||||
dialog._save()
|
||||
assert saved == [True]
|
||||
assert len(repository.updates) == 1
|
||||
payload = repository.updates[0]
|
||||
assert payload["chief_complaint"] == "口渴较前改善"
|
||||
assert "phone" not in payload and "id_card" not in payload
|
||||
assert "156****7862" not in str(payload)
|
||||
assert "1101**********1234" not in str(payload)
|
||||
finally:
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_plain_phone_permission_keeps_validation_and_duplicate_checks(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
class PlainRepository(VisualRepository):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.phone_checks = []
|
||||
|
||||
def check_diagnosis_phone(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
self.phone_checks.append(dict(payload))
|
||||
return {"exists": False}
|
||||
|
||||
repository = PlainRepository()
|
||||
dialog = _open_dialog(
|
||||
application, (1024, 640), mode="edit", repository=repository,
|
||||
permissions=PermissionSet(["tcm.diagnosis/edit", "tcm.diagnosis/phonePlain"]),
|
||||
)
|
||||
try:
|
||||
assert not dialog.edit_fields["phone"].isReadOnly()
|
||||
dialog.edit_fields["phone"].setPlainText("156****7862")
|
||||
dialog._save()
|
||||
assert not repository.phone_checks
|
||||
assert not repository.updates
|
||||
dialog.edit_fields["phone"].setPlainText("15600007862")
|
||||
dialog._save()
|
||||
assert repository.phone_checks == [{"id": 501, "phone": "15600007862"}]
|
||||
assert repository.updates[-1]["phone"] == "15600007862"
|
||||
finally:
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_order_detail_action_requires_exact_permission(application: QApplication) -> None:
|
||||
repository = VisualRepository()
|
||||
dialog = _open_dialog(
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import QEvent
|
||||
from PySide6.QtGui import QFont
|
||||
from PySide6.QtWidgets import QApplication, QLabel, QToolButton, QVBoxLayout, QWidget
|
||||
|
||||
from doctor_workstation.ui.diagnosis_index_widgets import DiagnosisTableHost
|
||||
from doctor_workstation.ui.reception_style import body_family
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def application() -> QApplication:
|
||||
return QApplication.instance() or QApplication([])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("tech_blue", [False, True])
|
||||
@pytest.mark.parametrize("size", [(1024, 640), (1710, 920)])
|
||||
@pytest.mark.parametrize("with_channel", [False, True])
|
||||
def test_public_video_status_fits_frozen_cell_with_inherited_item_padding(
|
||||
application: QApplication, tech_blue: bool, size: tuple[int, int],
|
||||
with_channel: bool,
|
||||
) -> None:
|
||||
root = QWidget()
|
||||
font = QFont(body_family())
|
||||
font.setPixelSize(13)
|
||||
root.setFont(font)
|
||||
# Match GLOBAL_QSS without changing the application's theme between tests.
|
||||
root.setStyleSheet("QTableView::item { padding: 6px 8px; border-bottom: 1px solid #E1E9F4; }")
|
||||
layout = QVBoxLayout(root)
|
||||
host = DiagnosisTableHost(
|
||||
parent=root, tech_blue=tech_blue,
|
||||
action_policy={"video_call": True, "view": True, "edit": True},
|
||||
video_entry_allowed=lambda _record: False,
|
||||
)
|
||||
layout.addWidget(host)
|
||||
statuses = [
|
||||
("ended", "视频已结束 · 10-17 14:19"),
|
||||
("ended", "视频已结束 · 10-17 14:27"),
|
||||
("ended", "视频已结束"),
|
||||
("live", "通话中"),
|
||||
("pending_room", "接通中"),
|
||||
("missed", "上次通话未接听"),
|
||||
("cancelled", "上次通话已取消"),
|
||||
("none", "—"),
|
||||
]
|
||||
rows = [
|
||||
{
|
||||
"id": index + 1, "patient_id": index + 100, "patient_name": "测试患者",
|
||||
"gender": 1, "age": 50, "has_appointment": 1,
|
||||
"appointment_id": index + 200, "appointment_status": 1,
|
||||
"appointment_type": "video",
|
||||
"latest_appointment_channel_text": "自媒体4H" if with_channel else "",
|
||||
"appointments": [{"id": index + 200, "status": 1, "doctor_name": "测试医生",
|
||||
"time_text": "2026-10-17 14:15", "appointment_type": "video"}],
|
||||
"video_call_hint": {"state": state, "label": text},
|
||||
}
|
||||
for index, (state, text) in enumerate(statuses * 4)
|
||||
]
|
||||
try:
|
||||
host.set_rows(rows)
|
||||
root.resize(*size)
|
||||
root.show()
|
||||
for _ in range(4):
|
||||
application.processEvents()
|
||||
for index, (_state, text) in enumerate(statuses):
|
||||
cell = host.fixed.indexWidget(host.model.index(index, 10))
|
||||
assert not cell.findChildren(QToolButton)
|
||||
label = cell.findChild(QLabel)
|
||||
assert label is not None
|
||||
assert label.height() >= label.heightForWidth(label.width())
|
||||
assert cell.rect().contains(label.geometry())
|
||||
assert abs(label.geometry().center().y() - cell.rect().center().y()) <= 1
|
||||
assert label.toolTip() == text
|
||||
if " · " in text:
|
||||
assert label.text() == text.replace(" · ", "\n")
|
||||
assert label.fontMetrics().horizontalAdvance("10-17 14:19") <= label.width()
|
||||
assert cell.width() >= host.fixed.columnWidth(10) - 2
|
||||
host.main.horizontalScrollBar().setValue(host.main.horizontalScrollBar().maximum())
|
||||
for fraction in (2, 3):
|
||||
host.main.verticalScrollBar().setValue(host.main.verticalScrollBar().maximum() // fraction)
|
||||
application.processEvents()
|
||||
assert host.fixed.verticalScrollBar().value() == host.main.verticalScrollBar().value()
|
||||
row = host.main.indexAt(host.main.viewport().rect().center()).row()
|
||||
assert row >= 0
|
||||
assert host.main.visualRect(host.model.index(row, 0)).top() == host.fixed.visualRect(
|
||||
host.model.index(row, 10)
|
||||
).top()
|
||||
finally:
|
||||
root.close()
|
||||
root.deleteLater()
|
||||
application.sendPostedEvents(None, QEvent.Type.DeferredDelete)
|
||||
@@ -23,7 +23,7 @@ from doctor_workstation.ui.pages import prescriptions as page_module
|
||||
|
||||
def batch(batch_id: int = 40, prescription_id: int = 801, status: str = "running") -> dict[str, Any]:
|
||||
return {
|
||||
"id": batch_id, "prescription_id": prescription_id, "prescription_revision": 2, "agreement_visible": True,
|
||||
"id": batch_id, "prescription_id": prescription_id, "prescription_revision": 2,
|
||||
"patient_id": 301, "diagnosis_id": 501, "status": status,
|
||||
"validity": "current", "comparison_type": "independent_baseline",
|
||||
"source_summary": {"diagnosis_count": 2, "attachment_count": 4},
|
||||
@@ -48,14 +48,10 @@ class Repository(DemoDoctorRepository):
|
||||
self.calls: list[tuple[str, Any]] = []
|
||||
self.batches = [batch()]
|
||||
self.enabled = True
|
||||
self.agreement_visible = True
|
||||
|
||||
def get_prescription_ai_capabilities(self) -> dict[str, Any]:
|
||||
return {"agreement_visible": self.agreement_visible, "can_manage_visibility": False}
|
||||
|
||||
def list_prescription_ai_reports(self, **params: Any) -> dict[str, Any]:
|
||||
self.calls.append(("reports", params))
|
||||
return {"agreement_visible": self.agreement_visible, "enabled": self.enabled, "lists": deepcopy(self.batches), "count": len(self.batches), "page_no": 1, "page_size": 20}
|
||||
return {"enabled": self.enabled, "lists": deepcopy(self.batches), "count": len(self.batches), "page_no": 1, "page_size": 20}
|
||||
|
||||
def get_prescription_ai_report(self, batch_id: int) -> dict[str, Any]:
|
||||
self.calls.append(("detail", batch_id))
|
||||
@@ -75,7 +71,7 @@ class Repository(DemoDoctorRepository):
|
||||
|
||||
def list_prescription_ai_statuses(self, ids: list[int]) -> dict[str, Any]:
|
||||
self.calls.append(("statuses", list(ids)))
|
||||
return {"agreement_visible": self.agreement_visible, "enabled": self.enabled, "items": [{"agreement_visible": self.agreement_visible, "prescription_id": value, "batch_id": 40, "status": "running", "validity": "current", "comparison_type": "independent_baseline", "models": {"qwen": {"status": "succeeded", "score": 0, "comparison_status": "comparable"}, "openai": {"status": "running", "score": None}}} for value in ids] if self.enabled else []}
|
||||
return {"enabled": self.enabled, "items": [{"prescription_id": value, "batch_id": 40, "status": "running", "validity": "current", "comparison_type": "independent_baseline", "models": {"qwen": {"status": "succeeded", "score": 0, "comparison_status": "comparable"}, "openai": {"status": "running", "score": None}}} for value in ids] if self.enabled else []}
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
@@ -599,9 +595,6 @@ def test_list_ignores_response_after_filter_generation_changes(application: QApp
|
||||
|
||||
def test_statistics_nulls_and_absent_review_samples(application: QApplication, immediate: None) -> None:
|
||||
class StatisticsRepository:
|
||||
def get_prescription_ai_capabilities(self) -> dict[str, Any]:
|
||||
return {"agreement_visible": True}
|
||||
|
||||
def prescription_ai_statistics(self, *_args: Any) -> dict[str, Any]:
|
||||
return {"total_count": 3, "patient_count": 2, "doctors": [{"doctor_name": "张医生", "total_count": 3, "patient_count": 2, "models": {"qwen": {"eligible_count": 0, "coverage_rate": 0, "mean": None, "median": None, "excluded_reasons": {"non_independent": 3}}, "openai": {"eligible_count": 1, "coverage_rate": 33.3, "mean": 0, "median": 0}}, "review": {"evaluated_count": 0, "qualified_count": 0, "qualified_rate": None}}]}
|
||||
dialog = ai.PrescriptionAiStatisticsDialog(StatisticsRepository(), ["*"])
|
||||
@@ -642,7 +635,6 @@ def test_repository_exact_read_and_mutation_contracts() -> None:
|
||||
return {"saved": True}
|
||||
client = Client()
|
||||
repository = RemoteDoctorRepository(client)
|
||||
repository.get_prescription_ai_capabilities()
|
||||
assert repository.list_prescription_ai_statuses([801, 802, 801]) == {"enabled": False, "items": []}
|
||||
repository.list_prescription_ai_reports(diagnosis_id=501, page_no=2, page_size=20)
|
||||
repository.get_prescription_ai_report(40)
|
||||
@@ -652,7 +644,6 @@ def test_repository_exact_read_and_mutation_contracts() -> None:
|
||||
repository.review_prescription_ai(40, "qwen", "reviewed", "核对完成")
|
||||
repository.prescription_ai_statistics("2026-09-01", "2026-09-09", 7)
|
||||
assert client.calls == [
|
||||
("GET", "tcm.prescriptionAi/capabilities", {}),
|
||||
("GET", "tcm.prescriptionAi/statuses", {"ids": "801,802"}),
|
||||
("GET", "tcm.prescriptionAi/reports", {"diagnosis_id": 501, "page_no": 2, "page_size": 20}),
|
||||
("GET", "tcm.prescriptionAi/detail", {"batch_id": 40}),
|
||||
@@ -669,217 +660,6 @@ def test_repository_exact_read_and_mutation_contracts() -> None:
|
||||
repository.retry_prescription_ai(40, "other")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("flag", [False, None, 1, "true"])
|
||||
def test_agreement_denied_or_missing_keeps_reports_review_and_retry(
|
||||
application: QApplication, immediate: None, flag: Any,
|
||||
) -> None:
|
||||
repository = Repository()
|
||||
repository.agreement_visible = flag
|
||||
value = repository.batches[0]
|
||||
value["agreement_visible"] = flag
|
||||
value["models"]["openai"].update(status="failed", error_message="上游超时")
|
||||
dialog = ai.IssuedPrescriptionAiDialog(repository, ["*"], diagnosis_id=501,
|
||||
current_user={"root": 1, "role_ids": [1]})
|
||||
dialog.show()
|
||||
application.processEvents()
|
||||
assert not dialog._agreement_visible
|
||||
assert "千问分析" in dialog.model_views["qwen"]["report"].toPlainText()
|
||||
assert dialog.model_views["qwen"]["comment"].isEnabled()
|
||||
assert dialog.model_views["qwen"]["save"].isVisible()
|
||||
assert dialog.model_views["openai"]["retry"].isVisible()
|
||||
assert dialog.model_views["openai"]["retry"].isEnabled()
|
||||
assert dialog.history.count() == 1
|
||||
assert dialog.comparison_panel.checklist.isVisible()
|
||||
assert dialog.comparison_panel.conclusions.isHidden()
|
||||
assert not dialog.comparison_panel.rows
|
||||
assert not dialog.comparison_panel.doses.rows
|
||||
assert dialog.nav_buttons["history"].isHidden()
|
||||
assert dialog.agreement_note.isHidden()
|
||||
for key, views in dialog.model_views.items():
|
||||
assert not views["score"].text() and not views["score"].toolTip()
|
||||
assert not views["agreement_bar"].toolTip() and not views["agreement_bar"].accessibleName()
|
||||
assert views["gauge"]._value is None and not views["gauge"].accessibleDescription()
|
||||
assert not views["comparison"].toPlainText()
|
||||
assert "comparison" not in dialog._batch["models"][key]
|
||||
assert not dialog.agreement.delta.text() and not dialog.agreement.overlap.text()
|
||||
for key in ("differences", "consensus"):
|
||||
assert not dialog.rail.focus_values[key].text()
|
||||
assert dialog.rail.focus_values[key].parentWidget().isHidden()
|
||||
dialog._goto("per_herb")
|
||||
assert dialog.tabs.currentWidget() is dialog.tab_pages["candidate"]
|
||||
assert "黄芪" in dialog.model_views["qwen"]["candidate"].toPlainText()
|
||||
dialog._goto("comparison_raw")
|
||||
assert dialog.tabs.currentWidget() is dialog.tab_pages["report"]
|
||||
dialog._goto("history")
|
||||
assert dialog.tabs.currentWidget() is dialog.tab_pages["report"]
|
||||
dialog.close()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("missing", ["reports", "history_batch", "detail"])
|
||||
def test_each_report_visibility_flag_is_required(application: QApplication, immediate: None, monkeypatch: pytest.MonkeyPatch, missing: str) -> None:
|
||||
repository = Repository()
|
||||
reports, detail = repository.list_prescription_ai_reports, repository.get_prescription_ai_report
|
||||
def read_reports(**params: Any) -> dict[str, Any]:
|
||||
data = reports(**params)
|
||||
if missing == "reports":
|
||||
data.pop("agreement_visible")
|
||||
if missing == "history_batch":
|
||||
data["lists"][0].pop("agreement_visible")
|
||||
return data
|
||||
def read_detail(batch_id: int) -> dict[str, Any]:
|
||||
data = detail(batch_id)
|
||||
if missing == "detail":
|
||||
data.pop("agreement_visible")
|
||||
return data
|
||||
monkeypatch.setattr(repository, "list_prescription_ai_reports", read_reports)
|
||||
monkeypatch.setattr(repository, "get_prescription_ai_report", read_detail)
|
||||
dialog = ai.IssuedPrescriptionAiDialog(repository, ["*"], prescription_id=801)
|
||||
dialog.show()
|
||||
assert not dialog._agreement_visible
|
||||
assert not dialog.model_views["qwen"]["score"].text()
|
||||
assert "千问分析" in dialog.model_views["qwen"]["report"].toPlainText()
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_agreement_revocation_and_failed_read_clear_cached_views_and_survive_theme(
|
||||
application: QApplication, immediate: None,
|
||||
) -> None:
|
||||
repository = Repository()
|
||||
dialog = ai.IssuedPrescriptionAiDialog(repository, ["*"], prescription_id=801)
|
||||
dialog.show()
|
||||
assert dialog.model_views["qwen"]["score"].text() == "0.0%"
|
||||
old_batch, old_generation = deepcopy(dialog._batch), dialog._generation
|
||||
dialog.model_views["qwen"]["comment"].setPlainText("保留复核草稿")
|
||||
repository.agreement_visible = False
|
||||
dialog.refresh()
|
||||
dialog._detail_ready(old_batch, old_generation, 40)
|
||||
assert not dialog._agreement_visible
|
||||
assert not dialog.model_views["qwen"]["score"].text()
|
||||
assert dialog.history_page.table.rowCount() == 0
|
||||
assert not dialog.history_page.chart._points
|
||||
assert dialog.model_views["qwen"]["comment"].toPlainText() == "保留复核草稿"
|
||||
dialog._switch_theme()
|
||||
application.processEvents()
|
||||
assert not dialog._agreement_visible and not dialog.model_views["qwen"]["score"].text()
|
||||
assert dialog.model_views["qwen"]["comment"].toPlainText() == "保留复核草稿"
|
||||
dialog._switch_theme()
|
||||
repository.agreement_visible = True
|
||||
dialog.refresh()
|
||||
assert dialog._agreement_visible
|
||||
dialog._detail_error(RuntimeError("offline"), dialog._generation)
|
||||
assert not dialog._agreement_visible
|
||||
assert not dialog.model_views["qwen"]["score"].text()
|
||||
assert "comparison" not in dialog._batch["models"]["qwen"]
|
||||
assert "千问分析" in dialog.model_views["qwen"]["report"].toPlainText()
|
||||
dialog._detail_ready(old_batch, dialog._generation, 40)
|
||||
assert not dialog._agreement_visible
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_revoked_agreement_cache_discards_flat_comparison_metadata() -> None:
|
||||
value = batch()
|
||||
sentinel = "previous-private-comparison"
|
||||
value["baseline_exclusions_json"] = sentinel
|
||||
value["models"]["qwen"].update({
|
||||
"reason": sentinel, "comparison_reason_code": sentinel,
|
||||
"dictionary_version": sentinel, "error_code": "",
|
||||
})
|
||||
value["models"]["openai"].update({
|
||||
"error_code": "UPSTREAM_TIMEOUT", "reason": "模型请求超时",
|
||||
})
|
||||
sanitized = ai.agreement_safe_batch(value, False)
|
||||
assert sentinel not in str(sanitized)
|
||||
assert "千问分析" in str(sanitized["models"]["qwen"]["report"])
|
||||
assert sanitized["models"]["openai"]["reason"] == "模型请求超时"
|
||||
assert value["models"]["qwen"]["reason"] == sentinel
|
||||
|
||||
|
||||
def test_list_capability_failure_revocation_and_late_status_cannot_restore_scores(
|
||||
application: QApplication, immediate: None, monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
repository = Repository()
|
||||
page = page_module.PrescriptionsPage(repository, ["*"], {"root": 1})
|
||||
page.resize(1366, 800)
|
||||
page.show()
|
||||
application.processEvents()
|
||||
assert not page.table.isColumnHidden(12)
|
||||
old_statuses = repository.list_prescription_ai_statuses([801, 802])
|
||||
old_token = page._ai_request_token
|
||||
old_capability_token = page._ai_capability_token
|
||||
monkeypatch.setattr(repository, "get_prescription_ai_capabilities", lambda: (_ for _ in ()).throw(RuntimeError("offline")))
|
||||
page._load_ai_capabilities()
|
||||
page._ai_statuses_ready(old_statuses, [801, 802], old_token, page._generation)
|
||||
page._ai_capabilities_ready({"agreement_visible": True}, old_capability_token, page._generation)
|
||||
assert page.table.isColumnHidden(12) and not page.table.isColumnHidden(11)
|
||||
assert page.ai_statistics_button.isHidden()
|
||||
assert page.ai_report_button.isVisible()
|
||||
assert page._ai_statuses
|
||||
for index in range(page.table.rowCount()):
|
||||
item = page.table.item(index, 12)
|
||||
assert not item.text() and not item.toolTip() and not item.data(Qt.ItemDataRole.AccessibleTextRole)
|
||||
assert all("score" not in model for row in page._ai_statuses.values() for model in row.get("models", {}).values())
|
||||
page.close()
|
||||
|
||||
|
||||
def test_list_rechecks_completed_scores_after_hide_and_show(application: QApplication, immediate: None, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
repository = Repository()
|
||||
original = repository.list_prescription_ai_statuses
|
||||
def statuses(ids: list[int]) -> dict[str, Any]:
|
||||
data = original(ids)
|
||||
for row in data["items"]:
|
||||
row["status"] = "success"
|
||||
for model in row["models"].values():
|
||||
model["status"] = "success"
|
||||
return data
|
||||
monkeypatch.setattr(repository, "list_prescription_ai_statuses", statuses)
|
||||
page = page_module.PrescriptionsPage(repository, ["*"])
|
||||
page.resize(1366, 800)
|
||||
page.show()
|
||||
application.processEvents()
|
||||
assert "0%" in page.table.item(0, 12).text()
|
||||
before = len([call for call in repository.calls if call[0] == "statuses"])
|
||||
page.hide()
|
||||
page.show()
|
||||
application.processEvents()
|
||||
assert len([call for call in repository.calls if call[0] == "statuses"]) > before
|
||||
assert "0%" in page.table.item(0, 12).text()
|
||||
page.close()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("flag", [False, None, 1, "true"])
|
||||
def test_direct_statistics_requires_strict_server_capability(application: QApplication, immediate: None, flag: Any) -> None:
|
||||
class StatisticsRepository:
|
||||
calls = 0
|
||||
def get_prescription_ai_capabilities(self) -> dict[str, Any]:
|
||||
return {"agreement_visible": flag, "can_manage_visibility": True}
|
||||
def prescription_ai_statistics(self, *_args: Any) -> dict[str, Any]:
|
||||
self.calls += 1
|
||||
return {}
|
||||
repository = StatisticsRepository()
|
||||
dialog = ai.PrescriptionAiStatisticsDialog(repository, ["*"])
|
||||
dialog.show()
|
||||
assert repository.calls == 0
|
||||
assert dialog.panel.isHidden() and not dialog.report.toPlainText()
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_statistics_failed_capability_clears_old_charts_and_ignores_late_result(
|
||||
application: QApplication, immediate: None, monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
repository = ChineseStatisticsRepository()
|
||||
dialog = ai.PrescriptionAiStatisticsDialog(repository, ["*"])
|
||||
dialog.show()
|
||||
assert dialog.panel.isVisible() and dialog.report.toPlainText()
|
||||
old_generation = dialog._generation
|
||||
monkeypatch.setattr(repository, "get_prescription_ai_capabilities", lambda: (_ for _ in ()).throw(RuntimeError("offline")))
|
||||
dialog.refresh()
|
||||
dialog._ready(repository.prescription_ai_statistics(), old_generation)
|
||||
assert dialog.panel.isHidden() and not dialog.report.toPlainText()
|
||||
assert not dialog.panel.distribution.has_data()
|
||||
assert dialog.panel.doctors.rowCount() == 0
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_stale_list_score_is_not_attached_to_current_prescription() -> None:
|
||||
value = {"validity": "superseded", "models": {"qwen": {"score": 100}, "openai": {"score": 90}}}
|
||||
assert ai.agreement_text(value) == "千问 —\nOpenAI —"
|
||||
@@ -993,7 +773,7 @@ def test_actual_history_pagination_contract(application: QApplication, immediate
|
||||
def reports(**params: Any) -> dict[str, Any]:
|
||||
repository.calls.append(("reports", params))
|
||||
page = params["page_no"]
|
||||
return {"agreement_visible": True, "lists": repository.batches[(page - 1) * 20:page * 20], "count": 21, "page_no": page, "page_size": 20}
|
||||
return {"lists": repository.batches[(page - 1) * 20:page * 20], "count": 21, "page_no": page, "page_size": 20}
|
||||
monkeypatch.setattr(repository, "list_prescription_ai_reports", reports)
|
||||
dialog = ai.IssuedPrescriptionAiDialog(repository, ["*"], prescription_id=801)
|
||||
dialog.show()
|
||||
@@ -1015,9 +795,6 @@ def test_server_empty_batch_statuses_are_not_fake_scores(status: str, expected:
|
||||
|
||||
def test_statistics_displays_actual_version_strata_without_filling_null_mean(application: QApplication, immediate: None) -> None:
|
||||
class StatisticsRepository:
|
||||
def get_prescription_ai_capabilities(self) -> dict[str, Any]:
|
||||
return {"agreement_visible": True}
|
||||
|
||||
def prescription_ai_statistics(self, *_args: Any) -> dict[str, Any]:
|
||||
strata = [{"versions": {"model_version": version, "algorithm_version": "v1", "prompt_version": "p1", "dictionary_version": "d1"}, "count": 1, "mean": mean, "median": mean, "distribution": {"[0,20)": 1 if mean == 0 else 0}, "sample_status": "insufficient_sample"} for version, mean in (("model-1", 0), ("model-2", 80))]
|
||||
return {"total_count": 2, "patient_count": 1, "doctors": [{"doctor_id": 7, "doctor_name": "张医生", "total_count": 2, "patient_count": 1, "paired_count": 0, "models": {"qwen": {"eligible_count": 2, "coverage_rate": 100, "mean": None, "median": None, "excluded_reasons": {}, "strata": strata}}, "review": {"evaluated_count": 0, "qualified_count": 0, "qualified_rate": None}}]}
|
||||
@@ -1188,9 +965,6 @@ def test_internal_version_labels_keep_revision_numbers(version: str, expected: s
|
||||
|
||||
|
||||
class ChineseStatisticsRepository:
|
||||
def get_prescription_ai_capabilities(self) -> dict[str, Any]:
|
||||
return {"agreement_visible": True}
|
||||
|
||||
def prescription_ai_statistics(self, *_args: Any) -> dict[str, Any]:
|
||||
exclusions = {"SOURCE_HISTORY_VERSIONS_UNAVAILABLE": 3, "transcript_not_final": 1,
|
||||
"ATTACHMENT_TARGET_PLAN_LEAKAGE_UNVERIFIED": 2, "future_reason_a": 4, "future_reason_b": 5}
|
||||
|
||||
@@ -7,7 +7,7 @@ import pytest
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import QApplication, QLabel, QPushButton, QToolButton
|
||||
|
||||
from doctor_workstation.core import PermissionSet, UserProfile
|
||||
from doctor_workstation.core import PermissionSet
|
||||
from doctor_workstation.core.appointment_modes import (
|
||||
appointment_type_description,
|
||||
appointment_type_value,
|
||||
@@ -76,18 +76,13 @@ def test_appointment_text_chat_carries_exact_ids_and_blocks_video_qr(application
|
||||
page.deleteLater()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("current_user", [
|
||||
None,
|
||||
{"id": 66, "role_id": 1},
|
||||
UserProfile.from_dict({"id": 66, "role_id": [1, 2]}),
|
||||
])
|
||||
def test_consultation_text_entry_does_not_wait_for_live_video(application, monkeypatch, current_user):
|
||||
def test_consultation_text_entry_does_not_wait_for_live_video(application, monkeypatch):
|
||||
monkeypatch.setattr(consultations, "run_async", lambda *_a, **_kw: None)
|
||||
row = {"id": 501, "patient_id": 901, "patient_name": "图文患者", "has_appointment": 1,
|
||||
"appointment_status": 1, "latest_appointment_id": 71,
|
||||
"appointments": [{"id": 71, "status": 1, "appointment_type": "text"}],
|
||||
"video_call_hint": {"state": "none"}}
|
||||
page = consultations.ConsultationsPage(DemoDoctorRepository(), PermissionSet(["*"]), current_user=current_user)
|
||||
page = consultations.ConsultationsPage(DemoDoctorRepository(), PermissionSet(["*"]))
|
||||
try:
|
||||
page.table_host.set_rows([row])
|
||||
page.table.selectRow(0)
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
# 业务订单辅方袋数:后端只读调查
|
||||
|
||||
调查日期:2026-10-17。范围为业务订单详情的服用参数修改链路;未修改业务代码、未读取业务数据库或配置中的敏感信息。仓库 `.trellis/` 不存在,已按现有代码约定调查。
|
||||
|
||||
## 结论
|
||||
|
||||
辅方「5g · 1袋」中的袋数已经持久化在 `zyt_tcm_prescription.aux_usage.dosage_bag_count`,无需新增数据库字段。业务订单的修改入口是 `POST /tcm.prescriptionOrder/patchPrescriptionUsage`,当前只更新辅方每天次数和开立天数,没有接受或保存辅方袋数。建议沿用此入口增加可选参数 `aux_dosage_bag_count`,限制为 1~5 的整数;未传时保留旧袋数,兼容现有调用方。
|
||||
|
||||
## 字段与读写链路
|
||||
|
||||
- `server/sql/1.9.20260520/add_prescription_aux_usage.sql`:处方表的 `aux_usage` 是已有 JSON 列。
|
||||
- `server/app/common/model/tcm/Prescription.php:21`:`aux_usage` 配置为 JSON,关联数组读写。主方袋数为处方独立字段 `dosage_bag_count`;辅方袋数位于 JSON 中。`bags_per_dose` 是另一个字段,不应用来保存本次截图的袋数。
|
||||
- `server/app/adminapi/logic/tcm/PrescriptionLogic.php:183`:`normalizeAuxUsage()` 维护 `dosage_amount`、`dosage_bag_count`、`need_decoction`、`bags_per_dose`、`times_per_day`、`usage_days`、`prescription_name`。`normalizeDosageBagCount()` 将袋数约束到 1~5,默认 1。
|
||||
- `server/app/adminapi/validate/tcm/PrescriptionValidate.php:15`:通用处方主方袋数采用 `integer|between:1,5`,可复用该业务口径。
|
||||
- `server/app/adminapi/controller/tcm/PrescriptionOrderController.php:240`:调用 `post()->goCheck('patchPrescriptionUsage')`,再传入管理员身份调用逻辑层。
|
||||
- `server/app/adminapi/logic/tcm/PrescriptionOrderLogic.php:5770`:公开方法进入受保护事务,内部 `patchPrescriptionUsageLocked()` 加载订单与关联处方。当前合并旧 `aux_usage` 后仅改 `times_per_day`、`usage_days`,其余键保持不变。
|
||||
- 同文件 `detail():1212` 复用处方详情,导出函数 `formatUsageSegmentForExport():3756` 和 `normalizeAuxUsageForExport():3807` 已读取辅方 `dosage_bag_count`。保存正确 JSON 键后,无需另建展示或导出字段。
|
||||
- 辅方存在性以 `herbs[].formula_type === '辅方'` 判定;残留 `aux_usage` 不代表仍有辅方。
|
||||
|
||||
## 校验现状与必须注意的问题
|
||||
|
||||
当前业务逻辑强制主方次数 1~6、主方开立天数 1~999、订单服用天数 1~999;存在辅方时要求辅方次数及开立天数,并分别检查 1~6、1~999。
|
||||
|
||||
发现既有验证器场景命名问题:`PrescriptionOrderValidate.php:136` 定义的是 `patchPrescriptionUsage()`,而非 `scenePatchPrescriptionUsage()`。ThinkPHP `vendor/topthink/framework/src/think/Validate.php:1673` 的 `getScene()` 只调用 `scene` 前缀方法。因此控制器当前场景校验不会执行该方法中的 `append()`;静态 `$scene` 虽然列了用量字段,但这些字段没有基础 `$rule`,实际主要只校验已有的 `id` 规则。逻辑层先 `(int)` 转换再判断范围,也会把部分非整数输入截断为整数。
|
||||
|
||||
已用只加载 Composer 和内存验证器的 PHP 探针复现:同一组非法次数/天数参数,`scene('patchPrescriptionUsage')->check(...)` 返回 `true`,手动先调用旧 `patchPrescriptionUsage()->check(...)` 返回 `false`。探针不初始化业务应用、不连接数据库。
|
||||
|
||||
最小修复应正确使用 `scenePatchPrescriptionUsage()`,把新参数加进场景并追加 `integer|between:1,5`。业务逻辑还应在保存前验证袋数确实为整数并在范围内,避免直接调用逻辑方法时发生小数截断。无需改变其他接口的验证场景。
|
||||
|
||||
## 权限、状态与事务
|
||||
|
||||
- 现有接口权限为 `tcm.prescriptionOrder/patchPrescriptionUsage`,菜单迁移在 `server/sql/1.9.20260708/add_prescription_order_patch_usage_menu.sql`;新增袋数应沿用此权限。
|
||||
- `AuthMiddleware` 根据菜单 URI 校验管理员权限;现有框架对未注册在菜单表的普通 URI 存在历史放行逻辑,因此部署应确保已有菜单迁移已执行。本次无需新增权限或迁移。
|
||||
- 逻辑层先检查订单存在、`canAccessOrder()`、药房快照可变、订单未取消、已关联有效处方,以及 `PrescriptionLogic::canViewPrescription()`。
|
||||
- `canAccessOrder()` 沿用当前全量角色、处方订单菜单访问、订单创建人、开方人授权或数据域可见创建人等口径;处方可见性另检查超管、创建人、医助、共享、可见角色及现有相关权限等。
|
||||
- `LockedPharmacySnapshotMutation::execute()` 在 `Db::transaction()` 中锁订单行及药房提交声明行;内部返回 `false` 会导致回滚。新增字段必须留在此现有事务内部。
|
||||
- `PharmacyRemoteSnapshotPolicy` 在已有药房远端单号、提交时间或声明状态为 `PENDING`、`UNKNOWN`、`PENDING_RECONCILE`、`SUCCESS` 时锁定修改。新增袋数不可绕开该限制。
|
||||
|
||||
## 日志
|
||||
|
||||
当前写入 `zyt_tcm_prescription_order_log`,`action = patch_rx_usage`,摘要为主方次数/天数、辅方次数/天数及订单天数的前后值。建议在原辅方段落加上「袋数 旧值 → 新值」,沿用当前日志动作,确保能回溯这次更改。
|
||||
|
||||
`writeLog():5723` 默认是非严格日志,日志保存失败会吞掉异常;本接口沿用该默认行为。是否改成严格日志属于额外行为变更,不是实现辅方袋数所必需。
|
||||
|
||||
## 最小修改建议
|
||||
|
||||
1. 修改 `PrescriptionOrderValidate` 的正确场景方法,仅对新增可选 `aux_dosage_bag_count` 检查整数及 1~5 范围。
|
||||
2. 在 `patchPrescriptionUsageLocked()` 中,存在辅方且新参数已提供时才更新 `aux_usage['dosage_bag_count']`;继续复制旧 JSON,保留用量、名称、代煎等所有其他键。不修改主方 `dosage_bag_count`。
|
||||
3. 缺少新参数时保留旧袋数;无辅方时沿用现有忽略辅方修改的逻辑。不因编辑次数或天数而把旧袋数重置为默认值。
|
||||
4. 扩展 `patch_rx_usage` 摘要记录袋数前后值。
|
||||
5. 验证有效 1、5,非法 0、6、小数、非数字,省略字段兼容,JSON 其他键保留,无辅方不产生新辅方用法,权限/取消/药房锁定失败,以及保存后回读与日志。参数校验至少要通过真实 `scene('patchPrescriptionUsage')` 路径测试,不能仅直接调用场景方法。
|
||||
|
||||
## 现有测试与本次验证
|
||||
|
||||
- 未发现针对辅方袋数修改或 `patch_rx_usage` 摘要的既有功能测试。
|
||||
- `server/tests/pharmacy/run.php:809` 对本接口的受保护事务、错误清理及回滚信息有源码契约检查;它不验证真实辅方袋数持久化。
|
||||
- `server/tests/pharmacy/prescription_order_export_name_regression.php` 覆盖辅方存在性及名称回退,本次运行 **7 项通过**。
|
||||
- `php tests/pharmacy/run.php` 本次基线运行在 `run.php:870` 失败,提示 `tracking correction must append a strict operation log containing old and new logistics values`。这是快递修改日志的源码文本断言,调查时未修改任何后端业务代码,应与本次新增功能分开判断。
|
||||
- 真实 ORM/事务测试可参考 `PrescriptionOrderReleaseAndTimeTest.php` 或 `PrescriptionOrderTrackingAuditTest.php` 的临时数据库夹具;它们要求显式指定隔离 MySQL 端口,不能直接用业务数据库。目前未运行这些数据库测试。
|
||||
|
||||
推荐从仓库根目录执行:
|
||||
|
||||
```powershell
|
||||
php -l server/app/adminapi/validate/tcm/PrescriptionOrderValidate.php
|
||||
php -l server/app/adminapi/logic/tcm/PrescriptionOrderLogic.php
|
||||
php server/tests/pharmacy/run.php
|
||||
php server/tests/pharmacy/prescription_order_export_name_regression.php
|
||||
```
|
||||
|
||||
若补充独立辅方袋数行为测试,应使用可控临时数据库或现有内存替身,覆盖上述范围、合并保存及权限/状态拒绝;测试命令随新测试实际路径确定。已有真实 ORM 测试仅在已准备隔离 MySQL 时运行,并按对应文件头说明设置环境变量。
|
||||
|
||||
## 实施结果(2026-10-17)
|
||||
|
||||
调查完成后,按授权实现了最小后端扩展:
|
||||
|
||||
- `PrescriptionOrderValidate` 已改为正确的 `scenePatchPrescriptionUsage()`,新增可选 `aux_dosage_bag_count`。使用场景闭包严格验证 1~5 整数/整数字符串;缺少字段可通过,但显式空值、布尔值、小数或其他类型会拒绝,避免框架普通可选规则跳过空值。
|
||||
- `PrescriptionOrderLogic` 仅在处方含辅方且 `prescription_type` 为「浓缩水丸」时处理新袋数,逻辑层独立重复严格校验;合并原 JSON 保存该键,省略字段保留原值,其他处方类型和无辅方处方忽略袋数变更。原有主方、次数/天数、权限、取消限制、药房锁及事务边界继续沿用。
|
||||
- 在 `patch_rx_usage` 原日志中增加「辅方袋数 X袋 → Y袋」;只在实际提供并处理袋数字段时记录,不改变原日志失败容错行为。
|
||||
- 控制器仅更新注释。未新增数据库迁移,未修改本分工之外的前端代码。
|
||||
|
||||
新增 `server/tests/PrescriptionOrderAuxBagCountTest.php`,直接运行真实场景验证器和公开业务逻辑入口,使用独立本地 MySQL 随机临时库,结束后自动删除该库;不加载业务数据库配置。覆盖整数边界、非法输入、JSON/主方字段保留、保存后数据库及模型回读、旧请求省略字段、旧 JSON 无袋数字段、非浓缩水丸、无辅方及残留 JSON、日志、权限/取消/药房快照拒绝,以及处方已保存后订单更新失败的真实事务回滚。
|
||||
|
||||
验证结果:
|
||||
|
||||
- 新增真实验证器/ORM/事务测试:**83 项断言通过**。
|
||||
- 相关 4 个 PHP 文件语法检查通过。
|
||||
- 辅方导出名称回归:**7 项通过**。
|
||||
- 3 个修改过的现有 PHP 文件 `git -c core.whitespace=cr-at-eol diff --check` 通过。
|
||||
- 上文记录的药房套件快递日志文本断言基线失败未扩大修改。
|
||||
|
||||
在已准备隔离 MySQL 实例后,可从仓库根目录重跑:
|
||||
|
||||
```powershell
|
||||
$env:ZYT_AUX_BAG_TEST_MYSQL_PORT='13483'
|
||||
php server/tests/PrescriptionOrderAuxBagCountTest.php
|
||||
php server/tests/pharmacy/prescription_order_export_name_regression.php
|
||||
```
|
||||
|
||||
本次测试使用 `.codex-tasks/aux-bag-count-mysql/data` 的专用实例,绑定本机回环地址与 13483 端口。测试会拒绝未指定端口或默认 3306 端口。
|
||||
@@ -1,75 +0,0 @@
|
||||
# Desktop AI quick-tools lifecycle review
|
||||
|
||||
审查时间:2026-10-17。范围:`app/src/doctor_workstation/ui/dialogs/ai_consult.py` 的工具绑定、流状态与关闭路径;辅助读取 repository / API SSE 实现和现有 UI 测试。审查时业务文件及测试已有未提交改动,未编辑这些文件,也未调用生产或任何外部服务。
|
||||
|
||||
## 结论
|
||||
|
||||
### 1. 已确认:done 后仍由 worker.finished 持有忙碌门控
|
||||
|
||||
- 六个工具在 `ai_consult.py:4009-4012` 用 `_ClickCard.clicked = Signal()` 绑定各自的默认参数 `prompt`。信号不携带 bool 参数,因此未发现常见的 lambda 参数覆盖或循环变量晚绑定问题。
|
||||
- `_ask()` 在 `5996` 遇到 `_asking` 就无提示返回;卡片本身仍保留可点击外观,忙碌期间的工具选择被直接丢弃。
|
||||
- `_stream_event(done)` 在 `6130-6143` 标记完成、停止总超时与耗时计时、渲染最终内容,但不清除 `_asking`,也不启用发送按钮。
|
||||
- `_asking` 只在 `_stream_finished()` 的 `6196-6197` 恢复。`_AiStreamWorker.run()` 在 `3185-3188` 对 done 仍然继续迭代,直到底层迭代器退出才会进入 `finally` 发出 finished。
|
||||
- 因而“已显示最终答案”和“允许再次点击工具”不是同一个时点。done 后迭代器或连接清理阻塞时,工具无反应可以持续;同时总超时已经停止,手工调用 `_stream_timed_out()` 也会被 `_stream_completed` 门控直接返回。
|
||||
- 当前 `RemoteDoctorRepository.stream_diagnosis_ai()` 在 yield done 后有 `return`(`repository.py:1438-1442`),通常会很快触发 finished,所以不能仅凭该缺陷断言真实部署每次都会永久卡住。确定的客户端缺陷是 UI 终态仍依赖传输退出,并且在此期间没有超时恢复。
|
||||
|
||||
### 2. 已确认:返回、侧栏 ×、Escape 的 reject 路径绕过流清理
|
||||
|
||||
- 返回与侧栏 × 绑定 `self.reject`(`3710`、`3966`),但唯一关闭清理在 `closeEvent()`(`6231-6237`);没有覆盖 `done()` / `reject()` 或连接 `finished` 作统一清理。
|
||||
- Qt 的 `QDialog.reject()` 隐藏对话框并结束 exec,不触发此处的 closeEvent 清理。离线调用已证实:reject 后 `_asking=True`、worker 未取消、两个流计时器仍运行、generation 未变化。
|
||||
- `open_for()` 会先 `_cancel_stream()` 并递增会话 generation(`3601-3608`),所以复用同一对话框且按标准 open_for 重开时能够隔离旧回调。`present_ai_consult()` 每次新建一个对话框,旧对话框带父对象时隐藏后仍可能被保留,造成后台 worker / timer 继续存在。
|
||||
- 这是生命周期缺陷;它本身不能证明同一已显示窗口的其他工具永久失效,但应与本次门控修复一起覆盖。
|
||||
|
||||
### 3. 其他退出路径
|
||||
|
||||
- `_stream_failed()` 与 done 相同,停止计时并显示错误,却只靠稍后的 finished 解锁。实际 worker 的 except 紧接 finally,常规错误路径间隙很短;仍建议终态入口统一恢复 UI 状态。
|
||||
- `_stream_finished()` 对 generation / stream_generation 作检查;`_cancel_stream()` 与 `open_for()` 的 generation 隔离正确,迟到旧事件不会覆盖新流。
|
||||
- 取消仅设置线程 Event,无法中断正在阻塞的 next()/网络读取;因此 UI 必须先解除对旧 worker 的依赖,再由 worker 完成清理。
|
||||
- 未发现六个 prompt 绑定错误。连续点击无反馈的直接入口是 `_ask()` 的无提示忙碌返回,而非按钮失去信号连接。
|
||||
|
||||
## 离线复现
|
||||
|
||||
使用 `app/.venv/Scripts/python.exe`、`QT_QPA_PLATFORM=offscreen`,以 stdin 临时脚本运行,未新增或修改业务 / 测试文件。
|
||||
|
||||
1. 将线程池 start 替换成仅记录 worker 的函数,点击第一张工具卡,直接投递 done,不投递 finished,再点第二张卡。
|
||||
|
||||
```json
|
||||
{"case":"done_before_finished","asking":true,"send_enabled":false,"timeout_active":false,"completed":true,"second_click_started_worker":false}
|
||||
{"case":"forced_timeout_after_done","asking":true}
|
||||
```
|
||||
|
||||
补发 finished 后再次点第二张卡,第二个 worker 正常创建。
|
||||
|
||||
2. 使用真实 QThreadPool / QRunnable 与完全本地的假 repository。假生成器 yield done 后在 Event 上等待,模拟终态数据已到但迭代器未退出。UI 事件循环实际收到 done 后立即发起第二问:
|
||||
|
||||
```json
|
||||
{"case":"real_qrunnable_done_then_open_iterator","done_delivered":true,"iterator_resumed_after_done":true,"asking":true,"timeout_active":false,"prompts":["first"]}
|
||||
{"case":"iterator_released","asking":false,"send_enabled":true}
|
||||
```
|
||||
|
||||
3. fake pool 下 show 对话框并开始一问,调用实际 `dialog.reject()`:
|
||||
|
||||
```json
|
||||
{"case":"reject","visible":false,"asking":true,"worker_cancelled":false,"generation_unchanged":true,"stream_generation_unchanged":true,"elapsed_timer_active":true,"timeout_active":true}
|
||||
{"case":"open_for_after_reject","worker_cancelled":true,"asking":false,"generation_changed":true}
|
||||
```
|
||||
|
||||
所有复现脚本 exit code 0,未访问 API。
|
||||
|
||||
## 建议的最小修复
|
||||
|
||||
1. 将 UI 终态处理集中为幂等 helper,确保 done、error、silent finished 都能停止计时、冲刷待显示文本、清除 `_asking` 并恢复发送按钮;done 不等待网络或 worker 退出。保留已完成气泡 / 元数据 / 最终耗时,避免用会清空这些字段的 `_cancel_stream()` 直接处理 done。
|
||||
2. worker 将有效 done 视为终止事件,发出后不再拉取下一项;在 finally 中安全关闭拥有 close() 的迭代器。即使底层关闭有延迟,UI 在步骤 1 已恢复。
|
||||
3. 选定忙碌时点击工具的明确策略:若允许切换,则工具入口取消当前流并发起新的工具请求;若保持串行,则提供可见的忙碌反馈或禁用状态。当前静默吞点击不利于判断应用是否失效。不要误将旧 finished 用于解锁新请求,继续依赖 stream_generation 隔离。
|
||||
4. 在 `done(result)` 或其他统一的对话框结束入口中执行 generation 失效与 `_cancel_stream()`,覆盖 reject / accept / Escape / 窗口关闭;确保和 closeEvent 重入时幂等。
|
||||
|
||||
## 建议回归情境
|
||||
|
||||
- 按截图六个工具依次真实点击(包含卡片文字和图标区域),每个 done 到达即点下一个,不提前补发 finished;每次 prompt 与 worker 都应正确创建。
|
||||
- done 后迭代器保持打开 / cleanup 延迟:UI 必须允许下一问,旧 worker 的迟到 delta/error/finished 不得改动新气泡或错误解锁新请求。
|
||||
- 正在首帧等待或生成中点击另一工具,验证所选忙碌策略且用户可理解当前状态。
|
||||
- error 到达但 finished 延迟、无 done 的静默结束、总超时、切换患者后迟到信号,均应恢复交互且不串患者。
|
||||
- 对返回按钮、侧栏 ×、Escape、原生窗口关闭分别验证 worker 已取消、计时器停止、旧 generation 无效;重开后新请求正常。
|
||||
- 保留首字即时显示、40ms 后续合并、完成后结构化渲染、首字/总耗时固定、一次性全量上下文提示的现有行为。
|
||||
|
||||
现有 `test_answered_stream_replaces_the_pending_placeholder` 与耗时完成测试在 done 后立即手工调用 finished,掩盖终态间隙;现有关闭耗时测试用 `dialog.close()`,未覆盖 reject 路径。
|
||||
@@ -1,55 +0,0 @@
|
||||
# 桌面端 AI 助手:服务端流式链路审查
|
||||
|
||||
日期:2026-10-17
|
||||
|
||||
## 结论
|
||||
|
||||
- 正常链路已经逐帧转发:`DifyChatService::sendStreamRequest()` 在 `CURLOPT_WRITEFUNCTION` 内解析 SSE 并立即调用 `onDelta`;`DiagnosisController::runAssistantSse()` 对每个 delta `echo` 后执行 `ob_flush()` / `flush()`,没有等模型全文完成才发送。
|
||||
- 控制器清理 PHP 输出缓冲、关闭输出压缩、设置 `X-Accel-Buffering: no` 和 `Cache-Control: no-cache, no-transform`,并先发送 2048 字节注释和 start 事件。
|
||||
- 首字之前仍有真实耗时:`prepareAssistant()` 在 SSE headers 前完成权限、DataScope、病例聚合和模型预检。病例正文超过 120,000 字节时,`fitContextForPrompt()` 同步调用 `PatientAiReportLogic::compactSourceForPrompt()`;后者依次通过 blocking 请求读取全部分片并可能归并摘要。保留这一流程以保持全量临床来源覆盖,不能用截断病例来换速度。
|
||||
- 兼容重试也可能增加首字等待:通用 `/v1` 地址存在协议回退;`streamPreparedAssistant()` 在尚未交付任何 delta 且流失败为 `UPSTREAM_REJECTED` / `INCOMPLETE_RESPONSE` / `EMPTY_RESPONSE` 时,会额外做一次 blocking 兼容尝试。已经交付文本后不会重复生成。
|
||||
- 因此前端需要在用户提交时立即反馈并开始计秒,不能等 SSE start 才开始计时。`AssistantSseTiming` 已提供不含患者正文的 prepare、first_delta、upstream_done 等日志,可定位实际延迟阶段。
|
||||
|
||||
## 修复
|
||||
|
||||
发现 SSE 分帧正则 `(?:\r\n|\r|\n){2}` 允许回溯,把一个 CRLF 拆成两个换行;仅收到一行 data 时就会提前解析。合法的多行 CRLF data 事件会被拆坏并丢失文本,可能进一步触发无 delta 的兼容重试。
|
||||
|
||||
将换行匹配改为原子组 `(?>\r\n|\r|\n){2}`,使 CRLF 始终作为一个换行。事件到达空行即转发,不等待 message_end 或网络 EOF。接口、权限、事件格式、错误码和凭据保护保持兼容。
|
||||
|
||||
修改文件:
|
||||
|
||||
- `server/app/common/service/DifyChatService.php`
|
||||
- `server/tests/DifyChatStreamContractTest.php`
|
||||
- `server/tests/DifyChatStreamDeliveryTest.php`(新增)
|
||||
|
||||
新增行为覆盖:
|
||||
|
||||
- data 行尚未结束事件时不解析;完整首帧在终止事件及 EOF 前立即交付。
|
||||
- 多行 CRLF data 在 1 字节、2 字节及整段传输切块下保持为同一事件。
|
||||
- 离线模拟实际 cURL header/write 回调:每帧在下一次网络读取之前、`curl_exec()` 返回之前到达消费者;Dify / OpenAI 均覆盖。
|
||||
- HTTP 错误正文不进入 delta;消费者拒绝后停止继续读取和交付。
|
||||
|
||||
新增回归在修复前实际失败:`FAIL: a data line alone is not a complete SSE event`;应用修复后通过。
|
||||
|
||||
## 验证
|
||||
|
||||
以下命令全部退出码 0:
|
||||
|
||||
```powershell
|
||||
php server/tests/DifyChatStreamContractTest.php
|
||||
php server/tests/DifyChatStreamDeliveryTest.php
|
||||
php server/tests/DifyChatAncientBooksReuseContractTest.php
|
||||
php server/tests/DiagnosisAiAssistantStreamContractTest.php
|
||||
php server/tests/DiagnosisAiAssistantTimingTest.php
|
||||
php server/tests/DiagnosisAiAssistantContractTest.php
|
||||
php server/tests/DiagnosisAiPrescriptionGeneratePermissionTest.php
|
||||
php server/tests/DiagnosisAiAnalysisSecurityTest.php
|
||||
php server/tests/DiagnosisAiAnalysisContractTest.php
|
||||
php server/tests/PrescriptionAiUpstreamContractTest.php
|
||||
php -l server/app/common/service/DifyChatService.php
|
||||
php -l server/tests/DifyChatStreamContractTest.php
|
||||
php -l server/tests/DifyChatStreamDeliveryTest.php
|
||||
git diff --check -- server/app/common/service/DifyChatService.php server/tests/DifyChatStreamContractTest.php server/tests/DifyChatStreamDeliveryTest.php
|
||||
```
|
||||
|
||||
验证均离线进行,没有调用生产模型、读取凭据或测试生产代理配置;因此不提供生产首字时延数值。
|
||||
|
Before Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 141 KiB |
@@ -1,53 +0,0 @@
|
||||
# 桌面 AI 对话即时反馈与秒数验证
|
||||
|
||||
日期:2026-10-17
|
||||
|
||||
## 行为
|
||||
|
||||
- 提交问题即显示“已等待 0 秒”,资料准备与模型等待期间每秒更新。
|
||||
- 第一段有效文本到达时立即替换等待占位;后续文本按 40 ms 合并刷新,保留顺序。
|
||||
- 生成期间显示“生成中 · 已用时 N 秒”;完成后显示模型、首字耗时、总耗时和时间。
|
||||
- 完成、错误、超时、静默断连、切换患者、关闭窗口均停止秒数计时;新问题重新计时。迟到事件不会覆盖已结束的结果。
|
||||
- 富文本解析和格式调整完成后统一测量高度,减少一次更新内的重复布局。
|
||||
- 服务端修复 CRLF 分帧错误,详见 [后端审查](desktop-ai-stream-backend-review.md)。
|
||||
|
||||
## 验证
|
||||
|
||||
在 `app/` 下执行:
|
||||
|
||||
```powershell
|
||||
.venv/Scripts/python.exe -m pytest -o addopts= tests/test_ai_consult_ui.py tests/test_ai_consult_workspace_ui.py tests/test_api_client.py tests/test_repository_parity.py
|
||||
.venv/Scripts/python.exe -m ruff check src/doctor_workstation/ui/dialogs/ai_consult.py tests/test_ai_consult_ui.py
|
||||
```
|
||||
|
||||
结果:156 passed in 4.75s;Ruff 通过。
|
||||
|
||||
新增端到端回归使用真实 httpx 解析器、仓库、线程池 worker 和 Qt 事件交付,暂停上游结束事件,确认首段中文文本已出现在界面;覆盖跨网络块的 UTF-8 字符、后续文本顺序、结束状态以及回调运行在 UI 主线程。未调用生产模型。
|
||||
|
||||
服务端 10 项相关契约/行为测试与 3 项 PHP 语法检查通过,命令及结果见后端审查。
|
||||
|
||||
1308 × 806 离屏检查三个状态,完成状态元信息没有截断:
|
||||
|
||||
- [等待](desktop-ai-stream-waiting.png)
|
||||
- [生成中](desktop-ai-stream-generating.png)
|
||||
- [完成](desktop-ai-stream-done.png)
|
||||
|
||||
本地一次富文本更新对比:同一离屏控件连续 20 次更新,原逻辑 157.9 ms,新逻辑 129.6 ms。该数值仅为本机布局检查,不代表生产模型首字时延。
|
||||
|
||||
## 扩展检查边界
|
||||
|
||||
额外运行 `test_dialog_detail_layout.py`、`test_list_detail_geometry.py`、`test_diagnosis_detail_contract.py`、`test_ui_detail_refinements.py`:34 通过,3 失败,分别为挂号列表横向溢出、患者状态按钮宽度、问诊筛选控件高度。这些模块未在本次任务中修改。将 AI 模块恢复为 Git HEAD 源码后再次运行同一组,仍是同样的 3 项失败(34 passed in 12.22s),确认不是本次修改引入。
|
||||
|
||||
AI 用例与其他布局用例连跑时,在后者 fixture 的 `apply_theme()` / `app.setStyle("Fusion")` 处停滞;独立运行布局组能完成。将 AI 模块恢复为 Git HEAD 源码后,该组合也会停滞,已停止诊断进程;没有修改全局主题或相应布局。
|
||||
|
||||
## 生效范围
|
||||
|
||||
已修改仓库源码,未生成安装包或部署生产服务。源码运行的桌面端重启后加载;已安装的打包版本需重新构建发布。服务端 CRLF 修复需部署。病例聚合及模型首字前的真实耗时仍然存在,当前以实时秒数明确展示。
|
||||
|
||||
## 追加:快捷工具后续点击无反应
|
||||
|
||||
同日修复后续反馈:完成/报错到达后立即解除忙碌状态,不依赖后台线程退出;worker 收到 done 后不再拉取下一个事件。生成中切换其他快捷工具、快捷指令或对话建议会停止当前请求,保留已有文本和耗时并明确标注停止;重复点击相同的进行中工具只提示,不重复提交。新请求自动展示问诊对话并恢复全部对话筛选,避免结果被其他资料页或筛选隐藏。
|
||||
|
||||
统一窗口结束清理,覆盖返回、侧栏 ×、Escape、accept/reject 和原生关闭;旧请求的 delta/error/finished 回调无法影响新请求。审查依据见 [快捷工具生命周期审查](desktop-ai-quick-tools-review.md)。
|
||||
|
||||
新增 4 项核心回归在修复前均实际失败,修复后通过;另覆盖 4 个快捷入口和 6 种窗口退出方式。相同桌面端测试命令最终结果为 **170 passed in 5.84s**,Ruff 与 `git diff --check` 通过。未修改服务端、打包或重启正在使用的桌面端进程。
|
||||
|
Before Width: | Height: | Size: 144 KiB |
@@ -1,45 +0,0 @@
|
||||
# 诊单编辑:脱敏手机号缺省提交的后端修复
|
||||
|
||||
日期:2026-10-17。范围:后端编辑校验及离线 PHP 回归。
|
||||
|
||||
## 根因
|
||||
|
||||
桌面医生账户无 `tcm.diagnosis/phonePlain` 权限时只看到脱敏手机号,保存时会排除 `phone`/`id_card`。`DiagnosisController::edit()` 先执行 `DiagnosisValidate::sceneEdit()`,后调用 `DiagnosisLogic::edit()`。编辑场景原样继承全局 `phone = require|mobile`,因此合法的缺省 `phone` 请求在到达更新逻辑前就被“请输入手机号”拦截。
|
||||
|
||||
同一编辑场景还继承 `patient_name`、`gender`、`age` 的全局 `require`,即使这些基本信息被业务锁定、调用者仅修改临床信息,省略后也会被拦截。新增回归在修复前首先复现“请输入患者姓名”;另有单独省略 `phone`、其他基础信息照常提交的用例对应本次医生保存失败。
|
||||
|
||||
`DiagnosisLogic::edit()` 已直接用收到的 `$params` 更新,不会为缺省手机号补空值。基本信息变更比较也已使用 `array_key_exists` 跳过未提交字段,因此不需要修改 controller 或 logic。
|
||||
|
||||
## 修改
|
||||
|
||||
- `server/app/adminapi/validate/tcm/DiagnosisValidate.php`:仅在 `sceneEdit()` 中,将 `phone`、`patient_name`、`gender`、`age` 的无条件 `require` 替换为 `requireWhenPresent`。
|
||||
- 新规则区分缺省键与显式提交:缺省表示保留;显式空串/null 仍拒绝,原有 `mobile`、姓名长度、性别范围、年龄类型/范围规则继续执行。`require` 前缀确保 ThinkPHP 对空串/null 也调用此规则。
|
||||
- `server/tests/DiagnosisValidateEditSceneTest.php`:运行真实 validator 与真实 `DiagnosisLogic::edit()`,仅模型、数据库边界及权限读取使用内存替身,覆盖保存与业务锁行为。
|
||||
|
||||
## 边界与回归覆盖
|
||||
|
||||
- 编辑可省略所有受保护的基本信息,也可仅省略手机号而保留姓名/性别/年龄;省略的 `phone`、`id_card`、姓名/性别/年龄不会加入 update,原值保持。
|
||||
- 显式手机号 `''`、null、`156****7862`、短号码、带空格号码、false、数组均拒绝;合法新号码仍通过格式校验。
|
||||
- 显式姓名/性别/年龄为空仍拒绝;姓名超长、非法性别、非法或越界年龄仍拒绝;`gender = 0`、`age = 0` 仍有效。
|
||||
- 编辑 ID 必填且诊单必须存在。`diagnosis_type`、`local_hospital_name` 等无关要求不变。
|
||||
- 新增场景仍要求手机号、姓名、性别、年龄;新增空/脱敏手机号仍拒绝。
|
||||
- 无额外权限的医生在最近订单未完成时,可以保存不含基本信息的临床修改;任何实际基本信息变更仍返回“最近业务订单未完成,无法修改患者基本信息”,且不会写入。原有 root 编辑能力保持。
|
||||
- 未修改明文手机号权限、返回数据、创建权限、其他业务授权、统计或 AI 服务实现。
|
||||
|
||||
## 实际验证
|
||||
|
||||
执行目录:`D:/web/zyt`;PHP 8.2.9 CLI。测试未初始化应用、未读取生产配置,数据库/模型/权限边界为测试内存替身,没有数据库或外部服务调用。
|
||||
|
||||
| 命令 | 实际结果 |
|
||||
| --- | --- |
|
||||
| `php server/tests/DiagnosisValidateEditSceneTest.php`(修复前) | exit 1,`edit accepts omitted locked/basic/masked fields; got: "请输入患者姓名"` |
|
||||
| `php -l server/app/adminapi/validate/tcm/DiagnosisValidate.php` | exit 0,No syntax errors detected |
|
||||
| `php -l server/tests/DiagnosisValidateEditSceneTest.php` | exit 0,No syntax errors detected |
|
||||
| `php server/tests/DiagnosisValidateEditSceneTest.php` | exit 0,Diagnosis edit-scene validation and masked/basic-field preservation: OK |
|
||||
| `php server/tests/DiagnosisValidateAddSceneTest.php` | exit 0,Diagnosis add-scene validation: OK |
|
||||
| `php server/tests/DiagnosisWorkspaceRowAuthorizationTest.php` | exit 0,Diagnosis workspace row authorization: OK |
|
||||
| `php server/tests/DiagnosisAiPrescriptionGeneratePermissionTest.php` | exit 0,Diagnosis AI prescription generation permission: OK |
|
||||
| `php server/tests/DiagnosisAiAnalysisSecurityTest.php` | exit 0,Diagnosis AI analysis security: OK |
|
||||
| `git diff --check -- server/app/adminapi/validate/tcm/DiagnosisValidate.php server/tests/DiagnosisValidateEditSceneTest.php` | exit 0,无输出 |
|
||||
|
||||
本报告证明离线编辑校验、更新参数保留与原有业务锁行为;未进行生产登录或真实数据库写入。
|
||||
@@ -1,35 +0,0 @@
|
||||
# 医生编辑诊单提示“请输入手机号”验证
|
||||
|
||||
日期:2026-10-17。
|
||||
|
||||
## 结论与修改
|
||||
|
||||
无 `tcm.diagnosis/phonePlain` 权限的医生只能看到脱敏手机号,桌面编辑器按既有隐私规则在保存时省略 `phone`、`id_card`。后端编辑场景继承了手机号的无条件必填规则,导致保存被拦截。
|
||||
|
||||
已修改 `server/app/adminapi/validate/tcm/DiagnosisValidate.php`:编辑场景允许省略受保护或被锁定的基本字段,省略即保留原值;主动提交的字段仍须非空且格式正确。新增诊单必填规则、明文权限、订单基本信息锁保持。桌面业务代码无需修改,仅增加回归。
|
||||
|
||||
后端详细覆盖与结果见 [后端报告](diagnosis-edit-masked-phone-backend.md)。
|
||||
|
||||
## 实际跨层核对
|
||||
|
||||
使用桌面测试数据构造无明文权限的医生编辑器,从真实 `DiagnosisDialog` 保存,经真实 `RemoteDoctorRepository`、`ApiClient`,用 `httpx.MockTransport` 捕获发送到 `tcm.diagnosis/edit` 的 JSON。临床修改已保留,手机号和身份证字段均缺省。数据为测试样例,无真实网络请求。
|
||||
|
||||
将这份原样请求交给真实 PHP 编辑校验器,仅替换诊单存在性数据库查询:
|
||||
|
||||
- HEAD 原校验器稳定复现“请输入手机号”。
|
||||
- 修复后校验通过。
|
||||
- 测试脚本:`.codex-tasks/diagnosis-edit-cross-layer-check.php`;捕获请求为同目录 `diagnosis-edit-masked-payload.json`。
|
||||
|
||||
## 回归结果
|
||||
|
||||
| 验证 | 结果 |
|
||||
| --- | --- |
|
||||
| 桌面新增回归:无明文权限、有/无基本信息锁保存;有明文权限时格式及重复检查 | 3 passed |
|
||||
| `pytest -o addopts= tests/test_diagnosis_drawer_visual.py tests/test_diagnosis_detail_contract.py tests/test_diagnosis_loading.py --tb=short` | 68 passed,exit 0 |
|
||||
| `ruff check tests/test_diagnosis_drawer_visual.py` | 通过 |
|
||||
| PHP 编辑、新增、行级授权、AI 处方权限、AI 分析安全离线回归 | 5 个脚本通过 |
|
||||
| 真实桌面请求对照原/新 PHP 校验器 | 原校验复现错误,新校验通过 |
|
||||
|
||||
桌面完整回归期间 Qt 媒体加载记录过 `0xc0000139` 原生日志;进程继续完成全部测试并以 0 退出。本次未修改相关媒体代码。
|
||||
|
||||
未登录生产、未写真实数据库、未执行部署。本修复需服务端更新后生效,医生无需补录明文手机号。
|
||||
|
Before Width: | Height: | Size: 68 KiB |
@@ -1,102 +0,0 @@
|
||||
# 桌面问诊列表:医生角色视频入口契约核对
|
||||
|
||||
核对日期:2026-10-17。范围仅为本地源码只读审查;未访问生产系统,未修改业务代码或测试代码。
|
||||
|
||||
## 结论
|
||||
|
||||
1. **医生角色是 `role_id=1`,医助是 `role_id=2`。** `3` 不是医生角色;“我的患者”管理角色集合为 `[3,7,8]`。
|
||||
2. 管理后台 PC/H5 的旁观入口均要求:**有已预约的视频挂号、当前用户等于诊单 `assistant_id`、拥有 `tcm.diagnosis/watchCall` 权限**。无入口资格时只能看通话状态。
|
||||
3. 服务端 `watchCall` 业务层再次强制 `assistant_id == 当前登录 admin_id`,**没有 root 绕过**。root 仅在通用鉴权中绕过菜单权限。
|
||||
4. 需要区分源码现状与本次新要求:**现有后台没有显式的“角色数组包含医生 1 即拒绝”分支**。混合医生/医助角色如果恰好被指派为助理且有权限,按现代码仍可能进入旁观;不能把这一规则误报为现有后端保证。
|
||||
5. 桌面列表当前不检查角色或助理归属,仅检查 repository 视频能力、预约和通话状态,因此医生也会得到“进入视频问诊”。本次用户要求应在桌面显示和点击处都补上医生角色限制。
|
||||
6. 桌面该入口实际发出 `mode="im"`,获取的是 `getCallSignature` 通话/聊天凭证,**不是**后台的 `watchCall` 纯拉流接口;不能仅根据文案把两者视为同一实现。
|
||||
|
||||
## 本次实施与验证
|
||||
|
||||
- 桌面列表对含医生角色 `1` 的账号(含混合角色及通配菜单权限)只显示 `通话中 / 接通中 / —` 等公开状态,不显示进入视频问诊;同类工具栏入口同步隐藏。
|
||||
- 表格显示、选中行按钮状态和实际点击处理使用同一资格判断;直接调用旧入口或强行启用按钮也不能发出视频请求。
|
||||
- 非医生账号仍须同时满足本人诊单医助归属和 `tcm.diagnosis/watchCall` 权限;超级管理员也不绕过医助归属。有效预约、live 状态与标识完整性仍由原有流程检查。
|
||||
- 医生的图文预约继续显示“图文沟通”,不受视频入口限制;接诊台的医生通话流程未改动。未将既有 IM 接收流程改造成纯拉流旁观,也未修改后端接口。
|
||||
- 新增角色/指派/权限矩阵回归在修改前 27 项失败,修改后全部通过;覆盖角色整数、字符串、数组、逗号字符串、对象列表及 UserProfile,以及混合角色、root、无身份、错误指派、仅二维码权限。
|
||||
- 最终组合检查:165 通过,1 项既有分页组合用例失败(`test_frozen_rows_track_main_pixel_scroll_and_host_height_is_append_stable` 的请求页码为 `[1,2]`,预期 `[1,2,3]`)。恢复 Git HEAD 的两个业务模块并排除新增用例后,同一组合仍复现该失败(136 通过、1 失败)。该分页用例及整个视觉模块单独运行均通过,未修改分页实现。
|
||||
- Ruff、修改范围的 `git diff --check` 通过;离屏检查已生成 [医生视角截图](doctor-video-column.png),使用本地示例数据,未连接生产服务。源码未打包发布,运行中的客户端需更新重启。
|
||||
|
||||
## 角色与身份数据
|
||||
|
||||
| 项目 | 源码契约 | 位置 |
|
||||
| --- | --- | --- |
|
||||
| 医生 | ID `1`;数据库角色关联按成员包含判断,而非要求只有单一角色 | `server/app/api/logic/DoctorLogic.php:96`、`server/app/adminapi/logic/firstvisit/MyPatientLogic.php:22` |
|
||||
| 医助 | ID `2` | `server/app/adminapi/logic/firstvisit/MyPatientLogic.php:23` |
|
||||
| 管理角色 | `[3,7,8]`,用于“我的患者”的团队范围判断 | `server/app/adminapi/logic/firstvisit/MyPatientLogic.php:24` |
|
||||
| 服务端旧角色字段 | `Admin` 模型追加 `role_id`,值是当前用户所有 `admin_role.role_id` 数组 | `server/app/common/model/auth/Admin.php:29`、`:44` |
|
||||
| 身份接口 | `auth.admin/mySelf` 的 `user` 包含 `id`、`root`、`role_ids`(整数数组),兼容模型追加的 `role_id` 数组;外层为 `user/menu/permissions` | `server/app/adminapi/logic/auth/AdminLogic.php:285`、`:319`、`:327` |
|
||||
| 桌面模型 | `UserProfile.id`、`role_ids: tuple[int,...]`、`root: bool`、`permissions`、`raw` | `app/src/doctor_workstation/core/models.py:125` |
|
||||
| 桌面转换 | 优先 `role_ids`,再 `role_id`,再 `roles`;支持数字、逗号字符串、集合/列表和 `{id: ...}` 元素,归一成整数元组;root 兼容 `is_root` | `app/src/doctor_workstation/core/models.py:94`、`:141` |
|
||||
| 当前用户流向 | repository 的 `get_session()` 读取 mySelf 并建立 `UserProfile`;shell 取得 `current_user` 后传入页面,`ConsultationsPage` 已保留 `self.current_user` | `app/src/doctor_workstation/services/repository.py:902`、`app/src/doctor_workstation/ui/shell.py:899`、`:2014`、`app/src/doctor_workstation/ui/pages/consultations.py:944` |
|
||||
|
||||
混合角色应检查**集合包含 `1`**,不能只比较 `role_id == 1`,也不能只检查角色数组首项。现有 shell 的角色文案也以 `1 in roles` 优先显示“医生”:`app/src/doctor_workstation/ui/shell.py:1521`。
|
||||
|
||||
权限 `"*"` 与 root 不能互相等同。后台 root 会获得 `['*']`,拥有全部菜单权限的非 root 也可能得到 `['*']`;桌面 `PermissionSet.is_superuser` 仅表示存在全局通配符,不代表 `UserProfile.root` 为真。
|
||||
|
||||
- 后台授予逻辑:`server/app/adminapi/logic/auth/AuthLogic.php:56`。
|
||||
- 桌面通配权限逻辑:`app/src/doctor_workstation/core/permissions.py:60`。
|
||||
|
||||
## 管理后台显示与点击逻辑
|
||||
|
||||
PC:`admin/src/views/tcm/diagnosis/index.vue`。
|
||||
|
||||
- `:362`:列标题为“视频旁观”。
|
||||
- `:1545`:`isAssignedAssistant` 使用 `row.assistant_id ?? row.assistant` 与 `userStore.userInfo.id` 数字比较。
|
||||
- `:1563`:`canWatchCallEntry = isAppointmentActiveForVideo && isAssignedAssistant && hasPermission(['tcm.diagnosis/watchCall'])`。
|
||||
- `:1971`:从 `appointments` 中找 `status==1` 的有效挂号,视频条件另要求 `canAppointmentVideoCall(type)`。旧空类型按视频处理,`text/phone/unknown` 不允许旁观视频,见 `admin/src/utils/appointment-type.ts:12`。
|
||||
- `:1579`:有入口资格且状态为 `live` 或 `pending_room` 时显示“进入旁观”;pending 状态点击后仅提示等待。
|
||||
- `:1591`:点击重新校验入口资格,并要求状态必须为 `live`,才打开旁观弹窗。
|
||||
- `:1609`:无入口资格时显示公开状态:`live=通话中`、`pending_room=接通中`、`none=—`,其他使用服务端 label。
|
||||
|
||||
H5:`admin/src/views/tcm/diagnosis/index_h5.vue`。
|
||||
|
||||
- `:210`、`:1035`、`:1040`、`:1044`、`:1075` 分别对应显示、指派关系、资格、状态和点击校验;规则与 PC 一致。
|
||||
- `:1068`:仅有入口资格,或状态处于 live/pending 时才显示旁观条;无资格时为只读状态文案。与 PC 的小差别是 none 状态文本为空,且整条通常不显示。
|
||||
|
||||
`hasPermission` 对 `"*"` 放行,但**不会跳过助理归属**:`admin/src/utils/perm.ts:3`。
|
||||
|
||||
## 服务端旁观保护与超管
|
||||
|
||||
`server/app/adminapi/controller/tcm/DiagnosisController.php:609` 的 `watchCall()` 从 GET 读取 diagnosis_id,但把调用者身份绑定为控制器的当前登录 `$this->adminId`,不采用客户端传来的 admin_id。
|
||||
|
||||
`server/app/adminapi/logic/tcm/DiagnosisLogic.php:2678` 的 `getAssistantWatchRoomParams()`:
|
||||
|
||||
1. diagnosis_id/admin_id 必须为正数。
|
||||
2. 诊单必须存在且未删除。
|
||||
3. `diag.assistant_id` 必须等于 admin_id,否则“仅本诊单指派的医助可旁观该通话”。
|
||||
4. 获取该诊单 `status=1` 的最新 CallRecord,room_id 必须存在,否则要求等医生接通。
|
||||
5. 返回当前登录账号对应的 `doctor_{adminId}` UserSig 和房间参数。
|
||||
|
||||
**此函数没有读取角色或 root,也没有主动重复前端的有效视频挂号校验。** 角色/menu 权限由通用鉴权负责;归属和进行中房间由业务逻辑负责。`AuthMiddleware.php:70` 的 root 放行发生在通用菜单鉴权层,不能使 watchCall 业务层的 assistant_id 检查失效。非 root 的菜单权限检查还受菜单路由登记情况影响,不宜把权限名称硬编码成额外、已存在的业务断言。
|
||||
|
||||
对照通话/聊天接口:桌面 `get_call_ticket()` 调用 `tcm.diagnosis/getCallSignature`(`app/src/doctor_workstation/services/repository.py:2696`)。该接口经 `DiagnosisLogic.php:898` 的 `canManageDiagnosis` 校验患者/诊单访问关系,并解析挂号通话策略;它不是旁观资格接口。`canManageDiagnosis` 使用 `MyPatientLogic`:root 可看全部、管理角色用团队数据范围、一线医生/医助按本人接诊/归属关系;这不意味着医生能从通用问诊列表旁观其他医生。
|
||||
|
||||
## 桌面入口缺口
|
||||
|
||||
| 环节 | 当前实现 | 位置 |
|
||||
| --- | --- | --- |
|
||||
| 列能力 | `action_policy['video_call'] = _native_video_capable`,只证明 repository 有 get_call_ticket/start_call/bind_call_room/end_call 方法 | `app/src/doctor_workstation/ui/pages/consultations.py:177`、`:1504` |
|
||||
| 表格按钮 | 有能力 + 已预约 +(图文类型或 live),显示“图文沟通”或“进入视频问诊”;只用 ID 完整性控制 enabled | `app/src/doctor_workstation/ui/diagnosis_index_widgets.py:1784` |
|
||||
| 顶部通话 | 可见性只检查原生能力,enabled 来自兼容 video_button | `app/src/doctor_workstation/ui/pages/consultations.py:1385`、`:3056` |
|
||||
| 兼容按钮 | “进入视频问诊”仍存在于隐藏兼容操作容器,可通过程序调用 | `app/src/doctor_workstation/ui/pages/consultations.py:1467` |
|
||||
| 行点击 | 选择该行后进入统一 `_request_video()` | `app/src/doctor_workstation/ui/pages/consultations.py:2366` |
|
||||
| 最终点击处理 | 检查能力、已预约、live(text 例外)、ID 完整,然后发出 video_requested;没有医生角色/助理归属/旁观权限校验 | `app/src/doctor_workstation/ui/pages/consultations.py:3499` |
|
||||
| 发出内容 | `source='consultations'`、`mode='im'`,注释明确此页为助理/管理员接收现有医生邀请 | `app/src/doctor_workstation/ui/pages/consultations.py:288` |
|
||||
|
||||
导航目前“我的患者”为 `PatientsPage`,“问诊列表”为 `ConsultationsPage`,两者不同;本次所述“视频问诊”列实际在后者 `DiagnosisTableHost`,见 `app/src/doctor_workstation/ui/shell.py:159`。本次无需扩大为其他医生主动发起通话入口的限制。
|
||||
|
||||
## 本次新要求对应的修复边界
|
||||
|
||||
- 医生角色集合包含 `1` 时,问诊列表的视频列不显示可点击“进入视频问诊”,并在最终点击处理再次拦截,避免工具栏、隐藏兼容按钮、信号或直接调用绕过。
|
||||
- 混合 `[1,2]`、`[1,3]` 等账号也属于含医生角色。用户要求是医生角色禁用该视频入口,不能依赖它是否还有其他授权角色或 `"*"` 权限来恢复入口。
|
||||
- 已有后台没有“root 优先于医生拒绝”的列表旁观特例;从本次“医生角色不允许”的文字看,含医生角色的账号即使还拥有 `"*"` 也不应因通配权限恢复入口。若实现保留 root 特例,应明确这是另行产品规则,而非从现有 watchCall 业务代码推导出的例外。
|
||||
- 医生主动接诊/接诊台的视频功能不在本次旁观入口限制范围内。
|
||||
- 表格“图文沟通”共用同一 action_policy 和 `_request_video()`,用户反馈针对视频入口;若不另有要求,应保留 text 预约正常图文入口,避免把 `video_call=False` 当作全部沟通能力开关。
|
||||
- 对不受限制的非医生账号,本次改动可保留既有流程;若还要完整对齐后台的指派助理 + watchCall 权限,则属于另外一项权限收敛,需要单独决定与验证,不能把现有 IM 接收入口直接替换成旁观拉流实现。
|
||||
|
||||
本文件为静态核对结果,无运行或生产授权验证结论。
|
||||
@@ -1,32 +0,0 @@
|
||||
# 处方 AI 一致度可见范围:后台实现
|
||||
|
||||
日期:2026-10-17。范围仅后台新文件与本报告、验证截图;没有修改 `server/` 或桌面 `app/`,没有提交、发布或连接生产接口。已读根 `AGENTS.md`,当前工作区无 `.trellis/`。
|
||||
|
||||
## 交付文件
|
||||
|
||||
- `admin/src/views/setting/prescription_ai_visibility/index.vue`:独立配置页面,沿用现有 Element Plus 设置表单与底部保存栏。
|
||||
- `admin/src/api/setting/prescription_ai_visibility.ts`:专用 capabilities、配置读取/保存、角色与用户选项接口及类型;对配置响应进行运行时校验。
|
||||
- `admin/tests/prescription-ai-visibility.test.cjs`:8 项 Node 测试,编译真实 Vue SFC、运行真实 setup 状态,并模拟 API;不调用真实后端。
|
||||
- `artifacts/prescription-ai-visibility-admin-local.png`:本地模拟浏览器截图。
|
||||
|
||||
## 行为
|
||||
|
||||
角色多选可搜索名称;用户多选标签包含姓名与账号,因此均可搜索。选项完全来自 `tcm.prescriptionAi/visibilityOptions`,不额外请求角色列表或管理员列表接口。当前已配置但不在活跃选项中的 ID 以明确占位标签保留;只有用户显式移除后保存才取消该项。
|
||||
|
||||
页面明确说明:角色或指定用户任一命中即可;两份名单均为空时所有账号(包括 root)均隐藏一致度、逐味对照与一致度统计;root 可管理策略,但管理权限不自动赋予查看权。AI 报告继续按原有权限开放,已有报告不受影响,原有患者/处方数据范围继续适用。
|
||||
|
||||
先读取 `capabilities.can_manage_visibility`,只有严格为 `true` 才读取配置与选项。两者全部成功后才填入并启用表单;首次失败不展示空表单,重新加载失败保留既有选择并禁用保存。保存期间禁用输入、重载及重复提交;失败保留当前选择并给出重试/重新读取提示;成功应用后端返回的规范化名单。配置响应缺字段、含非数字/非正整数 ID 等异常会拒绝,不会降级成可保存的空策略。
|
||||
|
||||
后端菜单需指向 `setting/prescription_ai_visibility/index`,管理权限为 `tcm.prescriptionAi/manageVisibility`;菜单迁移及服务端自鉴权由后端任务实现。
|
||||
|
||||
## 验证
|
||||
|
||||
1. `node --test tests/prescription-ai-visibility.test.cjs`:8/8 通过。覆盖固定接口契约、配置坏响应、pending/失败禁止保存、选项失败不部分覆盖策略、缺失 ID 保留、姓名账号标签、空名单与规范化回显、防重复提交、保存失败保留、管理与查看能力分离、真实模板编译。
|
||||
2. 对新增 API、Vue 页面、测试执行 Prettier `--check`:通过。
|
||||
3. 针对新增 API 与页面的临时 `vue-tsc` 项目(严格模板,使用真实 Element Plus/Vue 类型,隔离 request/feedback 的外部依赖):通过,无类型错误。
|
||||
4. 全后台 `vue-tsc --noEmit --pretty false --incremental false --composite false`:退出 2,报告 61 项新文件范围外的错误;没有错误涉及 `prescription_ai_visibility`。完整日志位于 `%TEMP%/zyt-prescription-ai-visibility-vue-tsc.log`。本任务没有扩大修改范围修复这些错误。
|
||||
5. 独立 `127.0.0.1:5568` Vite 浏览器模拟,使用真实新页面、Element Plus、后台样式与 FooterBtns;所有 API 均映射到本地假数据模块,没有加载主应用或真实网络请求。确认成功读取、缺失 ID 标签、账号 `li.assistant` 搜索、保存 pending 禁用、保存成功提示、加载失败禁用保存、保存失败保留修改、空名单警示及无管理权限状态。
|
||||
|
||||
未运行 `npm run build`,避免其自带 release 脚本;实际 Vite 浏览器编译已成功。临时验证 harness 位于忽略的 `admin/node_modules/.cache/prescription-ai-visibility/`,不属于业务交付。
|
||||
|
||||

|
||||
|
Before Width: | Height: | Size: 46 KiB |
@@ -1,63 +0,0 @@
|
||||
# 处方 AI 一致度授权:后端实施记录
|
||||
|
||||
日期:2026-10-17。仅修改 server 后端及本报告;未提交、未部署、未连接生产数据库或模型服务。
|
||||
|
||||
## 行为
|
||||
|
||||
- 新服务 `server/app/common/service/prescriptionai/PrescriptionAiVisibility.php` 使用 ConfigService 一条 JSON:`type=prescription_ai_visibility` / `name=agreement_policy`,字段仅 `role_ids:int[]`、`admin_ids:int[]`。
|
||||
- 默认空、缺失或格式错误策略全部不可查看一致度。角色或单用户命中任一即可;查看没有 root、通配符或管理权限豁免。
|
||||
- 账号每次重新检查 `disable=0`、`delete_time IS NULL`;角色关系实时查询并排除软删除角色。配置读取无长期缓存,授权或撤销下次请求立即生效。
|
||||
- 独立管理权限为 `tcm.prescriptionAi/manageVisibility`。root 可管理,其余账号须持此权限;三个管理入口均在服务端统一验证,防止未注册路由中间件默认放行产生越权。所有入口仍由控制器重载有效账号,拒绝未知参数与错误 HTTP 方法。
|
||||
- statuses 顶层及各 item、reports 顶层及各 batch、detail 顶层均返回 `agreement_visible`。未授权时共享 Logic 投影删除整个 comparison、扁平 score/herb_score、比较状态/原因/版本、比较类型/基线等派生数据;失败模型生成错误原因保留。
|
||||
- full 模型正文使用固定字段及嵌套结构投影,拒绝历史正文未知 raw/result/score、调试对象、伪造 progress 等旁路。报告、候选方案、来源与覆盖说明、进度、医生原方、复核意见保留。
|
||||
- statistics 在扫描/聚合之前拒绝没有白名单资格的账号;原有动作 RBAC 和处方/患者/来源范围校验仍执行。后台分析、比较、手动重试与复核的既有权限流程未加指标限制。
|
||||
|
||||
## 固定接口
|
||||
|
||||
| HTTP | `tcm.prescriptionAi/` 后 action | 数据 |
|
||||
| --- | --- | --- |
|
||||
| GET | capabilities | `{agreement_visible:boolean, can_manage_visibility:boolean}`,登录账号自查 |
|
||||
| GET | getVisibilityConfig | `{role_ids:number[],admin_ids:number[]}`,仅管理者 |
|
||||
| POST | setVisibilityConfig | 接收且仅接收上述两数组,返回去重排序后的策略;仅管理者 |
|
||||
| GET | visibilityOptions | `{roles:[{id,name}],admins:[{id,name,account}]}`,仅管理者 |
|
||||
|
||||
选项及保存列表上界:500 个角色、5000 个后台用户。选项只返回活跃记录,超过上界明确报错,不静默截断。保存只验证新提交的名单,因此失效 ID 可从旧名单中删除后保存,不会被旧数据阻塞。角色无 disable/status 列,按软删除判断。
|
||||
|
||||
## 迁移
|
||||
|
||||
新增 `server/database/migrations/2026_10_17_prescription_ai_agreement_visibility.sql`:
|
||||
|
||||
- 幂等创建系统设置下 type C 的“处方AI一致度授权”菜单,perms 为 `tcm.prescriptionAi/manageVisibility`,component 为 `setting/prescription_ai_visibility/index`。
|
||||
- 幂等预置一条空 JSON 策略,不自动授权任何非 root 角色,不填入查看白名单。
|
||||
- 不新增业务表,不改变既有报告或队列数据。上线时需要先执行该迁移;本次仅在隔离测试数据库中执行并重放验证。
|
||||
|
||||
## 修改文件
|
||||
|
||||
1. `server/app/common/service/prescriptionai/PrescriptionAiVisibility.php`(新):策略、管理校验、实时匹配、有限选项、输出投影。
|
||||
2. `server/app/adminapi/controller/tcm/PrescriptionAiController.php`:四个接口。
|
||||
3. `server/app/adminapi/logic/tcm/PrescriptionAiLogic.php`:统一能力标记、指标裁剪、统计授权、正文投影。
|
||||
4. `server/database/migrations/2026_10_17_prescription_ai_agreement_visibility.sql`(新):幂等菜单及空策略。
|
||||
5. `server/tests/PrescriptionAiVisibilityTest.php`(新):无应用初始化的纯离线策略/投影回归。
|
||||
6. `server/tests/PrescriptionAiQueueTest.php`:隔离真实 ORM、Cipher、Logic、Controller 及迁移回归。
|
||||
|
||||
## 验证结果
|
||||
|
||||
PHP 8.2.9;新增/修改的 5 个 PHP 文件全部 `php -l` 通过。`git diff --check -- server` 通过。全仓 diffcheck 中另有并行桌面端文件换行问题,已通知主代理,本任务未修改 app 文件。
|
||||
|
||||
纯离线:
|
||||
|
||||
- `php server/tests/PrescriptionAiVisibilityTest.php`:22 checks。
|
||||
- `php server/tests/PrescriptionAiProgressTest.php`:38 checks。
|
||||
- `php server/tests/PrescriptionAiComparisonTest.php`:266 checks。
|
||||
- `php server/tests/PrescriptionAiStatisticsTest.php`:64 checks。
|
||||
- `php server/tests/PrescriptionAiConfigurationTest.php`:通过。
|
||||
|
||||
隔离 MySQL:主代理建立的 `127.0.0.1:33447`,root 空密码,显式 `ZYT_AI_TEST_MYSQL_PORT=33447`。这些脚本不 App::initialize,不读生产 .env,创建并最终删除各自唯一 `prescription_ai_test_*` 临时库:
|
||||
|
||||
- `php server/tests/PrescriptionAiQueueTest.php`:113 checks。
|
||||
- `php server/tests/PrescriptionAiQueueTest.php --legacy-progress-schema`:111 checks。
|
||||
- `php server/tests/PrescriptionAiPipelineTest.php`:125 checks。
|
||||
|
||||
新增集成覆盖:默认空名单含 root 拒绝查看、root 管理与查看分离、独立非 root 管理权限、GET/POST/未知字段、伪 root/星号 token、配置越权拒绝、角色/个人任一授权、多角色、即时撤销、软删除角色、禁用/删除账号、无效策略、活跃最小选项与超限、菜单禁用即时撤销、allowlist 不替代动作 RBAC、真实加密旧正文注入及嵌套旁路、三个读取出口完整裁剪、统计拒绝、保留报告/候选/原方/复核/失败原因、限制指标后仍可重试/复核、迁移执行两次。
|
||||
|
||||
报告是实现交付,不表示生产迁移已经执行。测试 MySQL 进程由主代理负责最终关闭。
|
||||
@@ -1,118 +0,0 @@
|
||||
# 处方 AI 一致度授权:后端只读研究
|
||||
|
||||
日期:2026-10-17。范围:D:/web/zyt 本地源码;没有调用生产接口、连接数据库、调用模型或运行会初始化业务配置的测试。只新增本报告,未修改业务代码。
|
||||
|
||||
已读取根 AGENTS.md;工作区不存在 `.trellis/`。本文最初保留“仅一致度 / 整个 AI 模块”两种边界,随后按主代理明确的实现方向整理:**仅一致度独立授权,保留 AI 报告;角色或单个后台用户命中即可;默认空名单;root 可管理,但查看一致度也必须命中白名单。**
|
||||
|
||||
## 1. 现有权限模型
|
||||
|
||||
- `server/app/adminapi/logic/auth/AuthLogic.php:91`:`getAuthByAdminId` 经 `admin_role -> system_role_menu -> system_menu` 取权限,忽略 `is_disable=1` 的菜单。多角色权限取并集。
|
||||
- `server/app/common/model/auth/Admin.php:52`:`role_id` 是 `AdminRole` 关联查询结果,不是单值;新策略必须支持任一角色命中。
|
||||
- 没有发现通用“用户直接拥有菜单权限”表或管理机制。`AuthLogic:109` 会追加 `QywxPromotionOperatorAccess` 专用共享页面权限,是业务特例,不宜为了一个字段的可见性扩展全局 RBAC。
|
||||
- `server/app/common/service/qywx/QywxPromotionOperatorAccess.php:38`:可参考其服务端动态单用户判断、软删除过滤、菜单启用检查;不应复用该业务表。
|
||||
- `server/app/common/cache/AdminAuthCache.php:73`:菜单权限缓存最长 3600 秒;`:122` 清理具体账号缓存键。若本功能仅独立策略读取而不往 AuthLogic 注入权限,便不需要变更该全局缓存;每次请求实时读取策略可立即撤销。
|
||||
- `server/app/adminapi/http/middleware/AuthMiddleware.php:129` 附近:未注册为菜单的 URI 有默认放行逻辑。因此**新配置接口必须自带服务端管理鉴权**,不能只加菜单或依赖前端 `v-perms`。
|
||||
|
||||
一致度的最终允许条件应是:账号有效 AND 现有该动作 RBAC 权限通过 AND 原有处方/患者/来源快照数据范围通过 AND 一致度白名单命中。名单只增加指标可见资格,不授予患者或报告访问权限。root 仍按既有逻辑拥有普通 AI 动作权限,但不能绕过新的指标白名单。
|
||||
|
||||
## 2. 可直接采用的存储与接口形态
|
||||
|
||||
`server/app/common/service/ConfigService.php:32` 支持将数组 JSON 存到 `config` 表;`:65` 读取并反序列化。已有 `setting/DesktopWorkstationController.php` 与 `DesktopWorkstationLogic.php:39` 展示独立 getConfig/setConfig 的配置管理范式。
|
||||
|
||||
建议仅存一条对象(名字为候选,不代表已定路由):
|
||||
|
||||
```json
|
||||
{
|
||||
"role_ids": [1, 7],
|
||||
"admin_ids": [23, 91]
|
||||
}
|
||||
```
|
||||
|
||||
- 候选存储:`type=prescription_ai_visibility`, `name=agreement_policy`。
|
||||
- 缺失、格式错误、空名单均解释为不可查看一致度,不回退到“全部可见”。对象包含两个键,避免 ConfigService 将空数组视为假值并走默认配置的语义造成意外。
|
||||
- 写入要求两个字段均为数组;元素须正整数/规范十进制整数字符串,拒绝对象、浮点、布尔、负数及字符串拼接;去重排序,验证角色/账号实际有效;限制数量及拒绝未知字段。不要静默把任意字符串 `intval` 后授予权限。
|
||||
- 一次覆盖单 JSON,避免分别保存角色、用户导致半更新。`config` 安装表没有 `(type,name)` 唯一索引(`server/public/install/db/like.sql:142`),可由幂等迁移预置这一条空策略,避免首次并发创建;不要为本功能顺带对全表增加唯一约束。
|
||||
- 不存模型密钥、不新增业务表,不修改 `prescription_analysis.enabled` 或后台工作队列设置。
|
||||
|
||||
候选 API:
|
||||
|
||||
| 接口 | 返回/接收字段 | 必须执行的服务端鉴权 |
|
||||
| --- | --- | --- |
|
||||
| GET `tcm.prescriptionAi/capabilities` | `{agreement_visible:boolean, can_manage_agreement_visibility:boolean}` | 登录且实时有效账号;只返回当前账号布尔能力,不泄露名单 |
|
||||
| GET 专用 `getVisibilityConfig` | `{role_ids:number[], admin_ids:number[]}` | root 或独立管理权限 |
|
||||
| POST 专用 `setVisibilityConfig` | 严格上述两数组;返回规范化后的策略 | 同一管理鉴权;必须 POST |
|
||||
| GET 专用 `visibilityOptions` | `roles:[{id,name}]`, `admins:[{id,name,account,role_ids}]`,可增加有界搜索/分页 | 同一管理鉴权;只投影必要字段 |
|
||||
|
||||
配置 API 既可设在 `PrescriptionAiController`,也可采用 `setting.prescriptionAiVisibility/getConfig|setConfig|options`;关键是最终 action/perms 一致且逻辑层鉴权。若采用一个专用“管理一致度授权”权限,所有 get/set/options 均调用共同 `requireManage`,默认不向普通角色自动授予。不要把名单加到 `ConfigController::getConfig`:该控制器 `server/app/adminapi/controller/ConfigController.php:27` 明确 `notNeedLogin=['getConfig','dict']`。
|
||||
|
||||
## 3. 角色和用户选项:已有 API 与 active 字段
|
||||
|
||||
| 对象 | 当前可用 API | 实现与约束 |
|
||||
| --- | --- | --- |
|
||||
| 系统角色 | GET `/auth.role/all`,前端 `roleAll()` | `RoleController:118` -> `RoleLogic:181`;`SystemRole` 使用 SoftDelete,自动排除软删除;按 sort/id 降序 |
|
||||
| 后台用户 | GET `/auth.admin/lists?exclude_disabled=1&page_no=1&page_size=...`,前端 `adminLists()` | `AdminLists:165` 排除 disable;返回 `lists/count` 的列表封装,支持 name/account 模糊搜索 |
|
||||
| 后台全部用户 | 前端有 `adminAll()` wrapper `/auth.admin/all` | **后台 AdminController 没有 all action,不可直接使用此 wrapper** |
|
||||
|
||||
模型事实:
|
||||
|
||||
- `Admin`:`delete_time IS NULL` 且 `disable=0` 才是活跃账号,见 `PrescriptionAiAccess::actor` 第 15 行;必要字段 `id/name/account/root`;角色经 `admin_role` 关联。
|
||||
- `SystemRole`:`delete_time IS NULL`,没有发现 `disable` 或 `status` 字段;`server/public/install/db/like.sql:655` 及现有迁移仅见额外 `data_scope`。**不要对角色添加 `disable=0` 条件。**
|
||||
- `system_menu.is_disable` 是菜单状态,与系统角色不同。
|
||||
- `AdminLists` 返回手机号、登录 IP、头像等较多字段,授权下拉建议在新管理接口中直接复用模型查询,并只投影 id/name/account/role_ids。
|
||||
- `AdminLists::queryWhere` 一般默认不套 data_scope,只有显式 `apply_data_scope=1` 才启用;管理授权选项须由共同管理鉴权保护。其 role_id 过滤在该角色无成员时不会强制空集,因此若需角色联动筛选,专用 options 应正确处理空集。
|
||||
- `BaseDataLists.php:102`:`page_type=1` 表示分页,其它值只是返回最大条数,不是真正无限制;不能假定一次 `page_type=0` 可取完所有后台用户。
|
||||
- 即使保存时验证过 ID,查看时也只匹配实时有效账号与有效角色;被禁用/软删除账号不可使用旧 token,角色变更不可依赖 token 的历史 role_id。
|
||||
|
||||
## 4. 现有处方 AI 数据保护与必须覆盖的出口
|
||||
|
||||
`server/app/adminapi/controller/tcm/PrescriptionAiController.php:71` 的 handle 拒绝未知入参,校验 GET/POST,调用 `PrescriptionAiAccess::actor` 重新加载有效账号,不直接信任请求内角色。`PrescriptionAiAccess.php` 还分别校验处方、诊单及来源诊单范围。应保留这些保护,并在新增 capabilities/配置入口也使用同等实时账号检查。
|
||||
|
||||
`PrescriptionAiLogic.php` 的重要出口:
|
||||
|
||||
| 入口 | 当前信息 | 仅一致度授权时的处理 |
|
||||
| --- | --- | --- |
|
||||
| `statuses:22` | 列表轮询调用 `models(...,false)`,有 `score/herb_score/comparison_status/comparison_reason_code/reason/algorithm_version/dictionary_version` | 返回 `agreement_visible`;未授权时使用统一投影去掉所有比较字段,保留模型生成状态/进度 |
|
||||
| `reports:62` | 历史列表同样 `models(...,false)`,不能只保护 statuses | 同上;继续先按 visibleBatch 过滤再分页,不泄漏隐藏来源计数 |
|
||||
| `detail:107` | `models(...,true)` 解密正文,又包含医生原方快照 | 未授权仍保留报告、候选方及医生原方(按保留报告的当前范围),统一去掉比较数据;不要返回原始解密 body |
|
||||
| `statistics:206` | 均值、中位数、直方图、版本分层、paired_count 等全是这一指标的衍生信息 | 新白名单检查失败时直接抛 DomainException;必须在扫描/聚合前拒绝;不以 mean=null 等方式继续返回统计 |
|
||||
| `regenerate/retry/review` | 返回批次状态或 saved,没有返回比较正文 | 仅指标限制不应阻止既有报告操作;仍保留现有动作 RBAC、处方与来源范围 |
|
||||
|
||||
具体易漏点:
|
||||
|
||||
1. `models:285` 的数据库摘要列不仅 `score`,还包括 `herb_score`、比较状态/原因与版本。
|
||||
2. `models:302-315` 现为 `array_merge(task,result,body,progress)`。加一条 `unset($model['score'])` 不够:解密 body 里的 `comparison` 含同一分数,并可能包含历史未知字段或顶层别名;原始 body 不应不加选择地 merge 到对外模型。
|
||||
3. `Store::complete:348` 当前加密 body 的固定键是 `report,candidate,comparison,coverage,usage,knowledge_source,ancient_book_sources`,不是必须输出的完整 API 对象。建议按这些已知业务字段构造响应,单独按能力投影 comparison。这样历史 body 即使含 `score`, `raw`, `result`, `response`, `body`, `comparison_dump` 等未知附加键,也不会直接公开。
|
||||
4. `Comparison::compare:126` 的完整 comparison 除分数外还包含 `rows`、药量 ratio/贡献、`matched_count`、doctor/candidate_count、`usage_differences` 和 `normalization` 中的 denominator/contribution_sum,足以反推指标。未授权应移除**整个 comparison 对象**。
|
||||
5. `models:310` 的扁平 `reason` 取自 `body.comparison.reason`,也是比较派生信息;无结果任务的 `reason` 则是生成错误信息。应区别处理或在统一投影中使用保留的 `error_message` 展示模型失败原因,避免比较 reason 保留下来。
|
||||
6. `formatBatch:348` 还输出 `comparison_type`, `baseline_eligible`, `baseline_exclusion_reasons`,也应从未授权响应中移除。`coverage_status/source_summary/missing` 属于报告资料覆盖信息,可保留。
|
||||
7. 不把 `body_cipher` / `context_cipher` / `progress_cipher` / 原始 AI 调用响应增加到任何返回值。现有 Progress 采用固定字段投影并本地生成 label/notice,可继续复用;比较阶段名称本身不包含指标结果。
|
||||
8. 受保护字段应该被完全省略,而非伪装为 0 或“未生成”;布尔 `agreement_visible=false` 告诉 UI 不渲染指标功能。
|
||||
|
||||
保留医生原方与候选方案意味着用户仍可人工比较药味;本需求保护的是系统计算的一致度与相关比较/统计数据。若用户后续改为整个模块不可见,则应在所有 AI 动作的业务入口一并拒绝,前端隐藏全部入口;不能把当前字段裁剪方案误当作整模块授权。
|
||||
|
||||
## 5. MCP 与其它旁路
|
||||
|
||||
`server/app/mcp/catalog/review/tcm.php:185` 已开放 `detail/reports/statuses/statistics`,并说明依赖各入口内置数据范围保护。必须将授权/投影放在共享业务层,HTTP 控制器最终输出前才过滤会遗漏直接 Logic 测试或其它调用通路。新管理配置接口不应自动加入开放 MCP catalog。
|
||||
|
||||
对 `server/app` 的引用检索未发现这四个入口之外直接返回 `prescription_ai_result/body_cipher` 的业务出口;Store/Worker 为写入与后台计算。仍应在实现完成后检索新增调用与 MCP generated 元数据,确认统计内置保护说明匹配新规则。
|
||||
|
||||
## 6. menu 迁移惯例
|
||||
|
||||
- 当前处方 AI 迁移 `server/database/migrations/2026_09_09_prescription_ai_analysis.sql:132`:通过现有处方列表 perms 查父级;`INSERT ... SELECT ... WHERE NOT EXISTS(perms)` 幂等创建 type A 动作。
|
||||
- 原有 7 个动作分别注册 statuses/reports/detail/regenerate/retry/review/statistics;读权限继承自 patientAiReports,写权限继承自 generatePatientAiReport。**本需求不应把管理授权能力自动继承给这些读写角色。**
|
||||
- 现有设置页范式 `2026_08_21_desktop_workstation_update_menu.sql`:查询系统设置 type M 父级;type C 页面使用 getConfig perms/path/component,子级 type A 保存动作;时间戳 UNIX_TIMESTAMP,空 path/component 给动作,is_show=1/is_disable=0,事务包裹。
|
||||
- 可参考其幂等菜单写法,但本需求管理菜单默认 root 可用;非 root 只有明确授予独立管理权限后可管理。不要复制“所有设置角色自动继承”的授予部分。
|
||||
- 新 capabilities 仅返回当前身份能力且必须登录,是否注册菜单权限需与中间件设计一致;避免将自查接口变成必须先拥有一致度权限才可调用的循环依赖。
|
||||
|
||||
## 7. 可复用测试模式(本次未运行)
|
||||
|
||||
1. **纯函数/契约测试**:`server/tests/PrescriptionAiProgressTest.php`。直接 require autoload、不初始化 App,用固定 fixture 检查允许字段;策略匹配、规范化、投影最适合此方式。
|
||||
2. **无环境隔离桩**:`server/tests/DiagnosisValidateEditSceneTest.php:9` 创建 `new think\App()` 但不 initialize;在真实类加载前以 class_alias 安装内存模型/DB/Auth double。适合验证真实入口会拒绝未授权 statistics 与配置写入,避免初始化业务 .env。
|
||||
3. **真实加密/数据库 API 集成**:`server/tests/PrescriptionAiQueueTest.php:5` 明确禁止 initialize,必须显式 `ZYT_AI_TEST_MYSQL_PORT`,只连接 `127.0.0.1` 空密码临时 MySQL,数据库名只允许 `prescription_ai_test_[a-f0-9]{12}`,显式注入隔离 DB/config 并最终清理;现有 fixture 已覆盖 statuses/reports/detail/statistics、结果 Cipher、失败模型和旧 body 注入。扩展时可添加 config/system_role 夹具、非 root 多角色及账号。
|
||||
4. **迁移幂等**:QueueTest 重放 migration 两次并检查菜单数,可直接复用新菜单和空策略迁移的验证方式。
|
||||
5. **多角色缓存边界**:`AdminMultiRoleRegressionTest.php` 说明角色 ID 去重排序及撤销角色后 token/cache 失效;新指标策略应按实时 role_id 与新策略判断,不从旧 token 缓存白名单结果。
|
||||
6. **本地配置文件测试**:`PrescriptionAiConfigurationTest.php` 使用自定义 env fixture,绝不 bootstrap。可用于确认本次没有意外改变模型启用/生成配置。
|
||||
|
||||
最低必要用例:默认空/非法策略全部不可见(包括 root);只选角色/只选用户/任一匹配/多角色并集/其它账号不匹配;禁用或软删除账号、删除角色、实时撤销;未授权仍能读既有报告内容但 statuses/reports/detail 任何层均不存在比较字段或未知原始 body;授权用户数值保持不变;未授权 statistics 拒绝而不聚合;非管理者不能读名单、改名单或列选项;root 可管理但不会因此获得 view;已有处方/来源权限撤销仍拒绝报告。加入带唯一哨兵的嵌套 comparison/unknown raw body fixture,序列化整个响应检验哨兵不存在,不能只验证 UI 字段或顶层 score。
|
||||
|
||||
不建议直接运行会 `App::initialize()` 的旧测试(例如 PrescriptionAiSecretSafetyTest)来证明本特性;本功能可以完全使用上述隔离模式完成有效验证。
|
||||
@@ -1,57 +0,0 @@
|
||||
# 处方 AI 一致度可见范围:桌面实现交接
|
||||
|
||||
日期:2026-10-17。范围仅 `app/` 与本报告/离线截图;未修改 `admin/`、`server/`,未 commit、部署或调用生产接口。
|
||||
|
||||
## 完成行为
|
||||
|
||||
- 新增 `DoctorRepository` / `RemoteDoctorRepository.get_prescription_ai_capabilities()`,GET `tcm.prescriptionAi/capabilities`。
|
||||
- 一致度只接受服务端明确的 `agreement_visible is True`。没有本地 root、角色 ID、`*`、管理策略能力的豁免;缺失字段、非布尔 true、能力请求失败均拒绝。
|
||||
- 列表把 AI 状态与第 12 列一致度分离:状态仍按现有权限及分析开关加载,能力查询不阻塞状态请求。分数另需 capability + statuses 顶层 flag + 每批次 flag;统计按钮另需原 `statistics` 权限。
|
||||
- 列表隐藏/重显、刷新都会重查能力;重新授权会重新取已完成批次的分数,避免从已清理的缓存展示空白。
|
||||
- 共用报告(包括诊单入口)要求 reports 顶层、历史批次、detail 均明确授权。一致度受限时仍保留报告正文、模型筛选、AI 候选原文、来源与资料缺口、处理进度、历史批次选择与分页、重新分析、重试和复核。
|
||||
- 受限时总览使用原有复核清单作为“报告复核”安全骨架,隐藏结论、组成归属、剂量图、风险剂量图及差异/共识计数。原“候选与逐味”导航改为“AI 候选处方”,跳到现有候选原文页;逐味页、比较原文和历史趋势不可导航进入。
|
||||
- 顶部保留模型状态、资料状态与失败重试行,只隐藏分数、重合度、差值和仪表刻度;因此没有丢失原来附着在仪表卡上的重试按钮。
|
||||
- 未授权数据进入缓存前清除 comparison、score、herb_score、比较状态/类型、算法版本及相关聚合元数据。撤权和读取失败同时清空旧数字、图表数据、比较 HTML、历史图/表、tooltip、accessibility 值和派生计数。
|
||||
- 请求 token / generation 拒绝旧异步响应恢复分数;主题重建保持能力状态和复核草稿。
|
||||
- 统计对话框直接构造也先请求 capability;未明确授权不发 statistics 请求。每次查询先清图,能力/统计失败、隐藏窗口、旧响应都会清理或拒绝旧结果。
|
||||
|
||||
## 修改文件
|
||||
|
||||
- `app/src/doctor_workstation/services/repository.py`:能力接口协议及远程实现。
|
||||
- `app/src/doctor_workstation/ui/pages/prescriptions.py`:独立列/统计能力、列表缓存清理、异步 guard、受限入口文案。
|
||||
- `app/src/doctor_workstation/ui/dialogs/issued_prescription_ai.py`:共享数据清理、报告各入口统一切换、统计 capability 门禁。
|
||||
- `app/src/doctor_workstation/ui/dialogs/issued_prescription_ai_console.py`:仪表指标切换,同时保留状态与重试。
|
||||
- `app/src/doctor_workstation/ui/dialogs/issued_prescription_ai_workspace.py`:复核安全骨架;旧行先清文本、hide、detach、delete,防窗口瞬闪和残留轴控件。
|
||||
- `app/tests/test_issued_prescription_ai.py`:既有授权 fixture 明确 true;新增拒绝/缺失字段/非布尔 true、root 不豁免、撤权、失败、旧回调、主题、列表重显与统计直接入口回归。
|
||||
- `app/scripts/render_issued_prescription_ai.py`:原有授权预览 fixture 明确 true。
|
||||
- `app/scripts/render_prescription_ai_visibility.py`:可重复生成授权/受限离线截图。
|
||||
|
||||
变更行使用 LF,未改变的行按 HEAD 保留原始行尾 bytes,避免仓库原有混合换行导致整文件 diff。
|
||||
|
||||
## 验证
|
||||
|
||||
1. 核心 AI 测试,249 项通过(独立进程):
|
||||
|
||||
`app/.venv/Scripts/python.exe -m pytest app/tests/test_issued_prescription_ai.py app/tests/test_issued_prescription_ai_workspace.py app/tests/test_issued_prescription_ai_pages.py app/tests/test_issued_prescription_ai_comparison.py -q --tb=short`
|
||||
|
||||
2. 外围权限与处方界面测试,32 项通过(独立进程):
|
||||
|
||||
`app/.venv/Scripts/python.exe -m pytest app/tests/test_permissions.py app/tests/test_prescription_security_ui.py app/tests/test_prescription_list_density.py app/tests/test_prescriptions_tech_blue.py -q --tb=short`
|
||||
|
||||
3. Ruff 对上述 8 个变更 Python 文件检查通过。
|
||||
4. `git diff --check -- app` 通过。
|
||||
5. 新截图脚本离线执行通过;已目视检查受限列表、正文、候选原文、复核/重试和统计拒绝截图。
|
||||
|
||||
联合运行这 281 项时,核心及前面外围测试共 272 项无失败后,最后一组 `ShellWindow` 交互测试出现 Qt 同进程状态阻塞,已中断该联合进程;同一外围组单独执行通过。先前定位到的两个真实回归(旧控件瞬闪、旧轴残留)均已修复,相关定向测试通过。本交接采用上述两个独立进程验证结果,不宣称联合进程完整通过。
|
||||
|
||||
## 截图
|
||||
|
||||
目录:`artifacts/prescription-ai-visibility/`。全部为内存演示数据,无生产读取。
|
||||
|
||||
- `list-authorized.png` / `list-restricted.png`
|
||||
- `report-authorized.png` / `report-restricted.png`
|
||||
- `review-restricted.png`:复核清单、表单、保存与模型重试仍可用。
|
||||
- `candidate-restricted.png`:候选原文可读,无贡献/一致度。
|
||||
- `statistics-restricted.png`:直接统计入口拒绝,空图表且未请求统计。
|
||||
|
||||
服务端仍需按既定策略在 read API 屏蔽结构化比较,并在 statistics 强制校验;桌面不会从角色或登录快照重建授权。授权策略变更在下一次能力/状态/报告读取时更新显示,当前没有新增服务端推送通道。
|
||||
@@ -1,113 +0,0 @@
|
||||
# 已开处方 AI 一致度可见范围:前端只读研究
|
||||
|
||||
日期:2026-10-17。范围:桌面 `app/`、后台 `admin/` 的入口、登录元数据、配置承载点和测试;未修改业务代码,未调用任何生产接口。已阅读根目录 `AGENTS.md`;当前工作区 `.trellis/workflow.md` 不存在。
|
||||
|
||||
主任务已确定实施默认:仅一致度独立授权、保留报告;新独立设置页;角色或个人任一命中允许;空名单全隐藏;root 可管理策略,但查看分数也必须命中名单。API 使用 `GET tcm.prescriptionAi/capabilities -> {agreement_visible: bool}`,statuses/reports 顶层及每批次附该 flag,detail 顶层附 flag;未授权剔除 score/herb_score/comparison,statistics 拒绝。下文早期建议中的命名/承载页/初始行为均以上述约定为准。
|
||||
|
||||
## 结论
|
||||
|
||||
- 截图对应桌面 `PrescriptionsPage` 的第 12 列 `与 AI 一致度`。当前列显隐只跟随服务端 AI 总开关 `enabled`,没有按角色或个人控制一致度的能力。
|
||||
- 现有权限只拆分 `statuses/reports/detail/statistics/regenerate/retry/review`。`reports + detail` 共同允许打开报告,不区分“能看报告”和“能看分数”。单靠收回现有权限无法实现“保留 AI 报告、隐藏一致度”。
|
||||
- 仅隐藏列表列不完整:报告顶部双模型分数、逐味原文、总览结论、历史趋势、医生统计也展示一致度。
|
||||
- 桌面已经从 `auth.admin/mySelf` 得到用户 ID、多个角色 ID、root 和权限集合;但运行中的会话权限不适合单独承担可见范围判定。推荐服务端每次读 AI 数据返回当前账号的明确 capability,并在响应中屏蔽未授权分数;桌面统一使用这个判定渲染。
|
||||
- 后台没有现成处方 AI 配置页。最小配置入口可放现有“医生工作站”设置页单独配置区;该页目前专门管理升级包,建议新建独立读写配置接口,而不是把授权策略挂到其免登录更新检查响应。
|
||||
|
||||
## 桌面入口与显示面
|
||||
|
||||
| 位置 | 当前行为 | 仅一致度控制的接入点 |
|
||||
| --- | --- | --- |
|
||||
| `app/src/doctor_workstation/ui/pages/prescriptions.py:1050` | 顶部 `AI 一致度统计`;当前只查 `tcm.prescriptionAi/statistics` | 叠加一致度 capability;响应拒绝时清空既有统计 |
|
||||
| 同文件 `:1200`、`:1583`、`:1765`、`:1773` | 工具栏 AI 报告、每行报告按钮、右键 `AI 报告 / 逐味对照` | 若保留报告应继续可用;文案可缩成 `AI 报告`,避免暗示全部对比可见 |
|
||||
| 同文件 `:1255`–`:1273` | 第 11 列 AI 状态、第 12 列与 AI 一致度;两列初始隐藏 | 分离两列显隐,不能继续用 `_set_ai_columns` 同时切换 |
|
||||
| 同文件 `:1289`、`:1344` | 单击两列打开同一报告;自定义 cell widget 的事件过滤也处理两列 | 隐藏分数时保留状态打开报告的行为 |
|
||||
| 同文件 `:1647`–`:1762` | 只给可见处方批量拉 `statuses`,缓存后渲染;`enabled` 同时开启两列 | 读 capability、清掉旧第 12 列文本/tooltip/accessible text 及缓存中的分数,保留状态轮询 |
|
||||
| `app/src/doctor_workstation/ui/dialogs/diagnosis.py:1080`、`:1726`、`:1983` | 诊单只读页也能按 `diagnosis_id` 打开已开处方 AI 历史 | 必须走同一共享报告权限与 capability |
|
||||
| `app/src/doctor_workstation/ui/dialogs/issued_prescription_ai.py:401`、`:1817` | `can_open_issued_ai` + `present_issued_prescription_ai` 是共享报告入口 | 保留 reports/detail guard,另设 `can_view_agreement`;不要把报告 guard 改成分数 guard |
|
||||
| 同文件 `:664`、`:1377`–`:1417`、`:1459` | `AgreementBar`、model score、可访问名/tooltip、原文 `comparison` 都有分数 | 分数不授权时不构建/隐藏分数区域,不填分数到隐藏控件,并清理原文内容 |
|
||||
| 同文件 `:1210`;`issued_prescription_ai_pages.py:1156`、`:1191` | `HistoryPage` 从 `reports.lists` 渲染历史分数趋势,兼容 `comparison.score` 与模型顶层 `score` | 历史仍可选;关闭趋势分数,服务端历史摘要也需要去分数 |
|
||||
| `issued_prescription_ai_workspace.py:674`–`:687` | 总览结论有双方一致率、纯药味重合度和百分点差 | 一致度受限时不得拼出这些结论 |
|
||||
| `issued_prescription_ai_pages.py:364`、`:464` | 候选与逐味页说明算法、读取贡献行 | 要确认“仅一致度”是否包括每味贡献;至少不能泄露 contribution 与算法分子/分母 |
|
||||
| `issued_prescription_ai_console.py:260`、`:295`、`:378` | 顶部仪表、无障碍名称、纯药味重合度 | 与主报告 capability 保持一致 |
|
||||
| `issued_prescription_ai.py:1705`–`:1813`;`issued_prescription_ai_pages.py:1374` | 医生统计对话框;分布、均值、中位数、明细 HTML | 现有 statistics API 权限以外还应要求一致度可见;拒绝后整个统计关闭 |
|
||||
|
||||
补充:原处方详情 `dialogs/prescription.py` 没有独立已开处方一致度入口;目前可见入口集中在上述列表和诊单共享报告。后台 `admin/src/views/consumer/prescription/components/PrescriptionAiReportDialog.vue` 是另一个旧的处方解读报告流程,当前 `admin/src/views` / `admin/src/api` 没搜到 `tcm.prescriptionAi` 或一致度 UI,不应把它误当成本次双模型已开处方分析界面。
|
||||
|
||||
## 请求边界与元数据
|
||||
|
||||
`app/src/doctor_workstation/services/repository.py:1609`–`:1648` 透传以下服务端字典,新增 capability 无需先扩大类型 DTO:
|
||||
|
||||
- `statuses(ids)`:模型顶层 `score`、`comparison_status`,用于列表。
|
||||
- `reports(prescription_id XOR diagnosis_id)`:历史摘要;传入 `page_no/page_size`。
|
||||
- `detail(batch_id)`:完整 model `comparison`,用于报告全部页面。
|
||||
- `statistics(date_from,date_to,doctor_id?)`:独立统计读接口。
|
||||
- `regenerate/retry/review` 是显式写接口,此需求若只限制读分数,不必连带禁止已有 AI 生成和复核动作。
|
||||
|
||||
现有登录承载:
|
||||
|
||||
- `core/models.py:127`–`:176` 的 `UserProfile` 已包含 `id`、`role_ids`、`root`、`permissions`、完整 `raw`;多角色解析支持 `role_ids` / `role_id` / `roles`。
|
||||
- `services/repository.py:902`–`:927` 的 `get_session()` 读取 `auth.admin/mySelf`,构造 `PermissionSet` 并存 session。
|
||||
- `core/permissions.py:63`–`:83` 支持全局 `*` 与路由前缀 `/*`。`ui/widgets.py` 的 `has_permission` 已统一前端权限语义,新增功能不要另写角色硬编码。
|
||||
- 后台 `admin/src/stores/modules/user.ts:110` 左右同样读取 `data.user/data.permissions/data.menu`。服务端 `AdminLogic.php:319` 已填 `role_ids`。
|
||||
- 注意:`mySelf` 的 metadata 只是登录时快照;服务端改授权后应通过 AI read 响应刷新 capability,不能等用户下次登录才隐藏分数。
|
||||
|
||||
## 后台配置与选择器复用
|
||||
|
||||
### 可承载配置的页面
|
||||
|
||||
`admin/src/views/setting/desktop_workstation/index.vue` 有成熟 `el-form` + `getData` + `handleSubmit` + `footer-btns` 模式,当前管理升级策略;API 在 `admin/src/api/setting/desktop_workstation.ts`,对应 `setting.desktop_workstation/getConfig,setConfig`。可新增独立“处方 AI 一致度可见范围”设置区,提供角色多选 + 用户多选,并明确 OR 语义(命中任一角色,或被指定用户,均可见)。保存权限宜独立管理,避免升级包操作员默认拥有授权分配能力。
|
||||
|
||||
若希望未来扩展整个 AI 模块范围,新建 `setting/prescription_ai` 页面更清晰;当前没有可直接扩展的同名页。`ai_mcp/grant` 只管理员工外部 AI 助手令牌撤销,并非本功能授权。
|
||||
|
||||
### 角色与用户
|
||||
|
||||
- `admin/src/api/perms/role.ts:8` 的 `roleAll({})` → `/auth.role/all` 可复用。角色多选 UI 在 `views/permission/admin/edit.vue:73`–`:91`,`optionsData.role` 来自 `roleAll`;处方页 `consumer/prescription/index.vue:3303` 也有获取及失败降级模式。
|
||||
- 当前角色配置 `views/permission/role/auth.vue` 是菜单树,保存 `menu_id` 到 `roleEdit`;适合给配置页面授予管理权限,但无法直接表达个人白名单。
|
||||
- `admin/src/api/perms/admin.ts:4` 的 `adminLists` → `/auth.admin/lists` 是已实现的人员来源。`AdminLists.php:82` 支持 `name/account` 搜索,`:158` 支持角色筛选,`:165` 支持 `exclude_disabled=1`,列表带 `id/name/account/role_id`。建议用户多选下拉做姓名/账号远程分页,显示 `姓名(账号)`。
|
||||
- **不要直接复用 `adminAll`**:前端虽导出了 `/auth.admin/all` 包装,但当前 `AdminController.php` 无 `all()`,全仓也没找到该路径映射。
|
||||
- `admin/src/components` 没有通用后台用户多选组件。可用 Element Plus `el-select multiple filterable remote` 现有模式(处方页已有远程患者选择,但其数据类型和接口不能直接当员工选择器)。
|
||||
- 现有处方模型 `visible_role_ids` 是处方审核/数据可见范围,不是分数可见范围;不要复用该字段造成整个处方行被隐藏。
|
||||
|
||||
## 仅一致度、保留 AI 报告的最小实现建议
|
||||
|
||||
1. 服务端策略保留为独立 role IDs + admin IDs;服务端返回 `can_view_agreement`(名称仅建议)而不是让客户端拼白名单。root 的默认行为、未配置的初始行为须与产品决策一致。
|
||||
2. `statuses/reports/detail` 每个响应显式带当前 capability;未授权时剔除顶层/嵌套 `score`、`herb_score`、逐味 `contribution` 以及对应聚合分母,避免旧客户端或缓存旁路。统计接口直接拒绝未授权读。`enabled` 仍只代表分析功能总开关。
|
||||
3. 桌面以独立布尔能力驱动第 12 列和统计按钮;第 11 列分析状态、AI 报告按钮与 reports/detail 权限保持现状。`_set_ai_columns` 应拆开或接受第二个参数。改变能力或请求失败时清理旧数据,不能只 `hide()`。
|
||||
4. 共享报告统一把 capability 传至 AgreementBar / Workspace / HistoryPage / CandidatesPage;受限时保留完整报告、候选处方、来源、进度、复核与历史批次选择,但去掉一致度数字、趋势、贡献和相关说明。完整报告存量文本是否可能内嵌一致度,需要服务端研究确认;如允许模型原文包含此数值,仅过滤结构化字段不够。
|
||||
5. 新后台配置区复用现有表单样式与 roleAll;人员选择用 adminLists 或专门受配置管理权限保护的最小字段 options 接口,避免配置管理员为了选人还必须拥有完整管理员列表权限。
|
||||
|
||||
待用户确认:“仅一致度”是只控制分数/重合度/贡献统计,还是连逐味剂量比较也一起隐藏;后者需要隐藏总览和候选逐味相关导航,但仍可保留报告正文。整个 AI 模块方案则简单得多:按新策略门禁所有 AI 入口和 API;不能在未确认时用此方案替代仅一致度方案。
|
||||
|
||||
## 可复用测试
|
||||
|
||||
本次只读研究未运行测试。现有测试使用内存 fake repository + `run_async` immediate fixture,不需要生产 API。
|
||||
|
||||
- `app/tests/test_issued_prescription_ai.py:440` `test_list_batches_visible_ids_and_stops_on_hide`:可延伸验证受限账号仍拉状态、只隐藏第 12 列。
|
||||
- 同文件 `:462`、`:500`、`:525`、`:564`:空处方、总开关关闭、缺少 statuses 权限的入口及列显隐基线。
|
||||
- 同文件 `:428` `test_view_permission_cannot_mutate`:保持 reports/detail 与写权限解耦。
|
||||
- 同文件 `:1178` `test_access_revocation_clears_reports_and_all_progress`:可延伸为只撤销分数能力后清空数字,但正文保留。
|
||||
- 同文件 `:596`、`:796`、`:977`:统计 null/版本分层/排除原因;补未授权统计不发请求且清空旧图。
|
||||
- 同文件 `:626` `test_repository_exact_read_and_mutation_contracts`:可加服务端 capability 透传及不误改 mutation 请求。
|
||||
- 同文件 `:697`、`:714`、`:1236`:追加列表缓存、右键目标、tooltip;可防分数在缓存/无障碍文本中残留。
|
||||
- `app/tests/test_issued_prescription_ai_pages.py:288`–`:326`:历史读取嵌套和扁平两种分数;`:361`–`:418`:统计图表。新增受限 capability 时两种数据形状都不显示分数。
|
||||
- `app/tests/test_issued_prescription_ai_workspace.py:239`:不捏造一致率差值;可加受限时不生成现有分数结论。
|
||||
- `app/tests/test_permissions.py`:`*`、前缀通配符、AND/OR、空权限语义;如果新增菜单按钮权限应保持兼容。
|
||||
- `app/tests/test_repository_parity.py`:可复用登录/session 的用户 ID、role_ids 解析测试结构。
|
||||
- 后台当前无处方 AI 授权配置测试;`admin/tests/*.test.cjs` 是 Node 内建测试模式,`admin/package.json` 有 `type-check`,没有统一 test script。应新增只针对配置读写归一化、空选择与人员搜索的必要测试,避免写只镜像模板的快照测试。
|
||||
|
||||
## 实施交接:最小切换与 fixture
|
||||
|
||||
1. `issued_prescription_ai.py` 增加统一 `_apply_agreement_visibility()`(建议名)。`_build_ui` 默认隐藏;`_history_ready` / `_detail_ready` 应用 `agreement_visible is True`。缺失 flag 按 false;主题切换 `_switch_theme` 会重建 UI,必须继承能力并重新应用。从有权切成无权时,先清空旧 payload/图,再渲染正文。
|
||||
2. 顶部 `self.agreement` 与 `self.agreement_note` 一起隐藏。注意每个模型的 **retry 按钮目前经 `self.agreement.attach_action(key,retry)` 放在仪表区域**(`:891`);如要求保留重试,需要移到进度页/独立状态区域。
|
||||
3. 导航为 `NAV_PRIMARY`(`:68`),`self.nav_buttons` 是 `self.rail.buttons`(`:738`);overview/per_herb/history 都不是普通可见 tab,而是 rail 控制隐藏 tabBar 的 `QTabWidget`。隐藏导航后还要给 `_goto`(`:1065`)加拒绝/回落到 report,防 `_open_supporting_report`(`:1097`)和主题恢复旧页绕过。
|
||||
4. 最小完整隔离方式:受限时隐藏 overview 与 per_herb 导航/页面、comparison_raw 原文页;默认切 report。报告原文、candidate、sources/gaps、pipeline、original 继续可用。`HistoryPage` 可保持批次列表但隐藏图和 3/4 分数列;更省改动可隐藏 history 页面,顶部 `self.history` 批次选择与分页仍保持完整历史访问。
|
||||
5. **不能直接隐藏 overview 就结束**:`review_model` 与完整 review 表单在 `:898`–`:899` 被 reparent 到 `comparison_panel.checklist`。隐藏总览会丢失现有复核 UI。若保留复核,需把 selector + review widget 移到正文旁/独立复核页;或保留 overview 骨架,只隐藏 `conclusions/attribution/doses/risks` 及 comparison 链接,保留 checklist。`PrescriptionReviewWorkspace` 的这些已命名属性在 `issued_prescription_ai_workspace.py:1159`–`:1173`,有 `set_review_selector/set_review_widget`(`:1245`、`:1252`)。
|
||||
6. `StepRail` 的 `focus_values`(`issued_prescription_ai_console.py:475`)与 `_render_console` 的 badges/focus 含差异、共识药味等派生计数。若 comparison 整体移除,清空/隐藏相关焦点行,避免把它们误显示为 0。保留来源缺口和报告数量即可。
|
||||
7. `HistoryPage`(pages.py:1145)左 card 当前是局部变量 `left`;如仅隐藏趋势需将其保存为实例属性。表格 3/4 列是两模型分数,5 列算法;`strata` 和 `note` 也有“分数可以比较”等文案,受限时应清理。`set_history` 即使拿到剔除 comparison 的历史,也会保留这些文案,不能依赖没有数字自然隐藏。
|
||||
8. `CandidatesPage` 的 `cards` 显示原方/候选处方;下方表格同时混合剂量与贡献。若保留候选卡片,则只隐藏/跳过逐味 body;否则整页隐藏最少。候选独立原文页 `tab_pages['candidate']` 依旧能保留原始候选数据。
|
||||
9. `issued_prescription_ai.py:1414`–`:1417` 的 comparison HTML 应清空并跳过填充,而非仅藏页面。主界面 `model_views[*]['score']` 的 text/tooltip/accessibility、AgreementBar 内 gauge 和历史 `TrendBars` 都要拿空数据重置,避免 capability 撤销后留下旧值。
|
||||
|
||||
可直接扩展 fixture:
|
||||
|
||||
- `app/tests/test_issued_prescription_ai.py:24` 的 `batch()`,`:45` 的 `Repository`、`:86` 的 `no_network`(拒绝 socket)、`:95` 的 `immediate`;Repository 加 `agreement_visible` 属性和 capabilities 方法,read 字典统一附 flag。该组合最适合测从有权到无权的异步回调、缓存清理和主题重建。
|
||||
- `app/tests/test_issued_prescription_ai_pages.py:35` 的 `batch(**overrides)`、`:66` 的 `per_herb`、`history` fixture,可用同一批次先 true 再 false,确认 score fallback 两种形状都无残留。
|
||||
- `app/tests/test_issued_prescription_ai_workspace.py:25` 的 `batch(count)` 与 `:43` 的 `workspace` fixture 带真实保存逐味 identity 和 review slot;适合确认受限骨架不影响 review、report links 与布局。
|
||||
@@ -1,51 +0,0 @@
|
||||
# 处方 AI 一致度授权:验收记录
|
||||
|
||||
日期:2026-10-17。
|
||||
|
||||
## 用户确认范围
|
||||
|
||||
仅控制“与 AI 一致度”、一致度详情(逐味对照、贡献、差异等)与一致度统计,保留 AI 分析报告。授权对象可以是指定系统角色,也可以是具体后台用户,任一命中即可。
|
||||
|
||||
## 完成行为
|
||||
|
||||
- 新增后台“系统设置 → 处方AI一致度授权”页面,使用角色、用户两个可搜索多选框配置名单。
|
||||
- 默认空名单对所有账号隐藏,包括 root;管理策略的权限与查看分数的权限分开。
|
||||
- 服务端每次读取检查有效账号与实时角色归属。原有报告动作权限、患者/处方/来源范围继续执行,白名单不会扩大数据范围。
|
||||
- 未授权账号的状态、历史和详情响应移除一致度及其派生数据,统计请求在聚合前拒绝;模型报告、AI 候选方案、进度、来源、重试、重新分析与复核保留。
|
||||
- 桌面按服务端明确的布尔能力控制列、详情、导航和统计;缺失、失败按不可见处理。撤权后清理旧图表、比较原文、缓存、提示与可访问数据,旧异步回调不会恢复分数。
|
||||
- 最终复核补齐了桌面缓存中比较原因、原因码、药材字典版本与基线序列化字段的清理,并增加保留报告/生成失败说明、不修改原输入的回归。
|
||||
|
||||
策略变更在下一次能力/状态/报告读取时反映到桌面;没有新增推送通道。
|
||||
|
||||
## 验证
|
||||
|
||||
| 范围 | 实际结果 |
|
||||
| --- | --- |
|
||||
| 最终桌面核心 AI 组合(主代理复跑) | 250 passed,14.57s,exit 0 |
|
||||
| 桌面外围权限/处方界面(独立进程) | 32 passed |
|
||||
| 后台实际 Vue setup/API 模拟(主代理复跑) | 8/8 passed |
|
||||
| PHP 纯离线权限/投影(主代理复跑) | 22 checks passed |
|
||||
| 隔离 MySQL 真实 ORM/Cipher/Logic/Controller(主代理复跑) | 113 checks passed |
|
||||
| 后端 worker Pipeline / 旧进度 schema | 125 / 111 checks passed |
|
||||
| 后端比较、统计、进度、配置回归 | 通过,详见后端报告 |
|
||||
| Ruff、PHP lint、限定任务文件 diff 检查 | 通过 |
|
||||
| 本地后台浏览器与桌面离屏截图 | 已检查授权/受限、报告保留、重试/复核、统计拒绝 |
|
||||
|
||||
临时 MySQL 仅绑定 127.0.0.1:33447,独立初始化且不读取生产配置;临时数据库由测试清理,进程已由主代理正常关闭。未调用生产数据库、模型接口或发布流程。
|
||||
|
||||
已知验证边界:将桌面核心与 ShellWindow 外围组放在同一进程运行时,最后一组发生 Qt 阻塞;分进程全部通过。后台全量类型检查有 61 项范围外错误,新增文件的严格模板类型检查通过。当前工作区还有其它任务在修改订单辅方袋数,本验收没有修改其文件;最终 diff 检查限定本任务文件。
|
||||
|
||||
## 上线与配置
|
||||
|
||||
代码尚未部署。需要执行 [幂等迁移](../server/database/migrations/2026_10_17_prescription_ai_agreement_visibility.sql),部署服务端及后台,并更新桌面端。迁移创建菜单和空策略,不自动把任何角色或账号加入查看名单。
|
||||
|
||||
管理员进入“系统设置 → 处方AI一致度授权”,选择角色或用户并保存后,相应账号在下次读取时显示一致度。已有处方和报告无需重新生成。
|
||||
|
||||
## 交付与预览
|
||||
|
||||
- [后端实施与测试](prescription-ai-visibility-backend-implementation.md)
|
||||
- [后台实施与测试](prescription-ai-visibility-admin-implementation.md)
|
||||
- [桌面实施与测试](prescription-ai-visibility-desktop-implementation.md)
|
||||
- [后台授权配置预览](prescription-ai-visibility-admin-local.png)
|
||||
- [受限账号处方列表](prescription-ai-visibility/list-restricted.png)
|
||||
- [受限账号保留的报告](prescription-ai-visibility/report-restricted.png)
|
||||
|
Before Width: | Height: | Size: 111 KiB |
|
Before Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 173 KiB |
|
Before Width: | Height: | Size: 126 KiB |
|
Before Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 35 KiB |
@@ -1,61 +0,0 @@
|
||||
# 接诊台当前医助数据契约
|
||||
|
||||
审查范围:只读静态代码;未连接生产数据,未修改业务代码。仓库 `.trellis/` 不存在。
|
||||
|
||||
## 结论
|
||||
|
||||
接诊台头部应显示聚合详情的 `detail.appointment.assistant_name`。它的业务来源是当前诊单 `tcm_diagnosis.assistant_id → admin.name`,与“通知医助”的实际接收人一致。`detail.appointment.assistant_id` 也已由服务端覆盖为当前诊单医助 ID。
|
||||
|
||||
不要回退到历史挂号 `doctor_appointment.assistant_id` / `appointment_assistant_id`、指派日志前任医助、处方医助、`doctor_name` 或当前登录人姓名。成功加载详情后,空的当前医助字段应保持空态,不应再从旧队列记录补出姓名。
|
||||
|
||||
## 请求与结构
|
||||
|
||||
- 桌面端真实方法名是 `RemoteDoctorRepository.get_reception(appointment_id)`,不是 `get_reception_detail`;GET `doctor.appointment/reception`,参数 `{id: appointment_id}`,返回映射原样保留。见 [repository.py:1102](D:/web/zyt/app/src/doctor_workstation/services/repository.py:1102)。`remote_repository.py` 只是重导出模块。
|
||||
- 服务端返回 `{appointment, diagnosis, doctor_notes, tracking_notes}`,见 [AppointmentLogic.php:722](D:/web/zyt/server/app/adminapi/logic/doctor/AppointmentLogic.php:722)。桌面端兼容外层 `{data: ...}`,见 [reception.py:7392](D:/web/zyt/app/src/doctor_workstation/ui/pages/reception.py:7392)。
|
||||
- 关联关系特别注意:`appointment.patient_id == tcm_diagnosis.id`,不是基础患者表 ID;诊单另有自己的 `patient_id`。见 [AppointmentLogic.php:678](D:/web/zyt/server/app/adminapi/logic/doctor/AppointmentLogic.php:678)。
|
||||
|
||||
```json
|
||||
{
|
||||
"appointment": {
|
||||
"id": 123,
|
||||
"patient_id": 456,
|
||||
"assistant_id": 2001,
|
||||
"assistant_name": "某医助",
|
||||
"doctor_name": "某医生"
|
||||
},
|
||||
"diagnosis": {"id": 456, "assistant_id": 2001},
|
||||
"doctor_notes": [],
|
||||
"tracking_notes": []
|
||||
}
|
||||
```
|
||||
|
||||
以上仅是字段结构示例,不是线上数据。
|
||||
|
||||
## 服务端字段语义及通知一致性
|
||||
|
||||
| 位置 | 确切行为 |
|
||||
| --- | --- |
|
||||
| [AppointmentLogic.php:489](D:/web/zyt/server/app/adminapi/logic/doctor/AppointmentLogic.php:489) `detail()` | 选择 `a.*` 并关联患者姓名、电话、`ad.name as doctor_name`;本身不补医助姓名,原始 `a.assistant_id` 属于挂号记录。 |
|
||||
| [AppointmentLogic.php:687](D:/web/zyt/server/app/adminapi/logic/doctor/AppointmentLogic.php:687) `reception()` | 按 `diagnosis.assistant_id` 查 Admin,再写入 `appointment.assistant_name`;`appointment.assistant_id = diagnosis.assistant_id ?? 0`。这一覆盖使聚合 appointment 的医助变为当前诊单口径。 |
|
||||
| [AppointmentLogic.php:887](D:/web/zyt/server/app/adminapi/logic/doctor/AppointmentLogic.php:887) `notifyAssistant()` | 按挂号 `patient_id` 找诊单,再读诊单当前 `assistant_id`,查 Admin 的 `work_wechat_userid` 发送通知。桌面端仅 POST `{id: appointment_id}`,见 [repository.py:1164](D:/web/zyt/app/src/doctor_workstation/services/repository.py:1164)。 |
|
||||
| [AppointmentLists.php:241](D:/web/zyt/server/app/adminapi/lists/doctor/AppointmentLists.php:241) 队列列表 | `u.assistant_id as assistant_id`、`asst.name as assistant_name` 来自当前诊单;另外保留 `a.assistant_id as appointment_assistant_id`,两者语义不同。 |
|
||||
|
||||
未分配时,聚合详情姓名为 `""`,ID 为诊单原有空值(缺少时 `0`);`notifyAssistant()` 对 `assistant_id <= 0` 返回“该患者尚未指派医助”。若 ID 存在但 Admin 查不到/姓名为空,显示姓名同样为空;不能仅根据姓名为空断言未分配。通知另会返回“医助账号不存在”或未绑定企业微信的具体错误。UI 可用“未分配”表示明确的无 ID 状态,有 ID 无姓名可显示“—”或“医助 #ID”。
|
||||
|
||||
## 管理端与桌面端现状
|
||||
|
||||
- 管理端患者信息卡没有多级姓名回退:`apt = detail.appointment`,显示 `apt.assistant_name || '—'`(当前文案为“客服”);见 [index.vue:271](D:/web/zyt/admin/src/views/patient/reception/index.vue:271)、[PatientInfoCard.vue:19](D:/web/zyt/admin/src/views/tcm/diagnosis/components/PatientInfoCard.vue:19)。队列也显示 `row.assistant_name || '—'`,见 [index.vue:72](D:/web/zyt/admin/src/views/patient/reception/index.vue:72)。
|
||||
- `Appointment` 模型已有 `assistant_id: int | None`、`assistant_name: str`,`from_dict` 直接读取同名键,默认 `None` / `""`;见 [models.py:195](D:/web/zyt/app/src/doctor_workstation/core/models.py:195)、[models.py:233](D:/web/zyt/app/src/doctor_workstation/core/models.py:233)。`Consultation` 也有同名字段,未把医生姓名作为医助别名。接诊聚合没有专用 dataclass,直接用 dict。
|
||||
- 桌面端详情已把聚合 `appointment` 传入身份/预约渲染;见 [reception.py:7661](D:/web/zyt/app/src/doctor_workstation/ui/pages/reception.py:7661)。兼容字段 `appointment_labels['assistant']` 已读取 `appointment.assistant_name`,见 [reception.py:7888](D:/web/zyt/app/src/doctor_workstation/ui/pages/reception.py:7888);但其所在 `appointment_card` 被移到隐藏兼容容器,见 [reception.py:4740](D:/web/zyt/app/src/doctor_workstation/ui/pages/reception.py:4740)。因此修复可以在头部直接复用现有详情字段,无须新增 API 或修改 core model。
|
||||
|
||||
建议展示优先级:已成功取得接诊聚合详情时,以其 `appointment.assistant_name` 为准;加载前可使用当前选中队列的同口径姓名作临时展示。切换患者、取消选择、详情失败时按当前患者生命周期清空/重置,避免沿用上一位患者的名字。
|
||||
|
||||
## 实施与验证
|
||||
|
||||
已在“通知医助”按钮上方增加独立的“当前医助:姓名”文本,读取已核实的聚合详情字段。无医助 ID 时显示未分配;有 ID 但无姓名时显示姓名暂缺;加载与失败状态会清空上一位患者姓名。未修改 API、通知收件人或服务端代码。
|
||||
|
||||
离屏验证 816、1080、1340 三种页面宽度,文字与通知按钮不重叠且不产生横向滚动;检查了姓名、未分配、姓名暂缺、未知、患者切换和详情失败。截图:[头部预览](reception-current-assistant-header.png),其中姓名来自本地示例数据。
|
||||
|
||||
现有检查:蓝色布局 15 项通过;单独运行接诊与刷新模块 77 passed in 11.92s;结束问诊按钮 3 项通过;Ruff 和 `git diff --check` 通过。
|
||||
|
||||
组合运行全局主题布局模块与接诊模块时,原有 AI 放大按钮尺寸断言出现 28 × 36 对 28 × 28 的差异。恢复 Git HEAD 接诊模块后,在同一主题条件下仍复现该断言失败;接诊模块单独运行全部通过。该问题未在本次姓名显示修改中扩展处理。
|
||||
|
Before Width: | Height: | Size: 17 KiB |
@@ -1,62 +0,0 @@
|
||||
# 复诊接诊率:指派 / 继承写入与兼容性研究
|
||||
|
||||
2026-10-16。只读检查产品源码;未读取 `.env` 或凭据;未连接数据库。项目存在 `AGENTS.md`,但 `.trellis/workflow.md` 不存在,无法读取 Trellis 流程与分层规范。
|
||||
|
||||
## 关键结论
|
||||
|
||||
- 现有报表是“当月指派分母 + 当月成交分子”,不是“指派日期区间内患者的后续接诊队列”。仅把 `month` 换成起止日期会继续产生区间外患者进入分子的错误。
|
||||
- 新指派、继承已有可用事件字段,无需为重新起算修改患者全局诊次。应在统计层根据入组指派事件重新计算后续订单;`RevisitVisitSlotService` 同时服务诊单节点、到期快照,改变它会影响其他业务。
|
||||
- 自动指派和个人业绩都直接依赖当前统计实现。页面改队列口径时要明确兼容入口;尤其不要悄悄改变自动指派档位。
|
||||
|
||||
## 写入侧与数据字段
|
||||
|
||||
| 入口 | 现有行为 | 注意点 |
|
||||
| --- | --- | --- |
|
||||
| `DiagnosisLogic::assign`(约 673 行) | 诊单行 `FOR UPDATE`;更新 `assistant_id`、`assign_read_at`;同事务插入指派日志。`is_inherit=1` 仅在目标医助大于 0 时成立。 | `from==to` 允许写入;当前 slot 统计忽略该行,旧 exclude 仍计入新指派。新队列避免把自指派当成新承接事件。 |
|
||||
| 同上,继承 | 同事务用 `slotAtTime($id,$now)` 写 `inherit_visit_slot` 和 `inherit_visit_slot_calc_time`。 | 快照只用于展示 / 对账。现有统计通过有效订单实时重算 k,所以事后退款、取消、offset 修改会联动。不能把快照当唯一订单数依据;历史回退没有快照。 |
|
||||
| `AutoAssignPendingDiagnosis::applyAssignment`(约 567 行) | `is_inherit=0`、操作人 0、名称“系统自动分配”;同事务改医助并写日志;`from` 可推断为一中心原医助。 | 自动指派属于新指派,不能以 `from>0` 判定继承。 |
|
||||
| `AutoAssignLogLogic::rollbackOne`(约 72 行) | 锁自动指派日志和诊单;仅当前医助仍为自动分配目标时允许回退;回到前任 / 0。写指派日志 `is_inherit=0`,并写自动分配日志 rollback 字段。 | 保留写入规则;新口径需把历史回退识别为继承,目标 0 的释放行不入组。 |
|
||||
| `ExpressTrackingService`(约 702 行) | 发货释放写 `to_assistant_id=0`,未显式写 `is_inherit`(列默认 0)。 | 释放不是新指派,但会终止原医助持有时间段。 |
|
||||
|
||||
主要表 / 模型:
|
||||
|
||||
- `DiagnosisAssignLog` 只设置表名 `tcm_diagnosis_assign_log`,关闭自动时间戳,无字段白名单。指派核心数据:`id, diagnosis_id, from_assistant_id, to_assistant_id, operator_admin_id, create_time, is_inherit, inherit_visit_slot, inherit_visit_slot_calc_time`;还含最新订单创建人 / 时间快照与操作人信息。
|
||||
- `tcm_diagnosis`:`assistant_id` 是当前归属,不应代替历史持有时间线;`revisit_slot_start_offset` 为全局诊次偏移(钳制 0~20)。
|
||||
- `tcm_prescription_order`:有效业务单剔除软删、履约状态 4/9/10;统一调用 `YejiStatsLogic::applyPrescriptionOrderNotCancelledForPerformanceQuery`;序列稳定排序 `(diagnosis_id, create_time, id)`。
|
||||
- 迁移:`server/sql/1.9.20260601/add_diagnosis_assign_log_is_inherit.sql`;`server/sql/1.9.20261012/06_add_diagnosis_assign_log_inherit_visit_slot.sql`。继承标记引入前的数据无法可靠恢复继承类型,不应按 `from>0` 猜测。
|
||||
- 现有订单复合索引迁移:`server/sql/1.9.20261012/01_add_prescription_order_diag_ct_index.sql`,`(diagnosis_id, delete_time, fulfillment_status, create_time)`。源码内未找到指派日志建表 DDL,不能据此断言线上索引情况。
|
||||
|
||||
## rollback 与重新起算
|
||||
|
||||
- `RevisitRateLogic::fetchRollbackKeySet` 使用 `diagnosis_id | rollback_time | rollback_admin_id` 关联到指派日志 `diagnosis_id | create_time | operator_admin_id`;同一事务同一 `$now` 保证当前写入可匹配。需要继承独立页时复用这一兼容规则,不能只筛 `is_inherit=1`。
|
||||
- 现有 `RevisitRateCalculator::compute` 在 slot 模式将命中回退的日志当继承;exclude 模式完全保留其 `is_inherit=0` 的旧语义。回退无继承快照,订单实时计算仍可用。
|
||||
- 新继承“重新起算”应按继承事件时间切开序列,继承前历史单不进入继承后的接诊次数。保留同一秒约定:日志时间 `t <= order.create_time` 时归新医助,继承前完成单计数用严格 `< t`。
|
||||
- 转派 / 释放 / 再次继承会形成多个持有段;要先明确入组去重,再把订单归属到有效段,避免同一订单被多个队列事件重复领取。当前实现按同一医助 × 诊单去重,继承取区间内最早,且同区间新指派优先覆盖继承;这一合并规则不适合直接复用到“新指派和继承分别统计”。
|
||||
- 当前拒收 / 退款处理是“该诊单存在任意时间状态 9/10 有效订单,则整诊单从分母剔除”,分子只是剔除对应无效单。若沿用需在两个独立页面和明细保持一致;不要无意中改变这一业务规则。
|
||||
|
||||
## 当前统计调用方
|
||||
|
||||
1. `AutoAssignPendingDiagnosis::execute` 调 `RevisitRateLogic::overview(['month'=>上个自然月,'inherit_mode'=>'slot'])`,再从部门 children 中取医助率。配置实际值为 `project.revisit_rate.auto_assign_tier_field='visit2_rate_new_assign'`,页面默认实际是 `default_inherit_mode='exclude'`(部分注释仍写默认 slot)。
|
||||
- 分档字段白名单仅 `visit2_rate_new_assign` / `visit2_rate`,对应 `rate_version=v1/v2`。无分母必须继续返回 `null`,否则可能改变谁参与自动指派。
|
||||
- 自动日志同时记录两套率。历史 v1 行无 `visit2_rate_new_assign` 时,前端回落 `visit2_rate`。不要重解释历史日志字段;如变更分档口径要单独版本化。
|
||||
2. `AssistantPerformanceController::revisitRate` 只收 `month`,Logic 从配置取 mode,再调 `RevisitRateLogic::assistantOverview`,只返回本人行。
|
||||
- 本人统计有 600 秒缓存,key 为 `month + assistantId + mode`。改统计口径需更新缓存 key 版本;加日期范围 / 队列类型需纳入 key。
|
||||
- 前端个人业绩卡片仍按月,响应类型依赖 `month, inherit_mode, slots, in_scope, row`。保持 month 兼容可避免页面破坏,但卡片文案和规则应同步说明指派队列口径。
|
||||
3. `RevisitRateLogic::overview` 目前不处理 `assistant_id`;只有两个下钻方法调用 `applyRowScope`。新增医助筛选需同时约束 overview 的医助行、部门汇总、总计以及下钻集合。部门归属取当前 `admin_dept` 中最小有效 dept_id,默认限二中心子树。
|
||||
4. `YejiStatsLogic` 的“被指派数”仍独立按 `is_inherit=0`、区间指派、医助×诊单去重;避免此次顺带修改整个业绩看板。
|
||||
|
||||
新独立页还需菜单 / 权限迁移与 MCP review 文案。`AuthMiddleware` 对未登记接口默认放行,因此新增接口须登记权限。现有 `deptOptions` 还被复诊到期页复用,不应改成仅某一队列的选项。
|
||||
|
||||
## 测试运行方式与已验证基线
|
||||
|
||||
无根级 PHPUnit 配置,`server/composer.json` 无 test script / PHPUnit;相关测试是独立 PHP CLI 脚本,仅加载 `server/vendor/autoload.php`,不启动应用、不连库。当前 PATH PHP:`D:/phpstudy_pro/Extensions/php/php8.2.9nts/php.exe`。
|
||||
|
||||
已从项目根目录运行并通过:
|
||||
|
||||
```powershell
|
||||
php server/tests/RevisitRateContractTest.php # 45 checks
|
||||
php server/tests/RevisitRateInheritSlotTest.php # 57 checks
|
||||
php server/tests/RevisitVisitSlotServiceTest.php # 20 checks
|
||||
```
|
||||
|
||||
`RevisitRateContractTest` 是源码字符串 / Reflection 契约,明确锁住“分子按当月下单”“继承从 k+1 列入分母”等旧实现,新队列实现需要合理更新这些断言,不能仅为通过保留过时语义。建议纯函数覆盖:区间指派后跨月成交、区间外患者同月成交不计入、自指派、转派终止归属、多次继承与重置、回退继承、同秒排序、医助筛选汇总和两类明细对账;保留全局诊次服务现有测试。
|
||||
@@ -1,54 +0,0 @@
|
||||
# 复诊指派队列后端独立审阅
|
||||
|
||||
2026-10-16。范围:`RevisitCohortCalculator` / `RevisitCohortLogic`、两个 Controller、菜单 SQL、两项新增测试。未修改产品代码、未读凭据、未连接业务数据库。
|
||||
|
||||
主代理处理结果:下述释放返还问题已修复,计算器以实际时间线判断持有人是否变化;新增两项纯计算及两项 MySQL 集成回归通过。性能观察也已处理:先按周期(及选中医助)查询候选诊单,再分块读取对应完整时间线。
|
||||
|
||||
## 需要修复:1 项
|
||||
|
||||
### [P2] 释放后重新指派给原医助被误当作自指派,患者和后续订单全部漏计
|
||||
|
||||
- 位置:`server/app/adminapi/logic/stats/RevisitCohortCalculator.php:39`,`$from === $aid` 直接跳过整条事件,既不入组也不更新持有时间线。
|
||||
- 写入侧证据:`DiagnosisLogic::assign` 第 714~715 行在诊单当前 `assistant_id=0` 时调用 `inferPreviousAssistantIdFromLatestAssignLog`;该方法在最近日志为 `A→0` 时回落其 `from_assistant_id=A`。因此用户实际执行的“未分配→A”会写成 `from=A,to=A`,这与真实自指派有相同字段,但不是无操作。
|
||||
- 最小复现(同一诊单,日志 / 订单按要求排序):
|
||||
|
||||
| 时间戳 | 事件 |
|
||||
| --- | --- |
|
||||
| 10 | 指派 `0→7`,新指派 |
|
||||
| 11 | 第 1 笔有效订单 |
|
||||
| 20 | 释放 `7→0` |
|
||||
| 30 | 从未分配重新指派给 7;现有写入逻辑记录 `7→7`,`is_inherit=0` |
|
||||
| 31 | 第 2 笔有效订单 |
|
||||
|
||||
调用 `compute($logs,$orders,[],[],[],25,35,100,'new')` 后,实测 `assigned_count=0, visit2_count=0`,应各为 1。若重新承接时勾选继承,日志为 `7→7,is_inherit=1`,同样漏掉本次继承患者和其首单二诊。
|
||||
|
||||
这还会影响区间更早的原医助队列:重新承接事件被跳过后,时间线一直停在释放状态,后续成交不再归属任何有效持有段。
|
||||
|
||||
建议根据同一诊单此前真实时间线的最后目标判断是否“持有人没有变化”,不能仅比较日志的 `from/to`;至少识别前一有效事件为 `to=0` 的重新承接。保留真实 `A→A` 的不入组、不重置行为。增加“释放后返还同一医助”的新指派及继承两个纯函数 / 数据库用例,区别于已有“转到其他人后再次继承”测试。
|
||||
|
||||
## 其余检查
|
||||
|
||||
- 已确认日期筛选约束入组事件,成交累计到 `time()`;区间外患者不会进入分子,后续转派 / 释放终止该持有段。
|
||||
- 继承后订单以段内序号 `+1` 从二诊起算,忽略全局诊次 / offset;新指派保留全局诊次。真实自指派当前测试覆盖且不重置。
|
||||
- 历史 rollback 关联键参与继承识别,`is_inherit=0` 回退不会混入新指派。被拒收 / 退款的诊单同时从队列分母、分子剔除,无孤立分子。
|
||||
- 医助筛选先作用于整个 context,再构造部门 / 总计及明细;部门仍限制二中心。部门 / 总计按患者、诊次去重,明细与指标一致。
|
||||
- 继承 Controller 用 `static::COHORT_TYPE` 固定类型,不能通过请求参数切换到另一类;继承 Controller 可正常 autoload。
|
||||
- 菜单 SQL 按对应权限逐项授予原角色,未扩大只有患者明细角色的订单明细权限;页面与三个接口都登记。部署仍须执行 SQL 并清理 `AdminAuthCache`,否则系统现有“未登记接口默认放行”规则会作用于新接口。
|
||||
- 旧 `RevisitRateLogic` 仅将共用读取方法改为 protected,自动指派 / 个人业绩仍调用旧口径,未发现行为改动。
|
||||
|
||||
## 性能观察(未作为阻塞问题)
|
||||
|
||||
`RevisitCohortLogic::cohortContext` 第 188~198 行先把所有未删诊单截至当前的全部历史指派日志读入 PHP,再计算候选诊单并过滤;即使只查一天、单个医助甚至空队列,首个查询扫描 / 返回的数据量仍与全量历史指派数相关。随后订单扫描已收窄到候选诊单。建议后续先在 SQL 查区间内候选诊单,再按候选诊单拉全量持有时间线,保持释放 / 转派语义;本次没有业务库规模和执行计划,无法证明已有请求达到超时 / 内存上限,因此不将其列为已复现缺陷。
|
||||
|
||||
## 验证
|
||||
|
||||
独立运行通过:
|
||||
|
||||
```text
|
||||
RevisitCohortCalculatorTest OK (32 checks)
|
||||
RevisitRateContractTest OK (45 checks)
|
||||
RevisitRateInheritSlotTest OK (57 checks)
|
||||
RevisitVisitSlotServiceTest OK (20 checks)
|
||||
```
|
||||
|
||||
四个新增 / 修改核心 PHP 文件 `php -l` 通过。额外通过 PHP 标准输入执行上述最小复现,确认实际输出为全零。主代理报告的隔离 MySQL 5.7 数据库测试 20 checks 已阅读源码,本次未重跑其临时数据库。数据库测试目前没有“释放后返还原医助”场景。
|
||||
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"passed": 15,
|
||||
"checks": [
|
||||
"新指派页可见且独立命名",
|
||||
"默认新指派汇总加载",
|
||||
"日期范围允许未来结束日",
|
||||
"医助筛选传入汇总请求",
|
||||
"患者明细沿用日期和医助筛选",
|
||||
"切换部门清空范围外医助",
|
||||
"医助选项按部门限制",
|
||||
"重置清空部门医助",
|
||||
"独立继承页加载",
|
||||
"继承页说明首单二诊",
|
||||
"继承页人数列不混合新指派",
|
||||
"继承订单使用独立接口",
|
||||
"继承后诊次明细可见",
|
||||
"底栏展示复诊合计率",
|
||||
"浏览器无运行时异常"
|
||||
],
|
||||
"errors": []
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
> 2026-10-17 更正:以下为10月16日实施记录。原页面及原 API 已恢复,指派周期新报表迁至 revisit_assign_rate / stats.revisitAssignRate,继承报表仍独立。最新验证见 revisit-legacy-source-proof.json、revisit-legacy-restored-compile.json、revisit-legacy-restored-browser-check.json 及部署计划。
|
||||
|
||||
# 复诊统计前端交付与验证
|
||||
|
||||
日期:2026-10-16
|
||||
|
||||
## 实现
|
||||
|
||||
- `admin/src/views/stats/revisit_rate/index.vue`:原入口保留,专门展示新指派患者。
|
||||
- `admin/src/views/stats/revisit_inherit_rate/index.vue`:独立继承复诊入口。
|
||||
- `admin/src/views/stats/revisit_rate/components/CohortReport.vue`:共享 Element Plus 报表,按 `cohortType` 分离接口、权限、患者人数列及说明。不再保留旧/新继承混算切换。
|
||||
- `admin/src/api/stats.ts`:以第二参数选择 `/stats.revisitRate` 或 `/stats.revisitInheritRate`;保留个人业绩依赖的 `RevisitRateInheritMode`。
|
||||
|
||||
日期筛选默认当月 1 日至当天,支持任意范围及未来结束日期(如 10 月 30 日);日期范围选患者,订单累计至查询当天。继承页明确写明:继承后第一笔有效订单算二诊,第二笔算三诊,不使用继承前全局诊次或偏移。
|
||||
|
||||
保留部门树、医助/部门层级明细、服务端去重合计、患者只读详情、订单只读抽屉;新增可搜索和清空的医助筛选,按选中部门及其下级过滤,切换部门会清空范围外医助。展示服务端 `observed_until`。
|
||||
|
||||
下钻复用已展示报表的查询快照,顶层 `assistant_id` 与医助行 `scope_assistant_id` 独立传递。汇总和明细分别用请求序号丢弃旧响应;筛选重新查询会关闭并清空明细。
|
||||
|
||||
## 验证
|
||||
|
||||
1. 使用项目现有 `@vue/compiler-sfc` 编译两页入口和共享组件:脚本、模板、SCSS 均通过。
|
||||
2. 使用真实 Vue `ref/computed`、编译后的 SFC setup 及受控异步 API 验证 26 项行为,全部通过:
|
||||
- 默认日期、未来结束日期保留、部门子树医助过滤、超出范围自动清空。
|
||||
- 查询快照、保留顶层医助、独立行级医助/部门范围、返回 count、动态诊次排序去重。
|
||||
- 汇总/明细乱序响应、筛选后迟到明细的丢弃、重新查询清空明细。
|
||||
- 继承文案、单类人数列、独立继承权限、两套接口各四个 action 路径。
|
||||
3. `node node_modules/vue-tsc/bin/vue-tsc.js --noEmit --pretty false`:全项目有 61 条既有类型诊断;本次 API、两页入口及共享组件的诊断为 **0**。存量错误包括聊天组件、编辑器声明、录像 API 及既有处方表单类型等。
|
||||
4. 前端负责文件统一 LF;`git diff --check` 通过。
|
||||
|
||||
未执行含 release 的 `npm run build`,未发布。浏览器与真实后端联调由主代理继续完成。
|
||||
|
Before Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 60 KiB |
@@ -1,72 +0,0 @@
|
||||
# 原复诊接诊率入口恢复只读审查
|
||||
|
||||
日期:2026-10-17。基线:`HEAD=c67b3eff69d9a3ac2046861751f159692a0abcd7`。仅审查本地源码、Git 和既有验证记录;未读 `.env` / 凭据,未连接业务库,未发布,未修改产品文件。`.trellis/workflow.md` 在当前工作区不存在。本报告是唯一写入文件。
|
||||
|
||||
## 结论
|
||||
|
||||
最小可靠恢复是将原 Controller、原 `/stats/revisit_rate/index.vue` 及原 API 函数恢复为 HEAD 的完整契约,保留原 `RevisitRateLogic`、`RevisitRateCalculator`、`RevisitVisitSlotService`、配置、自动分配及个人业绩调用链。新队列功能另外使用 `stats.revisitAssignRate/*` 和 `/stats/revisit_assign_rate`;继承继续使用 `stats.revisitInheritRate/*` 和 `/stats/revisit_inherit_rate`。
|
||||
|
||||
仅保留旧计算器还不能保持原功能:上一轮将 `stats.revisitRate/*` Controller 改为队列逻辑,现有页面和外部调用者即使 URL 不变也会获得不同的患者集合、时间范围、分子与分母。因此恢复必须覆盖入口及 API 契约。
|
||||
|
||||
审查开始时,原 `RevisitRateLogic` 的差异仅为 7 个 `private` 方法改为 `protected`,计算器与诊次服务无 Git 差异。审查过程中主代理已将旧 Controller、旧页面、旧 MCP review 项恢复,旧 Logic 也恢复为 HEAD,并将队列数据源抽为独立 trait。该并行状态只是观察结果,最终应以主代理收尾 diff 为准。
|
||||
|
||||
## HEAD 原入口与依赖
|
||||
|
||||
| 层 | 原契约 |
|
||||
| --- | --- |
|
||||
| 菜单页面 | 名称「复诊接诊率」,`paths=/stats/revisit_rate`,`component=/stats/revisit_rate/index` |
|
||||
| 菜单/API 权限 | `stats.revisitRate/overview`、`stats.revisitRate/deptOptions`、`stats.revisitRate/assignLines`、`stats.revisitRate/visitOrderLines` |
|
||||
| Controller | 四个 action 直接调用 `RevisitRateLogic` 同名方法;overview / 两个明细保留 120 秒执行时间 |
|
||||
| 查询参数 | overview:`month, dept_ids, inherit_mode`;患者明细另带 `assistant_id, dept_id, kind=new|inherit|all`;订单明细另带 `slot=0|2..50` |
|
||||
| 前端 API | `revisitRateOverview`、`revisitRateDeptOptions`、`revisitRateAssignLines`、`revisitRateVisitOrderLines`;保留 `RevisitRateInheritMode` 和 `RevisitRateAssignKind` 导出 |
|
||||
| 默认口径 | `server/config/project.php` 的 `default_inherit_mode='exclude'`;页面首次请求不传 inherit_mode,接收服务端返回口径;用户可手动选择 slot |
|
||||
| 原页面筛选 | 月份、部门、exclude / slot 口径;重置回默认口径;包含新指派 / 继承 / 合计及订单下钻 |
|
||||
| 下钻权限 | 患者数:`stats.revisitRate/assignLines`;订单数:`stats.revisitRate/visitOrderLines`;患者详情:`tcm.diagnosis/detail`;订单详情:`tcm.prescriptionOrder/detail` |
|
||||
| 原个人业绩 | `AssistantPerformanceLogic::revisitRate` → `RevisitRateLogic::assistantOverview`;本人结果按月 / 医助 / 口径缓存 600 秒 |
|
||||
| 原自动分配 | `AutoAssignPendingDiagnosis` 使用旧 `RevisitRateLogic`;取 slot 模式 overview,但分档字段配置仍为 `visit2_rate_new_assign` |
|
||||
| 原 MCP 项 | `server/app/mcp/catalog/review/stats.php` 中原四个 `stats.revisitRate/*` 说明和参数白名单必须继续描述按月统计 |
|
||||
|
||||
原菜单来源为 `server/sql/1.9.20260610/add_revisit_rate_menu.sql`。恢复旧入口无需改原菜单记录、原权限或执行旧菜单脚本。Vue 路由由 `admin/src/router/index.ts` 的 `import.meta.glob('/src/views/**/*.vue')` 按数据库 component 解析,新增独立入口只需相应组件文件和菜单 component 正确对应。
|
||||
|
||||
## 需保持的算法含义
|
||||
|
||||
- 分母取所选月的指派事件;原 exclude 模式仅按 `is_inherit=0` 构造医助×诊单集合,保留自指派及历史回退的原处理方式。原 slot 模式另外计算继承起算诊次与每列分母。
|
||||
- 分子取所选月的有效订单,诊次为跨月累计的全局有效实单序号加原诊单偏移;订单按当时持有人归属。往月指派、当月成交仍可进入分子,率可超过 100%。
|
||||
- 有拒收 / 退款业务订单的诊单整体剔除;有效订单过滤仍委托原业绩口径;二中心组织范围、当前人事部门归类和去重规则保持 HEAD。
|
||||
- 队列统计把“入组指派周期”和“累计观察时刻”分开,继承重新起算诊次。这些是另一张统计表的含义,不能复用原四个 API 名称替换原算法。
|
||||
|
||||
## 新入口权限与发布边界
|
||||
|
||||
`AuthMiddleware::handle` 当前对未登记到菜单权限集合的普通 API 默认放行;只增加 PHP Controller 而漏掉菜单权限登记,会使新增数据接口对登录用户过宽。新增 `stats.revisitAssignRate` 的 overview 和 3 个子接口都应登记;继承入口同理。角色授权应逐 action 从原对应权限复制,不能因拥有 overview 就自动授予患者或订单明细。执行菜单迁移后需按现有流程刷新 `AdminAuthCache`。
|
||||
|
||||
原菜单无需改名或搬迁。新指派周期菜单应新增独立 path / component,避免覆盖旧 `stats.revisitRate/overview` 的记录。若已有继承菜单脚本会引用旧页作为父目录与授权来源,可保留这种引用;新增指派周期脚本也可按同样逐权限方式授权。
|
||||
|
||||
## 本地证据能确认的发布状态
|
||||
|
||||
- `git diff -- server/public/admin`、该目录的 `git status --short` 均为空:当前仓库的公开管理端产物没有相对 HEAD 被替换。
|
||||
- 公开产物 `server/public/admin/assets/stats-Bz24MGMf.js` 仍包含原四个 `stats.revisitRate/*` 路径;在公开 JS 中未找到 `stats.revisitInheritRate`、`scope_assistant_id` 或新队列标题。不能据此推断远端服务器文件状态。
|
||||
- 既有验证计划记录生产构建输出为 `.codex-tasks/revisit-cohort-build`,该隔离目录确实存在;记录明确写了未运行 release、业务库迁移未执行。它是先前代理的本地记录,不是生产部署审计。
|
||||
- `admin/package.json` 的 `npm run build` 实际执行 `vite build && node scripts/release.mjs`;release 会移除并重拷贝 `../server/public/admin`。只做验证构建时应继续使用单独输出目录,并避免运行 release。
|
||||
- 源码改动在当前工作区尚未提交。Git 未提交、公开目录未变和临时构建存在,可以支持“本地未完成正式 release”的判断,不能单独证明生产环境从未部署。此次未查询生产、不读凭据、不连业务库,故不能声称已经确认线上恢复或线上未受影响。
|
||||
|
||||
## 已执行验证
|
||||
|
||||
以下测试只加载 autoload / 纯函数或反射源码,不初始化应用数据库,全部通过:
|
||||
|
||||
| 命令 | 结果 |
|
||||
| --- | --- |
|
||||
| `php server/tests/RevisitRateContractTest.php` | 45 checks |
|
||||
| `php server/tests/RevisitRateInheritSlotTest.php` | 57 checks |
|
||||
| `php server/tests/RevisitVisitSlotServiceTest.php` | 20 checks |
|
||||
|
||||
合计 122 项旧链路检查。本次未执行数据库测试。
|
||||
|
||||
## 主代理收尾需要覆盖的检查
|
||||
|
||||
1. 旧 Controller / 旧页面与 HEAD 比对无差异;原 Logic / Calculator / 诊次服务 / 配置 / 自动分配 / 个人业绩无差异。API 文件可以新增独立函数,但原函数签名、URL、参数类型及旧类型导出应保持 HEAD。
|
||||
2. 原四个 Controller action 明确调用 `RevisitRateLogic`;新增四个指派周期 action 调用 `RevisitCohortLogic` 的 new 类型;继承 action 固定 inherit 类型。禁止通过请求参数切换到另一报表类型。
|
||||
3. 重跑上述 122 项旧检查;新增队列纯函数检查继续通过。隔离数据库验证若运行,只能显式指定独立本机临时库,并扩展到新指派菜单的 4 项登记、幂等及逐权限复制。
|
||||
4. 三页及共享组件编译成功;旧页仍调用旧 API,新指派页调用 `stats.revisitAssignRate/*`,继承页调用 `stats.revisitInheritRate/*`。新共享组件迁移后不得留下从原页面目录导入队列实现的误导路径或错误权限前缀。
|
||||
5. 原页月份 / 默认 exclude / 手动 slot / 患者与订单下钻至少验证一次;新两页的日期、医助筛选及 scope 筛选保持原队列验证。已有 `artifacts/revisit-cohort-browser-check.json` 使用虚构接口响应,只证明旧一轮 UI 行为,不能证明此次入口分离或真实业务数据一致。
|
||||
6. 同步计划和前端说明中“原入口只统计新指派”“新指派使用 stats.revisitRate”的陈旧描述;原 MCP review 恢复按月参数,新两个 API 项按实际路由声明。运行现有 MCP 目录测试,确认没有旧路径重新被队列说明覆盖。
|
||||
7. `git diff --check`;构建输出到隔离目录;收尾报告明确本地恢复完成、验证范围及未发布状态,不以虚构数据浏览器测试声称生产数据已核对。
|
||||
@@ -1,26 +0,0 @@
|
||||
{
|
||||
"passed": 20,
|
||||
"checks": [
|
||||
"原页面仍使用原按月接口和参数",
|
||||
"原月份筛选恢复",
|
||||
"原口径选择恢复",
|
||||
"原页面仍能切换原有slot口径",
|
||||
"原患者明细保留month/kind/口径契约",
|
||||
"新指派页可见且独立命名",
|
||||
"默认新指派汇总加载",
|
||||
"日期范围允许未来结束日",
|
||||
"新增医助筛选使用独立指派周期接口",
|
||||
"患者明细沿用日期和医助筛选",
|
||||
"切换部门清空范围外医助",
|
||||
"医助选项按部门限制",
|
||||
"重置清空部门医助",
|
||||
"独立继承页加载",
|
||||
"继承页说明首单二诊",
|
||||
"继承页人数列不混合新指派",
|
||||
"继承订单使用独立接口",
|
||||
"继承后诊次明细可见",
|
||||
"底栏展示复诊合计率",
|
||||
"浏览器无运行时异常"
|
||||
],
|
||||
"errors": []
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"sfcFiles": 4,
|
||||
"apiCalls": 12,
|
||||
"errors": []
|
||||
}
|
||||
|
Before Width: | Height: | Size: 77 KiB |
@@ -1,56 +0,0 @@
|
||||
{
|
||||
"baseline": "c67b3eff69d9a3ac2046861751f159692a0abcd7",
|
||||
"files": [
|
||||
{
|
||||
"file": "admin/src/views/stats/revisit_rate/index.vue",
|
||||
"identical": true,
|
||||
"sha256": "7bbd3e94e974e49367379ff69325ca93a7f903eb01020e4c7dfafd415a57a4df"
|
||||
},
|
||||
{
|
||||
"file": "server/app/adminapi/controller/stats/RevisitRateController.php",
|
||||
"identical": true,
|
||||
"sha256": "272f437a220f393f6b0c9c4833ccadaf30de1915777c25da906b6e9eb0c662e4"
|
||||
},
|
||||
{
|
||||
"file": "server/app/adminapi/logic/stats/RevisitRateLogic.php",
|
||||
"identical": true,
|
||||
"sha256": "86bac0184a8fda25e1b295527687568e5950b1c66a1a8f5a901617cc8624c3a1"
|
||||
},
|
||||
{
|
||||
"file": "server/app/adminapi/logic/stats/RevisitRateCalculator.php",
|
||||
"identical": true,
|
||||
"sha256": "c03d1440783695f6e094f16417697821fe8e793094f3070bca8461d5f2c9ddc6"
|
||||
},
|
||||
{
|
||||
"file": "server/app/common/service/tcm/RevisitVisitSlotService.php",
|
||||
"identical": true,
|
||||
"sha256": "2991166354b34964603557ab057ec1eb63e8c7cd09d42e6f525b9f89af93bb85"
|
||||
},
|
||||
{
|
||||
"file": "server/config/project.php",
|
||||
"identical": true,
|
||||
"sha256": "b2fb9c7f82ea57dfbe141a72c1f56f546db8206fc361b1b203470830cddd67a8"
|
||||
},
|
||||
{
|
||||
"file": "server/app/command/AutoAssignPendingDiagnosis.php",
|
||||
"identical": true,
|
||||
"sha256": "82366bc39f7d247dbdccdbe1b37c39f21cbeffb0fd46166455da81d97966ec2c"
|
||||
},
|
||||
{
|
||||
"file": "server/app/adminapi/logic/stats/AssistantPerformanceLogic.php",
|
||||
"identical": true,
|
||||
"sha256": "e81482157e8708d00da66f421416b1ba18de4dcec59443d22c9fb0ff28e21a72"
|
||||
},
|
||||
{
|
||||
"file": "server/app/adminapi/controller/stats/AssistantPerformanceController.php",
|
||||
"identical": true,
|
||||
"sha256": "a30f1e6cd2ab38c51fc7b6642d840278f465dca1c2b7542e8dfa912c15900ada"
|
||||
},
|
||||
{
|
||||
"file": "server/app/mcp/catalog/review/stats.php",
|
||||
"identical": true,
|
||||
"sha256": "cf2f97cb3e5d41c829f0d5c509e6ceba980297af69dfa061968a3a0bb55d5cc1"
|
||||
}
|
||||
],
|
||||
"original_api_bytes_preserved": true
|
||||
}
|
||||
|
Before Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 134 KiB |
|
Before Width: | Height: | Size: 122 KiB |
@@ -1,87 +0,0 @@
|
||||
# 桌面问诊列表:视频固定列布局审查
|
||||
|
||||
核对日期:2026-10-17。仅审查本地源码并运行离屏几何探针,未连接生产服务或使用 UI 账号;未修改业务代码和现有测试。已有 dirty 文件均保留。
|
||||
|
||||
## 规范与范围
|
||||
|
||||
- 已读取仓库根目录 `AGENTS.md`;本地 `.trellis/` 不存在,无法读取其 workflow/spec。`app/` 下未发现额外 `AGENTS.md`。
|
||||
- 对照 `app/research/diagnosis_index_visual_spec.md`:固定双表共享行高及垂直滚动,文本换行受实际列宽、字体和 DPR 影响。该文件记录的是早期后台复刻参考,当前桌面问诊列表的尺寸以实现和测试为准。
|
||||
- 任务提及的 `_call_cell` 在当前仓库不存在。实际入口为 `DiagnosisTableHost._video_widget()`,位于 `app/src/doctor_workstation/ui/diagnosis_index_widgets.py:1786`(以下行号为审查时快照;并行修复可能改变行号)。
|
||||
|
||||
## 根因
|
||||
|
||||
这是单元格内部可用宽度和标签高度协商不一致,不能单纯归因为行高不足或固定表滚动错位。
|
||||
|
||||
1. 问诊列表采用 tech-blue 固定列宽 `92 + 158 = 250px`,视频列声明宽度为 92px;见 `_BLUE_COLUMN_WIDTHS` 和 `DiagnosisTableHost.__init__()`。
|
||||
2. `app/src/doctor_workstation/ui/theme.py:701-703` 的全局 `QTableWidget::item, QTableView::item { padding: 6px 8px; ... }` 仍作用于固定表。Qt 为 index widget 分配的是 item 内容区,所以声明 92px 的视频列实际只给 `_FixedCell` 76px 宽。
|
||||
3. `_video_widget()` 内又有左右各 3px、上下各 2px 的布局边距,标签实际宽度进一步降至 70px。
|
||||
4. 视频布局整体设置 `QHBoxLayout.setAlignment(AlignCenter)`,状态标签同时 `setWordWrap(True)`。布局把标签约束到其 `sizeHint()` 高度,但受限宽度下实际 `heightForWidth()` 更高,产生标签内部上下裁切;`QLabel.AlignCenter` 使文字在不足的高度中居中,更容易出现首尾被切掉的观感。
|
||||
5. `_sync_row_heights()` 只根据前十列/挂号内容计算行高,不考虑视频列。它是未来异常长文案的潜在缺口,但在本次现有服务端文案中,即使整行已有足够空间,标签也仍会被裁切。单纯增高整行无法修复标签高度仍被限制的问题。
|
||||
|
||||
### 本地实测
|
||||
|
||||
环境:Windows、PySide6 6.11.1、`QT_QPA_PLATFORM=offscreen`,调用真实 `apply_theme()`,tech-blue `DiagnosisTableHost`,Microsoft YaHei UI 13px。使用已预约视频诊单、只读视频状态、挂号含最近渠道,使行高为 82px。
|
||||
|
||||
| 状态文案 | 表格 visualRect | index widget | 标签尺寸 | 标签实际所需高度 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `视频已结束 · 10-17 13:30` | 92 × 82 | 76 × 69,偏移 (8,6) | 70 × 33 | 50 |
|
||||
| `上次通话已取消` | 92 × 82 | 76 × 69 | 70 × 16 | 33 |
|
||||
| `上次通话未接听` | 92 × 82 | 76 × 69 | 70 × 16 | 33 |
|
||||
|
||||
因此 82px 行内仍有空间,但结束状态缺 17px 标签高度。未应用全局主题的独立 host 不会受到这层 item padding,结束状态可能恰好不裁切,容易给出错误的验证结论。
|
||||
|
||||
另一个无主题探针也确认:即便不受全局 padding 影响,整体 `AlignCenter` 仍能使长标签在宽度收缩后维持错误高度。例如含较长日期/标点的结束文案,标签 86 × 50,而 `heightForWidth(86)` 为 67;取消/未接听文案标签 86 × 16,实际需要 33。取消布局整体对齐后,82px 行中的标签可填满 86 × 78,已有文案能正常放下。
|
||||
|
||||
## 最小修复建议
|
||||
|
||||
1. 在固定表自己的局部 QSS 中清除 `::item` padding,让固定列的 index widget 使用真实列宽。范围应限定 `#DiagnosisFixedTable`,无需改变全局主题或临床数据主表的绘制规则。固定表两列均为 index widget,可同时避免操作按钮被额外压缩。
|
||||
2. 去掉视频 `QHBoxLayout` 的整体 `AlignCenter`,让只读 `QLabel` 填充布局可用宽高。继续保留标签自己的 `AlignCenter`、`wordWrap=True`;交互按钮使用单个布局项对齐来保持居中。
|
||||
3. 对服务端 `状态 · 时间` 文案,将这一明确分隔符变成展示换行,形成状态与时间层次;不要按任意字符截断,也不要丢弃时间。标签 tooltip 保存完整原文,便于查看完整时间或未来较长说明。
|
||||
4. 保留现有状态、角色/权限、点击流程、列宽和双表行高同步规则。无须为本次已知文案扩大固定区域、改全局字号或统一增高所有行。
|
||||
|
||||
### 是否需要动态行高
|
||||
|
||||
服务端 `server/app/adminapi/lists/tcm/DiagnosisLists.php:500-540` 的完整现有状态契约为:
|
||||
|
||||
| state | 服务端 label / 桌面公开展示 |
|
||||
| --- | --- |
|
||||
| none | 空 label / `—` |
|
||||
| live | `视频通话进行中` / 桌面简写 `通话中` |
|
||||
| pending_room | `通话发起中,待同步房间` / 桌面简写 `接通中`,可进入但等待的医助路径为 `等待接通` |
|
||||
| ended | `视频已结束 · m-d H:i`,无 end_time 时仅 `视频已结束` |
|
||||
| missed | `上次通话未接听` |
|
||||
| cancelled | `上次通话已取消` |
|
||||
|
||||
清除外层 item padding 后,92px 列减去内部 6px 边距,标签宽 86px;已知最长状态最多需要约 33px(两行),tech-blue 当前最小 72px 行可提供 68px 标签高度。因此当前契约无需增加动态行高。
|
||||
|
||||
截图叙述中的完整年份/秒并非当前服务端 `m-d H:i` 的固定格式;它可以作为兼容性测试数据,但不应据此修改后端时间格式。对于未来任意超长 label,如果产品要求完整正文始终可见,则需让状态展示与高度测量复用同一文本和实际宽度,并把其高度纳入双表 `max(...)` 行高;这属于额外防御,不是修复已知状态裁切的前提。
|
||||
|
||||
## 现有测试缺口
|
||||
|
||||
- `app/tests/test_diagnosis_index_visual.py:92-94` 的 QApplication fixture 没有调用 `apply_theme()`,无法复现全局 item padding。
|
||||
- `test_dedicated_model_fixed_columns_selection_and_sort` 验证 92/158px 和 250px 的声明尺寸,未验证 index widget 真正得到的矩形。
|
||||
- `test_frozen_rows_track_main_pixel_scroll_and_host_height_is_append_stable` 已覆盖双表像素滚动和行顶对齐,但不检查标签内部几何。
|
||||
- `test_required_reference_artifacts_exist` 只验证参考图片存在和尺寸,没有检查状态字形或标签是否裁切。
|
||||
- `app/tests/test_consultations_parity_ui.py` 的角色/权限回归只断言按钮、标签文字和信号。医生只读状态矩阵目前覆盖 live/pending_room/none,未覆盖 ended/missed/cancelled 及实际显示尺寸。
|
||||
- `app/scripts/render_diagnosis_visual.py:72` 的常规样本使用字符串 `video_call_hint`,并没有 `ended + label + end_time` 的真实复合数据;已有 `artifacts/doctor-video-column.png` 也只展示三个短状态。
|
||||
|
||||
## 应覆盖的几何场景
|
||||
|
||||
| 场景 | 必要断言 |
|
||||
| --- | --- |
|
||||
| 真实全局主题 + ConsultationsPage + 82px 行 | 视频列 index widget 的宽度等于对应 visualRect 的宽度;不能只断言 columnWidth=92 |
|
||||
| 最小 72px 行、不含最近渠道 | 状态标签处于单元格内部;`label.height() >= label.heightForWidth(label.width())`,并核对 contentsRect/文本所需高度 |
|
||||
| ended 当前完整 label、无时间 label | 状态与时间均可读;全文 tooltip 保留;不生成可点击视频入口 |
|
||||
| missed/cancelled 七字 label | 不因单行 sizeHint 只有 16px 而裁切两行正文 |
|
||||
| full-year/seconds、显式换行、未知较长 label | 不出现横向越界;已承诺完整显示的文本高度满足要求,其余由全文 tooltip 可访问 |
|
||||
| live/pending/none、未挂号、已完成、医助等待状态 | 原有状态及按钮规则保持;短文案仍居中 |
|
||||
| 合法医助 live 按钮、医生 text 按钮、操作列 | 按钮完整可见且居中,操作项不被新布局拉伸或挤出 |
|
||||
| 多预约/长挂号产生的较高行 | 标签居中不裁切;主表和固定表 rowHeight 仍相等 |
|
||||
| 1024×640、1280×800、1366×768、1920×1080 | 窗口改变不压缩固定列;横向滚动条显隐前后视频 index widget 与 visualRect 一致 |
|
||||
| 双表垂直滚动到中段/末尾 | 两表 viewport 和行顶保持同步;行本身被 viewport 正常截断应与标签内部错误裁切区分 |
|
||||
| 100% / 125% / 150% 缩放及实际加载字体 | 使用真实主题与字体测量,允许平台像素取整误差,不能依赖单一截图像素数 |
|
||||
| 静默相同刷新与状态变化刷新 | 同样数据继续保留既有 fixed widget 的零重建预算;变化后的标签按新文本重新布局 |
|
||||
|
||||
如果修改共享 `_video_widget()`,可再补 legacy 96px 视频列与 54px 最小行的轻量几何检查,避免布局更改引入其他页面回归;当前问诊列表验收以 tech-blue 92px 固定列为主。
|
||||
|
||||
本轮未运行既有 pytest,也未修改现有测试;以上数值来自离屏几何探针,不是生产客户端验收结果。
|
||||
@@ -1,36 +0,0 @@
|
||||
# 视频问诊固定列错位修复验证
|
||||
|
||||
日期:2026-10-17。范围:桌面问诊列表固定列布局。
|
||||
|
||||
## 修复
|
||||
|
||||
`app/src/doctor_workstation/ui/diagnosis_index_widgets.py`:
|
||||
|
||||
- 仅在固定表清除继承的 item padding,避免 92px 列被压成 76px 内容区。
|
||||
- 状态标签填充单元格并居中绘制,避免布局把标签限制在旧的 sizeHint 高度。
|
||||
- 将服务端 `状态 · 时间` 分成两行,完整原文留在 tooltip;使用纯文本显示。
|
||||
- 视频入口按钮继续单独居中;列宽、行高及双表滚动机制保持。
|
||||
|
||||
详细根因审查见 [审查记录](video-column-layout-review.md)。
|
||||
|
||||
## 截图核对
|
||||
|
||||
使用真实 `apply_theme()`、`ConsultationsPage`、医生角色和离线测试数据渲染;无真实账号或网络请求。
|
||||
|
||||
- [修复前](video-column-before-page.png):单元格为 76×69,结束状态标签为 70×33,而所需高度为 50;首尾被裁切。
|
||||
- [修复后](video-column-after-page.png):单元格为 92×81,标签可用区为 86×77;结束状态与时间两行所需高度 33,完整可读。
|
||||
|
||||
截图生成脚本保存在 `.codex-tasks/video-column-layout-check.py`,脚本当前读取修复后的源码。
|
||||
|
||||
## 实际回归结果
|
||||
|
||||
| 检查 | 结果 |
|
||||
| --- | --- |
|
||||
| 新布局回归修复前运行 | 4 个案例失败,复现标签高度不足/缺少全文提示 |
|
||||
| `test_diagnosis_video_status_layout.py` | 8 passed:两种表格风格、1024×640 / 1710×920、有/无渠道最小行高;覆盖结束状态有/无时间、通话中、接通中、未接听、取消、空状态;检查真实可用尺寸、标签高度、居中、全文提示及滚动行顶同步 |
|
||||
| `test_diagnosis_index_visual.py` | 26 passed |
|
||||
| `test_consultations_parity_ui.py` + `test_text_consult_ui.py` | 87 passed,含医生/医助权限入口及图文流程 |
|
||||
| Ruff:变更源码与新增测试 | 通过 |
|
||||
| 变更源码 `git diff --check` | 通过 |
|
||||
|
||||
共 121 个相关 UI 回归通过。未打包、未更新正在运行的客户端;桌面端更新后生效。
|
||||
@@ -1,12 +0,0 @@
|
||||
*
|
||||
!Dockerfile
|
||||
!audio-requirements.txt
|
||||
!cpu_preflight.py
|
||||
|
||||
__pycache__/
|
||||
|
||||
*.pyc
|
||||
|
||||
acceptance/
|
||||
|
||||
.gpu1-mode*
|
||||