556 lines
13 KiB
Vue
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>
|
|
|