Files
gao-customer-h5/src/pages/event/EventRecordList.vue
T
2026-09-01 16:27:53 +08:00

813 lines
23 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div v-can="permissionCodes.eventRecord.read" class="registration-record-list">
<div class="search-bar">
<t-input
v-model.trim="keyword"
class="search-input"
type="search"
borderless
placeholder="搜索客户、姓名拼音、编码、事件、备注、登记人"
/>
<div class="filter-line">
<t-dropdown-menu class="filter-menu">
<t-dropdown-item
v-model="dateFilterType"
:label="dateFilterLabel"
:options="dateOptions"
@change="selectDateFilter"
/>
<t-dropdown-item
v-model="actionFilter"
:label="eventFilterLabel"
options-columns="2"
:options="actionOptions"
multiple
/>
<t-dropdown-item
v-model="verificationFilter"
:label="verificationFilterLabel"
:options="verificationOptions"
/>
<t-dropdown-item
v-if="optionFilterOptions.length"
v-model="optionFilter"
:label="optionFilterLabel"
options-columns="2"
:options="optionFilterOptions"
multiple
/>
</t-dropdown-menu>
</div>
</div>
<t-pull-down-refresh
v-model="isRefreshing"
:refresh-timeout="30000"
@refresh="reloadRecords"
@scrolltolower="loadRecords"
>
<div class="list-wrapper">
<t-cell-group v-entrance-transition class="summary" theme="card">
<t-cell
title="开始时间"
:note="dateRange[0] || '-'"
arrow
@click="openStartTime"
/>
<t-cell
title="结束时间"
:note="dateRange[1] || '-'"
arrow
@click="openEndTime"
/>
<t-cell title="事件" :note="eventFilterLabel" />
<t-cell v-if="optionFilterLabel !== '全部选项'" title="选项" :note="optionFilterLabel" />
<t-cell title="数据量" :note="`共 ${pageResult.total} 条记录`" />
<t-cell v-if="summaryNumberValueText" title="数值合计" :note="summaryNumberValueText" />
</t-cell-group>
<t-cell-group v-if="hasSelectedOption && optionStatList.length" title="选项统计" class="option-stat" theme="card">
<t-cell
v-for="item in optionStatList"
:key="`${item.groupId}-${item.optionId}`"
:title="`${item.groupName || '选项组'}:${item.optionLabel || '未命名选项'}`"
:note="`${item.recordCount || 0} 条`"
/>
</t-cell-group>
<t-cell-group
v-if="pageResult.list.length"
class="record-list"
theme="card"
>
<event-record-cell
v-entrance-transition="index * 30"
v-for="(item, index) in pageResult.list"
:key="item.id"
:record="item"
@updated="reloadRecords"
/>
</t-cell-group>
<div v-else class="center-tips" v-text="isLoading ? '正在加载登记事件记录' : '暂无登记事件记录'" />
<div v-if="pageResult.list.length" class="center-tips" v-text="loadStateText" />
</div>
</t-pull-down-refresh>
</div>
<nav-bar-action-menu v-if="canShowActionMenu" :actions="actionList" />
<t-popup v-model="startVisible" placement="bottom">
<t-date-time-picker
v-if="startVisible"
v-model="startDraft"
format="YYYY-MM-DD HH:mm"
:mode="['date', 'minute']"
title="开始时间"
:default-value="dateRange[0]"
@cancel="cancelStartTime"
@confirm="confirmStartTime"
/>
</t-popup>
<t-popup v-model="endVisible" placement="bottom">
<t-date-time-picker
v-if="endVisible"
v-model="endDraft"
:mode="['date', 'minute']"
format="YYYY-MM-DD HH:mm"
title="结束时间"
:default-value="dateRange[1]"
@cancel="cancelEndTime"
@confirm="confirmEndTime"
/>
</t-popup>
</template>
<script setup lang="ts">
import type { DropdownValue } from "tdesign-mobile-vue";
import { Time, WebToolkit } from "timi-web";
import { useUserStore } from "timi-web-api";
import EventAPI from "@/api/EventAPI";
import EventRecordAPI from "@/api/EventRecordAPI";
import EventRecordCell from "@/pages/event/component/EventRecordCell.vue";
import { useNavBarStore } from "@/store/navBarStore";
import type { EventItem, EventStatus } from "@/types/EventItem";
import type { EventOptionStat, EventRecordPage, EventRecordPageResult, VerificationStatus } from "@/types/EventRecord";
import type { NavBarAction } from "@/types/NavBarAction";
import { isPackagePurchaseEvent } from "@/utils/event";
import { formatEventNumberValue } from "@/utils/eventValue";
import { permissionCodes } from "@/utils/permission";
import { Message } from "@/utils/message.ts";
type DateFilterType = "today" | "yesterday" | "lastWeek" | "thisMonth" | "lastMonth" | "all" | "custom";
type VerificationFilter = "ALL" | VerificationStatus;
defineOptions({
name: "EventRecordList"
});
const pageSize = 20;
const route = useRoute();
const router = useRouter();
const navBarStore = useNavBarStore();
const userStore = useUserStore();
const dateOptions: Array<{ label: string; value: DateFilterType }> = [
{ label: "今天", value: "today" },
{ label: "昨天", value: "yesterday" },
{ label: "上周", value: "lastWeek" },
{ label: "本月", value: "thisMonth" },
{ label: "上个月", value: "lastMonth" },
{ label: "全部", value: "all" },
{ label: "自定义范围", value: "custom" }
];
// ---------- 筛选 ----------
const keyword = ref(getInitialKeyword());
const customerId = ref(getQueryText("customerId"));
const dateFilterType = ref<DateFilterType>(parseDateFilterType(route.query.dateType));
const dateRange = ref(parseDateRange(dateFilterType.value));
const startDraft = ref("");
const endDraft = ref("");
const actionFilter = ref<string[]>([]);
const optionFilter = ref<string[]>([]);
const verificationOptions: Array<{ label: string; value: VerificationFilter }> = [
{ label: "全部核销状态", value: "ALL" },
{ label: "待核销", value: "PENDING" },
{ label: "已核销", value: "VERIFIED" },
{ label: "无需核销", value: "NOT_REQUIRED" }
];
const verificationFilter = ref<VerificationFilter>(parseVerificationFilter(route.query.verificationStatus));
const eventList = ref<EventItem[]>([]);
const startVisible = ref(false);
const endVisible = ref(false);
let keywordTimer: number | undefined;
let initialized = false;
const isExporting = ref(false);
let suppressOptionFilterReload = false;
const canCreateRecord = computed(() => userStore.hasPermissions(permissionCodes.eventRecord.create));
const canCreateEvent = computed(() => userStore.hasPermissions(permissionCodes.event.create));
const dateFilterLabel = computed(() => dateOptions.find(item => item.value === dateFilterType.value)?.label || "今天");
const actionOptions = computed(() => eventList.value.map(item => ({
label: getEventDisplayName(item),
value: item.id || ""
})).filter(item => item.value));
const eventFilterLabel = computed(() => {
if (!actionFilter.value.length || actionFilter.value.length === eventList.value.length) {
return "全部事件";
}
return eventList.value
.filter(item => item.id && actionFilter.value.includes(item.id))
.map(item => getEventDisplayName(item))
.join("、");
});
const selectedOptionEvent = computed(() => {
if (actionFilter.value.length !== 1) {
return undefined;
}
const event = eventList.value.find(item => item.id === actionFilter.value[0]);
return event?.optionGroupList?.some(group => group.optionList?.some(option => option.id)) ? event : undefined;
});
const optionFilterOptions = computed(() => {
const event = selectedOptionEvent.value;
if (!event) {
return [];
}
return (event.optionGroupList || []).flatMap(group => (group.optionList || [])
.filter(option => option.id)
.map(option => ({
label: `${group.name}:${option.label}`,
value: String(option.id)
})));
});
const optionFilterLabel = computed(() => {
if (!optionFilter.value.length) {
return "全部选项";
}
return optionFilterOptions.value
.filter(item => optionFilter.value.includes(item.value))
.map(item => item.label)
.join("、") || "全部选项";
});
const verificationFilterLabel = computed(() => verificationOptions.find(item => item.value === verificationFilter.value)?.label || "全部核销状态");
const selectedNumberEvent = computed(() => {
if (actionFilter.value.length !== 1) {
return undefined;
}
const event = eventList.value.find(item => item.id === actionFilter.value[0]);
return event?.valueType === "NUMBER" ? event : undefined;
});
const hasSelectedOption = computed(() => !!selectedOptionEvent.value);
const canExportRecord = computed(() => userStore.hasPermissions(permissionCodes.eventRecord.export));
const canShowActionMenu = computed(() => canCreateRecord.value || canExportRecord.value);
const actionList = computed<NavBarAction[]>(() => {
const list: NavBarAction[] = [];
if (canCreateRecord.value) {
list.push({
action: toCreate,
theme: "primary",
icon: "add",
key: "create",
text: "添加"
});
}
if (canExportRecord.value) {
list.push({
action: exportExcel,
icon: "download",
key: "export",
loading: isExporting.value,
text: "导出 Excel"
});
}
return list;
});
watch(keyword, () => {
if (!initialized) {
return;
}
window.clearTimeout(keywordTimer);
keywordTimer = window.setTimeout(async () => {
await replaceFilterQuery();
await reloadRecords();
}, 300);
});
watch(actionFilter, async () => {
if (!initialized) {
return;
}
const optionIdSet = new Set(optionFilterOptions.value.map(item => item.value));
const nextOptionFilter = optionFilter.value.filter(optionId => optionIdSet.has(optionId));
if (nextOptionFilter.length !== optionFilter.value.length) {
suppressOptionFilterReload = true;
optionFilter.value = nextOptionFilter;
}
await replaceFilterQuery();
await reloadRecords();
});
watch(optionFilter, async () => {
if (!initialized) {
return;
}
if (suppressOptionFilterReload) {
suppressOptionFilterReload = false;
return;
}
await replaceFilterQuery();
await reloadRecords();
});
watch(verificationFilter, async () => {
if (!initialized) {
return;
}
await replaceFilterQuery();
await reloadRecords();
});
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 reloadRecords();
}, { deep: true });
// ---------- 列表分页 ----------
const isRefreshing = ref(false);
const isLoading = ref(false);
const isNoMore = ref(false);
const page = reactive<EventRecordPage>({
index: 0,
size: pageSize
});
const pageResult = reactive<EventRecordPageResult>({
total: 0,
list: [],
numberValueTotal: null
});
const optionStatList = ref<EventOptionStat[]>([]);
let loadToken = 0;
const summaryNumberValueText = computed(() => {
if (!selectedNumberEvent.value || pageResult.numberValueTotal === undefined || pageResult.numberValueTotal === null) {
return "";
}
return `${formatEventNumberValue(selectedNumberEvent.value, pageResult.numberValueTotal)}`;
});
const loadStateText = computed(() => {
if (isLoading.value) {
return "正在加载更多";
}
return isNoMore.value ? "没有更多记录" : "上拉加载更多";
});
async function reloadRecords(): Promise<void> {
isRefreshing.value = true;
try {
page.index = 0;
pageResult.total = 0;
pageResult.list = [];
pageResult.numberValueTotal = null;
optionStatList.value = [];
isNoMore.value = false;
await Promise.all([loadRecords(), loadOptionStats()]);
} finally {
isRefreshing.value = false;
}
}
async function loadRecords(): Promise<void> {
if (isLoading.value || isNoMore.value) {
return;
}
if (!eventList.value.length) {
isNoMore.value = true;
return;
}
const token = loadToken + 1;
loadToken = token;
isLoading.value = true;
try {
const result = await EventRecordAPI.list({
index: page.index,
size: page.size,
...buildRangeReq()
});
if (token !== loadToken) {
return;
}
pageResult.list = pageResult.list.concat(result.list || []);
pageResult.total = result.total || 0;
pageResult.numberValueTotal = result.numberValueTotal ?? null;
page.index += 1;
isNoMore.value = pageResult.list.length >= pageResult.total || (result.list || []).length < page.size;
} finally {
if (token === loadToken) {
isLoading.value = false;
}
}
}
// ---------- 操作 ----------
async function loadActions(): Promise<void> {
// 历史登记记录需要关联已停用或已删除的事件,不能使用只返回 ACTIVE 事件的 listValid。
const result = canCreateEvent.value
? await EventAPI.listHistory()
: await EventAPI.list({ index: 0, size: 1000 });
eventList.value = (result.list || []).filter(item => !isPackagePurchaseEvent(item));
const eventIdList = eventList.value.map(item => item.id).filter((item): item is string => Boolean(item));
const eventIdSet = new Set(eventIdList);
const selectedEventIdList = parseEventIdList(route.query.eventIds).filter(eventId => eventIdSet.has(eventId));
actionFilter.value = selectedEventIdList.length ? selectedEventIdList : eventIdList;
const optionIdSet = new Set(optionFilterOptions.value.map(item => item.value));
optionFilter.value = parseEventIdList(route.query.optionIds).filter(optionId => optionIdSet.has(optionId));
}
function getEventDisplayName(event: EventItem): string {
const name = event.name || "未命名事件";
const statusText = getEventStatusText(event.status);
return statusText ? `${name}(${statusText})` : name;
}
function getEventStatusText(status?: EventStatus): string {
if (status === "DRAFT") {
return "草稿";
}
if (status === "INACTIVE") {
return "不可登记";
}
if (status === "DELETED") {
return "已删除";
}
return "";
}
async function selectDateFilter(type: DropdownValue): Promise<void> {
if (Array.isArray(type)) {
return;
}
dateFilterType.value = String(type) as DateFilterType;
if (dateFilterType.value !== "custom") {
dateRange.value = buildPresetRange(dateFilterType.value);
await replaceFilterQuery();
await reloadRecords();
} else {
await replaceFilterQuery();
}
}
function openStartTime(): void {
startDraft.value = dateRange.value[0];
startVisible.value = true;
}
function openEndTime(): void {
endDraft.value = dateRange.value[1];
endVisible.value = true;
}
function cancelStartTime(): void {
startVisible.value = false;
startDraft.value = dateRange.value[0];
}
function cancelEndTime(): void {
endVisible.value = false;
endDraft.value = dateRange.value[1];
}
async function confirmStartTime(value?: string | number): Promise<void> {
startVisible.value = false;
dateRange.value = [String(value || startDraft.value), dateRange.value[1]];
await confirmCustomRange();
}
async function confirmEndTime(value?: string | number): Promise<void> {
endVisible.value = false;
dateRange.value = [dateRange.value[0], String(value || endDraft.value)];
await confirmCustomRange();
}
async function confirmCustomRange(): Promise<void> {
if (dateFilterType.value === "custom") {
return;
}
dateFilterType.value = "custom";
await replaceFilterQuery();
await reloadRecords();
}
async function exportExcel(): Promise<void> {
if (isExporting.value) {
return;
}
isExporting.value = true;
try {
const blob = await EventRecordAPI.exportXSSF({
index: 0,
size: pageSize,
...buildRangeReq()
});
WebToolkit.downloadBlob(blob, `登记事件记录-${Time.formatDate(new Date())}.xlsx`);
Message.success("导出已开始");
} finally {
isExporting.value = false;
}
}
async function loadOptionStats(): Promise<void> {
if (!hasSelectedOption.value) {
optionStatList.value = [];
return;
}
try {
optionStatList.value = await EventRecordAPI.optionStat({
index: 0,
size: 1,
...buildRangeReq()
});
} catch (error) {
optionStatList.value = [];
console.warn("登记选项统计加载失败", error);
}
}
async function toCreate(): Promise<void> {
await router.push({
path: "/management/registration-event-records/create",
query: {
transitionDepth: String(getCurrentRouteDepth() + 1)
}
});
}
function buildRangeReq(): Omit<EventRecordPage, "index" | "size"> {
const [beginAt, endAt] = buildUnixRange();
const eventIdList = actionFilter.value.length
? actionFilter.value
: eventList.value.map(item => item.id).filter((item): item is string => Boolean(item));
return {
beginAt,
endAt,
customerId: customerId.value || undefined,
keyword: keyword.value.trim() || undefined,
eventIdList,
optionIdList: optionFilter.value.length ? optionFilter.value : undefined,
verificationStatus: verificationFilter.value === "ALL" ? undefined : verificationFilter.value
};
}
function getQueryText(key: string): string {
const value = route.query[key];
return Array.isArray(value) ? String(value[0] || "") : String(value || "");
}
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 : "today";
}
function parseDateRange(type: DateFilterType): [string, string] {
if (type !== "custom") {
return buildPresetRange(type);
}
const beginAt = getQueryText("beginAt");
const endAt = getQueryText("endAt");
return [beginAt, 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 parseVerificationFilter(value: unknown): VerificationFilter {
const text = Array.isArray(value) ? String(value[0] || "") : String(value || "");
return verificationOptions.some(item => item.value === text) ? text as VerificationFilter : "ALL";
}
function buildFilterQuery(transitionDepth = getQueryText("transitionDepth")): Record<string, string> {
const query = {
keyword: keyword.value.trim(),
dateType: dateFilterType.value,
beginAt: dateRange.value[0] || "",
endAt: dateRange.value[1] || "",
customerId: customerId.value,
customerName: getQueryText("customerName"),
eventIds: actionFilter.value.join(","),
optionIds: optionFilter.value.join(","),
verificationStatus: verificationFilter.value === "ALL" ? "" : verificationFilter.value
};
if (isValidTransitionDepth(transitionDepth)) {
return {
...query,
transitionDepth
};
}
return query;
}
function getInitialKeyword(): string {
return getQueryText("keyword") || getQueryText("customerName");
}
function getCurrentRouteDepth(): number {
const transitionDepth = getQueryText("transitionDepth").trim();
if (isValidTransitionDepth(transitionDepth)) {
return Number(transitionDepth);
}
return Number(route.meta.depth ?? 0);
}
function isValidTransitionDepth(value: string): boolean {
const depth = Number(value);
return value !== "" && Number.isInteger(depth) && 0 <= depth && depth <= 20;
}
async function replaceFilterQuery(): Promise<void> {
await router.replace({
name: "EventRecordList",
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`];
}
if (type === "lastMonth") {
const current = new Date();
const begin = new Date(current.getFullYear(), current.getMonth() - 1, 1);
const end = new Date(current.getFullYear(), current.getMonth(), 0);
return [`${Time.formatDate(begin)} 00:00`, `${Time.formatDate(end)} 23:59`];
}
const offsetMap: Record<Exclude<DateFilterType, "all" | "custom" | "lastWeek" | "thisMonth" | "lastMonth">, number> = {
today: 0,
yesterday: 1
};
const date = new Date();
date.setHours(0, 0, 0, 0);
date.setDate(date.getDate() - offsetMap[type]);
const value = `${Time.formatDate(date)} 00:00`;
return [value, `${Time.formatDate(date)} 23:59`];
}
function buildUnixRange(): [number, number] {
if (dateFilterType.value === "all") {
return [0, Time.toTimestamp(new Date())];
}
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 () => {
navBarStore.clearRight();
navBarStore.setCustomRightVisible(canShowActionMenu.value);
await loadActions();
initialized = true;
await reloadRecords();
});
onUnmounted(() => {
navBarStore.clearRight();
window.clearTimeout(keywordTimer);
});
</script>
<style scoped lang="less">
.registration-record-list {
gap: 1rem;
display: flex;
padding: calc(var(--app-nav-height)) 0 6rem 0;
flex-direction: column;
.search-bar {
top: var(--app-nav-height);
left: 0;
right: 0;
z-index: 10;
display: flex;
padding: .75rem 1rem;
position: sticky;
background: #F5F5F8;
box-shadow: var(--td-shadow-1);
flex-direction: column;
.search-input {
--td-input-vertical-padding: 8px;
border: 1px solid rgba(148, 163, 184, .28);
z-index: 1;
background: #f8fbff;
border-radius: 1rem;
}
.filter-menu {
--td-spacer-1: 6px;
width: 100%;
overflow: hidden;
margin-top: -1rem;
padding-top: .8rem;
background: #fff;
border: 1px solid rgba(148, 163, 184, .18);
border-radius: 0 0 1rem 1rem;
&:after {
display: none;
}
:deep(.t-dropdown-menu__item) {
min-width: 0;
}
:deep(.t-dropdown-menu__title) {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
}
}
.list-wrapper {
gap: 1rem;
display: flex;
flex-direction: column;
.summary {
overflow: hidden;
:deep(.t-cell__title) {
flex: 0 0 4.5rem;
}
:deep(.t-cell__note) {
flex: 1 1 0;
min-width: 0;
overflow: hidden;
text-align: right;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.record-list {
:deep(.item:last-child .cell:after) {
display: none;
}
}
}
}
:global(html.theme-dark) {
.registration-record-list {
.search-bar {
background: #10161f;
.search-input {
background: rgba(15, 23, 42, .72);
border-color: rgba(148, 163, 184, .16);
}
.filter-menu {
background: #172033;
border-color: rgba(148, 163, 184, .16);
}
}
.empty-block,
.center-tips,
.summary {
color: #94a3b8;
}
}
}
</style>