523 lines
14 KiB
Vue
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>
|