Files
gao-customer-h5/src/pages/customer/CustomerDetail.vue
T
2026-08-31 11:27:01 +08:00

523 lines
14 KiB
Vue

<template>
<div
v-if="customer"
v-can="permissionCodes.customer.read"
class="customer-detail"
>
<customer-profile-card
v-entrance-transition
class="customer"
:customer="customer"
/>
<t-cell-group v-entrance-transition="70" class="info" theme="card">
<t-cell title="出生日期">
<span
v-if="isLunarBirthdate"
class="birthdate-trigger gray"
type="button"
@click.stop="toggleBirthdateText"
>
<span v-text="birthdateText" />
</span>
<span v-else v-text="birthdateText" />
</t-cell>
<t-cell title="年龄" :note="ageText" />
<t-cell title="调理部位" :note="selectedAreaText" arrow @click="openBodyAreaPicker" />
<t-cell title="住址" :note="Text.display(customer.address)" />
<t-cell title="病症" :note="Text.display(customer.disease)" />
<t-cell title="备注" :note="Text.display(customer.remark)" />
<t-cell title="录入用户" :note="Text.display(customer.creatorUser?.nick || customer.creatorUser?.name)" />
<t-cell title="录入时间">
<template #note>
<pass-time-label :timestamp="customer.createdAt" underline />
</template>
</t-cell>
</t-cell-group>
<card-container v-entrance-transition="140">
<photo-scroller
class="photo-scroller"
:model-value="photoList"
:photo-items="photoItemList"
:show-viewer-actions="canUpdateCustomer"
:show-add="false"
:show-remove="false"
:submit="submitPhotoItems"
title="照片列表"
/>
</card-container>
<customer-tag-card
v-if="canReadTag"
:editable="canApplyTag"
:tag-list="customerTagList"
v-entrance-transition="210"
@edit="openTagSelector"
/>
<t-cell-group theme="card" v-entrance-transition="280">
<t-cell v-if="canReadEventRecord" title="最近登记事件" arrow @click="toEventRecordList">
<template #left-icon>
<t-icon name="history" />
</template>
</t-cell>
<t-cell v-if="canReadStat" title="客户数据图表" arrow @click="toCustomerDataCharts">
<template #left-icon>
<t-icon name="chart" />
</template>
</t-cell>
<t-cell
v-if="canReadPointAccount"
title="积分余额"
:note="pointBalanceText"
arrow
@click="toPointAccount"
>
<template #left-icon>
<t-icon name="money" />
</template>
</t-cell>
</t-cell-group>
<t-cell-group
v-if="inviter"
:title="`由 ${inviter.name} 邀请`"
theme="card"
v-entrance-transition="350"
>
<customer-profile-card :customer="inviter" @click="toCustomerDetail(inviter)"/>
</t-cell-group>
<t-cell-group theme="card">
<t-cell title="邀请客户" :note="inviteeCountText" arrow @click="toInviteeList" />
</t-cell-group>
<t-button
v-can="permissionCodes.customer.delete"
class="delete-action"
theme="danger"
:loading="isRemoving"
@click="confirmRemoveCustomer"
>
删除客户
</t-button>
<tag-selector
v-model="selectedTagIdList"
v-model:visible="tagSelectorVisible"
@change="handleTagSelected"
/>
<bottom-popup
v-model="bodyAreaPickerVisible"
title="调理部位"
height="38rem"
left-button-text="关闭"
right-button-text=""
@left-button-click="closeBodyAreaPicker"
>
<body-area-selector :model-value="selectedAreas" readonly />
</bottom-popup>
<customer-remove-dialog
v-model:visible="removeDialogVisible"
:customer="removeTarget"
:loading="isRemoving"
@confirm="removeConfirmedCustomer"
/>
</div>
</template>
<script setup lang="ts">
import { BottomPopup } from "timi-tdesign-mobile";
import { Message } from "@/utils/message";
import { PassTimeLabel, Text } from "timi-web";
import { useUserStore, type Attachment } from "timi-web-api";
import CustomerAPI from "@/api/CustomerAPI";
import PointAPI from "@/api/PointAPI";
import TagAPI from "@/api/TagAPI";
import BodyAreaSelector from "@/components/BodyAreaSelector.vue";
import CustomerRemoveDialog from "@/pages/customer/component/CustomerRemoveDialog.vue";
import CustomerProfileCard from "@/components/CustomerProfileCard.vue";
import PhotoScroller from "@/components/PhotoScroller.vue";
import TagSelector from "@/components/TagSelector.vue";
import CustomerTagCard from "@/pages/customer/component/CustomerTagCard.vue";
import { useNavBarStore } from "@/store/navBarStore";
import type { BodyAreaValue, Customer } from "@/types/Customer";
import type { PointAccount } from "@/types/Point";
import type { PhotoItem } from "@/types/Photo";
import type { Tag } from "@/types/Tag";
import { bodyAreaLabelMap } from "@/utils/bodyArea";
import { getAttachmentReadURL } from "@/utils/attachment";
import { normalizeBodyAreaValue } from "@/utils/customer";
import { corePermissionCodes, permissionCodes } from "@/utils/permission";
import { LunarCalendar } from "@/utils/LunarCalendar";
defineOptions({
name: "CustomerDetail"
});
const route = useRoute();
const router = useRouter();
const navBarStore = useNavBarStore();
const userStore = useUserStore();
const isRemoving = ref(false);
const removeDialogVisible = ref(false);
const removeTarget = ref<Customer>();
const canReadStat = computed(() => userStore.hasPermissions(permissionCodes.stat.read));
const canReadEventRecord = computed(() => userStore.hasPermissions(permissionCodes.eventRecord.read));
const canReadPointAccount = computed(() => userStore.hasPermissions(permissionCodes.point.accountRead));
const canReadTag = computed(() => userStore.hasPermissions(corePermissionCodes.tag.read));
const canApplyTag = computed(() => userStore.hasPermissions(corePermissionCodes.tag.apply));
const canUpdateCustomer = computed(() => userStore.hasPermissions(permissionCodes.customer.update));
// ---------- 资料 ----------
const customer = ref<Customer>();
const showSolarBirthdate = ref(false);
const isLunarBirthdate = computed(() => customer.value?.birthdateCalendar === "LUNAR" && !!customer.value?.birthdate);
const solarBirthdateText = computed(() => formatSolarBirthdate(customer.value));
const birthdateText = computed(() => showSolarBirthdate.value && isLunarBirthdate.value ? solarBirthdateText.value : formatBirthdate(customer.value));
const ageText = computed(() => customer.value?.age === undefined || customer.value?.age === null ? "" : `${customer.value.age} 岁`);
const inviteeCountText = computed(() => `${customer.value?.invitedCount ?? 0} 人`);
const photoItemList = computed(() => (customer.value?.attachmentList || [])
.filter(item => !!item.id)
.map(item => ({
attachment: item,
attachType: item.attachType || "PHOTO",
previewUrl: getAttachmentReadURL(item.id || ""),
url: getAttachmentReadURL(item.id || "", 90)
})));
const photoList = computed(() => photoItemList.value.map(item => item.url));
async function submitPhotoItems(photoItems: PhotoItem[]): Promise<void> {
const currentCustomer = customer.value;
if (!currentCustomer?.id || !currentCustomer.name) {
throw new Error("客户资料不完整");
}
const attachmentList = buildPhotoAttachmentList(photoItems);
const payload: Customer = {
...currentCustomer,
attachmentList
};
delete payload.tagApply;
delete payload.introducerCustomer;
await CustomerAPI.update(payload);
customer.value = {
...currentCustomer,
attachmentList
};
}
function buildPhotoAttachmentList(photoItems: PhotoItem[]): Attachment[] {
return photoItems.flatMap(item => {
if (item.attachment) {
return [{
...item.attachment,
attachType: item.attachType || item.attachment.attachType || "PHOTO"
}];
}
if (item.tempFileId) {
return [{
attachType: item.attachType || "PHOTO",
tempFileId: item.tempFileId
}];
}
return [];
});
}
const selectedAreas = computed<BodyAreaValue>({
get() {
return normalizeBodyAreaValue(customer.value?.conditioningPart);
},
set() {
return;
}
});
const bodyAreaPickerVisible = ref(false);
const selectedAreaText = computed(() => {
const selectedLabelList = selectedAreas.value.selectedCodeList.map(item => bodyAreaLabelMap.get(item) || item);
return selectedLabelList.length ? selectedLabelList.join("、") : "未选择";
});
function openBodyAreaPicker(): void {
bodyAreaPickerVisible.value = true;
}
function closeBodyAreaPicker(): void {
bodyAreaPickerVisible.value = false;
}
function formatBirthdate(value?: Customer): string {
if (!value?.birthdate) {
return "";
}
if (value.birthdateCalendar !== "LUNAR") {
return `公历 ${value.birthdate}`;
}
const lunarDate = LunarCalendar.solarToLunar(value.birthdate);
return lunarDate ? LunarCalendar.formatLunarDate(lunarDate) : "农历日期";
}
function formatSolarBirthdate(value?: Customer): string {
return value?.birthdate ? `公历 ${value.birthdate}` : "无";
}
function toggleBirthdateText(): void {
if (isLunarBirthdate.value) {
showSolarBirthdate.value = !showSolarBirthdate.value;
}
}
async function loadCustomer(): Promise<void> {
const id = String(route.params.id || "");
if (!id) {
customer.value = undefined;
customerTagList.value = [];
selectedTagIdList.value = [];
showSolarBirthdate.value = false;
return;
}
try {
showSolarBirthdate.value = false;
customer.value = await CustomerAPI.detail(id);
await loadPointAccount();
await loadCustomerTags();
} catch (err) {
customer.value = undefined;
customerTagList.value = [];
selectedTagIdList.value = [];
console.warn("客户详情加载失败", err);
Message.warning("客户详情加载失败");
}
}
// ---------- 客户标签 ----------
const customerTagList = ref<Tag[]>([]);
const selectedTagIdList = ref<string[]>([]);
const tagSelectorVisible = ref(false);
async function loadCustomerTags(): Promise<void> {
const customerId = customer.value?.id;
if (!customerId || !canReadTag.value) {
customerTagList.value = [];
selectedTagIdList.value = [];
return;
}
customerTagList.value = await TagAPI.listByBizType("GAO_CUSTOMER", customerId);
selectedTagIdList.value = customerTagList.value.map(item => item.id || "").filter(Boolean);
}
function openTagSelector(): void {
if (!canApplyTag.value) {
return;
}
selectedTagIdList.value = customerTagList.value.map(item => item.id || "").filter(Boolean);
tagSelectorVisible.value = true;
}
async function handleTagSelected(tagList: Tag[]): Promise<void> {
const customerId = customer.value?.id;
if (!customerId) {
return;
}
await TagAPI.apply({
bizType: "GAO_CUSTOMER",
bizId: customerId,
tagIdList: tagList.map(item => item.id || "").filter(Boolean)
});
customerTagList.value = tagList;
selectedTagIdList.value = tagList.map(item => item.id || "").filter(Boolean);
Message.success("客户标签已保存");
}
const inviter = computed(() => customer.value?.introducerCustomer);
// ---------- 删除客户 ----------
async function confirmRemoveCustomer(): Promise<void> {
const currentCustomer = customer.value;
if (!currentCustomer?.id || isRemoving.value) {
return;
}
removeTarget.value = currentCustomer;
removeDialogVisible.value = true;
}
function closeRemoveDialog(): void {
removeDialogVisible.value = false;
removeTarget.value = undefined;
}
async function removeConfirmedCustomer(target: Customer): Promise<void> {
const customerId = target.id;
if (!customerId || isRemoving.value) {
return;
}
closeRemoveDialog();
await removeCustomer(customerId);
}
async function removeCustomer(id: string): Promise<void> {
isRemoving.value = true;
try {
await CustomerAPI.remove(id);
Message.success("客户已删除");
await router.replace("/management/customers");
} finally {
isRemoving.value = false;
}
}
async function toInviteeList(): Promise<void> {
if (!customer.value?.id) {
return;
}
await router.push(`/management/customers/${customer.value.id}/invitees`);
}
async function toEventRecordList(): Promise<void> {
if (!canReadEventRecord.value || !customer.value?.id) {
return;
}
await router.push({
name: "EventRecordList",
query: {
customerId: customer.value.id,
customerName: customer.value.name || "",
transitionDepth: String(Number(route.meta.depth ?? 0) + 1)
}
});
}
async function toEdit(): Promise<void> {
if (!customer.value?.id) {
return;
}
await router.push(`/management/customers/${customer.value.id}/edit`);
}
// ---------- 积分账户 ----------
const pointAccount = ref<PointAccount>();
const pointBalanceText = computed(() => pointAccount.value ? `${(pointAccount.value.balance ?? 0).toLocaleString("zh-CN")}` : "未产生积分流水");
async function loadPointAccount(): Promise<void> {
pointAccount.value = undefined;
if (!customer.value?.id || !canReadPointAccount.value) {
return;
}
try {
pointAccount.value = await PointAPI.detailByCustomerId(customer.value.id, customer.value.storeId);
} catch {
pointAccount.value = undefined;
}
}
async function toPointAccount(): Promise<void> {
if (!customer.value?.id) {
return;
}
if (!pointAccount.value?.id) {
await router.push({ name: "PointAccountList" });
return;
}
await router.push({
name: "PointAccountDetail",
params: { id: pointAccount.value.id },
query: {
storeId: customer.value.storeId || "",
transitionDepth: String(Number(route.meta.depth ?? 0) + 1)
}
});
}
// ---------- 加载客户 ----------
async function toCustomerDetail(target: Customer): Promise<void> {
if (!target.id) {
return;
}
await router.push(`/management/customers/${target.id}`);
}
async function toCustomerDataCharts(): Promise<void> {
if (!customer.value?.id) {
return;
}
await router.push(`/management/customers/${customer.value.id}/data-charts`);
}
function updateNavBar(): void {
navBarStore.clearRight();
if (userStore.hasPermissions(permissionCodes.customer.update)) {
navBarStore.setRightText("编辑", "var(--td-brand-color)", toEdit);
}
}
onMounted(async () => {
await loadCustomer();
updateNavBar();
});
watch(() => route.params.id, loadCustomer);
onUnmounted(() => navBarStore.clearRight());
</script>
<style scoped lang="less">
.customer-detail {
gap: 1rem;
padding: calc(var(--app-nav-height) + 1rem) 0 2rem;
display: flex;
flex-direction: column;
.customer {
margin: 0 1rem;
}
.info {
:deep(.t-cell__title-text) {
white-space: nowrap;
}
:deep(.t-cell__note) {
flex: 1 1 auto;
min-width: 0;
white-space: normal;
overflow-wrap: anywhere;
}
.birthdate-trigger {
text-align: right;
text-decoration: underline;
text-underline-offset: .2rem;
}
}
:deep(.photo-scroller) {
padding: 1rem;
}
.delete-action {
--td-button-border-radius: 1rem;
margin: 1rem;
}
}
:global(html.theme-dark) {
.customer-detail {
.profile-main p,
.label,
.meta,
.sex-line,
.empty {
color: #94a3b8;
}
}
}
</style>