Merge branch 'master' into cs
This commit is contained in:
@@ -11,7 +11,11 @@
|
|||||||
"Bash(php think *)",
|
"Bash(php think *)",
|
||||||
"Bash(python3 -c ' *)",
|
"Bash(python3 -c ' *)",
|
||||||
"Bash(python3 *)",
|
"Bash(python3 *)",
|
||||||
"Bash(awk '/<template>/,/<\\\\/template>/{print NR\": \"$0; if\\(/<\\\\/template>/\\) exit}' D:/web/zyt/admin/src/views/consumer/prescription/order_list.vue)"
|
"Bash(awk '/<template>/,/<\\\\/template>/{print NR\": \"$0; if\\(/<\\\\/template>/\\) exit}' D:/web/zyt/admin/src/views/consumer/prescription/order_list.vue)",
|
||||||
|
"Bash(npx eslint *)",
|
||||||
|
"Bash(git *)",
|
||||||
|
"Bash(tail -c 100000 /Users/long/.claude/projects/-Users-long-Work-zyt/7be9f770-afc3-497d-87a3-2ffa1fbe3fd1.jsonl)",
|
||||||
|
"Read(//tmp/**)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,3 +26,6 @@ bin-release/
|
|||||||
/.cursor
|
/.cursor
|
||||||
/.codex
|
/.codex
|
||||||
/.agents
|
/.agents
|
||||||
|
/.claude
|
||||||
|
/.claude
|
||||||
|
/.claude
|
||||||
|
|||||||
+75
-7
@@ -5,8 +5,39 @@ export function tcmDiagnosisLists(params: any) {
|
|||||||
return request.get({ url: '/tcm.diagnosis/lists', params })
|
return request.get({ url: '/tcm.diagnosis/lists', params })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 二诊只读病例详情(T1+T2):返回 appointment + diagnosis + 未服务天数(不含跟踪记录) */
|
||||||
|
export function diagnosisReadonlyDetail(params: { id: number }) {
|
||||||
|
return request.get({ url: '/tcm.diagnosis/readonlyDetail', params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 跟踪信息(血糖血压 / 饮食 / 运动)按日期区间 lazy load — 由 TrackingMatrix 调用 */
|
||||||
|
export function diagnosisTrackingWindow(params: {
|
||||||
|
id: number
|
||||||
|
start_date?: string
|
||||||
|
end_date?: string
|
||||||
|
}) {
|
||||||
|
return request.get({ url: '/tcm.diagnosis/trackingWindow', params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 新增跟踪备注(按天合并追加,仅文字) */
|
||||||
|
export function diagnosisAddTrackingNote(params: {
|
||||||
|
diagnosis_id: number
|
||||||
|
tracking_content: string
|
||||||
|
}) {
|
||||||
|
return request.post({ url: '/tcm.diagnosis/addTrackingNote', params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 拉取跟踪备注列表(note_date DESC) */
|
||||||
|
export function diagnosisTrackingNotes(params: { diagnosis_id: number }) {
|
||||||
|
return request.get({ url: '/tcm.diagnosis/trackingNotes', params })
|
||||||
|
}
|
||||||
|
|
||||||
// 医助理诊单统计(按部门、按人)
|
// 医助理诊单统计(按部门、按人)
|
||||||
export function assistantDiagnosisStats(params?: { start_time?: string; end_time?: string; days?: number }) {
|
export function assistantDiagnosisStats(params?: {
|
||||||
|
start_time?: string
|
||||||
|
end_time?: string
|
||||||
|
days?: number
|
||||||
|
}) {
|
||||||
return request.get({ url: '/tcm.diagnosis/assistantDiagnosisStats', params })
|
return request.get({ url: '/tcm.diagnosis/assistantDiagnosisStats', params })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -298,7 +329,11 @@ export function prescriptionVoid(params: { id: number }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 处方审核:action approve | reject(驳回同时作废处方) */
|
/** 处方审核:action approve | reject(驳回同时作废处方) */
|
||||||
export function prescriptionAudit(params: { id: number; action: 'approve' | 'reject'; remark?: string }) {
|
export function prescriptionAudit(params: {
|
||||||
|
id: number
|
||||||
|
action: 'approve' | 'reject'
|
||||||
|
remark?: string
|
||||||
|
}) {
|
||||||
return request.post({ url: '/tcm.prescription/audit', params })
|
return request.post({ url: '/tcm.prescription/audit', params })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -401,10 +436,7 @@ export function prescriptionOrderAddPayOrder(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 为「已发货」订单关联已有支付单并重置支付审核为待审核 */
|
/** 为「已发货」订单关联已有支付单并重置支付审核为待审核 */
|
||||||
export function prescriptionOrderLinkPayOrder(params: {
|
export function prescriptionOrderLinkPayOrder(params: { id: number; pay_order_id: number }) {
|
||||||
id: number
|
|
||||||
pay_order_id: number
|
|
||||||
}) {
|
|
||||||
return request.post({ url: '/tcm.prescriptionOrder/linkPayOrder', params })
|
return request.post({ url: '/tcm.prescriptionOrder/linkPayOrder', params })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -419,7 +451,11 @@ export function prescriptionOrderSubmitGancaoRecipel(params: { id: number }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 甘草药管家:预下单测试(仅 CTM_PREVIEW,不提交订单) */
|
/** 甘草药管家:预下单测试(仅 CTM_PREVIEW,不提交订单) */
|
||||||
export function prescriptionOrderPreviewGancaoRecipel(params: { id: number; dose_count?: number; medication_days?: number }) {
|
export function prescriptionOrderPreviewGancaoRecipel(params: {
|
||||||
|
id: number
|
||||||
|
dose_count?: number
|
||||||
|
medication_days?: number
|
||||||
|
}) {
|
||||||
return request.post({ url: '/tcm.prescriptionOrder/previewGancaoRecipel', params })
|
return request.post({ url: '/tcm.prescriptionOrder/previewGancaoRecipel', params })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -454,3 +490,35 @@ export function prescriptionLibraryDelete(params: { id: number }) {
|
|||||||
export function prescriptionLibraryDetail(params: { id: number }) {
|
export function prescriptionLibraryDetail(params: { id: number }) {
|
||||||
return request.get({ url: '/tcm.prescriptionLibrary/detail', params })
|
return request.get({ url: '/tcm.prescriptionLibrary/detail', params })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========== 诊单待办事项(T5) ==========
|
||||||
|
|
||||||
|
/** 待办列表(按 diagnosis_id) */
|
||||||
|
export function diagnosisTodoLists(params: {
|
||||||
|
diagnosis_id: number
|
||||||
|
page_no?: number
|
||||||
|
page_size?: number
|
||||||
|
status?: number
|
||||||
|
creator_id?: number
|
||||||
|
}) {
|
||||||
|
return request.get({ url: '/tcm.diagnosisTodo/lists', params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 新增待办(remind_time 为 unix 秒级时间戳) */
|
||||||
|
export function diagnosisTodoAdd(params: {
|
||||||
|
diagnosis_id: number
|
||||||
|
content: string
|
||||||
|
remind_time: number
|
||||||
|
}) {
|
||||||
|
return request.post({ url: '/tcm.diagnosisTodo/add', params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 取消待办(仅 status=0 / 创建人或超管) */
|
||||||
|
export function diagnosisTodoCancel(params: { id: number }) {
|
||||||
|
return request.post({ url: '/tcm.diagnosisTodo/cancel', params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 待办详情 */
|
||||||
|
export function diagnosisTodoDetail(params: { id: number }) {
|
||||||
|
return request.get({ url: '/tcm.diagnosisTodo/detail', params })
|
||||||
|
}
|
||||||
|
|||||||
@@ -82,6 +82,24 @@ export const constantRoutes: Array<RouteRecordRaw> = [
|
|||||||
{
|
{
|
||||||
path: '/tcm/diagnosis/h5',
|
path: '/tcm/diagnosis/h5',
|
||||||
component: () => import('@/views/tcm/diagnosis/index_h5.vue')
|
component: () => import('@/views/tcm/diagnosis/index_h5.vue')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// T1+T2:患者信息详情页(隐藏路由 — 不在侧边栏出现,由诊单列表的「查看」/双击行驱动跳转)
|
||||||
|
path: '/tcm/diagnosis-readonly',
|
||||||
|
component: LAYOUT,
|
||||||
|
meta: { hidden: true },
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
component: () => import('@/views/tcm/diagnosis/readonly.vue'),
|
||||||
|
name: 'tcmDiagnosisReadonly',
|
||||||
|
meta: {
|
||||||
|
title: '患者信息详情',
|
||||||
|
hidden: true,
|
||||||
|
activeMenu: '/tcm/diagnosis'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
// {
|
// {
|
||||||
// path: '/dev_tools',
|
// path: '/dev_tools',
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
/**
|
||||||
|
* 血糖 / 血压 阈值与异常判定工具
|
||||||
|
*
|
||||||
|
* 阈值规则(与 reception/index.vue 抽离前一致):
|
||||||
|
* - 年龄 < 50:空腹血糖 ≥ 7、餐后/其他血糖 ≥ 9 视为偏高
|
||||||
|
* - 年龄 ≥ 50:空腹血糖 ≥ 8、餐后/其他血糖 ≥ 10 视为偏高
|
||||||
|
* - 收缩压 > 140 / 舒张压 > 90 视为偏高
|
||||||
|
*
|
||||||
|
* 任何字段缺失或非数值 → 视为「不偏高」(避免误报红色 ↑)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把任意值转成有限数字;空 / 非法 → null
|
||||||
|
*/
|
||||||
|
export function toNumeric(value: unknown): number | null {
|
||||||
|
if (value === null || value === undefined || value === '') return null
|
||||||
|
const num = Number(value as any)
|
||||||
|
return Number.isFinite(num) ? num : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据年龄返回血糖阈值;年龄缺失/非法 → null(=不判定偏高)
|
||||||
|
*/
|
||||||
|
export function getBloodSugarThresholds(
|
||||||
|
ageValue: unknown
|
||||||
|
): { fasting: number; postprandial: number } | null {
|
||||||
|
const age = toNumeric(ageValue)
|
||||||
|
if (age === null) return null
|
||||||
|
if (age < 50) return { fasting: 7, postprandial: 9 }
|
||||||
|
return { fasting: 8, postprandial: 10 }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isHighFastingBloodSugar(value: unknown, ageValue: unknown): boolean {
|
||||||
|
const num = toNumeric(value)
|
||||||
|
const thresholds = getBloodSugarThresholds(ageValue)
|
||||||
|
return num !== null && thresholds !== null && num >= thresholds.fasting
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isHighPostprandialBloodSugar(value: unknown, ageValue: unknown): boolean {
|
||||||
|
const num = toNumeric(value)
|
||||||
|
const thresholds = getBloodSugarThresholds(ageValue)
|
||||||
|
return num !== null && thresholds !== null && num >= thresholds.postprandial
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「其他血糖」按餐后阈值判定(与 reception 原实现一致)
|
||||||
|
*/
|
||||||
|
export function isHighOtherBloodSugar(value: unknown, ageValue: unknown): boolean {
|
||||||
|
const num = toNumeric(value)
|
||||||
|
const thresholds = getBloodSugarThresholds(ageValue)
|
||||||
|
return num !== null && thresholds !== null && num >= thresholds.postprandial
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isHighSystolicPressure(value: unknown): boolean {
|
||||||
|
const num = toNumeric(value)
|
||||||
|
return num !== null && num > 140
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isHighDiastolicPressure(value: unknown): boolean {
|
||||||
|
const num = toNumeric(value)
|
||||||
|
return num !== null && num > 90
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 一行血糖血压记录是否有任一压偏高
|
||||||
|
*/
|
||||||
|
export function isHighBloodPressure(row: {
|
||||||
|
systolic_pressure?: unknown
|
||||||
|
diastolic_pressure?: unknown
|
||||||
|
} | null | undefined): boolean {
|
||||||
|
if (!row) return false
|
||||||
|
return isHighSystolicPressure(row.systolic_pressure) || isHighDiastolicPressure(row.diastolic_pressure)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把一行记录的血压两值格式化为 `xxx / yyy`,缺值 → `—`
|
||||||
|
*/
|
||||||
|
export function formatBp(row: {
|
||||||
|
systolic_pressure?: unknown
|
||||||
|
diastolic_pressure?: unknown
|
||||||
|
} | null | undefined): string {
|
||||||
|
const sys = row?.systolic_pressure
|
||||||
|
const dia = row?.diastolic_pressure
|
||||||
|
if (sys == null && dia == null) return '—'
|
||||||
|
return `${sys ?? '—'} / ${dia ?? '—'}`
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
/**
|
||||||
|
* 病例 / 接诊页通用展示辅助
|
||||||
|
*
|
||||||
|
* - maskPhone:手机号脱敏 138****1234
|
||||||
|
* - formatGender:0/1 → 女/男
|
||||||
|
* - formatPeriod:morning/afternoon → 上午/下午
|
||||||
|
* - joinArray:数组 → 顿号拼接,非数组 → toString
|
||||||
|
* - makeTextOf:工厂,传入 diag ref/getter,返回闭包后的 textOf 函数
|
||||||
|
*
|
||||||
|
* 翻译规则(textOf):诊单字典字段由后端 `AppointmentLogic::enrichDiagnosisLabels`
|
||||||
|
* 预翻译为 `<field>_text`,前端优先读 `*_text`;找不到时回退原值。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Ref } from 'vue'
|
||||||
|
|
||||||
|
export function maskPhone(phone?: string | null): string {
|
||||||
|
if (!phone) return '—'
|
||||||
|
if (phone.length < 11) return phone
|
||||||
|
return phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatGender(g?: number | null): string {
|
||||||
|
if (g === 1) return '男'
|
||||||
|
if (g === 0) return '女'
|
||||||
|
return '—'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPeriod(period?: string | null): string {
|
||||||
|
if (period === 'morning') return '上午'
|
||||||
|
if (period === 'afternoon') return '下午'
|
||||||
|
return '全天'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function joinArray(arr: unknown): string {
|
||||||
|
if (!arr) return ''
|
||||||
|
if (Array.isArray(arr)) return arr.filter(Boolean).join('、')
|
||||||
|
return String(arr)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建闭包了具体 diag 的 textOf 取值器。
|
||||||
|
* 同时支持 `Ref` / `() => any` / 普通对象,避免在调用方处反复样板。
|
||||||
|
*/
|
||||||
|
export function makeTextOf(diagSource: Ref<any> | (() => any) | Record<string, any>) {
|
||||||
|
const resolve = (): Record<string, any> => {
|
||||||
|
if (typeof diagSource === 'function') {
|
||||||
|
return (diagSource as () => any)() || {}
|
||||||
|
}
|
||||||
|
if (diagSource && typeof diagSource === 'object' && 'value' in diagSource) {
|
||||||
|
return ((diagSource as Ref<any>).value ?? {}) as Record<string, any>
|
||||||
|
}
|
||||||
|
return (diagSource as Record<string, any>) || {}
|
||||||
|
}
|
||||||
|
|
||||||
|
return function textOf(field: string): string {
|
||||||
|
const d = resolve()
|
||||||
|
const t = d[field + '_text']
|
||||||
|
if (t !== undefined && t !== null && t !== '') return String(t)
|
||||||
|
const raw = d[field]
|
||||||
|
if (raw === undefined || raw === null || raw === '') return ''
|
||||||
|
return Array.isArray(raw) ? raw.join('、') : String(raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -111,126 +111,10 @@
|
|||||||
|
|
||||||
<template v-else-if="detail">
|
<template v-else-if="detail">
|
||||||
<!-- 患者信息 -->
|
<!-- 患者信息 -->
|
||||||
<div class="card patient-card">
|
<PatientInfoCard :apt="apt" :diag="diag" />
|
||||||
<div class="card-title">患者信息</div>
|
|
||||||
<div class="patient-hero">
|
|
||||||
<div class="patient-name">{{ apt.patient_name || '—' }}</div>
|
|
||||||
<div class="patient-meta">
|
|
||||||
<div>{{ maskPhone(apt.patient_phone) }} · {{ formatGender(diag.gender) }} · {{ diag.age != null ? diag.age + '岁' : '—' }}</div>
|
|
||||||
<div>{{ diag.height ? diag.height + 'cm' : '—' }} / {{ diag.weight ? diag.weight + 'kg' : '—' }} · {{ diag.region || '—' }}</div>
|
|
||||||
<div>预约:{{ apt.appointment_date }} {{ apt.appointment_time }} · {{ formatPeriod(apt.period) }}</div>
|
|
||||||
<div>医生:{{ apt.doctor_name || '—' }} 医助:{{ apt.assistant_name || '—' }}</div>
|
|
||||||
<div>状态:{{ apt.status_desc || '—' }} · {{ apt.has_prescription ? '已开方' : '未开方' }}</div>
|
|
||||||
<div v-if="apt.remark">备注:{{ apt.remark }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 患者病例(完整、字段与 tcm/diagnosis/edit.vue 保持同步,只读展示) -->
|
<!-- 患者病例(与 tcm/diagnosis/readonly 共用同一组件,确保两处字段一致) -->
|
||||||
<div class="card case-card">
|
<PatientCaseCard :apt="apt" :diag="diag" />
|
||||||
<div class="card-title">
|
|
||||||
患者病例
|
|
||||||
<span class="case-sub">{{ caseTypeLabel }} · 诊断日期 {{ diag.diagnosis_date || apt.appointment_date || '—' }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 基本信息 -->
|
|
||||||
<div class="grid-block">
|
|
||||||
<div class="grid-title">基本信息</div>
|
|
||||||
<div class="kv-grid four">
|
|
||||||
<KVItem label="诊单ID" :value="diag.patient_id" />
|
|
||||||
<KVItem label="姓名" :value="diag.patient_name" />
|
|
||||||
<KVItem label="身份证号" :value="diag.id_card" />
|
|
||||||
<KVItem label="手机号" :value="maskPhone(diag.phone || apt.patient_phone)" />
|
|
||||||
<KVItem label="性别" :value="textOf('gender')" />
|
|
||||||
<KVItem label="年龄" :value="diag.age != null ? diag.age + '岁' : ''" />
|
|
||||||
<KVItem label="婚姻状态" :value="textOf('marital_status')" />
|
|
||||||
<KVItem label="地区" :value="diag.region" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 生命体征 -->
|
|
||||||
<div class="grid-block">
|
|
||||||
<div class="grid-title">生命体征</div>
|
|
||||||
<div class="kv-grid four">
|
|
||||||
<KVItem label="身高" :value="diag.height ? diag.height + ' cm' : ''" />
|
|
||||||
<KVItem label="体重" :value="diag.weight ? diag.weight + ' kg' : ''" />
|
|
||||||
<MetricKVItem label="高压" :value="diag.systolic_pressure" unit=" mmHg" :high="isHighSystolicPressure(diag.systolic_pressure)" />
|
|
||||||
<MetricKVItem label="低压" :value="diag.diastolic_pressure" unit=" mmHg" :high="isHighDiastolicPressure(diag.diastolic_pressure)" />
|
|
||||||
<KVItem label="诊断类型" :value="textOf('diagnosis_type')" />
|
|
||||||
<MetricKVItem label="空腹血糖" :value="diag.fasting_blood_sugar" unit=" mmol/L" :high="isHighFastingBloodSugar(diag.fasting_blood_sugar, diag.age)" />
|
|
||||||
<KVItem label="在用药物" :value="diag.current_medications" multiline />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 主诉 -->
|
|
||||||
<div class="grid-block">
|
|
||||||
<div class="grid-title">主诉</div>
|
|
||||||
<div class="kv-grid four">
|
|
||||||
<KVItem label="当地医院诊断日期" :value="diag.diagnosis_date" />
|
|
||||||
<KVItem label="发现糖尿病病史" :value="formatDiabetesDiscoveryDisplay ? formatDiabetesDiscoveryDisplay(diag.diabetes_discovery_year) : diag.diabetes_discovery_year" />
|
|
||||||
<KVItem label="当地就诊医院" :value="diag.local_hospital_name" />
|
|
||||||
<KVItem label="当地医院诊断结果" :value="joinArray(diag.local_hospital_diagnosis)" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 现病史(后端已翻译 *_text) -->
|
|
||||||
<div class="grid-block">
|
|
||||||
<div class="grid-title">现病史</div>
|
|
||||||
<div class="kv-grid three">
|
|
||||||
<KVItem label="口腔感觉" :value="textOf('appetite')" />
|
|
||||||
<KVItem label="每日饮水量" :value="textOf('water_intake')" />
|
|
||||||
<KVItem label="近月体重变化" :value="textOf('weight_change')" />
|
|
||||||
<KVItem label="脂肪肝程度" :value="textOf('fatty_liver_degree')" />
|
|
||||||
<KVItem label="饮食情况" :value="textOf('diet_condition')" span="2" />
|
|
||||||
<KVItem label="肢体感觉" :value="textOf('body_feeling')" />
|
|
||||||
<KVItem label="睡眠情况" :value="textOf('sleep_condition')" />
|
|
||||||
<KVItem label="眼睛情况" :value="textOf('eye_condition')" />
|
|
||||||
<KVItem label="头部感觉" :value="textOf('head_feeling')" />
|
|
||||||
<KVItem label="出汗情况" :value="textOf('sweat_condition')" />
|
|
||||||
<KVItem label="皮肤情况" :value="textOf('skin_condition')" />
|
|
||||||
<KVItem label="小便情况" :value="textOf('urine_condition')" />
|
|
||||||
<KVItem label="大便情况" :value="textOf('stool_condition')" />
|
|
||||||
<KVItem label="腰肾情况" :value="textOf('kidney_condition')" />
|
|
||||||
<KVItem label="其他补充" :value="diag.symptoms" span="3" multiline />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 既往史 -->
|
|
||||||
<div class="grid-block">
|
|
||||||
<div class="grid-title">既往史</div>
|
|
||||||
<div class="kv-grid three">
|
|
||||||
<KVItem label="既往病史" :value="textOf('past_history')" span="3" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 其他病史 -->
|
|
||||||
<div class="grid-block">
|
|
||||||
<div class="grid-title">其他病史</div>
|
|
||||||
<div class="kv-grid three">
|
|
||||||
<KVItem label="外伤史" :value="textOf('trauma_history')" />
|
|
||||||
<KVItem label="手术史" :value="textOf('surgery_history')" />
|
|
||||||
<KVItem label="过敏史" :value="textOf('allergy_history')" />
|
|
||||||
<KVItem label="家族病史" :value="textOf('family_history')" />
|
|
||||||
<KVItem label="妊娠哺乳史" :value="textOf('pregnancy_history')" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 病史补充 -->
|
|
||||||
<div class="grid-block" v-if="diag.remark">
|
|
||||||
<div class="grid-title">病史补充</div>
|
|
||||||
<div class="kv-grid one">
|
|
||||||
<KVItem label="" :value="diag.remark" multiline />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 处方意见 -->
|
|
||||||
<div class="grid-block">
|
|
||||||
<div class="grid-title">处方意见</div>
|
|
||||||
<div class="rx-note">
|
|
||||||
{{ apt.has_prescription ? '已开方。' : '当前未开方。' }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 医生备注 & 舌苔照片 -->
|
<!-- 医生备注 & 舌苔照片 -->
|
||||||
<div class="card note-card">
|
<div class="card note-card">
|
||||||
@@ -242,65 +126,19 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 血糖血压记录 -->
|
<!-- 跟踪备注(只读时间轴;新增入口在「编辑诊单」内) -->
|
||||||
|
<!-- <div class="card tracking-note-card">
|
||||||
|
<div class="card-title">跟踪备注</div>
|
||||||
|
<TrackingNoteTimeline :notes="trackingNotes" readonly />
|
||||||
|
</div> -->
|
||||||
|
|
||||||
|
<!-- 跟踪信息(与只读病例页同口径,自治 lazy load) -->
|
||||||
<div class="card metric-card">
|
<div class="card metric-card">
|
||||||
<div class="card-title">血糖血压记录</div>
|
<div class="card-title">跟踪信息</div>
|
||||||
<div class="metric-viewport">
|
<TrackingMatrix
|
||||||
<el-table
|
:diagnosis-id="Number(diag?.id ?? 0)"
|
||||||
:data="bloodRecords"
|
:age="diag?.age ?? null"
|
||||||
size="small"
|
/>
|
||||||
stripe
|
|
||||||
empty-text="暂无血糖血压记录"
|
|
||||||
class="metric-table"
|
|
||||||
max-height="360"
|
|
||||||
>
|
|
||||||
<el-table-column prop="record_date" label="记录日期" width="110" />
|
|
||||||
<el-table-column prop="record_time" label="记录时间" width="100">
|
|
||||||
<template #default="{ row }">{{ row.record_time || '—' }}</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="空腹血糖(mmol/L)" width="150">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<span class="metric-value" :class="{ high: isHighFastingBloodSugar(row.fasting_blood_sugar, diag.age) }">
|
|
||||||
{{ row.fasting_blood_sugar ?? '—' }}
|
|
||||||
<span v-if="isHighFastingBloodSugar(row.fasting_blood_sugar, diag.age)" class="metric-up">↑</span>
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="餐后2小时血糖(mmol/L)" width="180">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<span class="metric-value" :class="{ high: isHighPostprandialBloodSugar(row.postprandial_blood_sugar, diag.age) }">
|
|
||||||
{{ row.postprandial_blood_sugar ?? '—' }}
|
|
||||||
<span v-if="isHighPostprandialBloodSugar(row.postprandial_blood_sugar, diag.age)" class="metric-up">↑</span>
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="其他血糖(mmol/L)" width="150">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<span class="metric-value" :class="{ high: isHighOtherBloodSugar(row.other_blood_sugar, diag.age) }">
|
|
||||||
{{ row.other_blood_sugar ?? '—' }}
|
|
||||||
<span v-if="isHighOtherBloodSugar(row.other_blood_sugar, diag.age)" class="metric-up">↑</span>
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="血压(mmHg)" width="130">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<span class="metric-value" :class="{ high: isHighBloodPressure(row) }">
|
|
||||||
{{ formatBp(row) }}
|
|
||||||
<span v-if="isHighBloodPressure(row)" class="metric-up">↑</span>
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="西药" width="120">
|
|
||||||
<template #default="{ row }">{{ row.western_medicine || '—' }}</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="胰岛素" width="120">
|
|
||||||
<template #default="{ row }">{{ row.insulin || '—' }}</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="备注" min-width="160">
|
|
||||||
<template #default="{ row }">{{ row.remark || '—' }}</template>
|
|
||||||
</el-table-column>
|
|
||||||
</el-table>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</section>
|
</section>
|
||||||
@@ -353,13 +191,18 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { defineAsyncComponent, onMounted, onUnmounted, h } from 'vue'
|
import { defineAsyncComponent, onMounted, onUnmounted } from 'vue'
|
||||||
import { receptionQueue, receptionDetail, notifyAssistant, addDoctorNote } from '@/api/patient'
|
import { receptionQueue, receptionDetail, notifyAssistant, addDoctorNote } from '@/api/patient'
|
||||||
import NoteTimeline from './components/NoteTimeline.vue'
|
import NoteTimeline from './components/NoteTimeline.vue'
|
||||||
|
import PatientInfoCard from '@/views/tcm/diagnosis/components/PatientInfoCard.vue'
|
||||||
|
import PatientCaseCard from '@/views/tcm/diagnosis/components/PatientCaseCard.vue'
|
||||||
|
import TrackingMatrix from '@/views/tcm/diagnosis/components/TrackingMatrix.vue'
|
||||||
|
import TrackingNoteTimeline from '@/views/tcm/diagnosis/components/TrackingNoteTimeline.vue'
|
||||||
import { completeAppointment } from '@/api/doctor'
|
import { completeAppointment } from '@/api/doctor'
|
||||||
import { getCallSignature } from '@/api/tcm'
|
import { getCallSignature } from '@/api/tcm'
|
||||||
import feedback from '@/utils/feedback'
|
import feedback from '@/utils/feedback'
|
||||||
import { formatDiabetesDiscoveryDisplay } from '@/utils/diabetes-discovery-display'
|
import { formatDiabetesDiscoveryDisplay } from '@/utils/diabetes-discovery-display'
|
||||||
|
import { formatGender } from '@/utils/diag-display'
|
||||||
import {
|
import {
|
||||||
Phone,
|
Phone,
|
||||||
Search,
|
Search,
|
||||||
@@ -370,55 +213,6 @@ import {
|
|||||||
const ChatDialog = defineAsyncComponent(() => import('@/components/chat-dialog/index.vue'))
|
const ChatDialog = defineAsyncComponent(() => import('@/components/chat-dialog/index.vue'))
|
||||||
const DiagnosisEdit = defineAsyncComponent(() => import('@/views/tcm/diagnosis/edit.vue'))
|
const DiagnosisEdit = defineAsyncComponent(() => import('@/views/tcm/diagnosis/edit.vue'))
|
||||||
|
|
||||||
/**
|
|
||||||
* 局部渲染组件:左侧 label + 右侧 value,支持 span / 换行
|
|
||||||
*/
|
|
||||||
const KVItem = (props: { label: string; value: any; span?: string | number; multiline?: boolean }) => {
|
|
||||||
const val = props.value
|
|
||||||
const empty = val === undefined || val === null || val === '' || (Array.isArray(val) && val.length === 0)
|
|
||||||
return h(
|
|
||||||
'div',
|
|
||||||
{
|
|
||||||
class: ['kv-item', props.multiline ? 'multiline' : '', empty ? 'empty' : ''],
|
|
||||||
style: props.span ? `grid-column: span ${props.span}` : ''
|
|
||||||
},
|
|
||||||
[
|
|
||||||
h('span', { class: 'kv-label' }, props.label),
|
|
||||||
h('span', { class: 'kv-value' }, empty ? '—' : String(val))
|
|
||||||
]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const MetricKVItem = (props: {
|
|
||||||
label: string
|
|
||||||
value: any
|
|
||||||
unit?: string
|
|
||||||
span?: string | number
|
|
||||||
high?: boolean
|
|
||||||
}) => {
|
|
||||||
const empty = props.value === undefined || props.value === null || props.value === ''
|
|
||||||
return h(
|
|
||||||
'div',
|
|
||||||
{
|
|
||||||
class: ['kv-item', 'metric-kv-item', empty ? 'empty' : ''],
|
|
||||||
style: props.span ? `grid-column: span ${props.span}` : ''
|
|
||||||
},
|
|
||||||
[
|
|
||||||
h('span', { class: 'kv-label' }, props.label),
|
|
||||||
h(
|
|
||||||
'span',
|
|
||||||
{ class: ['kv-value', 'metric-kv-value', props.high ? 'high' : ''] },
|
|
||||||
empty
|
|
||||||
? '—'
|
|
||||||
: [
|
|
||||||
`${props.value}${props.unit || ''}`,
|
|
||||||
props.high ? h('span', { class: 'metric-up' }, '↑') : null
|
|
||||||
]
|
|
||||||
)
|
|
||||||
]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const POLL_MS = 5_000
|
const POLL_MS = 5_000
|
||||||
const PAGE_SIZE = 15
|
const PAGE_SIZE = 15
|
||||||
|
|
||||||
@@ -478,8 +272,8 @@ const noteSaving = ref(false)
|
|||||||
|
|
||||||
const apt = computed<any>(() => detail.value?.appointment || {})
|
const apt = computed<any>(() => detail.value?.appointment || {})
|
||||||
const diag = computed<any>(() => detail.value?.diagnosis || {})
|
const diag = computed<any>(() => detail.value?.diagnosis || {})
|
||||||
const bloodRecords = computed<any[]>(() => detail.value?.blood_records || [])
|
|
||||||
const doctorNotes = computed<any[]>(() => detail.value?.doctor_notes || [])
|
const doctorNotes = computed<any[]>(() => detail.value?.doctor_notes || [])
|
||||||
|
const trackingNotes = computed<any[]>(() => detail.value?.tracking_notes || [])
|
||||||
|
|
||||||
const todayMeta = computed(() => {
|
const todayMeta = computed(() => {
|
||||||
const d = new Date()
|
const d = new Date()
|
||||||
@@ -489,38 +283,12 @@ const todayMeta = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
const caseTypeLabel = computed(() => {
|
|
||||||
if (!diag.value) return '初诊'
|
|
||||||
return diag.value.consultation_type === 'follow_up' ? '复诊' : '初诊'
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 诊单字典字段由后端 AppointmentLogic::enrichDiagnosisLabels 预翻译,
|
|
||||||
* 前端直接读 `<field>_text`;找不到时回退原值。
|
|
||||||
*/
|
|
||||||
const textOf = (field: string): string => {
|
|
||||||
const d: any = diag.value || {}
|
|
||||||
const t = d[field + '_text']
|
|
||||||
if (t !== undefined && t !== null && t !== '') return String(t)
|
|
||||||
const raw = d[field]
|
|
||||||
if (raw === undefined || raw === null || raw === '') return ''
|
|
||||||
return Array.isArray(raw) ? raw.join('、') : String(raw)
|
|
||||||
}
|
|
||||||
|
|
||||||
const todayStr = () => {
|
const todayStr = () => {
|
||||||
const d = new Date()
|
const d = new Date()
|
||||||
const pad = (n: number) => String(n).padStart(2, '0')
|
const pad = (n: number) => String(n).padStart(2, '0')
|
||||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const maskPhone = (phone?: string) => {
|
|
||||||
if (!phone) return '—'
|
|
||||||
if (phone.length < 11) return phone
|
|
||||||
return phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2')
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatGender = (g?: number) => (g === 1 ? '男' : g === 0 ? '女' : '—')
|
|
||||||
|
|
||||||
const formatGenderAge = (row: QueueRow) => {
|
const formatGenderAge = (row: QueueRow) => {
|
||||||
const parts = [formatGender(row.gender), row.age != null ? row.age + '岁' : '']
|
const parts = [formatGender(row.gender), row.age != null ? row.age + '岁' : '']
|
||||||
return parts.filter((p) => p && p !== '—').join(' ')
|
return parts.filter((p) => p && p !== '—').join(' ')
|
||||||
@@ -531,73 +299,6 @@ const formatTimeHM = (time?: string) => {
|
|||||||
return time.replace(/^(\d{2}:\d{2})(:\d{2})?$/, '$1')
|
return time.replace(/^(\d{2}:\d{2})(:\d{2})?$/, '$1')
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatPeriod = (period?: string) => {
|
|
||||||
if (period === 'morning') return '上午'
|
|
||||||
if (period === 'afternoon') return '下午'
|
|
||||||
return '全天'
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatBp = (row: any) => {
|
|
||||||
const sys = row?.systolic_pressure
|
|
||||||
const dia = row?.diastolic_pressure
|
|
||||||
if (sys == null && dia == null) return '—'
|
|
||||||
return `${sys ?? '—'} / ${dia ?? '—'}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const toNumeric = (value: any) => {
|
|
||||||
if (value === null || value === undefined || value === '') return null
|
|
||||||
const num = Number(value)
|
|
||||||
return Number.isFinite(num) ? num : null
|
|
||||||
}
|
|
||||||
|
|
||||||
const getBloodSugarThresholds = (ageValue: any) => {
|
|
||||||
const age = toNumeric(ageValue)
|
|
||||||
if (age === null) return null
|
|
||||||
if (age < 50) return { fasting: 7, postprandial: 9 }
|
|
||||||
if (age >= 50) return { fasting: 8, postprandial: 10 }
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const isHighFastingBloodSugar = (value: any, ageValue: any) => {
|
|
||||||
const num = toNumeric(value)
|
|
||||||
const thresholds = getBloodSugarThresholds(ageValue)
|
|
||||||
return num !== null && thresholds !== null && num >= thresholds.fasting
|
|
||||||
}
|
|
||||||
|
|
||||||
const isHighPostprandialBloodSugar = (value: any, ageValue: any) => {
|
|
||||||
const num = toNumeric(value)
|
|
||||||
const thresholds = getBloodSugarThresholds(ageValue)
|
|
||||||
return num !== null && thresholds !== null && num >= thresholds.postprandial
|
|
||||||
}
|
|
||||||
|
|
||||||
const isHighOtherBloodSugar = (value: any, ageValue: any) => {
|
|
||||||
const num = toNumeric(value)
|
|
||||||
const thresholds = getBloodSugarThresholds(ageValue)
|
|
||||||
return num !== null && thresholds !== null && num >= thresholds.postprandial
|
|
||||||
}
|
|
||||||
|
|
||||||
const isHighBloodPressure = (row: any) => {
|
|
||||||
const sys = toNumeric(row?.systolic_pressure)
|
|
||||||
const dia = toNumeric(row?.diastolic_pressure)
|
|
||||||
return (sys !== null && sys > 140) || (dia !== null && dia > 90)
|
|
||||||
}
|
|
||||||
|
|
||||||
const isHighSystolicPressure = (value: any) => {
|
|
||||||
const num = toNumeric(value)
|
|
||||||
return num !== null && num > 140
|
|
||||||
}
|
|
||||||
|
|
||||||
const isHighDiastolicPressure = (value: any) => {
|
|
||||||
const num = toNumeric(value)
|
|
||||||
return num !== null && num > 90
|
|
||||||
}
|
|
||||||
|
|
||||||
const joinArray = (arr: any): string => {
|
|
||||||
if (!arr) return ''
|
|
||||||
if (Array.isArray(arr)) return arr.filter(Boolean).join('、')
|
|
||||||
return String(arr)
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchQueuePage = async (pageNo: number, status?: number, patientName?: string) => {
|
const fetchQueuePage = async (pageNo: number, status?: number, patientName?: string) => {
|
||||||
const today = todayStr()
|
const today = todayStr()
|
||||||
const res: any = await receptionQueue({
|
const res: any = await receptionQueue({
|
||||||
|
|||||||
@@ -0,0 +1,313 @@
|
|||||||
|
<template>
|
||||||
|
<div class="diagnosis-todo-list">
|
||||||
|
<div class="toolbar">
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
:disabled="!props.diagnosisId"
|
||||||
|
@click="handleAdd"
|
||||||
|
v-perms="['tcm.diagnosisTodo/add']"
|
||||||
|
>
|
||||||
|
+ 新增待办
|
||||||
|
</el-button>
|
||||||
|
<el-radio-group v-model="filterStatus" class="ml-3" @change="handleFilterChange">
|
||||||
|
<el-radio-button :value="''">全部</el-radio-button>
|
||||||
|
<el-radio-button :value="0">待执行</el-radio-button>
|
||||||
|
<el-radio-button :value="1">已发送</el-radio-button>
|
||||||
|
<el-radio-button :value="3">失败</el-radio-button>
|
||||||
|
<el-radio-button :value="2">已取消</el-radio-button>
|
||||||
|
</el-radio-group>
|
||||||
|
<el-button class="ml-3" @click="fetchList">刷新</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-table
|
||||||
|
v-loading="pager.loading"
|
||||||
|
:data="pager.lists"
|
||||||
|
border
|
||||||
|
class="todo-table"
|
||||||
|
empty-text="暂无待办"
|
||||||
|
row-key="id"
|
||||||
|
>
|
||||||
|
<el-table-column label="提醒时间" prop="remind_time_text" width="160" />
|
||||||
|
<el-table-column label="内容" prop="content" min-width="240" show-overflow-tooltip />
|
||||||
|
<el-table-column label="状态" width="110" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="statusTagType(row.status)" effect="light" size="small">
|
||||||
|
{{ row.status_text }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="创建人" prop="creator_name" width="120">
|
||||||
|
<template #default="{ row }">{{ row.creator_name || '-' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="推送时间" prop="notified_at_text" width="160">
|
||||||
|
<template #default="{ row }">{{ row.notified_at_text || '-' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="失败原因" prop="error" min-width="200" show-overflow-tooltip>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span v-if="row.status === 3" class="text-danger">{{ row.error || '未知错误' }}</span>
|
||||||
|
<span v-else>-</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="120" fixed="right" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button
|
||||||
|
v-if="row.status === 0 && row.can_cancel"
|
||||||
|
link
|
||||||
|
type="danger"
|
||||||
|
@click="handleCancel(row)"
|
||||||
|
v-perms="['tcm.diagnosisTodo/cancel']"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</el-button>
|
||||||
|
<span v-else class="text-muted">-</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<div class="pagination-wrap">
|
||||||
|
<pagination v-model="pager" @change="fetchList" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 新增弹框 -->
|
||||||
|
<el-dialog
|
||||||
|
v-model="addDialogVisible"
|
||||||
|
title="新增待办"
|
||||||
|
width="520px"
|
||||||
|
:close-on-click-modal="false"
|
||||||
|
>
|
||||||
|
<el-form ref="addFormRef" :model="addForm" :rules="addRules" label-width="100px">
|
||||||
|
<el-form-item label="提醒时间" prop="remindAt">
|
||||||
|
<el-date-picker
|
||||||
|
v-model="addForm.remindAt"
|
||||||
|
type="datetime"
|
||||||
|
placeholder="选择提醒时间"
|
||||||
|
format="YYYY-MM-DD HH:mm"
|
||||||
|
value-format="x"
|
||||||
|
:disabled-date="disablePastDate"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="提醒内容" prop="content">
|
||||||
|
<el-input
|
||||||
|
v-model="addForm.content"
|
||||||
|
type="textarea"
|
||||||
|
:rows="4"
|
||||||
|
:maxlength="500"
|
||||||
|
show-word-limit
|
||||||
|
placeholder="例:3 天后回访患者复测餐后血糖"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="提醒人">
|
||||||
|
<span class="text-muted">企微推送给当前登录账号(创建人本人)</span>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="addDialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="addSubmitting" @click="handleAddSubmit">
|
||||||
|
提交
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { FormInstance, FormRules } from 'element-plus'
|
||||||
|
import { diagnosisTodoLists, diagnosisTodoAdd, diagnosisTodoCancel } from '@/api/tcm'
|
||||||
|
import feedback from '@/utils/feedback'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
diagnosisId: number | null | undefined
|
||||||
|
patientId?: number | null
|
||||||
|
patientName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
|
// pager 与项目内 <pagination v-model="pager" /> 约定的字段保持一致
|
||||||
|
const pager = reactive({
|
||||||
|
page: 1,
|
||||||
|
size: 15,
|
||||||
|
count: 0,
|
||||||
|
loading: false,
|
||||||
|
lists: [] as any[]
|
||||||
|
})
|
||||||
|
|
||||||
|
const filterStatus = ref<number | ''>('')
|
||||||
|
|
||||||
|
const fetchList = async () => {
|
||||||
|
if (!props.diagnosisId || Number(props.diagnosisId) <= 0) {
|
||||||
|
pager.lists = []
|
||||||
|
pager.count = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pager.loading = true
|
||||||
|
try {
|
||||||
|
const params: {
|
||||||
|
page_no: number
|
||||||
|
page_size: number
|
||||||
|
diagnosis_id: number
|
||||||
|
status?: number
|
||||||
|
} = {
|
||||||
|
page_no: pager.page,
|
||||||
|
page_size: pager.size,
|
||||||
|
diagnosis_id: Number(props.diagnosisId)
|
||||||
|
}
|
||||||
|
if (filterStatus.value !== '' && filterStatus.value !== undefined) {
|
||||||
|
params.status = Number(filterStatus.value)
|
||||||
|
}
|
||||||
|
const res: any = await diagnosisTodoLists(params)
|
||||||
|
pager.count = Number(res?.count ?? 0)
|
||||||
|
pager.lists = Array.isArray(res?.lists) ? res.lists : []
|
||||||
|
} catch {
|
||||||
|
// request 拦截器已有错误 toast
|
||||||
|
pager.lists = []
|
||||||
|
pager.count = 0
|
||||||
|
} finally {
|
||||||
|
pager.loading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.diagnosisId,
|
||||||
|
() => {
|
||||||
|
pager.page = 1
|
||||||
|
fetchList()
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleFilterChange = () => {
|
||||||
|
pager.page = 1
|
||||||
|
fetchList()
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusTagType = (status: number): 'info' | 'success' | 'warning' | 'danger' => {
|
||||||
|
switch (Number(status)) {
|
||||||
|
case 0:
|
||||||
|
return 'info'
|
||||||
|
case 1:
|
||||||
|
return 'success'
|
||||||
|
case 2:
|
||||||
|
return 'warning'
|
||||||
|
case 3:
|
||||||
|
return 'danger'
|
||||||
|
default:
|
||||||
|
return 'info'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 新增 ==========
|
||||||
|
const addDialogVisible = ref(false)
|
||||||
|
const addSubmitting = ref(false)
|
||||||
|
const addFormRef = ref<FormInstance>()
|
||||||
|
const addForm = reactive({
|
||||||
|
remindAt: '' as string | number,
|
||||||
|
content: ''
|
||||||
|
})
|
||||||
|
const addRules: FormRules = {
|
||||||
|
remindAt: [{ required: true, message: '请选择提醒时间', trigger: 'change' }],
|
||||||
|
content: [
|
||||||
|
{ required: true, message: '请输入提醒内容', trigger: 'blur' },
|
||||||
|
{ max: 500, message: '内容最多 500 字', trigger: 'blur' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const disablePastDate = (date: Date) => {
|
||||||
|
const today = new Date()
|
||||||
|
today.setHours(0, 0, 0, 0)
|
||||||
|
return date.getTime() < today.getTime()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAdd = () => {
|
||||||
|
if (!props.diagnosisId) {
|
||||||
|
feedback.msgWarning('诊单未保存,无法添加待办')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
addForm.remindAt = ''
|
||||||
|
addForm.content = ''
|
||||||
|
addDialogVisible.value = true
|
||||||
|
nextTick(() => addFormRef.value?.clearValidate())
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAddSubmit = async () => {
|
||||||
|
if (!addFormRef.value) return
|
||||||
|
const valid = await addFormRef.value.validate().catch(() => false)
|
||||||
|
if (!valid) return
|
||||||
|
|
||||||
|
const remindMs = Number(addForm.remindAt)
|
||||||
|
if (!Number.isFinite(remindMs) || remindMs <= Date.now() + 30 * 1000) {
|
||||||
|
feedback.msgWarning('提醒时间必须晚于当前时间至少 30 秒')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
addSubmitting.value = true
|
||||||
|
try {
|
||||||
|
await diagnosisTodoAdd({
|
||||||
|
diagnosis_id: Number(props.diagnosisId),
|
||||||
|
content: addForm.content.trim(),
|
||||||
|
remind_time: Math.floor(remindMs / 1000)
|
||||||
|
})
|
||||||
|
feedback.msgSuccess('已创建')
|
||||||
|
addDialogVisible.value = false
|
||||||
|
fetchList()
|
||||||
|
} catch {
|
||||||
|
// request 拦截器已有错误 toast
|
||||||
|
} finally {
|
||||||
|
addSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 取消 ==========
|
||||||
|
const handleCancel = async (row: any) => {
|
||||||
|
try {
|
||||||
|
await feedback.confirm(`确认取消「${row.remind_time_text} · ${truncate(row.content, 30)}」吗?`)
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await diagnosisTodoCancel({ id: Number(row.id) })
|
||||||
|
feedback.msgSuccess('已取消')
|
||||||
|
fetchList()
|
||||||
|
} catch {
|
||||||
|
// request 拦截器已有错误 toast
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const truncate = (text: string, len: number) => {
|
||||||
|
if (!text) return ''
|
||||||
|
return text.length > len ? text.slice(0, len) + '...' : text
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ refresh: fetchList })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.diagnosis-todo-list {
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-table {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-wrap {
|
||||||
|
margin-top: 12px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-danger {
|
||||||
|
color: var(--el-color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-muted {
|
||||||
|
color: var(--el-text-color-placeholder);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,369 @@
|
|||||||
|
<template>
|
||||||
|
<div class="card case-card">
|
||||||
|
<div class="card-title">
|
||||||
|
患者病例
|
||||||
|
<span class="case-sub">{{ caseTypeLabel }} · 诊断日期 {{ diag?.diagnosis_date || apt?.appointment_date || '—' }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 基本信息 -->
|
||||||
|
<div class="grid-block">
|
||||||
|
<div class="grid-title">基本信息</div>
|
||||||
|
<div class="kv-grid four">
|
||||||
|
<KVItem label="诊单ID" :value="diag?.patient_id" />
|
||||||
|
<KVItem label="姓名" :value="diag?.patient_name" />
|
||||||
|
<KVItem label="身份证号" :value="diag?.id_card" />
|
||||||
|
<KVItem label="手机号" :value="maskPhone(diag?.phone || apt?.patient_phone)" />
|
||||||
|
<KVItem label="性别" :value="textOf('gender')" />
|
||||||
|
<KVItem label="年龄" :value="diag?.age != null ? diag.age + '岁' : ''" />
|
||||||
|
<KVItem label="婚姻状态" :value="textOf('marital_status')" />
|
||||||
|
<KVItem label="地区" :value="diag?.region" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 生命体征 -->
|
||||||
|
<div class="grid-block">
|
||||||
|
<div class="grid-title">生命体征</div>
|
||||||
|
<div class="kv-grid four">
|
||||||
|
<KVItem label="身高" :value="diag?.height ? diag.height + ' cm' : ''" />
|
||||||
|
<KVItem label="体重" :value="diag?.weight ? diag.weight + ' kg' : ''" />
|
||||||
|
<MetricKVItem
|
||||||
|
label="高压"
|
||||||
|
:value="diag?.systolic_pressure"
|
||||||
|
unit=" mmHg"
|
||||||
|
:high="isHighSystolicPressure(diag?.systolic_pressure)"
|
||||||
|
/>
|
||||||
|
<MetricKVItem
|
||||||
|
label="低压"
|
||||||
|
:value="diag?.diastolic_pressure"
|
||||||
|
unit=" mmHg"
|
||||||
|
:high="isHighDiastolicPressure(diag?.diastolic_pressure)"
|
||||||
|
/>
|
||||||
|
<KVItem label="诊断类型" :value="textOf('diagnosis_type')" />
|
||||||
|
<MetricKVItem
|
||||||
|
label="空腹血糖"
|
||||||
|
:value="diag?.fasting_blood_sugar"
|
||||||
|
unit=" mmol/L"
|
||||||
|
:high="isHighFastingBloodSugar(diag?.fasting_blood_sugar, diag?.age)"
|
||||||
|
/>
|
||||||
|
<KVItem label="在用药物" :value="diag?.current_medications" multiline />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 主诉 -->
|
||||||
|
<div class="grid-block">
|
||||||
|
<div class="grid-title">主诉</div>
|
||||||
|
<div class="kv-grid four">
|
||||||
|
<KVItem label="当地医院诊断日期" :value="diag?.diagnosis_date" />
|
||||||
|
<KVItem
|
||||||
|
label="发现糖尿病病史"
|
||||||
|
:value="formatDiabetesDiscoveryDisplay
|
||||||
|
? formatDiabetesDiscoveryDisplay(diag?.diabetes_discovery_year)
|
||||||
|
: diag?.diabetes_discovery_year"
|
||||||
|
/>
|
||||||
|
<KVItem label="当地就诊医院" :value="diag?.local_hospital_name" />
|
||||||
|
<KVItem label="当地医院诊断结果" :value="joinArray(diag?.local_hospital_diagnosis)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 现病史(后端已翻译 *_text) -->
|
||||||
|
<div class="grid-block">
|
||||||
|
<div class="grid-title">现病史</div>
|
||||||
|
<div class="kv-grid three">
|
||||||
|
<KVItem label="口腔感觉" :value="textOf('appetite')" />
|
||||||
|
<KVItem label="每日饮水量" :value="textOf('water_intake')" />
|
||||||
|
<KVItem label="近月体重变化" :value="textOf('weight_change')" />
|
||||||
|
<KVItem label="脂肪肝程度" :value="textOf('fatty_liver_degree')" />
|
||||||
|
<KVItem label="饮食情况" :value="textOf('diet_condition')" span="2" />
|
||||||
|
<KVItem label="肢体感觉" :value="textOf('body_feeling')" />
|
||||||
|
<KVItem label="睡眠情况" :value="textOf('sleep_condition')" />
|
||||||
|
<KVItem label="眼睛情况" :value="textOf('eye_condition')" />
|
||||||
|
<KVItem label="头部感觉" :value="textOf('head_feeling')" />
|
||||||
|
<KVItem label="出汗情况" :value="textOf('sweat_condition')" />
|
||||||
|
<KVItem label="皮肤情况" :value="textOf('skin_condition')" />
|
||||||
|
<KVItem label="小便情况" :value="textOf('urine_condition')" />
|
||||||
|
<KVItem label="大便情况" :value="textOf('stool_condition')" />
|
||||||
|
<KVItem label="腰肾情况" :value="textOf('kidney_condition')" />
|
||||||
|
<KVItem label="其他补充" :value="diag?.symptoms" span="3" multiline />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 既往史 -->
|
||||||
|
<div class="grid-block">
|
||||||
|
<div class="grid-title">既往史</div>
|
||||||
|
<div class="kv-grid three">
|
||||||
|
<KVItem label="既往病史" :value="textOf('past_history')" span="3" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 其他病史 -->
|
||||||
|
<div class="grid-block">
|
||||||
|
<div class="grid-title">其他病史</div>
|
||||||
|
<div class="kv-grid three">
|
||||||
|
<KVItem label="外伤史" :value="textOf('trauma_history')" />
|
||||||
|
<KVItem label="手术史" :value="textOf('surgery_history')" />
|
||||||
|
<KVItem label="过敏史" :value="textOf('allergy_history')" />
|
||||||
|
<KVItem label="家族病史" :value="textOf('family_history')" />
|
||||||
|
<KVItem label="妊娠哺乳史" :value="textOf('pregnancy_history')" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 病史补充 -->
|
||||||
|
<div class="grid-block" v-if="diag?.remark">
|
||||||
|
<div class="grid-title">病史补充</div>
|
||||||
|
<div class="kv-grid one">
|
||||||
|
<KVItem label="" :value="diag.remark" multiline />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 处方意见 -->
|
||||||
|
<div class="grid-block">
|
||||||
|
<div class="grid-title">处方意见</div>
|
||||||
|
<div class="rx-note">
|
||||||
|
{{ apt?.has_prescription ? '已开方。' : '当前未开方。' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, h } from 'vue'
|
||||||
|
import {
|
||||||
|
isHighSystolicPressure,
|
||||||
|
isHighDiastolicPressure,
|
||||||
|
isHighFastingBloodSugar
|
||||||
|
} from '@/utils/blood-thresholds'
|
||||||
|
import { maskPhone, joinArray, makeTextOf } from '@/utils/diag-display'
|
||||||
|
import { formatDiabetesDiscoveryDisplay } from '@/utils/diabetes-discovery-display'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
apt?: Record<string, any> | null
|
||||||
|
diag?: Record<string, any> | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
apt: () => ({}),
|
||||||
|
diag: () => ({})
|
||||||
|
})
|
||||||
|
|
||||||
|
const caseTypeLabel = computed(() => {
|
||||||
|
const d: any = props.diag || {}
|
||||||
|
return d.consultation_type === 'follow_up' ? '复诊' : '初诊'
|
||||||
|
})
|
||||||
|
|
||||||
|
const textOf = makeTextOf(() => props.diag || {})
|
||||||
|
|
||||||
|
// ========== 本地函数式子组件 ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 局部渲染组件:左侧 label + 右侧 value,支持 span / 换行(与抽离前 reception 内联实现一致)
|
||||||
|
*/
|
||||||
|
const KVItem = (kvProps: { label: string; value: any; span?: string | number; multiline?: boolean }) => {
|
||||||
|
const val = kvProps.value
|
||||||
|
const empty =
|
||||||
|
val === undefined || val === null || val === '' || (Array.isArray(val) && val.length === 0)
|
||||||
|
return h(
|
||||||
|
'div',
|
||||||
|
{
|
||||||
|
class: ['kv-item', kvProps.multiline ? 'multiline' : '', empty ? 'empty' : ''],
|
||||||
|
style: kvProps.span ? `grid-column: span ${kvProps.span}` : ''
|
||||||
|
},
|
||||||
|
[
|
||||||
|
h('span', { class: 'kv-label' }, kvProps.label),
|
||||||
|
h('span', { class: 'kv-value' }, empty ? '—' : String(val))
|
||||||
|
]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数值型 KV:偏高时红色 + ↑ 箭头(与抽离前 reception 内联实现一致)
|
||||||
|
*/
|
||||||
|
const MetricKVItem = (mkvProps: {
|
||||||
|
label: string
|
||||||
|
value: any
|
||||||
|
unit?: string
|
||||||
|
span?: string | number
|
||||||
|
high?: boolean
|
||||||
|
}) => {
|
||||||
|
const empty = mkvProps.value === undefined || mkvProps.value === null || mkvProps.value === ''
|
||||||
|
return h(
|
||||||
|
'div',
|
||||||
|
{
|
||||||
|
class: ['kv-item', 'metric-kv-item', empty ? 'empty' : ''],
|
||||||
|
style: mkvProps.span ? `grid-column: span ${mkvProps.span}` : ''
|
||||||
|
},
|
||||||
|
[
|
||||||
|
h('span', { class: 'kv-label' }, mkvProps.label),
|
||||||
|
h(
|
||||||
|
'span',
|
||||||
|
{ class: ['kv-value', 'metric-kv-value', mkvProps.high ? 'high' : ''] },
|
||||||
|
empty
|
||||||
|
? '—'
|
||||||
|
: [
|
||||||
|
`${mkvProps.value}${mkvProps.unit || ''}`,
|
||||||
|
mkvProps.high ? h('span', { class: 'metric-up' }, '↑') : null
|
||||||
|
]
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.card {
|
||||||
|
background: var(--rx-surface);
|
||||||
|
border: 1px solid var(--rx-line);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 18px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
box-shadow: var(--rx-shadow-sm);
|
||||||
|
transition: box-shadow 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow: var(--rx-shadow);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--rx-ink);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding-left: 10px;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 3px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: linear-gradient(180deg, #4f8cff 0%, #2563eb 100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.case-sub {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--rx-soft);
|
||||||
|
font-family: var(--rx-font-data);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-block {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px dashed var(--rx-line);
|
||||||
|
|
||||||
|
&:first-of-type {
|
||||||
|
border-top: none;
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--rx-accent-strong);
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
width: 4px;
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--rx-accent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.kv-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px 16px;
|
||||||
|
|
||||||
|
&.one {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
&.two {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
&.three {
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
}
|
||||||
|
&.four {
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.kv-item) {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
line-height: 1.55;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
&.multiline {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kv-label {
|
||||||
|
color: var(--rx-soft);
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-width: 58px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kv-value {
|
||||||
|
color: var(--rx-ink);
|
||||||
|
word-break: break-word;
|
||||||
|
flex: 1;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.empty .kv-value {
|
||||||
|
color: #c0c4cc;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.multiline .kv-value {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.metric-kv-item .metric-kv-value) {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
|
||||||
|
&.high {
|
||||||
|
color: #dc2626;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.metric-up) {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1;
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rx-note {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--rx-muted);
|
||||||
|
line-height: 1.6;
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: linear-gradient(135deg, #fafbfd 0%, #f5f7fb 100%);
|
||||||
|
border-radius: 8px;
|
||||||
|
border-left: 3px solid var(--rx-accent);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
<template>
|
||||||
|
<div class="card patient-card">
|
||||||
|
<div class="card-title">患者信息</div>
|
||||||
|
<div class="patient-hero">
|
||||||
|
<div class="patient-name">{{ apt?.patient_name || '—' }}</div>
|
||||||
|
<div class="patient-meta">
|
||||||
|
<div>
|
||||||
|
{{ maskPhone(apt?.patient_phone) }} · {{ formatGender(diag?.gender) }} ·
|
||||||
|
{{ diag?.age != null ? diag.age + '岁' : '—' }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{{ diag?.height ? diag.height + 'cm' : '—' }} /
|
||||||
|
{{ diag?.weight ? diag.weight + 'kg' : '—' }} · {{ diag?.region || '—' }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
预约:{{ apt?.appointment_date }} {{ apt?.appointment_time }} ·
|
||||||
|
{{ formatPeriod(apt?.period) }}
|
||||||
|
</div>
|
||||||
|
<div>医生:{{ apt?.doctor_name || '—' }} 医助:{{ apt?.assistant_name || '—' }}</div>
|
||||||
|
<div>
|
||||||
|
状态:{{ apt?.status_desc || '—' }} ·
|
||||||
|
{{ apt?.has_prescription ? '已开方' : '未开方' }}
|
||||||
|
</div>
|
||||||
|
<div v-if="apt?.remark">备注:{{ apt.remark }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { maskPhone, formatGender, formatPeriod } from '@/utils/diag-display'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
apt?: Record<string, any> | null
|
||||||
|
diag?: Record<string, any> | null
|
||||||
|
}
|
||||||
|
|
||||||
|
withDefaults(defineProps<Props>(), {
|
||||||
|
apt: () => ({}),
|
||||||
|
diag: () => ({})
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.card {
|
||||||
|
background: var(--rx-surface);
|
||||||
|
border: 1px solid var(--rx-line);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 18px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
box-shadow: var(--rx-shadow-sm);
|
||||||
|
transition: box-shadow 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow: var(--rx-shadow);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--rx-ink);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding-left: 10px;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 3px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: linear-gradient(180deg, #4f8cff 0%, #2563eb 100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.patient-hero {
|
||||||
|
background: linear-gradient(135deg, #f5f8ff 0%, #eef3ff 100%);
|
||||||
|
border: 1px solid #dde7ff;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 16px 18px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
right: -20px;
|
||||||
|
top: -20px;
|
||||||
|
width: 100px;
|
||||||
|
height: 100px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: radial-gradient(circle, rgba(79, 140, 255, 0.08) 0%, transparent 70%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.patient-name {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--rx-ink);
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.patient-meta {
|
||||||
|
color: var(--rx-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.7;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,482 @@
|
|||||||
|
<template>
|
||||||
|
<div class="tracking-matrix" v-loading="loading">
|
||||||
|
<div class="toolbar">
|
||||||
|
<el-radio-group v-model="rangeMode" @change="onRangeModeChange">
|
||||||
|
<el-radio-button value="7">最近 7 天</el-radio-button>
|
||||||
|
<el-radio-button value="30">最近 30 天</el-radio-button>
|
||||||
|
<el-radio-button value="custom">自定义</el-radio-button>
|
||||||
|
</el-radio-group>
|
||||||
|
|
||||||
|
<el-date-picker
|
||||||
|
v-if="rangeMode === 'custom'"
|
||||||
|
v-model="customRange"
|
||||||
|
type="daterange"
|
||||||
|
value-format="YYYY-MM-DD"
|
||||||
|
range-separator="至"
|
||||||
|
start-placeholder="开始日期"
|
||||||
|
end-placeholder="结束日期"
|
||||||
|
:clearable="false"
|
||||||
|
size="default"
|
||||||
|
:disabled-date="disabledFutureDate"
|
||||||
|
@change="onCustomRangeChange"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<span class="hint">
|
||||||
|
超阈值红字↑(年龄分段:<50:空7/餐9,≥50:空8/餐10)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-empty
|
||||||
|
v-if="!loading && visibleDates.length === 0"
|
||||||
|
description="所选区间内无任何打卡记录"
|
||||||
|
:image-size="80"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<el-table
|
||||||
|
v-else
|
||||||
|
:data="rows"
|
||||||
|
border
|
||||||
|
stripe
|
||||||
|
class="matrix-table"
|
||||||
|
empty-text="暂无数据"
|
||||||
|
row-key="date"
|
||||||
|
max-height="640"
|
||||||
|
>
|
||||||
|
<el-table-column prop="dateLabel" label="日期" width="120" fixed="left" />
|
||||||
|
<el-table-column label="血糖 (mmol/L)" min-width="180">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div v-if="row.bloodAny" class="cell-blood">
|
||||||
|
<span :class="{ 'cell-high': row.fastingHigh }">
|
||||||
|
空
|
||||||
|
<span class="value">{{ row.fasting ?? '—' }}</span>
|
||||||
|
<span v-if="row.fastingHigh" class="cell-up">↑</span>
|
||||||
|
</span>
|
||||||
|
<span :class="{ 'cell-high': row.postprandialHigh }">
|
||||||
|
餐
|
||||||
|
<span class="value">{{ row.postprandial ?? '—' }}</span>
|
||||||
|
<span v-if="row.postprandialHigh" class="cell-up">↑</span>
|
||||||
|
</span>
|
||||||
|
<span :class="{ 'cell-high': row.otherHigh }">
|
||||||
|
其他
|
||||||
|
<span class="value">{{ row.other ?? '—' }}</span>
|
||||||
|
<span v-if="row.otherHigh" class="cell-up">↑</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span v-else class="cell-empty">—</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="血压 (mmHg)" width="120" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span v-if="row.bp" :class="{ 'cell-high': row.bpHigh }">
|
||||||
|
{{ row.bp }}
|
||||||
|
<span v-if="row.bpHigh" class="cell-up">↑</span>
|
||||||
|
</span>
|
||||||
|
<span v-else class="cell-empty">—</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="西药" min-width="120" show-overflow-tooltip>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span v-if="row.westernMedicine">{{ row.westernMedicine }}</span>
|
||||||
|
<span v-else class="cell-empty">—</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="胰岛素" min-width="120" show-overflow-tooltip>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span v-if="row.insulin">{{ row.insulin }}</span>
|
||||||
|
<span v-else class="cell-empty">—</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="饮食打卡" width="110" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tooltip
|
||||||
|
v-if="row.diet"
|
||||||
|
placement="top"
|
||||||
|
:disabled="!row.diet"
|
||||||
|
effect="light"
|
||||||
|
>
|
||||||
|
<template #content>
|
||||||
|
<div class="diet-tip">
|
||||||
|
<div>早:{{ row.diet.breakfast || '—' }}</div>
|
||||||
|
<div>午:{{ row.diet.lunch || '—' }}</div>
|
||||||
|
<div>晚:{{ row.diet.dinner || '—' }}</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<span class="cell-checked">已打卡</span>
|
||||||
|
</el-tooltip>
|
||||||
|
<span v-else class="cell-empty">—</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="运动打卡" min-width="160" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tooltip v-if="row.exercise" placement="top" effect="light">
|
||||||
|
<template #content>
|
||||||
|
<div class="exercise-tip">
|
||||||
|
<div>强度:{{ row.exercise.intensityText || '—' }}</div>
|
||||||
|
<div>时长:{{ row.exercise.duration ? row.exercise.duration + ' min' : '—' }}</div>
|
||||||
|
<div v-if="row.exercise.remark">备注:{{ row.exercise.remark }}</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<span class="cell-checked">
|
||||||
|
已打卡
|
||||||
|
<span v-if="row.exercise.duration" class="meta">· {{ row.exercise.duration }}min</span>
|
||||||
|
<span v-if="row.exercise.intensityText" class="meta">· {{ row.exercise.intensityText }}</span>
|
||||||
|
</span>
|
||||||
|
</el-tooltip>
|
||||||
|
<span v-else class="cell-empty">—</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
|
import {
|
||||||
|
isHighFastingBloodSugar,
|
||||||
|
isHighPostprandialBloodSugar,
|
||||||
|
isHighOtherBloodSugar,
|
||||||
|
isHighBloodPressure,
|
||||||
|
formatBp
|
||||||
|
} from '@/utils/blood-thresholds'
|
||||||
|
import { diagnosisTrackingWindow } from '@/api/tcm'
|
||||||
|
|
||||||
|
interface BloodRecord {
|
||||||
|
record_date?: string
|
||||||
|
record_date_ts?: number
|
||||||
|
fasting_blood_sugar?: any
|
||||||
|
postprandial_blood_sugar?: any
|
||||||
|
other_blood_sugar?: any
|
||||||
|
systolic_pressure?: any
|
||||||
|
diastolic_pressure?: any
|
||||||
|
western_medicine?: string
|
||||||
|
insulin?: string
|
||||||
|
[key: string]: any
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DietRecord {
|
||||||
|
record_date?: string
|
||||||
|
breakfast?: string
|
||||||
|
lunch?: string
|
||||||
|
dinner?: string
|
||||||
|
[key: string]: any
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExerciseRecord {
|
||||||
|
record_date?: string
|
||||||
|
duration?: number
|
||||||
|
intensity?: number
|
||||||
|
intensity_text?: string
|
||||||
|
remark?: string
|
||||||
|
[key: string]: any
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
diagnosisId: number
|
||||||
|
age?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
age: null
|
||||||
|
})
|
||||||
|
|
||||||
|
type RangeMode = '7' | '30' | 'custom'
|
||||||
|
const rangeMode = ref<RangeMode>('30')
|
||||||
|
const customRange = ref<[string, string] | null>(null)
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const bloodRecords = ref<BloodRecord[]>([])
|
||||||
|
const dietRecords = ref<DietRecord[]>([])
|
||||||
|
const exerciseRecords = ref<ExerciseRecord[]>([])
|
||||||
|
|
||||||
|
let requestSeq = 0
|
||||||
|
|
||||||
|
/** 把今天本地时间格式成 Y-m-d,避免 toISOString 的时区漂移 */
|
||||||
|
const formatDate = (d: Date): string =>
|
||||||
|
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||||
|
|
||||||
|
const computeRange = (mode: RangeMode): { start: string; end: string } => {
|
||||||
|
if (mode === 'custom') {
|
||||||
|
const [s, e] = customRange.value || ['', '']
|
||||||
|
return { start: s || '', end: e || '' }
|
||||||
|
}
|
||||||
|
const days = mode === '7' ? 7 : 30
|
||||||
|
const end = new Date()
|
||||||
|
end.setHours(0, 0, 0, 0)
|
||||||
|
const start = new Date(end)
|
||||||
|
start.setDate(start.getDate() - (days - 1))
|
||||||
|
return { start: formatDate(start), end: formatDate(end) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const disabledFutureDate = (date: Date) => date.getTime() > Date.now()
|
||||||
|
|
||||||
|
const fetchTracking = async () => {
|
||||||
|
if (!props.diagnosisId || props.diagnosisId <= 0) {
|
||||||
|
bloodRecords.value = []
|
||||||
|
dietRecords.value = []
|
||||||
|
exerciseRecords.value = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const range = computeRange(rangeMode.value)
|
||||||
|
if (rangeMode.value === 'custom' && (!range.start || !range.end)) {
|
||||||
|
// 等待用户选完日期
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const seq = ++requestSeq
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res: any = await diagnosisTrackingWindow({
|
||||||
|
id: props.diagnosisId,
|
||||||
|
start_date: range.start,
|
||||||
|
end_date: range.end
|
||||||
|
})
|
||||||
|
if (seq !== requestSeq) return
|
||||||
|
bloodRecords.value = res?.blood_records || []
|
||||||
|
dietRecords.value = res?.diet_records || []
|
||||||
|
exerciseRecords.value = res?.exercise_records || []
|
||||||
|
} catch {
|
||||||
|
if (seq === requestSeq) {
|
||||||
|
bloodRecords.value = []
|
||||||
|
dietRecords.value = []
|
||||||
|
exerciseRecords.value = []
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (seq === requestSeq) loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onRangeModeChange = () => {
|
||||||
|
if (rangeMode.value === 'custom') {
|
||||||
|
// 默认填入「最近 30 天」作为初值,让用户在此基础上调整
|
||||||
|
if (!customRange.value) {
|
||||||
|
const r = computeRange('30')
|
||||||
|
customRange.value = [r.start, r.end]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchTracking()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onCustomRangeChange = () => {
|
||||||
|
if (rangeMode.value === 'custom') fetchTracking()
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.diagnosisId,
|
||||||
|
(id) => {
|
||||||
|
if (id && id > 0) fetchTracking()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (props.diagnosisId && props.diagnosisId > 0) fetchTracking()
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把同一天可能多条的血糖记录聚合:每段取第一条非空值(与 BloodRecordLogic 趋势图一致)
|
||||||
|
*/
|
||||||
|
const bloodByDate = computed(() => {
|
||||||
|
const map = new Map<string, BloodRecord>()
|
||||||
|
for (const r of bloodRecords.value) {
|
||||||
|
const d = String(r.record_date || '')
|
||||||
|
if (!d) continue
|
||||||
|
if (!map.has(d)) {
|
||||||
|
map.set(d, {
|
||||||
|
record_date: d,
|
||||||
|
record_date_ts: r.record_date_ts
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const acc = map.get(d)!
|
||||||
|
const fields: Array<keyof BloodRecord> = [
|
||||||
|
'fasting_blood_sugar',
|
||||||
|
'postprandial_blood_sugar',
|
||||||
|
'other_blood_sugar',
|
||||||
|
'systolic_pressure',
|
||||||
|
'diastolic_pressure',
|
||||||
|
'western_medicine',
|
||||||
|
'insulin'
|
||||||
|
]
|
||||||
|
for (const f of fields) {
|
||||||
|
if (
|
||||||
|
(acc[f] === undefined || acc[f] === null || acc[f] === '') &&
|
||||||
|
r[f] !== undefined &&
|
||||||
|
r[f] !== null &&
|
||||||
|
r[f] !== ''
|
||||||
|
) {
|
||||||
|
;(acc as any)[f] = r[f]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
})
|
||||||
|
|
||||||
|
const dietByDate = computed(() => {
|
||||||
|
const map = new Map<string, DietRecord>()
|
||||||
|
for (const r of dietRecords.value) {
|
||||||
|
const d = String(r.record_date || '')
|
||||||
|
if (!d) continue
|
||||||
|
if (!map.has(d)) map.set(d, r)
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
})
|
||||||
|
|
||||||
|
const exerciseByDate = computed(() => {
|
||||||
|
const map = new Map<string, ExerciseRecord>()
|
||||||
|
for (const r of exerciseRecords.value) {
|
||||||
|
const d = String(r.record_date || '')
|
||||||
|
if (!d) continue
|
||||||
|
if (!map.has(d)) map.set(d, r)
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 已经由后端按区间过滤,前端只需取三类记录的日期并集 + desc 排序
|
||||||
|
*/
|
||||||
|
const visibleDates = computed<string[]>(() => {
|
||||||
|
const set = new Set<string>()
|
||||||
|
for (const r of bloodRecords.value) if (r.record_date) set.add(String(r.record_date))
|
||||||
|
for (const r of dietRecords.value) if (r.record_date) set.add(String(r.record_date))
|
||||||
|
for (const r of exerciseRecords.value) if (r.record_date) set.add(String(r.record_date))
|
||||||
|
const list = Array.from(set).filter((d) => d.length === 10)
|
||||||
|
list.sort((a, b) => (a < b ? 1 : a > b ? -1 : 0))
|
||||||
|
return list
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
const rows = computed(() =>
|
||||||
|
visibleDates.value.map((date) => {
|
||||||
|
const blood = bloodByDate.value.get(date)
|
||||||
|
const diet = dietByDate.value.get(date)
|
||||||
|
const exercise = exerciseByDate.value.get(date)
|
||||||
|
|
||||||
|
const fasting = blood?.fasting_blood_sugar ?? null
|
||||||
|
const postprandial = blood?.postprandial_blood_sugar ?? null
|
||||||
|
const other = blood?.other_blood_sugar ?? null
|
||||||
|
const sys = blood?.systolic_pressure ?? null
|
||||||
|
const dia = blood?.diastolic_pressure ?? null
|
||||||
|
|
||||||
|
const bloodAny =
|
||||||
|
(fasting !== null && fasting !== '') ||
|
||||||
|
(postprandial !== null && postprandial !== '') ||
|
||||||
|
(other !== null && other !== '')
|
||||||
|
|
||||||
|
const bp = sys || dia ? formatBp({ systolic_pressure: sys, diastolic_pressure: dia }) : ''
|
||||||
|
|
||||||
|
return {
|
||||||
|
date,
|
||||||
|
dateLabel: `${date}`,
|
||||||
|
// 血糖
|
||||||
|
bloodAny,
|
||||||
|
fasting: fasting ?? '—',
|
||||||
|
postprandial: postprandial ?? '—',
|
||||||
|
other: other ?? '—',
|
||||||
|
fastingHigh: isHighFastingBloodSugar(fasting, props.age),
|
||||||
|
postprandialHigh: isHighPostprandialBloodSugar(postprandial, props.age),
|
||||||
|
otherHigh: isHighOtherBloodSugar(other, props.age),
|
||||||
|
// 血压
|
||||||
|
bp,
|
||||||
|
bpHigh: isHighBloodPressure({ systolic_pressure: sys, diastolic_pressure: dia }),
|
||||||
|
// 用药
|
||||||
|
westernMedicine: blood?.western_medicine || '',
|
||||||
|
insulin: blood?.insulin || '',
|
||||||
|
// 饮食
|
||||||
|
diet: diet
|
||||||
|
? {
|
||||||
|
breakfast: diet.breakfast || '',
|
||||||
|
lunch: diet.lunch || '',
|
||||||
|
dinner: diet.dinner || ''
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
// 运动
|
||||||
|
exercise: exercise
|
||||||
|
? {
|
||||||
|
duration: exercise.duration || 0,
|
||||||
|
intensityText: exercise.intensity_text || '',
|
||||||
|
remark: exercise.remark || ''
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
defineExpose({ refresh: fetchTracking })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.tracking-matrix {
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.matrix-table {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-blood {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
line-height: 1.55;
|
||||||
|
|
||||||
|
> span {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
|
||||||
|
.value {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-high {
|
||||||
|
color: #dc2626;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-up {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-empty {
|
||||||
|
color: var(--el-text-color-placeholder);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-checked {
|
||||||
|
color: #2563eb;
|
||||||
|
font-weight: 500;
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
font-weight: 400;
|
||||||
|
margin-left: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.diet-tip,
|
||||||
|
.exercise-tip {
|
||||||
|
font-size: 12.5px;
|
||||||
|
line-height: 1.55;
|
||||||
|
|
||||||
|
> div {
|
||||||
|
margin-bottom: 2px;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
<template>
|
||||||
|
<div class="tracking-timeline-wrap">
|
||||||
|
<!-- 编辑模式:输入新备注 -->
|
||||||
|
<div v-if="!readonly && diagnosisId" class="timeline-input">
|
||||||
|
<el-input
|
||||||
|
v-model="draft"
|
||||||
|
type="textarea"
|
||||||
|
:rows="2"
|
||||||
|
placeholder="输入跟踪备注,回车换行;保存后将以「[HH:MM] 内容」追加到当天记录"
|
||||||
|
maxlength="1000"
|
||||||
|
show-word-limit
|
||||||
|
resize="none"
|
||||||
|
:disabled="saving"
|
||||||
|
/>
|
||||||
|
<div class="input-actions">
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
:loading="saving"
|
||||||
|
:disabled="!draft.trim()"
|
||||||
|
@click="handleSave"
|
||||||
|
>
|
||||||
|
添加
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 时间轴 -->
|
||||||
|
<div v-if="notes.length" class="tracking-timeline">
|
||||||
|
<div v-for="note in notes" :key="note.id" class="timeline-node">
|
||||||
|
<div class="timeline-dot"></div>
|
||||||
|
<div class="timeline-date">{{ note.note_date }}</div>
|
||||||
|
<div class="timeline-body">
|
||||||
|
<div v-if="note.content" class="timeline-content">
|
||||||
|
<div
|
||||||
|
v-for="(line, idx) in splitLines(note.content)"
|
||||||
|
:key="idx"
|
||||||
|
class="content-line"
|
||||||
|
>
|
||||||
|
{{ line }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-empty v-if="!notes.length && readonly" description="暂无跟踪备注" :image-size="48" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { diagnosisAddTrackingNote } from '@/api/tcm'
|
||||||
|
import feedback from '@/utils/feedback'
|
||||||
|
|
||||||
|
export interface TrackingNoteItem {
|
||||||
|
id: number
|
||||||
|
note_date: string
|
||||||
|
content: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
notes: TrackingNoteItem[]
|
||||||
|
diagnosisId?: number
|
||||||
|
readonly?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{ refresh: [] }>()
|
||||||
|
|
||||||
|
const draft = ref('')
|
||||||
|
const saving = ref(false)
|
||||||
|
|
||||||
|
const splitLines = (content: string | null): string[] => {
|
||||||
|
if (!content) return []
|
||||||
|
return content.split('\n').filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!props.diagnosisId) return
|
||||||
|
const content = draft.value.trim()
|
||||||
|
if (!content) return
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
await diagnosisAddTrackingNote({
|
||||||
|
diagnosis_id: props.diagnosisId,
|
||||||
|
tracking_content: content,
|
||||||
|
})
|
||||||
|
feedback.msgSuccess('已添加')
|
||||||
|
draft.value = ''
|
||||||
|
emit('refresh')
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.tracking-timeline-wrap {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-input {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
border-bottom: 1px dashed #ebeef5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tracking-timeline {
|
||||||
|
position: relative;
|
||||||
|
padding-left: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tracking-timeline::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 6px;
|
||||||
|
top: 4px;
|
||||||
|
bottom: 4px;
|
||||||
|
width: 2px;
|
||||||
|
background: #ebeef5;
|
||||||
|
border-radius: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-node {
|
||||||
|
position: relative;
|
||||||
|
padding-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-node:last-child {
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-dot {
|
||||||
|
position: absolute;
|
||||||
|
left: -19px;
|
||||||
|
top: 5px;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #16a34a;
|
||||||
|
border: 2px solid #dcfce7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-date {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #303133;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-family: 'IBM Plex Mono', Consolas, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-line {
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: #606266;
|
||||||
|
line-height: 1.6;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -562,6 +562,17 @@
|
|||||||
</div>
|
</div>
|
||||||
<el-empty v-else description="请先保存诊单后查看" />
|
<el-empty v-else description="请先保存诊单后查看" />
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
|
<el-tab-pane label="跟踪备注" name="trackingNote" :disabled="!formData.id" lazy>
|
||||||
|
<div v-if="formData.id" class="p-4">
|
||||||
|
<TrackingNoteTimeline
|
||||||
|
:notes="trackingNotes"
|
||||||
|
:diagnosis-id="Number(formData.id)"
|
||||||
|
:readonly="viewOnly"
|
||||||
|
@refresh="fetchTrackingNotes"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<el-empty v-else description="请先保存诊单后查看" />
|
||||||
|
</el-tab-pane>
|
||||||
<!-- 血糖血压记录标签页 -->
|
<!-- 血糖血压记录标签页 -->
|
||||||
<el-tab-pane label="血糖血压记录" name="blood" :disabled="!formData.id">
|
<el-tab-pane label="血糖血压记录" name="blood" :disabled="!formData.id">
|
||||||
<blood-record-list
|
<blood-record-list
|
||||||
@@ -594,7 +605,24 @@
|
|||||||
/>
|
/>
|
||||||
<el-empty v-else description="请先保存诊单后再添加运动打卡记录" />
|
<el-empty v-else description="请先保存诊单后再添加运动打卡记录" />
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<!-- 待办事项标签页(T5:企微推送给创建人本人) -->
|
||||||
|
<el-tab-pane
|
||||||
|
label="待办事项"
|
||||||
|
name="todo"
|
||||||
|
:disabled="!formData.id"
|
||||||
|
lazy
|
||||||
|
v-if="hasPermission(['tcm.diagnosisTodo/lists'])"
|
||||||
|
>
|
||||||
|
<diagnosis-todo-list
|
||||||
|
v-if="formData.id"
|
||||||
|
:diagnosis-id="Number(formData.id)"
|
||||||
|
:patient-id="Number(formData.patient_id)"
|
||||||
|
:patient-name="formData.patient_name"
|
||||||
|
/>
|
||||||
|
<el-empty v-else description="请先保存诊单后再添加待办事项" />
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
<!-- 病历记录标签页(开方后自动显示) -->
|
<!-- 病历记录标签页(开方后自动显示) -->
|
||||||
<el-tab-pane label="处方" name="bingli" :disabled="!formData.id" v-if="hasPermission(['tcm.diagnosis/chufang'])">
|
<el-tab-pane label="处方" name="bingli" :disabled="!formData.id" v-if="hasPermission(['tcm.diagnosis/chufang'])">
|
||||||
<div v-if="formData.id" class="bingli-tab-content">
|
<div v-if="formData.id" class="bingli-tab-content">
|
||||||
@@ -694,7 +722,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { tcmDiagnosisAdd, tcmDiagnosisEdit, tcmDiagnosisDetail, checkPhone, checkIdCard } from '@/api/tcm'
|
import { tcmDiagnosisAdd, tcmDiagnosisEdit, tcmDiagnosisDetail, checkPhone, checkIdCard, diagnosisTrackingNotes } from '@/api/tcm'
|
||||||
import { getDictData } from '@/api/app'
|
import { getDictData } from '@/api/app'
|
||||||
import feedback from '@/utils/feedback'
|
import feedback from '@/utils/feedback'
|
||||||
import { hasPermission } from '@/utils/perm'
|
import { hasPermission } from '@/utils/perm'
|
||||||
@@ -705,12 +733,14 @@ import { QuestionFilled } from '@element-plus/icons-vue'
|
|||||||
import BloodRecordList from './components/BloodRecordList.vue'
|
import BloodRecordList from './components/BloodRecordList.vue'
|
||||||
import DietRecordList from './components/DietRecordList.vue'
|
import DietRecordList from './components/DietRecordList.vue'
|
||||||
import ExerciseRecordList from './components/ExerciseRecordList.vue'
|
import ExerciseRecordList from './components/ExerciseRecordList.vue'
|
||||||
|
import DiagnosisTodoList from './components/DiagnosisTodoList.vue'
|
||||||
import CaseRecordList from './components/CaseRecordList.vue'
|
import CaseRecordList from './components/CaseRecordList.vue'
|
||||||
import CallRecordPanel from './components/CallRecordPanel.vue'
|
import CallRecordPanel from './components/CallRecordPanel.vue'
|
||||||
import ImChatRecordPanel from './components/ImChatRecordPanel.vue'
|
import ImChatRecordPanel from './components/ImChatRecordPanel.vue'
|
||||||
import AssignLogPanel from './components/AssignLogPanel.vue'
|
import AssignLogPanel from './components/AssignLogPanel.vue'
|
||||||
import AppointmentRecordPanel from './components/AppointmentRecordPanel.vue'
|
import AppointmentRecordPanel from './components/AppointmentRecordPanel.vue'
|
||||||
import NoteTimeline from '@/views/patient/reception/components/NoteTimeline.vue'
|
import NoteTimeline from '@/views/patient/reception/components/NoteTimeline.vue'
|
||||||
|
import TrackingNoteTimeline from './components/TrackingNoteTimeline.vue'
|
||||||
import TcmPrescription from '@/components/tcm-prescription/index.vue'
|
import TcmPrescription from '@/components/tcm-prescription/index.vue'
|
||||||
import { getDoctorNotes } from '@/api/patient'
|
import { getDoctorNotes } from '@/api/patient'
|
||||||
|
|
||||||
@@ -748,10 +778,22 @@ const fetchDiagNotes = async () => {
|
|||||||
} catch { diagNotes.value = [] }
|
} catch { diagNotes.value = [] }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const trackingNotes = ref<any[]>([])
|
||||||
|
|
||||||
|
const fetchTrackingNotes = async () => {
|
||||||
|
if (!formData.value.id) return
|
||||||
|
try {
|
||||||
|
trackingNotes.value = await diagnosisTrackingNotes({ diagnosis_id: Number(formData.value.id) }) || []
|
||||||
|
} catch { trackingNotes.value = [] }
|
||||||
|
}
|
||||||
|
|
||||||
watch(() => activeTab.value, async (tab) => {
|
watch(() => activeTab.value, async (tab) => {
|
||||||
if (tab === 'doctorNote' && formData.value.id) {
|
if (tab === 'doctorNote' && formData.value.id) {
|
||||||
await fetchDiagNotes()
|
await fetchDiagNotes()
|
||||||
}
|
}
|
||||||
|
if (tab === 'trackingNote' && formData.value.id) {
|
||||||
|
await fetchTrackingNotes()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
watch([visible, activeTab], () => {
|
watch([visible, activeTab], () => {
|
||||||
|
|||||||
@@ -116,11 +116,12 @@
|
|||||||
<div v-if="selectedIds.length > 0" class="list-selected">已选 {{ selectedIds.length }} 条</div>
|
<div v-if="selectedIds.length > 0" class="list-selected">已选 {{ selectedIds.length }} 条</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="table-wrap px-4">
|
<div class="table-wrap px-4">
|
||||||
<el-table
|
<el-table
|
||||||
:data="pager.lists"
|
:data="pager.lists"
|
||||||
size="default"
|
size="default"
|
||||||
v-loading="pager.loading"
|
v-loading="pager.loading"
|
||||||
@selection-change="handleSelectionChange"
|
@selection-change="handleSelectionChange"
|
||||||
|
@row-dblclick="goReadonly"
|
||||||
:row-class-name="getRowClassName"
|
:row-class-name="getRowClassName"
|
||||||
class="diagnosis-table"
|
class="diagnosis-table"
|
||||||
stripe
|
stripe
|
||||||
@@ -215,6 +216,20 @@
|
|||||||
<span v-else class="status-unprescribed">未开方</span>
|
<span v-else class="status-unprescribed">未开方</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
<el-table-column label="未服务天数" width="110" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tooltip
|
||||||
|
v-if="row.last_blood_record_at"
|
||||||
|
:content="`最近一次血糖打卡:${row.last_blood_record_at}`"
|
||||||
|
placement="top"
|
||||||
|
>
|
||||||
|
<span class="unserved-days" :class="unservedDaysClass(row.unserved_days)">
|
||||||
|
{{ row.unserved_days ?? '—' }}
|
||||||
|
</span>
|
||||||
|
</el-tooltip>
|
||||||
|
<span v-else class="unserved-days unserved-muted">—</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column label="视频旁观" width="120" fixed="right" align="center">
|
<el-table-column label="视频旁观" width="120" fixed="right" align="center">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div class="video-watch-col">
|
<div class="video-watch-col">
|
||||||
@@ -239,9 +254,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="300" fixed="right" align="left">
|
<el-table-column label="操作" width="340" fixed="right" align="left">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div class="action-cell">
|
<div class="action-cell" @click.stop @dblclick.stop>
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
link
|
||||||
|
size="small"
|
||||||
|
@click="goReadonly(row)"
|
||||||
|
v-perms="['tcm.diagnosis/readonlyDetail']"
|
||||||
|
>
|
||||||
|
查看
|
||||||
|
</el-button>
|
||||||
<el-button type="primary" link size="small" @click="handleEdit(row.id)" v-perms="['tcm.diagnosis/edit']">诊单</el-button>
|
<el-button type="primary" link size="small" @click="handleEdit(row.id)" v-perms="['tcm.diagnosis/edit']">诊单</el-button>
|
||||||
<el-button
|
<el-button
|
||||||
v-perms="['tcm.diagnosis/kaifang']"
|
v-perms="['tcm.diagnosis/kaifang']"
|
||||||
@@ -616,7 +640,7 @@ import feedback from '@/utils/feedback'
|
|||||||
import { hasPermission } from '@/utils/perm'
|
import { hasPermission } from '@/utils/perm'
|
||||||
import { Loading, Warning, List, CircleCheck, Clock, ArrowDown, Picture, User, Document, Delete, CircleClose, Remove } from '@element-plus/icons-vue'
|
import { Loading, Warning, List, CircleCheck, Clock, ArrowDown, Picture, User, Document, Delete, CircleClose, Remove } from '@element-plus/icons-vue'
|
||||||
import useUserStore from '@/stores/modules/user'
|
import useUserStore from '@/stores/modules/user'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { computed, defineAsyncComponent, onMounted, onUnmounted, watch } from 'vue'
|
import { computed, defineAsyncComponent, onMounted, onUnmounted, watch } from 'vue'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
|
|
||||||
@@ -944,6 +968,19 @@ const isDiagnosisConfirmed = (row: any) => {
|
|||||||
return records.length > 0 && records.some((r: any) => r.is_confirmed == 1)
|
return records.length > 0 && records.some((r: any) => r.is_confirmed == 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「未服务天数」着色规则(计划 T3):
|
||||||
|
* null/undefined → 灰;>=7 → 红;3-6 → 橙;<=2 → 绿
|
||||||
|
*/
|
||||||
|
const unservedDaysClass = (n: unknown) => {
|
||||||
|
if (n === null || n === undefined) return 'unserved-muted'
|
||||||
|
const num = Number(n)
|
||||||
|
if (!Number.isFinite(num)) return 'unserved-muted'
|
||||||
|
if (num >= 7) return 'unserved-red'
|
||||||
|
if (num >= 3) return 'unserved-orange'
|
||||||
|
return 'unserved-green'
|
||||||
|
}
|
||||||
|
|
||||||
// 行高亮:未确认诊单优先,其次未挂号
|
// 行高亮:未确认诊单优先,其次未挂号
|
||||||
const getRowClassName = ({ row }: { row: any }) => {
|
const getRowClassName = ({ row }: { row: any }) => {
|
||||||
if (!isDiagnosisConfirmed(row)) return 'row-unconfirmed'
|
if (!isDiagnosisConfirmed(row)) return 'row-unconfirmed'
|
||||||
@@ -1706,6 +1743,17 @@ const handleDeleteRecord = async (id: number) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
/** 跳到只读病例页(T1+T2):双击行 / 点「查看」按钮均触发 */
|
||||||
|
const goReadonly = (row: any) => {
|
||||||
|
// 没分配 readonlyDetail 权限的管理员:双击行也不跳转(按钮已用 v-perms 隐藏)
|
||||||
|
if (!hasPermission(['tcm.diagnosis/readonlyDetail'])) return
|
||||||
|
const id = Number(row?.id)
|
||||||
|
if (!id || id <= 0) return
|
||||||
|
router.push({ path: '/tcm/diagnosis-readonly', query: { id: String(id) } })
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
// 默认展示当天挂号,突出重点
|
// 默认展示当天挂号,突出重点
|
||||||
formData.appointment_date = todayStr.value
|
formData.appointment_date = todayStr.value
|
||||||
@@ -2091,6 +2139,33 @@ onUnmounted(() => {
|
|||||||
color: var(--el-text-color-placeholder);
|
color: var(--el-text-color-placeholder);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 未服务天数(T3):null=灰;>=7=红;3-6=橙;<=2=绿 */
|
||||||
|
.unserved-days {
|
||||||
|
display: inline-block;
|
||||||
|
min-width: 28px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unserved-muted {
|
||||||
|
color: var(--el-text-color-placeholder);
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unserved-green {
|
||||||
|
color: #16a34a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unserved-orange {
|
||||||
|
color: #ea580c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unserved-red {
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
.diagnosis-cell {
|
.diagnosis-cell {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -0,0 +1,283 @@
|
|||||||
|
<template>
|
||||||
|
<div class="readonly-page" v-loading="loading">
|
||||||
|
<!-- 顶部 hero -->
|
||||||
|
<div class="readonly-hero">
|
||||||
|
<div class="hero-left">
|
||||||
|
<el-button link size="default" @click="goBack" class="back-btn">
|
||||||
|
<el-icon><ArrowLeft /></el-icon>
|
||||||
|
<span>返回</span>
|
||||||
|
</el-button>
|
||||||
|
<span class="title">患者信息详情</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="diag?.patient_name" class="hero-right">
|
||||||
|
<span class="patient-name">{{ diag.patient_name }}</span>
|
||||||
|
<span class="patient-meta">
|
||||||
|
{{ formatGender(diag.gender) }}
|
||||||
|
<template v-if="diag.age != null"> · {{ diag.age }}岁</template>
|
||||||
|
</span>
|
||||||
|
<el-tooltip
|
||||||
|
v-if="lastBloodAt"
|
||||||
|
:content="`最近一次血糖打卡:${lastBloodAt}`"
|
||||||
|
placement="bottom"
|
||||||
|
>
|
||||||
|
<span :class="['unserved-badge', unservedDaysClass(unservedDays)]">
|
||||||
|
未服务 {{ unservedDays ?? '—' }} 天
|
||||||
|
</span>
|
||||||
|
</el-tooltip>
|
||||||
|
<span v-else class="unserved-badge unserved-muted">从未打卡血糖</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 加载失败 / 越权 -->
|
||||||
|
<el-empty
|
||||||
|
v-if="!loading && !detail"
|
||||||
|
:description="errorMsg || '诊单不存在或无权访问'"
|
||||||
|
class="empty-state"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<template v-else-if="detail">
|
||||||
|
<PatientInfoCard :apt="apt" :diag="diag" />
|
||||||
|
<PatientCaseCard :apt="apt" :diag="diag" />
|
||||||
|
|
||||||
|
<!-- 跟踪信息 -->
|
||||||
|
<div class="card tracking-card">
|
||||||
|
<div class="card-title">跟踪信息</div>
|
||||||
|
<TrackingMatrix
|
||||||
|
:diagnosis-id="Number(diagnosisIdNum)"
|
||||||
|
:age="diag?.age ?? null"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 医生备注(复用 reception 的 NoteTimeline,但只读) -->
|
||||||
|
<div v-if="doctorNotes.length" class="card note-card">
|
||||||
|
<div class="card-title">医生备注 & 舌苔照片 & 检查报告</div>
|
||||||
|
<NoteTimeline :notes="doctorNotes" :diagnosis-id="diag?.id" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 跟踪备注(只读时间轴) -->
|
||||||
|
<div class="card tracking-note-card">
|
||||||
|
<div class="card-title">跟踪备注</div>
|
||||||
|
<TrackingNoteTimeline :notes="trackingNotes" readonly />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { ArrowLeft } from '@element-plus/icons-vue'
|
||||||
|
import { diagnosisReadonlyDetail } from '@/api/tcm'
|
||||||
|
import { formatGender } from '@/utils/diag-display'
|
||||||
|
import PatientInfoCard from './components/PatientInfoCard.vue'
|
||||||
|
import PatientCaseCard from './components/PatientCaseCard.vue'
|
||||||
|
import TrackingMatrix from './components/TrackingMatrix.vue'
|
||||||
|
import TrackingNoteTimeline from './components/TrackingNoteTimeline.vue'
|
||||||
|
import NoteTimeline from '@/views/patient/reception/components/NoteTimeline.vue'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const diagnosisIdNum = computed(() => Number(route.query.id || 0))
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const errorMsg = ref('')
|
||||||
|
const detail = ref<any>(null)
|
||||||
|
|
||||||
|
const apt = computed<any>(() => detail.value?.appointment || {})
|
||||||
|
const diag = computed<any>(() => detail.value?.diagnosis || {})
|
||||||
|
const doctorNotes = computed<any[]>(() => detail.value?.doctor_notes || [])
|
||||||
|
const trackingNotes = computed<any[]>(() => detail.value?.tracking_notes || [])
|
||||||
|
const unservedDays = computed<number | null>(() => {
|
||||||
|
const v = detail.value?.unserved_days
|
||||||
|
return v === null || v === undefined ? null : Number(v)
|
||||||
|
})
|
||||||
|
const lastBloodAt = computed<string>(() => detail.value?.last_blood_record_at || '')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「未服务天数」着色规则(与 T3 列表一致):
|
||||||
|
* null/undefined → 灰;>=7 → 红;3-6 → 橙;<=2 → 绿
|
||||||
|
*/
|
||||||
|
const unservedDaysClass = (n: unknown) => {
|
||||||
|
if (n === null || n === undefined) return 'unserved-muted'
|
||||||
|
const num = Number(n)
|
||||||
|
if (!Number.isFinite(num)) return 'unserved-muted'
|
||||||
|
if (num >= 7) return 'unserved-red'
|
||||||
|
if (num >= 3) return 'unserved-orange'
|
||||||
|
return 'unserved-green'
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchDetail = async () => {
|
||||||
|
const id = diagnosisIdNum.value
|
||||||
|
if (!id || id <= 0) {
|
||||||
|
errorMsg.value = '诊单 id 缺失'
|
||||||
|
detail.value = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loading.value = true
|
||||||
|
errorMsg.value = ''
|
||||||
|
try {
|
||||||
|
const res: any = await diagnosisReadonlyDetail({ id })
|
||||||
|
detail.value = res || null
|
||||||
|
} catch (e: any) {
|
||||||
|
errorMsg.value = e?.message || ''
|
||||||
|
detail.value = null
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const goBack = () => {
|
||||||
|
if (window.history.length > 1) {
|
||||||
|
router.back()
|
||||||
|
} else {
|
||||||
|
router.push({ path: '/tcm/diagnosis' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(fetchDetail)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
/* 沿用接诊台的设计变量;该文件挂在管理后台,--rx-* 已在 reception/index.vue 全局可见的同款值 */
|
||||||
|
.readonly-page {
|
||||||
|
--rx-surface: #ffffff;
|
||||||
|
--rx-line: #e6ebf2;
|
||||||
|
--rx-ink: #1f2937;
|
||||||
|
--rx-muted: #4b5563;
|
||||||
|
--rx-soft: #6b7280;
|
||||||
|
--rx-accent: #2563eb;
|
||||||
|
--rx-accent-strong: #1d4ed8;
|
||||||
|
--rx-shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.04);
|
||||||
|
--rx-shadow: 0 4px 12px rgba(15, 23, 42, 0.06);
|
||||||
|
--rx-font-data: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.readonly-hero {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
background: linear-gradient(135deg, #f5f8ff 0%, #eef3ff 100%);
|
||||||
|
border: 1px solid #dde7ff;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
|
||||||
|
.hero-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
.back-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--rx-ink);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
.patient-name {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--rx-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.patient-meta {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--rx-muted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.unserved-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
background: rgba(255, 255, 255, 0.7);
|
||||||
|
border: 1px solid #dde7ff;
|
||||||
|
cursor: default;
|
||||||
|
|
||||||
|
&.unserved-muted {
|
||||||
|
color: var(--el-text-color-placeholder);
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.unserved-green {
|
||||||
|
color: #16a34a;
|
||||||
|
border-color: #bbf7d0;
|
||||||
|
background: rgba(22, 163, 74, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.unserved-orange {
|
||||||
|
color: #ea580c;
|
||||||
|
border-color: #fed7aa;
|
||||||
|
background: rgba(234, 88, 12, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.unserved-red {
|
||||||
|
color: #dc2626;
|
||||||
|
border-color: #fecaca;
|
||||||
|
background: rgba(220, 38, 38, 0.08);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
background: var(--rx-surface);
|
||||||
|
border: 1px solid var(--rx-line);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 40px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--rx-surface);
|
||||||
|
border: 1px solid var(--rx-line);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 18px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
box-shadow: var(--rx-shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--rx-ink);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding-left: 10px;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 3px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: linear-gradient(180deg, #4f8cff 0%, #2563eb 100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -18,6 +18,7 @@ use app\adminapi\controller\BaseAdminController;
|
|||||||
use app\adminapi\lists\tcm\DiagnosisLists;
|
use app\adminapi\lists\tcm\DiagnosisLists;
|
||||||
use app\adminapi\logic\order\OrderActionLogLogic;
|
use app\adminapi\logic\order\OrderActionLogLogic;
|
||||||
use app\adminapi\logic\tcm\DiagnosisLogic;
|
use app\adminapi\logic\tcm\DiagnosisLogic;
|
||||||
|
use app\adminapi\logic\tcm\TrackingNoteLogic;
|
||||||
use app\adminapi\validate\tcm\DiagnosisValidate;
|
use app\adminapi\validate\tcm\DiagnosisValidate;
|
||||||
use app\common\model\Order;
|
use app\common\model\Order;
|
||||||
use app\common\model\WechatChatRecord;
|
use app\common\model\WechatChatRecord;
|
||||||
@@ -113,12 +114,86 @@ class DiagnosisController extends BaseAdminController
|
|||||||
*/
|
*/
|
||||||
public function detail()
|
public function detail()
|
||||||
{
|
{
|
||||||
|
|
||||||
$params = (new DiagnosisValidate())->goCheck('id');
|
$params = (new DiagnosisValidate())->goCheck('id');
|
||||||
$result = DiagnosisLogic::detail($params);
|
$result = DiagnosisLogic::detail($params);
|
||||||
return $this->data($result);
|
return $this->data($result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @notes 二诊只读病例详情(患者信息 + 病例 + 跟踪记录)
|
||||||
|
*
|
||||||
|
* 路由:GET /tcm.diagnosis/readonlyDetail?id=:diagnosisId
|
||||||
|
* 权限:tcm.diagnosis/readonlyDetail
|
||||||
|
*
|
||||||
|
* @return \think\response\Json
|
||||||
|
*/
|
||||||
|
public function readonlyDetail()
|
||||||
|
{
|
||||||
|
$params = (new DiagnosisValidate())->goCheck('readonlyDetail');
|
||||||
|
$result = DiagnosisLogic::readonlyDetail($params, $this->adminId, $this->adminInfo);
|
||||||
|
if (empty($result)) {
|
||||||
|
return $this->fail(DiagnosisLogic::getError() ?: '诊单不存在或无权访问');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->data($result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @notes 跟踪信息(血糖血压 / 饮食 / 运动)—— 按日期区间 lazy load
|
||||||
|
*
|
||||||
|
* 路由:GET /tcm.diagnosis/trackingWindow?id=:diagnosisId&start_date=&end_date=
|
||||||
|
* 权限:tcm.diagnosis/readonlyDetail(与只读病例页同档;接诊台亦复用)
|
||||||
|
*
|
||||||
|
* @return \think\response\Json
|
||||||
|
*/
|
||||||
|
public function trackingWindow()
|
||||||
|
{
|
||||||
|
$params = (new DiagnosisValidate())->goCheck('trackingWindow');
|
||||||
|
$result = DiagnosisLogic::fetchTrackingWindow(
|
||||||
|
(int) $params['id'],
|
||||||
|
(string) ($params['start_date'] ?? ''),
|
||||||
|
(string) ($params['end_date'] ?? '')
|
||||||
|
);
|
||||||
|
return $this->data($result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @notes 新增跟踪备注(按天合并追加,仅文字)
|
||||||
|
*
|
||||||
|
* 路由:POST /tcm.diagnosis/addTrackingNote
|
||||||
|
* 权限:tcm.diagnosis/addTrackingNote
|
||||||
|
*
|
||||||
|
* @return \think\response\Json
|
||||||
|
*/
|
||||||
|
public function addTrackingNote()
|
||||||
|
{
|
||||||
|
$params = (new DiagnosisValidate())->post()->goCheck('addTrackingNote');
|
||||||
|
$ok = TrackingNoteLogic::addOrAppend([
|
||||||
|
'diagnosis_id' => (int) $params['diagnosis_id'],
|
||||||
|
'admin_id' => (int) $this->adminId,
|
||||||
|
'content' => (string) $params['tracking_content'],
|
||||||
|
]);
|
||||||
|
if ($ok === false) {
|
||||||
|
return $this->fail(TrackingNoteLogic::getError() ?: '保存失败');
|
||||||
|
}
|
||||||
|
return $this->success('保存成功');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @notes 拉取跟踪备注列表(note_date DESC)
|
||||||
|
*
|
||||||
|
* 路由:GET /tcm.diagnosis/trackingNotes?diagnosis_id=:diagnosisId
|
||||||
|
* 权限:tcm.diagnosis/trackingNotes
|
||||||
|
*
|
||||||
|
* @return \think\response\Json
|
||||||
|
*/
|
||||||
|
public function trackingNotes()
|
||||||
|
{
|
||||||
|
$params = (new DiagnosisValidate())->goCheck('trackingNotes');
|
||||||
|
return $this->data(TrackingNoteLogic::getByDiagnosis((int) $params['diagnosis_id']));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @notes 诊单挂号 / 取消挂号 操作日志(谁在何时操作)
|
* @notes 诊单挂号 / 取消挂号 操作日志(谁在何时操作)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
<?php
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | likeadmin快速开发前后端分离管理后台(PHP版)
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | 欢迎阅读学习系统程序代码,建议反馈是我们前进的动力
|
||||||
|
// | 开源版本可自由商用,可去除界面版权logo
|
||||||
|
// | gitee下载:https://gitee.com/likeshop_gitee/likeadmin
|
||||||
|
// | github下载:https://github.com/likeshop-github/likeadmin
|
||||||
|
// | 访问官网:https://www.likeadmin.cn
|
||||||
|
// | likeadmin团队 版权所有 拥有最终解释权
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | author: likeadminTeam
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace app\adminapi\controller\tcm;
|
||||||
|
|
||||||
|
use app\adminapi\controller\BaseAdminController;
|
||||||
|
use app\adminapi\lists\tcm\DiagnosisTodoLists;
|
||||||
|
use app\adminapi\logic\tcm\DiagnosisTodoLogic;
|
||||||
|
use app\adminapi\validate\tcm\DiagnosisTodoValidate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 诊单待办事项控制器
|
||||||
|
*
|
||||||
|
* 职责:仅 lists / add / cancel / detail;编辑能力故意不开放(要改 = 取消重建)
|
||||||
|
*
|
||||||
|
* @package app\adminapi\controller\tcm
|
||||||
|
*/
|
||||||
|
class DiagnosisTodoController extends BaseAdminController
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @notes 待办列表(按 diagnosis_id)
|
||||||
|
*/
|
||||||
|
public function lists()
|
||||||
|
{
|
||||||
|
(new DiagnosisTodoValidate())->goCheck('list');
|
||||||
|
|
||||||
|
return $this->dataLists(new DiagnosisTodoLists());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @notes 新增待办
|
||||||
|
*/
|
||||||
|
public function add()
|
||||||
|
{
|
||||||
|
$params = (new DiagnosisTodoValidate())->post()->goCheck('add');
|
||||||
|
$result = DiagnosisTodoLogic::add($params, $this->adminId, $this->adminInfo);
|
||||||
|
if (!$result) {
|
||||||
|
return $this->fail(DiagnosisTodoLogic::getError() ?: '创建失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->success('创建成功', [], 1, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @notes 取消待办(仅 status=0 可取消,仅创建人/超管)
|
||||||
|
*/
|
||||||
|
public function cancel()
|
||||||
|
{
|
||||||
|
$params = (new DiagnosisTodoValidate())->post()->goCheck('cancel');
|
||||||
|
$result = DiagnosisTodoLogic::cancel((int) $params['id'], $this->adminId);
|
||||||
|
if (!$result) {
|
||||||
|
return $this->fail(DiagnosisTodoLogic::getError() ?: '取消失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->success('已取消', [], 1, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @notes 待办详情
|
||||||
|
*/
|
||||||
|
public function detail()
|
||||||
|
{
|
||||||
|
$params = (new DiagnosisTodoValidate())->goCheck('detail');
|
||||||
|
$result = DiagnosisTodoLogic::detail((int) $params['id'], $this->adminId);
|
||||||
|
|
||||||
|
return $this->data($result);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ namespace app\adminapi\lists\tcm;
|
|||||||
use app\adminapi\lists\BaseAdminDataLists;
|
use app\adminapi\lists\BaseAdminDataLists;
|
||||||
use app\adminapi\logic\dept\DeptLogic;
|
use app\adminapi\logic\dept\DeptLogic;
|
||||||
use app\common\model\tcm\Diagnosis;
|
use app\common\model\tcm\Diagnosis;
|
||||||
|
use app\common\model\tcm\BloodRecord;
|
||||||
use app\common\model\DiagnosisViewRecord;
|
use app\common\model\DiagnosisViewRecord;
|
||||||
use app\common\model\doctor\Appointment;
|
use app\common\model\doctor\Appointment;
|
||||||
use app\common\model\tcm\Prescription;
|
use app\common\model\tcm\Prescription;
|
||||||
@@ -381,6 +382,25 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface
|
|||||||
foreach ($lists as &$item) {
|
foreach ($lists as &$item) {
|
||||||
$item['video_call_hint'] = $this->buildVideoCallHint($latestCallByDiag[$item['id']] ?? null);
|
$item['video_call_hint'] = $this->buildVideoCallHint($latestCallByDiag[$item['id']] ?? null);
|
||||||
}
|
}
|
||||||
|
unset($item);
|
||||||
|
|
||||||
|
// 未服务天数:以血糖记录最近一条 record_date 为锚点(口径见 prd T3)
|
||||||
|
// 单次 GROUP BY 聚合,不会成 N+1;走 idx_diagnosis_id 索引。
|
||||||
|
$diagIdsForUnserved = array_values(array_filter(array_unique(array_column($lists, 'id'))));
|
||||||
|
$maxBloodByDiag = [];
|
||||||
|
if (!empty($diagIdsForUnserved)) {
|
||||||
|
$maxBloodByDiag = BloodRecord::whereIn('diagnosis_id', $diagIdsForUnserved)
|
||||||
|
->whereNull('delete_time')
|
||||||
|
->group('diagnosis_id')
|
||||||
|
->column('MAX(record_date)', 'diagnosis_id');
|
||||||
|
}
|
||||||
|
$nowTs = time();
|
||||||
|
foreach ($lists as &$item) {
|
||||||
|
$last = (int) ($maxBloodByDiag[(int) $item['id']] ?? 0);
|
||||||
|
$item['last_blood_record_at'] = $last > 0 ? date('Y-m-d', $last) : null;
|
||||||
|
$item['unserved_days'] = $last > 0 ? max(0, (int) floor(($nowTs - $last) / 86400)) : null;
|
||||||
|
}
|
||||||
|
unset($item);
|
||||||
|
|
||||||
return $lists;
|
return $lists;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<?php
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | likeadmin快速开发前后端分离管理后台(PHP版)
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | 欢迎阅读学习系统程序代码,建议反馈是我们前进的动力
|
||||||
|
// | 开源版本可自由商用,可去除界面版权logo
|
||||||
|
// | gitee下载:https://gitee.com/likeshop_gitee/likeadmin
|
||||||
|
// | github下载:https://github.com/likeshop-github/likeadmin
|
||||||
|
// | 访问官网:https://www.likeadmin.cn
|
||||||
|
// | likeadmin团队 版权所有 拥有最终解释权
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | author: likeadminTeam
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace app\adminapi\lists\tcm;
|
||||||
|
|
||||||
|
use app\adminapi\lists\BaseAdminDataLists;
|
||||||
|
use app\adminapi\logic\tcm\DiagnosisTodoLogic;
|
||||||
|
use app\common\lists\ListsSearchInterface;
|
||||||
|
use app\common\model\tcm\DiagnosisTodo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 诊单待办事项列表
|
||||||
|
*
|
||||||
|
* 默认按 remind_time desc 排序;按 diagnosis_id 过滤。
|
||||||
|
*
|
||||||
|
* @package app\adminapi\lists\tcm
|
||||||
|
*/
|
||||||
|
class DiagnosisTodoLists extends BaseAdminDataLists implements ListsSearchInterface
|
||||||
|
{
|
||||||
|
public function setSearch(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'=' => ['diagnosis_id', 'status', 'creator_id'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function lists(): array
|
||||||
|
{
|
||||||
|
$lists = DiagnosisTodo::where($this->searchWhere)
|
||||||
|
->field([
|
||||||
|
'id', 'diagnosis_id', 'patient_id', 'content', 'remind_time',
|
||||||
|
'status', 'creator_id', 'creator_name', 'notified_at', 'error',
|
||||||
|
'cancelled_at', 'cancelled_by', 'create_time', 'update_time',
|
||||||
|
])
|
||||||
|
->append([
|
||||||
|
'status_text',
|
||||||
|
'remind_time_text',
|
||||||
|
'notified_at_text',
|
||||||
|
'cancelled_at_text',
|
||||||
|
])
|
||||||
|
->order('remind_time', 'desc')
|
||||||
|
->order('id', 'desc')
|
||||||
|
->limit($this->limitOffset, $this->limitLength)
|
||||||
|
->select()
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
// 业务态:是否能被「我」取消(前端按钮显隐用)
|
||||||
|
$adminId = (int) $this->adminId;
|
||||||
|
foreach ($lists as &$item) {
|
||||||
|
$item['can_cancel'] = DiagnosisTodoLogic::canCancel($item, $adminId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $lists;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function count(): int
|
||||||
|
{
|
||||||
|
return DiagnosisTodo::where($this->searchWhere)->count();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ use app\api\logic\ChatNotifyLogic;
|
|||||||
use app\adminapi\logic\tcm\PrescriptionLogic;
|
use app\adminapi\logic\tcm\PrescriptionLogic;
|
||||||
use app\adminapi\logic\tcm\DiagnosisLogic;
|
use app\adminapi\logic\tcm\DiagnosisLogic;
|
||||||
use app\adminapi\logic\tcm\BloodRecordLogic;
|
use app\adminapi\logic\tcm\BloodRecordLogic;
|
||||||
|
use app\adminapi\logic\tcm\TrackingNoteLogic;
|
||||||
use app\adminapi\logic\doctor\DoctorNoteLogic;
|
use app\adminapi\logic\doctor\DoctorNoteLogic;
|
||||||
use app\common\model\tcm\Prescription;
|
use app\common\model\tcm\Prescription;
|
||||||
use app\common\service\wechat\WechatWorkAppMessageService;
|
use app\common\service\wechat\WechatWorkAppMessageService;
|
||||||
@@ -516,10 +517,8 @@ class AppointmentLogic extends BaseLogic
|
|||||||
? DiagnosisLogic::detail(['id' => $diagnosisId])
|
? DiagnosisLogic::detail(['id' => $diagnosisId])
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
// 3) 血糖血压记录
|
// 3) 跟踪信息(血糖血压 / 饮食 / 运动)改为前端按窗口 lazy load,
|
||||||
$bloodRecords = $diagnosisId > 0
|
// 详见 DiagnosisController::trackingWindow。此处不再一次性返回。
|
||||||
? BloodRecordLogic::getRecordsByPatient(['diagnosis_id' => $diagnosisId])
|
|
||||||
: [];
|
|
||||||
|
|
||||||
// 4) 补全医助姓名(detail() 未关联 admin 助理表)
|
// 4) 补全医助姓名(detail() 未关联 admin 助理表)
|
||||||
$assistantName = '';
|
$assistantName = '';
|
||||||
@@ -551,23 +550,28 @@ class AppointmentLogic extends BaseLogic
|
|||||||
? DoctorNoteLogic::getByDiagnosis($diagnosisId)
|
? DoctorNoteLogic::getByDiagnosis($diagnosisId)
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
|
// 7.1) 跟踪备注
|
||||||
|
$trackingNotes = $diagnosisId > 0
|
||||||
|
? TrackingNoteLogic::getByDiagnosis($diagnosisId)
|
||||||
|
: [];
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'appointment' => $appointment,
|
'appointment' => $appointment,
|
||||||
'diagnosis' => $diagnosis,
|
'diagnosis' => $diagnosis,
|
||||||
'blood_records' => $bloodRecords,
|
'doctor_notes' => $doctorNotes,
|
||||||
'doctor_notes' => $doctorNotes,
|
'tracking_notes' => $trackingNotes,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 字典翻译:把诊单中以 code 存储的字段(字典/枚举)补一份中文 label 字段,
|
* 字典翻译:把诊单中以 code 存储的字段(字典/枚举)补一份中文 label 字段,
|
||||||
* 便于前端(接诊台等只读场景)直接渲染,无需加载字典。
|
* 便于前端(接诊台 / 只读病例页等只读场景)直接渲染,无需加载字典。
|
||||||
* 原始 code 字段保留不动,edit 场景仍可使用。
|
* 原始 code 字段保留不动,edit 场景仍可使用。
|
||||||
*
|
*
|
||||||
* @param array $diagnosis
|
* @param array $diagnosis
|
||||||
* @return array
|
* @return array
|
||||||
*/
|
*/
|
||||||
private static function enrichDiagnosisLabels(array $diagnosis): array
|
public static function enrichDiagnosisLabels(array $diagnosis): array
|
||||||
{
|
{
|
||||||
if (empty($diagnosis)) {
|
if (empty($diagnosis)) {
|
||||||
return $diagnosis;
|
return $diagnosis;
|
||||||
|
|||||||
@@ -19,9 +19,18 @@ use app\common\model\tcm\Diagnosis;
|
|||||||
use app\common\model\tcm\DiagnosisGuahaoLog;
|
use app\common\model\tcm\DiagnosisGuahaoLog;
|
||||||
use app\common\model\tcm\DiagnosisAssignLog;
|
use app\common\model\tcm\DiagnosisAssignLog;
|
||||||
use app\common\model\tcm\ImChatMessage;
|
use app\common\model\tcm\ImChatMessage;
|
||||||
|
use app\common\model\tcm\BloodRecord;
|
||||||
|
use app\common\model\tcm\DietRecord;
|
||||||
|
use app\common\model\tcm\ExerciseRecord;
|
||||||
use app\common\model\DiagnosisViewRecord;
|
use app\common\model\DiagnosisViewRecord;
|
||||||
|
use app\common\model\doctor\Appointment;
|
||||||
|
use app\common\model\auth\Admin;
|
||||||
|
use app\common\model\auth\AdminRole;
|
||||||
use app\adminapi\logic\doctor\DoctorNoteLogic;
|
use app\adminapi\logic\doctor\DoctorNoteLogic;
|
||||||
|
use app\adminapi\logic\doctor\AppointmentLogic;
|
||||||
|
use app\adminapi\logic\tcm\TrackingNoteLogic;
|
||||||
use app\common\service\FileService;
|
use app\common\service\FileService;
|
||||||
|
use app\common\service\DataScope\DataScopeService;
|
||||||
use think\facade\Db;
|
use think\facade\Db;
|
||||||
/**
|
/**
|
||||||
* 中医辨房病因诊单逻辑
|
* 中医辨房病因诊单逻辑
|
||||||
@@ -3324,4 +3333,270 @@ class DiagnosisLogic extends BaseLogic
|
|||||||
|
|
||||||
return $logs;
|
return $logs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @notes 二诊只读病例详情
|
||||||
|
*
|
||||||
|
* 用途:医助从诊单列表「查看 / 双击」进入只读页(T1+T2),返回与接诊台同结构的
|
||||||
|
* 三块内容(appointment + diagnosis + 跟踪记录)。
|
||||||
|
*
|
||||||
|
* 数据权限(与列表 lists 接口一致):
|
||||||
|
* - 医助 (role_id=2):仅能访问 assistant_id == self 的诊单
|
||||||
|
* - DataScope 启用:assistant_id 须落在 visibleAdminIds
|
||||||
|
* - 越权 → setError + 返回 []
|
||||||
|
*
|
||||||
|
* @param array $params 至少含 id
|
||||||
|
* @param int $adminId
|
||||||
|
* @param array<string,mixed> $adminInfo request->adminInfo
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public static function readonlyDetail(array $params, int $adminId, array $adminInfo): array
|
||||||
|
{
|
||||||
|
$diagnosisId = (int) ($params['id'] ?? 0);
|
||||||
|
if ($diagnosisId <= 0) {
|
||||||
|
self::setError('诊单 id 不能为空');
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1) 数据权限闸 — 不通过则返回「不存在或无权访问」
|
||||||
|
$accessQuery = Diagnosis::where('id', $diagnosisId)->whereNull('delete_time');
|
||||||
|
|
||||||
|
$roleIds = array_map('intval', AdminRole::where('admin_id', $adminId)->column('role_id'));
|
||||||
|
if (in_array(2, $roleIds, true)) {
|
||||||
|
// 医助仅看自己被指派的
|
||||||
|
$accessQuery->where('assistant_id', $adminId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (DataScopeService::isEnabled()) {
|
||||||
|
$visibleIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
|
||||||
|
if ($visibleIds === []) {
|
||||||
|
self::setError('诊单不存在或无权访问');
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
if (is_array($visibleIds)) {
|
||||||
|
$accessQuery->whereIn('assistant_id', $visibleIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$accessQuery->find()) {
|
||||||
|
self::setError('诊单不存在或无权访问');
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) 诊单详情(含图片聚合等)+ 字典翻译
|
||||||
|
$diagnosis = self::detail(['id' => $diagnosisId]);
|
||||||
|
if (empty($diagnosis)) {
|
||||||
|
self::setError('诊单数据为空');
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$diagnosis = AppointmentLogic::enrichDiagnosisLabels($diagnosis);
|
||||||
|
|
||||||
|
// 3) 最近一条挂号(PatientInfoCard 显示用,无挂号则给默认空对象)
|
||||||
|
$appointment = self::buildLatestAppointmentForReadonly((int) ($diagnosis['id'] ?? $diagnosisId), $diagnosis);
|
||||||
|
|
||||||
|
// 4) 跟踪信息(血糖血压 / 饮食 / 运动)改为前端按窗口 lazy load,
|
||||||
|
// 详见 DiagnosisController::trackingWindow。此处不再一次性返回。
|
||||||
|
|
||||||
|
// 5) 医生备注
|
||||||
|
$doctorNotes = DoctorNoteLogic::getByDiagnosis($diagnosisId);
|
||||||
|
|
||||||
|
// 5.1) 跟踪备注(只读展示,按 note_date DESC)
|
||||||
|
$trackingNotes = TrackingNoteLogic::getByDiagnosis($diagnosisId);
|
||||||
|
|
||||||
|
// 6) 未服务天数(与 T3 列同口径)
|
||||||
|
$maxBloodTs = BloodRecord::where('diagnosis_id', $diagnosisId)
|
||||||
|
->max('record_date');
|
||||||
|
$maxBloodTs = (int) $maxBloodTs;
|
||||||
|
$unservedDays = $maxBloodTs > 0 ? max(0, (int) floor((time() - $maxBloodTs) / 86400)) : null;
|
||||||
|
$lastBloodRecordAt = $maxBloodTs > 0 ? date('Y-m-d', $maxBloodTs) : null;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'appointment' => $appointment,
|
||||||
|
'diagnosis' => $diagnosis,
|
||||||
|
'doctor_notes' => $doctorNotes,
|
||||||
|
'tracking_notes' => $trackingNotes,
|
||||||
|
'unserved_days' => $unservedDays,
|
||||||
|
'last_blood_record_at' => $lastBloodRecordAt,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取指定日期区间内的三类跟踪记录(血糖血压 / 饮食 / 运动),供 readonlyDetail 与
|
||||||
|
* 医生接诊台 reception 通过独立接口 lazy load。
|
||||||
|
*
|
||||||
|
* 区间语义:闭区间 [startDate, endDate](Y-m-d),均不传则不限。
|
||||||
|
*
|
||||||
|
* @return array{
|
||||||
|
* blood_records: array<int,array<string,mixed>>,
|
||||||
|
* diet_records: array<int,array<string,mixed>>,
|
||||||
|
* exercise_records: array<int,array<string,mixed>>,
|
||||||
|
* start_date: string,
|
||||||
|
* end_date: string,
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public static function fetchTrackingWindow(int $diagnosisId, string $startDate = '', string $endDate = ''): array
|
||||||
|
{
|
||||||
|
$sinceTs = $startDate !== '' ? (int) strtotime($startDate . ' 00:00:00') : 0;
|
||||||
|
$untilTs = $endDate !== '' ? (int) strtotime($endDate . ' 23:59:59') : 0;
|
||||||
|
$sinceTs = $sinceTs > 0 ? $sinceTs : 0;
|
||||||
|
$untilTs = $untilTs > 0 ? $untilTs : 0;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'blood_records' => self::fetchBloodRecordsForReadonly($diagnosisId, $sinceTs, $untilTs),
|
||||||
|
'diet_records' => self::fetchDietRecordsForReadonly($diagnosisId, $sinceTs, $untilTs),
|
||||||
|
'exercise_records' => self::fetchExerciseRecordsForReadonly($diagnosisId, $sinceTs, $untilTs),
|
||||||
|
'start_date' => $startDate,
|
||||||
|
'end_date' => $endDate,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拼一份「最近一条挂号」给只读页 PatientInfoCard 用;没有挂号则给默认空骨架。
|
||||||
|
*
|
||||||
|
* @param int $diagnosisId
|
||||||
|
* @param array<string,mixed> $diagnosis 已字典翻译过的诊单数据
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
private static function buildLatestAppointmentForReadonly(int $diagnosisId, array $diagnosis): array
|
||||||
|
{
|
||||||
|
$apt = Appointment::where('patient_id', $diagnosisId)
|
||||||
|
->order('id', 'desc')
|
||||||
|
->find();
|
||||||
|
|
||||||
|
$patientName = (string) ($diagnosis['patient_name'] ?? '');
|
||||||
|
$patientPhone = (string) ($diagnosis['phone'] ?? '');
|
||||||
|
|
||||||
|
if (!$apt) {
|
||||||
|
return [
|
||||||
|
'id' => 0,
|
||||||
|
'patient_id' => $diagnosisId,
|
||||||
|
'patient_name' => $patientName,
|
||||||
|
'patient_phone' => $patientPhone,
|
||||||
|
'doctor_id' => 0,
|
||||||
|
'doctor_name' => '',
|
||||||
|
'assistant_id' => (int) ($diagnosis['assistant_id'] ?? 0),
|
||||||
|
'assistant_name' => self::resolveAssistantName((int) ($diagnosis['assistant_id'] ?? 0)),
|
||||||
|
'appointment_date' => '',
|
||||||
|
'appointment_time' => '',
|
||||||
|
'period' => '',
|
||||||
|
'status' => 0,
|
||||||
|
'status_desc' => '无挂号',
|
||||||
|
'has_prescription' => 0,
|
||||||
|
'remark' => '',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$aptArr = $apt->toArray();
|
||||||
|
$aptArr['patient_name'] = $patientName;
|
||||||
|
$aptArr['patient_phone'] = $patientPhone;
|
||||||
|
// 医生姓名
|
||||||
|
$aptArr['doctor_name'] = '';
|
||||||
|
$docId = (int) ($aptArr['doctor_id'] ?? 0);
|
||||||
|
if ($docId > 0) {
|
||||||
|
$aptArr['doctor_name'] = (string) Admin::where('id', $docId)->value('name');
|
||||||
|
}
|
||||||
|
$aptArr['assistant_id'] = (int) ($diagnosis['assistant_id'] ?? 0);
|
||||||
|
$aptArr['assistant_name'] = self::resolveAssistantName((int) ($diagnosis['assistant_id'] ?? 0));
|
||||||
|
// status_desc 与现有列表口径保持简单映射
|
||||||
|
$statusMap = [
|
||||||
|
0 => '未挂号',
|
||||||
|
1 => '已预约',
|
||||||
|
2 => '已取消',
|
||||||
|
3 => '已完成',
|
||||||
|
4 => '已过号',
|
||||||
|
];
|
||||||
|
$aptArr['status_desc'] = $statusMap[(int) ($aptArr['status'] ?? 0)] ?? '';
|
||||||
|
$aptArr['has_prescription'] = (int) ($diagnosis['has_prescription'] ?? 0);
|
||||||
|
|
||||||
|
return $aptArr;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function resolveAssistantName(int $assistantId): string
|
||||||
|
{
|
||||||
|
if ($assistantId <= 0) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return (string) Admin::where('id', $assistantId)->value('name');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int,array<string,mixed>>
|
||||||
|
*/
|
||||||
|
private static function fetchBloodRecordsForReadonly(int $diagnosisId, int $sinceTs, int $untilTs = 0): array
|
||||||
|
{
|
||||||
|
$query = BloodRecord::where('diagnosis_id', $diagnosisId);
|
||||||
|
if ($sinceTs > 0) {
|
||||||
|
$query->where('record_date', '>=', $sinceTs);
|
||||||
|
}
|
||||||
|
if ($untilTs > 0) {
|
||||||
|
$query->where('record_date', '<=', $untilTs);
|
||||||
|
}
|
||||||
|
$rows = $query->order('record_date', 'desc')
|
||||||
|
->order('record_time', 'desc')
|
||||||
|
->select()
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
// 给前端一个 Y-m-d 字符串方便按日期透视;保留原 record_date 整数戳作日期窗口比较
|
||||||
|
foreach ($rows as &$row) {
|
||||||
|
$ts = (int) ($row['record_date'] ?? 0);
|
||||||
|
$row['record_date_ts'] = $ts;
|
||||||
|
$row['record_date'] = $ts > 0 ? date('Y-m-d', $ts) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int,array<string,mixed>>
|
||||||
|
*/
|
||||||
|
private static function fetchDietRecordsForReadonly(int $diagnosisId, int $sinceTs, int $untilTs = 0): array
|
||||||
|
{
|
||||||
|
$query = DietRecord::where('diagnosis_id', $diagnosisId);
|
||||||
|
if ($sinceTs > 0) {
|
||||||
|
$query->where('record_date', '>=', $sinceTs);
|
||||||
|
}
|
||||||
|
if ($untilTs > 0) {
|
||||||
|
$query->where('record_date', '<=', $untilTs);
|
||||||
|
}
|
||||||
|
$rows = $query->order('record_date', 'desc')->select()->toArray();
|
||||||
|
|
||||||
|
foreach ($rows as &$row) {
|
||||||
|
$ts = (int) ($row['record_date'] ?? 0);
|
||||||
|
$row['record_date_ts'] = $ts;
|
||||||
|
$row['record_date'] = $ts > 0 ? date('Y-m-d', $ts) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int,array<string,mixed>>
|
||||||
|
*/
|
||||||
|
private static function fetchExerciseRecordsForReadonly(int $diagnosisId, int $sinceTs, int $untilTs = 0): array
|
||||||
|
{
|
||||||
|
$query = ExerciseRecord::where('diagnosis_id', $diagnosisId);
|
||||||
|
if ($sinceTs > 0) {
|
||||||
|
$query->where('record_date', '>=', $sinceTs);
|
||||||
|
}
|
||||||
|
if ($untilTs > 0) {
|
||||||
|
$query->where('record_date', '<=', $untilTs);
|
||||||
|
}
|
||||||
|
$rows = $query->order('record_date', 'desc')
|
||||||
|
->append(['intensity_text'])
|
||||||
|
->select()
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
foreach ($rows as &$row) {
|
||||||
|
$ts = (int) ($row['record_date'] ?? 0);
|
||||||
|
$row['record_date_ts'] = $ts;
|
||||||
|
$row['record_date'] = $ts > 0 ? date('Y-m-d', $ts) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $rows;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
<?php
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | likeadmin快速开发前后端分离管理后台(PHP版)
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | 欢迎阅读学习系统程序代码,建议反馈是我们前进的动力
|
||||||
|
// | 开源版本可自由商用,可去除界面版权logo
|
||||||
|
// | gitee下载:https://gitee.com/likeshop_gitee/likeadmin
|
||||||
|
// | github下载:https://github.com/likeshop-github/likeadmin
|
||||||
|
// | 访问官网:https://www.likeadmin.cn
|
||||||
|
// | likeadmin团队 版权所有 拥有最终解释权
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | author: likeadminTeam
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace app\adminapi\logic\tcm;
|
||||||
|
|
||||||
|
use app\common\logic\BaseLogic;
|
||||||
|
use app\common\model\auth\Admin;
|
||||||
|
use app\common\model\auth\AdminRole;
|
||||||
|
use app\common\model\tcm\Diagnosis;
|
||||||
|
use app\common\model\tcm\DiagnosisTodo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 诊单待办事项逻辑
|
||||||
|
*
|
||||||
|
* 状态机:
|
||||||
|
* add() → status=0 (待执行)
|
||||||
|
* cron → status=1 (已发送) / status=3 (失败)
|
||||||
|
* cancel() → status=2 (已取消,仅 status=0 可取消)
|
||||||
|
*
|
||||||
|
* 鉴权:
|
||||||
|
* add:诊单需存在;数据权限交由路由层「tcm.diagnosisTodo/add」节点 + 前端 tab disabled 控制。
|
||||||
|
* cancel:仅创建人本人或超级管理员 (role_id=1) 可取消,且仅 status=0 时。
|
||||||
|
*
|
||||||
|
* @package app\adminapi\logic\tcm
|
||||||
|
*/
|
||||||
|
class DiagnosisTodoLogic extends BaseLogic
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @notes 新增待办
|
||||||
|
*
|
||||||
|
* @param array<string,mixed> $params 已通过 sceneAdd 验证
|
||||||
|
* @param int $adminId 当前 admin id
|
||||||
|
* @param array<string,mixed> $adminInfo request->adminInfo
|
||||||
|
*/
|
||||||
|
public static function add(array $params, int $adminId, array $adminInfo): bool
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$diagnosisId = (int) ($params['diagnosis_id'] ?? 0);
|
||||||
|
$diagnosis = Diagnosis::findOrEmpty($diagnosisId);
|
||||||
|
if ($diagnosis->isEmpty()) {
|
||||||
|
self::setError('诊单不存在');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$remindTime = (int) ($params['remind_time'] ?? 0);
|
||||||
|
if ($remindTime <= time()) {
|
||||||
|
self::setError('提醒时间必须晚于当前时间');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$creatorName = trim((string) ($adminInfo['name'] ?? ''));
|
||||||
|
if ($creatorName === '' && $adminId > 0) {
|
||||||
|
$creatorName = (string) Admin::where('id', $adminId)->value('name');
|
||||||
|
}
|
||||||
|
|
||||||
|
DiagnosisTodo::create([
|
||||||
|
'diagnosis_id' => $diagnosisId,
|
||||||
|
'patient_id' => (int) ($diagnosis->getAttr('patient_id') ?? 0),
|
||||||
|
'content' => trim((string) ($params['content'] ?? '')),
|
||||||
|
'remind_time' => $remindTime,
|
||||||
|
'status' => DiagnosisTodo::STATUS_PENDING,
|
||||||
|
'creator_id' => $adminId,
|
||||||
|
'creator_name' => $creatorName,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
self::setError($e->getMessage());
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @notes 取消待办(人工)
|
||||||
|
*/
|
||||||
|
public static function cancel(int $id, int $adminId): bool
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$todo = DiagnosisTodo::findOrEmpty($id);
|
||||||
|
if ($todo->isEmpty()) {
|
||||||
|
self::setError('待办不存在');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$status = (int) $todo->getAttr('status');
|
||||||
|
if ($status !== DiagnosisTodo::STATUS_PENDING) {
|
||||||
|
self::setError('该待办已不是「待执行」状态,无法取消');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$creatorId = (int) $todo->getAttr('creator_id');
|
||||||
|
$isSuper = self::isSuperAdmin($adminId);
|
||||||
|
if ($creatorId !== $adminId && !$isSuper) {
|
||||||
|
self::setError('仅创建人或超级管理员可取消');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$todo->save([
|
||||||
|
'status' => DiagnosisTodo::STATUS_CANCELLED,
|
||||||
|
'cancelled_at' => time(),
|
||||||
|
'cancelled_by' => $adminId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
self::setError($e->getMessage());
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @notes 详情
|
||||||
|
*
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
public static function detail(int $id, int $adminId = 0): array
|
||||||
|
{
|
||||||
|
$todo = DiagnosisTodo::findOrEmpty($id);
|
||||||
|
if ($todo->isEmpty()) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$arr = $todo->append([
|
||||||
|
'status_text',
|
||||||
|
'remind_time_text',
|
||||||
|
'notified_at_text',
|
||||||
|
'cancelled_at_text',
|
||||||
|
])->toArray();
|
||||||
|
|
||||||
|
// 业务态:是否能被「我」取消(前端按钮显隐用)
|
||||||
|
$arr['can_cancel'] = self::canCancel($todo->toArray(), $adminId);
|
||||||
|
|
||||||
|
return $arr;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否超管:role_id=1
|
||||||
|
*/
|
||||||
|
public static function isSuperAdmin(int $adminId): bool
|
||||||
|
{
|
||||||
|
if ($adminId <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$roleIds = AdminRole::where('admin_id', $adminId)->column('role_id');
|
||||||
|
|
||||||
|
return in_array(1, array_map('intval', $roleIds), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 业务判定:当前 admin 能否取消该待办
|
||||||
|
*
|
||||||
|
* @param array<string,mixed> $todoRow
|
||||||
|
*/
|
||||||
|
public static function canCancel(array $todoRow, int $adminId): bool
|
||||||
|
{
|
||||||
|
if ((int) ($todoRow['status'] ?? -1) !== DiagnosisTodo::STATUS_PENDING) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if ($adminId <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if ((int) ($todoRow['creator_id'] ?? 0) === $adminId) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::isSuperAdmin($adminId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace app\adminapi\logic\tcm;
|
||||||
|
|
||||||
|
use app\common\logic\BaseLogic;
|
||||||
|
use app\common\model\tcm\TrackingNote;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 诊单跟踪备注 Logic
|
||||||
|
*
|
||||||
|
* - 按 diagnosis_id + 当天 find-or-create
|
||||||
|
* - 多次追加 content(换行分隔,带 [HH:MM] 前缀)
|
||||||
|
* - 不涉及附件(与医生备注 DoctorNoteLogic 不同)
|
||||||
|
*/
|
||||||
|
class TrackingNoteLogic extends BaseLogic
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 追加跟踪备注(按天合并)
|
||||||
|
*
|
||||||
|
* @param array $params diagnosis_id (int)、admin_id (int)、content (string)
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public static function addOrAppend(array $params): bool
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$diagnosisId = (int) ($params['diagnosis_id'] ?? 0);
|
||||||
|
$adminId = (int) ($params['admin_id'] ?? 0);
|
||||||
|
$newContent = trim((string) ($params['content'] ?? ''));
|
||||||
|
|
||||||
|
if ($diagnosisId <= 0) {
|
||||||
|
self::setError('诊单ID缺失');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if ($newContent === '') {
|
||||||
|
self::setError('备注内容不能为空');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$today = date('Y-m-d');
|
||||||
|
$time = date('H:i');
|
||||||
|
$line = "[{$time}] {$newContent}";
|
||||||
|
|
||||||
|
$existing = TrackingNote::where('diagnosis_id', $diagnosisId)
|
||||||
|
->where('note_date', $today)
|
||||||
|
->whereNull('delete_time')
|
||||||
|
->find();
|
||||||
|
|
||||||
|
if ($existing) {
|
||||||
|
$prev = trim((string) ($existing->content ?? ''));
|
||||||
|
$existing->content = $prev !== '' ? ($prev . "\n" . $line) : $line;
|
||||||
|
$existing->save();
|
||||||
|
} else {
|
||||||
|
TrackingNote::create([
|
||||||
|
'diagnosis_id' => $diagnosisId,
|
||||||
|
'admin_id' => $adminId,
|
||||||
|
'note_date' => $today,
|
||||||
|
'content' => $line,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
self::setError($e->getMessage());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按 diagnosis_id 获取跟踪备注列表(note_date DESC)
|
||||||
|
*
|
||||||
|
* @param int $diagnosisId
|
||||||
|
* @param int $limit 最近 N 天
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public static function getByDiagnosis(int $diagnosisId, int $limit = 60): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
if ($diagnosisId <= 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$records = TrackingNote::where('diagnosis_id', $diagnosisId)
|
||||||
|
->whereNull('delete_time')
|
||||||
|
->order('note_date', 'desc')
|
||||||
|
->limit($limit)
|
||||||
|
->select()
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
return $records;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
self::setError($e->getMessage());
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
<?php
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | likeadmin快速开发前后端分离管理后台(PHP版)
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | 欢迎阅读学习系统程序代码,建议反馈是我们前进的动力
|
||||||
|
// | 开源版本可自由商用,可去除界面版权logo
|
||||||
|
// | gitee下载:https://gitee.com/likeshop_gitee/likeadmin
|
||||||
|
// | github下载:https://github.com/likeshop-github/likeadmin
|
||||||
|
// | 访问官网:https://www.likeadmin.cn
|
||||||
|
// | likeadmin团队 版权所有 拥有最终解释权
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | author: likeadminTeam
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace app\adminapi\validate\tcm;
|
||||||
|
|
||||||
|
use app\common\model\tcm\Diagnosis;
|
||||||
|
use app\common\model\tcm\DiagnosisTodo;
|
||||||
|
use app\common\validate\BaseValidate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 诊单待办事项验证
|
||||||
|
* Class DiagnosisTodoValidate
|
||||||
|
* @package app\adminapi\validate\tcm
|
||||||
|
*/
|
||||||
|
class DiagnosisTodoValidate extends BaseValidate
|
||||||
|
{
|
||||||
|
protected $rule = [
|
||||||
|
'id' => 'require|number',
|
||||||
|
'diagnosis_id' => 'require|number|gt:0|checkDiagnosis',
|
||||||
|
'content' => 'require|length:1,500',
|
||||||
|
'remind_time' => 'require|number|checkRemindFuture',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $message = [
|
||||||
|
'id.require' => '参数缺失',
|
||||||
|
'diagnosis_id.require' => '诊单 id 不能为空',
|
||||||
|
'diagnosis_id.gt' => '诊单 id 非法',
|
||||||
|
'content.require' => '请输入提醒内容',
|
||||||
|
'content.length' => '提醒内容长度需在 1-500 字',
|
||||||
|
'remind_time.require' => '请选择提醒时间',
|
||||||
|
'remind_time.number' => '提醒时间格式错误',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function sceneAdd()
|
||||||
|
{
|
||||||
|
return $this->only(['diagnosis_id', 'content', 'remind_time']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function sceneCancel()
|
||||||
|
{
|
||||||
|
return $this->only(['id']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function sceneDetail()
|
||||||
|
{
|
||||||
|
return $this->only(['id']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function sceneList()
|
||||||
|
{
|
||||||
|
return $this->only(['diagnosis_id'])
|
||||||
|
->append('diagnosis_id', 'require|number|gt:0');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 诊单存在校验
|
||||||
|
*/
|
||||||
|
protected function checkDiagnosis($value): bool|string
|
||||||
|
{
|
||||||
|
$diagnosis = Diagnosis::findOrEmpty((int) $value);
|
||||||
|
if ($diagnosis->isEmpty()) {
|
||||||
|
return '诊单不存在';
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提醒时间必须在当前时间之后(至少 30 秒),避免立刻就过期
|
||||||
|
*/
|
||||||
|
protected function checkRemindFuture($value): bool|string
|
||||||
|
{
|
||||||
|
$ts = (int) $value;
|
||||||
|
if ($ts <= time() + 30) {
|
||||||
|
return '提醒时间必须晚于当前时间';
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,6 +37,10 @@ class DiagnosisValidate extends BaseValidate
|
|||||||
'tongue_images' => 'array',
|
'tongue_images' => 'array',
|
||||||
'report_files' => 'array',
|
'report_files' => 'array',
|
||||||
'diabetes_discovery_year' => 'max:50',
|
'diabetes_discovery_year' => 'max:50',
|
||||||
|
'start_date' => 'date',
|
||||||
|
'end_date' => 'date|checkDateRange',
|
||||||
|
'diagnosis_id' => 'require|integer|checkDiagnosisId',
|
||||||
|
'tracking_content' => 'require|length:1,1000',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $message = [
|
protected $message = [
|
||||||
@@ -55,6 +59,11 @@ class DiagnosisValidate extends BaseValidate
|
|||||||
'status.in' => '状态参数错误',
|
'status.in' => '状态参数错误',
|
||||||
'current_medications.max' => '在用药物最多2000个字符',
|
'current_medications.max' => '在用药物最多2000个字符',
|
||||||
'diabetes_discovery_year.max' => '发现糖尿病患病史最多50个字符',
|
'diabetes_discovery_year.max' => '发现糖尿病患病史最多50个字符',
|
||||||
|
'start_date.date' => '开始日期格式不正确',
|
||||||
|
'end_date.date' => '结束日期格式不正确',
|
||||||
|
'diagnosis_id.require' => '诊单ID不能为空',
|
||||||
|
'tracking_content.require' => '跟踪备注内容不能为空',
|
||||||
|
'tracking_content.length' => '跟踪备注最多1000个字符',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function sceneAdd()
|
public function sceneAdd()
|
||||||
@@ -71,7 +80,29 @@ class DiagnosisValidate extends BaseValidate
|
|||||||
{
|
{
|
||||||
return $this->only(['id']);
|
return $this->only(['id']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function sceneReadonlyDetail()
|
||||||
|
{
|
||||||
|
return $this->only(['id']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function sceneTrackingWindow()
|
||||||
|
{
|
||||||
|
return $this->only(['id', 'start_date', 'end_date']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 新增跟踪备注:诊单ID + 备注内容 */
|
||||||
|
public function sceneAddTrackingNote()
|
||||||
|
{
|
||||||
|
return $this->only(['diagnosis_id', 'tracking_content']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 拉取跟踪备注列表:诊单ID */
|
||||||
|
public function sceneTrackingNotes()
|
||||||
|
{
|
||||||
|
return $this->only(['diagnosis_id']);
|
||||||
|
}
|
||||||
|
|
||||||
public function sceneGenerateQrcode()
|
public function sceneGenerateQrcode()
|
||||||
{
|
{
|
||||||
return $this->only(['diagnosis_id', 'doctor_id', 'patient_id', 'share_user_id', 'mini_program_path'])
|
return $this->only(['diagnosis_id', 'doctor_id', 'patient_id', 'share_user_id', 'mini_program_path'])
|
||||||
@@ -107,6 +138,26 @@ class DiagnosisValidate extends BaseValidate
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 校验 diagnosis_id 字段(addTrackingNote 等场景使用,避开 id 字段) */
|
||||||
|
protected function checkDiagnosisId($value)
|
||||||
|
{
|
||||||
|
$diagnosis = Diagnosis::findOrEmpty($value);
|
||||||
|
if ($diagnosis->isEmpty()) {
|
||||||
|
return '诊单不存在';
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** end_date 不能早于 start_date */
|
||||||
|
protected function checkDateRange($value, $rule, $data = [])
|
||||||
|
{
|
||||||
|
$start = $data['start_date'] ?? '';
|
||||||
|
if ($start && $value && strtotime($value) < strtotime($start)) {
|
||||||
|
return '结束日期不能早于开始日期';
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/** 视频场景需 doctor_id,确认诊单需 diagnosis_id */
|
/** 视频场景需 doctor_id,确认诊单需 diagnosis_id */
|
||||||
protected function checkQrcodeIds($value, $rule, $data = [])
|
protected function checkQrcodeIds($value, $rule, $data = [])
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,207 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace app\command;
|
||||||
|
|
||||||
|
use app\common\model\auth\Admin;
|
||||||
|
use app\common\model\tcm\Diagnosis;
|
||||||
|
use app\common\model\tcm\DiagnosisTodo;
|
||||||
|
use app\common\service\wechat\WechatWorkAppMessageService;
|
||||||
|
use think\console\Command;
|
||||||
|
use think\console\Input;
|
||||||
|
use think\console\Output;
|
||||||
|
use think\facade\Db;
|
||||||
|
use think\facade\Log;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 诊单待办事项 - 企业微信定时推送
|
||||||
|
*
|
||||||
|
* 使用方法:
|
||||||
|
* php think tcm:diagnosis-todo-notify
|
||||||
|
*
|
||||||
|
* 推荐 cron(每分钟执行):
|
||||||
|
* * * * * * cd /path/to/server && php think tcm:diagnosis-todo-notify >> /var/log/zyt-todo.log 2>&1
|
||||||
|
*
|
||||||
|
* 行为:
|
||||||
|
* 1. 取出 status=0 且 remind_time<=now 的待办,按 remind_time asc,限 200 条
|
||||||
|
* 2. 每条用乐观锁 (update set status=1 where id=? and status=0) 抢占,避免并发重复推送
|
||||||
|
* 3. 找创建人 admin.work_wechat_userid,未绑定 → status=3 + error
|
||||||
|
* 4. 调 WechatWorkAppMessageService::sendTextToUser,失败 → status=3 + error(终态,不重试)
|
||||||
|
* 5. 整批不中断;输出 处理 N / 成功 X / 失败 Y / 跳过未绑定 Z
|
||||||
|
*/
|
||||||
|
class DiagnosisTodoNotify extends Command
|
||||||
|
{
|
||||||
|
/** 单次扫描最大条数 */
|
||||||
|
private const BATCH_LIMIT = 200;
|
||||||
|
|
||||||
|
protected function configure()
|
||||||
|
{
|
||||||
|
$this->setName('tcm:diagnosis-todo-notify')
|
||||||
|
->setDescription('诊单待办事项:扫描到点的待执行项并向创建人发送企业微信消息');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(Input $input, Output $output): int
|
||||||
|
{
|
||||||
|
$startTs = microtime(true);
|
||||||
|
$now = time();
|
||||||
|
|
||||||
|
$output->writeln('[' . date('Y-m-d H:i:s', $now) . '] 开始扫描诊单待办事项...');
|
||||||
|
|
||||||
|
$totalProcessed = 0;
|
||||||
|
$totalSent = 0;
|
||||||
|
$totalFailed = 0;
|
||||||
|
$totalSkipped = 0; // 被并发抢占
|
||||||
|
$totalUnbound = 0; // 创建人未绑定企微(计入 failed)
|
||||||
|
|
||||||
|
try {
|
||||||
|
$todoTable = (new DiagnosisTodo())->getTable();
|
||||||
|
|
||||||
|
$rows = Db::name(self::stripTablePrefix($todoTable))
|
||||||
|
->where('status', DiagnosisTodo::STATUS_PENDING)
|
||||||
|
->where('remind_time', '<=', $now)
|
||||||
|
->whereNull('delete_time')
|
||||||
|
->order('remind_time', 'asc')
|
||||||
|
->limit(self::BATCH_LIMIT)
|
||||||
|
->select()
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
$output->writeln('待处理数量:' . count($rows));
|
||||||
|
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$totalProcessed++;
|
||||||
|
$todoId = (int) ($row['id'] ?? 0);
|
||||||
|
if ($todoId <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 乐观锁:抢占成功时影响 1 行;并发场景下另一个进程已抢走则影响 0 行 → 跳过
|
||||||
|
$affected = Db::name(self::stripTablePrefix($todoTable))
|
||||||
|
->where('id', $todoId)
|
||||||
|
->where('status', DiagnosisTodo::STATUS_PENDING)
|
||||||
|
->update([
|
||||||
|
'status' => DiagnosisTodo::STATUS_SENT,
|
||||||
|
'notified_at' => $now,
|
||||||
|
'update_time' => $now,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($affected <= 0) {
|
||||||
|
$totalSkipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 取创建人 work_wechat_userid
|
||||||
|
$creatorId = (int) ($row['creator_id'] ?? 0);
|
||||||
|
$wxId = '';
|
||||||
|
if ($creatorId > 0) {
|
||||||
|
$wxId = (string) Admin::where('id', $creatorId)->value('work_wechat_userid');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($wxId === '') {
|
||||||
|
$this->markFailed($todoTable, $todoId, '创建人未绑定企业微信 userid');
|
||||||
|
$totalUnbound++;
|
||||||
|
$totalFailed++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 拼推送文本
|
||||||
|
$text = $this->buildText($row);
|
||||||
|
|
||||||
|
$res = WechatWorkAppMessageService::sendTextToUser($wxId, $text);
|
||||||
|
if (!($res['ok'] ?? false)) {
|
||||||
|
$errMsg = (string) ($res['message'] ?? '企业微信推送失败');
|
||||||
|
$this->markFailed($todoTable, $todoId, $errMsg);
|
||||||
|
$totalFailed++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$totalSent++;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::error('诊单待办推送异常 todo_id=' . $todoId . ' msg=' . $e->getMessage());
|
||||||
|
try {
|
||||||
|
$this->markFailed($todoTable, $todoId, '系统异常: ' . $e->getMessage());
|
||||||
|
} catch (\Throwable $ee) {
|
||||||
|
Log::error('回写失败状态出错 todo_id=' . $todoId . ' msg=' . $ee->getMessage());
|
||||||
|
}
|
||||||
|
$totalFailed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::error('诊单待办批处理异常: ' . $e->getMessage());
|
||||||
|
$output->error('批处理异常: ' . $e->getMessage());
|
||||||
|
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
$duration = round(microtime(true) - $startTs, 3);
|
||||||
|
$output->writeln(sprintf(
|
||||||
|
'处理完成。总数: %d, 成功: %d, 失败: %d (其中未绑定企微: %d), 并发跳过: %d, 耗时: %ss',
|
||||||
|
$totalProcessed,
|
||||||
|
$totalSent,
|
||||||
|
$totalFailed,
|
||||||
|
$totalUnbound,
|
||||||
|
$totalSkipped,
|
||||||
|
$duration
|
||||||
|
));
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拼接推送文本
|
||||||
|
*
|
||||||
|
* @param array<string,mixed> $row 待办记录原始行
|
||||||
|
*/
|
||||||
|
private function buildText(array $row): string
|
||||||
|
{
|
||||||
|
$patientName = '';
|
||||||
|
$diagnosisId = (int) ($row['diagnosis_id'] ?? 0);
|
||||||
|
if ($diagnosisId > 0) {
|
||||||
|
$patientName = (string) Diagnosis::where('id', $diagnosisId)->value('patient_name');
|
||||||
|
}
|
||||||
|
|
||||||
|
$remindAt = (int) ($row['remind_time'] ?? 0);
|
||||||
|
$content = trim((string) ($row['content'] ?? ''));
|
||||||
|
|
||||||
|
$lines = [
|
||||||
|
'【患者跟踪提醒】',
|
||||||
|
'患者:' . ($patientName !== '' ? $patientName : '-'),
|
||||||
|
'时间:' . ($remindAt > 0 ? date('Y-m-d H:i', $remindAt) : '-'),
|
||||||
|
'内容:' . ($content !== '' ? $content : '-'),
|
||||||
|
'— 二中心跟踪系统',
|
||||||
|
];
|
||||||
|
|
||||||
|
return implode("\n", $lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标记一条待办为「发送失败」,写入 error 字段
|
||||||
|
*/
|
||||||
|
private function markFailed(string $todoTable, int $todoId, string $errMsg): void
|
||||||
|
{
|
||||||
|
$errMsg = mb_substr($errMsg, 0, 500);
|
||||||
|
Db::name(self::stripTablePrefix($todoTable))
|
||||||
|
->where('id', $todoId)
|
||||||
|
->update([
|
||||||
|
'status' => DiagnosisTodo::STATUS_FAILED,
|
||||||
|
'error' => $errMsg,
|
||||||
|
'update_time' => time(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Db::name() 接收的是不带前缀的表名,BloodRecord 等 Model::getTable() 返回的是带前缀的全名。
|
||||||
|
* 这里去掉首段 `<prefix>_`。注意:项目实际前缀是 `zyt_`,但 think-orm 的 Db::name()
|
||||||
|
* 内部会用 config('database.prefix') 自动拼回,所以这里只需剥离一次即可。
|
||||||
|
*/
|
||||||
|
private static function stripTablePrefix(string $tableWithPrefix): string
|
||||||
|
{
|
||||||
|
$prefix = (string) config('database.connections.mysql.prefix', '');
|
||||||
|
if ($prefix !== '' && str_starts_with($tableWithPrefix, $prefix)) {
|
||||||
|
return substr($tableWithPrefix, strlen($prefix));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $tableWithPrefix;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
<?php
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | likeadmin快速开发前后端分离管理后台(PHP版)
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | 欢迎阅读学习系统程序代码,建议反馈是我们前进的动力
|
||||||
|
// | 开源版本可自由商用,可去除界面版权logo
|
||||||
|
// | gitee下载:https://gitee.com/likeshop_gitee/likeadmin
|
||||||
|
// | github下载:https://github.com/likeshop-github/likeadmin
|
||||||
|
// | 访问官网:https://www.likeadmin.cn
|
||||||
|
// | likeadmin团队 版权所有 拥有最终解释权
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
// | author: likeadminTeam
|
||||||
|
// +----------------------------------------------------------------------
|
||||||
|
|
||||||
|
namespace app\common\model\tcm;
|
||||||
|
|
||||||
|
use app\common\model\BaseModel;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 诊单待办事项模型
|
||||||
|
*
|
||||||
|
* 状态机:0=待执行 → 1=已发送 / 2=已取消 / 3=发送失败(终态,不重试)
|
||||||
|
*
|
||||||
|
* @package app\common\model\tcm
|
||||||
|
*/
|
||||||
|
class DiagnosisTodo extends BaseModel
|
||||||
|
{
|
||||||
|
protected $name = 'tcm_diagnosis_todo';
|
||||||
|
|
||||||
|
// 自动时间戳类型设置为整型
|
||||||
|
protected $autoWriteTimestamp = true;
|
||||||
|
protected $createTime = 'create_time';
|
||||||
|
protected $updateTime = 'update_time';
|
||||||
|
protected $deleteTime = 'delete_time';
|
||||||
|
|
||||||
|
// 取出字段保持整型,前端按需要再格式化
|
||||||
|
protected $dateFormat = false;
|
||||||
|
|
||||||
|
/** 状态:待执行 */
|
||||||
|
public const STATUS_PENDING = 0;
|
||||||
|
/** 状态:已发送(成功) */
|
||||||
|
public const STATUS_SENT = 1;
|
||||||
|
/** 状态:已取消(人工) */
|
||||||
|
public const STATUS_CANCELLED = 2;
|
||||||
|
/** 状态:发送失败(终态,不重试) */
|
||||||
|
public const STATUS_FAILED = 3;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 状态文本映射
|
||||||
|
*
|
||||||
|
* @var array<int,string>
|
||||||
|
*/
|
||||||
|
public const STATUS_TEXT_MAP = [
|
||||||
|
self::STATUS_PENDING => '待执行',
|
||||||
|
self::STATUS_SENT => '已发送',
|
||||||
|
self::STATUS_CANCELLED => '已取消',
|
||||||
|
self::STATUS_FAILED => '发送失败',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @notes 状态文本访问器
|
||||||
|
*/
|
||||||
|
public function getStatusTextAttr($value, $data): string
|
||||||
|
{
|
||||||
|
$status = (int) ($data['status'] ?? 0);
|
||||||
|
|
||||||
|
return self::STATUS_TEXT_MAP[$status] ?? '未知';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @notes 提醒时间格式化(Y-m-d H:i)
|
||||||
|
*/
|
||||||
|
public function getRemindTimeTextAttr($value, $data): string
|
||||||
|
{
|
||||||
|
$ts = (int) ($data['remind_time'] ?? 0);
|
||||||
|
|
||||||
|
return $ts > 0 ? date('Y-m-d H:i', $ts) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @notes 推送时间格式化
|
||||||
|
*/
|
||||||
|
public function getNotifiedAtTextAttr($value, $data): string
|
||||||
|
{
|
||||||
|
$ts = (int) ($data['notified_at'] ?? 0);
|
||||||
|
|
||||||
|
return $ts > 0 ? date('Y-m-d H:i', $ts) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @notes 取消时间格式化
|
||||||
|
*/
|
||||||
|
public function getCancelledAtTextAttr($value, $data): string
|
||||||
|
{
|
||||||
|
$ts = (int) ($data['cancelled_at'] ?? 0);
|
||||||
|
|
||||||
|
return $ts > 0 ? date('Y-m-d H:i', $ts) : '';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace app\common\model\tcm;
|
||||||
|
|
||||||
|
use app\common\model\BaseModel;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 诊单跟踪备注模型
|
||||||
|
* 表 zyt_tracking_note:按天一条,多次追加更新;只存文字。
|
||||||
|
*/
|
||||||
|
class TrackingNote extends BaseModel
|
||||||
|
{
|
||||||
|
protected $name = 'tracking_note';
|
||||||
|
|
||||||
|
protected $autoWriteTimestamp = true;
|
||||||
|
protected $createTime = 'create_time';
|
||||||
|
protected $updateTime = 'update_time';
|
||||||
|
protected $deleteTime = 'delete_time';
|
||||||
|
|
||||||
|
protected $dateFormat = false;
|
||||||
|
}
|
||||||
@@ -34,5 +34,7 @@ return [
|
|||||||
'gancao:sync-logistics' => 'app\\command\\GancaoSyncLogisticsRoute',
|
'gancao:sync-logistics' => 'app\\command\\GancaoSyncLogisticsRoute',
|
||||||
// 迁移诊单图片到医生备注表
|
// 迁移诊单图片到医生备注表
|
||||||
'migrate:images-to-doctor-note' => 'app\\command\\MigrateImagesToDoctorNote',
|
'migrate:images-to-doctor-note' => 'app\\command\\MigrateImagesToDoctorNote',
|
||||||
|
// 诊单待办事项:扫描到点的待执行项并向创建人发送企业微信消息
|
||||||
|
'tcm:diagnosis-todo-notify' => 'app\\command\\DiagnosisTodoNotify',
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
-- 清理菜单表中的孤儿记录(可选 · 跟 T1+T2 无关)
|
||||||
|
--
|
||||||
|
-- 背景:admin 启动时会按菜单表的 component 字段去 import.meta.glob 出来的 vue 文件里找匹配,
|
||||||
|
-- 找不到就 console.error("找不到组件 xxx")。前端 admin/src/views/stats/ 下没有 doctor-daily 目录,
|
||||||
|
-- 但菜单表里仍残留 component='stats/doctor-daily/index' 的记录 → 每次刷页面都会有这条警告。
|
||||||
|
--
|
||||||
|
-- 安全提示:
|
||||||
|
-- 1. **先跑下面的 SELECT**,确认要删的记录确实是孤儿(前端 views 下不存在)。
|
||||||
|
-- 2. 如果想保留菜单数据但不再触发 console.error,可以改 component 为空字符串而不是删除。
|
||||||
|
-- 3. 如果以后该功能要回归,可以从 git 历史里恢复或重新写 menu SQL。
|
||||||
|
--
|
||||||
|
-- 步骤 1:探测
|
||||||
|
-- =============================================================
|
||||||
|
-- SELECT id, pid, type, name, perms, paths, component, is_show, create_time
|
||||||
|
-- FROM zyt_system_menu
|
||||||
|
-- WHERE component LIKE '%doctor-daily%'
|
||||||
|
-- OR perms LIKE '%doctor.daily%'
|
||||||
|
-- OR paths LIKE '%doctor-daily%';
|
||||||
|
|
||||||
|
-- 步骤 2(可选 A):把这条菜单及其子按钮全部删掉
|
||||||
|
-- =============================================================
|
||||||
|
-- 先把它的子节点(按钮权限)一并清理
|
||||||
|
-- DELETE FROM zyt_system_menu
|
||||||
|
-- WHERE pid IN (
|
||||||
|
-- SELECT id FROM (
|
||||||
|
-- SELECT id FROM zyt_system_menu WHERE component = 'stats/doctor-daily/index'
|
||||||
|
-- ) t
|
||||||
|
-- );
|
||||||
|
-- DELETE FROM zyt_system_menu WHERE component = 'stats/doctor-daily/index';
|
||||||
|
|
||||||
|
-- 步骤 2(可选 B):仅置空 component,保留菜单与按钮权限不动(最稳妥)
|
||||||
|
-- =============================================================
|
||||||
|
-- UPDATE zyt_system_menu
|
||||||
|
-- SET component = ''
|
||||||
|
-- WHERE component = 'stats/doctor-daily/index';
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# 二诊只读病例详情接口权限注册(T1+T2 - 2026-05-05)
|
||||||
|
# 挂在「中医诊单」(perms=tcm.diagnosis/lists) 菜单下
|
||||||
|
# tcm.diagnosis/readonlyDetail 二诊只读病例详情接口
|
||||||
|
|
||||||
|
SET @diag_menu_id := (SELECT id FROM zyt_system_menu WHERE perms = 'tcm.diagnosis/lists' LIMIT 1);
|
||||||
|
|
||||||
|
INSERT INTO zyt_system_menu (
|
||||||
|
pid, type, name, icon, sort, perms, paths, component,
|
||||||
|
selected, params, is_cache, is_show, is_disable, create_time, update_time
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
@diag_menu_id, 'A', '患者信息详情', '', 70,
|
||||||
|
'tcm.diagnosis/readonlyDetail', '', '',
|
||||||
|
'', '', 0, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
|
||||||
|
FROM DUAL
|
||||||
|
WHERE @diag_menu_id IS NOT NULL
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM zyt_system_menu WHERE perms = 'tcm.diagnosis/readonlyDetail');
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# 诊单待办事项:菜单按钮权限注册(T5 - 2026-05-05)
|
||||||
|
# 挂在「中医诊单」(perms=tcm.diagnosis/lists) 菜单下,按钮型权限节点:
|
||||||
|
# tcm.diagnosisTodo/lists 待办列表
|
||||||
|
# tcm.diagnosisTodo/add 新增待办
|
||||||
|
# tcm.diagnosisTodo/cancel 取消待办
|
||||||
|
# detail 不单独注册(前端不主动调,由列表/详情接口已覆盖)
|
||||||
|
|
||||||
|
SET @diag_menu_id := (SELECT id FROM zyt_system_menu WHERE perms = 'tcm.diagnosis/lists' LIMIT 1);
|
||||||
|
|
||||||
|
INSERT INTO zyt_system_menu (
|
||||||
|
pid, type, name, icon, sort, perms, paths, component,
|
||||||
|
selected, params, is_cache, is_show, is_disable, create_time, update_time
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
@diag_menu_id, 'A', '待办列表', '', 60,
|
||||||
|
'tcm.diagnosisTodo/lists', '', '',
|
||||||
|
'', '', 0, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
|
||||||
|
FROM DUAL
|
||||||
|
WHERE @diag_menu_id IS NOT NULL
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM zyt_system_menu WHERE perms = 'tcm.diagnosisTodo/lists');
|
||||||
|
|
||||||
|
INSERT INTO zyt_system_menu (
|
||||||
|
pid, type, name, icon, sort, perms, paths, component,
|
||||||
|
selected, params, is_cache, is_show, is_disable, create_time, update_time
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
@diag_menu_id, 'A', '新增待办', '', 61,
|
||||||
|
'tcm.diagnosisTodo/add', '', '',
|
||||||
|
'', '', 0, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
|
||||||
|
FROM DUAL
|
||||||
|
WHERE @diag_menu_id IS NOT NULL
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM zyt_system_menu WHERE perms = 'tcm.diagnosisTodo/add');
|
||||||
|
|
||||||
|
INSERT INTO zyt_system_menu (
|
||||||
|
pid, type, name, icon, sort, perms, paths, component,
|
||||||
|
selected, params, is_cache, is_show, is_disable, create_time, update_time
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
@diag_menu_id, 'A', '取消待办', '', 62,
|
||||||
|
'tcm.diagnosisTodo/cancel', '', '',
|
||||||
|
'', '', 0, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
|
||||||
|
FROM DUAL
|
||||||
|
WHERE @diag_menu_id IS NOT NULL
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM zyt_system_menu WHERE perms = 'tcm.diagnosisTodo/cancel');
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
-- 诊单待办事项(T5 - 2026-05-05)
|
||||||
|
-- 用途:医助在诊单上挂「将来要提醒自己的事」,到点由企微推送给创建人本人。
|
||||||
|
-- 设计:状态机 0=待执行 → 1=已发送(成功)/ 2=已取消(人工) / 3=发送失败(终态,不重试)
|
||||||
|
-- 索引说明:
|
||||||
|
-- idx_diagnosis_id —— 编辑页/列表按诊单查询
|
||||||
|
-- idx_creator_id —— 反查某 admin 创建过的待办(取消权限校验等)
|
||||||
|
-- idx_status_remind_time —— 复合索引,cron 扫表「status=0 AND remind_time<=now」核心索引
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `zyt_tcm_diagnosis_todo` (
|
||||||
|
`id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||||
|
`diagnosis_id` int(11) unsigned NOT NULL DEFAULT 0 COMMENT '所属诊单 id(zyt_tcm_diagnosis.id)',
|
||||||
|
`patient_id` int(11) unsigned NOT NULL DEFAULT 0 COMMENT '冗余患者 id,便于反查',
|
||||||
|
`content` varchar(500) NOT NULL DEFAULT '' COMMENT '提醒内容(企微推送正文)',
|
||||||
|
`remind_time` int(11) unsigned NOT NULL DEFAULT 0 COMMENT '提醒时间戳(秒);cron 扫描 remind_time<=now',
|
||||||
|
`status` tinyint(1) unsigned NOT NULL DEFAULT 0 COMMENT '状态:0=待执行 1=已发送 2=已取消 3=发送失败',
|
||||||
|
`creator_id` int(11) unsigned NOT NULL DEFAULT 0 COMMENT '创建人 admin.id(推送目标)',
|
||||||
|
`creator_name` varchar(64) NOT NULL DEFAULT '' COMMENT '创建人姓名快照',
|
||||||
|
`notified_at` int(11) unsigned NOT NULL DEFAULT 0 COMMENT '实际推送时间(成功时写入)',
|
||||||
|
`error` varchar(500) NOT NULL DEFAULT '' COMMENT '失败原因(status=3 时写入)',
|
||||||
|
`cancelled_at` int(11) unsigned NOT NULL DEFAULT 0 COMMENT '取消时间',
|
||||||
|
`cancelled_by` int(11) unsigned NOT NULL DEFAULT 0 COMMENT '取消人 admin.id',
|
||||||
|
`create_time` int(11) unsigned NOT NULL DEFAULT 0 COMMENT '创建时间',
|
||||||
|
`update_time` int(11) unsigned NOT NULL DEFAULT 0 COMMENT '更新时间',
|
||||||
|
`delete_time` int(11) unsigned NULL DEFAULT NULL COMMENT '软删除时间',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_diagnosis_id` (`diagnosis_id`),
|
||||||
|
KEY `idx_creator_id` (`creator_id`),
|
||||||
|
KEY `idx_status_remind_time` (`status`, `remind_time`)
|
||||||
|
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '诊单待办事项(企微定时推送给创建人)';
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# 诊单跟踪备注接口权限注册(2026-05-05)
|
||||||
|
# 挂在「中医诊单」(perms=tcm.diagnosis/lists) 菜单下
|
||||||
|
# tcm.diagnosis/addTrackingNote 新增跟踪备注
|
||||||
|
# tcm.diagnosis/trackingNotes 拉取跟踪备注列表
|
||||||
|
|
||||||
|
SET @diag_menu_id := (SELECT id FROM zyt_system_menu WHERE perms = 'tcm.diagnosis/lists' LIMIT 1);
|
||||||
|
|
||||||
|
INSERT INTO zyt_system_menu (
|
||||||
|
pid, type, name, icon, sort, perms, paths, component,
|
||||||
|
selected, params, is_cache, is_show, is_disable, create_time, update_time
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
@diag_menu_id, 'A', '新增跟踪备注', '', 80,
|
||||||
|
'tcm.diagnosis/addTrackingNote', '', '',
|
||||||
|
'', '', 0, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
|
||||||
|
FROM DUAL
|
||||||
|
WHERE @diag_menu_id IS NOT NULL
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM zyt_system_menu WHERE perms = 'tcm.diagnosis/addTrackingNote');
|
||||||
|
|
||||||
|
INSERT INTO zyt_system_menu (
|
||||||
|
pid, type, name, icon, sort, perms, paths, component,
|
||||||
|
selected, params, is_cache, is_show, is_disable, create_time, update_time
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
@diag_menu_id, 'A', '跟踪备注列表', '', 81,
|
||||||
|
'tcm.diagnosis/trackingNotes', '', '',
|
||||||
|
'', '', 0, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
|
||||||
|
FROM DUAL
|
||||||
|
WHERE @diag_menu_id IS NOT NULL
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM zyt_system_menu WHERE perms = 'tcm.diagnosis/trackingNotes');
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
-- 诊单跟踪备注表(按天一条,多次追加更新;只存文字,不含附件)
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `zyt_tracking_note` (
|
||||||
|
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
|
||||||
|
`diagnosis_id` int(11) unsigned NOT NULL COMMENT '诊单ID (tcm_diagnosis.id)',
|
||||||
|
`admin_id` int(11) unsigned NOT NULL DEFAULT 0 COMMENT '记录人ID (system_admin.id)',
|
||||||
|
`note_date` date NOT NULL COMMENT '记录日期',
|
||||||
|
`content` text DEFAULT NULL COMMENT '跟踪备注文字(多次追加以换行分隔,带 [HH:MM] 前缀)',
|
||||||
|
`create_time` int(10) unsigned NOT NULL DEFAULT 0,
|
||||||
|
`update_time` int(10) unsigned NOT NULL DEFAULT 0,
|
||||||
|
`delete_time` int(10) unsigned DEFAULT NULL,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_diagnosis_date` (`diagnosis_id`, `note_date`),
|
||||||
|
KEY `idx_admin` (`admin_id`),
|
||||||
|
KEY `idx_diagnosis_date` (`diagnosis_id`, `note_date`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='诊单跟踪备注';
|
||||||
Reference in New Issue
Block a user