feat: 接入患者建档、档案编辑接口

This commit is contained in:
Jafeng 2026-01-23 17:10:23 +08:00
parent 17c2e25bc3
commit 7cb83882b8
21 changed files with 2167 additions and 398 deletions

View File

@ -19,28 +19,30 @@
<image class="pen" src="/static/icons/icon-pen.svg" /> <image class="pen" src="/static/icons/icon-pen.svg" />
</view> </view>
<view class="rows"> <view class="rows">
<view class="row"> <form-template
<view class="label">姓名</view> v-if="editing && effectiveBaseItems.length"
<view v-if="!editing" class="val">{{ data.name || '-' }}</view> ref="baseFormRef"
<input v-else v-model="draft.name" class="input" placeholder="请输入姓名" /> :items="effectiveBaseItems"
:form="forms"
@change="onChange"
/>
<template v-else>
<view
v-for="item in effectiveBaseItems"
:key="item.title"
class="row"
>
<view class="label">{{ item.name || item.title }}</view>
<view
v-if="item.title === 'mobile' && displayValue(item) !== '-'"
class="val link"
@click="call(rawValue(item))"
>
{{ displayValue(item) }}
</view> </view>
<view class="row"> <view v-else class="val" :class="displayValue(item) === '-' ? 'muted' : ''">{{ displayValue(item) }}</view>
<view class="label">性别</view>
<view v-if="!editing" class="val">{{ data.sex || '-' }}</view>
<picker v-else mode="selector" :range="sexOptions" @change="pickSex">
<view class="picker">{{ draft.sex || '请选择' }}</view>
</picker>
</view>
<view class="row">
<view class="label">年龄</view>
<view v-if="!editing" class="val">{{ data.age || '-' }}</view>
<input v-else v-model="draft.age" class="input" type="number" placeholder="请输入年龄" />
</view>
<view class="row">
<view class="label">联系电话</view>
<view v-if="!editing" class="val link" @click="call(data.mobile)">{{ data.mobile || '-' }}</view>
<input v-else v-model="draft.mobile" class="input" type="number" placeholder="请输入联系电话" />
</view> </view>
</template>
</view> </view>
</view> </view>
@ -53,35 +55,28 @@
<view class="row" @click="openTransferRecord"> <view class="row" @click="openTransferRecord">
<view class="label">院内来源</view> <view class="label">院内来源</view>
<view class="val link"> <view class="val link">
{{ data.creator || '点击查看' }} {{ forms.creator || '点击查看' }}
<uni-icons type="arrowright" size="14" color="#4f6ef7" /> <uni-icons type="arrowright" size="14" color="#4f6ef7" />
</view> </view>
</view> </view>
<view class="row"> <form-template
<view class="label">备注</view> v-if="editing && effectiveInternalItems.length"
<view v-if="!editing" class="val">{{ data.notes || '-' }}</view> ref="internalFormRef"
<input v-else v-model="draft.notes" class="input" placeholder="请输入备注" /> :items="effectiveInternalItems"
</view> :form="forms"
</view> :filterRule="filterRule"
</view> @change="onChange"
/>
<view class="card"> <template v-else>
<view id="anchor-behavior" class="section-title"> <view
行为画像 v-for="item in effectiveInternalItems"
</view> :key="item.title"
<view class="rows"> class="row"
<view class="row"> >
<view class="label">门诊号</view> <view class="label">{{ item.name || item.title }}</view>
<view class="val">{{ data.outpatientNo || '-' }}</view> <view class="val" :class="displayValue(item) === '-' ? 'muted' : ''">{{ displayValue(item) }}</view>
</view>
<view class="row">
<view class="label">住院号</view>
<view class="val">{{ data.inpatientNo || '-' }}</view>
</view>
<view class="row">
<view class="label">病案号</view>
<view class="val">{{ data.medicalRecordNo || '-' }}</view>
</view> </view>
</template>
</view> </view>
</view> </view>
@ -120,11 +115,14 @@
</template> </template>
<script setup> <script setup>
import { reactive, ref, watch } from 'vue'; import { computed, reactive, ref, watch } from 'vue';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import FormTemplate from '@/components/form-template/index.vue';
const props = defineProps({ const props = defineProps({
data: { type: Object, default: () => ({}) }, data: { type: Object, default: () => ({}) },
baseItems: { type: Array, default: () => ([]) },
internalItems: { type: Array, default: () => ([]) },
floatingBottom: { type: Number, default: 16 }, floatingBottom: { type: Number, default: 16 },
}); });
const emit = defineEmits(['save']); const emit = defineEmits(['save']);
@ -132,60 +130,74 @@ const emit = defineEmits(['save']);
const anchors = [ const anchors = [
{ label: '基本信息', value: 'base' }, { label: '基本信息', value: 'base' },
{ label: '内部信息', value: 'internal' }, { label: '内部信息', value: 'internal' },
{ label: '行为画像', value: 'behavior' },
]; ];
const activeAnchor = ref('base'); const activeAnchor = ref('base');
const editing = ref(false); const editing = ref(false);
const sexOptions = ['男', '女']; const baseFormRef = ref(null);
const draft = reactive({ const internalFormRef = ref(null);
name: '', const patch = reactive({});
sex: '', const forms = computed(() => ({ ...(props.data || {}), ...patch }));
age: '',
mobile: '', const fallbackBaseItems = [
notes: '', { title: 'name', name: '姓名', type: 'input', operateType: 'formCell', required: true, wordLimit: 20 },
}); { title: 'sex', name: '性别', type: 'select', operateType: 'formCell', required: false, range: ['男', '女'] },
{ title: 'age', name: '年龄', type: 'input', inputType: 'number', operateType: 'formCell', required: false, wordLimit: 3 },
{ title: 'mobile', name: '联系电话', type: 'input', inputType: 'number', operateType: 'formCell', required: false, wordLimit: 11 },
];
const fallbackInternalItems = [
{ title: 'notes', name: '备注', type: 'textarea', operateType: 'formCell', required: false, wordLimit: 200 },
];
const effectiveBaseItems = computed(() => (Array.isArray(props.baseItems) && props.baseItems.length ? props.baseItems : fallbackBaseItems));
const effectiveInternalItems = computed(() => (Array.isArray(props.internalItems) && props.internalItems.length ? props.internalItems : fallbackInternalItems));
const filterRule = {
reference(formModel) {
const customerSource = Array.isArray(formModel.customerSource)
? formModel.customerSource
: typeof formModel.customerSource === 'string'
? [formModel.customerSource]
: [];
return ['同事推荐', '客户推荐'].includes(customerSource[0]) && customerSource.length === 1;
},
};
watch( watch(
() => props.data, () => props.data,
() => { () => {
if (!editing.value) syncDraft(); if (!editing.value) Object.keys(patch).forEach((k) => delete patch[k]);
}, },
{ deep: true } { deep: true }
); );
function syncDraft() { function normalizeChangeValue(value) {
draft.name = props.data?.name || ''; if (value && typeof value === 'object' && 'value' in value) return value.value;
draft.sex = props.data?.sex || ''; return value;
draft.age = props.data?.age || '';
draft.mobile = props.data?.mobile || '';
draft.notes = props.data?.notes || '';
} }
function startEdit() { function startEdit() {
if (editing.value) return; if (editing.value) return;
editing.value = true; editing.value = true;
syncDraft();
} }
function cancel() { function cancel() {
editing.value = false; editing.value = false;
Object.keys(patch).forEach((k) => delete patch[k]);
} }
function save() { function save() {
emit('save', { if (baseFormRef.value?.verify && !baseFormRef.value.verify()) return;
name: draft.name, if (internalFormRef.value?.verify && !internalFormRef.value.verify()) return;
sex: draft.sex, const out = { ...patch };
age: draft.age, emit('save', out);
mobile: draft.mobile,
notes: draft.notes,
});
editing.value = false; editing.value = false;
uni.showToast({ title: '保存成功', icon: 'success' }); Object.keys(patch).forEach((k) => delete patch[k]);
} }
function pickSex(e) { function onChange({ title, value }) {
draft.sex = sexOptions[e.detail.value] || ''; patch[title] = normalizeChangeValue(value);
} }
function call(mobile) { function call(mobile) {
@ -193,6 +205,43 @@ function call(mobile) {
uni.makePhoneCall({ phoneNumber: String(mobile) }); uni.makePhoneCall({ phoneNumber: String(mobile) });
} }
function rawValue(item) {
const title = item?.title;
if (!title) return '';
const v = forms.value?.[title];
return v === undefined || v === null ? '' : v;
}
function displayValue(item) {
const title = item?.title;
const type = item?.type;
if (!title) return '-';
const v = forms.value?.[title];
if (v === undefined || v === null || v === '') return '-';
if (type === 'files') {
const list = Array.isArray(v) ? v : typeof v === 'string' && v ? [v] : [];
return list.length ? `已上传${list.length}` : '-';
}
if (Array.isArray(v)) return v.filter(Boolean).join('、') || '-';
if (typeof v === 'object') {
if ('label' in v) return String(v.label || '-');
if ('name' in v) return String(v.name || '-');
if ('value' in v) return String(v.value || '-');
return '-';
}
if (type === 'select' || type === 'radio' || type === 'selectAndImage') {
const range = Array.isArray(item?.range) ? item.range : [];
if (range.length && typeof range[0] === 'object') {
const found = range.find((i) => String(i?.value) === String(v));
if (found) return String(found.label || found.value || '-');
}
}
return String(v);
}
function scrollToAnchor(key) { function scrollToAnchor(key) {
activeAnchor.value = key; activeAnchor.value = key;
const selector = `#anchor-${key}`; const selector = `#anchor-${key}`;
@ -407,4 +456,3 @@ function closeTransferRecord() {
margin-right: 6px; margin-right: 6px;
} }
</style> </style>

View File

@ -0,0 +1,144 @@
<template>
<view class="files-wrap">
<view class="files-label" :class="required ? 'form-cell--required' : ''">{{ name }}</view>
<view class="grid">
<view v-for="(f, idx) in files" :key="idx" class="item" @click="preview(idx)">
<image class="thumb" :src="f.url" mode="aspectFill" />
<view class="remove" @click.stop="remove(idx)">×</view>
</view>
<view class="add" @click="add">
<view class="plus">+</view>
</view>
</view>
</view>
</template>
<script setup>
import { computed } from 'vue';
import { chooseAndUploadImage } from '@/utils/file';
import { toast } from '@/utils/widget';
const emits = defineEmits(['change']);
const props = defineProps({
form: { type: Object, default: () => ({}) },
name: { default: '' },
required: { type: Boolean, default: false },
title: { default: '' },
disableChange: { type: Boolean, default: false },
max: { type: [Number, String], default: 9 },
});
const maxCount = computed(() => {
const n = Number(props.max);
return Number.isFinite(n) && n > 0 ? Math.min(n, 9) : 9;
});
const files = computed(() => {
const v = props.form?.[props.title];
if (Array.isArray(v)) {
return v
.map((i) => {
if (typeof i === 'string') return { url: i };
if (i && typeof i === 'object' && i.url) return { url: String(i.url) };
return null;
})
.filter(Boolean);
}
if (typeof v === 'string' && v) return [{ url: v }];
return [];
});
function emitValue(list) {
emits('change', { title: props.title, value: list });
}
function preview(idx) {
const urls = files.value.map((i) => i.url);
if (!urls.length) return;
uni.previewImage({ urls, current: urls[idx] });
}
function remove(idx) {
if (props.disableChange) return;
const next = files.value.filter((_, i) => i !== idx);
emitValue(next);
}
async function add() {
if (props.disableChange) return;
if (files.value.length >= maxCount.value) {
toast(`最多上传${maxCount.value}`);
return;
}
const url = await chooseAndUploadImage({ count: 1 });
if (!url) return;
emitValue([...files.value, { url }]);
}
</script>
<style lang="scss" scoped>
@import '../cell-style.css';
.files-wrap {
padding: 24rpx 30rpx;
border-bottom: 1px solid #eee;
background: #fff;
}
.files-label {
font-size: 28rpx;
line-height: 42rpx;
}
.grid {
margin-top: 18rpx;
display: flex;
flex-wrap: wrap;
gap: 18rpx;
}
.item {
width: 160rpx;
height: 160rpx;
border-radius: 10rpx;
overflow: hidden;
position: relative;
background: #f6f6f6;
}
.thumb {
width: 100%;
height: 100%;
}
.remove {
position: absolute;
top: 6rpx;
right: 6rpx;
width: 36rpx;
height: 36rpx;
line-height: 36rpx;
text-align: center;
background: rgba(0, 0, 0, 0.55);
color: #fff;
border-radius: 18rpx;
font-size: 28rpx;
}
.add {
width: 160rpx;
height: 160rpx;
border-radius: 10rpx;
border: 1px dashed #cfcfcf;
display: flex;
align-items: center;
justify-content: center;
color: #999;
}
.plus {
font-size: 56rpx;
line-height: 56rpx;
}
</style>

View File

@ -1,7 +1,7 @@
<template> <template>
<common-cell :name="name" :required="required"> <common-cell :name="name" :required="required">
<view class="form-content__wrapper"> <view class="form-content__wrapper">
<input :disabled="disableChange" :value="value" :text="inputType" class="form-input" :placeholder="placeholder" <input :disabled="disableChange" :value="value" :type="inputType" class="form-input" :placeholder="placeholder"
placeholder-class="form__placeholder" :maxlength="wordLimit" @input="change($event)" /> placeholder-class="form__placeholder" :maxlength="wordLimit" @input="change($event)" />
<view v-if="appendText" class="appendText"> {{ appendText }}</view> <view v-if="appendText" class="appendText"> {{ appendText }}</view>
</view> </view>
@ -45,7 +45,10 @@ const props = defineProps({
}) })
const placeholder = computed(() => `请输入${props.name || ''}`) const placeholder = computed(() => `请输入${props.name || ''}`)
const value = computed(() => props.form && props.form && props.form[props.title] ? props.form[props.title] : '') const value = computed(() => {
const v = props.form?.[props.title];
return v === undefined || v === null ? '' : String(v);
})
function change(e) { function change(e) {
emits('change', { emits('change', {

View File

@ -0,0 +1,168 @@
<template>
<view class="multi-wrap">
<view class="label" :class="required ? 'form-cell--required' : ''">{{ name }}</view>
<view class="options" :class="hasOtherSelected ? 'with-other' : ''">
<view
v-for="opt in displayOptions"
:key="opt.value"
class="opt"
:class="isSelected(opt.value) ? 'active' : ''"
@click="toggle(opt.value)"
>
{{ opt.label }}
</view>
</view>
<view v-if="hasOtherSelected" class="other">
<input
:disabled="disableChange"
:value="otherText"
class="other-input"
placeholder="请补充其它内容"
placeholder-class="form__placeholder"
maxlength="50"
@input="onOtherInput"
/>
</view>
</view>
</template>
<script setup>
import { computed } from 'vue';
const emits = defineEmits(['change']);
const props = defineProps({
form: { type: Object, default: () => ({}) },
range: { type: Array, default: () => [] },
name: { default: '' },
required: { type: Boolean, default: false },
title: { default: '' },
disableChange: { type: Boolean, default: false },
otherList: { type: Array, default: () => ['其他'] },
});
function normalizeOptions(options) {
if (!Array.isArray(options)) return [];
if (!options.length) return [];
if (typeof options[0] === 'string') return options.filter(Boolean).map((i) => ({ label: String(i), value: String(i) }));
return options
.map((i) => {
const label = i?.label ?? i?.name ?? i?.text ?? i?.title ?? '';
const value = i?.value ?? i?.id ?? i?.key ?? label;
if (!label && (value === undefined || value === null || value === '')) return null;
return { label: String(label || value), value: String(value) };
})
.filter(Boolean);
}
const displayOptions = computed(() => normalizeOptions(props.range));
const valueList = computed(() => (Array.isArray(props.form?.[props.title]) ? props.form[props.title] : []));
const otherValue = computed(() => {
const selected = valueList.value.map(String);
const rangeValues = new Set(displayOptions.value.map((i) => String(i.value)));
const extra = selected.filter((i) => !rangeValues.has(String(i)));
return extra[0] || '';
});
const hasOtherSelected = computed(() => {
const selected = valueList.value.map(String);
return selected.some((v) => props.otherList.map(String).includes(v));
});
const otherText = computed(() => otherValue.value);
function isSelected(v) {
return valueList.value.map(String).includes(String(v));
}
function emitValue(next) {
emits('change', { title: props.title, value: next });
}
function toggle(v) {
if (props.disableChange) return;
const str = String(v);
let next = valueList.value.map(String);
if (next.includes(str)) {
next = next.filter((i) => i !== str);
} else {
next.push(str);
}
const isOther = props.otherList.map(String).includes(str);
if (!isOther) {
// other other
const stillHasOther = next.some((i) => props.otherList.map(String).includes(i));
if (!stillHasOther) {
const rangeValues = new Set(displayOptions.value.map((i) => String(i.value)));
next = next.filter((i) => rangeValues.has(i));
}
}
emitValue(next);
}
function onOtherInput(e) {
const text = String(e?.detail?.value || '').trim();
const rangeValues = new Set(displayOptions.value.map((i) => String(i.value)));
let next = valueList.value.map(String).filter((i) => rangeValues.has(i));
const hasOther = valueList.value.map(String).some((i) => props.otherList.map(String).includes(i));
if (hasOther) {
next.push(...props.otherList.map(String).slice(0, 1));
if (text) next.push(text);
}
emitValue(next);
}
</script>
<style lang="scss" scoped>
@import '../cell-style.css';
.multi-wrap {
padding: 24rpx 30rpx;
border-bottom: 1px solid #eee;
}
.label {
font-size: 28rpx;
line-height: 42rpx;
}
.options {
margin-top: 18rpx;
display: flex;
flex-wrap: wrap;
gap: 18rpx;
}
.opt {
min-width: 150rpx;
padding: 12rpx 20rpx;
border: 1px solid #ddd;
border-radius: 8rpx;
font-size: 26rpx;
text-align: center;
color: #333;
background: #fff;
}
.opt.active {
background: rgba(93, 138, 255, 0.12);
border-color: rgba(93, 138, 255, 0.35);
color: #5d8aff;
}
.other {
margin-top: 18rpx;
}
.other-input {
width: 100%;
font-size: 28rpx;
padding: 18rpx 20rpx;
border: 1px solid #eee;
border-radius: 8rpx;
box-sizing: border-box;
}
</style>

View File

@ -0,0 +1,89 @@
<template>
<view class="form-row">
<view class="form-row__label" :class="required ? 'form-cell--required' : ''">{{ name }}</view>
<view class="form-row__content runtime">
<input :disabled="disableChange" class="num" type="number" :value="year" @input="onInput($event, 'year')" />
<text class="unit"></text>
<input :disabled="disableChange" class="num" type="number" :value="month" @input="onInput($event, 'month')" />
<text class="unit"></text>
<input :disabled="disableChange" class="num" type="number" :value="day" @input="onInput($event, 'day')" />
<text class="unit"></text>
</view>
</view>
</template>
<script setup>
import { computed, ref, watch } from 'vue';
const emits = defineEmits(['change']);
const props = defineProps({
form: { type: Object, default: () => ({}) },
name: { default: '' },
required: { type: Boolean, default: false },
title: { default: '' },
disableChange: { type: Boolean, default: false },
});
const raw = computed(() => (props.form && props.form[props.title] ? String(props.form[props.title]) : ''));
const year = ref('');
const month = ref('');
const day = ref('');
watch(
raw,
(n) => {
if (!n) {
year.value = '';
month.value = '';
day.value = '';
return;
}
const yearMatch = n.match(/(\d+)年/);
const monthMatch = n.match(/(\d+)月/);
const dayMatch = n.match(/(\d+)日/);
year.value = yearMatch ? yearMatch[1] : '';
month.value = monthMatch ? monthMatch[1] : '';
day.value = dayMatch ? dayMatch[1] : '';
},
{ immediate: true }
);
function build() {
const y = year.value && year.value !== '0' ? `${year.value}` : '';
const m = month.value && month.value !== '0' ? `${month.value}` : '';
const d = day.value && day.value !== '0' ? `${day.value}` : '';
emits('change', { title: props.title, value: `${y}${m}${d}` });
}
function onInput(e, key) {
const v = String(e?.detail?.value || '').replace(/[^\d]/g, '');
if (key === 'year') year.value = v;
if (key === 'month') month.value = v;
if (key === 'day') day.value = v;
build();
}
</script>
<style lang="scss" scoped>
@import '../cell-style.css';
.runtime {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 10rpx;
}
.num {
width: 80rpx;
text-align: right;
font-size: 28rpx;
border-bottom: 1px solid #888;
}
.unit {
font-size: 26rpx;
color: #333;
}
</style>

View File

@ -0,0 +1,65 @@
<template>
<picker mode="selector" :range="displayRange" :disabled="disableChange" @change="change($event)">
<common-cell :name="name" :required="required">
<view class="form-content__wrapper">
<view class="flex-main-content truncate" :class="valueLabel ? '' : 'form__placeholder'">
{{ valueLabel || placeholder }}
</view>
<uni-icons class="form-arrow" type="arrowright"></uni-icons>
</view>
</common-cell>
</picker>
</template>
<script setup>
import { computed } from 'vue';
import commonCell from '../common-cell.vue';
const emits = defineEmits(['change']);
const props = defineProps({
form: { type: Object, default: () => ({}) },
name: { default: '' },
range: { type: Array, default: () => [] }, // string[] {label,value,image}[]
required: { type: Boolean, default: false },
disableChange: { type: Boolean, default: false },
title: { default: '' },
});
function normalizeOptions(options) {
if (!Array.isArray(options)) return [];
if (!options.length) return [];
if (typeof options[0] === 'string') return options.filter(Boolean).map((i) => ({ label: String(i), value: String(i) }));
return options
.map((i) => {
const label = i?.label ?? i?.name ?? i?.text ?? i?.title ?? '';
const value = i?.value ?? i?.id ?? i?.key ?? label;
if (!label && (value === undefined || value === null || value === '')) return null;
return { label: String(label || value), value: String(value) };
})
.filter(Boolean);
}
const options = computed(() => normalizeOptions(props.range));
const placeholder = computed(() => `请选择${props.name || ''}`);
const displayRange = computed(() => options.value.map((i) => i.label));
const rawValue = computed(() => {
const v = props.form?.[props.title];
return v === undefined || v === null ? '' : String(v);
});
const valueLabel = computed(() => {
if (!rawValue.value) return '';
const found = options.value.find((i) => String(i.value) === rawValue.value);
return found ? found.label : rawValue.value;
});
function change(e) {
const idx = Number(e?.detail?.value || 0);
const picked = options.value[idx];
emits('change', { title: props.title, value: picked ? picked.value : '' });
}
</script>
<style scoped>
@import '../cell-style.css';
</style>

View File

@ -0,0 +1,90 @@
<template>
<view class="form-row">
<view class="form-row__label" :class="required ? 'form-cell--required' : ''">{{ name }}</view>
<view class="form-row__content content">
<input
:disabled="disableChange"
:value="value"
class="input"
type="number"
:maxlength="11"
:placeholder="placeholder"
placeholder-class="form__placeholder"
@input="onInput"
/>
<picker mode="selector" :disabled="disableChange" :range="relationRange" @change="onPick">
<view class="relation" :class="noteValue ? '' : 'form__placeholder'">
{{ noteValue || '请选择' }}
<uni-icons class="arrow" type="arrowdown" size="16" color="#999" />
</view>
</picker>
</view>
</view>
</template>
<script setup>
import { computed } from 'vue';
const emits = defineEmits(['change']);
const props = defineProps({
form: { type: Object, default: () => ({}) },
name: { default: '' },
required: { type: Boolean, default: false },
title: { default: '' },
disableChange: { type: Boolean, default: false },
});
const placeholder = computed(() => `请输入${props.name || ''}`);
const relationRange = ['本人', '父亲', '母亲', '配偶', '子女', '其他'];
const noteTitle = computed(() => `${props.title || ''}Note`);
const value = computed(() => {
const v = props.form?.[props.title];
return v === undefined || v === null ? '' : String(v);
});
const noteValue = computed(() => {
const v = props.form?.[noteTitle.value];
return v === undefined || v === null ? '' : String(v);
});
function onInput(e) {
emits('change', { title: props.title, value: String(e?.detail?.value || '') });
}
function onPick(e) {
const idx = Number(e?.detail?.value || 0);
emits('change', { title: noteTitle.value, value: relationRange[idx] || '' });
}
</script>
<style lang="scss" scoped>
@import '../cell-style.css';
.content {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 16rpx;
}
.input {
flex: 1;
text-align: right;
font-size: 28rpx;
}
.relation {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8rpx;
min-width: 180rpx;
border-left: 1px solid #eee;
padding-left: 16rpx;
font-size: 28rpx;
}
.arrow {
flex-shrink: 0;
}
</style>

View File

@ -0,0 +1,121 @@
<template>
<view>
<picker mode="selector" :range="displayRange" :disabled="disableChange" @change="onPick">
<common-cell :name="name" :required="required">
<view class="form-content__wrapper">
<view class="flex-main-content truncate" :class="selectLabel ? '' : 'form__placeholder'">
{{ selectLabel || placeholder }}
</view>
<uni-icons class="form-arrow" type="arrowright"></uni-icons>
</view>
</common-cell>
</picker>
<view v-if="showOther" class="other-row">
<input
:disabled="disableChange"
:value="otherValue"
class="other-input"
placeholder="请输入补充内容"
placeholder-class="form__placeholder"
maxlength="50"
@input="onOtherInput"
/>
</view>
</view>
</template>
<script setup>
import { computed } from 'vue';
import commonCell from '../common-cell.vue';
const emits = defineEmits(['change']);
const props = defineProps({
form: { type: Object, default: () => ({}) },
name: { default: '' },
required: { type: Boolean, default: false },
title: { default: '' },
disableChange: { type: Boolean, default: false },
range: { type: Array, default: () => [] },
otherFiled: { type: String, default: '其他' },
});
function normalizeOptions(options) {
if (!Array.isArray(options)) return [];
if (!options.length) return [];
if (typeof options[0] === 'string') return options.filter(Boolean).map((i) => ({ label: String(i), value: String(i) }));
return options
.map((i) => {
const label = i?.label ?? i?.name ?? i?.text ?? i?.title ?? '';
const value = i?.value ?? i?.id ?? i?.key ?? label;
if (!label && (value === undefined || value === null || value === '')) return null;
return { label: String(label || value), value: String(value) };
})
.filter(Boolean);
}
const options = computed(() => normalizeOptions(props.range));
const displayRange = computed(() => options.value.map((i) => i.label));
const placeholder = computed(() => `请选择${props.name || ''}`);
const rawValue = computed(() => {
const v = props.form?.[props.title];
return v === undefined || v === null ? '' : String(v);
});
const isOther = computed(() => {
const v = String(rawValue.value || '');
if (!v) return false;
const known = new Set(options.value.map((i) => String(i.value)));
return !known.has(v);
});
const showOther = computed(() => {
const selected = selectLabel.value;
return selected === props.otherFiled || isOther.value;
});
const selectLabel = computed(() => {
const v = String(rawValue.value || '');
if (!v) return '';
const found = options.value.find((i) => String(i.value) === v);
if (found) return found.label;
return props.otherFiled;
});
const otherValue = computed(() => (isOther.value ? String(rawValue.value || '') : ''));
function onPick(e) {
const idx = Number(e?.detail?.value || 0);
const picked = options.value[idx];
if (!picked) return;
if (picked.label === props.otherFiled || String(picked.value) === String(props.otherFiled)) {
emits('change', { title: props.title, value: '' });
return;
}
emits('change', { title: props.title, value: picked.value });
}
function onOtherInput(e) {
emits('change', { title: props.title, value: String(e?.detail?.value || '') });
}
</script>
<style lang="scss" scoped>
@import '../cell-style.css';
.other-row {
padding: 0 30rpx 24rpx;
border-bottom: 1px solid #eee;
background: #fff;
}
.other-input {
width: 100%;
font-size: 28rpx;
padding: 18rpx 20rpx;
border: 1px solid #eee;
border-radius: 8rpx;
box-sizing: border-box;
}
</style>

View File

@ -50,9 +50,9 @@ const displayRange = computed(() => {
return props.range; return props.range;
}) })
const value = computed(() => { const value = computed(() => {
if (!props.form || !props.form[props.title]) return ''; if (!props.form) return '';
const currentValue = props.form[props.title]; const currentValue = props.form[props.title];
if (currentValue === undefined || currentValue === null || currentValue === '') return '';
// rangelabel // rangelabel
if (Array.isArray(props.range) && props.range.length > 0 && typeof props.range[0] === 'object') { if (Array.isArray(props.range) && props.range.length > 0 && typeof props.range[0] === 'object') {
const option = props.range.find(item => item.value === currentValue); const option = props.range.find(item => item.value === currentValue);

View File

@ -0,0 +1,35 @@
<template>
<form-multi-select-and-other
v-bind="attrs"
:range="normalizedRange"
:form="form"
:disableChange="disableChange"
:otherList="['有']"
@change="change"
/>
</template>
<script setup>
import { computed, useAttrs } from 'vue';
import formMultiSelectAndOther from './form-multiSelectAndOther.vue';
const attrs = useAttrs();
const emits = defineEmits(['change']);
defineProps({
form: { type: Object, default: () => ({}) },
disableChange: { type: Boolean, default: false },
});
const normalizedRange = computed(() => {
const r = attrs?.range;
if (Array.isArray(r) && r.length) return r;
return ['无', '有'];
});
function change(data) {
emits('change', data);
}
</script>
<style scoped></style>

View File

@ -42,10 +42,13 @@ const props = defineProps({
}) })
const placeholder = computed(() => `请输入${props.name || ''}`) const placeholder = computed(() => `请输入${props.name || ''}`)
const value = computed(() => props.form && props.form && props.form[props.title] ? props.form[props.title] : '') const value = computed(() => {
const v = props.form?.[props.title];
return v === undefined || v === null ? '' : String(v);
})
const wordLimit = computed(() => { const wordLimit = computed(() => {
if (typeof props.wordLimit === 'string' && Number(props.wordLimit) > 0) { if (typeof props.wordLimit === 'string' && Number(props.wordLimit) > 0) {
return Number.ceil(props.wordLimit) return Math.ceil(Number(props.wordLimit))
} }
if (typeof props.wordLimit === 'number' && props.wordLimit > 0) { if (typeof props.wordLimit === 'number' && props.wordLimit > 0) {
return props.wordLimit return props.wordLimit

View File

@ -1,11 +1,33 @@
<template> <template>
<form-datepicker v-if="attrs.type === 'date'" v-bind="attrs" :form="form" :disableChange="disableChange" @change="change" /> <form-surgical-history
v-if="attrs.title === 'surgicalHistory'"
v-bind="attrs"
:form="form"
:disableChange="disableChange"
@change="change"
/>
<form-datepicker v-else-if="attrs.type === 'date'" v-bind="attrs" :form="form" :disableChange="disableChange" @change="change" />
<form-input v-else-if="attrs.type === 'input'" v-bind="attrs" :form="form" :disableChange="disableChange" @change="change" /> <form-input v-else-if="attrs.type === 'input'" v-bind="attrs" :form="form" :disableChange="disableChange" @change="change" />
<form-radio v-else-if="attrs.type === 'radio'" v-bind="attrs" :form="form" :disableChange="disableChange" @change="change" /> <form-radio v-else-if="attrs.type === 'radio'" v-bind="attrs" :form="form" :disableChange="disableChange" @change="change" />
<form-region v-else-if="attrs.type === 'region'" v-bind="attrs" :form="form" :disableChange="disableChange" @change="change" /> <form-region v-else-if="attrs.type === 'region'" v-bind="attrs" :form="form" :disableChange="disableChange" @change="change" />
<form-select v-else-if="attrs.type === 'select'" v-bind="attrs" :form="form" :disableChange="disableChange" @change="change" /> <form-select v-else-if="attrs.type === 'select'" v-bind="attrs" :form="form" :disableChange="disableChange" @change="change" />
<form-select-other v-else-if="attrs.type === 'selectAndOther'" v-bind="attrs" :form="form" :disableChange="disableChange" @change="change" />
<form-select-image v-else-if="attrs.type === 'selectAndImage'" v-bind="attrs" :form="form" :disableChange="disableChange" @change="change" />
<form-select-mobile v-else-if="attrs.type === 'selectMobile'" v-bind="attrs" :form="form" :disableChange="disableChange" @change="change" />
<form-textarea v-else-if="attrs.type === 'textarea'" v-bind="attrs" :form="form" :disableChange="disableChange" <form-textarea v-else-if="attrs.type === 'textarea'" v-bind="attrs" :form="form" :disableChange="disableChange"
@change="change" /> @change="change" />
<form-multi-select-and-other
v-else-if="attrs.type === 'multiSelectAndOther'"
v-bind="attrs"
:form="form"
:disableChange="disableChange"
@change="change"
/>
<form-run-time v-else-if="attrs.type === 'runTime'" v-bind="attrs" :form="form" :disableChange="disableChange" @change="change" />
<form-files v-else-if="attrs.type === 'files'" v-bind="attrs" :form="form" :disableChange="disableChange" @change="change" />
<view v-else-if="attrs.type || attrs.name || attrs.title" class="py-20rpx px-30rpx border-bottom text-28rpx text-gray-500">
{{ attrs.name || attrs.title }}暂不支持{{ attrs.type }}
</view>
<!-- <!--
<form-operation v-else-if="attrs.title === 'surgicalHistory'" v-bind="attrs" :form="form" @change="change" <form-operation v-else-if="attrs.title === 'surgicalHistory'" v-bind="attrs" :form="form" @change="change"
@ -25,6 +47,13 @@ import formRadio from './form-radio.vue';
import formDatepicker from './form-datepicker.vue'; import formDatepicker from './form-datepicker.vue';
import formRegion from './form-region.vue'; import formRegion from './form-region.vue';
import formTextarea from './form-textarea.vue'; import formTextarea from './form-textarea.vue';
import formMultiSelectAndOther from './form-multiSelectAndOther.vue';
import formSelectOther from './form-select-other.vue';
import formSelectMobile from './form-select-mobile.vue';
import formRunTime from './form-run-time.vue';
import formFiles from './form-files.vue';
import formSelectImage from './form-select-image.vue';
import formSurgicalHistory from './form-surgical-history.vue';
defineProps({ defineProps({
form: { form: {

View File

@ -1,18 +1,13 @@
<template> <template>
<template v-for="item in formItems" :key="item.title"> <template v-for="item in formItems" :key="item.title">
<form-cell v-if="item.cellType === 'formCellItem'" v-bind="item" :form="form" :disableChange="disabledMap[item.title]" <form-cell v-bind="item" :form="form" :disableChange="disabledMap[item.title]" @change="change" />
@change="change" @addRule="addRule" />
<custom-cell v-else-if="item.cellType === 'customCellItem'" v-bind="item" :form="form"
:readonly="disabledMap[item.title]" @change="change" @addRule="addRule" />
</template> </template>
<form-cell />
</template> </template>
<script setup> <script setup>
import { computed, provide, ref } from 'vue'; import { computed, provide, ref } from 'vue';
import verifyForm from './verify.js'; import verifyForm from './verify.js';
import FormCell from './form-cell/index.vue'; import FormCell from './form-cell/index.vue';
// import CustomCell from './custom-cell/index.vue';
const emits = defineEmits(['change']); const emits = defineEmits(['change']);
const props = defineProps({ const props = defineProps({
@ -31,12 +26,13 @@ const props = defineProps({
rule: { rule: {
type: Object, type: Object,
default: () => ({}) default: () => ({})
},
filterRule: {
type: Object,
default: () => ({})
} }
}) })
const formCellType = ['input', 'select', 'date', 'radio', 'region', 'textarea', 'multiSelectAndOther', 'selfMultipleDiseases'];
const formCellTitle = ['surgicalHistory'];
const customCellType = ['BMI', 'selfMultipleDiseases', 'bloodPressure', 'diagnosis'];
const disabledMap = computed(() => props.disableTitles.reduce((m, i) => { const disabledMap = computed(() => props.disableTitles.reduce((m, i) => {
if (typeof i === 'string' && i.trim()) { if (typeof i === 'string' && i.trim()) {
m[i] = true; m[i] = true;
@ -49,22 +45,30 @@ provide('addRule', addRule);
const customRule = ref({}); const customRule = ref({});
const formItems = computed(() => { const formItems = computed(() => {
return props.items.map(i => { return props.items
let cellType = ''; .filter((i) => {
if (customCellType.includes(i.type)) { if (!i) return false;
cellType = 'customCellItem'; const fn = props.filterRule && typeof props.filterRule[i.title] === 'function' ? props.filterRule[i.title] : null;
} else if (formCellType.includes(i.type) || formCellTitle.includes(i.title)) { return fn ? fn(props.form) : true;
cellType = 'formCellItem';
}
return { ...i, cellType }
}) })
.map((i) => ({ ...i }));
}) })
const rules = computed(() => ({ ...customRule.value, ...props.rule })); const rules = computed(() => ({ ...customRule.value, ...props.rule }));
function addRule({ title, fn }) { function addRule(arg1, arg2) {
if (title && props.items.some(i => i.title === title) && typeof fn === 'function') { // addRule({title, fn}) / addRule(title, fn)
customRule.value[title] = fn; if (typeof arg1 === 'string' && typeof arg2 === 'function') {
const title = arg1;
const fn = arg2;
if (title && props.items.some((i) => i.title === title)) customRule.value[title] = fn;
return;
}
if (arg1 && typeof arg1 === 'object') {
const title = arg1.title;
const fn = arg1.fn;
if (title && props.items.some((i) => i.title === title) && typeof fn === 'function') customRule.value[title] = fn;
} }
} }
@ -73,7 +77,8 @@ function change(data) {
} }
function verify() { function verify() {
return verifyForm(props.items, rules.value, props.form) const visible = formItems.value.filter((i) => i && !i.hidden);
return verifyForm(visible, rules.value, props.form)
} }
defineExpose({ verify }) defineExpose({ verify })

View File

@ -8,6 +8,12 @@ const FormRule = {
radio: checkListItem, radio: checkListItem,
select: checkListItem, select: checkListItem,
textarea: checkInput, textarea: checkInput,
selectMobile: checkInput,
selectAndOther: checkInput,
selectAndImage: checkListItem,
multiSelectAndOther: checkMultiList,
files: checkFiles,
runTime: checkInput,
} }
const fixedRule = { const fixedRule = {
@ -41,8 +47,8 @@ export default function verify(items, rule = {}, data = {}) {
} }
function checkDate({ name, required }, value) { function checkDate({ name, required }, value) {
if (!required && isTrueValue(value)) return true; if (!required && !isTrueValue(value)) return true;
if (required && !value) { if (required && !isTrueValue(value)) {
toast(`请选择${name}`) toast(`请选择${name}`)
return false return false
} }
@ -55,7 +61,22 @@ function checkDate({ name, required }, value) {
function checkListItem({ range, name, required }, value) { function checkListItem({ range, name, required }, value) {
if (isTrueValue(value)) { if (isTrueValue(value)) {
if (!Array.isArray(range) || !range.includes(value)) { if (!Array.isArray(range) || range.length === 0) {
toast(`${name}所选值无效,请重新选择`)
return false
}
const candidate = typeof value === 'string' || typeof value === 'number' ? String(value) : value;
const isObjectRange = range.length > 0 && range[0] && typeof range[0] === 'object';
if (isObjectRange) {
const values = range
.map((i) => (i && typeof i === 'object' && 'value' in i ? i.value : undefined))
.filter((i) => i !== undefined && i !== null)
.map((i) => String(i));
if (!values.includes(String(candidate))) {
toast(`${name}所选值无效,请重新选择`)
return false
}
} else if (!range.map((i) => String(i)).includes(String(candidate))) {
toast(`${name}所选值无效,请重新选择`) toast(`${name}所选值无效,请重新选择`)
return false return false
} }
@ -87,7 +108,7 @@ function checkIdCard(value, name) {
} }
function checkInput({ name, required, wordLimit, inputType }, value) { function checkInput({ name, required, wordLimit, inputType }, value) {
if (!required && !value) return true; if (!required && !isTrueValue(value)) return true;
if (required && !isTrueValue(value)) { if (required && !isTrueValue(value)) {
toast(`请输入${name}`) toast(`请输入${name}`)
return false return false
@ -99,6 +120,34 @@ function checkInput({ name, required, wordLimit, inputType }, value) {
return true return true
} }
function checkMultiList({ name, required }, value) {
const list = Array.isArray(value) ? value.filter(isTrueValue) : [];
if (required && list.length === 0) {
toast(`请选择${name}`)
return false
}
return true
}
function checkFiles({ name, required }, value) {
const list = Array.isArray(value)
? value
.map((i) => {
if (typeof i === 'string') return i;
if (i && typeof i === 'object' && i.url) return String(i.url);
return '';
})
.filter((i) => typeof i === 'string' && i.trim() !== '')
: typeof value === 'string' && value.trim() !== ''
? [value]
: [];
if (required && list.length === 0) {
toast(`请上传${name}`)
return false
}
return true
}
function isTrueValue(value) { function isTrueValue(value) {
if (typeof value === 'string') return value.trim() !== ''; if (typeof value === 'string') return value.trim() !== '';
if (typeof value === 'number') return true; if (typeof value === 'number') return true;

View File

@ -219,13 +219,19 @@
<script setup> <script setup>
import { computed, getCurrentInstance, ref } from 'vue'; import { computed, getCurrentInstance, ref } from 'vue';
import { onLoad, onPullDownRefresh, onReachBottom, onReady, onShow } from '@dcloudio/uni-app'; import { onLoad, onPullDownRefresh, onReachBottom, onReady, onShow } from '@dcloudio/uni-app';
import { storeToRefs } from 'pinia';
import HealthProfileTab from '@/components/archive-detail/health-profile-tab.vue'; import HealthProfileTab from '@/components/archive-detail/health-profile-tab.vue';
import ServiceInfoTab from '@/components/archive-detail/service-info-tab.vue'; import ServiceInfoTab from '@/components/archive-detail/service-info-tab.vue';
import FollowUpManageTab from '@/components/archive-detail/follow-up-manage-tab.vue'; import FollowUpManageTab from '@/components/archive-detail/follow-up-manage-tab.vue';
import { ensureSeed } from '@/components/archive-detail/mock'; import { ensureSeed } from '@/components/archive-detail/mock';
import api from '@/utils/api';
import useAccountStore from '@/store/account';
import { hideLoading, loading, toast } from '@/utils/widget';
const STORAGE_KEY = 'ykt_case_archive_detail'; const STORAGE_KEY = 'ykt_case_archive_detail';
const CURRENT_TEAM_STORAGE_KEY = 'ykt_case_current_team';
const NEED_RELOAD_STORAGE_KEY = 'ykt_case_need_reload';
const tabs = [ const tabs = [
{ key: 'visitRecord', title: '病历记录' }, { key: 'visitRecord', title: '病历记录' },
@ -257,6 +263,110 @@ const archive = ref({
groupOptions: ['高血压', '糖尿病', '高血脂'] groupOptions: ['高血压', '糖尿病', '高血脂']
}); });
const accountStore = useAccountStore();
const { account, doctorInfo } = storeToRefs(accountStore);
const { getDoctorInfo } = accountStore;
function getUserId() {
const d = doctorInfo.value || {};
const a = account.value || {};
return String(d.userid || d.userId || d.corpUserId || a.userid || a.userId || '') || '';
}
function getCorpId() {
const d = doctorInfo.value || {};
const a = account.value || {};
const team = uni.getStorageSync(CURRENT_TEAM_STORAGE_KEY) || {};
return String(d.corpId || a.corpId || team.corpId || '') || '';
}
function normalizeArchiveFromApi(raw) {
const r = raw && typeof raw === 'object' ? raw : {};
const next = {
_id: r._id || archiveId.value,
name: r.name || '',
sex: r.sex || r.gender || '',
age: r.age ?? '',
avatar: r.avatar || '',
mobile: r.mobile || r.phone1 || r.phone || '',
outpatientNo: r.outpatientNo || '',
inpatientNo: r.inpatientNo || '',
medicalRecordNo: r.medicalRecordNo || '',
createTime: r.createTime || '',
creator: r.creator || '',
notes: r.notes || r.remark || '',
};
return next;
}
async function ensureDoctor() {
if (doctorInfo.value) return;
if (!account.value?.openid) return;
try {
await getDoctorInfo();
} catch (e) {
// ignore
}
}
async function fetchArchive() {
if (!archiveId.value) return;
await ensureDoctor();
loading('加载中...');
try {
const res = await api('getCustomerByCustomerId', { customerId: archiveId.value });
if (!res?.success) {
toast(res?.message || '获取档案失败');
return;
}
archive.value = { ...archive.value, ...normalizeArchiveFromApi(res.data) };
saveToStorage();
} catch (e) {
toast('获取档案失败');
} finally {
hideLoading();
}
}
async function updateArchive(patch) {
const id = String(archiveId.value || '');
if (!id) return false;
await ensureDoctor();
const userId = getUserId();
const corpId = getCorpId();
const team = uni.getStorageSync(CURRENT_TEAM_STORAGE_KEY) || {};
const createTeamId = team?.teamId ? String(team.teamId) : '';
const createTeamName = team?.name ? String(team.name) : '';
if (!userId || !corpId) {
toast('缺少登录信息,请先完成登录');
return false;
}
loading('保存中...');
try {
const res = await api('updateCustomer', {
id,
params: patch,
userId,
corpId,
createTeamId,
createTeamName,
});
if (!res?.success) {
toast(res?.message || '保存失败');
return false;
}
uni.setStorageSync(NEED_RELOAD_STORAGE_KEY, 1);
await fetchArchive();
return true;
} catch (e) {
toast('保存失败');
return false;
} finally {
hideLoading();
}
}
onLoad((options) => { onLoad((options) => {
archiveId.value = options?.id ? String(options.id) : String(archive.value.medicalRecordNo || archive.value.outpatientNo || archive.value.inpatientNo || ''); archiveId.value = options?.id ? String(options.id) : String(archive.value.medicalRecordNo || archive.value.outpatientNo || archive.value.inpatientNo || '');
@ -279,6 +389,9 @@ onLoad((options) => {
archiveId.value = String(archive.value.medicalRecordNo || archive.value.outpatientNo || archive.value.inpatientNo || `mock_${Date.now()}`); archiveId.value = String(archive.value.medicalRecordNo || archive.value.outpatientNo || archive.value.inpatientNo || `mock_${Date.now()}`);
} }
ensureSeed(archiveId.value, archive.value); ensureSeed(archiveId.value, archive.value);
//
fetchArchive();
}); });
function measureTabsTop() { function measureTabsTop() {
@ -308,6 +421,7 @@ onShow(() => {
}; };
} }
setTimeout(measureTabsTop, 30); setTimeout(measureTabsTop, 30);
fetchArchive();
}); });
onPullDownRefresh(() => { onPullDownRefresh(() => {
@ -389,9 +503,7 @@ const saveContact = () => {
uni.showToast({ title: '请输入联系电话', icon: 'none' }); uni.showToast({ title: '请输入联系电话', icon: 'none' });
return; return;
} }
archive.value.mobile = v; updateArchive({ mobile: v }).then((ok) => ok && closeContact());
saveToStorage();
closeContact();
}; };
// ===== ===== // ===== =====
@ -408,9 +520,8 @@ const closeNotes = () => {
}; };
const saveNotes = () => { const saveNotes = () => {
archive.value.notes = String(notesInput.value || '').trim(); const v = String(notesInput.value || '').trim();
saveToStorage(); updateArchive({ notes: v }).then((ok) => ok && closeNotes());
closeNotes();
}; };
// ===== ===== // ===== =====

View File

@ -1,16 +1,27 @@
<template> <template>
<view class="page"> <view class="page">
<CustomerProfileTab :data="archive" :floatingBottom="16" @save="savePatch" /> <CustomerProfileTab
:data="archive"
:baseItems="baseItems"
:internalItems="internalItems"
:floatingBottom="16"
@save="savePatch"
/>
</view> </view>
</template> </template>
<script setup> <script setup>
import { ref } from 'vue'; import { ref } from 'vue';
import { onLoad, onShow } from '@dcloudio/uni-app'; import { onLoad, onShow } from '@dcloudio/uni-app';
import { storeToRefs } from 'pinia';
import CustomerProfileTab from '@/components/archive-detail/customer-profile-tab.vue'; import CustomerProfileTab from '@/components/archive-detail/customer-profile-tab.vue';
import { ensureSeed } from '@/components/archive-detail/mock'; import api from '@/utils/api';
import useAccountStore from '@/store/account';
import { hideLoading, loading, toast } from '@/utils/widget';
const STORAGE_KEY = 'ykt_case_archive_detail'; const STORAGE_KEY = 'ykt_case_archive_detail';
const CURRENT_TEAM_STORAGE_KEY = 'ykt_case_current_team';
const NEED_RELOAD_STORAGE_KEY = 'ykt_case_need_reload';
const archiveId = ref(''); const archiveId = ref('');
const archive = ref({ const archive = ref({
@ -31,6 +42,108 @@ const archive = ref({
groupOptions: [], groupOptions: [],
}); });
const baseItems = ref([]);
const internalItems = ref([]);
const accountStore = useAccountStore();
const { account, doctorInfo } = storeToRefs(accountStore);
const { getDoctorInfo } = accountStore;
function normalizeOptions(options) {
if (!Array.isArray(options)) return [];
if (!options.length) return [];
if (typeof options[0] === 'string') return options.filter((i) => typeof i === 'string');
if (typeof options[0] === 'object') {
return options
.map((i) => {
const label = i?.label ?? i?.name ?? i?.text ?? i?.title ?? '';
const value = i?.value ?? i?.id ?? i?.key ?? label;
if (!label && (value === undefined || value === null || value === '')) return null;
return { label: String(label || value), value: String(value) };
})
.filter(Boolean);
}
return [];
}
function normalizeTemplateItem(item) {
const next = { ...(item || {}) };
if (next.operateType === 'custom') next.operateType = 'formCell';
const originalType = next.type;
const customTypeMap = {
customerSource: 'select',
customerStage: 'select',
tag: 'multiSelectAndOther',
reference: 'input',
selectWwuser: 'select',
files: 'files',
corpProject: 'select',
diagnosis: 'textarea',
BMI: 'input',
bloodPressure: 'textarea',
selfMultipleDiseases: 'textarea',
};
if (originalType && customTypeMap[originalType]) {
next.__originType = originalType;
next.type = customTypeMap[originalType];
}
const aliasTypeMap = {
text: 'input',
string: 'input',
number: 'input',
integer: 'input',
int: 'input',
};
if (next.type && aliasTypeMap[next.type]) {
next.type = aliasTypeMap[next.type];
if (!next.inputType && (originalType === 'number' || originalType === 'integer' || originalType === 'int')) next.inputType = 'number';
}
const rawRange = next.range || next.options || next.optionList || next.values || [];
const range = normalizeOptions(rawRange);
if (next.type === 'select' || next.type === 'selectAndOther' || next.type === 'selectAndImage') {
next.range = range;
} else if (next.type === 'radio') {
if (range.length && typeof range[0] === 'object') {
next.type = 'select';
next.range = range;
} else {
next.range = Array.isArray(rawRange) ? rawRange : [];
}
}
if (!next.operateType) next.operateType = 'formCell';
next.required = Boolean(next.required);
if (next.type === 'input' && (next.wordLimit === undefined || next.wordLimit === null || next.wordLimit === '')) next.wordLimit = 20;
if (next.type === 'textarea' && (next.wordLimit === undefined || next.wordLimit === null || next.wordLimit === '')) next.wordLimit = 200;
return next;
}
function getUserId() {
const d = doctorInfo.value || {};
const a = account.value || {};
return String(d.userid || d.userId || d.corpUserId || a.userid || a.userId || '') || '';
}
function getCorpId() {
const d = doctorInfo.value || {};
const a = account.value || {};
const team = uni.getStorageSync(CURRENT_TEAM_STORAGE_KEY) || {};
return String(d.corpId || a.corpId || team.corpId || '') || '';
}
async function ensureDoctor() {
if (doctorInfo.value) return;
if (!account.value?.openid) return;
try {
await getDoctorInfo();
} catch (e) {
// ignore
}
}
function loadFromStorage() { function loadFromStorage() {
const cached = uni.getStorageSync(STORAGE_KEY); const cached = uni.getStorageSync(STORAGE_KEY);
if (cached && typeof cached === 'object') { if (cached && typeof cached === 'object') {
@ -41,23 +154,109 @@ function loadFromStorage() {
groupOptions: Array.isArray(cached.groupOptions) ? cached.groupOptions : archive.value.groupOptions, groupOptions: Array.isArray(cached.groupOptions) ? cached.groupOptions : archive.value.groupOptions,
}; };
} }
if (!archiveId.value) {
archiveId.value = String(archive.value.medicalRecordNo || archive.value.outpatientNo || archive.value.inpatientNo || `mock_${Date.now()}`);
}
ensureSeed(archiveId.value, archive.value);
} }
function savePatch(patch) { async function loadTemplates() {
archive.value = { ...archive.value, ...(patch && typeof patch === 'object' ? patch : {}) }; const corpId = getCorpId();
if (!corpId) return;
const [baseRes, internalRes] = await Promise.all([
api('getCurrentTemplate', { corpId, templateType: 'baseTemplate' }),
api('getCurrentTemplate', { corpId, templateType: 'internalTemplate' }),
]);
if (baseRes?.success) {
const temp = baseRes?.data && typeof baseRes.data === 'object' ? baseRes.data : baseRes;
const list = Array.isArray(temp.templateList) ? temp.templateList : [];
baseItems.value = list
.filter((i) => i && i.fieldStatus !== 'disable')
.filter((i) => i.operateType !== 'onlyRead')
.map(normalizeTemplateItem);
}
if (internalRes?.success) {
const temp = internalRes?.data && typeof internalRes.data === 'object' ? internalRes.data : internalRes;
const list = Array.isArray(temp.templateList) ? temp.templateList : [];
internalItems.value = list
.filter((i) => i && i.fieldStatus !== 'disable')
.filter((i) => i.operateType !== 'onlyRead')
.map(normalizeTemplateItem);
}
}
async function loadArchiveFromApi() {
if (!archiveId.value) return;
loading('加载中...');
try {
const res = await api('getCustomerByCustomerId', { customerId: archiveId.value });
if (!res?.success) {
toast(res?.message || '获取档案失败');
return;
}
archive.value = { ...archive.value, ...(res.data && typeof res.data === 'object' ? res.data : {}) };
uni.setStorageSync(STORAGE_KEY, { ...archive.value }); uni.setStorageSync(STORAGE_KEY, { ...archive.value });
} catch (e) {
toast('获取档案失败');
} finally {
hideLoading();
}
}
async function savePatch(patch) {
const p = patch && typeof patch === 'object' ? patch : {};
const params = Object.keys(p).reduce((acc, k) => {
const v = p[k];
if (v !== undefined) acc[k] = v;
return acc;
}, {});
if (Object.keys(params).length === 0) return;
await ensureDoctor();
const userId = getUserId();
const corpId = getCorpId();
const team = uni.getStorageSync(CURRENT_TEAM_STORAGE_KEY) || {};
const createTeamId = team?.teamId ? String(team.teamId) : '';
const createTeamName = team?.name ? String(team.name) : '';
if (!archiveId.value || !userId || !corpId) {
toast('缺少用户/团队信息,请先完成登录与个人信息');
return;
}
loading('保存中...');
try {
const res = await api('updateCustomer', {
id: archiveId.value,
params,
userId,
corpId,
createTeamId,
createTeamName,
});
if (!res?.success) {
toast(res?.message || '保存失败');
return;
}
toast('保存成功');
uni.setStorageSync(NEED_RELOAD_STORAGE_KEY, 1);
await loadArchiveFromApi();
} catch (e) {
toast('保存失败');
} finally {
hideLoading();
}
} }
onLoad((options) => { onLoad((options) => {
archiveId.value = options?.archiveId ? String(options.archiveId) : ''; archiveId.value = options?.archiveId ? String(options.archiveId) : '';
loadFromStorage(); loadFromStorage();
ensureDoctor().then(async () => {
await Promise.all([loadTemplates(), loadArchiveFromApi()]);
});
}); });
onShow(() => loadFromStorage()); onShow(() => {
loadFromStorage();
loadArchiveFromApi();
});
</script> </script>
<style scoped> <style scoped>

View File

@ -52,17 +52,19 @@
class="patient-list" class="patient-list"
:scroll-into-view="scrollIntoId" :scroll-into-view="scrollIntoId"
:scroll-with-animation="true" :scroll-with-animation="true"
lower-threshold="80"
@scrolltolower="loadMore"
> >
<view v-for="group in patientList" :key="group.letter" :id="'letter-' + group.letter"> <view v-for="group in patientList" :key="group.letter" :id="letterToDomId(group.letter)">
<view class="group-title">{{ group.letter }}</view> <view class="group-title">{{ group.letter }}</view>
<view v-for="(patient, pIndex) in group.data" :key="pIndex" class="patient-card" @click="handlePatientClick(patient)"> <view v-for="(patient, pIndex) in group.data" :key="pIndex" class="patient-card" @click="handlePatientClick(patient)">
<!-- Checkbox for Batch Mode --> <!-- Checkbox for Batch Mode -->
<view v-if="isBatchMode" class="checkbox-area"> <view v-if="isBatchMode" class="checkbox-area">
<uni-icons <uni-icons
:type="selectedItems.includes(patient.phone) ? 'checkbox-filled' : 'checkbox'" :type="selectedItems.includes(getSelectId(patient)) ? 'checkbox-filled' : 'checkbox'"
size="24" size="24"
:color="selectedItems.includes(patient.phone) ? '#007aff' : '#ccc'" :color="selectedItems.includes(getSelectId(patient)) ? '#007aff' : '#ccc'"
></uni-icons> ></uni-icons>
</view> </view>
<view class="card-content"> <view class="card-content">
@ -133,10 +135,18 @@
</template> </template>
<script setup> <script setup>
import { ref, computed } from 'vue'; import { ref, computed, watch } from 'vue';
import { onLoad, onShow } from '@dcloudio/uni-app';
import { storeToRefs } from 'pinia';
import dayjs from 'dayjs';
import api from '@/utils/api';
import useAccountStore from '@/store/account';
import { toast } from '@/utils/widget';
// State // State
const currentTeam = ref('张敏西服务团队'); const teams = ref([]);
const currentTeam = ref(null);
const currentTab = ref(0); const currentTab = ref(0);
const scrollIntoId = ref(''); const scrollIntoId = ref('');
const tabs = ['全部', '新患者', '糖尿病', '高血压', '冠心病', '慢阻肺']; const tabs = ['全部', '新患者', '糖尿病', '高血压', '冠心病', '慢阻肺'];
@ -144,85 +154,230 @@ const isBatchMode = ref(false);
const selectedItems = ref([]); // Stores patient phone or unique ID const selectedItems = ref([]); // Stores patient phone or unique ID
// //
const managedArchiveCountAllTeams = ref(10); // const managedArchiveCountAllTeams = ref(0); //
const isVerified = ref(true); // const isVerified = ref(true); //
const hasVerifyFailedHistory = ref(false); // const hasVerifyFailedHistory = ref(false); //
const verifyFailedReason = ref('资料不完整,请补充营业执照/资质证明后重新提交。'); const verifyFailedReason = ref('资料不完整,请补充营业执照/资质证明后重新提交。');
const DETAIL_STORAGE_KEY = 'ykt_case_archive_detail'; const DETAIL_STORAGE_KEY = 'ykt_case_archive_detail';
const CURRENT_TEAM_STORAGE_KEY = 'ykt_case_current_team';
const NEED_RELOAD_STORAGE_KEY = 'ykt_case_need_reload';
const teamDisplay = computed(() => `${currentTeam.value}(${managedArchiveCountAllTeams.value})`); const page = ref(1);
const pages = ref(0);
const pageSize = ref(50);
const totalFromApi = ref(0);
const loading = ref(false);
const rawPatients = ref([]);
const more = computed(() => page.value < pages.value);
// Mock Data const accountStore = useAccountStore();
const allPatients = [ const { account, doctorInfo } = storeToRefs(accountStore);
{ const { getDoctorInfo } = accountStore;
letter: 'A',
data: [ const teamDisplay = computed(() => `${currentTeam.value?.name || ''}(${managedArchiveCountAllTeams.value})`);
{
name: '安乐', gender: '男', age: 45, tags: ['糖尿病'], function asArray(value) {
record: { type: '门诊', date: '2026.1.10', diagnosis: '2型糖尿病' }, return Array.isArray(value) ? value : [];
createTime: '2026.1.19 14:30', creator: '李医生', phone: '13888888888', hospitalId: '1001', }
outpatientNo: '2828393893', inpatientNo: '', medicalRecordNo: '',
createdByDoctor: true, hasBindWechat: false function normalizeTeam(raw) {
}, if (!raw || typeof raw !== 'object') return null;
{ const teamId = raw.teamId || raw.id || raw._id || '';
name: '奥利奥', gender: '女', age: 22, tags: [], record: null, const name = raw.name || raw.teamName || raw.team || '';
createTime: '2026.1.15 09:00', creator: '王医生', phone: '13999999999', hospitalId: '1002', const corpId = raw.corpId || raw.corpID || '';
outpatientNo: '', inpatientNo: '', medicalRecordNo: '', const userId = raw.userId || raw.userid || raw.corpUserId || '';
createdByDoctor: false, hasBindWechat: true if (!teamId || !name) return null;
return { teamId: String(teamId), name: String(name), corpId: corpId ? String(corpId) : '', userId: userId ? String(userId) : '' };
}
function getUserId() {
const d = doctorInfo.value || {};
const a = account.value || {};
return String(d.userid || d.userId || d.corpUserId || a.userid || a.userId || '') || '';
}
function getCorpId() {
const t = currentTeam.value || {};
const a = account.value || {};
return String(t.corpId || a.corpId || '') || '';
}
function getTeamId() {
return String(currentTeam.value?.teamId || '') || '';
}
function getLetter(patient) {
const raw = patient?.firstLetter || patient?.nameFirstLetter || patient?.pinyinFirstLetter || patient?.letter || '';
const candidate = String(raw || '').trim();
if (candidate && /^[A-Za-z]$/.test(candidate)) return candidate.toUpperCase();
const name = String(patient?.name || '').trim();
const first = name ? name[0] : '';
if (/^[A-Za-z]$/.test(first)) return first.toUpperCase();
return '#';
}
function letterToDomId(letter) {
return `letter-${letter === '#' ? 'HASH' : letter}`;
}
function getSelectId(patient) {
return patient?._id || patient?.id || patient?.phone || patient?.mobile || '';
}
function parseCreateTime(value) {
if (!value) return null;
if (typeof value === 'number') return dayjs(value);
if (typeof value === 'string' && /^\d{10,13}$/.test(value)) {
const n = Number(value);
const ms = value.length === 10 ? n * 1000 : n;
return dayjs(ms);
} }
] const asString = String(value);
}, const d1 = dayjs(asString);
{ if (d1.isValid()) return d1;
letter: 'L', const normalized = asString.replace(/\./g, '-').replace(/\//g, '-');
data: [ const d2 = dayjs(normalized);
{ return d2.isValid() ? d2 : null;
name: '李珊珊', gender: '女', age: 37, tags: ['糖尿病', '高血压'], }
record: { type: '门诊', date: '2026.1.10', diagnosis: '急性上呼吸道感染' },
createTime: '2026.1.10 10:20', creator: '张医生', phone: '13666666666', hospitalId: '1003', function formatPatient(raw) {
outpatientNo: '2828393893', inpatientNo: '2828393893', medicalRecordNo: '2828393893', const name = raw?.name || raw?.customerName || '';
createdByDoctor: true, hasBindWechat: false const sex = raw?.sex || raw?.gender || '';
}, const age = raw?.age ?? '';
{ const mobiles = asArray(raw?.mobiles).map(String).filter(Boolean);
name: '李珊珊', gender: '女', age: 37, tags: [], const mobile = raw?.mobile ? String(raw.mobile) : (mobiles[0] || '');
record: { type: '住院', date: '2026.1.10', diagnosis: '急性上呼吸道感染' },
createTime: '2025.12.30 11:00', creator: '张医生', phone: '13666666667', hospitalId: '10031', const createTime = parseCreateTime(raw?.createTime);
outpatientNo: '', inpatientNo: '2828393893', medicalRecordNo: '', const createTimeStr = createTime ? createTime.format('YYYY-MM-DD HH:mm') : '';
createdByDoctor: false, hasBindWechat: true
}, const rawTags = asArray(raw?.tags).filter((i) => typeof i === 'string');
{ const rawTagNames = asArray(raw?.tagNames).filter((i) => typeof i === 'string');
name: '李某某', gender: '女', age: 37, tags: [], record: null,
createTime: '2025.12.01 08:30', creator: '系统导入', phone: '13555555555', hospitalId: '1004', const tagIds = asArray(raw?.tagIds).map(String).filter(Boolean);
outpatientNo: '', inpatientNo: '', medicalRecordNo: '',
createdByDoctor: false, hasBindWechat: false return {
}, ...raw,
{ _id: raw?._id || raw?.id || '',
name: '李四', gender: '男', age: 50, tags: ['高血压'], record: null, name: String(name || ''),
createTime: '2026.1.18 16:45', creator: '管理员', phone: '13444444444', hospitalId: '1005', gender: String(sex || ''),
outpatientNo: '', inpatientNo: '', medicalRecordNo: '', age,
createdByDoctor: true, hasBindWechat: true tags: rawTags.length ? rawTags : (rawTagNames.length ? rawTagNames : tagIds),
mobiles,
mobile,
createTime: createTimeStr,
creator: raw?.creatorName || raw?.creator || '',
hospitalId: raw?.customerNumber || raw?.hospitalId || '',
record: null,
createdByDoctor: raw?.addMethod ? String(raw.addMethod) === 'manual' : Boolean(raw?.createdByDoctor),
hasBindWechat: Boolean(raw?.externalUserId || raw?.unionid || raw?.hasBindWechat),
};
}
function groupByLetter(list) {
const map = new Map();
list.forEach((item) => {
const letter = getLetter(item);
const arr = map.get(letter) || [];
arr.push(item);
map.set(letter, arr);
});
const letters = Array.from(map.keys()).sort((a, b) => {
if (a === '#') return 1;
if (b === '#') return -1;
return a.localeCompare(b);
});
return letters.map((letter) => ({ letter, data: map.get(letter) || [] }));
}
async function loadTeams() {
if (!doctorInfo.value && account.value?.openid) {
try {
await getDoctorInfo();
} catch (e) {
// ignore
} }
]
},
{
letter: 'Z',
data: [
{
name: '张三', gender: '男', age: 28, tags: [], record: null,
createTime: '2026.1.19 10:00', creator: '赵医生', phone: '13333333333', hospitalId: '1006',
outpatientNo: '2828393893', inpatientNo: '', medicalRecordNo: '',
createdByDoctor: true, hasBindWechat: false
},
{
name: '张敏', gender: '女', age: 32, tags: ['高血压'],
record: { type: '门诊', date: '2025.12.15', diagnosis: '高血压' },
createTime: '2025.11.20 15:15', creator: '孙医生', phone: '13222222222', hospitalId: '1007',
outpatientNo: '', inpatientNo: '', medicalRecordNo: '2828393893',
createdByDoctor: false, hasBindWechat: true
} }
]
const userId = getUserId();
const corpId = String(account.value?.corpId || doctorInfo.value?.corpId || '') || '';
if (!corpId || !userId) {
toast('缺少用户信息,请先完善个人信息');
return;
} }
];
const res = await api('getTeamBymember', { corpId, corpUserId: userId });
if (!res?.success) {
toast(res?.message || '获取团队失败');
return;
}
const list = Array.isArray(res?.data) ? res.data : Array.isArray(res?.data?.data) ? res.data.data : [];
const normalized = list.map(normalizeTeam).filter(Boolean);
teams.value = normalized;
const saved = uni.getStorageSync(CURRENT_TEAM_STORAGE_KEY);
const savedTeamId = saved?.teamId ? String(saved.teamId) : '';
currentTeam.value = normalized.find((t) => savedTeamId && t.teamId === savedTeamId) || normalized[0] || null;
if (currentTeam.value) uni.setStorageSync(CURRENT_TEAM_STORAGE_KEY, currentTeam.value);
}
async function reload(reset = true) {
if (!currentTeam.value) return;
if (loading.value) return;
const userId = getUserId();
const corpId = getCorpId();
const teamId = getTeamId();
if (!corpId || !teamId || !userId) {
toast('缺少用户/团队信息,请先完成登录与个人信息');
return;
}
if (reset) {
page.value = 1;
rawPatients.value = [];
pages.value = 0;
totalFromApi.value = 0;
}
loading.value = true;
const res = await api('searchCorpCustomerWithFollowTime', {
corpId,
userId,
teamId,
page: page.value,
pageSize: pageSize.value,
});
loading.value = false;
if (!res?.success) {
toast(res?.message || '获取患者列表失败');
return;
}
const payload =
res && typeof res === 'object'
? res.data && typeof res.data === 'object' && !Array.isArray(res.data)
? res.data
: res
: {};
const list = Array.isArray(payload.list) ? payload.list : Array.isArray(payload.data) ? payload.data : [];
const next = list.map(formatPatient);
rawPatients.value = page.value === 1 ? next : [...rawPatients.value, ...next];
pages.value = Number(payload.pages || 0) || 0;
totalFromApi.value = Number(payload.total || 0) || rawPatients.value.length;
managedArchiveCountAllTeams.value =
Number(
payload.totalAllTeams ||
payload.totalAllTeam ||
payload.totalAllTeamsCount ||
managedArchiveCountAllTeams.value ||
totalFromApi.value ||
0
) || (totalFromApi.value || 0);
}
const handlePatientClick = (patient) => { const handlePatientClick = (patient) => {
if (isBatchMode.value) { if (isBatchMode.value) {
@ -230,10 +385,14 @@ const handlePatientClick = (patient) => {
return; return;
} }
const id = patient._id || patient.id || patient.mobile || patient.phone || '';
uni.setStorageSync(DETAIL_STORAGE_KEY, { uni.setStorageSync(DETAIL_STORAGE_KEY, {
_id: id,
name: patient.name, name: patient.name,
sex: patient.gender, sex: patient.gender,
age: patient.age, age: patient.age,
mobile: patient.mobile,
mobiles: Array.isArray(patient.mobiles) ? patient.mobiles : (patient.mobile ? [patient.mobile] : []),
outpatientNo: patient.outpatientNo, outpatientNo: patient.outpatientNo,
inpatientNo: patient.inpatientNo, inpatientNo: patient.inpatientNo,
medicalRecordNo: patient.medicalRecordNo, medicalRecordNo: patient.medicalRecordNo,
@ -242,60 +401,35 @@ const handlePatientClick = (patient) => {
createdByDoctor: patient.createdByDoctor, createdByDoctor: patient.createdByDoctor,
hasBindWechat: patient.hasBindWechat hasBindWechat: patient.hasBindWechat
}); });
uni.navigateTo({ url: '/pages/case/archive-detail' }); uni.navigateTo({ url: `/pages/case/archive-detail?id=${encodeURIComponent(String(id))}` });
}; };
// Computed // Computed
const patientList = computed(() => { const patientList = computed(() => {
let list = allPatients; const all = rawPatients.value || [];
// New Patient Filter (Last 7 days) // New Patient Filter (Last 7 days)
if (currentTab.value === 1) { if (currentTab.value === 1) {
// Current date logic: 2026-01-20 const now = dayjs();
const now = new Date('2026-01-20').getTime(); const sevenDaysAgo = now.subtract(7, 'day').valueOf();
const sevenDaysAgo = now - 7 * 24 * 60 * 60 * 1000; const flatList = all
.map((p) => {
let flatList = []; const t = parseCreateTime(p.createTime)?.valueOf();
list.forEach(group => { return t ? { ...p, _ts: t } : null;
group.data.forEach(p => { })
if (p.createTime) { .filter(Boolean)
// Parse format 2026.1.19 14:30 .filter((p) => p._ts >= sevenDaysAgo)
const timeStr = p.createTime.replace(/\./g, '/'); // safari compatibility often needs /, but for calculation standardizing .sort((a, b) => b._ts - a._ts);
const pTime = new Date(timeStr).getTime();
if (pTime >= sevenDaysAgo) {
flatList.push({ ...p, _ts: pTime });
}
}
});
});
// Sort by create time desc
flatList.sort((a, b) => b._ts - a._ts);
// Re-group or just show one group?
// Usually grouped list maintains grouping or creates a "Search Result" group.
// Requirement says "New Patient show...". Usually lists are still grouped by alphabet or just a flat list.
// The requirement "Sorted from late to early" for creation time implies order is important, creating letter groups breaks time order.
// So for "New Patient" tab, we should probably output a single group or regoup by date?
// Requirement 1.2 "New Patient card... order late to early". This strongly implies creating a single list sorted by time.
// But the template expects `group.letter` and `group.data`.
// I will return a single group named 'Recent' or similar, or just empty letter to hide header.
// The template has `<view class="group-title">{{ group.letter }}</view>`. If letter is empty, it shows empty line.
// I will return [{ letter: '', data: flatList }]
return [{ letter: '最近新增', data: flatList }]; return [{ letter: '最近新增', data: flatList }];
} }
// Tab Filtering (Mock logic for other tabs) // Tab Filtering (Mock logic for other tabs)
let filtered = all;
if (currentTab.value > 1) { if (currentTab.value > 1) {
const tabName = tabs[currentTab.value]; const tabName = tabs[currentTab.value];
list = list.map(group => ({ filtered = filtered.filter((p) => Array.isArray(p.tags) && p.tags.includes(tabName));
...group,
data: group.data.filter(p => p.tags.includes(tabName))
})).filter(group => group.data.length > 0);
} }
return groupByLetter(filtered);
return list;
}); });
const indexList = computed(() => { const indexList = computed(() => {
@ -306,6 +440,7 @@ const indexList = computed(() => {
const totalPatients = computed(() => { const totalPatients = computed(() => {
let count = 0; let count = 0;
patientList.value.forEach(g => count += g.data.length); patientList.value.forEach(g => count += g.data.length);
if (currentTab.value === 0 && totalFromApi.value) return totalFromApi.value;
return count; return count;
}); });
@ -320,16 +455,21 @@ const checkBatchMode = () => {
const scrollToLetter = (letter) => { const scrollToLetter = (letter) => {
if (currentTab.value === 1) return; if (currentTab.value === 1) return;
scrollIntoId.value = 'letter-' + letter; scrollIntoId.value = letterToDomId(letter);
}; };
const toggleTeamPopup = () => { const toggleTeamPopup = () => {
if (checkBatchMode()) return; if (checkBatchMode()) return;
if (!teams.value.length) {
toast('暂无可选团队');
return;
}
uni.showActionSheet({ uni.showActionSheet({
itemList: ['张敏西服务团队', '李医生团队', '王医生团队'], itemList: teams.value.map((i) => i.name),
success: function (res) { success: function (res) {
const teams = ['张敏西服务团队', '李医生团队', '王医生团队']; currentTeam.value = teams.value[res.tapIndex] || teams.value[0] || null;
currentTeam.value = teams[res.tapIndex]; if (currentTeam.value) uni.setStorageSync(CURRENT_TEAM_STORAGE_KEY, currentTeam.value);
reload(true);
} }
}); });
}; };
@ -450,7 +590,7 @@ const openCreatePatientEntry = () => {
const toggleSelect = (patient) => { const toggleSelect = (patient) => {
if (!isBatchMode.value) return; // Should not happen if click handler is correct if (!isBatchMode.value) return; // Should not happen if click handler is correct
const id = patient.phone; // Using phone as unique ID for mock const id = patient._id || patient.id || patient.phone || patient.mobile; // Prefer server id
const index = selectedItems.value.indexOf(id); const index = selectedItems.value.indexOf(id);
if (index > -1) { if (index > -1) {
selectedItems.value.splice(index, 1); selectedItems.value.splice(index, 1);
@ -465,7 +605,7 @@ const handleSelectAll = () => {
if (selectedItems.value.length === currentList.length) { if (selectedItems.value.length === currentList.length) {
selectedItems.value = []; // Unselect All selectedItems.value = []; // Unselect All
} else { } else {
selectedItems.value = currentList.map(p => p.phone); selectedItems.value = currentList.map(p => p._id || p.id || p.phone || p.mobile);
} }
}; };
@ -492,6 +632,31 @@ const handleShare = () => {
uni.navigateTo({ url: '/pages/case/batch-share' }); uni.navigateTo({ url: '/pages/case/batch-share' });
}; };
function loadMore() {
if (currentTab.value === 1) return;
if (!more.value || loading.value) return;
page.value += 1;
reload(false);
}
watch(currentTeam, (t) => {
if (!t) return;
uni.setStorageSync(CURRENT_TEAM_STORAGE_KEY, t);
});
onLoad(async () => {
await loadTeams();
if (currentTeam.value) await reload(true);
});
onShow(async () => {
const need = uni.getStorageSync(NEED_RELOAD_STORAGE_KEY);
if (need) {
uni.removeStorageSync(NEED_RELOAD_STORAGE_KEY);
await reload(true);
}
});
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View File

@ -5,12 +5,23 @@
<view class="form-wrap"> <view class="form-wrap">
<form-template ref="formRef" :items="baseItems" :form="form" :rule="rules" @change="onChange" /> <form-template ref="formRef" :items="baseItems" :form="form" :rule="rules" @change="onChange" />
</view> </view>
<view class="scroll-spacer" />
</scroll-view> </scroll-view>
<!-- #ifdef MP-WEIXIN -->
<cover-view class="footer">
<cover-view class="btn plain" @tap="cancel">取消</cover-view>
<cover-view class="btn primary" @tap="next">下一步</cover-view>
</cover-view>
<!-- #endif -->
<!-- #ifndef MP-WEIXIN -->
<view class="footer"> <view class="footer">
<button class="primary" @click="next">下一步</button> <button class="btn plain" @click="cancel">取消</button>
<button class="btn primary" @click="next">下一步</button>
</view> </view>
<!-- #endif -->
</view> </view>
</view> </view>
</template> </template>
@ -18,36 +29,151 @@
<script setup> <script setup>
import { reactive, ref } from 'vue'; import { reactive, ref } from 'vue';
import { onLoad } from '@dcloudio/uni-app'; import { onLoad } from '@dcloudio/uni-app';
import { storeToRefs } from 'pinia';
import dayjs from 'dayjs';
import FormTemplate from '@/components/form-template/index.vue'; import FormTemplate from '@/components/form-template/index.vue';
import api from '@/utils/api';
import useAccountStore from '@/store/account';
import { hideLoading, loading, toast } from '@/utils/widget';
import validate from '@/utils/validate'; import validate from '@/utils/validate';
const STORAGE_KEY = 'patient-create-base'; const STORAGE_KEY = 'patient-create-base';
const CURRENT_TEAM_STORAGE_KEY = 'ykt_case_current_team';
const formRef = ref(null); const formRef = ref(null);
const form = reactive({ const form = reactive({});
name: '', const baseItems = ref([]);
gender: '',
age: '',
mobile: '',
birthday: '',
idType: '',
idNo: ''
});
const baseItems = [ const accountStore = useAccountStore();
{ title: 'name', name: '姓名', type: 'input', operateType: 'formCell', required: true, wordLimit: 20, inputType: 'text' }, const { account, doctorInfo } = storeToRefs(accountStore);
{ title: 'gender', name: '性别', type: 'select', operateType: 'formCell', required: false, range: ['男', '女'] }, const { getDoctorInfo } = accountStore;
{ title: 'age', name: '年龄', type: 'input', operateType: 'formCell', required: false, wordLimit: 3, inputType: 'number' },
{ title: 'mobile', name: '手机号', type: 'input', operateType: 'formCell', required: false, wordLimit: 11, inputType: 'number' }, function normalizeChangeValue(value) {
{ title: 'birthday', name: '出生日期', type: 'date', operateType: 'formCell', required: false }, if (value && typeof value === 'object' && 'value' in value) return value.value;
{ title: 'idType', name: '证件类型', type: 'select', operateType: 'formCell', required: false, range: ['身份证', '护照', '港澳台通行证', '其他'] }, return value;
{ title: 'idNo', name: '证件号', type: 'input', operateType: 'formCell', required: false, wordLimit: 30, inputType: 'text' } }
];
function normalizeOptions(options) {
if (!Array.isArray(options)) return [];
if (!options.length) return [];
if (typeof options[0] === 'string') return options.filter((i) => typeof i === 'string');
if (typeof options[0] === 'object') {
return options
.map((i) => {
const label = i?.label ?? i?.name ?? i?.text ?? i?.title ?? '';
const value = i?.value ?? i?.id ?? i?.key ?? label;
if (!label && (value === undefined || value === null || value === '')) return null;
return { label: String(label || value), value: String(value) };
})
.filter(Boolean);
}
return [];
}
function normalizeTemplateItem(item) {
const next = { ...(item || {}) };
if (next.operateType === 'custom') next.operateType = 'formCell';
const originalType = next.type;
const customTypeMap = {
customerSource: 'select',
customerStage: 'select',
tag: 'multiSelectAndOther',
reference: 'input',
selectWwuser: 'select',
files: 'files',
corpProject: 'select',
diagnosis: 'textarea',
BMI: 'input',
bloodPressure: 'textarea',
selfMultipleDiseases: 'textarea',
};
if (originalType && customTypeMap[originalType]) {
next.__originType = originalType;
next.type = customTypeMap[originalType];
}
const aliasTypeMap = {
text: 'input',
string: 'input',
number: 'input',
integer: 'input',
int: 'input',
};
if (next.type && aliasTypeMap[next.type]) {
next.type = aliasTypeMap[next.type];
if (!next.inputType && (originalType === 'number' || originalType === 'integer' || originalType === 'int')) next.inputType = 'number';
}
const rawRange = next.range || next.options || next.optionList || next.values || [];
const range = normalizeOptions(rawRange);
if (next.type === 'select' || next.type === 'selectAndOther' || next.type === 'selectAndImage') {
next.range = range;
} else if (next.type === 'radio') {
// wxapp radio select
if (range.length && typeof range[0] === 'object') {
next.type = 'select';
next.range = range;
} else {
next.range = Array.isArray(rawRange) ? rawRange : [];
}
}
if (!next.operateType) next.operateType = 'formCell';
next.required = Boolean(next.required);
if (next.type === 'input' && (next.wordLimit === undefined || next.wordLimit === null || next.wordLimit === '')) next.wordLimit = 20;
if (next.type === 'textarea' && (next.wordLimit === undefined || next.wordLimit === null || next.wordLimit === '')) next.wordLimit = 200;
return next;
}
async function loadBaseTemplate() {
const corpId = String(account.value?.corpId || doctorInfo.value?.corpId || '') || '';
if (!corpId) return;
loading('加载中...');
const res = await api('getCurrentTemplate', { corpId, templateType: 'baseTemplate' });
hideLoading();
if (!res?.success) {
toast(res?.message || '获取模板失败');
return;
}
const temp = res?.data && typeof res.data === 'object' ? res.data : res;
const list = Array.isArray(temp.templateList) ? temp.templateList : [];
baseItems.value = list
.filter((i) => i && i.fieldStatus !== 'disable')
.filter((i) => i.operateType !== 'onlyRead')
.map(normalizeTemplateItem);
const initialValue = { relationship: '本人', cardType: '身份证' };
baseItems.value.forEach((i) => {
const title = i?.title;
if (!title) return;
if (!(title in form) && initialValue[title] !== undefined) {
form[title] = initialValue[title];
}
});
const cachedTeam = uni.getStorageSync(CURRENT_TEAM_STORAGE_KEY) || {};
if (cachedTeam?.teamId && !form.teamId) form.teamId = String(cachedTeam.teamId);
}
const rules = { const rules = {
idNo(value) { idNo(value) {
if (!value) return true; if (!value) return true;
if (form.idType === '身份证') { if (form.idType === '身份证' || form.cardType === '身份证') {
const [ok, msg] = validate.isChinaId(value);
if (!ok) return msg || '证件号格式不正确';
}
return true;
},
idCard(value) {
if (!value) return true;
if (form.cardType === '身份证' || form.idType === '身份证') {
const [ok, msg] = validate.isChinaId(value); const [ok, msg] = validate.isChinaId(value);
if (!ok) return msg || '证件号格式不正确'; if (!ok) return msg || '证件号格式不正确';
} }
@ -55,15 +181,31 @@ const rules = {
} }
}; };
onLoad(() => { onLoad(async () => {
const cached = uni.getStorageSync(STORAGE_KEY); const cached = uni.getStorageSync(STORAGE_KEY);
if (cached && typeof cached === 'object') { if (cached && typeof cached === 'object') {
Object.assign(form, cached); Object.assign(form, cached);
} }
if (!doctorInfo.value && account.value?.openid) {
try {
await getDoctorInfo();
} catch (e) {
// ignore
}
}
await loadBaseTemplate();
}); });
function onChange({ title, value }) { function onChange({ title, value }) {
form[title] = value; form[title] = normalizeChangeValue(value);
if (title === 'idCard' || title === 'idNo') handleIdCardChange(form[title]);
else if (title === 'birthday') handleBirthdayChange(form[title]);
}
function cancel() {
uni.navigateBack();
} }
function next() { function next() {
@ -71,22 +213,56 @@ function next() {
uni.setStorageSync(STORAGE_KEY, { ...form }); uni.setStorageSync(STORAGE_KEY, { ...form });
uni.navigateTo({ url: '/pages/case/patient-inner-info' }); uni.navigateTo({ url: '/pages/case/patient-inner-info' });
} }
function handleBirthdayChange(value) {
if (!value) return;
if (!baseItems.value.some((i) => i?.title === 'age')) return;
const d = dayjs(String(value));
if (!d.isValid()) return;
form.age = getAgeFromBirthday(d.format('YYYY-MM-DD'));
}
function handleIdCardChange(value) {
if (!value) return;
const [ok, birthday, gender] = validate.isChinaId(String(value));
if (!ok) return;
if (baseItems.value.some((i) => i?.title === 'cardType')) form.cardType = '身份证';
if (baseItems.value.some((i) => i?.title === 'birthday')) form.birthday = birthday;
if (baseItems.value.some((i) => i?.title === 'sex')) form.sex = gender === 'MALE' ? '男' : '女';
if (baseItems.value.some((i) => i?.title === 'gender')) form.gender = gender === 'MALE' ? '男' : '女';
if (baseItems.value.some((i) => i?.title === 'age')) form.age = getAgeFromBirthday(birthday);
}
function getAgeFromBirthday(birthday) {
const d = dayjs(String(birthday));
if (!d.isValid()) return '';
const now = dayjs();
let age = now.year() - d.year();
if (now.isBefore(d.add(age, 'year'))) age -= 1;
if (age < 0) age = 0;
return String(age);
}
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.page { .page {
height: 100vh; height: 100vh;
background: #f6f6f6; background: #f6f6f6;
display: flex;
flex-direction: column;
} }
.body { .body {
height: 100vh; flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
.scroll { .scroll {
flex: 1; flex: 1;
min-height: 0;
height: 0;
} }
.form-wrap { .form-wrap {
@ -113,20 +289,37 @@ function next() {
bottom: 0; bottom: 0;
background: #fff; background: #fff;
padding: 12px 16px calc(12px + env(safe-area-inset-bottom)); padding: 12px 16px calc(12px + env(safe-area-inset-bottom));
display: flex;
gap: 12px;
z-index: 10;
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.06); box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.06);
} }
.primary { .scroll-spacer {
width: 100%; height: calc(120px + env(safe-area-inset-bottom));
height: 48px;
line-height: 48px;
background: #5d8aff;
color: #fff;
border-radius: 6px;
font-size: 16px;
} }
.primary::after { .btn {
flex: 1;
height: 44px;
line-height: 44px;
border-radius: 6px;
font-size: 15px;
text-align: center;
}
.btn::after {
border: none; border: none;
} }
.btn.plain {
background: #fff;
color: #666;
border: 1px solid #ddd;
}
.btn.primary {
background: #5d8aff;
color: #fff;
}
</style> </style>

View File

@ -3,86 +3,283 @@
<view class="body"> <view class="body">
<scroll-view scroll-y class="scroll"> <scroll-view scroll-y class="scroll">
<view class="form-wrap"> <view class="form-wrap">
<form-template v-if="items.length" ref="formRef" :items="items" :form="form" :rule="rules" @change="onChange" /> <form-template v-if="items.length" ref="formRef" :items="items" :form="forms" :rule="rules" :filterRule="filterRule" @change="onChange" />
</view> </view>
<view class="scroll-spacer" />
</scroll-view> </scroll-view>
<!-- #ifdef MP-WEIXIN -->
<cover-view class="footer">
<cover-view class="btn plain" @tap="prev">上一步</cover-view>
<cover-view class="btn primary" @tap="save">保存</cover-view>
</cover-view>
<!-- #endif -->
<!-- #ifndef MP-WEIXIN -->
<view class="footer"> <view class="footer">
<button class="btn plain" @click="cancel">取消</button> <button class="btn plain" @click="prev">上一步</button>
<button class="btn primary" @click="save">完成</button> <button class="btn primary" @click="save">保存</button>
</view> </view>
<!-- #endif -->
</view> </view>
</view> </view>
</template> </template>
<script setup> <script setup>
import { reactive, ref } from 'vue'; import { computed, reactive, ref } from 'vue';
import { onLoad } from '@dcloudio/uni-app'; import { onLoad } from '@dcloudio/uni-app';
import FormTemplate from '@/components/form-template/index.vue'; import FormTemplate from '@/components/form-template/index.vue';
import { storeToRefs } from 'pinia';
import api from '@/utils/api';
import useAccountStore from '@/store/account';
import { hideLoading, loading, toast } from '@/utils/widget';
const BASE_KEY = 'patient-create-base'; const BASE_KEY = 'patient-create-base';
const INNER_KEY = 'patient-create-inner'; const INNER_KEY = 'patient-create-inner';
const CURRENT_TEAM_STORAGE_KEY = 'ykt_case_current_team';
const NEED_RELOAD_STORAGE_KEY = 'ykt_case_need_reload';
const formRef = ref(null); const formRef = ref(null);
const form = reactive({ const form = reactive({});
customerSource: '', const baseForm = reactive({});
firstVisitDate: '', const forms = computed(() => ({ ...baseForm, ...form }));
diseaseTag: '',
remark: ''
});
const items = [ const items = ref([]);
{ title: 'customerSource', name: '患者来源', type: 'select', operateType: 'formCell', required: false, range: ['线上咨询', '同事推荐', '客户推荐', '其他'] },
{ title: 'firstVisitDate', name: '首次就诊日期', type: 'date', operateType: 'formCell', required: false },
{ title: 'diseaseTag', name: '慢病标签', type: 'select', operateType: 'formCell', required: false, range: ['糖尿病', '高血压', '冠心病', '慢阻肺'] },
{ title: 'remark', name: '备注', type: 'textarea', operateType: 'formCell', required: false, wordLimit: 200 }
];
const rules = {}; const rules = {};
const filterRule = {
reference(formModel) {
const customerSource = Array.isArray(formModel.customerSource)
? formModel.customerSource
: typeof formModel.customerSource === 'string'
? [formModel.customerSource]
: [];
return ['同事推荐', '客户推荐'].includes(customerSource[0]) && customerSource.length === 1;
},
};
onLoad(() => { const accountStore = useAccountStore();
const { account, doctorInfo } = storeToRefs(accountStore);
const { getDoctorInfo } = accountStore;
function normalizeChangeValue(value) {
if (value && typeof value === 'object' && 'value' in value) return value.value;
return value;
}
function normalizeOptions(options) {
if (!Array.isArray(options)) return [];
if (!options.length) return [];
if (typeof options[0] === 'string') return options.filter((i) => typeof i === 'string');
if (typeof options[0] === 'object') {
return options
.map((i) => {
const label = i?.label ?? i?.name ?? i?.text ?? i?.title ?? '';
const value = i?.value ?? i?.id ?? i?.key ?? label;
if (!label && (value === undefined || value === null || value === '')) return null;
return { label: String(label || value), value: String(value) };
})
.filter(Boolean);
}
return [];
}
function normalizeTemplateItem(item) {
const next = { ...(item || {}) };
if (next.operateType === 'custom') next.operateType = 'formCell';
const originalType = next.type;
const customTypeMap = {
customerSource: 'select',
customerStage: 'select',
tag: 'multiSelectAndOther',
reference: 'input',
selectWwuser: 'select',
files: 'files',
corpProject: 'select',
diagnosis: 'textarea',
BMI: 'input',
bloodPressure: 'textarea',
selfMultipleDiseases: 'textarea',
};
if (originalType && customTypeMap[originalType]) {
next.__originType = originalType;
next.type = customTypeMap[originalType];
}
const aliasTypeMap = {
text: 'input',
string: 'input',
number: 'input',
integer: 'input',
int: 'input',
};
if (next.type && aliasTypeMap[next.type]) {
next.type = aliasTypeMap[next.type];
if (!next.inputType && (originalType === 'number' || originalType === 'integer' || originalType === 'int')) next.inputType = 'number';
}
const rawRange = next.range || next.options || next.optionList || next.values || [];
const range = normalizeOptions(rawRange);
if (next.type === 'select' || next.type === 'selectAndOther' || next.type === 'selectAndImage') {
next.range = range;
} else if (next.type === 'radio') {
if (range.length && typeof range[0] === 'object') {
next.type = 'select';
next.range = range;
} else {
next.range = Array.isArray(rawRange) ? rawRange : [];
}
}
if (!next.operateType) next.operateType = 'formCell';
next.required = Boolean(next.required);
if (next.type === 'input' && (next.wordLimit === undefined || next.wordLimit === null || next.wordLimit === '')) next.wordLimit = 20;
if (next.type === 'textarea' && (next.wordLimit === undefined || next.wordLimit === null || next.wordLimit === '')) next.wordLimit = 200;
return next;
}
async function loadInternalTemplate() {
const corpId = String(account.value?.corpId || doctorInfo.value?.corpId || '') || '';
if (!corpId) return;
loading('加载中...');
const res = await api('getCurrentTemplate', { corpId, templateType: 'internalTemplate' });
hideLoading();
if (!res?.success) {
toast(res?.message || '获取模板失败');
return;
}
const temp = res?.data && typeof res.data === 'object' ? res.data : res;
const list = Array.isArray(temp.templateList) ? temp.templateList : [];
items.value = list
.filter((i) => i && i.fieldStatus !== 'disable')
.filter((i) => i.operateType !== 'onlyRead')
.map(normalizeTemplateItem);
}
onLoad(async () => {
const base = uni.getStorageSync(BASE_KEY); const base = uni.getStorageSync(BASE_KEY);
if (!base || typeof base !== 'object') { if (!base || typeof base !== 'object') {
uni.showToast({ title: '请先填写基础信息', icon: 'none' }); uni.showToast({ title: '请先填写基础信息', icon: 'none' });
uni.navigateBack(); uni.navigateBack();
return; return;
} }
Object.assign(baseForm, base);
const cached = uni.getStorageSync(INNER_KEY); const cached = uni.getStorageSync(INNER_KEY);
if (cached && typeof cached === 'object') { if (cached && typeof cached === 'object') {
Object.assign(form, cached); Object.assign(form, cached);
} }
if (!doctorInfo.value && account.value?.openid) {
try {
await getDoctorInfo();
} catch (e) {
// ignore
}
}
await loadInternalTemplate();
}); });
function onChange({ title, value }) { function onChange({ title, value }) {
form[title] = value; form[title] = normalizeChangeValue(value);
} }
function cancel() { function prev() {
uni.navigateBack(); uni.navigateBack();
} }
function save() { function getUserId() {
const d = doctorInfo.value || {};
const a = account.value || {};
return String(d.userid || d.userId || d.corpUserId || a.userid || a.userId || '') || '';
}
function normalizeFormValue(value) {
if (value && typeof value === 'object' && 'value' in value) return value.value;
if (Array.isArray(value)) return value.map(normalizeFormValue);
return value;
}
function normalizeFormObject(obj) {
if (!obj || typeof obj !== 'object') return {};
return Object.keys(obj).reduce((acc, key) => {
acc[key] = normalizeFormValue(obj[key]);
return acc;
}, {});
}
function buildPayload(base, inner) {
const team = uni.getStorageSync(CURRENT_TEAM_STORAGE_KEY) || {};
const corpId = String(account.value?.corpId || team?.corpId || base?.corpId || '') || '';
const baseForm = normalizeFormObject(base);
const innerForm = normalizeFormObject(inner);
const payload = {
...baseForm,
...innerForm,
corpId,
teamId: baseForm.teamId || (team?.teamId ? String(team.teamId) : ''),
creator: getUserId(),
addMethod: 'manual',
};
//
if (payload.gender && !payload.sex) payload.sex = payload.gender;
if (payload.idNo && !payload.idCard) payload.idCard = payload.idNo;
if (payload.idType && !payload.cardType) payload.cardType = payload.idType;
if (Array.isArray(payload.teamId)) payload.teamId = payload.teamId[0] || '';
if (payload.customerSource && typeof payload.customerSource === 'string') {
payload.customerSource = [payload.customerSource];
}
return { payload, team };
}
async function save() {
if (formRef.value?.verify && !formRef.value.verify()) return; if (formRef.value?.verify && !formRef.value.verify()) return;
const base = uni.getStorageSync(BASE_KEY) || {}; const base = uni.getStorageSync(BASE_KEY) || {};
const payload = { ...base, ...form };
uni.setStorageSync(INNER_KEY, { ...form }); uni.setStorageSync(INNER_KEY, { ...form });
// payload const { payload, team } = buildPayload(base, form);
uni.showModal({ if (!payload.corpId || !payload.teamId || !payload.creator) {
title: '提示', toast('缺少用户/团队信息,请先完成登录与个人信息');
content: '已完成新增mock。后续将对接真实建档接口。', return;
showCancel: false, }
success: () => {
loading('请求中...');
try {
const createTeamId = Array.isArray(payload.teamId) ? (payload.teamId[0] || '') : String(payload.teamId || '');
const res = await api('addCustomer', {
createTeamId,
createTeamName: team?.name || '',
params: payload,
});
hideLoading();
if (!res?.success) {
toast(res?.message || '新增失败');
return;
}
toast('新增成功');
uni.removeStorageSync(BASE_KEY); uni.removeStorageSync(BASE_KEY);
uni.removeStorageSync(INNER_KEY); uni.removeStorageSync(INNER_KEY);
// uni.setStorageSync(NEED_RELOAD_STORAGE_KEY, 1);
uni.navigateBack({ delta: 2 }); uni.navigateBack({ delta: 2 });
} catch (e) {
hideLoading();
toast('新增失败');
} }
});
} }
</script> </script>
@ -90,16 +287,20 @@ function save() {
.page { .page {
height: 100vh; height: 100vh;
background: #f6f6f6; background: #f6f6f6;
display: flex;
flex-direction: column;
} }
.body { .body {
height: 100vh; flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
.scroll { .scroll {
flex: 1; flex: 1;
min-height: 0;
height: 0;
} }
@ -116,15 +317,21 @@ function save() {
padding: 12px 16px calc(12px + env(safe-area-inset-bottom)); padding: 12px 16px calc(12px + env(safe-area-inset-bottom));
display: flex; display: flex;
gap: 12px; gap: 12px;
z-index: 10;
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.06); box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.06);
} }
.scroll-spacer {
height: calc(120px + env(safe-area-inset-bottom));
}
.btn { .btn {
flex: 1; flex: 1;
height: 44px; height: 44px;
line-height: 44px; line-height: 44px;
border-radius: 6px; border-radius: 6px;
font-size: 15px; font-size: 15px;
text-align: center;
} }
.btn::after { .btn::after {

View File

@ -19,12 +19,16 @@
<!-- Search Results --> <!-- Search Results -->
<scroll-view v-if="searchQuery" scroll-y class="search-results"> <scroll-view v-if="searchQuery" scroll-y class="search-results">
<view v-if="searchResults.length === 0" class="empty-state"> <view v-if="searching && searchResults.length === 0" class="empty-state">
<text class="empty-text">搜索中...</text>
</view>
<view v-else-if="searchResults.length === 0" class="empty-state">
<text class="empty-text">暂无搜索结果</text> <text class="empty-text">暂无搜索结果</text>
</view> </view>
<view v-else> <view v-else>
<view v-for="(patient, index) in searchResults" :key="index" class="patient-card"> <view v-for="(patient, index) in searchResults" :key="index" class="patient-card" @click="goDetail(patient)">
<!-- Row 1 --> <!-- Row 1 -->
<view class="card-row-top"> <view class="card-row-top">
<view class="patient-info"> <view class="patient-info">
@ -58,97 +62,135 @@
<script setup> <script setup>
import { ref, computed } from 'vue'; import { ref, computed } from 'vue';
import { storeToRefs } from 'pinia';
import api from '@/utils/api';
import useAccountStore from '@/store/account';
import useDebounce from '@/utils/useDebounce';
import { toast } from '@/utils/widget';
// State // State
const searchQuery = ref(''); const searchQuery = ref('');
const searchResultsRaw = ref([]);
const searching = ref(false);
// Mock all patients data (same as case.vue) const CURRENT_TEAM_STORAGE_KEY = 'ykt_case_current_team';
const allPatients = [ const DETAIL_STORAGE_KEY = 'ykt_case_archive_detail';
{
letter: 'A', const accountStore = useAccountStore();
data: [ const { account, doctorInfo } = storeToRefs(accountStore);
{
name: '安乐', gender: '男', age: 45, tags: ['糖尿病'], function asArray(value) {
record: { type: '门诊', date: '2026.1.10', diagnosis: '2型糖尿病' }, return Array.isArray(value) ? value : [];
createTime: '2026.1.19 14:30', creator: '李医生', phone: '13888888888', hospitalId: '1001' }
},
{ function getUserId() {
name: '奥利奥', gender: '女', age: 22, tags: [], record: null, const d = doctorInfo.value || {};
createTime: '2026.1.15 09:00', creator: '王医生', phone: '13999999999', hospitalId: '1002' const a = account.value || {};
} return String(d.userid || d.userId || d.corpUserId || a.userid || a.userId || '') || '';
] }
},
{ function getTeamContext() {
letter: 'L', const cached = uni.getStorageSync(CURRENT_TEAM_STORAGE_KEY) || {};
data: [ const corpId = String(cached.corpId || account.value?.corpId || '') || '';
{ const teamId = String(cached.teamId || '') || '';
name: '李珊珊', gender: '女', age: 37, tags: ['糖尿病', '高血压'], return { corpId, teamId };
record: { type: '门诊', date: '2026.1.10', diagnosis: '急性上呼吸道感染' }, }
createTime: '2026.1.10 10:20', creator: '张医生', phone: '13666666666', hospitalId: '1003'
}, function formatPatient(raw) {
{ const rawTags = asArray(raw?.tags).filter((i) => typeof i === 'string');
name: '李珊珊', gender: '女', age: 37, tags: [], const rawTagNames = asArray(raw?.tagNames).filter((i) => typeof i === 'string');
record: { type: '住院', date: '2026.1.10', diagnosis: '急性上呼吸道感染' },
createTime: '2025.12.30 11:00', creator: '张医生', phone: '13666666666', hospitalId: '1003' const tagIds = asArray(raw?.tagIds).map(String).filter(Boolean);
}, const mobiles = asArray(raw?.mobiles).map(String).filter(Boolean);
{ const mobile = raw?.mobile ? String(raw.mobile) : (mobiles[0] || '');
name: '李某某', gender: '女', age: 37, tags: [], record: null,
createTime: '2025.12.01 08:30', creator: '系统导入', phone: '13555555555', hospitalId: '1004' return {
}, ...raw,
{ _id: raw?._id || raw?.id || '',
name: '李四', gender: '男', age: 50, tags: ['高血压'], record: null, name: raw?.name || raw?.customerName || '',
createTime: '2026.1.18 16:45', creator: '管理员', phone: '13444444444', hospitalId: '1005' gender: raw?.sex || raw?.gender || '',
} age: raw?.age ?? '',
] tags: rawTags.length ? rawTags : (rawTagNames.length ? rawTagNames : tagIds),
}, mobiles,
{ mobile,
letter: 'Z', record: null,
data: [ createTime: raw?.createTime || '',
{ creator: raw?.creatorName || raw?.creator || '',
name: '张三', gender: '男', age: 28, tags: [], record: null, hospitalId: raw?.customerNumber || raw?.hospitalId || '',
createTime: '2026.1.19 10:00', creator: '赵医生', phone: '13333333333', hospitalId: '1006' };
}, }
{
name: '张敏', gender: '女', age: 32, tags: ['高血压'],
record: { type: '门诊', date: '2025.12.15', diagnosis: '高血压' },
createTime: '2025.11.20 15:15', creator: '孙医生', phone: '13222222222', hospitalId: '1007'
}
]
}
];
// Computed // Computed
const searchResults = computed(() => { const searchResults = computed(() => {
if (!searchQuery.value) return []; return searchResultsRaw.value || [];
const q = searchQuery.value.toLowerCase();
let results = [];
allPatients.forEach(group => {
group.data.forEach(p => {
if (p.name.includes(q) ||
(p.phone && p.phone.includes(q)) ||
(p.hospitalId && p.hospitalId.includes(q))) {
results.push(p);
}
});
});
return results;
}); });
// Methods // Methods
const handleSearch = () => { const doSearch = useDebounce(async () => {
// Search logic handled by computed property const q = String(searchQuery.value || '').trim();
}; if (!q) {
searchResultsRaw.value = [];
return;
}
const userId = getUserId();
const { corpId, teamId } = getTeamContext();
if (!corpId || !teamId || !userId) {
toast('缺少用户/团队信息,请先完成登录与个人信息');
return;
}
searching.value = true;
const res = await api('searchCorpCustomerWithFollowTime', {
corpId,
userId,
teamId,
name: q,
page: 1,
pageSize: 50,
});
searching.value = false;
if (!res?.success) {
toast(res?.message || '搜索失败');
return;
}
const payload =
res && typeof res === 'object'
? res.data && typeof res.data === 'object' && !Array.isArray(res.data)
? res.data
: res
: {};
const list = Array.isArray(payload.list) ? payload.list : Array.isArray(payload.data) ? payload.data : [];
searchResultsRaw.value = list.map(formatPatient);
}, 600);
const handleSearch = () => doSearch();
const clearSearch = () => { const clearSearch = () => {
searchQuery.value = ''; searchQuery.value = '';
searchResultsRaw.value = [];
}; };
const goBack = () => { const goBack = () => {
uni.navigateBack(); uni.navigateBack();
}; };
const goDetail = (patient) => {
const id = patient?._id || patient?.id || patient?.mobile || patient?.phone || '';
uni.setStorageSync(DETAIL_STORAGE_KEY, {
_id: id,
name: patient.name,
sex: patient.gender,
age: patient.age,
mobile: patient.mobile,
mobiles: Array.isArray(patient.mobiles) ? patient.mobiles : (patient.mobile ? [patient.mobile] : []),
createTime: patient.createTime,
creator: patient.creator,
});
uni.navigateTo({ url: `/pages/case/archive-detail?id=${encodeURIComponent(String(id))}` });
};
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View File

@ -5,7 +5,8 @@ const urlsConfig = {
getCorpMemberHomepageInfo: 'getCorpMemberHomepageInfo', getCorpMemberHomepageInfo: 'getCorpMemberHomepageInfo',
getTeamBaseInfo: 'getTeamBaseInfo', getTeamBaseInfo: 'getTeamBaseInfo',
getTeamData: 'getTeamData', getTeamData: 'getTeamData',
queryWxJoinedTeams: 'queryWxJoinedTeams', getTeamBymember: 'getTeamBymember',
getCurrentTemplate: 'getCurrentTemplate',
wxAppLogin: 'wxAppLogin', wxAppLogin: 'wxAppLogin',
getDeptList: 'getRealDeptList', getDeptList: 'getRealDeptList',
getHospitalList: 'getRealHospital', getHospitalList: 'getRealHospital',
@ -19,11 +20,14 @@ const urlsConfig = {
}, },
member: { member: {
addCustomer: 'add', addCustomer: 'add',
updateCustomer: 'update',
bindMiniAppArchive: "bindMiniAppArchive", bindMiniAppArchive: "bindMiniAppArchive",
getCustomerByCustomerId: 'getCustomerByCustomerId', getCustomerByCustomerId: 'getCustomerByCustomerId',
getMiniAppCustomers: 'getMiniAppCustomers', getMiniAppCustomers: 'getMiniAppCustomers',
getTeamCustomers: 'getTeamCustomers', getTeamCustomers: 'getTeamCustomers',
getUnbindMiniAppCustomers: 'getUnbindMiniAppCustomers', getUnbindMiniAppCustomers: 'getUnbindMiniAppCustomers',
searchCorpCustomer: 'searchCorpCustomer',
searchCorpCustomerWithFollowTime: 'searchCorpCustomerWithFollowTime',
unbindMiniAppArchive: 'unbindMiniAppArchive', unbindMiniAppArchive: 'unbindMiniAppArchive',
}, },
wecom: { wecom: {
@ -65,4 +69,3 @@ export default async function api(urlId, data) {
} }
}) })
} }