feat: 完成营业时间模块拆分并补充页面注释规范

This commit is contained in:
2026-02-16 09:59:44 +08:00
parent 4be997df63
commit 14857549ba
31 changed files with 3726 additions and 1 deletions

View File

@@ -5,6 +5,7 @@ dev-dist
node_modules
.nvmrc
coverage
backup
CODEOWNERS
.nitro
.output

View File

@@ -2,3 +2,4 @@ dist
public
__tests__
coverage
backup

View File

@@ -15,6 +15,7 @@
5. **推送优先级**:提交后推送时,优先使用 SSH 远程(`git@github.com:...`),避免 HTTPS TLS 问题。
6. **推送默认**:提交后自动执行 `git push`,无需再次提醒或确认。
7. **不确定配置**:拿不准(如接口字段、鉴权流程)直接问用户。
8. **需求开发前置步骤(强制)**:每次开始“新增/修改需求”前,必须先从 `https://api-tenant-dev.laosankeji.com/swagger/v1/swagger.json` 拉取最新 Swagger定位本次需求涉及的接口契约路径、入参、出参、字段再进行代码变更。
## 1. 技术栈详细版本
@@ -56,6 +57,9 @@
- **常量/枚举**`PascalCase``UPPER_SNAKE_CASE`
- **路径别名****严禁**使用 `../../` 穿越多层。必须使用 `#/*``@vben/*``@vben-core/*` 等别名。
- **逻辑注释 (强制)**代码逻辑块必须空行分隔并加序号注释1. 验证... 2. 请求...)。
- **文件头注释 (强制)**:所有新增 `*.ts`/`*.vue`/`*.less`/`*.scss` 文件,必须包含“文件职责”注释,说明该文件负责的业务边界。
- **关键函数注释 (强制)**:对外导出的函数、含分支校验/提交流程的核心函数,必须添加简短中文注释,至少说明输入意图与副作用(如会触发请求/刷新/消息提示)。
- **重构注释保留 (强制)**:拆分文件时,必须把原有关键流程注释同步迁移,禁止出现“重构后逻辑完整但无注释”的情况。
- **组件通信**:优先 `props/emit`,跨层用 `mitt` 或 store**慎用** `provide/inject`
## 4. 接口与 HTTP 规范 (含 .NET 兼容)
@@ -77,6 +81,19 @@
- **Vue 3.5 新特性**:使用 Props 解构 (`const { count = 0 } = defineProps<{...}>`)。
- **表单交互**:使用 Element Plus 表单校验;避免直接操作 DOM。
- **Loading**:所有修改类操作必须绑定 `loading` 状态。
- **页面拆分(强制)**:当 `views/**/index.vue` 逻辑复杂或总行数超过 400 行时,必须拆分为:
- `composables/useXxxPage.ts`(业务状态、校验、提交流程)
- `components/*.vue`(抽屉、弹窗、复杂表单等子视图)
- `styles/index.less|scss`(页面样式)
- **Composables 二级拆分(强制)**:当 `composables/useXxxPage.ts` 超过 300 行或同时承担 4 类以上职责(常量/转换/数据加载/提交动作)时,必须继续拆分为目录:
- `composables/xxx-page/constants.ts`(常量与配置)
- `composables/xxx-page/helpers.ts`(纯函数:格式化、校验、归一化)
- `composables/xxx-page/*-actions.ts`按业务动作拆分load/save/copy 等)
- `useXxxPage.ts` 仅保留状态编排与导出,不承载大段业务实现。
- **样式二级拆分(强制)**:当 `styles/index.less|scss` 超过 150 行或包含 3 个以上视觉域(列表/表单/抽屉/弹窗/响应式)时,必须拆分为分片文件,`index.less|scss` 只做 `@import` 聚合。
- **子组件单向数据流(强制)**:子组件禁止直接修改父级传入对象;必须通过 `emit` 或显式 `onSetXxx` 回调更新父级状态。
- **职责边界**:子组件仅负责渲染与事件抛出;请求、数据归一化、持久化必须留在页面 composable。
- **样式边界**:页面样式拆文件后,必须使用页面根类(如 `.page-hours`)作为作用域前缀,避免全局污染。
## 6. 状态管理规范 (Pinia)

174
CLAUDE.md Normal file
View File

