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/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<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 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<T> {
|
||||
success: boolean;
|
||||
@@ -205,7 +223,10 @@ export const listRolePage = (params: RoleListParams) => {
|
||||
};
|
||||
|
||||
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) => {
|
||||
@@ -224,6 +245,13 @@ export const deleteStore = (id: number) => {
|
||||
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) => {
|
||||
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) => {
|
||||
return http.request<void>("delete", `${API_PREFIX}/employees/${id}`);
|
||||
};
|
||||
|
||||
+298
-39
@@ -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<FormInstance>();
|
||||
const passwordFormRef = ref<FormInstance>();
|
||||
const employees = ref<Employee[]>([]);
|
||||
const stores = ref<StoreOption[]>([]);
|
||||
const stores = ref<Store[]>([]);
|
||||
const roles = ref<RoleOption[]>([]);
|
||||
const originalStoreId = ref<number>();
|
||||
|
||||
const query = reactive({
|
||||
storeId: undefined as number | undefined,
|
||||
@@ -81,6 +98,13 @@ const form = reactive<EmployeeFormState>({
|
||||
roleIds: []
|
||||
});
|
||||
|
||||
const passwordForm = reactive<PasswordFormState>({
|
||||
employeeId: undefined,
|
||||
employeeName: "",
|
||||
oldPassword: "",
|
||||
newPassword: ""
|
||||
});
|
||||
|
||||
const rules: FormRules<EmployeeFormState> = {
|
||||
storeId: [{ required: true, message: "请选择所属门店", trigger: "change" }],
|
||||
name: [
|
||||
@@ -100,6 +124,27 @@ const rules: FormRules<EmployeeFormState> = {
|
||||
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(
|
||||
() => 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<EmployeePayload> = { ...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()]);
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -388,13 +550,14 @@ onMounted(async () => {
|
||||
filterable
|
||||
placeholder="全部门店"
|
||||
class="toolbar-control"
|
||||
:loading="catalogLoading"
|
||||
:loading="storeLoading"
|
||||
@change="handleSearch"
|
||||
@visible-change="handleStoreDropdownVisible"
|
||||
>
|
||||
<el-option
|
||||
v-for="store in stores"
|
||||
:key="store.id"
|
||||
:label="store.name"
|
||||
:label="getStoreOptionLabel(store)"
|
||||
:value="store.id"
|
||||
/>
|
||||
</el-select>
|
||||
@@ -440,7 +603,21 @@ onMounted(async () => {
|
||||
</div>
|
||||
</template>
|
||||
</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">
|
||||
<template #default="{ row }">
|
||||
<div class="role-tags">
|
||||
@@ -456,14 +633,19 @@ onMounted(async () => {
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="110">
|
||||
<el-table-column label="状态" min-width="190">
|
||||
<template #default="{ row }">
|
||||
<el-tag
|
||||
:type="row.status === 'ACTIVE' ? 'success' : 'info'"
|
||||
effect="light"
|
||||
>
|
||||
{{ row.status === "ACTIVE" ? "启用" : "停用" }}
|
||||
</el-tag>
|
||||
<div class="status-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 prop="remark" label="备注" min-width="180">
|
||||
@@ -479,7 +661,7 @@ onMounted(async () => {
|
||||
<el-table-column
|
||||
v-if="canOperateEmployee"
|
||||
label="操作"
|
||||
width="260"
|
||||
width="420"
|
||||
fixed="right"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
@@ -501,6 +683,24 @@ 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"
|
||||
link
|
||||
type="warning"
|
||||
:icon="RefreshLeft"
|
||||
@click="resetPassword(row)"
|
||||
>
|
||||
重置密码
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="canDeleteEmployee"
|
||||
link
|
||||
@@ -550,13 +750,17 @@ onMounted(async () => {
|
||||
filterable
|
||||
placeholder="请选择门店"
|
||||
class="full-width"
|
||||
:loading="catalogLoading"
|
||||
:loading="storeLoading"
|
||||
@visible-change="handleStoreDropdownVisible"
|
||||
>
|
||||
<el-option
|
||||
v-for="store in stores"
|
||||
:key="store.id"
|
||||
:label="store.name"
|
||||
:label="getStoreOptionLabel(store)"
|
||||
:value="store.id"
|
||||
:disabled="
|
||||
store.status === 'INACTIVE' && store.id !== form.storeId
|
||||
"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
@@ -587,7 +791,7 @@ onMounted(async () => {
|
||||
collapse-tags-tooltip
|
||||
placeholder="请选择角色"
|
||||
class="full-width"
|
||||
:loading="catalogLoading"
|
||||
:loading="roleLoading"
|
||||
>
|
||||
<el-option
|
||||
v-for="role in roles"
|
||||
@@ -616,6 +820,53 @@ onMounted(async () => {
|
||||
</el-button>
|
||||
</template>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -26,13 +26,16 @@ const policies = ref<PermissionPolicy[]>([]);
|
||||
const definitionGroups = ref<PermissionDefinitionGroup[]>([]);
|
||||
const editingPolicy = ref<PermissionPolicy | null>(null);
|
||||
const checkedPermissions = ref<string[]>([]);
|
||||
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)
|
||||
}}
|
||||
</el-tag>
|
||||
<span v-if="row.permissions.length === 0" class="muted-text">
|
||||
未分配
|
||||
@@ -233,14 +274,11 @@ onMounted(fetchPermissionData);
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="132" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-tooltip
|
||||
:content="row.editable ? '分配权限' : '系统最高权限不可编辑'"
|
||||
placement="top"
|
||||
>
|
||||
<el-tooltip :content="getAssignTooltip(row)" placement="top">
|
||||
<span>
|
||||
<el-button
|
||||
:icon="Edit"
|
||||
:disabled="!row.editable || !canUpdatePermissions"
|
||||
:disabled="!canAssignPolicy(row)"
|
||||
text
|
||||
type="primary"
|
||||
@click="openEditor(row)"
|
||||
@@ -472,8 +510,8 @@ onMounted(fetchPermissionData);
|
||||
|
||||
:deep(.el-checkbox.is-bordered) {
|
||||
height: auto;
|
||||
margin-right: 0;
|
||||
padding: 10px 12px;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
:deep(.el-checkbox__label) {
|
||||
@@ -495,19 +533,19 @@ onMounted(fetchPermissionData);
|
||||
|
||||
small {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
.drawer-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
@media (width <= 760px) {
|
||||
.permission-page {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
@@ -568,7 +568,7 @@ onMounted(fetchRoles);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
@media (width <= 760px) {
|
||||
.management-page {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
+255
-12
@@ -9,13 +9,18 @@ import {
|
||||
import {
|
||||
createStore,
|
||||
deleteStore,
|
||||
getStore,
|
||||
listStores,
|
||||
removeStoreEmployee,
|
||||
updateStore,
|
||||
type Employee,
|
||||
type EmployeeStatusTag,
|
||||
type Store,
|
||||
type StoreDetail,
|
||||
type StorePayload,
|
||||
type StoreStatus
|
||||
} from "@/api/access";
|
||||
import { hasMenuAction } from "@/utils/auth";
|
||||
import { hasMenuAction, hasPerms } from "@/utils/auth";
|
||||
|
||||
import Plus from "~icons/ep/plus";
|
||||
import Search from "~icons/ep/search";
|
||||
@@ -24,6 +29,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 View from "~icons/ep/view";
|
||||
import Remove from "~icons/ep/remove";
|
||||
|
||||
defineOptions({
|
||||
name: "StoreManagement"
|
||||
@@ -36,9 +43,13 @@ type StoreFormState = StorePayload & {
|
||||
|
||||
const tableLoading = ref(false);
|
||||
const submitLoading = ref(false);
|
||||
const detailLoading = ref(false);
|
||||
const dialogVisible = ref(false);
|
||||
const detailDialogVisible = ref(false);
|
||||
const formRef = ref<FormInstance>();
|
||||
const stores = ref<Store[]>([]);
|
||||
const detailStore = ref<StoreDetail | null>(null);
|
||||
const removingEmployeeId = ref<number>();
|
||||
|
||||
const query = reactive({
|
||||
status: undefined as StoreStatus | undefined,
|
||||
@@ -82,9 +93,7 @@ const dialogTitle = computed(() => (form.id ? "编辑门店" : "新增门店"));
|
||||
const canCreateStore = computed(() => hasMenuAction("stores", "create"));
|
||||
const canUpdateStore = computed(() => hasMenuAction("stores", "update"));
|
||||
const canDeleteStore = computed(() => hasMenuAction("stores", "delete"));
|
||||
const canOperateStore = computed(
|
||||
() => canUpdateStore.value || canDeleteStore.value
|
||||
);
|
||||
const canRemoveStoreEmployee = computed(() => hasPerms("employee:manage"));
|
||||
|
||||
function getErrorMessage(error: unknown, fallback: string) {
|
||||
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() {
|
||||
Object.assign(form, {
|
||||
id: undefined,
|
||||
@@ -117,6 +150,18 @@ function resetFormState() {
|
||||
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 而不是空值。 */
|
||||
function buildPayload(): StorePayload {
|
||||
const address = form.address?.trim();
|
||||
@@ -194,6 +239,15 @@ function openEditDialog(row: Store) {
|
||||
formRef.value?.clearValidate();
|
||||
}
|
||||
|
||||
function openDetailDialog(row: Store) {
|
||||
detailStore.value = {
|
||||
...row,
|
||||
employees: []
|
||||
};
|
||||
detailDialogVisible.value = true;
|
||||
loadStoreDetail(row.id);
|
||||
}
|
||||
|
||||
async function submitForm() {
|
||||
if (form.id ? !canUpdateStore.value : !canCreateStore.value) return;
|
||||
await formRef.value?.validate();
|
||||
@@ -227,7 +281,9 @@ async function toggleStatus(row: Store) {
|
||||
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确认${action}门店「${row.name}」?`,
|
||||
nextStatus === "INACTIVE"
|
||||
? `确认停用门店「${row.name}」?`
|
||||
: `确认启用门店「${row.name}」?`,
|
||||
"状态变更",
|
||||
{
|
||||
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) {
|
||||
if (!canDeleteStore.value) return;
|
||||
try {
|
||||
@@ -375,13 +458,16 @@ onMounted(fetchStores);
|
||||
{{ formatTime(row.updatedAt) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
v-if="canOperateStore"
|
||||
label="操作"
|
||||
width="260"
|
||||
fixed="right"
|
||||
>
|
||||
<el-table-column label="操作" width="330" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
link
|
||||
type="primary"
|
||||
:icon="View"
|
||||
@click="openDetailDialog(row)"
|
||||
>
|
||||
详情
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="canUpdateStore"
|
||||
link
|
||||
@@ -480,6 +566,112 @@ onMounted(fetchStores);
|
||||
</el-button>
|
||||
</template>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user