ykt-wxapp/pages/case/visit-record-view.vue

764 lines
23 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<!-- 详情页参考截图顶部蓝条显示创建信息支持编辑/删除黄底提示不渲染 -->
<view class="page">
<scroll-view scroll-y class="scroll" :style="{ height: scrollHeight + 'px' }">
<view class="topbar">
<view class="topbar-text">{{ topText }}</view>
</view>
<view class="content">
<view class="section">
<view class="row">
<view class="label">病历类型</view>
<view class="value">{{ typeLabel }}</view>
</view>
<view class="row">
<view class="label">{{ visitDateLabel }}</view>
<view class="value">{{ visitDate || '--' }}</view>
</view>
<view v-if="showDiagnosisRow" class="row">
<view class="label">诊断</view>
<view class="value">{{ diagnosisText }}</view>
</view>
<view v-if="templateType === 'inhospital' && record.surgeryName" class="row">
<view class="label">手术名称</view>
<view class="value">{{ record.surgeryName }}</view>
</view>
</view>
<view v-for="(s, idx) in sections" :key="idx" class="section">
<view class="h2">{{ s.title }}</view>
<view class="p">{{ s.value }}</view>
</view>
<view v-if="showFilesSection" class="section">
<view class="h2">文件上传</view>
<view v-if="files.length" class="files">
<view v-for="(f, idx) in files" :key="idx" class="file" @click="preview(idx)">
<image class="thumb" :src="f.url" mode="aspectFill" />
</view>
</view>
<view v-else class="files-empty">暂无附件</view>
</view>
</view>
<view class="scroll-spacer" />
</scroll-view>
<view class="footer">
<button class="btn danger" @click="remove">删除</button>
<button class="btn primary" @click="edit">编辑</button>
</view>
</view>
</template>
<script setup>
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { onLoad, onShow } from '@dcloudio/uni-app';
import dayjs from 'dayjs';
import { storeToRefs } from 'pinia';
import api from '@/utils/api';
import useAccountStore from '@/store/account';
import { loading, hideLoading, toast } from '@/utils/widget';
import { getVisitRecordTemplate } from './components/archive-detail/templates';
import { normalizeVisitRecordFormData } from './utils/visit-record';
import { normalizeTemplate, unwrapTemplateResponse } from './utils/template';
import { normalizeFileUrl } from '@/utils/file';
const scrollHeight = ref(0);
const accountStore = useAccountStore();
const { account, doctorInfo } = storeToRefs(accountStore);
const { getDoctorInfo } = accountStore;
const archiveId = ref('');
const id = ref('');
const medicalType = ref('');
const rawType = ref('');
const record = ref({});
const temp = ref(null);
const needReload = ref(false);
let recordChangedHandler = null;
const userNameMap = ref({});
const loadedMembersTeamId = ref('');
const corpMemberNameInflight = new Map(); // userId -> Promise<string>
const corpMemberNameTried = new Set(); // avoid retry storms on failures
const files = computed(() => {
const arr = record.value?.files;
return Array.isArray(arr)
? arr
.filter((i) => i && i.url)
.map((i) => ({ ...i, url: normalizeFileUrl(i.url) }))
: [];
});
function normalizeMedicalType(raw) {
const s = String(raw || '').trim();
if (!s) return '';
const lower = s.toLowerCase();
if (lower.includes('preconsultationrecord')) return 'preConsultationRecord';
if (lower.includes('preconsult') || (lower.includes('pre') && lower.includes('consult'))) return 'preConsultationRecord';
if (lower === 'outpatient' || lower === 'out_patient' || lower === 'out-patient') return 'outpatient';
if (lower === 'inhospital' || lower === 'in_hospital' || lower === 'in-hospital' || lower === 'inpatient') return 'inhospital';
if (lower === 'preconsultation' || lower === 'pre_consultation' || lower === 'pre-consultation') return 'preConsultationRecord';
if (lower === 'physicalexaminationtemplate' || lower === 'physicalexamination' || lower === 'physical_examination') return 'physicalExaminationTemplate';
if (s === 'outPatient') return 'outpatient';
if (s === 'inHospital') return 'inhospital';
if (s === 'preConsultationRecord') return 'preConsultationRecord';
if (s === 'preConsultationRecord') return 'preConsultationRecord';
if (s === 'physicalExaminationTemplate') return 'physicalExaminationTemplate';
return s;
}
const templateType = computed(() => normalizeMedicalType(rawType.value || medicalType.value || record.value?.templateType || record.value?.medicalType || ''));
const typeLabel = computed(() => record.value?.tempName || temp.value?.name || getVisitRecordTemplate(templateType.value || medicalType.value)?.templateName || '病历');
function getDefaultTimeTitle(t) {
if (t === 'outpatient') return 'visitTime';
if (t === 'inhospital') return 'inhosDate';
if (t === 'preConsultationRecord') return 'consultationDate';
if (t === 'physicalExaminationTemplate') return 'inspectDate';
return '';
}
function getDefaultTimeName(t) {
if (t === 'outpatient') return '就诊日期';
if (t === 'inhospital') return '入院日期';
if (t === 'preConsultationRecord') return '问诊日期';
if (t === 'physicalExaminationTemplate') return '体检日期';
return '日期';
}
function normalizeText(v) {
if (Array.isArray(v)) return v.filter((i) => i !== null && i !== undefined && String(i).trim()).join('');
if (v === 0) return '0';
if (v && typeof v === 'object') {
const o = v;
const candidate = o.label ?? o.name ?? o.text ?? o.title ?? o.value ?? o.code ?? '';
return candidate ? String(candidate) : '';
}
return v ? String(v) : '';
}
function formatPositiveFind(v, { withOpinion = false } = {}) {
if (Array.isArray(v)) {
const list = v
.map((i) => (i && typeof i === 'object' ? { category: i.category, opinion: i.opinion } : null))
.filter((i) => i && (i.category || i.opinion));
if (!list.length) return '';
if (!withOpinion) return list.map((i) => String(i.category || '').trim()).filter(Boolean).join('');
return list
.map((i) => {
const c = String(i.category || '').trim();
const o = String(i.opinion || '').trim();
if (c && o) return `${c}${o}`;
return c || o;
})
.filter(Boolean)
.join('\n');
}
return normalizeText(v);
}
async function ensureDoctor() {
if (doctorInfo.value) return;
if (!account.value?.openid) return;
try {
await getDoctorInfo();
} catch {
// ignore
}
}
function extractDisplayNameFromAny(raw) {
const obj = raw && typeof raw === 'object' ? raw : {};
const candidate =
obj.anotherName ??
obj.name ??
obj.username ??
obj.userName ??
obj.nickname ??
obj.nickName ??
obj.realName ??
obj.memberName ??
obj.doctorName ??
obj.title ??
'';
return candidate ? String(candidate).trim() : '';
}
function extractDisplayNameFromCorpMember(row) {
const m = row && typeof row === 'object' ? row : {};
return String(m.anotherName || m.name || '').trim();
}
function getCorpIdForQuery() {
// 优先使用已拉取到的企业信息,其次用本地当前团队(最后兜底)
const d = doctorInfo.value || {};
const a = account.value || {};
const team = uni.getStorageSync('ykt_case_current_team') || {};
return String(d.corpId || a.corpId || team.corpId || '') || '';
}
function getCorpId() {
return getCorpIdForQuery();
}
function getTeamId() {
const team = uni.getStorageSync('ykt_case_current_team') || {};
return team?.teamId ? String(team.teamId) : '';
}
function normalizeUserId(value) {
if (value === null || value === undefined) return '';
if (typeof value === 'object') {
const obj = value;
const picked =
obj.userid ||
obj.userId ||
obj.userID ||
obj.corpUserId ||
obj.corpUserID ||
obj._id ||
obj.id ||
'';
return String(picked || '').trim();
}
return String(value || '').trim();
}
function isLikelyUserId(value) {
const id = normalizeUserId(value);
if (!id) return false;
if (/[-—]{1,2}/.test(id) && ['-', '—', '--'].includes(id.trim())) return false;
if (/\s/.test(id)) return false;
if (/[\u4e00-\u9fa5]/.test(id)) return false;
return true;
}
async function loadTeamMembers() {
const corpId = getCorpId();
const teamId = getTeamId();
if (!corpId || !teamId) return;
if (loadedMembersTeamId.value === teamId && Object.keys(userNameMap.value || {}).length > 0) return;
try {
const res = await api('getTeamData', { corpId, teamId });
if (!res?.success) return;
const t = res?.data && typeof res.data === 'object' ? res.data : {};
const members = Array.isArray(t.memberList) ? t.memberList : [];
const nextMap = {};
members.forEach((m) => {
if (!m || typeof m !== 'object') return;
const display = String(m?.anotherName || m?.name || m?.userid || m?.userId || m?.corpUserId || '').trim();
const keys = [m?.userid, m?.userId, m?.corpUserId].map(normalizeUserId).filter(Boolean);
keys.forEach((k) => {
nextMap[k] = display || nextMap[k] || k;
nextMap[String(k).toLowerCase()] = display || nextMap[String(k).toLowerCase()] || k;
});
});
userNameMap.value = nextMap;
loadedMembersTeamId.value = teamId;
} catch {
// ignore
}
}
async function prefetchCorpMemberName(userId) {
const id = normalizeUserId(userId);
if (!id || !isLikelyUserId(id)) return '';
const map = userNameMap.value || {};
const existing = String(map[id] || map[id.toLowerCase()] || '').trim();
if (existing && existing !== id) return existing;
if (corpMemberNameInflight.has(id)) return corpMemberNameInflight.get(id);
if (corpMemberNameTried.has(id)) return '';
const p = (async () => {
corpMemberNameTried.add(id);
await ensureDoctor();
const corpId = getCorpIdForQuery();
// 1) 首选:成员主页信息(更可能支持 userid 查询)
try {
const res = await api('getCorpMemberHomepageInfo', { corpId, corpUserId: id }, false);
if (res?.success) {
const name =
extractDisplayNameFromAny(res?.data) ||
extractDisplayNameFromAny(res?.data?.data) ||
extractDisplayNameFromAny(res?.data?.member) ||
'';
if (name) return name;
}
} catch {
// ignore
}
// 1.1) 部分环境参数名是 userId
try {
const res = await api('getCorpMemberHomepageInfo', { corpId, userId: id }, false);
if (res?.success) {
const name =
extractDisplayNameFromAny(res?.data) ||
extractDisplayNameFromAny(res?.data?.data) ||
extractDisplayNameFromAny(res?.data?.member) ||
'';
if (name) return name;
}
} catch {
// ignore
}
// 2) 兜底:成员数据接口
try {
const res = await api(
'getCorpMember',
{
corpId,
page: 1,
pageSize: 10,
params: {
corpId,
memberList: [id],
},
},
false
);
if (res?.success) {
const rows = Array.isArray(res?.data) ? res.data : Array.isArray(res?.data?.data) ? res.data.data : [];
const row = rows.find((m) => normalizeUserId(m?.userid || m?.userId || m?.corpUserId || '') === id) || rows[0] || null;
const name = extractDisplayNameFromCorpMember(row) || '';
if (name) return name;
}
} catch {
// ignore
}
return '';
})()
.then((name) => {
const display = String(name || '').trim();
if (display) {
const next = { ...(userNameMap.value || {}) };
next[id] = display;
next[id.toLowerCase()] = display;
userNameMap.value = next;
}
return display;
})
.finally(() => {
corpMemberNameInflight.delete(id);
});
corpMemberNameInflight.set(id, p);
return p;
}
function resolveUserName(userId) {
const id = normalizeUserId(userId);
if (!id) return '';
const map = userNameMap.value || {};
const resolved = String(map[id] || map[id.toLowerCase()] || '').trim();
if (resolved) return resolved;
// 无 teamId/未初始化 localStorage 时,尝试用企业成员接口补齐
void prefetchCorpMemberName(id);
return id;
}
function parseAnyTimeMs(v) {
if (v === null || v === undefined) return 0;
if (typeof v === 'number') {
// 10位秒级时间戳
if (v > 1e9 && v < 1e12) return v * 1000;
return v;
}
const s = String(v).trim();
if (!s) return 0;
if (/^\d{10,13}$/.test(s)) return Number(s.length === 10 ? `${s}000` : s);
const d = dayjs(s);
return d.isValid() ? d.valueOf() : 0;
}
function formatAnyDate(v, fmt = 'YYYY-MM-DD') {
const ms = parseAnyTimeMs(v);
if (!ms) return '';
const d = dayjs(ms);
return d.isValid() ? d.format(fmt) : '';
}
const visitDate = computed(() => {
const t = templateType.value;
const timeTitle = temp.value?.service?.timeTitle || getDefaultTimeTitle(t);
const raw = timeTitle ? record.value?.[timeTitle] : (record.value?.dateStr ?? record.value?.sortTime ?? '');
return formatAnyDate(raw) || normalizeText(raw) || '';
});
const visitDateLabel = computed(() => {
const t = templateType.value;
return String(temp.value?.service?.timeName || getDefaultTimeName(t) || '日期');
});
const showDiagnosisRow = computed(() => {
const list = Array.isArray(temp.value?.templateList)
? temp.value.templateList
: Array.isArray(getVisitRecordTemplate(templateType.value)?.templateList)
? getVisitRecordTemplate(templateType.value).templateList
: [];
return list.some((i) => i && (i.title === 'diagnosis' || i.title === 'diagnosisName'));
});
const diagnosisText = computed(() => {
const t = templateType.value;
if (!showDiagnosisRow.value) return '--';
if (t === 'outpatient' || t === 'inhospital') return normalizeText(record.value?.diagnosisName || record.value?.diagnosis) || normalizeText(record.value?.summary) || '--';
return normalizeText(record.value?.diagnosisName || record.value?.diagnosis || record.value?.summary) || '--';
});
const showFilesSection = computed(() => {
if (files.value.length) return true;
const list = Array.isArray(temp.value?.templateList)
? temp.value.templateList
: Array.isArray(getVisitRecordTemplate(templateType.value)?.templateList)
? getVisitRecordTemplate(templateType.value).templateList
: [];
return list.some((i) => i && (i.type === 'files' || i.title === 'files'));
});
const sections = computed(() => {
const t = templateType.value;
const hiddenKeys = new Set(t === 'outpatient'
? ['corp', 'deptName', 'corpName', 'doctor']
: t === 'inhospital'
? ['corp', 'corpName']
: t === 'physicalExaminationTemplate'
? ['inspectPakageName']
: []);
const list = [];
const pushedNames = new Set();
const pushRow = (name, value) => {
const v = normalizeText(value);
if (!v.trim()) return;
if (pushedNames.has(name)) return;
pushedNames.add(name);
list.push({ title: name, value: v });
};
const resolveOptionLabel = (item, candidate) => {
const range = Array.isArray(item?.range) ? item.range : [];
if (!range.length) return normalizeText(candidate);
const isObjectRange = range[0] && typeof range[0] === 'object';
const toLabel = (v) => {
const s = normalizeText(v);
if (!s) return '';
if (!isObjectRange) return s;
const found = range.find((opt) => opt && typeof opt === 'object' && String(opt.value) === String(s));
return found ? String(found.label ?? found.value ?? s) : s;
};
if (Array.isArray(candidate)) return candidate.map(toLabel).filter((i) => String(i).trim()).join('');
return toLabel(candidate);
};
const templateList = Array.isArray(temp.value?.templateList)
? temp.value.templateList
: Array.isArray(getVisitRecordTemplate(t)?.templateList)
? getVisitRecordTemplate(t).templateList
: [];
const timeTitle = temp.value?.service?.timeTitle || getDefaultTimeTitle(t);
templateList.forEach((item) => {
const key = item?.title ? String(item.title) : '';
if (!key) return;
if (key === 'files') return;
if (key === 'diagnosis' || key === 'diagnosisName') return;
if (timeTitle && key === timeTitle) return;
if (hiddenKeys.has(key)) return;
const raw = record.value?.[key];
const display = key === 'positiveFind'
? formatPositiveFind(raw, { withOpinion: true })
: item?.type === 'date'
? (formatAnyDate(raw) || normalizeText(raw))
: resolveOptionLabel(item, raw);
pushRow(String(item?.name || key), display);
});
return list;
});
async function loadTemplate(t) {
if (!t) return null;
const corpId = getCorpId();
try {
const res = await api('getCurrentTemplate', { corpId, templateType: t });
if (!res?.success) return null;
const raw = unwrapTemplateResponse(res);
return normalizeTemplate(raw);
} catch (e) {
return null;
}
}
const topText = computed(() => {
const time = record.value?.createTime ? dayjs(record.value.createTime).format('YYYY-MM-DD HH:mm') : '';
const byCustomer = record.value?.ignore === 'checkIn';
if (byCustomer) return `${time || '--'} 患者自建`;
const nameFromApiRaw = record.value?.creatorName ? String(record.value.creatorName).trim() : '';
const nameFromApi = ['-', '—', '--'].includes(nameFromApiRaw) ? '' : nameFromApiRaw;
if (nameFromApi && !isLikelyUserId(nameFromApi)) {
return `${time || '--'} ${nameFromApi}代建`;
}
const creatorId = normalizeUserId(
record.value?.creator ||
record.value?.creatorUserId ||
record.value?.createUserId ||
record.value?.executor ||
record.value?.executorUserId ||
''
);
const fallbackId = !creatorId && nameFromApi && isLikelyUserId(nameFromApi) ? nameFromApi : '';
const display = (creatorId || fallbackId) ? resolveUserName(creatorId || fallbackId) : '';
const suffix = display ? `${display}代建` : '';
return suffix ? `${time || '--'} ${suffix}` : `${time || '--'}`;
});
async function fetchRecord({ silent = false } = {}) {
if (!archiveId.value || !id.value || !medicalType.value) return { ok: false, reason: 'request_failed' };
await ensureDoctor();
const corpId = getCorpId();
if (!silent) loading('加载中...');
try {
const res = await api('getMedicalRecordById', {
_id: id.value,
corpId,
memberId: archiveId.value,
medicalType: medicalType.value,
});
const r = res?.record || res?.data?.record || null;
if (res?.success && r) {
const raw = String(r?.templateType || r?.medicalType || medicalType.value || '');
rawType.value = raw;
const ui = normalizeMedicalType(raw);
record.value = normalizeVisitRecordFormData(ui, r);
temp.value = await loadTemplate(raw);
uni.setNavigationBarTitle({ title: String(typeLabel.value || '病历详情') });
return { ok: true, reason: '' };
}
const msg = String(res?.message || res?.msg || '').trim();
const isNotFound = (res?.success && !r) || /不存在|not\s*found|not\s*exist/i.test(msg);
return { ok: false, reason: isNotFound ? 'not_found' : 'request_failed' };
} catch (error) {
console.error('获取病历记录失败:', error);
return { ok: false, reason: 'request_failed' };
} finally {
if (!silent) hideLoading();
}
}
onLoad(async (opt) => {
try {
const { windowHeight } = uni.getSystemInfoSync();
scrollHeight.value = windowHeight || 0;
} catch {
scrollHeight.value = 0;
}
archiveId.value = opt?.archiveId ? String(opt.archiveId) : '';
id.value = opt?.id ? String(opt.id) : '';
medicalType.value = opt?.type ? String(opt.type) : '';
if (!archiveId.value || !id.value || !medicalType.value) {
toast('参数缺失');
setTimeout(() => uni.navigateBack(), 300);
return;
}
// 异步加载团队成员映射用于展示“xx代建”的真实姓名
void loadTeamMembers();
const result = await fetchRecord();
if (!result?.ok) {
toast(result?.reason === 'not_found' ? '记录不存在' : '请求失败');
setTimeout(() => uni.navigateBack(), 300);
}
});
onMounted(() => {
recordChangedHandler = () => {
needReload.value = true;
};
// 页面在编辑页返回前可能收到事件:先标记,回到页面再刷新
uni.$on('archive-detail:visit-record-changed', recordChangedHandler);
});
onUnmounted(() => {
if (recordChangedHandler) uni.$off('archive-detail:visit-record-changed', recordChangedHandler);
recordChangedHandler = null;
});
onShow(async () => {
if (!needReload.value) return;
needReload.value = false;
await fetchRecord({ silent: true });
});
function preview(idx) {
const urls = files.value.map((i) => i.url);
uni.previewImage({ urls, current: urls[idx] });
}
function edit() {
const type = record.value?.templateType || record.value?.medicalType || '';
uni.navigateTo({
url: `/pages/case/visit-record-detail?archiveId=${encodeURIComponent(archiveId.value)}&id=${encodeURIComponent(id.value)}&type=${encodeURIComponent(type)}`,
});
}
function remove() {
uni.showModal({
title: '提示',
content: '确定删除当前记录?',
success: async (res) => {
if (!res.confirm) return;
loading('删除中...');
try {
const corpId = getCorpId();
if (!corpId) {
hideLoading();
return toast('缺少 corpId');
}
await api('removeMedicalRecord', { corpId, memberId: archiveId.value, medicalType: medicalType.value, _id: id.value });
hideLoading();
uni.$emit('archive-detail:visit-record-changed');
toast('已删除');
setTimeout(() => uni.navigateBack(), 300);
} catch (error) {
hideLoading();
console.error('删除病历记录失败:', error);
toast('删除失败');
}
},
});
}
</script>
<style scoped>
.page {
height: 100vh;
overflow: hidden;
background: #fff;
}
.scroll {
width: 100%;
}
.topbar {
background: #5d6df0;
padding: 20rpx 28rpx;
}
.topbar-text {
color: #fff;
font-size: 28rpx;
text-align: center;
}
.content {
padding: 28rpx 28rpx 0;
}
.section {
margin-bottom: 28rpx;
}
.row {
display: flex;
padding: 20rpx 0;
}
.label {
width: 180rpx;
font-size: 28rpx;
font-weight: 600;
color: #111827;
}
.value {
flex: 1;
font-size: 28rpx;
color: #111827;
word-break: break-all;
}
.h2 {
font-size: 28rpx;
font-weight: 700;
color: #111827;
padding: 16rpx 0;
}
.p {
font-size: 28rpx;
color: #111827;
line-height: 40rpx;
white-space: pre-wrap;
word-break: break-all;
word-wrap: break-word;
}
.files {
display: flex;
gap: 20rpx;
flex-wrap: wrap;
}
.file {
width: 180rpx;
height: 140rpx;
border: 2rpx solid #d1d5db;
background: #f9fafb;
}
.thumb {
width: 180rpx;
height: 140rpx;
}
.files-empty {
font-size: 26rpx;
color: #9aa0a6;
padding: 16rpx 0;
}
.scroll-spacer {
height: calc(152rpx + env(safe-area-inset-bottom));
}
.footer {
position: fixed;
left: 0;
right: 0;
bottom: 0;
background: #fff;
padding: 24rpx 28rpx calc(24rpx + env(safe-area-inset-bottom));
display: flex;
justify-content: flex-end;
gap: 28rpx;
box-shadow: 0 -8rpx 24rpx rgba(0, 0, 0, 0.06);
}
.btn {
width: 240rpx;
height: 88rpx;
line-height: 88rpx;
border-radius: 12rpx;
font-size: 30rpx;
}
.btn::after {
border: none;
}
.btn.danger {
background: #fff;
color: #ff4d4f;
border: 2rpx solid #ff4d4f;
}
.btn.primary {
background: #0877F1;
color: #fff;
}
</style>