From e9778249e7bea6c4b09aecd6aac5e21d05e87c14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B9=9B=E5=85=AE?= Date: Tue, 26 May 2026 18:01:52 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=E5=91=98=E5=B7=A5?= =?UTF-8?q?=E9=97=A8=E5=BA=97=E7=AE=A1=E7=90=86=E4=BA=A4=E4=BA=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 9 +- src/api/access.ts | 48 ++++- src/views/employees/index.vue | 337 ++++++++++++++++++++++++++++---- src/views/permissions/index.vue | 72 +++++-- src/views/roles/index.vue | 2 +- src/views/stores/index.vue | 267 +++++++++++++++++++++++-- 6 files changed, 662 insertions(+), 73 deletions(-) diff --git a/README.md b/README.md index 56fed31..5634f8f 100644 --- a/README.md +++ b/README.md @@ -98,9 +98,9 @@ http://localhost:8848/ ## 业务模块 -- `src/views/stores/index.vue`: 门店管理,筛选、重置、启停、删除后都会重新调用接口,支持新增和编辑。 +- `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/api/user.ts`: 登录、当前用户和当前权限菜单接口封装。 @@ -124,6 +124,7 @@ http://localhost:8848/ - `GET /api/stores/:id` - `POST /api/stores` - `PATCH /api/stores/:id` +- `DELETE /api/stores/:storeId/employees/:employeeId` - `DELETE /api/stores/:id` - `GET /api/roles`,角色管理列表会携带 `page`、`pageSize`,筛选时会携带 `keyword`、`isSystem` - `GET /api/roles/:id` @@ -135,9 +136,11 @@ http://localhost:8848/ - `POST /api/employees` - `PATCH /api/employees/:id` - `PATCH /api/employees/:id/status` +- `PATCH /api/employees/:id/password` +- `PATCH /api/employees/:id/password/reset` - `DELETE /api/employees/:id` -接口响应统一在 `src/api/access.ts` 中使用 `ApiResult` 或 `PaginatedData` 描述,页面层只消费 `result.data`,避免在视图里重复拼接接口路径。门店、角色、员工列表的搜索、重置、分页和状态变更后的刷新都应通过接口层完成,不直接依赖页面内存里的旧列表。 +接口响应统一在 `src/api/access.ts` 中使用 `ApiResult` 或 `PaginatedData` 描述,页面层只消费 `result.data`,避免在视图里重复拼接接口路径。门店、角色、员工列表的搜索、重置、分页和状态变更后的刷新都应通过接口层完成,不直接依赖页面内存里的旧列表。员工对象会消费后端返回的 `statusTags`;所属门店停用时展示“门店被禁用”标签。 ## 登录与鉴权流程 diff --git a/src/api/access.ts b/src/api/access.ts index b83b8dd..4d82cf7 100644 --- a/src/api/access.ts +++ b/src/api/access.ts @@ -11,6 +11,13 @@ const API_PREFIX = "/api"; /** 后端员工与门店共用的启停状态枚举。 */ export type EmployeeStatus = "ACTIVE" | "INACTIVE"; export type StoreStatus = "ACTIVE" | "INACTIVE"; +export type EmployeeStatusTagVariant = "success" | "warning" | "default"; + +export interface EmployeeStatusTag { + code: "EMPLOYEE_ACTIVE" | "EMPLOYEE_INACTIVE" | "STORE_INACTIVE"; + label: string; + variant: EmployeeStatusTagVariant; +} /** 员工列表中展示的角色摘要。 */ export interface RoleSummary { @@ -23,9 +30,11 @@ export interface Employee { id: number; storeId: number; storeName: string; + storeStatus: StoreStatus; name: string; phone: string; status: EmployeeStatus; + statusTags: EmployeeStatusTag[]; remark: string | null; roles: RoleSummary[]; createdAt: string; @@ -53,6 +62,10 @@ export interface Store extends StoreOption { updatedAt: string; } +export interface StoreDetail extends Store { + employees: Employee[]; +} + export interface Role extends RoleOption { createdAt: string; updatedAt: string; @@ -150,6 +163,11 @@ export interface EmployeePayload { roleIds: number[]; } +export interface EmployeePasswordPayload { + oldPassword: string; + newPassword: string; +} + /** access-manage 后端普通响应包裹结构。 */ export interface ApiResult { success: boolean; @@ -205,7 +223,10 @@ export const listRolePage = (params: RoleListParams) => { }; export const getStore = (id: number) => { - return http.request>("get", `${API_PREFIX}/stores/${id}`); + return http.request>( + "get", + `${API_PREFIX}/stores/${id}` + ); }; export const createStore = (data: StorePayload) => { @@ -224,6 +245,13 @@ export const deleteStore = (id: number) => { return http.request("delete", `${API_PREFIX}/stores/${id}`); }; +export const removeStoreEmployee = (storeId: number, employeeId: number) => { + return http.request( + "delete", + `${API_PREFIX}/stores/${storeId}/employees/${employeeId}` + ); +}; + /** 角色接口:维护员工可绑定的权限角色。 */ export const getRole = (id: number) => { return http.request>("get", `${API_PREFIX}/roles/${id}`); @@ -283,6 +311,24 @@ export const updateEmployeeStatus = (id: number, status: EmployeeStatus) => { ); }; +export const updateEmployeePassword = ( + id: number, + data: EmployeePasswordPayload +) => { + return http.request>( + "patch", + `${API_PREFIX}/employees/${id}/password`, + { data } + ); +}; + +export const resetEmployeePassword = (id: number) => { + return http.request>( + "patch", + `${API_PREFIX}/employees/${id}/password/reset` + ); +}; + export const deleteEmployee = (id: number) => { return http.request("delete", `${API_PREFIX}/employees/${id}`); }; diff --git a/src/views/employees/index.vue b/src/views/employees/index.vue index f4fb442..871694e 100644 --- a/src/views/employees/index.vue +++ b/src/views/employees/index.vue @@ -11,15 +11,18 @@ import { deleteEmployee, getEmployee, listEmployees, + listAllStores, listRoles, - listStoreOptions, + resetEmployeePassword, updateEmployee, + updateEmployeePassword, updateEmployeeStatus, type Employee, type EmployeePayload, type EmployeeStatus, + type EmployeeStatusTag, type RoleOption, - type StoreOption + type Store } from "@/api/access"; import { hasMenuAction, hasPerms } from "@/utils/auth"; @@ -30,6 +33,8 @@ import EditPen from "~icons/ep/edit-pen"; 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"; defineOptions({ name: "EmployeeManagement" @@ -40,6 +45,13 @@ type EmployeeFormState = EmployeePayload & { id?: number; }; +type PasswordFormState = { + employeeId?: number; + employeeName: string; + oldPassword: string; + newPassword: string; +}; + /** 员工手机号按中国大陆手机号做前端第一层校验,最终唯一性仍由后端保证。 */ const phonePattern = /^1[3-9]\d{9}$/; const bindableRoleCodes = new Set([ @@ -50,13 +62,18 @@ const bindableRoleCodes = new Set([ "admin" ]); const tableLoading = ref(false); -const catalogLoading = ref(false); +const storeLoading = ref(false); +const roleLoading = ref(false); const submitLoading = ref(false); +const passwordSubmitLoading = ref(false); const dialogVisible = ref(false); +const passwordDialogVisible = ref(false); const formRef = ref(); +const passwordFormRef = ref(); const employees = ref([]); -const stores = ref([]); +const stores = ref([]); const roles = ref([]); +const originalStoreId = ref(); const query = reactive({ storeId: undefined as number | undefined, @@ -81,6 +98,13 @@ const form = reactive({ roleIds: [] }); +const passwordForm = reactive({ + employeeId: undefined, + employeeName: "", + oldPassword: "", + newPassword: "" +}); + const rules: FormRules = { storeId: [{ required: true, message: "请选择所属门店", trigger: "change" }], name: [ @@ -100,6 +124,27 @@ const rules: FormRules = { remark: [{ max: 500, message: "备注不能超过 500 个字符", trigger: "blur" }] }; +const passwordRules: FormRules = { + oldPassword: [ + { 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 个字符之间", + trigger: "blur" + } + ] +}; + const activeCount = computed( () => employees.value.filter(item => item.status === "ACTIVE").length ); @@ -135,10 +180,39 @@ function formatTime(value: string) { }); } +function getStoreOptionLabel(store: Store) { + return store.status === "ACTIVE" ? store.name : `${store.name}(停用)`; +} + +function getEmployeeStatusTagType(tag: EmployeeStatusTag) { + return tag.variant === "default" ? "info" : tag.variant; +} + +function resolveStatusTags(row: Employee): EmployeeStatusTag[] { + if (row.statusTags?.length) return row.statusTags; + + const tags: EmployeeStatusTag[] = [ + row.status === "ACTIVE" + ? { code: "EMPLOYEE_ACTIVE", label: "员工启用", variant: "success" } + : { code: "EMPLOYEE_INACTIVE", label: "员工停用", variant: "default" } + ]; + + if (row.storeStatus === "INACTIVE") { + tags.push({ + code: "STORE_INACTIVE", + label: "门店被禁用", + variant: "warning" + }); + } + + return tags; +} + function resetFormState() { + originalStoreId.value = undefined; Object.assign(form, { id: undefined, - storeId: stores.value[0]?.id, + storeId: undefined, name: "", phone: "", status: "ACTIVE", @@ -148,6 +222,16 @@ function resetFormState() { formRef.value?.clearValidate(); } +function resetPasswordFormState() { + Object.assign(passwordForm, { + employeeId: undefined, + employeeName: "", + oldPassword: "", + newPassword: "" + }); + passwordFormRef.value?.clearValidate(); +} + /** 清理提交数据,避免把仅包含空格的备注写入后端。 */ function buildPayload(): EmployeePayload { return { @@ -160,30 +244,47 @@ function buildPayload(): EmployeePayload { }; } -/** 门店和角色是员工表单的基础字典,打开弹窗前必须先加载。 */ -async function fetchCatalog() { - const shouldLoadStores = - canViewAllEmployees.value || - canCreateEmployee.value || - canUpdateEmployee.value; - const shouldLoadRoles = canCreateEmployee.value || canUpdateEmployee.value; +/** 门店可能在其他页面被新增,所以下拉展开时单独拉取最新选项。 */ +async function fetchStores() { + if ( + !canViewAllEmployees.value && + !canCreateEmployee.value && + !canUpdateEmployee.value + ) { + return; + } - if (!shouldLoadStores && !shouldLoadRoles) return; - - catalogLoading.value = true; + storeLoading.value = true; try { - const [storeResult, roleResult] = await Promise.all([ - shouldLoadStores ? listStoreOptions() : Promise.resolve({ data: [] }), - shouldLoadRoles ? listRoles() : Promise.resolve({ data: [] }) - ]); + const storeResult = await listAllStores({ includeInactive: true }); stores.value = storeResult.data; + } catch (error) { + ElMessage.error(getErrorMessage(error, "加载门店选项失败")); + } finally { + storeLoading.value = false; + } +} + +function handleStoreDropdownVisible(visible: boolean) { + if (visible) { + fetchStores(); + } +} + +/** 角色是员工表单基础字典,页面进入时加载一次即可。 */ +async function fetchRoles() { + if (!canCreateEmployee.value && !canUpdateEmployee.value) return; + + roleLoading.value = true; + try { + const roleResult = await listRoles(); roles.value = roleResult.data.filter(role => bindableRoleCodes.has(role.code) ); } catch (error) { - ElMessage.error(getErrorMessage(error, "加载门店和角色选项失败")); + ElMessage.error(getErrorMessage(error, "加载角色选项失败")); } finally { - catalogLoading.value = false; + roleLoading.value = false; } } @@ -237,6 +338,7 @@ function openCreateDialog() { if (!canCreateEmployee.value) return; resetFormState(); dialogVisible.value = true; + fetchStores(); } /** 编辑前重新拉详情,确保角色绑定不是来自列表摘要的过期数据。 */ @@ -245,6 +347,7 @@ async function openEditDialog(row: Employee) { try { const result = await getEmployee(row.id); const employee = result.data; + originalStoreId.value = employee.storeId; Object.assign(form, { id: employee.id, storeId: employee.storeId, @@ -255,6 +358,7 @@ async function openEditDialog(row: Employee) { roleIds: employee.roles.map(role => role.id) }); dialogVisible.value = true; + fetchStores(); formRef.value?.clearValidate(); } catch (error) { ElMessage.error(getErrorMessage(error, "加载员工详情失败")); @@ -270,7 +374,13 @@ async function submitForm() { const payload = buildPayload(); if (form.id) { - await updateEmployee(form.id, payload); + const updatePayload: Partial = { ...payload }; + + if (payload.storeId === originalStoreId.value) { + delete updatePayload.storeId; + } + + await updateEmployee(form.id, updatePayload); ElMessage.success("员工信息已更新"); } else { await createEmployee(payload); @@ -286,6 +396,59 @@ async function submitForm() { } } +function openPasswordDialog(row: Employee) { + if (!canUpdateEmployee.value) return; + resetPasswordFormState(); + Object.assign(passwordForm, { + employeeId: row.id, + employeeName: row.name, + oldPassword: "", + newPassword: "" + }); + passwordDialogVisible.value = true; +} + +async function submitPasswordForm() { + if (!canUpdateEmployee.value || !passwordForm.employeeId) return; + await passwordFormRef.value?.validate(); + passwordSubmitLoading.value = true; + + try { + await updateEmployeePassword(passwordForm.employeeId, { + oldPassword: passwordForm.oldPassword, + newPassword: passwordForm.newPassword + }); + ElMessage.success("员工密码已修改"); + passwordDialogVisible.value = false; + } catch (error) { + ElMessage.error(getErrorMessage(error, "修改密码失败")); + } finally { + passwordSubmitLoading.value = false; + } +} + +async function resetPassword(row: Employee) { + if (!canUpdateEmployee.value) return; + + 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, "重置密码失败")); + } + } +} + async function toggleStatus(row: Employee) { if (!canUpdateEmployee.value) return; const nextStatus: EmployeeStatus = @@ -339,8 +502,7 @@ async function removeEmployee(row: Employee) { } onMounted(async () => { - await fetchCatalog(); - await fetchEmployees(); + await Promise.all([fetchRoles(), fetchEmployees()]); }); @@ -388,13 +550,14 @@ onMounted(async () => { filterable placeholder="全部门店" class="toolbar-control" - :loading="catalogLoading" + :loading="storeLoading" @change="handleSearch" + @visible-change="handleStoreDropdownVisible" > @@ -440,7 +603,21 @@ onMounted(async () => { - + + + - + @@ -479,7 +661,7 @@ onMounted(async () => { + + + + + + + + + + + + + + + + @@ -737,6 +988,14 @@ onMounted(async () => { gap: 6px; } +.store-cell, +.status-tags { + display: flex; + flex-wrap: wrap; + gap: 6px; + align-items: center; +} + .muted { color: #94a3b8; } @@ -765,7 +1024,7 @@ onMounted(async () => { width: 100%; } -@media (max-width: 760px) { +@media (width <= 760px) { .employee-page { padding: 12px; } diff --git a/src/views/permissions/index.vue b/src/views/permissions/index.vue index b4a53bf..9be8766 100644 --- a/src/views/permissions/index.vue +++ b/src/views/permissions/index.vue @@ -26,13 +26,16 @@ const policies = ref([]); const definitionGroups = ref([]); const editingPolicy = ref(null); const checkedPermissions = ref([]); +const userStore = useUserStoreHook(); const canUpdatePermissions = computed(() => hasMenuAction("permissions", "update") ); -const editableRoleCount = computed( - () => policies.value.filter(item => item.editable).length +const currentRoleCodes = computed(() => new Set(userStore.roles ?? [])); +const editablePolicies = computed(() => + policies.value.filter(policy => canAssignPolicy(policy)) ); +const editableRoleCount = computed(() => editablePolicies.value.length); const menuTotal = computed(() => policies.value.reduce((total, item) => total + item.menus.length, 0) ); @@ -70,7 +73,9 @@ function formatActions(actions: string[]) { function formatPermissionTitle(code: string) { for (const group of definitionGroups.value) { - const found = group.permissions.find(permission => permission.code === code); + const found = group.permissions.find( + permission => permission.code === code + ); if (found) return found.title; } @@ -91,6 +96,23 @@ function isGroupIndeterminate(group: PermissionDefinitionGroup) { return checkedCount > 0 && checkedCount < codes.length; } +function isCurrentUserRole(policy: PermissionPolicy) { + return currentRoleCodes.value.has(policy.roleCode); +} + +function canAssignPolicy(policy: PermissionPolicy) { + return ( + canUpdatePermissions.value && policy.editable && !isCurrentUserRole(policy) + ); +} + +function getAssignTooltip(policy: PermissionPolicy) { + if (!canUpdatePermissions.value) return "暂无分配权限"; + if (!policy.editable) return "系统最高权限不可编辑"; + if (isCurrentUserRole(policy)) return "不可修改当前账号所属角色权限"; + return "分配权限"; +} + function toggleGroup(group: PermissionDefinitionGroup, checked: boolean) { const nextPermissions = new Set(checkedPermissions.value); @@ -106,7 +128,7 @@ function toggleGroup(group: PermissionDefinitionGroup, checked: boolean) { } function openEditor(policy: PermissionPolicy) { - if (!policy.editable || !canUpdatePermissions.value) return; + if (!canAssignPolicy(policy)) return; editingPolicy.value = policy; checkedPermissions.value = policy.permissions.filter( @@ -115,6 +137,17 @@ function openEditor(policy: PermissionPolicy) { drawerVisible.value = true; } +function replacePolicy(policy: PermissionPolicy) { + const policyIndex = policies.value.findIndex( + item => item.roleId === policy.roleId + ); + + if (policyIndex === -1) return; + + policies.value.splice(policyIndex, 1, policy); + editingPolicy.value = policy; +} + async function fetchPermissionData() { tableLoading.value = true; try { @@ -134,15 +167,19 @@ async function fetchPermissionData() { async function savePermissions() { if (!editingPolicy.value) return; + if (!canAssignPolicy(editingPolicy.value)) { + ElMessage.warning("不可修改当前账号所属角色权限"); + drawerVisible.value = false; + return; + } saving.value = true; try { - await updateRolePermissions( + const result = await updateRolePermissions( editingPolicy.value.roleId, checkedPermissions.value ); - await fetchPermissionData(); - await useUserStoreHook().loadAuthContext(true); + replacePolicy(result.data); drawerVisible.value = false; ElMessage.success("权限已更新"); } catch (error) { @@ -210,7 +247,11 @@ onMounted(fetchPermissionData); effect="plain" size="small" > - {{ permission === "*" ? "全部权限" : formatPermissionTitle(permission) }} + {{ + permission === "*" + ? "全部权限" + : formatPermissionTitle(permission) + }} 未分配 @@ -233,14 +274,11 @@ onMounted(fetchPermissionData); - + + + +
+ + + {{ detailStore.name }} + + + + {{ detailStore.status === "ACTIVE" ? "启用" : "停用" }} + + + + {{ detailStore.phone || "-" }} + + + {{ formatTime(detailStore.updatedAt) }} + + + {{ detailStore.address || "-" }} + + + +
+

门店员工

+ {{ detailStore?.employees.length ?? 0 }} 人 +
+ + + + + + + + + + + + + + + + + + +
+
@@ -604,7 +796,58 @@ onMounted(fetchStores); gap: 12px; } -@media (max-width: 760px) { +.store-detail { + min-height: 220px; +} + +.detail-section-heading { + display: flex; + align-items: center; + justify-content: space-between; + margin: 18px 0 10px; + + h3 { + margin: 0; + font-size: 16px; + font-weight: 650; + color: #111827; + letter-spacing: 0; + } + + span { + font-size: 13px; + color: #64748b; + } +} + +.employee-cell { + display: flex; + flex-direction: column; + gap: 3px; + + strong { + font-weight: 650; + color: #111827; + } + + span { + font-size: 12px; + color: #64748b; + } +} + +.detail-tags { + display: flex; + flex-wrap: wrap; + gap: 6px; + align-items: center; +} + +.muted { + color: #94a3b8; +} + +@media (width <= 760px) { .management-page { padding: 12px; }