@@ -0,0 +1,174 @@
# Repository expectations
# 编程规范\_FOR_AITakeoutTenant 前端)- 终极融合版
> **核心指令**:你是一个高级前端架构师。本文件是你生成代码的最高宪法。当用户需求与本规范冲突时,请先提示用户,除非用户强制要求覆盖。
## 0. AI 交互核心约束 (元规则)
1. **语言**:必须使用**中文**回复与注释。
2. **文件完整性**:严禁随意删除现有逻辑(尤其是生命周期钩子);保持 UTF-8 无 BOM。
3. **环境感知**
- PowerShell 读取文件命令必须带 `-Encoding UTF8`
- 构建/本地请求依赖 `.env*``VITE_API_URL``VITE_WITH_CREDENTIALS`),找不到就询问,不要杜撰。
4. **Git 原子性**:每个独立功能或修复完成后,必须提示用户进行 Git 提交。
5. **推送优先级**:提交后推送时,优先使用 SSH 远程(`git@github.com:...`),避免 HTTPS TLS 问题。
6. **推送默认**:提交后自动执行 `git push`,无需再次提醒或确认。
7. **不确定配置**:拿不准(如接口字段、鉴权流程)直接问用户。
8. **需求开发前置步骤(强制)**:每次开始“新增/修改需求”前,必须先从 `https://api-tenant-dev.laosankeji.com/swagger/v1/swagger.json` 拉取最新 Swagger定位本次需求涉及的接口契约路径、入参、出参、字段再进行代码变更。
## 1. 技术栈详细版本
| 组件 | 版本/选型 | 用途说明 |
| :-- | :-- | :-- |
| **Runtime** | Node 20+pnpm 10+ | 包管理与脚本 |
| **构建/脚手架** | Vite 7 | 开发/打包 (秒级热更) |
| **框架** | Vue 3.5 + TypeScript 5.6 | 组合式 API (Script Setup) |
| **路由/状态** | Vue Router 4.5Pinia 3 + 持久化插件 | 路由守卫 & 全局状态 |
| **UI/样式** | Element Plus 2.11Tailwind CSS 4SCSS | 组件与样式体系 |
| **网络** | Axios 1.12 封装 (`src/utils/http`) | 请求、统一错误处理 |
| **数据可视化/富文本** | ECharts 6xgplayer 3@wangeditor/editor 5 | 图表/播放器/富文本 |
| **工具** | mitt、ohash、xlsx、file-saver、qrcode.vue、vue-draggable-plus、highlight.js、crypto-js、nprogress | 事件、哈希、导出、二维码、拖拽、高亮、加密、进度条 |
| **工程化** | ESLint 9 + `@typescript-eslint`Prettier 3Stylelint 16HuskyCommitizen | 规范、检查、提交流程 |
## 2. 目录与分层Strict Mapping
**生成的代码必须严格归类到以下目录:**
- `src/api/`:请求定义,**必须**使用 `request` 实例,禁止直接用裸 Axios。
- `src/components/`**全局通用** UI 组件 (`PascalCase.vue`),禁止包含特定业务耦合。
- `src/config/`:全局配置、常量(如主题、布局)。
- `src/directives/`:自定义指令,按功能拆文件。
- `src/enums/`:业务枚举常量(如 `OrderStatus.ts`)。
- `src/hooks/`:可复用的组合式函数,命名 `useXxx.ts`
- `src/locales/`:多语言资源,新增文案必须**同步补齐**各语言。
- `src/mock/`:本地 mock 数据/接口。
- `src/plugins/`Vue 插件注册。
- `src/router/`:路由表与守卫,**鉴权逻辑放守卫中**,页面勿重复判断。
- `src/store/`Pinia store模块化放 `modules/`
- `src/types/`:公共类型定义(如 `types/common/response.ts`)。
- `src/utils/`工具库HTTP 封装、`StorageKeyManager`、加密等)。
- `src/views/`:页面级组件,按 `views/业务模块/页面.vue` 组织。
## 3. 命名与代码风格
- **文件命名**:组件 `PascalCase.vue`Hooks `useCamelCase.ts`;工具 `camelCase.ts`;样式 `kebab-case.scss`
- **变量/函数**`camelCase`;布尔变量强制加 `is/has/should` 前缀。
- **常量/枚举**`PascalCase``UPPER_SNAKE_CASE`
- **路径别名****严禁**使用 `../../` 穿越多层。必须使用 `#/*``@vben/*``@vben-core/*` 等别名。
- **逻辑注释 (强制)**代码逻辑块必须空行分隔并加序号注释1. 验证... 2. 请求...)。
- **文件头注释 (强制)**:所有新增 `*.ts`/`*.vue`/`*.less`/`*.scss` 文件,必须包含“文件职责”注释,说明该文件负责的业务边界。
- **关键函数注释 (强制)**:对外导出的函数、含分支校验/提交流程的核心函数,必须添加简短中文注释,至少说明输入意图与副作用(如会触发请求/刷新/消息提示)。
- **重构注释保留 (强制)**:拆分文件时,必须把原有关键流程注释同步迁移,禁止出现“重构后逻辑完整但无注释”的情况。
- **组件通信**:优先 `props/emit`,跨层用 `mitt` 或 store**慎用** `provide/inject`
## 4. 接口与 HTTP 规范 (含 .NET 兼容)
1. **封装入口**:必须使用 `src/utils/http``api` 对象,禁止裸 `axios`
2. **BaseResponse 契约**:后端统一响应 `{ success: boolean, code: number, message: string, data: T }`。错误展示优先用 `message`
3. **Snowflake ID 精度处理 (关键)**
- 后端 `.NET``long` 类型传到前端会有精度丢失。
- **接收时**:确保后端 DTO 已序列化为 String。
- **发送时**:前端保持 String 传输,由后端反序列化。
4. **401 处理**:拦截器已自动登出。不要在业务内重复处理 401 跳转。
5. **参数处理**POST/PUT 若传 `params` 封装层会自动转为 `data`
6. **跨域/凭证**:严格跟随 `.env``VITE_WITH_CREDENTIALS` 配置。
## 5. 组件/页面开发规范
- **组织**:页面放 `views/`,复用组件抽到 `components/`
- **脚本语法**:全线使用 `<script setup lang="ts">`,禁止 Options API。
- **Vue 3.5 新特性**:使用 Props 解构 (`const { count = 0 } = defineProps<{...}>`)。
- **表单交互**:使用 Element Plus 表单校验;避免直接操作 DOM。
- **Loading**:所有修改类操作必须绑定 `loading` 状态。
- **页面拆分(强制)**:当 `views/**/index.vue` 逻辑复杂或总行数超过 400 行时,必须拆分为:
- `composables/useXxxPage.ts`(业务状态、校验、提交流程)
- `components/*.vue`(抽屉、弹窗、复杂表单等子视图)
- `styles/index.less|scss`(页面样式)
- **Composables 二级拆分(强制)**:当 `composables/useXxxPage.ts` 超过 300 行或同时承担 4 类以上职责(常量/转换/数据加载/提交动作)时,必须继续拆分为目录:
- `composables/xxx-page/constants.ts`(常量与配置)
- `composables/xxx-page/helpers.ts`(纯函数:格式化、校验、归一化)
- `composables/xxx-page/*-actions.ts`按业务动作拆分load/save/copy 等)
- `useXxxPage.ts` 仅保留状态编排与导出,不承载大段业务实现。
- **样式二级拆分(强制)**:当 `styles/index.less|scss` 超过 150 行或包含 3 个以上视觉域(列表/表单/抽屉/弹窗/响应式)时,必须拆分为分片文件,`index.less|scss` 只做 `@import` 聚合。
- **子组件单向数据流(强制)**:子组件禁止直接修改父级传入对象;必须通过 `emit` 或显式 `onSetXxx` 回调更新父级状态。
- **职责边界**:子组件仅负责渲染与事件抛出;请求、数据归一化、持久化必须留在页面 composable。
- **样式边界**:页面样式拆文件后,必须使用页面根类(如 `.page-hours`)作为作用域前缀,避免全局污染。
## 6. 状态管理规范 (Pinia)
- **定义**Store 命名 `useXxxStore`,文件 `modules/xxx.ts`,使用 **Setup Store** 写法。
- **持久化 (核心)**
- 默认通过 `pinia-plugin-persistedstate`
- **强制**Storage Key **必须**由 `src/utils/StorageKeyManager` 生成,**严禁**硬编码字符串 Key。
- **职责**Store 保持纯逻辑UI 只负责触发。
## 7. 路由与导航守卫
- **中心化鉴权**动态路由加载、Token 校验、登出逻辑均在守卫中处理。
- **回退机制**401 登出后,守卫应记录 `redirect` 参数,登录后自动跳回。
- **禁止硬跳**:禁止 `window.location`,统一用 router 实例。
## 8. 类型与可维护性
- **TypeScript 强制****严禁 Any**。接口/类型定义放 `types/` 或同级 `types.ts`
- **类型收窄**:必要时使用 `unknown` + 类型断言/守卫。
- **API 类型**:请求必须泛型声明返回类型 `api.get<UserDto>(...)`
- **导入**:优先使用 `import type`
## 9. 国际化与文案
- **全量覆盖**:所有可见文本走 `$t('key')`
- **同步维护**:新增 Key 时,必须同时更新 `zh-CN``en` (或其他语言) 文件。
- **动态优先**:确认错误/成功提示复用后端返回 `message` 优先。
## 10. 样式与设计约束 (Tailwind First)
- **优先级**
1. **Tailwind CSS 4** (原子类) —— **首选**
2. Element Plus 变量 (`var(--el-color-primary)`)。
3. Scoped SCSS (仅用于复杂定制)。
- **主题配置**:禁止硬编码 HEX 色值,必须引用 `config` 或 CSS 变量。
- **布局安全**:禁止内联 `style` 操作布局。
## 11. 工程化与脚本
- **常用脚本**`pnpm dev:ele``pnpm build:ele``pnpm lint``pnpm commit`
- **Lint 要求**:每次修改代码后必须执行 `pnpm lint`,确保无错误和警告。
- **提交规范**Husky/Lefthook + lint-staged 已启用。
- **Commitizen**:提交信息必须遵循 Conventional Commits格式为 `<type>: <中文说明>`(类型英文,说明中文)。
## 12. 性能与可用性
- **虚拟滚动**:长列表(>100条使用 `vue-draggable-plus` 或 Virtual Table。
- **按需加载**:路由组件使用 `() => import(...)`ECharts 按需引入。
- **资源优化**:导出/加密等计算密集型任务,若卡顿则考虑 Web Worker。
- **反馈**:长链路操作补充进度条 (`nprogress`) 或 Loading。
## 13. 安全与合规
- **零信任**:前端**严禁**硬编码 Token、密钥、内网地址。
- **XSS 防御**:使用 `v-html` 必须经过 `DOMPurify` 清洗。
- **文件处理**:下载/导出使用 `file-saver`,避免在主线程进行大数据解析。
## 14. 绝对禁止事项 (AI 自检清单)
**生成代码前,请自查是否违反以下红线:**
1. [ ] **裸连 API**:是否直接使用 `axios` 或绕过 `utils/http`
2. [ ] **雪花算法灾难**:是否直接把后端的 `long` ID 当数字处理(导致精度丢失)?
3. [ ] **持久化隐患**:是否手写了 Storage Key 而非调用 `StorageKeyManager`
4. [ ] **401 冗余**:是否在组件里重复处理了登出跳转?
5. [ ] **文案写死**:是否在 Template 中直接写了中文?
6. [ ] **路径地狱**:是否使用了 `../../` 而非别名导入?
7. [ ] **类型偷懒**API 返回值是否标记为 `any`
8. [ ] **逻辑混乱**:是否在 `views` 下堆砌了本该在 `components` 的通用组件?
9. [ ] **配置硬编码**:是否忽略了 `.env` 中的 `VITE_API_URL`
---
# Working agreements
- 严格遵循上述 14 条规范与目录职责。
- 保持代码可读、可测、可维护。
- 你的目标是协助我构建一个企业级、健壮的 `TakeoutTenant` 租户管理前端系统。

View File

@@ -0,0 +1,127 @@
import { requestClient } from '#/api/request';
// ========== 枚举 ==========
/** 时段类型 */
export enum SlotType {
/** 营业 */
Business = 1,
/** 配送 */
Delivery = 2,
/** 自提 */
Pickup = 3,
}
/** 特殊日期类型 */
export enum HolidayType {
/** 休息 */
Closed = 1,
/** 特殊营业 */
Special = 2,
}
// ========== DTO ==========
/** 时段 */
export interface TimeSlotDto {
id: string;
/** 时段类型 */
type: SlotType;
/** 开始时间 HH:mm */
startTime: string;
/** 结束时间 HH:mm */
endTime: string;
/** 容量上限(配送类型) */
capacity?: number;
/** 备注 */
remark?: string;
}
/** 每日营业时间 */
export interface DayHoursDto {
/** 星期几 0=周一 6=周日 */
dayOfWeek: number;
/** 是否营业 */
isOpen: boolean;
/** 时段列表 */
slots: TimeSlotDto[];
}
/** 特殊日期 */
export interface HolidayDto {
id: string;
/** 开始日期 */
startDate: string;
/** 结束日期(单日时与 startDate 相同) */
endDate: string;
/** 类型 */
type: HolidayType;
/** 营业开始时间(特殊营业) */
startTime?: string;
/** 营业结束时间(特殊营业) */
endTime?: string;
/** 原因 */
reason: string;
/** 备注 */
remark?: string;
}
/** 门店营业时间聚合 */
export interface StoreHoursDto {
storeId: string;
weeklyHours: DayHoursDto[];
holidays: HolidayDto[];
}
/** 保存每周时段参数 */
export interface SaveWeeklyHoursParams {
storeId: string;
weeklyHours: DayHoursDto[];
}
/** 保存特殊日期参数 */
export interface SaveHolidayParams {
storeId: string;
holiday: Omit<HolidayDto, 'id'> & { id?: string };
}
/** 复制营业时间参数 */
export interface CopyStoreHoursParams {
/** 源门店ID */
sourceStoreId: string;
/** 目标门店ID列表 */
targetStoreIds: string[];
/** 是否包含每周营业时间 */
includeWeeklyHours?: boolean;
/** 是否包含特殊日期 */
includeHolidays?: boolean;
}
// ========== API ==========
/** 获取门店营业时间 */
export async function getStoreHoursApi(storeId: string) {
return requestClient.get<StoreHoursDto>('/store/hours', {
params: { storeId },
});
}
/** 保存每周营业时间 */
export async function saveWeeklyHoursApi(data: SaveWeeklyHoursParams) {
return requestClient.post('/store/hours/weekly', data);
}
/** 保存特殊日期 */
export async function saveHolidayApi(data: SaveHolidayParams) {
return requestClient.post('/store/hours/holiday', data);
}
/** 删除特殊日期 */
export async function deleteHolidayApi(id: string) {
return requestClient.post('/store/hours/holiday/delete', { id });
}
/** 复制营业时间到其他门店 */
export async function copyStoreHoursApi(data: CopyStoreHoursParams) {
return requestClient.post('/store/hours/copy', data);
}

View File

@@ -1,4 +1,5 @@
// Mock 数据入口,仅在开发环境下使用
import './store';
import './store-hours';
console.warn('[Mock] Mock 数据已启用');

View File

@@ -0,0 +1,417 @@
import Mock from 'mockjs';
const Random = Mock.Random;
/** mockjs 请求回调参数 */
interface MockRequestOptions {
url: string;
type: string;
body: null | string;
}
interface TimeSlotMock {
id: string;
type: number;
startTime: string;
endTime: string;
capacity?: number;
remark?: string;
}
interface DayHoursMock {
dayOfWeek: number;
isOpen: boolean;
slots: TimeSlotMock[];
}
interface HolidayMock {
id: string;
startDate: string;
endDate: string;
type: number;
startTime?: string;
endTime?: string;
reason: string;
remark?: string;
}
interface StoreHoursState {
holidays: HolidayMock[];
weeklyHours: DayHoursMock[];
}
function parseUrlParams(url: string) {
const parsed = new URL(url, 'http://localhost');
const params: Record<string, string> = {};
parsed.searchParams.forEach((value, key) => {
params[key] = value;
});
return params;
}
function parseBody(options: MockRequestOptions) {
if (!options.body) return {};
try {
return JSON.parse(options.body);
} catch (error) {
console.error('[mock-store-hours] parseBody error:', error);
return {};
}
}
function normalizeDate(date?: string) {
if (!date) return '';
return String(date).slice(0, 10);
}
function normalizeTime(time?: string) {
if (!time) return '';
const matched = /(\d{2}:\d{2})/.exec(time);
return matched?.[1] ?? '';
}
function sortSlots(slots: TimeSlotMock[]) {
return [...slots].toSorted((a, b) => {
const startA = a.startTime;
const startB = b.startTime;
if (startA !== startB) return startA.localeCompare(startB);
return a.type - b.type;
});
}
function sortHolidays(holidays: HolidayMock[]) {
return [...holidays].toSorted((a, b) => {
const dateCompare = a.startDate.localeCompare(b.startDate);
if (dateCompare !== 0) return dateCompare;
return a.id.localeCompare(b.id);
});
}
function cloneWeeklyHours(weeklyHours: DayHoursMock[]) {
return weeklyHours.map((day) => ({
...day,
slots: day.slots.map((slot) => ({ ...slot })),
}));
}
function cloneHolidays(holidays: HolidayMock[]) {
return holidays.map((holiday) => ({ ...holiday }));
}
function createDefaultWeeklyHours(): DayHoursMock[] {
const weekdays = [
{
dayOfWeek: 0,
bizEnd: '22:00',
delEnd: '21:30',
delCap: 50,
pickEnd: '21:00',
},
{
dayOfWeek: 1,
bizEnd: '22:00',
delEnd: '21:30',
delCap: 50,
pickEnd: '21:00',
},
{
dayOfWeek: 2,
bizEnd: '22:00',
delEnd: '21:30',
delCap: 50,
pickEnd: '21:00',
},
{
dayOfWeek: 3,
bizEnd: '22:00',
delEnd: '21:30',
delCap: 50,
pickEnd: '21:00',
},
{
dayOfWeek: 4,
bizEnd: '23:00',
delEnd: '22:30',
delCap: 80,
pickEnd: '22:00',
},
{
dayOfWeek: 5,
bizEnd: '23:00',
delEnd: '22:30',
delCap: 80,
pickEnd: '22:00',
},
];
const result = weekdays.map((day) => ({
dayOfWeek: day.dayOfWeek,
isOpen: true,
slots: [
{ id: Random.guid(), type: 1, startTime: '09:00', endTime: day.bizEnd },
{
id: Random.guid(),
type: 2,
startTime: '10:00',
endTime: day.delEnd,
capacity: day.delCap,
},
{ id: Random.guid(), type: 3, startTime: '09:00', endTime: day.pickEnd },
],
}));
result.push({
dayOfWeek: 6,
isOpen: true,
slots: [
{ id: Random.guid(), type: 1, startTime: '10:00', endTime: '22:00' },
{
id: Random.guid(),
type: 2,
startTime: '10:30',
endTime: '21:30',
capacity: 60,
},
],
});
return result;
}
function createDefaultHolidays(): HolidayMock[] {
return [
{
id: Random.guid(),
startDate: '2026-02-17',
endDate: '2026-02-19',
type: 1,
reason: '春节假期',
},
{
id: Random.guid(),
startDate: '2026-04-05',
endDate: '2026-04-05',
type: 1,
reason: '清明节',
},
{
id: Random.guid(),
startDate: '2026-02-14',
endDate: '2026-02-14',
type: 2,
startTime: '09:00',
endTime: '23:30',
reason: '情人节延长营业',
},
{
id: Random.guid(),
startDate: '2026-05-01',
endDate: '2026-05-01',
type: 2,
startTime: '10:00',
endTime: '20:00',
reason: '劳动节缩短营业',
},
];
}
function normalizeWeeklyHoursInput(list: any): DayHoursMock[] {
const dayMap = new Map<number, DayHoursMock>();
if (Array.isArray(list)) {
for (const item of list) {
const dayOfWeek = Number(item?.dayOfWeek);
if (!Number.isInteger(dayOfWeek) || dayOfWeek < 0 || dayOfWeek > 6)
continue;
const slots: TimeSlotMock[] = Array.isArray(item?.slots)
? item.slots.map((slot: any) => ({
id: String(slot?.id || Random.guid()),
type: Number(slot?.type) || 1,
startTime: normalizeTime(slot?.startTime) || '09:00',
endTime: normalizeTime(slot?.endTime) || '22:00',
capacity:
Number(slot?.type) === 2 && slot?.capacity !== undefined
? Number(slot.capacity)
: undefined,
remark: slot?.remark || undefined,
}))
: [];
dayMap.set(dayOfWeek, {
dayOfWeek,
isOpen: Boolean(item?.isOpen),
slots: sortSlots(slots),
});
}
}
return Array.from({ length: 7 }).map((_, dayOfWeek) => {
return (
dayMap.get(dayOfWeek) ?? {
dayOfWeek,
isOpen: false,
slots: [],
}
);
});
}
function normalizeHolidayInput(holiday: any): HolidayMock {
const type = Number(holiday?.type) === 2 ? 2 : 1;
return {
id: String(holiday?.id || Random.guid()),
startDate:
normalizeDate(holiday?.startDate) || normalizeDate(holiday?.date),
endDate:
normalizeDate(holiday?.endDate) ||
normalizeDate(holiday?.startDate) ||
normalizeDate(holiday?.date),
type,
startTime:
type === 2 ? normalizeTime(holiday?.startTime) || undefined : undefined,
endTime:
type === 2 ? normalizeTime(holiday?.endTime) || undefined : undefined,
reason: holiday?.reason || '',
remark: holiday?.remark || undefined,
};
}
const storeHoursMap = new Map<string, StoreHoursState>();
function ensureStoreState(storeId = '') {
const key = storeId || 'default';
let state = storeHoursMap.get(key);
if (!state) {
state = {
weeklyHours: createDefaultWeeklyHours(),
holidays: createDefaultHolidays(),
};
storeHoursMap.set(key, state);
}
return state;
}
// 获取门店营业时间
Mock.mock(/\/store\/hours(?:\?|$)/, 'get', (options: MockRequestOptions) => {
const params = parseUrlParams(options.url);
const storeId = params.storeId || '';
const state = ensureStoreState(storeId);
return {
code: 200,
data: {
storeId,
weeklyHours: cloneWeeklyHours(state.weeklyHours),
holidays: cloneHolidays(state.holidays),
},
};
});
// 保存每周营业时间
Mock.mock(/\/store\/hours\/weekly/, 'post', (options: MockRequestOptions) => {
const body = parseBody(options);
const storeId = String(body.storeId || '');
const state = ensureStoreState(storeId);
state.weeklyHours = normalizeWeeklyHoursInput(body.weeklyHours);
return { code: 200, data: null };
});
// 删除特殊日期
Mock.mock(
/\/store\/hours\/holiday\/delete/,
'post',
(options: MockRequestOptions) => {
const body = parseBody(options);
const holidayId = String(body.id || '');
if (!holidayId) return { code: 200, data: null };
for (const [, state] of storeHoursMap) {
const index = state.holidays.findIndex(
(holiday) => holiday.id === holidayId,
);
if (index !== -1) {
state.holidays.splice(index, 1);
break;
}
}
return { code: 200, data: null };
},
);
// 新增 / 编辑特殊日期
Mock.mock(
/\/store\/hours\/holiday(?!\/delete)/,
'post',
(options: MockRequestOptions) => {
const body = parseBody(options);
const storeId = String(body.storeId || '');
const state = ensureStoreState(storeId);
const incomingHoliday = normalizeHolidayInput(body.holiday);
const existingIndex = state.holidays.findIndex(
(item) => item.id === incomingHoliday.id,
);
if (existingIndex === -1) {
state.holidays.push(incomingHoliday);
} else {
state.holidays[existingIndex] = incomingHoliday;
}
state.holidays = sortHolidays(state.holidays);
return {
code: 200,
data: { ...incomingHoliday },
};
},
);
// 复制营业时间
Mock.mock(/\/store\/hours\/copy/, 'post', (options: MockRequestOptions) => {
const body = parseBody(options);
const sourceStoreId = String(body.sourceStoreId || '');
const targetStoreIds: string[] = Array.isArray(body.targetStoreIds)
? body.targetStoreIds.map(String).filter(Boolean)
: [];
if (!sourceStoreId || targetStoreIds.length === 0) {
return {
code: 200,
data: { copiedCount: 0 },
};
}
const includeWeeklyHours = body.includeWeeklyHours !== false;
const includeHolidays = body.includeHolidays !== false;
const sourceState = ensureStoreState(sourceStoreId);
const uniqueTargets = [...new Set<string>(targetStoreIds)].filter(
(id) => id !== sourceStoreId,
);
for (const targetId of uniqueTargets) {
const targetState = ensureStoreState(targetId);
if (includeWeeklyHours) {
targetState.weeklyHours = cloneWeeklyHours(sourceState.weeklyHours);
}
if (includeHolidays) {
targetState.holidays = cloneHolidays(sourceState.holidays).map(
(holiday) => ({
...holiday,
id: Random.guid(),
}),
);
}
}
return {
code: 200,
data: {
copiedCount: uniqueTargets.length,
includeHolidays,
includeWeeklyHours,
},
};
});

View File

@@ -19,6 +19,15 @@ const routes: RouteRecordRaw[] = [
title: '门店列表',
},
},
{
name: 'StoreHours',
path: '/store/hours',
component: () => import('#/views/store/hours/index.vue'),
meta: {
icon: 'lucide:clock',
title: '营业时间',
},
},
],
},
];

