ykt-wxapp/pages/case/followup-detail.vue
2026-01-26 15:39:14 +08:00

666 lines
18 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 class="page">
<!-- 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>
</view>
<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>
</view>
<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>
</view>
</view>
<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>
</view>
<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>
</view>
<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>
</view>
<view v-if="taskId && canRemove" class="delete-fab" @click="remove">
<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';
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';
const archiveId = ref('');
const mode = ref('add');
const taskId = ref('');
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;
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');
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));
});
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 || '');
});
onLoad((options) => {
archiveId.value = options?.archiveId ? String(options.archiveId) : '';
mode.value = options?.mode ? String(options.mode) : 'add';
taskId.value = options?.id ? String(options.id) : '';
const cached = uni.getStorageSync('ykt_case_archive_detail');
if (cached && typeof cached === 'object') customer.value = cached;
if (!taskId.value) {
toast('缺少回访任务 id');
setTimeout(() => uni.navigateBack(), 300);
return;
}
getTodo();
});
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: '' };
}
function cancel() {
uni.navigateBack();
}
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请先完成登录/团队选择');
return;
}
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 || '') || '';
}
function toggleMethod(v) {
if (!editable.value) return;
todoMethod.value = v;
if (v !== 'phone') phone.value = '';
}
function pickPhone() {
if (!editable.value) return;
if (todoMethod.value !== 'phone') return;
uni.showActionSheet({
itemList: mobiles.value,
success: ({ tapIndex }) => {
phone.value = mobiles.value[tapIndex] || '';
todoMethod.value = 'phone';
},
});
}
function markDone() {
if (!editable.value) return;
if (!['phone', 'wechat'].includes(todoMethod.value)) return uni.showToast({ title: '请选择回访方式', icon: 'none' });
uni.showModal({
title: '提示',
content: '确定完成该回访任务吗?',
success: async (res) => {
if (!res.confirm) return;
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);
},
});
}
function cancelTask() {
if (!editable.value) return;
uni.showModal({
title: '提示',
content: '确定取消该回访任务吗?',
success: async (res) => {
if (!res.confirm) return;
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);
},
});
}
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);
}
function remove() {
if (!canRemove.value) return;
uni.showModal({
title: '提示',
content: '确定删除当前记录?',
success: async (res) => {
if (!res.confirm) return;
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 || '删除失败');
uni.$emit('archive-detail:followup-changed');
toast('已删除');
setTimeout(() => uni.navigateBack(), 300);
},
});
}
</script>
<style scoped>
.page {
height: 100vh;
background: #f6f7f8;
}
.scroll {
height: 100vh;
}
.section {
background: #fff;
padding: 24rpx 30rpx;
}
.border-top {
border-top: 1px solid #e5e7eb;
}
.head-row {
display: flex;
align-items: center;
justify-content: space-between;
}
.head-title {
font-size: 32rpx;
font-weight: 600;
color: #111827;
}
.head-tag {
flex-shrink: 0;
font-size: 24rpx;
padding: 12rpx 20rpx;
border-radius: 16rpx;
background: #f3f4f6;
color: #6b7280;
}
.tag-processing {
background: #fee2e2;
color: #ef4444;
}
.tag-notStart {
background: #dbeafe;
color: #2563eb;
}
.tag-treated {
background: #dcfce7;
color: #16a34a;
}
.tag-cancelled,
.tag-expired {
background: #f3f4f6;
color: #6b7280;
}
.head-content {
margin-top: 24rpx;
font-size: 28rpx;
color: #6b7280;
line-height: 44rpx;
}
.sub-label {
font-size: 28rpx;
color: #6b7280;
}
.sub-content {
margin-top: 24rpx;
font-size: 28rpx;
color: #6b7280;
line-height: 44rpx;
}
.file-line {
margin-top: 12rpx;
font-size: 28rpx;
line-height: 40rpx;
}
.file-type {
color: #111827;
margin-right: 12rpx;
}
.file-name {
color: #2563eb;
}
.section-title {
font-size: 28rpx;
font-weight: 600;
color: #111827;
}
.method-row {
display: flex;
align-items: center;
margin-top: 24rpx;
}
.radio {
width: 32rpx;
height: 32rpx;
margin-right: 20rpx;
}
.method-label {
font-size: 28rpx;
color: #111827;
margin-right: 20rpx;
}
.method-select {
margin-left: auto;
width: 400rpx;
height: 60rpx;
border: 1px solid #e5e7eb;
border-radius: 16rpx;
padding: 12rpx 20rpx;
display: flex;
align-items: center;
justify-content: space-between;
}
.method-value {
font-size: 28rpx;
color: #111827;
max-width: 320rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.method-value.muted {
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;
}
.mt-20 {
margin-top: 20rpx;
}
.result-box {
margin-top: 24rpx;
padding: 20rpx;
border: 1px solid #e5e7eb;
border-radius: 16rpx;
}
.result-textarea {
width: 100%;
min-height: 160rpx;
font-size: 28rpx;
line-height: 48rpx;
color: #111827;
box-sizing: border-box;
}
.meta {
font-size: 28rpx;
}
.meta-row {
margin-top: 24rpx;
}
.meta-row:first-child {
margin-top: 0;
}
.meta-key {
color: #6b7280;
margin-right: 12rpx;
}
.meta-val {
color: #6b7280;
}
.footer {
position: fixed;
left: 0;
right: 0;
bottom: 0;
background: #fff;
display: flex;
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 {
border: none;
}
.footer-btn.plain {
background: #fff;
color: #2563eb;
border: 1px solid #2563eb;
}
.footer-btn.plain.danger {
color: #ef4444;
border-color: #ef4444;
}
.footer-btn.primary {
background: #2563eb;
color: #fff;
}
.delete-fab {
position: fixed;
right: 32rpx;
bottom: calc(200rpx + env(safe-area-inset-bottom));
width: 104rpx;
height: 104rpx;
border-radius: 52rpx;
background: #fff;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 10rpx 18rpx rgba(0, 0, 0, 0.12);
z-index: 30;
}
</style>