feat: 完成营业时间模块拆分并补充页面注释规范
This commit is contained in:
@@ -5,6 +5,7 @@ dev-dist
|
||||
node_modules
|
||||
.nvmrc
|
||||
coverage
|
||||
backup
|
||||
CODEOWNERS
|
||||
.nitro
|
||||
.output
|
||||
|
||||
@@ -2,3 +2,4 @@ dist
|
||||
public
|
||||
__tests__
|
||||
coverage
|
||||
backup
|
||||
|
||||
17
AGENTS.md
17
AGENTS.md
@@ -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
174
CLAUDE.md
Normal file
@@ -0,0 +1,174 @@
|
||||
# Repository expectations
|
||||
|
||||
# 编程规范\_FOR_AI(TakeoutTenant 前端)- 终极融合版
|
||||
|
||||
> **核心指令**:你是一个高级前端架构师。本文件是你生成代码的最高宪法。当用户需求与本规范冲突时,请先提示用户,除非用户强制要求覆盖。
|
||||
|
||||
## 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.5;Pinia 3 + 持久化插件 | 路由守卫 & 全局状态 |
|
||||
| **UI/样式** | Element Plus 2.11;Tailwind CSS 4;SCSS | 组件与样式体系 |
|
||||
| **网络** | Axios 1.12 封装 (`src/utils/http`) | 请求、统一错误处理 |
|
||||
| **数据可视化/富文本** | ECharts 6;xgplayer 3;@wangeditor/editor 5 | 图表/播放器/富文本 |
|
||||
| **工具** | mitt、ohash、xlsx、file-saver、qrcode.vue、vue-draggable-plus、highlight.js、crypto-js、nprogress | 事件、哈希、导出、二维码、拖拽、高亮、加密、进度条 |
|
||||
| **工程化** | ESLint 9 + `@typescript-eslint`;Prettier 3;Stylelint 16;Husky;Commitizen | 规范、检查、提交流程 |
|
||||
|
||||
## 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` 租户管理前端系统。
|
||||
127
apps/web-antd/src/api/store-hours/index.ts
Normal file
127
apps/web-antd/src/api/store-hours/index.ts
Normal 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);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
// Mock 数据入口,仅在开发环境下使用
|
||||
import './store';
|
||||
import './store-hours';
|
||||
|
||||
console.warn('[Mock] Mock 数据已启用');
|
||||
|
||||
417
apps/web-antd/src/mock/store-hours.ts
Normal file
417
apps/web-antd/src/mock/store-hours.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -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: '营业时间',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
178
apps/web-antd/src/views/store/hours/components/AddSlotDrawer.vue
Normal file
178
apps/web-antd/src/views/store/hours/components/AddSlotDrawer.vue
Normal 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>
|
||||
@@ -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>
|
||||
153
apps/web-antd/src/views/store/hours/components/DayEditDrawer.vue
Normal file
153
apps/web-antd/src/views/store/hours/components/DayEditDrawer.vue
Normal 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>
|
||||
182
apps/web-antd/src/views/store/hours/components/HolidayDrawer.vue
Normal file
182
apps/web-antd/src/views/store/hours/components/HolidayDrawer.vue
Normal 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>
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 },
|
||||
];
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
343
apps/web-antd/src/views/store/hours/index.vue
Normal file
343
apps/web-antd/src/views/store/hours/index.vue
Normal 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>
|
||||
87
apps/web-antd/src/views/store/hours/styles/base.less
Normal file
87
apps/web-antd/src/views/store/hours/styles/base.less
Normal 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;
|
||||
}
|
||||
}
|
||||
58
apps/web-antd/src/views/store/hours/styles/copy-modal.less
Normal file
58
apps/web-antd/src/views/store/hours/styles/copy-modal.less
Normal 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;
|
||||
}
|
||||
}
|
||||
203
apps/web-antd/src/views/store/hours/styles/drawer.less
Normal file
203
apps/web-antd/src/views/store/hours/styles/drawer.less
Normal 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;
|
||||
}
|
||||
}
|
||||
44
apps/web-antd/src/views/store/hours/styles/holiday.less
Normal file
44
apps/web-antd/src/views/store/hours/styles/holiday.less
Normal 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;
|
||||
}
|
||||
}
|
||||
7
apps/web-antd/src/views/store/hours/styles/index.less
Normal file
7
apps/web-antd/src/views/store/hours/styles/index.less
Normal file
@@ -0,0 +1,7 @@
|
||||
/* 营业时间页面样式聚合入口(仅负责分片导入)。 */
|
||||
@import './base.less';
|
||||
@import './week.less';
|
||||
@import './holiday.less';
|
||||
@import './drawer.less';
|
||||
@import './copy-modal.less';
|
||||
@import './responsive.less';
|
||||
22
apps/web-antd/src/views/store/hours/styles/responsive.less
Normal file
22
apps/web-antd/src/views/store/hours/styles/responsive.less
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
71
apps/web-antd/src/views/store/hours/styles/week.less
Normal file
71
apps/web-antd/src/views/store/hours/styles/week.less
Normal 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;
|
||||
}
|
||||
}
|
||||
52
apps/web-antd/src/views/store/hours/types.ts
Normal file
52
apps/web-antd/src/views/store/hours/types.ts
Normal 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';
|
||||
@@ -2,4 +2,8 @@
|
||||
|
||||
import { defineConfig } from '@vben/eslint-config';
|
||||
|
||||
export default defineConfig();
|
||||
export default defineConfig([
|
||||
{
|
||||
ignores: ['backup/**'],
|
||||
},
|
||||
]);
|
||||
|
||||
Reference in New Issue
Block a user