ykt-wxapp/pages/case/followup-detail.vue

666 lines
18 KiB
Vue
Raw Normal View History

2026-01-22 15:54:15 +08:00
<template>
<view class="page">
2026-01-26 15:39:14 +08:00
<!-- Mobile 来源: ykt-management-mobile/src/pages/customer/followup-detail/followup-detail.vue -->
<scroll-view scroll-y class="scroll">
<view class="section">
<view class="head-row">
<view class="head-title">{{ currentType.label }}</view>
<view class="head-tag" :class="`tag-${currentStatus.value}`">{{ currentStatus.label }}</view>
</view>
<view class="head-content">{{ form.taskContent || '暂无任务内容' }}</view>
2026-01-22 15:54:15 +08:00
</view>
2026-01-26 15:39:14 +08:00
<view v-if="todo?.sendContent" class="section border-top">
<view class="sub-label">发送内容 </view>
<view class="sub-content">{{ todo.sendContent }}</view>
<view v-for="(f, idx) in showFileList" :key="idx" class="file-line">
<text class="file-type">{{ f.typeStr }}</text>
<text class="file-name">{{ f.name }}</text>
</view>
</view>
<view class="section border-top kv">
<view class="kv-left">计划执行时间{{ planDate || '--' }}</view>
<view class="kv-right">{{ executorDisplay || '--' }}</view>
</view>
<view class="section border-top kv">
<view class="kv-left">客户:</view>
<view class="kv-right link">{{ customerDisplay || '--' }}</view>
2026-01-22 15:54:15 +08:00
</view>
2026-01-26 15:39:14 +08:00
<view class="section mt-20">
<view class="section-title">回访方式</view>
<view class="method-row" @click="toggleMethod('phone')">
<image class="radio" :src="`/static/circle${todoMethod === 'phone' ? 'd' : ''}.svg`" />
<view class="method-label">电话</view>
<view class="method-select" @click.stop="pickPhone">
<view class="method-value" :class="{ muted: !phone }">{{ phone || '请选择号码' }}</view>
<uni-icons type="arrowright" size="16" color="#999" />
</view>
</view>
<view class="method-row" @click="toggleMethod('wechat')">
<image class="radio" :src="`/static/circle${todoMethod === 'wechat' ? 'd' : ''}.svg`" />
<view class="method-label">微信</view>
2026-01-22 15:54:15 +08:00
</view>
</view>
2026-01-26 15:39:14 +08:00
<view class="section mt-20">
<view class="section-title">回访结果</view>
<view class="result-box">
<textarea
v-model="form.result"
class="result-textarea"
placeholder="请填写回访结果"
maxlength="500"
:disabled="!(editable || canEditResult)"
/>
</view>
2026-01-22 15:54:15 +08:00
</view>
2026-01-26 15:39:14 +08:00
<view class="section mt-20 meta">
<view class="meta-row">
<text class="meta-key">创建人 :</text>
<text class="meta-val">{{ creatorDisplay || '--' }}</text>
</view>
<view class="meta-row">
<text class="meta-key">创建时间 :</text>
<text class="meta-val">{{ createTimeStr || '--' }}</text>
</view>
<view v-if="endTimeStr" class="meta-row">
<text class="meta-key">执行时间 :</text>
<text class="meta-val">{{ endTimeStr }}</text>
</view>
2026-01-22 15:54:15 +08:00
</view>
2026-01-26 15:39:14 +08:00
<view style="height: 160rpx" />
</scroll-view>
<view v-if="editable || canEditResult" class="footer">
<button v-if="editable" class="footer-btn plain danger" @click="cancelTask">取消任务</button>
<button v-if="editable" class="footer-btn primary" @click="markDone">设为完成</button>
<button v-else class="footer-btn primary" @click="save">保存</button>
2026-01-22 15:54:15 +08:00
</view>
2026-01-26 15:39:14 +08:00
<view v-if="taskId && canRemove" class="delete-fab" @click="remove">
2026-01-22 15:54:15 +08:00
<uni-icons type="trash" size="22" color="#ff4d4f" />
</view>
</view>
</template>
<script setup>
import { computed, reactive, ref } from 'vue';
import { onLoad } from '@dcloudio/uni-app';
import dayjs from 'dayjs';
2026-01-26 15:39:14 +08:00
import { storeToRefs } from 'pinia';
import api from '@/utils/api';
import useAccountStore from '@/store/account';
import { toast } from '@/utils/widget';
import { getTodoEventTypeLabel } from '@/utils/todo-const';
2026-01-22 15:54:15 +08:00
const archiveId = ref('');
const mode = ref('add');
const taskId = ref('');
2026-01-26 15:39:14 +08:00
const todo = ref(null);
const currentType = computed(() => ({ label: getTodoEventTypeLabel(todo.value?.eventType), value: String(todo.value?.eventType || '') }));
const customer = ref(null);
const userNameMap = ref({});
function statusLabelFromStatus(status) {
const map = {
processing: '待处理',
notStart: '未开始',
treated: '已完成',
cancelled: '已取消',
expired: '已过期',
};
return map[status] || '未知';
}
function getStatus(t) {
const endOfToday = dayjs().endOf('day').valueOf();
const startOfToday = dayjs().startOf('day').valueOf();
const plannedExecutionTime = Number(t?.plannedExecutionTime || 0) || 0;
const expireTime = Number(t?.expireTime || 0) || 0;
const eventStatus = String(t?.eventStatus || '');
if (eventStatus === 'treated') return 'treated';
if (eventStatus === 'closed') return 'cancelled';
if (eventStatus === 'expire') return 'expired';
if (eventStatus === 'untreated') {
if (expireTime && expireTime < startOfToday) return 'expired';
if (plannedExecutionTime >= endOfToday) return 'notStart';
if (plannedExecutionTime <= startOfToday && (!expireTime || expireTime >= endOfToday)) return 'processing';
return 'processing';
}
return 'processing';
}
const currentStatus = computed(() => {
const status = getStatus(todo.value);
return { value: status, label: statusLabelFromStatus(status) };
});
const planDate = computed(() => {
const v = todo.value?.plannedExecutionTime;
return v && dayjs(v).isValid() ? dayjs(v).format('YYYY-MM-DD') : '';
});
const createTimeStr = computed(() => {
const v = todo.value?.createTime;
return v && dayjs(v).isValid() ? dayjs(v).format('YYYY-MM-DD HH:mm') : '';
});
const endTimeStr = computed(() => {
const v = todo.value?.endTime;
return v && dayjs(v).isValid() ? dayjs(v).format('YYYY-MM-DD HH:mm') : '';
});
const showFileList = computed(() => {
const list = Array.isArray(todo.value?.fileList) ? todo.value.fileList : [];
return list.map((i) => {
const type = String(i?.type || '');
const fileType = i?.file && typeof i.file.type === 'string' ? i.file.type : '';
let typeStr = '';
if (type === 'video' || fileType.includes('video')) typeStr = '【视频】';
else if (type === 'image' || fileType.includes('image')) typeStr = '【图片】';
else if (fileType === 'article') typeStr = '【文章】';
else if (fileType === 'questionnaire') typeStr = '【问卷】';
else if (type === 'link') typeStr = '【链接】';
const name = i?.file?.name ? String(i.file.name) : '';
return { typeStr, name };
});
});
const accountStore = useAccountStore();
const { account, doctorInfo } = storeToRefs(accountStore);
const { getDoctorInfo } = accountStore;
2026-01-22 15:54:15 +08:00
const form = reactive({
executorName: '',
taskContent: '',
result: '',
});
const todoMethod = ref('phone'); // phone | wechat
const phone = ref('');
const mobiles = computed(() => {
const arr = [];
const cached = uni.getStorageSync('ykt_case_archive_detail');
2026-01-26 15:39:14 +08:00
if (cached && typeof cached === 'object') {
if (cached.mobile) arr.push(String(cached.mobile));
if (Array.isArray(cached.mobiles)) arr.push(...cached.mobiles.map(String));
}
const cleaned = arr.map((i) => String(i || '').trim()).filter(Boolean);
if (!cleaned.includes('13800000000')) cleaned.push('13800000000');
return Array.from(new Set(cleaned));
2026-01-22 15:54:15 +08:00
});
2026-01-26 15:39:14 +08:00
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 = uni.getStorageSync('ykt_case_current_team') || {};
const a = account.value || {};
const d = doctorInfo.value || {};
return String(t.corpId || a.corpId || d.corpId || '') || '';
}
async function ensureDoctor() {
if (doctorInfo.value) return;
if (!account.value?.openid) return;
try {
await getDoctorInfo();
} catch {
// ignore
}
}
function resolveUserName(userId) {
const id = String(userId || '');
if (!id) return '';
const map = userNameMap.value || {};
return String(map[id] || '') || id;
}
async function loadUserNameMap(teamId) {
if (!teamId) return;
const corpId = getCorpId();
if (!corpId) return;
const res = await api('getTeamData', { corpId, teamId });
if (!res?.success) return;
const t = res?.data && typeof res.data === 'object' ? res.data : {};
const list = Array.isArray(t.memberList) ? t.memberList : [];
const map = list.reduce((acc, cur) => {
const id = cur?.userid ? String(cur.userid) : '';
if (!id) return acc;
acc[id] = String(cur?.anotherName || cur?.name || cur?.userid || '');
return acc;
}, {});
userNameMap.value = { ...(userNameMap.value || {}), ...map };
}
const isSelf = computed(() => {
const id = getUserId();
return Boolean(id && todo.value && String(todo.value.executorUserId || '') === id);
});
const editable = computed(() => isSelf.value && ['notStart', 'processing'].includes(currentStatus.value.value));
const canEditResult = computed(() => isSelf.value && ['treated', 'cancelled'].includes(currentStatus.value.value));
const canRemove = computed(() => {
const t = todo.value;
if (!t) return false;
const userId = getUserId();
return String(t.creatorUserId || '') === userId;
});
const executorDisplay = computed(() => {
const t = todo.value || {};
const name = resolveUserName(t.executorUserId);
const teamName = String(t.executeTeamName || '');
return teamName ? `${name}${teamName}` : name;
});
const creatorDisplay = computed(() => resolveUserName(todo.value?.creatorUserId));
const customerDisplay = computed(() => {
const t = todo.value || {};
const c = customer.value || {};
return String(t.customerName || c.name || '');
});
2026-01-22 15:54:15 +08:00
onLoad((options) => {
archiveId.value = options?.archiveId ? String(options.archiveId) : '';
mode.value = options?.mode ? String(options.mode) : 'add';
taskId.value = options?.id ? String(options.id) : '';
2026-01-26 15:39:14 +08:00
const cached = uni.getStorageSync('ykt_case_archive_detail');
if (cached && typeof cached === 'object') customer.value = cached;
2026-01-22 15:54:15 +08:00
2026-01-26 15:39:14 +08:00
if (!taskId.value) {
toast('缺少回访任务 id');
setTimeout(() => uni.navigateBack(), 300);
return;
}
getTodo();
2026-01-22 15:54:15 +08:00
});
2026-01-26 15:39:14 +08:00
function parseTodoMethod(value) {
if (typeof value !== 'string') return { todoMethod: '', phone: '' };
if (value.startsWith('phone')) {
const [, num] = value.split(':');
return { todoMethod: 'phone', phone: num || '' };
}
if (value === 'wechat') return { todoMethod: 'wechat', phone: '' };
return { todoMethod: '', phone: '' };
2026-01-22 15:54:15 +08:00
}
function cancel() {
uni.navigateBack();
}
2026-01-26 15:39:14 +08:00
function buildTodoMethodValue() {
if (todoMethod.value === 'phone') return phone.value ? `phone:${phone.value}` : 'phone';
if (todoMethod.value === 'wechat') return 'wechat';
return '';
}
async function getTodo() {
await ensureDoctor();
const corpId = getCorpId();
if (!corpId) {
toast('缺少 corpId请先完成登录/团队选择');
2026-01-22 15:54:15 +08:00
return;
}
2026-01-26 15:39:14 +08:00
const res = await api('getTodoById', { corpId, id: taskId.value });
if (!res?.success) {
toast(res?.message || '获取回访任务失败');
todo.value = null;
return;
}
todo.value = res.data && typeof res.data === 'object' ? res.data : null;
await loadUserNameMap(String(todo.value?.executeTeamId || ''));
const parsed = parseTodoMethod(todo.value?.todoMethod);
if (parsed.todoMethod) {
todoMethod.value = parsed.todoMethod;
phone.value = parsed.phone || '';
} else if (editable.value) {
todoMethod.value = 'phone';
phone.value = mobiles.value[0] || '';
}
form.executorName = resolveUserName(todo.value?.executorUserId);
form.taskContent = String(todo.value?.taskContent || '') || '';
form.result = String(todo.value?.result || '') || '';
2026-01-22 15:54:15 +08:00
}
function toggleMethod(v) {
2026-01-26 15:39:14 +08:00
if (!editable.value) return;
2026-01-22 15:54:15 +08:00
todoMethod.value = v;
if (v !== 'phone') phone.value = '';
}
function pickPhone() {
2026-01-26 15:39:14 +08:00
if (!editable.value) return;
if (todoMethod.value !== 'phone') return;
2026-01-22 15:54:15 +08:00
uni.showActionSheet({
itemList: mobiles.value,
success: ({ tapIndex }) => {
phone.value = mobiles.value[tapIndex] || '';
todoMethod.value = 'phone';
},
});
}
function markDone() {
2026-01-26 15:39:14 +08:00
if (!editable.value) return;
2026-01-22 15:54:15 +08:00
if (!['phone', 'wechat'].includes(todoMethod.value)) return uni.showToast({ title: '请选择回访方式', icon: 'none' });
uni.showModal({
title: '提示',
content: '确定完成该回访任务吗?',
2026-01-26 15:39:14 +08:00
success: async (res) => {
2026-01-22 15:54:15 +08:00
if (!res.confirm) return;
2026-01-26 15:39:14 +08:00
await ensureDoctor();
const corpId = getCorpId();
const userId = getUserId();
const methodValue = buildTodoMethodValue();
const result = String(form.result || '').trim() || '已完成';
const r = await api('setTodoStatus', { corpId, id: taskId.value, eventStatus: 'treated', result, userId, todoMethod: methodValue });
if (!r?.success) return toast(r?.message || '操作失败');
toast('操作成功');
uni.$emit('archive-detail:followup-changed');
setTimeout(() => uni.navigateBack(), 300);
2026-01-22 15:54:15 +08:00
},
});
}
function cancelTask() {
2026-01-26 15:39:14 +08:00
if (!editable.value) return;
2026-01-22 15:54:15 +08:00
uni.showModal({
title: '提示',
content: '确定取消该回访任务吗?',
2026-01-26 15:39:14 +08:00
success: async (res) => {
2026-01-22 15:54:15 +08:00
if (!res.confirm) return;
2026-01-26 15:39:14 +08:00
await ensureDoctor();
const corpId = getCorpId();
const userId = getUserId();
const methodValue = buildTodoMethodValue();
const result = String(form.result || '').trim() || '已取消';
const r = await api('setTodoStatus', { corpId, id: taskId.value, eventStatus: 'closed', result, userId, todoMethod: methodValue });
if (!r?.success) return toast(r?.message || '操作失败');
toast('取消成功');
uni.$emit('archive-detail:followup-changed');
setTimeout(() => uni.navigateBack(), 300);
2026-01-22 15:54:15 +08:00
},
});
}
2026-01-26 15:39:14 +08:00
async function save() {
if (!canEditResult.value) return;
if (String(form.result || '').trim() === '') return toast('请填写回访结果');
await ensureDoctor();
const corpId = getCorpId();
if (!corpId) return toast('缺少 corpId');
const methodValue = buildTodoMethodValue();
const res = await api('updateTaskTodoResult', { corpId, id: taskId.value, result: String(form.result || ''), todoMethod: methodValue });
if (!res?.success) return toast(res?.message || '操作失败');
toast('操作成功');
uni.$emit('archive-detail:followup-changed');
setTimeout(() => uni.navigateBack(), 300);
}
2026-01-22 15:54:15 +08:00
function remove() {
2026-01-26 15:39:14 +08:00
if (!canRemove.value) return;
2026-01-22 15:54:15 +08:00
uni.showModal({
title: '提示',
content: '确定删除当前记录?',
2026-01-26 15:39:14 +08:00
success: async (res) => {
2026-01-22 15:54:15 +08:00
if (!res.confirm) return;
2026-01-26 15:39:14 +08:00
await ensureDoctor();
const corpId = getCorpId();
const userId = getUserId();
const r = await api('removeTodo', { corpId, userId, id: taskId.value });
if (!r?.success) return toast(r?.message || '删除失败');
2026-01-22 15:54:15 +08:00
uni.$emit('archive-detail:followup-changed');
2026-01-26 15:39:14 +08:00
toast('已删除');
2026-01-22 15:54:15 +08:00
setTimeout(() => uni.navigateBack(), 300);
},
});
}
</script>
<style scoped>
.page {
2026-01-26 15:39:14 +08:00
height: 100vh;
background: #f6f7f8;
}
.scroll {
height: 100vh;
2026-01-22 15:54:15 +08:00
}
2026-01-26 15:39:14 +08:00
.section {
2026-01-22 15:54:15 +08:00
background: #fff;
2026-01-26 15:39:14 +08:00
padding: 24rpx 30rpx;
}
.border-top {
border-top: 1px solid #e5e7eb;
2026-01-22 15:54:15 +08:00
}
2026-01-26 15:39:14 +08:00
.head-row {
2026-01-22 15:54:15 +08:00
display: flex;
align-items: center;
justify-content: space-between;
}
2026-01-26 15:39:14 +08:00
.head-title {
font-size: 32rpx;
2026-01-22 15:54:15 +08:00
font-weight: 600;
2026-01-26 15:39:14 +08:00
color: #111827;
2026-01-22 15:54:15 +08:00
}
2026-01-26 15:39:14 +08:00
.head-tag {
flex-shrink: 0;
font-size: 24rpx;
padding: 12rpx 20rpx;
border-radius: 16rpx;
2026-01-22 15:54:15 +08:00
background: #f3f4f6;
2026-01-26 15:39:14 +08:00
color: #6b7280;
2026-01-22 15:54:15 +08:00
}
2026-01-26 15:39:14 +08:00
.tag-processing {
background: #fee2e2;
color: #ef4444;
2026-01-22 15:54:15 +08:00
}
2026-01-26 15:39:14 +08:00
.tag-notStart {
background: #dbeafe;
color: #2563eb;
2026-01-22 15:54:15 +08:00
}
2026-01-26 15:39:14 +08:00
.tag-treated {
2026-01-22 15:54:15 +08:00
background: #dcfce7;
color: #16a34a;
}
2026-01-26 15:39:14 +08:00
.tag-cancelled,
.tag-expired {
2026-01-22 15:54:15 +08:00
background: #f3f4f6;
2026-01-26 15:39:14 +08:00
color: #6b7280;
2026-01-22 15:54:15 +08:00
}
2026-01-26 15:39:14 +08:00
.head-content {
margin-top: 24rpx;
font-size: 28rpx;
color: #6b7280;
line-height: 44rpx;
2026-01-22 15:54:15 +08:00
}
2026-01-26 15:39:14 +08:00
.sub-label {
font-size: 28rpx;
color: #6b7280;
}
.sub-content {
margin-top: 24rpx;
font-size: 28rpx;
color: #6b7280;
line-height: 44rpx;
2026-01-22 15:54:15 +08:00
}
2026-01-26 15:39:14 +08:00
.file-line {
margin-top: 12rpx;
font-size: 28rpx;
line-height: 40rpx;
}
.file-type {
color: #111827;
margin-right: 12rpx;
}
.file-name {
color: #2563eb;
2026-01-22 15:54:15 +08:00
}
.section-title {
2026-01-26 15:39:14 +08:00
font-size: 28rpx;
2026-01-22 15:54:15 +08:00
font-weight: 600;
2026-01-26 15:39:14 +08:00
color: #111827;
2026-01-22 15:54:15 +08:00
}
.method-row {
display: flex;
align-items: center;
2026-01-26 15:39:14 +08:00
margin-top: 24rpx;
2026-01-22 15:54:15 +08:00
}
.radio {
2026-01-26 15:39:14 +08:00
width: 32rpx;
height: 32rpx;
margin-right: 20rpx;
2026-01-22 15:54:15 +08:00
}
.method-label {
2026-01-26 15:39:14 +08:00
font-size: 28rpx;
color: #111827;
margin-right: 20rpx;
2026-01-22 15:54:15 +08:00
}
2026-01-26 15:39:14 +08:00
.method-select {
2026-01-22 15:54:15 +08:00
margin-left: auto;
2026-01-26 15:39:14 +08:00
width: 400rpx;
height: 60rpx;
border: 1px solid #e5e7eb;
border-radius: 16rpx;
padding: 12rpx 20rpx;
2026-01-22 15:54:15 +08:00
display: flex;
align-items: center;
justify-content: space-between;
}
.method-value {
2026-01-26 15:39:14 +08:00
font-size: 28rpx;
color: #111827;
max-width: 320rpx;
2026-01-22 15:54:15 +08:00
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.method-value.muted {
2026-01-26 15:39:14 +08:00
color: #9ca3af;
}
.kv {
display: flex;
align-items: center;
justify-content: space-between;
}
.kv-left {
font-size: 28rpx;
color: #6b7280;
}
.kv-right {
font-size: 28rpx;
color: #111827;
max-width: 70%;
text-align: right;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.kv-right.link {
color: #2563eb;
text-decoration: underline;
2026-01-22 15:54:15 +08:00
}
2026-01-26 15:39:14 +08:00
.mt-20 {
margin-top: 20rpx;
}
.result-box {
margin-top: 24rpx;
padding: 20rpx;
border: 1px solid #e5e7eb;
border-radius: 16rpx;
2026-01-22 15:54:15 +08:00
}
2026-01-26 15:39:14 +08:00
.result-textarea {
2026-01-22 15:54:15 +08:00
width: 100%;
2026-01-26 15:39:14 +08:00
min-height: 160rpx;
font-size: 28rpx;
line-height: 48rpx;
color: #111827;
2026-01-22 15:54:15 +08:00
box-sizing: border-box;
}
2026-01-26 15:39:14 +08:00
.meta {
font-size: 28rpx;
2026-01-22 15:54:15 +08:00
}
2026-01-26 15:39:14 +08:00
.meta-row {
margin-top: 24rpx;
}
.meta-row:first-child {
margin-top: 0;
}
.meta-key {
color: #6b7280;
margin-right: 12rpx;
}
.meta-val {
color: #6b7280;
2026-01-22 15:54:15 +08:00
}
.footer {
position: fixed;
left: 0;
right: 0;
bottom: 0;
background: #fff;
display: flex;
2026-01-26 15:39:14 +08:00
justify-content: flex-end;
gap: 20rpx;
padding: 30rpx;
box-shadow: 0 -8rpx 24rpx rgba(0, 0, 0, 0.06);
}
.footer-btn {
flex: 0 0 auto;
min-width: 260rpx;
height: 80rpx;
line-height: 80rpx;
border-radius: 16rpx;
font-size: 28rpx;
}
.footer-btn::after {
2026-01-22 15:54:15 +08:00
border: none;
}
2026-01-26 15:39:14 +08:00
.footer-btn.plain {
2026-01-22 15:54:15 +08:00
background: #fff;
2026-01-26 15:39:14 +08:00
color: #2563eb;
border: 1px solid #2563eb;
2026-01-22 15:54:15 +08:00
}
2026-01-26 15:39:14 +08:00
.footer-btn.plain.danger {
color: #ef4444;
border-color: #ef4444;
2026-01-22 15:54:15 +08:00
}
2026-01-26 15:39:14 +08:00
.footer-btn.primary {
background: #2563eb;
2026-01-22 15:54:15 +08:00
color: #fff;
}
.delete-fab {
position: fixed;
2026-01-26 15:39:14 +08:00
right: 32rpx;
bottom: calc(200rpx + env(safe-area-inset-bottom));
width: 104rpx;
height: 104rpx;
border-radius: 52rpx;
2026-01-22 15:54:15 +08:00
background: #fff;
display: flex;
align-items: center;
justify-content: center;
2026-01-26 15:39:14 +08:00
box-shadow: 0 10rpx 18rpx rgba(0, 0, 0, 0.12);
2026-01-22 15:54:15 +08:00
z-index: 30;
}
</style>