feat: 完善员工门店管理交互
This commit is contained in:
@@ -98,9 +98,9 @@ http://localhost:8848/
|
|||||||
|
|
||||||
## 业务模块
|
## 业务模块
|
||||||
|
|
||||||
- `src/views/stores/index.vue`: 门店管理,筛选、重置、启停、删除后都会重新调用接口,支持新增和编辑。
|
- `src/views/stores/index.vue`: 门店管理,筛选、重置、启停、删除后都会重新调用接口,支持新增、编辑、详情员工列表和移除员工。
|
||||||
- `src/views/roles/index.vue`: 角色管理,搜索、重置、删除和保存后都会重新调用接口,支持角色编码校验。
|
- `src/views/roles/index.vue`: 角色管理,搜索、重置、删除和保存后都会重新调用接口,支持角色编码校验。
|
||||||
- `src/views/employees/index.vue`: 员工管理,门店/状态/关键词筛选、重置、分页、启停、删除和保存后都会重新调用接口。
|
- `src/views/employees/index.vue`: 员工管理,门店/状态/关键词筛选、重置、分页、启停、旧密码校验后改密、初始密码重置、删除和保存后都会重新调用接口,并展示员工状态标签。
|
||||||
- `src/views/permissions/index.vue`: 权限策略,支持查看角色权限、按角色勾选权限点并保存到后端。
|
- `src/views/permissions/index.vue`: 权限策略,支持查看角色权限、按角色勾选权限点并保存到后端。
|
||||||
- `src/api/access.ts`: 门店、角色、员工、权限策略和角色权限分配接口类型与 HTTP 方法封装。
|
- `src/api/access.ts`: 门店、角色、员工、权限策略和角色权限分配接口类型与 HTTP 方法封装。
|
||||||
- `src/api/user.ts`: 登录、当前用户和当前权限菜单接口封装。
|
- `src/api/user.ts`: 登录、当前用户和当前权限菜单接口封装。
|
||||||
@@ -124,6 +124,7 @@ http://localhost:8848/
|
|||||||
- `GET /api/stores/:id`
|
- `GET /api/stores/:id`
|
||||||
- `POST /api/stores`
|
- `POST /api/stores`
|
||||||
- `PATCH /api/stores/:id`
|
- `PATCH /api/stores/:id`
|
||||||
|
- `DELETE /api/stores/:storeId/employees/:employeeId`
|
||||||
- `DELETE /api/stores/:id`
|
- `DELETE /api/stores/:id`
|
||||||
- `GET /api/roles`,角色管理列表会携带 `page`、`pageSize`,筛选时会携带 `keyword`、`isSystem`
|
- `GET /api/roles`,角色管理列表会携带 `page`、`pageSize`,筛选时会携带 `keyword`、`isSystem`
|
||||||
- `GET /api/roles/:id`
|
- `GET /api/roles/:id`
|
||||||
@@ -135,9 +136,11 @@ http://localhost:8848/
|
|||||||
- `POST /api/employees`
|
- `POST /api/employees`
|
||||||
- `PATCH /api/employees/:id`
|
- `PATCH /api/employees/:id`
|
||||||
- `PATCH /api/employees/:id/status`
|
- `PATCH /api/employees/:id/status`
|
||||||
|
- `PATCH /api/employees/:id/password`
|
||||||
|
- `PATCH /api/employees/:id/password/reset`
|
||||||
- `DELETE /api/employees/:id`
|
- `DELETE /api/employees/:id`
|
||||||
|
|
||||||
接口响应统一在 `src/api/access.ts` 中使用 `ApiResult<T>` 或 `PaginatedData<T>` 描述,页面层只消费 `result.data`,避免在视图里重复拼接接口路径。门店、角色、员工列表的搜索、重置、分页和状态变更后的刷新都应通过接口层完成,不直接依赖页面内存里的旧列表。
|
接口响应统一在 `src/api/access.ts` 中使用 `ApiResult<T>` 或 `PaginatedData<T>` 描述,页面层只消费 `result.data`,避免在视图里重复拼接接口路径。门店、角色、员工列表的搜索、重置、分页和状态变更后的刷新都应通过接口层完成,不直接依赖页面内存里的旧列表。员工对象会消费后端返回的 `statusTags`;所属门店停用时展示“门店被禁用”标签。
|
||||||
|
|
||||||
## 登录与鉴权流程
|
## 登录与鉴权流程
|
||||||
|
|
||||||
|
|||||||
+47
-1
@@ -11,6 +11,13 @@ const API_PREFIX = "/api";
|
|||||||
/** 后端员工与门店共用的启停状态枚举。 */
|
/** 后端员工与门店共用的启停状态枚举。 */
|
||||||
export type EmployeeStatus = "ACTIVE" | "INACTIVE";
|
export type EmployeeStatus = "ACTIVE" | "INACTIVE";
|
||||||
export type StoreStatus = "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 {
|
export interface RoleSummary {
|
||||||
@@ -23,9 +30,11 @@ export interface Employee {
|
|||||||
id: number;
|
id: number;
|
||||||
storeId: number;
|
storeId: number;
|
||||||
storeName: string;
|
storeName: string;
|
||||||
|
storeStatus: StoreStatus;
|
||||||
name: string;
|
name: string;
|
||||||
phone: string;
|
phone: string;
|
||||||
status: EmployeeStatus;
|
status: EmployeeStatus;
|
||||||
|
statusTags: EmployeeStatusTag[];
|
||||||
remark: string | null;
|
remark: string | null;
|
||||||
roles: RoleSummary[];
|
roles: RoleSummary[];
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
@@ -53,6 +62,10 @@ export interface Store extends StoreOption {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface StoreDetail extends Store {
|
||||||
|
employees: Employee[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface Role extends RoleOption {
|
export interface Role extends RoleOption {
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
@@ -150,6 +163,11 @@ export interface EmployeePayload {
|
|||||||
roleIds: number[];
|
roleIds: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface EmployeePasswordPayload {
|
||||||
|
oldPassword: string;
|
||||||
|
newPassword: string;
|
||||||
|
}
|
||||||
|
|
||||||
/** access-manage 后端普通响应包裹结构。 */
|
/** access-manage 后端普通响应包裹结构。 */
|
||||||
export interface ApiResult<T> {
|
export interface ApiResult<T> {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
@@ -205,7 +223,10 @@ export const listRolePage = (params: RoleListParams) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const getStore = (id: number) => {
|
export const getStore = (id: number) => {
|
||||||
return http.request<ApiResult<Store>>("get", `${API_PREFIX}/stores/${id}`);
|
return http.request<ApiResult<StoreDetail>>(
|
||||||
|
"get",
|
||||||
|
`${API_PREFIX}/stores/${id}`
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createStore = (data: StorePayload) => {
|
export const createStore = (data: StorePayload) => {
|
||||||
@@ -224,6 +245,13 @@ export const deleteStore = (id: number) => {
|
|||||||
return http.request<void>("delete", `${API_PREFIX}/stores/${id}`);
|
return http.request<void>("delete", `${API_PREFIX}/stores/${id}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const removeStoreEmployee = (storeId: number, employeeId: number) => {
|
||||||
|
return http.request<void>(
|
||||||
|
"delete",
|
||||||
|
`${API_PREFIX}/stores/${storeId}/employees/${employeeId}`
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
/** 角色接口:维护员工可绑定的权限角色。 */
|
/** 角色接口:维护员工可绑定的权限角色。 */
|
||||||
export const getRole = (id: number) => {
|
export const getRole = (id: number) => {
|
||||||
return http.request<ApiResult<Role>>("get", `${API_PREFIX}/roles/${id}`);
|
return http.request<ApiResult<Role>>("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<ApiResult<Employee>>(
|
||||||
|
"patch",
|
||||||
|
`${API_PREFIX}/employees/${id}/password`,
|
||||||
|
{ data }
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resetEmployeePassword = (id: number) => {
|
||||||
|
return http.request<ApiResult<Employee>>(
|
||||||
|
"patch",
|
||||||
|
`${API_PREFIX}/employees/${id}/password/reset`
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const deleteEmployee = (id: number) => {
|
export const deleteEmployee = (id: number) => {
|
||||||
return http.request<void>("delete", `${API_PREFIX}/employees/${id}`);
|
return http.request<void>("delete", `${API_PREFIX}/employees/${id}`);
|
||||||
};
|
};
|
||||||
|
|||||||
+298
-39
@@ -11,15 +11,18 @@ import {
|
|||||||
deleteEmployee,
|
deleteEmployee,
|
||||||
getEmployee,
|
getEmployee,
|
||||||
listEmployees,
|
listEmployees,
|
||||||
|
listAllStores,
|
||||||
listRoles,
|
listRoles,
|
||||||
listStoreOptions,
|
resetEmployeePassword,
|
||||||
updateEmployee,
|
updateEmployee,
|
||||||
|
updateEmployeePassword,
|
||||||
updateEmployeeStatus,
|
updateEmployeeStatus,
|
||||||
type Employee,
|
type Employee,
|
||||||
type EmployeePayload,
|
type EmployeePayload,
|
||||||
type EmployeeStatus,
|
type EmployeeStatus,
|
||||||
|
type EmployeeStatusTag,
|
||||||
type RoleOption,
|
type RoleOption,
|
||||||
type StoreOption
|
type Store
|
||||||
} from "@/api/access";
|
} from "@/api/access";
|
||||||
import { hasMenuAction, hasPerms } from "@/utils/auth";
|
import { hasMenuAction, hasPerms } from "@/utils/auth";
|
||||||
|
|
||||||
@@ -30,6 +33,8 @@ import EditPen from "~icons/ep/edit-pen";
|
|||||||
import Delete from "~icons/ep/delete";
|
import Delete from "~icons/ep/delete";
|
||||||
import CircleCheck from "~icons/ep/circle-check";
|
import CircleCheck from "~icons/ep/circle-check";
|
||||||
import CircleClose from "~icons/ep/circle-close";
|
import CircleClose from "~icons/ep/circle-close";
|
||||||
|
import Key from "~icons/ep/key";
|
||||||
|
import RefreshLeft from "~icons/ep/refresh-left";
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
name: "EmployeeManagement"
|
name: "EmployeeManagement"
|
||||||
@@ -40,6 +45,13 @@ type EmployeeFormState = EmployeePayload & {
|
|||||||
id?: number;
|
id?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type PasswordFormState = {
|
||||||
|
employeeId?: number;
|
||||||
|
employeeName: string;
|
||||||
|
oldPassword: string;
|
||||||
|
newPassword: string;
|
||||||
|
};
|
||||||
|
|
||||||
/** 员工手机号按中国大陆手机号做前端第一层校验,最终唯一性仍由后端保证。 */
|
/** 员工手机号按中国大陆手机号做前端第一层校验,最终唯一性仍由后端保证。 */
|
||||||
const phonePattern = /^1[3-9]\d{9}$/;
|
const phonePattern = /^1[3-9]\d{9}$/;
|
||||||
const bindableRoleCodes = new Set([
|
const bindableRoleCodes = new Set([
|
||||||
@@ -50,13 +62,18 @@ const bindableRoleCodes = new Set([
|
|||||||
"admin"
|
"admin"
|
||||||
]);
|
]);
|
||||||
const tableLoading = ref(false);
|
const tableLoading = ref(false);
|
||||||
const catalogLoading = ref(false);
|
const storeLoading = ref(false);
|
||||||
|
const roleLoading = ref(false);
|
||||||
const submitLoading = ref(false);
|
const submitLoading = ref(false);
|
||||||
|
const passwordSubmitLoading = ref(false);
|
||||||
const dialogVisible = ref(false);
|
const dialogVisible = ref(false);
|
||||||
|
const passwordDialogVisible = ref(false);
|
||||||
const formRef = ref<FormInstance>();
|
const formRef = ref<FormInstance>();
|
||||||
|
const passwordFormRef = ref<FormInstance>();
|
||||||
const employees = ref<Employee[]>([]);
|
const employees = ref<Employee[]>([]);
|
||||||
const stores = ref<StoreOption[]>([]);
|
const stores = ref<Store[]>([]);
|
||||||
const roles = ref<RoleOption[]>([]);
|
const roles = ref<RoleOption[]>([]);
|
||||||
|
const originalStoreId = ref<number>();
|
||||||
|
|
||||||
const query = reactive({
|
const query = reactive({
|
||||||
storeId: undefined as number | undefined,
|
storeId: undefined as number | undefined,
|
||||||
@@ -81,6 +98,13 @@ const form = reactive<EmployeeFormState>({
|
|||||||
roleIds: []
|
roleIds: []
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const passwordForm = reactive<PasswordFormState>({
|
||||||
|
employeeId: undefined,
|
||||||
|
employeeName: "",
|
||||||
|
oldPassword: "",
|
||||||
|
newPassword: ""
|
||||||
|
});
|
||||||
|
|
||||||
const rules: FormRules<EmployeeFormState> = {
|
const rules: FormRules<EmployeeFormState> = {
|
||||||
storeId: [{ required: true, message: "请选择所属门店", trigger: "change" }],
|
storeId: [{ required: true, message: "请选择所属门店", trigger: "change" }],
|
||||||
name: [
|
name: [
|
||||||
@@ -100,6 +124,27 @@ const rules: FormRules<EmployeeFormState> = {
|
|||||||
remark: [{ max: 500, message: "备注不能超过 500 个字符", trigger: "blur" }]
|
remark: [{ max: 500, message: "备注不能超过 500 个字符", trigger: "blur" }]
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const passwordRules: FormRules<PasswordFormState> = {
|
||||||
|
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(
|
const activeCount = computed(
|
||||||
() => employees.value.filter(item => item.status === "ACTIVE").length
|
() => 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() {
|
function resetFormState() {
|
||||||
|
originalStoreId.value = undefined;
|
||||||
Object.assign(form, {
|
Object.assign(form, {
|
||||||
id: undefined,
|
id: undefined,
|
||||||
storeId: stores.value[0]?.id,
|
storeId: undefined,
|
||||||
name: "",
|
name: "",
|
||||||
phone: "",
|
phone: "",
|
||||||
status: "ACTIVE",
|
status: "ACTIVE",
|
||||||
@@ -148,6 +222,16 @@ function resetFormState() {
|
|||||||
formRef.value?.clearValidate();
|
formRef.value?.clearValidate();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resetPasswordFormState() {
|
||||||
|
Object.assign(passwordForm, {
|
||||||
|
employeeId: undefined,
|
||||||
|
employeeName: "",
|
||||||
|
oldPassword: "",
|
||||||
|
newPassword: ""
|
||||||
|
});
|
||||||
|
passwordFormRef.value?.clearValidate();
|
||||||
|
}
|
||||||
|
|
||||||
/** 清理提交数据,避免把仅包含空格的备注写入后端。 */
|
/** 清理提交数据,避免把仅包含空格的备注写入后端。 */
|
||||||
function buildPayload(): EmployeePayload {
|
function buildPayload(): EmployeePayload {
|
||||||
return {
|
return {
|
||||||
@@ -160,30 +244,47 @@ function buildPayload(): EmployeePayload {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 门店和角色是员工表单的基础字典,打开弹窗前必须先加载。 */
|
/** 门店可能在其他页面被新增,所以下拉展开时单独拉取最新选项。 */
|
||||||
async function fetchCatalog() {
|
async function fetchStores() {
|
||||||
const shouldLoadStores =
|
if (
|
||||||
canViewAllEmployees.value ||
|
!canViewAllEmployees.value &&
|
||||||
canCreateEmployee.value ||
|
!canCreateEmployee.value &&
|
||||||
canUpdateEmployee.value;
|
!canUpdateEmployee.value
|
||||||
const shouldLoadRoles = canCreateEmployee.value || canUpdateEmployee.value;
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!shouldLoadStores && !shouldLoadRoles) return;
|
storeLoading.value = true;
|
||||||
|
|
||||||
catalogLoading.value = true;
|
|
||||||
try {
|
try {
|
||||||
const [storeResult, roleResult] = await Promise.all([
|
const storeResult = await listAllStores({ includeInactive: true });
|
||||||
shouldLoadStores ? listStoreOptions() : Promise.resolve({ data: [] }),
|
|
||||||
shouldLoadRoles ? listRoles() : Promise.resolve({ data: [] })
|
|
||||||
]);
|
|
||||||
stores.value = storeResult.data;
|
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 =>
|
roles.value = roleResult.data.filter(role =>
|
||||||
bindableRoleCodes.has(role.code)
|
bindableRoleCodes.has(role.code)
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ElMessage.error(getErrorMessage(error, "加载门店和角色选项失败"));
|
ElMessage.error(getErrorMessage(error, "加载角色选项失败"));
|
||||||
} finally {
|
} finally {
|
||||||
catalogLoading.value = false;
|
roleLoading.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,6 +338,7 @@ function openCreateDialog() {
|
|||||||
if (!canCreateEmployee.value) return;
|
if (!canCreateEmployee.value) return;
|
||||||
resetFormState();
|
resetFormState();
|
||||||
dialogVisible.value = true;
|
dialogVisible.value = true;
|
||||||
|
fetchStores();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 编辑前重新拉详情,确保角色绑定不是来自列表摘要的过期数据。 */
|
/** 编辑前重新拉详情,确保角色绑定不是来自列表摘要的过期数据。 */
|
||||||
@@ -245,6 +347,7 @@ async function openEditDialog(row: Employee) {
|
|||||||
try {
|
try {
|
||||||
const result = await getEmployee(row.id);
|
const result = await getEmployee(row.id);
|
||||||
const employee = result.data;
|
const employee = result.data;
|
||||||
|
originalStoreId.value = employee.storeId;
|
||||||
Object.assign(form, {
|
Object.assign(form, {
|
||||||
id: employee.id,
|
id: employee.id,
|
||||||
storeId: employee.storeId,
|
storeId: employee.storeId,
|
||||||
@@ -255,6 +358,7 @@ async function openEditDialog(row: Employee) {
|
|||||||
roleIds: employee.roles.map(role => role.id)
|
roleIds: employee.roles.map(role => role.id)
|
||||||
});
|
});
|
||||||
dialogVisible.value = true;
|
dialogVisible.value = true;
|
||||||
|
fetchStores();
|
||||||
formRef.value?.clearValidate();
|
formRef.value?.clearValidate();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ElMessage.error(getErrorMessage(error, "加载员工详情失败"));
|
ElMessage.error(getErrorMessage(error, "加载员工详情失败"));
|
||||||
@@ -270,7 +374,13 @@ async function submitForm() {
|
|||||||
const payload = buildPayload();
|
const payload = buildPayload();
|
||||||
|
|
||||||
if (form.id) {
|
if (form.id) {
|
||||||
await updateEmployee(form.id, payload);
|
const updatePayload: Partial<EmployeePayload> = { ...payload };
|
||||||
|
|
||||||
|
if (payload.storeId === originalStoreId.value) {
|
||||||
|
delete updatePayload.storeId;
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateEmployee(form.id, updatePayload);
|
||||||
ElMessage.success("员工信息已更新");
|
ElMessage.success("员工信息已更新");
|
||||||
} else {
|
} else {
|
||||||
await createEmployee(payload);
|
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) {
|
async function toggleStatus(row: Employee) {
|
||||||
if (!canUpdateEmployee.value) return;
|
if (!canUpdateEmployee.value) return;
|
||||||
const nextStatus: EmployeeStatus =
|
const nextStatus: EmployeeStatus =
|
||||||
@@ -339,8 +502,7 @@ async function removeEmployee(row: Employee) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await fetchCatalog();
|
await Promise.all([fetchRoles(), fetchEmployees()]);
|
||||||
await fetchEmployees();
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -388,13 +550,14 @@ onMounted(async () => {
|
|||||||
filterable
|
filterable
|
||||||
placeholder="全部门店"
|
placeholder="全部门店"
|
||||||
class="toolbar-control"
|
class="toolbar-control"
|
||||||
:loading="catalogLoading"
|
:loading="storeLoading"
|
||||||
@change="handleSearch"
|
@change="handleSearch"
|
||||||
|
@visible-change="handleStoreDropdownVisible"
|
||||||
>
|
>
|
||||||
<el-option
|
<el-option
|
||||||
v-for="store in stores"
|
v-for="store in stores"
|
||||||
:key="store.id"
|
:key="store.id"
|
||||||
:label="store.name"
|
:label="getStoreOptionLabel(store)"
|
||||||
:value="store.id"
|
:value="store.id"
|
||||||
/>
|
/>
|
||||||
</el-select>
|
</el-select>
|
||||||
@@ -440,7 +603,21 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="storeName" label="门店" min-width="150" />
|
<el-table-column prop="storeName" label="门店" min-width="170">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="store-cell">
|
||||||
|
<span>{{ row.storeName }}</span>
|
||||||
|
<el-tag
|
||||||
|
v-if="row.storeStatus === 'INACTIVE'"
|
||||||
|
size="small"
|
||||||
|
type="warning"
|
||||||
|
effect="light"
|
||||||
|
>
|
||||||
|
停用
|
||||||
|
</el-tag>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column label="角色" min-width="220">
|
<el-table-column label="角色" min-width="220">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div class="role-tags">
|
<div class="role-tags">
|
||||||
@@ -456,14 +633,19 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="状态" width="110">
|
<el-table-column label="状态" min-width="190">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag
|
<div class="status-tags">
|
||||||
:type="row.status === 'ACTIVE' ? 'success' : 'info'"
|
<el-tag
|
||||||
effect="light"
|
v-for="tag in resolveStatusTags(row)"
|
||||||
>
|
:key="tag.code"
|
||||||
{{ row.status === "ACTIVE" ? "启用" : "停用" }}
|
:type="getEmployeeStatusTagType(tag)"
|
||||||
</el-tag>
|
effect="light"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
{{ tag.label }}
|
||||||
|
</el-tag>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="remark" label="备注" min-width="180">
|
<el-table-column prop="remark" label="备注" min-width="180">
|
||||||
@@ -479,7 +661,7 @@ onMounted(async () => {
|
|||||||
<el-table-column
|
<el-table-column
|
||||||
v-if="canOperateEmployee"
|
v-if="canOperateEmployee"
|
||||||
label="操作"
|
label="操作"
|
||||||
width="260"
|
width="420"
|
||||||
fixed="right"
|
fixed="right"
|
||||||
>
|
>
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
@@ -501,6 +683,24 @@ onMounted(async () => {
|
|||||||
>
|
>
|
||||||
{{ row.status === "ACTIVE" ? "停用" : "启用" }}
|
{{ row.status === "ACTIVE" ? "停用" : "启用" }}
|
||||||
</el-button>
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
v-if="canUpdateEmployee"
|
||||||
|
link
|
||||||
|
type="primary"
|
||||||
|
:icon="Key"
|
||||||
|
@click="openPasswordDialog(row)"
|
||||||
|
>
|
||||||
|
改密
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
v-if="canUpdateEmployee"
|
||||||
|
link
|
||||||
|
type="warning"
|
||||||
|
:icon="RefreshLeft"
|
||||||
|
@click="resetPassword(row)"
|
||||||
|
>
|
||||||
|
重置密码
|
||||||
|
</el-button>
|
||||||
<el-button
|
<el-button
|
||||||
v-if="canDeleteEmployee"
|
v-if="canDeleteEmployee"
|
||||||
link
|
link
|
||||||
@@ -550,13 +750,17 @@ onMounted(async () => {
|
|||||||
filterable
|
filterable
|
||||||
placeholder="请选择门店"
|
placeholder="请选择门店"
|
||||||
class="full-width"
|
class="full-width"
|
||||||
:loading="catalogLoading"
|
:loading="storeLoading"
|
||||||
|
@visible-change="handleStoreDropdownVisible"
|
||||||
>
|
>
|
||||||
<el-option
|
<el-option
|
||||||
v-for="store in stores"
|
v-for="store in stores"
|
||||||
:key="store.id"
|
:key="store.id"
|
||||||
:label="store.name"
|
:label="getStoreOptionLabel(store)"
|
||||||
:value="store.id"
|
:value="store.id"
|
||||||
|
:disabled="
|
||||||
|
store.status === 'INACTIVE' && store.id !== form.storeId
|
||||||
|
"
|
||||||
/>
|
/>
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
@@ -587,7 +791,7 @@ onMounted(async () => {
|
|||||||
collapse-tags-tooltip
|
collapse-tags-tooltip
|
||||||
placeholder="请选择角色"
|
placeholder="请选择角色"
|
||||||
class="full-width"
|
class="full-width"
|
||||||
:loading="catalogLoading"
|
:loading="roleLoading"
|
||||||
>
|
>
|
||||||
<el-option
|
<el-option
|
||||||
v-for="role in roles"
|
v-for="role in roles"
|
||||||
@@ -616,6 +820,53 @@ onMounted(async () => {
|
|||||||
</el-button>
|
</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog
|
||||||
|
v-model="passwordDialogVisible"
|
||||||
|
title="修改员工密码"
|
||||||
|
width="420px"
|
||||||
|
destroy-on-close
|
||||||
|
>
|
||||||
|
<el-form
|
||||||
|
ref="passwordFormRef"
|
||||||
|
:model="passwordForm"
|
||||||
|
:rules="passwordRules"
|
||||||
|
label-position="top"
|
||||||
|
>
|
||||||
|
<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-form-item>
|
||||||
|
<el-form-item label="新密码" prop="newPassword">
|
||||||
|
<el-input
|
||||||
|
v-model="passwordForm.newPassword"
|
||||||
|
type="password"
|
||||||
|
maxlength="128"
|
||||||
|
show-password
|
||||||
|
placeholder="请输入 8-128 位新密码"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="passwordDialogVisible = false">取消</el-button>
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
:loading="passwordSubmitLoading"
|
||||||
|
@click="submitPasswordForm"
|
||||||
|
>
|
||||||
|
保存
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -737,6 +988,14 @@ onMounted(async () => {
|
|||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.store-cell,
|
||||||
|
.status-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
.muted {
|
.muted {
|
||||||
color: #94a3b8;
|
color: #94a3b8;
|
||||||
}
|
}
|
||||||
@@ -765,7 +1024,7 @@ onMounted(async () => {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 760px) {
|
@media (width <= 760px) {
|
||||||
.employee-page {
|
.employee-page {
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,13 +26,16 @@ const policies = ref<PermissionPolicy[]>([]);
|
|||||||
const definitionGroups = ref<PermissionDefinitionGroup[]>([]);
|
const definitionGroups = ref<PermissionDefinitionGroup[]>([]);
|
||||||
const editingPolicy = ref<PermissionPolicy | null>(null);
|
const editingPolicy = ref<PermissionPolicy | null>(null);
|
||||||
const checkedPermissions = ref<string[]>([]);
|
const checkedPermissions = ref<string[]>([]);
|
||||||
|
const userStore = useUserStoreHook();
|
||||||
|
|
||||||
const canUpdatePermissions = computed(() =>
|
const canUpdatePermissions = computed(() =>
|
||||||
hasMenuAction("permissions", "update")
|
hasMenuAction("permissions", "update")
|
||||||
);
|
);
|
||||||
const editableRoleCount = computed(
|
const currentRoleCodes = computed(() => new Set(userStore.roles ?? []));
|
||||||
() => policies.value.filter(item => item.editable).length
|
const editablePolicies = computed(() =>
|
||||||
|
policies.value.filter(policy => canAssignPolicy(policy))
|
||||||
);
|
);
|
||||||
|
const editableRoleCount = computed(() => editablePolicies.value.length);
|
||||||
const menuTotal = computed(() =>
|
const menuTotal = computed(() =>
|
||||||
policies.value.reduce((total, item) => total + item.menus.length, 0)
|
policies.value.reduce((total, item) => total + item.menus.length, 0)
|
||||||
);
|
);
|
||||||
@@ -70,7 +73,9 @@ function formatActions(actions: string[]) {
|
|||||||
|
|
||||||
function formatPermissionTitle(code: string) {
|
function formatPermissionTitle(code: string) {
|
||||||
for (const group of definitionGroups.value) {
|
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;
|
if (found) return found.title;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,6 +96,23 @@ function isGroupIndeterminate(group: PermissionDefinitionGroup) {
|
|||||||
return checkedCount > 0 && checkedCount < codes.length;
|
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) {
|
function toggleGroup(group: PermissionDefinitionGroup, checked: boolean) {
|
||||||
const nextPermissions = new Set(checkedPermissions.value);
|
const nextPermissions = new Set(checkedPermissions.value);
|
||||||
|
|
||||||
@@ -106,7 +128,7 @@ function toggleGroup(group: PermissionDefinitionGroup, checked: boolean) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openEditor(policy: PermissionPolicy) {
|
function openEditor(policy: PermissionPolicy) {
|
||||||
if (!policy.editable || !canUpdatePermissions.value) return;
|
if (!canAssignPolicy(policy)) return;
|
||||||
|
|
||||||
editingPolicy.value = policy;
|
editingPolicy.value = policy;
|
||||||
checkedPermissions.value = policy.permissions.filter(
|
checkedPermissions.value = policy.permissions.filter(
|
||||||
@@ -115,6 +137,17 @@ function openEditor(policy: PermissionPolicy) {
|
|||||||
drawerVisible.value = true;
|
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() {
|
async function fetchPermissionData() {
|
||||||
tableLoading.value = true;
|
tableLoading.value = true;
|
||||||
try {
|
try {
|
||||||
@@ -134,15 +167,19 @@ async function fetchPermissionData() {
|
|||||||
|
|
||||||
async function savePermissions() {
|
async function savePermissions() {
|
||||||
if (!editingPolicy.value) return;
|
if (!editingPolicy.value) return;
|
||||||
|
if (!canAssignPolicy(editingPolicy.value)) {
|
||||||
|
ElMessage.warning("不可修改当前账号所属角色权限");
|
||||||
|
drawerVisible.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
saving.value = true;
|
saving.value = true;
|
||||||
try {
|
try {
|
||||||
await updateRolePermissions(
|
const result = await updateRolePermissions(
|
||||||
editingPolicy.value.roleId,
|
editingPolicy.value.roleId,
|
||||||
checkedPermissions.value
|
checkedPermissions.value
|
||||||
);
|
);
|
||||||
await fetchPermissionData();
|
replacePolicy(result.data);
|
||||||
await useUserStoreHook().loadAuthContext(true);
|
|
||||||
drawerVisible.value = false;
|
drawerVisible.value = false;
|
||||||
ElMessage.success("权限已更新");
|
ElMessage.success("权限已更新");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -210,7 +247,11 @@ onMounted(fetchPermissionData);
|
|||||||
effect="plain"
|
effect="plain"
|
||||||
size="small"
|
size="small"
|
||||||
>
|
>
|
||||||
{{ permission === "*" ? "全部权限" : formatPermissionTitle(permission) }}
|
{{
|
||||||
|
permission === "*"
|
||||||
|
? "全部权限"
|
||||||
|
: formatPermissionTitle(permission)
|
||||||
|
}}
|
||||||
</el-tag>
|
</el-tag>
|
||||||
<span v-if="row.permissions.length === 0" class="muted-text">
|
<span v-if="row.permissions.length === 0" class="muted-text">
|
||||||
未分配
|
未分配
|
||||||
@@ -233,14 +274,11 @@ onMounted(fetchPermissionData);
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="132" fixed="right">
|
<el-table-column label="操作" width="132" fixed="right">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tooltip
|
<el-tooltip :content="getAssignTooltip(row)" placement="top">
|
||||||
:content="row.editable ? '分配权限' : '系统最高权限不可编辑'"
|
|
||||||
placement="top"
|
|
||||||
>
|
|
||||||
<span>
|
<span>
|
||||||
<el-button
|
<el-button
|
||||||
:icon="Edit"
|
:icon="Edit"
|
||||||
:disabled="!row.editable || !canUpdatePermissions"
|
:disabled="!canAssignPolicy(row)"
|
||||||
text
|
text
|
||||||
type="primary"
|
type="primary"
|
||||||
@click="openEditor(row)"
|
@click="openEditor(row)"
|
||||||
@@ -472,8 +510,8 @@ onMounted(fetchPermissionData);
|
|||||||
|
|
||||||
:deep(.el-checkbox.is-bordered) {
|
:deep(.el-checkbox.is-bordered) {
|
||||||
height: auto;
|
height: auto;
|
||||||
margin-right: 0;
|
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
|
margin-right: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.el-checkbox__label) {
|
:deep(.el-checkbox__label) {
|
||||||
@@ -495,19 +533,19 @@ onMounted(fetchPermissionData);
|
|||||||
|
|
||||||
small {
|
small {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.drawer-footer {
|
.drawer-footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 760px) {
|
@media (width <= 760px) {
|
||||||
.permission-page {
|
.permission-page {
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -568,7 +568,7 @@ onMounted(fetchRoles);
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 760px) {
|
@media (width <= 760px) {
|
||||||
.management-page {
|
.management-page {
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
}
|
}
|
||||||
|
|||||||
+255
-12
@@ -9,13 +9,18 @@ import {
|
|||||||
import {
|
import {
|
||||||
createStore,
|
createStore,
|
||||||
deleteStore,
|
deleteStore,
|
||||||
|
getStore,
|
||||||
listStores,
|
listStores,
|
||||||
|
removeStoreEmployee,
|
||||||
updateStore,
|
updateStore,
|
||||||
|
type Employee,
|
||||||
|
type EmployeeStatusTag,
|
||||||
type Store,
|
type Store,
|
||||||
|
type StoreDetail,
|
||||||
type StorePayload,
|
type StorePayload,
|
||||||
type StoreStatus
|
type StoreStatus
|
||||||
} from "@/api/access";
|
} from "@/api/access";
|
||||||
import { hasMenuAction } from "@/utils/auth";
|
import { hasMenuAction, hasPerms } from "@/utils/auth";
|
||||||
|
|
||||||
import Plus from "~icons/ep/plus";
|
import Plus from "~icons/ep/plus";
|
||||||
import Search from "~icons/ep/search";
|
import Search from "~icons/ep/search";
|
||||||
@@ -24,6 +29,8 @@ import EditPen from "~icons/ep/edit-pen";
|
|||||||
import Delete from "~icons/ep/delete";
|
import Delete from "~icons/ep/delete";
|
||||||
import CircleCheck from "~icons/ep/circle-check";
|
import CircleCheck from "~icons/ep/circle-check";
|
||||||
import CircleClose from "~icons/ep/circle-close";
|
import CircleClose from "~icons/ep/circle-close";
|
||||||
|
import View from "~icons/ep/view";
|
||||||
|
import Remove from "~icons/ep/remove";
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
name: "StoreManagement"
|
name: "StoreManagement"
|
||||||
@@ -36,9 +43,13 @@ type StoreFormState = StorePayload & {
|
|||||||
|
|
||||||
const tableLoading = ref(false);
|
const tableLoading = ref(false);
|
||||||
const submitLoading = ref(false);
|
const submitLoading = ref(false);
|
||||||
|
const detailLoading = ref(false);
|
||||||
const dialogVisible = ref(false);
|
const dialogVisible = ref(false);
|
||||||
|
const detailDialogVisible = ref(false);
|
||||||
const formRef = ref<FormInstance>();
|
const formRef = ref<FormInstance>();
|
||||||
const stores = ref<Store[]>([]);
|
const stores = ref<Store[]>([]);
|
||||||
|
const detailStore = ref<StoreDetail | null>(null);
|
||||||
|
const removingEmployeeId = ref<number>();
|
||||||
|
|
||||||
const query = reactive({
|
const query = reactive({
|
||||||
status: undefined as StoreStatus | undefined,
|
status: undefined as StoreStatus | undefined,
|
||||||
@@ -82,9 +93,7 @@ const dialogTitle = computed(() => (form.id ? "编辑门店" : "新增门店"));
|
|||||||
const canCreateStore = computed(() => hasMenuAction("stores", "create"));
|
const canCreateStore = computed(() => hasMenuAction("stores", "create"));
|
||||||
const canUpdateStore = computed(() => hasMenuAction("stores", "update"));
|
const canUpdateStore = computed(() => hasMenuAction("stores", "update"));
|
||||||
const canDeleteStore = computed(() => hasMenuAction("stores", "delete"));
|
const canDeleteStore = computed(() => hasMenuAction("stores", "delete"));
|
||||||
const canOperateStore = computed(
|
const canRemoveStoreEmployee = computed(() => hasPerms("employee:manage"));
|
||||||
() => canUpdateStore.value || canDeleteStore.value
|
|
||||||
);
|
|
||||||
|
|
||||||
function getErrorMessage(error: unknown, fallback: string) {
|
function getErrorMessage(error: unknown, fallback: string) {
|
||||||
const message = (
|
const message = (
|
||||||
@@ -106,6 +115,30 @@ function formatTime(value: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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() {
|
function resetFormState() {
|
||||||
Object.assign(form, {
|
Object.assign(form, {
|
||||||
id: undefined,
|
id: undefined,
|
||||||
@@ -117,6 +150,18 @@ function resetFormState() {
|
|||||||
formRef.value?.clearValidate();
|
formRef.value?.clearValidate();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadStoreDetail(id: number) {
|
||||||
|
detailLoading.value = true;
|
||||||
|
try {
|
||||||
|
const result = await getStore(id);
|
||||||
|
detailStore.value = result.data;
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(getErrorMessage(error, "加载门店详情失败"));
|
||||||
|
} finally {
|
||||||
|
detailLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** 提交前统一清理空字符串,保持后端收到的是 null 而不是空值。 */
|
/** 提交前统一清理空字符串,保持后端收到的是 null 而不是空值。 */
|
||||||
function buildPayload(): StorePayload {
|
function buildPayload(): StorePayload {
|
||||||
const address = form.address?.trim();
|
const address = form.address?.trim();
|
||||||
@@ -194,6 +239,15 @@ function openEditDialog(row: Store) {
|
|||||||
formRef.value?.clearValidate();
|
formRef.value?.clearValidate();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openDetailDialog(row: Store) {
|
||||||
|
detailStore.value = {
|
||||||
|
...row,
|
||||||
|
employees: []
|
||||||
|
};
|
||||||
|
detailDialogVisible.value = true;
|
||||||
|
loadStoreDetail(row.id);
|
||||||
|
}
|
||||||
|
|
||||||
async function submitForm() {
|
async function submitForm() {
|
||||||
if (form.id ? !canUpdateStore.value : !canCreateStore.value) return;
|
if (form.id ? !canUpdateStore.value : !canCreateStore.value) return;
|
||||||
await formRef.value?.validate();
|
await formRef.value?.validate();
|
||||||
@@ -227,7 +281,9 @@ async function toggleStatus(row: Store) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await ElMessageBox.confirm(
|
await ElMessageBox.confirm(
|
||||||
`确认${action}门店「${row.name}」?`,
|
nextStatus === "INACTIVE"
|
||||||
|
? `确认停用门店「${row.name}」?`
|
||||||
|
: `确认启用门店「${row.name}」?`,
|
||||||
"状态变更",
|
"状态变更",
|
||||||
{
|
{
|
||||||
type: "warning",
|
type: "warning",
|
||||||
@@ -245,6 +301,33 @@ async function toggleStatus(row: Store) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function removeEmployeeFromStore(row: Employee) {
|
||||||
|
if (!canRemoveStoreEmployee.value || !detailStore.value) return;
|
||||||
|
const storeId = detailStore.value.id;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`确认从门店「${detailStore.value.name}」移除员工「${row.name}」?`,
|
||||||
|
"移除员工",
|
||||||
|
{
|
||||||
|
type: "warning",
|
||||||
|
confirmButtonText: "移除",
|
||||||
|
cancelButtonText: "取消"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
removingEmployeeId.value = row.id;
|
||||||
|
await removeStoreEmployee(storeId, row.id);
|
||||||
|
ElMessage.success("员工已移除");
|
||||||
|
await loadStoreDetail(storeId);
|
||||||
|
} catch (error) {
|
||||||
|
if (error !== "cancel") {
|
||||||
|
ElMessage.error(getErrorMessage(error, "移除员工失败"));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
removingEmployeeId.value = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function removeStore(row: Store) {
|
async function removeStore(row: Store) {
|
||||||
if (!canDeleteStore.value) return;
|
if (!canDeleteStore.value) return;
|
||||||
try {
|
try {
|
||||||
@@ -375,13 +458,16 @@ onMounted(fetchStores);
|
|||||||
{{ formatTime(row.updatedAt) }}
|
{{ formatTime(row.updatedAt) }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column
|
<el-table-column label="操作" width="330" fixed="right">
|
||||||
v-if="canOperateStore"
|
|
||||||
label="操作"
|
|
||||||
width="260"
|
|
||||||
fixed="right"
|
|
||||||
>
|
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
|
<el-button
|
||||||
|
link
|
||||||
|
type="primary"
|
||||||
|
:icon="View"
|
||||||
|
@click="openDetailDialog(row)"
|
||||||
|
>
|
||||||
|
详情
|
||||||
|
</el-button>
|
||||||
<el-button
|
<el-button
|
||||||
v-if="canUpdateStore"
|
v-if="canUpdateStore"
|
||||||
link
|
link
|
||||||
@@ -480,6 +566,112 @@ onMounted(fetchStores);
|
|||||||
</el-button>
|
</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog
|
||||||
|
v-model="detailDialogVisible"
|
||||||
|
:title="detailStore ? `门店详情:${detailStore.name}` : '门店详情'"
|
||||||
|
width="860px"
|
||||||
|
destroy-on-close
|
||||||
|
>
|
||||||
|
<div v-loading="detailLoading" class="store-detail">
|
||||||
|
<el-descriptions v-if="detailStore" :column="2" border>
|
||||||
|
<el-descriptions-item label="门店名称">
|
||||||
|
{{ detailStore.name }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="状态">
|
||||||
|
<el-tag
|
||||||
|
:type="detailStore.status === 'ACTIVE' ? 'success' : 'info'"
|
||||||
|
effect="light"
|
||||||
|
>
|
||||||
|
{{ detailStore.status === "ACTIVE" ? "启用" : "停用" }}
|
||||||
|
</el-tag>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="联系电话">
|
||||||
|
{{ detailStore.phone || "-" }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="更新时间">
|
||||||
|
{{ formatTime(detailStore.updatedAt) }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="门店地址" :span="2">
|
||||||
|
{{ detailStore.address || "-" }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
|
||||||
|
<div class="detail-section-heading">
|
||||||
|
<h3>门店员工</h3>
|
||||||
|
<span>{{ detailStore?.employees.length ?? 0 }} 人</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-table
|
||||||
|
:data="detailStore?.employees ?? []"
|
||||||
|
row-key="id"
|
||||||
|
size="small"
|
||||||
|
empty-text="暂无员工"
|
||||||
|
>
|
||||||
|
<el-table-column label="员工" min-width="150">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="employee-cell">
|
||||||
|
<strong>{{ row.name }}</strong>
|
||||||
|
<span>{{ row.phone }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="角色" min-width="180">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="detail-tags">
|
||||||
|
<el-tag
|
||||||
|
v-for="role in row.roles"
|
||||||
|
:key="role.id"
|
||||||
|
effect="plain"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
{{ role.name }}
|
||||||
|
</el-tag>
|
||||||
|
<span v-if="row.roles.length === 0" class="muted">未绑定</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="状态" min-width="190">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="detail-tags">
|
||||||
|
<el-tag
|
||||||
|
v-for="tag in resolveStatusTags(row)"
|
||||||
|
:key="tag.code"
|
||||||
|
:type="getEmployeeStatusTagType(tag)"
|
||||||
|
effect="light"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
{{ tag.label }}
|
||||||
|
</el-tag>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="备注" min-width="150">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ row.remark || "-" }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column
|
||||||
|
v-if="canRemoveStoreEmployee"
|
||||||
|
label="操作"
|
||||||
|
width="100"
|
||||||
|
fixed="right"
|
||||||
|
>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button
|
||||||
|
link
|
||||||
|
type="danger"
|
||||||
|
:icon="Remove"
|
||||||
|
:loading="removingEmployeeId === row.id"
|
||||||
|
@click="removeEmployeeFromStore(row)"
|
||||||
|
>
|
||||||
|
移除
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
|
</el-dialog>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -604,7 +796,58 @@ onMounted(fetchStores);
|
|||||||
gap: 12px;
|
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 {
|
.management-page {
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user