Files
gao-customer-h5/src/pages/event/EventRecordList.vue
T
2026-08-11 15:24:36 +08:00

693 lines
17 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-menu>
</div>
</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>
<div class="summary">
<div v-text="summaryText" />
<div v-text="summaryBehaviorText" />
<div v-text="summaryTotalText" />
</div>
<t-pull-down-refresh
v-model="isRefreshing"
:refresh-timeout="30000"
@refresh="reloadRecords"
@scrolltolower="loadRecords"
>
<t-cell-group
v-if="pageResult.list.length"
class="record-list"
theme="card"
>
<t-swipe-cell
v-for="item in pageResult.list"
:key="item.id"
class="item"
>
<t-cell
class="cell"
:title="getRecordEventName(item)"
:description="getRecordCustomerName(item)"
:note="item.registeredAt ? Time.toDateTime(item.registeredAt) : '-'"
arrow
hover
@click="toDetail(item.id || '')"
>
<template #leftIcon>
<t-image
v-if="getRecordPhotoURL(item)"
:src="getRecordPhotoURL(item)"
:alt="getRecordEventName(item)"
fit="cover"
class="photo"
/>
<div v-else class="photo fallback" v-text="getRecordCustomerInitial(item)" />
</template>
</t-cell>
<template v-if="canDeleteRecord" #right>
<t-button
class="delete"
theme="danger"
:loading="isDeleting && deletingId === item.id"
@click.stop="confirmRemoveRecord(item)"
>
删除
</t-button>
</template>
</t-swipe-cell>
</t-cell-group>
<div v-else class="load-state" v-text="isLoading ? '正在加载登记事件记录' : '暂无登记事件记录'" />
<div v-if="pageResult.list.length" class="load-state" v-text="loadStateText" />
</t-pull-down-refresh>
</div>
<t-button
v-can="permissionCodes.eventRecord.export"
class="export"
theme="primary"
:loading="isExporting"
@click="exportExcel"
>
<template #icon>
<t-icon name="download" />
</template>
导出 Excel
</t-button>
<t-popup v-model="startVisible" placement="bottom">
<t-date-time-picker
:default-value="dateRange[0]"
format="YYYY-MM-DD HH:mm"
:mode="['date', 'minute']"
title="开始时间"
v-model="dateRange[0]"
@confirm="startVisible = false"
/>
</t-popup>
<t-popup v-model="endVisible" placement="bottom">
<t-date-time-picker
:default-value="dateRange[1]"
:mode="['date', 'minute']"
format="YYYY-MM-DD HH:mm"
title="结束时间"
v-model="dateRange[1]"
@confirm="endVisible = false"
/>
</t-popup>
</template>
<script setup lang="ts">
import { DialogPlugin, Toast, type DropdownValue } from "tdesign-mobile-vue";
import { Time, Toolkit, type PageResult, useUserStore } from "timi-web";
import EventAPI from "@/api/EventAPI";
import EventRecordAPI from "@/api/EventRecordAPI";
import { useNavBarStore } from "@/store/navBarStore";
import type { EventItem } from "@/types/EventItem";
import type { EventRecordListItemView, EventRecordPage } from "@/types/EventRecord";
import { getCustomerPhotoURL } from "@/utils/customer";
import { permissionCodes } from "@/utils/permission";
type DateFilterType = "today" | "yesterday" | "beforeYesterday" | "lastWeek" | "all" | "custom";
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: "beforeYesterday" },
{ label: "上周", value: "lastWeek" },
{ label: "全部", value: "all" },
{ label: "自定义范围", value: "custom" }
];
// ---------- 筛选 ----------
const keyword = ref(getQueryText("keyword"));
const dateFilterType = ref<DateFilterType>(parseDateFilterType(route.query.dateType));
const dateRange = ref(parseDateRange(dateFilterType.value));
const actionFilter = ref<string[]>([]);
const eventList = ref<EventItem[]>([]);
const startVisible = ref(false);
const endVisible = ref(false);
let keywordTimer: number | undefined;
let initialized = false;
const canCreateRecord = computed(() => userStore.hasPermissions(permissionCodes.eventRecord.create));
const isCustomRange = computed(() => dateFilterType.value === "custom");
const dateFilterLabel = computed(() => dateOptions.find(item => item.value === dateFilterType.value)?.label || "今天");
const actionOptions = computed(() => eventList.value.map(item => ({
label: item.name || "未命名事件",
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 => item.name || "未命名事件")
.join("、");
});
const summaryBehaviorText = computed(() => `事件:${eventFilterLabel.value}`);
const canDeleteRecord = computed(() => userStore.hasPermissions(permissionCodes.eventRecord.delete));
const summaryText = computed(() => {
if (!isCustomRange.value) {
const current = dateOptions.find(item => item.value === dateFilterType.value);
return `范围:${current?.label || "今天"}|${dateRange.value[0] || "不限"}`;
}
return `范围:${dateRange.value[0] || "-"} ~ ${dateRange.value[1] || "-"}`;
});
watch(keyword, () => {
if (!initialized) {
return;
}
window.clearTimeout(keywordTimer);
keywordTimer = window.setTimeout(async () => {
await replaceFilterQuery();
await reloadRecords();
}, 300);
});
watch(actionFilter, 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<PageResult<EventRecordListItemView>>({
total: 0,
list: []
});
let loadToken = 0;
const summaryTotalText = computed(() => `共 ${pageResult.total} 条数据,已加载 ${pageResult.list.length} 条`);
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 = [];
isNoMore.value = false;
await loadRecords();
} finally {
isRefreshing.value = false;
}
}
async function loadRecords(): Promise<void> {
if (isLoading.value || isNoMore.value) {
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;
page.index += 1;
isNoMore.value = pageResult.list.length >= pageResult.total || (result.list || []).length < page.size;
} finally {
if (token === loadToken) {
isLoading.value = false;
}
}
}
// ---------- 操作 ----------
const isExporting = ref(false);
const isDeleting = ref(false);
const deletingId = ref("");
async function loadActions(): Promise<void> {
eventList.value = await EventAPI.listValid();
const eventIdList = parseEventIdList(route.query.eventIds);
actionFilter.value = eventIdList.length
? eventIdList
: eventList.value.map(item => item.id).filter((item): item is string => Boolean(item));
}
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();
}
}
async function exportExcel(): Promise<void> {
if (isExporting.value) {
return;
}
isExporting.value = true;
try {
const blob = await EventRecordAPI.exportXSSF({
index: 0,
size: pageSize,
...buildRangeReq()
});
Toolkit.downloadBlob(blob, `登记事件记录-${Time.formatDate(new Date())}.xlsx`);
Toast.success("导出已开始");
} finally {
isExporting.value = false;
}
}
async function toDetail(id: string): Promise<void> {
if (!id) {
return;
}
await router.push({
path: `/management/registration-event-records/${id}`,
query: buildFilterQuery()
});
}
async function toCreate(): Promise<void> {
await router.push("/management/registration-event-records/create");
}
async function confirmRemoveRecord(record: EventRecordListItemView): Promise<void> {
if (!record.id) {
return;
}
const recordId = record.id;
const dialog = DialogPlugin.confirm({
title: "删除登记记录",
content: `确定删除 ${getRecordCustomerName(record)} 的 ${getRecordEventName(record)} 记录吗?删除后无法恢复。`,
cancelBtn: "取消",
confirmBtn: "删除",
onCancel() {
dialog.destroy();
},
async onConfirm() {
dialog.destroy();
await removeRecord(recordId);
}
});
}
async function removeRecord(id: string): Promise<void> {
if (isDeleting.value) {
return;
}
isDeleting.value = true;
deletingId.value = id;
try {
await EventRecordAPI.remove(id);
pageResult.list = pageResult.list.filter(item => item.id !== id);
pageResult.total = Math.max(0, pageResult.total - 1);
Toast.success("登记记录已删除");
} finally {
isDeleting.value = false;
deletingId.value = "";
}
}
// ---------- 格式化 ----------
function getRecordEventName(record: EventRecordListItemView): string {
return record.event?.name || "未知事件";
}
function getRecordCustomerName(record: EventRecordListItemView): string {
return record.customer?.name || "未知客户";
}
function getRecordPhotoURL(record: EventRecordListItemView): string {
return getCustomerPhotoURL(record.customer);
}
function getRecordCustomerInitial(record: EventRecordListItemView): string {
return getRecordCustomerName(record).slice(0, 1);
}
function buildRangeReq(): Omit<EventRecordPage, "index" | "size"> {
const [beginAt, endAt] = buildUnixRange();
const selectedAllEvent = !actionFilter.value.length || actionFilter.value.length === eventList.value.length;
return {
beginAt,
endAt,
keyword: keyword.value.trim() || undefined,
eventIdList: selectedAllEvent ? undefined : actionFilter.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 buildFilterQuery(): Record<string, string> {
return {
keyword: keyword.value.trim(),
dateType: dateFilterType.value,
beginAt: dateRange.value[0] || "",
endAt: dateRange.value[1] || "",
eventIds: actionFilter.value.join(",")
};
}
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`];
}
const offsetMap: Record<Exclude<DateFilterType, "all" | "custom" | "lastWeek">, number> = {
today: 0,
yesterday: 1,
beforeYesterday: 2
};
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();
if (canCreateRecord.value) {
navBarStore.setRightText("添加", "var(--td-brand-color)", toCreate);
}
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;
}
}
}
.summary {
color: #52607a;
font-size: .8125rem;
text-align: center;
}
.record-list {
.item {
&:last-child {
:deep(.t-cell:after) {
display: none;
}
}
.cell {
padding: .5rem;
:deep(.t-cell__title) {
flex: 1 1 0;
min-width: 0;
}
:deep(.t-cell__title-text) {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
:deep(.t-cell__note) {
flex: 0 0 auto;
white-space: nowrap;
}
.photo {
width: 3.5rem;
height: 3.5rem;
border-radius: .5rem;
&.fallback {
color: #2563eb;
display: flex;
font-size: 1.25rem;
background: #dbeafe;
align-items: center;
font-weight: 600;
justify-content: center;
}
}
}
.delete {
height: 100%;
padding: 0 1rem;
border-radius: 0;
}
}
}
}
.export {
left: 1rem;
right: 1rem;
bottom: calc(.75rem + env(safe-area-inset-bottom, 0));
z-index: 20;
position: fixed;
box-shadow: 0 -.375rem 1.25rem rgba(15, 23, 42, .08);
backdrop-filter: blur(.75rem);
}
: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,
.load-state,
.summary {
color: #94a3b8;
}
}
.bottom-bar {
background: rgba(15, 23, 42, .88);
box-shadow: 0 -.375rem 1.25rem rgba(2, 6, 23, .32);
}
}
</style>