first commit

This commit is contained in:
湛兮
2026-05-26 11:14:37 +08:00
commit 88d4578600
214 changed files with 25313 additions and 0 deletions
+748
View File
@@ -0,0 +1,748 @@
<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"
});
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"
>
<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"
>
<el-option label="启用" value="ACTIVE" />
<el-option label="停用" value="INACTIVE" />
</el-select>
<el-input
v-model="query.keyword"
clearable
class="keyword-input"
placeholder="搜索姓名或手机号"
@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>