init project

This commit is contained in:
Timi
2026-08-01 18:01:23 +08:00
parent 286fa1ba56
commit 11c48bf00c
107 changed files with 19350 additions and 1 deletions
@@ -0,0 +1,629 @@
<template>
<div ref="pageRef" v-can="gaoPermissionCodes.registrationEventRecord.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-cell-group
v-if="recordList.length"
class="record-list"
theme="card"
>
<t-swipe-cell
v-for="item in recordList"
:key="item.id"
class="item"
>
<t-cell
:title="getRecordRegistrationEventName(item)"
:description="getRecordCustomerName(item)"
:note="formatTime(item.registeredAt)"
arrow
hover
@click="goDetail(item.id || '')"
>
<template #leftIcon>
<t-image
v-if="getRecordPhotoURL(item)"
:src="getRecordPhotoURL(item)"
:alt="getRecordRegistrationEventName(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="deletingId === item.id"
@click.stop="confirmRemoveRecord(item)"
>
删除
</t-button>
</template>
</t-swipe-cell>
</t-cell-group>
<div v-else class="empty-block" v-text="loading ? '正在加载登记事件记录' : '暂无登记事件记录'" />
<div v-if="recordList.length" class="load-state" v-text="loadStateText" />
</div>
<t-button
v-can="gaoPermissionCodes.registrationEventRecord.read"
class="export"
theme="primary"
:loading="exporting"
@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 { AttachmentAPI, Scroller, Time, Toolkit, useUserStore } from "timi-web";
import RegistrationEventAPI from "@/api/RegistrationEventAPI";
import RegistrationEventRecordAPI from "@/api/RegistrationEventRecordAPI";
import { useNavBarStore } from "@/store/navBarStore";
import type { RegistrationEvent } from "@/types/RegistrationEvent";
import type { RegistrationEventRecordListItemView, RegistrationEventRecordRangeReq } from "@/types/RegistrationEventRecord";
import { gaoPermissionCodes } from "@/utils/gaoPermission";
type DateFilterType = "today" | "yesterday" | "beforeYesterday" | "lastWeek" | "all" | "custom";
defineOptions({
name: "RegistrationEventRecordList"
});
const pageSize = 20;
const scrollListenerName = "RegistrationEventRecordList";
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("");
const dateFilterType = ref<DateFilterType>("today");
const dateRange = ref(buildPresetRange("today"));
const actionFilter = ref<string[]>([]);
const registrationEventList = ref<RegistrationEvent[]>([]);
const startVisible = ref(false);
const endVisible = ref(false);
const pageRef = ref<HTMLElement>();
let keywordTimer: number | undefined;
let initialized = false;
function getPageScrollElement(): HTMLElement | null {
return pageRef.value?.closest(".pages") || null;
}
const canCreateRecord = computed(() => userStore.hasPermissions(gaoPermissionCodes.registrationEventRecord.create));
const isCustomRange = computed(() => dateFilterType.value === "custom");
const dateFilterLabel = computed(() => dateOptions.find(item => item.value === dateFilterType.value)?.label || "今天");
const actionOptions = computed(() => registrationEventList.value.map(item => ({
label: item.name || "未命名事件",
value: item.id || ""
})).filter(item => item.value));
const eventFilterLabel = computed(() => {
if (!actionFilter.value.length || actionFilter.value.length === registrationEventList.value.length) {
return "全部事件";
}
return registrationEventList.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(gaoPermissionCodes.registrationEventRecord.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 reloadRecords();
}, 300);
});
watch(actionFilter, async () => {
if (!initialized) {
return;
}
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 reloadRecords();
}, { deep: true });
// ---------- 列表分页 ----------
const pageIndex = ref(0);
const totalCount = ref(0);
const recordList = ref<RegistrationEventRecordListItemView[]>([]);
const loading = ref(false);
const noMore = ref(false);
let loadToken = 0;
const summaryTotalText = computed(() => `共 ${totalCount.value} 条数据,已加载 ${recordList.value.length} 条`);
const loadStateText = computed(() => {
if (loading.value) {
return "正在加载更多";
}
return noMore.value ? "没有更多记录" : "上拉加载更多";
});
async function reloadRecords(): Promise<void> {
pageIndex.value = 0;
totalCount.value = 0;
recordList.value = [];
noMore.value = false;
await loadRecords();
}
async function loadRecords(): Promise<void> {
if (loading.value || noMore.value) {
return;
}
const token = loadToken + 1;
loadToken = token;
loading.value = true;
try {
const result = await RegistrationEventRecordAPI.rangePage({
index: pageIndex.value,
size: pageSize,
equalsExample: buildRangeReq()
});
if (token !== loadToken) {
return;
}
recordList.value = recordList.value.concat(result.list || []);
totalCount.value = result.total || 0;
pageIndex.value += 1;
noMore.value = recordList.value.length >= totalCount.value || (result.list || []).length < pageSize;
} finally {
if (token === loadToken) {
loading.value = false;
}
}
}
// ---------- 操作 ----------
const exporting = ref(false);
const deletingId = ref("");
async function loadActions(): Promise<void> {
registrationEventList.value = await RegistrationEventAPI.enabledList();
actionFilter.value = registrationEventList.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 reloadRecords();
}
}
async function exportExcel(): Promise<void> {
if (exporting.value) {
return;
}
exporting.value = true;
try {
const blob = await RegistrationEventRecordAPI.rangeExport(buildRangeReq());
Toolkit.downloadBlob(blob, `登记事件记录-${Time.formatDate(new Date())}.xlsx`);
showToast("导出已开始");
} finally {
exporting.value = false;
}
}
async function goDetail(id: string): Promise<void> {
if (!id) {
return;
}
await router.push(`/management/registration-event-records/${id}`);
}
async function goCreate(): Promise<void> {
await router.push("/management/registration-event-records/create");
}
async function confirmRemoveRecord(record: RegistrationEventRecordListItemView): Promise<void> {
if (!record.id) {
return;
}
const recordId = record.id;
const dialog = DialogPlugin.confirm({
title: "删除登记记录",
content: `确定删除 ${getRecordCustomerName(record)} 的 ${getRecordRegistrationEventName(record)} 记录吗?删除后无法恢复。`,
cancelBtn: "取消",
confirmBtn: "删除",
onCancel() {
dialog.destroy();
},
async onConfirm() {
dialog.destroy();
await removeRecord(recordId);
}
});
}
async function removeRecord(id: string): Promise<void> {
if (deletingId.value) {
return;
}
deletingId.value = id;
try {
await RegistrationEventRecordAPI.remove(id);
recordList.value = recordList.value.filter(item => item.id !== id);
totalCount.value = Math.max(0, totalCount.value - 1);
showToast("登记记录已删除");
} finally {
deletingId.value = "";
}
}
// ---------- 格式化 ----------
function getRecordRegistrationEventName(record: RegistrationEventRecordListItemView): string {
return record.registrationEventName || "未知事件";
}
function getRecordCustomerName(record: RegistrationEventRecordListItemView): string {
return record.customerName || "未知客户";
}
function getRecordPhotoURL(record: RegistrationEventRecordListItemView): string {
if (!record.customerPhotoAttachmentId) {
return "";
}
return AttachmentAPI.getReadURL(record.customerPhotoAttachmentId);
}
function getRecordCustomerInitial(record: RegistrationEventRecordListItemView): string {
return (record.customerName || "客").slice(0, 1);
}
function formatTime(value?: number): string {
if (!value) {
return "-";
}
const date = Time.toDateObject(value);
if (!date) {
return "-";
}
return Time.formatDateTime(date);
}
function buildRangeReq(): RegistrationEventRecordRangeReq {
const [beginRegisteredAt, endRegisteredAt] = buildUnixRange();
const selectedAllEvent = !actionFilter.value.length || actionFilter.value.length === registrationEventList.value.length;
return {
beginRegisteredAt,
endRegisteredAt,
keyword: keyword.value.trim() || undefined,
registrationEventIdList: selectedAllEvent ? undefined : actionFilter.value
};
}
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);
}
function showToast(message: string, theme: "success" | "warning" = "success"): void {
Toast({
message,
theme
});
}
onMounted(async () => {
navBarStore.clearRight();
if (canCreateRecord.value) {
navBarStore.setRightText("添加", "var(--td-brand-color)", goCreate);
}
await loadActions();
initialized = true;
await reloadRecords();
Scroller.addBottomListener(scrollListenerName, loadRecords, {
canLoad: () => !loading.value && !noMore.value,
getElement: getPageScrollElement
});
});
onUnmounted(() => {
navBarStore.clearRight();
window.clearTimeout(keywordTimer);
Scroller.removeListener(scrollListenerName);
});
</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;
}
}
}
.photo {
width: 3.5rem;
height: 3.5rem;
border-radius: .875rem;
&.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;
}
}
.empty-block,
.load-state {
color: #52607a;
text-align: center;
}
}
.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>