Files
gao-customer-h5/src/components/EventRecordCalendar.vue
T
2026-08-24 16:51:21 +08:00

556 lines
13 KiB
Vue

<template>
<section class="register-record-calendar">
<div class="calendar-head">
<t-button class="month-btn" shape="square" variant="text" @click="shiftMonth(-1)">
<template #icon>
<t-icon name="chevron-left" />
</template>
</t-button>
<h2 v-text="monthTitle" />
<div class="head-actions">
<t-button class="month-btn" shape="square" variant="text" @click="shiftMonth(1)">
<template #icon>
<t-icon name="chevron-right" />
</template>
</t-button>
<t-button
v-if="showEventFilter"
class="filter-btn"
theme="primary"
size="extra-small"
variant="outline"
@click="eventFilterVisible = true"
content="登记事件"
>
<template #icon>
<t-icon name="filter" />
</template>
</t-button>
</div>
</div>
<div class="week-line">
<span v-for="item in weekNameList" :key="item" v-text="item" />
</div>
<div class="calendar-grid">
<button
v-for="item in calendarDayList"
:key="item.key"
class="day-cell"
:class="{muted: !item.inMonth, today: item.isToday, marked: 0 < item.records.length, 'rest-day': item.isRestDay}"
type="button"
:disabled="!item.records.length"
@click="openDayRecords(item)"
>
<span class="date">
<span v-text="item.day" />
<span v-if="item.isRestDay" class="rest">休</span>
</span>
<span v-if="item.records.length" class="count" v-text="`${item.records.length} 条`" />
</button>
</div>
<div v-if="loading" class="empty-block">正在加载登记日历</div>
<div v-else-if="!currentMonthFilteredRecords.length" class="empty-block" v-text="emptyText" />
<t-cell title="登记事件记录列表" arrow @click="openRecordList">
<template #left-icon>
<t-icon name="catalog" />
</template>
</t-cell>
<t-popup v-model="dayRecordVisible" placement="bottom" destroy-on-close>
<div class="day-record-panel">
<div class="popup-head">
<h3 v-text="selectedDayTitle" />
<t-button theme="default" variant="text" size="small" @click="dayRecordVisible = false">
关闭
</t-button>
</div>
<div class="popup-body">
<t-cell-group v-if="selectedDayRecords.length" theme="card">
<t-cell
v-for="item in selectedDayRecords"
:key="item.id"
:title="getRecordEventName(item)"
:description="getRecordCustomerName(item)"
:note="`${formatRecordTime(item.registeredAt)}`"
arrow
hover
@click="toRecordDetail(item)"
/>
</t-cell-group>
<div v-else class="empty-block">该日暂无登记事件记录</div>
</div>
</div>
</t-popup>
<t-popup v-model="eventFilterVisible" placement="bottom" destroy-on-close>
<div class="filter-popup">
<div class="popup-head">
<div class="title">登记事件筛选</div>
<t-button theme="default" variant="text" size="small" @click="eventFilterVisible = false">
关闭
</t-button>
</div>
<div class="popup-body">
<event-selector
v-model="selectedEventIdListProxy"
:event-list="eventList"
label=""
/>
</div>
</div>
</t-popup>
</section>
</template>
<script setup lang="ts">
import StoreAPI from "@/api/StoreAPI";
import EventSelector from "@/components/EventSelector.vue";
import type {EventItem} from "@/types/EventItem";
import type {EventRecord} from "@/types/EventRecord";
import type {StoreBusinessDay} from "@/types/Store";
import {getCurrentStoreId} from "@/utils/store";
import {Time} from "timi-web";
interface CalendarDay {
key: string;
day: string;
dateText: string;
inMonth: boolean;
isToday: boolean;
isRestDay: boolean;
records: EventRecord[];
}
export interface EventRecordCalendarListPayload {
monthCursor: Date;
selectedEventIdList: string[];
}
const props = withDefaults(defineProps<{
eventList: EventItem[];
customerName?: string;
emptyText?: string;
loading?: boolean;
modelValue: Date;
records: EventRecord[];
selectedEventIdList: string[];
showEventFilter?: boolean;
storeId?: string;
}>(), {
customerName: "",
emptyText: "当前月份暂无匹配登记",
loading: false,
showEventFilter: true,
storeId: ""
});
const emit = defineEmits<{
"update:modelValue": [value: Date];
"update:selectedEventIdList": [value: string[]];
list: [value: EventRecordCalendarListPayload];
}>();
const route = useRoute();
const router = useRouter();
const weekNameList = ["日", "一", "二", "三", "四", "五", "六"];
const dayRecordVisible = ref(false);
const eventFilterVisible = ref(false);
const selectedDayTitle = ref("");
const selectedDayRecords = ref<EventRecord[]>([]);
const businessDayList = ref<StoreBusinessDay[]>([]);
let businessDayRequestId = 0;
const selectedEventIdListProxy = computed({
get: () => props.selectedEventIdList,
set: value => emit("update:selectedEventIdList", value)
});
const eventNameMap = computed(() => {
const map = new Map<string, string>();
for (const item of props.eventList) {
if (item.id) {
map.set(item.id, item.name || "未命名事件");
}
}
return map;
});
const selectedEventIdSet = computed(() => new Set(props.selectedEventIdList));
const resolvedStoreId = computed(() => props.storeId || getCurrentStoreId());
const restDaySet = computed(() => new Set(
businessDayList.value
.filter(item => item.status === "CLOSED" && item.dateValue)
.map(item => item.dateValue as number)
));
const filteredRecords = computed(() => {
if (!props.showEventFilter || !props.selectedEventIdList.length) {
return props.records;
}
return props.records.filter(item => item.eventId && selectedEventIdSet.value.has(item.eventId));
});
const currentMonthFilteredRecords = computed(() => filteredRecords.value.filter(item => isInCurrentMonth(item.registeredAt)));
const monthTitle = computed(() => `${props.modelValue.getFullYear()} 年 ${props.modelValue.getMonth() + 1} 月`);
const calendarDayList = computed<CalendarDay[]>(() => {
const beginDate = getCalendarBeginDate(props.modelValue);
const todayText = Time.formatDate(new Date());
const list: CalendarDay[] = [];
for (let index = 0; index < 42; index += 1) {
const date = addDays(beginDate, index);
const dateText = Time.formatDate(date);
list.push({
key: dateText,
day: String(date.getDate()),
dateText,
inMonth: date.getMonth() === props.modelValue.getMonth(),
isToday: dateText === todayText,
isRestDay: restDaySet.value.has(toDateValue(date)),
records: filteredRecords.value.filter(item => getRecordDateText(item) === dateText)
});
}
return list;
});
watch([() => props.modelValue, resolvedStoreId], async () => {
await loadBusinessDays();
}, {
deep: true,
immediate: true
});
async function loadBusinessDays(): Promise<void> {
const storeId = resolvedStoreId.value;
if (!storeId) {
businessDayList.value = [];
return;
}
const requestId = ++businessDayRequestId;
const beginDate = getCalendarBeginDate(props.modelValue);
const endDate = addDays(beginDate, 41);
try {
const result = await StoreAPI.businessDayCalendar(storeId, toDateValue(beginDate), toDateValue(endDate));
if (requestId === businessDayRequestId) {
businessDayList.value = result || [];
}
} catch {
if (requestId === businessDayRequestId) {
businessDayList.value = [];
}
}
}
function shiftMonth(offset: number): void {
emit("update:modelValue", addMonths(props.modelValue, offset));
}
function openDayRecords(day: CalendarDay): void {
if (!day.records.length) {
return;
}
selectedDayTitle.value = `${day.dateText} 登记事件记录`;
selectedDayRecords.value = [...day.records].sort((left, right) => (right.registeredAt || 0) - (left.registeredAt || 0));
dayRecordVisible.value = true;
}
function openRecordList(): void {
emit("list", {
monthCursor: props.modelValue,
selectedEventIdList: props.selectedEventIdList
});
}
async function toRecordDetail(record: EventRecord): Promise<void> {
if (!record.id) {
return;
}
dayRecordVisible.value = false;
await router.push({
path: `/management/registration-event-records/${record.id}`,
query: {
transitionDepth: String(getCurrentRouteDepth() + 1)
}
});
}
function getRecordEventName(record?: EventRecord): string {
if (!record) {
return "";
}
return record.event?.name || (record.eventId ? eventNameMap.value.get(record.eventId) : "") || "未知事件";
}
function getRecordCustomerName(record: EventRecord): string {
return record.customer?.name || props.customerName || record.customerId || "未知客户";
}
function formatRecordTime(value?: number): string {
if (!value) {
return "-";
}
const date = Time.toDateObject(value);
if (!date) {
return "-";
}
return Time.formatDateTime(date);
}
function getRecordDateText(record: EventRecord): string {
const date = Time.toDateObject(record.registeredAt);
return date ? Time.formatDate(date) : "";
}
function isInCurrentMonth(value?: number): boolean {
const date = Time.toDateObject(value);
return !!date && date.getFullYear() === props.modelValue.getFullYear() && date.getMonth() === props.modelValue.getMonth();
}
function getCurrentRouteDepth(): number {
return Number(route.meta.depth ?? 0);
}
function getMonthStart(date: Date): Date {
return new Date(date.getFullYear(), date.getMonth(), 1, 0, 0, 0, 0);
}
function getCalendarBeginDate(date: Date): Date {
const monthStart = getMonthStart(date);
return addDays(monthStart, -monthStart.getDay());
}
function addDays(date: Date, days: number): Date {
const value = new Date(date);
value.setDate(value.getDate() + days);
return value;
}
function addMonths(date: Date, months: number): Date {
return new Date(date.getFullYear(), date.getMonth() + months, 1, 0, 0, 0, 0);
}
function toDateValue(date: Date): number {
return Number(Time.formatDate(date).replaceAll("-", ""));
}
</script>
<style scoped lang="less">
.register-record-calendar {
display: flex;
overflow: hidden;
background: rgba(255, 255, 255, .92);
box-shadow: var(--td-shadow-1);
border-radius: .75rem;
flex-direction: column;
.calendar-head {
display: flex;
padding: .5rem 1rem;
align-items: center;
justify-content: space-between;
h2 {
margin: 0;
color: #0f172a;
font-size: 1rem;
font-weight: 700;
}
.month-btn {
width: 2.5rem;
height: 2.5rem;
}
.head-actions {
gap: .5rem;
display: flex;
align-items: center;
}
}
.week-line,
.calendar-grid {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
}
.week-line {
color: #52607a;
padding: 0 1rem;
font-size: .75rem;
text-align: center;
}
.calendar-grid {
gap: .375rem;
padding: 1rem 1rem .5rem 1rem;
.day-cell {
padding: .25rem;
height: 2.5rem;
display: flex;
min-width: 0;
color: #0f172a;
border: 1px solid var(--td-component-border);
background: #fff;
text-align: left;
align-items: flex-start;
border-radius: .25rem;
flex-direction: column;
.date {
display: flex;
font-size: .875rem;
align-items: center;
font-weight: 700;
}
.rest {
color: orange;
margin-left: .125rem;
font-size: .625rem;
font-weight: 700;
}
.count {
width: 100%;
color: var(--td-brand-color);
overflow: hidden;
font-size: .625rem;
font-weight: 700;
white-space: nowrap;
text-overflow: ellipsis;
}
&.muted {
color: #94a3b8;
background: #f8fafc;
}
&.today {
color: var(--td-brand-color);
}
&.marked {
background: var(--td-brand-color-1);
}
&.rest-day {
background: #fef2f2;
}
&:disabled {
cursor: default;
}
}
}
.empty-block {
padding: 1rem;
color: #52607a;
text-align: center;
background: #f7faff;
border-radius: .75rem;
}
}
.day-record-panel,
.filter-popup {
gap: .875rem;
padding: 1rem 0 calc(1rem + env(safe-area-inset-bottom, 0)) 0;
display: flex;
background: #f5f5f8;
border-radius: 1rem 1rem 0 0;
flex-direction: column;
max-height: min(80vh, 40rem);
h3,
.title {
margin: 0;
color: #0f172a;
font-size: 1rem;
text-align: center;
font-weight: 700;
}
.popup-head {
gap: .5rem;
display: flex;
padding: 0 1rem;
align-items: center;
justify-content: space-between;
}
.popup-body {
flex: 1;
overflow-y: auto;
}
}
.filter-popup {
padding: 1rem 1rem calc(1rem + env(safe-area-inset-bottom, 0)) 1rem;
.popup-head,
.popup-body {
padding: 0;
}
}
:global(html.theme-dark) {
.register-record-calendar {
background: rgba(15, 23, 42, .88);
box-shadow: 0 .75rem 2rem rgba(2, 6, 23, .32);
.calendar-head {
h2 {
color: #e2e8f0;
}
}
.week-line {
color: #94a3b8;
}
.calendar-grid {
.day-cell {
color: #e2e8f0;
background: rgba(30, 41, 59, .9);
.count {
color: #60a5fa;
}
&.muted {
color: #94a3b8;
background: rgba(15, 23, 42, .7);
}
&.marked {
background: rgba(37, 99, 235, .2);
}
&.rest-day {
background: rgba(220, 38, 38, .12);
}
}
}
.empty-block {
color: #94a3b8;
background: rgba(30, 41, 59, .9);
}
}
.day-record-panel,
.filter-popup {
background: #10161f;
h3,
.title {
color: #e2e8f0;
}
}
}
</style>