View File

@@ -0,0 +1,178 @@
<script setup lang="ts">
import type {
AddDaysMode,
AddSlotFormState,
DayName,
SlotTypeOption,
} from '../types';
import type { SlotType } from '#/api/store-hours';
import { Button, Drawer, Textarea } from 'ant-design-vue';
interface Props {
addSlotForm: AddSlotFormState;
dayNames: DayName[];
getSlotTypePillClass: (type: SlotType) => string;
isAddDaySelected: (dayOfWeek: number) => boolean;
isWeeklySubmitting: boolean;
onSetCapacity: (value: number) => void;
onSetEndTime: (value: string) => void;
onSetRemark: (value: string) => void;
onSetStartTime: (value: string) => void;
onSetType: (type: SlotType) => void;
open: boolean;
slotTypeDelivery: SlotType;
slotTypeOptions: SlotTypeOption[];
}
const props = defineProps<Props>();
const emit = defineEmits<{
(event: 'quickSelect', mode: AddDaysMode): void;
(event: 'submit'): void;
(event: 'toggleDay', dayOfWeek: number): void;
(event: 'update:open', value: boolean): void;
}>();
function getInputValue(event: Event) {
const target = event.target as HTMLInputElement | null;
return target?.value ?? '';
}
</script>
<template>
<Drawer
:open="props.open"
title="添加时段"
:width="500"
:mask-closable="false"
:body-style="{ paddingBottom: '88px' }"
@update:open="(value) => emit('update:open', value)"
>
<div class="form-block">
<label class="form-label required">时段类型</label>
<div class="type-pill-group">
<button
v-for="item in props.slotTypeOptions"
:key="item.value"
type="button"
class="type-pill"
:class="[
props.getSlotTypePillClass(item.value),
{ active: props.addSlotForm.type === item.value },
]"
@click="props.onSetType(item.value)"
>
{{ item.label }}
</button>
</div>
</div>
<div class="form-block">
<label class="form-label required">适用星期</label>
<div class="day-pill-group">
<button
v-for="(day, index) in props.dayNames"
:key="day.label"
type="button"
class="day-pill"
:class="{ selected: props.isAddDaySelected(index) }"
@click="emit('toggleDay', index)"
>
{{ day.label }}
</button>
</div>
<div class="quick-actions">
<button
type="button"
class="quick-btn"
@click="emit('quickSelect', 'all')"
>
全选
</button>
<button
type="button"
class="quick-btn"
@click="emit('quickSelect', 'weekday')"
>
工作日
</button>
<button
type="button"
class="quick-btn"
@click="emit('quickSelect', 'weekend')"
>
周末
</button>
</div>
</div>
<div class="time-grid">
<div class="form-block">
<label class="form-label required">开始时间</label>
<input
:value="props.addSlotForm.startTime"
type="time"
class="native-input"
@input="(event) => props.onSetStartTime(getInputValue(event))"
/>
</div>
<div class="form-block">
<label class="form-label required">结束时间</label>
<input
:value="props.addSlotForm.endTime"
type="time"
class="native-input"
@input="(event) => props.onSetEndTime(getInputValue(event))"
/>
</div>
</div>
<div
v-if="props.addSlotForm.type === props.slotTypeDelivery"
class="form-block"
>
<label class="form-label">容量上限</label>
<div class="capacity-row">
<input
:value="props.addSlotForm.capacity"
type="number"
class="native-input capacity-input"
min="1"
step="1"
@input="
(event) => {
const value = Number(getInputValue(event));
props.onSetCapacity(Number.isFinite(value) ? value : 0);
}
"
/>
<span>/小时</span>
</div>
</div>
<div class="form-block">
<label class="form-label">备注</label>
<Textarea
:value="props.addSlotForm.remark"
:rows="2"
placeholder="可选,如:午市高峰时段"
@update:value="props.onSetRemark"
/>
</div>
<template #footer>
<div class="drawer-footer">
<Button @click="emit('update:open', false)">取消</Button>
<Button
type="primary"
:loading="props.isWeeklySubmitting"
@click="emit('submit')"
>
确认添加
</Button>
</div>
</template>
</Drawer>
</template>

View File

@@ -0,0 +1,93 @@
<script setup lang="ts">
import type { StoreListItemDto } from '#/api/store';
import { Alert, Checkbox, Modal } from 'ant-design-vue';
interface Props {
copyCandidates: StoreListItemDto[];
copyTargetStoreIds: string[];
isCopyAllChecked: boolean;
isCopyIndeterminate: boolean;
isCopySubmitting: boolean;
open: boolean;
selectedStoreName: string;
}
const props = defineProps<Props>();
const emit = defineEmits<{
(event: 'checkAll', checked: boolean): void;
(event: 'storeChange', payload: { checked: boolean; storeId: string }): void;
(event: 'submit'): void;
(event: 'toggleStore', payload: { checked: boolean; storeId: string }): void;
(event: 'update:open', value: boolean): void;
}>();
function readChecked(event: { target?: { checked?: boolean } }) {
return Boolean(event.target?.checked);
}
</script>
<template>
<Modal
:open="props.open"
title="复制到其他门店"
:confirm-loading="props.isCopySubmitting"
ok-text="确认复制"
cancel-text="取消"
@ok="emit('submit')"
@update:open="(value) => emit('update:open', value)"
>
<div class="copy-modal-content">
<Alert
message="将覆盖目标门店的现有设置,请谨慎操作"
type="warning"
show-icon
/>
<div class="copy-all-row">
<Checkbox
:checked="props.isCopyAllChecked"
:indeterminate="props.isCopyIndeterminate"
@change="(event) => emit('checkAll', readChecked(event))"
>
全选
</Checkbox>
</div>
<div class="copy-store-list">
<div
v-for="store in props.copyCandidates"
:key="store.id"
class="copy-store-item"
@click="
emit('toggleStore', {
storeId: store.id,
checked: !props.copyTargetStoreIds.includes(store.id),
})
"
>
<Checkbox
:checked="props.copyTargetStoreIds.includes(store.id)"
@click.stop
@change="
(event) =>
emit('storeChange', {
storeId: store.id,
checked: readChecked(event),
})
"
/>
<div class="copy-store-info">
<div class="copy-store-name">{{ store.name }}</div>
<div class="copy-store-address">{{ store.address || '--' }}</div>
</div>
</div>
</div>
<div class="copy-source-tip">
来源门店{{ props.selectedStoreName || '--' }}
</div>
</div>
</Modal>
</template>

View File

@@ -0,0 +1,153 @@
<script setup lang="ts">
import type { DayEditFormState, SlotTypeOption } from '../types';
import type { SlotType } from '#/api/store-hours';
import { Button, Drawer, Select, Switch } from 'ant-design-vue';
interface Props {
dayEditForm: DayEditFormState;
isWeeklySubmitting: boolean;
onSetDayOpen: (value: boolean) => void;
onSetSlotCapacity: (slotId: string, value: number) => void;
onSetSlotEndTime: (slotId: string, value: string) => void;
onSetSlotStartTime: (slotId: string, value: string) => void;
onSetSlotType: (slotId: string, value: SlotType) => void;
open: boolean;
slotTypeDelivery: SlotType;
slotTypeOptions: SlotTypeOption[];
title: string;
}
const props = defineProps<Props>();
const emit = defineEmits<{
(event: 'addSlotRow'): void;
(event: 'removeSlot', slotId: string): void;
(event: 'save'): void;
(event: 'update:open', value: boolean): void;
}>();
function getInputValue(event: Event) {
const target = event.target as HTMLInputElement | null;
return target?.value ?? '';
}
</script>
<template>
<Drawer
:open="props.open"
:title="props.title"
:width="560"
:mask-closable="false"
:body-style="{ paddingBottom: '88px' }"
@update:open="(value) => emit('update:open', value)"
>
<div class="day-open-row">
<Switch
:checked="props.dayEditForm.isOpen"
@update:checked="(checked) => props.onSetDayOpen(Boolean(checked))"
/>
<span>今日营业</span>
<span v-if="!props.dayEditForm.isOpen" class="day-close-hint">
该日休息不接单
</span>
</div>
<div
class="slot-edit-list"
:class="{ disabled: !props.dayEditForm.isOpen }"
>
<div
v-for="slot in props.dayEditForm.slots"
:key="slot.id"
class="slot-edit-card"
>
<div class="slot-edit-head">
<Select
:value="slot.type"
class="slot-type-select"
:options="props.slotTypeOptions"
@update:value="
(value) => props.onSetSlotType(slot.id, Number(value) as SlotType)
"
/>
<Button
type="text"
danger
size="small"
@click="emit('removeSlot', slot.id)"
>
删除
</Button>
</div>
<div class="time-grid">
<div class="form-block">
<label class="form-label required">开始</label>
<input
:value="slot.startTime"
type="time"
class="native-input"
@input="
(event) =>
props.onSetSlotStartTime(slot.id, getInputValue(event))
"
/>
</div>
<div class="form-block">
<label class="form-label required">结束</label>
<input
:value="slot.endTime"
type="time"
class="native-input"
@input="
(event) => props.onSetSlotEndTime(slot.id, getInputValue(event))
"
/>
</div>
</div>
<div v-if="slot.type === props.slotTypeDelivery" class="form-block">
<label class="form-label">容量</label>
<div class="capacity-row">
<input
:value="slot.capacity"
type="number"
class="native-input capacity-input"
min="1"
step="1"
@input="
(event) => {
const value = Number(getInputValue(event));
props.onSetSlotCapacity(
slot.id,
Number.isFinite(value) ? value : 0,
);
}
"
/>
<span>/小时</span>
</div>
</div>
</div>
<button type="button" class="add-dashed-btn" @click="emit('addSlotRow')">
添加时段
</button>
</div>
<template #footer>
<div class="drawer-footer">
<Button @click="emit('update:open', false)">取消</Button>
<Button
type="primary"
:loading="props.isWeeklySubmitting"
@click="emit('save')"
>
保存修改
</Button>
</div>
</template>
</Drawer>
</template>

