feat: 完善员工门店管理交互
This commit is contained in:
+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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user