feat: 完善员工门店管理交互

This commit is contained in:
湛兮
2026-05-26 18:01:52 +08:00
parent 304589bf8b
commit e9778249e7
6 changed files with 662 additions and 73 deletions
+6 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
+55 -17
View File
@@ -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;
}
+1 -1
View File
@@ -568,7 +568,7 @@ onMounted(fetchRoles);
gap: 12px;
}
@media (max-width: 760px) {
@media (width <= 760px) {
.management-page {
padding: 12px;
}
+255 -12
View File
@@ -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;
}