Files
gao-customer-h5/src/pages/event/EventRecordRank.vue
T
2026-08-18 15:15:47 +08:00

709 lines
18 KiB
Vue

<template>
<div v-can="permissionCodes.stat.read" class="event-record-rank">
<div class="filters">
<t-dropdown-menu class="filter-menu">
<t-dropdown-item
v-model="rankType"
:label="rankTypeLabel"
:options="rankTypeOptions"
@change="selectRankType"
/>
<t-dropdown-item
v-model="dateFilterType"
:label="dateFilterLabel"
:options="dateOptions"
@change="selectDateFilter"
/>
<t-dropdown-item
v-model="eventFilter"
:label="eventFilterLabel"
options-columns="2"
:options="eventOptions"
multiple
/>
</t-dropdown-menu>
</div>
<t-cell-group v-if="isCustomRange" class="time-range" theme="card">
<t-cell
title="开始时间"
:note="dateRange[0] || '-'"
@click="startVisible = true"
/>
<t-cell
title="结束时间"
:note="dateRange[1] || '-'"
@click="endVisible = true"
/>
</t-cell-group>
<t-pull-down-refresh
class="list-wrapper"
v-model="isRefreshing"
:refresh-timeout="30000"
@refresh="reloadRanks"
@scrolltolower="loadRanks"
>
<t-cell-group
v-if="pageResult.list.length"
class="list"
theme="card"
>
<t-cell
class="item"
:class="{ 'in-top3': index < 3}"
v-for="(item, index) in pageResult.list"
:key="item.customerId"
arrow
@click="toCustomerDetail(item.customerId || '')"
>
<template #left-icon>
<t-image
v-if="getCustomerPhotoURL(item.customer)"
:src="getCustomerPhotoURL(item.customer)"
:alt="getCustomerName(item)"
fit="cover"
class="avatar"
/>
<div v-else class="avatar fallback" v-text="getCustomerInitial(item)" />
</template>
<template #title>
<div class="title">
<div class="left">
<div
class="rank"
:class="{
top1: index === 0,
top2: index === 1,
top3: index === 2,
}"
v-text="index + 1"
/>
<div class="name" v-text="getCustomerName(item)" />
</div>
<t-tag size="small" variant="light-outline" :content="item.customer?.code" />
</div>
</template>
<template #description>
<div class="description">
<t-tag
v-if="rankType === 'TOTAL_DAYS'"
size="small"
variant="outline"
:content="`累计天数 ${item.totalEventDays || 0}`"
/>
<t-tag
v-else-if="rankType === 'TOTAL_COUNT'"
size="small"
variant="outline"
:content="`累计次数 ${item.totalEventCount || 0}`"
/>
<t-tag
v-else-if="rankType === 'CONTINUOUS_DAYS'"
size="small"
variant="outline"
:content="`连续天数 ${item.recentContinuousEventDays || 0}`"
/>
<t-tag
v-else
size="small"
variant="outline"
:content="`登记数值 ${getTotalNumberValueText(item)}`"
/>
</div>
</template>
</t-cell>
</t-cell-group>
<div v-else class="load-state" v-text="emptyStateText" />
<div v-if="pageResult.list.length" class="load-state" v-text="loadStateText" />
</t-pull-down-refresh>
</div>
<t-popup v-model="startVisible" placement="bottom">
<t-date-time-picker
v-model="dateRange[0]"
:default-value="dateRange[0]"
format="YYYY-MM-DD HH:mm"
:mode="['date', 'minute']"
title="开始时间"
@confirm="startVisible = false"
/>
</t-popup>
<t-popup v-model="endVisible" placement="bottom">
<t-date-time-picker
v-model="dateRange[1]"
:default-value="dateRange[1]"
:mode="['date', 'minute']"
format="YYYY-MM-DD HH:mm"
title="结束时间"
@confirm="endVisible = false"
/>
</t-popup>
</template>
<script setup lang="ts">
import { Toast, type DropdownValue } from "tdesign-mobile-vue";
import { Time, type PageResult } from "timi-web";
import EventAPI from "@/api/EventAPI";
import EventRecordAPI from "@/api/EventRecordAPI";
import type { CustomerEventRankView, EventRecordRankPage, EventRecordRankType } from "@/types/EventRecord";
import type { EventItem } from "@/types/EventItem";
import { getCustomerPhotoURL } from "@/utils/customer";
import { getErrorMessage } from "@/utils/error";
import { formatEventNumberValue } from "@/utils/eventValue";
import { permissionCodes } from "@/utils/permission";
type DateFilterType = "all" | "today" | "yesterday" | "lastWeek" | "thisMonth" | "custom";
defineOptions({
name: "EventRecordRank"
});
const pageSize = 20;
const route = useRoute();
const router = useRouter();
const rankTypeOptions: Array<{ label: string; value: EventRecordRankType }> = [
{ label: "按登记天数", value: "TOTAL_DAYS" },
{ label: "按登记次数", value: "TOTAL_COUNT" },
{ label: "按连续天数", value: "CONTINUOUS_DAYS" },
{ label: "按登记数值", value: "TOTAL_NUMBER_VALUE" }
];
const dateOptions: Array<{ label: string; value: DateFilterType }> = [
{ label: "全部时间", value: "all" },
{ label: "今天", value: "today" },
{ label: "昨天", value: "yesterday" },
{ label: "上周", value: "lastWeek" },
{ label: "本月", value: "thisMonth" },
{ label: "自定义范围", value: "custom" }
];
// ---------- 筛选 ----------
const rankType = ref<EventRecordRankType>(parseRankType(route.query.rankType));
const dateFilterType = ref<DateFilterType>(parseDateFilterType(route.query.dateType));
const dateRange = ref(parseDateRange(dateFilterType.value));
const eventFilter = ref<string[]>([]);
const eventList = ref<EventItem[]>([]);
const startVisible = ref(false);
const endVisible = ref(false);
let initialized = false;
const isCustomRange = computed(() => dateFilterType.value === "custom");
const rankTypeLabel = computed(() => rankTypeOptions.find(item => item.value === rankType.value)?.label || "按登记天数");
const dateFilterLabel = computed(() => dateOptions.find(item => item.value === dateFilterType.value)?.label || "全部时间");
const selectableEventList = computed(() => rankType.value === "TOTAL_NUMBER_VALUE"
? eventList.value.filter(item => item.valueType === "NUMBER")
: eventList.value);
const eventOptions = computed(() => selectableEventList.value.map(item => ({
label: item.name || "未命名事件",
value: item.id || ""
})).filter(item => item.value));
const eventFilterLabel = computed(() => {
if (!eventFilter.value.length || eventFilter.value.length === selectableEventList.value.length) {
return rankType.value === "TOTAL_NUMBER_VALUE" ? "全部有数值事件" : "全部事件";
}
return selectableEventList.value
.filter(item => item.id && eventFilter.value.includes(item.id))
.map(item => item.name || "未命名事件")
.join("、");
});
const selectedNumberEvent = computed(() => {
if (rankType.value !== "TOTAL_NUMBER_VALUE" || eventFilter.value.length !== 1) {
return undefined;
}
return selectableEventList.value.find(item => item.id === eventFilter.value[0]);
});
const canLoadRank = computed(() => {
if (rankType.value === "CONTINUOUS_DAYS") {
return eventFilter.value.length === 1;
}
if (rankType.value === "TOTAL_NUMBER_VALUE") {
return Boolean(selectedNumberEvent.value);
}
return true;
});
const emptyStateText = computed(() => {
if (isLoading.value) {
return "正在加载登记记录排行";
}
if (rankType.value === "TOTAL_NUMBER_VALUE") {
return "数值排行需要选择单个有数值事件";
}
return canLoadRank.value ? "暂无登记记录排行" : "连续排行需要选择单个登记事件";
});
watch(eventFilter, async () => {
if (!initialized) {
return;
}
await replaceFilterQuery();
await reloadRanks();
});
watch(dateRange, async (value) => {
if (!initialized || dateFilterType.value !== "custom") {
return;
}
if (value[0] && value[1] && value[1] < value[0]) {
dateRange.value = [value[1], value[0]];
return;
}
await replaceFilterQuery();
await reloadRanks();
}, { deep: true });
// ---------- 列表分页 ----------
const isRefreshing = ref(false);
const isLoading = ref(false);
const isNoMore = ref(false);
const page = reactive<EventRecordRankPage>({
index: 0,
size: pageSize
});
const pageResult = reactive<PageResult<CustomerEventRankView>>({
total: 0,
list: []
});
let loadToken = 0;
const loadStateText = computed(() => {
if (isLoading.value) {
return "正在加载更多";
}
return isNoMore.value ? "没有更多排行" : "上拉加载更多";
});
async function reloadRanks(): Promise<void> {
isRefreshing.value = true;
try {
page.index = 0;
pageResult.total = 0;
pageResult.list = [];
isNoMore.value = false;
await loadRanks();
} finally {
isRefreshing.value = false;
}
}
async function loadRanks(): Promise<void> {
if (isLoading.value || isNoMore.value) {
return;
}
if (!canLoadRank.value) {
pageResult.total = 0;
pageResult.list = [];
isNoMore.value = true;
return;
}
const token = loadToken + 1;
loadToken = token;
isLoading.value = true;
try {
const result = await EventRecordAPI.eventRank({
index: page.index,
size: page.size,
...buildRankReq()
});
if (token !== loadToken) {
return;
}
pageResult.list = pageResult.list.concat(result.list || []);
pageResult.total = result.total || 0;
page.index += 1;
isNoMore.value = pageResult.total <= pageResult.list.length || (result.list || []).length < page.size;
} catch (error) {
Toast.error(getErrorMessage(error, "登记记录排行加载失败"));
} finally {
if (token === loadToken) {
isLoading.value = false;
}
}
}
// ---------- 操作 ----------
async function loadEvents(): Promise<void> {
eventList.value = await EventAPI.listValid();
const eventIdList = parseEventIdList(route.query.eventIds);
eventFilter.value = eventIdList.length
? eventIdList
: eventList.value.map(item => item.id).filter((item): item is string => Boolean(item));
normalizeEventFilter();
}
async function selectRankType(value: DropdownValue): Promise<void> {
if (Array.isArray(value)) {
return;
}
rankType.value = parseRankType(value);
const filterChanged = normalizeEventFilter();
await replaceFilterQuery();
if (!canLoadRank.value) {
Toast.warning(rankType.value === "TOTAL_NUMBER_VALUE" ? "数值排行需要选择单个有数值事件" : "连续排行需要选择单个登记事件");
}
if (!filterChanged) {
await reloadRanks();
}
}
async function selectDateFilter(value: DropdownValue): Promise<void> {
if (Array.isArray(value)) {
return;
}
dateFilterType.value = parseDateFilterType(value);
if (dateFilterType.value !== "custom") {
dateRange.value = buildPresetRange(dateFilterType.value);
await replaceFilterQuery();
await reloadRanks();
return;
}
await replaceFilterQuery();
}
async function toCustomerDetail(customerId: string): Promise<void> {
if (!customerId) {
return;
}
await router.push(`/management/customers/${customerId}`);
}
// ---------- 格式化 ----------
function getCustomerName(item: CustomerEventRankView): string {
return item.customer?.name || "未知客户";
}
function getCustomerInitial(item: CustomerEventRankView): string {
return getCustomerName(item).slice(0, 1);
}
function getTotalNumberValueText(item: CustomerEventRankView): string {
return formatEventNumberValue(selectedNumberEvent.value, item.totalNumberValue);
}
function buildRankReq(): Omit<EventRecordRankPage, "index" | "size"> {
const [beginAt, endAt] = buildUnixRange();
const selectedAllEvent = (rankType.value === "TOTAL_DAYS" || rankType.value === "TOTAL_COUNT")
&& (!eventFilter.value.length || eventFilter.value.length === eventList.value.length);
return {
beginAt,
endAt,
rankType: rankType.value,
eventIdList: selectedAllEvent ? undefined : eventFilter.value
};
}
function getQueryText(key: string): string {
const value = route.query[key];
return Array.isArray(value) ? String(value[0] || "") : String(value || "");
}
function parseRankType(value: unknown): EventRecordRankType {
const text = Array.isArray(value) ? String(value[0] || "") : String(value || "");
return rankTypeOptions.some(item => item.value === text) ? text as EventRecordRankType : "TOTAL_DAYS";
}
function parseDateFilterType(value: unknown): DateFilterType {
const text = Array.isArray(value) ? String(value[0] || "") : String(value || "");
return dateOptions.some(item => item.value === text) ? text as DateFilterType : "all";
}
function parseDateRange(type: DateFilterType): [string, string] {
if (type !== "custom") {
return buildPresetRange(type);
}
return [getQueryText("beginAt"), getQueryText("endAt")];
}
function parseEventIdList(value: unknown): string[] {
const text = Array.isArray(value) ? String(value[0] || "") : String(value || "");
return text.split(",").map(item => item.trim()).filter(Boolean);
}
function normalizeEventFilter(): boolean {
const eventIdSet = new Set(selectableEventList.value.map(item => item.id).filter((item): item is string => Boolean(item)));
let nextEventIdList = eventFilter.value.filter(item => eventIdSet.has(item));
if (!nextEventIdList.length) {
nextEventIdList = [...eventIdSet];
}
const changed = nextEventIdList.join(",") !== eventFilter.value.join(",");
if (changed) {
eventFilter.value = nextEventIdList;
}
return changed;
}
function buildFilterQuery(): Record<string, string> {
return {
rankType: rankType.value,
dateType: dateFilterType.value,
beginAt: dateRange.value[0] || "",
endAt: dateRange.value[1] || "",
eventIds: eventFilter.value.join(",")
};
}
async function replaceFilterQuery(): Promise<void> {
await router.replace({
name: "EventRecordRank",
query: buildFilterQuery()
});
}
function buildPresetRange(type: Exclude<DateFilterType, "custom">): [string, string] {
if (type === "all") {
return ["", ""];
}
if (type === "lastWeek") {
const current = new Date();
current.setHours(0, 0, 0, 0);
const day = current.getDay() || 7;
const thisWeekMonday = new Date(current);
thisWeekMonday.setDate(current.getDate() - day + 1);
const lastWeekMonday = new Date(thisWeekMonday);
lastWeekMonday.setDate(thisWeekMonday.getDate() - 7);
const lastWeekSunday = new Date(thisWeekMonday);
lastWeekSunday.setDate(thisWeekMonday.getDate() - 1);
return [`${Time.formatDate(lastWeekMonday)} 00:00`, `${Time.formatDate(lastWeekSunday)} 23:59`];
}
if (type === "thisMonth") {
const current = new Date();
const begin = new Date(current.getFullYear(), current.getMonth(), 1);
return [`${Time.formatDate(begin)} 00:00`, `${Time.formatDate(current)} 23:59`];
}
const offsetMap: Record<Exclude<DateFilterType, "all" | "custom" | "lastWeek" | "thisMonth">, number> = {
today: 0,
yesterday: 1
};
const date = new Date();
date.setHours(0, 0, 0, 0);
date.setDate(date.getDate() - offsetMap[type]);
return [`${Time.formatDate(date)} 00:00`, `${Time.formatDate(date)} 23:59`];
}
function buildUnixRange(): [number | undefined, number | undefined] {
if (dateFilterType.value === "all") {
return [undefined, undefined];
}
return [
Time.toTimestamp(parseDateTime(dateRange.value[0], false)),
Time.toTimestamp(parseDateTime(dateRange.value[1], true))
];
}
function parseDateTime(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);
}
onMounted(async () => {
await loadEvents();
initialized = true;
await reloadRanks();
});
</script>
<style scoped lang="less">
.event-record-rank {
gap: .75rem;
display: flex;
padding: calc(var(--app-nav-height)) 0 6rem 0;
flex-direction: column;
.filters {
top: var(--app-nav-height);
z-index: 5;
position: sticky;
.filter-menu {
box-shadow: 0 .25rem .75rem rgb(15 23 42 / 6%);
}
}
.time-range {
margin: 0 .75rem;
}
.list-wrapper {
min-height: 70vh;
.list {
.item {
--td-spacer-1: 8px;
--td-spacer-2: 4px;
&.in-top3 {
.title {
.name {
font-size: 1rem;
font-weight: 600;
}
}
}
.avatar {
width: 3.25rem;
height: 3.25rem;
}
.title {
flex: 1 1 auto;
display: flex;
min-width: 0;
justify-content: space-between;
.left {
gap: .5rem;
display: flex;
.rank {
flex: 0 0 1.5rem;
width: 1.5rem;
color: #52607a;
height: 1.5rem;
display: flex;
font-size: .76rem;
background: #F0F0F0;
font-weight: 700;
align-items: center;
border-radius: .5rem;
justify-content: center;
&.top1 {
color: #5C3A00;
background: #FFD700;
}
&.top2 {
color: #374151;
background: #C0C0C0;
}
&.top3 {
color: #FFF8F0;
background: #CD7F32;
}
}
.name {
color: #0F172A;
overflow: hidden;
font-size: .875rem;
white-space: nowrap;
text-overflow: ellipsis;
}
}
}
.description {
gap: .5rem;
display: flex;
font-size: .6875rem;
}
.stats {
min-width: 5.25rem;
text-align: right;
.primary {
color: #0F766E;
font-size: 1.5rem;
font-weight: 700;
line-height: 1.1;
}
.primary-label {
color: #475569;
font-size: .75rem;
margin-top: .25rem;
}
.sub {
color: #94A3B8;
font-size: .6875rem;
margin-top: .375rem;
white-space: nowrap;
}
}
}
}
}
}
:global(html.theme-dark) {
.event-record-rank {
.summary {
color: #94A3B8;
}
.list {
.item {
background: #182231;
box-shadow: none;
.rank {
color: #CBD5E1;
background: #243244;
&.top1 {
color: #FACC15;
background: #3F3415;
}
&.top2 {
color: #E2E8F0;
background: #334155;
}
&.top3 {
color: #FDBA74;
background: #3F2A1B;
}
}
.main {
.name {
color: #E2E8F0;
}
.code,
.latest {
color: #94A3B8;
}
}
.stats {
.primary {
color: #2DD4BF;
}
.primary-label {
color: #CBD5E1;
}
.sub {
color: #94A3B8;
}
}
}
}
}
}
</style>