ykt-wxapp/components/archive-detail/customer-profile-tab.vue
2026-01-30 10:45:36 +08:00

558 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/customer-profile.vue -->
<view class="wrap">
<view class="card">
<view id="anchor-base" class="section-title" @click="startEdit">
<text>基本信息</text>
<image class="pen" src="/static/icons/icon-pen.svg" />
</view>
<view class="rows">
<form-template
v-if="editing && effectiveBaseItems.length"
ref="baseFormRef"
:items="effectiveBaseItems"
:form="forms"
@change="onChange"
/>
<template v-else>
<view
v-for="item in effectiveBaseItems"
:key="item.title"
class="row"
>
<view class="label">{{ item.name || item.title }}</view>
<view
v-if="item.title === 'mobile' && displayValue(item) !== '-'"
class="val link"
@click="call(rawValue(item))"
>
{{ displayValue(item) }}
</view>
<view v-else class="val" :class="displayValue(item) === '-' ? 'muted' : ''">{{ displayValue(item) }}</view>
</view>
</template>
</view>
</view>
<view class="card">
<view id="anchor-internal" class="section-title" @click="startEdit">
<text>内部信息</text>
<image class="pen" src="/static/icons/icon-pen.svg" />
</view>
<view class="rows">
<view class="row" @click="openTransferRecord">
<view class="label">院内来源</view>
<view class="val link">
{{ latestTransferRecord?.executeTeamName || '点击查看' }}
<uni-icons type="arrowright" size="14" color="#4f6ef7" />
</view>
</view>
<form-template
v-if="editing && effectiveInternalItems.length"
ref="internalFormRef"
:items="effectiveInternalItems"
:form="forms"
:filterRule="filterRule"
@change="onChange"
/>
<template v-else>
<view
v-for="item in effectiveInternalItems"
:key="item.title"
class="row"
>
<view class="label">{{ item.name || item.title }}</view>
<view class="val" :class="displayValue(item) === '-' ? 'muted' : ''">{{ displayValue(item) }}</view>
</view>
</template>
</view>
</view>
<view v-if="editing" class="bottom-bar" :style="{ bottom: `${floatingBottom}px` }">
<button class="btn plain" @click="cancel">取消</button>
<button class="btn primary" @click="save">保存</button>
</view>
<uni-popup ref="transferPopupRef" type="bottom" :mask-click="true">
<view class="popup">
<view class="popup-title">
<view class="popup-title-text">院内流转记录</view>
<view class="popup-close" @click="closeTransferRecord">
<uni-icons type="closeempty" size="18" color="#666" />
</view>
</view>
<scroll-view scroll-y class="popup-body">
<view v-if="transferRecords.length" class="timeline">
<view class="line"></view>
<view v-for="r in transferRecords" :key="r._id" class="item">
<view class="dot"></view>
<view class="content">
<view class="time">{{ r.createTime }}</view>
<view class="card2">
<view v-if="r.executeTeamName" class="trow"><text class="tlabel">转入团队:</text>{{ r.executeTeamName }}</view>
<view v-if="r.eventTypeName" class="trow"><text class="tlabel">转入方式:</text>{{ r.eventTypeName }}</view>
<view v-if="r.creatorUserName" class="trow">
<text class="tlabel">操作人:</text>
<text>{{ r.creatorUserName }}</text>
</view>
</view>
</view>
</view>
</view>
<view v-else class="empty-state">
<text class="empty-text">暂无流转记录</text>
</view>
</scroll-view>
</view>
</uni-popup>
</view>
</template>
<script setup>
import { computed, reactive, ref, watch, onMounted } from 'vue';
import { storeToRefs } from 'pinia';
import dayjs from 'dayjs';
import FormTemplate from '@/components/form-template/index.vue';
import api from '@/utils/api';
import useAccountStore from '@/store/account';
import useTeamStore from '@/store/team';
const props = defineProps({
data: { type: Object, default: () => ({}) },
baseItems: { type: Array, default: () => ([]) },
internalItems: { type: Array, default: () => ([]) },
floatingBottom: { type: Number, default: 16 },
});
const emit = defineEmits(['save']);
const accountStore = useAccountStore();
const teamStore = useTeamStore();
const { account, doctorInfo } = storeToRefs(accountStore);
const { teams } = storeToRefs(teamStore);
const editing = ref(false);
const baseFormRef = ref(null);
const internalFormRef = ref(null);
const patch = reactive({});
const forms = computed(() => ({ ...(props.data || {}), ...patch }));
const fallbackBaseItems = [
{ title: 'name', name: '姓名', type: 'input', operateType: 'formCell', required: true, wordLimit: 20 },
{ title: 'sex', name: '性别', type: 'select', operateType: 'formCell', required: false, range: ['男', '女'] },
{ title: 'age', name: '年龄', type: 'input', inputType: 'number', operateType: 'formCell', required: false, wordLimit: 3 },
{ title: 'mobile', name: '联系电话', type: 'input', inputType: 'number', operateType: 'formCell', required: false, wordLimit: 11 },
];
const fallbackInternalItems = [
{ title: 'notes', name: '备注', type: 'textarea', operateType: 'formCell', required: false, wordLimit: 200 },
];
const effectiveBaseItems = computed(() => (Array.isArray(props.baseItems) && props.baseItems.length ? props.baseItems : fallbackBaseItems));
const effectiveInternalItems = computed(() => (Array.isArray(props.internalItems) && props.internalItems.length ? props.internalItems : fallbackInternalItems));
const filterRule = {
reference(formModel) {
const customerSource = Array.isArray(formModel.customerSource)
? formModel.customerSource
: typeof formModel.customerSource === 'string'
? [formModel.customerSource]
: [];
return ['同事推荐', '客户推荐'].includes(customerSource[0]) && customerSource.length === 1;
},
};
watch(
() => props.data,
() => {
if (!editing.value) Object.keys(patch).forEach((k) => delete patch[k]);
},
{ deep: true }
);
function normalizeChangeValue(value) {
if (value && typeof value === 'object' && 'value' in value) return value.value;
return value;
}
function startEdit() {
if (editing.value) return;
editing.value = true;
}
function cancel() {
editing.value = false;
Object.keys(patch).forEach((k) => delete patch[k]);
}
function save() {
if (baseFormRef.value?.verify && !baseFormRef.value.verify()) return;
if (internalFormRef.value?.verify && !internalFormRef.value.verify()) return;
const out = { ...patch };
emit('save', out);
editing.value = false;
Object.keys(patch).forEach((k) => delete patch[k]);
}
function onChange({ title, value }) {
patch[title] = normalizeChangeValue(value);
}
function call(mobile) {
if (!mobile) return;
uni.makePhoneCall({ phoneNumber: String(mobile) });
}
function rawValue(item) {
const title = item?.title;
if (!title) return '';
const v = forms.value?.[title];
return v === undefined || v === null ? '' : v;
}
function displayValue(item) {
const title = item?.title;
const type = item?.type;
if (!title) return '-';
const v = forms.value?.[title];
if (v === undefined || v === null || v === '') return '-';
if (type === 'files') {
const list = Array.isArray(v) ? v : typeof v === 'string' && v ? [v] : [];
return list.length ? `已上传${list.length}` : '-';
}
if (Array.isArray(v)) {
// 对于数组类型如标签查找range中的label
if (type === 'select' || type === 'radio' || type === 'selectAndImage' || type === 'tagPicker' || item.__originType === 'tag') {
const range = Array.isArray(item?.range) ? item.range : [];
if (range.length && typeof range[0] === 'object') {
const labels = v.map((val) => {
const found = range.find((i) => String(i?.value) === String(val));
return found ? String(found.label || found.value || val) : String(val);
}).filter(Boolean);
return labels.length ? labels.join('、') : '-';
}
}
return v.filter(Boolean).join('、') || '-';
}
if (typeof v === 'object') {
if ('label' in v) return String(v.label || '-');
if ('name' in v) return String(v.name || '-');
if ('value' in v) return String(v.value || '-');
return '-';
}
if (type === 'select' || type === 'radio' || type === 'selectAndImage') {
const range = Array.isArray(item?.range) ? item.range : [];
if (range.length && typeof range[0] === 'object') {
const found = range.find((i) => String(i?.value) === String(v));
if (found) return String(found.label || found.value || '-');
}
}
return String(v);
}
function scrollToAnchor(key) {
activeAnchor.value = key;
const selector = `#anchor-${key}`;
const query = uni.createSelectorQuery();
query.select(selector).boundingClientRect();
query.selectViewport().scrollOffset();
query.exec((res) => {
const rect = res[0];
const scroll = res[1];
if (!rect || !scroll) return;
uni.pageScrollTo({ scrollTop: rect.top + scroll.scrollTop - 60, duration: 200 });
});
}
const transferPopupRef = ref(null);
const transferRecords = ref([]);
const latestTransferRecord = computed(() => transferRecords.value[0]);
const userNameMap = ref({});
const CURRENT_TEAM_STORAGE_KEY = 'ykt_case_current_team';
function getCorpId() {
const d = doctorInfo.value || {};
const a = account.value || {};
const team = uni.getStorageSync(CURRENT_TEAM_STORAGE_KEY) || {};
return String(d.corpId || a.corpId || team.corpId || '') || '';
}
async function loadTeamMembers() {
const corpId = getCorpId();
const team = uni.getStorageSync(CURRENT_TEAM_STORAGE_KEY) || {};
const teamId = team?.teamId ? String(team.teamId) : '';
if (!corpId || !teamId) return;
if (Object.keys(userNameMap.value || {}).length > 0) return;
try {
const res = await api('getTeamData', { corpId, teamId });
if (!res?.success) return;
const t = res?.data && typeof res.data === 'object' ? res.data : {};
const members = Array.isArray(t.memberList) ? t.memberList : [];
userNameMap.value = 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;
}, {});
} catch (e) {
console.error('获取团队成员失败', e);
}
}
function resolveUserName(userId) {
const id = String(userId || '');
if (!id) return '';
const map = userNameMap.value || {};
return String(map[id] || id) || id;
}
const ServiceType = {
remindFiling: '自主开拓',
adminTransferTeams: '团队流转',
adminRemoveTeams: '移出团队',
systemAutoDistribute: '系统分配',
bindWechatCustomer: '绑定企业微信',
share: '共享',
transfer: '转移',
importCustomer: '导入客户',
addCustomer: '新增客户',
};
async function fetchTransferRecords() {
const customerId = props.data?._id;
if (!customerId) return;
// 先加载团队成员信息
await loadTeamMembers();
try {
const res = await api('customerTransferRecord', { customerId });
if (res?.success && res.list) {
const allTeams = Array.isArray(teams.value) ? teams.value : [];
transferRecords.value = res.list.map((item) => {
const record = { ...item };
record.createTime = dayjs(item.createTime).format('YYYY-MM-DD HH:mm:ss');
record.executeTeamName = allTeams.find((team) => team.teamId === item.executeTeamId)?.name || item.executeTeamName;
record.eventTypeName = ServiceType[item.eventType] || item.eventType;
record.creatorUserName = item.creatorUserId === 'system' ? '系统自动建档' : resolveUserName(item.creatorUserId);
if (item.transferToTeamIds && Array.isArray(item.transferToTeamIds)) {
record.teamName = item.transferToTeamIds.map((teamId) => allTeams.find((team) => team.teamId === teamId)?.name).join('、');
}
if (item.removeTeamIds && Array.isArray(item.removeTeamIds)) {
record.teamName = item.removeTeamIds.map((teamId) => allTeams.find((team) => team.teamId === teamId)?.name).join('、');
}
if (item.eventType === 'remindFiling') record.eventTypeName = '自主开拓';
return record;
});
}
} catch (e) {
console.error('获取流转记录失败', e);
}
}
async function openTransferRecord() {
await fetchTransferRecords();
transferPopupRef.value?.open?.();
}
function closeTransferRecord() {
transferPopupRef.value?.close?.();
}
onMounted(() => {
fetchTransferRecords();
});
watch(() => props.data?._id, () => {
if (props.data?._id) fetchTransferRecords();
});
</script>
<style scoped>
.wrap {
padding: 24rpx 0 192rpx;
}
.card {
background: #fff;
margin-top: 20rpx;
}
.section-title {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24rpx 28rpx;
font-size: 30rpx;
font-weight: 600;
color: #333;
border-bottom: 2rpx solid #f2f2f2;
}
.pen {
width: 36rpx;
height: 36rpx;
}
.rows {
padding: 4rpx 28rpx;
}
.row {
display: flex;
align-items: center;
padding: 24rpx 0;
border-bottom: 2rpx solid #f6f6f6;
}
.row:last-child {
border-bottom: none;
}
.label {
width: 180rpx;
font-size: 28rpx;
color: #666;
}
.val {
flex: 1;
text-align: right;
font-size: 28rpx;
color: #333;
}
.val.link {
color: #4f6ef7;
display: flex;
justify-content: flex-end;
align-items: center;
gap: 12rpx;
}
.input,
.picker {
flex: 1;
text-align: right;
font-size: 28rpx;
color: #333;
}
.bottom-bar {
position: fixed;
left: 0;
right: 0;
background: #fff;
padding: 24rpx 28rpx;
display: flex;
gap: 24rpx;
box-shadow: 0 -8rpx 24rpx rgba(0, 0, 0, 0.06);
z-index: 30;
}
.btn {
flex: 1;
height: 88rpx;
line-height: 88rpx;
border-radius: 12rpx;
font-size: 30rpx;
}
.btn::after {
border: none;
}
.btn.plain {
background: #fff;
color: #4f6ef7;
border: 2rpx solid #4f6ef7;
}
.btn.primary {
background: #4f6ef7;
color: #fff;
}
.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-body {
max-height: 70vh;
}
.timeline {
position: relative;
padding: 40rpx 28rpx 36rpx 28rpx;
}
.line {
position: absolute;
left: 48rpx;
top: 56rpx;
bottom: 36rpx;
width: 4rpx;
background: #4f6ef7;
}
.item {
position: relative;
padding-left: 80rpx;
margin-bottom: 40rpx;
}
.item:last-child {
margin-bottom: 0;
}
.dot {
position: absolute;
left: 6rpx;
top: 8rpx;
width: 24rpx;
height: 24rpx;
border-radius: 50%;
background: #4f6ef7;
border: 4rpx solid #fff;
box-shadow: 0 0 0 4rpx #4f6ef7;
}
.time {
font-size: 24rpx;
color: #999;
margin-bottom: 16rpx;
}
.card2 {
border: 2rpx solid #f0f0f0;
border-radius: 16rpx;
padding: 24rpx;
background: #fafafa;
}
.trow {
font-size: 28rpx;
color: #333;
line-height: 40rpx;
}
.tlabel {
color: #666;
margin-right: 12rpx;
}
.empty-state {
padding: 80rpx 40rpx;
text-align: center;
}
.empty-text {
font-size: 28rpx;
color: #999;
}
</style>