add registration event rank

This commit is contained in:
Timi
2026-08-03 23:45:46 +08:00
parent dfdf79c8ea
commit f19cbc085d
5 changed files with 437 additions and 9 deletions
+6
View File
@@ -1,6 +1,7 @@
import { axios, type Page, type PageResult } from "timi-web";
import type {
CustomerRegisterStatView,
CustomerRegistrationRankView,
RegistrationEventRecordCalendarView,
RegistrationEventRecord,
RegistrationEventRecordCreateReq,
@@ -64,6 +65,10 @@ async function allCustomerRangeStat(req: RegistrationEventRecordStatReq): Promis
return await axios.post(`${BASE_URI}/all-customer/range/stat`, req);
}
async function customerRegistrationRank(req?: Partial<RegistrationEventRecordRangeReq>): Promise<CustomerRegistrationRankView[]> {
return await axios.post(`${BASE_URI}/customer/registration-rank`, req);
}
async function customerRegistrationEventDailyStat(req: RegistrationEventRecordTrendStatReq): Promise<RegistrationEventRecordDailyStatView[]> {
return await axios.post(`${BASE_URI}/customer/registration-event/daily/stat`, req);
}
@@ -84,6 +89,7 @@ export default {
remove,
customerRangeStat,
allCustomerRangeStat,
customerRegistrationRank,
customerRegistrationEventDailyStat,
allCustomerRegistrationEventDailyStat
};
+414 -4
View File
@@ -23,6 +23,24 @@
@status-change="handleScanStatusChange"
/>
</div>
<div v-if="!selectedCustomer" class="filter-line">
<t-dropdown-menu class="filter-menu">
<t-dropdown-item
v-model="rankDateFilterType"
:label="rankDateFilterLabel"
:options="rankDateOptions"
@change="selectRankDateFilter"
/>
<t-dropdown-item
v-model="rankRegistrationEventIdList"
:label="rankRegistrationEventFilterLabel"
:options="rankRegistrationEventOptions"
options-columns="2"
multiple
@change="reloadCustomerRegistrationRank"
/>
</t-dropdown-menu>
</div>
</div>
<section v-if="selectedCustomer" class="summary">
<div v-for="item in summaryList" :key="item.label" class="summary-item">
@@ -48,34 +66,94 @@
<h2 v-text="item.title" />
<div :ref="element => setChartRef(item.id, element)" class="chart" />
</section>
<div v-if="!selectedCustomer" class="empty-block">请选择客户后查看专属图表</div>
<div v-if="!selectedCustomer" v-text="rankSummaryText"></div>
<t-cell-group
v-if="!selectedCustomer && customerRegistrationRankList.length"
class="rank-list"
theme="card"
>
<t-cell
v-for="item in customerRegistrationRankList"
:key="item.customerId"
class="rank-item"
:title="item.customerName || '未知客户'"
arrow
hover
@click="selectRankCustomer(item.customerId || '')"
>
<template #leftIcon>
<span class="rank-left">
<span class="rank-no" v-text="item.rank" />
<t-image
v-if="getRankCustomerPhotoURL(item)"
:src="getRankCustomerPhotoURL(item)"
:alt="item.customerName || '客户头像'"
fit="cover"
class="rank-photo"
/>
<span v-else class="rank-photo fallback" v-text="getRankCustomerInitial(item)" />
</span>
</template>
<template #description>
<div v-text="getRankCustomerMeta(item)"></div>
<div class="rank-stat">
<strong v-text="`${item.totalRegistrationDays || 0} 天`" />
<small v-text="`${item.recentContinuousRegistrationDays || 0} 天连续`" />
</div>
</template>
</t-cell>
</t-cell-group>
<div v-else class="empty-block" v-text="rankLoading ? '正在加载排行榜' : '暂无登记排行数据'" />
<customer-selector
v-model="selectedCustomerId"
v-model:visible="selectorVisible"
@change="handleCustomerSelected"
/>
<t-popup v-model="rankStartPickerVisible" placement="bottom">
<t-date-time-picker
v-model="rankDateRange[0]"
:default-value="rankDateRange[0]"
format="YYYY-MM-DD HH:mm"
:mode="['date', 'minute']"
title="开始时间"
@confirm="confirmRankStartTime"
/>
</t-popup>
<t-popup v-model="rankEndPickerVisible" placement="bottom">
<t-date-time-picker
v-model="rankDateRange[1]"
:default-value="rankDateRange[1]"
format="YYYY-MM-DD HH:mm"
:mode="['date', 'minute']"
title="结束时间"
@confirm="confirmRankEndTime"
/>
</t-popup>
</div>
</template>
<script setup lang="ts">
import type {ECharts, EChartsOption} from "echarts";
import type {DropdownValue} from "tdesign-mobile-vue";
import {BarChart, LineChart} from "echarts/charts";
import {GridComponent, LegendComponent, TooltipComponent} from "echarts/components";
import {init, use} from "echarts/core";
import {CanvasRenderer} from "echarts/renderers";
import {AttachmentAPI, Time} from "timi-web";
import CustomerAPI from "@/api/CustomerAPI";
import RegistrationEventAPI from "@/api/RegistrationEventAPI";
import RegistrationEventRecordAPI from "@/api/RegistrationEventRecordAPI";
import type {Customer} from "@/types/Customer";
import type {RegistrationEvent} from "@/types/RegistrationEvent";
import type {RegistrationEventRecord} from "@/types/RegistrationEventRecord";
import type {CustomerRegistrationRankView, RegistrationEventRecord, RegistrationEventRecordRangeReq} from "@/types/RegistrationEventRecord";
import CodeScanner from "@/components/CodeScanner.vue";
import CustomerSelector from "@/components/CustomerSelector.vue";
import RegistrationEventRecordCalendar from "@/components/RegistrationEventRecordCalendar.vue";
import {useSettingStore} from "@/store/settingStore";
import {Time} from "timi-web";
import {gaoPermissionCodes} from "@/utils/gaoPermission";
type RankDateFilterType = "all" | "today" | "thisMonth" | "custom";
interface SummaryItem {
label: string;
value: string;
@@ -104,6 +182,12 @@ const settingStore = useSettingStore();
const route = useRoute();
const chartRefs = new Map<string, HTMLElement>();
const chartInstances = new Map<string, ECharts>();
const rankDateOptions: Array<{ label: string; value: RankDateFilterType }> = [
{label: "全部", value: "all"},
{label: "今天", value: "today"},
{label: "本月", value: "thisMonth"},
{label: "自定义范围", value: "custom"}
];
const routeCustomerId = computed(() => String(route.params.id || ""));
const routeCustomerCode = computed(() => String(route.query.customerCode || ""));
const customerCode = ref(routeCustomerCode.value);
@@ -117,6 +201,13 @@ const selectedRegistrationEventIdList = ref<string[]>([]);
const monthCursor = ref(getMonthStart(new Date()));
const monthRecords = ref<RegistrationEventRecord[]>([]);
const calendarLoading = ref(false);
const customerRegistrationRankList = ref<CustomerRegistrationRankView[]>([]);
const rankDateFilterType = ref<RankDateFilterType>("all");
const rankDateRange = ref(buildRankPresetRange("all"));
const rankRegistrationEventIdList = ref<string[]>([]);
const rankLoading = ref(false);
const rankStartPickerVisible = ref(false);
const rankEndPickerVisible = ref(false);
let latestDecodedText = "";
const registrationEventNameMap = computed(() => {
@@ -180,6 +271,27 @@ const chartList = computed<ChartProfile[]>(() => {
}
];
});
const rankDateFilterLabel = computed(() => rankDateOptions.find(item => item.value === rankDateFilterType.value)?.label || "全部");
const rankRegistrationEventOptions = computed(() => registrationEventList.value.map(item => ({
label: item.name || "未命名事件",
value: item.id || ""
})).filter(item => item.value));
const rankRegistrationEventFilterLabel = computed(() => {
if (!rankRegistrationEventIdList.value.length || rankRegistrationEventIdList.value.length === registrationEventList.value.length) {
return "全部事件";
}
return registrationEventList.value
.filter(item => item.id && rankRegistrationEventIdList.value.includes(item.id))
.map(item => item.name || "未命名事件")
.join("、");
});
const rankSummaryText = computed(() => {
if (rankDateFilterType.value === "custom") {
return `${rankDateRange.value[0] || "-"} ~ ${rankDateRange.value[1] || "-"}|${rankRegistrationEventFilterLabel.value}`;
}
return `范围:${rankDateFilterLabel.value}|${rankRegistrationEventFilterLabel.value}`;
});
watch(chartList, async () => {
await nextTick();
@@ -208,6 +320,7 @@ watch([selectedCustomer, monthCursor], async () => {
onMounted(async () => {
await loadActions();
await loadCustomerRegistrationRank();
if (routeCustomerId.value) {
await loadCustomerById(routeCustomerId.value);
} else if (customerCode.value) {
@@ -262,6 +375,7 @@ function resizeCharts(): void {
async function loadActions(): Promise<void> {
registrationEventList.value = await RegistrationEventAPI.enabledList();
selectedRegistrationEventIdList.value = registrationEventList.value.map(item => item.id).filter((item): item is string => Boolean(item));
rankRegistrationEventIdList.value = selectedRegistrationEventIdList.value;
}
async function loadCustomer(code: string): Promise<void> {
@@ -309,6 +423,10 @@ async function handleCustomerSelected(id: string): Promise<void> {
await loadCustomerById(id);
}
async function selectRankCustomer(id: string): Promise<void> {
await loadCustomerById(id);
}
async function loadMonthRecords(): Promise<void> {
const customerId = selectedCustomer.value?.id;
if (!customerId) {
@@ -330,6 +448,54 @@ async function loadMonthRecords(): Promise<void> {
}
}
async function loadCustomerRegistrationRank(): Promise<void> {
rankLoading.value = true;
try {
customerRegistrationRankList.value = await RegistrationEventRecordAPI.customerRegistrationRank(buildRankStatReq());
} finally {
rankLoading.value = false;
}
}
async function reloadCustomerRegistrationRank(): Promise<void> {
await loadCustomerRegistrationRank();
}
async function selectRankDateFilter(value: DropdownValue): Promise<void> {
if (Array.isArray(value)) {
return;
}
rankDateFilterType.value = String(value) as RankDateFilterType;
if (rankDateFilterType.value !== "custom") {
rankDateRange.value = buildRankPresetRange(rankDateFilterType.value);
await loadCustomerRegistrationRank();
return;
}
if (!rankDateRange.value[0] || !rankDateRange.value[1]) {
rankDateRange.value = buildRankPresetRange("today");
}
rankStartPickerVisible.value = true;
}
function confirmRankStartTime(): void {
rankStartPickerVisible.value = false;
rankEndPickerVisible.value = true;
}
async function confirmRankEndTime(): Promise<void> {
rankEndPickerVisible.value = false;
normalizeRankDateRange();
await loadCustomerRegistrationRank();
}
function normalizeRankDateRange(): void {
if (rankDateRange.value[0] && rankDateRange.value[1] && rankDateRange.value[1] < rankDateRange.value[0]) {
rankDateRange.value = [rankDateRange.value[1], rankDateRange.value[0]];
}
}
// ---------- 扫码 ----------
async function toggleScanner(): Promise<void> {
@@ -370,6 +536,24 @@ function getRecordRegistrationEventName(record?: RegistrationEventRecord): strin
return record.registrationEvent?.name || (record.registrationEventId ? registrationEventNameMap.value.get(record.registrationEventId) : "") || "未知事件";
}
function getRankCustomerMeta(item: CustomerRegistrationRankView): string {
const codeText = item.customerCode ? `编码 ${item.customerCode}` : "暂无编码";
const countText = `${item.totalRegistrationCount || 0} 次登记`;
const latestText = item.latestRegisteredAt ? `最近 ${formatDateTime(item.latestRegisteredAt)}` : "暂无最近登记";
return `${codeText}|${countText}|${latestText}`;
}
function getRankCustomerPhotoURL(item: CustomerRegistrationRankView): string {
if (!item.customerPhotoAttachmentId) {
return "";
}
return AttachmentAPI.getReadURL(item.customerPhotoAttachmentId);
}
function getRankCustomerInitial(item: CustomerRegistrationRankView): string {
return (item.customerName || "客").slice(0, 1);
}
// ---------- 图表 ----------
function buildBaseOption(): EChartsOption {
@@ -522,6 +706,52 @@ function getDateEnd(date: Date): Date {
return new Date(date.getFullYear(), date.getMonth(), date.getDate(), 23, 59, 59, 999);
}
function buildRankStatReq(): Partial<RegistrationEventRecordRangeReq> | undefined {
const selectedAllEvent = !rankRegistrationEventIdList.value.length || rankRegistrationEventIdList.value.length === registrationEventList.value.length;
if (rankDateFilterType.value === "all") {
return selectedAllEvent ? undefined : {
registrationEventIdList: rankRegistrationEventIdList.value
};
}
return {
beginRegisteredAt: Time.toTimestamp(parseRankDateTime(rankDateRange.value[0], false)),
endRegisteredAt: Time.toTimestamp(parseRankDateTime(rankDateRange.value[1], true)),
registrationEventIdList: selectedAllEvent ? undefined : rankRegistrationEventIdList.value
};
}
function buildRankPresetRange(type: Exclude<RankDateFilterType, "custom">): [string, string] {
if (type === "all") {
return ["", ""];
}
const now = new Date();
if (type === "thisMonth") {
const monthStart = getMonthStart(now);
return [`${Time.formatDate(monthStart)} 00:00`, `${Time.formatDate(getDateEnd(now))} 23:59`];
}
const dayStart = new Date(now);
dayStart.setHours(0, 0, 0, 0);
return [`${Time.formatDate(dayStart)} 00:00`, `${Time.formatDate(getDateEnd(now))} 23:59`];
}
function parseRankDateTime(value: string, isEnd: boolean): Date {
if (!value) {
return isEnd ? new Date() : new Date(0);
}
const [dateText, timeText = isEnd ? "23:59" : "00:00"] = value.split(" ");
const [year, month, day] = dateText.split("-").map(Number);
const [hour, minute] = timeText.split(":").map(Number);
return new Date(year, month - 1, day, hour || 0, minute || 0, isEnd ? 59 : 0, 0);
}
function formatDateTime(value: number): string {
const date = Time.toDateObject(value);
return date ? Time.formatDateTime(date) : "-";
}
</script>
<style scoped lang="less">
@@ -556,6 +786,30 @@ function getDateEnd(date: Date): Date {
width: 3rem;
}
}
.filter-line {
.filter-menu {
width: 100%;
overflow: hidden;
background: #fff;
border: 1px solid rgba(148, 163, 184, .18);
border-radius: .625rem;
&:after {
display: none;
}
:deep(.t-dropdown-menu__item) {
min-width: 0;
}
:deep(.t-dropdown-menu__title) {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
}
}
}
.summary {
@@ -607,6 +861,120 @@ function getDateEnd(date: Date): Date {
margin: 0 1rem;
}
.rank-section {
gap: .75rem;
margin: 0 1rem;
display: flex;
padding: 1rem;
background: rgba(255, 255, 255, .92);
box-shadow: var(--td-shadow-1);
border-radius: .75rem;
flex-direction: column;
.rank-header {
gap: .75rem;
display: flex;
align-items: flex-start;
justify-content: space-between;
h2 {
margin: 0;
color: #0f172a;
font-size: 1rem;
font-weight: 700;
}
p {
margin: .25rem 0 0 0;
color: #52607a;
font-size: .75rem;
}
}
.rank-list {
margin: 0;
}
.rank-item {
:deep(.t-cell__title) {
flex: 1 1 0;
min-width: 0;
}
:deep(.t-cell__title-text),
:deep(.t-cell__description) {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
:deep(.t-cell__right) {
min-width: 4rem;
}
}
.rank-left {
gap: .5rem;
display: flex;
align-items: center;
}
.rank-no {
width: 2rem;
color: #2563eb;
height: 2rem;
display: flex;
background: #dbeafe;
font-size: .875rem;
align-items: center;
font-weight: 700;
border-radius: 999px;
justify-content: center;
}
.rank-photo {
width: 2.75rem;
height: 2.75rem;
border-radius: .5rem;
&.fallback {
color: #2563eb;
display: flex;
font-size: 1rem;
background: #dbeafe;
align-items: center;
font-weight: 700;
justify-content: center;
}
}
.rank-stat {
min-width: 0;
display: flex;
text-align: right;
flex-direction: column;
strong,
small {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
strong {
color: #0f172a;
font-size: .9375rem;
font-weight: 700;
}
small {
margin-top: .25rem;
color: #52607a;
font-size: .75rem;
}
}
}
.chart-section {
h2 {
margin-bottom: .875rem;
@@ -633,6 +1001,13 @@ function getDateEnd(date: Date): Date {
.customer-data-charts-page {
.selector {
background: #10161f;
.filter-line {
.filter-menu {
background: #172033;
border-color: rgba(148, 163, 184, .16);
}
}
}
.summary {
@@ -651,7 +1026,8 @@ function getDateEnd(date: Date): Date {
}
.calendar-section,
.chart-section {
.chart-section,
.rank-section {
background: rgba(15, 23, 42, .88);
box-shadow: 0 .75rem 2rem rgba(2, 6, 23, .32);
@@ -660,6 +1036,40 @@ function getDateEnd(date: Date): Date {
}
}
.rank-section {
.rank-header {
p {
color: #94a3b8;
}
.rank-filter {
border-color: rgba(148, 163, 184, .16);
}
}
.rank-no {
color: #bfdbfe;
background: rgba(37, 99, 235, .28);
}
.rank-photo {
&.fallback {
color: #bfdbfe;
background: rgba(37, 99, 235, .28);
}
}
.rank-stat {
strong {
color: #e2e8f0;
}
small {
color: #94a3b8;
}
}
}
.empty-block {
color: #94a3b8;
background: rgba(30, 41, 59, .9);
+1 -1
View File
@@ -148,7 +148,7 @@ const menuGroupList = computed<MenuGroup[]>(() => [
]
},
{
title: "图表",
title: "统计",
items: [
{
icon: "usergroup",
+2 -2
View File
@@ -9,7 +9,7 @@ const chartRoutes: RouteRecordRaw[] = [
depth: 3,
navBarVisible: true,
navBarCanBack: true,
navBarTitle: "全局数据图表",
navBarTitle: "全局数据",
tabBarVisible: false,
bodyBackground: "#F5F5F8",
darkBodyBackground: "#10161f"
@@ -23,7 +23,7 @@ const chartRoutes: RouteRecordRaw[] = [
depth: 3,
navBarVisible: true,
navBarCanBack: true,
navBarTitle: "客户数据图表",
navBarTitle: "客户数据",
tabBarVisible: false,
bodyBackground: "#F5F5F8",
darkBodyBackground: "#10161f"
+14 -2
View File
@@ -36,8 +36,8 @@ export type RegistrationEventRecordRangeReq = {
export type RegistrationEventRecordStatReq = {
customerId?: string;
beginRegisteredAt: number;
endRegisteredAt: number;
beginRegisteredAt?: number;
endRegisteredAt?: number;
};
export type RegistrationEventRecordTrendStatReq = {
@@ -56,6 +56,18 @@ export type CustomerRegisterStatView = {
latestRegisteredAt?: number;
};
export type CustomerRegistrationRankView = {
rank?: number;
customerId?: string;
customerCode?: string;
customerName?: string;
customerPhotoAttachmentId?: string;
totalRegistrationDays?: number;
totalRegistrationCount?: number;
recentContinuousRegistrationDays?: number;
latestRegisteredAt?: number;
};
export type RegistrationEventRecordDailyStatView = {
dateValue?: number;
dailyCount?: number;