2026-02-06 18:04:34 +08:00

811 lines
21 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/service-info/service-info.vue -->
<view class="wrap">
<view class="filters">
<picker class="filter-item" mode="selector" :range="typeList" range-key="label" @change="pickType">
<view class="filter-pill">
<view class="pill-text" :class="{ muted: currentType.value === 'ALL' }">{{ currentType.value === 'ALL' ? '服务类型' : currentType.label }}</view>
<uni-icons type="arrowdown" size="12" color="#666" />
</view>
</picker>
<uni-datetime-picker class="filter-item wide-item" v-model="dateRange" type="daterange" rangeSeparator="-" @change="changeDates">
<view class="filter-pill wide">
<view class="pill-text" :class="{ muted: !dateRange.length }">
{{ dateRange.length ? dateRange.join('~') : '服务时间' }}
</view>
<view class="pill-icon" @click.stop="clearDates">
<uni-icons v-if="dateRange.length" type="closeempty" size="14" color="#666" />
<uni-icons v-else type="arrowdown" size="12" color="#666" />
</view>
</view>
</uni-datetime-picker>
<picker class="filter-item" mode="selector" :range="teamList" range-key="label" @change="pickTeam">
<view class="filter-pill">
<view class="pill-text">{{ currentTeam.label }}</view>
<uni-icons type="arrowdown" size="12" color="#666" />
</view>
</picker>
</view>
<view class="timeline">
<view v-for="(i, idx) in list" :key="i._id" class="cell" @click="toggleExpand(i)">
<view class="head">
<view class="dot"></view>
<view class="time">{{ i.timeStr }}</view>
<view v-if="i.hasFile" class="file-link" @click.stop="viewFile(i)">
{{ i.fileType === 'article' ? '查看文章' : '查看问卷' }}
</view>
</view>
<view class="meta">
<view class="tag">{{ i.typeStr }}</view>
<view class="meta-text">{{ executorText(i) }}</view>
<view class="meta-text truncate">{{ executeTeamText(i) }}</view>
</view>
<view class="body">
<view class="content" :class="{ clamp: !expandMap[i._id] }">
{{ displayTaskContent(i) || '暂无内容' }}
</view>
<image class="pen" src="/static/icons/icon-pen.svg" @click.stop="edit(i)" />
</view>
<view v-if="idx < list.length - 1" class="line"></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>
<uni-popup ref="filePopupRef" type="bottom" :mask-click="true">
<view class="popup">
<view class="popup-title">
<view class="popup-title-text">{{ fileTitle }}</view>
<view class="popup-close" @click="closeFilePopup">
<uni-icons type="closeempty" size="18" color="#666" />
</view>
</view>
<view class="popup-body2">
<view class="desc">{{ fileDesc }}</view>
<button class="btn primary" @click="copyFile">{{ fileAction }}</button>
</view>
</view>
</uni-popup>
<!-- 编辑服务内容 -->
<uni-popup ref="editPopupRef" type="bottom" :mask-click="true" @maskClick="closeEditPopup">
<view class="edit-sheet">
<view class="edit-header">
<view class="edit-header-left" />
<view class="edit-title">修改服务内容</view>
<view class="edit-close" @click="closeEditPopup">
<uni-icons type="closeempty" size="18" color="#333" />
</view>
</view>
<view class="edit-body">
<textarea
v-model="editContent"
class="edit-textarea"
placeholder="请输入服务内容"
:maxlength="1000"
/>
<view class="counter">{{ (editContent || '').length }}/1000</view>
</view>
<view class="edit-footer">
<button class="btn primary" @click="saveEdit">保存</button>
</view>
</view>
</uni-popup>
</view>
</template>
<script setup>
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import dayjs from 'dayjs';
import { storeToRefs } from 'pinia';
import api from '@/utils/api';
import useAccountStore from '@/store/account';
import { toast } from '@/utils/widget';
import { getServiceTypeLabel, getServiceTypeOptions } from '@/utils/service-type-const';
const props = defineProps({
data: { type: Object, default: () => ({}) },
archiveId: { type: String, default: '' },
reachBottomTime: { type: [String, Number], default: '' },
floatingBottom: { type: Number, default: 16 },
});
const typeList = [{ label: '全部', value: 'ALL' }, ...getServiceTypeOptions({ excludeCustomerUpdate: true })];
const teamList = ref([{ label: '全部', value: 'ALL' }]);
const currentType = ref(typeList[0]);
const currentTeam = ref(teamList.value[0]);
const dateRange = ref([]);
const page = ref(1);
const pageSize = 10;
const pages = ref(1);
const loading = ref(false);
const list = ref([]);
const expandMap = ref({});
const userNameMap = ref({});
const accountStore = useAccountStore();
const { account, doctorInfo } = storeToRefs(accountStore);
const { getDoctorInfo } = accountStore;
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 || {};
const t = uni.getStorageSync('ykt_case_current_team') || {};
return String(d.userid || d.userId || d.corpUserId || a.userid || a.userId || t.userId || t.userid || t.corpUserId || '') || '';
}
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 getCurrentTeamId() {
const t = uni.getStorageSync('ykt_case_current_team') || {};
return String(t.teamId || '') || '';
}
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 getExecutorId(r) {
const row = r && typeof r === 'object' ? r : {};
return String(
row.executorUserId ||
row.executorId ||
row.executor ||
row.creatorUserId ||
row.creator ||
row.updateUserId ||
''
) || '';
}
function executorText(r) {
const row = r && typeof r === 'object' ? r : {};
const fromRow = normalizeName(row.executorName || row.executorUserName || row.creatorName || row.updateUserName || '');
if (fromRow) return fromRow;
const uid = getExecutorId(row);
const mapped = normalizeName(resolveUserName(uid));
return mapped || (uid ? uid : '--');
}
function getExecuteTeamId(r) {
const row = r && typeof r === 'object' ? r : {};
return String(row.executeTeamId || row.teamId || row.teamID || '') || '';
}
function resolveTeamName(teamId) {
const tid = String(teamId || '') || '';
if (!tid) return '';
const list = teamList.value || [];
const hit = list.find((i) => i && i.value === tid);
return hit?.label ? String(hit.label) : '';
}
function executeTeamText(r) {
const row = r && typeof r === 'object' ? r : {};
const fromRow = normalizeName(row.executeTeamName || row.teamName || '');
if (fromRow) return fromRow;
const tid = getExecuteTeamId(row) || getCurrentTeamId();
const mapped = normalizeName(resolveTeamName(tid));
return mapped || (tid ? tid : '--');
}
function resolveUserName(userId) {
const id = String(userId || '');
if (!id) return '';
const map = userNameMap.value || {};
return String(map[id] || '') || id;
}
function escapeRegExp(str) {
return String(str).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function replaceKnownUserIds(text) {
const map = userNameMap.value || {};
const ids = Object.keys(map);
if (!ids.length) return text;
let out = text;
ids.forEach((id) => {
const name = String(map[id] || '');
if (!id || !name || name === id) return;
const re = new RegExp(`(^|[^0-9A-Za-z_])(${escapeRegExp(id)})(?=[^0-9A-Za-z_]|$)`, 'g');
out = out.replace(re, (_, p1) => `${p1}${name}`);
});
return out;
}
function formatTaskContent(text) {
if (typeof text !== 'string') return '';
const withPlaceholders = text.replace(/&&&([^&]+)&&&/g, (_, id) => resolveUserName(id));
return replaceKnownUserIds(withPlaceholders).trim();
}
function displayTaskContent(r) {
return formatTaskContent(String(r?.taskContent || ''));
}
const loadedTeamMemberIds = new Set();
async function loadTeamMembers(teamId) {
const tid = String(teamId || '') || '';
if (!tid) return;
if (loadedTeamMemberIds.has(tid)) return;
loadedTeamMemberIds.add(tid);
await ensureDoctor();
const corpId = getCorpId();
if (!corpId) return;
const res = await api('getTeamData', { corpId, teamId: tid });
if (!res?.success) return;
const t = res?.data && typeof res.data === 'object' ? res.data : {};
const members = Array.isArray(t.memberList) ? t.memberList : [];
const map = members.reduce((acc, m) => {
const uid = String(m?.userid || '');
if (!uid) return acc;
acc[uid] = String(m?.anotherName || m?.name || m?.userid || '') || uid;
return acc;
}, {});
userNameMap.value = { ...(userNameMap.value || {}), ...map };
}
const moreStatus = computed(() => {
if (loading.value) return 'loading';
return page.value <= pages.value ? 'more' : 'no-more';
});
const loadMoreText = {
contentdown: '点击加载更多',
contentrefresh: '加载中...',
contentnomore: '没有更多了',
};
function mapRow(i) {
const hasFile = Boolean(i.pannedEventSendFile);
const fileType = i.pannedEventSendFile?.type === 'article' ? 'article' : i.pannedEventSendFile?.type === 'questionnaire' ? 'questionnaire' : '';
return {
...i,
_id: String(i?._id || i?.id || ''),
executorUserId: getExecutorId(i),
executeTeamId: getExecuteTeamId(i),
hasFile,
fileType,
timeStr: i.executionTime ? dayjs(i.executionTime).format('YYYY-MM-DD HH:mm') : '--',
typeStr: getServiceTypeLabel(i.eventType),
};
}
function reset() {
page.value = 1;
pages.value = 1;
list.value = [];
getMore();
}
function getEventTypeList() {
const v = currentType.value?.value || 'ALL';
if (v === 'ALL') return typeList.filter((i) => i.value !== 'ALL').map((i) => i.value);
return [v];
}
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();
if (!corpId) {
toast('缺少 corpId请先完成登录/团队选择');
return;
}
const params = {
corpId,
customerId: String(props.archiveId),
eventTypeList: getEventTypeList(),
};
if (dateRange.value.length) params.executionTime = dateRange.value;
const teamId = currentTeam.value?.value && currentTeam.value.value !== 'ALL' ? currentTeam.value.value : '';
const res = await api('getServiceRecord', {
page: page.value,
pageSize,
params,
queryType: '',
teamId,
});
if (!res?.success) {
toast(res?.message || '获取服务记录失败');
return;
}
const payload = res?.data;
const arr = Array.isArray(payload?.data) ? payload.data : Array.isArray(payload) ? payload : [];
const p =
typeof payload?.pages === 'number'
? payload.pages
: typeof payload?.total === 'number'
? Math.ceil(payload.total / pageSize) || 0
: typeof res?.total === 'number'
? Math.ceil(res.total / pageSize) || 0
: arr.length < pageSize
? page.value
: page.value + 1;
pages.value = p;
const mapped = arr.map(mapRow).filter((i) => i && i._id);
list.value = page.value === 1 ? mapped : [...list.value, ...mapped];
page.value += 1;
// 尽量加载记录所属团队成员,用于执行人展示
const teamIds = mapped.map((i) => i.executeTeamId).filter(Boolean);
Array.from(new Set(teamIds)).forEach((tid) => loadTeamMembers(tid));
} finally {
loading.value = false;
}
}
function toggleExpand(r) {
expandMap.value[r._id] = !expandMap.value[r._id];
}
function pickType(e) {
currentType.value = typeList[e.detail.value] || typeList[0];
reset();
}
function pickTeam(e) {
currentTeam.value = teamList.value[e.detail.value] || teamList.value[0];
const tid = currentTeam.value?.value && currentTeam.value.value !== 'ALL' ? currentTeam.value.value : getCurrentTeamId();
loadTeamMembers(tid);
reset();
}
function changeDates() {
reset();
}
function clearDates() {
if (!dateRange.value.length) return;
dateRange.value = [];
reset();
}
function add() {
const archive = props.data || {};
const customerUserId = String(archive.externalUserId || archive.customerUserId || '') || '';
uni.setStorageSync('service-record-detail', {
customerId: String(props.archiveId),
customerName: String(archive.name || ''),
customerUserId,
eventType: '',
});
uni.navigateTo({ url: `/pages/case/service-record-detail?archiveId=${encodeURIComponent(props.archiveId)}&mode=add` });
}
function edit(record) {
if (!record?._id) return;
editingRecord.value = record;
editContent.value = String(record?.taskContent || '') || '';
editPopupRef.value?.open?.();
}
const filePopupRef = ref(null);
const fileTitle = ref('');
const fileDesc = ref('');
const fileAction = ref('');
const fileCopyText = ref('');
function viewFile(record) {
if (!record.hasFile) return;
if (record.fileType === 'article') {
fileTitle.value = '查看文章';
fileDesc.value = record.pannedEventSendFile?.url || '';
fileAction.value = '复制链接';
fileCopyText.value = record.pannedEventSendFile?.url || '';
} else {
fileTitle.value = '查看问卷';
fileDesc.value = `问卷ID: ${record.pannedEventSendFile?.surveryId || '--'}`;
fileAction.value = '复制问卷ID';
fileCopyText.value = record.pannedEventSendFile?.surveryId || '';
}
filePopupRef.value?.open?.();
}
function closeFilePopup() {
filePopupRef.value?.close?.();
}
function copyFile() {
if (!fileCopyText.value) return;
uni.setClipboardData({
data: fileCopyText.value,
success: () => uni.showToast({ title: '已复制', icon: 'success' }),
});
closeFilePopup();
}
const editPopupRef = ref(null);
const editingRecord = ref(null);
const editContent = ref('');
function closeEditPopup() {
editPopupRef.value?.close?.();
editingRecord.value = null;
}
async function saveEdit() {
const r = editingRecord.value;
if (!r?._id) return;
if (!String(editContent.value || '').trim()) return toast('请输入服务内容');
await ensureDoctor();
const corpId = getCorpId();
const userId = getUserId();
if (!corpId || !userId) return toast('缺少用户信息');
const res = await api('updateServiceRecord', {
corpId,
id: String(r._id),
params: {
taskContent: String(editContent.value || ''),
updateUserId: userId,
},
});
if (!res?.success) return toast(res?.message || '修改失败');
// 本地同步,避免闪烁
const idx = list.value.findIndex((i) => i && i._id === String(r._id));
if (idx > -1) list.value[idx] = { ...list.value[idx], taskContent: String(editContent.value || '') };
uni.$emit('archive-detail:service-record-changed');
uni.showToast({ title: '保存成功', icon: 'success' });
closeEditPopup();
}
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);
teamList.value = [{ label: '全部', value: 'ALL' }, ...normalized];
currentTeam.value = teamList.value.find((i) => i.value === currentTeam.value?.value) || teamList.value[0];
}
onMounted(() => {
loadTeams();
loadTeamMembers(getCurrentTeamId());
reset();
uni.$on('archive-detail:service-record-changed', reset);
});
onUnmounted(() => {
uni.$off('archive-detail:service-record-changed', reset);
});
watch(
() => props.reachBottomTime,
() => getMore()
);
</script>
<style scoped>
.wrap {
padding: 16rpx 0 192rpx;
}
.filters {
padding: 20rpx 28rpx;
background: #fff;
border-bottom: 2rpx solid #f0f0f0;
display: flex;
align-items: center;
gap: 24rpx;
}
.filter-item {
display: block;
flex: 1;
min-width: 0;
}
.filter-item.wide-item {
flex: 1.4;
}
/* Removed old deep selectors */
.filter-pill {
background: #f7f8fa;
border: 2rpx solid #e5e7eb;
border-radius: 12rpx;
padding: 20rpx 24rpx;
display: flex;
align-items: center;
gap: 16rpx;
width: 100%;
min-width: 0;
box-sizing: border-box; /* Ensure padding doesn't overflow width */
}
.pill-text {
font-size: 26rpx;
color: #333;
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 500;
}
.pill-text.muted {
color: #999;
font-weight: 400;
}
.pill-icon {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
}
.filter-pill :deep(uni-icons) {
flex-shrink: 0;
}
.timeline {
background: #fff;
margin-top: 12rpx;
padding: 20rpx 0 140rpx;
}
.cell {
padding: 0 28rpx;
position: relative;
}
.head {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
height: 88rpx;
padding-left: 36rpx;
}
.dot {
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 16rpx;
height: 16rpx;
border-radius: 50%;
background: #0877F1;
}
.time {
font-size: 28rpx;
font-weight: 600;
color: #1f1f1f;
}
.file-link {
font-size: 26rpx;
color: #0877F1;
}
.meta {
display: flex;
align-items: center;
gap: 20rpx;
padding-left: 36rpx;
margin-bottom: 12rpx;
}
.tag {
font-size: 24rpx;
color: #0877F1;
border: 2rpx solid #0877F1;
border-radius: 999rpx;
padding: 8rpx 16rpx;
}
.meta-text {
font-size: 26rpx;
color: #333;
}
.truncate {
max-width: 320rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.body {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding-left: 36rpx;
padding-bottom: 24rpx;
}
.content {
flex: 1;
font-size: 26rpx;
color: #666;
line-height: 36rpx;
margin-right: 20rpx;
}
.content.clamp {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
overflow: hidden;
}
.pen {
width: 36rpx;
height: 36rpx;
margin-top: 4rpx;
}
.line {
position: absolute;
left: 36rpx;
top: 68rpx;
bottom: 0;
width: 4rpx;
background: #0877F1;
opacity: 0.6;
}
.empty {
padding: 240rpx 0;
text-align: center;
color: #9aa0a6;
font-size: 26rpx;
}
.fab {
position: fixed;
right: 32rpx;
width: 104rpx;
height: 104rpx;
border-radius: 52rpx;
background: #0877F1;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 20rpx 36rpx rgba(79, 110, 247, 0.35);
z-index: 20;
}
.popup {
background: #fff;
border-top-left-radius: 20rpx;
border-top-right-radius: 20rpx;
overflow: hidden;
}
.popup-title {
position: relative;
padding: 28rpx;
border-bottom: 2rpx solid #f0f0f0;
}
.popup-title-text {
text-align: center;
font-size: 32rpx;
font-weight: 600;
color: #333;
}
.popup-close {
position: absolute;
right: 24rpx;
top: 0;
height: 100%;
display: flex;
align-items: center;
}
.popup-body2 {
padding: 28rpx;
}
.desc {
font-size: 28rpx;
color: #333;
line-height: 40rpx;
word-break: break-all;
margin-bottom: 28rpx;
}
.btn {
width: 100%;
height: 88rpx;
line-height: 88rpx;
border-radius: 12rpx;
font-size: 30rpx;
}
.btn::after {
border: none;
}
.btn.primary {
background: #0877F1;
color: #fff;
}
.edit-sheet {
background: #fff;
border-top-left-radius: 20rpx;
border-top-right-radius: 20rpx;
overflow: hidden;
padding-bottom: calc(24rpx + env(safe-area-inset-bottom));
}
.edit-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 28rpx;
border-bottom: 2rpx solid #f0f0f0;
}
.edit-header-left {
width: 36rpx;
height: 36rpx;
}
.edit-title {
font-size: 32rpx;
font-weight: 600;
color: #333;
}
.edit-close {
width: 36rpx;
height: 36rpx;
display: flex;
align-items: center;
justify-content: center;
}
.edit-body {
padding: 28rpx 28rpx 0;
}
.edit-textarea {
width: 100%;
height: 260rpx;
padding: 20rpx;
border: 2rpx solid #e5e7eb;
border-radius: 12rpx;
font-size: 28rpx;
box-sizing: border-box;
}
.counter {
margin-top: 12rpx;
text-align: right;
font-size: 24rpx;
color: #999;
}
.edit-footer {
padding: 24rpx 28rpx 0;
}
</style>