View File

@@ -0,0 +1,182 @@
<script setup lang="ts">
import type { HolidayFormState } from '../types';
import type { HolidayType } from '#/api/store-hours';
import { Button, Drawer, Input, Textarea } from 'ant-design-vue';
interface Props {
holidayForm: HolidayFormState;
holidayTypeClosed: HolidayType;
holidayTypeSpecial: HolidayType;
isHolidaySubmitting: boolean;
onSetDateMode: (mode: 'range' | 'single') => void;
onSetEndTime: (value: string) => void;
onSetRangeEnd: (value: string) => void;
onSetRangeStart: (value: string) => void;
onSetReason: (value: string) => void;
onSetRemark: (value: string) => void;
onSetSingleDate: (value: string) => void;
onSetStartTime: (value: string) => void;
onSetType: (type: HolidayType) => void;
open: boolean;
submitText: string;
title: string;
}
const props = defineProps<Props>();
const emit = defineEmits<{
(event: 'submit'): void;
(event: 'update:open', value: boolean): void;
}>();
function getInputValue(event: Event) {
const target = event.target as HTMLInputElement | null;
return target?.value ?? '';
}
</script>
<template>
<Drawer
:open="props.open"
:title="props.title"
:width="500"
:mask-closable="false"
:body-style="{ paddingBottom: '88px' }"
@update:open="(value) => emit('update:open', value)"
>
<div class="form-block">
<label class="form-label required">类型</label>
<div class="type-pill-group">
<button
type="button"
class="type-pill ht-closed"
:class="{
active: props.holidayForm.type === props.holidayTypeClosed,
}"
@click="props.onSetType(props.holidayTypeClosed)"
>
休息
</button>
<button
type="button"
class="type-pill ht-special"
:class="{
active: props.holidayForm.type === props.holidayTypeSpecial,
}"
@click="props.onSetType(props.holidayTypeSpecial)"
>
特殊营业
</button>
</div>
</div>
<div class="form-block">
<label class="form-label required">日期</label>
<div class="date-mode-row">
<button
type="button"
class="date-mode-pill"
:class="{ active: props.holidayForm.dateMode === 'single' }"
@click="props.onSetDateMode('single')"
>
单日
</button>
<button
type="button"
class="date-mode-pill"
:class="{ active: props.holidayForm.dateMode === 'range' }"
@click="props.onSetDateMode('range')"
>
日期范围
</button>
</div>
<template v-if="props.holidayForm.dateMode === 'single'">
<input
:value="props.holidayForm.singleDate"
type="date"
class="native-input"
@input="(event) => props.onSetSingleDate(getInputValue(event))"
/>
</template>
<template v-else>
<div class="date-range-row">
<input
:value="props.holidayForm.rangeStart"
type="date"
class="native-input"
@input="(event) => props.onSetRangeStart(getInputValue(event))"
/>
<span>~</span>
<input
:value="props.holidayForm.rangeEnd"
type="date"
class="native-input"
@input="(event) => props.onSetRangeEnd(getInputValue(event))"
/>
</div>
</template>
</div>
<div
v-if="props.holidayForm.type === props.holidayTypeSpecial"
class="form-block"
>
<label class="form-label">营业时间</label>
<div class="time-grid">
<div class="form-block">
<label class="form-sub-label">开始</label>
<input
:value="props.holidayForm.startTime"
type="time"
class="native-input"
@input="(event) => props.onSetStartTime(getInputValue(event))"
/>
</div>
<div class="form-block">
<label class="form-sub-label">结束</label>
<input
:value="props.holidayForm.endTime"
type="time"
class="native-input"
@input="(event) => props.onSetEndTime(getInputValue(event))"
/>
</div>
</div>
</div>
<div class="form-block">
<label class="form-label required">原因</label>
<Input
:value="props.holidayForm.reason"
placeholder="如:春节假期、情人节延长营业"
@update:value="props.onSetReason"
/>
</div>
<div class="form-block">
<label class="form-label">备注</label>
<Textarea
:value="props.holidayForm.remark"
:rows="2"
placeholder="可选"
@update:value="props.onSetRemark"
/>
</div>
<template #footer>
<div class="drawer-footer">
<Button @click="emit('update:open', false)">取消</Button>
<Button
type="primary"
:loading="props.isHolidaySubmitting"
@click="emit('submit')"
>
{{ props.submitText }}
</Button>
</div>
</template>
</Drawer>
</template>

View File

@@ -0,0 +1,165 @@
/**
* 添加时段抽屉动作:
* - 表单字段更新
* - 快捷选星期
* - 提交时段并做冲突校验
*/
import type { Ref } from 'vue';
import type { AddDaysMode, AddSlotFormState, DayName } from '../../types';
import type { DayHoursDto, TimeSlotDto } from '#/api/store-hours';
import { message } from 'ant-design-vue';
import { SlotType } from '#/api/store-hours';
interface CreateAddSlotActionsOptions {
addSlotForm: AddSlotFormState;
cloneWeeklyHours: (source: DayHoursDto[]) => DayHoursDto[];
createSlotId: () => string;
defaultDayName: DayName;
dayNames: DayName[];
isAddDrawerOpen: Ref<boolean>;
persistWeeklyHours: (
nextWeekly: DayHoursDto[],
successText: string,
) => Promise<boolean>;
selectedStoreId: Ref<string>;
sortSlots: (slots: TimeSlotDto[]) => TimeSlotDto[];
validateDaySlots: (slots: TimeSlotDto[]) => null | string;
validateSlot: (slot: TimeSlotDto) => null | string;
weeklyHours: Ref<DayHoursDto[]>;
}
export function createAddSlotActions(options: CreateAddSlotActionsOptions) {
// 1. 表单字段 setter。
function setAddSlotType(type: SlotType) {
options.addSlotForm.type = type;
}
function setAddSlotStartTime(value: string) {
options.addSlotForm.startTime = value;
}
function setAddSlotEndTime(value: string) {
options.addSlotForm.endTime = value;
}
function setAddSlotCapacity(value: number) {
options.addSlotForm.capacity = Math.max(0, Number(value) || 0);
}
function setAddSlotRemark(value: string) {
options.addSlotForm.remark = value;
}
// 2. 抽屉初始化与星期选择。
function resetAddSlotForm() {
options.addSlotForm.type = SlotType.Business;
options.addSlotForm.selectedDays = [0, 1, 2, 3, 4];
options.addSlotForm.startTime = '09:00';
options.addSlotForm.endTime = '22:00';
options.addSlotForm.capacity = 50;
options.addSlotForm.remark = '';
}
function openAddSlotDrawer() {
resetAddSlotForm();
options.isAddDrawerOpen.value = true;
}
function isAddDaySelected(dayOfWeek: number) {
return options.addSlotForm.selectedDays.includes(dayOfWeek);
}
function toggleAddDay(dayOfWeek: number) {
const existed = options.addSlotForm.selectedDays.includes(dayOfWeek);
options.addSlotForm.selectedDays = existed
? options.addSlotForm.selectedDays.filter((item) => item !== dayOfWeek)
: [...options.addSlotForm.selectedDays, dayOfWeek].toSorted(
(a, b) => a - b,
);
}
function quickSelectAddDays(mode: AddDaysMode) {
if (mode === 'all') {
options.addSlotForm.selectedDays = [0, 1, 2, 3, 4, 5, 6];
return;
}
if (mode === 'weekday') {
options.addSlotForm.selectedDays = [0, 1, 2, 3, 4];
return;
}
options.addSlotForm.selectedDays = [5, 6];
}
// 3. 提交前校验并持久化。
async function handleAddSlotSubmit() {
if (!options.selectedStoreId.value) return;
if (options.addSlotForm.selectedDays.length === 0) {
message.error('请至少选择一个适用星期');
return;
}
const nextType = Number(options.addSlotForm.type) as SlotType;
const slotTemplate: TimeSlotDto = {
id: options.createSlotId(),
type: nextType,
startTime: options.addSlotForm.startTime,
endTime: options.addSlotForm.endTime,
capacity:
nextType === SlotType.Delivery
? Number(options.addSlotForm.capacity)
: undefined,
remark: options.addSlotForm.remark.trim() || undefined,
};
const slotError = options.validateSlot(slotTemplate);
if (slotError) {
message.error(slotError);
return;
}
const nextWeekly = options.cloneWeeklyHours(options.weeklyHours.value);
for (const dayOfWeek of options.addSlotForm.selectedDays) {
const day = nextWeekly[dayOfWeek];
if (!day) continue;
day.isOpen = true;
day.slots = options.sortSlots([
...day.slots,
{
...slotTemplate,
id: options.createSlotId(),
},
]);
const dayError = options.validateDaySlots(day.slots);
if (dayError) {
const dayMeta = options.dayNames[dayOfWeek] ?? options.defaultDayName;
message.error(`${dayMeta.label}${dayError}`);
return;
}
}
const saved = await options.persistWeeklyHours(nextWeekly, '添加时段成功');
if (saved) {
options.isAddDrawerOpen.value = false;
}
}
return {
handleAddSlotSubmit,
isAddDaySelected,
openAddSlotDrawer,
quickSelectAddDays,
setAddSlotCapacity,
setAddSlotEndTime,
setAddSlotRemark,
setAddSlotStartTime,
setAddSlotType,
toggleAddDay,
};
}

View File

@@ -0,0 +1,79 @@
/**
* 营业时间页面常量集合:
* - 星期维度
* - 颜色与文案映射
* - 下拉选项
*/
import type { DayName, SlotTypeOption } from '../../types';
import { HolidayType, SlotType } from '#/api/store-hours';
export const DAY_NAMES: DayName[] = [
{ label: '周一', labelEn: 'Monday' },
{ label: '周二', labelEn: 'Tuesday' },
{ label: '周三', labelEn: 'Wednesday' },
{ label: '周四', labelEn: 'Thursday' },
{ label: '周五', labelEn: 'Friday' },
{ label: '周六', labelEn: 'Saturday' },
{ label: '周日', labelEn: 'Sunday' },
];
export const DEFAULT_DAY_NAME: DayName = {
label: '周一',
labelEn: 'Monday',
};
export const DAY_INDEXES = [0, 1, 2, 3, 4, 5, 6] as const;
export const SLOT_COLORS: Record<
number,
{ bg: string; border: string; dot: string; text: string }
> = {
[SlotType.Business]: {
bg: '#f6ffed',
border: '#b7eb8f',
dot: '#b7eb8f',
text: '#389e0d',
},
[SlotType.Delivery]: {
bg: '#e6f7ff',
border: '#91d5ff',
dot: '#91d5ff',
text: '#1890ff',
},
[SlotType.Pickup]: {
bg: '#fff7e6',
border: '#ffd591',
dot: '#ffd591',
text: '#d46b08',
},
};
export const HOLIDAY_COLORS: Record<number, { bg: string; text: string }> = {
[HolidayType.Closed]: { bg: '#fff2f0', text: '#ef4444' },
[HolidayType.Special]: { bg: '#fff7e6', text: '#f59e0b' },
};
export const DEFAULT_SLOT_COLOR = {
bg: '#f6ffed',
border: '#b7eb8f',
dot: '#b7eb8f',
text: '#389e0d',
};
export const DEFAULT_HOLIDAY_COLOR = {
bg: '#fff2f0',
text: '#ef4444',
};
export const SLOT_TYPE_LABELS: Record<number, string> = {
[SlotType.Business]: '营业',
[SlotType.Delivery]: '配送',
[SlotType.Pickup]: '自提',
};
export const SLOT_TYPE_OPTIONS: SlotTypeOption[] = [
{ label: '营业', value: SlotType.Business },
{ label: '配送', value: SlotType.Delivery },
{ label: '自提', value: SlotType.Pickup },
];

View File

