feat: 完成营业时间模块拆分并补充页面注释规范
This commit is contained in:
@@ -5,6 +5,7 @@ dev-dist
|
|||||||
node_modules
|
node_modules
|
||||||
.nvmrc
|
.nvmrc
|
||||||
coverage
|
coverage
|
||||||
|
backup
|
||||||
CODEOWNERS
|
CODEOWNERS
|
||||||
.nitro
|
.nitro
|
||||||
.output
|
.output
|
||||||
|
|||||||
@@ -2,3 +2,4 @@ dist
|
|||||||
public
|
public
|
||||||
__tests__
|
__tests__
|
||||||
coverage
|
coverage
|
||||||
|
backup
|
||||||
|
|||||||
17
AGENTS.md
17
AGENTS.md
@@ -15,6 +15,7 @@
|
|||||||
5. **推送优先级**:提交后推送时,优先使用 SSH 远程(`git@github.com:...`),避免 HTTPS TLS 问题。
|
5. **推送优先级**:提交后推送时,优先使用 SSH 远程(`git@github.com:...`),避免 HTTPS TLS 问题。
|
||||||
6. **推送默认**:提交后自动执行 `git push`,无需再次提醒或确认。
|
6. **推送默认**:提交后自动执行 `git push`,无需再次提醒或确认。
|
||||||
7. **不确定配置**:拿不准(如接口字段、鉴权流程)直接问用户。
|
7. **不确定配置**:拿不准(如接口字段、鉴权流程)直接问用户。
|
||||||
|
8. **需求开发前置步骤(强制)**:每次开始“新增/修改需求”前,必须先从 `https://api-tenant-dev.laosankeji.com/swagger/v1/swagger.json` 拉取最新 Swagger,定位本次需求涉及的接口契约(路径、入参、出参、字段)后,再进行代码变更。
|
||||||
|
|
||||||
## 1. 技术栈详细版本
|
## 1. 技术栈详细版本
|
||||||
|
|
||||||
@@ -56,6 +57,9 @@
|
|||||||
- **常量/枚举**:`PascalCase` 或 `UPPER_SNAKE_CASE`。
|
- **常量/枚举**:`PascalCase` 或 `UPPER_SNAKE_CASE`。
|
||||||
- **路径别名**:**严禁**使用 `../../` 穿越多层。必须使用 `#/*`、`@vben/*`、`@vben-core/*` 等别名。
|
- **路径别名**:**严禁**使用 `../../` 穿越多层。必须使用 `#/*`、`@vben/*`、`@vben-core/*` 等别名。
|
||||||
- **逻辑注释 (强制)**:代码逻辑块必须空行分隔,并加序号注释(1. 验证... 2. 请求...)。
|
- **逻辑注释 (强制)**:代码逻辑块必须空行分隔,并加序号注释(1. 验证... 2. 请求...)。
|
||||||
|
- **文件头注释 (强制)**:所有新增 `*.ts`/`*.vue`/`*.less`/`*.scss` 文件,必须包含“文件职责”注释,说明该文件负责的业务边界。
|
||||||
|
- **关键函数注释 (强制)**:对外导出的函数、含分支校验/提交流程的核心函数,必须添加简短中文注释,至少说明输入意图与副作用(如会触发请求/刷新/消息提示)。
|
||||||
|
- **重构注释保留 (强制)**:拆分文件时,必须把原有关键流程注释同步迁移,禁止出现“重构后逻辑完整但无注释”的情况。
|
||||||
- **组件通信**:优先 `props/emit`,跨层用 `mitt` 或 store,**慎用** `provide/inject`。
|
- **组件通信**:优先 `props/emit`,跨层用 `mitt` 或 store,**慎用** `provide/inject`。
|
||||||
|
|
||||||
## 4. 接口与 HTTP 规范 (含 .NET 兼容)
|
## 4. 接口与 HTTP 规范 (含 .NET 兼容)
|
||||||
@@ -77,6 +81,19 @@
|
|||||||
- **Vue 3.5 新特性**:使用 Props 解构 (`const { count = 0 } = defineProps<{...}>`)。
|
- **Vue 3.5 新特性**:使用 Props 解构 (`const { count = 0 } = defineProps<{...}>`)。
|
||||||
- **表单交互**:使用 Element Plus 表单校验;避免直接操作 DOM。
|
- **表单交互**:使用 Element Plus 表单校验;避免直接操作 DOM。
|
||||||
- **Loading**:所有修改类操作必须绑定 `loading` 状态。
|
- **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)
|
## 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 数据入口,仅在开发环境下使用
|
// Mock 数据入口,仅在开发环境下使用
|
||||||
import './store';
|
import './store';
|
||||||
|
import './store-hours';
|
||||||
|
|
||||||
console.warn('[Mock] Mock 数据已启用');
|
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: '门店列表',
|
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';
|
import { defineConfig } from '@vben/eslint-config';
|
||||||
|
|
||||||
export default defineConfig();
|
export default defineConfig([
|
||||||
|
{
|
||||||
|
ignores: ['backup/**'],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|||||||
Reference in New Issue
Block a user