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>
这个提交包含在:
liuwenxin和Claude Code 于 2026-10-22 19:45:54 +08:00 提交
1 父节点 f88857e026
当前提交 e61800dcdc
34 个文件已更改
+3960 -2041

没有与搜索条件相匹配的文件

+104 -83
查看文件
@@ -8,6 +8,7 @@ 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'
@@ -64,29 +65,22 @@ interface SelectOption {
status: number
}
const roleOptions = ref<SelectOption[]>([])
const managerOptions = ref<SelectOption[]>([])
async function loadRoleOptions(): Promise<void> {
try {
const { options: roleOptions, load: loadRoleOptions } = useDropdownOptions<SelectOption>(
async () => {
// 岗位规模有限(内置 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) // 下拉为空不阻断列表使用
}
}
return data.map((r) => ({ id: r.id, name: r.role_name, status: r.status }))
},
)
async function loadManagerOptions(): Promise<void> {
try {
const { options: managerOptions, load: loadManagerOptions } = useDropdownOptions<SelectOption>(
async () => {
// 经理下拉的近似全量数据源:内部系统人员规模有限,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)
}
}
return data.list.map((p) => ({ id: p.id, name: p.personnel_name, status: p.status }))
},
)
// ==================== 新增 / 编辑(基本信息) ====================
@@ -372,54 +366,73 @@ onMounted(() => {
<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"
<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-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>
</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>
<el-table v-loading="loading" :data="records" stripe>
<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>
@@ -444,7 +457,7 @@ onMounted(() => {
<el-table-column label="工资标准(元)" min-width="120" align="right">
<template #default="{ row }">
<!-- 字符串直出:金额红线,禁 toFixed / float 格式化 -->
<span class="cell-amount">{{ row.salary_standard ?? '—' }}</span>
<span class="cell-num">{{ row.salary_standard ?? '—' }}</span>
</template>
</el-table-column>
<el-table-column label="状态" width="90" align="center">
@@ -469,14 +482,16 @@ onMounted(() => {
</el-table-column>
</el-table>
<AppPagination
:total="total"
:page-num="pageNum"
:page-size="pageSize"
@page-change="onPageChange"
@size-change="onSizeChange"
/>
</el-card>
<div class="card-foot">
<AppPagination
:total="total"
:page-num="pageNum"
:page-size="pageSize"
@page-change="onPageChange"
@size-change="onSizeChange"
/>
</div>
</div>
<!-- 新增 / 编辑(基本信息;不含状态与子表) -->
<el-dialog
@@ -567,7 +582,12 @@ onMounted(() => {
</el-button>
</span>
</div>
<el-table :data="regionRows" stripe size="small">
<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="如 沈阳市铁西区" />
@@ -598,7 +618,12 @@ onMounted(() => {
</el-button>
</span>
</div>
<el-table :data="planRows" stripe size="small">
<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" />
@@ -629,23 +654,19 @@ onMounted(() => {
</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);
}
/* .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;
}