PayrollManagementSystem/frontend/src/views/base/personnel/index.vue
liuwenxin e61800dcdc feat(frontend): 菜单图标与视觉设计系统落地
- 引入 @element-plus/icons-vue 官方图标库,菜单项按模块配图标,消除
  纯文字导航的辨识成本(新增依赖已评估:Element Plus 官方同源维护)
- AppMenu/BasicLayout/router 同步:图标渲染、菜单结构与路由 meta 对齐
- index.css 视觉系统化:品牌色、语义三色(ok/warn/danger 及其 wash 层次)、
  形体与材质(圆角、缓动、阴影、等宽字体)统一为 CSS 变量,并覆盖
  Element Plus 主色
- 登录页、业务页、结算页、工作台、审批页等视图随新变量适配

验证:pnpm type-check 与 pnpm build 均通过;后端接口契约未变。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-22 19:45:54 +08:00

686 lines
24 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, type FormInstance, type FormRules } from 'element-plus'
import * as businessRoleApi from '@/api/businessRoles'
import * as personnelApi from '@/api/personnel'
import type { PersonnelItem } from '@/api/personnel'
import AppPagination from '@/components/table/AppPagination.vue'
import StatusTag from '@/components/table/StatusTag.vue'
import { useConfirmAction } from '@/composables/useConfirmAction'
import { useDropdownOptions } from '@/composables/useDropdownOptions'
import { usePagedList } from '@/composables/usePagedList'
import { useEditingRow } from '@/composables/useEditingRow'
import { AMOUNT_PATTERN } from '@/utils/validate'
import { handledError } from '@/utils/error'
/**
* 人员管理(需求 2.1.1:人员档案 + 辐射区域 + 拓展计划)。
*
* 与后端约定对齐:
* - 基本信息新增/编辑走弹窗(全量提交、不含状态);在职/离职切换、
* 删除均为独立操作,删除被引用时后端拒绝,此时应改为离职
* - 编辑抽屉承载子表:辐射区域与拓展计划行内增删,保存走
* PUT /{id}/regions、PUT /{id}/plans 全量替换(空列表即清空)
* - 工资标准为字符串直出(金额红线:不经 float、不 toFixed)
*/
const { confirmAndRun } = useConfirmAction()
// ==================== 列表 ====================
/** 在职/离职筛选项(与产品页「启用/禁用」语义不同,页面内定义) */
const EMPLOYMENT_OPTIONS = [
{ label: '在职', value: 1 },
{ label: '离职', value: 0 },
] as const
const filters = reactive({
keyword: '',
status: undefined as number | undefined,
business_role_id: undefined as number | undefined,
})
const { records, total, pageNum, pageSize, loading, load, search, onPageChange, onSizeChange, reloadAfterDelete } =
usePagedList<PersonnelItem, personnelApi.PersonnelQueryParams>(
personnelApi.listPersonnel,
() => ({
keyword: filters.keyword,
status: filters.status,
business_role_id: filters.business_role_id,
}),
)
function resetFilters(): void {
filters.keyword = ''
filters.status = undefined
filters.business_role_id = undefined
search()
}
// ==================== 岗位 / 经理下拉数据源 ====================
interface SelectOption {
id: number
name: string
status: number
}
const { options: roleOptions, load: loadRoleOptions } = useDropdownOptions<SelectOption>(
async () => {
// 岗位规模有限(内置 3 个 + 自定义),选项接口一次取全
const data = await businessRoleApi.listBusinessRoleOptions()
return data.map((r) => ({ id: r.id, name: r.role_name, status: r.status }))
},
)
const { options: managerOptions, load: loadManagerOptions } = useDropdownOptions<SelectOption>(
async () => {
// 经理下拉的近似全量数据源:内部系统人员规模有限,200 条足够;
// 超出规模的极端场景由后端对经理合法性的校验兜底
const data = await personnelApi.listPersonnel({ pageNum: 1, pageSize: 200 })
return data.list.map((p) => ({ id: p.id, name: p.personnel_name, status: p.status }))
},
)
// ==================== 新增 / 编辑(基本信息) ====================
const dialogVisible = ref(false)
const editingId = ref<number | null>(null)
/** 单一状态源:编辑中 ⇔ editingId 非空(避免双状态漂移) */
const isEdit = computed(() => editingId.value !== null)
const formRef = ref<FormInstance>()
const submitting = ref(false)
const form = reactive({
personnel_name: '',
business_role_id: undefined as number | undefined,
manager_id: undefined as number | undefined,
phone: '',
hire_date: '',
is_probation: 0,
salary_standard: '',
remark: '',
})
/** 编辑中的行(用于停用岗位/离职经理的回显兜底) */
const editingRow = useEditingRow(records, editingId)
/** 搜索下拉显示全部岗位(含停用,便于筛出仍绑定停用岗位的人员) */
const searchRoleOptions = computed(() =>
roleOptions.value.map((r) => ({ ...r, name: r.status === 1 ? r.name : `${r.name}(已停用)` })),
)
/** 表单下拉只列启用岗位;编辑时若原岗位已停用则保留一项供回显 */
const formRoleOptions = computed(() => {
const enabled = roleOptions.value.filter((r) => r.status === 1)
const row = editingRow.value
if (row && !enabled.some((r) => r.id === row.business_role_id)) {
enabled.unshift({ id: row.business_role_id, name: `${row.role_name ?? ''}(已停用)`, status: 0 })
}
return enabled
})
/** 经理下拉:排除编辑中的人员自己(后端校验「经理不能是自己」) */
const formManagerOptions = computed(() =>
managerOptions.value.filter((m) => m.id !== editingId.value),
)
const rules = computed<FormRules>(() => ({
personnel_name: [
{ required: true, message: '请输入姓名', trigger: 'blur' },
{ max: 64, message: '姓名最长 64 个字符', trigger: 'blur' },
],
business_role_id: [{ required: true, message: '请选择岗位', trigger: 'change' }],
phone: [{ max: 20, message: '联系电话最长 20 个字符', trigger: 'blur' }],
salary_standard: [
{ pattern: AMOUNT_PATTERN, message: '工资标准须为非负数字,最多两位小数', trigger: 'blur' },
],
remark: [{ max: 255, message: '备注最长 255 个字符', trigger: 'blur' }],
}))
function openCreate(): void {
editingId.value = null
Object.assign(form, {
personnel_name: '',
business_role_id: undefined,
manager_id: undefined,
phone: '',
hire_date: '',
is_probation: 0,
salary_standard: '',
remark: '',
})
dialogVisible.value = true
formRef.value?.clearValidate()
}
function openEdit(row: PersonnelItem): void {
editingId.value = row.id
Object.assign(form, {
personnel_name: row.personnel_name,
business_role_id: row.business_role_id,
manager_id: row.manager_id ?? undefined,
phone: row.phone ?? '',
hire_date: row.hire_date ?? '',
is_probation: row.is_probation,
salary_standard: row.salary_standard ?? '',
remark: row.remark ?? '',
})
dialogVisible.value = true
formRef.value?.clearValidate()
}
async function handleSubmit(): Promise<void> {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
// 空串归一为 null(与后端「空串归一 NULL」口径一致);金额原串提交
const payload: personnelApi.PersonnelSaveParams = {
personnel_name: form.personnel_name.trim(),
business_role_id: form.business_role_id as number,
manager_id: form.manager_id ?? null,
phone: form.phone.trim() || null,
hire_date: form.hire_date || null,
is_probation: form.is_probation,
salary_standard: form.salary_standard.trim() || null,
remark: form.remark.trim() || null,
}
submitting.value = true
try {
if (editingId.value !== null) {
await personnelApi.updatePersonnel(editingId.value, payload)
ElMessage.success('已保存')
} else {
await personnelApi.createPersonnel(payload)
ElMessage.success('人员已创建')
}
dialogVisible.value = false
await load()
} catch (error) {
handledError(error) // 失败提示由请求拦截器统一完成
} finally {
submitting.value = false
}
}
// ==================== 编辑抽屉:辐射区域 / 拓展计划 ====================
interface RegionDraft {
region_name: string
remark: string
}
interface PlanDraft {
plan_period: string
plan_content: string
target_desc: string
}
const drawerVisible = ref(false)
const drawerRow = ref<PersonnelItem | null>(null)
const drawerTitle = computed(() =>
drawerRow.value ? `「${drawerRow.value.personnel_name}」· 区域与拓展计划` : '区域与拓展计划',
)
const detailLoading = ref(false)
const regionSaving = ref(false)
const planSaving = ref(false)
/** 本地草稿行(全量替换:保存即以后端返回为准,无需保留行 id) */
const regionRows = ref<RegionDraft[]>([])
const planRows = ref<PlanDraft[]>([])
async function openSubTables(row: PersonnelItem): Promise<void> {
drawerRow.value = row
drawerVisible.value = true
detailLoading.value = true
regionRows.value = []
planRows.value = []
try {
const detail = await personnelApi.getPersonnelDetail(row.id)
regionRows.value = detail.regions.map((r) => ({
region_name: r.region_name,
remark: r.remark ?? '',
}))
planRows.value = detail.plans.map((p) => ({
plan_period: p.plan_period,
plan_content: p.plan_content,
target_desc: p.target_desc ?? '',
}))
} catch (error) {
handledError(error) // 失败提示由请求拦截器统一完成,抽屉显示空表
} finally {
detailLoading.value = false
}
}
function addRegionRow(): void {
regionRows.value.push({ region_name: '', remark: '' })
}
function removeRegionRow(index: number): void {
regionRows.value.splice(index, 1)
}
function addPlanRow(): void {
planRows.value.push({ plan_period: '', plan_content: '', target_desc: '' })
}
function removePlanRow(index: number): void {
planRows.value.splice(index, 1)
}
/** 保存辐射区域:全量替换,未填名称的行不能混入 */
async function saveRegions(): Promise<void> {
const row = drawerRow.value
if (!row) return
const blank = regionRows.value.findIndex((r) => !r.region_name.trim())
if (blank >= 0) {
ElMessage.warning(`第 ${blank + 1} 行的区域名称未填写`)
return
}
regionSaving.value = true
try {
await personnelApi.replacePersonnelRegions(
row.id,
regionRows.value.map((r) => ({
region_name: r.region_name.trim(),
remark: r.remark.trim() || null,
})),
)
ElMessage.success('辐射区域已保存')
} catch (error) {
handledError(error)
} finally {
regionSaving.value = false
}
}
/** 保存拓展计划:期间与内容必填,指标说明可空 */
async function savePlans(): Promise<void> {
const row = drawerRow.value
if (!row) return
const blank = planRows.value.findIndex((r) => !r.plan_period.trim() || !r.plan_content.trim())
if (blank >= 0) {
ElMessage.warning(`第 ${blank + 1} 行的计划期间或内容未填写`)
return
}
planSaving.value = true
try {
await personnelApi.replacePersonnelPlans(
row.id,
planRows.value.map((r) => ({
plan_period: r.plan_period.trim(),
plan_content: r.plan_content.trim(),
target_desc: r.target_desc.trim() || null,
})),
)
ElMessage.success('拓展计划已保存')
} catch (error) {
handledError(error)
} finally {
planSaving.value = false
}
}
// ==================== 在职切换 / 删除 ====================
async function handleToggleStatus(row: PersonnelItem): Promise<void> {
const next = row.status === 1 ? 0 : 1
const action = next === 1 ? '设为在职' : '设为离职'
await confirmAndRun({
confirmText:
next === 0
? `确定将「${row.personnel_name}」设为离职?离职人员不可再被业务与客户归属引用。`
: `确定将「${row.personnel_name}」设为在职?`,
title: '状态确认',
confirmButtonText: action,
successText: `已${action}`,
run: () => personnelApi.setPersonnelStatus(row.id, next),
after: load,
})
}
async function handleDelete(row: PersonnelItem): Promise<void> {
await confirmAndRun({
confirmText: `确定删除人员「${row.personnel_name}」?删除为物理删除、不可恢复;若该人员已被团队成员或客户归属引用将无法删除,此时应改为离职。`,
title: '删除确认',
confirmButtonText: '删除',
successText: '人员已删除',
run: () => personnelApi.deletePersonnel(row.id),
after: reloadAfterDelete,
})
}
// ==================== 初始化 ====================
onMounted(() => {
// 三个请求各自容错(load 内部已兜底),互不拖累
void load()
void loadRoleOptions()
void loadManagerOptions()
})
</script>
<template>
<div>
<div class="page-head">
<h1 class="page-title">人员管理</h1>
<p class="page-desc">
人员岗位与适用计提参数在这里定;改岗位会改变后续回款的绩效口径,设为离职后不可再被业务与客户归属引用
</p>
<span class="waterline"></span>
</div>
<!--
.filter-bar 直接挂在 el-form 上:el-form 不是 flex 透明容器,
外面再包一层 div 会让 el-form-item 丢掉 gap 排版,筛选条退化成竖排。
-->
<el-form class="filter-bar" @submit.prevent>
<el-form-item label="姓名">
<el-input
v-model="filters.keyword"
placeholder="姓名,模糊匹配"
clearable
style="width: 180px"
@keyup.enter="search"
/>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="filters.status" placeholder="全部" clearable style="width: 110px">
<el-option
v-for="item in EMPLOYMENT_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="岗位">
<el-select
v-model="filters.business_role_id"
placeholder="全部"
clearable
filterable
style="width: 160px"
>
<el-option
v-for="item in searchRoleOptions"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
<div class="filter-bar__actions">
<el-button type="primary" @click="search">查询</el-button>
<el-button @click="resetFilters">重置</el-button>
</div>
</el-form>
<div class="toolbar">
<el-button type="primary" @click="openCreate">新增人员</el-button>
<span class="toolbar-note">辐射区域与拓展计划在行内「区域与计划」维护,保存即全量替换</span>
</div>
<!-- ys-rise:整页只给列表卡一次入场上浮,筛选与页头立即呈现,避免整屏一起动 -->
<div class="card ys-rise">
<el-table
v-loading="loading"
:data="records"
stripe
empty-text="还没有人员 —— 点「新增人员」建第一个,或换个关键词再查"
>
<el-table-column prop="personnel_name" label="姓名" min-width="100" />
<el-table-column prop="role_name" label="岗位" min-width="100">
<template #default="{ row }">{{ row.role_name ?? '—' }}</template>
</el-table-column>
<el-table-column prop="manager_name" label="直属经理" min-width="100">
<template #default="{ row }">{{ row.manager_name ?? '—' }}</template>
</el-table-column>
<el-table-column prop="phone" label="联系电话" min-width="120">
<template #default="{ row }">{{ row.phone ?? '—' }}</template>
</el-table-column>
<el-table-column prop="hire_date" label="入职日期" min-width="110">
<template #default="{ row }">{{ row.hire_date ?? '—' }}</template>
</el-table-column>
<el-table-column label="试用期" width="90" align="center">
<template #default="{ row }">
<el-tag v-if="row.is_probation === 1" type="warning" size="small" disable-transitions>
试用期
</el-tag>
<span v-else class="cell-muted">正式</span>
</template>
</el-table-column>
<el-table-column label="工资标准(元)" min-width="120" align="right">
<template #default="{ row }">
<!-- 字符串直出:金额红线,禁 toFixed / float 格式化 -->
<span class="cell-num">{{ row.salary_standard ?? '—' }}</span>
</template>
</el-table-column>
<el-table-column label="状态" width="90" align="center">
<template #default="{ row }">
<StatusTag :value="row.status" active-text="在职" inactive-text="离职" />
</template>
</el-table-column>
<el-table-column label="操作" width="270" fixed="right" align="center">
<template #default="{ row }">
<div class="table-actions">
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
<el-button link type="primary" size="small" @click="openSubTables(row)">
区域与计划
</el-button>
<el-button link type="warning" size="small" @click="handleToggleStatus(row)">
{{ row.status === 1 ? '设为离职' : '设为在职' }}
</el-button>
<span class="table-actions__divider" aria-hidden="true"></span>
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
</div>
</template>
</el-table-column>
</el-table>
<div class="card-foot">
<AppPagination
:total="total"
:page-num="pageNum"
:page-size="pageSize"
@page-change="onPageChange"
@size-change="onSizeChange"
/>
</div>
</div>
<!-- 新增 / 编辑(基本信息;不含状态与子表) -->
<el-dialog
v-model="dialogVisible"
:title="isEdit ? '编辑人员' : '新增人员'"
width="560px"
destroy-on-close
>
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px">
<el-form-item label="姓名" prop="personnel_name">
<el-input v-model="form.personnel_name" placeholder="营销人员姓名" />
</el-form-item>
<el-form-item label="岗位" prop="business_role_id">
<el-select
v-model="form.business_role_id"
placeholder="选择岗位(计提公式随岗位绑定)"
filterable
style="width: 100%"
>
<el-option
v-for="item in formRoleOptions"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="直属经理" prop="manager_id">
<el-select
v-model="form.manager_id"
placeholder="不选择=无直属经理"
clearable
filterable
style="width: 100%"
>
<el-option
v-for="item in formManagerOptions"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="联系电话" prop="phone">
<el-input v-model="form.phone" maxlength="20" placeholder="选填" />
</el-form-item>
<el-form-item label="入职日期" prop="hire_date">
<el-date-picker
v-model="form.hire_date"
type="date"
value-format="YYYY-MM-DD"
placeholder="选择日期"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="试用期" prop="is_probation">
<el-switch v-model="form.is_probation" :active-value="1" :inactive-value="0" />
<span class="switch-hint">试用期绩效照算但不发放,年累计超 18000 元部分年底一次性结算</span>
</el-form-item>
<el-form-item label="工资标准" prop="salary_standard">
<el-input v-model="form.salary_standard" placeholder="如 5000.50(月,元),选填" />
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input v-model="form.remark" type="textarea" :rows="2" maxlength="255" placeholder="选填" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleSubmit">保存</el-button>
</template>
</el-dialog>
<!-- 编辑抽屉:辐射区域 / 拓展计划(行内增删,全量替换保存) -->
<el-drawer v-model="drawerVisible" :title="drawerTitle" size="720px" destroy-on-close>
<div v-loading="detailLoading">
<div class="sub-section">
<div class="sub-section-head">
<span class="sub-section-title">辐射区域</span>
<span>
<el-button size="small" @click="addRegionRow">添加区域</el-button>
<el-button
size="small"
type="primary"
:loading="regionSaving"
@click="saveRegions"
>
保存区域
</el-button>
</span>
</div>
<el-table
:data="regionRows"
stripe
size="small"
empty-text="还没有辐射区域 —— 点「添加区域」加一条"
>
<el-table-column label="区域名称" min-width="200">
<template #default="{ row }">
<el-input v-model="row.region_name" maxlength="128" placeholder="如 沈阳市铁西区" />
</template>
</el-table-column>
<el-table-column label="备注" min-width="220">
<template #default="{ row }">
<el-input v-model="row.remark" maxlength="255" placeholder="选填" />
</template>
</el-table-column>
<el-table-column label="操作" width="80" align="center">
<template #default="{ $index }">
<el-button link type="danger" size="small" @click="removeRegionRow($index)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div class="sub-section">
<div class="sub-section-head">
<span class="sub-section-title">拓展计划</span>
<span>
<el-button size="small" @click="addPlanRow">添加计划</el-button>
<el-button size="small" type="primary" :loading="planSaving" @click="savePlans">
保存计划
</el-button>
</span>
</div>
<el-table
:data="planRows"
stripe
size="small"
empty-text="还没有拓展计划 —— 点「添加计划」加一条"
>
<el-table-column label="期间" min-width="110">
<template #default="{ row }">
<el-input v-model="row.plan_period" maxlength="32" placeholder="如 2026Q1" />
</template>
</el-table-column>
<el-table-column label="计划内容" min-width="240">
<template #default="{ row }">
<el-input v-model="row.plan_content" maxlength="500" placeholder="计划内容" />
</template>
</el-table-column>
<el-table-column label="指标说明" min-width="160">
<template #default="{ row }">
<el-input v-model="row.target_desc" maxlength="255" placeholder="选填" />
</template>
</el-table-column>
<el-table-column label="操作" width="80" align="center">
<template #default="{ $index }">
<el-button link type="danger" size="small" @click="removePlanRow($index)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
</div>
</div>
</el-drawer>
</div>
</template>
<style scoped>
/* .cell-muted / .cell-num 由全局 index.css 提供,本页不再重复定义 */
/*
* 试用期的计算口径必须写在开关旁:它不是状态而是计提规则,
* 单靠「试用」二字看不出「照算不照发、超 18000 年底结」。
*/
.switch-hint {
margin-left: 10px;
color: var(--ys-ink-3);
font-size: 12px;
}
/* 抽屉内的子表分组:区域与计划各自成块,块内标题与操作按钮同行 */
.sub-section {
margin-bottom: 20px;
}
.sub-section-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.sub-section-title {
font-weight: 600;
color: var(--ys-ink);
}
</style>