Files
role-admin/src/views/employees/index.vue
T
2026-05-26 11:43:27 +08:00

759 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from "vue";
import {
ElMessage,
ElMessageBox,
type FormInstance,
type FormRules
} from "element-plus";
import {
createEmployee,
deleteEmployee,
getEmployee,
listEmployees,
listRoles,
listStores,
updateEmployee,
updateEmployeeStatus,
type Employee,
type EmployeePayload,
type EmployeeStatus,
type RoleOption,
type StoreOption
} from "@/api/access";
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";
defineOptions({
name: "EmployeeManagement"
});
/** 表单状态复用后端员工 payload,id 用来切换创建/更新接口。 */
type EmployeeFormState = EmployeePayload & {
id?: number;
};
/** 员工手机号按中国大陆手机号做前端第一层校验,最终唯一性仍由后端保证。 */
const phonePattern = /^1[3-9]\d{9}$/;
const tableLoading = ref(false);
const catalogLoading = ref(false);
const submitLoading = ref(false);
const dialogVisible = ref(false);
const formRef = ref<FormInstance>();
const employees = ref<Employee[]>([]);
const stores = ref<StoreOption[]>([]);
const roles = ref<RoleOption[]>([]);
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 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 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 ? "编辑员工" : "新增员工"));
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 resetFormState() {
Object.assign(form, {
id: undefined,
storeId: stores.value[0]?.id,
name: "",
phone: "",
status: "ACTIVE",
remark: "",
roleIds: []
});
formRef.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 fetchCatalog() {
catalogLoading.value = true;
try {
const [storeResult, roleResult] = await Promise.all([
listStores(),
listRoles()
]);
stores.value = storeResult.data;
roles.value = roleResult.data;
} catch (error) {
ElMessage.error(getErrorMessage(error, "加载门店和角色选项失败"));
} finally {
catalogLoading.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() {
resetFormState();
dialogVisible.value = true;
}
/** 编辑前重新拉详情,确保角色绑定不是来自列表摘要的过期数据。 */
async function openEditDialog(row: Employee) {
try {
const result = await getEmployee(row.id);
const employee = result.data;
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;
formRef.value?.clearValidate();
} catch (error) {
ElMessage.error(getErrorMessage(error, "加载员工详情失败"));
}
}
async function submitForm() {
await formRef.value?.validate();
submitLoading.value = true;
try {
const payload = buildPayload();
if (form.id) {
await updateEmployee(form.id, payload);
ElMessage.success("员工信息已更新");
} else {
await createEmployee(payload);
ElMessage.success("员工已创建");
}
dialogVisible.value = false;
fetchEmployees();
} catch (error) {
ElMessage.error(getErrorMessage(error, "保存员工失败"));
} finally {
submitLoading.value = false;
}
}
async function toggleStatus(row: Employee) {
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) {
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 fetchCatalog();
await fetchEmployees();
});
</script>
<template>
<section class="employee-page">
<div class="page-heading">
<div>
<p class="eyebrow">门店员工权限管理</p>
<h1>员工管理</h1>
</div>
<el-button 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-model="query.storeId"
clearable
filterable
placeholder="全部门店"
class="toolbar-control"
:loading="catalogLoading"
@change="handleSearch"
>
<el-option
v-for="store in stores"
:key="store.id"
:label="store.name"
: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="150" />
<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="状态" width="110">
<template #default="{ row }">
<el-tag
:type="row.status === 'ACTIVE' ? 'success' : 'info'"
effect="light"
>
{{ row.status === "ACTIVE" ? "启用" : "停用" }}
</el-tag>
</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 label="操作" width="260" fixed="right">
<template #default="{ row }">
<el-button
link
type="primary"
:icon="EditPen"
@click="openEditDialog(row)"
>
编辑
</el-button>
<el-button
link
:type="row.status === 'ACTIVE' ? 'warning' : 'success'"
:icon="row.status === 'ACTIVE' ? CircleClose : CircleCheck"
@click="toggleStatus(row)"
>
{{ row.status === "ACTIVE" ? "停用" : "启用" }}
</el-button>
<el-button
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="catalogLoading"
>
<el-option
v-for="store in stores"
:key="store.id"
:label="store.name"
:value="store.id"
/>
</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="catalogLoading"
>
<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>
</section>
</template>
<style scoped lang="scss">
.employee-page {
min-height: 100%;
padding: 20px;
background: #f6f8fb;
}
.page-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
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;
}
.muted {
color: #94a3b8;
}
.pagination-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
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%;
}
@media (max-width: 760px) {
.employee-page {
padding: 12px;
}
.page-heading {
align-items: flex-start;
flex-direction: column;
}
.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 {
align-items: flex-start;
flex-direction: column;
}
.form-grid {
grid-template-columns: 1fr;
}
}
</style>