add registration event rank
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user