551 lines
16 KiB
Vue
551 lines
16 KiB
Vue
<template>
|
||
<view>
|
||
<!-- 回访记录表单抽屉:使用 page-container 防止用户误退出页面;用 v-if 控制挂载/卸载 -->
|
||
<page-container
|
||
v-if="visible"
|
||
:show="visible"
|
||
:overlay="true"
|
||
position="bottom"
|
||
:round="true"
|
||
@beforeleave="onClose"
|
||
@clickoverlay="onClose"
|
||
>
|
||
<view class="cl-drawer">
|
||
<view class="cl-head">
|
||
<text class="cl-title">记录本次回访</text>
|
||
<text class="cl-close" @click="onClose">关闭</text>
|
||
</view>
|
||
<scroll-view scroll-y class="cl-body">
|
||
<view class="cl-section">
|
||
<!-- 拨打号码:展示脱敏,form.phone 仍存完整号供提交 -->
|
||
<view class="cl-row">
|
||
<text class="cl-label">拨打号码</text>
|
||
<text class="cl-value">{{ phoneDisplay || '—' }}</text>
|
||
</view>
|
||
<!-- 拨号时间(只读展示) -->
|
||
<view class="cl-row">
|
||
<text class="cl-label">拨号时间</text>
|
||
<text class="cl-value">{{ callAtText || '—' }}</text>
|
||
</view>
|
||
</view>
|
||
<!-- 回访结果:tag 横排单选(dict 驱动,颜色预计算到 computed) -->
|
||
<view class="cl-section">
|
||
<text class="cl-section-title">是否接通 <text class="cl-required">*</text></text>
|
||
<view class="cl-tags">
|
||
<view
|
||
v-for="r in dictResultsView"
|
||
:key="r.value"
|
||
class="cl-tag"
|
||
:style="{ color: r.selected ? r.color : '#6b7280', background: r.selected ? r.bgColor : '#f3f4f6', borderColor: r.selected ? r.color : '#e5e7eb' }"
|
||
@click="form.result = r.value"
|
||
>
|
||
<text class="cl-tag-text">{{ r.name }}</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
<!-- 下次回访时间:紧跟是否接通;仅选日期(年月日) -->
|
||
<view class="cl-section">
|
||
<text class="cl-section-title">下次回访时间</text>
|
||
<!-- 快捷标签:3天后/7天后;选中态与 form.next_at 联动 -->
|
||
<view class="cl-quick-tags">
|
||
<view
|
||
v-for="opt in nextAtQuickOptions"
|
||
:key="opt.key"
|
||
class="cl-tag"
|
||
:style="{ color: opt.selected ? '#1a9b88' : '#6b7280', background: opt.selected ? '#e8f5f2' : '#f3f4f6', borderColor: opt.selected ? '#1a9b88' : '#e5e7eb' }"
|
||
@click="onQuickPick(opt)"
|
||
>
|
||
<text class="cl-tag-text">{{ opt.label }}</text>
|
||
</view>
|
||
</view>
|
||
<view class="cl-picker" @click="openNextAtPicker">
|
||
<text :class="['cl-picker-text', { placeholder: !nextAtText }]">{{ nextAtText || '暂不安排' }}</text>
|
||
<text v-if="form.next_at" class="cl-clear" @click.stop="clearNextAt">清除</text>
|
||
<text class="cl-arrow">›</text>
|
||
</view>
|
||
</view>
|
||
<!-- 回访标签:tag 横排多选(dict 驱动,颜色预计算到 computed) -->
|
||
<view class="cl-section">
|
||
<text class="cl-section-title">回访标签</text>
|
||
<view class="cl-tags">
|
||
<view
|
||
v-for="tag in dictTagsView"
|
||
:key="tag.value"
|
||
class="cl-tag"
|
||
:style="{ color: tag.selected ? tag.color : '#6b7280', background: tag.selected ? tag.bgColor : '#f3f4f6', borderColor: tag.selected ? tag.color : '#e5e7eb' }"
|
||
@click="toggleTag(tag.value)"
|
||
>
|
||
<text class="cl-tag-text">{{ tag.name }}</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
<!-- 随访结论:textarea -->
|
||
<view class="cl-section">
|
||
<text class="cl-section-title">随访结论</text>
|
||
<textarea
|
||
class="cl-textarea"
|
||
v-model="form.conclusion"
|
||
:maxlength="120"
|
||
placeholder="请填写随访结论(选填)"
|
||
placeholder-class="cl-placeholder"
|
||
/>
|
||
</view>
|
||
<!-- 备注:textarea -->
|
||
<view class="cl-section">
|
||
<text class="cl-section-title">备注</text>
|
||
<textarea
|
||
class="cl-textarea"
|
||
v-model="form.remark"
|
||
:maxlength="200"
|
||
placeholder="请填写备注(选填)"
|
||
placeholder-class="cl-placeholder"
|
||
/>
|
||
</view>
|
||
</scroll-view>
|
||
<view class="cl-footer safe-area-inset-bottom">
|
||
<view class="cl-btn cl-btn-cancel" @click="onClose"><text>取消</text></view>
|
||
<view class="cl-btn cl-btn-ok" @click="onSubmit"><text>提交</text></view>
|
||
</view>
|
||
</view>
|
||
</page-container>
|
||
<!-- 下次回访时间 picker:uView 时间模式(只到天,不选时分) -->
|
||
<u-picker
|
||
v-model="nextAtPickerShow"
|
||
mode="time"
|
||
:params="nextAtParams"
|
||
confirm-text="确定"
|
||
@confirm="onNextAtConfirm"
|
||
@cancel="nextAtPickerShow = false"
|
||
></u-picker>
|
||
</view>
|
||
</template>
|
||
|
||
<script>
|
||
/**
|
||
* 回访记录表单抽屉
|
||
* 用途:在就诊人详情页(抽屉/页面)拨号后或手动点击"记录本次回访"时打开
|
||
* 字段:phone/call_at(由父组件预填) + result/tags/conclusion/remark/next_at
|
||
* 数据源:dict(回访结果/回访标签)从后台维护的 xk_patient_call_log_dict 表加载
|
||
*/
|
||
import { createPatientCallLog, getPatientCallLogDict } from '@/api/userPatientProfile.js';
|
||
|
||
/** 兜底数据:dict 接口失败时使用,保证 UI 不崩 */
|
||
const FALLBACK_RESULTS = [
|
||
{ value: 'connected', name: '接通', color: '#1a9b88' },
|
||
{ value: 'no_answer', name: '未接', color: '#b45309' },
|
||
{ value: 'hangup', name: '挂断', color: '#b91c1c' },
|
||
{ value: 'invalid', name: '号码错误', color: '#6b7280' },
|
||
];
|
||
const FALLBACK_TAGS = [
|
||
{ value: 'care', name: '关怀', color: '#6acdbb' },
|
||
{ value: 'condition', name: '病情', color: '#ef4444' },
|
||
{ value: 'medication', name: '用药提醒', color: '#3b82f6' },
|
||
{ value: 'review', name: '复诊提醒', color: '#f59e0b' },
|
||
{ value: 'complaint', name: '投诉处理', color: '#8b5cf6' },
|
||
];
|
||
|
||
/** 时间选择器精度:仅年月日(用户要求选日期即可,不选时分) */
|
||
const NEXT_AT_PARAMS = {
|
||
year: true,
|
||
month: true,
|
||
day: true,
|
||
hour: false,
|
||
minute: false,
|
||
second: false,
|
||
};
|
||
|
||
export default {
|
||
name: 'CallLogDrawer',
|
||
props: {
|
||
/** 控制抽屉显隐,v-if 挂载 */
|
||
visible: { type: Boolean, default: false },
|
||
/** 就诊人ID */
|
||
upId: { type: [Number, String], default: 0 },
|
||
/**
|
||
* 预填数据:拨号场景下父组件传入 { phone, call_at }
|
||
* 手动点击场景不传,phone/call_at 留空,由用户填写
|
||
*/
|
||
prefill: { type: Object, default: () => ({}) },
|
||
},
|
||
data() {
|
||
return {
|
||
submitting: false,
|
||
form: {
|
||
phone: '',
|
||
call_at: 0,
|
||
result: '',
|
||
tags: [],
|
||
conclusion: '',
|
||
remark: '',
|
||
next_at: 0,
|
||
},
|
||
// dict:后台维护的结果/标签列表,含 color
|
||
dictResults: FALLBACK_RESULTS,
|
||
dictTags: FALLBACK_TAGS,
|
||
nextAtParams: NEXT_AT_PARAMS,
|
||
nextAtPickerShow: false,
|
||
};
|
||
},
|
||
computed: {
|
||
/**
|
||
* 拨打号码脱敏展示:保留前3后4,中间 ****
|
||
* form.phone 仍为完整号码,提交时不脱敏
|
||
*/
|
||
phoneDisplay() {
|
||
const phone = String(this.form.phone || '').trim();
|
||
if (!phone) return '';
|
||
if (phone.length < 7) return phone;
|
||
return phone.slice(0, 3) + '****' + phone.slice(-4);
|
||
},
|
||
/** 拨号时间格式化展示(call_at 为秒级时间戳) */
|
||
callAtText() {
|
||
if (!this.form.call_at) return '';
|
||
return this.formatTimestamp(Number(this.form.call_at), true);
|
||
},
|
||
/** 下次回访时间展示(仅日期) */
|
||
nextAtText() {
|
||
if (!this.form.next_at) return '';
|
||
return this.formatTimestamp(Number(this.form.next_at), false);
|
||
},
|
||
/**
|
||
* 回访结果列表(带 selected 标记 + 浅色背景 bgColor)
|
||
* 注意:小程序模板 :style 不允许调函数,故把颜色计算放进 computed
|
||
*/
|
||
dictResultsView() {
|
||
const cur = this.form.result;
|
||
return this.dictResults.map((r) => {
|
||
const color = r.color || '#6acdbb';
|
||
return {
|
||
value: r.value,
|
||
name: r.name,
|
||
color,
|
||
bgColor: this.hexToRgba(color, 0.1),
|
||
selected: r.value === cur,
|
||
};
|
||
});
|
||
},
|
||
/** 回访标签列表(同上) */
|
||
dictTagsView() {
|
||
const tags = this.form.tags || [];
|
||
return this.dictTags.map((t) => {
|
||
const color = t.color || '#6acdbb';
|
||
return {
|
||
value: t.value,
|
||
name: t.name,
|
||
color,
|
||
bgColor: this.hexToRgba(color, 0.1),
|
||
selected: tags.indexOf(t.value) >= 0,
|
||
};
|
||
});
|
||
},
|
||
/**
|
||
* 下次回访时间快捷选项:3天后 / 7天后
|
||
* selected 标记是否与当前 form.next_at 命中同一天(避免时分差异导致不选中)
|
||
*/
|
||
nextAtQuickOptions() {
|
||
const cur = this.form.next_at;
|
||
const curDay = cur ? this.dayStartTs(cur) : 0;
|
||
return [
|
||
{ key: '3d', label: '3天后', days: 3, ts: this.daysLaterTs(3) },
|
||
{ key: '7d', label: '7天后', days: 7, ts: this.daysLaterTs(7) },
|
||
].map((o) => Object.assign({}, o, {
|
||
selected: cur > 0 && this.dayStartTs(o.ts) === curDay,
|
||
}));
|
||
},
|
||
},
|
||
watch: {
|
||
/**
|
||
* v-if 挂载时 visible 已是 true,用 immediate 同步初始化表单 + 加载 dict
|
||
*/
|
||
visible: {
|
||
immediate: true,
|
||
handler(val) {
|
||
if (val) {
|
||
this.initForm();
|
||
this.loadDict();
|
||
}
|
||
},
|
||
},
|
||
},
|
||
methods: {
|
||
/** 打开抽屉时根据 prefill 初始化表单 */
|
||
initForm() {
|
||
const pre = this.prefill || {};
|
||
this.submitting = false;
|
||
this.form = {
|
||
phone: String(pre.phone || ''),
|
||
call_at: Number(pre.call_at || 0),
|
||
result: '',
|
||
tags: [],
|
||
conclusion: '',
|
||
remark: '',
|
||
next_at: 0,
|
||
};
|
||
this.nextAtPickerShow = false;
|
||
},
|
||
/** 拉取后台维护的回访结果/标签字典,加载失败兜底用 FALLBACK */
|
||
async loadDict() {
|
||
try {
|
||
const wrap = await getPatientCallLogDict();
|
||
const body = wrap && wrap.res ? wrap.res : null;
|
||
const payload = body && body.result ? body.result : null;
|
||
if (payload) {
|
||
if (Array.isArray(payload.results) && payload.results.length) {
|
||
this.dictResults = payload.results;
|
||
}
|
||
if (Array.isArray(payload.tags) && payload.tags.length) {
|
||
this.dictTags = payload.tags;
|
||
}
|
||
}
|
||
} catch (e) {
|
||
// 静默兜底,使用 FALLBACK 数据保证 UI 可用
|
||
}
|
||
},
|
||
/** 关闭抽屉 */
|
||
onClose() {
|
||
if (this.submitting) return;
|
||
this.$emit('update:visible', false);
|
||
this.$emit('close');
|
||
},
|
||
/** 判断标签是否已选中 */
|
||
isTagChecked(value) {
|
||
return this.form.tags.indexOf(value) >= 0;
|
||
},
|
||
/** 切换标签选中态 */
|
||
toggleTag(value) {
|
||
const idx = this.form.tags.indexOf(value);
|
||
if (idx >= 0) {
|
||
this.form.tags.splice(idx, 1);
|
||
} else {
|
||
this.form.tags.push(value);
|
||
}
|
||
},
|
||
/** hex(#RRGGBB) -> rgba 字符串,alpha 控制浅色背景 */
|
||
hexToRgba(hex, alpha) {
|
||
const m = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex || '');
|
||
if (!m) return `rgba(106,205,187,${alpha})`;
|
||
const r = parseInt(m[1], 16);
|
||
const g = parseInt(m[2], 16);
|
||
const b = parseInt(m[3], 16);
|
||
return `rgba(${r},${g},${b},${alpha})`;
|
||
},
|
||
/** 打开下次回访时间选择器 */
|
||
openNextAtPicker() {
|
||
this.nextAtPickerShow = true;
|
||
},
|
||
/**
|
||
* 下次回访时间确认
|
||
* uView u-picker mode=time 的 confirm 回调返回对象:
|
||
* { year, month, day, hour, minute, second, timestamp }
|
||
* 其中 timestamp 为秒级时间戳;仅选年月日时,时间部分为当天 0 点
|
||
*/
|
||
onNextAtConfirm(e) {
|
||
if (e && typeof e.timestamp === 'number' && e.timestamp > 0) {
|
||
// 优先用 uView 提供的秒级时间戳
|
||
this.form.next_at = e.timestamp;
|
||
} else if (e && e.year) {
|
||
// 兜底:手动拼接 YYYY-MM-DD 再解析(兼容 Safari/iOS)
|
||
const pad = (n) => (Number(n) < 10 ? '0' + Number(n) : '' + n);
|
||
const text = `${e.year}-${pad(e.month)}-${pad(e.day)}`;
|
||
this.form.next_at = Math.floor(Date.parse(text.replace(/-/g, '/')) / 1000) || 0;
|
||
} else {
|
||
this.form.next_at = 0;
|
||
}
|
||
this.nextAtPickerShow = false;
|
||
},
|
||
/** 清除下次回访时间 */
|
||
clearNextAt() {
|
||
this.form.next_at = 0;
|
||
},
|
||
/**
|
||
* 点击快捷标签:再次点击同一天则取消;否则写入"今天起 N 天后 00:00:00"
|
||
* @param opt {key,label,days,ts,selected}
|
||
*/
|
||
onQuickPick(opt) {
|
||
if (opt && opt.selected) {
|
||
this.form.next_at = 0;
|
||
} else if (opt) {
|
||
this.form.next_at = this.daysLaterTs(opt.days);
|
||
}
|
||
},
|
||
/** 当前时间 + N 天的当天 00:00:00 秒级时间戳 */
|
||
daysLaterTs(days) {
|
||
const n = Number(days) || 0;
|
||
const now = new Date();
|
||
const d = new Date(now.getFullYear(), now.getMonth(), now.getDate() + n, 0, 0, 0);
|
||
return Math.floor(d.getTime() / 1000);
|
||
},
|
||
/** 取某时间戳所在自然日 00:00:00 的秒级时间戳(同日判断用) */
|
||
dayStartTs(ts) {
|
||
const d = new Date(ts * 1000);
|
||
const start = new Date(d.getFullYear(), d.getMonth(), d.getDate(), 0, 0, 0);
|
||
return Math.floor(start.getTime() / 1000);
|
||
},
|
||
/**
|
||
* 时间戳 -> 字符串
|
||
* @param ts 秒级时间戳
|
||
* @param withTime true 返回 YYYY-MM-DD HH:mm;false 仅 YYYY-MM-DD
|
||
*/
|
||
formatTimestamp(ts, withTime) {
|
||
if (!ts) return '';
|
||
const d = new Date(ts * 1000);
|
||
const pad = (n) => (n < 10 ? '0' + n : '' + n);
|
||
const ymd = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||
return withTime ? `${ymd} ${pad(d.getHours())}:${pad(d.getMinutes())}` : ymd;
|
||
},
|
||
/** 提交表单 */
|
||
async onSubmit() {
|
||
if (this.submitting) return;
|
||
if (!this.form.result) {
|
||
uni.showToast({ title: '请选择是否接通', icon: 'none' });
|
||
return;
|
||
}
|
||
if (!this.upId) {
|
||
uni.showToast({ title: '缺少就诊人信息', icon: 'none' });
|
||
return;
|
||
}
|
||
this.submitting = true;
|
||
uni.showLoading({ title: '提交中...', mask: true });
|
||
try {
|
||
const payload = {
|
||
phone: this.form.phone,
|
||
call_at: this.form.call_at,
|
||
result: this.form.result,
|
||
tags: this.form.tags,
|
||
conclusion: this.form.conclusion,
|
||
remark: this.form.remark,
|
||
next_at: this.form.next_at,
|
||
};
|
||
const wrap = await createPatientCallLog(this.upId, payload);
|
||
const ok = !!(wrap && wrap.ok);
|
||
uni.hideLoading();
|
||
if (!ok) {
|
||
uni.showToast({ title: '提交失败', icon: 'none' });
|
||
return;
|
||
}
|
||
uni.showToast({ title: '已记录', icon: 'success' });
|
||
this.$emit('success');
|
||
this.$emit('update:visible', false);
|
||
this.$emit('close');
|
||
} catch (e) {
|
||
uni.hideLoading();
|
||
uni.showToast({ title: '网络异常', icon: 'none' });
|
||
} finally {
|
||
this.submitting = false;
|
||
}
|
||
},
|
||
},
|
||
};
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
.cl-drawer {
|
||
height: 80vh;
|
||
background: #f9fafb;
|
||
display: flex;
|
||
flex-direction: column;
|
||
padding-bottom: env(safe-area-inset-bottom);
|
||
box-sizing: border-box;
|
||
}
|
||
.cl-head {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 28rpx 32rpx;
|
||
background: #fff;
|
||
border-bottom: 1rpx solid #f0f0f0;
|
||
flex-shrink: 0;
|
||
}
|
||
.cl-title { font-size: 30rpx; font-weight: 600; color: #111827; }
|
||
.cl-close { font-size: 26rpx; color: #6acdbb; }
|
||
.cl-body {
|
||
flex: 1;
|
||
min-height: 0;
|
||
padding: 16rpx 24rpx;
|
||
box-sizing: border-box;
|
||
}
|
||
.cl-section {
|
||
background: #fff;
|
||
border-radius: 14rpx;
|
||
padding: 20rpx 24rpx;
|
||
margin-bottom: 16rpx;
|
||
}
|
||
.cl-section-title {
|
||
display: block;
|
||
font-size: 26rpx;
|
||
font-weight: 600;
|
||
color: #1f2937;
|
||
margin-bottom: 16rpx;
|
||
}
|
||
.cl-required { color: #dc2626; margin-left: 4rpx; }
|
||
.cl-row {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 8rpx 0;
|
||
}
|
||
.cl-label { font-size: 24rpx; color: #9ca3af; }
|
||
.cl-value { font-size: 26rpx; color: #374151; }
|
||
.cl-picker {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12rpx;
|
||
border: 1rpx solid #e5e7eb;
|
||
border-radius: 12rpx;
|
||
padding: 18rpx 20rpx;
|
||
}
|
||
.cl-picker-text { flex: 1; font-size: 26rpx; color: #111827; }
|
||
.cl-picker-text.placeholder { color: #9ca3af; }
|
||
.cl-clear { font-size: 24rpx; color: #6acdbb; padding: 0 12rpx; }
|
||
.cl-arrow { font-size: 32rpx; color: #9ca3af; line-height: 1; }
|
||
.cl-tags {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 16rpx;
|
||
}
|
||
.cl-quick-tags {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 16rpx;
|
||
margin-bottom: 16rpx;
|
||
}
|
||
.cl-tag {
|
||
padding: 10rpx 24rpx;
|
||
border-radius: 24rpx;
|
||
background: #f3f4f6;
|
||
border: 1rpx solid #e5e7eb;
|
||
}
|
||
.cl-tag-text { font-size: 24rpx; color: #6b7280; }
|
||
.cl-textarea {
|
||
width: 100%;
|
||
box-sizing: border-box;
|
||
background: #f9fafb;
|
||
border: 1rpx solid #e5e7eb;
|
||
border-radius: 12rpx;
|
||
padding: 18rpx 20rpx;
|
||
font-size: 26rpx;
|
||
color: #111827;
|
||
min-height: 120rpx;
|
||
}
|
||
.cl-placeholder { color: #9ca3af; font-size: 26rpx; }
|
||
.cl-footer {
|
||
display: flex;
|
||
gap: 16rpx;
|
||
padding: 16rpx 24rpx 24rpx;
|
||
background: #fff;
|
||
border-top: 1rpx solid #f0f0f0;
|
||
flex-shrink: 0;
|
||
}
|
||
.cl-btn {
|
||
flex: 1;
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
height: 80rpx;
|
||
border-radius: 40rpx;
|
||
font-size: 28rpx;
|
||
}
|
||
.cl-btn-cancel { background: #f3f4f6; color: #6b7280; }
|
||
.cl-btn-ok { background: #6acdbb; color: #fff; }
|
||
</style>
|