Files
gao-customer-h5/src/pages/event/EventRecordList.vue
T
2026-09-21 13:05:37 +08:00

814 lines
22 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">
<div class="search-date">
<t-input
v-model.trim="keyword"
class="search-input"
type="search"
borderless
placeholder="搜索客户、姓名拼音、编码、事件、备注、登记人"
>
<template #suffix-icon>
<t-button
size="extra-small"
theme="primary"
variant="outline"
content="选择客户"
@click.stop="customerSelectorVisible = true"
/>
</template>
</t-input>
<t-dropdown-menu class="date-filter">
<t-dropdown-item
v-model="dateFilterType"
:label="dateFilterLabel"
:options="dateOptions"
@change="selectDateFilter"
/>
</t-dropdown-menu>
</div>
<t-dropdown-menu class="filter-menu">
<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-model="optionFilter"
:label="optionFilterLabel"
:footer="!optionFilterOptions.length ? optionFilterUnavailableFooter : undefined"
options-columns="2"
:options="optionFilterOptions"
multiple
/>
</t-dropdown-menu>
</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="Text.display(pageResult.total)" />
<t-cell title="客户数" :note="Text.display(pageResult.customerCount)" />
<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" />
<customer-selector
v-model="customerId"
v-model:visible="customerSelectorVisible"
@change="handleCustomerChange"
/>
<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, Text, WebToolkit } from "timi-web";
import { useUserStore } from "timi-web-api";
import EventAPI from "@/api/EventAPI";
import EventRecordAPI from "@/api/EventRecordAPI";
import CustomerSelector from "@/components/CustomerSelector.vue";
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 { isRetiredEvent } from "@/utils/event";
import { formatEventNumberValue } from "@/utils/eventValue";
import { buildPresetDateRange, parseDateFilterType, parseDateTime } from "@/utils/date";
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 customerSelectorVisible = ref(false);
const dateFilterType = ref<DateFilterType>(parseDateFilterType(route.query.dateType, dateOptions, "today"));
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 optionFilterUnavailableReason = computed(() => actionFilter.value.length === 1
? "当前选择的事件没有可用选项"
: "请先只选择一个事件,才能按选项筛选");
const optionFilterUnavailableFooter = (h: typeof import("vue").h) => h(
"div",
{ class: "center-tips red" },
optionFilterUnavailableReason.value
);
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;
});
async function handleCustomerChange(value: string | string[]): Promise<void> {
customerId.value = Array.isArray(value) ? String(value[0] || "") : value;
await replaceFilterQuery();
await reloadRecords();
}
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,
customerCount: 0
});
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;
pageResult.customerCount = 0;
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;
pageResult.customerCount = result.customerCount ?? 0;
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 => !isRetiredEvent(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 = buildPresetDateRange(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 parseDateRange(type: DateFilterType): [string, string] {
if (type !== "custom") {
return buildPresetDateRange(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 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))
];
}
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-date {
display: flex;
.search-input {
--td-button-border-radius: .75rem;
--td-input-vertical-padding: .25rem 1rem;
flex: 1;
border: 1px solid rgba(148, 163, 184, .28);
z-index: 2;
background: #f8fbff;
padding-right: .3rem;
border-radius: 1rem;
}
.date-filter {
--td-spacer: 4px;
--td-spacer-1: 6px;
border: 1px solid rgba(148, 163, 184, .18);
z-index: 1;
margin-left: -2.5rem;
padding-left: 3rem;
padding-right: .7rem;
border-radius: 1rem;
&:after {
display: none;
}
}
}
.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-top: none;
border-radius: 0 0 1rem 1rem;
.option-empty-tip {
color: #64748b;
padding: .75rem 1rem;
font-size: .8125rem;
text-align: center;
}
}
}
.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);
}
.date-filter,
.filter-menu {
background: #172033;
border-color: rgba(148, 163, 184, .16);
}
}
.empty-block,
.center-tips,
.summary {
color: #94a3b8;
}
}
}
</style>