Files
gao-customer-h5/src/pages/customer/CustomerDetail.vue
T

610 lines
15 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="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="录入时间" :note="Time.toPassedDateTime(customer.createdAt)" />
</t-cell-group>
<t-cell-group v-if="customer" theme="card">
<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 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 { Text, Time, useUserStore } from "timi-web";
import CustomerAPI from "@/api/CustomerAPI";
import PointAPI from "@/api/PointAPI";
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 type { PointAccount } from "@/types/Point";
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 isRemoving = ref(false);
const canReadStat = computed(() => userStore.hasPermissions(permissionCodes.stat.read));
const canReadPointAccount = computed(() => userStore.hasPermissions(permissionCodes.point.accountRead));
// ---------- 资料 ----------
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 selectedAreas = computed<BodyAreaValue>({
get() {
return normalizeBodyAreaValue(customer.value?.conditioningPart);
},
set() {
return;
}
});
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;
invitees.value = [];
showSolarBirthdate.value = false;
return;
}
try {
showSolarBirthdate.value = false;
customer.value = await CustomerAPI.detail(id);
await loadPointAccount();
await loadInvitees();
} catch (err) {
customer.value = undefined;
invitees.value = [];
console.warn("客户详情加载失败", err);
Toast.warning("客户详情加载失败");
}
}
// ---------- 邀请来源和邀请列表 ----------
let latestDecodedText = "";
const invitees = ref<Customer[]>([]);
const selectedInviteeId = ref("");
const selectorVisible = ref(false);
const scanPopupVisible = ref(false);
const pendingInvitee = ref<Customer>();
const scanStatus = ref("点击扫码开始识别");
const inviter = computed(() => customer.value?.introducerCustomer);
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 loadInvitees(): Promise<void> {
const customerId = customer.value?.id;
if (!customerId) {
invitees.value = [];
return;
}
invitees.value = await CustomerAPI.listInvited(customerId);
}
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);
}
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`);
}
// ---------- 积分账户 ----------
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.accountDetail(customer.value.id, customer.value.storeId);
} catch {
pointAccount.value = undefined;
}
}
async function toPointAccount(): Promise<void> {
if (!customer.value?.id) {
return;
}
await router.push({
name: "PointAccount",
query: {
customerId: customer.value.id,
storeId: customer.value.storeId || "",
transitionDepth: String(Number(route.meta.depth ?? 0) + 1)
}
});
}
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 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(loadCustomer);
watch(() => route.params.id, 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;
}
}
}
.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);
}
}
}
</style>