@@ -0,0 +1,91 @@
/**
* 复制到其他门店动作:
* - 选择目标门店
* - 全选/单选联动
* - 提交复制请求
*/
import type { ComputedRef, Ref } from 'vue';
import type { StoreListItemDto } from '#/api/store';
import { message } from 'ant-design-vue';
import { copyStoreHoursApi } from '#/api/store-hours';
interface CreateCopyActionsOptions {
copyCandidates: ComputedRef<StoreListItemDto[]>;
copyTargetStoreIds: Ref<string[]>;
isCopyModalOpen: Ref<boolean>;
isCopySubmitting: Ref<boolean>;
selectedStoreId: Ref<string>;
}
export function createCopyActions(options: CreateCopyActionsOptions) {
// 1. 弹窗与选中状态维护。
function openCopyModal() {
if (!options.selectedStoreId.value) return;
options.copyTargetStoreIds.value = [];
options.isCopyModalOpen.value = true;
}
function toggleCopyStore(storeId: string, checked: boolean) {
options.copyTargetStoreIds.value = checked
? [...new Set([storeId, ...options.copyTargetStoreIds.value])]
: options.copyTargetStoreIds.value.filter((id) => id !== storeId);
}
function toggleCopyAll(checked: boolean) {
if (checked) {
options.copyTargetStoreIds.value = options.copyCandidates.value.map(
(store) => store.id,
);
return;
}
options.copyTargetStoreIds.value = [];
}
function handleCopyCheckAll(checked: boolean) {
toggleCopyAll(Boolean(checked));
}
function handleCopyStoreChange(storeId: string, checked: boolean) {
toggleCopyStore(storeId, Boolean(checked));
}
// 2. 调用复制接口并处理结果提示。
async function handleCopySubmit() {
if (!options.selectedStoreId.value) return;
if (options.copyTargetStoreIds.value.length === 0) {
message.error('请至少选择一个目标门店');
return;
}
options.isCopySubmitting.value = true;
try {
await copyStoreHoursApi({
sourceStoreId: options.selectedStoreId.value,
targetStoreIds: options.copyTargetStoreIds.value,
includeWeeklyHours: true,
includeHolidays: true,
});
message.success(
`已复制到 ${options.copyTargetStoreIds.value.length} 家门店`,
);
options.isCopyModalOpen.value = false;
options.copyTargetStoreIds.value = [];
} catch (error) {
console.error(error);
} finally {
options.isCopySubmitting.value = false;
}
}
return {
handleCopyCheckAll,
handleCopyStoreChange,
handleCopySubmit,
openCopyModal,
toggleCopyStore,
};
}

View File

@@ -0,0 +1,89 @@
/**
* 门店与营业时间数据加载动作:
* - 拉取门店列表
* - 按门店拉取营业时间
*/
import type { Ref } from 'vue';
import type { StoreListItemDto } from '#/api/store';
import type { DayHoursDto, HolidayDto } from '#/api/store-hours';
import { getStoreListApi } from '#/api/store';
import { getStoreHoursApi } from '#/api/store-hours';
interface CreateDataActionsOptions {
holidays: Ref<HolidayDto[]>;
isHoursLoading: Ref<boolean>;
isStoreLoading: Ref<boolean>;
normalizeHolidays: (source: HolidayDto[]) => HolidayDto[];
normalizeWeeklyHours: (source: DayHoursDto[]) => DayHoursDto[];
selectedStoreId: Ref<string>;
stores: Ref<StoreListItemDto[]>;
weeklyHours: Ref<DayHoursDto[]>;
}
export function createDataActions(options: CreateDataActionsOptions) {
// 1. 加载指定门店的营业时间配置。
async function loadStoreHours(storeId: string) {
options.isHoursLoading.value = true;
try {
const currentStoreId = storeId;
const result = await getStoreHoursApi(storeId);
if (options.selectedStoreId.value !== currentStoreId) return;
options.weeklyHours.value = options.normalizeWeeklyHours(
result.weeklyHours ?? [],
);
options.holidays.value = options.normalizeHolidays(result.holidays ?? []);
} catch (error) {
console.error(error);
} finally {
options.isHoursLoading.value = false;
}
}
// 2. 加载门店列表并处理默认选中逻辑。
async function loadStores() {
options.isStoreLoading.value = true;
try {
const result = await getStoreListApi({
keyword: undefined,
businessStatus: undefined,
auditStatus: undefined,
serviceType: undefined,
page: 1,
pageSize: 200,
});
options.stores.value = result.items ?? [];
if (options.stores.value.length === 0) {
options.selectedStoreId.value = '';
options.weeklyHours.value = options.normalizeWeeklyHours([]);
options.holidays.value = [];
return;
}
const hasSelected = options.stores.value.some(
(store) => store.id === options.selectedStoreId.value,
);
if (!hasSelected) {
const firstStore = options.stores.value[0];
if (firstStore) {
options.selectedStoreId.value = firstStore.id;
}
} else if (options.selectedStoreId.value) {
await loadStoreHours(options.selectedStoreId.value);
}
} catch (error) {
console.error(error);
} finally {
options.isStoreLoading.value = false;
}
}
return {
loadStoreHours,
loadStores,
};
}

View File

@@ -0,0 +1,179 @@
/**
* 单日时段编辑动作:
* - 打开日编辑抽屉
* - 编辑/删除时段行
* - 保存当日配置
*/
import type { ComputedRef, Ref } from 'vue';
import type { DayEditFormState, DayName, EditSlotItem } from '../../types';
import type { DayHoursDto, TimeSlotDto } from '#/api/store-hours';
import { message } from 'ant-design-vue';
import { SlotType } from '#/api/store-hours';
interface CreateDayEditActionsOptions {
cloneWeeklyHours: (source: DayHoursDto[]) => DayHoursDto[];
createSlotId: () => string;
currentDayMeta: ComputedRef<DayName>;
dayEditForm: DayEditFormState;
isDayDrawerOpen: Ref<boolean>;
normalizeWeeklyHours: (source: DayHoursDto[]) => DayHoursDto[];
persistWeeklyHours: (
nextWeekly: DayHoursDto[],
successText: string,
) => Promise<boolean>;
selectedStoreId: Ref<string>;
sortSlots: (slots: TimeSlotDto[]) => TimeSlotDto[];
validateDaySlots: (slots: TimeSlotDto[]) => null | string;
weeklyHours: Ref<DayHoursDto[]>;
}
export function createDayEditActions(options: CreateDayEditActionsOptions) {
// 1. 抽屉初始化与行增删。
function openDayDrawer(dayOfWeek: number) {
const source = options.normalizeWeeklyHours(options.weeklyHours.value)[
dayOfWeek
];
if (!source) return;
options.dayEditForm.dayOfWeek = dayOfWeek;
options.dayEditForm.isOpen = source.isOpen;
options.dayEditForm.slots = source.slots.map(
(slot): EditSlotItem => ({
id: slot.id || options.createSlotId(),
type: Number(slot.type) as SlotType,
startTime: slot.startTime,
endTime: slot.endTime,
capacity:
Number(slot.type) === SlotType.Delivery
? Number(slot.capacity ?? 50)
: undefined,
remark: slot.remark || '',
}),
);
options.isDayDrawerOpen.value = true;
}
function addDaySlotRow() {
options.dayEditForm.slots = [
...options.dayEditForm.slots,
{
id: options.createSlotId(),
type: SlotType.Business,
startTime: '09:00',
endTime: '22:00',
remark: '',
},
];
}
function removeDaySlot(slotId: string) {
options.dayEditForm.slots = options.dayEditForm.slots.filter(
(slot) => slot.id !== slotId,
);
}
// 2. 表单字段 setter。
function setDayEditOpen(value: boolean) {
options.dayEditForm.isOpen = value;
}
function updateDaySlot(
slotId: string,
updater: (slot: EditSlotItem) => void,
) {
const target = options.dayEditForm.slots.find((slot) => slot.id === slotId);
if (!target) return;
updater(target);
}
function setDaySlotType(slotId: string, value: SlotType) {
updateDaySlot(slotId, (slot) => {
slot.type = value;
if (value !== SlotType.Delivery) {
slot.capacity = undefined;
} else if (!slot.capacity || slot.capacity <= 0) {
slot.capacity = 50;
}
});
}
function setDaySlotStartTime(slotId: string, value: string) {
updateDaySlot(slotId, (slot) => {
slot.startTime = value;
});
}
function setDaySlotEndTime(slotId: string, value: string) {
updateDaySlot(slotId, (slot) => {
slot.endTime = value;
});
}
function setDaySlotCapacity(slotId: string, value: number) {
updateDaySlot(slotId, (slot) => {
slot.capacity = Math.max(0, Number(value) || 0);
});
}
// 3. 保存前校验并持久化。
async function handleSaveDay() {
if (!options.selectedStoreId.value) return;
if (options.dayEditForm.isOpen && options.dayEditForm.slots.length === 0) {
message.error('该日营业已开启,至少保留一个时段');
return;
}
const nextSlots: TimeSlotDto[] = options.dayEditForm.slots.map((slot) => {
const slotType = Number(slot.type) as SlotType;
return {
id: slot.id || options.createSlotId(),
type: slotType,
startTime: slot.startTime,
endTime: slot.endTime,
capacity:
slotType === SlotType.Delivery ? Number(slot.capacity) : undefined,
remark: slot.remark?.trim() || undefined,
};
});
if (options.dayEditForm.isOpen) {
const dayError = options.validateDaySlots(nextSlots);
if (dayError) {
message.error(`${options.currentDayMeta.value.label}${dayError}`);
return;
}
}
const nextWeekly = options.cloneWeeklyHours(options.weeklyHours.value);
nextWeekly[options.dayEditForm.dayOfWeek] = {
dayOfWeek: options.dayEditForm.dayOfWeek,
isOpen: options.dayEditForm.isOpen,
slots: options.sortSlots(nextSlots),
};
const saved = await options.persistWeeklyHours(
nextWeekly,
'营业时间已更新',
);
if (saved) {
options.isDayDrawerOpen.value = false;
}
}
return {
addDaySlotRow,
handleSaveDay,
openDayDrawer,
removeDaySlot,
setDayEditOpen,
setDaySlotCapacity,
setDaySlotEndTime,
setDaySlotStartTime,
setDaySlotType,
};
}

View File

