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
+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;
}