ykt-wxapp/pages/case/components/archive-detail/follow-up-manage-tab.vue
2026-02-11 15:24:39 +08:00

1602 lines
41 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>
<!-- Mobile 来源: ykt-management-mobile/src/pages/customer/customer-detail/followup-manage/followup-manage.vue -->
<view class="wrap">
<view class="top">
<view class="top-row">
<view class="my" @click="toggleMy">
<image
:src="`/static/checkbox${query.isMy ? '-checked' : ''}.svg`"
class="checkbox"
/>
<view class="my-text">我的</view>
</view>
<view class="status-scroll">
<scroll-view scroll-x>
<view class="status-tabs">
<view
v-for="t in statusTabs"
:key="t.value"
class="status-tab"
:class="{ active: query.status === t.value }"
@click="toggleStatus(t.value)"
>
{{ t.label }}
</view>
</view>
</scroll-view>
</view>
<view class="filter-btn" @click="openFilter">
<image
class="filter-icon"
:src="`/static/icons/icon-filter${filtered ? '-active' : ''}.svg`"
/>
</view>
</view>
<view class="total"
><text class="total-num">{{ total }}</text
></view
>
</view>
<view class="list">
<view v-for="i in list" :key="i._id" class="card" @click="toDetail(i)">
<view class="head">
<view class="date"
>计划日期: <text class="date-val">{{ i.planDate }}</text></view
>
<view class="executor truncate"
>{{ i.executorName
}}<text v-if="i.executeTeamName"
>{{ i.executeTeamName }}</text
></view
>
</view>
<view class="body">
<view class="title-row">
<view class="type">{{ i.eventTypeLabel }}</view>
<view class="status" :class="`st-${i.status}`">{{
i.eventStatusLabel
}}</view>
</view>
<view class="content">{{ i.taskContent || "暂无内容" }}</view>
<view
v-if="i.sendContent || (i.fileList && i.fileList.length > 0)"
class="send-content-wrapper"
>
<view class="send-content-section">
<view class="send-content-label">发送内容:</view>
<view class="send-content-body">
<view v-if="i.sendContent" class="send-text">{{
i.sendContent
}}</view>
<view
v-if="i.fileList && i.fileList.length > 0"
class="file-list"
:class="{ 'no-send-text': !i.sendContent }"
>
<view
v-for="(file, idx) in i.fileList"
:key="idx"
class="file-item"
:class="`file-type-${file.type}`"
>
<view class="file-name">{{
file.file?.name || file.name
}}</view>
</view>
</view>
</view>
</view>
<button
v-if="canShowSendButton(i)"
class="action-btn send-btn"
@click.stop="goChatAndSend(i)"
>
发送
</button>
</view>
<view v-if="i.status === 'treated'" class="result"
>处理结果 {{ i.result || "" }}</view
>
<view class="footer-row">
<view class="footer"
>创建: {{ i.createTimeStr }} {{ i.creatorName }}</view
>
</view>
</view>
</view>
<view v-if="list.length === 0" class="empty">暂无数据</view>
<uni-load-more
v-if="list.length"
:status="moreStatus"
:contentText="loadMoreText"
@clickLoadMore="getMore"
/>
</view>
<view class="fab" :style="{ bottom: `${floatingBottom}px` }" @click="add">
<uni-icons type="plusempty" size="24" color="#fff" />
</view>
<!-- 筛选弹窗简化 mock -->
<uni-popup ref="filterPopupRef" type="bottom" :mask-click="true">
<view class="popup">
<view class="popup-title">
<view class="popup-title-text">全部筛选</view>
<view class="popup-close" @click="closeFilter(true)">
<uni-icons type="closeempty" size="18" color="#666" />
</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">
<view
v-for="t in typeOptions"
:key="t.value"
class="chip"
:class="{ active: typeSelectedMap[t.value] }"
@click="toggleType(t.value)"
>
{{ t.label }}
</view>
</view>
</view>
<view class="section">
<view class="section-title">所属团队</view>
<picker
mode="selector"
:range="teamOptions"
range-key="label"
@change="pickTeam"
>
<view class="select-row">
<view
class="select-text"
:class="{ muted: teamPicked.value === 'ALL' }"
>{{ teamPicked.label }}</view
>
<uni-icons type="right" size="16" color="#999" />
</view>
</picker>
</view>
<view class="section">
<view class="section-title">计划日期</view>
<view class="range-row">
<picker mode="date" @change="pickStart">
<view class="range-pill" :class="{ muted: !planRange[0] }">{{
planRange[0] || "开始日期"
}}</view>
</picker>
<view class="sep">-</view>
<picker mode="date" @change="pickEnd">
<view class="range-pill" :class="{ muted: !planRange[1] }">{{
planRange[1] || "结束日期"
}}</view>
</picker>
<view class="clear" @click="clearPlanRange">
<uni-icons type="closeempty" size="16" color="#999" />
</view>
</view>
</view>
</scroll-view>
<view class="actions">
<button class="btn plain" @click="resetFilter">重置</button>
<button class="btn primary" @click="confirmFilter">确定</button>
</view>
</view>
</uni-popup>
</view>
</template>
<script setup>
import { computed, onMounted, onUnmounted, reactive, ref, watch } from "vue";
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: () => ({}) },
archiveId: { type: String, default: "" },
reachBottomTime: { type: [String, Number], default: "" },
floatingBottom: { type: Number, default: 16 },
fromChat: { type: Boolean, default: false },
});
const accountStore = useAccountStore();
const { account, doctorInfo } = storeToRefs(accountStore);
const { getDoctorInfo } = accountStore;
function getUserId() {
const d = doctorInfo.value || {};
const a = account.value || {};
return String(d.userid || d.userId || d.corpUserId || a.userid || a.userId || "") || "";
}
function isExecutor(todo) {
const currentUserId = getUserId();
const executorUserId = String(todo?.executorUserId || "");
return currentUserId && executorUserId && currentUserId === executorUserId;
}
function getCorpId() {
const team = uni.getStorageSync("ykt_case_current_team") || {};
const d = doctorInfo.value || {};
const a = account.value || {};
return String(d.corpId || a.corpId || team.corpId || "") || "";
}
function getCurrentTeamId() {
const team = uni.getStorageSync("ykt_case_current_team") || {};
return String(team?.teamId || team?._id || team?.id || "") || "";
}
function normalizeGroupId(v) {
const s = String(v || "").trim();
if (!s) return "";
return s.startsWith("GROUP") ? s.slice(5) : s;
}
const statusTabs = [
{ label: "全部", value: "all" },
{ label: "待处理", value: "processing" },
{ label: "未开始", value: "notStart" },
{ label: "已完成", value: "treated" },
{ label: "已取消", value: "cancelled" },
{ label: "已过期", value: "expired" },
];
const typeOptions = [
{ label: "全部", value: "all" },
...getTodoEventTypeOptions(),
];
const teamOptions = ref([{ label: "全部", value: "ALL" }]);
const query = reactive({
isMy: false,
status: "all",
eventTypes: [],
teamId: "ALL",
planRange: ["", ""],
});
const list = ref([]);
const total = ref(0);
const chatGroupId = ref("");
const currentChatGroupId = computed(() => normalizeGroupId(chatGroupId.value || ""));
const PENDING_FOLLOWUP_SEND_STORAGE_KEY = "ykt_followup_pending_send";
const page = ref(1);
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";
});
const loadMoreText = {
contentdown: "点击加载更多",
contentrefresh: "加载中...",
contentnomore: "没有更多了",
};
const filtered = ref(false);
const typeSelectedMap = computed(() => {
const s = new Set(query.eventTypes || []);
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;
}, {});
});
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 normalizeName(v) {
const s = v === 0 ? "0" : v ? String(v) : "";
const trimmed = s.trim();
if (!trimmed) return "";
if (["-", "—", "--"].includes(trimmed)) return "";
return trimmed;
}
function getExecuteTeamId(todo) {
const row = todo && typeof todo === "object" ? todo : {};
return String(
row.executeTeamId ||
row.executeTeamID ||
row.teamId ||
row.teamID ||
row.executeTeam?._id ||
row.executeTeam?.teamId ||
""
)
.trim();
}
const loadedTeamMemberIds = new Set();
const teamMemberInflight = new Map();
async function loadTeamMembers(teamId) {
const tid = String(teamId || "").trim();
if (!tid) return;
if (loadedTeamMemberIds.has(tid)) return;
const existingInflight = teamMemberInflight.get(tid);
if (existingInflight) return existingInflight;
const inflight = (async () => {
await ensureDoctor();
const corpId = getCorpId();
if (!corpId) return;
try {
const fallback = await api("getTeamData", { corpId, teamId: tid }, false);
if (!fallback?.success) return;
const t = fallback?.data && typeof fallback.data === "object" ? fallback.data : {};
const members = Array.isArray(t.memberList) ? t.memberList : [];
const map = members.reduce((acc, m) => {
if (typeof m === "string") {
const id = String(m || "");
if (id) acc[id] = id;
return acc;
}
const uid = String(m?.userid || m?.userId || m?.corpUserId || m?._id || m?.id || "").trim();
if (!uid) return acc;
const display = String(m?.anotherName || m?.name || m?.userid || m?.userId || "").trim();
acc[uid] = display || uid;
return acc;
}, {});
if (Object.keys(map).length) userNameMap.value = { ...(userNameMap.value || {}), ...map };
// 补缺:仅当当前没有映射时才用 avatars 接口补齐,避免覆盖正确姓名
try {
const res = await api("getTeamMemberAvatarsAndName", { corpId, teamId: tid }, false);
if (res?.success && res?.data && typeof res.data === "object") {
const raw = res.data;
const patch = Object.keys(raw).reduce((acc, uid) => {
const id = String(uid || "").trim();
if (!id) return acc;
const existing = userNameMap.value?.[id];
if (existing && existing !== id) return acc;
const name = String(raw?.[uid]?.name || raw?.[uid]?.anotherName || "").trim();
if (!name || name === id) return acc;
acc[id] = name;
return acc;
}, {});
if (Object.keys(patch).length) userNameMap.value = { ...(userNameMap.value || {}), ...patch };
}
} catch {
// ignore
}
} finally {
loadedTeamMemberIds.add(tid);
}
})().finally(() => {
teamMemberInflight.delete(tid);
});
teamMemberInflight.set(tid, inflight);
return inflight;
}
let corpMemberBatchInflight = null;
async function batchLoadCorpMembers(userIds) {
const ids = Array.isArray(userIds)
? userIds.map((v) => String(v || "").trim()).filter(Boolean)
: [];
if (!ids.length) return;
const uniq = Array.from(new Set(ids));
const unknown = uniq.filter((id) => {
const existing = userNameMap.value?.[id];
return !existing || existing === id;
});
if (!unknown.length) return;
if (corpMemberBatchInflight) return corpMemberBatchInflight;
await ensureDoctor();
const corpId = getCorpId();
if (!corpId) return;
corpMemberBatchInflight = (async () => {
try {
const res = await api(
"getCorpMember",
{
page: 1,
pageSize: Math.min(Math.max(unknown.length, 10), 500),
params: {
corpId,
memberList: unknown,
},
},
false
);
if (!res?.success) return;
const rows = Array.isArray(res?.data)
? res.data
: Array.isArray(res?.data?.data)
? res.data.data
: [];
if (!rows.length) return;
const patch = rows.reduce((acc, m) => {
const id = String(m?.userid || m?.userId || m?.corpUserId || "").trim();
if (!id) return acc;
const existing = userNameMap.value?.[id];
if (existing && existing !== id) return acc;
const display = String(m?.anotherName || m?.name || "").trim();
if (!display || display === id) return acc;
acc[id] = display;
return acc;
}, {});
if (Object.keys(patch).length) userNameMap.value = { ...(userNameMap.value || {}), ...patch };
} catch {
// ignore
}
})().finally(() => {
corpMemberBatchInflight = null;
});
return corpMemberBatchInflight;
}
let ensureNamesInflight = null;
async function ensureTodoNames(todos) {
if (ensureNamesInflight) return ensureNamesInflight;
const rows = Array.isArray(todos) ? todos : [];
if (!rows.length) return;
const teamIds = Array.from(
new Set(
rows
.map((t) => getExecuteTeamId(t) || getCurrentTeamId())
.map((v) => String(v || "").trim())
.filter(Boolean)
)
);
const unknownUserIds = Array.from(
new Set(
rows
.flatMap((t) => [t?.executorUserId, t?.creatorUserId])
.map((v) => String(v || "").trim())
.filter(Boolean)
.filter((id) => {
const existing = userNameMap.value?.[id];
return !existing || existing === id;
})
)
);
ensureNamesInflight = (async () => {
// 先按团队拉成员(并发限制),再按 corp 兜底补齐
const limit = 3;
let idx = 0;
const workers = Array.from({ length: Math.min(limit, teamIds.length || 1) }, async () => {
while (idx < teamIds.length) {
const tid = teamIds[idx++];
try {
await loadTeamMembers(tid);
} catch {
// ignore
}
}
});
await Promise.allSettled(workers);
await batchLoadCorpMembers(unknownUserIds);
// 重新补齐列表显示
list.value = (Array.isArray(list.value) ? list.value : []).map((t) => ({
...t,
executorName: normalizeName(t?.executorName) || resolveUserName(t?.executorUserId),
creatorName: normalizeName(t?.creatorName) || resolveUserName(t?.creatorUserId),
}));
})().finally(() => {
ensureNamesInflight = null;
});
return ensureNamesInflight;
}
function refreshChatRoom() {
// 兼容:优先用档案详情数据里已有的 chatGroupId
const fromArchive =
props?.data && typeof props.data === "object" ? props.data : {};
const direct = String(fromArchive?.chatGroupId || fromArchive?.groupId || "");
if (direct) {
chatGroupId.value = direct;
return;
}
// 如果没有,走接口探测可用会话
refreshChatRoomByApi();
}
function getConversationIdForChat(groupId) {
const gid = normalizeGroupId(groupId || "");
return gid ? `GROUP${gid}` : "";
}
function hasSendContent(todo) {
return (
Boolean(todo?.sendContent) ||
(Array.isArray(todo?.fileList) && todo.fileList.length > 0)
);
}
function isExecutorMe(todo) {
const me = String(getUserId() || "");
const executor = String(todo?.executorUserId || "");
if (!me || !executor) return false;
return me === executor;
}
function canShowSendButton(todo) {
if (!hasSendContent(todo)) return false;
if (!isExecutorMe(todo)) return false;
// 当前患者无会话则不展示
return Boolean(currentChatGroupId.value);
}
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;
list.value = [];
getMore();
}
async function getMore() {
if (!props.archiveId) return;
if (loading.value) return;
if (page.value > pages.value) return;
loading.value = true;
try {
await ensureDoctor();
const corpId = getCorpId();
const userId = getUserId();
if (!corpId) {
return;
}
const params = {
corpId,
customerId: String(props.archiveId),
page: page.value,
pageSize,
};
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;
}
// 对齐管理端同接口返回结构:{ success, data: { data: [], total } }
const payload = res?.data;
const arr = payload && Array.isArray(payload.data)
? payload.data
: Array.isArray(payload)
? payload
: [];
const next = arr.map(formatTodo);
total.value = payload && typeof payload.total === "number" ? payload.total : 0;
pages.value = Math.ceil(total.value / pageSize) || 0;
list.value = page.value === 1 ? next : [...list.value, ...next];
page.value += 1;
// 不阻塞渲染:后台补齐 executor/creator 的姓名
void ensureTodoNames(list.value);
} catch (e) {
console.error("getCustomerTodos failed:", e);
toast("获取回访任务失败");
} finally {
loading.value = false;
}
}
function toggleMy() {
query.isMy = !query.isMy;
resetList();
}
function toggleStatus(v) {
query.status = 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: ["+新增任务", "+使用模板", "+回访记录"],
success: ({ tapIndex }) => {
if (tapIndex === 0) {
uni.setStorageSync("new-followup-customer", {
_id: props.archiveId,
name: props.data?.name || "",
});
uni.navigateTo({
url: `/pages/case/new-followup?archiveId=${encodeURIComponent(
props.archiveId
)}`,
});
} else if (tapIndex === 1) {
uni.setStorageSync("new-followup-plan-customer", {
_id: props.archiveId,
name: props.data?.name || "",
});
uni.setStorageSync("select-mamagement-plan", "");
uni.navigateTo({
url: `/pages/case/plan-list?archiveId=${encodeURIComponent(
props.archiveId
)}`,
});
} else if (tapIndex === 2) {
uni.setStorageSync("new-followup-record-customer", {
_id: props.archiveId,
name: props.data?.name || "",
});
uni.navigateTo({
url: `/pages/case/new-followup-record?archiveId=${encodeURIComponent(
props.archiveId
)}`,
});
}
},
});
}
function toDetail(todo) {
uni.navigateTo({
url: `/pages/case/followup-detail?archiveId=${encodeURIComponent(
props.archiveId
)}&mode=edit&id=${encodeURIComponent(todo._id)}`,
});
}
function buildFollowUpMessages(todo) {
const messages = [];
if (todo?.sendContent) {
messages.push({ type: "text", content: String(todo.sendContent) });
}
if (Array.isArray(todo?.fileList)) {
for (const file of todo.fileList) {
const outerType = String(file?.type || "");
let innerFile = file?.file;
if (typeof innerFile === "string") {
try {
innerFile = JSON.parse(innerFile);
} catch {
// ignore
}
}
innerFile = innerFile && typeof innerFile === "object" ? innerFile : null;
const innerType = String(innerFile?.type || "");
const outerUrl = String(file?.URL || file?.url || "");
const innerUrl = String(innerFile?.url || "");
let fileType = "";
if (outerType === "image" || innerType.includes("image")) fileType = "image";
else if (innerType === "article") fileType = "article";
else if (innerType === "questionnaire") fileType = "questionnaire";
else fileType = outerType;
const url =
fileType === "article" || fileType === "questionnaire"
? innerUrl || outerUrl
: outerUrl || innerUrl;
if (fileType === "image" && url) {
messages.push({
type: "image",
content: url,
name: innerFile?.name || file?.name || "图片",
});
continue;
}
if (fileType === "article") {
const fallbackArticleId =
String(
innerFile?._id ||
file?._id ||
innerFile?.articleId ||
file?.articleId ||
""
) || "";
const extractedId = extractIdFromUrl(url);
const articleId = String(extractedId || fallbackArticleId || "");
let articleUrl = String(url || "");
if (!articleUrl && articleId) {
const corpId = getCorpId();
articleUrl = `${__VITE_ENV__?.MP_PATIENT_PAGE_BASE_URL || ""}pages/article/index?id=${encodeURIComponent(
articleId
)}&corpId=${encodeURIComponent(corpId || "")}`;
}
if (!articleId && !articleUrl) continue;
messages.push({
type: "article",
content: {
_id: articleId,
title: innerFile?.name || file?.name || "宣教文章",
url: articleUrl,
subtitle: innerFile?.subtitle || "",
cover: innerFile?.cover || file?.URL || "",
articleId: articleId,
},
});
continue;
}
if (fileType === "questionnaire") {
const surveryId = innerFile?.surveryId || file?.surveryId;
if (!surveryId) continue;
const surveyId =
String(innerFile?._id || file?._id || surveryId || "") || "";
messages.push({
type: "questionnaire",
content: {
_id: surveyId,
name: innerFile?.name || file?.name || "问卷",
surveryId,
url: String(url || ""),
createBy: innerFile?.createBy,
},
});
}
}
}
return messages;
}
async function goChatAndSend(todo) {
if (!canShowSendButton(todo)) return;
if (!props.archiveId) return;
let gid = normalizeGroupId(currentChatGroupId.value || "");
if (!gid) {
await refreshChatRoomByApi();
gid = normalizeGroupId(currentChatGroupId.value || "");
}
if (!gid) {
toast("暂无可进入的会话");
return;
}
const messages = buildFollowUpMessages(todo);
if (!messages.length) {
console.warn("[followup] buildFollowUpMessages empty:", {
sendContent: todo?.sendContent,
fileList: todo?.fileList,
});
toast("发送内容解析失败");
return;
}
const conversationID = `GROUP${gid}`;
uni.setStorageSync(PENDING_FOLLOWUP_SEND_STORAGE_KEY, {
createdAt: Date.now(),
groupId: gid,
conversationID,
messages,
context: {
userId: getUserId(),
customerId: props.archiveId,
customerName: props.data?.name || "",
corpId: getCorpId(),
env: __VITE_ENV__,
},
});
uni.navigateTo({
url: `/pages/message/index?conversationID=${encodeURIComponent(
conversationID
)}&groupID=${encodeURIComponent(gid)}&fromCase=true&pendingFollowUpSend=1`,
});
}
/**
* 从 URL 中提取 id 参数
* @param {string} url - 完整的 URL
* @returns {string} 提取出的 id 值
*/
function extractIdFromUrl(url) {
if (!url) return "";
try {
// 处理格式: https://www.youcan365.com/patientDeploy/#/pages/article/index?id=267epkhd3xbklcnbf0f45gzp1769567841991&corpId=...
const urlObj = new URL(url);
const id = urlObj.searchParams.get("id");
if (id) return id;
// 备用方案:使用正则表达式提取
const match = url.match(/[?&]id=([^&]+)/);
return match ? decodeURIComponent(match[1]) : "";
} catch (error) {
console.error("解析 URL 失败:", error);
// 备用方案:使用正则表达式提取
const match = url.match(/[?&]id=([^&]+)/);
return match ? decodeURIComponent(match[1]) : "";
}
}
/**
* 从问卷 URL 中提取 surveryId 参数
* @param {string} url - 完整的 URL格式如: https://www.youcan365.com/patientDeploy/#/pages/survery/fill?corpId=wwe3fb2faa52cf9dfb&surveryId=9ji5kg2oa9x52oyg9w4rj5k81769510562099
* @returns {string} 提取出的 surveryId 值
*/
function extractSurveryIdFromUrl(url) {
if (!url) return "";
try {
// 使用正则表达式提取 surveryId 参数
// 处理格式: ?surveryId=xxx 或 &surveryId=xxx
const match = url.match(/[?&]surveryId=([^&]+)/);
return match ? decodeURIComponent(match[1]) : "";
} catch (error) {
console.error("解析问卷 URL 失败:", error);
return "";
}
}
const isRefreshingChatRoom = ref(false);
let lastRefreshChatRoomAt = 0;
function parseAnyTimeMs(v) {
if (v === null || v === undefined) return 0;
if (typeof v === "number") return v;
const s = String(v).trim();
if (!s) return 0;
if (/^\d{10,13}$/.test(s)) return Number(s.length === 10 ? `${s}000` : s);
const d = dayjs(s);
return d.isValid() ? d.valueOf() : 0;
}
async function refreshChatRoomByApi() {
const customerId = String(props.archiveId || "");
if (!customerId) return;
if (isRefreshingChatRoom.value) return;
const now = Date.now();
if (now - lastRefreshChatRoomAt < 5000) return;
lastRefreshChatRoomAt = now;
isRefreshingChatRoom.value = true;
try {
await ensureDoctor();
const corpId = getCorpId();
const teamId = getCurrentTeamId();
const baseQuery = {
corpId,
customerId,
page: 1,
pageSize: 50,
};
const queryWithTeam = teamId ? { ...baseQuery, teamId } : baseQuery;
let detailRes = await api("getGroupList", queryWithTeam, false);
let details = Array.isArray(detailRes?.data?.list) ? detailRes.data.list : [];
if (!details.length && teamId) {
detailRes = await api("getGroupList", baseQuery, false);
details = Array.isArray(detailRes?.data?.list) ? detailRes.data.list : [];
}
if (!detailRes?.success || !details.length) {
chatGroupId.value = "";
return;
}
const currentTeamId = getCurrentTeamId();
const detailsForCurrentTeam = currentTeamId
? details.filter(
(g) =>
String(g?.teamId || g?.team?._id || g?.team?.teamId || "") ===
currentTeamId
)
: [];
const candidates = detailsForCurrentTeam.length ? detailsForCurrentTeam : details;
const statusRank = (s) => (s === "processing" ? 3 : s === "pending" ? 2 : 1);
candidates.sort((a, b) => {
const ra = statusRank(String(a?.orderStatus || ""));
const rb = statusRank(String(b?.orderStatus || ""));
if (rb !== ra) return rb - ra;
const ta = parseAnyTimeMs(a?.updatedAt) || parseAnyTimeMs(a?.createdAt);
const tb = parseAnyTimeMs(b?.updatedAt) || parseAnyTimeMs(b?.createdAt);
return tb - ta;
});
const best = candidates[0] || {};
const gid = normalizeGroupId(best.groupId || best.groupID || best.group_id || "");
chatGroupId.value = gid ? String(gid) : "";
} catch (e) {
// ignore
} finally {
isRefreshingChatRoom.value = false;
}
}
// ---- filter popup ----
const filterPopupRef = ref(null);
const state = ref(null);
const teamPicked = ref(teamOptions.value[0]);
const planRange = ref(["", ""]);
function openFilter() {
state.value = {
query: {
...query,
eventTypes: [...(query.eventTypes || [])],
planRange: [...(query.planRange || ["", ""])],
},
team: { ...teamPicked.value },
range: [...planRange.value],
};
planRange.value = [...(query.planRange || ["", ""])];
teamPicked.value =
teamOptions.value.find((i) => i.value === query.teamId) ||
teamOptions.value[0];
filterPopupRef.value?.open?.();
}
function closeFilter(revert) {
if (revert && state.value) {
Object.assign(query, state.value.query);
planRange.value = state.value.range;
teamPicked.value = state.value.team;
}
filterPopupRef.value?.close?.();
}
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);
query.eventTypes = Array.from(set);
}
function pickTeam(e) {
teamPicked.value = teamOptions.value[e.detail.value] || teamOptions.value[0];
query.teamId = teamPicked.value.value;
}
function pickStart(e) {
planRange.value = [e.detail.value || "", planRange.value[1] || ""];
query.planRange = [...planRange.value];
}
function pickEnd(e) {
planRange.value = [planRange.value[0] || "", e.detail.value || ""];
query.planRange = [...planRange.value];
}
function clearPlanRange() {
planRange.value = ["", ""];
query.planRange = ["", ""];
}
function resetFilter() {
query.eventTypes = [];
query.teamId = "ALL";
teamPicked.value = teamOptions.value[0];
clearPlanRange();
}
function confirmFilter() {
filtered.value = Boolean(
(query.eventTypes && query.eventTypes.length) ||
query.teamId !== "ALL" ||
query.planRange[0] ||
query.planRange[1]
);
closeFilter(false);
resetList();
}
onMounted(() => {
const userId = getUserId();
const name = String(
doctorInfo.value?.anotherName || doctorInfo.value?.name || ""
);
if (userId && name)
userNameMap.value = { ...(userNameMap.value || {}), [userId]: name };
loadTeams();
refreshChatRoom();
resetList();
uni.$on("archive-detail:followup-changed", resetList);
});
onUnmounted(() => {
uni.$off("archive-detail:followup-changed", resetList);
});
watch(
() => props.reachBottomTime,
() => getMore()
);
</script>
<style scoped>
.wrap {
padding: 0 0 96px;
}
.top {
background: #f5f6f8;
padding: 10px 14px 8px;
border-bottom: 1px solid #f2f2f2;
}
.top-row {
display: flex;
align-items: center;
}
.my {
display: flex;
align-items: center;
flex-shrink: 0;
}
.checkbox {
width: 18px;
height: 18px;
margin-right: 6px;
}
.my-text {
font-size: 13px;
color: #333;
}
.status-scroll {
flex: 1;
margin-left: 10px;
}
.status-tabs {
display: flex;
flex-wrap: nowrap;
}
.status-tab {
flex-shrink: 0;
padding: 8px 10px;
font-size: 12px;
border-radius: 6px;
margin-right: 8px;
background: #eaecef;
color: #333;
}
.status-tab.active {
background: #dbe6ff;
color: #0877f1;
}
.filter-btn {
flex-shrink: 0;
padding-left: 10px;
}
.filter-icon {
width: 20px;
height: 20px;
}
.total {
margin-top: 6px;
font-size: 12px;
color: #666;
}
.total-num {
color: #ff4d4f;
margin: 0 4px;
}
.list {
padding: 0 14px;
}
.card {
background: #fff;
border-radius: 8px;
margin-top: 10px;
overflow: hidden;
}
.head {
padding: 12px 12px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid #f2f2f2;
}
.date {
font-size: 13px;
color: #666;
flex-shrink: 0;
margin-right: 10px;
}
.date-val {
color: #333;
}
.executor {
flex: 1;
text-align: right;
font-size: 13px;
color: #333;
}
.truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.body {
padding: 12px 12px;
}
.title-row {
display: flex;
align-items: center;
justify-content: space-between;
}
.type {
font-size: 15px;
font-weight: 600;
color: #1f1f1f;
}
.status {
font-size: 12px;
padding: 6px 10px;
border-radius: 6px;
}
.st-processing {
background: #ffe5e5;
color: #ff4d4f;
}
.st-notStart {
background: #dbe6ff;
color: #0877f1;
}
.st-treated {
background: #dcfce7;
color: #16a34a;
}
.st-cancelled,
.st-expired {
background: #f3f4f6;
color: #666;
}
.content {
margin-top: 10px;
font-size: 13px;
color: #666;
line-height: 18px;
}
.send-content-wrapper {
margin-top: 10px;
display: flex;
align-items: flex-start;
gap: 10px;
}
.send-content-section {
flex: 1;
padding: 10px;
background: #f9f9f9;
border-radius: 6px;
display: flex;
align-items: flex-start;
gap: 10px;
}
.send-content-label {
font-size: 13px;
color: #666;
flex-shrink: 0;
font-weight: 500;
white-space: nowrap;
}
.send-content-body {
flex: 1;
min-width: 0;
}
.send-text {
font-size: 13px;
color: #333;
line-height: 18px;
word-break: break-word;
}
.file-list {
margin-top: 6px;
}
.file-list.no-send-text {
margin-top: 0;
}
.file-item {
display: flex;
align-items: center;
gap: 6px;
}
.file-item + .file-item {
margin-top: 4px;
}
.file-icon {
font-size: 14px;
flex-shrink: 0;
}
.file-name {
font-size: 12px;
color: #0877f1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
width: 330rpx;
}
.file-type-image .file-name {
color: #0877f1;
}
.file-type-article .file-name {
color: #16a34a;
}
.file-type-questionnaire .file-name {
color: #f59e0b;
}
.result {
margin-top: 10px;
font-size: 13px;
color: #666;
}
.footer-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid #f2f2f2;
gap: 10px;
}
.footer {
font-size: 12px;
color: #999;
flex: 1;
}
.footer-row .send-btn {
flex: 0 0 auto;
width: 60px;
height: 28px;
line-height: 28px;
padding: 0;
margin: 0;
}
.card-actions {
display: flex;
gap: 10px;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid #f2f2f2;
}
.action-btn {
flex: 1;
height: 32px;
line-height: 32px;
border-radius: 6px;
font-size: 12px;
border: none;
background: #f5f6f8;
color: #333;
}
.action-btn::after {
border: none;
}
.detail-btn {
background: #f5f6f8;
color: #333;
}
.send-btn {
flex: 0 0 auto;
width: 60px;
background: #0877f1;
color: #fff;
}
.send-btn.loading {
opacity: 0.6;
}
.send-btn:disabled {
opacity: 0.6;
}
.empty {
padding: 120px 0;
text-align: center;
color: #9aa0a6;
font-size: 13px;
}
.fab {
position: fixed;
right: 16px;
width: 52px;
height: 52px;
border-radius: 26px;
background: #0877f1;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 10px 18px rgba(79, 110, 247, 0.35);
z-index: 20;
}
.popup {
background: #fff;
border-top-left-radius: 10px;
border-top-right-radius: 10px;
overflow: hidden;
}
.popup-title {
position: relative;
padding: 14px;
border-bottom: 1px solid #f0f0f0;
}
.popup-title-text {
text-align: center;
font-size: 16px;
font-weight: 600;
color: #333;
}
.popup-close {
position: absolute;
right: 12px;
top: 0;
height: 100%;
display: flex;
align-items: center;
}
.popup-body {
max-height: 70vh;
}
.section {
padding: 14px;
}
.section-title {
font-size: 14px;
font-weight: 600;
color: #333;
margin-bottom: 10px;
}
.chip-wrap {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.chip {
width: 110px;
text-align: center;
padding: 8px 0;
border: 1px solid #e6e6e6;
border-radius: 8px;
font-size: 12px;
color: #333;
}
.chip.active {
background: #0877f1;
border-color: #0877f1;
color: #fff;
}
.select-row {
height: 40px;
border: 1px solid #e6e6e6;
border-radius: 8px;
padding: 0 10px;
display: flex;
align-items: center;
justify-content: space-between;
}
.select-text {
font-size: 12px;
color: #333;
max-width: 250px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.select-text.muted {
color: #999;
}
.range-row {
display: flex;
align-items: center;
gap: 8px;
}
.range-pill {
width: 110px;
height: 40px;
line-height: 40px;
text-align: center;
border: 1px solid #e6e6e6;
border-radius: 8px;
font-size: 12px;
color: #333;
}
.range-pill.muted {
color: #999;
}
.sep {
color: #999;
}
.clear {
padding: 6px;
}
.actions {
padding: 12px 14px calc(12px + env(safe-area-inset-bottom));
display: flex;
gap: 12px;
}
.btn {
flex: 1;
height: 44px;
line-height: 44px;
border-radius: 6px;
font-size: 15px;
}
.btn::after {
border: none;
}
.btn.plain {
background: #fff;
color: #0877f1;
border: 1px solid #0877f1;
}
.btn.primary {
background: #0877f1;
color: #fff;
}
</style>