1082 lines
27 KiB
Vue
1082 lines
27 KiB
Vue
<script setup lang="ts">
|
||
import { computed, onMounted, reactive, ref } from "vue";
|
||
import {
|
||
ElMessage,
|
||
ElMessageBox,
|
||
ElNotification,
|
||
type FormInstance,
|
||
type FormRules
|
||
} from "element-plus";
|
||
import {
|
||
createEmployee,
|
||
deleteEmployee,
|
||
getEmployee,
|
||
listEmployees,
|
||
listAllStores,
|
||
listRoles,
|
||
resetEmployeePassword,
|
||
updateEmployee,
|
||
updateEmployeeStatus,
|
||
type Employee,
|
||
type EmployeePayload,
|
||
type EmployeeStatus,
|
||
type EmployeeStatusTag,
|
||
type RoleOption,
|
||
type Store
|
||
} from "@/api/access";
|
||
import { hasMenuAction, hasPerms } from "@/utils/auth";
|
||
|
||
import Plus from "~icons/ep/plus";
|
||
import Search from "~icons/ep/search";
|
||
import Refresh from "~icons/ep/refresh";
|
||
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 CopyDocument from "~icons/ep/copy-document";
|
||
|
||
defineOptions({
|
||
name: "EmployeeManagement"
|
||
});
|
||
|
||
/** 表单状态复用后端员工 payload,id 用来切换创建/更新接口。 */
|
||
type EmployeeFormState = EmployeePayload & {
|
||
id?: number;
|
||
};
|
||
|
||
type PasswordFormState = {
|
||
employeeId?: number;
|
||
employeeName: string;
|
||
reason: string;
|
||
};
|
||
|
||
/** 员工手机号按中国大陆手机号做前端第一层校验,最终唯一性仍由后端保证。 */
|
||
const phonePattern = /^1[3-9]\d{9}$/;
|
||
const bindableRoleCodes = new Set([
|
||
"store_manager",
|
||
"cashier",
|
||
"kitchen",
|
||
"part_time",
|
||
"admin"
|
||
]);
|
||
const tableLoading = 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 temporaryPasswordVisible = ref(false);
|
||
const formRef = ref<FormInstance>();
|
||
const passwordFormRef = ref<FormInstance>();
|
||
const employees = ref<Employee[]>([]);
|
||
const stores = ref<Store[]>([]);
|
||
const roles = ref<RoleOption[]>([]);
|
||
const originalStoreId = ref<number>();
|
||
const temporaryPassword = ref("");
|
||
|
||
const query = reactive({
|
||
storeId: undefined as number | undefined,
|
||
status: undefined as EmployeeStatus | undefined,
|
||
keyword: "",
|
||
page: 1,
|
||
pageSize: 20
|
||
});
|
||
|
||
/** 员工列表由后端分页,这里只保存当前查询返回的分页摘要。 */
|
||
const pagination = reactive({
|
||
total: 0,
|
||
totalPages: 0
|
||
});
|
||
|
||
const form = reactive<EmployeeFormState>({
|
||
storeId: undefined as unknown as number,
|
||
name: "",
|
||
phone: "",
|
||
status: "ACTIVE",
|
||
remark: "",
|
||
roleIds: []
|
||
});
|
||
|
||
const passwordForm = reactive<PasswordFormState>({
|
||
employeeId: undefined,
|
||
employeeName: "",
|
||
reason: ""
|
||
});
|
||
|
||
const rules: FormRules<EmployeeFormState> = {
|
||
storeId: [{ required: true, message: "请选择所属门店", trigger: "change" }],
|
||
name: [
|
||
{ required: true, message: "请输入员工姓名", trigger: "blur" },
|
||
{ max: 50, message: "员工姓名不能超过 50 个字符", trigger: "blur" }
|
||
],
|
||
phone: [
|
||
{ required: true, message: "请输入手机号", trigger: "blur" },
|
||
{
|
||
pattern: phonePattern,
|
||
message: "请输入正确的中国大陆手机号",
|
||
trigger: "blur"
|
||
}
|
||
],
|
||
status: [{ required: true, message: "请选择员工状态", trigger: "change" }],
|
||
roleIds: [{ type: "array", message: "请选择角色", trigger: "change" }],
|
||
remark: [{ max: 500, message: "备注不能超过 500 个字符", trigger: "blur" }]
|
||
};
|
||
|
||
const passwordRules: FormRules<PasswordFormState> = {
|
||
reason: [
|
||
{ required: true, message: "请填写重置原因", trigger: "blur" },
|
||
{
|
||
min: 4,
|
||
max: 255,
|
||
message: "原因需要在 4-255 个字符之间",
|
||
trigger: "blur"
|
||
}
|
||
]
|
||
};
|
||
|
||
const activeCount = computed(
|
||
() => employees.value.filter(item => item.status === "ACTIVE").length
|
||
);
|
||
const inactiveCount = computed(
|
||
() => employees.value.filter(item => item.status === "INACTIVE").length
|
||
);
|
||
const dialogTitle = computed(() => (form.id ? "编辑员工" : "新增员工"));
|
||
const canCreateEmployee = computed(() => hasMenuAction("employees", "create"));
|
||
const canUpdateEmployee = computed(() => hasMenuAction("employees", "update"));
|
||
const canDeleteEmployee = computed(() => hasMenuAction("employees", "delete"));
|
||
const canResetCredential = computed(() => hasPerms("credential:reset"));
|
||
const canOperateEmployee = computed(
|
||
() =>
|
||
canUpdateEmployee.value ||
|
||
canDeleteEmployee.value ||
|
||
canResetCredential.value
|
||
);
|
||
const canViewAllEmployees = computed(() => hasPerms("employee:view:all"));
|
||
|
||
function getErrorMessage(error: unknown, fallback: string) {
|
||
const message = (
|
||
error as { response?: { data?: { error?: { message?: string } } } }
|
||
)?.response?.data?.error?.message;
|
||
|
||
if (message) return message;
|
||
if (error instanceof Error && error.message) return error.message;
|
||
return fallback;
|
||
}
|
||
|
||
function formatTime(value: string) {
|
||
return new Date(value).toLocaleString("zh-CN", {
|
||
year: "numeric",
|
||
month: "2-digit",
|
||
day: "2-digit",
|
||
hour: "2-digit",
|
||
minute: "2-digit"
|
||
});
|
||
}
|
||
|
||
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: undefined,
|
||
name: "",
|
||
phone: "",
|
||
status: "ACTIVE",
|
||
remark: "",
|
||
roleIds: []
|
||
});
|
||
formRef.value?.clearValidate();
|
||
}
|
||
|
||
function resetPasswordFormState() {
|
||
temporaryPassword.value = "";
|
||
Object.assign(passwordForm, {
|
||
employeeId: undefined,
|
||
employeeName: "",
|
||
reason: ""
|
||
});
|
||
passwordFormRef.value?.clearValidate();
|
||
}
|
||
|
||
/** 清理提交数据,避免把仅包含空格的备注写入后端。 */
|
||
function buildPayload(): EmployeePayload {
|
||
return {
|
||
storeId: form.storeId,
|
||
name: form.name.trim(),
|
||
phone: form.phone.trim(),
|
||
status: form.status,
|
||
remark: form.remark?.trim() ? form.remark.trim() : null,
|
||
roleIds: form.roleIds
|
||
};
|
||
}
|
||
|
||
/** 门店可能在其他页面被新增,所以下拉展开时单独拉取最新选项。 */
|
||
async function fetchStores() {
|
||
if (
|
||
!canViewAllEmployees.value &&
|
||
!canCreateEmployee.value &&
|
||
!canUpdateEmployee.value
|
||
) {
|
||
return;
|
||
}
|
||
|
||
storeLoading.value = true;
|
||
try {
|
||
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, "加载角色选项失败"));
|
||
} finally {
|
||
roleLoading.value = false;
|
||
}
|
||
}
|
||
|
||
/** 员工列表筛选、分页都交给后端,前端只传递当前查询条件。 */
|
||
async function fetchEmployees() {
|
||
tableLoading.value = true;
|
||
try {
|
||
const result = await listEmployees({
|
||
storeId: query.storeId,
|
||
status: query.status,
|
||
keyword: query.keyword.trim() || undefined,
|
||
page: query.page,
|
||
pageSize: query.pageSize
|
||
});
|
||
employees.value = result.data.items;
|
||
pagination.total = result.data.pagination.total;
|
||
pagination.totalPages = result.data.pagination.totalPages;
|
||
} catch (error) {
|
||
ElMessage.error(getErrorMessage(error, "加载员工列表失败"));
|
||
} finally {
|
||
tableLoading.value = false;
|
||
}
|
||
}
|
||
|
||
function handleSearch() {
|
||
query.page = 1;
|
||
fetchEmployees();
|
||
}
|
||
|
||
function handleReset() {
|
||
query.storeId = undefined;
|
||
query.status = undefined;
|
||
query.keyword = "";
|
||
query.page = 1;
|
||
query.pageSize = 20;
|
||
fetchEmployees();
|
||
}
|
||
|
||
function handlePageChange(page: number) {
|
||
query.page = page;
|
||
fetchEmployees();
|
||
}
|
||
|
||
function handleSizeChange(pageSize: number) {
|
||
query.page = 1;
|
||
query.pageSize = pageSize;
|
||
fetchEmployees();
|
||
}
|
||
|
||
function openCreateDialog() {
|
||
if (!canCreateEmployee.value) return;
|
||
resetFormState();
|
||
dialogVisible.value = true;
|
||
fetchStores();
|
||
}
|
||
|
||
/** 编辑前重新拉详情,确保角色绑定不是来自列表摘要的过期数据。 */
|
||
async function openEditDialog(row: Employee) {
|
||
if (!canUpdateEmployee.value) return;
|
||
try {
|
||
const result = await getEmployee(row.id);
|
||
const employee = result.data;
|
||
originalStoreId.value = employee.storeId;
|
||
Object.assign(form, {
|
||
id: employee.id,
|
||
storeId: employee.storeId,
|
||
name: employee.name,
|
||
phone: employee.phone,
|
||
status: employee.status,
|
||
remark: employee.remark ?? "",
|
||
roleIds: employee.roles.map(role => role.id)
|
||
});
|
||
dialogVisible.value = true;
|
||
fetchStores();
|
||
formRef.value?.clearValidate();
|
||
} catch (error) {
|
||
ElMessage.error(getErrorMessage(error, "加载员工详情失败"));
|
||
}
|
||
}
|
||
|
||
async function submitForm() {
|
||
if (form.id ? !canUpdateEmployee.value : !canCreateEmployee.value) return;
|
||
await formRef.value?.validate();
|
||
submitLoading.value = true;
|
||
|
||
try {
|
||
const payload = buildPayload();
|
||
|
||
if (form.id) {
|
||
const updatePayload: Partial<EmployeePayload> = { ...payload };
|
||
|
||
if (payload.storeId === originalStoreId.value) {
|
||
delete updatePayload.storeId;
|
||
}
|
||
|
||
await updateEmployee(form.id, updatePayload);
|
||
ElMessage.success("员工信息已更新");
|
||
} else {
|
||
await createEmployee(payload);
|
||
ElMessage.success("员工已创建");
|
||
}
|
||
|
||
dialogVisible.value = false;
|
||
fetchEmployees();
|
||
} catch (error) {
|
||
ElMessage.error(getErrorMessage(error, "保存员工失败"));
|
||
} finally {
|
||
submitLoading.value = false;
|
||
}
|
||
}
|
||
|
||
function openPasswordDialog(row: Employee) {
|
||
if (!canResetCredential.value) return;
|
||
resetPasswordFormState();
|
||
Object.assign(passwordForm, {
|
||
employeeId: row.id,
|
||
employeeName: row.name,
|
||
reason: ""
|
||
});
|
||
passwordDialogVisible.value = true;
|
||
}
|
||
|
||
async function submitPasswordForm() {
|
||
if (!canResetCredential.value || !passwordForm.employeeId) return;
|
||
await passwordFormRef.value?.validate();
|
||
passwordSubmitLoading.value = true;
|
||
|
||
try {
|
||
const result = await resetEmployeePassword(passwordForm.employeeId, {
|
||
reason: passwordForm.reason.trim()
|
||
});
|
||
passwordDialogVisible.value = false;
|
||
temporaryPassword.value = result.data.temporaryPassword;
|
||
temporaryPasswordVisible.value = true;
|
||
fetchEmployees();
|
||
} catch (error) {
|
||
ElMessage.error(getErrorMessage(error, "重置密码失败"));
|
||
} finally {
|
||
passwordSubmitLoading.value = false;
|
||
}
|
||
}
|
||
|
||
async function copyTemporaryPassword() {
|
||
if (!temporaryPassword.value) return;
|
||
await navigator.clipboard.writeText(temporaryPassword.value);
|
||
ElMessage.success("临时密码已复制");
|
||
}
|
||
|
||
function clearTemporaryPassword() {
|
||
temporaryPassword.value = "";
|
||
}
|
||
|
||
function showPasswordResetBoundary() {
|
||
ElNotification.warning({
|
||
title: "安全边界",
|
||
message: "正式版不支持查看当前明文密码,只能重置并一次性查看临时密码。"
|
||
});
|
||
}
|
||
|
||
async function toggleStatus(row: Employee) {
|
||
if (!canUpdateEmployee.value) return;
|
||
const nextStatus: EmployeeStatus =
|
||
row.status === "ACTIVE" ? "INACTIVE" : "ACTIVE";
|
||
const action = nextStatus === "ACTIVE" ? "启用" : "停用";
|
||
|
||
try {
|
||
await ElMessageBox.confirm(
|
||
`确认${action}员工「${row.name}」?`,
|
||
"状态变更",
|
||
{
|
||
type: "warning",
|
||
confirmButtonText: action,
|
||
cancelButtonText: "取消"
|
||
}
|
||
);
|
||
await updateEmployeeStatus(row.id, nextStatus);
|
||
ElMessage.success(`已${action}`);
|
||
fetchEmployees();
|
||
} catch (error) {
|
||
if (error !== "cancel") {
|
||
ElMessage.error(getErrorMessage(error, "状态变更失败"));
|
||
}
|
||
}
|
||
}
|
||
|
||
async function removeEmployee(row: Employee) {
|
||
if (!canDeleteEmployee.value) return;
|
||
try {
|
||
await ElMessageBox.confirm(
|
||
`删除后员工「${row.name}」会被软删除并停用,确认继续?`,
|
||
"删除员工",
|
||
{
|
||
type: "warning",
|
||
confirmButtonText: "删除",
|
||
cancelButtonText: "取消"
|
||
}
|
||
);
|
||
await deleteEmployee(row.id);
|
||
ElMessage.success("员工已删除");
|
||
|
||
if (employees.value.length === 1 && query.page > 1) {
|
||
query.page -= 1;
|
||
}
|
||
fetchEmployees();
|
||
} catch (error) {
|
||
if (error !== "cancel") {
|
||
ElMessage.error(getErrorMessage(error, "删除员工失败"));
|
||
}
|
||
}
|
||
}
|
||
|
||
onMounted(async () => {
|
||
await Promise.all([fetchRoles(), fetchEmployees()]);
|
||
});
|
||
</script>
|
||
|
||
<template>
|
||
<section class="employee-page">
|
||
<div class="page-heading">
|
||
<div>
|
||
<p class="eyebrow">门店员工权限管理</p>
|
||
<h1>员工管理</h1>
|
||
</div>
|
||
<el-button
|
||
v-if="canCreateEmployee"
|
||
type="primary"
|
||
:icon="Plus"
|
||
@click="openCreateDialog"
|
||
>
|
||
新增员工
|
||
</el-button>
|
||
</div>
|
||
|
||
<div class="summary-strip">
|
||
<div class="summary-item">
|
||
<span>总员工</span>
|
||
<strong>{{ pagination.total }}</strong>
|
||
</div>
|
||
<div class="summary-item">
|
||
<span>当前页启用</span>
|
||
<strong>{{ activeCount }}</strong>
|
||
</div>
|
||
<div class="summary-item">
|
||
<span>当前页停用</span>
|
||
<strong>{{ inactiveCount }}</strong>
|
||
</div>
|
||
<div class="summary-item">
|
||
<span>角色数量</span>
|
||
<strong>{{ roles.length }}</strong>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="toolbar">
|
||
<el-select
|
||
v-if="canViewAllEmployees"
|
||
v-model="query.storeId"
|
||
clearable
|
||
filterable
|
||
placeholder="全部门店"
|
||
class="toolbar-control"
|
||
:loading="storeLoading"
|
||
@change="handleSearch"
|
||
@visible-change="handleStoreDropdownVisible"
|
||
>
|
||
<el-option
|
||
v-for="store in stores"
|
||
:key="store.id"
|
||
:label="getStoreOptionLabel(store)"
|
||
:value="store.id"
|
||
/>
|
||
</el-select>
|
||
<el-select
|
||
v-model="query.status"
|
||
clearable
|
||
placeholder="全部状态"
|
||
class="toolbar-control"
|
||
@change="handleSearch"
|
||
>
|
||
<el-option label="启用" value="ACTIVE" />
|
||
<el-option label="停用" value="INACTIVE" />
|
||
</el-select>
|
||
<el-input
|
||
v-model="query.keyword"
|
||
clearable
|
||
class="keyword-input"
|
||
placeholder="搜索姓名或手机号"
|
||
@clear="handleSearch"
|
||
@keyup.enter="handleSearch"
|
||
/>
|
||
<div class="toolbar-actions">
|
||
<el-button type="primary" :icon="Search" @click="handleSearch">
|
||
查询
|
||
</el-button>
|
||
<el-button :icon="Refresh" @click="handleReset">重置</el-button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="table-shell">
|
||
<el-table
|
||
v-loading="tableLoading"
|
||
:data="employees"
|
||
row-key="id"
|
||
stripe
|
||
class="employee-table"
|
||
>
|
||
<el-table-column prop="name" label="员工" min-width="160">
|
||
<template #default="{ row }">
|
||
<div class="employee-cell">
|
||
<strong>{{ row.name }}</strong>
|
||
<span>{{ row.phone }}</span>
|
||
</div>
|
||
</template>
|
||
</el-table-column>
|
||
<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">
|
||
<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="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">
|
||
<template #default="{ row }">
|
||
<span>{{ row.remark || "-" }}</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="更新时间" width="180">
|
||
<template #default="{ row }">
|
||
{{ formatTime(row.updatedAt) }}
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column
|
||
v-if="canOperateEmployee"
|
||
label="操作"
|
||
width="420"
|
||
fixed="right"
|
||
>
|
||
<template #default="{ row }">
|
||
<el-button
|
||
v-if="canUpdateEmployee"
|
||
link
|
||
type="primary"
|
||
:icon="EditPen"
|
||
@click="openEditDialog(row)"
|
||
>
|
||
编辑
|
||
</el-button>
|
||
<el-button
|
||
v-if="canUpdateEmployee"
|
||
link
|
||
:type="row.status === 'ACTIVE' ? 'warning' : 'success'"
|
||
:icon="row.status === 'ACTIVE' ? CircleClose : CircleCheck"
|
||
@click="toggleStatus(row)"
|
||
>
|
||
{{ row.status === "ACTIVE" ? "停用" : "启用" }}
|
||
</el-button>
|
||
<el-button
|
||
v-if="canResetCredential"
|
||
link
|
||
type="warning"
|
||
:icon="Key"
|
||
@click="openPasswordDialog(row)"
|
||
>
|
||
重置密码
|
||
</el-button>
|
||
<el-button
|
||
v-if="canDeleteEmployee"
|
||
link
|
||
type="danger"
|
||
:icon="Delete"
|
||
@click="removeEmployee(row)"
|
||
>
|
||
删除
|
||
</el-button>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
|
||
<div class="pagination-row">
|
||
<span
|
||
>共 {{ pagination.total }} 条,{{ pagination.totalPages }} 页</span
|
||
>
|
||
<el-pagination
|
||
v-model:current-page="query.page"
|
||
v-model:page-size="query.pageSize"
|
||
background
|
||
layout="sizes, prev, pager, next"
|
||
:total="pagination.total"
|
||
:page-sizes="[10, 20, 50, 100]"
|
||
@current-change="handlePageChange"
|
||
@size-change="handleSizeChange"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<el-dialog
|
||
v-model="dialogVisible"
|
||
:title="dialogTitle"
|
||
width="560px"
|
||
destroy-on-close
|
||
>
|
||
<el-form
|
||
ref="formRef"
|
||
:model="form"
|
||
:rules="rules"
|
||
label-position="top"
|
||
class="employee-form"
|
||
>
|
||
<el-form-item label="所属门店" prop="storeId">
|
||
<el-select
|
||
v-model="form.storeId"
|
||
filterable
|
||
placeholder="请选择门店"
|
||
class="full-width"
|
||
:loading="storeLoading"
|
||
@visible-change="handleStoreDropdownVisible"
|
||
>
|
||
<el-option
|
||
v-for="store in stores"
|
||
:key="store.id"
|
||
:label="getStoreOptionLabel(store)"
|
||
:value="store.id"
|
||
:disabled="
|
||
store.status === 'INACTIVE' && store.id !== form.storeId
|
||
"
|
||
/>
|
||
</el-select>
|
||
</el-form-item>
|
||
<div class="form-grid">
|
||
<el-form-item label="员工姓名" prop="name">
|
||
<el-input v-model="form.name" maxlength="50" placeholder="请输入" />
|
||
</el-form-item>
|
||
<el-form-item label="手机号" prop="phone">
|
||
<el-input
|
||
v-model="form.phone"
|
||
maxlength="11"
|
||
placeholder="请输入 11 位手机号"
|
||
/>
|
||
</el-form-item>
|
||
</div>
|
||
<el-form-item label="状态" prop="status">
|
||
<el-radio-group v-model="form.status">
|
||
<el-radio-button label="ACTIVE">启用</el-radio-button>
|
||
<el-radio-button label="INACTIVE">停用</el-radio-button>
|
||
</el-radio-group>
|
||
</el-form-item>
|
||
<el-form-item label="角色" prop="roleIds">
|
||
<el-select
|
||
v-model="form.roleIds"
|
||
multiple
|
||
filterable
|
||
collapse-tags
|
||
collapse-tags-tooltip
|
||
placeholder="请选择角色"
|
||
class="full-width"
|
||
:loading="roleLoading"
|
||
>
|
||
<el-option
|
||
v-for="role in roles"
|
||
:key="role.id"
|
||
:label="`${role.name}(${role.code})`"
|
||
:value="role.id"
|
||
/>
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="备注" prop="remark">
|
||
<el-input
|
||
v-model="form.remark"
|
||
type="textarea"
|
||
maxlength="500"
|
||
show-word-limit
|
||
:rows="4"
|
||
placeholder="可填写班次、权限说明或其他备注"
|
||
/>
|
||
</el-form-item>
|
||
</el-form>
|
||
|
||
<template #footer>
|
||
<el-button @click="dialogVisible = false">取消</el-button>
|
||
<el-button type="primary" :loading="submitLoading" @click="submitForm">
|
||
保存
|
||
</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<el-dialog
|
||
v-model="passwordDialogVisible"
|
||
title="重置员工密码"
|
||
width="460px"
|
||
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-alert
|
||
type="warning"
|
||
show-icon
|
||
:closable="false"
|
||
class="password-alert"
|
||
title="不会展示员工当前密码。提交后仅在下一步弹窗显示一次临时密码。"
|
||
/>
|
||
<el-form-item label="重置原因" prop="reason">
|
||
<el-input
|
||
v-model="passwordForm.reason"
|
||
type="textarea"
|
||
maxlength="255"
|
||
show-word-limit
|
||
:rows="4"
|
||
placeholder="请填写重置原因,用于凭据审计"
|
||
/>
|
||
</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>
|
||
|
||
<el-dialog
|
||
v-model="temporaryPasswordVisible"
|
||
title="一次性临时密码"
|
||
width="460px"
|
||
destroy-on-close
|
||
@closed="clearTemporaryPassword"
|
||
>
|
||
<el-alert
|
||
type="warning"
|
||
show-icon
|
||
:closable="false"
|
||
class="password-alert"
|
||
title="关闭弹窗后前端会清空临时密码,请立即复制并通过安全渠道交付员工。"
|
||
/>
|
||
<el-input
|
||
:model-value="temporaryPassword"
|
||
readonly
|
||
class="temporary-password"
|
||
@focus="showPasswordResetBoundary"
|
||
/>
|
||
<template #footer>
|
||
<el-button @click="temporaryPasswordVisible = false">关闭</el-button>
|
||
<el-button
|
||
type="primary"
|
||
:icon="CopyDocument"
|
||
@click="copyTemporaryPassword"
|
||
>
|
||
复制临时密码
|
||
</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
</section>
|
||
</template>
|
||
|
||
<style scoped lang="scss">
|
||
.employee-page {
|
||
min-height: 100%;
|
||
padding: 20px;
|
||
background: #f6f8fb;
|
||
}
|
||
|
||
.page-heading {
|
||
display: flex;
|
||
gap: 16px;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
margin-bottom: 16px;
|
||
|
||
h1 {
|
||
margin: 4px 0 0;
|
||
font-size: 24px;
|
||
font-weight: 650;
|
||
color: #111827;
|
||
letter-spacing: 0;
|
||
}
|
||
}
|
||
|
||
.eyebrow {
|
||
margin: 0;
|
||
font-size: 13px;
|
||
color: #64748b;
|
||
}
|
||
|
||
.summary-strip {
|
||
display: grid;
|
||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||
gap: 12px;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.summary-item {
|
||
padding: 14px 16px;
|
||
background: #fff;
|
||
border: 1px solid #e5e7eb;
|
||
border-radius: 8px;
|
||
|
||
span {
|
||
display: block;
|
||
margin-bottom: 8px;
|
||
font-size: 13px;
|
||
color: #64748b;
|
||
}
|
||
|
||
strong {
|
||
font-size: 24px;
|
||
font-weight: 700;
|
||
color: #111827;
|
||
letter-spacing: 0;
|
||
}
|
||
}
|
||
|
||
.toolbar,
|
||
.table-shell {
|
||
background: #fff;
|
||
border: 1px solid #e5e7eb;
|
||
border-radius: 8px;
|
||
}
|
||
|
||
.toolbar {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 12px;
|
||
align-items: center;
|
||
padding: 14px;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.toolbar-control {
|
||
width: 180px;
|
||
}
|
||
|
||
.keyword-input {
|
||
width: 240px;
|
||
}
|
||
|
||
.toolbar-actions {
|
||
display: flex;
|
||
gap: 8px;
|
||
margin-left: auto;
|
||
}
|
||
|
||
.table-shell {
|
||
padding: 8px 0 14px;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.employee-table {
|
||
width: 100%;
|
||
}
|
||
|
||
.employee-cell {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 3px;
|
||
|
||
strong {
|
||
font-weight: 650;
|
||
color: #111827;
|
||
}
|
||
|
||
span {
|
||
font-size: 12px;
|
||
color: #64748b;
|
||
}
|
||
}
|
||
|
||
.role-tags {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 6px;
|
||
}
|
||
|
||
.store-cell,
|
||
.status-tags {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 6px;
|
||
align-items: center;
|
||
}
|
||
|
||
.muted {
|
||
color: #94a3b8;
|
||
}
|
||
|
||
.pagination-row {
|
||
display: flex;
|
||
gap: 16px;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 14px 16px 0;
|
||
font-size: 13px;
|
||
color: #64748b;
|
||
}
|
||
|
||
.employee-form {
|
||
padding-top: 4px;
|
||
}
|
||
|
||
.form-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
gap: 12px;
|
||
}
|
||
|
||
.full-width {
|
||
width: 100%;
|
||
}
|
||
|
||
.password-alert {
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.temporary-password {
|
||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||
}
|
||
|
||
@media (width <= 760px) {
|
||
.employee-page {
|
||
padding: 12px;
|
||
}
|
||
|
||
.page-heading {
|
||
flex-direction: column;
|
||
align-items: flex-start;
|
||
}
|
||
|
||
.summary-strip {
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
}
|
||
|
||
.toolbar-control,
|
||
.keyword-input,
|
||
.toolbar-actions {
|
||
width: 100%;
|
||
margin-left: 0;
|
||
}
|
||
|
||
.toolbar-actions {
|
||
justify-content: flex-end;
|
||
}
|
||
|
||
.pagination-row {
|
||
flex-direction: column;
|
||
align-items: flex-start;
|
||
}
|
||
|
||
.form-grid {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
}
|
||
</style>
|