Files
gao-customer-h5/src/pages/customer/CustomerDetail.vue
T
2026-08-11 15:24:36 +08:00

587 lines
14 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>
<div v-can="permissionCodes.customer.read" class="customer-detail">
<customer-profile-card
class="customer"
:customer="customer"
/>
<t-cell-group v-if="customer" 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="customer.address || '无'" />
<t-cell title="病症" :note="customer.disease || '无'" />
<t-cell title="备注" :note="customer.remark || '无'" />
<t-cell title="录入用户" :note="creatorName" />
<t-cell title="录入时间" :note="createdAtText" />
</t-cell-group>
<t-cell-group v-if="customer && canReadStat" theme="card">
<t-cell title="客户数据图表" arrow @click="toCustomerDataCharts">
<template #leftIcon>
<t-icon name="chart" size="1.5rem" />
</template>
</t-cell>
</t-cell-group>
<t-cell-group theme="card">
<div class="conditioning-part bg-white">
<span class="gray">调理部位</span>
<body-area-selector v-model="selectedAreas" />
</div>
</t-cell-group>
<t-cell-group v-if="inviter" :title="`由 ${inviter.name} 邀请`" class="invite-list" theme="card">
<customer-profile-card :customer="inviter" @click="toCustomerDetail(inviter)"/>
</t-cell-group>
<div class="invite-list">
<div class="header">
<div class="title">邀请客户</div>
<div v-can="permissionCodes.customer.update" class="actions">
<t-button
theme="primary"
variant="outline"
size="extra-small"
content="选择添加"
@click="selectorVisible = true"
/>
<t-button
theme="primary"
size="extra-small"
content="扫码添加"
@click="openScanPopup"
/>
</div>
</div>
<t-cell-group theme="card">
<t-swipe-cell
class="item"
v-for="(item, index) in invitees"
:key="item.id || item.code || index"
>
<t-cell
class="cell"
:title="item.name || item.code || '-'"
arrow
hover
@click="toCustomerDetail(item)"
>
<template #description>
<telephone-link :value="item.telephone" placeholder="未填写电话" />
</template>
<template #leftIcon>
<t-image
v-if="getCustomerPhotoURL(item)"
class="avatar"
:src="getCustomerPhotoURL(item)"
:alt="item.name"
fit="cover"
/>
<div v-else class="avatar fallback" v-text="(item.name || '客').slice(0, 1)" />
</template>
</t-cell>
<template #right>
<div v-can="permissionCodes.customer.update" class="remove bg-red white" @click="removeInvitee(item)">删除</div>
</template>
</t-swipe-cell>
<div v-if="!invitees.length" class="empty">暂无邀请客户</div>
</t-cell-group>
</div>
<div class="bottom-actions">
<t-button
v-can="permissionCodes.customer.delete"
class="delete bg-white"
theme="danger"
variant="outline"
:loading="isRemoving"
@click="confirmRemoveCustomer"
>
删除客户
</t-button>
<t-button v-can="permissionCodes.customer.update" theme="primary" class="edit" @click="toEdit">编辑客户</t-button>
</div>
<customer-selector
v-model="selectedInviteeId"
v-model:visible="selectorVisible"
@change="handleInviteeSelected"
/>
<t-popup v-model="scanPopupVisible" placement="bottom">
<div class="scan-popup bg-white">
<div class="header">
<t-button
variant="text"
size="small"
@click="closeScanPopup"
content="取消"
/>
<h3>扫码添加邀请客户</h3>
<t-button
theme="primary"
size="small"
content="确认"
@click="confirmScannedInvitee"
/>
</div>
<code-scanner
v-if="scanPopupVisible"
:visible="scanPopupVisible"
:status-text="scanStatus"
active-text="正在识别客户编码,请将二维码移入取景框"
@decode="handleDecodedText"
@status-change="handleScanStatusChange"
/>
<div v-if="pendingInvitee" class="confirm">
<customer-profile-card :customer="pendingInvitee" />
</div>
</div>
</t-popup>
</div>
</template>
<script setup lang="ts">
import { DialogPlugin, Toast } from "tdesign-mobile-vue";
import { Time, useUserStore } from "timi-web";
import CustomerAPI from "@/api/CustomerAPI";
import BodyAreaSelector from "@/components/BodyAreaSelector.vue";
import CodeScanner from "@/components/CodeScanner.vue";
import CustomerProfileCard from "@/components/CustomerProfileCard.vue";
import CustomerSelector from "@/components/CustomerSelector.vue";
import { useSettingStore } from "@/store/settingStore";
import type { BodyAreaValue, Customer } from "@/types/Customer";
import { getCustomerPhotoURL, normalizeBodyAreaValue } from "@/utils/customer";
import { permissionCodes } from "@/utils/permission";
import { LunarCalendar } from "@/utils/LunarCalendar";
defineOptions({
name: "CustomerDetail"
});
const route = useRoute();
const router = useRouter();
const settingStore = useSettingStore();
const userStore = useUserStore();
const customer = ref<Customer>();
const invitees = ref<Customer[]>([]);
const selectedInviteeId = ref("");
const selectorVisible = ref(false);
const scanPopupVisible = ref(false);
const pendingInvitee = ref<Customer>();
const scanStatus = ref("点击扫码开始识别。");
const isRemoving = ref(false);
const showSolarBirthdate = ref(false);
let latestDecodedText = "";
const inviter = computed(() => customer.value?.introducerCustomer);
const canReadStat = computed(() => userStore.hasPermissions(permissionCodes.stat.read));
const creatorName = computed(() => {
const creatorUser = customer.value?.creatorUser;
return creatorUser?.nick || creatorUser?.name || customer.value?.creatorUserId || "无";
});
const createdAtText = computed(() => customer.value?.createdAt ? Time.toPassedDateTime(customer.value.createdAt) : "无");
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 selectedAreas = computed<BodyAreaValue>({
get() {
return normalizeBodyAreaValue(customer.value?.conditioningPart);
},
set() {
return;
}
});
async function loadCustomer(): Promise<void> {
const id = String(route.params.id || "");
if (!id) {
customer.value = undefined;
invitees.value = [];
showSolarBirthdate.value = false;
return;
}
try {
showSolarBirthdate.value = false;
customer.value = await CustomerAPI.detail(id);
await loadInvitees();
} catch (err) {
customer.value = undefined;
invitees.value = [];
console.warn("客户详情加载失败", err);
Toast.warning("客户详情加载失败");
}
}
async function loadInvitees(): Promise<void> {
const customerId = customer.value?.id;
if (!customerId) {
invitees.value = [];
return;
}
invitees.value = await CustomerAPI.listInvited(customerId);
}
async function removeInvitee(item: Customer): Promise<void> {
if (!item.id) {
return;
}
await CustomerAPI.deleteInvite(item.id);
await loadCustomer();
Toast.success("已删除邀请关系");
}
async function confirmRemoveCustomer(): Promise<void> {
const currentCustomer = customer.value;
if (!currentCustomer?.id) {
return;
}
const customerId = currentCustomer.id;
const customerName = currentCustomer.name || currentCustomer.code || "未命名客户";
const dialog = DialogPlugin.confirm({
title: "删除客户",
content: `确定删除客户 ${customerName} 吗?删除后无法恢复。`,
cancelBtn: "取消",
confirmBtn: "删除",
onCancel() {
dialog.destroy();
},
async onConfirm() {
dialog.destroy();
await removeCustomer(customerId);
}
});
}
async function removeCustomer(id: string): Promise<void> {
isRemoving.value = true;
try {
await CustomerAPI.remove(id);
Toast.success("客户已删除");
await router.replace("/management/customers");
} finally {
isRemoving.value = false;
}
}
async function toEdit(): Promise<void> {
if (!customer.value?.id) {
return;
}
await router.push(`/management/customers/${customer.value.id}/edit`);
}
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;
}
}
function isCustomerAncestor(invitee: Customer): boolean {
if (!customer.value?.id) {
return false;
}
let current = customer.value.introducerCustomer;
while (current?.id) {
if (current.id === invitee.id) {
return true;
}
current = current.introducerCustomer;
}
return false;
}
async function addInvitee(invitee: Customer): Promise<boolean> {
if (!customer.value?.id || !invitee.id) {
return false;
}
if (invitee.id === customer.value.id) {
Toast.warning("不能把客户本人添加为邀请客户");
return false;
}
if (isCustomerAncestor(invitee)) {
Toast.warning("不能把上级邀请人添加为邀请客户");
return false;
}
await CustomerAPI.saveInvite(customer.value.id, invitee.id);
await loadCustomer();
Toast.success(`已添加 ${invitee.name || invitee.code}`);
return true;
}
async function handleInviteeSelected(id: string): Promise<void> {
selectedInviteeId.value = id;
const invitee = await CustomerAPI.detail(id);
await addInvitee(invitee);
}
function openScanPopup(): void {
pendingInvitee.value = undefined;
latestDecodedText = "";
scanStatus.value = "正在启动扫码预览";
scanPopupVisible.value = true;
}
function closeScanPopup(): void {
scanPopupVisible.value = false;
}
async function handleDecodedText(rawText: string): Promise<void> {
const normalizedText = rawText.trim();
if (!normalizedText || normalizedText === latestDecodedText) {
return;
}
latestDecodedText = normalizedText;
await settingStore.playScanSuccessSound();
const matched = await loadCustomerByCode(normalizedText);
if (!matched) {
scanStatus.value = `已识别 ${normalizedText},未匹配客户档案。`;
return;
}
pendingInvitee.value = matched;
scanStatus.value = `已识别 ${matched.code},请确认是否添加 ${matched.name}。`;
}
function handleScanStatusChange(value: string): void {
scanStatus.value = value;
}
async function confirmScannedInvitee(): Promise<void> {
if (!pendingInvitee.value) {
Toast.warning("请先扫码识别客户");
return;
}
if (await addInvitee(pendingInvitee.value)) {
closeScanPopup();
}
}
async function loadCustomerByCode(code: string): Promise<Customer | undefined> {
return await CustomerAPI.findByCode(code) || undefined;
}
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`);
}
onMounted(async () => {
await loadCustomer();
});
watch(() => route.params.id, async () => {
await loadCustomer();
});
</script>
<style scoped lang="less">
.customer-detail {
gap: 1rem;
padding: calc(var(--app-nav-height) + 1rem) 0 4rem 0;
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;
}
}
.conditioning-part {
gap: .5rem;
padding: 1rem;
display: flex;
flex-direction: column;
}
.invite-list {
.header {
display: flex;
padding: 0 var(--td-cell-group-title-padding-left, 16px);
font-size: var(--td-cell-group-title-font-size, 14px);
text-align: left;
background: var(--td-cell-group-title-bg-color, var(--td-bg-color-secondarycontainer, var(--td-gray-color-1, #f3f3f3)));
align-items: center;
line-height: var(--td-cell-group-title-line-height, 45px);
justify-content: space-between;
.title {
color: var(--td-cell-group-title-color, var(--td-text-color-placeholder, var(--td-font-gray-3, rgba(0, 0, 0, .4))));
}
.actions {
gap: .875rem;
display: flex;
}
}
.item {
&:last-child {
:deep(.t-cell:after) {
display: none;
}
}
.cell {
padding: 6px 10px 6px 6px;
.avatar {
width: 3.25rem;
height: 3.25rem;
border-radius: .875rem;
&.fallback {
color: #2563eb;
display: flex;
font-size: 1.125rem;
background: #dbeafe;
align-items: center;
font-weight: 600;
justify-content: center;
}
}
}
}
.remove {
width: fit-content;
padding: 0 1rem;
display: flex;
align-items: center;
}
.empty {
color: #52607a;
padding: 1rem;
text-align: center;
background: #f7faff;
border-radius: 1rem;
}
}
.bottom-actions {
gap: .75rem;
left: 1rem;
right: 1rem;
display: flex;
bottom: calc(.75rem + env(safe-area-inset-bottom, 0));
position: fixed;
.delete {
flex: 1;
}
.edit {
flex: 2;
}
}
}
.birthdate-trigger {
text-align: right;
text-decoration: underline;
text-underline-offset: .2rem;
}
.scan-popup {
gap: 1rem;
padding: 1rem 1rem calc(1rem + env(safe-area-inset-bottom, 0)) 1rem;
display: flex;
overflow: auto;
max-height: 86vh;
border-radius: 1.25rem 1.25rem 0 0;
flex-direction: column;
.header {
display: flex;
align-items: center;
justify-content: space-between;
h3 {
margin: 0;
font-size: 1rem;
}
}
}
:global(html.theme-dark) {
.customer-detail {
.profile-main p,
.label,
.meta,
.sex-line,
.empty {
color: #94a3b8;
}
.scan-popup {
color: #e2e8f0;
background: rgba(15, 23, 42, .96);
}
.avatar {
&.fallback {
color: #93c5fd;
background: rgba(30, 64, 175, .32);
}
}
}
}
</style>