feat:回访接口接入

This commit is contained in:
Jafeng 2026-01-26 15:39:14 +08:00
parent da7397ce7e
commit 70d0f5e496
15 changed files with 2061 additions and 456 deletions

View File

@ -67,6 +67,21 @@
</view>
</view>
<scroll-view scroll-y class="popup-body">
<view class="section">
<view class="section-title">任务状态</view>
<view class="chip-wrap">
<view
v-for="t in statusTabs"
:key="t.value"
class="chip"
:class="{ active: query.status === t.value }"
@click="query.status = t.value"
>
{{ t.label }}
</view>
</view>
</view>
<view class="section">
<view class="section-title">任务类型</view>
<view class="chip-wrap">
@ -119,7 +134,12 @@
<script setup>
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue';
import { ensureSeed, queryFollowups } from './mock';
import { storeToRefs } from 'pinia';
import dayjs from 'dayjs';
import api from '@/utils/api';
import useAccountStore from '@/store/account';
import { toast } from '@/utils/widget';
import { getTodoEventTypeLabel, getTodoEventTypeOptions } from '@/utils/todo-const';
const props = defineProps({
data: { type: Object, default: () => ({}) },
@ -128,6 +148,10 @@ const props = defineProps({
floatingBottom: { type: Number, default: 16 },
});
const accountStore = useAccountStore();
const { account, doctorInfo } = storeToRefs(accountStore);
const { getDoctorInfo } = accountStore;
const statusTabs = [
{ label: '全部', value: 'all' },
{ label: '待处理', value: 'processing' },
@ -137,18 +161,9 @@ const statusTabs = [
{ label: '已过期', value: 'expired' },
];
const typeOptions = [
{ label: '回访', value: 'followup' },
{ label: '复诊提醒', value: 'revisit' },
{ label: '问卷', value: 'questionnaire' },
{ label: '其他', value: 'other' },
];
const typeOptions = [{ label: '全部', value: 'all' }, ...getTodoEventTypeOptions()];
const teamOptions = [
{ label: '全部', value: 'ALL' },
{ label: '口腔一科(示例)', value: 'team_1' },
{ label: '正畸团队(示例)', value: 'team_2' },
];
const teamOptions = ref([{ label: '全部', value: 'ALL' }]);
const query = reactive({
isMy: false,
@ -166,6 +181,8 @@ const pageSize = 10;
const pages = ref(1);
const loading = ref(false);
const userNameMap = ref({});
const moreStatus = computed(() => {
if (loading.value) return 'loading';
return page.value <= pages.value ? 'more' : 'no-more';
@ -179,9 +196,94 @@ const loadMoreText = {
const filtered = ref(false);
const typeSelectedMap = computed(() => {
const s = new Set(query.eventTypes || []);
return typeOptions.reduce((acc, cur) => ((acc[cur.value] = s.has(cur.value)), acc), {});
return typeOptions.reduce((acc, cur) => {
if (cur.value === 'all') acc[cur.value] = s.size === 0;
else acc[cur.value] = s.has(cur.value);
return acc;
}, {});
});
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 statusLabelFromStatus(status) {
const map = {
processing: '待处理',
notStart: '未开始',
treated: '已完成',
cancelled: '已取消',
expired: '已过期',
};
return map[status] || '未知';
}
function getStatus(todo) {
const endOfToday = dayjs().endOf('day').valueOf();
const startOfToday = dayjs().startOf('day').valueOf();
const plannedExecutionTime = Number(todo?.plannedExecutionTime || 0) || 0;
const expireTime = Number(todo?.expireTime || 0) || 0;
const eventStatus = String(todo?.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';
}
function eventTypeLabel(eventType) {
return getTodoEventTypeLabel(eventType);
}
function resolveUserName(userId) {
const id = String(userId || '');
if (!id) return '';
const map = userNameMap.value || {};
return String(map[id] || '') || id;
}
function formatTodo(todo) {
const status = getStatus(todo);
const plannedExecutionTime = todo?.plannedExecutionTime;
const createTime = todo?.createTime;
return {
...todo,
status,
eventStatusLabel: statusLabelFromStatus(status),
eventTypeLabel: eventTypeLabel(todo?.eventType),
planDate: plannedExecutionTime && dayjs(plannedExecutionTime).isValid() ? dayjs(plannedExecutionTime).format('YYYY-MM-DD') : '',
createTimeStr: createTime && dayjs(createTime).isValid() ? dayjs(createTime).format('YYYY-MM-DD HH:mm') : '',
executorName: resolveUserName(todo?.executorUserId),
creatorName: resolveUserName(todo?.creatorUserId),
};
}
function resetList() {
page.value = 1;
pages.value = 1;
@ -189,26 +291,47 @@ function resetList() {
getMore();
}
function getMore() {
async function getMore() {
if (!props.archiveId) return;
if (loading.value) return;
if (page.value > pages.value) return;
loading.value = true;
try {
const { list: arr, total: t, pages: p } = queryFollowups({
archiveId: props.archiveId,
await ensureDoctor();
const corpId = getCorpId();
const userId = getUserId();
if (!corpId) {
toast('缺少 corpId请先完成登录/团队选择');
return;
}
const params = {
corpId,
customerId: String(props.archiveId),
page: page.value,
pageSize,
status: query.status,
isMy: query.isMy,
eventTypes: query.eventTypes,
teamId: query.teamId,
planRange: query.planRange,
});
total.value = t;
pages.value = p;
list.value = page.value === 1 ? arr : [...list.value, ...arr];
};
if (query.status !== 'all') params.statusList = [query.status];
if (query.isMy) params.executorUserId = userId;
if (Array.isArray(query.eventTypes) && query.eventTypes.length) params.eventType = query.eventTypes;
if (query.teamId && query.teamId !== 'ALL') params.teamId = query.teamId;
if (query.planRange?.[0]) params.startDate = query.planRange[0];
if (query.planRange?.[1]) params.endDate = query.planRange[1];
const res = await api('getCustomerTodos', params);
if (!res?.success) {
toast(res?.message || '获取回访任务失败');
return;
}
const arr = Array.isArray(res.data) ? res.data : [];
const next = arr.map(formatTodo);
total.value = typeof res.total === 'number' ? res.total : 0;
pages.value = Math.ceil(total.value / pageSize) || 0;
list.value = page.value === 1 ? next : [...list.value, ...next];
page.value += 1;
} finally {
loading.value = false;
@ -224,6 +347,26 @@ function toggleStatus(v) {
resetList();
}
async function loadTeams() {
await ensureDoctor();
const corpId = getCorpId();
const userId = getUserId();
if (!corpId || !userId) return;
const res = await api('getTeamBymember', { corpId, corpUserId: userId });
if (!res?.success) return;
const list = Array.isArray(res?.data) ? res.data : Array.isArray(res?.data?.data) ? res.data.data : [];
const normalized = list
.map((raw) => {
if (!raw || typeof raw !== 'object') return null;
const teamId = raw.teamId || raw.id || raw._id || '';
const name = raw.name || raw.teamName || raw.team || '';
if (!teamId || !name) return null;
return { label: String(name), value: String(teamId) };
})
.filter(Boolean);
teamOptions.value = [{ label: '全部', value: 'ALL' }, ...normalized];
}
function add() {
uni.showActionSheet({
itemList: ['+新增任务', '+使用模板', '+回访记录'],
@ -250,7 +393,7 @@ function toDetail(todo) {
// ---- filter popup ----
const filterPopupRef = ref(null);
const state = ref(null);
const teamPicked = ref(teamOptions[0]);
const teamPicked = ref(teamOptions.value[0]);
const planRange = ref(['', '']);
function openFilter() {
@ -260,7 +403,7 @@ function openFilter() {
range: [...planRange.value],
};
planRange.value = [...(query.planRange || ['', ''])];
teamPicked.value = teamOptions.find((i) => i.value === query.teamId) || teamOptions[0];
teamPicked.value = teamOptions.value.find((i) => i.value === query.teamId) || teamOptions.value[0];
filterPopupRef.value?.open?.();
}
function closeFilter(revert) {
@ -273,6 +416,10 @@ function closeFilter(revert) {
}
function toggleType(v) {
if (v === 'all') {
query.eventTypes = [];
return;
}
const set = new Set(query.eventTypes || []);
if (set.has(v)) set.delete(v);
else set.add(v);
@ -280,7 +427,7 @@ function toggleType(v) {
}
function pickTeam(e) {
teamPicked.value = teamOptions[e.detail.value] || teamOptions[0];
teamPicked.value = teamOptions.value[e.detail.value] || teamOptions.value[0];
query.teamId = teamPicked.value.value;
}
@ -300,7 +447,7 @@ function clearPlanRange() {
function resetFilter() {
query.eventTypes = [];
query.teamId = 'ALL';
teamPicked.value = teamOptions[0];
teamPicked.value = teamOptions.value[0];
clearPlanRange();
}
@ -311,7 +458,10 @@ function confirmFilter() {
}
onMounted(() => {
ensureSeed(props.archiveId, props.data);
const userId = getUserId();
const name = String(doctorInfo.value?.anotherName || doctorInfo.value?.name || '');
if (userId && name) userNameMap.value = { ...(userNameMap.value || {}), [userId]: name };
loadTeams();
resetList();
uni.$on('archive-detail:followup-changed', resetList);
});

View File

@ -0,0 +1,54 @@
<template>
<view class="node" :class="{ last }">
<view class="node-index">{{ index }}</view>
<view v-if="!last" class="node-line" />
<view class="node-body">
<slot />
</view>
</view>
</template>
<script setup>
defineProps({
index: { type: Number, default: 1 },
last: { type: Boolean, default: false },
});
</script>
<style scoped>
.node {
position: relative;
padding-left: 60rpx;
margin-right: 30rpx;
}
.node-index {
position: absolute;
top: 20rpx;
left: 30rpx;
width: 40rpx;
height: 40rpx;
line-height: 40rpx;
transform: translateX(-50%);
border-radius: 50%;
background: #4f6ef7;
color: #fff;
font-size: 24rpx;
text-align: center;
z-index: 3;
}
.node-line {
position: absolute;
top: 20rpx;
left: 30rpx;
width: 2rpx;
height: 100%;
transform: translateX(-50%);
background: #4f6ef7;
z-index: 2;
}
.node-body {
position: relative;
z-index: 4;
}
</style>

View File

@ -0,0 +1,113 @@
<template>
<view class="card" :class="classnames">
<view class="block">
<view class="label">待办类型</view>
<view class="value">{{ getTodoEventTypeLabel(item?.eventType) }}</view>
</view>
<view class="block">
<view class="label">任务内容</view>
<view
v-if="item?.taskContent"
class="value"
:class="{ clamp2: foldContent }"
@click="foldContent = !foldContent"
>
{{ item.taskContent }}
</view>
<view v-else class="value muted">暂无内容</view>
</view>
<view class="block">
<view class="label">向客户发送</view>
<view
v-if="item?.sendContent"
class="send"
:class="{ clamp1: foldSendContent }"
@click="foldSendContent = !foldSendContent"
>
<text class="muted">提醒</text>
<text>{{ item.sendContent }}</text>
</view>
<view v-if="sendFile" class="send">
<text class="muted">{{ sendFile.fileLabel }}</text>
<text class="link">{{ sendFile.name }}</text>
</view>
<view v-if="!item?.sendContent && !sendFile" class="send muted">暂无发送内容</view>
</view>
</view>
</template>
<script setup>
import { computed, ref } from 'vue';
import { getTodoEventTypeLabel } from '@/utils/todo-const';
const props = defineProps({
item: { type: Object, default: () => ({}) },
classnames: { type: String, default: '' },
});
const foldContent = ref(true);
const foldSendContent = ref(true);
const sendFile = computed(() => {
const file = props.item?.pannedEventSendFile;
if (!file || typeof file !== 'object') return null;
if (file.type === 'questionnaire' || file.type === 'article') {
return {
...file,
fileLabel: file.type === 'questionnaire' ? '问卷' : '文章',
name: String(file.name || file.title || file.url || ''),
};
}
return null;
});
</script>
<style scoped>
.card {
font-size: 28rpx;
background: #fff;
border-radius: 16rpx;
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.06);
}
.block {
padding: 20rpx;
border-bottom: 1px solid #eef0f2;
}
.block:last-child {
border-bottom: none;
}
.label {
margin-bottom: 20rpx;
color: #6b7280;
}
.value {
color: #111827;
line-height: 44rpx;
word-break: break-all;
}
.send {
margin-top: 20rpx;
color: #111827;
line-height: 44rpx;
word-break: break-all;
}
.muted {
color: #9ca3af;
}
.link {
color: #4f6ef7;
}
.clamp2 {
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.clamp1 {
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
}
</style>

View File

@ -0,0 +1,128 @@
<template>
<view>
<node-list v-for="(g, idx) in taskShowList" :key="g.id" :index="idx + 1" :last="idx === taskShowList.length - 1">
<view class="group-head">
<view class="group-title">{{ g.title }}</view>
<view class="group-toggle" @click.stop="toggle(g.id)">
<view class="toggle-text">{{ shrinkMap[g.id] ? '展开' : '收起' }}</view>
<uni-icons class="toggle-icon" size="12" :type="shrinkMap[g.id] ? 'arrowup' : 'arrowdown'" color="#6b7280" />
</view>
</view>
<view v-if="!shrinkMap[g.id]" class="group-body">
<plan-node-item v-for="(t, tIdx) in g.list" :key="String(t.taskId || tIdx)" :item="t" :classnames="tIdx ? 'mt-20' : ''" />
</view>
</node-list>
</view>
</template>
<script setup>
import { computed, ref } from 'vue';
import dayjs from 'dayjs';
import nodeList from './node-list.vue';
import planNodeItem from './plan-node-item.vue';
const props = defineProps({
taskList: { type: Array, default: () => [] },
planExecutionTime: { type: String, default: '' },
});
const shrinkMap = ref({});
function toggle(id) {
shrinkMap.value[id] = !shrinkMap.value[id];
}
function timeTypeTransform(timeType) {
if (!timeType || timeType === 'day') return '天';
if (timeType === 'week') return '周';
if (timeType === 'month') return '月';
if (timeType === 'year') return '年';
return String(timeType);
}
function normalizedDays(taskTime, timeType) {
const t = typeof taskTime === 'number' ? taskTime : 0;
if (timeType === 'week') return t * 7;
if (timeType === 'month') return t * 30;
if (timeType === 'year') return t * 365;
return t;
}
function dayTransform(baseDate, taskTime, timeType) {
if (!baseDate) return '';
if (!dayjs(baseDate).isValid()) return '';
const t = typeof taskTime === 'number' ? taskTime : 0;
const unit = ['day', 'week', 'month', 'year'].includes(String(timeType || '')) ? String(timeType) : 'day';
return dayjs(baseDate).add(t, unit).format('YYYY-MM-DD');
}
const taskShowList = computed(() => {
if (!Array.isArray(props.taskList) || props.taskList.length === 0) return [];
const grouped = props.taskList.reduce((acc, task) => {
const obj = task && typeof task === 'object' ? task : {};
const timeKey = normalizedDays(obj.taskTime, obj.timeType);
const key = String(timeKey);
if (!acc[key]) acc[key] = [];
acc[key].push(obj);
return acc;
}, {});
const baseDate = props.planExecutionTime && dayjs(props.planExecutionTime).isValid() ? props.planExecutionTime : '';
return Object.entries(grouped)
.sort((a, b) => Number(a[0]) - Number(b[0]))
.map(([key, list]) => {
const first = list[0] || {};
const taskTime = typeof first.taskTime === 'number' ? first.taskTime : 0;
const unit = timeTypeTransform(first.timeType);
const isToday = (first.timeType === 'day' || !first.timeType) && taskTime === 0;
const offsetText = isToday ? '当天' : `${taskTime}${unit}`;
const dateText = baseDate ? dayTransform(baseDate, taskTime, first.timeType) : '';
return {
id: key,
title: `计划开始后: ${offsetText}${dateText ? `${dateText}` : ''}`,
list,
};
});
});
</script>
<style scoped>
.group-head {
display: flex;
justify-content: space-between;
align-items: center;
padding: 24rpx 0 20rpx;
}
.group-title {
flex: 1;
min-width: 0;
padding-left: 20rpx;
font-size: 28rpx;
font-weight: 600;
color: #111827;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.group-toggle {
flex-shrink: 0;
display: flex;
align-items: center;
margin-left: 20rpx;
}
.toggle-text {
font-size: 28rpx;
color: #6b7280;
}
.toggle-icon {
margin-left: 10rpx;
}
.group-body {
padding-bottom: 20rpx;
}
.mt-20 {
margin-top: 20rpx;
}
</style>

View File

@ -19,12 +19,6 @@
"navigationStyle": "custom"
}
},
// {
// "path": "pages/message/index",
// "style": {
// "navigationBarTitleText": "消息"
// }
// },
{
"path": "pages/message/index",
"style": {
@ -134,6 +128,18 @@
"navigationBarTitleText": "回访计划"
}
},
{
"path": "pages/case/plan-preview",
"style": {
"navigationBarTitleText": "回访计划详情"
}
},
{
"path": "pages/case/plan-execute",
"style": {
"navigationBarTitleText": "执行回访计划"
}
},
{
"path": "pages/work/work",
"style": {

View File

@ -1,51 +1,87 @@
<template>
<view class="page">
<!-- Mobile 来源: ykt-management-mobile/src/pages/customer/followup-detail/followup-detail.vue简化移植去除微信会话/员工选择/接口 -->
<view class="card">
<view class="title-row">
<view class="title">{{ currentType.label }}</view>
<view class="tag" :class="`st-${currentStatus.value}`">{{ currentStatus.label }}</view>
<!-- 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 class="desc">{{ form.taskContent || '暂无任务内容' }}</view>
<view class="info-row">
<view class="info">计划执行时间{{ planDate || '--' }}</view>
<view class="info">执行人{{ form.executorName || '--' }}</view>
</view>
</view>
<view class="card">
<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-input" @click.stop="pickPhone">
<view class="method-value" :class="{ muted: !phone }">{{ phone || '选择号码' }}</view>
<uni-icons type="arrowright" size="16" color="#999" />
<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="method-row" @click="toggleMethod('wechat')">
<image class="radio" :src="`/static/circle${todoMethod === 'wechat' ? 'd' : ''}.svg`" />
<view class="method-label">微信</view>
<view class="method-note">mock不接入会话</view>
<view class="section border-top kv">
<view class="kv-left">计划执行时间{{ planDate || '--' }}</view>
<view class="kv-right">{{ executorDisplay || '--' }}</view>
</view>
</view>
<view class="card">
<view class="section-title">回访结果</view>
<view class="textarea-box">
<textarea v-model="form.result" class="textarea tall" placeholder="请填写回访结果" maxlength="500" />
<view class="counter">{{ (form.result || '').length }}/500</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 class="footer">
<button class="btn plain" @click="cancel">返回</button>
<button v-if="canEdit" class="btn plain danger" @click="cancelTask">取消任务</button>
<button v-if="canEdit" class="btn primary" @click="markDone">设为完成</button>
<button v-else class="btn primary" @click="save">保存</button>
</view>
<view v-if="taskId" class="delete-fab" @click="remove">
<view v-if="taskId && canRemove" class="delete-fab" @click="remove">
<uni-icons type="trash" size="22" color="#ff4d4f" />
</view>
</view>
@ -55,36 +91,90 @@
import { computed, reactive, ref } from 'vue';
import { onLoad } from '@dcloudio/uni-app';
import dayjs from 'dayjs';
import { ensureSeed, getFollowup, removeFollowup, upsertFollowup } from '@/components/archive-detail/mock';
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 typeOptions = [
{ label: '回访', value: 'followup' },
{ label: '复诊提醒', value: 'revisit' },
{ label: '问卷', value: 'questionnaire' },
{ label: '其他', value: 'other' },
];
const currentType = ref(typeOptions[0]);
const todo = ref(null);
const currentType = computed(() => ({ label: getTodoEventTypeLabel(todo.value?.eventType), value: String(todo.value?.eventType || '') }));
const customer = ref(null);
const userNameMap = ref({});
const statusOptions = [
{ label: '待处理', value: 'processing' },
{ label: '未开始', value: 'notStart' },
{ label: '已完成', value: 'treated' },
{ label: '已取消', value: 'cancelled' },
{ label: '已过期', value: 'expired' },
];
const currentStatus = ref(statusOptions[0]);
function statusLabelFromStatus(status) {
const map = {
processing: '待处理',
notStart: '未开始',
treated: '已完成',
cancelled: '已取消',
expired: '已过期',
};
return map[status] || '未知';
}
const teamOptions = [
{ label: '口腔一科(示例)', value: 'team_1' },
{ label: '正畸团队(示例)', value: 'team_2' },
];
const currentTeam = ref(teamOptions[0]);
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 planDate = ref('');
const form = reactive({
executorName: '',
taskContent: '',
@ -94,105 +184,166 @@ const form = reactive({
const todoMethod = ref('phone'); // phone | wechat
const phone = ref('');
const mobiles = computed(() => {
const m = String(form.executorName || '').trim();
// mock +
const arr = [];
const cached = uni.getStorageSync('ykt_case_archive_detail');
if (cached && typeof cached === 'object' && cached.mobile) arr.push(String(cached.mobile));
if (!arr.includes('13800000000')) arr.push('13800000000');
return arr;
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));
});
const canEdit = computed(() => ['processing', 'notStart'].includes(currentStatus.value.value));
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) : '';
ensureSeed(archiveId.value, {});
const cached = uni.getStorageSync('ykt_case_archive_detail');
if (cached && typeof cached === 'object') customer.value = cached;
if (taskId.value) {
const task = getFollowup({ archiveId: archiveId.value, id: taskId.value });
if (task) {
currentType.value = typeOptions.find((i) => i.value === task.eventType) || typeOptions[0];
currentStatus.value = statusOptions.find((i) => i.value === task.status) || statusOptions[0];
currentTeam.value = teamOptions.find((i) => i.value === task.executeTeamId) || teamOptions[0];
planDate.value = task.planDate || '';
form.executorName = task.executorName || '';
form.taskContent = task.taskContent || '';
form.result = task.result || '';
if (typeof task.todoMethod === 'string') {
if (task.todoMethod.startsWith('phone')) {
todoMethod.value = 'phone';
const parts = task.todoMethod.split(':');
phone.value = parts[1] || '';
} else if (task.todoMethod === 'wechat') {
todoMethod.value = 'wechat';
phone.value = '';
}
}
return;
}
if (!taskId.value) {
toast('缺少回访任务 id');
setTimeout(() => uni.navigateBack(), 300);
return;
}
planDate.value = dayjs().format('YYYY-MM-DD');
getTodo();
});
function pickType(e) {
currentType.value = typeOptions[e.detail.value] || typeOptions[0];
}
function pickStatus(e) {
currentStatus.value = statusOptions[e.detail.value] || statusOptions[0];
}
function pickTeam(e) {
currentTeam.value = teamOptions[e.detail.value] || teamOptions[0];
}
function pickPlanDate(e) {
planDate.value = e.detail.value || '';
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 save() {
if (!archiveId.value) {
uni.showToast({ title: '缺少 archiveId', icon: 'none' });
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 plannedExecutionTime = planDate.value ? dayjs(planDate.value).valueOf() : Date.now();
upsertFollowup({
archiveId: archiveId.value,
followup: {
_id: taskId.value || '',
plannedExecutionTime,
status: currentStatus.value.value,
eventStatusLabel: currentStatus.value.label,
eventType: currentType.value.value,
eventTypeLabel: currentType.value.label,
executeTeamId: currentTeam.value.value,
executeTeamName: currentTeam.value.label,
executorName: form.executorName || '李医生',
creatorName: '管理员A',
taskContent: form.taskContent,
result: form.result,
todoMethod: todoMethod.value === 'phone' ? (phone.value ? `phone:${phone.value}` : 'phone') : 'wechat',
},
});
uni.$emit('archive-detail:followup-changed');
uni.showToast({ title: '保存成功', icon: 'success' });
setTimeout(() => uni.navigateBack(), 300);
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 (!canEdit.value) return;
if (!editable.value) return;
todoMethod.value = v;
if (v !== 'phone') phone.value = '';
}
function pickPhone() {
if (!canEdit.value) return;
if (!editable.value) return;
if (todoMethod.value !== 'phone') return;
uni.showActionSheet({
itemList: mobiles.value,
success: ({ tapIndex }) => {
@ -203,41 +354,76 @@ function pickPhone() {
}
function markDone() {
if (!canEdit.value) return;
if (!editable.value) return;
if (!['phone', 'wechat'].includes(todoMethod.value)) return uni.showToast({ title: '请选择回访方式', icon: 'none' });
uni.showModal({
title: '提示',
content: '确定完成该回访任务吗?',
success: (res) => {
success: async (res) => {
if (!res.confirm) return;
currentStatus.value = statusOptions.find((i) => i.value === 'treated') || currentStatus.value;
save();
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 (!canEdit.value) return;
if (!editable.value) return;
uni.showModal({
title: '提示',
content: '确定取消该回访任务吗?',
success: (res) => {
success: async (res) => {
if (!res.confirm) return;
currentStatus.value = statusOptions.find((i) => i.value === 'cancelled') || currentStatus.value;
save();
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: (res) => {
success: async (res) => {
if (!res.confirm) return;
removeFollowup({ archiveId: archiveId.value, id: taskId.value });
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');
uni.showToast({ title: '已删除', icon: 'success' });
toast('已删除');
setTimeout(() => uni.navigateBack(), 300);
},
});
@ -246,136 +432,183 @@ function remove() {
<style scoped>
.page {
min-height: 100vh;
background: #f5f6f8;
padding-bottom: calc(76px + env(safe-area-inset-bottom));
height: 100vh;
background: #f6f7f8;
}
.card {
.scroll {
height: 100vh;
}
.section {
background: #fff;
margin: 10px 14px 0;
border-radius: 8px;
padding: 14px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.06);
padding: 24rpx 30rpx;
}
.border-top {
border-top: 1px solid #e5e7eb;
}
.title-row {
.head-row {
display: flex;
align-items: center;
justify-content: space-between;
}
.title {
font-size: 16px;
.head-title {
font-size: 32rpx;
font-weight: 600;
color: #333;
color: #111827;
}
.tag {
font-size: 12px;
padding: 6px 10px;
border-radius: 6px;
.head-tag {
flex-shrink: 0;
font-size: 24rpx;
padding: 12rpx 20rpx;
border-radius: 16rpx;
background: #f3f4f6;
color: #666;
color: #6b7280;
}
.st-processing {
background: #ffe5e5;
color: #ff4d4f;
.tag-processing {
background: #fee2e2;
color: #ef4444;
}
.st-notStart {
background: #dbe6ff;
color: #4f6ef7;
.tag-notStart {
background: #dbeafe;
color: #2563eb;
}
.st-treated {
.tag-treated {
background: #dcfce7;
color: #16a34a;
}
.st-cancelled,
.st-expired {
.tag-cancelled,
.tag-expired {
background: #f3f4f6;
color: #666;
color: #6b7280;
}
.desc {
margin-top: 10px;
font-size: 14px;
color: #666;
line-height: 20px;
.head-content {
margin-top: 24rpx;
font-size: 28rpx;
color: #6b7280;
line-height: 44rpx;
}
.info-row {
margin-top: 10px;
display: flex;
justify-content: space-between;
gap: 10px;
.sub-label {
font-size: 28rpx;
color: #6b7280;
}
.info {
font-size: 12px;
color: #999;
.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: 15px;
font-size: 28rpx;
font-weight: 600;
color: #333;
margin-bottom: 10px;
color: #111827;
}
.method-row {
display: flex;
align-items: center;
padding: 12px 0;
margin-top: 24rpx;
}
.radio {
width: 16px;
height: 16px;
margin-right: 10px;
width: 32rpx;
height: 32rpx;
margin-right: 20rpx;
}
.method-label {
font-size: 14px;
color: #333;
margin-right: 10px;
font-size: 28rpx;
color: #111827;
margin-right: 20rpx;
}
.method-note {
font-size: 12px;
color: #999;
}
.method-input {
.method-select {
margin-left: auto;
width: 220px;
height: 36px;
border: 1px solid #e6e6e6;
border-radius: 6px;
padding: 0 10px;
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: 13px;
color: #333;
max-width: 180px;
font-size: 28rpx;
color: #111827;
max-width: 320rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.method-value.muted {
color: #999;
color: #9ca3af;
}
.textarea-box {
border: 1px solid #e6e6e6;
border-radius: 8px;
padding: 10px;
.kv {
display: flex;
align-items: center;
justify-content: space-between;
}
.textarea {
.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%;
height: 140px;
font-size: 14px;
min-height: 160rpx;
font-size: 28rpx;
line-height: 48rpx;
color: #111827;
box-sizing: border-box;
}
.textarea.tall {
height: 140px;
.meta {
font-size: 28rpx;
}
.counter {
margin-top: 6px;
text-align: right;
font-size: 12px;
color: #999;
.meta-row {
margin-top: 24rpx;
}
.meta-row:first-child {
margin-top: 0;
}
.meta-key {
color: #6b7280;
margin-right: 12rpx;
}
.meta-val {
color: #6b7280;
}
.footer {
@ -384,47 +617,49 @@ function remove() {
right: 0;
bottom: 0;
background: #fff;
padding: 12px 14px calc(12px + env(safe-area-inset-bottom));
display: flex;
gap: 12px;
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.06);
justify-content: flex-end;
gap: 20rpx;
padding: 30rpx;
box-shadow: 0 -8rpx 24rpx rgba(0, 0, 0, 0.06);
}
.btn {
flex: 1;
height: 44px;
line-height: 44px;
border-radius: 6px;
font-size: 15px;
.footer-btn {
flex: 0 0 auto;
min-width: 260rpx;
height: 80rpx;
line-height: 80rpx;
border-radius: 16rpx;
font-size: 28rpx;
}
.btn::after {
.footer-btn::after {
border: none;
}
.btn.plain {
.footer-btn.plain {
background: #fff;
color: #4f6ef7;
border: 1px solid #4f6ef7;
color: #2563eb;
border: 1px solid #2563eb;
}
.btn.plain.danger {
color: #ff4d4f;
border-color: #ff4d4f;
.footer-btn.plain.danger {
color: #ef4444;
border-color: #ef4444;
}
.btn.primary {
background: #4f6ef7;
.footer-btn.primary {
background: #2563eb;
color: #fff;
}
.delete-fab {
position: fixed;
right: 16px;
bottom: calc(96px + env(safe-area-inset-bottom));
width: 52px;
height: 52px;
border-radius: 26px;
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 10px 18px rgba(0, 0, 0, 0.12);
box-shadow: 0 10rpx 18rpx rgba(0, 0, 0, 0.12);
z-index: 30;
}
</style>

View File

@ -57,6 +57,27 @@
<button class="btn plain" @click="cancel">取消</button>
<button class="btn primary" @click="save">保存</button>
</view>
<uni-popup ref="typePopup" type="bottom" :mask-click="true">
<view class="picker-sheet">
<view class="picker-title">选择回访类型</view>
<scroll-view scroll-y class="picker-body">
<view
v-for="t in eventTypeList"
:key="t.value"
class="picker-item"
:class="{ active: form.eventType === t.value }"
@click="pickType(t.value)"
>
<view class="picker-item-text">{{ t.label }}</view>
</view>
</scroll-view>
<view class="picker-actions">
<button class="btn plain" @click="closeTypePicker">取消</button>
<button class="btn primary" @click="closeTypePicker">确定</button>
</view>
</view>
</uni-popup>
</view>
</template>
@ -64,11 +85,22 @@
import { computed, reactive, ref } from 'vue';
import { onLoad } from '@dcloudio/uni-app';
import dayjs from 'dayjs';
import { ensureSeed, upsertFollowup } from '@/components/archive-detail/mock';
import { storeToRefs } from 'pinia';
import api from '@/utils/api';
import useAccountStore from '@/store/account';
import { toast } from '@/utils/widget';
import { getTodoEventTypeLabel, getTodoEventTypeOptions } from '@/utils/todo-const';
const archiveId = ref('');
const archiveName = ref('');
const archiveMobile = ref('');
const customerData = ref({});
const accountStore = useAccountStore();
const { account, doctorInfo } = storeToRefs(accountStore);
const { getDoctorInfo } = accountStore;
const teams = ref([]);
const form = reactive({
plannedExecutionTime: '',
@ -80,18 +112,8 @@ const form = reactive({
result: '',
});
const eventTypeList = [
{ label: '回访', value: 'followup' },
{ label: '复诊提醒', value: 'revisit' },
{ label: '问卷', value: 'questionnaire' },
{ label: '其他', value: 'other' },
];
const eventTypeLabel = computed(() => eventTypeList.find((i) => i.value === form.eventType)?.label || '');
const teamOptions = [
{ label: '口腔一科(示例)', value: 'team_1' },
{ label: '正畸团队(示例)', value: 'team_2' },
];
const eventTypeList = getTodoEventTypeOptions();
const eventTypeLabel = computed(() => getTodoEventTypeLabel(form.eventType));
const mobiles = computed(() => {
const arr = [];
@ -106,19 +128,84 @@ onLoad((options) => {
if (c && typeof c === 'object') {
archiveId.value = archiveId.value || String(c._id || '');
archiveName.value = String(c.name || '');
customerData.value = c;
}
const cached = uni.getStorageSync('ykt_case_archive_detail');
if (cached && typeof cached === 'object') archiveMobile.value = String(cached.mobile || '');
if (cached && typeof cached === 'object' && String(cached?._id || '') === archiveId.value) {
archiveMobile.value = String(cached.mobile || '');
customerData.value = cached;
archiveName.value = archiveName.value || String(cached.name || '');
}
if (!archiveId.value) {
uni.showToast({ title: '缺少 archiveId', icon: 'none' });
setTimeout(() => uni.navigateBack(), 300);
return;
}
ensureSeed(archiveId.value, { name: archiveName.value });
form.plannedExecutionTime = dayjs().format('YYYY-MM-DD');
initDefaultTeam();
});
async function ensureDoctor() {
if (doctorInfo.value) return;
if (!account.value?.openid) return;
try {
await getDoctorInfo();
} catch {
// ignore
}
}
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 || '') || '';
}
function normalizeTeam(raw) {
if (!raw || typeof raw !== 'object') return null;
const teamId = raw.teamId || raw.id || raw._id || '';
const name = raw.name || raw.teamName || raw.team || '';
const corpId = raw.corpId || raw.corpID || '';
if (!teamId || !name) return null;
return { teamId: String(teamId), name: String(name), corpId: corpId ? String(corpId) : '' };
}
async function loadTeams() {
await ensureDoctor();
const corpId = getCorpId();
const userId = getUserId();
if (!corpId || !userId) return;
const res = await api('getTeamBymember', { corpId, corpUserId: userId });
if (!res?.success) return;
const list = Array.isArray(res?.data) ? res.data : Array.isArray(res?.data?.data) ? res.data.data : [];
teams.value = list.map(normalizeTeam).filter(Boolean);
}
async function initDefaultTeam() {
await ensureDoctor();
const currentTeam = uni.getStorageSync('ykt_case_current_team') || {};
const teamId = String(currentTeam.teamId || '');
const teamName = String(currentTeam.name || '');
if (teamId) {
form.teamId = teamId;
form.teamName = teamName;
} else {
await loadTeams();
if (teams.value[0]) {
form.teamId = teams.value[0].teamId;
form.teamName = teams.value[0].name;
}
}
}
function changeDate(e) {
form.plannedExecutionTime = e.detail.value || '';
}
@ -143,23 +230,26 @@ function selectMethod(method) {
}
function selectType() {
uni.showActionSheet({
itemList: eventTypeList.map((i) => i.label),
success: ({ tapIndex }) => {
form.eventType = eventTypeList[tapIndex]?.value || '';
},
});
typePopup.value?.open?.();
}
function selectTeam() {
uni.showActionSheet({
itemList: teamOptions.map((i) => i.label),
success: ({ tapIndex }) => {
const t = teamOptions[tapIndex];
form.teamId = t.value;
form.teamName = t.label;
},
});
(async () => {
if (!teams.value.length) await loadTeams();
if (!teams.value.length) {
toast('暂无可选团队');
return;
}
uni.showActionSheet({
itemList: teams.value.map((t) => t.name),
success: ({ tapIndex }) => {
const t = teams.value[tapIndex];
if (!t) return;
form.teamId = t.teamId;
form.teamName = t.name;
},
});
})();
}
function cancel() {
@ -170,40 +260,64 @@ function cancel() {
});
}
function save() {
async function save() {
if (!form.plannedExecutionTime) return uni.showToast({ title: '请选择回访日期', icon: 'none' });
if (!form.todoMethod) return uni.showToast({ title: '请选择回访方式', icon: 'none' });
if (!form.eventType) return uni.showToast({ title: '请选择回访类型', icon: 'none' });
if (!form.teamId) return uni.showToast({ title: '请选择所在团队', icon: 'none' });
if (!String(form.result || '').trim()) return uni.showToast({ title: '请输入回访结果', icon: 'none' });
await ensureDoctor();
const corpId = getCorpId();
const userId = getUserId();
if (!corpId || !userId) {
toast('缺少用户/团队信息,请先完成登录与团队选择');
return;
}
const customer = customerData.value && typeof customerData.value === 'object' ? customerData.value : {};
const customerId = String(customer._id || archiveId.value || '');
const customerName = String(customer.name || archiveName.value || '');
const customerUserId = String(customer.externalUserId || customer.customerUserId || '') || '';
const phoneValue = form.phoneNumber ? `phone:${form.phoneNumber}` : 'phone';
const plannedExecutionTime = dayjs(form.plannedExecutionTime).valueOf();
upsertFollowup({
archiveId: archiveId.value,
followup: {
plannedExecutionTime,
endTime: plannedExecutionTime,
status: 'treated',
eventStatusLabel: '已完成',
eventType: form.eventType,
eventTypeLabel: eventTypeLabel.value || '回访',
executeTeamId: form.teamId,
executeTeamName: form.teamName,
executorName: '我',
creatorName: '我',
taskContent: '',
result: form.result,
todoMethod: form.todoMethod === 'phone' ? phoneValue : form.todoMethod,
createTime: Date.now(),
},
});
const params = {
corpId,
eventStatus: 'treated',
customerId,
customerName,
customerUserId,
executeTeamId: form.teamId,
executeTeamName: form.teamName,
executorUserId: userId,
creatorUserId: userId,
taskContent: '',
result: String(form.result || ''),
todoMethod: form.todoMethod === 'phone' ? phoneValue : form.todoMethod,
plannedExecutionTime,
endTime: plannedExecutionTime,
eventType: form.eventType,
};
const res = await api('createEvents', { params });
if (!res?.success) {
toast(res?.message || '保存失败');
return;
}
uni.$emit('archive-detail:followup-changed');
uni.showToast({ title: '保存成功', icon: 'success' });
toast('保存成功');
setTimeout(() => uni.navigateBack(), 300);
}
const typePopup = ref(null);
function pickType(v) {
form.eventType = String(v || '');
}
function closeTypePicker() {
typePopup.value?.close?.();
}
</script>
<style scoped>
@ -340,5 +454,38 @@ function save() {
background: #4f6ef7;
color: #fff;
}
</style>
.picker-sheet {
background: #fff;
border-top-left-radius: 10px;
border-top-right-radius: 10px;
overflow: hidden;
}
.picker-title {
text-align: center;
font-size: 16px;
font-weight: 600;
padding: 14px;
border-bottom: 1px solid #f0f0f0;
}
.picker-body {
max-height: 60vh;
}
.picker-item {
padding: 14px;
border-bottom: 1px solid #f2f2f2;
}
.picker-item.active {
background: #f2f6ff;
}
.picker-item-text {
font-size: 14px;
color: #333;
}
.picker-actions {
padding: 12px 14px calc(12px + env(safe-area-inset-bottom));
display: flex;
gap: 12px;
background: #fff;
}
</style>

View File

@ -1,5 +1,5 @@
<template>
<!-- Mobile 来源: ykt-management-mobile/src/pages/customer/new-followup/new-followup.vue简化移植去除员工组件/群发附件/接口 -->
<!-- Mobile 来源: ykt-management-mobile/src/pages/customer/new-followup/new-followup.vuewxapp仅新增待办任务 -->
<view class="page">
<view class="card">
<picker mode="date" :value="form.planExecutionTime" @change="changeDate">
@ -46,22 +46,6 @@
<view class="counter">{{ (form.taskContent || '').length }}/200</view>
</view>
</view>
<view class="block">
<view class="block-title">跟进方式</view>
<view class="toggle-row">
<view class="pill" :class="{ active: form.executeMethod === 'todo' }" @click="form.executeMethod = 'todo'">待办</view>
<view class="pill" :class="{ active: form.executeMethod === 'groupMessage' }" @click="form.executeMethod = 'groupMessage'">群发</view>
<view class="info" @click="showInfo">i</view>
</view>
<view v-if="form.executeMethod === 'groupMessage'" class="block">
<view class="block-title">发送内容</view>
<view class="textarea-box">
<textarea v-model="form.sendContent" class="textarea" placeholder="请输入群发内容mock" maxlength="500" />
<view class="counter">{{ (form.sendContent || '').length }}/500</view>
</view>
</view>
</view>
</view>
<view class="footer">
@ -69,15 +53,44 @@
<button class="btn primary" @click="save">保存</button>
</view>
<uni-popup ref="infoPopup" type="center">
<view class="modal">
<view class="modal-title">跟进方式说明</view>
<view class="modal-body">
<view class="modal-text">待办生成待办单需员工手动进行处理</view>
<view class="modal-text">群发生成群发单员工可批量进行处理wxapp mock 不接入群发</view>
<uni-popup ref="typePopup" type="bottom" :mask-click="true">
<view class="picker-sheet">
<view class="picker-title">选择类型</view>
<scroll-view scroll-y class="picker-body">
<view
v-for="t in eventTypeList"
:key="t.value"
class="picker-item"
:class="{ active: form.eventType === t.value }"
@click="pickType(t.value)"
>
<view class="picker-item-text">{{ t.label }}</view>
</view>
</scroll-view>
<view class="picker-actions">
<button class="btn plain" @click="closeTypePicker">取消</button>
<button class="btn primary" @click="closeTypePicker">确定</button>
</view>
<view class="modal-actions">
<view class="modal-btn save" @click="closeInfo">关闭</view>
</view>
</uni-popup>
<uni-popup ref="executorPopup" type="bottom" :mask-click="true">
<view class="picker-sheet">
<view class="picker-title">选择处理人本团队</view>
<scroll-view scroll-y class="picker-body">
<view
v-for="m in teamMembers"
:key="String(m?.userid || '')"
class="picker-item"
:class="{ active: form.executorUserId && String(m?.userid || '') === form.executorUserId }"
@click="pickExecutor(m)"
>
<view class="picker-item-text">{{ String(m?.anotherName || m?.name || m?.userid || '') }}</view>
</view>
</scroll-view>
<view class="picker-actions">
<button class="btn plain" @click="closeExecutorPicker">取消</button>
<button class="btn primary" @click="closeExecutorPicker">确定</button>
</view>
</view>
</uni-popup>
@ -88,42 +101,44 @@
import { computed, reactive, ref } from 'vue';
import { onLoad } from '@dcloudio/uni-app';
import dayjs from 'dayjs';
import { ensureSeed, upsertFollowup } from '@/components/archive-detail/mock';
import { storeToRefs } from 'pinia';
import api from '@/utils/api';
import useAccountStore from '@/store/account';
import { toast } from '@/utils/widget';
import { getTodoEventTypeLabel, getTodoEventTypeOptions } from '@/utils/todo-const';
const archiveId = ref('');
const archiveName = ref('');
const customerData = ref({});
const eventTypeList = [
{ label: '回访', value: 'followup' },
{ label: '复诊提醒', value: 'revisit' },
{ label: '问卷', value: 'questionnaire' },
{ label: '其他', value: 'other' },
];
const eventTypeList = getTodoEventTypeOptions();
const teamOptions = [
{ label: '口腔一科(示例)', value: 'team_1' },
{ label: '正畸团队(示例)', value: 'team_2' },
];
const accountStore = useAccountStore();
const { account, doctorInfo } = storeToRefs(accountStore);
const { getDoctorInfo } = accountStore;
const teamMembers = ref([]);
const form = reactive({
planExecutionTime: '',
executeTeamId: '',
executeTeamName: '',
executorName: '',
executeTeamId: '', // teamId
executeTeamName: '', // teamName
executorUserId: '', // userid
executorName: '', // anotherName
eventType: '',
taskContent: '',
executeMethod: 'todo', // todo | groupMessage
sendContent: '',
});
const eventTypeLabel = computed(() => eventTypeList.find((i) => i.value === form.eventType)?.label || '');
const eventTypeLabel = computed(() => getTodoEventTypeLabel(form.eventType));
onLoad((options) => {
resetForm();
archiveId.value = options?.archiveId ? String(options.archiveId) : '';
const c = uni.getStorageSync('new-followup-customer');
if (c && typeof c === 'object') {
archiveId.value = archiveId.value || String(c._id || '');
archiveName.value = String(c.name || '');
customerData.value = c;
}
if (!archiveId.value) {
uni.showToast({ title: '缺少 archiveId', icon: 'none' });
@ -131,19 +146,87 @@ onLoad((options) => {
return;
}
ensureSeed(archiveId.value, { name: archiveName.value });
// 使 plan-list select-mamagement-plan
const plan = uni.getStorageSync('select-mamagement-plan');
if (plan && typeof plan === 'object' && plan.planName) {
form.eventType = plan.eventType || 'followup';
form.taskContent = plan.taskContent || `执行回访计划:${plan.planName}`;
uni.setNavigationBarTitle({ title: '使用模板新增任务' });
const cached = uni.getStorageSync('ykt_case_archive_detail');
if (cached && typeof cached === 'object' && String(cached?._id || '') === archiveId.value) {
customerData.value = cached;
archiveName.value = archiveName.value || String(cached.name || '');
}
if (!form.planExecutionTime) form.planExecutionTime = dayjs().add(1, 'day').format('YYYY-MM-DD');
initDefaultExecutor();
});
function resetForm(keepCustomer = false) {
form.planExecutionTime = '';
form.executeTeamId = '';
form.executeTeamName = '';
form.executorUserId = '';
form.executorName = '';
form.eventType = '';
form.taskContent = '';
if (!keepCustomer) {
archiveId.value = '';
archiveName.value = '';
customerData.value = {};
}
teamMembers.value = [];
uni.setStorageSync('select-mamagement-plan', '');
}
async function ensureDoctor() {
if (doctorInfo.value) return;
if (!account.value?.openid) return;
try {
await getDoctorInfo();
} catch {
// ignore
}
}
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 loadTeamMembers(teamId) {
const corpId = getCorpId();
if (!corpId || !teamId) return;
const res = await api('getTeamData', { corpId, teamId });
if (!res?.success) {
teamMembers.value = [];
return;
}
const t = res?.data && typeof res.data === 'object' ? res.data : {};
teamMembers.value = Array.isArray(t.memberList) ? t.memberList : [];
}
async function initDefaultExecutor() {
await ensureDoctor();
const userId = getUserId();
const name = String(doctorInfo.value?.anotherName || doctorInfo.value?.name || '');
const currentTeam = uni.getStorageSync('ykt_case_current_team') || {};
const teamId = String(currentTeam.teamId || '');
const teamName = String(currentTeam.name || '');
if (teamId) {
form.executeTeamId = teamId;
form.executeTeamName = teamName;
await loadTeamMembers(teamId);
}
if (userId) {
form.executorUserId = userId;
form.executorName = name || '我';
}
}
function changeDate(e) {
const date = String(e.detail.value || '');
if (dayjs().startOf('day').isAfter(dayjs(date))) {
@ -154,25 +237,12 @@ function changeDate(e) {
}
function selectType() {
uni.showActionSheet({
itemList: eventTypeList.map((i) => i.label),
success: ({ tapIndex }) => {
form.eventType = eventTypeList[tapIndex]?.value || '';
},
});
typePopup.value?.open?.();
}
function selectExecutor() {
// wxapp mock +
uni.showActionSheet({
itemList: teamOptions.map((i) => i.label),
success: ({ tapIndex }) => {
const t = teamOptions[tapIndex];
form.executeTeamId = t.value;
form.executeTeamName = t.label;
form.executorName = '李医生';
},
});
if (!teamMembers.value.length) return toast('当前团队暂无可选成员');
executorPopup.value?.open?.();
}
function cancel() {
@ -183,47 +253,76 @@ function cancel() {
});
}
function save() {
async function save() {
if (!form.planExecutionTime) return uni.showToast({ title: '请选择回访日期', icon: 'none' });
if (!form.executorName) return uni.showToast({ title: '请选择处理人', icon: 'none' });
if (!form.executeTeamId) return uni.showToast({ title: '请选择处理人', icon: 'none' });
if (!form.executorUserId) return uni.showToast({ title: '请选择处理人', icon: 'none' });
if (!form.eventType) return uni.showToast({ title: '请选择类型', icon: 'none' });
if (!String(form.taskContent || '').trim()) return uni.showToast({ title: '请输入目的', icon: 'none' });
if (form.executeMethod === 'groupMessage' && !String(form.sendContent || '').trim()) {
return uni.showToast({ title: '请输入发送内容', icon: 'none' });
await ensureDoctor();
const corpId = getCorpId();
const userId = getUserId();
if (!corpId || !userId) {
toast('缺少用户/团队信息,请先完成登录与团队选择');
return;
}
const customer = customerData.value && typeof customerData.value === 'object' ? customerData.value : {};
const customerId = String(customer._id || archiveId.value || '');
const customerName = String(customer.name || archiveName.value || '');
const customerUserId = String(customer.externalUserId || customer.customerUserId || '') || '';
const plannedExecutionTime = dayjs(form.planExecutionTime).valueOf();
upsertFollowup({
archiveId: archiveId.value,
followup: {
plannedExecutionTime,
status: 'processing',
eventStatusLabel: '待处理',
eventType: form.eventType,
eventTypeLabel: eventTypeLabel.value || '回访',
executeTeamId: form.executeTeamId || 'team_1',
executeTeamName: form.executeTeamName || '口腔一科(示例)',
executorName: form.executorName,
creatorName: '我',
taskContent: form.taskContent,
result: '',
executeMethod: form.executeMethod,
sendContent: form.executeMethod === 'groupMessage' ? form.sendContent : '',
createTime: Date.now(),
},
});
const params = {
corpId,
customerId,
customerName,
customerUserId,
executeTeamId: form.executeTeamId,
executeTeamName: form.executeTeamName,
creatorUserId: userId,
userId: form.executorUserId || userId,
taskList: [
{
enableSend: false,
eventType: form.eventType,
executeMethod: 'todo',
executorUserId: form.executorUserId || userId,
planExecutionTime: form.planExecutionTime,
sendContent: '',
taskContent: form.taskContent,
taskId: `${Date.now()}_${Math.random().toString(16).slice(2)}`,
fileList: [],
},
],
};
const res = await api('executeManagementPlanTodo', params);
if (!res?.success) {
toast(res?.message || '保存失败');
return;
}
uni.$emit('archive-detail:followup-changed');
uni.showToast({ title: '保存成功', icon: 'success' });
toast('保存成功');
setTimeout(() => uni.navigateBack(), 300);
}
const infoPopup = ref(null);
function showInfo() {
infoPopup.value?.open?.();
const typePopup = ref(null);
function pickType(v) {
form.eventType = String(v || '');
}
function closeInfo() {
infoPopup.value?.close?.();
function closeTypePicker() {
typePopup.value?.close?.();
}
const executorPopup = ref(null);
function pickExecutor(m) {
const id = String(m?.userid || '');
if (!id) return;
form.executorUserId = id;
form.executorName = String(m?.anotherName || m?.name || m?.userid || '') || '';
}
function closeExecutorPicker() {
executorPopup.value?.close?.();
}
</script>
@ -291,6 +390,39 @@ function closeInfo() {
color: #666;
margin-bottom: 10px;
}
.picker-sheet {
background: #fff;
border-top-left-radius: 10px;
border-top-right-radius: 10px;
overflow: hidden;
}
.picker-title {
text-align: center;
font-size: 16px;
font-weight: 600;
padding: 14px;
border-bottom: 1px solid #f0f0f0;
}
.picker-body {
max-height: 60vh;
}
.picker-item {
padding: 14px;
border-bottom: 1px solid #f2f2f2;
}
.picker-item.active {
background: #f2f6ff;
}
.picker-item-text {
font-size: 14px;
color: #333;
}
.picker-actions {
padding: 12px 14px calc(12px + env(safe-area-inset-bottom));
display: flex;
gap: 12px;
background: #fff;
}
.textarea-box {
border: 1px solid #e6e6e6;
border-radius: 8px;
@ -405,4 +537,3 @@ function closeInfo() {
color: #fff;
}
</style>

400
pages/case/plan-execute.vue Normal file
View File

@ -0,0 +1,400 @@
<template>
<view class="page">
<scroll-view scroll-y class="scroll">
<view class="section header">
<view class="header-title">{{ plan.planName || '回访计划' }}</view>
</view>
<view class="section border-top">
<view class="sub">应用范围: {{ plan.planDetail || '无' }}</view>
</view>
<view class="section mt-20">
<picker mode="date" :value="form.planExecutionTime" @change="changeDate">
<view class="row clickable">
<view class="left">
<view class="label">开始时间</view>
<view class="req">*</view>
</view>
<view class="right">
<view class="value" :class="{ muted: !form.planExecutionTime }">{{ form.planExecutionTime || '请选择开始时间' }}</view>
<uni-icons type="arrowright" size="16" color="#999" />
</view>
</view>
</picker>
<view class="row clickable" @click="openExecutor">
<view class="left">
<view class="label">处理人</view>
<view class="req">*</view>
</view>
<view class="right">
<view class="value" :class="{ muted: !form.executorName }">{{ form.executorName || '请选择处理人' }}</view>
<uni-icons type="arrowright" size="16" color="#999" />
</view>
</view>
</view>
<view class="section mt-20">
<view v-if="taskList.length === 0" class="empty">暂无任务</view>
<plan-node-list v-else :taskList="taskList" :planExecutionTime="form.planExecutionTime" />
</view>
</scroll-view>
<view class="footer">
<button class="btn plain" @click="cancel">取消</button>
<button class="btn primary" @click="confirm">确定</button>
</view>
<uni-popup ref="executorPopup" type="bottom" :mask-click="true">
<view class="picker-sheet">
<view class="picker-title">选择处理人本团队</view>
<scroll-view scroll-y class="picker-body">
<view
v-for="m in teamMembers"
:key="String(m?.userid || '')"
class="picker-item"
:class="{ active: form.executorUserId && String(m?.userid || '') === form.executorUserId }"
@click="pickExecutor(m)"
>
<view class="picker-item-text">{{ String(m?.anotherName || m?.name || m?.userid || '') }}</view>
</view>
</scroll-view>
<view class="picker-actions">
<button class="btn plain" @click="closeExecutor">取消</button>
<button class="btn primary" @click="closeExecutor">确定</button>
</view>
</view>
</uni-popup>
</view>
</template>
<script setup>
import { computed, reactive, ref } from 'vue';
import { onLoad } 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';
import planNodeList from '@/components/manage-plan/plan-node-list.vue';
const archiveId = ref('');
const plan = ref({});
const customer = ref({});
const teamMembers = ref([]);
const accountStore = useAccountStore();
const { account, doctorInfo } = storeToRefs(accountStore);
const { getDoctorInfo } = accountStore;
const form = reactive({
planExecutionTime: '',
executorUserId: '',
executorName: '',
});
const taskList = computed(() => {
const list = Array.isArray(plan.value?.taskList) ? plan.value.taskList : [];
return list.map((t) => (t && typeof t === 'object' ? { ...t } : {}));
});
const previewTasks = computed(() => {
const base = form.planExecutionTime && dayjs(form.planExecutionTime).isValid() ? dayjs(form.planExecutionTime) : null;
const list = taskList.value;
const next = list
.map((t) => {
if (!base) return { ...t, planExecutionTime: '' };
const taskTime = typeof t.taskTime === 'number' ? t.taskTime : 0;
const timeType = ['day', 'week', 'month', 'year'].includes(String(t.timeType || '')) ? String(t.timeType) : 'day';
return { ...t, planExecutionTime: base.add(taskTime, timeType).format('YYYY-MM-DD') };
})
.sort((a, b) => String(a.planExecutionTime || '').localeCompare(String(b.planExecutionTime || '')));
return next;
});
onLoad((options) => {
archiveId.value = options?.archiveId ? String(options.archiveId) : '';
const rawPlan = uni.getStorageSync('select-mamagement-plan');
plan.value = rawPlan && typeof rawPlan === 'object' ? rawPlan : {};
const rawCustomer = uni.getStorageSync('new-followup-plan-customer');
customer.value = rawCustomer && typeof rawCustomer === 'object' ? rawCustomer : {};
const cached = uni.getStorageSync('ykt_case_archive_detail');
if (cached && typeof cached === 'object' && String(cached?._id || '') === String(customer.value?._id || archiveId.value || '')) {
customer.value = { ...cached, ...customer.value };
}
if (!form.planExecutionTime) form.planExecutionTime = dayjs().format('YYYY-MM-DD');
initTeamMembers();
});
async function ensureDoctor() {
if (doctorInfo.value) return;
if (!account.value?.openid) return;
try {
await getDoctorInfo();
} catch {
// ignore
}
}
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 || '') || '';
}
function getCurrentTeam() {
const t = uni.getStorageSync('ykt_case_current_team') || {};
return { teamId: String(t.teamId || ''), name: String(t.name || '') };
}
async function initTeamMembers() {
await ensureDoctor();
const corpId = getCorpId();
const { teamId } = getCurrentTeam();
if (!corpId || !teamId) return;
const res = await api('getTeamData', { corpId, teamId });
if (!res?.success) {
teamMembers.value = [];
return;
}
const t = res?.data && typeof res.data === 'object' ? res.data : {};
teamMembers.value = Array.isArray(t.memberList) ? t.memberList : [];
const userId = getUserId();
const me = teamMembers.value.find((m) => String(m?.userid || '') === userId);
if (me) {
form.executorUserId = userId;
form.executorName = String(me?.anotherName || me?.name || me?.userid || '') || '';
}
}
function changeDate(e) {
const date = String(e.detail.value || '');
if (dayjs().startOf('day').isAfter(dayjs(date))) {
toast('请选择有效的开始时间');
return;
}
form.planExecutionTime = date;
}
const executorPopup = ref(null);
function openExecutor() {
if (!teamMembers.value.length) return toast('当前团队暂无可选成员');
executorPopup.value?.open?.();
}
function pickExecutor(m) {
const id = String(m?.userid || '');
if (!id) return;
form.executorUserId = id;
form.executorName = String(m?.anotherName || m?.name || m?.userid || '') || '';
}
function closeExecutor() {
executorPopup.value?.close?.();
}
function cancel() {
uni.navigateBack();
}
async function confirm() {
if (!form.planExecutionTime) return toast('请选择开始时间');
if (!form.executorUserId) return toast('请选择处理人');
const tasks = previewTasks.value;
if (!tasks.length) return toast('任务不能为空');
await ensureDoctor();
const corpId = getCorpId();
const creatorUserId = getUserId();
const { teamId: executeTeamId, name: executeTeamName } = getCurrentTeam();
if (!corpId || !creatorUserId || !executeTeamId) return toast('缺少用户/团队信息');
const c = customer.value && typeof customer.value === 'object' ? customer.value : {};
const customerId = String(c._id || archiveId.value || '');
const cached = uni.getStorageSync('ykt_case_archive_detail');
const cachedName = cached && typeof cached === 'object' ? String(cached.name || '') : '';
const customerName = String(c.name || '') || cachedName;
const customerUserId = String(c.externalUserId || c.customerUserId || '') || '';
const payload = {
corpId,
customerId,
customerName,
customerUserId,
executeTeamId,
executeTeamName,
planId: String(plan.value?.planId || ''),
planName: String(plan.value?.planName || ''),
userId: creatorUserId,
creatorUserId,
taskList: tasks.map((t) => ({
taskId: String(t.taskId || `${Date.now()}_${Math.random().toString(16).slice(2)}`),
eventType: String(t.eventType || ''),
taskContent: String(t.taskContent || ''),
planExecutionTime: String(t.planExecutionTime || ''),
executorUserId: form.executorUserId,
executeMethod: String(t.executeMethod || 'todo'),
enableSend: typeof t.enableSend === 'boolean' ? t.enableSend : false,
sendContent: String(t.sendContent || ''),
fileList: Array.isArray(t.fileList) ? t.fileList : [],
})),
};
const res = await api('executeManagementPlanTodo', payload);
if (!res?.success) return toast(res?.message || '执行失败');
toast('执行成功');
uni.$emit('archive-detail:followup-changed');
uni.setStorageSync('select-mamagement-plan', '');
uni.setStorageSync('preview-mamagement-plan', '');
uni.navigateBack({ delta: 2 });
}
</script>
<style scoped>
.page {
height: 100vh;
display: flex;
flex-direction: column;
background: #f6f7f8;
}
.scroll {
flex: 1;
height: 0;
}
.section {
background: #fff;
padding: 24rpx 30rpx;
}
.section.header {
padding: 24rpx 30rpx;
border-bottom: 1px solid #e5e7eb;
}
.section.border-top {
border-bottom: 1px solid #e5e7eb;
}
.mt-20 {
margin-top: 20rpx;
}
.header-title {
font-size: 32rpx;
font-weight: 600;
color: #111827;
}
.sub {
font-size: 28rpx;
color: #6b7280;
line-height: 48rpx;
}
.empty {
padding: 120rpx 0;
text-align: center;
color: #9ca3af;
font-size: 28rpx;
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 28rpx 0;
border-bottom: 1px solid #eef0f2;
}
.row:last-child {
border-bottom: none;
}
.left {
display: flex;
align-items: center;
}
.label {
font-size: 28rpx;
color: #6b7280;
}
.req {
margin-left: 8rpx;
font-size: 28rpx;
color: #ff4d4f;
}
.right {
display: flex;
align-items: center;
gap: 20rpx;
}
.value {
font-size: 28rpx;
color: #111827;
max-width: 420rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.value.muted {
color: #9ca3af;
}
.footer {
background: #fff;
padding: 24rpx 30rpx calc(24rpx + env(safe-area-inset-bottom));
display: flex;
gap: 24rpx;
box-shadow: 0 -8rpx 24rpx rgba(0, 0, 0, 0.06);
}
.btn {
flex: 1;
height: 80rpx;
line-height: 80rpx;
border-radius: 16rpx;
font-size: 30rpx;
}
.btn::after {
border: none;
}
.btn.plain {
background: #fff;
color: #4f6ef7;
border: 1px solid #4f6ef7;
}
.btn.primary {
background: #4f6ef7;
color: #fff;
}
.picker-sheet {
background: #fff;
border-top-left-radius: 20rpx;
border-top-right-radius: 20rpx;
overflow: hidden;
}
.picker-title {
text-align: center;
font-size: 32rpx;
font-weight: 600;
padding: 28rpx;
border-bottom: 1px solid #f0f0f0;
}
.picker-body {
max-height: 60vh;
}
.picker-item {
padding: 28rpx;
border-bottom: 1px solid #f2f2f2;
}
.picker-item.active {
background: #f2f6ff;
}
.picker-item-text {
font-size: 28rpx;
color: #111827;
}
.picker-actions {
padding: 24rpx 30rpx calc(24rpx + env(safe-area-inset-bottom));
display: flex;
gap: 24rpx;
background: #fff;
}
</style>

View File

@ -1,7 +1,8 @@
<template>
<!-- Mobile 来源: ykt-management-mobile/src/pages/manage-plan/plan-list/plan-list.vuewxapp mock仅选择模板并回到 new-followup -->
<!-- Mobile 来源: ykt-management-mobile/src/pages/manage-plan/plan-list/plan-list.vue -->
<view class="page">
<view v-if="list.length === 0" class="empty">
<view v-if="loading" class="loading">加载中...</view>
<view v-else-if="list.length === 0" class="empty">
<image class="empty-img" src="/static/empty.svg" mode="aspectFit" />
<view class="empty-text">暂无回访计划</view>
</view>
@ -26,43 +27,117 @@
<script setup>
import { ref } from 'vue';
import { onLoad } from '@dcloudio/uni-app';
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 loading = ref(false);
const list = ref([
{
id: 'p1',
planName: '复诊提醒模板',
planType: 'corp',
planDetail: '适用于复诊提醒人群',
eventType: 'revisit',
taskContent: '请于本周内完成复诊预约与提醒。',
},
{
id: 'p2',
planName: '随访回访模板',
planType: 'team',
planDetail: '适用于普通随访',
eventType: 'followup',
taskContent: '请电话回访患者,确认恢复情况并记录结果。',
},
]);
const list = ref([]);
const accountStore = useAccountStore();
const { account, doctorInfo } = storeToRefs(accountStore);
const { getDoctorInfo } = accountStore;
onLoad((options) => {
archiveId.value = options?.archiveId ? String(options.archiveId) : '';
loadList();
});
async function ensureDoctor() {
if (doctorInfo.value) return;
if (!account.value?.openid) return;
try {
await getDoctorInfo();
} catch {
// ignore
}
}
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 || '') || '';
}
function getTeamId() {
const t = uni.getStorageSync('ykt_case_current_team') || {};
return String(t.teamId || '') || '';
}
function normalizePlan(raw) {
if (!raw || typeof raw !== 'object') return null;
const id = String(raw.planId || raw._id || '');
if (!id) return null;
const taskList = Array.isArray(raw.taskList) ? raw.taskList : [];
const first = taskList[0] && typeof taskList[0] === 'object' ? taskList[0] : {};
const eventType = String(first.eventType || '');
const taskContent = String(first.taskContent || '');
return {
...raw,
id,
planName: String(raw.planName || ''),
planType: String(raw.planType || ''),
planDetail: String(raw.planDetail || ''),
firstEventType: eventType,
firstEventTypeLabel: eventType ? getTodoEventTypeLabel(eventType) : '',
taskContent,
taskCount: taskList.length,
taskList,
};
}
async function loadList() {
if (loading.value) return;
loading.value = true;
try {
await ensureDoctor();
const corpId = getCorpId();
const userId = getUserId();
const teamId = getTeamId();
if (!corpId || !userId || !teamId) {
list.value = [];
toast('缺少用户/团队信息');
return;
}
const res = await api('getManagementPlan', {
corpId,
userId,
planType: 'team',
teamId,
page: 1,
pageSize: 999,
});
if (!res?.success) {
list.value = [];
toast(res?.message || '获取回访计划失败');
return;
}
const arr = Array.isArray(res.data) ? res.data : [];
list.value = arr.filter((p) => p && p.planStatus === true).map(normalizePlan).filter(Boolean);
} finally {
loading.value = false;
}
}
function select(plan) {
uni.setStorageSync('select-mamagement-plan', plan);
uni.navigateTo({ url: `/pages/case/new-followup?archiveId=${encodeURIComponent(archiveId.value)}&fromPlan=1` });
uni.navigateTo({ url: `/pages/case/plan-execute?archiveId=${encodeURIComponent(archiveId.value)}` });
}
function preview(plan) {
uni.showModal({
title: plan.planName || '回访计划',
content: plan.taskContent || plan.planDetail || '',
showCancel: false,
});
uni.setStorageSync('preview-mamagement-plan', plan);
uni.navigateTo({ url: `/pages/case/plan-preview?archiveId=${encodeURIComponent(archiveId.value)}` });
}
</script>
@ -74,6 +149,11 @@ function preview(plan) {
.scroll {
height: 100vh;
}
.loading {
padding: 16px;
font-size: 13px;
color: #9aa0a6;
}
.item {
display: flex;
align-items: center;
@ -147,4 +227,3 @@ function preview(plan) {
color: #9aa0a6;
}
</style>

104
pages/case/plan-preview.vue Normal file
View File

@ -0,0 +1,104 @@
<template>
<view class="page">
<scroll-view scroll-y class="scroll">
<view class="section header">
<view class="header-title">{{ plan.planName || '回访计划' }}</view>
</view>
<view class="section border-top">
<view class="sub">应用范围: {{ plan.planDetail || '无' }}</view>
</view>
<view class="section mt-20">
<view v-if="tasks.length === 0" class="empty">暂无任务</view>
<plan-node-list v-else class="node-list" :taskList="tasks" />
</view>
</scroll-view>
<view class="footer">
<button class="footer-btn" @click="back">返回</button>
</view>
</view>
</template>
<script setup>
import { computed, ref } from 'vue';
import { onLoad } from '@dcloudio/uni-app';
import planNodeList from '@/components/manage-plan/plan-node-list.vue';
const plan = ref({});
const tasks = computed(() => {
const list = Array.isArray(plan.value?.taskList) ? plan.value.taskList : [];
return list.map((i) => (i && typeof i === 'object' ? i : {}));
});
onLoad(() => {
const p = uni.getStorageSync('preview-mamagement-plan');
if (p && typeof p === 'object') plan.value = p;
});
function back() {
uni.navigateBack();
}
</script>
<style scoped>
.page {
height: 100vh;
display: flex;
flex-direction: column;
background: #f6f7f8;
}
.scroll {
flex: 1;
height: 0;
}
.section {
background: #fff;
padding: 24rpx 30rpx;
}
.section.header {
padding: 24rpx 30rpx;
border-bottom: 1px solid #e5e7eb;
}
.section.border-top {
border-bottom: 1px solid #e5e7eb;
}
.mt-20 {
margin-top: 20rpx;
}
.header-title {
font-size: 32rpx;
font-weight: 600;
color: #111827;
}
.empty {
padding: 120rpx 0;
text-align: center;
color: #9ca3af;
font-size: 28rpx;
}
.sub {
font-size: 28rpx;
color: #6b7280;
line-height: 48rpx;
}
.footer {
background: #fff;
padding: 30rpx 30rpx calc(30rpx + env(safe-area-inset-bottom));
box-shadow: 0 -8rpx 24rpx rgba(0, 0, 0, 0.06);
}
.footer-btn {
width: 100%;
height: 80rpx;
line-height: 80rpx;
border-radius: 16rpx;
font-size: 28rpx;
background: #fff;
color: #111827;
border: 1px solid #e5e7eb;
}
.footer-btn::after {
border: none;
}
</style>

View File

@ -14,7 +14,8 @@ export default [
},
{
path: 'pages/message/index',
meta: { title: '消息', login: false },
meta: { title: '聊天', login: false },
style: { enablePullDownRefresh: false },
},
{
path: 'pages/case/case',
@ -84,6 +85,14 @@ export default [
path: 'pages/case/plan-list',
meta: { title: '回访计划', login: false },
},
{
path: 'pages/case/plan-preview',
meta: { title: '回访计划详情', login: false },
},
{
path: 'pages/case/plan-execute',
meta: { title: '执行回访计划', login: false },
},
{
path: 'pages/work/work',
meta: { title: '工作台', login: false }
@ -97,4 +106,3 @@ export default [
meta: { title: '选择科室', login: false }
}
]

View File

@ -1,6 +1,13 @@
const fs = require('fs');
const path = require('path');
function stripJsonComments(content) {
// Remove /* */ comments first, then // comments.
return String(content || '')
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/^\s*\/\/.*$/gm, '');
}
// 读取 routes 配置并转换为 pages.json
function generatePagesJson() {
// 读取 routes/index.js
@ -41,7 +48,7 @@ function generatePagesJson() {
if (fs.existsSync(pagesJsonPath)) {
const content = fs.readFileSync(pagesJsonPath, 'utf-8');
pagesJson = JSON.parse(content);
pagesJson = JSON.parse(stripJsonComments(content));
}
// 转换 routes 为 pages 格式

View File

@ -44,6 +44,19 @@ const urlsConfig = {
getUserSig: 'getUserSig',
sendSystemMessage: "sendSystemMessage",
getChatRecordsByGroupId: "getChatRecordsByGroupId"
},
todo: {
getCustomerTodos: 'getCustomerTodos',
getTodoById: 'getTodoById',
setTodoStatus: 'setTodoStatus',
updateTaskTodoResult: 'updateTaskTodoResult',
updateEvent: 'updateEvent',
removeTodo: 'removeTodo',
createEvents: 'createEvents',
executeManagementPlanTodo: 'executeManagementPlanTodo',
getManagementPlan: 'getManagementPlan',
getManagementPlanById: 'getManagementPlanById',
getNextFollowUpTime: 'getNextFollowUpTime',
}
}

30
utils/todo-const.js Normal file
View File

@ -0,0 +1,30 @@
export const TODO_EVENT_TYPE_LABELS = {
followUpNoShow: '未到院回访',
followUpNoDeal: '未成交回访',
followUp: '诊后回访',
followUpPostSurgery: '术后回访',
followUpPostTreatment: '治疗后回访',
appointmentReminder: '就诊提醒',
followUpReminder: '复诊提醒',
medicationReminder: '用药提醒',
serviceSummary: '咨询服务',
eventNotification: '活动通知',
ContentReminder: '宣教发送',
questionnaire: '问卷调查',
followUpComplaint: '投诉回访',
followUpActivity: '活动回访',
other: '其他',
};
export function getTodoEventTypeLabel(value) {
const v = String(value || '');
return TODO_EVENT_TYPE_LABELS[v] || v || '其他';
}
export function getTodoEventTypeOptions() {
return Object.keys(TODO_EVENT_TYPE_LABELS).map((key) => ({
value: key,
label: TODO_EVENT_TYPE_LABELS[key],
}));
}