Files
xk-doctor-wx/subPackages/sub_business_shared/components/CallLogDrawer.vue

551 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view>
<!-- 回访记录表单抽屉使用 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>
<!-- 下次回访时间 pickeruView 时间模式只到天不选时分 -->
<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:mmfalse 仅 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>