ykt-wxapp/components/archive-detail/service-info-tab.vue
2026-01-27 16:46:36 +08:00

621 lines
16 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">{{ i.executorName }}</view>
<view class="meta-text truncate">{{ i.executeTeamName }}</view>
</view>
<view class="body">
<view class="content" :class="{ clamp: !expandMap[i._id] }">
{{ i.taskContentDisplay || '暂无内容' }}
</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>
</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 || {};
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 getCurrentTeamId() {
const t = uni.getStorageSync('ykt_case_current_team') || {};
return String(t.teamId || '') || '';
}
function resolveUserName(userId) {
const id = String(userId || '');
if (!id) return '';
const map = userNameMap.value || {};
return String(map[id] || '') || id;
}
function formatTaskContent(text) {
if (typeof text !== 'string') return '';
return text.replace(/&&&([^&]+)&&&/g, (_, id) => resolveUserName(id)).trim();
}
async function loadTeamMembers(teamId) {
const tid = String(teamId || '') || '';
if (!tid) return;
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 || ''),
hasFile,
fileType,
timeStr: i.executionTime ? dayjs(i.executionTime).format('YYYY-MM-DD HH:mm') : '--',
typeStr: getServiceTypeLabel(i.eventType),
taskContentDisplay: formatTaskContent(String(i?.taskContent || '')),
};
}
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;
} 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) {
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,
id: String(record?._id || ''),
executionTime: record?.executionTime || 0,
executeTeamId: String(record?.executeTeamId || ''),
executeTeamName: String(record?.executeTeamName || ''),
eventType: String(record?.eventType || ''),
taskContent: String(record?.taskContent || ''),
pannedEventSendFile: record?.pannedEventSendFile || null,
});
uni.navigateTo({ url: `/pages/case/service-record-detail?archiveId=${encodeURIComponent(props.archiveId)}&mode=edit&id=${encodeURIComponent(record._id)}` });
}
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();
}
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: 8px 0 96px;
}
.filters {
padding: 10px 14px;
background: #fff;
border-bottom: 1px solid #f0f0f0;
display: flex;
align-items: center;
gap: 12px;
}
.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: 1px solid #e5e7eb;
border-radius: 6px;
padding: 10px 12px;
display: flex;
align-items: center;
gap: 8px;
width: 100%;
min-width: 0;
box-sizing: border-box; /* Ensure padding doesn't overflow width */
}
.pill-text {
font-size: 13px;
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: 6px;
padding: 10px 0 70px;
}
.cell {
padding: 0 14px;
position: relative;
}
.head {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
height: 44px;
padding-left: 18px;
}
.dot {
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 8px;
height: 8px;
border-radius: 50%;
background: #4f6ef7;
}
.time {
font-size: 14px;
font-weight: 600;
color: #1f1f1f;
}
.file-link {
font-size: 13px;
color: #4f6ef7;
}
.meta {
display: flex;
align-items: center;
gap: 10px;
padding-left: 18px;
margin-bottom: 6px;
}
.tag {
font-size: 12px;
color: #4f6ef7;
border: 1px solid #4f6ef7;
border-radius: 999px;
padding: 4px 8px;
}
.meta-text {
font-size: 13px;
color: #333;
}
.truncate {
max-width: 160px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.body {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding-left: 18px;
padding-bottom: 12px;
}
.content {
flex: 1;
font-size: 13px;
color: #666;
line-height: 18px;
margin-right: 10px;
}
.content.clamp {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
overflow: hidden;
}
.pen {
width: 18px;
height: 18px;
margin-top: 2px;
}
.line {
position: absolute;
left: 18px;
top: 34px;
bottom: 0;
width: 2px;
background: #4f6ef7;
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: #4f6ef7;
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-body2 {
padding: 14px;
}
.desc {
font-size: 14px;
color: #333;
line-height: 20px;
word-break: break-all;
margin-bottom: 14px;
}
.btn {
width: 100%;
height: 44px;
line-height: 44px;
border-radius: 6px;
font-size: 15px;
}
.btn::after {
border: none;
}
.btn.primary {
background: #4f6ef7;
color: #fff;
}
</style>