610 lines
15 KiB
Vue
610 lines
15 KiB
Vue
<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>
|