From ab73565d37447b87927d8a00b2afdc29c29fad7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B9=9B=E5=85=AE?= Date: Tue, 2 Jun 2026 14:23:31 +0800 Subject: [PATCH] feat: add employee workspace operations admin --- README.md | 38 +- docs/C_APP_ADMIN_REQUIREMENTS.md | 155 ++++++ src/api/access.ts | 730 +++++++++++++++++++++++++- src/layout/types.ts | 32 ++ src/router/modules/employees.ts | 167 ++++-- src/views/announcements/index.vue | 680 ++++++++++++++++++++++++ src/views/credential-audits/index.vue | 381 ++++++++++++++ src/views/employees/index.vue | 182 ++++--- src/views/shifts/index.vue | 603 +++++++++++++++++++++ src/views/tasks/index.vue | 702 +++++++++++++++++++++++++ 10 files changed, 3523 insertions(+), 147 deletions(-) create mode 100644 docs/C_APP_ADMIN_REQUIREMENTS.md create mode 100644 src/views/announcements/index.vue create mode 100644 src/views/credential-audits/index.vue create mode 100644 src/views/shifts/index.vue create mode 100644 src/views/tasks/index.vue diff --git a/README.md b/README.md index 5634f8f..5ba6cfb 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,8 @@ http://localhost:8848/ ├── .husky/ # Git hooks,提交前执行 lint-staged,提交信息走 commitlint ├── .vscode/ # VS Code 推荐配置与 Vue 代码片段 ├── build/ # Vite 插件、CDN、压缩、构建信息与工具函数 +├── docs/ # 项目需求和后台扩展说明 +│ └── C_APP_ADMIN_REQUIREMENTS.md # C 端正式版管理后台需求文档 ├── public/ # 静态资源与运行时平台配置 ├── src/ # 前端源码 │ ├── api/ # HTTP API 封装,业务接口集中在 access.ts @@ -64,7 +66,7 @@ http://localhost:8848/ │ ├── store/ # Pinia store,含用户、权限、标签页、主题等模块 │ ├── style/ # 全局样式、主题、暗色模式、侧边栏和过渡样式 │ ├── utils/ # 鉴权、HTTP、缓存、消息、进度条、树处理等工具 -│ └── views/ # 页面视图,当前业务页在 employees、roles、stores、permissions +│ └── views/ # 页面视图,当前业务页在 employees、roles、stores、permissions 和员工工作台运营模块 ├── types/ # 全局类型声明、组件声明、路由声明和 Vue shim ├── AGENTS.md # Codex/Agent 入口规则,转到 RTK.md ├── RTK.md # 本仓库协作规则与 README 同步要求 @@ -100,12 +102,17 @@ http://localhost:8848/ - `src/views/stores/index.vue`: 门店管理,筛选、重置、启停、删除后都会重新调用接口,支持新增、编辑、详情员工列表和移除员工。 - `src/views/roles/index.vue`: 角色管理,搜索、重置、删除和保存后都会重新调用接口,支持角色编码校验。 -- `src/views/employees/index.vue`: 员工管理,门店/状态/关键词筛选、重置、分页、启停、旧密码校验后改密、初始密码重置、删除和保存后都会重新调用接口,并展示员工状态标签。 +- `src/views/employees/index.vue`: 员工管理,门店/状态/关键词筛选、重置、分页、启停、临时密码重置、删除和保存后都会重新调用接口,并展示员工状态标签;重置成功后只在弹窗内显示一次性临时密码。 - `src/views/permissions/index.vue`: 权限策略,支持查看角色权限、按角色勾选权限点并保存到后端。 -- `src/api/access.ts`: 门店、角色、员工、权限策略和角色权限分配接口类型与 HTTP 方法封装。 +- `src/views/announcements/index.vue`: 公告管理,支持状态/级别/关键词筛选、分页、新建、编辑、保存草稿、发布和归档。 +- `src/views/tasks/index.vue`: 任务管理,支持门店/员工/状态/优先级/关键词筛选、分页、新建、编辑、取消任务和流转记录展示。 +- `src/views/shifts/index.vue`: 排班管理,支持门店/员工/日期范围筛选、分页、新增、编辑和取消排班,选择门店后刷新员工选项。 +- `src/views/credential-audits/index.vue`: 凭据审计,展示操作者、目标员工、动作、时间、IP、User-Agent 和原因,支持操作者/目标员工/门店/时间筛选。 +- `src/api/access.ts`: 门店、角色、员工、权限策略、员工工作台运营和角色权限分配接口类型与 HTTP 方法封装,并集中完成运营模块正式后端字段适配。 - `src/api/user.ts`: 登录、当前用户和当前权限菜单接口封装。 -- `src/router/modules/employees.ts`: 权限管理菜单入口,挂载门店、角色、员工、权限策略四个页面。 +- `src/router/modules/employees.ts`: 权限管理和员工工作台运营菜单入口,挂载门店、角色、员工、权限策略、公告、任务、排班和凭据审计页面。 - `src/store/modules/user.ts`: 保存 JWT 登录态、当前用户、权限码和后端菜单动作权限。 +- `docs/C_APP_ADMIN_REQUIREMENTS.md`: C 端正式版管理后台需求文档,定义公告、任务、排班、密码重置和凭据审计等新增后台能力。 ## 后端对接 @@ -137,10 +144,24 @@ http://localhost:8848/ - `PATCH /api/employees/:id` - `PATCH /api/employees/:id/status` - `PATCH /api/employees/:id/password` -- `PATCH /api/employees/:id/password/reset` +- `POST /api/admin/employees/:id/password/reset`,需要 `credential:reset`,请求体携带重置原因,响应只返回一次性临时密码 - `DELETE /api/employees/:id` +- `GET /api/admin/announcements`,公告管理列表会携带 `page`、`pageSize`,筛选时会把页面 `importance` 转为后端 `level`,并携带 `status`、`keyword` +- `POST /api/admin/announcements`,页面 `importance`、`targetScope` 和目标 ID 数组会转为后端 `level`、`targetType`、`targets` +- `PATCH /api/admin/announcements/:id`,使用同一套公告字段转换 +- `POST /api/admin/announcements/:id/publish` +- `POST /api/admin/announcements/:id/archive` +- `GET /api/admin/tasks`,任务管理列表会携带 `page`、`pageSize`,筛选时会携带 `storeId`、`employeeId`、`status`、`priority`、`keyword`,并统一使用后端 `CANCELLED` +- `POST /api/admin/tasks`,页面 `deadlineAt` 会转为后端 `dueAt`,后端 `assignees` 会回填为页面 `assigneeIds`、`assigneeNames` +- `PATCH /api/admin/tasks/:id`,使用同一套任务字段转换 +- `POST /api/admin/tasks/:id/cancel` +- `GET /api/admin/shifts`,排班管理列表会携带 `page`、`pageSize`,筛选时会携带 `storeId`、`employeeId`、`startDate`、`endDate` +- `POST /api/admin/shifts`,页面 `position` 会转为后端 `roleName` +- `PATCH /api/admin/shifts/:id`,使用同一套排班字段转换 +- `DELETE /api/admin/shifts/:id` +- `GET /api/admin/credential-audits`,凭据审计列表会携带 `page`、`pageSize`,筛选时会携带 `operatorId`、`targetEmployeeId`、`storeId`、`startDate`、`endDate`,并把后端 `actorName`、`createdAt` 转为页面 `operatorName`、`operatedAt` -接口响应统一在 `src/api/access.ts` 中使用 `ApiResult` 或 `PaginatedData` 描述,页面层只消费 `result.data`,避免在视图里重复拼接接口路径。门店、角色、员工列表的搜索、重置、分页和状态变更后的刷新都应通过接口层完成,不直接依赖页面内存里的旧列表。员工对象会消费后端返回的 `statusTags`;所属门店停用时展示“门店被禁用”标签。 +接口响应统一在 `src/api/access.ts` 中使用 `ApiResult` 或 `PaginatedData` 描述,页面层只消费 `result.data`,避免在视图里重复拼接接口路径。门店、角色、员工、公告、任务、排班和凭据审计列表的搜索、重置、分页和状态变更后的刷新都应通过接口层完成,不直接依赖页面内存里的旧列表。公告、任务、排班和凭据审计的后端正式字段统一在接口层转换,页面继续使用 `importance`、`targetScope`、`deadlineAt`、`assigneeIds`、`assigneeNames`、`position`、`operatorName`、`operatedAt` 等展示和表单字段。员工对象会消费后端返回的 `statusTags`;所属门店停用时展示“门店被禁用”标签。 ## 登录与鉴权流程 @@ -148,8 +169,9 @@ http://localhost:8848/ 2. 前端保存后端返回的 `data.token`,并按 `expiresIn` 换算本地过期时间。 3. 登录成功后立即调用 `GET /api/auth/me` 校验当前账号,再调用 `GET /api/permissions/me` 获取权限码、菜单和动作权限。 4. 路由守卫会在刷新页面后重新拉取当前用户和权限,未登录或 token 过期会跳回 `/login`。 -5. 菜单按路由 `meta.menuKey`、`meta.permission` 与 `/api/permissions/me` 返回的后端菜单过滤;按钮按后端菜单 `actions` 控制新增、编辑、启停和删除显隐。 -6. 后端当前没有 refresh-token 接口,收到 `401` 或本地 token 过期时直接清理登录态并要求重新登录。 +5. 菜单按路由 `meta.menuKey`、`meta.permission` 与 `/api/permissions/me` 返回的后端菜单过滤;按钮按后端菜单 `actions` 和对应权限码控制新增、编辑、启停、删除、运营管理和凭据重置显隐。 +6. 凭据安全不提供查看当前明文密码入口;后台只调用 `POST /api/admin/employees/:id/password/reset` 生成一次性临时密码,前端不写入本地存储、日志或 URL,弹窗关闭后立即清空。 +7. 后端当前没有 refresh-token 接口,收到 `401` 或本地 token 过期时直接清理登录态并要求重新登录。 ## 配置说明 diff --git a/docs/C_APP_ADMIN_REQUIREMENTS.md b/docs/C_APP_ADMIN_REQUIREMENTS.md new file mode 100644 index 0000000..38b2238 --- /dev/null +++ b/docs/C_APP_ADMIN_REQUIREMENTS.md @@ -0,0 +1,155 @@ +# role-admin C 端正式版管理后台需求文档 + +## 1. 后台定位 + +`role-admin` 继续作为管理后台,不承载员工日常移动端工作。正式版需要在现有门店、角色、员工、权限策略基础上,增加 C 端运营能力: + +- 公告管理。 +- 任务管理。 +- 排班管理。 +- 员工密码重置和凭据审计。 + +## 2. 现有能力保留 + +继续保留当前页面: + +- 门店管理。 +- 角色管理。 +- 员工管理。 +- 权限策略。 + +登录与鉴权仍按现有流程: + +1. `POST /api/auth/admin/login` +2. `GET /api/auth/me` +3. `GET /api/permissions/me` + +菜单和按钮继续以后端返回的 `menus`、`permissions`、`actions` 为准。 + +## 3. 新增菜单 + +在现有“权限管理”业务组之外,新增“员工工作台运营”业务组。 + +| 路由 | 菜单 | 权限 | 说明 | +| --- | --- | --- | --- | +| `/announcements` | 公告管理 | `announcement:view` | 管理 C 端公告 | +| `/tasks` | 任务管理 | `task:view` | 管理门店和员工任务 | +| `/shifts` | 排班管理 | `shift:view` | 管理员工排班 | +| `/credential-audits` | 凭据审计 | `credential:audit:view` | 查看密码重置审计 | + +## 4. 公告管理 + +列表: + +- 标题、重要级别、目标范围、状态、发布人、发布时间、已读人数。 +- 支持状态、重要级别、关键词筛选。 + +表单: + +- 标题、内容、重要级别。 +- 目标范围:全部门店、指定门店、指定角色、指定员工。 +- 保存草稿、发布、归档。 + +权限: + +- `announcement:view`: 查看列表和详情。 +- `announcement:manage`: 新建、编辑、发布、归档。 + +## 5. 任务管理 + +列表: + +- 标题、目标门店、分配员工、状态、优先级、截止时间、创建人。 +- 支持门店、员工、状态、优先级、关键词筛选。 + +表单: + +- 标题、描述、目标门店、分配员工、优先级、截止时间。 +- 支持取消未完成任务。 + +详情: + +- 展示任务状态流转记录和员工备注。 + +权限: + +- `task:view`: 查看任务。 +- `task:manage`: 新建、编辑、取消任务。 + +## 6. 排班管理 + +列表: + +- 门店、员工、岗位、开始时间、结束时间、状态。 +- 支持门店、员工、日期范围筛选。 + +表单: + +- 选择门店后加载员工。 +- 选择员工、岗位、开始时间、结束时间。 +- 同一员工同一时间段不能重复排班。 + +权限: + +- `shift:view`: 查看排班。 +- `shift:manage`: 新增、编辑、取消排班。 + +## 7. 密码重置与凭据审计 + +用户提出“超级管理员与管理员需要支持查看自身和下级用户密码”。后台正式实现为“重置和一次性查看临时密码”,不做明文原密码查看。 + +### 7.1 员工管理页改造 + +在员工列表和员工详情增加: + +- “重置密码”按钮:需要 `credential:reset`。 +- 重置确认弹窗:必须填写原因。 +- 重置成功弹窗:显示一次性临时密码,并提供复制按钮。 +- 弹窗关闭后不再展示该临时密码。 + +范围规则: + +- 超级管理员可重置所有员工。 +- 管理员只能重置自己权限范围内的员工。 +- 店长如被授予 `credential:reset`,只能重置本店员工。 +- 不支持查看自身当前密码;自身密码走修改密码。 + +### 7.2 凭据审计页 + +展示: + +- 操作者。 +- 目标员工。 +- 动作类型。 +- 时间。 +- IP。 +- User-Agent。 +- 原因。 + +筛选: + +- 操作者、目标员工、时间范围、门店。 + +## 8. 前端实现要求 + +- 业务接口继续集中在 `src/api/access.ts` 或按模块拆分到 `src/api/*.ts`,页面不直接拼接 URL。 +- 路由、菜单和按钮显隐继续基于后端权限。 +- 新增页面应复用现有 Element Plus 和 pure-admin 页面风格。 +- 列表页都要支持分页、筛选、重置、保存后刷新。 +- 重置密码成功弹窗不能把临时密码写入本地存储、日志或 URL。 + +## 9. README 同步 + +新增页面、API 模块、路由模块或关键配置后,必须同步更新 `README.md` 的: + +- 业务模块。 +- 后端对接接口。 +- 登录与鉴权流程中涉及的新增凭据规则。 + +## 10. 验收标准 + +- 超级管理员可以看到新增菜单。 +- 没有权限的用户看不到对应菜单和按钮。 +- 员工密码重置只显示一次性临时密码。 +- 凭据审计能查到每次重置行为。 +- 不存在“查看当前明文密码”的入口。 diff --git a/src/api/access.ts b/src/api/access.ts index 4d82cf7..ba96664 100644 --- a/src/api/access.ts +++ b/src/api/access.ts @@ -7,6 +7,7 @@ import { http } from "@/utils/http"; * 响应结构和状态枚举。后端统一挂在 `/api` 前缀下,由 Vite 代理转发。 */ const API_PREFIX = "/api"; +const EMPLOYEE_OPTION_PAGE_SIZE = 100; /** 后端员工与门店共用的启停状态枚举。 */ export type EmployeeStatus = "ACTIVE" | "INACTIVE"; @@ -130,6 +131,10 @@ export interface EmployeeListParams extends PageParams { keyword?: string; } +export type EmployeeOptionParams = Partial< + Pick +>; + export interface StoreListParams extends PageParams { includeInactive?: boolean; status?: StoreStatus; @@ -168,6 +173,153 @@ export interface EmployeePasswordPayload { newPassword: string; } +export interface EmployeePasswordResetPayload { + reason: string; +} + +export interface EmployeePasswordResetResult { + employeeId: number; + temporaryPassword: string; +} + +export type AnnouncementImportance = "NORMAL" | "IMPORTANT" | "URGENT"; +export type AnnouncementStatus = "DRAFT" | "PUBLISHED" | "ARCHIVED"; +export type AnnouncementTargetScope = + | "ALL_STORES" + | "STORES" + | "ROLES" + | "EMPLOYEES"; + +export interface Announcement { + id: number; + title: string; + content: string; + importance: AnnouncementImportance; + targetScope: AnnouncementTargetScope; + targetStoreIds: number[]; + targetRoleIds: number[]; + targetEmployeeIds: number[]; + status: AnnouncementStatus; + publisherName: string | null; + publishedAt: string | null; + readCount: number; + createdAt: string; + updatedAt: string; +} + +export interface AnnouncementListParams extends PageParams { + status?: AnnouncementStatus; + importance?: AnnouncementImportance; + keyword?: string; +} + +export interface AnnouncementPayload { + title: string; + content: string; + importance: AnnouncementImportance; + targetScope: AnnouncementTargetScope; + targetStoreIds: number[]; + targetRoleIds: number[]; + targetEmployeeIds: number[]; +} + +export type TaskStatus = "PENDING" | "IN_PROGRESS" | "COMPLETED" | "CANCELLED"; +export type TaskPriority = "LOW" | "NORMAL" | "HIGH" | "URGENT"; + +export interface TaskEvent { + id: number; + action: string; + remark: string | null; + operatorName: string | null; + createdAt: string; +} + +export interface Task { + id: number; + title: string; + description: string | null; + storeId: number; + storeName: string; + assigneeIds: number[]; + assigneeNames: string[]; + status: TaskStatus; + priority: TaskPriority; + deadlineAt: string | null; + creatorName: string | null; + events?: TaskEvent[]; + createdAt: string; + updatedAt: string; +} + +export interface TaskListParams extends PageParams { + storeId?: number; + employeeId?: number; + status?: TaskStatus; + priority?: TaskPriority; + keyword?: string; +} + +export interface TaskPayload { + title: string; + description?: string | null; + storeId: number; + assigneeIds: number[]; + priority: TaskPriority; + deadlineAt?: string | null; +} + +export type ShiftStatus = "SCHEDULED" | "CANCELLED" | "COMPLETED"; + +export interface Shift { + id: number; + storeId: number; + storeName: string; + employeeId: number; + employeeName: string; + position: string; + startAt: string; + endAt: string; + status: ShiftStatus; + createdAt: string; + updatedAt: string; +} + +export interface ShiftListParams extends PageParams { + storeId?: number; + employeeId?: number; + startDate?: string; + endDate?: string; +} + +export interface ShiftPayload { + storeId: number; + employeeId: number; + position: string; + startAt: string; + endAt: string; +} + +export interface CredentialAudit { + id: number; + operatorName: string; + targetEmployeeName: string; + targetEmployeePhone: string | null; + storeName: string | null; + action: string; + operatedAt: string; + ip: string | null; + userAgent: string | null; + reason: string; +} + +export interface CredentialAuditListParams extends PageParams { + operatorId?: number; + targetEmployeeId?: number; + storeId?: number; + startDate?: string; + endDate?: string; +} + /** access-manage 后端普通响应包裹结构。 */ export interface ApiResult { success: boolean; @@ -186,6 +338,407 @@ export interface PaginatedData { pagination: Pagination; } +type LegacyAnnouncementImportance = "LOW" | "HIGH"; +type BackendAnnouncementTargetType = "ALL" | "STORE" | "ROLE" | "EMPLOYEE"; +type BackendConcreteAnnouncementTargetType = Exclude< + BackendAnnouncementTargetType, + "ALL" +>; + +interface BackendAnnouncementTarget { + type: BackendConcreteAnnouncementTargetType; + id: number; +} + +interface BackendAnnouncement { + id: number; + title: string; + content: string; + level?: AnnouncementImportance | LegacyAnnouncementImportance; + importance?: AnnouncementImportance | LegacyAnnouncementImportance; + status: AnnouncementStatus; + targetType?: BackendAnnouncementTargetType; + targetScope?: AnnouncementTargetScope; + targets?: BackendAnnouncementTarget[]; + targetStoreIds?: number[]; + targetRoleIds?: number[]; + targetEmployeeIds?: number[]; + publisherName?: string | null; + publishedAt: string | null; + readCount?: number; + createdAt: string; + updatedAt: string; +} + +type BackendAnnouncementPayload = Omit< + AnnouncementPayload, + | "importance" + | "targetScope" + | "targetStoreIds" + | "targetRoleIds" + | "targetEmployeeIds" +> & { + level: AnnouncementImportance; + targetType: BackendAnnouncementTargetType; + targets: BackendAnnouncementTarget[]; +}; + +type BackendTaskStatus = TaskStatus | "CANCELED"; + +interface BackendTaskAssignee { + id: number; + name: string; + phone?: string | null; +} + +interface BackendTaskEvent { + id: number; + action?: string; + eventType?: string; + remark?: string | null; + comment?: string | null; + operatorName?: string | null; + createdAt: string; +} + +interface BackendTask { + id: number; + title: string; + description: string | null; + storeId: number | null; + storeName: string | null; + assignees?: BackendTaskAssignee[]; + assigneeIds?: number[]; + assigneeNames?: string[]; + status: BackendTaskStatus; + priority: TaskPriority; + dueAt?: string | null; + deadlineAt?: string | null; + creatorName?: string | null; + events?: BackendTaskEvent[]; + createdAt: string; + updatedAt: string; +} + +type BackendTaskPayload = Omit & { + dueAt?: string | null; + assignees?: number[]; +}; + +type BackendShiftStatus = ShiftStatus | "CANCELED"; + +interface BackendShift { + id: number; + storeId: number; + storeName: string; + employeeId: number; + employeeName: string; + roleName?: string | null; + position?: string | null; + startAt: string; + endAt: string; + status: BackendShiftStatus; + createdAt: string; + updatedAt: string; +} + +type BackendShiftPayload = Omit & { + roleName?: string | null; + status?: "SCHEDULED"; +}; + +interface BackendCredentialAudit { + id: number; + actorName?: string | null; + operatorName?: string | null; + targetEmployeeName: string; + targetEmployeePhone?: string | null; + storeName?: string | null; + action: string; + createdAt?: string; + operatedAt?: string; + ip: string | null; + userAgent: string | null; + reason: string | null; +} + +function mapApiResult( + result: ApiResult, + mapper: (data: T) => R +): ApiResult { + return { + ...result, + data: mapper(result.data) + }; +} + +function mapPaginatedData( + data: PaginatedData, + mapper: (item: T) => R +): PaginatedData { + return { + ...data, + items: data.items.map(mapper) + }; +} + +function hasOwn(data: T, key: keyof T) { + return Object.prototype.hasOwnProperty.call(data, key); +} + +function toBackendDateTime(value?: string | null) { + if (value === undefined) return undefined; + if (!value) return null; + + const normalizedValue = value.includes("T") ? value : value.replace(" ", "T"); + const date = new Date(normalizedValue); + + return Number.isNaN(date.getTime()) ? value : date.toISOString(); +} + +function normalizeAnnouncementImportance( + value?: AnnouncementImportance | LegacyAnnouncementImportance | null +): AnnouncementImportance { + if (value === "URGENT" || value === "IMPORTANT" || value === "NORMAL") { + return value; + } + + return value === "HIGH" ? "IMPORTANT" : "NORMAL"; +} + +function toBackendAnnouncementTargetType( + scope: AnnouncementTargetScope +): BackendAnnouncementTargetType { + const scopeMap: Record< + AnnouncementTargetScope, + BackendAnnouncementTargetType + > = { + ALL_STORES: "ALL", + STORES: "STORE", + ROLES: "ROLE", + EMPLOYEES: "EMPLOYEE" + }; + + return scopeMap[scope]; +} + +function toAnnouncementTargetScope( + targetType?: BackendAnnouncementTargetType +): AnnouncementTargetScope { + const targetTypeMap: Record< + BackendAnnouncementTargetType, + AnnouncementTargetScope + > = { + ALL: "ALL_STORES", + STORE: "STORES", + ROLE: "ROLES", + EMPLOYEE: "EMPLOYEES" + }; + + return targetTypeMap[targetType ?? "ALL"]; +} + +function getAnnouncementTargetIds( + targets: BackendAnnouncementTarget[], + type: BackendConcreteAnnouncementTargetType +) { + return targets + .filter(target => target.type === type) + .map(target => target.id); +} + +function toBackendAnnouncementPayload( + data: Partial +): Partial { + const payload: Partial = {}; + + if (hasOwn(data, "title")) payload.title = data.title; + if (hasOwn(data, "content")) payload.content = data.content; + if (hasOwn(data, "importance")) { + payload.level = normalizeAnnouncementImportance(data.importance); + } + + if (hasOwn(data, "targetScope")) { + payload.targetType = toBackendAnnouncementTargetType( + data.targetScope ?? "ALL_STORES" + ); + } + + if ( + hasOwn(data, "targetScope") || + hasOwn(data, "targetStoreIds") || + hasOwn(data, "targetRoleIds") || + hasOwn(data, "targetEmployeeIds") + ) { + const targetType = + payload.targetType ?? + toBackendAnnouncementTargetType(data.targetScope ?? "ALL_STORES"); + const targetIds = + targetType === "STORE" + ? (data.targetStoreIds ?? []) + : targetType === "ROLE" + ? (data.targetRoleIds ?? []) + : targetType === "EMPLOYEE" + ? (data.targetEmployeeIds ?? []) + : []; + + payload.targets = + targetType === "ALL" + ? [] + : targetIds.map(id => ({ + type: targetType, + id + })); + } + + return payload; +} + +function normalizeAnnouncement( + announcement: BackendAnnouncement +): Announcement { + const targets = announcement.targets ?? []; + const targetScope = + announcement.targetScope ?? + toAnnouncementTargetScope(announcement.targetType); + + return { + id: announcement.id, + title: announcement.title, + content: announcement.content, + importance: normalizeAnnouncementImportance( + announcement.level ?? announcement.importance + ), + targetScope, + targetStoreIds: + announcement.targetStoreIds ?? getAnnouncementTargetIds(targets, "STORE"), + targetRoleIds: + announcement.targetRoleIds ?? getAnnouncementTargetIds(targets, "ROLE"), + targetEmployeeIds: + announcement.targetEmployeeIds ?? + getAnnouncementTargetIds(targets, "EMPLOYEE"), + status: announcement.status, + publisherName: announcement.publisherName ?? null, + publishedAt: announcement.publishedAt, + readCount: announcement.readCount ?? 0, + createdAt: announcement.createdAt, + updatedAt: announcement.updatedAt + }; +} + +function normalizeTaskStatus(status: BackendTaskStatus): TaskStatus { + return status === "CANCELED" ? "CANCELLED" : status; +} + +function normalizeShiftStatus(status: BackendShiftStatus): ShiftStatus { + return status === "CANCELED" ? "CANCELLED" : status; +} + +function toBackendTaskStatus(status?: BackendTaskStatus) { + return status === "CANCELED" ? "CANCELLED" : status; +} + +function normalizeTask(task: BackendTask): Task { + const assignees = task.assignees ?? []; + + return { + id: task.id, + title: task.title, + description: task.description, + storeId: task.storeId ?? 0, + storeName: task.storeName ?? "-", + assigneeIds: task.assigneeIds ?? assignees.map(assignee => assignee.id), + assigneeNames: + task.assigneeNames ?? assignees.map(assignee => assignee.name), + status: normalizeTaskStatus(task.status), + priority: task.priority, + deadlineAt: task.deadlineAt ?? task.dueAt ?? null, + creatorName: task.creatorName ?? null, + events: task.events?.map(event => ({ + id: event.id, + action: event.action ?? event.eventType ?? "", + remark: event.remark ?? event.comment ?? null, + operatorName: event.operatorName ?? null, + createdAt: event.createdAt + })), + createdAt: task.createdAt, + updatedAt: task.updatedAt + }; +} + +function toBackendTaskPayload( + data: Partial +): Partial { + const payload = { ...data } as Partial & + Partial; + + delete payload.deadlineAt; + + if (hasOwn(data, "deadlineAt")) { + payload.dueAt = toBackendDateTime(data.deadlineAt); + } + + if (hasOwn(data, "assigneeIds")) { + payload.assignees = data.assigneeIds; + } + + return payload; +} + +function normalizeShift(shift: BackendShift): Shift { + return { + id: shift.id, + storeId: shift.storeId, + storeName: shift.storeName, + employeeId: shift.employeeId, + employeeName: shift.employeeName, + position: shift.position ?? shift.roleName ?? "", + startAt: shift.startAt, + endAt: shift.endAt, + status: normalizeShiftStatus(shift.status), + createdAt: shift.createdAt, + updatedAt: shift.updatedAt + }; +} + +function toBackendShiftPayload( + data: Partial +): Partial { + const payload = { ...data } as Partial & + Partial; + + delete payload.position; + + if (hasOwn(data, "position")) { + payload.roleName = data.position?.trim() || null; + } + if (hasOwn(data, "startAt")) { + payload.startAt = toBackendDateTime(data.startAt) ?? ""; + } + if (hasOwn(data, "endAt")) { + payload.endAt = toBackendDateTime(data.endAt) ?? ""; + } + + return payload; +} + +function normalizeCredentialAudit( + audit: BackendCredentialAudit +): CredentialAudit { + return { + id: audit.id, + operatorName: audit.operatorName ?? audit.actorName ?? "-", + targetEmployeeName: audit.targetEmployeeName, + targetEmployeePhone: audit.targetEmployeePhone ?? null, + storeName: audit.storeName ?? null, + action: audit.action, + operatedAt: audit.operatedAt ?? audit.createdAt ?? "", + ip: audit.ip, + userAgent: audit.userAgent, + reason: audit.reason ?? "" + }; +} + export type PermissionPolicyResult = ApiResult; export type PermissionDefinitionsResult = ApiResult; @@ -282,6 +835,31 @@ export const listEmployees = (params: EmployeeListParams) => { ); }; +export const listEmployeeOptions = async ( + params: EmployeeOptionParams = {} +): Promise> => { + const items: Employee[] = []; + let page = 1; + let totalPages = 1; + + do { + const result = await listEmployees({ + ...params, + page, + pageSize: EMPLOYEE_OPTION_PAGE_SIZE + }); + + items.push(...result.data.items); + totalPages = result.data.pagination.totalPages; + page += 1; + } while (page <= totalPages); + + return { + success: true, + data: items + }; +}; + export const getEmployee = (id: number) => { return http.request>( "get", @@ -322,10 +900,14 @@ export const updateEmployeePassword = ( ); }; -export const resetEmployeePassword = (id: number) => { - return http.request>( - "patch", - `${API_PREFIX}/employees/${id}/password/reset` +export const resetEmployeePassword = ( + id: number, + data: EmployeePasswordResetPayload +) => { + return http.request>( + "post", + `${API_PREFIX}/admin/employees/${id}/password/reset`, + { data } ); }; @@ -360,3 +942,143 @@ export const updateRolePermissions = ( } ); }; + +/** 员工工作台运营接口:公告、任务、排班和凭据审计均按后台 admin 路径对接。 */ +export const listAnnouncements = (params: AnnouncementListParams) => { + const { importance, ...restParams } = params; + + return http + .request>>( + "get", + `${API_PREFIX}/admin/announcements`, + { + params: { + ...restParams, + level: importance + } + } + ) + .then(result => + mapApiResult(result, data => + mapPaginatedData(data, normalizeAnnouncement) + ) + ); +}; + +export const createAnnouncement = (data: AnnouncementPayload) => { + return http + .request< + ApiResult + >("post", `${API_PREFIX}/admin/announcements`, { data: toBackendAnnouncementPayload(data) }) + .then(result => mapApiResult(result, normalizeAnnouncement)); +}; + +export const updateAnnouncement = ( + id: number, + data: Partial +) => { + return http + .request< + ApiResult + >("patch", `${API_PREFIX}/admin/announcements/${id}`, { data: toBackendAnnouncementPayload(data) }) + .then(result => mapApiResult(result, normalizeAnnouncement)); +}; + +export const publishAnnouncement = (id: number) => { + return http + .request< + ApiResult + >("post", `${API_PREFIX}/admin/announcements/${id}/publish`) + .then(result => mapApiResult(result, normalizeAnnouncement)); +}; + +export const archiveAnnouncement = (id: number) => { + return http + .request< + ApiResult + >("post", `${API_PREFIX}/admin/announcements/${id}/archive`) + .then(result => mapApiResult(result, normalizeAnnouncement)); +}; + +export const listTasks = (params: TaskListParams) => { + return http + .request>>( + "get", + `${API_PREFIX}/admin/tasks`, + { + params: { + ...params, + status: toBackendTaskStatus(params.status) + } + } + ) + .then(result => + mapApiResult(result, data => mapPaginatedData(data, normalizeTask)) + ); +}; + +export const createTask = (data: TaskPayload) => { + return http + .request>("post", `${API_PREFIX}/admin/tasks`, { + data: toBackendTaskPayload(data) + }) + .then(result => mapApiResult(result, normalizeTask)); +}; + +export const updateTask = (id: number, data: Partial) => { + return http + .request< + ApiResult + >("patch", `${API_PREFIX}/admin/tasks/${id}`, { data: toBackendTaskPayload(data) }) + .then(result => mapApiResult(result, normalizeTask)); +}; + +export const cancelTask = (id: number) => { + return http + .request< + ApiResult + >("post", `${API_PREFIX}/admin/tasks/${id}/cancel`) + .then(result => mapApiResult(result, normalizeTask)); +}; + +export const listShifts = (params: ShiftListParams) => { + return http + .request< + ApiResult> + >("get", `${API_PREFIX}/admin/shifts`, { params }) + .then(result => + mapApiResult(result, data => mapPaginatedData(data, normalizeShift)) + ); +}; + +export const createShift = (data: ShiftPayload) => { + return http + .request>("post", `${API_PREFIX}/admin/shifts`, { + data: toBackendShiftPayload(data) + }) + .then(result => mapApiResult(result, normalizeShift)); +}; + +export const updateShift = (id: number, data: Partial) => { + return http + .request< + ApiResult + >("patch", `${API_PREFIX}/admin/shifts/${id}`, { data: toBackendShiftPayload(data) }) + .then(result => mapApiResult(result, normalizeShift)); +}; + +export const cancelShift = (id: number) => { + return http.request("delete", `${API_PREFIX}/admin/shifts/${id}`); +}; + +export const listCredentialAudits = (params: CredentialAuditListParams) => { + return http + .request< + ApiResult> + >("get", `${API_PREFIX}/admin/credential-audits`, { params }) + .then(result => + mapApiResult(result, data => + mapPaginatedData(data, normalizeCredentialAudit) + ) + ); +}; diff --git a/src/layout/types.ts b/src/layout/types.ts index 4230885..176a98c 100644 --- a/src/layout/types.ts +++ b/src/layout/types.ts @@ -38,6 +38,38 @@ export const routerArrays: Array = [ title: "权限策略", icon: "ep/key" } + }, + { + path: "/announcements", + name: "AnnouncementManagement", + meta: { + title: "公告管理", + icon: "ep/bell" + } + }, + { + path: "/tasks", + name: "TaskManagement", + meta: { + title: "任务管理", + icon: "ep/tickets" + } + }, + { + path: "/shifts", + name: "ShiftManagement", + meta: { + title: "排班管理", + icon: "ep/calendar" + } + }, + { + path: "/credential-audits", + name: "CredentialAuditManagement", + meta: { + title: "凭据审计", + icon: "ep/document-checked" + } } ]; diff --git a/src/router/modules/employees.ts b/src/router/modules/employees.ts index f5259ff..aecf62d 100644 --- a/src/router/modules/employees.ts +++ b/src/router/modules/employees.ts @@ -6,60 +6,119 @@ const Layout = () => import("@/layout/index.vue"); * 子页面都是静态路由,菜单展示顺序由这里的 children 决定; * 默认访问该模块时进入员工管理,保证和登录/登出后的主工作流一致。 */ -export default { - path: "/access", - name: "AccessManagement", - component: Layout, - redirect: "/employees", - meta: { - icon: "ep/user-filled", - title: "权限管理", - rank: 1 +export default [ + { + path: "/access", + name: "AccessManagement", + component: Layout, + redirect: "/employees", + meta: { + icon: "ep/user-filled", + title: "权限管理", + rank: 1 + }, + children: [ + { + path: "/stores", + name: "StoreManagement", + component: () => import("@/views/stores/index.vue"), + meta: { + title: "门店管理", + menuKey: "stores", + permission: "store:view", + keepAlive: true + } + }, + { + path: "/roles", + name: "RoleManagement", + component: () => import("@/views/roles/index.vue"), + meta: { + title: "角色管理", + menuKey: "roles", + permission: "role:view", + keepAlive: true + } + }, + { + path: "/employees", + name: "EmployeeManagement", + component: () => import("@/views/employees/index.vue"), + meta: { + title: "员工管理", + menuKey: "employees", + permission: ["employee:view:all", "employee:view:store"], + keepAlive: true + } + }, + { + path: "/permissions", + name: "PermissionPolicies", + component: () => import("@/views/permissions/index.vue"), + meta: { + title: "权限策略", + menuKey: "permissions", + permission: "permission:view", + keepAlive: true + } + } + ] }, - children: [ - { - path: "/stores", - name: "StoreManagement", - component: () => import("@/views/stores/index.vue"), - meta: { - title: "门店管理", - menuKey: "stores", - permission: "store:view", - keepAlive: true - } + { + path: "/operations", + name: "EmployeeWorkspaceOperations", + component: Layout, + redirect: "/announcements", + meta: { + icon: "ep/operation", + title: "员工工作台运营", + rank: 2 }, - { - path: "/roles", - name: "RoleManagement", - component: () => import("@/views/roles/index.vue"), - meta: { - title: "角色管理", - menuKey: "roles", - permission: "role:view", - keepAlive: true + children: [ + { + path: "/announcements", + name: "AnnouncementManagement", + component: () => import("@/views/announcements/index.vue"), + meta: { + title: "公告管理", + menuKey: "announcements", + permission: "announcement:view", + keepAlive: true + } + }, + { + path: "/tasks", + name: "TaskManagement", + component: () => import("@/views/tasks/index.vue"), + meta: { + title: "任务管理", + menuKey: "tasks", + permission: "task:view", + keepAlive: true + } + }, + { + path: "/shifts", + name: "ShiftManagement", + component: () => import("@/views/shifts/index.vue"), + meta: { + title: "排班管理", + menuKey: "shifts", + permission: "shift:view", + keepAlive: true + } + }, + { + path: "/credential-audits", + name: "CredentialAuditManagement", + component: () => import("@/views/credential-audits/index.vue"), + meta: { + title: "凭据审计", + menuKey: "credential-audits", + permission: "credential:audit:view", + keepAlive: true + } } - }, - { - path: "/employees", - name: "EmployeeManagement", - component: () => import("@/views/employees/index.vue"), - meta: { - title: "员工管理", - menuKey: "employees", - permission: ["employee:view:all", "employee:view:store"], - keepAlive: true - } - }, - { - path: "/permissions", - name: "PermissionPolicies", - component: () => import("@/views/permissions/index.vue"), - meta: { - title: "权限策略", - menuKey: "permissions", - permission: "permission:view", - keepAlive: true - } - } - ] -} satisfies RouteConfigsTable; + ] + } +] satisfies RouteConfigsTable[]; diff --git a/src/views/announcements/index.vue b/src/views/announcements/index.vue new file mode 100644 index 0000000..710e08f --- /dev/null +++ b/src/views/announcements/index.vue @@ -0,0 +1,680 @@ + + + + + diff --git a/src/views/credential-audits/index.vue b/src/views/credential-audits/index.vue new file mode 100644 index 0000000..98f1242 --- /dev/null +++ b/src/views/credential-audits/index.vue @@ -0,0 +1,381 @@ + + + + + diff --git a/src/views/employees/index.vue b/src/views/employees/index.vue index 871694e..a8c13f1 100644 --- a/src/views/employees/index.vue +++ b/src/views/employees/index.vue @@ -3,6 +3,7 @@ import { computed, onMounted, reactive, ref } from "vue"; import { ElMessage, ElMessageBox, + ElNotification, type FormInstance, type FormRules } from "element-plus"; @@ -15,7 +16,6 @@ import { listRoles, resetEmployeePassword, updateEmployee, - updateEmployeePassword, updateEmployeeStatus, type Employee, type EmployeePayload, @@ -34,7 +34,7 @@ import Delete from "~icons/ep/delete"; import CircleCheck from "~icons/ep/circle-check"; import CircleClose from "~icons/ep/circle-close"; import Key from "~icons/ep/key"; -import RefreshLeft from "~icons/ep/refresh-left"; +import CopyDocument from "~icons/ep/copy-document"; defineOptions({ name: "EmployeeManagement" @@ -48,8 +48,7 @@ type EmployeeFormState = EmployeePayload & { type PasswordFormState = { employeeId?: number; employeeName: string; - oldPassword: string; - newPassword: string; + reason: string; }; /** 员工手机号按中国大陆手机号做前端第一层校验,最终唯一性仍由后端保证。 */ @@ -68,12 +67,14 @@ const submitLoading = ref(false); const passwordSubmitLoading = ref(false); const dialogVisible = ref(false); const passwordDialogVisible = ref(false); +const temporaryPasswordVisible = ref(false); const formRef = ref(); const passwordFormRef = ref(); const employees = ref([]); const stores = ref([]); const roles = ref([]); const originalStoreId = ref(); +const temporaryPassword = ref(""); const query = reactive({ storeId: undefined as number | undefined, @@ -101,8 +102,7 @@ const form = reactive({ const passwordForm = reactive({ employeeId: undefined, employeeName: "", - oldPassword: "", - newPassword: "" + reason: "" }); const rules: FormRules = { @@ -125,21 +125,12 @@ const rules: FormRules = { }; const passwordRules: FormRules = { - oldPassword: [ - { required: true, message: "请输入旧密码", trigger: "blur" }, + reason: [ + { required: true, message: "请填写重置原因", trigger: "blur" }, { - min: 8, - max: 128, - message: "密码长度需要在 8-128 个字符之间", - trigger: "blur" - } - ], - newPassword: [ - { required: true, message: "请输入新密码", trigger: "blur" }, - { - min: 8, - max: 128, - message: "密码长度需要在 8-128 个字符之间", + min: 4, + max: 255, + message: "原因需要在 4-255 个字符之间", trigger: "blur" } ] @@ -155,8 +146,12 @@ const dialogTitle = computed(() => (form.id ? "编辑员工" : "新增员工")); const canCreateEmployee = computed(() => hasMenuAction("employees", "create")); const canUpdateEmployee = computed(() => hasMenuAction("employees", "update")); const canDeleteEmployee = computed(() => hasMenuAction("employees", "delete")); +const canResetCredential = computed(() => hasPerms("credential:reset")); const canOperateEmployee = computed( - () => canUpdateEmployee.value || canDeleteEmployee.value + () => + canUpdateEmployee.value || + canDeleteEmployee.value || + canResetCredential.value ); const canViewAllEmployees = computed(() => hasPerms("employee:view:all")); @@ -223,11 +218,11 @@ function resetFormState() { } function resetPasswordFormState() { + temporaryPassword.value = ""; Object.assign(passwordForm, { employeeId: undefined, employeeName: "", - oldPassword: "", - newPassword: "" + reason: "" }); passwordFormRef.value?.clearValidate(); } @@ -397,56 +392,51 @@ async function submitForm() { } function openPasswordDialog(row: Employee) { - if (!canUpdateEmployee.value) return; + if (!canResetCredential.value) return; resetPasswordFormState(); Object.assign(passwordForm, { employeeId: row.id, employeeName: row.name, - oldPassword: "", - newPassword: "" + reason: "" }); passwordDialogVisible.value = true; } async function submitPasswordForm() { - if (!canUpdateEmployee.value || !passwordForm.employeeId) return; + if (!canResetCredential.value || !passwordForm.employeeId) return; await passwordFormRef.value?.validate(); passwordSubmitLoading.value = true; try { - await updateEmployeePassword(passwordForm.employeeId, { - oldPassword: passwordForm.oldPassword, - newPassword: passwordForm.newPassword + const result = await resetEmployeePassword(passwordForm.employeeId, { + reason: passwordForm.reason.trim() }); - ElMessage.success("员工密码已修改"); passwordDialogVisible.value = false; + temporaryPassword.value = result.data.temporaryPassword; + temporaryPasswordVisible.value = true; + fetchEmployees(); } catch (error) { - ElMessage.error(getErrorMessage(error, "修改密码失败")); + ElMessage.error(getErrorMessage(error, "重置密码失败")); } finally { passwordSubmitLoading.value = false; } } -async function resetPassword(row: Employee) { - if (!canUpdateEmployee.value) return; +async function copyTemporaryPassword() { + if (!temporaryPassword.value) return; + await navigator.clipboard.writeText(temporaryPassword.value); + ElMessage.success("临时密码已复制"); +} - try { - await ElMessageBox.confirm( - `确认将员工「${row.name}」的密码重置为初始密码 pw111111?`, - "重置密码", - { - type: "warning", - confirmButtonText: "重置", - cancelButtonText: "取消" - } - ); - await resetEmployeePassword(row.id); - ElMessage.success("员工密码已重置为初始密码"); - } catch (error) { - if (error !== "cancel") { - ElMessage.error(getErrorMessage(error, "重置密码失败")); - } - } +function clearTemporaryPassword() { + temporaryPassword.value = ""; +} + +function showPasswordResetBoundary() { + ElNotification.warning({ + title: "安全边界", + message: "正式版不支持查看当前明文密码,只能重置并一次性查看临时密码。" + }); } async function toggleStatus(row: Employee) { @@ -684,20 +674,11 @@ onMounted(async () => { {{ row.status === "ACTIVE" ? "停用" : "启用" }} - 改密 - - 重置密码 @@ -823,8 +804,8 @@ onMounted(async () => { { - + + - - - @@ -863,7 +843,39 @@ onMounted(async () => { :loading="passwordSubmitLoading" @click="submitPasswordForm" > - 保存 + 重置并生成临时密码 + + + + + + + + @@ -1024,6 +1036,14 @@ onMounted(async () => { width: 100%; } +.password-alert { + margin-bottom: 16px; +} + +.temporary-password { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; +} + @media (width <= 760px) { .employee-page { padding: 12px; diff --git a/src/views/shifts/index.vue b/src/views/shifts/index.vue new file mode 100644 index 0000000..bc7aeaa --- /dev/null +++ b/src/views/shifts/index.vue @@ -0,0 +1,603 @@ + + + + + diff --git a/src/views/tasks/index.vue b/src/views/tasks/index.vue new file mode 100644 index 0000000..5547d64 --- /dev/null +++ b/src/views/tasks/index.vue @@ -0,0 +1,702 @@ + + + + +