This commit is contained in:
2026-05-05 16:16:55 +08:00
parent 19bfeef98f
commit 49d0fed14e
8 changed files with 241 additions and 139 deletions
+10 -1
View File
@@ -5,11 +5,20 @@ export function tcmDiagnosisLists(params: any) {
return request.get({ url: '/tcm.diagnosis/lists', params })
}
/** 二诊只读病例详情(T1+T2):返回 appointment + diagnosis + tracking + 未服务天数 */
/** 二诊只读病例详情(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 assistantDiagnosisStats(params?: {
start_time?: string
+7 -66
View File
@@ -126,65 +126,13 @@
/>
</div>
<!-- 血糖血压记录 -->
<!-- 跟踪信息与只读病例页同口径自治 lazy load -->
<div class="card metric-card">
<div class="card-title">血糖血压记录</div>
<div class="metric-viewport">
<el-table
:data="bloodRecords"
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 class="card-title">跟踪信息</div>
<TrackingMatrix
:diagnosis-id="Number(diag?.id ?? 0)"
:age="diag?.age ?? null"
/>
</div>
</template>
</section>
@@ -242,18 +190,12 @@ import { receptionQueue, receptionDetail, notifyAssistant, addDoctorNote } from
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 { completeAppointment } from '@/api/doctor'
import { getCallSignature } from '@/api/tcm'
import feedback from '@/utils/feedback'
import { formatDiabetesDiscoveryDisplay } from '@/utils/diabetes-discovery-display'
import { formatGender } from '@/utils/diag-display'
import {
isHighFastingBloodSugar,
isHighPostprandialBloodSugar,
isHighOtherBloodSugar,
isHighBloodPressure,
formatBp
} from '@/utils/blood-thresholds'
import {
Phone,
Search,
@@ -323,7 +265,6 @@ const noteSaving = ref(false)
const apt = computed<any>(() => detail.value?.appointment || {})
const diag = computed<any>(() => detail.value?.diagnosis || {})
const bloodRecords = computed<any[]>(() => detail.value?.blood_records || [])
const doctorNotes = computed<any[]>(() => detail.value?.doctor_notes || [])
const todayMeta = computed(() => {
@@ -1,16 +1,34 @@
<template>
<div class="tracking-matrix">
<div class="tracking-matrix" v-loading="loading">
<div class="toolbar">
<el-radio-group v-model="windowDays" @change="onWindowChange">
<el-radio-button :value="7">最近 7 </el-radio-button>
<el-radio-button :value="30">最近 30 </el-radio-button>
<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>
<span class="hint">同一天血糖三段挤一格超阈值红字年龄分段&lt;50空7/餐950空8/餐10</span>
<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">
超阈值红字年龄分段&lt;50空7/餐950空8/餐10
</span>
</div>
<el-empty
v-if="visibleDates.length === 0"
description="近期无任何打卡记录"
v-if="!loading && visibleDates.length === 0"
description="所选区间内无任何打卡记录"
:image-size="80"
/>
@@ -112,7 +130,7 @@
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import {
isHighFastingBloodSugar,
isHighPostprandialBloodSugar,
@@ -120,6 +138,7 @@ import {
isHighBloodPressure,
formatBp
} from '@/utils/blood-thresholds'
import { diagnosisTrackingWindow } from '@/api/tcm'
interface BloodRecord {
record_date?: string
@@ -136,7 +155,6 @@ interface BloodRecord {
interface DietRecord {
record_date?: string
record_date_ts?: number
breakfast?: string
lunch?: string
dinner?: string
@@ -145,7 +163,6 @@ interface DietRecord {
interface ExerciseRecord {
record_date?: string
record_date_ts?: number
duration?: number
intensity?: number
intensity_text?: string
@@ -154,35 +171,112 @@ interface ExerciseRecord {
}
interface Props {
diagnosisId?: number
patientId?: number | null
diagnosisId: number
age?: number | null
bloodRecords: BloodRecord[]
dietRecords: DietRecord[]
exerciseRecords: ExerciseRecord[]
}
const props = withDefaults(defineProps<Props>(), {
diagnosisId: 0,
patientId: null,
age: null,
bloodRecords: () => [],
dietRecords: () => [],
exerciseRecords: () => []
age: null
})
const windowDays = ref<7 | 30>(30)
type RangeMode = '7' | '30' | 'custom'
const rangeMode = ref<RangeMode>('30')
const customRange = ref<[string, string] | null>(null)
const onWindowChange = () => {
// 触发 computed 重算;预留扩展点:未来可在此处通知父组件重拉
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 props.bloodRecords || []) {
for (const r of bloodRecords.value) {
const d = String(r.record_date || '')
if (!d) continue
if (!map.has(d)) {
@@ -202,7 +296,12 @@ const bloodByDate = computed(() => {
'insulin'
]
for (const f of fields) {
if ((acc[f] === undefined || acc[f] === null || acc[f] === '') && r[f] !== undefined && r[f] !== null && r[f] !== '') {
if (
(acc[f] === undefined || acc[f] === null || acc[f] === '') &&
r[f] !== undefined &&
r[f] !== null &&
r[f] !== ''
) {
;(acc as any)[f] = r[f]
}
}
@@ -212,7 +311,7 @@ const bloodByDate = computed(() => {
const dietByDate = computed(() => {
const map = new Map<string, DietRecord>()
for (const r of props.dietRecords || []) {
for (const r of dietRecords.value) {
const d = String(r.record_date || '')
if (!d) continue
if (!map.has(d)) map.set(d, r)
@@ -222,7 +321,7 @@ const dietByDate = computed(() => {
const exerciseByDate = computed(() => {
const map = new Map<string, ExerciseRecord>()
for (const r of props.exerciseRecords || []) {
for (const r of exerciseRecords.value) {
const d = String(r.record_date || '')
if (!d) continue
if (!map.has(d)) map.set(d, r)
@@ -231,24 +330,18 @@ const exerciseByDate = computed(() => {
})
/**
* 取三类记录的日期并集,按 desc 排序并去重,限制最大 windowDays 条
* 已经由后端按区间过滤,前端只需取三类记录的日期并集 + desc 排序
*/
const visibleDates = computed<string[]>(() => {
const set = new Set<string>()
for (const r of props.bloodRecords || []) if (r.record_date) set.add(String(r.record_date))
for (const r of props.dietRecords || []) if (r.record_date) set.add(String(r.record_date))
for (const r of props.exerciseRecords || []) if (r.record_date) set.add(String(r.record_date))
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.slice(0, windowDays.value)
return list
})
const weekText = (d: string) => {
const ts = Date.parse(d + 'T00:00:00')
if (!Number.isFinite(ts)) return ''
const w = new Date(ts).getDay()
return ['日', '一', '二', '三', '四', '五', '六'][w]
}
const rows = computed(() =>
visibleDates.value.map((date) => {
@@ -263,15 +356,15 @@ const rows = computed(() =>
const dia = blood?.diastolic_pressure ?? null
const bloodAny =
fasting !== null && fasting !== '' ||
postprandial !== null && postprandial !== '' ||
other !== null && other !== ''
(fasting !== null && fasting !== '') ||
(postprandial !== null && postprandial !== '') ||
(other !== null && other !== '')
const bp = sys || dia ? formatBp({ systolic_pressure: sys, diastolic_pressure: dia }) : ''
return {
date,
dateLabel: `${date.slice(5)}(周${weekText(date)}`,
dateLabel: `${date}`,
// 血糖
bloodAny,
fasting: fasting ?? '—',
@@ -305,6 +398,8 @@ const rows = computed(() =>
}
})
)
defineExpose({ refresh: fetchTracking })
</script>
<style lang="scss" scoped>
+1 -8
View File
@@ -41,14 +41,10 @@
<!-- 跟踪信息 -->
<div class="card tracking-card">
<div class="card-title">跟踪信息最近 30 </div>
<div class="card-title">跟踪信息</div>
<TrackingMatrix
:diagnosis-id="Number(diagnosisIdNum)"
:patient-id="Number(diag?.patient_id ?? 0)"
:age="diag?.age ?? null"
:blood-records="bloodRecords"
:diet-records="dietRecords"
:exercise-records="exerciseRecords"
/>
</div>
@@ -83,9 +79,6 @@ const detail = ref<any>(null)
const apt = computed<any>(() => detail.value?.appointment || {})
const diag = computed<any>(() => detail.value?.diagnosis || {})
const bloodRecords = computed<any[]>(() => detail.value?.blood_records || [])
const dietRecords = computed<any[]>(() => detail.value?.diet_records || [])
const exerciseRecords = computed<any[]>(() => detail.value?.exercise_records || [])
const doctorNotes = computed<any[]>(() => detail.value?.doctor_notes || [])
const unservedDays = computed<number | null>(() => {
const v = detail.value?.unserved_days
@@ -138,6 +138,25 @@ class DiagnosisController extends BaseAdminController
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 诊单挂号 / 取消挂号 操作日志(谁在何时操作)
*/
@@ -516,10 +516,8 @@ class AppointmentLogic extends BaseLogic
? DiagnosisLogic::detail(['id' => $diagnosisId])
: [];
// 3) 血糖血压记录
$bloodRecords = $diagnosisId > 0
? BloodRecordLogic::getRecordsByPatient(['diagnosis_id' => $diagnosisId])
: [];
// 3) 跟踪信息(血糖血压 / 饮食 / 运动)改为前端按窗口 lazy load,
// 详见 DiagnosisController::trackingWindow。此处不再一次性返回。
// 4) 补全医助姓名(detail() 未关联 admin 助理表)
$assistantName = '';
@@ -554,7 +552,6 @@ class AppointmentLogic extends BaseLogic
return [
'appointment' => $appointment,
'diagnosis' => $diagnosis,
'blood_records' => $bloodRecords,
'doctor_notes' => $doctorNotes,
];
}
@@ -3397,14 +3397,8 @@ class DiagnosisLogic extends BaseLogic
// 3) 最近一条挂号(PatientInfoCard 显示用,无挂号则给默认空对象)
$appointment = self::buildLatestAppointmentForReadonly((int) ($diagnosis['id'] ?? $diagnosisId), $diagnosis);
// 4) 最近 30 天的跟踪记录(一次性 join 出三类,前端透视为矩阵)
$windowDays = 30;
$sinceTs = strtotime('-' . $windowDays . ' days');
$sinceTs = $sinceTs > 0 ? $sinceTs : 0;
$bloodRecords = self::fetchBloodRecordsForReadonly($diagnosisId, $sinceTs);
$dietRecords = self::fetchDietRecordsForReadonly($diagnosisId, $sinceTs);
$exerciseRecords = self::fetchExerciseRecordsForReadonly($diagnosisId, $sinceTs);
// 4) 跟踪信息(血糖血压 / 饮食 / 运动)改为前端按窗口 lazy load,
// 详见 DiagnosisController::trackingWindow。此处不再一次性返回。
// 5) 医生备注
$doctorNotes = DoctorNoteLogic::getByDiagnosis($diagnosisId);
@@ -3419,13 +3413,39 @@ class DiagnosisLogic extends BaseLogic
return [
'appointment' => $appointment,
'diagnosis' => $diagnosis,
'blood_records' => $bloodRecords,
'diet_records' => $dietRecords,
'exercise_records' => $exerciseRecords,
'doctor_notes' => $doctorNotes,
'unserved_days' => $unservedDays,
'last_blood_record_at' => $lastBloodRecordAt,
'window_days' => $windowDays,
];
}
/**
* 取指定日期区间内的三类跟踪记录(血糖血压 / 饮食 / 运动),供 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,
];
}
@@ -3502,12 +3522,15 @@ class DiagnosisLogic extends BaseLogic
/**
* @return array<int,array<string,mixed>>
*/
private static function fetchBloodRecordsForReadonly(int $diagnosisId, int $sinceTs): array
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()
@@ -3526,12 +3549,15 @@ class DiagnosisLogic extends BaseLogic
/**
* @return array<int,array<string,mixed>>
*/
private static function fetchDietRecordsForReadonly(int $diagnosisId, int $sinceTs): array
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) {
@@ -3546,12 +3572,15 @@ class DiagnosisLogic extends BaseLogic
/**
* @return array<int,array<string,mixed>>
*/
private static function fetchExerciseRecordsForReadonly(int $diagnosisId, int $sinceTs): array
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()
@@ -37,6 +37,8 @@ class DiagnosisValidate extends BaseValidate
'tongue_images' => 'array',
'report_files' => 'array',
'diabetes_discovery_year' => 'max:50',
'start_date' => 'date',
'end_date' => 'date|checkDateRange',
];
protected $message = [
@@ -55,6 +57,8 @@ class DiagnosisValidate extends BaseValidate
'status.in' => '状态参数错误',
'current_medications.max' => '在用药物最多2000个字符',
'diabetes_discovery_year.max' => '发现糖尿病患病史最多50个字符',
'start_date.date' => '开始日期格式不正确',
'end_date.date' => '结束日期格式不正确',
];
public function sceneAdd()
@@ -76,7 +80,12 @@ class DiagnosisValidate extends BaseValidate
{
return $this->only(['id']);
}
public function sceneTrackingWindow()
{
return $this->only(['id', 'start_date', 'end_date']);
}
public function sceneGenerateQrcode()
{
return $this->only(['diagnosis_id', 'doctor_id', 'patient_id', 'share_user_id', 'mini_program_path'])
@@ -112,6 +121,16 @@ class DiagnosisValidate extends BaseValidate
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 */
protected function checkQrcodeIds($value, $rule, $data = [])
{