454 lines
11 KiB
Vue
454 lines
11 KiB
Vue
<template>
|
||
<div
|
||
v-if="store"
|
||
v-can="permissionCodes.store.read"
|
||
class="store-business-day-calendar"
|
||
>
|
||
<header class="summary bg-white" v-entrance-transition>
|
||
<header class="header">
|
||
<div class="text-bold" v-text="store.name" />
|
||
<t-tag variant="outline" :content="store.code" />
|
||
</header>
|
||
<div class="address gray">
|
||
<span>地址:</span>
|
||
<span v-text="store.address" />
|
||
</div>
|
||
</header>
|
||
<card-container
|
||
class="calendar-card"
|
||
description="设置门店休息日,该日无任何登记记录不视为中断连续登记事件。已有登记不可设为休息,标记休息不能产生登记"
|
||
tips="点击日期即可新增营休日"
|
||
v-entrance-transition="70"
|
||
>
|
||
<t-calendar
|
||
:key="calendarRenderKey"
|
||
class="calendar"
|
||
:value="selectedCalendarDate"
|
||
:min-date="calendarMinDate"
|
||
:max-date="calendarMaxDate"
|
||
:format="calendarFormat"
|
||
switch-mode="month"
|
||
:use-popup="false"
|
||
@panel-change="handlePanelChange"
|
||
@select="selectCalendarDate"
|
||
/>
|
||
<div v-if="isCalendarLoading" class="loading" v-text="'正在加载当月营业日'" />
|
||
</card-container>
|
||
</div>
|
||
<t-popup v-model="editorVisible" placement="bottom">
|
||
<div class="editor">
|
||
<div class="header">
|
||
<div class="text-bold" v-text="editingId ? '编辑营业日' : '新增营业日'" />
|
||
<div v-text="formModel.dateText" />
|
||
</div>
|
||
<t-form label-align="left">
|
||
<t-form-item label="状态">
|
||
<t-radio-group v-model="formModel.status" variant="default-filled">
|
||
<t-radio :block="false" borderless value="OPEN">营业</t-radio>
|
||
<t-radio :block="false" borderless value="CLOSED">休息</t-radio>
|
||
</t-radio-group>
|
||
</t-form-item>
|
||
<t-form-item
|
||
class="reason"
|
||
:class="{ 'is-hidden': formModel.status !== 'CLOSED' }"
|
||
label="原因"
|
||
>
|
||
<t-input
|
||
v-model.trim="formModel.reason"
|
||
:disabled="formModel.status !== 'CLOSED'"
|
||
placeholder="休息原因或备注"
|
||
borderless
|
||
/>
|
||
</t-form-item>
|
||
</t-form>
|
||
<span class="hint orange">切换营业或休息可能影响连续登记统计,系统重排营业日事件序列可能需要较长时间。</span>
|
||
<div class="actions">
|
||
<t-button
|
||
v-if="editingId"
|
||
v-can="deletePermissionCode"
|
||
class="action"
|
||
theme="danger"
|
||
variant="outline"
|
||
:loading="isDeleting"
|
||
@click="confirmRemoveDay"
|
||
>删除</t-button>
|
||
<t-button
|
||
v-can="savePermissionCode"
|
||
class="action"
|
||
theme="primary"
|
||
:loading="isSaving"
|
||
@click="saveDay"
|
||
>保存</t-button>
|
||
</div>
|
||
</div>
|
||
</t-popup>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { type CalendarProps, DialogPlugin, type TDate } from "tdesign-mobile-vue";
|
||
import { useUserStore } from "timi-web-api";
|
||
import StoreAPI from "@/api/StoreAPI";
|
||
import type { Store, StoreBusinessDay, StoreBusinessDayStatus } from "@/types/Store";
|
||
import { gaoRoleCodes, permissionCodes } from "@/utils/permission";
|
||
import { Message } from "@/utils/message";
|
||
|
||
defineOptions({
|
||
name: "StoreBusinessDayCalendar"
|
||
});
|
||
|
||
const route = useRoute();
|
||
const userStore = useUserStore();
|
||
const storeId = computed(() => String(route.params.id || ""));
|
||
const isStoreManagerScope = computed(() => userStore.hasRoles(gaoRoleCodes.storeManager) && !userStore.hasRoles(gaoRoleCodes.globalManager));
|
||
|
||
// ---------- 门店信息 ----------
|
||
|
||
const store = ref<Store>();
|
||
|
||
// ---------- 日历数据 ----------
|
||
|
||
const today = new Date();
|
||
const calendarMinDate = new Date(today.getFullYear() - 2, 0, 1);
|
||
const calendarMaxDate = new Date(today.getFullYear() + 2, 11, 31);
|
||
const selectedCalendarDate = ref<Date>(today);
|
||
const calendarMonth = reactive({
|
||
year: today.getFullYear(),
|
||
month: today.getMonth() + 1
|
||
});
|
||
const businessDayList = ref<StoreBusinessDay[]>([]);
|
||
const isCalendarLoading = ref(false);
|
||
const calendarRenderKey = ref(0);
|
||
let calendarRequestId = 0;
|
||
|
||
const businessDayMap = computed(() => new Map(
|
||
businessDayList.value
|
||
.filter(item => item.dateValue)
|
||
.map(item => [item.dateValue as number, item])
|
||
));
|
||
|
||
const calendarFormat = computed<NonNullable<CalendarProps["format"]>>(() => {
|
||
const dayMap = businessDayMap.value;
|
||
return (dateItem: TDate) => {
|
||
const day = dayMap.get(toDateValue(dateItem.date));
|
||
if (!day) {
|
||
return dateItem;
|
||
}
|
||
return {
|
||
...dateItem,
|
||
className: `business-day-${day.status!.toLowerCase()}`,
|
||
suffix: day.status === "OPEN" ? "营" : "休"
|
||
};
|
||
};
|
||
});
|
||
|
||
async function loadCalendar(year: number, month: number): Promise<void> {
|
||
const requestId = ++calendarRequestId;
|
||
isCalendarLoading.value = true;
|
||
try {
|
||
const beginDate = new Date(year, month - 1, 1);
|
||
const endDate = new Date(year, month, 0);
|
||
const result = await StoreAPI.businessDayCalendar(storeId.value, toDateValue(beginDate), toDateValue(endDate));
|
||
if (requestId === calendarRequestId) {
|
||
businessDayList.value = result || [];
|
||
calendarRenderKey.value += 1;
|
||
}
|
||
} finally {
|
||
if (requestId === calendarRequestId) {
|
||
isCalendarLoading.value = false;
|
||
}
|
||
}
|
||
}
|
||
|
||
async function handlePanelChange(context: { year: number; month: number }): Promise<void> {
|
||
if (context.year === calendarMonth.year && context.month === calendarMonth.month) {
|
||
return;
|
||
}
|
||
calendarMonth.year = context.year;
|
||
calendarMonth.month = context.month;
|
||
selectedCalendarDate.value = new Date(context.year, context.month - 1, 1);
|
||
try {
|
||
await loadCalendar(context.year, context.month);
|
||
} catch {
|
||
Message.error("营业日加载失败");
|
||
}
|
||
}
|
||
|
||
// ---------- 编辑 ----------
|
||
|
||
const editorVisible = ref(false);
|
||
const isSaving = ref(false);
|
||
const isDeleting = ref(false);
|
||
const editingId = ref("");
|
||
const formModel = reactive({
|
||
dateText: "",
|
||
status: "OPEN" as StoreBusinessDayStatus,
|
||
reason: "正常休息"
|
||
});
|
||
const savePermissionCode = computed(() => {
|
||
if (isStoreManagerScope.value) {
|
||
return permissionCodes.store.update;
|
||
}
|
||
|
||
return editingId.value ? permissionCodes.store.update : permissionCodes.store.create;
|
||
});
|
||
const deletePermissionCode = computed(() => isStoreManagerScope.value ? permissionCodes.store.update : permissionCodes.store.delete);
|
||
|
||
function openDayEditor(day?: StoreBusinessDay, dateValue = toDateValue(new Date())): void {
|
||
editingId.value = day?.id || "";
|
||
Object.assign(formModel, {
|
||
dateText: formatDateValue(day?.dateValue || dateValue),
|
||
status: day?.status || "OPEN",
|
||
reason: day ? day.reason : "正常休息"
|
||
});
|
||
editorVisible.value = true;
|
||
}
|
||
|
||
function selectCalendarDate(date: Date): void {
|
||
selectedCalendarDate.value = date;
|
||
const dateValue = toDateValue(date);
|
||
openDayEditor(businessDayMap.value.get(dateValue), dateValue);
|
||
}
|
||
|
||
async function saveDay(): Promise<void> {
|
||
if (!validateDay() || isSaving.value) {
|
||
return;
|
||
}
|
||
|
||
isSaving.value = true;
|
||
try {
|
||
const day: StoreBusinessDay = {
|
||
id: editingId.value || undefined,
|
||
storeId: storeId.value,
|
||
dateValue: parseDateValue(formModel.dateText),
|
||
status: formModel.status,
|
||
reason: formModel.status === "CLOSED" ? formModel.reason || undefined : undefined
|
||
};
|
||
if (editingId.value) {
|
||
await StoreAPI.updateBusinessDay(day);
|
||
} else {
|
||
await StoreAPI.createBusinessDay(day);
|
||
}
|
||
Message.success("营业日已保存");
|
||
editorVisible.value = false;
|
||
await loadCalendar(calendarMonth.year, calendarMonth.month);
|
||
} catch {
|
||
Message.error("营业日保存失败");
|
||
} finally {
|
||
isSaving.value = false;
|
||
}
|
||
}
|
||
|
||
function validateDay(): boolean {
|
||
if (!formModel.dateText) {
|
||
Message.warning("请选择日期");
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
// ---------- 删除 ----------
|
||
|
||
function confirmRemoveDay(): void {
|
||
if (!editingId.value || isDeleting.value) {
|
||
return;
|
||
}
|
||
|
||
const dayId = editingId.value;
|
||
const dialog = DialogPlugin.confirm({
|
||
title: "删除营业日",
|
||
content: `确定删除 ${formModel.dateText} 的营业日配置吗?已有登记记录的营业日系统会拒绝删除`,
|
||
cancelBtn: "取消",
|
||
confirmBtn: "删除",
|
||
onCancel() {
|
||
dialog.destroy();
|
||
},
|
||
async onConfirm() {
|
||
dialog.destroy();
|
||
await removeDay(dayId);
|
||
}
|
||
});
|
||
}
|
||
|
||
async function removeDay(id: string): Promise<void> {
|
||
isDeleting.value = true;
|
||
try {
|
||
await StoreAPI.removeBusinessDay(id);
|
||
Message.success("营业日已删除");
|
||
editorVisible.value = false;
|
||
await loadCalendar(calendarMonth.year, calendarMonth.month);
|
||
} catch {
|
||
Message.error("营业日删除失败");
|
||
} finally {
|
||
isDeleting.value = false;
|
||
}
|
||
}
|
||
|
||
// ---------- 日期 ----------
|
||
|
||
function formatDateValue(value?: number): string {
|
||
if (!value) {
|
||
return "";
|
||
}
|
||
|
||
const text = String(value);
|
||
return `${text.slice(0, 4)}-${text.slice(4, 6)}-${text.slice(6, 8)}`;
|
||
}
|
||
|
||
function parseDateValue(text: string): number {
|
||
return Number(text.replaceAll("-", ""));
|
||
}
|
||
|
||
function toDateValue(date: Date): number {
|
||
const year = date.getFullYear();
|
||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||
const day = String(date.getDate()).padStart(2, "0");
|
||
return Number(`${year}${month}${day}`);
|
||
}
|
||
|
||
onMounted(async () => {
|
||
try {
|
||
const [currentStore] = await Promise.all([
|
||
StoreAPI.detail(storeId.value),
|
||
loadCalendar(calendarMonth.year, calendarMonth.month)
|
||
]);
|
||
store.value = currentStore;
|
||
} catch {
|
||
Message.error("营业日数据加载失败");
|
||
}
|
||
});
|
||
</script>
|
||
|
||
<style scoped lang="less">
|
||
.store-business-day-calendar {
|
||
gap: .75rem;
|
||
display: grid;
|
||
padding: calc(var(--app-nav-height) + 1rem) 0 6rem;
|
||
|
||
.summary {
|
||
gap: .75rem;
|
||
margin: 0 1rem;
|
||
display: flex;
|
||
padding: .75rem;
|
||
border-radius: .75rem;
|
||
flex-direction: column;
|
||
|
||
.header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
}
|
||
|
||
.address {
|
||
font-size: .875rem;
|
||
}
|
||
}
|
||
|
||
.legend {
|
||
gap: .375rem;
|
||
display: flex;
|
||
flex: none;
|
||
}
|
||
|
||
.calendar-card {
|
||
|
||
:deep(.t-calendar-header) {
|
||
padding-top: 8px;
|
||
}
|
||
|
||
:deep(.t-calendar) {
|
||
z-index: 1;
|
||
overflow: hidden;
|
||
border-radius: .75rem;
|
||
|
||
&__title {
|
||
display: none;
|
||
}
|
||
|
||
&__months {
|
||
height: unset;
|
||
}
|
||
|
||
&__dates-item {
|
||
height: 40px;
|
||
align-items: unset;
|
||
|
||
&.business-day-open {
|
||
--td-calendar-item-suffix-color: var(--td-brand-color);
|
||
}
|
||
|
||
&.business-day-closed .t-calendar__dates-item-suffix {
|
||
--td-calendar-item-suffix-color: var(--td-error-color);
|
||
}
|
||
}
|
||
}
|
||
|
||
:deep(.business-day-open),
|
||
:deep(.business-day-closed) {
|
||
font-weight: 700;
|
||
}
|
||
|
||
:deep(.business-day-closed .t-calendar__dates-item-suffix) {
|
||
}
|
||
}
|
||
|
||
.loading,
|
||
.empty {
|
||
color: #52607a;
|
||
padding: .75rem 1rem;
|
||
font-size: .8125rem;
|
||
text-align: center;
|
||
}
|
||
}
|
||
|
||
.editor {
|
||
gap: .75rem;
|
||
display: flex;
|
||
padding: 1rem;
|
||
flex-direction: column;
|
||
|
||
.header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
}
|
||
|
||
.reason.is-hidden {
|
||
visibility: hidden;
|
||
}
|
||
|
||
.hint {
|
||
text-indent: 2em;
|
||
}
|
||
|
||
.actions {
|
||
gap: .75rem;
|
||
display: flex;
|
||
|
||
.action {
|
||
flex: 1;
|
||
}
|
||
}
|
||
}
|
||
|
||
:global(html.theme-dark) {
|
||
.store-business-day-calendar {
|
||
.summary,
|
||
.calendar-card,
|
||
.editor {
|
||
background: #1e293b;
|
||
}
|
||
|
||
.summary span,
|
||
.loading,
|
||
.empty,
|
||
.editor .hint {
|
||
color: #94a3b8;
|
||
}
|
||
|
||
.summary strong,
|
||
.editor h3 {
|
||
color: #e2e8f0;
|
||
}
|
||
}
|
||
}
|
||
</style>
|