feat: add employee workspace operations admin

This commit is contained in:
湛兮
2026-06-02 14:23:31 +08:00
parent 835b52709f
commit ab73565d37
10 changed files with 3523 additions and 147 deletions
+30 -8
View File
@@ -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 过期时直接清理登录态并要求重新登录。
## 配置说明
+155
View File
@@ -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
View File
@@ -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)
)
);
};
+32
View File
@@ -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"
}
}
];
+61 -2
View File
@@ -6,7 +6,8 @@ const Layout = () => import("@/layout/index.vue");
* 子页面都是静态路由,菜单展示顺序由这里的 children 决定;
* 默认访问该模块时进入员工管理,保证和登录/登出后的主工作流一致。
*/
export default {
export default [
{
path: "/access",
name: "AccessManagement",
component: Layout,
@@ -62,4 +63,62 @@ export default {
}
}
]
} satisfies RouteConfigsTable;
},
{
path: "/operations",
name: "EmployeeWorkspaceOperations",
component: Layout,
redirect: "/announcements",
meta: {
icon: "ep/operation",
title: "员工工作台运营",
rank: 2
},
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
}
}
]
}
] satisfies RouteConfigsTable[];
+680
View File
@@ -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>
+381
View File
@@ -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>
+100 -80
View File
@@ -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-input
v-model="passwordForm.oldPassword"
type="password"
maxlength="128"
show-password
placeholder="请输入旧密码"
<el-alert
type="warning"
show-icon
:closable="false"
class="password-alert"
title="不会展示员工当前密码。提交后仅在下一步弹窗显示一次临时密码。"
/>
</el-form-item>
<el-form-item label="新密码" prop="newPassword">
<el-form-item label="重置原因" prop="reason">
<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;
+603
View File
@@ -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>
+702
View File
@@ -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>