feat:优化、补充页面

This commit is contained in:
Jafeng 2026-01-22 17:39:23 +08:00
parent bc01ba97ca
commit 1650ccb8f9
11 changed files with 1695 additions and 117 deletions

View File

@ -226,12 +226,18 @@ function toggleStatus(v) {
function add() {
uni.showActionSheet({
itemList: ['+新增任务', '+回访记录'],
itemList: ['+新增任务', '+使用模板', '+回访记录'],
success: ({ tapIndex }) => {
if (tapIndex === 0) {
uni.navigateTo({ url: `/pages/case/followup-detail?archiveId=${encodeURIComponent(props.archiveId)}&mode=add` });
uni.setStorageSync('new-followup-customer', { _id: props.archiveId, name: props.data?.name || '' });
uni.navigateTo({ url: `/pages/case/new-followup?archiveId=${encodeURIComponent(props.archiveId)}` });
} else if (tapIndex === 1) {
uni.navigateTo({ url: `/pages/case/followup-detail?archiveId=${encodeURIComponent(props.archiveId)}&mode=record` });
uni.setStorageSync('new-followup-plan-customer', { _id: props.archiveId, name: props.data?.name || '' });
uni.setStorageSync('select-mamagement-plan', '');
uni.navigateTo({ url: `/pages/case/plan-list?archiveId=${encodeURIComponent(props.archiveId)}` });
} else if (tapIndex === 2) {
uni.setStorageSync('new-followup-record-customer', { _id: props.archiveId, name: props.data?.name || '' });
uni.navigateTo({ url: `/pages/case/new-followup-record?archiveId=${encodeURIComponent(props.archiveId)}` });
}
},
});

View File

@ -4,42 +4,51 @@
<view class="filters">
<picker mode="selector" :range="typeRange" range-key="name" @change="pickType">
<view class="filter-pill">
<view class="pill-text" :class="{ muted: currentType.value === 'ALL' }">{{ currentType.value === 'ALL' ? '档案类型' : currentType.name }}</view>
<view class="pill-text">{{ currentType.value === 'ALL' ? '全部病历' : currentType.name }}</view>
<uni-icons type="arrowdown" size="12" color="#666" />
</view>
</picker>
<view class="filter-pill">
<picker mode="date" @change="pickDate">
<view class="pill-text" :class="{ muted: !date }">{{ date || '时间筛选' }}</view>
</picker>
<view class="pill-icon" @click.stop="clearDate">
<uni-icons v-if="date" type="closeempty" size="14" color="#666" />
<uni-icons v-else type="arrowdown" size="12" color="#666" />
<picker mode="selector" :range="timeRangeOptions" range-key="label" @change="pickTimeRange">
<view class="filter-pill">
<view class="pill-text">{{ currentTimeRange.label }}</view>
<uni-icons type="arrowdown" size="12" color="#666" />
</view>
</view>
</picker>
</view>
<view v-if="showShareTip" class="share-tip">
<text class="share-tip-text">患者已授权其他团队病历信息互通</text>
</view>
<view class="list">
<view v-for="r in records" :key="r._id" class="card record" @click="edit(r)">
<view class="record-head">
<view class="record-date">{{ r.dateStr }}</view>
<view class="record-tag" :class="tagClass[r.templateType] || 'bg-blue'">{{ r.tempName }}</view>
<view v-if="r.corpName === '其他'" class="record-tag bg-rose">外院</view>
<view class="record-date">{{ r.dateStr || '--' }}</view>
<view class="record-tag" :class="tagClass[r.templateType] || 'bg-blue'">{{ r.tempName || '病历' }}</view>
<view v-if="r.corpName === '其他' || r.corp === '其他'" class="record-tag bg-rose">外院</view>
</view>
<view class="record-body">
<view v-for="row in buildSummaryRows(r)" :key="row.k" class="kv">
<view class="k">{{ row.k }}</view>
<view class="v">{{ row.v }}</view>
<view class="line">
<text class="line-label">诊断</text>
<text class="line-value">{{ getDiagnosis(r) }}</text>
</view>
<view v-if="!buildSummaryRows(r).length" class="kv">
<view class="k">摘要</view>
<view class="v">{{ r.summary || '暂无内容' }}</view>
<view v-if="r.templateType === 'inhospital' && r.surgeryName" class="line">
<text class="line-label">手术</text>
<text class="line-value">{{ r.surgeryName }}</text>
</view>
<view v-if="getFiles(r).length" class="thumbs">
<view v-for="(f, idx) in getFiles(r).slice(0, 3)" :key="idx" class="thumb" @click.stop="previewFiles(r, idx)">
<image class="thumb-img" :src="f.url" mode="aspectFill" />
</view>
<view v-if="getFiles(r).length > 3" class="thumb-more">+{{ getFiles(r).length - 3 }}</view>
</view>
</view>
<view class="record-foot">
<view class="foot-left">创建时间{{ r.createTimeStr }}</view>
<view class="foot-right">记录人{{ r.creatorName || '—' }}</view>
<view class="foot-left">创建时间{{ r.createDateStr || '' }}</view>
<view class="foot-right">创建{{ r.creatorName || '—' }}</view>
</view>
</view>
<view v-if="records.length === 0" class="empty">暂无数据</view>
@ -53,8 +62,7 @@
<script setup>
import { computed, onMounted, onUnmounted, ref } from 'vue';
import dayjs from 'dayjs';
import { ensureSeed, getVisitRecordTemplates, queryVisitRecords } from './mock';
import { ensureSeed, getCurrentTeamId, getVisitRecordTemplates, queryVisitRecords } from './mock';
const props = defineProps({
data: { type: Object, default: () => ({}) },
@ -62,8 +70,6 @@ const props = defineProps({
floatingBottom: { type: Number, default: 16 },
});
const date = ref('');
const templates = ref(getVisitRecordTemplates());
const typeRange = computed(() => [{ name: '全部', value: 'ALL' }, ...templates.value.map((t) => ({ name: t.name, value: t.templateType }))]);
@ -71,63 +77,71 @@ const currentType = ref({ name: '全部', value: 'ALL' });
const records = ref([]);
const timeRangeOptions = [
{ label: '全部时间', value: 'ALL' },
{ label: '今天', value: 'today' },
{ label: '近7天', value: '7d' },
{ label: '近30天', value: '30d' },
];
const currentTimeRange = ref(timeRangeOptions[0]);
const teamId = ref(getCurrentTeamId());
const showShareTip = computed(() => {
if (props.data && typeof props.data.shareAllTeams === 'boolean') return props.data.shareAllTeams;
const list = props.data?.authorizedTeams;
return Array.isArray(list) && list.length > 1;
});
const shareAllTeamsForQuery = computed(() => {
if (props.data && typeof props.data.shareAllTeams === 'boolean') return props.data.shareAllTeams;
const list = props.data?.authorizedTeams;
if (Array.isArray(list)) return list.length > 1;
// mock
return true;
});
function refreshList() {
if (!props.archiveId) return;
records.value = queryVisitRecords({
archiveId: props.archiveId,
medicalType: currentType.value.value,
date: date.value,
}).map((r) => ({
...r,
createTimeStr: r.createTime ? dayjs(r.createTime).format('YYYY-MM-DD') : '',
}));
timeRange: currentTimeRange.value.value,
teamId: teamId.value,
shareAllTeams: shareAllTeamsForQuery.value,
});
}
const tagClass = {
outpatient: 'bg-amber',
inhospital: 'bg-teal',
preConsultation: 'bg-indigo',
physicalExaminationTemplate: 'bg-green',
};
function buildSummaryRows(r) {
const rows = [];
if (r.templateType === 'outpatient') {
if (r.corpName) rows.push({ k: '机构', v: r.corpName });
if (r.deptName) rows.push({ k: '科室', v: r.deptName });
if (r.doctor) rows.push({ k: '医生', v: r.doctor });
if (r.diagnosisName) rows.push({ k: '诊断', v: r.diagnosisName });
if (r.summary) rows.push({ k: '摘要', v: r.summary });
return rows;
}
if (r.templateType === 'inhospital') {
if (r.corpName) rows.push({ k: '机构', v: r.corpName });
if (r.diagnosisName) rows.push({ k: '诊断', v: r.diagnosisName });
if (r.summary) rows.push({ k: '摘要', v: r.summary });
return rows;
}
if (r.templateType === 'physicalExaminationTemplate') {
if (r.corpName) rows.push({ k: '机构', v: r.corpName });
if (r.inspectPakageName) rows.push({ k: '套餐', v: r.inspectPakageName });
if (r.positiveFind) rows.push({ k: '阳性', v: r.positiveFind });
if (r.summary) rows.push({ k: '摘要', v: r.summary });
return rows;
}
if (r.summary) rows.push({ k: '摘要', v: r.summary });
return rows;
function getDiagnosis(r) {
if (!r) return '--';
if (r.templateType === 'preConsultation') return r.chiefComplaint || r.summary || '--';
return r.diagnosisName || r.summary || '--';
}
function pickType(e) {
currentType.value = typeRange.value[e.detail.value] || { name: '全部', value: 'ALL' };
refreshList();
}
function pickDate(e) {
date.value = e.detail.value || '';
function pickTimeRange(e) {
currentTimeRange.value = timeRangeOptions[e.detail.value] || timeRangeOptions[0];
refreshList();
}
function clearDate() {
if (!date.value) return;
date.value = '';
refreshList();
function getFiles(r) {
const arr = r?.files;
return Array.isArray(arr) ? arr.filter((i) => i && i.url) : [];
}
function previewFiles(r, idx) {
const urls = getFiles(r).map((i) => i.url);
if (!urls.length) return;
uni.previewImage({ urls, current: urls[idx] });
}
function add() {
@ -144,7 +158,7 @@ function add() {
function edit(record) {
uni.navigateTo({
url: `/pages/case/visit-record-detail?archiveId=${encodeURIComponent(props.archiveId)}&id=${encodeURIComponent(record._id)}&type=${encodeURIComponent(record.templateType || record.medicalType || '')}`,
url: `/pages/case/visit-record-view?archiveId=${encodeURIComponent(props.archiveId)}&id=${encodeURIComponent(record._id)}`,
});
}
@ -175,26 +189,32 @@ onUnmounted(() => {
background: #fff;
border: 1px solid #e6e6e6;
border-radius: 6px;
padding: 10px 10px;
padding: 10px 12px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
min-width: 110px;
flex: 1;
}
.pill-text {
font-size: 13px;
color: #333;
max-width: 140px;
max-width: 180px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pill-text.muted {
color: #999;
.share-tip {
padding: 10px 14px 0;
}
.pill-icon {
padding-left: 8px;
.share-tip-text {
display: inline-block;
background: #eef2ff;
color: #4338ca;
font-size: 12px;
padding: 6px 10px;
border-radius: 6px;
}
.list {
@ -229,21 +249,49 @@ onUnmounted(() => {
.record-body {
padding: 0 12px 12px;
}
.kv {
.line {
display: flex;
padding-top: 10px;
font-size: 13px;
color: #333;
line-height: 18px;
}
.k {
.line-label {
flex-shrink: 0;
color: #666;
}
.v {
.line-value {
flex: 1;
color: #333;
word-break: break-all;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.thumbs {
padding-top: 10px;
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
}
.thumb {
width: 84px;
height: 64px;
border-radius: 6px;
overflow: hidden;
background: #f3f4f6;
border: 1px solid #e5e7eb;
}
.thumb-img {
width: 84px;
height: 64px;
}
.thumb-more {
font-size: 12px;
color: #6b7280;
}
.record-foot {
display: flex;
@ -281,6 +329,9 @@ onUnmounted(() => {
.bg-teal {
background: #0f766e;
}
.bg-indigo {
background: #4f46e5;
}
.bg-green {
background: #16a34a;
}

View File

@ -7,33 +7,52 @@ export const VISIT_RECORD_TEMPLATES = [
templateType: 'outpatient',
templateName: '门诊记录',
templateList: [
{ title: 'visitTime', name: '就诊日期', type: 'date', required: true, format: 'YYYY-MM-DD' },
{ title: 'corpName', name: '就诊机构', type: 'input', required: false, wordLimit: 30, inputType: 'text' },
{ title: 'deptName', name: '科室', type: 'input', required: false, wordLimit: 30, inputType: 'text' },
{ title: 'doctor', name: '医生', type: 'input', required: false, wordLimit: 30, inputType: 'text' },
{ title: 'diagnosisName', name: '诊断', type: 'textarea', required: false, wordLimit: 200 },
{ title: 'summary', name: '摘要', type: 'textarea', required: false, wordLimit: 200 },
{ title: 'visitTime', name: '就诊日期', type: 'date', operateType: 'formCell', required: true, format: 'YYYY-MM-DD' },
{ title: 'corpName', name: '就诊机构', type: 'input', operateType: 'formCell', required: false, wordLimit: 30, inputType: 'text' },
{ title: 'deptName', name: '科室', type: 'input', operateType: 'formCell', required: false, wordLimit: 30, inputType: 'text' },
{ title: 'doctor', name: '医生', type: 'input', operateType: 'formCell', required: false, wordLimit: 30, inputType: 'text' },
{ title: 'diagnosisName', name: '门诊诊断', type: 'textarea', operateType: 'formCell', required: false, wordLimit: 200 },
{ title: 'treatmentPlan', name: '治疗方案', type: 'textarea', operateType: 'formCell', required: false, wordLimit: 1000 },
{ title: 'disposePlan', name: '处置计划', type: 'textarea', operateType: 'formCell', required: false, wordLimit: 1000 },
{ title: 'summary', name: '备注/摘要', type: 'textarea', operateType: 'formCell', required: false, wordLimit: 200 },
{ title: 'files', name: '文件上传', type: 'files', required: false },
],
},
{
templateType: 'inhospital',
templateName: '住院记录',
templateList: [
{ title: 'inhospitalDate', name: '入院日期', type: 'date', required: true, format: 'YYYY-MM-DD' },
{ title: 'corpName', name: '住院机构', type: 'input', required: false, wordLimit: 30, inputType: 'text' },
{ title: 'diagnosisName', name: '诊断', type: 'textarea', required: false, wordLimit: 200 },
{ title: 'summary', name: '摘要', type: 'textarea', required: false, wordLimit: 200 },
{ title: 'inhosDate', name: '入院日期', type: 'date', operateType: 'formCell', required: true, format: 'YYYY-MM-DD' },
{ title: 'corpName', name: '住院机构', type: 'input', operateType: 'formCell', required: false, wordLimit: 30, inputType: 'text' },
{ title: 'diagnosisName', name: '入院诊断', type: 'textarea', operateType: 'formCell', required: false, wordLimit: 200 },
{ title: 'surgeryName', name: '手术名称', type: 'input', operateType: 'formCell', required: false, wordLimit: 50, inputType: 'text' },
{ title: 'summary', name: '摘要', type: 'textarea', operateType: 'formCell', required: false, wordLimit: 200 },
{ title: 'files', name: '文件上传', type: 'files', required: false },
],
},
{
templateType: 'preConsultation',
templateName: '预问诊记录',
templateList: [
{ title: 'consultDate', name: '问诊日期', type: 'date', operateType: 'formCell', required: true, format: 'YYYY-MM-DD' },
{ title: 'chiefComplaint', name: '主诉', type: 'textarea', operateType: 'formCell', required: false, wordLimit: 300 },
{ title: 'presentIllness', name: '现病史', type: 'textarea', operateType: 'formCell', required: false, wordLimit: 800 },
{ title: 'pastHistory', name: '既往史', type: 'textarea', operateType: 'formCell', required: false, wordLimit: 800 },
{ title: 'allergyHistory', name: '过敏史', type: 'textarea', operateType: 'formCell', required: false, wordLimit: 300 },
{ title: 'summary', name: '摘要', type: 'textarea', operateType: 'formCell', required: false, wordLimit: 200 },
{ title: 'files', name: '文件上传', type: 'files', required: false },
],
},
{
templateType: 'physicalExaminationTemplate',
templateName: '体检记录',
templateList: [
{ title: 'inspectDate', name: '体检日期', type: 'date', required: true, format: 'YYYY-MM-DD' },
{ title: 'corpName', name: '体检机构', type: 'input', required: false, wordLimit: 30, inputType: 'text' },
{ title: 'inspectPakageName', name: '体检套餐', type: 'input', required: false, wordLimit: 50, inputType: 'text' },
{ title: 'positiveFind', name: '阳性发现', type: 'textarea', required: false, wordLimit: 300 },
{ title: 'summary', name: '摘要', type: 'textarea', required: false, wordLimit: 200 },
{ title: 'inspectDate', name: '体检日期', type: 'date', operateType: 'formCell', required: true, format: 'YYYY-MM-DD' },
{ title: 'corpName', name: '体检机构', type: 'input', operateType: 'formCell', required: false, wordLimit: 30, inputType: 'text' },
{ title: 'inspectPakageName', name: '体检套餐', type: 'input', operateType: 'formCell', required: false, wordLimit: 50, inputType: 'text' },
{ title: 'positiveFind', name: '阳性发现', type: 'textarea', operateType: 'formCell', required: false, wordLimit: 300 },
{ title: 'summary', name: '摘要', type: 'textarea', operateType: 'formCell', required: false, wordLimit: 200 },
{ title: 'files', name: '文件上传', type: 'files', required: false },
],
},
];
@ -84,29 +103,53 @@ export function ensureSeed(archiveId, archive) {
medicalType: 'outpatient',
tempName: '门诊记录',
templateType: 'outpatient',
teamId: 'team_1',
sortTime: now - 1000 * 60 * 60 * 24 * 2,
visitTime: dayjs(now - 1000 * 60 * 60 * 24 * 2).format('YYYY-MM-DD'),
corpName: '某某医院',
deptName: '口腔科',
deptName: '呼吸内科',
doctor: '李医生',
diagnosisName: '牙列不齐mock',
summary: '初诊:拍片、取模,制定治疗方案。',
diagnosisName: '急性上呼吸道感染mock',
treatmentPlan: '建议1对症处理退热2多饮水休息3如出现呼吸困难及时就医。',
disposePlan: '建议1继续对症治疗2监测体温与血压3如3天无缓解或加重立即复诊。',
summary: '初诊:对症处理并随访。',
files: [{ url: '/static/tabbar/home.png', name: '示例图片1' }],
createTime: now - 1000 * 60 * 60 * 24 * 2,
creatorName: '李医生',
creatorName: '李珊珊',
},
{
_id: uid('mr'),
medicalType: 'inhospital',
tempName: '住院记录',
templateType: 'inhospital',
teamId: 'team_2',
sortTime: now - 1000 * 60 * 60 * 24 * 15,
inhospitalDate: dayjs(now - 1000 * 60 * 60 * 24 * 15).format('YYYY-MM-DD'),
inhosDate: dayjs(now - 1000 * 60 * 60 * 24 * 15).format('YYYY-MM-DD'),
corpName: '某某医院',
diagnosisName: '术后复查mock',
surgeryName: '阑尾切除术',
summary: '复诊:术后复查,恢复良好。',
files: [],
createTime: now - 1000 * 60 * 60 * 24 * 15,
creatorName: '王护士',
},
{
_id: uid('mr'),
medicalType: 'preConsultation',
tempName: '预问诊记录',
templateType: 'preConsultation',
teamId: 'team_1',
sortTime: now - 1000 * 60 * 60 * 6,
consultDate: dayjs(now - 1000 * 60 * 60 * 6).format('YYYY-MM-DD'),
chiefComplaint: '咽痛、流涕 2 天mock',
presentIllness: '近2天受凉后出现咽痛、流涕体温最高 38.2℃。',
pastHistory: '既往体健。',
allergyHistory: '无明确过敏史。',
summary: '建议对症处理,必要时线下就医。',
files: [],
createTime: now - 1000 * 60 * 60 * 6,
creatorName: '李珊珊',
},
];
}
@ -183,18 +226,52 @@ export function ensureSeed(archiveId, archive) {
setDb(db);
}
export function queryVisitRecords({ archiveId, medicalType = 'ALL', date = '' }) {
export function getCurrentTeamId() {
const v = uni.getStorageSync('ykt_mock_current_team_id');
return v ? String(v) : 'team_1';
}
function getSortTimeTitle(templateType) {
if (templateType === 'outpatient') return 'visitTime';
if (templateType === 'inhospital') return 'inhosDate';
if (templateType === 'preConsultation') return 'consultDate';
if (templateType === 'physicalExaminationTemplate') return 'inspectDate';
return '';
}
export function queryVisitRecords({ archiveId, medicalType = 'ALL', timeRange = 'ALL', teamId = '', shareAllTeams = false }) {
const db = getDb();
const list = Array.isArray(db.visitRecordsByArchiveId[archiveId]) ? db.visitRecordsByArchiveId[archiveId] : [];
const withDate = list.map((i) => ({
...i,
dateStr: i.sortTime ? dayjs(i.sortTime).format('YYYY-MM-DD') : '',
}));
const withDate = list.map((i) => {
const type = i.templateType || i.medicalType || '';
const timeTitle = getSortTimeTitle(type);
const rawDate = timeTitle ? i[timeTitle] : '';
const fallback = i.sortTime ? dayjs(i.sortTime).format('YYYY-MM-DD') : '';
const date = rawDate || fallback;
return {
...i,
dateStr: date ? String(date) : '',
date: date ? String(date) : '',
createDateStr: i.createTime ? dayjs(i.createTime).format('YYYY-MM-DD') : '',
};
});
const matchType = medicalType === 'ALL' ? withDate : withDate.filter((i) => i.medicalType === medicalType);
const matchDate = date ? matchType.filter((i) => i.dateStr === date) : matchType;
return matchDate.sort((a, b) => (b.sortTime || 0) - (a.sortTime || 0));
let filtered = [...withDate];
if (medicalType !== 'ALL') filtered = filtered.filter((i) => (i.medicalType || i.templateType) === medicalType);
if (!shareAllTeams && teamId) filtered = filtered.filter((i) => !i.teamId || i.teamId === teamId);
if (timeRange && timeRange !== 'ALL') {
const days = timeRange === 'today' ? 0 : timeRange === '7d' ? 7 : timeRange === '30d' ? 30 : null;
if (days !== null) {
const start = days === 0 ? dayjs().startOf('day') : dayjs().subtract(days, 'day').startOf('day');
const startMs = start.valueOf();
filtered = filtered.filter((i) => (i.sortTime || 0) >= startMs);
}
}
filtered.sort((a, b) => (b.sortTime || 0) - (a.sortTime || 0));
return filtered;
}
export function getVisitRecord({ archiveId, id }) {

View File

@ -1,7 +1,26 @@
{
"pages": [
{
"path": "pages/login/login",
"style": {
"navigationBarTitleText": "登录"
}
},
{
"path": "pages/login/redirect-page",
"style": {
"navigationBarTitleText": "登录"
}
},
{
"path": "pages/message/message",
"style": {
"navigationBarTitleText": "消息",
"navigationStyle": "custom"
}
},
{
"path": "pages/message/index",
"style": {
"navigationBarTitleText": "消息"
}
@ -72,6 +91,12 @@
"navigationBarTitleText": "健康档案"
}
},
{
"path": "pages/case/visit-record-view",
"style": {
"navigationBarTitleText": "病历详情"
}
},
{
"path": "pages/case/service-record-detail",
"style": {
@ -84,6 +109,24 @@
"navigationBarTitleText": "回访详情"
}
},
{
"path": "pages/case/new-followup",
"style": {
"navigationBarTitleText": "新增回访"
}
},
{
"path": "pages/case/new-followup-record",
"style": {
"navigationBarTitleText": "回访记录"
}
},
{
"path": "pages/case/plan-list",
"style": {
"navigationBarTitleText": "回访计划"
}
},
{
"path": "pages/work/work",
"style": {
@ -101,12 +144,6 @@
"style": {
"navigationBarTitleText": "选择科室"
}
},
{
"path": "pages/login/login",
"style": {
"navigationBarTitleText": "登录"
}
}
],
"globalStyle": {
@ -142,4 +179,4 @@
]
},
"uniIdRouter": {}
}
}

View File

@ -217,8 +217,8 @@
</template>
<script setup>
import { computed, ref } from 'vue';
import { onLoad, onReachBottom, onShow } from '@dcloudio/uni-app';
import { computed, getCurrentInstance, ref } from 'vue';
import { onLoad, onPullDownRefresh, onReachBottom, onReady, onShow } from '@dcloudio/uni-app';
import HealthProfileTab from '@/components/archive-detail/health-profile-tab.vue';
import ServiceInfoTab from '@/components/archive-detail/service-info-tab.vue';
@ -236,6 +236,8 @@ const tabs = [
const currentTab = ref('visitRecord');
const reachBottomTime = ref(0);
const archiveId = ref('');
const tabsScrollTop = ref(0);
const instanceProxy = getCurrentInstance()?.proxy;
const archive = ref({
name: '',
@ -279,6 +281,22 @@ onLoad((options) => {
ensureSeed(archiveId.value, archive.value);
});
function measureTabsTop() {
const q = instanceProxy ? uni.createSelectorQuery().in(instanceProxy) : uni.createSelectorQuery();
q.select('.tabs').boundingClientRect();
q.selectViewport().scrollOffset();
q.exec((res) => {
const rect = res && res[0];
const viewport = res && res[1];
if (!rect || !viewport) return;
tabsScrollTop.value = (rect.top || 0) + (viewport.scrollTop || 0);
});
}
onReady(() => {
setTimeout(measureTabsTop, 30);
});
onShow(() => {
const cached = uni.getStorageSync(STORAGE_KEY);
if (cached && typeof cached === 'object') {
@ -289,6 +307,15 @@ onShow(() => {
groupOptions: Array.isArray(cached.groupOptions) ? cached.groupOptions : archive.value.groupOptions,
};
}
setTimeout(measureTabsTop, 30);
});
onPullDownRefresh(() => {
uni.pageScrollTo({
scrollTop: tabsScrollTop.value || 0,
duration: 0,
});
setTimeout(() => uni.stopPullDownRefresh(), 150);
});
onReachBottom(() => {
@ -667,6 +694,9 @@ const saveAddGroup = () => {
display: flex;
border-top: 1px solid #f2f2f2;
border-bottom: 1px solid #f2f2f2;
position: sticky;
top: 0;
z-index: 30;
}
.tab {

View File

@ -0,0 +1,344 @@
<template>
<!-- Mobile 来源: ykt-management-mobile/src/pages/customer/new-followup-record/new-followup-record.vue简化移植去除 pinia/接口 -->
<view class="page">
<view class="card">
<picker mode="date" :value="form.plannedExecutionTime" @change="changeDate">
<view class="row clickable">
<view class="label">回访日期</view>
<view class="right">
<view class="value" :class="{ muted: !form.plannedExecutionTime }">{{ form.plannedExecutionTime || '请选择回访日期' }}</view>
<uni-icons type="arrowright" size="16" color="#999" />
</view>
</view>
</picker>
<view class="block">
<view class="block-title">回访方式</view>
<view class="method-row">
<view class="method" @click="selectMethod('phone')">
<image class="radio" :src="`/static/circle${form.todoMethod === 'phone' ? 'd' : ''}.svg`" />
<view class="m-label">电话</view>
<view v-if="form.todoMethod === 'phone'" class="m-value" :class="{ muted: !form.phoneNumber }">{{ form.phoneNumber || '请选择电话号码' }}</view>
<uni-icons v-if="form.todoMethod === 'phone'" type="arrowright" size="16" color="#999" />
</view>
<view class="method" @click="selectMethod('wechat')">
<image class="radio" :src="`/static/circle${form.todoMethod === 'wechat' ? 'd' : ''}.svg`" />
<view class="m-label">微信</view>
</view>
</view>
</view>
<view class="row clickable" @click="selectType">
<view class="label">回访类型</view>
<view class="right">
<view class="value" :class="{ muted: !form.eventType }">{{ eventTypeLabel || '请选择回访类型' }}</view>
<uni-icons type="arrowright" size="16" color="#999" />
</view>
</view>
<view class="row clickable" @click="selectTeam">
<view class="label">所在团队</view>
<view class="right">
<view class="value" :class="{ muted: !form.teamId }">{{ form.teamName || '请选择团队' }}</view>
<uni-icons type="arrowright" size="16" color="#999" />
</view>
</view>
<view class="block">
<view class="block-title">回访结果</view>
<view class="textarea-box">
<textarea v-model="form.result" class="textarea tall" placeholder="请输入回访结果" maxlength="500" />
<view class="counter">{{ (form.result || '').length }}/500</view>
</view>
</view>
</view>
<view class="footer">
<button class="btn plain" @click="cancel">取消</button>
<button class="btn primary" @click="save">保存</button>
</view>
</view>
</template>
<script setup>
import { computed, reactive, ref } from 'vue';
import { onLoad } from '@dcloudio/uni-app';
import dayjs from 'dayjs';
import { ensureSeed, upsertFollowup } from '@/components/archive-detail/mock';
const archiveId = ref('');
const archiveName = ref('');
const archiveMobile = ref('');
const form = reactive({
plannedExecutionTime: '',
todoMethod: '',
phoneNumber: '',
eventType: '',
teamId: '',
teamName: '',
result: '',
});
const eventTypeList = [
{ label: '回访', value: 'followup' },
{ label: '复诊提醒', value: 'revisit' },
{ label: '问卷', value: 'questionnaire' },
{ label: '其他', value: 'other' },
];
const eventTypeLabel = computed(() => eventTypeList.find((i) => i.value === form.eventType)?.label || '');
const teamOptions = [
{ label: '口腔一科(示例)', value: 'team_1' },
{ label: '正畸团队(示例)', value: 'team_2' },
];
const mobiles = computed(() => {
const arr = [];
if (archiveMobile.value) arr.push(String(archiveMobile.value));
if (!arr.includes('13800000000')) arr.push('13800000000');
return arr;
});
onLoad((options) => {
archiveId.value = options?.archiveId ? String(options.archiveId) : '';
const c = uni.getStorageSync('new-followup-record-customer');
if (c && typeof c === 'object') {
archiveId.value = archiveId.value || String(c._id || '');
archiveName.value = String(c.name || '');
}
const cached = uni.getStorageSync('ykt_case_archive_detail');
if (cached && typeof cached === 'object') archiveMobile.value = String(cached.mobile || '');
if (!archiveId.value) {
uni.showToast({ title: '缺少 archiveId', icon: 'none' });
setTimeout(() => uni.navigateBack(), 300);
return;
}
ensureSeed(archiveId.value, { name: archiveName.value });
form.plannedExecutionTime = dayjs().format('YYYY-MM-DD');
});
function changeDate(e) {
form.plannedExecutionTime = e.detail.value || '';
}
function selectMethod(method) {
if (method === 'phone') {
if (mobiles.value.length === 0) {
uni.showToast({ title: '暂无可用电话号码', icon: 'none' });
return;
}
uni.showActionSheet({
itemList: mobiles.value,
success: ({ tapIndex }) => {
form.todoMethod = 'phone';
form.phoneNumber = mobiles.value[tapIndex] || '';
},
});
} else {
form.todoMethod = 'wechat';
form.phoneNumber = '';
}
}
function selectType() {
uni.showActionSheet({
itemList: eventTypeList.map((i) => i.label),
success: ({ tapIndex }) => {
form.eventType = eventTypeList[tapIndex]?.value || '';
},
});
}
function selectTeam() {
uni.showActionSheet({
itemList: teamOptions.map((i) => i.label),
success: ({ tapIndex }) => {
const t = teamOptions[tapIndex];
form.teamId = t.value;
form.teamName = t.label;
},
});
}
function cancel() {
uni.showModal({
title: '确认取消',
content: '确定要取消新建回访记录吗?',
success: (res) => res.confirm && uni.navigateBack(),
});
}
function save() {
if (!form.plannedExecutionTime) return uni.showToast({ title: '请选择回访日期', icon: 'none' });
if (!form.todoMethod) return uni.showToast({ title: '请选择回访方式', icon: 'none' });
if (!form.eventType) return uni.showToast({ title: '请选择回访类型', icon: 'none' });
if (!form.teamId) return uni.showToast({ title: '请选择所在团队', icon: 'none' });
if (!String(form.result || '').trim()) return uni.showToast({ title: '请输入回访结果', icon: 'none' });
const phoneValue = form.phoneNumber ? `phone:${form.phoneNumber}` : 'phone';
const plannedExecutionTime = dayjs(form.plannedExecutionTime).valueOf();
upsertFollowup({
archiveId: archiveId.value,
followup: {
plannedExecutionTime,
endTime: plannedExecutionTime,
status: 'treated',
eventStatusLabel: '已完成',
eventType: form.eventType,
eventTypeLabel: eventTypeLabel.value || '回访',
executeTeamId: form.teamId,
executeTeamName: form.teamName,
executorName: '我',
creatorName: '我',
taskContent: '',
result: form.result,
todoMethod: form.todoMethod === 'phone' ? phoneValue : form.todoMethod,
createTime: Date.now(),
},
});
uni.$emit('archive-detail:followup-changed');
uni.showToast({ title: '保存成功', icon: 'success' });
setTimeout(() => uni.navigateBack(), 300);
}
</script>
<style scoped>
.page {
min-height: 100vh;
background: #f5f6f8;
padding-bottom: calc(76px + env(safe-area-inset-bottom));
}
.card {
background: #fff;
margin: 10px 14px 0;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.06);
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 14px;
border-bottom: 1px solid #f2f2f2;
}
.row.clickable:active {
background: #fafafa;
}
.label {
font-size: 14px;
color: #666;
}
.right {
display: flex;
align-items: center;
gap: 10px;
}
.value {
font-size: 14px;
color: #333;
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.value.muted {
color: #999;
}
.block {
padding: 14px 14px;
border-bottom: 1px solid #f2f2f2;
}
.block:last-child {
border-bottom: none;
}
.block-title {
font-size: 14px;
color: #666;
margin-bottom: 10px;
}
.method-row {
display: flex;
align-items: center;
gap: 18px;
}
.method {
display: flex;
align-items: center;
gap: 10px;
}
.radio {
width: 16px;
height: 16px;
}
.m-label {
font-size: 14px;
color: #333;
}
.m-value {
font-size: 14px;
color: #333;
max-width: 140px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.m-value.muted {
color: #999;
}
.textarea-box {
border: 1px solid #e6e6e6;
border-radius: 8px;
padding: 10px;
}
.textarea {
width: 100%;
min-height: 140px;
font-size: 14px;
box-sizing: border-box;
}
.textarea.tall {
min-height: 160px;
}
.counter {
margin-top: 6px;
text-align: right;
font-size: 12px;
color: #999;
}
.footer {
position: fixed;
left: 0;
right: 0;
bottom: 0;
background: #fff;
padding: 12px 14px calc(12px + env(safe-area-inset-bottom));
display: flex;
gap: 12px;
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.06);
}
.btn {
flex: 1;
height: 44px;
line-height: 44px;
border-radius: 6px;
font-size: 15px;
}
.btn::after {
border: none;
}
.btn.plain {
background: #fff;
color: #4f6ef7;
border: 1px solid #4f6ef7;
}
.btn.primary {
background: #4f6ef7;
color: #fff;
}
</style>

408
pages/case/new-followup.vue Normal file
View File

@ -0,0 +1,408 @@
<template>
<!-- Mobile 来源: ykt-management-mobile/src/pages/customer/new-followup/new-followup.vue简化移植去除员工组件/群发附件/接口 -->
<view class="page">
<view class="card">
<picker mode="date" :value="form.planExecutionTime" @change="changeDate">
<view class="row clickable">
<view class="left">
<view class="label">回访日期</view>
<view class="req">*</view>
</view>
<view class="right">
<view class="value" :class="{ muted: !form.planExecutionTime }">{{ form.planExecutionTime || '请选择回访日期' }}</view>
<uni-icons type="arrowright" size="16" color="#999" />
</view>
</view>
</picker>
<view class="row clickable" @click="selectExecutor">
<view class="left">
<view class="label">处理人</view>
<view class="req">*</view>
</view>
<view class="right">
<view class="value" :class="{ muted: !form.executorName }">
{{ form.executorName ? `${form.executorName}${form.executeTeamName ? `(${form.executeTeamName})` : ''}` : '请选择处理人' }}
</view>
<uni-icons type="arrowright" size="16" color="#999" />
</view>
</view>
<view class="row clickable" @click="selectType">
<view class="left">
<view class="label">类型</view>
<view class="req">*</view>
</view>
<view class="right">
<view class="value" :class="{ muted: !form.eventType }">{{ eventTypeLabel || '请选择类型' }}</view>
<uni-icons type="arrowright" size="16" color="#999" />
</view>
</view>
<view class="block">
<view class="block-title">目的</view>
<view class="textarea-box">
<textarea v-model="form.taskContent" class="textarea" placeholder="请输入文字提醒" maxlength="200" />
<view class="counter">{{ (form.taskContent || '').length }}/200</view>
</view>
</view>
<view class="block">
<view class="block-title">跟进方式</view>
<view class="toggle-row">
<view class="pill" :class="{ active: form.executeMethod === 'todo' }" @click="form.executeMethod = 'todo'">待办</view>
<view class="pill" :class="{ active: form.executeMethod === 'groupMessage' }" @click="form.executeMethod = 'groupMessage'">群发</view>
<view class="info" @click="showInfo">i</view>
</view>
<view v-if="form.executeMethod === 'groupMessage'" class="block">
<view class="block-title">发送内容</view>
<view class="textarea-box">
<textarea v-model="form.sendContent" class="textarea" placeholder="请输入群发内容mock" maxlength="500" />
<view class="counter">{{ (form.sendContent || '').length }}/500</view>
</view>
</view>
</view>
</view>
<view class="footer">
<button class="btn plain" @click="cancel">取消</button>
<button class="btn primary" @click="save">保存</button>
</view>
<uni-popup ref="infoPopup" type="center">
<view class="modal">
<view class="modal-title">跟进方式说明</view>
<view class="modal-body">
<view class="modal-text">待办生成待办单需员工手动进行处理</view>
<view class="modal-text">群发生成群发单员工可批量进行处理wxapp mock 不接入群发</view>
</view>
<view class="modal-actions">
<view class="modal-btn save" @click="closeInfo">关闭</view>
</view>
</view>
</uni-popup>
</view>
</template>
<script setup>
import { computed, reactive, ref } from 'vue';
import { onLoad } from '@dcloudio/uni-app';
import dayjs from 'dayjs';
import { ensureSeed, upsertFollowup } from '@/components/archive-detail/mock';
const archiveId = ref('');
const archiveName = ref('');
const eventTypeList = [
{ label: '回访', value: 'followup' },
{ label: '复诊提醒', value: 'revisit' },
{ label: '问卷', value: 'questionnaire' },
{ label: '其他', value: 'other' },
];
const teamOptions = [
{ label: '口腔一科(示例)', value: 'team_1' },
{ label: '正畸团队(示例)', value: 'team_2' },
];
const form = reactive({
planExecutionTime: '',
executeTeamId: '',
executeTeamName: '',
executorName: '',
eventType: '',
taskContent: '',
executeMethod: 'todo', // todo | groupMessage
sendContent: '',
});
const eventTypeLabel = computed(() => eventTypeList.find((i) => i.value === form.eventType)?.label || '');
onLoad((options) => {
archiveId.value = options?.archiveId ? String(options.archiveId) : '';
const c = uni.getStorageSync('new-followup-customer');
if (c && typeof c === 'object') {
archiveId.value = archiveId.value || String(c._id || '');
archiveName.value = String(c.name || '');
}
if (!archiveId.value) {
uni.showToast({ title: '缺少 archiveId', icon: 'none' });
setTimeout(() => uni.navigateBack(), 300);
return;
}
ensureSeed(archiveId.value, { name: archiveName.value });
// 使 plan-list select-mamagement-plan
const plan = uni.getStorageSync('select-mamagement-plan');
if (plan && typeof plan === 'object' && plan.planName) {
form.eventType = plan.eventType || 'followup';
form.taskContent = plan.taskContent || `执行回访计划:${plan.planName}`;
uni.setNavigationBarTitle({ title: '使用模板新增任务' });
}
if (!form.planExecutionTime) form.planExecutionTime = dayjs().add(1, 'day').format('YYYY-MM-DD');
});
function changeDate(e) {
const date = String(e.detail.value || '');
if (dayjs().startOf('day').isAfter(dayjs(date))) {
uni.showToast({ title: '请选择有效的回访日期', icon: 'none' });
return;
}
form.planExecutionTime = date;
}
function selectType() {
uni.showActionSheet({
itemList: eventTypeList.map((i) => i.label),
success: ({ tapIndex }) => {
form.eventType = eventTypeList[tapIndex]?.value || '';
},
});
}
function selectExecutor() {
// wxapp mock +
uni.showActionSheet({
itemList: teamOptions.map((i) => i.label),
success: ({ tapIndex }) => {
const t = teamOptions[tapIndex];
form.executeTeamId = t.value;
form.executeTeamName = t.label;
form.executorName = '李医生';
},
});
}
function cancel() {
uni.showModal({
title: '确认取消',
content: '确定要取消新建回访任务吗?',
success: (res) => res.confirm && uni.navigateBack(),
});
}
function save() {
if (!form.planExecutionTime) return uni.showToast({ title: '请选择回访日期', icon: 'none' });
if (!form.executorName) return uni.showToast({ title: '请选择处理人', icon: 'none' });
if (!form.eventType) return uni.showToast({ title: '请选择类型', icon: 'none' });
if (!String(form.taskContent || '').trim()) return uni.showToast({ title: '请输入目的', icon: 'none' });
if (form.executeMethod === 'groupMessage' && !String(form.sendContent || '').trim()) {
return uni.showToast({ title: '请输入发送内容', icon: 'none' });
}
const plannedExecutionTime = dayjs(form.planExecutionTime).valueOf();
upsertFollowup({
archiveId: archiveId.value,
followup: {
plannedExecutionTime,
status: 'processing',
eventStatusLabel: '待处理',
eventType: form.eventType,
eventTypeLabel: eventTypeLabel.value || '回访',
executeTeamId: form.executeTeamId || 'team_1',
executeTeamName: form.executeTeamName || '口腔一科(示例)',
executorName: form.executorName,
creatorName: '我',
taskContent: form.taskContent,
result: '',
executeMethod: form.executeMethod,
sendContent: form.executeMethod === 'groupMessage' ? form.sendContent : '',
createTime: Date.now(),
},
});
uni.$emit('archive-detail:followup-changed');
uni.showToast({ title: '保存成功', icon: 'success' });
setTimeout(() => uni.navigateBack(), 300);
}
const infoPopup = ref(null);
function showInfo() {
infoPopup.value?.open?.();
}
function closeInfo() {
infoPopup.value?.close?.();
}
</script>
<style scoped>
.page {
min-height: 100vh;
background: #f5f6f8;
padding-bottom: calc(76px + env(safe-area-inset-bottom));
}
.card {
background: #fff;
margin: 10px 14px 0;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.06);
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 14px;
border-bottom: 1px solid #f2f2f2;
}
.row.clickable:active {
background: #fafafa;
}
.left {
display: flex;
align-items: center;
}
.label {
font-size: 14px;
color: #666;
}
.req {
margin-left: 6px;
color: #ff4d4f;
font-size: 14px;
}
.right {
display: flex;
align-items: center;
gap: 10px;
}
.value {
font-size: 14px;
color: #333;
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.value.muted {
color: #999;
}
.block {
padding: 14px 14px;
border-bottom: 1px solid #f2f2f2;
}
.block:last-child {
border-bottom: none;
}
.block-title {
font-size: 14px;
color: #666;
margin-bottom: 10px;
}
.textarea-box {
border: 1px solid #e6e6e6;
border-radius: 8px;
padding: 10px;
}
.textarea {
width: 100%;
min-height: 90px;
font-size: 14px;
box-sizing: border-box;
}
.counter {
margin-top: 6px;
text-align: right;
font-size: 12px;
color: #999;
}
.toggle-row {
display: flex;
align-items: center;
gap: 10px;
}
.pill {
padding: 8px 12px;
border-radius: 999px;
background: #eaecef;
font-size: 12px;
color: #333;
}
.pill.active {
background: #4f6ef7;
color: #fff;
}
.info {
margin-left: auto;
width: 18px;
height: 18px;
border-radius: 9px;
background: #cfd3dc;
color: #fff;
font-size: 12px;
display: flex;
align-items: center;
justify-content: center;
}
.footer {
position: fixed;
left: 0;
right: 0;
bottom: 0;
background: #fff;
padding: 12px 14px calc(12px + env(safe-area-inset-bottom));
display: flex;
gap: 12px;
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.06);
}
.btn {
flex: 1;
height: 44px;
line-height: 44px;
border-radius: 6px;
font-size: 15px;
}
.btn::after {
border: none;
}
.btn.plain {
background: #fff;
color: #4f6ef7;
border: 1px solid #4f6ef7;
}
.btn.primary {
background: #4f6ef7;
color: #fff;
}
.modal {
width: 320px;
background: #fff;
border-radius: 8px;
overflow: hidden;
}
.modal-title {
font-size: 16px;
font-weight: 600;
text-align: center;
padding: 14px 12px;
color: #333;
border-bottom: 1px solid #f0f0f0;
}
.modal-body {
padding: 14px;
}
.modal-text {
font-size: 14px;
color: #666;
line-height: 20px;
margin-bottom: 10px;
}
.modal-actions {
padding: 12px 14px 14px;
}
.modal-btn {
height: 40px;
line-height: 40px;
text-align: center;
border-radius: 4px;
font-size: 14px;
}
.modal-btn.save {
background: #4f6ef7;
color: #fff;
}
</style>

150
pages/case/plan-list.vue Normal file
View File

@ -0,0 +1,150 @@
<template>
<!-- Mobile 来源: ykt-management-mobile/src/pages/manage-plan/plan-list/plan-list.vuewxapp mock仅选择模板并回到 new-followup -->
<view class="page">
<view v-if="list.length === 0" class="empty">
<image class="empty-img" src="/static/empty.svg" mode="aspectFit" />
<view class="empty-text">暂无回访计划</view>
</view>
<scroll-view v-else scroll-y class="scroll">
<view v-for="(p, idx) in list" :key="p.id" class="item">
<view class="left">
<view class="name-row">
<view class="name">{{ p.planName }}</view>
<view v-if="p.planType === 'corp'" class="tag corp">机构</view>
<view class="tag outline" @click.stop="preview(p)">详情</view>
</view>
<view class="desc">应用范围{{ p.planDetail }}</view>
</view>
<view class="btn" @click="select(p)">选择</view>
</view>
<view style="height: 24px;"></view>
</scroll-view>
</view>
</template>
<script setup>
import { ref } from 'vue';
import { onLoad } from '@dcloudio/uni-app';
const archiveId = ref('');
const list = ref([
{
id: 'p1',
planName: '复诊提醒模板',
planType: 'corp',
planDetail: '适用于复诊提醒人群',
eventType: 'revisit',
taskContent: '请于本周内完成复诊预约与提醒。',
},
{
id: 'p2',
planName: '随访回访模板',
planType: 'team',
planDetail: '适用于普通随访',
eventType: 'followup',
taskContent: '请电话回访患者,确认恢复情况并记录结果。',
},
]);
onLoad((options) => {
archiveId.value = options?.archiveId ? String(options.archiveId) : '';
});
function select(plan) {
uni.setStorageSync('select-mamagement-plan', plan);
uni.navigateTo({ url: `/pages/case/new-followup?archiveId=${encodeURIComponent(archiveId.value)}&fromPlan=1` });
}
function preview(plan) {
uni.showModal({
title: plan.planName || '回访计划',
content: plan.taskContent || plan.planDetail || '',
showCancel: false,
});
}
</script>
<style scoped>
.page {
height: 100vh;
background: #fff;
}
.scroll {
height: 100vh;
}
.item {
display: flex;
align-items: center;
padding: 12px 14px;
border-bottom: 1px solid #f2f2f2;
}
.left {
flex: 1;
min-width: 0;
margin-right: 10px;
}
.name-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.name {
font-size: 14px;
font-weight: 600;
color: #333;
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tag {
font-size: 12px;
padding: 4px 8px;
border-radius: 6px;
}
.tag.corp {
background: #4f6ef7;
color: #fff;
}
.tag.outline {
border: 1px solid #4f6ef7;
color: #4f6ef7;
background: #fff;
}
.desc {
font-size: 12px;
color: #999;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.btn {
flex-shrink: 0;
padding: 8px 12px;
border-radius: 999px;
border: 1px solid #4f6ef7;
color: #4f6ef7;
font-size: 13px;
}
.empty {
height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.empty-img {
width: 160px;
height: 160px;
opacity: 0.9;
}
.empty-text {
margin-top: 10px;
font-size: 13px;
color: #9aa0a6;
}
</style>

View File

@ -1,5 +1,5 @@
<template>
<view class="page">
<view class="page">
<!-- Mobile 来源: ykt-management-mobile/src/pages/customer/visit-record-detail/visit-record-detail.vue -->
<view class="body">
<scroll-view scroll-y class="scroll">
@ -11,6 +11,20 @@
<FormTemplate v-if="template" ref="formRef" :items="showItems" :form="forms" @change="onChange" />
</view>
<!-- 附件上传FormTemplate 不支持 files单独实现 -->
<view v-if="hasFilesField" class="upload-wrap">
<view class="upload-title">文件上传</view>
<view class="upload-grid">
<view v-for="(f, idx) in fileList" :key="idx" class="upload-item" @click="previewFile(idx)">
<image class="upload-thumb" :src="f.url" mode="aspectFill" />
<view class="upload-remove" @click.stop="removeFile(idx)">×</view>
</view>
<view class="upload-add" @click="addFiles">
<view class="plus">+</view>
</view>
</view>
</view>
<view style="height: 120px;"></view>
</scroll-view>
@ -31,7 +45,7 @@ import { computed, reactive, ref } from 'vue';
import { onLoad } from '@dcloudio/uni-app';
import dayjs from 'dayjs';
import FormTemplate from '@/components/form-template/index.vue';
import { ensureSeed, getVisitRecord, getVisitRecordTemplate, removeVisitRecord, upsertVisitRecord } from '@/components/archive-detail/mock';
import { ensureSeed, getCurrentTeamId, getVisitRecord, getVisitRecordTemplate, removeVisitRecord, upsertVisitRecord } from '@/components/archive-detail/mock';
const archiveId = ref('');
const recordId = ref('');
@ -45,6 +59,7 @@ const showItems = computed(() => {
const list = template.value?.templateList || [];
// referenceField mobile
return list.filter((i) => {
if (i?.type === 'files') return false;
if (i && typeof i.referenceField === 'string') {
return forms.value[i.referenceField] === i.referenceValue;
}
@ -52,7 +67,22 @@ const showItems = computed(() => {
});
});
const hasFilesField = computed(() => {
const list = template.value?.templateList || [];
return list.some((i) => i && (i.type === 'files' || i.title === 'files'));
});
const formRef = ref(null);
const fileList = computed(() => {
const arr = forms.value?.files;
return Array.isArray(arr) ? arr.filter((i) => i && i.url) : [];
});
function ensureFilesField() {
if (form.files !== undefined) return;
if (detail.value && detail.value.files !== undefined) return;
form.files = [];
}
onLoad((options) => {
archiveId.value = options?.archiveId ? String(options.archiveId) : '';
@ -66,11 +96,13 @@ onLoad((options) => {
if (record) {
templateType.value = record.templateType || record.medicalType || templateType.value;
detail.value = record;
ensureFilesField();
return;
}
}
if (!templateType.value) templateType.value = 'outpatient';
ensureFilesField();
});
function onChange({ title, value }) {
@ -98,7 +130,9 @@ function save() {
templateType.value === 'outpatient'
? 'visitTime'
: templateType.value === 'inhospital'
? 'inhospitalDate'
? 'inhosDate'
: templateType.value === 'preConsultation'
? 'consultDate'
: templateType.value === 'physicalExaminationTemplate'
? 'inspectDate'
: '';
@ -113,7 +147,9 @@ function save() {
templateType: templateType.value,
tempName: template.value?.templateName || '健康档案',
sortTime,
teamId: detail.value?.teamId || getCurrentTeamId(),
...form,
files: fileList.value,
createTime: detail.value?.createTime || Date.now(),
creatorName: detail.value?.creatorName || '我',
},
@ -136,6 +172,28 @@ function remove() {
},
});
}
function addFiles() {
uni.chooseImage({
count: 9,
success: (res) => {
const paths = Array.isArray(res.tempFilePaths) ? res.tempFilePaths : [];
const next = paths.map((p) => ({ url: p, name: '' }));
const cur = Array.isArray(forms.value.files) ? forms.value.files : [];
form.files = [...cur, ...next];
},
});
}
function removeFile(idx) {
const cur = Array.isArray(forms.value.files) ? forms.value.files : [];
form.files = cur.filter((_, i) => i !== idx);
}
function previewFile(idx) {
const urls = fileList.value.map((i) => i.url);
uni.previewImage({ urls, current: urls[idx] });
}
</script>
<style scoped>
@ -167,6 +225,61 @@ function remove() {
margin-top: 10px;
padding: 4px 0;
}
.upload-wrap {
background: #fff;
margin: 10px 14px 0;
border-radius: 8px;
padding: 14px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.06);
}
.upload-title {
font-size: 14px;
font-weight: 700;
color: #111827;
margin-bottom: 10px;
}
.upload-grid {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.upload-item {
width: 90px;
height: 70px;
position: relative;
border: 1px solid #d1d5db;
background: #f9fafb;
}
.upload-thumb {
width: 90px;
height: 70px;
}
.upload-remove {
position: absolute;
right: 0;
top: 0;
width: 20px;
height: 20px;
line-height: 20px;
text-align: center;
background: rgba(0, 0, 0, 0.55);
color: #fff;
font-size: 14px;
}
.upload-add {
width: 90px;
height: 70px;
border: 1px dashed #c7c7c7;
display: flex;
align-items: center;
justify-content: center;
color: #666;
}
.plus {
font-size: 26px;
line-height: 26px;
}
.footer {
position: fixed;
left: 0;

View File

@ -0,0 +1,282 @@
<template>
<!-- 详情页参考截图顶部蓝条显示创建信息支持编辑/删除黄底提示不渲染 -->
<view class="page">
<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">就诊日期</view>
<view class="value">{{ visitDate || '--' }}</view>
</view>
<view 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 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="footer">
<button class="btn danger" @click="remove">删除</button>
<button class="btn primary" @click="edit">编辑</button>
</view>
</view>
</template>
<script setup>
import { computed, ref } from 'vue';
import { onLoad } from '@dcloudio/uni-app';
import dayjs from 'dayjs';
import { getVisitRecord, removeVisitRecord } from '@/components/archive-detail/mock';
const archiveId = ref('');
const id = ref('');
const record = ref({});
const files = computed(() => {
const arr = record.value?.files;
return Array.isArray(arr) ? arr.filter((i) => i && i.url) : [];
});
const templateType = computed(() => record.value?.templateType || record.value?.medicalType || '');
const typeLabel = computed(() => record.value?.tempName || '病历');
const visitDate = computed(() => {
const t = templateType.value;
if (t === 'outpatient') return record.value?.visitTime || '';
if (t === 'inhospital') return record.value?.inhosDate || '';
if (t === 'preConsultation') return record.value?.consultDate || '';
if (t === 'physicalExaminationTemplate') return record.value?.inspectDate || '';
return record.value?.dateStr || '';
});
const diagnosisText = computed(() => {
const t = templateType.value;
if (t === 'preConsultation') return record.value?.chiefComplaint || record.value?.summary || '--';
if (t === 'physicalExaminationTemplate') return record.value?.positiveFind || record.value?.summary || '--';
return record.value?.diagnosisName || record.value?.summary || '--';
});
const sections = computed(() => {
const t = templateType.value;
const push = (title, value) => {
const v = value === 0 ? '0' : value ? String(value) : '';
if (!v.trim()) return;
return { title, value: v };
};
const list = [];
if (t === 'outpatient') {
const corp = push('就诊机构', record.value?.corpName);
const dept = push('科室', record.value?.deptName);
const doctor = push('医生', record.value?.doctor);
const treatment = push('治疗方案', record.value?.treatmentPlan);
const dispose = push('处置计划', record.value?.disposePlan);
const summary = push('备注/摘要', record.value?.summary);
[corp, dept, doctor, treatment, dispose, summary].forEach((i) => i && list.push(i));
return list;
}
if (t === 'inhospital') {
const corp = push('住院机构', record.value?.corpName);
const summary = push('摘要', record.value?.summary);
[corp, summary].forEach((i) => i && list.push(i));
return list;
}
if (t === 'preConsultation') {
const illness = push('现病史', record.value?.presentIllness);
const past = push('既往史', record.value?.pastHistory);
const allergy = push('过敏史', record.value?.allergyHistory);
const summary = push('摘要', record.value?.summary);
[illness, past, allergy, summary].forEach((i) => i && list.push(i));
return list;
}
if (t === 'physicalExaminationTemplate') {
const corp = push('体检机构', record.value?.corpName);
const pkg = push('体检套餐', record.value?.inspectPakageName);
const positive = push('阳性发现', record.value?.positiveFind);
const summary = push('摘要', record.value?.summary);
[corp, pkg, positive, summary].forEach((i) => i && list.push(i));
return list;
}
const summary = push('摘要', record.value?.summary);
if (summary) list.push(summary);
return list;
});
const topText = computed(() => {
const time = record.value?.createTime ? dayjs(record.value.createTime).format('YYYY-MM-DD HH:mm') : '';
const name = record.value?.creatorName ? String(record.value.creatorName) : '';
return `${time || '--'} ${name || '--'}创建`;
});
onLoad((opt) => {
archiveId.value = opt?.archiveId ? String(opt.archiveId) : '';
id.value = opt?.id ? String(opt.id) : '';
if (!archiveId.value || !id.value) {
uni.showToast({ title: '参数缺失', icon: 'none' });
setTimeout(() => uni.navigateBack(), 300);
return;
}
const r = getVisitRecord({ archiveId: archiveId.value, id: id.value });
if (!r) {
uni.showToast({ title: '记录不存在', icon: 'none' });
setTimeout(() => uni.navigateBack(), 300);
return;
}
record.value = r;
uni.setNavigationBarTitle({ title: r?.tempName ? String(r.tempName) : '病历详情' });
});
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: (res) => {
if (!res.confirm) return;
removeVisitRecord({ archiveId: archiveId.value, id: id.value });
uni.$emit('archive-detail:visit-record-changed');
uni.showToast({ title: '已删除', icon: 'success' });
setTimeout(() => uni.navigateBack(), 300);
},
});
}
</script>
<style scoped>
.page {
min-height: 100vh;
background: #fff;
padding-bottom: calc(76px + env(safe-area-inset-bottom));
}
.topbar {
background: #5d6df0;
padding: 10px 14px;
}
.topbar-text {
color: #fff;
font-size: 14px;
text-align: center;
}
.content {
padding: 14px 14px 0;
}
.section {
margin-bottom: 14px;
}
.row {
display: flex;
padding: 10px 0;
}
.label {
width: 90px;
font-size: 14px;
font-weight: 600;
color: #111827;
}
.value {
flex: 1;
font-size: 14px;
color: #111827;
word-break: break-all;
}
.h2 {
font-size: 14px;
font-weight: 700;
color: #111827;
padding: 8px 0;
}
.p {
font-size: 14px;
color: #111827;
line-height: 20px;
white-space: pre-wrap;
}
.files {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.file {
width: 90px;
height: 70px;
border: 1px solid #d1d5db;
background: #f9fafb;
}
.thumb {
width: 90px;
height: 70px;
}
.files-empty {
font-size: 13px;
color: #9aa0a6;
padding: 8px 0;
}
.footer {
position: fixed;
left: 0;
right: 0;
bottom: 0;
background: #fff;
padding: 12px 14px calc(12px + env(safe-area-inset-bottom));
display: flex;
justify-content: flex-end;
gap: 14px;
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.06);
}
.btn {
width: 120px;
height: 44px;
line-height: 44px;
border-radius: 6px;
font-size: 15px;
}
.btn::after {
border: none;
}
.btn.danger {
background: #fff;
color: #ff4d4f;
border: 1px solid #ff4d4f;
}
.btn.primary {
background: #4f6ef7;
color: #fff;
}
</style>

View File

@ -1,9 +1,89 @@
export default [
{
path: 'pages/login/login',
meta: { title: '登录', login: false },
},
{
path: 'pages/login/redirect-page',
meta: { title: '登录', login: false },
},
{
path: 'pages/message/message',
meta: { title: '首页', login: false },
meta: { title: '消息', login: false },
style: { navigationStyle: 'custom' }
},
{
path: 'pages/message/index',
meta: { title: '消息', login: false },
},
{
path: 'pages/case/case',
meta: { title: '病例', login: false },
},
{
path: 'pages/case/search',
meta: { title: '搜索患者', login: false },
},
{
path: 'pages/case/group-manage',
meta: { title: '分组管理', login: false },
},
{
path: 'pages/case/batch-transfer',
meta: { title: '转移客户给其他团队', login: false },
},
{
path: 'pages/case/batch-share',
meta: { title: '共享客户', login: false },
},
{
path: 'pages/case/patient-invite',
meta: { title: '邀请患者', login: false },
},
{
path: 'pages/case/patient-create',
meta: { title: '新增患者', login: false },
},
{
path: 'pages/case/patient-inner-info',
meta: { title: '内部信息', login: false },
},
{
path: 'pages/case/archive-detail',
meta: { title: '档案详情', login: false },
},
{
path: 'pages/case/archive-edit',
meta: { title: '档案编辑', login: false },
},
{
path: 'pages/case/visit-record-detail',
meta: { title: '健康档案', login: false },
},
{
path: 'pages/case/visit-record-view',
meta: { title: '病历详情', login: false },
},
{
path: 'pages/case/service-record-detail',
meta: { title: '服务记录', login: false },
},
{
path: 'pages/case/followup-detail',
meta: { title: '回访详情', login: false },
},
{
path: 'pages/case/new-followup',
meta: { title: '新增回访', login: false },
},
{
path: 'pages/case/new-followup-record',
meta: { title: '回访记录', login: false },
},
{
path: 'pages/case/plan-list',
meta: { title: '回访计划', login: false },
},
{
path: 'pages/work/work',
meta: { title: '工作台', login: false }