@@ -0,0 +1,221 @@
/**
* 营业时间页面纯函数工具集:
* 仅包含格式化、归一化、样式映射、校验,不依赖组件状态。
*/
import type { DayHoursDto, HolidayDto, TimeSlotDto } from '#/api/store-hours';
import { HolidayType, SlotType } from '#/api/store-hours';
import {
DAY_INDEXES,
DEFAULT_HOLIDAY_COLOR,
DEFAULT_SLOT_COLOR,
HOLIDAY_COLORS,
SLOT_COLORS,
SLOT_TYPE_LABELS,
} from './constants';
// 1. 基础格式化工具。
export function createSlotId() {
return `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
}
export function getTodayDate() {
const today = new Date();
const month = String(today.getMonth() + 1).padStart(2, '0');
const date = String(today.getDate()).padStart(2, '0');
return `${today.getFullYear()}-${month}-${date}`;
}
export function toDateOnly(value?: string) {
if (!value) return '';
return String(value).slice(0, 10);
}
export function toTimeHHmm(value?: string) {
if (!value) return '';
const matched = /(\d{2}:\d{2})/.exec(value);
return matched?.[1] ?? '';
}
export function parseTimeToMinutes(time: string) {
const matched = /^([01]\d|2[0-3]):([0-5]\d)$/.exec(time);
if (!matched) return null;
return Number(matched[1]) * 60 + Number(matched[2]);
}
// 2. 数据归一化与排序。
export function sortSlots(slots: TimeSlotDto[]) {
return [...slots].toSorted((a, b) => {
const startA = parseTimeToMinutes(a.startTime) ?? 0;
const startB = parseTimeToMinutes(b.startTime) ?? 0;
if (startA !== startB) return startA - startB;
return Number(a.type) - Number(b.type);
});
}
export function normalizeSlots(slots: TimeSlotDto[]) {
return sortSlots(
(slots ?? []).map((slot) => {
const slotType = Number(slot.type) as SlotType;
return {
id: slot.id || createSlotId(),
type: SLOT_TYPE_LABELS[slotType] ? slotType : SlotType.Business,
startTime: toTimeHHmm(slot.startTime) || '09:00',
endTime: toTimeHHmm(slot.endTime) || '22:00',
capacity:
slotType === SlotType.Delivery && slot.capacity !== undefined
? Number(slot.capacity)
: undefined,
remark: slot.remark || '',
} as TimeSlotDto;
}),
);
}
export function normalizeWeeklyHours(source: DayHoursDto[]) {
const dayMap = new Map<number, DayHoursDto>();
for (const item of source ?? []) {
const dayOfWeek = Number(item.dayOfWeek);
if (!Number.isInteger(dayOfWeek) || dayOfWeek < 0 || dayOfWeek > 6)
continue;
const slots = normalizeSlots(item.slots ?? []);
dayMap.set(dayOfWeek, {
dayOfWeek,
isOpen: typeof item.isOpen === 'boolean' ? item.isOpen : slots.length > 0,
slots,
});
}
return DAY_INDEXES.map((dayOfWeek) => {
return (
dayMap.get(dayOfWeek) ?? {
dayOfWeek,
isOpen: false,
slots: [],
}
);
});
}
export function normalizeHolidays(source: HolidayDto[]) {
return [...(source ?? [])]
.map((holiday) => ({
id: String(holiday.id || createSlotId()),
startDate: toDateOnly(holiday.startDate),
endDate: toDateOnly(holiday.endDate || holiday.startDate),
type:
Number(holiday.type) === HolidayType.Special
? HolidayType.Special
: HolidayType.Closed,
startTime: toTimeHHmm(holiday.startTime),
endTime: toTimeHHmm(holiday.endTime),
reason: holiday.reason || '',
remark: holiday.remark || '',
}))
.toSorted((a, b) => {
const dateCompare = a.startDate.localeCompare(b.startDate);
if (dateCompare !== 0) return dateCompare;
return a.id.localeCompare(b.id);
});
}
export function cloneWeeklyHours(source: DayHoursDto[]) {
return normalizeWeeklyHours(source).map((day) => ({
...day,
slots: day.slots.map((slot) => ({ ...slot })),
}));
}
// 3. 展示映射工具(标签、颜色、文案)。
export function getSlotTypeLabel(type: SlotType) {
return SLOT_TYPE_LABELS[type] || '未知';
}
export function getSlotStyle(type: SlotType) {
const color = SLOT_COLORS[type] ?? DEFAULT_SLOT_COLOR;
return {
background: color.bg,
borderColor: color.border,
color: color.text,
};
}
export function getHolidayTagStyle(type: HolidayType) {
const color = HOLIDAY_COLORS[type] ?? DEFAULT_HOLIDAY_COLOR;
return {
background: color.bg,
color: color.text,
};
}
export function getLegendDotColor(type: SlotType) {
return (SLOT_COLORS[type] ?? DEFAULT_SLOT_COLOR).dot;
}
export function getHolidayTypeText(type: HolidayType) {
return type === HolidayType.Special ? '特殊营业' : '休息';
}
export function formatHolidayDate(holiday: HolidayDto) {
const start = toDateOnly(holiday.startDate);
const end = toDateOnly(holiday.endDate || holiday.startDate);
if (!end || start === end) return start;
return `${start} ~ ${end}`;
}
export function formatHolidayTime(holiday: HolidayDto) {
if (holiday.type === HolidayType.Closed) return '全天';
const start = toTimeHHmm(holiday.startTime);
const end = toTimeHHmm(holiday.endTime);
return start && end ? `${start} - ${end}` : '--';
}
export function getSlotTypePillClass(type: SlotType) {
if (type === SlotType.Business) return 'tp-biz';
if (type === SlotType.Delivery) return 'tp-del';
return 'tp-pick';
}
// 4. 业务校验工具(单时段与日内冲突)。
export function validateSlot(slot: TimeSlotDto) {
const start = parseTimeToMinutes(slot.startTime);
const end = parseTimeToMinutes(slot.endTime);
if (start === null || end === null) {
return '开始时间或结束时间格式不正确';
}
if (start >= end) {
return '开始时间必须早于结束时间';
}
if (slot.type === SlotType.Delivery) {
const capacity = Number(slot.capacity);
if (!Number.isInteger(capacity) || capacity <= 0) {
return '配送时段容量必须为正整数';
}
}
return null;
}
export function validateDaySlots(slots: TimeSlotDto[]) {
for (const slot of slots) {
const slotError = validateSlot(slot);
if (slotError) return slotError;
}
for (const type of [SlotType.Business, SlotType.Delivery, SlotType.Pickup]) {
const sameTypeSlots = sortSlots(slots.filter((slot) => slot.type === type));
for (let index = 1; index < sameTypeSlots.length; index++) {
const prevSlot = sameTypeSlots[index - 1];
const currentSlot = sameTypeSlots[index];
if (!prevSlot || !currentSlot) continue;
const prevEnd = parseTimeToMinutes(prevSlot.endTime) ?? 0;
const currentStart = parseTimeToMinutes(currentSlot.startTime) ?? 0;
if (currentStart < prevEnd) {
return `${getSlotTypeLabel(type)}时段存在重叠,请调整`;
}
}
}
return null;
}

View File

@@ -0,0 +1,248 @@
/**
* 节假日/特殊营业动作:
* - 抽屉初始化
* - 表单字段维护
* - 新增、编辑、删除
*/
import type { Ref } from 'vue';
import type { HolidayFormState } from '../../types';
import type { HolidayDto } from '#/api/store-hours';
import { message } from 'ant-design-vue';
import {
deleteHolidayApi,
HolidayType,
saveHolidayApi,
} from '#/api/store-hours';
interface HolidayPayload {
endDate: string;
endTime?: string;
id?: string;
reason: string;
remark?: string;
startDate: string;
startTime?: string;
type: HolidayType;
}
interface CreateHolidayActionsOptions {
deletingHolidayId: Ref<string>;
getTodayDate: () => string;
holidayDrawerMode: Ref<'create' | 'edit'>;
holidayForm: HolidayFormState;
isHolidayDrawerOpen: Ref<boolean>;
isHolidaySubmitting: Ref<boolean>;
loadStoreHours: (storeId: string) => Promise<void>;
parseTimeToMinutes: (time: string) => null | number;
selectedStoreId: Ref<string>;
toDateOnly: (value?: string) => string;
toTimeHHmm: (value?: string) => string;
}
export function createHolidayActions(options: CreateHolidayActionsOptions) {
// 1. 表单字段 setter。
function setHolidayType(value: HolidayType) {
options.holidayForm.type = value;
}
function setHolidayDateMode(mode: 'range' | 'single') {
options.holidayForm.dateMode = mode;
}
function setHolidaySingleDate(value: string) {
options.holidayForm.singleDate = value;
}
function setHolidayRangeStart(value: string) {
options.holidayForm.rangeStart = value;
}
function setHolidayRangeEnd(value: string) {
options.holidayForm.rangeEnd = value;
}
function setHolidayStartTime(value: string) {
options.holidayForm.startTime = value;
}
function setHolidayEndTime(value: string) {
options.holidayForm.endTime = value;
}
function setHolidayReason(value: string) {
options.holidayForm.reason = value;
}
function setHolidayRemark(value: string) {
options.holidayForm.remark = value;
}
// 2. 抽屉初始化与回填。
function resetHolidayForm() {
const today = options.getTodayDate();
options.holidayForm.id = '';
options.holidayForm.type = HolidayType.Closed;
options.holidayForm.dateMode = 'single';
options.holidayForm.singleDate = today;
options.holidayForm.rangeStart = today;
options.holidayForm.rangeEnd = today;
options.holidayForm.startTime = '09:00';
options.holidayForm.endTime = '22:00';
options.holidayForm.reason = '';
options.holidayForm.remark = '';
}
function openHolidayDrawer(mode: 'create' | 'edit', holiday?: HolidayDto) {
options.holidayDrawerMode.value = mode;
if (mode === 'create' || !holiday) {
resetHolidayForm();
options.isHolidayDrawerOpen.value = true;
return;
}
const startDate = options.toDateOnly(holiday.startDate);
const endDate = options.toDateOnly(holiday.endDate || holiday.startDate);
options.holidayForm.id = holiday.id;
options.holidayForm.type = holiday.type;
options.holidayForm.dateMode = startDate === endDate ? 'single' : 'range';
options.holidayForm.singleDate = startDate;
options.holidayForm.rangeStart = startDate;
options.holidayForm.rangeEnd = endDate;
options.holidayForm.startTime =
options.toTimeHHmm(holiday.startTime) || '09:00';
options.holidayForm.endTime =
options.toTimeHHmm(holiday.endTime) || '22:00';
options.holidayForm.reason = holiday.reason || '';
options.holidayForm.remark = holiday.remark || '';
options.isHolidayDrawerOpen.value = true;
}
// 3. 组装提交载荷并进行合法性校验。
function buildHolidayPayload(): HolidayPayload | null {
const reason = options.holidayForm.reason.trim();
if (!reason) {
message.error('请填写原因');
return null;
}
let startDate = '';
let endDate = '';
if (options.holidayForm.dateMode === 'single') {
if (!options.holidayForm.singleDate) {
message.error('请选择日期');
return null;
}
startDate = options.holidayForm.singleDate;
endDate = options.holidayForm.singleDate;
} else {
if (!options.holidayForm.rangeStart || !options.holidayForm.rangeEnd) {
message.error('请完整填写日期范围');
return null;
}
if (options.holidayForm.rangeStart > options.holidayForm.rangeEnd) {
message.error('开始日期不能晚于结束日期');
return null;
}
startDate = options.holidayForm.rangeStart;
endDate = options.holidayForm.rangeEnd;
}
let startTime: string | undefined;
let endTime: string | undefined;
if (options.holidayForm.type === HolidayType.Special) {
const start = options.toTimeHHmm(options.holidayForm.startTime);
const end = options.toTimeHHmm(options.holidayForm.endTime);
const startMinutes = options.parseTimeToMinutes(start);
const endMinutes = options.parseTimeToMinutes(end);
if (
startMinutes === null ||
endMinutes === null ||
startMinutes >= endMinutes
) {
message.error('特殊营业时间不合法');
return null;
}
startTime = start;
endTime = end;
}
return {
id:
options.holidayDrawerMode.value === 'edit'
? options.holidayForm.id || undefined
: undefined,
startDate,
endDate,
type: options.holidayForm.type,
startTime,
endTime,
reason,
remark: options.holidayForm.remark.trim() || undefined,
};
}
// 4. 保存与删除动作。
async function handleHolidaySubmit() {
if (!options.selectedStoreId.value) return;
const payload = buildHolidayPayload();
if (!payload) return;
options.isHolidaySubmitting.value = true;
try {
await saveHolidayApi({
storeId: options.selectedStoreId.value,
holiday: payload,
});
message.success(
options.holidayDrawerMode.value === 'edit'
? '日期配置已更新'
: '日期配置已添加',
);
options.isHolidayDrawerOpen.value = false;
await options.loadStoreHours(options.selectedStoreId.value);
} catch (error) {
console.error(error);
} finally {
options.isHolidaySubmitting.value = false;
}
}
async function handleDeleteHoliday(id: string) {
if (!options.selectedStoreId.value) return;
options.deletingHolidayId.value = id;
try {
await deleteHolidayApi(id);
message.success('已删除');
await options.loadStoreHours(options.selectedStoreId.value);
} catch (error) {
console.error(error);
} finally {
options.deletingHolidayId.value = '';
}
}
return {
handleDeleteHoliday,
handleHolidaySubmit,
openHolidayDrawer,
setHolidayDateMode,
setHolidayEndTime,
setHolidayRangeEnd,
setHolidayRangeStart,
setHolidayReason,
setHolidayRemark,
setHolidaySingleDate,
setHolidayStartTime,
setHolidayType,
};
}

View File

@@ -0,0 +1,47 @@
/**
* 每周营业时间保存动作。
*/
import type { Ref } from 'vue';
import type { DayHoursDto } from '#/api/store-hours';
import { message } from 'ant-design-vue';
import { saveWeeklyHoursApi } from '#/api/store-hours';
interface CreateWeeklyActionsOptions {
isWeeklySubmitting: Ref<boolean>;
loadStoreHours: (storeId: string) => Promise<void>;
normalizeWeeklyHours: (source: DayHoursDto[]) => DayHoursDto[];
selectedStoreId: Ref<string>;
}
export function createWeeklyActions(options: CreateWeeklyActionsOptions) {
// 保存后刷新当前门店数据,保证页面展示与服务端一致。
async function persistWeeklyHours(
nextWeekly: DayHoursDto[],
successText: string,
) {
if (!options.selectedStoreId.value) return false;
options.isWeeklySubmitting.value = true;
try {
await saveWeeklyHoursApi({
storeId: options.selectedStoreId.value,
weeklyHours: options.normalizeWeeklyHours(nextWeekly),
});
message.success(successText);
await options.loadStoreHours(options.selectedStoreId.value);
return true;
} catch (error) {
console.error(error);
return false;
} finally {
options.isWeeklySubmitting.value = false;
}
}
return {
persistWeeklyHours,
};
}

View File

@@ -0,0 +1,362 @@
/**
* 营业时间页面主编排:
* 1. 维护页面级响应式状态
* 2. 组装各子域 action加载、每周时段、日期、复制
* 3. 对外暴露视图层可直接消费的状态与方法
*/
import type {
AddSlotFormState,
DayEditFormState,
HolidayFormState,
} from '../types';
import type { StoreListItemDto } from '#/api/store';
import type { DayHoursDto, HolidayDto } from '#/api/store-hours';
import { computed, onMounted, reactive, ref, watch } from 'vue';
import { HolidayType, SlotType } from '#/api/store-hours';
import { createAddSlotActions } from './store-hours-page/add-slot-actions';
import {
DAY_NAMES,
DEFAULT_DAY_NAME,
SLOT_TYPE_OPTIONS,
} from './store-hours-page/constants';
import { createCopyActions } from './store-hours-page/copy-actions';
import { createDataActions } from './store-hours-page/data-actions';
import { createDayEditActions } from './store-hours-page/day-edit-actions';
import {
cloneWeeklyHours,
createSlotId,
formatHolidayDate,
formatHolidayTime,
getHolidayTagStyle,
getHolidayTypeText,
getLegendDotColor,
getSlotStyle,
getSlotTypeLabel,
getSlotTypePillClass,
getTodayDate,
normalizeHolidays,
normalizeWeeklyHours,
parseTimeToMinutes,
sortSlots,
toDateOnly,
toTimeHHmm,
validateDaySlots,
validateSlot,
} from './store-hours-page/helpers';
import { createHolidayActions } from './store-hours-page/holiday-actions';
import { createWeeklyActions } from './store-hours-page/weekly-actions';
export function useStoreHoursPage() {
// 1. 页面级 loading / submitting 状态。
const isStoreLoading = ref(false);
const isHoursLoading = ref(false);
const isWeeklySubmitting = ref(false);
const isHolidaySubmitting = ref(false);
const isCopySubmitting = ref(false);
const deletingHolidayId = ref('');
// 2. 页面核心业务数据。
const stores = ref<StoreListItemDto[]>([]);
const selectedStoreId = ref('');
const weeklyHours = ref<DayHoursDto[]>([]);
const holidays = ref<HolidayDto[]>([]);
// 3. 抽屉/弹窗可见性。
const isAddDrawerOpen = ref(false);
const isDayDrawerOpen = ref(false);
const isHolidayDrawerOpen = ref(false);
const isCopyModalOpen = ref(false);
// 4. 各子模块表单状态。
const addSlotForm = reactive<AddSlotFormState>({
type: SlotType.Business,
selectedDays: [0, 1, 2, 3, 4],
startTime: '09:00',
endTime: '22:00',
capacity: 50,
remark: '',
});
const dayEditForm = reactive<DayEditFormState>({
dayOfWeek: 0,
isOpen: true,
slots: [],
});
const holidayDrawerMode = ref<'create' | 'edit'>('create');
const holidayForm = reactive<HolidayFormState>({
id: '',
type: HolidayType.Closed,
dateMode: 'single',
singleDate: '',
rangeStart: '',
rangeEnd: '',
startTime: '09:00',
endTime: '22:00',
reason: '',
remark: '',
});
const copyTargetStoreIds = ref<string[]>([]);
// 5. 页面衍生视图数据。
const storeOptions = computed(() =>
stores.value.map((store) => ({ label: store.name, value: store.id })),
);
const copyCandidates = computed(() =>
stores.value.filter((store) => store.id !== selectedStoreId.value),
);
const selectedStoreName = computed(
() =>
stores.value.find((store) => store.id === selectedStoreId.value)?.name ??
'',
);
const weekRows = computed(() =>
normalizeWeeklyHours(weeklyHours.value).map((day, index) => {
const dayMeta = DAY_NAMES[index] ?? DEFAULT_DAY_NAME;
return {
...day,
label: dayMeta.label,
labelEn: dayMeta.labelEn,
slots: sortSlots(day.slots),
};
}),
);
const holidayRows = computed(() => normalizeHolidays(holidays.value));
const currentDayMeta = computed(
() => DAY_NAMES[dayEditForm.dayOfWeek] ?? DEFAULT_DAY_NAME,
);
const holidayDrawerTitle = computed(() =>
holidayDrawerMode.value === 'edit' ? '编辑日期' : '添加日期',
);
const holidaySubmitText = computed(() =>
holidayDrawerMode.value === 'edit' ? '保存修改' : '确认添加',
);
const isCopyAllChecked = computed(
() =>
copyCandidates.value.length > 0 &&
copyTargetStoreIds.value.length === copyCandidates.value.length,
);
const isCopyIndeterminate = computed(
() =>
copyTargetStoreIds.value.length > 0 &&
copyTargetStoreIds.value.length < copyCandidates.value.length,
);
// 6. 数据加载域 action。
const { loadStoreHours, loadStores } = createDataActions({
holidays,
isHoursLoading,
isStoreLoading,
normalizeHolidays,
normalizeWeeklyHours,
selectedStoreId,
stores,
weeklyHours,
});
const { persistWeeklyHours } = createWeeklyActions({
isWeeklySubmitting,
loadStoreHours,
normalizeWeeklyHours,
selectedStoreId,
});
// 7. 添加时段域 action。
const {
handleAddSlotSubmit,
isAddDaySelected,
openAddSlotDrawer,
quickSelectAddDays,
setAddSlotCapacity,
setAddSlotEndTime,
setAddSlotRemark,
setAddSlotStartTime,
setAddSlotType,
toggleAddDay,
} = createAddSlotActions({
addSlotForm,
cloneWeeklyHours,
createSlotId,
defaultDayName: DEFAULT_DAY_NAME,
dayNames: DAY_NAMES,
isAddDrawerOpen,
persistWeeklyHours,
selectedStoreId,
sortSlots,
validateDaySlots,
validateSlot,
weeklyHours,
});
// 8. 日编辑域 action。
const {
addDaySlotRow,
handleSaveDay,
openDayDrawer,
removeDaySlot,
setDayEditOpen,
setDaySlotCapacity,
setDaySlotEndTime,
setDaySlotStartTime,
setDaySlotType,
} = createDayEditActions({
cloneWeeklyHours,
createSlotId,
currentDayMeta,
dayEditForm,
isDayDrawerOpen,
normalizeWeeklyHours,
persistWeeklyHours,
selectedStoreId,
sortSlots,
validateDaySlots,
weeklyHours,
});
// 9. 节假日域 action。
const {
handleDeleteHoliday,
handleHolidaySubmit,
openHolidayDrawer,
setHolidayDateMode,
setHolidayEndTime,
setHolidayRangeEnd,
setHolidayRangeStart,
setHolidayReason,
setHolidayRemark,
setHolidaySingleDate,
setHolidayStartTime,
setHolidayType,
} = createHolidayActions({
deletingHolidayId,
getTodayDate,
holidayDrawerMode,
holidayForm,
isHolidayDrawerOpen,
isHolidaySubmitting,
loadStoreHours,
parseTimeToMinutes,
selectedStoreId,
toDateOnly,
toTimeHHmm,
});
// 10. 复制域 action。
const {
handleCopyCheckAll,
handleCopyStoreChange,
handleCopySubmit,
openCopyModal,
toggleCopyStore,
} = createCopyActions({
copyCandidates,
copyTargetStoreIds,
isCopyModalOpen,
isCopySubmitting,
selectedStoreId,
});
// 11. 门店切换后自动刷新对应营业时间。
watch(selectedStoreId, async (storeId) => {
if (!storeId) {
weeklyHours.value = normalizeWeeklyHours([]);
holidays.value = [];
return;
}
await loadStoreHours(storeId);
});
// 12. 页面首屏初始化。
onMounted(loadStores);
return {
DAY_NAMES,
HolidayType,
SLOT_TYPE_OPTIONS,
SlotType,
addDaySlotRow,
addSlotForm,
copyCandidates,
copyTargetStoreIds,
currentDayMeta,
dayEditForm,
deletingHolidayId,
formatHolidayDate,
formatHolidayTime,
getHolidayTagStyle,
getHolidayTypeText,
getLegendDotColor,
getSlotStyle,
getSlotTypeLabel,
getSlotTypePillClass,
handleAddSlotSubmit,
handleCopyCheckAll,
handleCopyStoreChange,
handleCopySubmit,
handleDeleteHoliday,
handleHolidaySubmit,
handleSaveDay,
holidayDrawerTitle,
holidayForm,
holidayRows,
holidaySubmitText,
isAddDaySelected,
isAddDrawerOpen,
isCopyAllChecked,
isCopyIndeterminate,
isCopyModalOpen,
isCopySubmitting,
isDayDrawerOpen,
isHolidayDrawerOpen,
isHolidaySubmitting,
isHoursLoading,
isStoreLoading,
isWeeklySubmitting,
openAddSlotDrawer,
openCopyModal,
openDayDrawer,
openHolidayDrawer,
quickSelectAddDays,
removeDaySlot,
selectedStoreId,
selectedStoreName,
setAddSlotCapacity,
setAddSlotEndTime,
setAddSlotRemark,
setAddSlotStartTime,
setAddSlotType,
setDayEditOpen,
setDaySlotCapacity,
setDaySlotEndTime,
setDaySlotStartTime,
setDaySlotType,
setHolidayDateMode,
setHolidayEndTime,
setHolidayRangeEnd,
setHolidayRangeStart,
setHolidayReason,
setHolidayRemark,
setHolidaySingleDate,
setHolidayStartTime,
setHolidayType,
storeOptions,
toggleAddDay,
toggleCopyStore,
weekRows,
};
}

View File

@@ -0,0 +1,343 @@
<script setup lang="ts">
import { Page } from '@vben/common-ui';
import { Button, Card, Empty, Popconfirm, Select, Spin } from 'ant-design-vue';
import AddSlotDrawer from './components/AddSlotDrawer.vue';
import CopyStoreModal from './components/CopyStoreModal.vue';
import DayEditDrawer from './components/DayEditDrawer.vue';
import HolidayDrawer from './components/HolidayDrawer.vue';
import { useStoreHoursPage } from './composables/useStoreHoursPage';
const {
DAY_NAMES,
HolidayType,
SLOT_TYPE_OPTIONS,
SlotType,
addDaySlotRow,
addSlotForm,
copyCandidates,
copyTargetStoreIds,
currentDayMeta,
dayEditForm,
deletingHolidayId,
formatHolidayDate,
formatHolidayTime,
getHolidayTagStyle,
getHolidayTypeText,
getLegendDotColor,
getSlotStyle,
getSlotTypeLabel,
getSlotTypePillClass,
handleAddSlotSubmit,
handleCopyCheckAll,
handleCopyStoreChange,
handleCopySubmit,
handleDeleteHoliday,
handleHolidaySubmit,
handleSaveDay,
holidayDrawerTitle,
holidayForm,
holidayRows,
holidaySubmitText,
isAddDaySelected,
isAddDrawerOpen,
isCopyAllChecked,
isCopyIndeterminate,
isCopyModalOpen,
isCopySubmitting,
isDayDrawerOpen,
isHolidayDrawerOpen,
isHolidaySubmitting,
isHoursLoading,
isStoreLoading,
isWeeklySubmitting,
openAddSlotDrawer,
openCopyModal,
openDayDrawer,
openHolidayDrawer,
quickSelectAddDays,
removeDaySlot,
selectedStoreId,
selectedStoreName,
setAddSlotCapacity,
setAddSlotEndTime,
setAddSlotRemark,
setAddSlotStartTime,
setAddSlotType,
setDayEditOpen,
setDaySlotCapacity,
setDaySlotEndTime,
setDaySlotStartTime,
setDaySlotType,
setHolidayDateMode,
setHolidayEndTime,
setHolidayRangeEnd,
setHolidayRangeStart,
setHolidayReason,
setHolidayRemark,
setHolidaySingleDate,
setHolidayStartTime,
setHolidayType,
storeOptions,
toggleAddDay,
toggleCopyStore,
weekRows,
} = useStoreHoursPage();
</script>
<template>
<Page title="营业时间" content-class="space-y-4 page-hours">
<Card :bordered="false" class="hours-toolbar-card">
<div class="hours-toolbar">
<Select
v-model:value="selectedStoreId"
class="store-selector"
placeholder="请选择门店"
:loading="isStoreLoading"
:options="storeOptions"
:disabled="isStoreLoading || storeOptions.length === 0"
/>
<div class="toolbar-spacer"></div>
<Button
:disabled="!selectedStoreId || copyCandidates.length === 0"
@click="openCopyModal"
>
复制到其他门店
</Button>
</div>
</Card>
<template v-if="storeOptions.length === 0">
<Card :bordered="false">
<Empty description="暂无门店,请先创建门店" />
</Card>
</template>
<template v-else>
<Spin :spinning="isHoursLoading">
<Card :bordered="false">
<template #title>
<span class="section-title">每周营业时间</span>
</template>
<template #extra>
<Button
type="primary"
:disabled="!selectedStoreId"
@click="openAddSlotDrawer"
>
添加时段
</Button>
</template>
<div class="legend-row">
<span
v-for="item in SLOT_TYPE_OPTIONS"
:key="item.value"
class="legend-item"
>
<span
class="legend-dot"
:style="{ background: getLegendDotColor(item.value) }"
></span>
{{ item.label }}
</span>
</div>
<div class="week-grid">
<div v-for="day in weekRows" :key="day.dayOfWeek" class="week-row">
<div class="week-day">
{{ day.label }}
<div class="week-day-en">{{ day.labelEn }}</div>
</div>
<div class="week-slots">
<template v-if="day.isOpen && day.slots.length > 0">
<div
v-for="slot in day.slots"
:key="slot.id"
class="time-slot"
:style="getSlotStyle(slot.type)"
>
<span class="slot-type">{{
getSlotTypeLabel(slot.type)
}}</span>
<span class="slot-time">
{{ slot.startTime }}-{{ slot.endTime }}
</span>
<span
v-if="slot.type === SlotType.Delivery && slot.capacity"
class="slot-cap"
>
{{ slot.capacity }}/h
</span>
</div>
</template>
<span v-else class="week-closed">休息</span>
</div>
<div class="week-actions">
<Button
type="text"
size="small"
@click="openDayDrawer(day.dayOfWeek)"
>
编辑
</Button>
</div>
</div>
</div>
</Card>
<Card :bordered="false">
<template #title>
<span class="section-title">节假日 / 特殊日期</span>
</template>
<template #extra>
<Button
type="primary"
:disabled="!selectedStoreId"
@click="openHolidayDrawer('create')"
>
添加日期
</Button>
</template>
<div class="holiday-table-wrap">
<table class="holiday-table">
<thead>
<tr>
<th>日期</th>
<th>类型</th>
<th>时间</th>
<th>原因</th>
<th class="op-column">操作</th>
</tr>
</thead>
<tbody>
<tr v-if="holidayRows.length === 0">
<td colspan="5">
<Empty description="暂无节假日配置" />
</td>
</tr>
<tr v-for="holiday in holidayRows" v-else :key="holiday.id">
<td>{{ formatHolidayDate(holiday) }}</td>
<td>
<span
class="holiday-tag"
:style="getHolidayTagStyle(holiday.type)"
>
{{ getHolidayTypeText(holiday.type) }}
</span>
</td>
<td>{{ formatHolidayTime(holiday) }}</td>
<td>{{ holiday.reason || '--' }}</td>
<td>
<Button
type="link"
size="small"
@click="openHolidayDrawer('edit', holiday)"
>
编辑
</Button>
<Popconfirm
title="确认删除该日期配置吗?"
ok-text="确认"
cancel-text="取消"
@confirm="handleDeleteHoliday(holiday.id)"
>
<Button
type="link"
size="small"
danger
:loading="deletingHolidayId === holiday.id"
>
删除
</Button>
</Popconfirm>
</td>
</tr>
</tbody>
</table>
</div>
</Card>
</Spin>
</template>
<AddSlotDrawer
v-model:open="isAddDrawerOpen"
:add-slot-form="addSlotForm"
:day-names="DAY_NAMES"
:get-slot-type-pill-class="getSlotTypePillClass"
:is-add-day-selected="isAddDaySelected"
:is-weekly-submitting="isWeeklySubmitting"
:on-set-capacity="setAddSlotCapacity"
:on-set-end-time="setAddSlotEndTime"
:on-set-remark="setAddSlotRemark"
:on-set-start-time="setAddSlotStartTime"
:on-set-type="setAddSlotType"
:slot-type-delivery="SlotType.Delivery"
:slot-type-options="SLOT_TYPE_OPTIONS"
@quick-select="quickSelectAddDays"
@submit="handleAddSlotSubmit"
@toggle-day="toggleAddDay"
/>
<DayEditDrawer
v-model:open="isDayDrawerOpen"
:day-edit-form="dayEditForm"
:is-weekly-submitting="isWeeklySubmitting"
:on-set-day-open="setDayEditOpen"
:on-set-slot-capacity="setDaySlotCapacity"
:on-set-slot-end-time="setDaySlotEndTime"
:on-set-slot-start-time="setDaySlotStartTime"
:on-set-slot-type="setDaySlotType"
:slot-type-delivery="SlotType.Delivery"
:slot-type-options="SLOT_TYPE_OPTIONS"
:title="`编辑时段 - ${currentDayMeta.label}`"
@add-slot-row="addDaySlotRow"
@remove-slot="removeDaySlot"
@save="handleSaveDay"
/>
<HolidayDrawer
v-model:open="isHolidayDrawerOpen"
:holiday-form="holidayForm"
:holiday-type-closed="HolidayType.Closed"
:holiday-type-special="HolidayType.Special"
:is-holiday-submitting="isHolidaySubmitting"
:on-set-date-mode="setHolidayDateMode"
:on-set-end-time="setHolidayEndTime"
:on-set-range-end="setHolidayRangeEnd"
:on-set-range-start="setHolidayRangeStart"
:on-set-reason="setHolidayReason"
:on-set-remark="setHolidayRemark"
:on-set-single-date="setHolidaySingleDate"
:on-set-start-time="setHolidayStartTime"
:on-set-type="setHolidayType"
:submit-text="holidaySubmitText"
:title="holidayDrawerTitle"
@submit="handleHolidaySubmit"
/>
<CopyStoreModal
v-model:open="isCopyModalOpen"
:copy-candidates="copyCandidates"
:copy-target-store-ids="copyTargetStoreIds"
:is-copy-all-checked="isCopyAllChecked"
:is-copy-indeterminate="isCopyIndeterminate"
:is-copy-submitting="isCopySubmitting"
:selected-store-name="selectedStoreName"
@check-all="handleCopyCheckAll"
@store-change="
({ storeId, checked }) => handleCopyStoreChange(storeId, checked)
"
@submit="handleCopySubmit"
@toggle-store="
({ storeId, checked }) => toggleCopyStore(storeId, checked)
"
/>
</Page>
</template>
<style src="./styles/index.less"></style>

View File

@@ -0,0 +1,87 @@
/* 页面基础骨架与通用表单样式。 */
.page-hours {
max-width: 980px;
.hours-toolbar-card .ant-card-body {
padding: 14px 16px;
}
.hours-toolbar {
display: flex;
gap: 12px;
align-items: center;
}
.store-selector {
width: 280px;
}
.toolbar-spacer {
flex: 1;
}
.section-title {
font-size: 14px;
font-weight: 600;
color: #1a1a2e;
}
.legend-row {
display: flex;
gap: 16px;
margin-bottom: 12px;
font-size: 12px;
color: #9ca3af;
}
.legend-item {
display: flex;
gap: 4px;
align-items: center;
}
.legend-dot {
width: 10px;
height: 10px;
border-radius: 2px;
}
.form-block {
margin-bottom: 14px;
}
.form-label {
display: block;
margin-bottom: 8px;
font-size: 13px;
font-weight: 500;
color: #1f2937;
}
.form-label.required::before {
margin-right: 4px;
color: #ef4444;
content: '*';
}
.form-sub-label {
display: block;
margin-bottom: 6px;
font-size: 11px;
color: #9ca3af;
}
.native-input {
box-sizing: border-box;
width: 100%;
height: 34px;
padding: 0 10px;
border: 1px solid #d9d9d9;
border-radius: 8px;
}
.native-input:focus {
outline: none;
border-color: #1677ff;
}
}

View File

@@ -0,0 +1,58 @@
/* 复制到门店弹窗区域样式。 */
.page-hours {
.copy-modal-content {
display: flex;
flex-direction: column;
gap: 12px;
}
.copy-all-row {
padding-bottom: 8px;
border-bottom: 1px solid #f0f0f0;
}
.copy-store-list {
max-height: 320px;
overflow-y: auto;
border: 1px solid #f0f0f0;
border-radius: 8px;
}
.copy-store-item {
display: flex;
gap: 10px;
align-items: flex-start;
padding: 10px 12px;
cursor: pointer;
}
.copy-store-item + .copy-store-item {
border-top: 1px solid #f5f5f5;
}
.copy-store-item:hover {
background: #fafcff;
}
.copy-store-info {
flex: 1;
min-width: 0;
}
.copy-store-name {
font-size: 13px;
font-weight: 500;
color: #1a1a2e;
}
.copy-store-address {
margin-top: 2px;
font-size: 12px;
color: #9ca3af;
}
.copy-source-tip {
font-size: 12px;
color: #6b7280;
}
}

View File

@@ -0,0 +1,203 @@
/* 抽屉与编辑表单样式(新增时段、编辑时段、节假日抽屉)。 */
.page-hours {
.type-pill-group {
display: flex;
gap: 8px;
}
.type-pill {
padding: 6px 16px;
font-size: 13px;
color: #4b5563;
cursor: pointer;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 8px;
}
.type-pill.active.tp-biz {
font-weight: 600;
color: #389e0d;
background: #f6ffed;
border-color: #b7eb8f;
}
.type-pill.active.tp-del {
font-weight: 600;
color: #1890ff;
background: #e6f7ff;
border-color: #91d5ff;
}
.type-pill.active.tp-pick {
font-weight: 600;
color: #d46b08;
background: #fff7e6;
border-color: #ffd591;
}
.type-pill.active.ht-closed {
font-weight: 600;
color: #ef4444;
background: #fff2f0;
border-color: #ffa39e;
}
.type-pill.active.ht-special {
font-weight: 600;
color: #f59e0b;
background: #fff7e6;
border-color: #ffd591;
}
.day-pill-group {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.day-pill {
display: flex;
align-items: center;
justify-content: center;
width: 42px;
height: 34px;
font-size: 12px;
color: #4b5563;
cursor: pointer;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 8px;
}
.day-pill.selected {
color: #fff;
background: #1677ff;
border-color: #1677ff;
}
.quick-actions {
display: flex;
gap: 10px;
margin-top: 8px;
}
.quick-btn {
padding: 0;
font-size: 12px;
color: #1677ff;
cursor: pointer;
background: transparent;
border: none;
}
.quick-btn:hover {
text-decoration: underline;
}
.time-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
column-gap: 12px;
}
.capacity-row {
display: flex;
gap: 8px;
align-items: center;
}
.capacity-input {
max-width: 150px;
}
.drawer-footer {
display: flex;
gap: 8px;
justify-content: flex-end;
}
.day-open-row {
display: flex;
gap: 10px;
align-items: center;
margin-bottom: 16px;
}
.day-close-hint {
font-size: 12px;
color: #ef4444;
}
.slot-edit-list.disabled {
pointer-events: none;
opacity: 0.5;
}
.slot-edit-card {
padding: 12px 14px;
margin-bottom: 10px;
background: #f8f9fb;
border: 1px solid #e5e7eb;
border-radius: 10px;
}
.slot-edit-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.slot-type-select {
width: 140px;
}
.add-dashed-btn {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
min-height: 40px;
font-size: 13px;
color: #6b7280;
cursor: pointer;
background: transparent;
border: 1px dashed #d1d5db;
border-radius: 10px;
}
.add-dashed-btn:hover {
color: #1677ff;
border-color: #1677ff;
}
.date-mode-row {
display: flex;
gap: 8px;
margin-bottom: 10px;
}
.date-mode-pill {
padding: 4px 12px;
font-size: 12px;
color: #4b5563;
cursor: pointer;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 8px;
}
.date-mode-pill.active {
color: #fff;
background: #1677ff;
border-color: #1677ff;
}
.date-range-row {
display: grid;
grid-template-columns: 1fr auto 1fr;
gap: 8px;
align-items: center;
}
}

View File

@@ -0,0 +1,44 @@
/* 节假日/特殊日期表格区域样式。 */
.page-hours {
.holiday-table-wrap {
overflow-x: auto;
}
.holiday-table {
width: 100%;
font-size: 13px;
border-collapse: collapse;
}
.holiday-table th {
padding: 8px 12px;
font-size: 12px;
font-weight: 600;
color: #6b7280;
text-align: left;
background: #f8f9fb;
border-bottom: 1px solid #e5e7eb;
}
.holiday-table td {
padding: 10px 12px;
color: #1a1a2e;
border-bottom: 1px solid #f3f4f6;
}
.holiday-table tr:hover td {
background: #fafcff;
}
.holiday-table .op-column {
width: 120px;
}
.holiday-tag {
display: inline-block;
padding: 2px 8px;
font-size: 11px;
font-weight: 600;
border-radius: 6px;
}
}

View File

@@ -0,0 +1,7 @@
/* 营业时间页面样式聚合入口(仅负责分片导入)。 */
@import './base.less';
@import './week.less';
@import './holiday.less';
@import './drawer.less';
@import './copy-modal.less';
@import './responsive.less';

View File

@@ -0,0 +1,22 @@
/* 营业时间页面响应式规则。 */
.page-hours {
@media (max-width: 768px) {
.store-selector {
width: 100%;
}
.hours-toolbar {
flex-direction: column;
align-items: stretch;
}
.toolbar-spacer {
display: none;
}
.week-row {
grid-template-columns: 1fr;
row-gap: 8px;
}
}
}

View File

@@ -0,0 +1,71 @@
/* 每周营业时间区域样式。 */
.page-hours {
.week-grid {
display: flex;
flex-direction: column;
}
.week-row {
display: grid;
grid-template-columns: 84px 1fr auto;
gap: 12px;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid #f3f4f6;
}
.week-row:hover {
background: #fafcff;
}
.week-row:last-child {
border-bottom: none;
}
.week-day {
font-size: 13px;
font-weight: 500;
color: #1a1a2e;
}
.week-day-en {
font-size: 11px;
font-weight: 400;
color: #9ca3af;
}
.week-slots {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.time-slot {
display: inline-flex;
gap: 6px;
align-items: center;
padding: 4px 10px;
font-size: 12px;
border: 1px solid;
border-radius: 6px;
}
.slot-type {
font-weight: 600;
}
.slot-time {
opacity: 0.88;
}
.slot-cap {
font-size: 10px;
opacity: 0.75;
}
.week-closed {
font-size: 12px;
font-style: italic;
color: #9ca3af;
}
}

View File

@@ -0,0 +1,52 @@
import type { HolidayType, SlotType } from '#/api/store-hours';
export interface DayName {
label: string;
labelEn: string;
}
export interface SlotTypeOption {
label: string;
value: SlotType;
}
export interface AddSlotFormState {
type: SlotType;
selectedDays: number[];
startTime: string;
endTime: string;
capacity: number;
remark: string;
}
export interface EditSlotItem {
id: string;
type: SlotType;
startTime: string;
endTime: string;
capacity?: number;
remark?: string;
}
export interface DayEditFormState {
dayOfWeek: number;
isOpen: boolean;
slots: EditSlotItem[];
}
export type HolidayDateMode = 'range' | 'single';
export interface HolidayFormState {
id: string;
type: HolidayType;
dateMode: HolidayDateMode;
singleDate: string;
rangeStart: string;
rangeEnd: string;
startTime: string;
endTime: string;
reason: string;
remark: string;
}
export type AddDaysMode = 'all' | 'weekday' | 'weekend';

View File

@@ -2,4 +2,8 @@
import { defineConfig } from '@vben/eslint-config';
export default defineConfig();
export default defineConfig([
{
ignores: ['backup/**'],
},
]);