feat: add employee workspace operations admin
This commit is contained in:
@@ -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<T>` 或 `PaginatedData<T>` 描述,页面层只消费 `result.data`,避免在视图里重复拼接接口路径。门店、角色、员工列表的搜索、重置、分页和状态变更后的刷新都应通过接口层完成,不直接依赖页面内存里的旧列表。员工对象会消费后端返回的 `statusTags`;所属门店停用时展示“门店被禁用”标签。
|
||||
接口响应统一在 `src/api/access.ts` 中使用 `ApiResult<T>` 或 `PaginatedData<T>` 描述,页面层只消费 `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 过期时直接清理登录态并要求重新登录。
|
||||
|
||||
## 配置说明
|
||||
|
||||
|
||||
@@ -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. 验收标准
|
||||
|
||||
- 超级管理员可以看到新增菜单。
|
||||
- 没有权限的用户看不到对应菜单和按钮。
|
||||
- 员工密码重置只显示一次性临时密码。
|
||||
- 凭据审计能查到每次重置行为。
|
||||
- 不存在“查看当前明文密码”的入口。
|
||||
+726
-4
@@ -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<EmployeeListParams, "storeId" | "status" | "keyword">
|
||||
>;
|
||||
|
||||
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<T> {
|
||||
success: boolean;
|
||||
@@ -186,6 +338,407 @@ export interface PaginatedData<T> {
|
||||
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<TaskPayload, "deadlineAt"> & {
|
||||
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<ShiftPayload, "position"> & {
|
||||
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<T, R>(
|
||||
result: ApiResult<T>,
|
||||
mapper: (data: T) => R
|
||||
): ApiResult<R> {
|
||||
return {
|
||||
...result,
|
||||
data: mapper(result.data)
|
||||
};
|
||||
}
|
||||
|
||||
function mapPaginatedData<T, R>(
|
||||
data: PaginatedData<T>,
|
||||
mapper: (item: T) => R
|
||||
): PaginatedData<R> {
|
||||
return {
|
||||
...data,
|
||||
items: data.items.map(mapper)
|
||||
};
|
||||
}
|
||||
|
||||
function hasOwn<T extends object>(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<AnnouncementPayload>
|
||||
): Partial<BackendAnnouncementPayload> {
|
||||
const payload: Partial<BackendAnnouncementPayload> = {};
|
||||
|
||||
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<TaskPayload>
|
||||
): Partial<BackendTaskPayload> {
|
||||
const payload = { ...data } as Partial<BackendTaskPayload> &
|
||||
Partial<TaskPayload>;
|
||||
|
||||
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<ShiftPayload>
|
||||
): Partial<BackendShiftPayload> {
|
||||
const payload = { ...data } as Partial<BackendShiftPayload> &
|
||||
Partial<ShiftPayload>;
|
||||
|
||||
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<PermissionPolicy[]>;
|
||||
export type PermissionDefinitionsResult = ApiResult<PermissionDefinitions>;
|
||||
|
||||
@@ -282,6 +835,31 @@ export const listEmployees = (params: EmployeeListParams) => {
|
||||
);
|
||||
};
|
||||
|
||||
export const listEmployeeOptions = async (
|
||||
params: EmployeeOptionParams = {}
|
||||
): Promise<ApiResult<Employee[]>> => {
|
||||
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<ApiResult<Employee>>(
|
||||
"get",
|
||||
@@ -322,10 +900,14 @@ export const updateEmployeePassword = (
|
||||
);
|
||||
};
|
||||
|
||||
export const resetEmployeePassword = (id: number) => {
|
||||
return http.request<ApiResult<Employee>>(
|
||||
"patch",
|
||||
`${API_PREFIX}/employees/${id}/password/reset`
|
||||
export const resetEmployeePassword = (
|
||||
id: number,
|
||||
data: EmployeePasswordResetPayload
|
||||
) => {
|
||||
return http.request<ApiResult<EmployeePasswordResetResult>>(
|
||||
"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<ApiResult<PaginatedData<BackendAnnouncement>>>(
|
||||
"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<BackendAnnouncement>
|
||||
>("post", `${API_PREFIX}/admin/announcements`, { data: toBackendAnnouncementPayload(data) })
|
||||
.then(result => mapApiResult(result, normalizeAnnouncement));
|
||||
};
|
||||
|
||||
export const updateAnnouncement = (
|
||||
id: number,
|
||||
data: Partial<AnnouncementPayload>
|
||||
) => {
|
||||
return http
|
||||
.request<
|
||||
ApiResult<BackendAnnouncement>
|
||||
>("patch", `${API_PREFIX}/admin/announcements/${id}`, { data: toBackendAnnouncementPayload(data) })
|
||||
.then(result => mapApiResult(result, normalizeAnnouncement));
|
||||
};
|
||||
|
||||
export const publishAnnouncement = (id: number) => {
|
||||
return http
|
||||
.request<
|
||||
ApiResult<BackendAnnouncement>
|
||||
>("post", `${API_PREFIX}/admin/announcements/${id}/publish`)
|
||||
.then(result => mapApiResult(result, normalizeAnnouncement));
|
||||
};
|
||||
|
||||
export const archiveAnnouncement = (id: number) => {
|
||||
return http
|
||||
.request<
|
||||
ApiResult<BackendAnnouncement>
|
||||
>("post", `${API_PREFIX}/admin/announcements/${id}/archive`)
|
||||
.then(result => mapApiResult(result, normalizeAnnouncement));
|
||||
};
|
||||
|
||||
export const listTasks = (params: TaskListParams) => {
|
||||
return http
|
||||
.request<ApiResult<PaginatedData<BackendTask>>>(
|
||||
"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<ApiResult<BackendTask>>("post", `${API_PREFIX}/admin/tasks`, {
|
||||
data: toBackendTaskPayload(data)
|
||||
})
|
||||
.then(result => mapApiResult(result, normalizeTask));
|
||||
};
|
||||
|
||||
export const updateTask = (id: number, data: Partial<TaskPayload>) => {
|
||||
return http
|
||||
.request<
|
||||
ApiResult<BackendTask>
|
||||
>("patch", `${API_PREFIX}/admin/tasks/${id}`, { data: toBackendTaskPayload(data) })
|
||||
.then(result => mapApiResult(result, normalizeTask));
|
||||
};
|
||||
|
||||
export const cancelTask = (id: number) => {
|
||||
return http
|
||||
.request<
|
||||
ApiResult<BackendTask>
|
||||
>("post", `${API_PREFIX}/admin/tasks/${id}/cancel`)
|
||||
.then(result => mapApiResult(result, normalizeTask));
|
||||
};
|
||||
|
||||
export const listShifts = (params: ShiftListParams) => {
|
||||
return http
|
||||
.request<
|
||||
ApiResult<PaginatedData<BackendShift>>
|
||||
>("get", `${API_PREFIX}/admin/shifts`, { params })
|
||||
.then(result =>
|
||||
mapApiResult(result, data => mapPaginatedData(data, normalizeShift))
|
||||
);
|
||||
};
|
||||
|
||||
export const createShift = (data: ShiftPayload) => {
|
||||
return http
|
||||
.request<ApiResult<BackendShift>>("post", `${API_PREFIX}/admin/shifts`, {
|
||||
data: toBackendShiftPayload(data)
|
||||
})
|
||||
.then(result => mapApiResult(result, normalizeShift));
|
||||
};
|
||||
|
||||
export const updateShift = (id: number, data: Partial<ShiftPayload>) => {
|
||||
return http
|
||||
.request<
|
||||
ApiResult<BackendShift>
|
||||
>("patch", `${API_PREFIX}/admin/shifts/${id}`, { data: toBackendShiftPayload(data) })
|
||||
.then(result => mapApiResult(result, normalizeShift));
|
||||
};
|
||||
|
||||
export const cancelShift = (id: number) => {
|
||||
return http.request<void>("delete", `${API_PREFIX}/admin/shifts/${id}`);
|
||||
};
|
||||
|
||||
export const listCredentialAudits = (params: CredentialAuditListParams) => {
|
||||
return http
|
||||
.request<
|
||||
ApiResult<PaginatedData<BackendCredentialAudit>>
|
||||
>("get", `${API_PREFIX}/admin/credential-audits`, { params })
|
||||
.then(result =>
|
||||
mapApiResult(result, data =>
|
||||
mapPaginatedData(data, normalizeCredentialAudit)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
@@ -38,6 +38,38 @@ export const routerArrays: Array<RouteConfigs> = [
|
||||
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"
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
+113
-54
@@ -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[];
|
||||
|
||||
@@ -0,0 +1,680 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import {
|
||||
ElMessage,
|
||||
ElMessageBox,
|
||||
type FormInstance,
|
||||
type FormRules
|
||||
} from "element-plus";
|
||||
import {
|
||||
archiveAnnouncement,
|
||||
createAnnouncement,
|
||||
listAllStores,
|
||||
listAnnouncements,
|
||||
listEmployeeOptions,
|
||||
listRoles,
|
||||
publishAnnouncement,
|
||||
updateAnnouncement,
|
||||
type Announcement,
|
||||
type AnnouncementImportance,
|
||||
type AnnouncementPayload,
|
||||
type AnnouncementStatus,
|
||||
type AnnouncementTargetScope,
|
||||
type Employee,
|
||||
type RoleOption,
|
||||
type Store
|
||||
} from "@/api/access";
|
||||
import { hasMenuAction, hasPerms } from "@/utils/auth";
|
||||
|
||||
import Plus from "~icons/ep/plus";
|
||||
import Search from "~icons/ep/search";
|
||||
import Refresh from "~icons/ep/refresh";
|
||||
import EditPen from "~icons/ep/edit-pen";
|
||||
import Promotion from "~icons/ep/promotion";
|
||||
import Box from "~icons/ep/box";
|
||||
|
||||
defineOptions({
|
||||
name: "AnnouncementManagement"
|
||||
});
|
||||
|
||||
type AnnouncementFormState = AnnouncementPayload & {
|
||||
id?: number;
|
||||
};
|
||||
|
||||
const tableLoading = ref(false);
|
||||
const optionLoading = ref(false);
|
||||
const submitLoading = ref(false);
|
||||
const dialogVisible = ref(false);
|
||||
const formRef = ref<FormInstance>();
|
||||
const announcements = ref<Announcement[]>([]);
|
||||
const stores = ref<Store[]>([]);
|
||||
const roles = ref<RoleOption[]>([]);
|
||||
const employees = ref<Employee[]>([]);
|
||||
|
||||
const query = reactive({
|
||||
status: undefined as AnnouncementStatus | undefined,
|
||||
importance: undefined as AnnouncementImportance | undefined,
|
||||
keyword: "",
|
||||
page: 1,
|
||||
pageSize: 20
|
||||
});
|
||||
|
||||
const pagination = reactive({
|
||||
total: 0,
|
||||
totalPages: 0
|
||||
});
|
||||
|
||||
const form = reactive<AnnouncementFormState>({
|
||||
title: "",
|
||||
content: "",
|
||||
importance: "NORMAL",
|
||||
targetScope: "ALL_STORES",
|
||||
targetStoreIds: [],
|
||||
targetRoleIds: [],
|
||||
targetEmployeeIds: []
|
||||
});
|
||||
|
||||
const rules: FormRules<AnnouncementFormState> = {
|
||||
title: [
|
||||
{ required: true, message: "请输入公告标题", trigger: "blur" },
|
||||
{ max: 100, message: "标题不能超过 100 个字符", trigger: "blur" }
|
||||
],
|
||||
content: [
|
||||
{ required: true, message: "请输入公告内容", trigger: "blur" },
|
||||
{ max: 5000, message: "内容不能超过 5000 个字符", trigger: "blur" }
|
||||
],
|
||||
importance: [
|
||||
{ required: true, message: "请选择重要级别", trigger: "change" }
|
||||
],
|
||||
targetScope: [
|
||||
{ required: true, message: "请选择目标范围", trigger: "change" }
|
||||
]
|
||||
};
|
||||
|
||||
const dialogTitle = computed(() => (form.id ? "编辑公告" : "新建公告"));
|
||||
const canManage = computed(
|
||||
() =>
|
||||
hasMenuAction("announcements", "manage") || hasPerms("announcement:manage")
|
||||
);
|
||||
|
||||
const statusMap: Record<AnnouncementStatus, { label: string; type: string }> = {
|
||||
DRAFT: { label: "草稿", type: "info" },
|
||||
PUBLISHED: { label: "已发布", type: "success" },
|
||||
ARCHIVED: { label: "已归档", type: "warning" }
|
||||
};
|
||||
|
||||
const importanceMap: Record<
|
||||
AnnouncementImportance,
|
||||
{ label: string; type: string }
|
||||
> = {
|
||||
NORMAL: { label: "普通", type: "" },
|
||||
IMPORTANT: { label: "重要", type: "warning" },
|
||||
URGENT: { label: "紧急", type: "danger" }
|
||||
};
|
||||
|
||||
const scopeMap: Record<AnnouncementTargetScope, string> = {
|
||||
ALL_STORES: "全部门店",
|
||||
STORES: "指定门店",
|
||||
ROLES: "指定角色",
|
||||
EMPLOYEES: "指定员工"
|
||||
};
|
||||
|
||||
function getErrorMessage(error: unknown, fallback: string) {
|
||||
const message = (
|
||||
error as { response?: { data?: { error?: { message?: string } } } }
|
||||
)?.response?.data?.error?.message;
|
||||
|
||||
if (message) return message;
|
||||
if (error instanceof Error && error.message) return error.message;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function formatTime(value?: string | null) {
|
||||
if (!value) return "-";
|
||||
return new Date(value).toLocaleString("zh-CN", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit"
|
||||
});
|
||||
}
|
||||
|
||||
function resetFormState() {
|
||||
Object.assign(form, {
|
||||
id: undefined,
|
||||
title: "",
|
||||
content: "",
|
||||
importance: "NORMAL",
|
||||
targetScope: "ALL_STORES",
|
||||
targetStoreIds: [],
|
||||
targetRoleIds: [],
|
||||
targetEmployeeIds: []
|
||||
});
|
||||
formRef.value?.clearValidate();
|
||||
}
|
||||
|
||||
function buildPayload(): AnnouncementPayload {
|
||||
return {
|
||||
title: form.title.trim(),
|
||||
content: form.content.trim(),
|
||||
importance: form.importance,
|
||||
targetScope: form.targetScope,
|
||||
targetStoreIds: form.targetScope === "STORES" ? form.targetStoreIds : [],
|
||||
targetRoleIds: form.targetScope === "ROLES" ? form.targetRoleIds : [],
|
||||
targetEmployeeIds:
|
||||
form.targetScope === "EMPLOYEES" ? form.targetEmployeeIds : []
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchOptions() {
|
||||
optionLoading.value = true;
|
||||
try {
|
||||
const [storeResult, roleResult, employeeResult] = await Promise.all([
|
||||
listAllStores({ includeInactive: true }),
|
||||
listRoles(),
|
||||
listEmployeeOptions()
|
||||
]);
|
||||
stores.value = storeResult.data;
|
||||
roles.value = roleResult.data;
|
||||
employees.value = employeeResult.data;
|
||||
} catch (error) {
|
||||
ElMessage.error(getErrorMessage(error, "加载目标选项失败"));
|
||||
} finally {
|
||||
optionLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAnnouncements() {
|
||||
tableLoading.value = true;
|
||||
try {
|
||||
const result = await listAnnouncements({
|
||||
status: query.status,
|
||||
importance: query.importance,
|
||||
keyword: query.keyword.trim() || undefined,
|
||||
page: query.page,
|
||||
pageSize: query.pageSize
|
||||
});
|
||||
announcements.value = result.data.items;
|
||||
pagination.total = result.data.pagination.total;
|
||||
pagination.totalPages = result.data.pagination.totalPages;
|
||||
} catch (error) {
|
||||
ElMessage.error(getErrorMessage(error, "加载公告列表失败"));
|
||||
} finally {
|
||||
tableLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
query.page = 1;
|
||||
fetchAnnouncements();
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
query.status = undefined;
|
||||
query.importance = undefined;
|
||||
query.keyword = "";
|
||||
query.page = 1;
|
||||
query.pageSize = 20;
|
||||
fetchAnnouncements();
|
||||
}
|
||||
|
||||
function handlePageChange(page: number) {
|
||||
query.page = page;
|
||||
fetchAnnouncements();
|
||||
}
|
||||
|
||||
function handleSizeChange(pageSize: number) {
|
||||
query.page = 1;
|
||||
query.pageSize = pageSize;
|
||||
fetchAnnouncements();
|
||||
}
|
||||
|
||||
function openCreateDialog() {
|
||||
if (!canManage.value) return;
|
||||
resetFormState();
|
||||
dialogVisible.value = true;
|
||||
fetchOptions();
|
||||
}
|
||||
|
||||
function openEditDialog(row: Announcement) {
|
||||
if (!canManage.value) return;
|
||||
Object.assign(form, {
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
content: row.content,
|
||||
importance: row.importance,
|
||||
targetScope: row.targetScope,
|
||||
targetStoreIds: row.targetStoreIds ?? [],
|
||||
targetRoleIds: row.targetRoleIds ?? [],
|
||||
targetEmployeeIds: row.targetEmployeeIds ?? []
|
||||
});
|
||||
dialogVisible.value = true;
|
||||
fetchOptions();
|
||||
formRef.value?.clearValidate();
|
||||
}
|
||||
|
||||
async function submitForm(publish = false) {
|
||||
if (!canManage.value) return;
|
||||
await formRef.value?.validate();
|
||||
submitLoading.value = true;
|
||||
|
||||
try {
|
||||
const payload = buildPayload();
|
||||
const result = form.id
|
||||
? await updateAnnouncement(form.id, payload)
|
||||
: await createAnnouncement(payload);
|
||||
|
||||
if (publish) {
|
||||
await publishAnnouncement(result.data.id);
|
||||
ElMessage.success("公告已发布");
|
||||
} else {
|
||||
ElMessage.success(form.id ? "公告已保存" : "公告草稿已保存");
|
||||
}
|
||||
|
||||
dialogVisible.value = false;
|
||||
fetchAnnouncements();
|
||||
} catch (error) {
|
||||
ElMessage.error(getErrorMessage(error, "保存公告失败"));
|
||||
} finally {
|
||||
submitLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePublish(row: Announcement) {
|
||||
if (!canManage.value) return;
|
||||
try {
|
||||
await publishAnnouncement(row.id);
|
||||
ElMessage.success("公告已发布");
|
||||
fetchAnnouncements();
|
||||
} catch (error) {
|
||||
ElMessage.error(getErrorMessage(error, "发布公告失败"));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleArchive(row: Announcement) {
|
||||
if (!canManage.value) return;
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认归档公告「${row.title}」?`, "归档公告", {
|
||||
type: "warning",
|
||||
confirmButtonText: "归档",
|
||||
cancelButtonText: "取消"
|
||||
});
|
||||
await archiveAnnouncement(row.id);
|
||||
ElMessage.success("公告已归档");
|
||||
fetchAnnouncements();
|
||||
} catch (error) {
|
||||
if (error !== "cancel") {
|
||||
ElMessage.error(getErrorMessage(error, "归档公告失败"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchAnnouncements);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="operation-page">
|
||||
<div class="page-heading">
|
||||
<div>
|
||||
<p class="eyebrow">员工工作台运营</p>
|
||||
<h1>公告管理</h1>
|
||||
</div>
|
||||
<el-button
|
||||
v-if="canManage"
|
||||
type="primary"
|
||||
:icon="Plus"
|
||||
@click="openCreateDialog"
|
||||
>
|
||||
新建公告
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div class="toolbar">
|
||||
<el-select
|
||||
v-model="query.status"
|
||||
clearable
|
||||
placeholder="全部状态"
|
||||
class="toolbar-control"
|
||||
@change="handleSearch"
|
||||
>
|
||||
<el-option label="草稿" value="DRAFT" />
|
||||
<el-option label="已发布" value="PUBLISHED" />
|
||||
<el-option label="已归档" value="ARCHIVED" />
|
||||
</el-select>
|
||||
<el-select
|
||||
v-model="query.importance"
|
||||
clearable
|
||||
placeholder="全部级别"
|
||||
class="toolbar-control"
|
||||
@change="handleSearch"
|
||||
>
|
||||
<el-option label="普通" value="NORMAL" />
|
||||
<el-option label="重要" value="IMPORTANT" />
|
||||
<el-option label="紧急" value="URGENT" />
|
||||
</el-select>
|
||||
<el-input
|
||||
v-model="query.keyword"
|
||||
clearable
|
||||
class="keyword-input"
|
||||
placeholder="搜索标题或内容"
|
||||
@clear="handleSearch"
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
<div class="toolbar-actions">
|
||||
<el-button type="primary" :icon="Search" @click="handleSearch">
|
||||
查询
|
||||
</el-button>
|
||||
<el-button :icon="Refresh" @click="handleReset">重置</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-shell">
|
||||
<el-table
|
||||
v-loading="tableLoading"
|
||||
:data="announcements"
|
||||
row-key="id"
|
||||
stripe
|
||||
>
|
||||
<el-table-column prop="title" label="标题" min-width="220" />
|
||||
<el-table-column label="重要级别" width="110">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="importanceMap[row.importance].type" effect="light">
|
||||
{{ importanceMap[row.importance].label }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="目标范围" width="130">
|
||||
<template #default="{ row }">
|
||||
{{ scopeMap[row.targetScope] }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="110">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="statusMap[row.status].type" effect="light">
|
||||
{{ statusMap[row.status].label }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="publisherName" label="发布人" min-width="120">
|
||||
<template #default="{ row }">{{ row.publisherName || "-" }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="发布时间" width="180">
|
||||
<template #default="{ row }">{{
|
||||
formatTime(row.publishedAt)
|
||||
}}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="readCount" label="已读人数" width="100" />
|
||||
<el-table-column
|
||||
v-if="canManage"
|
||||
label="操作"
|
||||
width="220"
|
||||
fixed="right"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
link
|
||||
type="primary"
|
||||
:icon="EditPen"
|
||||
@click="openEditDialog(row)"
|
||||
>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="row.status !== 'PUBLISHED'"
|
||||
link
|
||||
type="success"
|
||||
:icon="Promotion"
|
||||
@click="handlePublish(row)"
|
||||
>
|
||||
发布
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="row.status !== 'ARCHIVED'"
|
||||
link
|
||||
type="warning"
|
||||
:icon="Box"
|
||||
@click="handleArchive(row)"
|
||||
>
|
||||
归档
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="pagination-row">
|
||||
<span
|
||||
>共 {{ pagination.total }} 条,{{ pagination.totalPages }} 页</span
|
||||
>
|
||||
<el-pagination
|
||||
v-model:current-page="query.page"
|
||||
v-model:page-size="query.pageSize"
|
||||
background
|
||||
layout="sizes, prev, pager, next"
|
||||
:total="pagination.total"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
@current-change="handlePageChange"
|
||||
@size-change="handleSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="dialogTitle"
|
||||
width="640px"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-position="top">
|
||||
<el-form-item label="标题" prop="title">
|
||||
<el-input v-model="form.title" maxlength="100" placeholder="请输入" />
|
||||
</el-form-item>
|
||||
<el-form-item label="内容" prop="content">
|
||||
<el-input
|
||||
v-model="form.content"
|
||||
type="textarea"
|
||||
maxlength="5000"
|
||||
show-word-limit
|
||||
:rows="6"
|
||||
placeholder="请输入公告内容"
|
||||
/>
|
||||
</el-form-item>
|
||||
<div class="form-grid">
|
||||
<el-form-item label="重要级别" prop="importance">
|
||||
<el-select v-model="form.importance" class="full-width">
|
||||
<el-option label="普通" value="NORMAL" />
|
||||
<el-option label="重要" value="IMPORTANT" />
|
||||
<el-option label="紧急" value="URGENT" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="目标范围" prop="targetScope">
|
||||
<el-select v-model="form.targetScope" class="full-width">
|
||||
<el-option label="全部门店" value="ALL_STORES" />
|
||||
<el-option label="指定门店" value="STORES" />
|
||||
<el-option label="指定角色" value="ROLES" />
|
||||
<el-option label="指定员工" value="EMPLOYEES" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</div>
|
||||
<el-form-item v-if="form.targetScope === 'STORES'" label="目标门店">
|
||||
<el-select
|
||||
v-model="form.targetStoreIds"
|
||||
multiple
|
||||
filterable
|
||||
class="full-width"
|
||||
:loading="optionLoading"
|
||||
>
|
||||
<el-option
|
||||
v-for="store in stores"
|
||||
:key="store.id"
|
||||
:label="store.name"
|
||||
:value="store.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="form.targetScope === 'ROLES'" label="目标角色">
|
||||
<el-select
|
||||
v-model="form.targetRoleIds"
|
||||
multiple
|
||||
filterable
|
||||
class="full-width"
|
||||
:loading="optionLoading"
|
||||
>
|
||||
<el-option
|
||||
v-for="role in roles"
|
||||
:key="role.id"
|
||||
:label="`${role.name}(${role.code})`"
|
||||
:value="role.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="form.targetScope === 'EMPLOYEES'" label="目标员工">
|
||||
<el-select
|
||||
v-model="form.targetEmployeeIds"
|
||||
multiple
|
||||
filterable
|
||||
class="full-width"
|
||||
:loading="optionLoading"
|
||||
>
|
||||
<el-option
|
||||
v-for="employee in employees"
|
||||
:key="employee.id"
|
||||
:label="`${employee.name}(${employee.phone})`"
|
||||
:value="employee.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button :loading="submitLoading" @click="submitForm(false)">
|
||||
保存草稿
|
||||
</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
:loading="submitLoading"
|
||||
@click="submitForm(true)"
|
||||
>
|
||||
发布
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.operation-page {
|
||||
min-height: 100%;
|
||||
padding: 20px;
|
||||
background: #f6f8fb;
|
||||
}
|
||||
|
||||
.page-heading {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
|
||||
h1 {
|
||||
margin: 4px 0 0;
|
||||
font-size: 24px;
|
||||
font-weight: 650;
|
||||
color: #111827;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.toolbar,
|
||||
.table-shell {
|
||||
background: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 14px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.toolbar-control {
|
||||
width: 180px;
|
||||
}
|
||||
|
||||
.keyword-input {
|
||||
width: 260px;
|
||||
}
|
||||
|
||||
.toolbar-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.table-shell {
|
||||
padding: 8px 0 14px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pagination-row {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 16px 0;
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.full-width {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (width <= 760px) {
|
||||
.operation-page {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.page-heading {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.toolbar-control,
|
||||
.keyword-input,
|
||||
.toolbar-actions {
|
||||
width: 100%;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.toolbar-actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.pagination-row {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,381 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from "vue";
|
||||
import {
|
||||
listAllStores,
|
||||
listCredentialAudits,
|
||||
listEmployeeOptions,
|
||||
type CredentialAudit,
|
||||
type Employee,
|
||||
type Store
|
||||
} from "@/api/access";
|
||||
import { ElMessage } from "element-plus";
|
||||
|
||||
import Search from "~icons/ep/search";
|
||||
import Refresh from "~icons/ep/refresh";
|
||||
|
||||
defineOptions({
|
||||
name: "CredentialAuditManagement"
|
||||
});
|
||||
|
||||
const tableLoading = ref(false);
|
||||
const optionLoading = ref(false);
|
||||
const audits = ref<CredentialAudit[]>([]);
|
||||
const stores = ref<Store[]>([]);
|
||||
const employees = ref<Employee[]>([]);
|
||||
|
||||
const query = reactive({
|
||||
operatorId: undefined as number | undefined,
|
||||
targetEmployeeId: undefined as number | undefined,
|
||||
storeId: undefined as number | undefined,
|
||||
dateRange: [] as string[],
|
||||
page: 1,
|
||||
pageSize: 20
|
||||
});
|
||||
|
||||
const pagination = reactive({
|
||||
total: 0,
|
||||
totalPages: 0
|
||||
});
|
||||
|
||||
function getErrorMessage(error: unknown, fallback: string) {
|
||||
const message = (
|
||||
error as { response?: { data?: { error?: { message?: string } } } }
|
||||
)?.response?.data?.error?.message;
|
||||
|
||||
if (message) return message;
|
||||
if (error instanceof Error && error.message) return error.message;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function formatTime(value: string) {
|
||||
return new Date(value).toLocaleString("zh-CN", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit"
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchOptions() {
|
||||
optionLoading.value = true;
|
||||
try {
|
||||
const [storeResult, employeeResult] = await Promise.all([
|
||||
listAllStores({ includeInactive: true }),
|
||||
listEmployeeOptions()
|
||||
]);
|
||||
stores.value = storeResult.data;
|
||||
employees.value = employeeResult.data;
|
||||
} catch (error) {
|
||||
ElMessage.error(getErrorMessage(error, "加载筛选选项失败"));
|
||||
} finally {
|
||||
optionLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAudits() {
|
||||
tableLoading.value = true;
|
||||
try {
|
||||
const result = await listCredentialAudits({
|
||||
operatorId: query.operatorId,
|
||||
targetEmployeeId: query.targetEmployeeId,
|
||||
storeId: query.storeId,
|
||||
startDate: query.dateRange?.[0],
|
||||
endDate: query.dateRange?.[1],
|
||||
page: query.page,
|
||||
pageSize: query.pageSize
|
||||
});
|
||||
audits.value = result.data.items;
|
||||
pagination.total = result.data.pagination.total;
|
||||
pagination.totalPages = result.data.pagination.totalPages;
|
||||
} catch (error) {
|
||||
ElMessage.error(getErrorMessage(error, "加载凭据审计失败"));
|
||||
} finally {
|
||||
tableLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
query.page = 1;
|
||||
fetchAudits();
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
query.operatorId = undefined;
|
||||
query.targetEmployeeId = undefined;
|
||||
query.storeId = undefined;
|
||||
query.dateRange = [];
|
||||
query.page = 1;
|
||||
query.pageSize = 20;
|
||||
fetchAudits();
|
||||
}
|
||||
|
||||
function handlePageChange(page: number) {
|
||||
query.page = page;
|
||||
fetchAudits();
|
||||
}
|
||||
|
||||
function handleSizeChange(pageSize: number) {
|
||||
query.page = 1;
|
||||
query.pageSize = pageSize;
|
||||
fetchAudits();
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([fetchOptions(), fetchAudits()]);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="operation-page">
|
||||
<div class="page-heading">
|
||||
<div>
|
||||
<p class="eyebrow">员工工作台运营</p>
|
||||
<h1>凭据审计</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toolbar">
|
||||
<el-select
|
||||
v-model="query.operatorId"
|
||||
clearable
|
||||
filterable
|
||||
placeholder="全部操作者"
|
||||
class="toolbar-control"
|
||||
:loading="optionLoading"
|
||||
@change="handleSearch"
|
||||
>
|
||||
<el-option
|
||||
v-for="employee in employees"
|
||||
:key="employee.id"
|
||||
:label="`${employee.name}(${employee.phone})`"
|
||||
:value="employee.id"
|
||||
/>
|
||||
</el-select>
|
||||
<el-select
|
||||
v-model="query.targetEmployeeId"
|
||||
clearable
|
||||
filterable
|
||||
placeholder="全部目标员工"
|
||||
class="toolbar-control"
|
||||
:loading="optionLoading"
|
||||
@change="handleSearch"
|
||||
>
|
||||
<el-option
|
||||
v-for="employee in employees"
|
||||
:key="employee.id"
|
||||
:label="`${employee.name}(${employee.phone})`"
|
||||
:value="employee.id"
|
||||
/>
|
||||
</el-select>
|
||||
<el-select
|
||||
v-model="query.storeId"
|
||||
clearable
|
||||
filterable
|
||||
placeholder="全部门店"
|
||||
class="toolbar-control"
|
||||
:loading="optionLoading"
|
||||
@change="handleSearch"
|
||||
>
|
||||
<el-option
|
||||
v-for="store in stores"
|
||||
:key="store.id"
|
||||
:label="store.name"
|
||||
:value="store.id"
|
||||
/>
|
||||
</el-select>
|
||||
<el-date-picker
|
||||
v-model="query.dateRange"
|
||||
type="daterange"
|
||||
value-format="YYYY-MM-DD"
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
class="date-range"
|
||||
@change="handleSearch"
|
||||
/>
|
||||
<div class="toolbar-actions">
|
||||
<el-button type="primary" :icon="Search" @click="handleSearch">
|
||||
查询
|
||||
</el-button>
|
||||
<el-button :icon="Refresh" @click="handleReset">重置</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-shell">
|
||||
<el-table v-loading="tableLoading" :data="audits" row-key="id" stripe>
|
||||
<el-table-column prop="operatorName" label="操作者" min-width="140" />
|
||||
<el-table-column label="目标员工" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<div class="employee-cell">
|
||||
<strong>{{ row.targetEmployeeName }}</strong>
|
||||
<span>{{ row.targetEmployeePhone || "-" }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="storeName" label="门店" min-width="150">
|
||||
<template #default="{ row }">{{ row.storeName || "-" }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="action" label="动作" min-width="140" />
|
||||
<el-table-column label="时间" width="180">
|
||||
<template #default="{ row }">{{
|
||||
formatTime(row.operatedAt)
|
||||
}}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="ip" label="IP" min-width="140">
|
||||
<template #default="{ row }">{{ row.ip || "-" }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="userAgent" label="User-Agent" min-width="260">
|
||||
<template #default="{ row }">
|
||||
<span class="wrap-text">{{ row.userAgent || "-" }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="reason" label="原因" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<span class="wrap-text">{{ row.reason || "-" }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="pagination-row">
|
||||
<span
|
||||
>共 {{ pagination.total }} 条,{{ pagination.totalPages }} 页</span
|
||||
>
|
||||
<el-pagination
|
||||
v-model:current-page="query.page"
|
||||
v-model:page-size="query.pageSize"
|
||||
background
|
||||
layout="sizes, prev, pager, next"
|
||||
:total="pagination.total"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
@current-change="handlePageChange"
|
||||
@size-change="handleSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.operation-page {
|
||||
min-height: 100%;
|
||||
padding: 20px;
|
||||
background: #f6f8fb;
|
||||
}
|
||||
|
||||
.page-heading {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
|
||||
h1 {
|
||||
margin: 4px 0 0;
|
||||
font-size: 24px;
|
||||
font-weight: 650;
|
||||
color: #111827;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.toolbar,
|
||||
.table-shell {
|
||||
background: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 14px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.toolbar-control {
|
||||
width: 190px;
|
||||
}
|
||||
|
||||
.date-range {
|
||||
width: 300px;
|
||||
}
|
||||
|
||||
.toolbar-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.table-shell {
|
||||
padding: 8px 0 14px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.employee-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
|
||||
strong {
|
||||
font-weight: 650;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
}
|
||||
|
||||
.wrap-text {
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.pagination-row {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 16px 0;
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
@media (width <= 760px) {
|
||||
.operation-page {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.page-heading {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.toolbar-control,
|
||||
.date-range,
|
||||
.toolbar-actions {
|
||||
width: 100%;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.toolbar-actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.pagination-row {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+101
-81
@@ -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<FormInstance>();
|
||||
const passwordFormRef = ref<FormInstance>();
|
||||
const employees = ref<Employee[]>([]);
|
||||
const stores = ref<Store[]>([]);
|
||||
const roles = ref<RoleOption[]>([]);
|
||||
const originalStoreId = ref<number>();
|
||||
const temporaryPassword = ref("");
|
||||
|
||||
const query = reactive({
|
||||
storeId: undefined as number | undefined,
|
||||
@@ -101,8 +102,7 @@ const form = reactive<EmployeeFormState>({
|
||||
const passwordForm = reactive<PasswordFormState>({
|
||||
employeeId: undefined,
|
||||
employeeName: "",
|
||||
oldPassword: "",
|
||||
newPassword: ""
|
||||
reason: ""
|
||||
});
|
||||
|
||||
const rules: FormRules<EmployeeFormState> = {
|
||||
@@ -125,21 +125,12 @@ const rules: FormRules<EmployeeFormState> = {
|
||||
};
|
||||
|
||||
const passwordRules: FormRules<PasswordFormState> = {
|
||||
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" ? "停用" : "启用" }}
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="canUpdateEmployee"
|
||||
link
|
||||
type="primary"
|
||||
:icon="Key"
|
||||
@click="openPasswordDialog(row)"
|
||||
>
|
||||
改密
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="canUpdateEmployee"
|
||||
v-if="canResetCredential"
|
||||
link
|
||||
type="warning"
|
||||
:icon="RefreshLeft"
|
||||
@click="resetPassword(row)"
|
||||
:icon="Key"
|
||||
@click="openPasswordDialog(row)"
|
||||
>
|
||||
重置密码
|
||||
</el-button>
|
||||
@@ -823,8 +804,8 @@ onMounted(async () => {
|
||||
|
||||
<el-dialog
|
||||
v-model="passwordDialogVisible"
|
||||
title="修改员工密码"
|
||||
width="420px"
|
||||
title="重置员工密码"
|
||||
width="460px"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form
|
||||
@@ -836,22 +817,21 @@ onMounted(async () => {
|
||||
<el-form-item label="员工">
|
||||
<el-input v-model="passwordForm.employeeName" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item label="旧密码" prop="oldPassword">
|
||||
<el-alert
|
||||
type="warning"
|
||||
show-icon
|
||||
:closable="false"
|
||||
class="password-alert"
|
||||
title="不会展示员工当前密码。提交后仅在下一步弹窗显示一次临时密码。"
|
||||
/>
|
||||
<el-form-item label="重置原因" prop="reason">
|
||||
<el-input
|
||||
v-model="passwordForm.oldPassword"
|
||||
type="password"
|
||||
maxlength="128"
|
||||
show-password
|
||||
placeholder="请输入旧密码"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="新密码" prop="newPassword">
|
||||
<el-input
|
||||
v-model="passwordForm.newPassword"
|
||||
type="password"
|
||||
maxlength="128"
|
||||
show-password
|
||||
placeholder="请输入 8-128 位新密码"
|
||||
v-model="passwordForm.reason"
|
||||
type="textarea"
|
||||
maxlength="255"
|
||||
show-word-limit
|
||||
:rows="4"
|
||||
placeholder="请填写重置原因,用于凭据审计"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
@@ -863,7 +843,39 @@ onMounted(async () => {
|
||||
:loading="passwordSubmitLoading"
|
||||
@click="submitPasswordForm"
|
||||
>
|
||||
保存
|
||||
重置并生成临时密码
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog
|
||||
v-model="temporaryPasswordVisible"
|
||||
title="一次性临时密码"
|
||||
width="460px"
|
||||
destroy-on-close
|
||||
@closed="clearTemporaryPassword"
|
||||
>
|
||||
<el-alert
|
||||
type="warning"
|
||||
show-icon
|
||||
:closable="false"
|
||||
class="password-alert"
|
||||
title="关闭弹窗后前端会清空临时密码,请立即复制并通过安全渠道交付员工。"
|
||||
/>
|
||||
<el-input
|
||||
:model-value="temporaryPassword"
|
||||
readonly
|
||||
class="temporary-password"
|
||||
@focus="showPasswordResetBoundary"
|
||||
/>
|
||||
<template #footer>
|
||||
<el-button @click="temporaryPasswordVisible = false">关闭</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
:icon="CopyDocument"
|
||||
@click="copyTemporaryPassword"
|
||||
>
|
||||
复制临时密码
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,603 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import {
|
||||
ElMessage,
|
||||
ElMessageBox,
|
||||
type FormInstance,
|
||||
type FormRules
|
||||
} from "element-plus";
|
||||
import {
|
||||
cancelShift,
|
||||
createShift,
|
||||
listAllStores,
|
||||
listEmployeeOptions,
|
||||
listShifts,
|
||||
updateShift,
|
||||
type Employee,
|
||||
type Shift,
|
||||
type ShiftPayload,
|
||||
type ShiftStatus,
|
||||
type Store
|
||||
} from "@/api/access";
|
||||
import { hasMenuAction, hasPerms } from "@/utils/auth";
|
||||
|
||||
import Plus from "~icons/ep/plus";
|
||||
import Search from "~icons/ep/search";
|
||||
import Refresh from "~icons/ep/refresh";
|
||||
import EditPen from "~icons/ep/edit-pen";
|
||||
import CircleClose from "~icons/ep/circle-close";
|
||||
|
||||
defineOptions({
|
||||
name: "ShiftManagement"
|
||||
});
|
||||
|
||||
type ShiftFormState = ShiftPayload & {
|
||||
id?: number;
|
||||
};
|
||||
|
||||
const tableLoading = ref(false);
|
||||
const optionLoading = ref(false);
|
||||
const submitLoading = ref(false);
|
||||
const dialogVisible = ref(false);
|
||||
const formRef = ref<FormInstance>();
|
||||
const shifts = ref<Shift[]>([]);
|
||||
const stores = ref<Store[]>([]);
|
||||
const employees = ref<Employee[]>([]);
|
||||
|
||||
const query = reactive({
|
||||
storeId: undefined as number | undefined,
|
||||
employeeId: undefined as number | undefined,
|
||||
dateRange: [] as string[],
|
||||
page: 1,
|
||||
pageSize: 20
|
||||
});
|
||||
|
||||
const pagination = reactive({
|
||||
total: 0,
|
||||
totalPages: 0
|
||||
});
|
||||
|
||||
const form = reactive<ShiftFormState>({
|
||||
storeId: undefined as unknown as number,
|
||||
employeeId: undefined as unknown as number,
|
||||
position: "",
|
||||
startAt: "",
|
||||
endAt: ""
|
||||
});
|
||||
|
||||
const rules: FormRules<ShiftFormState> = {
|
||||
storeId: [{ required: true, message: "请选择门店", trigger: "change" }],
|
||||
employeeId: [{ required: true, message: "请选择员工", trigger: "change" }],
|
||||
position: [
|
||||
{ required: true, message: "请输入岗位", trigger: "blur" },
|
||||
{ max: 50, message: "岗位不能超过 50 个字符", trigger: "blur" }
|
||||
],
|
||||
startAt: [{ required: true, message: "请选择开始时间", trigger: "change" }],
|
||||
endAt: [{ required: true, message: "请选择结束时间", trigger: "change" }]
|
||||
};
|
||||
|
||||
const dialogTitle = computed(() => (form.id ? "编辑排班" : "新增排班"));
|
||||
const canManage = computed(
|
||||
() => hasMenuAction("shifts", "manage") || hasPerms("shift:manage")
|
||||
);
|
||||
|
||||
const statusMap: Record<ShiftStatus, { label: string; type: string }> = {
|
||||
SCHEDULED: { label: "已排班", type: "success" },
|
||||
CANCELLED: { label: "已取消", type: "danger" },
|
||||
COMPLETED: { label: "已完成", type: "info" }
|
||||
};
|
||||
|
||||
function getErrorMessage(error: unknown, fallback: string) {
|
||||
const message = (
|
||||
error as { response?: { data?: { error?: { message?: string } } } }
|
||||
)?.response?.data?.error?.message;
|
||||
|
||||
if (message) return message;
|
||||
if (error instanceof Error && error.message) return error.message;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function formatTime(value: string) {
|
||||
return new Date(value).toLocaleString("zh-CN", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit"
|
||||
});
|
||||
}
|
||||
|
||||
function resetFormState() {
|
||||
Object.assign(form, {
|
||||
id: undefined,
|
||||
storeId: undefined,
|
||||
employeeId: undefined,
|
||||
position: "",
|
||||
startAt: "",
|
||||
endAt: ""
|
||||
});
|
||||
formRef.value?.clearValidate();
|
||||
}
|
||||
|
||||
function buildPayload(): ShiftPayload {
|
||||
return {
|
||||
storeId: form.storeId,
|
||||
employeeId: form.employeeId,
|
||||
position: form.position.trim(),
|
||||
startAt: form.startAt,
|
||||
endAt: form.endAt
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchOptions(storeId?: number) {
|
||||
optionLoading.value = true;
|
||||
try {
|
||||
const [storeResult, employeeResult] = await Promise.all([
|
||||
listAllStores({ includeInactive: true }),
|
||||
listEmployeeOptions({ storeId })
|
||||
]);
|
||||
stores.value = storeResult.data;
|
||||
employees.value = employeeResult.data;
|
||||
} catch (error) {
|
||||
ElMessage.error(getErrorMessage(error, "加载门店和员工选项失败"));
|
||||
} finally {
|
||||
optionLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchShifts() {
|
||||
tableLoading.value = true;
|
||||
try {
|
||||
const result = await listShifts({
|
||||
storeId: query.storeId,
|
||||
employeeId: query.employeeId,
|
||||
startDate: query.dateRange?.[0],
|
||||
endDate: query.dateRange?.[1],
|
||||
page: query.page,
|
||||
pageSize: query.pageSize
|
||||
});
|
||||
shifts.value = result.data.items;
|
||||
pagination.total = result.data.pagination.total;
|
||||
pagination.totalPages = result.data.pagination.totalPages;
|
||||
} catch (error) {
|
||||
ElMessage.error(getErrorMessage(error, "加载排班列表失败"));
|
||||
} finally {
|
||||
tableLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
query.page = 1;
|
||||
fetchShifts();
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
query.storeId = undefined;
|
||||
query.employeeId = undefined;
|
||||
query.dateRange = [];
|
||||
query.page = 1;
|
||||
query.pageSize = 20;
|
||||
fetchShifts();
|
||||
}
|
||||
|
||||
function handlePageChange(page: number) {
|
||||
query.page = page;
|
||||
fetchShifts();
|
||||
}
|
||||
|
||||
function handleSizeChange(pageSize: number) {
|
||||
query.page = 1;
|
||||
query.pageSize = pageSize;
|
||||
fetchShifts();
|
||||
}
|
||||
|
||||
function openCreateDialog() {
|
||||
if (!canManage.value) return;
|
||||
resetFormState();
|
||||
dialogVisible.value = true;
|
||||
fetchOptions();
|
||||
}
|
||||
|
||||
function openEditDialog(row: Shift) {
|
||||
if (!canManage.value) return;
|
||||
Object.assign(form, {
|
||||
id: row.id,
|
||||
storeId: row.storeId,
|
||||
employeeId: row.employeeId,
|
||||
position: row.position,
|
||||
startAt: row.startAt,
|
||||
endAt: row.endAt
|
||||
});
|
||||
dialogVisible.value = true;
|
||||
fetchOptions(row.storeId);
|
||||
formRef.value?.clearValidate();
|
||||
}
|
||||
|
||||
async function handleFormStoreChange(storeId: number) {
|
||||
form.employeeId = undefined as unknown as number;
|
||||
await fetchOptions(storeId);
|
||||
}
|
||||
|
||||
async function submitForm() {
|
||||
if (!canManage.value) return;
|
||||
await formRef.value?.validate();
|
||||
|
||||
if (new Date(form.startAt).getTime() >= new Date(form.endAt).getTime()) {
|
||||
ElMessage.warning("结束时间必须晚于开始时间");
|
||||
return;
|
||||
}
|
||||
|
||||
submitLoading.value = true;
|
||||
|
||||
try {
|
||||
const payload = buildPayload();
|
||||
|
||||
if (form.id) {
|
||||
await updateShift(form.id, payload);
|
||||
ElMessage.success("排班已更新");
|
||||
} else {
|
||||
await createShift(payload);
|
||||
ElMessage.success("排班已创建");
|
||||
}
|
||||
|
||||
dialogVisible.value = false;
|
||||
fetchShifts();
|
||||
} catch (error) {
|
||||
ElMessage.error(getErrorMessage(error, "保存排班失败"));
|
||||
} finally {
|
||||
submitLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCancel(row: Shift) {
|
||||
if (!canManage.value) return;
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确认取消「${row.employeeName}」这条排班?`,
|
||||
"取消排班",
|
||||
{
|
||||
type: "warning",
|
||||
confirmButtonText: "取消排班",
|
||||
cancelButtonText: "关闭"
|
||||
}
|
||||
);
|
||||
await cancelShift(row.id);
|
||||
ElMessage.success("排班已取消");
|
||||
fetchShifts();
|
||||
} catch (error) {
|
||||
if (error !== "cancel") {
|
||||
ElMessage.error(getErrorMessage(error, "取消排班失败"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([fetchOptions(), fetchShifts()]);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="operation-page">
|
||||
<div class="page-heading">
|
||||
<div>
|
||||
<p class="eyebrow">员工工作台运营</p>
|
||||
<h1>排班管理</h1>
|
||||
</div>
|
||||
<el-button
|
||||
v-if="canManage"
|
||||
type="primary"
|
||||
:icon="Plus"
|
||||
@click="openCreateDialog"
|
||||
>
|
||||
新增排班
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div class="toolbar">
|
||||
<el-select
|
||||
v-model="query.storeId"
|
||||
clearable
|
||||
filterable
|
||||
placeholder="全部门店"
|
||||
class="toolbar-control"
|
||||
:loading="optionLoading"
|
||||
@change="handleSearch"
|
||||
>
|
||||
<el-option
|
||||
v-for="store in stores"
|
||||
:key="store.id"
|
||||
:label="store.name"
|
||||
:value="store.id"
|
||||
/>
|
||||
</el-select>
|
||||
<el-select
|
||||
v-model="query.employeeId"
|
||||
clearable
|
||||
filterable
|
||||
placeholder="全部员工"
|
||||
class="toolbar-control"
|
||||
:loading="optionLoading"
|
||||
@change="handleSearch"
|
||||
>
|
||||
<el-option
|
||||
v-for="employee in employees"
|
||||
:key="employee.id"
|
||||
:label="`${employee.name}(${employee.phone})`"
|
||||
:value="employee.id"
|
||||
/>
|
||||
</el-select>
|
||||
<el-date-picker
|
||||
v-model="query.dateRange"
|
||||
type="daterange"
|
||||
value-format="YYYY-MM-DD"
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
class="date-range"
|
||||
@change="handleSearch"
|
||||
/>
|
||||
<div class="toolbar-actions">
|
||||
<el-button type="primary" :icon="Search" @click="handleSearch">
|
||||
查询
|
||||
</el-button>
|
||||
<el-button :icon="Refresh" @click="handleReset">重置</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-shell">
|
||||
<el-table v-loading="tableLoading" :data="shifts" row-key="id" stripe>
|
||||
<el-table-column prop="storeName" label="门店" min-width="160" />
|
||||
<el-table-column prop="employeeName" label="员工" min-width="140" />
|
||||
<el-table-column prop="position" label="岗位" min-width="120" />
|
||||
<el-table-column label="开始时间" width="180">
|
||||
<template #default="{ row }">{{ formatTime(row.startAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="结束时间" width="180">
|
||||
<template #default="{ row }">{{ formatTime(row.endAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="110">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="statusMap[row.status].type" effect="light">
|
||||
{{ statusMap[row.status].label }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
v-if="canManage"
|
||||
label="操作"
|
||||
width="170"
|
||||
fixed="right"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
link
|
||||
type="primary"
|
||||
:icon="EditPen"
|
||||
@click="openEditDialog(row)"
|
||||
>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="row.status === 'SCHEDULED'"
|
||||
link
|
||||
type="warning"
|
||||
:icon="CircleClose"
|
||||
@click="handleCancel(row)"
|
||||
>
|
||||
取消
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="pagination-row">
|
||||
<span
|
||||
>共 {{ pagination.total }} 条,{{ pagination.totalPages }} 页</span
|
||||
>
|
||||
<el-pagination
|
||||
v-model:current-page="query.page"
|
||||
v-model:page-size="query.pageSize"
|
||||
background
|
||||
layout="sizes, prev, pager, next"
|
||||
:total="pagination.total"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
@current-change="handlePageChange"
|
||||
@size-change="handleSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="dialogTitle"
|
||||
width="560px"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-position="top">
|
||||
<div class="form-grid">
|
||||
<el-form-item label="门店" prop="storeId">
|
||||
<el-select
|
||||
v-model="form.storeId"
|
||||
filterable
|
||||
class="full-width"
|
||||
:loading="optionLoading"
|
||||
@change="handleFormStoreChange"
|
||||
>
|
||||
<el-option
|
||||
v-for="store in stores"
|
||||
:key="store.id"
|
||||
:label="store.name"
|
||||
:value="store.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="员工" prop="employeeId">
|
||||
<el-select
|
||||
v-model="form.employeeId"
|
||||
filterable
|
||||
class="full-width"
|
||||
:loading="optionLoading"
|
||||
>
|
||||
<el-option
|
||||
v-for="employee in employees"
|
||||
:key="employee.id"
|
||||
:label="`${employee.name}(${employee.phone})`"
|
||||
:value="employee.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</div>
|
||||
<el-form-item label="岗位" prop="position">
|
||||
<el-input
|
||||
v-model="form.position"
|
||||
maxlength="50"
|
||||
placeholder="请输入"
|
||||
/>
|
||||
</el-form-item>
|
||||
<div class="form-grid">
|
||||
<el-form-item label="开始时间" prop="startAt">
|
||||
<el-date-picker
|
||||
v-model="form.startAt"
|
||||
type="datetime"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
class="full-width"
|
||||
placeholder="请选择开始时间"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="结束时间" prop="endAt">
|
||||
<el-date-picker
|
||||
v-model="form.endAt"
|
||||
type="datetime"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
class="full-width"
|
||||
placeholder="请选择结束时间"
|
||||
/>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitLoading" @click="submitForm">
|
||||
保存
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.operation-page {
|
||||
min-height: 100%;
|
||||
padding: 20px;
|
||||
background: #f6f8fb;
|
||||
}
|
||||
|
||||
.page-heading {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
|
||||
h1 {
|
||||
margin: 4px 0 0;
|
||||
font-size: 24px;
|
||||
font-weight: 650;
|
||||
color: #111827;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.toolbar,
|
||||
.table-shell {
|
||||
background: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 14px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.toolbar-control {
|
||||
width: 180px;
|
||||
}
|
||||
|
||||
.date-range {
|
||||
width: 300px;
|
||||
}
|
||||
|
||||
.toolbar-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.table-shell {
|
||||
padding: 8px 0 14px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pagination-row {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 16px 0;
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.full-width {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (width <= 760px) {
|
||||
.operation-page {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.page-heading {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.toolbar-control,
|
||||
.date-range,
|
||||
.toolbar-actions {
|
||||
width: 100%;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.toolbar-actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.pagination-row {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,702 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import {
|
||||
ElMessage,
|
||||
ElMessageBox,
|
||||
type FormInstance,
|
||||
type FormRules
|
||||
} from "element-plus";
|
||||
import {
|
||||
cancelTask,
|
||||
createTask,
|
||||
listAllStores,
|
||||
listEmployeeOptions,
|
||||
listTasks,
|
||||
updateTask,
|
||||
type Employee,
|
||||
type Store,
|
||||
type Task,
|
||||
type TaskPayload,
|
||||
type TaskPriority,
|
||||
type TaskStatus
|
||||
} from "@/api/access";
|
||||
import { hasMenuAction, hasPerms } from "@/utils/auth";
|
||||
|
||||
import Plus from "~icons/ep/plus";
|
||||
import Search from "~icons/ep/search";
|
||||
import Refresh from "~icons/ep/refresh";
|
||||
import EditPen from "~icons/ep/edit-pen";
|
||||
import CircleClose from "~icons/ep/circle-close";
|
||||
|
||||
defineOptions({
|
||||
name: "TaskManagement"
|
||||
});
|
||||
|
||||
type TaskFormState = TaskPayload & {
|
||||
id?: number;
|
||||
};
|
||||
|
||||
const tableLoading = ref(false);
|
||||
const optionLoading = ref(false);
|
||||
const submitLoading = ref(false);
|
||||
const dialogVisible = ref(false);
|
||||
const formRef = ref<FormInstance>();
|
||||
const tasks = ref<Task[]>([]);
|
||||
const stores = ref<Store[]>([]);
|
||||
const employees = ref<Employee[]>([]);
|
||||
|
||||
const query = reactive({
|
||||
storeId: undefined as number | undefined,
|
||||
employeeId: undefined as number | undefined,
|
||||
status: undefined as TaskStatus | undefined,
|
||||
priority: undefined as TaskPriority | undefined,
|
||||
keyword: "",
|
||||
page: 1,
|
||||
pageSize: 20
|
||||
});
|
||||
|
||||
const pagination = reactive({
|
||||
total: 0,
|
||||
totalPages: 0
|
||||
});
|
||||
|
||||
const form = reactive<TaskFormState>({
|
||||
title: "",
|
||||
description: "",
|
||||
storeId: undefined as unknown as number,
|
||||
assigneeIds: [],
|
||||
priority: "NORMAL",
|
||||
deadlineAt: ""
|
||||
});
|
||||
|
||||
const rules: FormRules<TaskFormState> = {
|
||||
title: [
|
||||
{ required: true, message: "请输入任务标题", trigger: "blur" },
|
||||
{ max: 100, message: "标题不能超过 100 个字符", trigger: "blur" }
|
||||
],
|
||||
description: [
|
||||
{ max: 2000, message: "描述不能超过 2000 个字符", trigger: "blur" }
|
||||
],
|
||||
storeId: [{ required: true, message: "请选择目标门店", trigger: "change" }],
|
||||
assigneeIds: [
|
||||
{
|
||||
type: "array",
|
||||
required: true,
|
||||
message: "请选择分配员工",
|
||||
trigger: "change"
|
||||
}
|
||||
],
|
||||
priority: [{ required: true, message: "请选择优先级", trigger: "change" }]
|
||||
};
|
||||
|
||||
const dialogTitle = computed(() => (form.id ? "编辑任务" : "新建任务"));
|
||||
const canManage = computed(
|
||||
() => hasMenuAction("tasks", "manage") || hasPerms("task:manage")
|
||||
);
|
||||
|
||||
const statusMap: Record<TaskStatus, { label: string; type: string }> = {
|
||||
PENDING: { label: "待处理", type: "info" },
|
||||
IN_PROGRESS: { label: "处理中", type: "warning" },
|
||||
COMPLETED: { label: "已完成", type: "success" },
|
||||
CANCELLED: { label: "已取消", type: "danger" }
|
||||
};
|
||||
|
||||
const priorityMap: Record<TaskPriority, { label: string; type: string }> = {
|
||||
LOW: { label: "低", type: "info" },
|
||||
NORMAL: { label: "普通", type: "" },
|
||||
HIGH: { label: "高", type: "warning" },
|
||||
URGENT: { label: "紧急", type: "danger" }
|
||||
};
|
||||
|
||||
function getErrorMessage(error: unknown, fallback: string) {
|
||||
const message = (
|
||||
error as { response?: { data?: { error?: { message?: string } } } }
|
||||
)?.response?.data?.error?.message;
|
||||
|
||||
if (message) return message;
|
||||
if (error instanceof Error && error.message) return error.message;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function formatTime(value?: string | null) {
|
||||
if (!value) return "-";
|
||||
return new Date(value).toLocaleString("zh-CN", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit"
|
||||
});
|
||||
}
|
||||
|
||||
function resetFormState() {
|
||||
Object.assign(form, {
|
||||
id: undefined,
|
||||
title: "",
|
||||
description: "",
|
||||
storeId: undefined,
|
||||
assigneeIds: [],
|
||||
priority: "NORMAL",
|
||||
deadlineAt: ""
|
||||
});
|
||||
formRef.value?.clearValidate();
|
||||
}
|
||||
|
||||
function buildPayload(): TaskPayload {
|
||||
const description = form.description?.trim();
|
||||
|
||||
return {
|
||||
title: form.title.trim(),
|
||||
description: description ? description : null,
|
||||
storeId: form.storeId,
|
||||
assigneeIds: form.assigneeIds,
|
||||
priority: form.priority,
|
||||
deadlineAt: form.deadlineAt || null
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchOptions(storeId?: number) {
|
||||
optionLoading.value = true;
|
||||
try {
|
||||
const [storeResult, employeeResult] = await Promise.all([
|
||||
listAllStores({ includeInactive: true }),
|
||||
listEmployeeOptions({ storeId })
|
||||
]);
|
||||
stores.value = storeResult.data;
|
||||
employees.value = employeeResult.data;
|
||||
} catch (error) {
|
||||
ElMessage.error(getErrorMessage(error, "加载门店和员工选项失败"));
|
||||
} finally {
|
||||
optionLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchTasks() {
|
||||
tableLoading.value = true;
|
||||
try {
|
||||
const result = await listTasks({
|
||||
storeId: query.storeId,
|
||||
employeeId: query.employeeId,
|
||||
status: query.status,
|
||||
priority: query.priority,
|
||||
keyword: query.keyword.trim() || undefined,
|
||||
page: query.page,
|
||||
pageSize: query.pageSize
|
||||
});
|
||||
tasks.value = result.data.items;
|
||||
pagination.total = result.data.pagination.total;
|
||||
pagination.totalPages = result.data.pagination.totalPages;
|
||||
} catch (error) {
|
||||
ElMessage.error(getErrorMessage(error, "加载任务列表失败"));
|
||||
} finally {
|
||||
tableLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
query.page = 1;
|
||||
fetchTasks();
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
query.storeId = undefined;
|
||||
query.employeeId = undefined;
|
||||
query.status = undefined;
|
||||
query.priority = undefined;
|
||||
query.keyword = "";
|
||||
query.page = 1;
|
||||
query.pageSize = 20;
|
||||
fetchTasks();
|
||||
}
|
||||
|
||||
function handlePageChange(page: number) {
|
||||
query.page = page;
|
||||
fetchTasks();
|
||||
}
|
||||
|
||||
function handleSizeChange(pageSize: number) {
|
||||
query.page = 1;
|
||||
query.pageSize = pageSize;
|
||||
fetchTasks();
|
||||
}
|
||||
|
||||
function openCreateDialog() {
|
||||
if (!canManage.value) return;
|
||||
resetFormState();
|
||||
dialogVisible.value = true;
|
||||
fetchOptions();
|
||||
}
|
||||
|
||||
function openEditDialog(row: Task) {
|
||||
if (!canManage.value) return;
|
||||
Object.assign(form, {
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
description: row.description ?? "",
|
||||
storeId: row.storeId,
|
||||
assigneeIds: row.assigneeIds ?? [],
|
||||
priority: row.priority,
|
||||
deadlineAt: row.deadlineAt ?? ""
|
||||
});
|
||||
dialogVisible.value = true;
|
||||
fetchOptions(row.storeId);
|
||||
formRef.value?.clearValidate();
|
||||
}
|
||||
|
||||
async function handleFormStoreChange(storeId: number) {
|
||||
form.assigneeIds = [];
|
||||
await fetchOptions(storeId);
|
||||
}
|
||||
|
||||
async function submitForm() {
|
||||
if (!canManage.value) return;
|
||||
await formRef.value?.validate();
|
||||
submitLoading.value = true;
|
||||
|
||||
try {
|
||||
const payload = buildPayload();
|
||||
|
||||
if (form.id) {
|
||||
await updateTask(form.id, payload);
|
||||
ElMessage.success("任务已更新");
|
||||
} else {
|
||||
await createTask(payload);
|
||||
ElMessage.success("任务已创建");
|
||||
}
|
||||
|
||||
dialogVisible.value = false;
|
||||
fetchTasks();
|
||||
} catch (error) {
|
||||
ElMessage.error(getErrorMessage(error, "保存任务失败"));
|
||||
} finally {
|
||||
submitLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCancel(row: Task) {
|
||||
if (!canManage.value) return;
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认取消任务「${row.title}」?`, "取消任务", {
|
||||
type: "warning",
|
||||
confirmButtonText: "取消任务",
|
||||
cancelButtonText: "关闭"
|
||||
});
|
||||
await cancelTask(row.id);
|
||||
ElMessage.success("任务已取消");
|
||||
fetchTasks();
|
||||
} catch (error) {
|
||||
if (error !== "cancel") {
|
||||
ElMessage.error(getErrorMessage(error, "取消任务失败"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([fetchOptions(), fetchTasks()]);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="operation-page">
|
||||
<div class="page-heading">
|
||||
<div>
|
||||
<p class="eyebrow">员工工作台运营</p>
|
||||
<h1>任务管理</h1>
|
||||
</div>
|
||||
<el-button
|
||||
v-if="canManage"
|
||||
type="primary"
|
||||
:icon="Plus"
|
||||
@click="openCreateDialog"
|
||||
>
|
||||
新建任务
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div class="toolbar">
|
||||
<el-select
|
||||
v-model="query.storeId"
|
||||
clearable
|
||||
filterable
|
||||
placeholder="全部门店"
|
||||
class="toolbar-control"
|
||||
:loading="optionLoading"
|
||||
@change="handleSearch"
|
||||
>
|
||||
<el-option
|
||||
v-for="store in stores"
|
||||
:key="store.id"
|
||||
:label="store.name"
|
||||
:value="store.id"
|
||||
/>
|
||||
</el-select>
|
||||
<el-select
|
||||
v-model="query.employeeId"
|
||||
clearable
|
||||
filterable
|
||||
placeholder="全部员工"
|
||||
class="toolbar-control"
|
||||
:loading="optionLoading"
|
||||
@change="handleSearch"
|
||||
>
|
||||
<el-option
|
||||
v-for="employee in employees"
|
||||
:key="employee.id"
|
||||
:label="`${employee.name}(${employee.phone})`"
|
||||
:value="employee.id"
|
||||
/>
|
||||
</el-select>
|
||||
<el-select
|
||||
v-model="query.status"
|
||||
clearable
|
||||
placeholder="全部状态"
|
||||
class="toolbar-control"
|
||||
@change="handleSearch"
|
||||
>
|
||||
<el-option label="待处理" value="PENDING" />
|
||||
<el-option label="处理中" value="IN_PROGRESS" />
|
||||
<el-option label="已完成" value="COMPLETED" />
|
||||
<el-option label="已取消" value="CANCELLED" />
|
||||
</el-select>
|
||||
<el-select
|
||||
v-model="query.priority"
|
||||
clearable
|
||||
placeholder="全部优先级"
|
||||
class="toolbar-control"
|
||||
@change="handleSearch"
|
||||
>
|
||||
<el-option label="低" value="LOW" />
|
||||
<el-option label="普通" value="NORMAL" />
|
||||
<el-option label="高" value="HIGH" />
|
||||
<el-option label="紧急" value="URGENT" />
|
||||
</el-select>
|
||||
<el-input
|
||||
v-model="query.keyword"
|
||||
clearable
|
||||
class="keyword-input"
|
||||
placeholder="搜索标题或描述"
|
||||
@clear="handleSearch"
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
<div class="toolbar-actions">
|
||||
<el-button type="primary" :icon="Search" @click="handleSearch">
|
||||
查询
|
||||
</el-button>
|
||||
<el-button :icon="Refresh" @click="handleReset">重置</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-shell">
|
||||
<el-table v-loading="tableLoading" :data="tasks" row-key="id" stripe>
|
||||
<el-table-column prop="title" label="标题" min-width="220" />
|
||||
<el-table-column prop="storeName" label="目标门店" min-width="160" />
|
||||
<el-table-column label="分配员工" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<div class="tag-list">
|
||||
<el-tag
|
||||
v-for="name in row.assigneeNames"
|
||||
:key="name"
|
||||
size="small"
|
||||
effect="plain"
|
||||
>
|
||||
{{ name }}
|
||||
</el-tag>
|
||||
<span v-if="!row.assigneeNames?.length" class="muted"
|
||||
>未分配</span
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="110">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="statusMap[row.status].type" effect="light">
|
||||
{{ statusMap[row.status].label }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="优先级" width="110">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="priorityMap[row.priority].type" effect="light">
|
||||
{{ priorityMap[row.priority].label }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="截止时间" width="180">
|
||||
<template #default="{ row }">{{
|
||||
formatTime(row.deadlineAt)
|
||||
}}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="creatorName" label="创建人" min-width="120">
|
||||
<template #default="{ row }">{{ row.creatorName || "-" }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="流转记录" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<div class="event-list">
|
||||
<span v-for="event in row.events ?? []" :key="event.id">
|
||||
{{ event.action }} {{ event.remark || "" }}
|
||||
</span>
|
||||
<span v-if="!row.events?.length" class="muted">暂无记录</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
v-if="canManage"
|
||||
label="操作"
|
||||
width="170"
|
||||
fixed="right"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
link
|
||||
type="primary"
|
||||
:icon="EditPen"
|
||||
@click="openEditDialog(row)"
|
||||
>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="row.status !== 'COMPLETED' && row.status !== 'CANCELLED'"
|
||||
link
|
||||
type="warning"
|
||||
:icon="CircleClose"
|
||||
@click="handleCancel(row)"
|
||||
>
|
||||
取消
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="pagination-row">
|
||||
<span
|
||||
>共 {{ pagination.total }} 条,{{ pagination.totalPages }} 页</span
|
||||
>
|
||||
<el-pagination
|
||||
v-model:current-page="query.page"
|
||||
v-model:page-size="query.pageSize"
|
||||
background
|
||||
layout="sizes, prev, pager, next"
|
||||
:total="pagination.total"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
@current-change="handlePageChange"
|
||||
@size-change="handleSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="dialogTitle"
|
||||
width="620px"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-position="top">
|
||||
<el-form-item label="标题" prop="title">
|
||||
<el-input v-model="form.title" maxlength="100" placeholder="请输入" />
|
||||
</el-form-item>
|
||||
<el-form-item label="描述" prop="description">
|
||||
<el-input
|
||||
v-model="form.description"
|
||||
type="textarea"
|
||||
maxlength="2000"
|
||||
show-word-limit
|
||||
:rows="4"
|
||||
placeholder="请输入任务描述"
|
||||
/>
|
||||
</el-form-item>
|
||||
<div class="form-grid">
|
||||
<el-form-item label="目标门店" prop="storeId">
|
||||
<el-select
|
||||
v-model="form.storeId"
|
||||
filterable
|
||||
class="full-width"
|
||||
:loading="optionLoading"
|
||||
@change="handleFormStoreChange"
|
||||
>
|
||||
<el-option
|
||||
v-for="store in stores"
|
||||
:key="store.id"
|
||||
:label="store.name"
|
||||
:value="store.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="优先级" prop="priority">
|
||||
<el-select v-model="form.priority" class="full-width">
|
||||
<el-option label="低" value="LOW" />
|
||||
<el-option label="普通" value="NORMAL" />
|
||||
<el-option label="高" value="HIGH" />
|
||||
<el-option label="紧急" value="URGENT" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</div>
|
||||
<el-form-item label="分配员工" prop="assigneeIds">
|
||||
<el-select
|
||||
v-model="form.assigneeIds"
|
||||
multiple
|
||||
filterable
|
||||
class="full-width"
|
||||
:loading="optionLoading"
|
||||
>
|
||||
<el-option
|
||||
v-for="employee in employees"
|
||||
:key="employee.id"
|
||||
:label="`${employee.name}(${employee.phone})`"
|
||||
:value="employee.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="截止时间" prop="deadlineAt">
|
||||
<el-date-picker
|
||||
v-model="form.deadlineAt"
|
||||
type="datetime"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
class="full-width"
|
||||
placeholder="请选择截止时间"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitLoading" @click="submitForm">
|
||||
保存
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.operation-page {
|
||||
min-height: 100%;
|
||||
padding: 20px;
|
||||
background: #f6f8fb;
|
||||
}
|
||||
|
||||
.page-heading {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
|
||||
h1 {
|
||||
margin: 4px 0 0;
|
||||
font-size: 24px;
|
||||
font-weight: 650;
|
||||
color: #111827;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.toolbar,
|
||||
.table-shell {
|
||||
background: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 14px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.toolbar-control {
|
||||
width: 170px;
|
||||
}
|
||||
|
||||
.keyword-input {
|
||||
width: 240px;
|
||||
}
|
||||
|
||||
.toolbar-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.table-shell {
|
||||
padding: 8px 0 14px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tag-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.event-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.pagination-row {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 16px 0;
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.full-width {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (width <= 760px) {
|
||||
.operation-page {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.page-heading {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.toolbar-control,
|
||||
.keyword-input,
|
||||
.toolbar-actions {
|
||||
width: 100%;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.toolbar-actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.pagination-row {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user