330 lines
9.0 KiB
Vue
330 lines
9.0 KiB
Vue
<template>
|
||
<div v-can="permissionCodes.stat.read" class="customer-data-charts-page">
|
||
<div class="header bg-white">
|
||
<div class="buttons">
|
||
<t-button class="selector" size="small" theme="primary" variant="outline" @click="selectorVisible = true">
|
||
<template #icon>
|
||
<t-icon name="user-search" />
|
||
</template>
|
||
<span>选择客户</span>
|
||
</t-button>
|
||
<t-button size="small" theme="primary" @click="toggleScanner">
|
||
<template #icon>
|
||
<t-icon name="scan" />
|
||
</template>
|
||
<span v-text="scannerVisible ? '关闭扫码' : '扫码'" />
|
||
</t-button>
|
||
</div>
|
||
<div v-if="scannerVisible" class="line scanner-wrapper">
|
||
<code-scanner
|
||
visible
|
||
:status-text="scanStatus"
|
||
active-text="正在识别客户编码,请将二维码移入取景框"
|
||
@decode="handleDecodedText"
|
||
@status-change="handleScanStatusChange"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<customer-profile-card v-entrance-transition class="customer" :customer="selectedCustomer" />
|
||
<div class="charts">
|
||
<customer-chart-summary
|
||
v-entrance-transition="70"
|
||
v-if="selectedCustomer"
|
||
:customer="selectedCustomer"
|
||
:month-cursor="monthCursor"
|
||
:selected-event-id-list="selectedEventIdList"
|
||
/>
|
||
<customer-event-record-calendar-section
|
||
v-entrance-transition="140"
|
||
v-if="selectedCustomer"
|
||
v-model="monthCursor"
|
||
v-model:selected-event-id-list="selectedEventIdList"
|
||
:customer-id="selectedCustomer.id || ''"
|
||
:customer-name="selectedCustomer.name"
|
||
:store-id="selectedCustomer.storeId"
|
||
@list="toEventRecordList"
|
||
/>
|
||
<customer-record-trend-chart
|
||
v-entrance-transition="210"
|
||
v-if="selectedCustomer"
|
||
:customer-id="selectedCustomer.id || ''"
|
||
:date-text-list="chartDateTextList"
|
||
:selected-event-id-list="selectedEventIdList"
|
||
/>
|
||
<customer-event-structure-chart
|
||
v-entrance-transition="280"
|
||
v-if="selectedCustomer"
|
||
:customer-id="selectedCustomer.id || ''"
|
||
:date-text-list="monthDateTextList"
|
||
:selected-event-id-list="selectedEventIdList"
|
||
/>
|
||
<customer-selector
|
||
v-entrance-transition="350"
|
||
v-model="selectedCustomerId"
|
||
v-model:visible="selectorVisible"
|
||
@change="handleCustomerSelected"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { Time } from "timi-web";
|
||
import CustomerAPI from "@/api/CustomerAPI";
|
||
import CodeScanner from "@/components/CodeScanner.vue";
|
||
import CustomerSelector from "@/components/CustomerSelector.vue";
|
||
import type { EventRecordCalendarListPayload } from "@/components/EventRecordCalendar.vue";
|
||
import CustomerChartSummary from "@/pages/customer/component/CustomerChartSummary.vue";
|
||
import CustomerEventRecordCalendarSection from "@/pages/customer/component/CustomerEventRecordCalendarSection.vue";
|
||
import CustomerEventStructureChart from "@/pages/customer/component/CustomerEventStructureChart.vue";
|
||
import CustomerRecordTrendChart from "@/pages/customer/component/CustomerRecordTrendChart.vue";
|
||
import { useSettingStore } from "@/store/settingStore";
|
||
import type { Customer } from "@/types/Customer";
|
||
import { permissionCodes } from "@/utils/permission";
|
||
|
||
defineOptions({
|
||
name: "CustomerDataCharts"
|
||
});
|
||
|
||
const route = useRoute();
|
||
const router = useRouter();
|
||
const settingStore = useSettingStore();
|
||
|
||
// ---------- 路由 ----------
|
||
|
||
const routeCustomerId = computed(() => String(route.params.id || ""));
|
||
const routeCustomerCode = computed(() => String(route.query.customerCode || ""));
|
||
const customerCode = ref(routeCustomerCode.value);
|
||
const selectedCustomerId = ref(routeCustomerId.value);
|
||
|
||
watch(routeCustomerCode, (value) => {
|
||
if (value) {
|
||
customerCode.value = value;
|
||
}
|
||
});
|
||
|
||
watch(routeCustomerId, async (value) => {
|
||
if (value) {
|
||
await loadCustomerById(value);
|
||
}
|
||
});
|
||
|
||
onMounted(async () => {
|
||
if (routeCustomerId.value) {
|
||
await loadCustomerById(routeCustomerId.value);
|
||
} else if (customerCode.value) {
|
||
await loadCustomer(customerCode.value);
|
||
}
|
||
});
|
||
|
||
// ---------- 数据 ----------
|
||
|
||
const selectorVisible = ref(false);
|
||
const selectedCustomer = ref<Customer>();
|
||
const selectedEventIdList = ref<string[]>([]);
|
||
|
||
watch(customerCode, async (value) => {
|
||
await loadCustomer(value);
|
||
});
|
||
|
||
async function loadCustomer(code: string): Promise<void> {
|
||
const normalizedCode = code.trim();
|
||
if (!normalizedCode) {
|
||
clearLoadedCustomer();
|
||
return;
|
||
}
|
||
|
||
const customer = await CustomerAPI.findByCode(normalizedCode);
|
||
if (!customer) {
|
||
clearLoadedCustomer();
|
||
return;
|
||
}
|
||
|
||
selectedCustomer.value = customer;
|
||
selectedCustomerId.value = customer.id || "";
|
||
}
|
||
|
||
async function loadCustomerById(id: string): Promise<void> {
|
||
const normalizedId = id.trim();
|
||
if (!normalizedId) {
|
||
clearLoadedCustomer();
|
||
return;
|
||
}
|
||
|
||
try {
|
||
selectedCustomer.value = await CustomerAPI.detail(normalizedId);
|
||
selectedCustomerId.value = selectedCustomer.value.id || "";
|
||
} catch (err) {
|
||
clearLoadedCustomer();
|
||
console.warn("客户详情加载失败", err);
|
||
}
|
||
}
|
||
|
||
async function handleCustomerSelected(id: string): Promise<void> {
|
||
await loadCustomerById(id);
|
||
}
|
||
|
||
function clearLoadedCustomer(): void {
|
||
selectedCustomer.value = undefined;
|
||
selectedCustomerId.value = "";
|
||
}
|
||
|
||
async function toEventRecordList(payload: EventRecordCalendarListPayload): Promise<void> {
|
||
if (!selectedCustomer.value?.id) {
|
||
return;
|
||
}
|
||
|
||
await router.push({
|
||
name: "EventRecordList",
|
||
query: buildEventRecordListQuery(payload.monthCursor, payload.selectedEventIdList, selectedCustomer.value)
|
||
});
|
||
}
|
||
|
||
function buildEventRecordListQuery(monthCursor: Date, eventIdList: string[], customer: Customer): Record<string, string> {
|
||
const monthStart = getMonthStart(monthCursor);
|
||
const monthEnd = getDateEnd(addDays(addMonths(monthStart, 1), -1));
|
||
return {
|
||
customerId: customer.id || "",
|
||
customerName: customer.name || "",
|
||
dateType: "custom",
|
||
beginAt: `${Time.formatDate(monthStart)} 00:00`,
|
||
endAt: `${Time.formatDate(monthEnd)} 23:59`,
|
||
transitionDepth: String(getCurrentRouteDepth() + 1),
|
||
eventIds: eventIdList.join(",")
|
||
};
|
||
}
|
||
|
||
function getCurrentRouteDepth(): number {
|
||
return Number(route.meta.depth ?? 0);
|
||
}
|
||
|
||
// ---------- 扫码 ----------
|
||
|
||
const scannerVisible = ref(false);
|
||
const scanStatus = ref("点击扫码开始识别");
|
||
let latestDecodedText = "";
|
||
|
||
async function toggleScanner(): Promise<void> {
|
||
scannerVisible.value = !scannerVisible.value;
|
||
if (scannerVisible.value) {
|
||
latestDecodedText = "";
|
||
scanStatus.value = "正在启动扫码预览";
|
||
}
|
||
}
|
||
|
||
async function handleDecodedText(rawText: string): Promise<void> {
|
||
const normalizedText = rawText.trim();
|
||
if (!normalizedText || normalizedText === latestDecodedText) {
|
||
return;
|
||
}
|
||
latestDecodedText = normalizedText;
|
||
await settingStore.playScanSuccessSound();
|
||
customerCode.value = normalizedText;
|
||
scanStatus.value = `已识别 ${normalizedText},正在加载客户档案`;
|
||
scannerVisible.value = false;
|
||
}
|
||
|
||
function handleScanStatusChange(value: string): void {
|
||
scanStatus.value = value;
|
||
}
|
||
|
||
// ---------- 图表日期 ----------
|
||
|
||
const monthCursor = ref(getMonthStart(new Date()));
|
||
const chartBeginDate = getDateStart(addMonths(new Date(), -1));
|
||
const chartEndDate = getDateEnd(new Date());
|
||
const chartDateTextList = buildDateTextList(chartBeginDate, chartEndDate);
|
||
const monthDateTextList = computed(() => buildMonthDateTextList(monthCursor.value));
|
||
|
||
function getMonthStart(date: Date): Date {
|
||
return new Date(date.getFullYear(), date.getMonth(), 1, 0, 0, 0, 0);
|
||
}
|
||
|
||
function getDateStart(date: Date): Date {
|
||
return new Date(date.getFullYear(), date.getMonth(), date.getDate(), 0, 0, 0, 0);
|
||
}
|
||
|
||
function buildMonthDateTextList(date: Date): string[] {
|
||
const monthStart = getMonthStart(date);
|
||
const nextMonthStart = addMonths(monthStart, 1);
|
||
const list: string[] = [];
|
||
for (let item = monthStart; item < nextMonthStart; item = addDays(item, 1)) {
|
||
list.push(Time.formatDate(item));
|
||
}
|
||
return list;
|
||
}
|
||
|
||
function buildDateTextList(begin: Date, end: Date): string[] {
|
||
const list: string[] = [];
|
||
for (let item = getDateStart(begin); item <= end; item = addDays(item, 1)) {
|
||
list.push(Time.formatDate(item));
|
||
}
|
||
return list;
|
||
}
|
||
|
||
function addDays(date: Date, days: number): Date {
|
||
const value = new Date(date);
|
||
value.setDate(value.getDate() + days);
|
||
return value;
|
||
}
|
||
|
||
function addMonths(date: Date, months: number): Date {
|
||
return new Date(date.getFullYear(), date.getMonth() + months, 1, 0, 0, 0, 0);
|
||
}
|
||
|
||
function getDateEnd(date: Date): Date {
|
||
return new Date(date.getFullYear(), date.getMonth(), date.getDate(), 23, 59, 59, 999);
|
||
}
|
||
|
||
</script>
|
||
|
||
<style scoped lang="less">
|
||
.customer-data-charts-page {
|
||
gap: 1rem;
|
||
padding: var(--app-nav-height) 0 1rem 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
|
||
.header {
|
||
top: var(--app-nav-height);
|
||
z-index: 10;
|
||
padding: .5rem 1rem;
|
||
position: sticky;
|
||
box-shadow: var(--td-shadow-1);
|
||
|
||
.buttons {
|
||
gap: 1rem;
|
||
display: flex;
|
||
|
||
.selector {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
}
|
||
|
||
.scanner-wrapper {
|
||
margin-top: 1rem;
|
||
}
|
||
}
|
||
|
||
.customer {
|
||
margin: 0 1rem;
|
||
}
|
||
|
||
.charts {
|
||
gap: 1rem;
|
||
padding: 0 1rem;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
}
|
||
|
||
:global(html.theme-dark) {
|
||
.customer-data-charts-page {
|
||
.selector {
|
||
background: #10161f;
|
||
}
|
||
}
|
||
}
|
||
</style>
|