PayrollManagementSystem/frontend/src/views/base/personnel/index.vue
liuwenxin ebb3a92f25 fix(report): 报表统计修复批次——对账口径、导出审计与月份校验
- 部门汇总全公司 5% 按每位有台账聚合行的营销经理各计一次,
  团队应发小计取聚合额 + 调整净额;与月结 total_payable 对拍
  (有调整单时 + adjust_net_total),分列舍入差单列
- 人员归属按行类型拆分(普通/经理个人户/仅调整单),调整单全量并入,
  团队与未分配不重复;按经理筛选只汇总展示团队
- 导出改单 sheet 分块签字稿,审计覆盖取数与超限;筛选页眉 ID→名称
- 月份校验抽 core/month,报表/月结/调整单非法月份统一 400;
  岗位只读选项供财务/领导筛选
- 前端修复:部门下钻带入筛选、回款多桶独立状态、打印截断判定、
  summary 请求序号保护

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-20 09:44:43 +08:00

665 lines
23 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 { 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 roleOptions = ref<SelectOption[]>([])
const managerOptions = ref<SelectOption[]>([])
async function loadRoleOptions(): Promise<void> {
try {
// 岗位规模有限(内置 3 个 + 自定义),选项接口一次取全
const data = await businessRoleApi.listBusinessRoleOptions()
roleOptions.value = data.map((r) => ({ id: r.id, name: r.role_name, status: r.status }))
} catch (error) {
handledError(error) // 下拉为空不阻断列表使用
}
}
async function loadManagerOptions(): Promise<void> {
try {
// 经理下拉的近似全量数据源:内部系统人员规模有限,200 条足够;
// 超出规模的极端场景由后端对经理合法性的校验兜底
const data = await personnelApi.listPersonnel({ pageNum: 1, pageSize: 200 })
managerOptions.value = data.list.map((p) => ({ id: p.id, name: p.personnel_name, status: p.status }))
} catch (error) {
handledError(error)
}
}
// ==================== 新增 / 编辑(基本信息) ====================
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>
<el-card shadow="never">
<el-form class="filter-form" :inline="true" @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>
<el-form-item>
<el-button type="primary" @click="search">查询</el-button>
<el-button @click="resetFilters">重置</el-button>
</el-form-item>
</el-form>
<div class="toolbar">
<el-button type="primary" @click="openCreate">新增人员</el-button>
</div>
<el-table v-loading="loading" :data="records" stripe>
<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-amount">{{ 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>
<AppPagination
:total="total"
:page-num="pageNum"
:page-size="pageSize"
@page-change="onPageChange"
@size-change="onSizeChange"
/>
</el-card>
<!-- 新增 / 编辑(基本信息;不含状态与子表) -->
<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">
<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">
<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 {
color: var(--ys-ink-3);
font-size: 12px;
}
.cell-amount {
font-family: 'SFMono-Regular', Consolas, 'Courier New', monospace;
font-size: 12px;
color: var(--ys-ink-2);
}
.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>