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>
This commit is contained in:
liuwenxin 2026-10-22 19:45:54 +08:00
parent f88857e026
commit e61800dcdc
34 changed files with 3960 additions and 2041 deletions

View File

@ -79,7 +79,7 @@ pnpm build # 类型检查 + 生产构建
## 视觉设计
**设计立意**:公司名「衍水」是水,而中文「流水账」字面就是**流水之账**——系统的全部工作正是记录回款怎么流、绩效怎么算。视觉方向由此而生:中性灰白的水面纸感 + 靛蓝点缀,安静、克制,长时间核数不累眼。完整规范见 `docs/design/视觉方向预览.html`(2026-10-12 起为 v3「质感版」)。
**设计立意**:公司名「衍水」是水,而中文「流水账」字面就是**流水之账**——系统的全部工作正是记录回款怎么流、绩效怎么算。视觉方向由此而生:中性灰白的水面纸感 + 靛蓝点缀,安静、克制,长时间核数不累眼。完整规范见 `docs/design/视觉方向预览.html`(2026-10-12 起为 v3「质感版」;2026-10-22 升 v4「同步版」——设计稿与已交付的月结 / 审批 / 报表 / 工作台对齐,色板、字体与组件基调未变)。
**配色**(靛蓝只作重点点缀——主按钮 / 链接 / 焦点 / 当前菜单,大面积用中性灰白)
@ -100,17 +100,41 @@ pnpm build # 类型检查 + 生产构建
**落地方式**:品牌色已提升为全局 CSS 变量(`src/assets/styles/index.css` 的 `:root`,`--ys-*` 前缀),登录页、布局与各业务页面共用;全局把 `--el-color-primary` 指向靛蓝,并按 Element Plus 公式预计算了 light-3/5/7/8/9 与 dark-2 色阶(不用 `color-mix()`,避免依赖较新浏览器特性)。旧 token 名(深水青 / 金色系列)在 `:root` 中保留映射,存量页面迁移完成后移除。
**签名元素**:**渐隐靛蓝水线**——仪表盘等页面标题下的细线(56px,左实右虚)与登录页品牌名下的短线(40px),「衍水」之水的提纯,全站唯一的装饰性元素;登录页右上角另有涟漪同心圆与顶部靛蓝天光作背景(低透明度)。与早期深色面板 + 水纹层理同一意象,品牌连续而非推倒重来。
**语义三色**(2026-10-22 落地):成功 `#1e9e6a` / 警示 `#c98a1b` / 危险 `#c94f4f`,已覆盖 `--el-color-success/warning/danger/error` 及其 `-rgb`、`light-3/5/7/8/9`、`dark-2` 全阶。Element Plus 默认的绿黄红饱和度过高,放在灰底卡片上会跳色。**`-rgb` 变体必须一并覆盖**——组件库的半透明底色用 `rgba(var(--el-color-success-rgb), .1)` 取色,漏改会出现「文字是新的、底色还是旧的」。色阶按 Element Plus 公式预计算,不用 `color-mix()`。
**组件基调**(同样写在 `src/assets/styles/index.css`,页面不要再重复):按钮高 34px / 圆角 7px / 字号 13px,主按钮是 `#3a58bc → #2e4ba8` 微渐变 + 内高光 + 悬停上抬 1px 并加深阴影;输入框高 34px,焦点为靛蓝描边加 3px 柔光外环;表头 12px/500/字距 `0.04em` + 极浅渐变底,单元格内距 12px 16px,行悬停 120ms;分页需 `background` 模式,当前页与主按钮同材质;`el-tag` 压到 11px 方角。动效统一 120–180ms,只做颜色、阴影与 1px 位移,并尊重 `prefers-reduced-motion`。
**页面版式:只用全局类**(新页面不再各写一套)
| 类 | 用途 |
| --- | --- |
| `.page-head` / `.page-title` / `.page-desc` / `.waterline` | 页面头:18px 标题 + 一句 12px 描述 + 水线(**每页一条,不重复**) |
| `.card` / `.card-head` / `.card-title` / `.card-body` / `.card-foot` | 内容卡片;卡脚右对齐,承载分页或合计条 |
| `.filter-bar` / `.filter-bar__actions` | 筛选条(横向 flex);按钮组靠右 |
| `.toolbar` / `.toolbar-note` | 主操作行 / 右侧提示文字 |
| `.section-title` | 区块小标题(13px/600) |
| `.cell-code` / `.cell-num` / `.cell-muted` | 表格列语义:等宽编号 / 数字右对齐 + 等宽数字 / 弱化文本 |
| `.ys-pill` + `--ok` `--wait` `--lock` `--reject` | 状态胶囊(浅底 + 6px 圆点),四档语义与月结生命周期一致 |
| `.ys-rise` | 入场上浮动画(卡片默认已带) |
`.cell-num` 有两种用法:直接挂在 `td` 上,或包一层 `<span class="cell-num">` 处理「一行里既文本又数字」。给后者单独撑满一行是必要的——行内元素的 `text-align` 无效;但**不能**给 `.cell-num` 统一加 `display`,那会把 `td` 从 `table-cell` 变成 `inline-block`,表格列宽算法随即失效。
**菜单图标**(2026-10-22 落地,M1 遗留项):路由 `meta.icon` 只写**语义 key**,图标实现集中在 `src/components/layout/menuIcons.ts` 的注册表(`@element-plus/icons-vue` 具名导入,可摇树)。换图标库或换风格只改注册表,路由表不动。视觉口径沿用设计稿:16px、透明度 0.7(激活项 1)、右间距 9px;分组标题**不加图标**,只有菜单项有。**新增页面必须补 `meta.icon`**,否则菜单项只有文字。
**签名元素**:**渐隐靛蓝水线**——各页面标题下的细线(56px,左实右虚)与登录页品牌名下的短线(40px),「衍水」之水的提纯,全站唯一的装饰性元素;登录页右上角另有涟漪同心圆与顶部靛蓝天光作背景(低透明度)。与早期深色面板 + 水纹层理同一意象,品牌连续而非推倒重来。
**打印视图**(`views/reports/print/`):纸面产物与屏幕稿不同源——独立顶层路由、不挂侧栏顶栏,白灰画布上一张定宽白稿纸,表格 11px 细实线、表头浅底左对齐(数值列表头右对齐)、章节编号、三栏签字(制表 / 复核 / 审批),无圆角无阴影。屏幕上的操作条带 `.no-print`,打印时整块消失。超出打印行数上限时只打前 N 行并在稿面明确提示,不静默截断。
**构图**:登录页品牌内容(名称 / 标语 / 水线)**垂直居中聚成一块**,配一层柔和径向光晕作落点。
早期版本用 `space-between` 把品牌与标语顶到两端,中间空出大半屏,两处都成了孤岛——超宽屏会把这种疏离放大得更明显。**内容块要限宽**(`max-width`),否则宽屏下文字会被拉散。
### 写代码时的四条约定
### 写代码时的五条约定
- 覆盖 Element Plus 主题请改 **CSS 变量**(`--el-button-bg-color` 等),不要直接写 `background-color` —— 后者与 `.el-button--primary` 同权重,会受样式注入顺序影响而不稳定
- 中文可在任意字间断行,**关键文案的断行位置要显式控制**(如主标语拆成两个 `<span>`),交给浏览器自动换行会出现单字独占一行的难看结果
- 新页面需响应式到移动端、保留键盘焦点可见、并尊重 `prefers-reduced-motion`
- **确认框统一加 `customClass: 'ys-confirm-box'`,不要再传 `type: 'warning'`** —— Element Plus 的默认 warning 图标会渲染在正文左侧独立成列(多行正文时图标悬在文字块中间),与 v3 靛蓝体系也冲突。全局样式(`src/assets/styles/index.css`)已把图标改为 18px 描边样式并放在标题行左侧,正文与标题文字对齐
- **页面 scoped 里只写本页特有的东西**:表格 / 按钮 / 输入框 / 表头 / 分页的皮肤已经全局覆盖,页面再写一遍与组件库同权重,会受样式注入顺序影响而不稳定;结构用全局类(见上一节表格),样式只补本页独有的块(如展开区、抽屉内的键值表)
## 验证码图片

View File

@ -11,6 +11,7 @@
"type-check": "vue-tsc --noEmit"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"@vue/devtools-api": "^8.1.5",
"axios": "^1.20.0",
"echarts": "^5.6.0",

View File

@ -8,6 +8,9 @@ importers:
.:
dependencies:
'@element-plus/icons-vue':
specifier: ^2.3.2
version: 2.3.2(vue@3.5.43(typescript@5.9.3))
'@vue/devtools-api':
specifier: ^8.1.5
version: 8.2.1

View File

@ -1,24 +1,60 @@
/* 全局样式:重置 + 品牌 token + 基础版式 */
/*
* 全局样式:品牌 token + Element Plus 组件基调 + 全站版式工具类
*
* 设计来源:docs/design/视觉方向预览.html(v4,2026-10-22)。
* 落实口径:token 与设计稿 :root 同名同值;组件基调按设计稿 §03;
* 版式工具类(.page-head / .card / .filter-bar / .data-table 等)按 §04、§09,
* 各页面只写业务差异,不再重复定义这些类。
*
* 加载顺序:main.ts 里排在 element-plus/dist/index.css 之后,
* 同权重选择器靠后者生效 —— 覆盖组件库样式必须与此保持一致。
*/
:root {
/*
* 品牌色(2026-10-12 视觉方向:企业级扁平 · 轻质感 · 靛蓝点缀)。
* 靛蓝只作重点点缀(主按钮/链接/焦点/当前菜单),大面积用中性灰白。
* 设计预览与规范:docs/design/视觉方向预览.html
*/
/* ── 品牌色 ─────────────────────────────────────────────────────────── */
/* 靛蓝只作重点点缀(主按钮/链接/焦点/当前菜单),大面积用中性灰白。 */
--ys-indigo: #2e4ba8;
--ys-indigo-hi: #3a58bc; /* 主按钮渐变亮端 */
--ys-indigo-deep: #223a85;
--ys-indigo-wash: #edf1fa;
--ys-paper: #ffffff;
--ys-mist: #f4f5f8;
--ys-line: rgba(28, 35, 51, 0.09);
--ys-aside-1: #f2f4f8;
--ys-aside-2: #ecedf3;
--ys-ink: #1c2333;
--ys-ink-2: #5b6577;
--ys-ink-3: #9aa3b2;
--ys-line: rgba(28, 35, 51, 0.09);
--ys-line-soft: rgba(28, 35, 51, 0.065);
/* 语义三色:饱和度压到与靛蓝同级,保证灰底卡片上不跳色 */
--ys-ok: #1e9e6a;
--ys-ok-wash: #ecf8f2;
--ys-ok-wash-2: #e4f3ec;
--ys-warn: #c98a1b;
--ys-warn-wash: #fbf4e4;
--ys-warn-wash-2: #f8efd9;
--ys-danger: #c94f4f;
--ys-danger-wash: #fbeded;
--ys-danger-wash-2: #f6e3e3;
/* 形体与材质 */
--ys-radius: 10px;
--ys-radius-sm: 7px;
--ys-ease: cubic-bezier(0.25, 0.1, 0.25, 1);
--ys-shadow-card:
0 1px 2px rgba(28, 35, 51, 0.04),
0 3px 10px rgba(28, 35, 51, 0.03);
--ys-shadow-raise:
0 1px 2px rgba(28, 35, 51, 0.05),
0 6px 18px rgba(28, 35, 51, 0.07);
/*
* 旧 token 名保留映射(BasicLayout / 登录页存量引用,值迁移到新体系:
* 深水青 → 冷墨系、金色 → 靛蓝系),存量页面迁移完成后移除。
* 旧 token 名保留映射(存量页面引用,值已迁移到新体系):
* 深水青 → 冷墨系、金色 → 靛蓝系。存量页面迁移完成后移除。
*/
--ys-ink-900: #16203a;
--ys-ink-800: #1c2333;
@ -26,7 +62,6 @@
--ys-ink-600: #3a4a6e;
--ys-gold-bright: #6d81c2;
--ys-gold-deep: #2e4ba8;
--ys-text: #1c2333;
--ys-muted: #5b6577;
@ -36,11 +71,12 @@
*/
--ys-font-brand: 'Noto Sans SC', 'Source Han Sans SC', 'HarmonyOS Sans SC', 'PingFang SC',
sans-serif;
--ys-font-mono: 'SF Mono', 'Cascadia Mono', Consolas, 'Courier New', monospace;
/* ── Element Plus 主色 ──────────────────────────────────────────────── */
/*
* Element Plus 主色 = 靛蓝(白字对比约 7.5:1)。
* 色阶为按 Element Plus 公式「主色与白色混合」预先算好的静态值 ——
* 不用 color-mix(),避免依赖较新的浏览器特性。
* 白字对比约 7.5:1。色阶为按 Element Plus 公式「主色与白色混合」预先算好的
* 静态值 —— 不用 color-mix(),避免依赖较新的浏览器特性。
*/
--el-color-primary: #2e4ba8;
--el-color-primary-light-3: #6d81c2;
@ -49,6 +85,72 @@
--el-color-primary-light-8: #d5dbee;
--el-color-primary-light-9: #eaedf6;
--el-color-primary-dark-2: #253c86;
/*
* 语义三色(设计稿规范值):成功 #1E9E6A / 警示 #C98A1B / 危险 #C94F4F。
*
* Element Plus 默认是绿 #67C23A / 黄 #E6A23C / 红 #F56C6C,饱和度明显高于
* 本系统「中性灰白 + 靛蓝点缀」基调,状态标签放在灰底卡片上会跳色。
* 色阶同样按 Element Plus 公式预计算(light-N = N×10% 白 + 其余主色,
* dark-2 = 20% 黑 + 80% 主色),不用 color-mix(),与主色色阶口径一致。
* -rgb 变体一并覆盖:组件库的半透明底色(rgba(var(--el-color-success-rgb), .1))
* 依赖它,漏改会出现「文字是新的、底色还是旧的」。
* error 与 danger 同值:表单校验错误与危险操作在本系统语义一致。
*/
--el-color-success: #1e9e6a;
--el-color-success-rgb: 30, 158, 106;
--el-color-success-light-3: #62bb97;
--el-color-success-light-5: #8eceb4;
--el-color-success-light-7: #bce2d2;
--el-color-success-light-8: #d2ece1;
--el-color-success-light-9: #e8f5f0;
--el-color-success-dark-2: #187e55;
--el-color-warning: #c98a1b;
--el-color-warning-rgb: 201, 138, 27;
--el-color-warning-light-3: #d9ad5f;
--el-color-warning-light-5: #e4c48d;
--el-color-warning-light-7: #efdcbb;
--el-color-warning-light-8: #f4e8d1;
--el-color-warning-light-9: #faf3e8;
--el-color-warning-dark-2: #a16e16;
--el-color-danger: #c94f4f;
--el-color-danger-rgb: 201, 79, 79;
--el-color-danger-light-3: #d98484;
--el-color-danger-light-5: #e4a7a7;
--el-color-danger-light-7: #efcaca;
--el-color-danger-light-8: #f4dcdc;
--el-color-danger-light-9: #faeded;
--el-color-danger-dark-2: #a13f3f;
--el-color-error: #c94f4f;
--el-color-error-rgb: 201, 79, 79;
--el-color-error-light-3: #d98484;
--el-color-error-light-5: #e4a7a7;
--el-color-error-light-7: #efcaca;
--el-color-error-light-8: #f4dcdc;
--el-color-error-light-9: #faeded;
--el-color-error-dark-2: #a13f3f;
/* ── Element Plus 形体基准(设计稿 §03:控件高 34px、圆角 7px)─────── */
--el-component-size: 34px;
--el-component-size-small: 28px;
--el-component-size-large: 38px;
--el-border-radius-base: 7px;
--el-border-radius-small: 6px;
/* ── Element Plus 表格 ──────────────────────────────────────────────── */
--el-table-border-color: var(--ys-line-soft);
--el-table-text-color: var(--ys-ink);
--el-table-header-text-color: var(--ys-ink-3);
--el-table-row-hover-bg-color: #f7f9fd;
/* 斑马纹压到几乎不可见:设计稿列表只有行悬停,没有斑马带 */
--el-fill-color-lighter: #fafbfd;
/* ── 字体 ───────────────────────────────────────────────────────────── */
--el-font-family: 'Noto Sans SC', 'Source Han Sans SC', 'HarmonyOS Sans SC', 'PingFang SC',
'Helvetica Neue', Arial, sans-serif;
}
* {
@ -73,15 +175,528 @@ body {
-webkit-font-smoothing: antialiased;
}
/* 系统管理列表页共用的版式(原在三个页面 scoped 中各自重复) */
.filter-form {
margin-bottom: 4px;
/* 滚动条:细、低对比,避免在密集表格页抢视线 */
*::-webkit-scrollbar {
width: 8px;
height: 8px;
}
*::-webkit-scrollbar-thumb {
background-color: rgba(28, 35, 51, 0.16);
border: 2px solid transparent;
border-radius: 6px;
background-clip: padding-box;
}
*::-webkit-scrollbar-thumb:hover {
background-color: rgba(28, 35, 51, 0.28);
}
*::-webkit-scrollbar-track {
background: transparent;
}
/* ══════════════════════════════════════════════════════════════════════
* 全站版式工具类(设计稿 §04 / §09):页面不再各自重复定义
* ══════════════════════════════════════════════════════════════════════ */
/* 页面头:标题 + 描述 + 签名水线,每页出现一次 */
.page-head {
margin-bottom: 18px;
}
.page-title {
margin: 0;
font-size: 18px;
font-weight: 600;
letter-spacing: -0.01em;
color: var(--ys-ink);
}
.page-desc {
margin: 6px 0 0;
font-size: 12px;
letter-spacing: 0.01em;
color: var(--ys-ink-3);
}
/*
* 签名元素:渐隐靛蓝水线。
* 全站唯一的装饰性元素 —— 其余层次靠底色、描边与阴影区分,
* 所以每页只允许出现一次(页面标题下,或登录卡品牌区)。
*/
.waterline {
display: block;
width: 56px;
height: 2px;
margin-top: 10px;
border-radius: 1px;
background: linear-gradient(90deg, var(--ys-indigo), rgba(46, 75, 168, 0));
}
/* 区块小标题(抽屉内、卡片内分组) */
.section-title {
margin: 0 0 10px;
font-size: 13px;
font-weight: 600;
letter-spacing: 0.02em;
color: var(--ys-ink-2);
}
/* 卡片:纸白微渐变 + 半透明描边 + 双层柔影 */
.card {
background: linear-gradient(180deg, var(--ys-paper), #fcfcfe);
border: 1px solid var(--ys-line);
border-radius: var(--ys-radius);
box-shadow: var(--ys-shadow-card);
}
.card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 13px 16px;
border-bottom: 1px solid var(--ys-line-soft);
}
.card-title {
font-size: 15px;
font-weight: 600;
color: var(--ys-ink);
}
.card-body {
padding: 16px;
}
.card-foot {
display: flex;
align-items: center;
justify-content: flex-end;
padding: 10px 16px;
background: linear-gradient(180deg, #fdfdfe, #fbfcfe);
border-top: 1px solid var(--ys-line-soft);
border-radius: 0 0 var(--ys-radius) var(--ys-radius);
}
/* 筛选条:与卡片同材质,比表格低一档体量 */
.filter-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
padding: 14px 16px;
margin-bottom: 14px;
background: linear-gradient(180deg, var(--ys-paper), #fcfcfe);
border: 1px solid var(--ys-line);
border-radius: var(--ys-radius);
box-shadow: var(--ys-shadow-card);
}
.filter-bar .el-form-item {
margin-right: 0;
margin-bottom: 0;
}
.filter-bar__actions {
display: flex;
gap: 10px;
margin-left: auto;
}
/* 工具栏:主操作行,与筛选条/表格区分(无底色,只占位) */
.toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
margin-bottom: 12px;
}
.toolbar-note {
font-size: 12px;
color: var(--ys-ink-3);
}
/* 编号/代码类单元格:等宽字体,弱一档颜色 */
.cell-code {
font-family: var(--ys-font-mono);
font-size: 12px;
color: var(--ys-ink-2);
}
/*
* 金额/数量类单元格:等宽数字右对齐(对齐是工资表的可读性前提)。
*
* 两种用法都要成立:
* 1) 直接挂在 td 上 —— 单元格本身就是右对齐的容器;
* 2) 包在单元格里当 <span class="cell-num"> —— 一行里既有文本又有数字时用。
* 行内元素的 text-align 无效,所以要给 span 单独撑满一行再右对齐;
* 反过来不能给 .cell-num 统一加 display,那会把 td 从 table-cell 变成
* inline-block,整个表格的列宽算法随即失效。
*/
.cell-num {
font-variant-numeric: tabular-nums;
}
td.cell-num,
th.cell-num {
text-align: right;
}
span.cell-num {
display: block;
text-align: right;
}
.cell-muted {
color: var(--ys-ink-3);
}
/*
* 状态 pill(设计稿 §03):浅底 + 细边 + 6px 圆点。
* 四档语义与月结生命周期一致:草稿/待处理(中性)、待办(琥珀)、
* 已锁定/完成(靛蓝)、驳回(砖红)。
*/
.ys-pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 2px 10px;
font-size: 12px;
line-height: 18px;
white-space: nowrap;
color: var(--ys-ink-2);
background: linear-gradient(180deg, #f4f5f8, #eff1f5);
border: 1px solid var(--ys-line);
border-radius: 999px;
}
.ys-pill::before {
content: '';
flex: none;
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
opacity: 0.65;
}
.ys-pill--ok {
color: var(--ys-ok);
background: linear-gradient(180deg, var(--ys-ok-wash), var(--ys-ok-wash-2));
border-color: rgba(30, 158, 106, 0.2);
}
.ys-pill--wait {
color: var(--ys-warn);
background: linear-gradient(180deg, var(--ys-warn-wash), var(--ys-warn-wash-2));
border-color: rgba(201, 138, 27, 0.22);
}
.ys-pill--lock {
color: var(--ys-indigo);
background: linear-gradient(180deg, #eef2fc, #e5eaf8);
border-color: rgba(46, 75, 168, 0.2);
}
.ys-pill--reject {
color: var(--ys-danger);
background: linear-gradient(180deg, var(--ys-danger-wash), var(--ys-danger-wash-2));
border-color: rgba(201, 79, 79, 0.2);
}
/* ══════════════════════════════════════════════════════════════════════
* Element Plus 组件基调(设计稿 §02 字体与数字 / §03 组件 / §04 表格)
* ══════════════════════════════════════════════════════════════════════ */
/* 按钮:高 34px、圆角 7px、字号 13px、150ms 过渡 */
.el-button {
height: var(--el-component-size);
padding: 0 15px;
font-size: 13px;
font-weight: 400;
border-radius: var(--ys-radius-sm);
transition:
background-color 150ms var(--ys-ease),
background-image 150ms var(--ys-ease),
border-color 150ms var(--ys-ease),
color 150ms var(--ys-ease),
box-shadow 150ms var(--ys-ease),
transform 150ms var(--ys-ease);
}
.el-button--large {
font-size: 14px;
}
.el-button--small {
height: var(--el-component-size-small);
padding: 0 11px;
font-size: 12px;
}
.el-button + .el-button {
margin-left: 0;
}
.el-button.is-text,
.el-button.is-link {
height: auto;
padding: 2px 0;
font-size: 13px;
}
/* 主按钮:靛蓝微渐变 + 顶部内高光,悬停抬高 1px 并扩散柔影 */
.el-button--primary:not(.is-plain):not(.is-text):not(.is-link):not(.is-disabled) {
background-color: var(--ys-indigo);
background-image: linear-gradient(180deg, var(--ys-indigo-hi), var(--ys-indigo));
border-color: var(--ys-indigo);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.18),
0 1px 2px rgba(46, 75, 168, 0.22);
}
.el-button--primary:not(.is-plain):not(.is-text):not(.is-link):not(.is-disabled):hover {
background-image: linear-gradient(180deg, #31509f, var(--ys-indigo-deep));
border-color: var(--ys-indigo-deep);
transform: translateY(-1px);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.2),
0 4px 12px rgba(46, 75, 168, 0.3);
}
.el-button--primary:not(.is-plain):not(.is-text):not(.is-link):not(.is-disabled):active {
transform: translateY(0);
background-image: linear-gradient(180deg, #2b4699, #1f3478);
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.18);
}
.el-button--primary.is-disabled,
.el-button--primary.is-loading {
background-image: none;
box-shadow: none;
}
/*
* 默认按钮:纸白微渐变 + 细描边,悬停只加靛蓝描边与文字色,不做位移
* —— 筛选条里可能并排四五个默认按钮,集体位移会让整行抖动。
*/
.el-button:not(
.el-button--primary,
.el-button--success,
.el-button--warning,
.el-button--danger,
.el-button--info,
.is-link,
.is-text,
.is-plain
) {
background-image: linear-gradient(180deg, var(--ys-paper), #fbfcfe);
border-color: var(--ys-line);
box-shadow: 0 1px 2px rgba(28, 35, 51, 0.03);
}
.el-button:not(
.el-button--primary,
.el-button--success,
.el-button--warning,
.el-button--danger,
.el-button--info,
.is-link,
.is-text,
.is-plain
):hover {
color: var(--ys-indigo);
background-image: linear-gradient(180deg, var(--ys-paper), #f8fafd);
border-color: rgba(46, 75, 168, 0.45);
}
/* 危险按钮:白底 + 砖红文字与描边(红色只提示边界,不做大面积填充) */
.el-button--danger:not(.is-link):not(.is-text):not(.is-plain):not(.is-disabled) {
color: var(--ys-danger);
background-color: var(--ys-paper);
background-image: linear-gradient(180deg, var(--ys-paper), #fdfbfb);
border-color: rgba(201, 79, 79, 0.28);
box-shadow: 0 1px 2px rgba(28, 35, 51, 0.03);
}
.el-button--danger:not(.is-link):not(.is-text):not(.is-plain):not(.is-disabled):hover {
color: #b23f3f;
background-color: var(--ys-danger-wash);
border-color: rgba(201, 79, 79, 0.5);
}
/* 输入框 / 选择器 / 文本域:微内阴影 + 靛蓝焦点环 */
.el-input__wrapper,
.el-select__wrapper,
.el-textarea__inner {
border-radius: var(--ys-radius-sm);
box-shadow:
0 0 0 1px rgba(28, 35, 51, 0.13) inset,
inset 0 1px 2px rgba(28, 35, 51, 0.035);
transition: box-shadow 150ms var(--ys-ease);
}
.el-input__wrapper:hover,
.el-select__wrapper:hover,
.el-textarea__inner:hover {
box-shadow:
0 0 0 1px rgba(28, 35, 51, 0.24) inset,
inset 0 1px 2px rgba(28, 35, 51, 0.035);
}
.el-input__wrapper.is-focus,
.el-select__wrapper.is-focused,
.el-textarea__inner:focus {
box-shadow:
0 0 0 1px var(--ys-indigo) inset,
inset 0 1px 2px rgba(28, 35, 51, 0.03),
0 0 0 3px rgba(46, 75, 168, 0.13);
}
.el-input__inner,
.el-textarea__inner {
font-size: 13px;
}
/* 表单标签:13px 次墨,不抢输入框的注意力 */
.el-form-item__label {
font-size: 13px;
color: var(--ys-ink-2);
}
/* 表格容器:与卡片同材质;裸表格(未套 .card)也不会贴在雾灰底上 */
.el-table {
border-radius: var(--ys-radius);
background-color: transparent;
}
.el-table th.el-table__cell {
padding: 10px 0;
font-size: 12px;
font-weight: 500;
letter-spacing: 0.04em;
background-color: transparent;
background-image: linear-gradient(180deg, #fafbfc, #f5f6f9);
}
.el-table td.el-table__cell {
padding: 12px 0;
font-size: 13px;
}
.el-table .cell {
padding: 0 16px;
}
.el-table__body tr:hover > td.el-table__cell {
transition: background-color 120ms var(--ys-ease);
}
/* 分页:当前页与主按钮同材质(靛蓝渐变 + 内高光) */
.el-pagination button,
.el-pagination .el-pager li {
border-radius: 6px;
font-variant-numeric: tabular-nums;
}
.el-pagination.is-background .el-pager li.is-active {
color: #fff;
background-color: var(--ys-indigo);
background-image: linear-gradient(180deg, var(--ys-indigo-hi), var(--ys-indigo));
border: 1px solid var(--ys-indigo);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.18),
0 1px 2px rgba(46, 75, 168, 0.22);
}
/* 标签:默认压成 11px 方角小签(状态列请改用 .ys-pill) */
.el-tag {
font-size: 11px;
border-radius: 4px;
}
.el-tag--small {
height: 20px;
padding: 0 7px;
font-size: 11px;
}
/* 弹层:淡入 + 上移 4px(160ms),与设计稿 §08 一致 */
.el-popper.is-light {
border-radius: var(--ys-radius-sm);
box-shadow: var(--ys-shadow-raise);
}
.el-drawer__header {
margin-bottom: 0;
padding: 16px 20px;
font-size: 15px;
font-weight: 600;
color: var(--ys-ink);
border-bottom: 1px solid var(--ys-line-soft);
}
.el-drawer__body {
padding: 18px 20px;
}
/* 空状态:弱墨、留白,不喊叫 */
.el-table__empty-text {
font-size: 13px;
color: var(--ys-ink-3);
}
/* 入场上浮:卡片/面板通用(设计稿 §08 320ms,可被页面用 animation-delay 交错) */
@keyframes ys-rise {
from {
opacity: 0;
transform: translateY(6px);
}
}
.ys-rise {
animation: ys-rise 320ms var(--ys-ease) backwards;
}
/* 不可再补装饰的场合(打印)统一去动效 */
@media print {
.ys-rise {
animation: none;
}
}
@media (prefers-reduced-motion: reduce) {
.el-button,
.el-input__wrapper,
.el-select__wrapper,
.el-textarea__inner,
.el-table__body tr:hover > td.el-table__cell {
transition: none;
}
.el-button--primary:not(.is-disabled):hover,
.el-button--primary:not(.is-disabled):active {
transform: none;
}
.ys-rise {
animation: none;
}
}
/*
* 分页放进卡片页脚时去掉自身的上外边距 —— 页脚已有 padding,
* 再加 14px 会让卡片底部比顶部厚一档(各页原本各写一遍 :deep 覆盖)。
* 连 .app-pager 一起写:AppPagination 组件自己的 scoped 样式里有
* margin-top,同权重下后注入的 scoped 会赢,这里多一级类压住它。
*/
.card-foot .el-pagination.app-pager {
margin-top: 0;
}
/*
* 确认框(ElMessageBox)统一视觉
*

View File

@ -11,11 +11,19 @@ const { topLevelMenus, groupedMenus } = useAppMenu()
<template>
<el-menu class="app-menu" :default-active="route.path" router>
<el-menu-item v-for="item in topLevelMenus" :key="item.path" :index="item.path">
<el-icon v-if="item.icon" class="app-menu__icon" aria-hidden="true">
<component :is="item.icon" />
</el-icon>
{{ item.title }}
</el-menu-item>
<el-sub-menu v-for="group in groupedMenus" :key="group.name" :index="group.name">
<template #title>{{ group.name }}</template>
<template #title>
<span class="app-menu__group">{{ group.name }}</span>
</template>
<el-menu-item v-for="item in group.items" :key="item.path" :index="item.path">
<el-icon v-if="item.icon" class="app-menu__icon" aria-hidden="true">
<component :is="item.icon" />
</el-icon>
{{ item.title }}
</el-menu-item>
</el-sub-menu>
@ -32,34 +40,129 @@ const { topLevelMenus, groupedMenus } = useAppMenu()
--el-menu-bg-color: transparent;
--el-menu-text-color: var(--ys-ink-2);
--el-menu-active-color: var(--ys-indigo);
--el-menu-hover-bg-color: rgba(255, 255, 255, 0.6);
--el-menu-item-height: 40px;
--el-menu-hover-bg-color: rgba(255, 255, 255, 0.55);
--el-menu-item-height: 36px;
padding: 0 10px;
border-right: none;
}
/* 菜单项:内距 9px 10px、圆角 7px、13px 字 —— 组内层级靠缩进区分,不加分隔线 */
.app-menu :deep(.el-menu-item) {
height: 36px;
padding: 0 10px;
margin-bottom: 2px;
font-size: 13px;
line-height: 36px;
border-radius: var(--ys-radius-sm);
transition:
background-color 150ms var(--ys-ease),
color 150ms var(--ys-ease);
}
.app-menu :deep(.el-menu-item:hover) {
color: var(--ys-ink);
}
/* 子项向右缩进一档,与顶层项形成「树」的暗示而非并列 */
.app-menu :deep(.el-sub-menu .el-menu-item) {
padding-left: 22px !important;
}
.app-menu :deep(.el-menu-item.is-active) {
position: relative;
background: linear-gradient(180deg, #ffffff, #fbfcfe);
font-weight: 500;
background: linear-gradient(180deg, var(--ys-paper), #fbfcfe);
box-shadow:
inset 0 1px 0 #fff,
0 1px 2px rgba(28, 35, 51, 0.06),
0 4px 10px rgba(28, 35, 51, 0.06);
font-weight: 500;
}
.app-menu :deep(.el-menu-item.is-active)::before {
content: '';
position: absolute;
left: -14px;
top: 9px;
bottom: 9px;
left: -10px;
width: 2px;
border-radius: 1px;
background: var(--ys-indigo);
}
.app-menu :deep(.el-sub-menu.is-active > .el-sub-menu__title) {
/* 组标题:11px 大字距的「目录行」,不抢子项;展开时转靛蓝并压一层白底 */
.app-menu :deep(.el-sub-menu__title) {
height: 32px;
padding: 0 10px;
line-height: 32px;
border-radius: var(--ys-radius-sm);
transition:
background-color 150ms var(--ys-ease),
color 150ms var(--ys-ease);
}
.app-menu__group {
font-size: 11px;
font-weight: 500;
letter-spacing: 0.1em;
color: var(--ys-ink-3);
transition: color 150ms var(--ys-ease);
}
.app-menu :deep(.el-sub-menu__title:hover) {
background-color: rgba(255, 255, 255, 0.55);
}
.app-menu :deep(.el-sub-menu.is-opened > .el-sub-menu__title) {
background-color: rgba(255, 255, 255, 0.45);
}
.app-menu :deep(.el-sub-menu.is-opened > .el-sub-menu__title) .app-menu__group {
color: var(--ys-indigo);
}
/*
* 展开/收起箭头:Element Plus 默认「闭合向下、展开转 180°」,
* 设计稿口径是「闭合向右、展开向下」——先统一掰到向右(-90°),
* 展开时回到 0°。选择器权重高于组件库默认值,且 scoped 属性使其稳定生效。
*/
.app-menu :deep(.el-sub-menu__icon-arrow) {
width: 11px;
height: 11px;
margin-top: -6px;
color: var(--ys-ink-3);
transform: rotate(-90deg);
transition: transform 150ms var(--ys-ease);
}
.app-menu :deep(.el-sub-menu.is-opened > .el-sub-menu__title .el-sub-menu__icon-arrow) {
color: var(--ys-indigo);
transform: rotate(0deg);
}
/*
* 菜单图标:设计稿口径为 16px、透明度 0.7(激活项 1)——Element Plus 默认
* 给菜单图标 24px 宽 + 5px 间距,直接沿用会让图标比文字更抢眼、字距发散,
* 故在此显式覆盖。
*/
.app-menu__icon {
width: 16px;
height: 16px;
margin-right: 9px;
font-size: 16px;
opacity: 0.7;
}
.app-menu :deep(.el-menu-item.is-active) .app-menu__icon {
opacity: 1;
}
@media (prefers-reduced-motion: reduce) {
.app-menu :deep(.el-menu-item),
.app-menu :deep(.el-sub-menu__title),
.app-menu :deep(.el-sub-menu__icon-arrow),
.app-menu__group {
transition: none;
}
}
</style>

View File

@ -17,6 +17,9 @@ const drawerVisible = ref(false)
/** 「首页」按角色解析:普通用户没有工作台权限,固定链到业务数据页 */
const homePath = computed(() => firstAccessiblePath(router, userStore.roles))
/** 头像取显示名首字:没有头像上传能力,首字比图标更有辨识度 */
const avatarInitial = computed(() => userStore.displayName.trim().charAt(0) || '衍')
/** 面包屑:首页 + 分组(如有)+ 当前页标题(首页自身不重复) */
const breadcrumbs = computed<string[]>(() => {
const items: string[] = []
@ -46,13 +49,13 @@ async function handleLogout(): Promise<void> {
<div class="app-brand">
<span class="app-brand__name">衍水集团</span>
<span class="app-brand__sub">营销人员工资管理系统</span>
<span class="app-brand__line"></span>
<span class="waterline app-brand__line"></span>
</div>
<AppMenu class="app-aside__menu" />
</el-aside>
<el-container class="app-body">
<el-header class="app-header">
<el-header class="app-header" height="52px">
<div class="app-header__left">
<button
type="button"
@ -81,7 +84,9 @@ async function handleLogout(): Promise<void> {
<el-tag v-for="role in userStore.roles" :key="role" size="small" type="info">
{{ role }}
</el-tag>
<span class="app-header__avatar" aria-hidden="true">{{ avatarInitial }}</span>
<span class="app-header__username">{{ userStore.displayName }}</span>
<span class="app-header__divider" aria-hidden="true"></span>
<el-button link type="primary" @click="handleLogout">退出登录</el-button>
</div>
</el-header>
@ -101,7 +106,7 @@ async function handleLogout(): Promise<void> {
<div class="app-brand">
<span class="app-brand__name">衍水集团</span>
<span class="app-brand__sub">营销人员工资管理系统</span>
<span class="app-brand__line"></span>
<span class="waterline app-brand__line"></span>
</div>
<AppMenu />
</el-drawer>
@ -117,7 +122,7 @@ async function handleLogout(): Promise<void> {
display: flex;
flex-direction: column;
/* 中性灰侧栏:比页面底沉一档,与纸白卡片拉开层次 */
background: linear-gradient(180deg, #f2f4f8 0%, #ecedf3 100%);
background: linear-gradient(180deg, var(--ys-aside-1) 0%, var(--ys-aside-2) 100%);
border-right: 1px solid var(--ys-line);
}
@ -130,9 +135,9 @@ async function handleLogout(): Promise<void> {
.app-brand__name {
font-family: var(--ys-font-brand);
font-size: 16px;
font-size: 15px;
font-weight: 500;
letter-spacing: 4px;
letter-spacing: 0.32em;
color: var(--ys-ink);
}
@ -142,17 +147,15 @@ async function handleLogout(): Promise<void> {
color: var(--ys-ink-3);
}
/* 品牌签名:logo 下的渐隐水线(全站唯一装饰元素,页面标题处同款) */
/* 品牌签名:logo 下的渐隐水线(全站唯一装饰元素,页面标题处同款,此处收窄) */
.app-brand__line {
width: 24px;
height: 2px;
border-radius: 1px;
margin-top: 8px;
background: linear-gradient(90deg, var(--ys-indigo), rgba(46, 75, 168, 0));
}
.app-aside__menu {
flex: 1;
padding: 2px 0 12px;
overflow-y: auto;
}
@ -160,8 +163,8 @@ async function handleLogout(): Promise<void> {
display: flex;
align-items: center;
justify-content: space-between;
height: 56px;
background: linear-gradient(180deg, #ffffff, #fdfdfe);
padding: 0 18px;
background: linear-gradient(180deg, var(--ys-paper), #fdfdfe);
border-bottom: 1px solid var(--ys-line);
}
@ -179,15 +182,16 @@ async function handleLogout(): Promise<void> {
width: 32px;
height: 32px;
padding: 0;
color: var(--ys-text);
color: var(--ys-ink-2);
cursor: pointer;
background: none;
border: none;
border-radius: 6px;
border-radius: 7px;
}
.app-header__menu-toggle:hover {
background-color: #f2f3f5;
color: var(--ys-indigo);
background-color: var(--ys-indigo-wash);
}
.app-header__menu-toggle:focus-visible {
@ -198,20 +202,46 @@ async function handleLogout(): Promise<void> {
.app-header__user {
display: flex;
align-items: center;
gap: 10px;
gap: 9px;
}
/* 首字头像:靛蓝洗底,26px —— 与菜单图标同为「轻质感」而非实心色块 */
.app-header__avatar {
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
font-size: 12px;
font-weight: 600;
color: var(--ys-indigo);
background: linear-gradient(180deg, #f1f4fb, var(--ys-indigo-wash));
border: 1px solid rgba(46, 75, 168, 0.14);
border-radius: 50%;
}
.app-header__username {
font-size: 14px;
color: var(--ys-text);
font-size: 13px;
color: var(--ys-ink);
}
.app-header__divider {
width: 1px;
height: 14px;
background-color: var(--ys-line);
}
.app-main {
padding: 20px;
/* 雾灰底 + 点阵网格:内容区的秩序与密度 */
/*
* 内容区:雾灰底 + 顶部受光(靛蓝微光叠一层高光)。
* 受光用固定像素半径而非百分比:百分比会随窗口高度变化,
* 窄高三列布局时天光会被拉成竖条。
*/
padding: 22px;
background-color: var(--ys-mist);
background-image: radial-gradient(rgba(28, 35, 51, 0.05) 1px, transparent 1px);
background-size: 24px 24px;
background-image:
radial-gradient(900px 320px at 50% -60px, rgba(46, 75, 168, 0.055), rgba(46, 75, 168, 0) 70%),
radial-gradient(1200px 420px at 50% -140px, rgba(255, 255, 255, 0.85), rgba(255, 255, 255, 0) 75%);
}
@media (max-width: 768px) {
@ -222,6 +252,10 @@ async function handleLogout(): Promise<void> {
.app-header__menu-toggle {
display: inline-flex;
}
.app-main {
padding: 14px;
}
}
@media (prefers-reduced-motion: reduce) {
@ -235,6 +269,6 @@ async function handleLogout(): Promise<void> {
/* 抽屉内容铺满侧栏同款灰(非 scoped:目标元素由 el-drawer 渲染在组件根之外) */
.app-drawer .el-drawer__body {
padding: 0;
background: linear-gradient(180deg, #f2f4f8 0%, #ecedf3 100%);
background: linear-gradient(180deg, var(--ys-aside-1) 0%, var(--ys-aside-2) 100%);
}
</style>

View File

@ -0,0 +1,72 @@
import type { Component } from 'vue'
import {
Avatar,
Box,
Briefcase,
Calendar,
DataLine,
Histogram,
House,
Management,
Money,
Notebook,
OfficeBuilding,
PieChart,
Postcard,
SetUp,
Shop,
Stamp,
Star,
Tickets,
TrendCharts,
User,
Van,
} from '@element-plus/icons-vue'
/**
* 鑿滃崟鍥炬爣娉ㄥ唽琛ㄣ??
*
* **璺敱鍙啓璇箟 key锛坢eta.icon锛夛紝涓嶇洿鎺ュ紩鐢ㄥ浘鏍囩粍浠?**锛氳矾鐢辫〃淇濇寔杞婚噺鍙?
* 搴忓垪鍖栵紝鍥炬爣瀹炵幇鎹㈠簱鎴栨崲椋庢牸鏃跺彧鏀规湰鏂囦欢锛岃矾鐢变笌鑿滃崟缁勪欢涓嶅姩銆?
*
* 鍏峰悕瀵煎叆锛堜笉鐢? `import * as`锛変互淇濊瘉鎽囨爲 鈥斺?? 鏁村寘寮曞叆浼氭妸 200+ 鍥炬爣鎵撹繘
* 浜х墿銆傛敞鍐岃〃閲屽浣欑殑鍥炬爣鍚屾牱浼氳鎽囨帀锛屼絾浼氳銆岀敤浜嗗摢浜涖?嶉毦浠ユ牳瀵癸紝鏁?
* 鍙繚鐣欏疄闄呯敤鍒扮殑銆?
*
* **鍏ㄩ儴鍙栫嚎鎬э紙outline锛夊浘鏍囷紝涓嶇敤 *-filled 鍙樹綋**锛氫晶鏍忓悓涓?灞傜骇娣风敤瀹炲績涓?
* 绾挎?у浘鏍囦細璁╄彍鍗曠湅鐫?涓嶆槸涓?濂楄瑷?锛堣璁$ǹ 搂04 鐨勫浘鏍囨棌鍗? 16px 绾挎?э級銆?
* 棣栭〉鐢? House锛堢嚎鎬ф埧灞嬶級鑰岄潪 HomeFilled锛屼汉鍛樼鐞嗙敤 Postcard锛堜汉鍛樻。妗堝崱锛?
* 鑰岄潪 UserFilled 鈥斺?? 鐢ㄦ埛绠$悊宸插崰 User锛屼汉鍛樻敼鐢ㄦ。妗堝崱浠ュ厤涓や釜銆屼汉銆嶆挒褰€??
*/
const ICONS = {
home: House,
users: User,
roles: Avatar,
'business-roles': Management,
params: SetUp,
settings: OfficeBuilding,
approvals: Stamp,
logs: Tickets,
personnel: Postcard,
products: Box,
customers: Shop,
businesses: Briefcase,
shipments: Van,
receipts: Money,
rating: Star,
ledger: Notebook,
settlements: Calendar,
'report-personal': DataLine,
'report-department': Histogram,
'report-business': TrendCharts,
'report-payment': PieChart,
} satisfies Record<string, Component>
/** 鑿滃崟鍥炬爣鐨勮涔? key锛堣矾鐢? meta.icon 鐨勫彇鍊艰寖鍥达級銆? */
export type MenuIconName = keyof typeof ICONS
/** 鎸夎涔? key 鍙栧浘鏍囷紱鏈厤缃椂杩斿洖 undefined锛堣彍鍗曢」鍙樉绀烘枃瀛楋級銆? */
export function menuIcon(name?: string): Component | undefined {
return name ? (ICONS[name as MenuIconName] ?? undefined) : undefined
}

View File

@ -1,7 +1,10 @@
<script setup lang="ts">
/**
* 分页条:统一四个列表页的布局与页码尺寸选项。
* 分页条:统一各列表页的布局与页码尺寸选项。
* 与 `usePagedList` 的分页字段(pageNum/pageSize/total)配套使用。
*
* 开 background:设计稿 §03 要求当前页与主按钮同材质(靛蓝渐变 + 内高光),
* 该效果只在 is-background 模式下由 .el-pager li 承载,样式见全局 index.css。
*/
defineProps<{
total: number
@ -18,6 +21,7 @@ defineEmits<{
<template>
<el-pagination
class="app-pager"
background
layout="total, sizes, prev, pager, next, jumper"
:total="total"
:current-page="pageNum"
@ -31,6 +35,6 @@ defineEmits<{
<style scoped>
.app-pager {
justify-content: flex-end;
margin-top: 16px;
margin-top: 14px;
}
</style>

View File

@ -4,8 +4,15 @@
*
* 用户状态、角色状态、操作结果、登录结果四处共用,故提取;
* 语义文字由调用方通过 props 定制(如同一组件展示「启用/禁用」与「成功/失败」)。
*
* 视觉按设计稿 §03 的状态 pill 落地(浅底 + 6px 圆点 + 999px 圆角),
* 用 span 而非 el-tag:el-tag 的实心/描边形态与四档色阶对不上,
* 强行覆盖会牵连其它用 el-tag 的地方(角色标签、日志模块标签)。
* activeType/inactiveType 保留为语义到 pill 色调的映射,调用方无需改动。
*/
withDefaults(
import { computed } from 'vue'
const props = withDefaults(
defineProps<{
value: number
activeText?: string
@ -20,10 +27,23 @@ withDefaults(
inactiveType: 'info',
},
)
/** Element Plus 的语义类型 → 设计稿的四档 pill 色调 */
const TONE_BY_TYPE: Record<string, string> = {
success: 'ok',
warning: 'wait',
danger: 'reject',
primary: 'lock',
info: '',
}
const isActive = computed(() => props.value === 1)
const tone = computed(() =>
TONE_BY_TYPE[isActive.value ? props.activeType : props.inactiveType] ?? '',
)
const text = computed(() => (isActive.value ? props.activeText : props.inactiveText))
</script>
<template>
<el-tag :type="value === 1 ? activeType : inactiveType" size="small" disable-transitions>
{{ value === 1 ? activeText : inactiveText }}
</el-tag>
<span class="ys-pill" :class="tone ? `ys-pill--${tone}` : ''">{{ text }}</span>
</template>

View File

@ -1,6 +1,8 @@
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import type { Component } from 'vue'
import { menuIcon } from '@/components/layout/menuIcons'
import { canAccessRoute, isMenuRecord } from '@/router/access'
import { useUserStore } from '@/store/user'
@ -10,6 +12,8 @@ export interface MenuNode {
title: string
order: number
group?: string
/** 图标组件(由 meta.icon 语义 key 解析;未配置则为 undefined) */
icon?: Component
}
export interface MenuGroup {
@ -42,6 +46,7 @@ export function useAppMenu() {
title: record.meta.title ?? '',
order: record.meta.order ?? 99,
group: record.meta.group,
icon: menuIcon(record.meta.icon),
}))
.sort((a, b) => a.order - b.order),
)

View File

@ -1,6 +1,7 @@
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
import { ElMessage } from 'element-plus'
import type { MenuIconName } from '@/components/layout/menuIcons'
import { firstAccessiblePath } from '@/router/access'
import { useUserStore } from '@/store/user'
import { ROLE_ADMIN } from '@/utils/constants'
@ -20,6 +21,11 @@ declare module 'vue-router' {
menu?: boolean
/** 菜单分组名(同名聚合为一个子菜单);不设则为顶层菜单项 */
group?: string
/**
* 菜单图标语义 key —— 取值见 components/layout/menuIcons.ts 的注册表。
* 路由只写 key 不写图标组件:换图标库/换风格只改注册表。
*/
icon?: MenuIconName
/** 菜单排序,越小越靠前 */
order?: number
}
@ -46,6 +52,7 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/dashboard/index.vue'),
meta: {
title: '首页',
icon: 'home',
order: 1,
allowedRoles: [ROLE_ADMIN, 'finance', 'leader'],
},
@ -54,19 +61,25 @@ const routes: RouteRecordRaw[] = [
path: '/system/users',
name: 'system-users',
component: () => import('@/views/system/users/index.vue'),
meta: { title: '用户管理', group: '系统管理', requiresAdmin: true, order: 2 },
meta: { title: '用户管理', icon: 'users', group: '系统管理', requiresAdmin: true, order: 2 },
},
{
path: '/system/roles',
name: 'system-roles',
component: () => import('@/views/system/roles/index.vue'),
meta: { title: '角色管理', group: '系统管理', requiresAdmin: true, order: 3 },
meta: { title: '角色管理', icon: 'roles', group: '系统管理', requiresAdmin: true, order: 3 },
},
{
path: '/system/business-roles',
name: 'system-business-roles',
component: () => import('@/views/system/business-roles/index.vue'),
meta: { title: '岗位角色', group: '系统管理', requiresAdmin: true, order: 5 },
meta: {
title: '岗位角色',
icon: 'business-roles',
group: '系统管理',
requiresAdmin: true,
order: 5,
},
},
{
// 计提参数:查看放开到管理员/财务/领导(#17 答复「查看可、修改仅
@ -76,6 +89,7 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/system/params/index.vue'),
meta: {
title: '计提参数',
icon: 'params',
group: '系统管理',
allowedRoles: [ROLE_ADMIN, 'finance', 'leader'],
order: 6,
@ -85,7 +99,13 @@ const routes: RouteRecordRaw[] = [
path: '/system/settings',
name: 'system-settings',
component: () => import('@/views/system/settings/index.vue'),
meta: { title: '公司信息', group: '系统管理', requiresAdmin: true, order: 7 },
meta: {
title: '公司信息',
icon: 'settings',
group: '系统管理',
requiresAdmin: true,
order: 7,
},
},
{
// 审批管理:管理员/财务/领导可见(营销后勤待权限清单答复后放开)
@ -94,6 +114,7 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/system/approvals/index.vue'),
meta: {
title: '审批管理',
icon: 'approvals',
group: '系统管理',
allowedRoles: [ROLE_ADMIN, 'finance', 'leader'],
order: 8,
@ -103,45 +124,51 @@ const routes: RouteRecordRaw[] = [
path: '/system/logs',
name: 'system-logs',
component: () => import('@/views/system/logs/index.vue'),
meta: { title: '日志查询', group: '系统管理', requiresAdmin: true, order: 9 },
meta: {
title: '日志查询',
icon: 'logs',
group: '系统管理',
requiresAdmin: true,
order: 9,
},
},
{
// 基础数据属业务操作:登录即可访问,不设 requiresAdmin(对照后端 api/v1/base)
path: '/base/personnel',
name: 'base-personnel',
component: () => import('@/views/base/personnel/index.vue'),
meta: { title: '人员管理', group: '基础数据', order: 10 },
meta: { title: '人员管理', icon: 'personnel', group: '基础数据', order: 10 },
},
{
path: '/base/products',
name: 'base-products',
component: () => import('@/views/base/products/index.vue'),
meta: { title: '产品管理', group: '基础数据', order: 11 },
meta: { title: '产品管理', icon: 'products', group: '基础数据', order: 11 },
},
{
path: '/base/customers',
name: 'base-customers',
component: () => import('@/views/base/customers/index.vue'),
meta: { title: '客户管理', group: '基础数据', order: 12 },
meta: { title: '客户管理', icon: 'customers', group: '基础数据', order: 12 },
},
{
// 业务数据组一律为业务操作:登录即可访问,不设 requiresAdmin
path: '/business/businesses',
name: 'business-businesses',
component: () => import('@/views/business/businesses/index.vue'),
meta: { title: '业务管理', group: '业务数据', order: 13 },
meta: { title: '业务管理', icon: 'businesses', group: '业务数据', order: 13 },
},
{
path: '/business/shipments',
name: 'business-shipments',
component: () => import('@/views/business/shipments/index.vue'),
meta: { title: '发货管理', group: '业务数据', order: 14 },
meta: { title: '发货管理', icon: 'shipments', group: '业务数据', order: 14 },
},
{
path: '/business/receipts',
name: 'business-receipts',
component: () => import('@/views/business/receipts/index.vue'),
meta: { title: '回款管理', group: '业务数据', order: 15 },
meta: { title: '回款管理', icon: 'receipts', group: '业务数据', order: 15 },
},
{
// 业务数据组首个页面:评级计算器是打分工具,不影响绩效金额,
@ -149,7 +176,7 @@ const routes: RouteRecordRaw[] = [
path: '/business/rating',
name: 'business-rating',
component: () => import('@/views/business/rating/index.vue'),
meta: { title: '客户评级计算器', group: '业务数据', order: 16 },
meta: { title: '客户评级计算器', icon: 'rating', group: '业务数据', order: 16 },
},
{
// 绩效工资组(M3 ④):核算台账读登录即可,月结写限管理员与营销后勤
@ -157,13 +184,13 @@ const routes: RouteRecordRaw[] = [
path: '/calc/ledger',
name: 'calc-ledger',
component: () => import('@/views/calc/ledger/index.vue'),
meta: { title: '绩效台账', group: '绩效工资', order: 17 },
meta: { title: '绩效台账', icon: 'ledger', group: '绩效工资', order: 17 },
},
{
path: '/settlements',
name: 'settlements',
component: () => import('@/views/settlements/index.vue'),
meta: { title: '月度结算', group: '绩效工资', order: 18 },
meta: { title: '月度结算', icon: 'settlements', group: '绩效工资', order: 18 },
},
// 报表统计组(M4 ⑤):FR-030/031 限领导/财务/管理员;admin 须显式
// 列入 allowedRoles(项目既定规则,admin 不天然放行)
@ -173,6 +200,7 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/reports/personal-detail/index.vue'),
meta: {
title: '个人绩效明细',
icon: 'report-personal',
group: '报表统计',
order: 19,
allowedRoles: [ROLE_ADMIN, 'finance', 'leader'],
@ -184,6 +212,7 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/reports/department-summary/index.vue'),
meta: {
title: '部门绩效汇总',
icon: 'report-department',
group: '报表统计',
order: 20,
allowedRoles: [ROLE_ADMIN, 'finance', 'leader'],
@ -195,6 +224,7 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/reports/business-stats/index.vue'),
meta: {
title: '业务统计',
icon: 'report-business',
group: '报表统计',
order: 21,
allowedRoles: [ROLE_ADMIN, 'finance', 'leader'],
@ -206,6 +236,7 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/reports/payment-stats/index.vue'),
meta: {
title: '回款统计',
icon: 'report-payment',
group: '报表统计',
order: 22,
allowedRoles: [ROLE_ADMIN, 'finance', 'leader'],

View File

@ -7,6 +7,7 @@ import type { CustomerItem, OwnershipLogItem } from '@/api/customers'
import * as personnelApi from '@/api/personnel'
import AppPagination from '@/components/table/AppPagination.vue'
import { useConfirmAction } from '@/composables/useConfirmAction'
import { useDropdownOptions } from '@/composables/useDropdownOptions'
import { usePagedList } from '@/composables/usePagedList'
import { formatDateTime } from '@/utils/format'
import { handledError } from '@/utils/error'
@ -56,18 +57,14 @@ interface PersonnelOption {
status: number
}
const ownerOptions = ref<PersonnelOption[]>([])
async function loadOwnerOptions(): Promise<void> {
try {
const { options: ownerOptions, load: loadOwnerOptions } = useDropdownOptions<PersonnelOption>(
async () => {
// 归属下拉的近似全量数据源:内部系统人员规模有限,200 条足够;
// 极端场景由后端对归属合法性的校验兜底
const data = await personnelApi.listPersonnel({ pageNum: 1, pageSize: 200 })
ownerOptions.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 }))
},
)
/** 下拉项展示名:离职人员加后缀标注(仍可被选作归属吗?由后端校验兜底) */
function ownerLabel(option: PersonnelOption): string {
@ -322,61 +319,86 @@ 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: 190px"
@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: 190px"
@keyup.enter="search"
/>
</el-form-item>
<el-form-item label="分类">
<el-select v-model="filters.customer_type" placeholder="全部" clearable style="width: 130px">
<el-option
v-for="item in customerApi.CUSTOMER_TYPE_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-form-item>
<el-form-item label="分类">
<el-select v-model="filters.customer_type" placeholder="全部" clearable style="width: 130px">
<el-option
v-for="item in customerApi.CUSTOMER_TYPE_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="归属人员">
<el-select
v-model="filters.owner_personnel_id"
placeholder="全部"
clearable
filterable
style="width: 160px"
>
<el-option
v-for="item in ownerOptions"
:key="item.id"
:label="ownerLabel(item)"
: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.owner_personnel_id"
placeholder="全部"
clearable
filterable
style="width: 160px"
>
<el-option
v-for="item in ownerOptions"
:key="item.id"
:label="ownerLabel(item)"
: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="company_name" label="公司名称" min-width="180" />
<el-table-column prop="owner_name" label="负责人" min-width="90" />
<el-table-column prop="contact_person" label="联系人" min-width="90">
<template #default="{ row }">{{ row.contact_person ?? '—' }}</template>
<template #default="{ row }">
<span v-if="row.contact_person">{{ row.contact_person }}</span>
<span v-else class="cell-muted">—</span>
</template>
</el-table-column>
<el-table-column prop="contact_phone" label="电话" min-width="120">
<template #default="{ row }">{{ row.contact_phone ?? '—' }}</template>
<template #default="{ row }">
<span v-if="row.contact_phone">{{ row.contact_phone }}</span>
<span v-else class="cell-muted">—</span>
</template>
</el-table-column>
<el-table-column label="分类" width="110" align="center">
<template #default="{ row }">
@ -396,7 +418,10 @@ onMounted(() => {
</template>
</el-table-column>
<el-table-column label="归属人员" min-width="110">
<template #default="{ row }">{{ row.owner_personnel_name ?? '(无归属)' }}</template>
<template #default="{ row }">
<span v-if="row.owner_personnel_name">{{ row.owner_personnel_name }}</span>
<span v-else class="cell-muted">(无归属)</span>
</template>
</el-table-column>
<el-table-column label="操作" width="260" fixed="right" align="center">
<template #default="{ row }">
@ -413,14 +438,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
@ -532,7 +559,13 @@ onMounted(() => {
<!-- 归属变更记录(分页留痕,按变更时间倒序) -->
<el-drawer v-model="logsVisible" :title="logsTitle" size="760px" destroy-on-close>
<el-table v-loading="logsLoading" :data="logRecords" stripe size="small">
<el-table
v-loading="logsLoading"
:data="logRecords"
stripe
size="small"
empty-text="这个客户还没有归属变更记录 —— 调整一次归属后这里就会出现留痕"
>
<el-table-column label="变更时间" width="160">
<template #default="{ row }">{{ formatDateTime(row.create_time) }}</template>
</el-table-column>

View File

@ -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;
}

View File

@ -185,6 +185,14 @@ onMounted(() => {
<template>
<div>
<div class="page-head">
<h1 class="page-title">产品管理</h1>
<p class="page-desc">
产品主数据;类别是折算阈值的行级锚点,改类别会改变该产品成交回款的绩效口径
</p>
<span class="waterline"></span>
</div>
<el-alert
class="page-alert"
type="info"
@ -193,48 +201,59 @@ onMounted(() => {
title="产品类别决定折算阈值:市政水利 30% / 轨道交通 20%;阈值在「系统设置-计提参数」维护。"
/>
<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"
<!--
.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.product_category" placeholder="全部" clearable style="width: 130px">
<el-option
v-for="item in productApi.PRODUCT_CATEGORY_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-form-item>
<el-form-item label="类别">
<el-select v-model="filters.product_category" placeholder="全部" clearable style="width: 130px">
<el-option
v-for="item in productApi.PRODUCT_CATEGORY_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="filters.status" placeholder="全部" clearable style="width: 110px">
<el-option
v-for="item in STATUS_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</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.status" placeholder="全部" clearable style="width: 110px">
<el-option
v-for="item in STATUS_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</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="product_name" label="产品名称" min-width="150" />
<el-table-column prop="spec_model" label="规格型号" min-width="140">
<template #default="{ row }">{{ row.spec_model || '—' }}</template>
@ -245,12 +264,12 @@ onMounted(() => {
<el-table-column label="数量" min-width="100" align="right">
<template #default="{ row }">
<!-- 字符串直出:金额红线,禁 toFixed / float 格式化 -->
<span class="cell-amount">{{ row.stock_quantity ?? '—' }}</span>
<span class="cell-num">{{ row.stock_quantity ?? '—' }}</span>
</template>
</el-table-column>
<el-table-column label="单价(元)" min-width="110" align="right">
<template #default="{ row }">
<span class="cell-amount">{{ row.sale_price ?? '—' }}</span>
<span class="cell-num">{{ row.sale_price ?? '—' }}</span>
</template>
</el-table-column>
<el-table-column label="类别" width="110" align="center">
@ -283,14 +302,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
@ -342,13 +363,10 @@ onMounted(() => {
</template>
<style scoped>
.page-alert {
margin-bottom: 12px;
}
/* .cell-num 由全局 index.css 提供(等宽数字 + 右对齐),本页不再重复定义 */
.cell-amount {
font-family: 'SFMono-Regular', Consolas, 'Courier New', monospace;
font-size: 12px;
color: var(--ys-ink-2);
/* 阈值提示条:与卡片同宽,下距由这里控制,不占卡的内部留白 */
.page-alert {
margin-bottom: 14px;
}
</style>

View File

@ -10,6 +10,7 @@ import * as personnelApi from '@/api/personnel'
import { productCategoryLabel } from '@/api/products'
import AppPagination from '@/components/table/AppPagination.vue'
import { useConfirmAction } from '@/composables/useConfirmAction'
import { useDropdownOptions } from '@/composables/useDropdownOptions'
import { usePagedList } from '@/composables/usePagedList'
import { formatDateTime } from '@/utils/format'
import { AMOUNT_PATTERN, PRICE_PATTERN, ZERO_PATTERN } from '@/utils/validate'
@ -69,37 +70,51 @@ interface PersonnelOption {
status: number
}
const customerOptions = ref<CustomerOption[]>([])
const personnelOptions = ref<PersonnelOption[]>([])
async function loadCustomerOptions(): Promise<void> {
try {
const { options: customerOptions, load: loadCustomerOptions } = useDropdownOptions<CustomerOption>(
async () => {
// 客户下拉的近似全量数据源:内部系统规模有限,200 条足够;超出由后端校验兜底
const data = await customerApi.listCustomers({ pageNum: 1, pageSize: 200 })
customerOptions.value = data.list.map((c) => ({ id: c.id, name: c.company_name }))
} catch (error) {
handledError(error) // 下拉为空不阻断列表使用
}
}
return data.list.map((c) => ({ id: c.id, name: c.company_name }))
},
)
async function loadPersonnelOptions(): Promise<void> {
try {
const { options: personnelOptions, load: loadPersonnelOptions } = useDropdownOptions<PersonnelOption>(
async () => {
const data = await personnelApi.listPersonnel({ pageNum: 1, pageSize: 200 })
personnelOptions.value = data.list.map((p) => ({
return data.list.map((p) => ({
id: p.id,
name: p.personnel_name,
status: p.status,
}))
} catch (error) {
handledError(error)
}
}
},
)
/** 下拉项展示名:离职人员加后缀标注(可否被选由后端归属校验兜底) */
function personnelLabel(option: PersonnelOption): string {
return option.status === 1 ? option.name : `${option.name}(离职)`
}
// ==================== 状态呈现(只做样式映射,不改判定) ====================
/**
* Element Plus 语义类型 → 设计稿四档 pill。
*
* 状态判定沿用 businessApi.businessStatusTagType(三色语义已在接口层定死),
* 这里只把语义翻成 pill 的浅底 + 圆点形态:列表的状态列统一成 pill 之后,
* el-tag 的实心块状形态会与相邻的编号/金额列打架。
*/
const STATUS_PILL_TONE: Record<string, string> = {
success: 'ys-pill--ok',
danger: 'ys-pill--reject',
primary: 'ys-pill--lock',
info: '',
}
function statusPillClass(status: number): string {
const tone = STATUS_PILL_TONE[businessApi.businessStatusTagType(status)] ?? ''
return tone ? `ys-pill ${tone}` : 'ys-pill'
}
// ==================== 新增 / 编辑(含明细行编辑器) ====================
const dialogVisible = ref(false)
@ -602,50 +617,75 @@ 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: 190px"
@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 businessApi.BUSINESS_STATUS_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="业务日期">
<el-date-picker
v-model="dateRange"
type="daterange"
value-format="YYYY-MM-DD"
range-separator="至"
start-placeholder="起"
end-placeholder="止"
style="width: 240px"
/>
</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="page-head">
<h1 class="page-title">业务管理</h1>
<p class="page-desc">
业务台账是发货、回款与绩效的源头:客户、归属人员与产品类别在这里定下来,后面的核算才有依据
</p>
<span class="waterline"></span>
</div>
<div class="toolbar">
<el-button type="primary" @click="openCreate">新增业务</el-button>
<!--
.filter-bar 直接挂在 el-form 上(去掉 inline):el-form 不是 flex 透明容器,
外面再包一层 div 会让 el-form-item 丢掉 gap 排版,筛选条退化成竖排;
查询/重置收进 .filter-bar__actions,由 margin-left:auto 推到行尾。
-->
<el-form class="filter-bar" @submit.prevent>
<el-form-item label="关键词">
<el-input
v-model="filters.keyword"
placeholder="业务编号或公司名称"
clearable
style="width: 190px"
@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 businessApi.BUSINESS_STATUS_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="业务日期">
<el-date-picker
v-model="dateRange"
type="daterange"
value-format="YYYY-MM-DD"
range-separator="至"
start-placeholder="起"
end-placeholder="止"
style="width: 240px"
/>
</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>
<el-table-column prop="business_no" label="业务编号" min-width="150" />
<div class="toolbar">
<el-button type="primary" @click="openCreate">新增业务</el-button>
<span class="toolbar-note">状态流转与归属调整走行内操作,每次变更都写留痕</span>
</div>
<!-- 表格与分页共用一张卡:分页落进 card-foot,表体与表尾同一材质 -->
<div class="card">
<el-table
v-loading="loading"
:data="records"
stripe
empty-text="还没有业务记录 —— 点「新增业务」建第一笔,或换个关键词再查"
>
<el-table-column label="业务编号" min-width="150">
<template #default="{ row }">
<span class="cell-code">{{ row.business_no }}</span>
</template>
</el-table-column>
<el-table-column prop="customer_name" label="客户名称" min-width="160">
<template #default="{ row }">{{ row.customer_name ?? '—' }}</template>
</el-table-column>
@ -654,13 +694,10 @@ onMounted(() => {
</el-table-column>
<el-table-column label="状态" width="90" align="center">
<template #default="{ row }">
<el-tag
:type="businessApi.businessStatusTagType(row.status)"
size="small"
disable-transitions
>
<!-- 状态列统一用 pill:颜色之外必有文字,色觉差异不影响判读 -->
<span :class="statusPillClass(row.status)">
{{ businessApi.businessStatusLabel(row.status) }}
</el-tag>
</span>
</template>
</el-table-column>
<el-table-column prop="owner_personnel_name" label="负责人员" min-width="100">
@ -672,22 +709,22 @@ onMounted(() => {
<el-table-column label="销售额(元)" min-width="120" align="right">
<template #default="{ row }">
<!-- 字符串直出:金额红线,禁 toFixed / float 格式化 -->
<span class="cell-amount">{{ row.total_sale_amount ?? '—' }}</span>
<span class="cell-num">{{ row.total_sale_amount ?? '—' }}</span>
</template>
</el-table-column>
<el-table-column label="成本(元)" min-width="120" align="right">
<template #default="{ row }">
<span class="cell-amount">{{ row.total_cost_amount ?? '—' }}</span>
<span class="cell-num">{{ row.total_cost_amount ?? '—' }}</span>
</template>
</el-table-column>
<el-table-column label="发货额(元)" min-width="120" align="right">
<template #default="{ row }">
<span class="cell-amount">{{ row.total_ship_amount ?? '—' }}</span>
<span class="cell-num">{{ row.total_ship_amount ?? '—' }}</span>
</template>
</el-table-column>
<el-table-column label="回款额(元)" min-width="120" align="right">
<template #default="{ row }">
<span class="cell-amount">{{ row.total_received_amount ?? '—' }}</span>
<span class="cell-num">{{ row.total_received_amount ?? '—' }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="300" fixed="right" align="center">
@ -734,14 +771,17 @@ onMounted(() => {
</el-table-column>
</el-table>
<AppPagination
:total="total"
:page-num="pageNum"
:page-size="pageSize"
@page-change="onPageChange"
@size-change="onSizeChange"
/>
</el-card>
<!-- 分页落进 card-foot:与表体同一张卡,卡底微渐变收边 -->
<div class="card-foot">
<AppPagination
:total="total"
:page-num="pageNum"
:page-size="pageSize"
@page-change="onPageChange"
@size-change="onSizeChange"
/>
</div>
</div>
<!-- 新增 / 编辑(含明细行编辑器;编辑不含归属,归属走独立按钮保证留痕) -->
<el-dialog
@ -1077,19 +1117,19 @@ onMounted(() => {
<span v-else class="cell-muted">否</span>
</el-descriptions-item>
<el-descriptions-item label="销售额(元)">
<span class="cell-amount">{{ detail.total_sale_amount ?? '—' }}</span>
<span class="cell-num">{{ detail.total_sale_amount ?? '—' }}</span>
</el-descriptions-item>
<el-descriptions-item label="成本(元)">
<span class="cell-amount">{{ detail.total_cost_amount ?? '—' }}</span>
<span class="cell-num">{{ detail.total_cost_amount ?? '—' }}</span>
</el-descriptions-item>
<el-descriptions-item label="发货额(元)">
<span class="cell-amount">{{ detail.total_ship_amount ?? '—' }}</span>
<span class="cell-num">{{ detail.total_ship_amount ?? '—' }}</span>
</el-descriptions-item>
<el-descriptions-item label="回款额(元)">
<span class="cell-amount">{{ detail.total_received_amount ?? '—' }}</span>
<span class="cell-num">{{ detail.total_received_amount ?? '—' }}</span>
</el-descriptions-item>
<el-descriptions-item label="运费额(元)">
<span class="cell-amount">{{ detail.total_freight_amount ?? '—' }}</span>
<span class="cell-num">{{ detail.total_freight_amount ?? '—' }}</span>
</el-descriptions-item>
<el-descriptions-item label="备注">
{{ detail.remark ?? '—' }}
@ -1115,27 +1155,27 @@ onMounted(() => {
</el-table-column>
<el-table-column label="数量" min-width="80" align="right">
<template #default="{ row }">
<span class="cell-amount">{{ row.quantity }}</span>
<span class="cell-num">{{ row.quantity }}</span>
</template>
</el-table-column>
<el-table-column label="销售单价" min-width="100" align="right">
<template #default="{ row }">
<span class="cell-amount">{{ row.sale_price ?? '—' }}</span>
<span class="cell-num">{{ row.sale_price ?? '—' }}</span>
</template>
</el-table-column>
<el-table-column label="行销售额" min-width="110" align="right">
<template #default="{ row }">
<span class="cell-amount">{{ row.sale_amount }}</span>
<span class="cell-num">{{ row.sale_amount }}</span>
</template>
</el-table-column>
<el-table-column label="成本额" min-width="100" align="right">
<template #default="{ row }">
<span class="cell-amount">{{ row.cost_amount ?? '—' }}</span>
<span class="cell-num">{{ row.cost_amount ?? '—' }}</span>
</template>
</el-table-column>
<el-table-column label="指导价" min-width="100" align="right">
<template #default="{ row }">
<span class="cell-amount">{{ row.guided_price ?? '—' }}</span>
<span class="cell-num">{{ row.guided_price ?? '—' }}</span>
</template>
</el-table-column>
</el-table>
@ -1155,7 +1195,7 @@ onMounted(() => {
<el-table-column prop="personnel_name" label="参与业务员" min-width="140" />
<el-table-column label="分配比例" width="110" align="right">
<template #default="{ row }">
<span class="cell-amount">{{ row.ratio }}%</span>
<span class="cell-num">{{ row.ratio }}%</span>
</template>
</el-table-column>
<el-table-column label="状态" width="90">
@ -1259,18 +1299,18 @@ onMounted(() => {
</div>
<div class="gp-grid">
<span class="gp-label">销售额</span>
<span class="gp-value cell-amount">{{ cat.sale_amount }}</span>
<span class="gp-value cell-num">{{ cat.sale_amount }}</span>
<span class="gp-label">成本额</span>
<span class="gp-value cell-amount">{{ cat.cost_amount }}</span>
<span class="gp-value cell-num">{{ cat.cost_amount }}</span>
<span class="gp-label">毛利润额</span>
<span class="gp-value cell-amount">{{ cat.profit_amount }}</span>
<span class="gp-value cell-num">{{ cat.profit_amount }}</span>
<span class="gp-label">毛利润率</span>
<!-- 分数字符串原样展示(如 997/1000),禁止数值化 -->
<span class="gp-value cell-amount">{{ cat.profit_rate }}</span>
<span class="gp-value cell-num">{{ cat.profit_rate }}</span>
<span class="gp-label">折算阈值</span>
<span class="gp-value cell-amount">{{ cat.threshold }}</span>
<span class="gp-value cell-num">{{ cat.threshold }}</span>
<span class="gp-label">折算毛利润额</span>
<span class="gp-value cell-amount">{{ cat.converted_profit }}</span>
<span class="gp-value cell-num">{{ cat.converted_profit }}</span>
</div>
</div>
</el-col>
@ -1287,38 +1327,38 @@ onMounted(() => {
</el-table-column>
<el-table-column label="数量" min-width="80" align="right">
<template #default="{ row }">
<span class="cell-amount">{{ row.quantity }}</span>
<span class="cell-num">{{ row.quantity }}</span>
</template>
</el-table-column>
<el-table-column label="销售额" min-width="100" align="right">
<template #default="{ row }">
<span class="cell-amount">{{ row.sale_amount }}</span>
<span class="cell-num">{{ row.sale_amount }}</span>
</template>
</el-table-column>
<el-table-column label="成本额" min-width="120" align="right">
<template #default="{ row }">
<span v-if="row.cost_amount" class="cell-amount">{{ row.cost_amount }}</span>
<span v-if="row.cost_amount" class="cell-num">{{ row.cost_amount }}</span>
<span v-else class="cell-missing">{{ row.missing_reason ?? '成本缺失' }}</span>
</template>
</el-table-column>
<el-table-column label="毛利润额" min-width="100" align="right">
<template #default="{ row }">
<span class="cell-amount">{{ row.profit_amount ?? '—' }}</span>
<span class="cell-num">{{ row.profit_amount ?? '—' }}</span>
</template>
</el-table-column>
<el-table-column label="毛利润率" min-width="100" align="right">
<template #default="{ row }">
<span class="cell-amount">{{ row.profit_rate ?? '—' }}</span>
<span class="cell-num">{{ row.profit_rate ?? '—' }}</span>
</template>
</el-table-column>
<el-table-column label="折算阈值" min-width="90" align="right">
<template #default="{ row }">
<span class="cell-amount">{{ row.threshold }}</span>
<span class="cell-num">{{ row.threshold }}</span>
</template>
</el-table-column>
<el-table-column label="折算毛利润额" min-width="110" align="right">
<template #default="{ row }">
<span class="cell-amount">{{ row.converted_profit ?? '—' }}</span>
<span class="cell-num">{{ row.converted_profit ?? '—' }}</span>
</template>
</el-table-column>
</el-table>
@ -1333,16 +1373,8 @@ 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-code / .cell-num / .cell-muted)与表头、按钮、输入框皮肤
已由全局 index.css 提供,此处不再重复定义,避免两处口径打架 */
.cell-missing {
font-size: 12px;
@ -1512,4 +1544,5 @@ onMounted(() => {
color: var(--ys-ink-3);
font-size: 12px;
}
</style>

View File

@ -206,6 +206,15 @@ onMounted(() => {
<template>
<div class="rating-page">
<!-- 页面头:标题 + 一句说明 + 签名水线(每页只出现一次) -->
<div class="page-head">
<h1 class="page-title">客户评级计算器</h1>
<p class="page-desc">
五项各 0-20 分、总分 0-100,按配置的分值下限实时判定等级;评分只决定评级,不参与绩效金额计算
</p>
<span class="waterline"></span>
</div>
<!-- 对照表 #25:常驻口径提示,防止误认为评分会影响绩效金额 -->
<el-alert
class="page-alert"
@ -215,57 +224,61 @@ onMounted(() => {
show-icon
/>
<!-- 评分计算区:无保存按钮,纯前端实时计算 -->
<el-card shadow="never">
<div class="page-toolbar">
<span class="page-desc">五项各 0-20 分,总分 0-100;评级等级按配置的下限实时判定。</span>
<span>
<!-- 计算器是一张卡:卡头放标题与操作,卡体内分「评分项 / 判定结果」两块 -->
<div class="card">
<div class="card-head">
<span class="card-title">评分计算</span>
<span class="card-head__actions">
<el-button @click="resetScores">重置</el-button>
<el-button type="primary" @click="openDrawer">等级映射设置</el-button>
</span>
</div>
<div class="calc-grid">
<div class="calc-form">
<div v-for="item in SCORE_ITEMS" :key="item.key" class="score-row">
<span class="score-label">{{ item.label }}</span>
<el-input-number
v-model="scores[item.key]"
:min="0"
:max="20"
:step="1"
controls-position="right"
/>
<span class="score-unit">分</span>
<div class="card-body">
<div class="calc-grid">
<div class="calc-form">
<div class="section-title">评分项(各 0-20 分,步进 1)</div>
<div v-for="item in SCORE_ITEMS" :key="item.key" class="score-row">
<span class="score-label">{{ item.label }}</span>
<el-input-number
v-model="scores[item.key]"
:min="0"
:max="20"
:step="1"
controls-position="right"
/>
<span class="score-unit">分</span>
</div>
</div>
</div>
<div class="calc-result">
<div>
<span class="result-label">总分</span>
<b class="total-num">{{ totalScore }}</b>
<span class="total-max">/ 100</span>
</div>
<div>
<span class="result-label">评级等级</span>
<template v-if="grades.length === 0">
<div class="grade-name">—</div>
<div class="grade-hint">未配置等级映射</div>
</template>
<template v-else-if="matchedGrade">
<div class="grade-name">{{ matchedGrade.grade_name }}</div>
<div class="grade-hint">
分值区间 {{ matchedGrade.min_score }} ~ {{ matchedGrade.max_score }}
</div>
</template>
<template v-else>
<div class="grade-name">—</div>
<div class="grade-hint">总分低于全部等级下限,未命中等级</div>
</template>
<div class="calc-result">
<div class="section-title">判定结果</div>
<div>
<span class="result-label">总分</span>
<b class="total-num">{{ totalScore }}</b>
<span class="total-max">/ 100</span>
</div>
<div>
<span class="result-label">评级等级</span>
<template v-if="grades.length === 0">
<div class="grade-name">—</div>
<div class="grade-hint">未配置等级映射</div>
</template>
<template v-else-if="matchedGrade">
<div class="grade-name">{{ matchedGrade.grade_name }}</div>
<div class="grade-hint">
分值区间 {{ matchedGrade.min_score }} ~ {{ matchedGrade.max_score }}
</div>
</template>
<template v-else>
<div class="grade-name">—</div>
<div class="grade-hint">总分低于全部等级下限,未命中等级</div>
</template>
</div>
</div>
</div>
</div>
</el-card>
</div>
<!-- 等级映射设置抽屉 -->
<el-drawer v-model="drawerVisible" title="等级映射设置" size="640px">
@ -346,22 +359,17 @@ onMounted(() => {
</template>
<style scoped>
/* 页面头(.page-head/.page-title/.page-desc/.waterline)、.card 与 .section-title
都由全局 index.css 提供,此处只保留本页特有的排版 */
.page-alert {
margin-bottom: 14px;
}
.page-toolbar {
/* 卡头右侧操作组:.card-head 已是 flex + justify-between,这里只补按钮之间的间距 */
.card-head__actions {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
gap: 12px;
flex-wrap: wrap;
}
.page-desc {
font-size: 13px;
color: var(--ys-ink-2);
gap: 10px;
}
.calc-grid {

View File

@ -242,108 +242,134 @@ onMounted(() => {
<template>
<div>
<el-card shadow="never">
<el-form class="filter-form" :inline="true" @submit.prevent>
<el-form-item label="业务">
<el-select
v-model="filters.business_id"
placeholder="全部"
clearable
filterable
style="width: 230px"
>
<el-option
v-for="item in businessOptions"
:key="item.id"
:label="businessLabel(item)"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="回款日期">
<el-date-picker
v-model="dateRange"
type="daterange"
value-format="YYYY-MM-DD"
range-separator="至"
start-placeholder="起"
end-placeholder="止"
style="width: 240px"
<!-- 页面头:标题 + 一句口径说明(回款是绩效源头)+ 签名水线 -->
<div class="page-head">
<h1 class="page-title">回款管理</h1>
<p class="page-desc">回款是绩效的源头:批次、到账日与产品类别决定计提口径</p>
<span class="waterline"></span>
</div>
<!--
.filter-bar 直接挂在 el-form 上(去掉 inline):el-form 不是 flex 透明容器,
外面再包一层 div 会让 el-form-item 丢掉 gap 排版,筛选条退化成竖排。
-->
<el-form class="filter-bar" @submit.prevent>
<el-form-item label="业务">
<el-select
v-model="filters.business_id"
placeholder="全部"
clearable
filterable
style="width: 230px"
>
<el-option
v-for="item in businessOptions"
:key="item.id"
:label="businessLabel(item)"
:value="item.id"
/>
</el-form-item>
<el-form-item label="以物抵顶">
<el-select v-model="filters.is_barter" placeholder="全部" clearable style="width: 120px">
<el-option
v-for="item in receiptApi.BARTER_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="核算状态">
<el-select
v-model="filters.settlement_status"
placeholder="全部"
clearable
style="width: 110px"
>
<el-option
v-for="item in receiptApi.SETTLEMENT_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</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-date-picker
v-model="dateRange"
type="daterange"
value-format="YYYY-MM-DD"
range-separator="至"
start-placeholder="起"
end-placeholder="止"
style="width: 240px"
/>
</el-form-item>
<el-form-item label="以物抵顶">
<el-select v-model="filters.is_barter" placeholder="全部" clearable style="width: 120px">
<el-option
v-for="item in receiptApi.BARTER_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="核算状态">
<el-select
v-model="filters.settlement_status"
placeholder="全部"
clearable
style="width: 110px"
>
<el-option
v-for="item in receiptApi.SETTLEMENT_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</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>
<el-table-column prop="receipt_batch_no" label="批次号" min-width="150" />
<el-table-column prop="business_no" label="业务编号" min-width="140">
<template #default="{ row }">{{ row.business_no }}</template>
<div class="toolbar">
<el-button type="primary" @click="openCreate">新增回款</el-button>
<span class="toolbar-note">已核算的回款不可变更;批次一旦指定,回款天数即按发货日派生</span>
</div>
<div class="card">
<el-table
v-loading="loading"
:data="records"
stripe
empty-text="还没有回款记录 —— 点「新增回款」登记第一笔,或换个业务再查"
>
<el-table-column label="批次号" min-width="150">
<template #default="{ row }">
<span class="cell-code">{{ row.receipt_batch_no }}</span>
</template>
</el-table-column>
<el-table-column label="业务编号" min-width="140">
<template #default="{ row }">
<span v-if="row.business_no" class="cell-code">{{ row.business_no }}</span>
<span v-else class="cell-muted">—</span>
</template>
</el-table-column>
<el-table-column label="发货单号" min-width="140">
<template #default="{ row }">{{ row.shipment_no ?? '—' }}</template>
<template #default="{ row }">
<span v-if="row.shipment_no" class="cell-code">{{ row.shipment_no }}</span>
<span v-else class="cell-muted">—</span>
</template>
</el-table-column>
<el-table-column prop="receipt_date" label="回款日期" min-width="110" />
<el-table-column label="金额(元)" min-width="120" align="right">
<template #default="{ row }">
<!-- 字符串直出:金额红线,禁 toFixed / float 格式化 -->
<span class="cell-amount">{{ row.amount }}</span>
<span class="cell-num">{{ row.amount }}</span>
</template>
</el-table-column>
<el-table-column label="以物抵顶" width="100" align="center">
<el-table-column label="以物抵顶" width="110" align="center">
<template #default="{ row }">
<el-tag v-if="row.is_barter === 1" type="warning" size="small" disable-transitions>
以物抵顶
</el-tag>
<!-- 以物抵顶影响折算比例,属需要一眼看见的分支,用 pill 而非普通文字 -->
<span v-if="row.is_barter === 1" class="ys-pill ys-pill--wait">以物抵顶</span>
<span v-else class="cell-muted">否</span>
</template>
</el-table-column>
<el-table-column label="回款天数" width="120" align="center">
<el-table-column label="回款天数" width="120" align="right">
<template #default="{ row }">
<span v-if="row.payment_days !== null" class="cell-amount">{{ row.payment_days }} 天</span>
<span v-if="row.payment_days !== null" class="cell-num">{{ row.payment_days }} 天</span>
<span v-else class="cell-muted">未指定批次</span>
</template>
</el-table-column>
<el-table-column label="核算状态" width="90" align="center">
<el-table-column label="核算状态" width="100" align="center">
<template #default="{ row }">
<el-tag
:type="row.settlement_status === 1 ? 'success' : 'info'"
size="small"
disable-transitions
<!-- 已核算=已确认(靛绿)、未核算=待处理(琥珀);已核算行禁改禁删 -->
<span
class="ys-pill"
:class="row.settlement_status === 1 ? 'ys-pill--ok' : 'ys-pill--wait'"
>
{{ row.settlement_status === 1 ? '已核算' : '未核算' }}
</el-tag>
</span>
</template>
</el-table-column>
<el-table-column label="操作" width="130" fixed="right" align="center">
@ -389,14 +415,17 @@ onMounted(() => {
</el-table-column>
</el-table>
<AppPagination
:total="total"
:page-num="pageNum"
:page-size="pageSize"
@page-change="onPageChange"
@size-change="onSizeChange"
/>
</el-card>
<!-- 分页落进 card-foot:与表体同一张卡,卡底微渐变收边 -->
<div class="card-foot">
<AppPagination
:total="total"
:page-num="pageNum"
:page-size="pageSize"
@page-change="onPageChange"
@size-change="onSizeChange"
/>
</div>
</div>
<!-- 新增 / 编辑(业务必选 + 发货批次级联指定,可清空=暂不指定) -->
<el-dialog
@ -474,16 +503,7 @@ 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-code / .cell-num / .cell-muted)由全局 index.css 提供,此处不再重复定义 */
.op-wrap {
display: inline-block;

View File

@ -273,49 +273,75 @@ onMounted(() => {
<template>
<div>
<el-card shadow="never">
<el-form class="filter-form" :inline="true" @submit.prevent>
<el-form-item label="业务">
<el-select
v-model="filters.business_id"
placeholder="全部"
clearable
filterable
style="width: 240px"
>
<el-option
v-for="item in businessOptions"
:key="item.id"
:label="businessLabel(item)"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="发货日期">
<el-date-picker
v-model="dateRange"
type="daterange"
value-format="YYYY-MM-DD"
range-separator="至"
start-placeholder="起"
end-placeholder="止"
style="width: 240px"
<!-- 页面头:标题 + 一句口径说明(发货日是回款天数的起算点)+ 签名水线 -->
<div class="page-head">
<h1 class="page-title">发货管理</h1>
<p class="page-desc">
发货单挂靠执行中的业务,发货日就是回款天数的起算点(发货日计第 1 天);批次与金额经回款指定后决定计提比例与绩效
</p>
<span class="waterline"></span>
</div>
<!--
.filter-bar 直接挂在 el-form 上(去掉 inline):el-form 不是 flex 透明容器,
外面再包一层 div 会让 el-form-item 丢掉 gap 排版,筛选条退化成竖排。
-->
<el-form class="filter-bar" @submit.prevent>
<el-form-item label="业务">
<el-select
v-model="filters.business_id"
placeholder="全部"
clearable
filterable
style="width: 240px"
>
<el-option
v-for="item in businessOptions"
:key="item.id"
:label="businessLabel(item)"
:value="item.id"
/>
</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-date-picker
v-model="dateRange"
type="daterange"
value-format="YYYY-MM-DD"
range-separator="至"
start-placeholder="起"
end-placeholder="止"
style="width: 240px"
/>
</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>
<el-table-column prop="shipment_no" label="发货单号" min-width="150" />
<el-table-column prop="business_no" label="业务编号" min-width="150">
<template #default="{ row }">{{ row.business_no ?? '—' }}</template>
<div class="toolbar">
<el-button type="primary" @click="openCreate">新增发货单</el-button>
<span class="toolbar-note">发货明细与运费在弹窗内维护,提交后自动回写业务发货额</span>
</div>
<div class="card">
<el-table
v-loading="loading"
:data="records"
stripe
empty-text="还没有发货单 —— 点「新增发货单」登记第一批,或换个业务再查"
>
<el-table-column label="发货单号" min-width="150">
<template #default="{ row }">
<span class="cell-code">{{ row.shipment_no }}</span>
</template>
</el-table-column>
<el-table-column label="业务编号" min-width="150">
<template #default="{ row }">
<span v-if="row.business_no" class="cell-code">{{ row.business_no }}</span>
<span v-else class="cell-muted">—</span>
</template>
</el-table-column>
<el-table-column label="发货日期" min-width="140">
<template #default="{ row }">
@ -329,12 +355,12 @@ onMounted(() => {
<el-table-column label="运费(元)" min-width="110" align="right">
<template #default="{ row }">
<!-- 字符串直出:金额红线,禁 toFixed / float 格式化 -->
<span class="cell-amount">{{ row.freight_amount }}</span>
<span class="cell-num">{{ row.freight_amount }}</span>
</template>
</el-table-column>
<el-table-column label="发货总额(元)" min-width="130" align="right">
<template #default="{ row }">
<span class="cell-amount">{{ row.total_amount }}</span>
<span class="cell-num">{{ row.total_amount }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="130" fixed="right" align="center">
@ -348,14 +374,17 @@ onMounted(() => {
</el-table-column>
</el-table>
<AppPagination
:total="total"
:page-num="pageNum"
:page-size="pageSize"
@page-change="onPageChange"
@size-change="onSizeChange"
/>
</el-card>
<!-- 分页落进 card-foot:与表体同一张卡,卡底微渐变收边 -->
<div class="card-foot">
<AppPagination
:total="total"
:page-num="pageNum"
:page-size="pageSize"
@page-change="onPageChange"
@size-change="onSizeChange"
/>
</div>
</div>
<!-- 新增 / 编辑(明细行编辑器;编辑不含 business_id,挂靠业务不可改) -->
<el-dialog
@ -450,16 +479,7 @@ onMounted(() => {
</template>
<style scoped>
.cell-amount {
font-family: 'SFMono-Regular', Consolas, 'Courier New', monospace;
font-size: 12px;
color: var(--ys-ink-2);
}
.cell-muted {
color: var(--ys-ink-3);
font-size: 12px;
}
/* 单元格口径(.cell-code / .cell-num / .cell-muted)由全局 index.css 提供,此处不再重复定义 */
.field-hint {
display: block;
@ -489,4 +509,5 @@ onMounted(() => {
.tail-form {
margin-top: 4px;
}
</style>

View File

@ -70,7 +70,20 @@ async function submitRecalc(): Promise<void> {
<template>
<div class="page">
<el-form inline class="filters" @submit.prevent>
<!-- 页面头:标题 + 一句口径说明(台账是核算事实的固化点)+ 签名水线 -->
<div class="page-head">
<h1 class="page-title">绩效台账</h1>
<p class="page-desc">
每笔回款只核算一次;重算作废旧版本、按当前参数落新版本并留痕,展开行可看当次核算的参数快照
</p>
<span class="waterline"></span>
</div>
<!--
.filter-bar 直接挂在 el-form 上(去掉 inline):el-form 不是 flex 透明容器,
外面再包一层 div 会让 el-form-item 丢掉 gap 排版,筛选条退化成竖排。
-->
<el-form class="filter-bar" @submit.prevent>
<el-form-item label="归属月">
<el-input
v-model="filters.settlement_month"
@ -93,57 +106,90 @@ async function submitRecalc(): Promise<void> {
<el-option label="仅历史版本" :value="0" />
</el-select>
</el-form-item>
<el-form-item>
<div class="filter-bar__actions">
<el-button type="primary" @click="search">查询</el-button>
<el-button @click="resetFilters">重置</el-button>
</el-form-item>
</div>
</el-form>
<el-table v-loading="loading" :data="records" stripe>
<el-table-column type="expand">
<template #default="{ row }">
<pre class="snapshot">{{ JSON.stringify(row.snapshot_json ?? {}, null, 2) }}</pre>
</template>
</el-table-column>
<el-table-column prop="settlement_month" label="归属月" width="90" />
<el-table-column prop="business_no" label="业务编号" min-width="150" />
<el-table-column prop="personnel_name" label="归属人员" width="100" />
<el-table-column prop="role_code" label="岗位" width="110" />
<el-table-column prop="receipt_date" label="回款日" width="100" />
<el-table-column prop="payment_days" label="天数" width="70" />
<el-table-column prop="rate_text" label="计提比例" min-width="90" />
<el-table-column prop="converted_profit" label="折算毛利" min-width="100" />
<el-table-column prop="performance_amount" label="绩效金额" min-width="100" />
<el-table-column prop="payable_amount" label="应付绩效" min-width="100" />
<el-table-column label="版本" width="80">
<template #default="{ row }">
v{{ row.calc_version }}
<el-tag v-if="row.is_active === 0" size="small" type="info" disable-transitions>
已替代
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="90" fixed="right" align="center">
<template #default="{ row }">
<el-button
v-if="row.is_active === 1 && row.calc_type === 1"
link
type="primary"
@click="openRecalc(row)"
>
重算
</el-button>
</template>
</el-table-column>
</el-table>
<div class="card">
<el-table
v-loading="loading"
:data="records"
stripe
empty-text="台账还没有核算行 —— 换个归属月看看,或先在回款管理里登记回款后再核算"
>
<el-table-column type="expand">
<template #default="{ row }">
<pre class="snapshot">{{ JSON.stringify(row.snapshot_json ?? {}, null, 2) }}</pre>
</template>
</el-table-column>
<el-table-column prop="settlement_month" label="归属月" width="90" />
<el-table-column label="业务编号" min-width="150">
<template #default="{ row }">
<span class="cell-code">{{ row.business_no }}</span>
</template>
</el-table-column>
<el-table-column prop="personnel_name" label="归属人员" width="100" />
<el-table-column prop="role_code" label="岗位" width="110" />
<el-table-column prop="receipt_date" label="回款日" width="100" />
<el-table-column label="天数" width="70" align="right">
<template #default="{ row }">
<span class="cell-num">{{ row.payment_days }}</span>
</template>
</el-table-column>
<el-table-column label="计提比例" min-width="90" align="right">
<template #default="{ row }">
<span class="cell-num">{{ row.rate_text }}</span>
</template>
</el-table-column>
<el-table-column label="折算毛利" min-width="100" align="right">
<template #default="{ row }">
<span class="cell-num">{{ row.converted_profit }}</span>
</template>
</el-table-column>
<el-table-column label="绩效金额" min-width="100" align="right">
<template #default="{ row }">
<span class="cell-num">{{ row.performance_amount }}</span>
</template>
</el-table-column>
<el-table-column label="应付绩效" min-width="100" align="right">
<template #default="{ row }">
<span class="cell-num">{{ row.payable_amount }}</span>
</template>
</el-table-column>
<el-table-column label="版本" width="100">
<template #default="{ row }">
<span class="cell-code">v{{ row.calc_version }}</span>
<!-- 历史版本用中性 pill:被替代不是异常,只是不再是生效版本 -->
<span v-if="row.is_active === 0" class="ys-pill">已替代</span>
</template>
</el-table-column>
<el-table-column label="操作" width="90" fixed="right" align="center">
<template #default="{ row }">
<el-button
v-if="row.is_active === 1 && row.calc_type === 1"
link
type="primary"
@click="openRecalc(row)"
>
重算
</el-button>
</template>
</el-table-column>
</el-table>
<AppPagination
:total="total"
:page-num="pageNum"
:page-size="pageSize"
@page-change="onPageChange"
@size-change="onSizeChange"
/>
<!-- 分页落进 card-foot:与表体同一张卡,卡底微渐变收边 -->
<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="recalcVisible" title="重算确认" width="460px">
<p class="recalc-warn">
@ -166,9 +212,8 @@ async function submitRecalc(): Promise<void> {
</template>
<style scoped>
.filters {
margin-bottom: 4px;
}
/* 筛选条(.filter-bar)、卡片与表尾(.card/.card-foot)的皮肤来自全局 index.css,
此处只保留本页特有的快照块与重算提示 */
.snapshot {
margin: 0;
@ -186,4 +231,5 @@ async function submitRecalc(): Promise<void> {
font-size: 12px;
color: var(--ys-ink-3);
}
</style>

View File

@ -83,23 +83,53 @@ function renderCharts(): void {
}
}
/**
* 图表轴:设计稿 §05 的口径 —— 轴标签 9px 弱墨、无 y 网格线、无刻度,
* 只留一条极淡的 x 轴线。字号必须显式给,ECharts 默认 12px 会把
* 150px 高的图压满标签。
*/
function axisCommon(trend: TrendPoint[]) {
return {
xAxis: {
type: 'category' as const,
data: trend.map((item) => item.month.slice(5)),
axisLine: { lineStyle: { color: 'rgba(28,35,51,.15)' } },
axisTick: { show: false },
axisLabel: { fontFamily: CHART_FONT, color: '#9aa3b2', fontSize: 9, margin: 6 },
},
yAxis: {
type: 'value' as const,
splitLine: { show: false },
axisLabel: { fontFamily: CHART_FONT, color: '#9aa3b2', fontSize: 9, margin: 6 },
},
}
}
/**
* 竖向渐变(从上到下),设计稿要求柱体与折线面积都用「上亮下暗」的品牌渐变。
* color-mix 与 canvas 渐变对象在 SVG renderer 下表现一致,故直接用 echarts 的
* linear 渐变描述对象,不依赖 canvas API。
*/
function verticalGradient(top: string, bottom: string) {
return {
type: 'linear' as const,
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: top },
{ offset: 1, color: bottom },
],
}
}
function performanceOption(trend: TrendPoint[]): EChartsCoreOption {
return {
textStyle: { fontFamily: CHART_FONT },
grid: { left: 8, right: 12, top: 18, bottom: 4, containLabel: true },
tooltip: { trigger: 'axis', valueFormatter: (value: unknown) => Number(value).toFixed(2) },
xAxis: {
type: 'category',
data: trend.map((item) => item.month.slice(5)),
axisLine: { lineStyle: { color: 'rgba(28,35,51,.15)' } },
axisTick: { show: false },
axisLabel: { fontFamily: CHART_FONT, color: '#9aa3b2' },
},
yAxis: {
type: 'value',
splitLine: { show: false },
axisLabel: { fontFamily: CHART_FONT, color: '#9aa3b2' },
},
...axisCommon(trend),
series: [
{
name: '绩效',
@ -108,48 +138,35 @@ function performanceOption(trend: TrendPoint[]): EChartsCoreOption {
symbol: 'none',
data: trend.map((item) => Number(item.performance_total ?? 0)),
lineStyle: { color: '#2e4ba8', width: 2 },
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: 'rgba(46,75,168,.22)' },
{ offset: 1, color: 'rgba(46,75,168,0)' },
],
},
},
areaStyle: { color: verticalGradient('rgba(46,75,168,.22)', 'rgba(46,75,168,0)') },
},
],
}
}
function receiptOption(trend: TrendPoint[]): EChartsCoreOption {
// 最近一个月用饱和靛蓝强调,其余压成浅靛蓝:一眼就能读出「本月」在哪
const lastIndex = trend.length - 1
return {
textStyle: { fontFamily: CHART_FONT },
grid: { left: 8, right: 12, top: 18, bottom: 4, containLabel: true },
tooltip: { trigger: 'axis', valueFormatter: (value: unknown) => Number(value).toFixed(2) },
xAxis: {
type: 'category',
data: trend.map((item) => item.month.slice(5)),
axisLine: { lineStyle: { color: 'rgba(28,35,51,.15)' } },
axisTick: { show: false },
axisLabel: { fontFamily: CHART_FONT, color: '#9aa3b2' },
},
yAxis: {
type: 'value',
splitLine: { show: false },
axisLabel: { fontFamily: CHART_FONT, color: '#9aa3b2' },
},
...axisCommon(trend),
series: [
{
name: '回款',
type: 'bar',
data: trend.map((item) => Number(item.receipt_total ?? 0)),
barMaxWidth: 22,
itemStyle: { color: '#2e4ba8', borderRadius: [4, 4, 0, 0] },
data: trend.map((item, index) => ({
value: Number(item.receipt_total ?? 0),
itemStyle: {
color:
index === lastIndex
? verticalGradient('#3a58bc', '#2e4ba8')
: verticalGradient('#d5def3', '#e8edf8'),
borderRadius: [3, 3, 0, 0],
},
})),
},
],
}
@ -383,42 +400,7 @@ function formatTime(value: string): string {
</template>
<style scoped>
.page-head {
margin-bottom: 18px;
}
.page-title {
margin: 0;
font-size: 22px;
font-weight: 600;
color: var(--ys-ink);
}
.page-desc {
margin: 6px 0 0;
font-size: 13px;
color: var(--ys-ink-3);
}
.waterline {
display: block;
width: 46px;
height: 2px;
margin-top: 12px;
background: linear-gradient(90deg, var(--ys-indigo), rgba(46, 75, 168, 0));
}
.toolbar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 14px;
}
.toolbar-note {
font-size: 12px;
color: var(--ys-ink-3);
}
/* 页面头(.page-head/.page-title/.page-desc/.waterline)、.toolbar 均来自全局 index.css */
.bento {
display: grid;
@ -430,21 +412,49 @@ function formatTime(value: string): string {
.bento-card {
position: relative;
padding: 16px 18px;
background: linear-gradient(180deg, #ffffff, #fcfcfe);
border: 1px solid var(--ys-line);
border-radius: 10px;
box-shadow:
0 1px 2px rgba(28, 35, 51, 0.04),
0 10px 24px -18px rgba(28, 35, 51, 0.18);
transition: transform 0.18s ease, box-shadow 0.18s ease;
overflow: hidden;
background: linear-gradient(180deg, var(--ys-paper), #fcfcfe);
border: 1px solid var(--ys-line);
border-radius: var(--ys-radius);
box-shadow: var(--ys-shadow-card);
transition:
transform 180ms var(--ys-ease),
box-shadow 180ms var(--ys-ease);
/* 交错入场:45ms 一檔,卡片依次上浮,避免整屏同时闪一下 */
animation: ys-rise 320ms var(--ys-ease) backwards;
}
.bento > :nth-child(2) {
animation-delay: 45ms;
}
.bento > :nth-child(3) {
animation-delay: 90ms;
}
.bento > :nth-child(4) {
animation-delay: 135ms;
}
.bento > :nth-child(5) {
animation-delay: 180ms;
}
.bento > :nth-child(6) {
animation-delay: 225ms;
}
.bento > :nth-child(7) {
animation-delay: 270ms;
}
.bento > :nth-child(8) {
animation-delay: 315ms;
}
.bento-card:hover {
transform: translateY(-2px);
box-shadow:
0 2px 4px rgba(28, 35, 51, 0.05),
0 16px 30px -18px rgba(28, 35, 51, 0.22);
box-shadow: var(--ys-shadow-raise);
}
.b-2x2 {
@ -583,7 +593,7 @@ function formatTime(value: string): string {
}
.activity-time {
width: 118px;
width: 108px;
color: var(--ys-ink-3);
font-variant-numeric: tabular-nums;
}
@ -608,14 +618,23 @@ function formatTime(value: string): string {
padding: 6px 14px;
font-size: 13px;
color: var(--ys-indigo);
cursor: pointer;
background: var(--ys-indigo-wash);
border: 1px solid transparent;
border-radius: 6px;
cursor: pointer;
border-radius: var(--ys-radius-sm);
transition:
border-color 150ms var(--ys-ease),
background-color 150ms var(--ys-ease);
}
.b-op:hover {
border-color: var(--ys-indigo);
background: #e6ecf9;
border-color: rgba(46, 75, 168, 0.35);
}
.b-op:focus-visible {
outline: 2px solid var(--ys-indigo);
outline-offset: 2px;
}
@media (max-width: 1024px) {

View File

@ -217,11 +217,11 @@ onMounted(() => {
position: absolute;
top: -180px;
left: 50%;
width: 900px;
height: 480px;
width: 860px;
height: 460px;
transform: translateX(-50%);
pointer-events: none;
background: radial-gradient(closest-side, rgba(46, 75, 168, 0.09), rgba(46, 75, 168, 0));
background: radial-gradient(closest-side, rgba(46, 75, 168, 0.08), rgba(46, 75, 168, 0));
}
/* 右上涟漪:同心圆水波,部分出画 */
@ -229,8 +229,8 @@ onMounted(() => {
position: absolute;
top: -190px;
right: -150px;
width: 560px;
height: 560px;
width: 520px;
height: 520px;
pointer-events: none;
}
@ -242,17 +242,17 @@ onMounted(() => {
}
.login-ripple i:nth-child(2) {
inset: 70px;
inset: 65px;
border-color: rgba(46, 75, 168, 0.075);
}
.login-ripple i:nth-child(3) {
inset: 140px;
inset: 130px;
border-color: rgba(46, 75, 168, 0.055);
}
.login-ripple i:nth-child(4) {
inset: 210px;
inset: 195px;
border: none;
background: radial-gradient(circle, rgba(46, 75, 168, 0.05), rgba(46, 75, 168, 0.015));
}
@ -266,6 +266,12 @@ onMounted(() => {
/* 登录卡:纸白微渐变 + 半透明描边 + 分层阴影 + 入场上浮 */
.login-card {
/*
* 卡内控件比列表页高一档(38px):登录是单任务场景,输入框更宽敞可减少误触;
* 覆盖组件基准变量,让输入框/按钮/验证码框一起长高,避免逐个子选择器硬编码。
*/
--el-component-size: 38px;
width: 400px;
max-width: 100%;
padding: 30px 36px 32px;
@ -307,20 +313,16 @@ onMounted(() => {
}
.card-brand__slogan {
margin-top: 8px;
margin: 8px 0 0;
font-size: 12.5px;
letter-spacing: 0.02em;
color: var(--ys-ink-3);
}
/* 签名元素:渐隐靛蓝水线 */
/* 签名元素:渐变来自全局 .waterline,此处只收窄到 40px 并水平居中 */
.waterline {
display: block;
width: 40px;
height: 2px;
margin: 14px auto 0;
border-radius: 1px;
background: linear-gradient(90deg, var(--ys-indigo), rgba(46, 75, 168, 0));
}
.card-head {
@ -361,14 +363,14 @@ onMounted(() => {
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 120px;
height: 40px;
width: 104px;
height: 38px;
padding: 0;
overflow: hidden;
cursor: pointer;
background: linear-gradient(180deg, #f7f8fb, #f1f3f8);
border: 1px solid var(--ys-line);
border-radius: 7px;
border-radius: var(--ys-radius-sm);
box-shadow: inset 0 1px 2px rgba(28, 35, 51, 0.04);
transition: border-color 150ms var(--ys-ease, ease);
}
@ -405,16 +407,7 @@ onMounted(() => {
color: var(--ys-ink-3);
}
/* 输入聚焦落在体系主色上(Element Plus 主色已随全局 token 换为靛蓝) */
.login :deep(.el-input__wrapper) {
box-shadow: 0 0 0 1px rgba(28, 35, 51, 0.13) inset;
}
.login :deep(.el-input__wrapper.is-focus) {
box-shadow:
0 0 0 1px var(--ys-indigo) inset,
0 0 0 3px rgba(46, 75, 168, 0.13);
}
/* 输入框的描边/焦点环已由全局 index.css 统一(登录卡只把控件高度调到 38px) */
@media (max-width: 640px) {
.login {

View File

@ -155,7 +155,20 @@ function today(): string {
<template>
<div class="page">
<el-form inline class="filters" @submit.prevent>
<!-- 页面头:一句话说清这张报表回答什么问题、口径是什么;水线每页只出现一次 -->
<div class="page-head">
<h1 class="page-title">业务统计</h1>
<p class="page-desc">
按业务看发货、回款与绩效的累计进度;回款与绩效是累计值,不随业务日期筛选变化
</p>
<span class="waterline"></span>
</div>
<!--
.filter-bar 直接挂在 el-form 上(去掉 inline):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"
@ -218,103 +231,151 @@ function today(): string {
style="width: 240px"
/>
</el-form-item>
<el-form-item>
<div class="filter-bar__actions">
<el-button type="primary" @click="search">查询</el-button>
<el-button @click="resetFilters">重置</el-button>
<el-button :loading="exporting" @click="doExport">导出 Excel</el-button>
<el-button @click="doPrint">打印</el-button>
</el-form-item>
</div>
</el-form>
<div class="hint">回款额与绩效为业务累计值,不随业务日期筛选变化</div>
<!-- 表格包进卡片:报表页的信息密度靠卡片边界收拢,分页条共用卡脚材质 -->
<div class="card">
<el-table
v-loading="loading"
class="dense"
:data="records"
stripe
empty-text="还没有符合条件的业务 —— 先清空筛选看全量,或点「重置」重来"
@expand-change="loadDrill"
>
<el-table-column type="expand">
<template #default="{ row }">
<div class="drill-wrap">
<p class="section-title">回款明细</p>
<el-table :data="receiptsMap[row.business_id] ?? []" size="small" stripe>
<el-table-column prop="receipt_batch_no" label="回款批次号" width="160" />
<el-table-column prop="receipt_date" label="回款日" width="110" />
<el-table-column prop="amount" label="回款金额" width="120" align="right" />
<el-table-column prop="settlement_status" label="入结状态" width="100" />
<el-table-column prop="remark" label="备注" min-width="140" />
</el-table>
<p class="section-title">台账明细</p>
<el-table :data="ledgersMap[row.business_id] ?? []" size="small" stripe>
<el-table-column prop="personnel_name" label="归属人员" width="120" />
<el-table-column prop="receipt_date" label="回款日" width="110" />
<el-table-column prop="payment_days" label="天数" width="80" align="right" />
<el-table-column prop="rate_text" label="计提比例" width="110" />
<el-table-column
prop="performance_amount"
label="绩效金额"
width="120"
align="right"
/>
<el-table-column prop="payable_amount" label="应付绩效" width="120" align="right" />
</el-table>
</div>
</template>
</el-table-column>
<el-table-column prop="business_no" label="业务编号" min-width="160">
<template #default="{ row }">
<span class="cell-code">{{ row.business_no }}</span>
</template>
</el-table-column>
<el-table-column prop="business_date" label="业务日期" width="110" />
<el-table-column prop="customer_name" label="客户" min-width="170" />
<el-table-column prop="owner_personnel_name" label="归属人" width="110" />
<el-table-column label="状态" width="100">
<template #default="{ row }">{{ businessStatusLabel(row.status) }}</template>
</el-table-column>
<el-table-column prop="total_sale_amount" label="销售额" width="120" align="right">
<template #default="{ row }">
<span class="cell-num">{{ row.total_sale_amount }}</span>
</template>
</el-table-column>
<el-table-column prop="total_ship_amount" label="发货额" width="120" align="right">
<template #default="{ row }">
<span class="cell-num">{{ row.total_ship_amount }}</span>
</template>
</el-table-column>
<el-table-column prop="total_received_amount" label="回款额" width="120" align="right">
<template #default="{ row }">
<span class="cell-num">{{ row.total_received_amount }}</span>
</template>
</el-table-column>
<el-table-column prop="performance_amount" label="绩效金额" width="120" align="right">
<template #default="{ row }">
<!-- 绩效列加粗:一屏几十行时,加粗是读者定位「钱」的唯一线索 -->
<span class="cell-num cell-strong">{{ row.performance_amount }}</span>
</template>
</el-table-column>
<el-table-column prop="payable_amount" label="应付绩效" width="120" align="right">
<template #default="{ row }">
<span class="cell-num">{{ row.payable_amount }}</span>
</template>
</el-table-column>
</el-table>
<el-table v-loading="loading" :data="records" stripe @expand-change="loadDrill">
<el-table-column type="expand">
<template #default="{ row }">
<div class="drill-wrap">
<h4>回款明细</h4>
<el-table :data="receiptsMap[row.business_id] ?? []" size="small" stripe>
<el-table-column prop="receipt_batch_no" label="回款批次号" width="160" />
<el-table-column prop="receipt_date" label="回款日" width="110" />
<el-table-column prop="amount" label="回款金额" width="120" align="right" />
<el-table-column prop="settlement_status" label="入结状态" width="100" />
<el-table-column prop="remark" label="备注" min-width="140" />
</el-table>
<h4>台账明细</h4>
<el-table :data="ledgersMap[row.business_id] ?? []" size="small" stripe>
<el-table-column prop="personnel_name" label="归属人员" width="120" />
<el-table-column prop="receipt_date" label="回款日" width="110" />
<el-table-column prop="payment_days" label="天数" width="80" align="right" />
<el-table-column prop="rate_text" label="计提比例" width="110" />
<el-table-column prop="performance_amount" label="绩效金额" width="120" align="right" />
<el-table-column prop="payable_amount" label="应付绩效" width="120" align="right" />
</el-table>
</div>
</template>
</el-table-column>
<el-table-column prop="business_no" label="业务编号" min-width="160" />
<el-table-column prop="business_date" label="业务日期" width="110" />
<el-table-column prop="customer_name" label="客户" min-width="170" />
<el-table-column prop="owner_personnel_name" label="归属人" width="110" />
<el-table-column label="状态" width="100">
<template #default="{ row }">{{ businessStatusLabel(row.status) }}</template>
</el-table-column>
<el-table-column prop="total_sale_amount" label="销售额" width="120" align="right" />
<el-table-column prop="total_ship_amount" label="发货额" width="120" align="right" />
<el-table-column prop="total_received_amount" label="回款额" width="120" align="right" />
<el-table-column prop="performance_amount" label="绩效金额" width="120" align="right" />
<el-table-column prop="payable_amount" label="应付绩效" width="120" align="right" />
</el-table>
<!-- 合计与分页共用卡脚:合计靠 margin-right:auto 占到左侧,分页恒靠右 -->
<div class="card-foot">
<div v-if="summary" class="summary-bar">
全部筛选结果:{{ summary.row_count }} 笔业务 | 发货合计
{{ summary.ship_total ?? '0.00' }} | 回款合计 {{ summary.received_total ?? '0.00' }} |
绩效合计 {{ summary.performance_total ?? '0.00' }}
<span class="summary-note">(合计为全部筛选结果,非当前页)</span>
</div>
<div v-if="summary" class="summary-bar">
全部筛选结果:{{ summary.row_count }} 笔业务 | 发货合计 {{ summary.ship_total ?? '0.00' }}
| 回款合计 {{ summary.received_total ?? '0.00' }} | 绩效合计
{{ summary.performance_total ?? '0.00' }}
<span class="summary-note">(合计为全部筛选结果,非当前页)</span>
<!-- AppPagination 只派发 page-change/size-change(无 update:pageNum) -->
<AppPagination
:total="total"
:page-num="pageNum"
:page-size="pageSize"
@page-change="onPageChange"
@size-change="onSizeChange"
/>
</div>
</div>
<!-- AppPagination 只派发 page-change/size-change(无 update:pageNum) -->
<AppPagination
:total="total"
:page-num="pageNum"
:page-size="pageSize"
@page-change="onPageChange"
@size-change="onSizeChange"
/>
</div>
</template>
<style scoped>
.filters {
margin-bottom: 4px;
/*
* 密集表(设计稿 §09-3):报表要的是信息密度,行高与字号各降半档。
* 只写在页面 scoped 里 —— 写进全局会波及其它列表页的行高。
*/
.dense :deep(.el-table__cell) {
padding: 7px 0;
}
.hint {
margin-bottom: 8px;
font-size: 12px;
color: var(--ys-ink-3);
.dense :deep(.el-table__cell .cell) {
font-size: 12.5px;
}
.drill-wrap {
padding: 8px 16px;
}
.drill-wrap h4 {
margin: 8px 0;
font-size: 13px;
/* 绩效列加粗:设计稿要求「密集不等于看不清」 */
.cell-strong {
font-weight: 600;
}
/* 展开区:两块下钻表跟着密集表收内距,字号由 size="small" 与 .dense 决定 */
.drill-wrap {
padding: 10px 14px 6px;
}
.drill-wrap .section-title {
margin-top: 8px;
}
/* 合计条:比正文加粗、字号放大半档,读者一眼能把「合计」与数据行分开 */
.summary-bar {
margin-top: 10px;
padding: 8px 12px;
font-size: 13px;
background: var(--ys-indigo-wash);
border-radius: 6px;
margin-right: auto;
font-size: 13.5px;
font-weight: 600;
color: var(--ys-ink);
}
.summary-note {
color: var(--ys-ink-3);
font-size: 12px;
font-weight: 400;
color: var(--ys-ink-3);
}
</style>

View File

@ -135,7 +135,20 @@ function pct(value: string): string {
<template>
<div class="page">
<el-form inline class="filters" @submit.prevent>
<!-- 页面头:一句话说清这张报表回答什么问题、口径是什么;水线每页只出现一次 -->
<div class="page-head">
<h1 class="page-title">部门绩效汇总</h1>
<p class="page-desc">
按经理团队汇总当月绩效与应付;团队/全公司分成为月结同源口径,可与冻结额对账
</p>
<span class="waterline"></span>
</div>
<!--
.filter-bar 直接挂在 el-form 上(去掉 inline):el-form 本身就是 flex 容器,
外面再包一层 div 会让 el-form-item 的 gap 排版失效。
-->
<el-form class="filter-bar" @submit.prevent>
<el-form-item label="统计月份" required>
<el-date-picker
v-model="filters.settlement_month"
@ -164,17 +177,20 @@ function pct(value: string): string {
/>
</el-select>
</el-form-item>
<el-form-item>
<div class="filter-bar__actions">
<el-button type="primary" :loading="loading" @click="load">查询</el-button>
<el-button :loading="exporting" @click="doExport">导出 Excel</el-button>
<el-button @click="doPrint">打印</el-button>
</el-form-item>
</div>
</el-form>
<div v-if="data?.params" class="params-hint">
分成参数:团队 {{ data.params.team_text }}% | 全公司 {{ data.params.company_text }}% |
经理个人户{{ data.params.manager_personal_included === 1 ? '计入' : '不计入' }}基数 |
取值时点 {{ data.params.params_as_of }}
<!-- 参数口径行:动态值,不能并进页面描述(描述是稳定的,参数随在线配置变) -->
<div v-if="data?.params" class="toolbar">
<span class="toolbar-note">
分成参数:团队 {{ data.params.team_text }}% | 全公司 {{ data.params.company_text }}% |
经理个人户{{ data.params.manager_personal_included === 1 ? '计入' : '不计入' }}基数 |
取值时点 {{ data.params.params_as_of }}
</span>
</div>
<div v-loading="loading" class="company-card">
@ -191,83 +207,149 @@ function pct(value: string): string {
</span>
</div>
<el-table v-loading="loading" :data="data?.teams ?? []" row-key="manager_id" stripe>
<el-table-column type="expand">
<template #default="{ row }">
<div class="member-wrap">
<el-table :data="row.members" size="small" stripe>
<el-table-column prop="personnel_name" label="成员" width="120" />
<el-table-column prop="role_code" label="岗位" width="120" />
<el-table-column prop="performance_sum" label="绩效金额" width="120" align="right" />
<el-table-column prop="payable_sum" label="应付绩效" width="120" align="right" />
<el-table-column label="调整补发" width="110" align="right">
<template #default="{ row: member }">{{ member.adjust_extra }}</template>
</el-table-column>
<el-table-column label="调整扣减" width="110" align="right">
<template #default="{ row: member }">{{ member.adjust_deduct }}</template>
</el-table-column>
<el-table-column prop="net_total" label="净合计" width="120" align="right" />
<el-table-column label="操作" width="100" fixed="right">
<template #default="{ row: member }">
<el-button link type="primary" @click="openPersonalDetail(member)">
查看明细
</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-if="row.members.length === 0" description="该经理本月无团队成员业绩" />
</div>
</template>
</el-table-column>
<el-table-column prop="manager_name" label="经理团队" width="130">
<template #default="{ row }">
{{ row.manager_name }}
<el-tag v-if="row.manager_status === 0" size="small" type="info" disable-transitions>
离职
</el-tag>
</template>
</el-table-column>
<el-table-column prop="member_count" label="团队人数" width="90" align="right" />
<el-table-column prop="team_base" label="团队基数" width="120" align="right" />
<el-table-column prop="team_share_amount" :label="teamShareLabel" width="150" align="right" />
<el-table-column prop="company_share_amount" :label="companyShareLabel" width="150" align="right" />
<el-table-column label="占全公司比" width="110" align="right">
<template #default="{ row }">{{ pct(row.company_base_ratio) }}</template>
</el-table-column>
<el-table-column prop="adjust_net" label="调整净额" width="110" align="right" />
<el-table-column prop="net_total" label="净合计" width="120" align="right" />
<el-table-column label="月结冻结额" width="130" align="right">
<template #default="{ row }">
<span v-if="row.frozen_amount !== null" class="frozen">{{ row.frozen_amount }}</span>
<span v-else class="frozen-none">未生成月结</span>
</template>
</el-table-column>
</el-table>
<div class="card">
<el-table
v-loading="loading"
class="dense"
:data="data?.teams ?? []"
row-key="manager_id"
stripe
empty-text="还没有这个月的团队绩效 —— 先确认统计月份,再点查询"
>
<el-table-column type="expand">
<template #default="{ row }">
<div class="member-wrap">
<el-table :data="row.members" size="small" stripe>
<el-table-column prop="personnel_name" label="成员" width="120" />
<el-table-column prop="role_code" label="岗位" width="120" />
<el-table-column prop="performance_sum" label="绩效金额" width="120" align="right">
<template #default="{ row: member }">
<span class="cell-num cell-strong">{{ member.performance_sum }}</span>
</template>
</el-table-column>
<el-table-column prop="payable_sum" label="应付绩效" width="120" align="right">
<template #default="{ row: member }">
<span class="cell-num">{{ member.payable_sum }}</span>
</template>
</el-table-column>
<el-table-column label="调整补发" width="110" align="right">
<template #default="{ row: member }">
<span class="cell-num">{{ member.adjust_extra }}</span>
</template>
</el-table-column>
<el-table-column label="调整扣减" width="110" align="right">
<template #default="{ row: member }">
<span class="cell-num">{{ member.adjust_deduct }}</span>
</template>
</el-table-column>
<el-table-column prop="net_total" label="净合计" width="120" align="right">
<template #default="{ row: member }">
<span class="cell-num">{{ member.net_total }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="100" fixed="right">
<template #default="{ row: member }">
<el-button link type="primary" @click="openPersonalDetail(member)">
查看明细
</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-if="row.members.length === 0" description="该经理本月无团队成员业绩" />
</div>
</template>
</el-table-column>
<el-table-column prop="manager_name" label="经理团队" width="130">
<template #default="{ row }">
{{ row.manager_name }}
<el-tag v-if="row.manager_status === 0" size="small" type="info" disable-transitions>
离职
</el-tag>
</template>
</el-table-column>
<el-table-column prop="member_count" label="团队人数" width="90" align="right">
<template #default="{ row }">
<span class="cell-num">{{ row.member_count }}</span>
</template>
</el-table-column>
<el-table-column prop="team_base" label="团队基数" width="120" align="right">
<template #default="{ row }">
<span class="cell-num">{{ row.team_base }}</span>
</template>
</el-table-column>
<el-table-column :label="teamShareLabel" width="150" align="right">
<template #default="{ row }">
<span class="cell-num">{{ row.team_share_amount }}</span>
</template>
</el-table-column>
<el-table-column :label="companyShareLabel" width="150" align="right">
<template #default="{ row }">
<span class="cell-num">{{ row.company_share_amount }}</span>
</template>
</el-table-column>
<el-table-column label="占全公司比" width="110" align="right">
<template #default="{ row }">
<span class="cell-num">{{ pct(row.company_base_ratio) }}</span>
</template>
</el-table-column>
<el-table-column prop="adjust_net" label="调整净额" width="110" align="right">
<template #default="{ row }">
<span class="cell-num">{{ row.adjust_net }}</span>
</template>
</el-table-column>
<!-- 本表没有单列绩效列,「钱」的落点就是净合计:按设计稿对绩效列的加粗处理 -->
<el-table-column prop="net_total" label="净合计" width="120" align="right">
<template #default="{ row }">
<span class="cell-num cell-strong">{{ row.net_total }}</span>
</template>
</el-table-column>
<el-table-column label="月结冻结额" width="130" align="right">
<template #default="{ row }">
<span v-if="row.frozen_amount !== null" class="cell-num frozen">
{{ row.frozen_amount }}
</span>
<span v-else class="cell-num frozen-none">未生成月结</span>
</template>
</el-table-column>
</el-table>
</div>
<div v-if="data && data.unassigned.length" class="unassigned">
<h4>未纳入团队基数(无经理 / 非营销经理上级的下属)</h4>
<p class="section-title">未纳入团队基数(无经理 / 非营销经理上级的下属)</p>
<el-table :data="data.unassigned" size="small" stripe>
<el-table-column prop="personnel_name" label="人员" width="130" />
<el-table-column prop="role_code" label="岗位" width="120" />
<el-table-column prop="performance_sum" label="绩效金额" width="130" align="right" />
<el-table-column prop="net_total" label="净合计" width="130" align="right" />
<el-table-column prop="performance_sum" label="绩效金额" width="130" align="right">
<template #default="{ row }">
<span class="cell-num">{{ row.performance_sum }}</span>
</template>
</el-table-column>
<el-table-column prop="net_total" label="净合计" width="130" align="right">
<template #default="{ row }">
<span class="cell-num">{{ row.net_total }}</span>
</template>
</el-table-column>
</el-table>
</div>
</div>
</template>
<style scoped>
.filters {
margin-bottom: 4px;
/*
* 密集表(设计稿 §09-3):报表要的是信息密度,行高与字号各降半档。
* 只写在页面 scoped 里 —— 写进全局会波及其它列表页的行高。
*/
.dense :deep(.el-table__cell) {
padding: 7px 0;
}
.params-hint {
margin-bottom: 10px;
padding: 8px 12px;
font-size: 12px;
color: var(--ys-ink-2);
background: var(--ys-mist);
border-radius: 6px;
.dense :deep(.el-table__cell .cell) {
font-size: 12.5px;
}
/* 绩效列加粗:设计稿要求「密集不等于看不清」 */
.cell-strong {
font-weight: 600;
}
.company-card {
@ -277,7 +359,8 @@ function pct(value: string): string {
margin-bottom: 12px;
padding: 12px 16px;
background: var(--ys-indigo-wash);
border-radius: 8px;
border: 1px solid var(--ys-line);
border-radius: var(--ys-radius);
font-size: 13px;
}
@ -290,8 +373,9 @@ function pct(value: string): string {
font-size: 12px;
}
/* 展开区:内容字号与内距跟着密集表再收半档,不与主表抢注意力 */
.member-wrap {
padding: 8px 16px;
padding: 8px 14px;
}
.frozen {
@ -306,10 +390,4 @@ function pct(value: string): string {
.unassigned {
margin-top: 20px;
}
.unassigned h4 {
margin: 0 0 8px;
font-size: 14px;
font-weight: 600;
}
</style>

View File

@ -161,7 +161,20 @@ function today(): string {
<template>
<div class="page">
<el-form inline class="filters" @submit.prevent>
<!-- 页面头:一句话说清这张报表回答什么问题、口径是什么;水线每页只出现一次 -->
<div class="page-head">
<h1 class="page-title">回款统计</h1>
<p class="page-desc">
按回款天数区间看回款结构与绩效分布;只统计已计提(有台账行)的回款,空区间记 0
</p>
<span class="waterline"></span>
</div>
<!--
.filter-bar 直接挂在 el-form 上(去掉 inline):el-form 本身就是 flex 容器,
外面再包一层 div 会让 el-form-item 的 gap 排版失效。
-->
<el-form class="filter-bar" @submit.prevent>
<el-form-item label="月份">
<el-date-picker
v-model="filters.settlement_month"
@ -205,94 +218,148 @@ function today(): string {
<el-option label="混合业务" value="mixed" />
</el-select>
</el-form-item>
<el-form-item>
<div class="filter-bar__actions">
<el-button type="primary" :loading="loading" @click="load">查询</el-button>
<el-button :loading="exporting" @click="doExport">导出 Excel</el-button>
<el-button @click="doPrint">打印</el-button>
</el-form-item>
</div>
</el-form>
<div class="hint">
统计口径:已计提回款(有台账行)的金额与绩效;进度登记回款不计入。空区间显示 0。
</div>
<!-- 表格包进卡片:报表页的信息密度靠卡片边界收拢,合计行共用卡脚材质 -->
<div class="card">
<el-table
v-loading="loading"
class="bucket-table dense"
data-testid="payment-bucket-table"
:data="data?.buckets ?? []"
row-key="code"
stripe
empty-text="所选条件下还没有回款记录 —— 先选择月份再查一次"
@expand-change="loadDrill"
>
<el-table-column type="expand">
<template #default="{ row }">
<div class="drill-wrap">
<el-table
v-loading="drillOf(row.code).loading"
:data="drillOf(row.code).rows"
size="small"
stripe
>
<el-table-column prop="business_no" label="合同编号" min-width="150" />
<el-table-column prop="receipt_batch_no" label="回款批次号" width="160" />
<el-table-column prop="receipt_date" label="回款日" width="110" />
<el-table-column prop="amount" label="回款金额" width="120" align="right" />
<el-table-column prop="personnel_name" label="归属人员" width="110" />
<el-table-column prop="payment_days" label="天数" width="80" align="right" />
<el-table-column label="客户类型" width="100">
<template #default="{ row: detail }">
{{ customerTypeLabel(detail.customer_type) }}
</template>
</el-table-column>
<el-table-column label="产品类别" width="100">
<template #default="{ row: detail }">
{{ reportApi.reportCategoryLabel(detail.product_category) }}
</template>
</el-table-column>
<el-table-column
prop="performance_amount"
label="绩效金额"
width="120"
align="right"
/>
</el-table>
<AppPagination
:total="drillOf(row.code).total"
:page-num="drillOf(row.code).pageNum"
:page-size="drillOf(row.code).pageSize"
@page-change="onDrillPage(row.code, $event)"
@size-change="onDrillSize(row.code, $event)"
/>
</div>
</template>
</el-table-column>
<el-table-column prop="label" label="回款天数区间" min-width="150" />
<el-table-column prop="receipt_amount" label="回款金额" width="140" align="right">
<template #default="{ row }">
<span class="cell-num">{{ row.receipt_amount }}</span>
</template>
</el-table-column>
<el-table-column prop="receipt_count" label="回款笔数" width="100" align="right">
<template #default="{ row }">
<span class="cell-num">{{ row.receipt_count }}</span>
</template>
</el-table-column>
<el-table-column prop="performance_amount" label="绩效金额" width="130" align="right">
<template #default="{ row }">
<!-- 绩效列加粗:一屏几十行时,加粗是读者定位「钱」的唯一线索 -->
<span class="cell-num cell-strong">{{ row.performance_amount }}</span>
</template>
</el-table-column>
<el-table-column prop="payable_amount" label="应付绩效" width="130" align="right">
<template #default="{ row }">
<span class="cell-num">{{ row.payable_amount }}</span>
</template>
</el-table-column>
<el-table-column prop="ledger_count" label="台账行数" width="100" align="right">
<template #default="{ row }">
<span class="cell-num">{{ row.ledger_count }}</span>
</template>
</el-table-column>
</el-table>
<el-table
v-loading="loading"
class="bucket-table"
data-testid="payment-bucket-table"
:data="data?.buckets ?? []"
row-key="code"
stripe
@expand-change="loadDrill"
>
<el-table-column type="expand">
<template #default="{ row }">
<div class="drill-wrap">
<el-table
v-loading="drillOf(row.code).loading"
:data="drillOf(row.code).rows"
size="small"
stripe
>
<el-table-column prop="business_no" label="合同编号" min-width="150" />
<el-table-column prop="receipt_batch_no" label="回款批次号" width="160" />
<el-table-column prop="receipt_date" label="回款日" width="110" />
<el-table-column prop="amount" label="回款金额" width="120" align="right" />
<el-table-column prop="personnel_name" label="归属人员" width="110" />
<el-table-column prop="payment_days" label="天数" width="80" align="right" />
<el-table-column label="客户类型" width="100">
<template #default="{ row }">
{{ customerTypeLabel(row.customer_type) }}
</template>
</el-table-column>
<el-table-column label="产品类别" width="100">
<template #default="{ row }">
{{ reportApi.reportCategoryLabel(row.product_category) }}
</template>
</el-table-column>
<el-table-column prop="performance_amount" label="绩效金额" width="120" align="right" />
</el-table>
<AppPagination
:total="drillOf(row.code).total"
:page-num="drillOf(row.code).pageNum"
:page-size="drillOf(row.code).pageSize"
@page-change="onDrillPage(row.code, $event)"
@size-change="onDrillSize(row.code, $event)"
/>
</div>
</template>
</el-table-column>
<el-table-column prop="label" label="回款天数区间" min-width="150" />
<el-table-column prop="receipt_amount" label="回款金额" width="140" align="right" />
<el-table-column prop="receipt_count" label="回款笔数" width="100" align="right" />
<el-table-column prop="performance_amount" label="绩效金额" width="130" align="right" />
<el-table-column prop="payable_amount" label="应付绩效" width="130" align="right" />
<el-table-column prop="ledger_count" label="台账行数" width="100" align="right" />
</el-table>
<div v-if="data" class="summary-bar">
合计:回款 {{ data.totals.receipt_amount ?? '0.00' }} | 笔数
{{ data.totals.receipt_count ?? 0 }} | 绩效 {{ data.totals.performance_amount ?? '0.00' }}
| 应付 {{ data.totals.payable_amount ?? '0.00' }}
<!-- 合计行:本表无分页,卡脚只承载合计;加粗并放大半档,与数据行区分 -->
<div class="card-foot">
<div v-if="data" class="summary-bar">
合计:回款 {{ data.totals.receipt_amount ?? '0.00' }} | 笔数
{{ data.totals.receipt_count ?? 0 }} | 绩效
{{ data.totals.performance_amount ?? '0.00' }} | 应付
{{ data.totals.payable_amount ?? '0.00' }}
</div>
</div>
</div>
<div v-if="data" class="dimension-grid">
<div>
<h4>客户类型对比</h4>
<p class="section-title">客户类型对比</p>
<el-table :data="data.by_customer_type" size="small" stripe>
<el-table-column prop="label" label="客户类型" width="120" />
<el-table-column prop="receipt_amount" label="回款金额" width="130" align="right" />
<el-table-column prop="performance_amount" label="绩效金额" width="130" align="right" />
<el-table-column prop="ledger_count" label="台账行数" width="100" align="right" />
<el-table-column prop="receipt_amount" label="回款金额" width="130" align="right">
<template #default="{ row }">
<span class="cell-num">{{ row.receipt_amount }}</span>
</template>
</el-table-column>
<el-table-column prop="performance_amount" label="绩效金额" width="130" align="right">
<template #default="{ row }">
<span class="cell-num">{{ row.performance_amount }}</span>
</template>
</el-table-column>
<el-table-column prop="ledger_count" label="台账行数" width="100" align="right">
<template #default="{ row }">
<span class="cell-num">{{ row.ledger_count }}</span>
</template>
</el-table-column>
</el-table>
</div>
<div>
<h4>产品类别对比</h4>
<p class="section-title">产品类别对比</p>
<el-table :data="data.by_product_category" size="small" stripe>
<el-table-column prop="label" label="产品类别" width="120" />
<el-table-column prop="receipt_amount" label="回款金额" width="130" align="right" />
<el-table-column prop="performance_amount" label="绩效金额" width="130" align="right" />
<el-table-column prop="ledger_count" label="台账行数" width="100" align="right" />
<el-table-column prop="receipt_amount" label="回款金额" width="130" align="right">
<template #default="{ row }">
<span class="cell-num">{{ row.receipt_amount }}</span>
</template>
</el-table-column>
<el-table-column prop="performance_amount" label="绩效金额" width="130" align="right">
<template #default="{ row }">
<span class="cell-num">{{ row.performance_amount }}</span>
</template>
</el-table-column>
<el-table-column prop="ledger_count" label="台账行数" width="100" align="right">
<template #default="{ row }">
<span class="cell-num">{{ row.ledger_count }}</span>
</template>
</el-table-column>
</el-table>
</div>
</div>
@ -300,26 +367,34 @@ function today(): string {
</template>
<style scoped>
.filters {
margin-bottom: 4px;
/*
* 密集表(设计稿 §09-3):报表要的是信息密度,行高与字号各降半档。
* 只写在页面 scoped 里 —— 写进全局会波及其它列表页的行高。
*/
.dense :deep(.el-table__cell) {
padding: 7px 0;
}
.hint {
margin-bottom: 8px;
font-size: 12px;
color: var(--ys-ink-3);
.dense :deep(.el-table__cell .cell) {
font-size: 12.5px;
}
/* 绩效列加粗:设计稿要求「密集不等于看不清」 */
.cell-strong {
font-weight: 600;
}
/* 展开区:内距跟着密集表收半档,字号由 size="small" 与 .dense 决定 */
.drill-wrap {
padding: 8px 16px;
padding: 10px 14px 6px;
}
/* 合计条:比正文加粗、字号放大半档,读者一眼能把「合计」与数据行分开 */
.summary-bar {
margin-top: 10px;
padding: 8px 12px;
font-size: 13px;
background: var(--ys-indigo-wash);
border-radius: 6px;
margin-right: auto;
font-size: 13.5px;
font-weight: 600;
color: var(--ys-ink);
}
.dimension-grid {
@ -328,10 +403,4 @@ function today(): string {
gap: 20px;
margin-top: 20px;
}
.dimension-grid h4 {
margin: 0 0 8px;
font-size: 14px;
font-weight: 600;
}
</style>

View File

@ -170,7 +170,18 @@ function today(): string {
<template>
<div class="page">
<el-form inline class="filters" @submit.prevent>
<!-- 页面头:一句话说清这张报表回答什么问题、口径是什么;水线每页只出现一次 -->
<div class="page-head">
<h1 class="page-title">个人绩效明细</h1>
<p class="page-desc">按人拆到每一笔回款;绩效 = 回款 × 计提比例 × 权重</p>
<span class="waterline"></span>
</div>
<!--
.filter-bar 直接挂在 el-form 上(去掉 inline):el-form 本身就是 flex 容器,
外面再包一层 div 会让 el-form-item 的 gap 排版失效。
-->
<el-form class="filter-bar" @submit.prevent>
<el-form-item label="月份">
<el-date-picker
v-model="filters.settlement_month"
@ -229,103 +240,165 @@ function today(): string {
/>
</el-select>
</el-form-item>
<el-form-item>
<div class="filter-bar__actions">
<el-button type="primary" @click="search">查询</el-button>
<el-button @click="resetFilters">重置</el-button>
<el-button :loading="exporting" @click="doExport">导出 Excel</el-button>
<el-button @click="doPrint">打印</el-button>
</el-form-item>
</div>
</el-form>
<el-table v-loading="loading" :data="records" stripe @expand-change="loadSnapshot">
<el-table-column type="expand">
<template #default="{ row }">
<pre class="snapshot">{{
JSON.stringify(snapshots[row.ledger_id]?.snapshot_json ?? '加载中…', null, 2)
}}</pre>
</template>
</el-table-column>
<el-table-column prop="business_no" label="合同编号" min-width="160" />
<el-table-column prop="ship_date" label="发货日" width="105" />
<el-table-column prop="receipt_date" label="回款日" width="105" />
<el-table-column prop="payment_days" label="回款天数" width="90" align="right" />
<el-table-column label="产品类别" width="100">
<template #default="{ row }">{{ reportApi.reportCategoryLabel(row.product_category) }}</template>
</el-table-column>
<el-table-column label="客户类型" width="100">
<template #default="{ row }">{{ customerTypeLabel(row.customer_type) }}</template>
</el-table-column>
<el-table-column prop="role_name" label="岗位" width="100" />
<el-table-column prop="rate_text" label="计提比例" width="110" />
<el-table-column prop="weight_r" label="支付权重R" width="95" align="right" />
<el-table-column prop="performance_amount" label="绩效金额" width="110" align="right">
<template #default="{ row }">
<span :class="{ negative: Number(row.performance_amount) < 0 }">
{{ row.performance_amount ?? '—' }}
</span>
</template>
</el-table-column>
<el-table-column prop="payable_amount" label="应付绩效" width="110" align="right" />
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag v-if="row.is_probation === 1" size="small" type="warning" disable-transitions>
试用期
</el-tag>
<el-tag v-if="row.missing_cost === 1" size="small" type="info" disable-transitions>
缺成本
</el-tag>
</template>
</el-table-column>
</el-table>
<!-- 表格包进卡片:报表页的信息密度靠卡片边界收拢,分页条共用卡脚材质 -->
<div class="card">
<el-table
v-loading="loading"
class="dense"
:data="records"
stripe
empty-text="还没有符合条件的明细 —— 换个月份或放宽人员再查一次"
@expand-change="loadSnapshot"
>
<el-table-column type="expand">
<template #default="{ row }">
<pre class="snapshot">{{
JSON.stringify(snapshots[row.ledger_id]?.snapshot_json ?? '加载中…', null, 2)
}}</pre>
</template>
</el-table-column>
<el-table-column prop="business_no" label="合同编号" min-width="160">
<template #default="{ row }">
<span class="cell-code">{{ row.business_no }}</span>
</template>
</el-table-column>
<el-table-column prop="ship_date" label="发货日" width="105" />
<el-table-column prop="receipt_date" label="回款日" width="105" />
<el-table-column prop="payment_days" label="回款天数" width="90" align="right">
<template #default="{ row }">
<span class="cell-num">{{ row.payment_days }}</span>
</template>
</el-table-column>
<el-table-column label="产品类别" width="100">
<template #default="{ row }">{{
reportApi.reportCategoryLabel(row.product_category)
}}</template>
</el-table-column>
<el-table-column label="客户类型" width="100">
<template #default="{ row }">{{ customerTypeLabel(row.customer_type) }}</template>
</el-table-column>
<el-table-column prop="role_name" label="岗位" width="100" />
<el-table-column prop="rate_text" label="计提比例" width="110">
<template #default="{ row }">
<span class="cell-num">{{ row.rate_text }}</span>
</template>
</el-table-column>
<el-table-column prop="weight_r" label="支付权重R" width="95" align="right">
<template #default="{ row }">
<span class="cell-num">{{ row.weight_r }}</span>
</template>
</el-table-column>
<el-table-column prop="performance_amount" label="绩效金额" width="110" align="right">
<template #default="{ row }">
<!-- 绩效列加粗:一屏几十行时,加粗是读者定位「钱」的唯一线索 -->
<span
class="cell-num cell-strong"
:class="{ negative: Number(row.performance_amount) < 0 }"
>
{{ row.performance_amount ?? '—' }}
</span>
</template>
</el-table-column>
<el-table-column prop="payable_amount" label="应付绩效" width="110" align="right">
<template #default="{ row }">
<span class="cell-num">{{ row.payable_amount }}</span>
</template>
</el-table-column>
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag v-if="row.is_probation === 1" size="small" type="warning" disable-transitions>
试用期
</el-tag>
<el-tag v-if="row.missing_cost === 1" size="small" type="info" disable-transitions>
缺成本
</el-tag>
</template>
</el-table-column>
</el-table>
<div v-if="summary" class="summary-bar">
全部筛选结果:{{ summary.row_count }} 行 | 绩效合计 {{ summary.performance_total ?? '0.00' }}
| 应付合计 {{ summary.payable_total ?? '0.00' }}
<span class="summary-note">(合计为全部筛选结果,非当前页)</span>
<!-- 合计与分页共用卡脚:合计靠 margin-right:auto 占到左侧,分页恒靠右 -->
<div class="card-foot">
<div v-if="summary" class="summary-bar">
全部筛选结果:{{ summary.row_count }} 行 | 绩效合计
{{ summary.performance_total ?? '0.00' }} | 应付合计
{{ summary.payable_total ?? '0.00' }}
<span class="summary-note">(合计为全部筛选结果,非当前页)</span>
</div>
<!-- AppPagination 只派发 page-change/size-change(无 update:pageNum),
不能用 v-model 或 @change——翻了页不会加载 -->
<AppPagination
:total="total"
:page-num="pageNum"
:page-size="pageSize"
@page-change="onPageChange"
@size-change="onSizeChange"
/>
</div>
</div>
<!-- AppPagination 只派发 page-change/size-change(无 update:pageNum),
不能用 v-model 或 @change——翻了页不会加载 -->
<AppPagination
:total="total"
:page-num="pageNum"
:page-size="pageSize"
@page-change="onPageChange"
@size-change="onSizeChange"
/>
</div>
</template>
<style scoped>
.filters {
margin-bottom: 4px;
/*
* 密集表(设计稿 §09-3):报表要的是信息密度,行高与字号各降半档。
* 只写在页面 scoped 里 —— 写进全局会波及其它列表页的行高。
*/
.dense :deep(.el-table__cell) {
padding: 7px 0;
}
.dense :deep(.el-table__cell .cell) {
font-size: 12.5px;
}
/*
* 金额/比例 span 变块级:行内元素的 text-align 无效,只有块级才真正右对齐;
* 表头保持左对齐(若改用列 align 会连表头一起右移,与设计稿不符)。
*/
.cell-num {
display: block;
}
/* 绩效列加粗:设计稿要求「密集不等于看不清」 */
.cell-strong {
font-weight: 600;
}
.snapshot {
margin: 0;
padding: 10px;
font-size: 12px;
padding: 12px 14px;
font-size: 12.5px;
line-height: 1.6;
background: #f4f5f8;
border-radius: 6px;
background: var(--ys-mist);
border-radius: var(--ys-radius-sm);
white-space: pre-wrap;
word-break: break-all;
}
.negative {
color: #c45656;
color: var(--ys-danger);
}
/* 合计条:比正文加粗、字号放大半档,读者一眼能把「合计」与数据行分开 */
.summary-bar {
margin-top: 10px;
padding: 8px 12px;
font-size: 13px;
background: var(--ys-indigo-wash);
border-radius: 6px;
margin-right: auto;
font-size: 13.5px;
font-weight: 600;
color: var(--ys-ink);
}
.summary-note {
color: var(--ys-ink-3);
font-size: 12px;
font-weight: 400;
color: var(--ys-ink-3);
}
</style>

View File

@ -171,387 +171,485 @@ onMounted(load)
<template>
<div class="print-page" v-loading="loading">
<div class="no-print toolbar">
<el-button type="primary" @click="doPrint">打印 / 另存为 PDF</el-button>
<span class="toolbar-hint">在打印对话框选择「另存为 PDF」即可导出 PDF</span>
</div>
<header class="sheet-header">
<img v-if="logoUrl" :src="logoUrl" class="logo" alt="公司 logo" />
<div class="company">{{ companyName }}</div>
<h1 class="title">{{ title }}</h1>
<div class="meta">
<span>筛选条件:{{ filterText }}</span>
<span>打印时间:{{ printedAt.toLocaleString('zh-CN') }} | 模板版本 V1.2</span>
<!-- 屏幕操作条:打印时随 .no-print 一起消失,纸面上不出现任何按钮 -->
<div class="no-print print-bar">
<div class="print-bar__crumb">
<b>报表打印</b>
<span class="print-bar__sep">/</span>
<span>{{ title }}</span>
</div>
<div class="print-bar__actions">
<span class="print-bar__hint">在打印对话框选择「另存为 PDF」即可导出</span>
<el-button type="primary" class="print-bar__btn" @click="doPrint">
打印 / 另存为 PDF
</el-button>
</div>
</header>
<div v-if="truncated" class="truncated">
数据量超过打印上限({{ PRINT_MAX_ROWS }} 行),仅打印前 {{ PRINT_MAX_ROWS }}
行;请缩小筛选范围后重新打印。
</div>
<template v-if="code === 'personal-detail'">
<table class="sheet-table">
<thead>
<tr>
<th>合同编号</th><th>发货日</th><th>回款日</th><th>回款天数</th>
<th>产品类别</th><th>客户类型</th><th>岗位</th><th>计提比例</th>
<th>支付权重R</th><th>绩效金额</th><th>应付绩效</th>
</tr>
</thead>
<tbody>
<tr v-for="row in rows" :key="String(row.ledger_id)">
<td>{{ row.business_no }}</td>
<td>{{ row.ship_date }}</td>
<td>{{ row.receipt_date }}</td>
<td class="num">{{ row.payment_days ?? '—' }}</td>
<td>{{ row.product_category }}</td>
<td>{{ row.customer_type }}</td>
<td>{{ row.role_name }}</td>
<td>{{ row.rate_text }}</td>
<td class="num">{{ row.weight_r ?? '—' }}</td>
<td class="num">{{ row.performance_amount ?? '—' }}</td>
<td class="num">{{ row.payable_amount ?? '—' }}</td>
</tr>
</tbody>
</table>
</template>
<!-- 稿纸:与屏幕稿不同源 —— 白底、实线表格、章节编号、签字栏,无圆角与阴影 -->
<div class="sheet">
<header class="sheet-header">
<img v-if="logoUrl" :src="logoUrl" class="logo" alt="公司 logo" />
<div class="company">{{ companyName }}</div>
<h1 class="title">{{ title }}</h1>
<div class="sheet-meta">筛选条件:{{ filterText }}</div>
<div class="sheet-meta">
打印时间:{{ printedAt.toLocaleString('zh-CN') }} | 模板版本 V1.2
</div>
</header>
<template v-else-if="code === 'department-summary' && department">
<h2 class="block-title">一、团队分成汇总</h2>
<table class="sheet-table" data-testid="print-department-teams">
<thead>
<tr>
<th>序号</th><th>经理团队</th><th>状态</th><th>团队人数</th><th>团队基数</th>
<th>团队分成({{ department.params?.team_text ?? '—' }}%)</th>
<th>调整净额(仅核对)</th><th>团队应发小计(含聚合分成)</th><th>月结冻结额</th>
</tr>
</thead>
<tbody>
<tr v-for="(team, index) in department.teams" :key="team.manager_id">
<td>{{ index + 1 }}</td>
<td>{{ team.manager_name }}</td>
<td>{{ team.manager_status === 1 ? '在职' : '离职' }}</td>
<td class="num">{{ team.member_count }}</td>
<td class="num">{{ team.team_base }}</td>
<td class="num">{{ team.team_share_amount }}</td>
<td class="num">{{ team.adjust_net }}</td>
<td class="num">{{ team.team_subtotal }}</td>
<td class="num">{{ team.frozen_amount ?? '—' }}</td>
</tr>
</tbody>
</table>
<div v-if="truncated" class="truncated">
数据量超过打印上限({{ PRINT_MAX_ROWS }} 行),本页只含前 {{ PRINT_MAX_ROWS }}
行;请缩小筛选范围后重新打印。
</div>
<h2 class="block-title">
二、全公司分成(每位有聚合行的经理各计一次,共
{{ department.company?.company_share_count ?? 0 }} 次)
</h2>
<table class="sheet-table" data-testid="print-company-block">
<thead>
<tr>
<th>全公司基数</th><th>分成比例</th><th>单经理分成</th>
<th>计入次数</th><th>分成合计</th>
</tr>
</thead>
<tbody>
<tr>
<td class="num">{{ department.company?.company_base ?? '—' }}</td>
<td class="num">{{ department.params?.company_text ?? '—' }}%</td>
<td class="num">{{ department.company?.company_share_amount ?? '—' }}</td>
<td class="num">{{ department.company?.company_share_count ?? 0 }}</td>
<td class="num">{{ department.company?.company_share_total ?? '—' }}</td>
</tr>
</tbody>
</table>
<h2 class="block-title">三、对账汇总</h2>
<table class="sheet-table" data-testid="print-reconciliation">
<thead>
<tr><th>项目</th><th>金额</th><th>说明</th></tr>
</thead>
<tbody>
<tr>
<td>团队成员应付合计</td>
<td class="num">{{ department.reconciliation?.team_member_payable_total ?? '—' }}</td>
<td />
</tr>
<tr>
<td>未分配人员应付合计</td>
<td class="num">{{ department.reconciliation?.unassigned_payable_total ?? '—' }}</td>
<td />
</tr>
<tr>
<td>团队分成合计(分列)</td>
<td class="num">{{ department.reconciliation?.team_share_total ?? '—' }}</td>
<td />
</tr>
<tr>
<td>
全公司分成合计({{ department.company?.company_share_count ?? 0 }} 次,分列)
</td>
<td class="num">{{ department.reconciliation?.company_share_total ?? '—' }}</td>
<td>每位有聚合行的经理各计一次</td>
</tr>
<tr>
<td>聚合行舍入差</td>
<td class="num">{{ department.reconciliation?.aggregate_rounding_diff ?? '—' }}</td>
<td>分列舍入与聚合行一次舍入的差</td>
</tr>
<tr>
<td>调整净额合计(明细层)</td>
<td class="num">{{ department.reconciliation?.adjust_net_total ?? '—' }}</td>
<td />
</tr>
<tr>
<td>应发合计</td>
<td class="num">{{ department.reconciliation?.payable_total ?? '—' }}</td>
<td>上列各项之和</td>
</tr>
<tr>
<td>团队基数合计</td>
<td class="num">{{ department.reconciliation?.team_base_total ?? '—' }}</td>
<td />
</tr>
<tr>
<td>未分配绩效合计</td>
<td class="num">{{ department.reconciliation?.unassigned_performance_total ?? '—' }}</td>
<td />
</tr>
<tr>
<td>其他未展示基数</td>
<td class="num">{{ department.reconciliation?.other_team_base ?? '—' }}</td>
<td>筛选时含其他团队与全公司未分配</td>
</tr>
<tr>
<td>全公司基数</td>
<td class="num">{{ department.reconciliation?.company_base ?? '—' }}</td>
<td />
</tr>
</tbody>
</table>
<h2 class="block-title">四、团队成员明细</h2>
<table class="sheet-table" data-testid="print-department-members">
<thead>
<tr>
<th>经理团队</th><th>成员</th><th>岗位</th><th>来源</th><th>绩效金额</th>
<th>应付绩效</th><th>调整补发</th><th>调整扣减</th><th>调整净额</th><th>净合计</th>
</tr>
</thead>
<tbody>
<tr
v-for="member in departmentMembers"
:key="`${member.personnel_id}-${member.member_kind}`"
>
<td>{{ member.manager_name }}</td>
<td>{{ member.personnel_name }}</td>
<td>{{ member.role_code }}</td>
<td>{{ memberKindLabel(member.member_kind) }}</td>
<td class="num">{{ member.performance_sum }}</td>
<td class="num">{{ member.payable_sum }}</td>
<td class="num">{{ member.adjust_extra }}</td>
<td class="num">{{ member.adjust_deduct }}</td>
<td class="num">{{ member.adjust_net }}</td>
<td class="num">{{ member.net_total }}</td>
</tr>
</tbody>
</table>
<h2 class="block-title">五、未分配人员明细</h2>
<table class="sheet-table" data-testid="print-unassigned">
<thead>
<tr>
<th>人员</th><th>岗位</th><th>未纳入原因</th><th>绩效金额</th><th>应付绩效</th>
<th>调整补发</th><th>调整扣减</th><th>调整净额</th><th>净合计</th>
</tr>
</thead>
<tbody>
<tr
v-for="member in department.unassigned"
:key="`${member.personnel_id}-${member.member_kind}`"
>
<td>{{ member.personnel_name }}</td>
<td>{{ member.role_code }}</td>
<td>{{ member.unassigned_reason ?? '—' }}</td>
<td class="num">{{ member.performance_sum }}</td>
<td class="num">{{ member.payable_sum }}</td>
<td class="num">{{ member.adjust_extra }}</td>
<td class="num">{{ member.adjust_deduct }}</td>
<td class="num">{{ member.adjust_net }}</td>
<td class="num">{{ member.net_total }}</td>
</tr>
<tr v-if="department.unassigned.length === 0">
<td colspan="9">(无)</td>
</tr>
</tbody>
</table>
</template>
<template v-else-if="code === 'business-stats'">
<table class="sheet-table">
<thead>
<tr>
<th>业务编号</th><th>业务日期</th><th>客户</th><th>归属人</th><th>状态</th>
<th>销售额</th><th>发货额</th><th>回款额</th><th>绩效金额</th><th>应付绩效</th>
</tr>
</thead>
<tbody>
<tr v-for="row in rows" :key="String(row.business_id)">
<td>{{ row.business_no }}</td>
<td>{{ row.business_date }}</td>
<td>{{ row.customer_name }}</td>
<td>{{ row.owner_personnel_name }}</td>
<td>{{ row.status }}</td>
<td class="num">{{ row.total_sale_amount }}</td>
<td class="num">{{ row.total_ship_amount }}</td>
<td class="num">{{ row.total_received_amount }}</td>
<td class="num">{{ row.performance_amount }}</td>
<td class="num">{{ row.payable_amount }}</td>
</tr>
</tbody>
</table>
</template>
<template v-else>
<table class="sheet-table">
<thead>
<tr>
<th>回款天数区间</th><th>回款金额</th><th>回款笔数</th>
<th>绩效金额</th><th>应付绩效</th><th>台账行数</th>
</tr>
</thead>
<tbody>
<tr v-for="row in buckets" :key="String(row.code)">
<td>{{ row.label }}</td>
<td class="num">{{ row.receipt_amount }}</td>
<td class="num">{{ row.receipt_count }}</td>
<td class="num">{{ row.performance_amount }}</td>
<td class="num">{{ row.payable_amount }}</td>
<td class="num">{{ row.ledger_count }}</td>
</tr>
</tbody>
</table>
<div v-for="group in groups" :key="group.title" class="group-block">
<h3>{{ group.title }}</h3>
<template v-if="code === 'personal-detail'">
<table class="sheet-table">
<thead>
<tr><th>维度</th><th>回款金额</th><th>绩效金额</th><th>台账行数</th></tr>
<tr>
<th>合同编号</th><th>发货日</th><th>回款日</th><th class="num">回款天数</th>
<th>产品类别</th><th>客户类型</th><th>岗位</th><th class="num">计提比例</th>
<th class="num">支付权重R</th><th class="num">绩效金额</th><th class="num">应付绩效</th>
</tr>
</thead>
<tbody>
<tr v-for="row in group.rows" :key="String(row.code)">
<tr v-for="row in rows" :key="String(row.ledger_id)">
<td>{{ row.business_no }}</td>
<td>{{ row.ship_date }}</td>
<td>{{ row.receipt_date }}</td>
<td class="num">{{ row.payment_days ?? '—' }}</td>
<td>{{ row.product_category }}</td>
<td>{{ row.customer_type }}</td>
<td>{{ row.role_name }}</td>
<td>{{ row.rate_text }}</td>
<td class="num">{{ row.weight_r ?? '—' }}</td>
<td class="num">{{ row.performance_amount ?? '—' }}</td>
<td class="num">{{ row.payable_amount ?? '—' }}</td>
</tr>
</tbody>
</table>
</template>
<template v-else-if="code === 'department-summary' && department">
<h2 class="block-title">一、团队分成汇总</h2>
<table class="sheet-table" data-testid="print-department-teams">
<thead>
<tr>
<th>序号</th><th>经理团队</th><th>状态</th>
<th class="num">团队人数</th><th class="num">团队基数</th>
<th class="num">团队分成({{ department.params?.team_text ?? '—' }}%)</th>
<th class="num">调整净额(仅核对)</th>
<th class="num">团队应发小计(含聚合分成)</th>
<th class="num">月结冻结额</th>
</tr>
</thead>
<tbody>
<tr v-for="(team, index) in department.teams" :key="team.manager_id">
<td>{{ index + 1 }}</td>
<td>{{ team.manager_name }}</td>
<td>{{ team.manager_status === 1 ? '在职' : '离职' }}</td>
<td class="num">{{ team.member_count }}</td>
<td class="num">{{ team.team_base }}</td>
<td class="num">{{ team.team_share_amount }}</td>
<td class="num">{{ team.adjust_net }}</td>
<td class="num">{{ team.team_subtotal }}</td>
<td class="num">{{ team.frozen_amount ?? '—' }}</td>
</tr>
</tbody>
</table>
<h2 class="block-title">
二、全公司分成(每位有聚合行的经理各计一次,共
{{ department.company?.company_share_count ?? 0 }} 次)
</h2>
<table class="sheet-table" data-testid="print-company-block">
<thead>
<tr>
<th class="num">全公司基数</th><th class="num">分成比例</th>
<th class="num">单经理分成</th>
<th class="num">计入次数</th><th class="num">分成合计</th>
</tr>
</thead>
<tbody>
<tr>
<td class="num">{{ department.company?.company_base ?? '—' }}</td>
<td class="num">{{ department.params?.company_text ?? '—' }}%</td>
<td class="num">{{ department.company?.company_share_amount ?? '—' }}</td>
<td class="num">{{ department.company?.company_share_count ?? 0 }}</td>
<td class="num">{{ department.company?.company_share_total ?? '—' }}</td>
</tr>
</tbody>
</table>
<h2 class="block-title">三、对账汇总</h2>
<table class="sheet-table" data-testid="print-reconciliation">
<thead>
<tr><th>项目</th><th class="num">金额</th><th>说明</th></tr>
</thead>
<tbody>
<tr>
<td>团队成员应付合计</td>
<td class="num">{{ department.reconciliation?.team_member_payable_total ?? '—' }}</td>
<td />
</tr>
<tr>
<td>未分配人员应付合计</td>
<td class="num">{{ department.reconciliation?.unassigned_payable_total ?? '—' }}</td>
<td />
</tr>
<tr>
<td>团队分成合计(分列)</td>
<td class="num">{{ department.reconciliation?.team_share_total ?? '—' }}</td>
<td />
</tr>
<tr>
<td>
全公司分成合计({{ department.company?.company_share_count ?? 0 }} 次,分列)
</td>
<td class="num">{{ department.reconciliation?.company_share_total ?? '—' }}</td>
<td>每位有聚合行的经理各计一次</td>
</tr>
<tr>
<td>聚合行舍入差</td>
<td class="num">{{ department.reconciliation?.aggregate_rounding_diff ?? '—' }}</td>
<td>分列舍入与聚合行一次舍入的差</td>
</tr>
<tr>
<td>调整净额合计(明细层)</td>
<td class="num">{{ department.reconciliation?.adjust_net_total ?? '—' }}</td>
<td />
</tr>
<tr>
<td>应发合计</td>
<td class="num">{{ department.reconciliation?.payable_total ?? '—' }}</td>
<td>上列各项之和</td>
</tr>
<tr>
<td>团队基数合计</td>
<td class="num">{{ department.reconciliation?.team_base_total ?? '—' }}</td>
<td />
</tr>
<tr>
<td>未分配绩效合计</td>
<td class="num">{{ department.reconciliation?.unassigned_performance_total ?? '—' }}</td>
<td />
</tr>
<tr>
<td>其他未展示基数</td>
<td class="num">{{ department.reconciliation?.other_team_base ?? '—' }}</td>
<td>筛选时含其他团队与全公司未分配</td>
</tr>
<tr>
<td>全公司基数</td>
<td class="num">{{ department.reconciliation?.company_base ?? '—' }}</td>
<td />
</tr>
</tbody>
</table>
<h2 class="block-title">四、团队成员明细</h2>
<table class="sheet-table" data-testid="print-department-members">
<thead>
<tr>
<th>经理团队</th><th>成员</th><th>岗位</th><th>来源</th>
<th class="num">绩效金额</th><th class="num">应付绩效</th>
<th class="num">调整补发</th><th class="num">调整扣减</th>
<th class="num">调整净额</th><th class="num">净合计</th>
</tr>
</thead>
<tbody>
<tr
v-for="member in departmentMembers"
:key="`${member.personnel_id}-${member.member_kind}`"
>
<td>{{ member.manager_name }}</td>
<td>{{ member.personnel_name }}</td>
<td>{{ member.role_code }}</td>
<td>{{ memberKindLabel(member.member_kind) }}</td>
<td class="num">{{ member.performance_sum }}</td>
<td class="num">{{ member.payable_sum }}</td>
<td class="num">{{ member.adjust_extra }}</td>
<td class="num">{{ member.adjust_deduct }}</td>
<td class="num">{{ member.adjust_net }}</td>
<td class="num">{{ member.net_total }}</td>
</tr>
</tbody>
</table>
<h2 class="block-title">五、未分配人员明细</h2>
<table class="sheet-table" data-testid="print-unassigned">
<thead>
<tr>
<th>人员</th><th>岗位</th><th>未纳入原因</th>
<th class="num">绩效金额</th><th class="num">应付绩效</th>
<th class="num">调整补发</th><th class="num">调整扣减</th>
<th class="num">调整净额</th><th class="num">净合计</th>
</tr>
</thead>
<tbody>
<tr
v-for="member in department.unassigned"
:key="`${member.personnel_id}-${member.member_kind}`"
>
<td>{{ member.personnel_name }}</td>
<td>{{ member.role_code }}</td>
<td>{{ member.unassigned_reason ?? '—' }}</td>
<td class="num">{{ member.performance_sum }}</td>
<td class="num">{{ member.payable_sum }}</td>
<td class="num">{{ member.adjust_extra }}</td>
<td class="num">{{ member.adjust_deduct }}</td>
<td class="num">{{ member.adjust_net }}</td>
<td class="num">{{ member.net_total }}</td>
</tr>
<tr v-if="department.unassigned.length === 0">
<td colspan="9">(无)</td>
</tr>
</tbody>
</table>
</template>
<template v-else-if="code === 'business-stats'">
<table class="sheet-table">
<thead>
<tr>
<th>业务编号</th><th>业务日期</th><th>客户</th><th>归属人</th><th>状态</th>
<th class="num">销售额</th><th class="num">发货额</th><th class="num">回款额</th>
<th class="num">绩效金额</th><th class="num">应付绩效</th>
</tr>
</thead>
<tbody>
<tr v-for="row in rows" :key="String(row.business_id)">
<td>{{ row.business_no }}</td>
<td>{{ row.business_date }}</td>
<td>{{ row.customer_name }}</td>
<td>{{ row.owner_personnel_name }}</td>
<td>{{ row.status }}</td>
<td class="num">{{ row.total_sale_amount }}</td>
<td class="num">{{ row.total_ship_amount }}</td>
<td class="num">{{ row.total_received_amount }}</td>
<td class="num">{{ row.performance_amount }}</td>
<td class="num">{{ row.payable_amount }}</td>
</tr>
</tbody>
</table>
</template>
<template v-else>
<table class="sheet-table">
<thead>
<tr>
<th>回款天数区间</th><th class="num">回款金额</th><th class="num">回款笔数</th>
<th class="num">绩效金额</th><th class="num">应付绩效</th><th class="num">台账行数</th>
</tr>
</thead>
<tbody>
<tr v-for="row in buckets" :key="String(row.code)">
<td>{{ row.label }}</td>
<td class="num">{{ row.receipt_amount }}</td>
<td class="num">{{ row.receipt_count }}</td>
<td class="num">{{ row.performance_amount }}</td>
<td class="num">{{ row.payable_amount }}</td>
<td class="num">{{ row.ledger_count }}</td>
</tr>
</tbody>
</table>
</div>
</template>
<div v-for="group in groups" :key="group.title" class="group-block">
<h3>{{ group.title }}</h3>
<table class="sheet-table">
<thead>
<tr>
<th>维度</th><th class="num">回款金额</th>
<th class="num">绩效金额</th><th class="num">台账行数</th>
</tr>
</thead>
<tbody>
<tr v-for="row in group.rows" :key="String(row.code)">
<td>{{ row.label }}</td>
<td class="num">{{ row.receipt_amount }}</td>
<td class="num">{{ row.performance_amount }}</td>
<td class="num">{{ row.ledger_count }}</td>
</tr>
</tbody>
</table>
</div>
</template>
<footer class="signature">
<div>财务复核:____________________</div>
<div>领导签字:____________________</div>
<div>日期:______年__月__日</div>
</footer>
<!-- 三栏签字:制表 / 复核 / 审批,栏位用上横线表示手写位置 -->
<footer class="sign-row">
<div>制表:____________</div>
<div>复核:____________</div>
<div>审批:____________</div>
</footer>
</div>
</div>
</template>
<style scoped>
/*
* 打印视图:屏幕上是「灰画布 + 白稿纸」,打印时退化为纯纸面。
* 画布灰比内容页沉一档,让白稿纸一眼是「一张纸」而不是页面里的一个区块。
*/
.print-page {
min-height: 100vh;
padding: 24px;
background: #fff;
color: #1c2333;
padding-bottom: 28px;
background: #eceef2;
color: var(--ys-ink);
}
.toolbar {
/* 屏幕操作条:只在屏幕上出现,纸面上不出现任何按钮 */
.print-bar {
display: flex;
align-items: center;
justify-content: space-between;
height: 52px;
padding: 0 22px;
margin-bottom: 22px;
background: linear-gradient(180deg, #ffffff, #fdfdfe);
border-bottom: 1px solid var(--ys-line);
}
.print-bar__crumb {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--ys-ink-3);
}
.print-bar__crumb b {
font-weight: 600;
color: var(--ys-ink);
}
.print-bar__sep {
color: var(--ys-line);
}
.print-bar__actions {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 20px;
}
.toolbar-hint {
.print-bar__hint {
font-size: 12px;
color: var(--ys-ink-3);
}
/* 顶栏内的按钮压到 30px/12px:与页面顶栏的节奏一致(全局按钮是 34px) */
.print-bar__btn {
height: 30px;
padding: 0 14px;
font-size: 12px;
}
/*
* 稿纸:按 A4 横向版心宽度定宽,屏幕预览与打印同宽 ——
* 两套宽度会让「预览时一页放得下、打出来却折行」。
*/
.sheet {
width: 1040px;
max-width: calc(100% - 44px);
margin: 0 auto;
padding: 26px 30px 30px;
background: #fff;
}
.sheet-header {
margin-bottom: 16px;
text-align: center;
}
.logo {
max-height: 48px;
margin-bottom: 6px;
max-height: 44px;
margin-bottom: 8px;
}
.company {
font-size: 18px;
font-weight: 700;
font-size: 13px;
font-weight: 500;
letter-spacing: 0.18em;
color: var(--ys-ink-2);
}
.title {
margin: 6px 0;
font-size: 20px;
margin: 4px 0 0;
font-size: 15px;
font-weight: 600;
}
.meta {
display: flex;
justify-content: space-between;
font-size: 12px;
color: #5b6577;
.sheet-meta {
margin-top: 4px;
font-size: 11px;
color: var(--ys-ink-3);
}
/* 截断提示:纸面上也要让人一眼看出「这不是全部数据」 */
.truncated {
margin-bottom: 10px;
padding: 8px 12px;
font-size: 12px;
margin-top: 12px;
padding: 6px 10px;
font-size: 11px;
color: #8a5a00;
background: #fdf6e3;
border-radius: 4px;
background: #fbf4e4;
border: 1px solid #f0e2c4;
}
/*
* 稿面表格:纸面口径 —— 11px、细实线 #d9dde5、表头浅底左对齐。
* th 一律左对齐,只有数值列右对齐(与数据行同向),否则表头与数字错位。
*/
.sheet-table {
width: 100%;
margin-top: 8px;
border-collapse: collapse;
font-size: 12px;
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.sheet-table th,
.sheet-table td {
padding: 6px 8px;
border: 1px solid #b0b7c3;
padding: 4px 7px;
text-align: left;
vertical-align: top;
border: 1px solid #d9dde5;
}
.sheet-table th {
background: #dde3f0;
font-weight: 600;
background: #f6f7f9;
font-weight: 500;
color: var(--ys-ink-2);
white-space: nowrap;
}
.sheet-table .num {
text-align: right;
font-variant-numeric: tabular-nums;
}
.block-title {
margin: 18px 0 6px;
font-size: 14px;
margin: 14px 0 6px;
font-size: 12px;
font-weight: 600;
}
.group-block {
margin-top: 18px;
.group-block h3 {
margin: 12px 0 6px;
font-size: 12px;
font-weight: 500;
color: var(--ys-ink-2);
}
.signature {
display: flex;
justify-content: space-between;
margin-top: 40px;
padding-top: 16px;
font-size: 13px;
/* 三栏签字:上横线代替手写下划线,栏宽等分 */
.sign-row {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 18px;
margin-top: 22px;
font-size: 11px;
color: var(--ys-ink-3);
}
.sign-row div {
padding-top: 5px;
border-top: 1px solid var(--ys-line);
}
@media print {
@ -559,8 +657,17 @@ onMounted(load)
display: none !important;
}
/* 打印时画布与稿纸合并成一张纸:外层灰底、定宽、内距都是屏幕专有 */
.print-page {
padding: 0;
background: #fff;
}
.sheet {
width: auto;
max-width: none;
margin: 0;
padding: 0;
}
@page {
@ -575,5 +682,10 @@ onMounted(load)
.sheet-table tr {
page-break-inside: avoid;
}
/* 浏览器默认不打印背景色,表头浅底会丢失,这里强制保留 */
.sheet-table th {
print-color-adjust: exact;
}
}
</style>

View File

@ -7,7 +7,6 @@ import {
getSettlement,
listSettlements,
settlementStatusLabel,
settlementStatusTagType,
submitSettlement,
type Settlement,
type SettlementDetail,
@ -39,6 +38,39 @@ function resetFilters(): void {
search()
}
/**
* 月结生命周期四段(设计稿 §09-1)。
* 状态条同时是快捷筛选:与下拉读同一个 filters.status,因此下拉变化时
* 状态条自动跟随、点击状态条时下拉也自动跟随,不需要额外同步逻辑;
* 复用 search() 意味着**不新增任何后端请求口径**。
*/
const FLOW_STEPS = [
{ value: 1, label: '草稿' },
{ value: 2, label: '待审批' },
{ value: 3, label: '已审定(锁定)' },
{ value: 4, label: '已驳回' },
] as const
/** 点当前段再点一次即取消筛选(idle 态),避免「点了没反应」的死角 */
function pickStatus(status: number): void {
filters.status = filters.status === status ? undefined : status
search()
}
/**
* 状态 → pill 修饰符。
* 草稿是中性态(无修饰符);待审批琥珀、已审定靛蓝、已驳回砖红,
* 与设计稿四档语义一致,颜色之外还有文字,不单靠颜色表意。
*/
function statusPillClass(status: number): string {
return (
({ 1: '', 2: 'ys-pill--wait', 3: 'ys-pill--lock', 4: 'ys-pill--reject' } as Record<
number,
string
>)[status] ?? ''
)
}
const generateMonth = ref('')
const generating = ref(false)
@ -96,21 +128,49 @@ async function handleSubmit(row: Settlement): Promise<void> {
<template>
<div class="page">
<div class="toolbar">
<div class="gen-row">
<el-input
v-model="generateMonth"
placeholder="结算月份 YYYY-MM"
style="width: 160px"
maxlength="7"
/>
<el-button type="primary" :loading="generating" @click="handleGenerate">
生成月结快照
</el-button>
</div>
<div class="page-head">
<h1 class="page-title">月度结算</h1>
<p class="page-desc">
按月生成绩效结算批次;送审后锁定,人数与金额不再随源数据变化
</p>
<span class="waterline"></span>
</div>
<el-form inline class="filters" @submit.prevent>
<!--
月结生命周期条:用 button 而非 div —— 它可点击、可聚焦,
键盘 Tab + Enter 也能筛选(div 做不到),且不会有默认提交行为。
-->
<div class="flow-step">
<button
v-for="step in FLOW_STEPS"
:key="step.value"
type="button"
class="flow-step__item"
:class="{ 'flow-step__item--cur': filters.status === step.value }"
@click="pickStatus(step.value)"
>
<span class="flow-step__dot"></span>{{ step.label }}
</button>
</div>
<!-- 生成行:文案保留「生成月结快照」,对应真实动作(生成的是快照批次) -->
<div class="toolbar">
<el-input
v-model="generateMonth"
placeholder="结算月份 YYYY-MM"
style="width: 160px"
maxlength="7"
/>
<el-button type="primary" :loading="generating" @click="handleGenerate">
生成月结快照
</el-button>
</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-select v-model="filters.status" placeholder="全部" clearable style="width: 150px">
<el-option label="草稿" :value="1" />
@ -119,68 +179,102 @@ async function handleSubmit(row: Settlement): Promise<void> {
<el-option label="已驳回" :value="4" />
</el-select>
</el-form-item>
<el-form-item>
<div class="filter-bar__actions">
<el-button type="primary" @click="search">查询</el-button>
<el-button @click="resetFilters">重置</el-button>
</el-form-item>
</div>
</el-form>
<el-table v-loading="loading" :data="records" stripe>
<el-table-column prop="settlement_month" label="结算月份" width="100" />
<el-table-column label="状态" width="130">
<template #default="{ row }">
<el-tag :type="settlementStatusTagType(row.status)" size="small" disable-transitions>
{{ settlementStatusLabel(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="total_performance" label="绩效合计" min-width="110" />
<el-table-column prop="total_payable" label="应付合计" min-width="110" />
<el-table-column prop="person_count" label="人数" width="70" />
<el-table-column prop="approval_order_no" label="审批单号" min-width="150" />
<el-table-column label="提交时间" min-width="150">
<template #default="{ row }">{{ formatDateTime(row.create_time) }}</template>
</el-table-column>
<el-table-column label="操作" width="140" fixed="right" align="center">
<template #default="{ row }">
<div class="table-actions">
<el-button link type="primary" @click="openDetail(row)">详情</el-button>
<el-button
v-if="row.status === 1 || row.status === 4"
link
type="success"
:loading="submitting"
@click="handleSubmit(row)"
>
提交审批
</el-button>
</div>
</template>
</el-table-column>
</el-table>
<div class="card">
<el-table v-loading="loading" :data="records" stripe>
<el-table-column prop="settlement_month" label="结算月份" width="110" />
<el-table-column label="状态" width="150">
<template #default="{ row }">
<span class="ys-pill" :class="statusPillClass(row.status)">
{{ settlementStatusLabel(row.status) }}
</span>
</template>
</el-table-column>
<el-table-column label="绩效合计" min-width="115">
<template #default="{ row }">
<span class="cell-num">{{ row.total_performance ?? '—' }}</span>
</template>
</el-table-column>
<el-table-column label="应付合计" min-width="115">
<template #default="{ row }">
<span class="cell-num">{{ row.total_payable ?? '—' }}</span>
</template>
</el-table-column>
<el-table-column label="人数" width="80">
<template #default="{ row }">
<span class="cell-num">{{ row.person_count }}</span>
</template>
</el-table-column>
<el-table-column label="审批单号" min-width="140">
<template #default="{ row }">
<span v-if="row.approval_order_no" class="cell-code">{{ row.approval_order_no }}</span>
<span v-else class="cell-muted">—</span>
</template>
</el-table-column>
<el-table-column label="提交时间" min-width="150">
<template #default="{ row }">
<span class="cell-muted">{{ formatDateTime(row.create_time) }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="140" fixed="right" align="center">
<template #default="{ row }">
<div class="table-actions">
<el-button link type="primary" @click="openDetail(row)">详情</el-button>
<el-button
v-if="row.status === 1 || row.status === 4"
link
type="success"
:loading="submitting"
@click="handleSubmit(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"
/>
<div class="card-foot">
<AppPagination
:total="total"
:page-num="pageNum"
:page-size="pageSize"
@page-change="onPageChange"
@size-change="onSizeChange"
/>
</div>
</div>
<el-drawer v-model="detailVisible" title="月结详情" size="560px">
<div v-loading="detailLoading">
<template v-if="detail">
<div class="detail-head">
<span class="detail-month">{{ detail.settlement_month }}</span>
<el-tag :type="settlementStatusTagType(detail.status)" size="small" disable-transitions>
<span class="ys-pill" :class="statusPillClass(detail.status)">
{{ settlementStatusLabel(detail.status) }}
</el-tag>
</div>
<div class="detail-meta">
<span>审批单号:{{ detail.approval_order_no || '—' }}</span>
<span>提交人:{{ detail.submitter_name }}</span>
<span v-if="detail.locked_by">锁定人:{{ detail.locked_by }}</span>
</span>
</div>
<p class="section-title section-title--gap">基本信息</p>
<!--
dl.kv 两列网格:值缺省时统一显示 — 而不是整行隐藏,
审批人才能区分「没有这个字段」与「有字段但为空」。
-->
<dl class="kv">
<dt>审批单号</dt>
<dd>{{ detail.approval_order_no || '—' }}</dd>
<dt>提交人</dt>
<dd>{{ detail.submitter_name || '—' }}</dd>
<dt>锁定人</dt>
<dd>{{ detail.locked_by || '—' }}</dd>
</dl>
<p class="section-title section-title--gap">按人明细</p>
<el-table :data="detail.items" size="small" stripe>
<el-table-column prop="personnel_name" label="人员" min-width="90" />
<el-table-column label="类型" width="90">
@ -188,17 +282,32 @@ async function handleSubmit(row: Settlement): Promise<void> {
{{ row.calc_type === 1 ? '单笔回款' : '经理聚合' }}
</template>
</el-table-column>
<el-table-column prop="performance_sum" label="绩效" min-width="90" />
<el-table-column prop="payable_sum" label="应付" min-width="90" />
<el-table-column prop="net_amount" label="净工资" min-width="90" />
<el-table-column label="绩效" min-width="90">
<template #default="{ row }">
<span class="cell-num">{{ row.performance_sum }}</span>
</template>
</el-table-column>
<el-table-column label="应付" min-width="90">
<template #default="{ row }">
<span class="cell-num">{{ row.payable_sum }}</span>
</template>
</el-table-column>
<el-table-column label="净工资" min-width="90">
<template #default="{ row }">
<span class="cell-num">{{ row.net_amount ?? '—' }}</span>
</template>
</el-table-column>
<el-table-column label="试用期" width="70">
<template #default="{ row }">{{ row.is_probation === 1 ? '是' : '否' }}</template>
</el-table-column>
</el-table>
<p v-if="detail.status === 1 || detail.status === 4" class="hint">
<p v-if="detail.status === 1 || detail.status === 4" class="drawer-hint">
草稿/已驳回状态可重新生成快照后再次提交。
</p>
<p v-else-if="detail.status === 3" class="hint">已锁定:源数据改动只产生提示与调整单。</p>
<p v-else-if="detail.status === 3" class="drawer-hint">
已锁定:源数据改动只产生提示与调整单。
</p>
</template>
</div>
</el-drawer>
@ -206,19 +315,78 @@ async function handleSubmit(row: Settlement): Promise<void> {
</template>
<style scoped>
.toolbar {
display: flex;
justify-content: flex-end;
margin-bottom: 12px;
/*
* 金额列右对齐:全局 .cell-num 是给 td 设计的,这里只能包 span,
* 行内元素的 text-align 无效,所以把 span 变块级让对齐真正生效;
* 表头保持左对齐,与设计稿一致(列 align="right" 会连表头一起右移)。
*/
.cell-num {
display: block;
}
.gen-row {
/* 生命周期条:横向分段,相邻段共用描边(border-left:0)避免双线 */
.flow-step {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 14px;
}
.filters {
margin-bottom: 4px;
.flow-step__item {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 14px;
font-family: inherit;
font-size: 12.5px;
color: var(--ys-ink-2);
white-space: nowrap;
/* 白底而非设计稿的极淡渐变:色阶靠全局 token 表达,页面不引入新色值 */
background: var(--ys-paper);
border: 1px solid var(--ys-line);
cursor: pointer;
transition:
color 150ms var(--ys-ease),
background-color 150ms var(--ys-ease),
border-color 150ms var(--ys-ease);
}
.flow-step__item:first-child {
border-radius: var(--ys-radius-sm) 0 0 var(--ys-radius-sm);
}
.flow-step__item:last-child {
border-radius: 0 var(--ys-radius-sm) var(--ys-radius-sm) 0;
}
.flow-step__item + .flow-step__item {
border-left: 0;
}
.flow-step__item:hover {
color: var(--ys-ink);
}
.flow-step__item:focus-visible {
outline: 2px solid rgba(46, 75, 168, 0.3);
outline-offset: 2px;
}
.flow-step__dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--ys-ink-3);
}
.flow-step__item--cur {
font-weight: 500;
color: var(--ys-indigo);
background: var(--ys-indigo-wash);
border-color: rgba(46, 75, 168, 0.3);
}
.flow-step__item--cur .flow-step__dot {
background: var(--ys-indigo);
}
.detail-head {
@ -228,22 +396,38 @@ async function handleSubmit(row: Settlement): Promise<void> {
}
.detail-month {
font-size: 15px;
font-size: 16px;
font-weight: 600;
letter-spacing: -0.01em;
color: var(--ys-ink);
}
.detail-meta {
display: flex;
flex-direction: column;
gap: 2px;
padding: 10px 0;
/* 分块标题之间需要上间距;不改全局 .section-title,用局部修饰符叠加 */
.section-title--gap {
margin-top: 18px;
}
/* 键值两列:72px 的键列足够放四个汉字,值列自适应余宽(抽屉 560px 内可读) */
.kv {
display: grid;
grid-template-columns: 72px 1fr;
gap: 8px 10px;
margin: 0;
}
.kv dt {
font-size: 12px;
color: var(--ys-ink-3);
}
.hint {
margin-top: 12px;
.kv dd {
margin: 0;
font-size: 13px;
color: var(--ys-ink);
}
.drawer-hint {
margin: 14px 0 0;
font-size: 12px;
color: var(--ys-ink-3);
}

View File

@ -5,7 +5,6 @@ import { ElMessage } from 'element-plus'
import {
actApproval,
approvalStatusLabel,
approvalStatusTagType,
getApproval,
listApprovals,
orderTypeLabel,
@ -62,6 +61,18 @@ function resetFilters(): void {
search()
}
/**
* 状态 → pill 修饰符(设计稿 §03 四档语义)。
* 在途单(待财务复核/待领导审核)统一琥珀「待办」;已通过绿、已驳回砖红;
* 已撤回是中性终态,不带修饰符,避免与「待办」抢注意力。
*/
function approvalPillClass(status: number): string {
return (
({ 3: 'ys-pill--ok', 4: 'ys-pill--reject' } as Record<number, string>)[status] ??
(status === 1 || status === 2 ? 'ys-pill--wait' : '')
)
}
// ==================== 详情抽屉(节点时间线) ====================
const detailVisible = ref(false)
@ -156,7 +167,19 @@ async function submitAction(): Promise<void> {
<template>
<div class="page">
<el-form inline class="filters" @submit.prevent>
<div class="page-head">
<h1 class="page-title">审批管理</h1>
<p class="page-desc">
二次分配方案与月度结算的送审复核;送审内容为快照,与源单据当前值无关
</p>
<span class="waterline"></span>
</div>
<!--
.filter-bar 直接挂到 el-form 上:再包一层 div 会让 el-form-item
失去 gap 排版(el-form 不是 flex 透明容器)。
-->
<el-form class="filter-bar" @submit.prevent>
<el-form-item label="关键词">
<el-input
v-model="filters.keyword"
@ -182,70 +205,92 @@ async function submitAction(): Promise<void> {
<el-option label="调整单" :value="3" />
</el-select>
</el-form-item>
<el-form-item>
<div class="filter-bar__actions">
<el-button type="primary" @click="search">查询</el-button>
<el-button @click="resetFilters">重置</el-button>
</el-form-item>
</div>
</el-form>
<el-table v-loading="loading" :data="records" stripe>
<el-table-column prop="order_no" label="审批单号" min-width="150" />
<el-table-column label="类型" width="120">
<template #default="{ row }">{{ orderTypeLabel(row.order_type) }}</template>
</el-table-column>
<el-table-column label="版本" width="70" align="center">
<template #default="{ row }">V{{ row.version_no }}</template>
</el-table-column>
<el-table-column prop="refer_no" label="引用单号" min-width="140" />
<el-table-column label="状态" width="110">
<template #default="{ row }">
<el-tag :type="approvalStatusTagType(row.status)" size="small" disable-transitions>
{{ approvalStatusLabel(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="submitter_name" label="提交人" width="110" />
<el-table-column label="提交时间" min-width="150">
<template #default="{ row }">{{ formatDateTime(row.create_time) }}</template>
</el-table-column>
<el-table-column label="操作" width="90" fixed="right" align="center">
<template #default="{ row }">
<el-button link type="primary" @click="openDetail(row)">详情</el-button>
</template>
</el-table-column>
</el-table>
<div class="card">
<el-table v-loading="loading" :data="records" stripe>
<el-table-column label="审批单号" min-width="140">
<template #default="{ row }">
<span class="cell-code">{{ row.order_no }}</span>
</template>
</el-table-column>
<el-table-column label="类型" width="130">
<template #default="{ row }">{{ orderTypeLabel(row.order_type) }}</template>
</el-table-column>
<!-- 版本号与状态同屏:审批人必须能判断自己批的是哪一版,不能藏进 tooltip -->
<el-table-column label="版本" width="80">
<template #default="{ row }">
<span class="cell-num">v{{ row.version_no }}</span>
</template>
</el-table-column>
<el-table-column label="引用单号" min-width="140">
<template #default="{ row }">
<span v-if="row.refer_no" class="cell-code">{{ row.refer_no }}</span>
<span v-else class="cell-muted">—</span>
</template>
</el-table-column>
<el-table-column label="状态" width="130">
<template #default="{ row }">
<span class="ys-pill" :class="approvalPillClass(row.status)">
{{ approvalStatusLabel(row.status) }}
</span>
</template>
</el-table-column>
<el-table-column prop="submitter_name" label="提交人" width="110" />
<el-table-column label="提交时间" min-width="150">
<template #default="{ row }">
<span class="cell-muted">{{ formatDateTime(row.create_time) }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="90" fixed="right" align="center">
<template #default="{ row }">
<el-button link type="primary" @click="openDetail(row)">详情</el-button>
</template>
</el-table-column>
</el-table>
<AppPagination
:total="total"
:page-num="pageNum"
:page-size="pageSize"
@page-change="onPageChange"
@size-change="onSizeChange"
/>
<div class="card-foot">
<AppPagination
:total="total"
:page-num="pageNum"
:page-size="pageSize"
@page-change="onPageChange"
@size-change="onSizeChange"
/>
</div>
</div>
<el-drawer v-model="detailVisible" title="审批详情" size="720px">
<div v-loading="detailLoading">
<template v-if="detail">
<div class="detail-head">
<span class="detail-no">{{ detail.order_no }}</span>
<el-tag
:type="approvalStatusTagType(detail.status)"
size="small"
disable-transitions
>
<div class="panel-title">
<span class="panel-title__no">{{ detail.order_no }}</span>
<span class="ys-pill" :class="approvalPillClass(detail.status)">
{{ approvalStatusLabel(detail.status) }}
</el-tag>
</div>
<div class="detail-meta">
<span>类型:{{ orderTypeLabel(detail.order_type) }} · 版本 V{{ detail.version_no }}</span>
<span>引用单号:{{ detail.refer_no }}</span>
<span>提交人:{{ detail.submitter_name }}</span>
<span>提交时间:{{ formatDateTime(detail.create_time) }}</span>
</span>
</div>
<p class="panel-sub">送审内容为快照,与源单据当前值无关</p>
<dl class="kv">
<dt>类型</dt>
<dd>{{ orderTypeLabel(detail.order_type) }}</dd>
<dt>版本</dt>
<dd>v{{ detail.version_no }}</dd>
<dt>引用单号</dt>
<dd>{{ detail.refer_no || '—' }}</dd>
<dt>提交人</dt>
<dd>{{ detail.submitter_name }}</dd>
<dt>提交时间</dt>
<dd>{{ formatDateTime(detail.create_time) }}</dd>
</dl>
<!-- 审批内容:优先提交时快照;历史单缺快照时读当前并显式标注 -->
<div class="content-section">
<div class="content-title">审批内容</div>
<p class="section-title">审批内容</p>
<!-- 采集时间让复核者明确「看到的是哪个时点的内容」:快照=送审时刻;
live=打开详情时的当前内容(不做时刻展示——渲染函数里的
new Date() 非响应式且 toISOString 是 UTC,会差 8 小时) -->
@ -271,30 +316,26 @@ async function submitAction(): Promise<void> {
/>
<template v-if="allocationData">
<el-descriptions :column="2" border size="small" class="content-desc">
<el-descriptions-item label="业务编号">
{{ allocationData.business_no }}
</el-descriptions-item>
<el-descriptions-item label="业务日期">
{{ allocationData.business_date ?? '—' }}
</el-descriptions-item>
<el-descriptions-item label="客户名称">
{{ allocationData.customer_name ?? '—' }}
</el-descriptions-item>
<el-descriptions-item label="负责人员">
{{ allocationData.owner_personnel_name ?? '(待指定)' }}
</el-descriptions-item>
<el-descriptions-item label="销售额(元)">
{{ allocationData.sale_amount ?? '—' }}
</el-descriptions-item>
<el-descriptions-item label="成本(元)">
{{ allocationData.cost_amount ?? '—' }}
</el-descriptions-item>
</el-descriptions>
<dl class="kv">
<dt>业务编号</dt>
<dd>{{ allocationData.business_no }}</dd>
<dt>业务日期</dt>
<dd>{{ allocationData.business_date ?? '—' }}</dd>
<dt>客户名称</dt>
<dd>{{ allocationData.customer_name ?? '—' }}</dd>
<dt>负责人员</dt>
<dd>{{ allocationData.owner_personnel_name ?? '(待指定)' }}</dd>
<dt>销售额</dt>
<dd>{{ allocationData.sale_amount ? `${allocationData.sale_amount} 元` : '—' }}</dd>
<dt>成本</dt>
<dd>{{ allocationData.cost_amount ? `${allocationData.cost_amount} 元` : '—' }}</dd>
</dl>
<el-table :data="allocationData.allocations" stripe size="small" class="content-table">
<el-table-column prop="personnel_name" label="参与业务员" min-width="160" />
<el-table-column label="分配比例" width="120" align="right">
<template #default="{ row }">{{ row.ratio }}%</template>
<el-table-column label="分配比例" width="120">
<template #default="{ row }">
<span class="cell-num">{{ row.ratio }}%</span>
</template>
</el-table-column>
<template #empty>
<span class="content-empty">未设置二次分配 —— 全部绩效归负责营销人员</span>
@ -307,60 +348,63 @@ async function submitAction(): Promise<void> {
</template>
<template v-else-if="settlementData">
<el-descriptions :column="2" border size="small" class="content-desc">
<el-descriptions-item label="结算月份">
{{ settlementData.settlement_month }}
</el-descriptions-item>
<el-descriptions-item label="结算人数">
{{ settlementData.person_count }}
</el-descriptions-item>
<el-descriptions-item label="绩效合计(元)">
{{ settlementData.total_performance ?? '—' }}
</el-descriptions-item>
<el-descriptions-item label="应付合计(元)">
{{ settlementData.total_payable ?? '—' }}
</el-descriptions-item>
</el-descriptions>
<dl class="kv">
<dt>结算月份</dt>
<dd>{{ settlementData.settlement_month }}</dd>
<dt>核算人数</dt>
<dd>{{ settlementData.person_count }} 人</dd>
<dt>绩效合计</dt>
<dd>
{{
settlementData.total_performance ? `${settlementData.total_performance} 元` : '—'
}}
</dd>
<dt>应付合计</dt>
<dd>
{{ settlementData.total_payable ? `${settlementData.total_payable} 元` : '—' }}
</dd>
</dl>
<el-table :data="settlementData.items" stripe size="small" class="content-table">
<el-table-column prop="personnel_name" label="人员" min-width="110" />
<el-table-column prop="role_code" label="岗位" width="110" />
<el-table-column label="绩效(元)" width="110" align="right">
<template #default="{ row }">{{ row.performance_sum }}</template>
<el-table-column label="绩效(元)" width="120">
<template #default="{ row }">
<span class="cell-num">{{ row.performance_sum }}</span>
</template>
</el-table-column>
<el-table-column label="应付(元)" width="110" align="right">
<template #default="{ row }">{{ row.payable_sum }}</template>
<el-table-column label="应付(元)" width="120">
<template #default="{ row }">
<span class="cell-num">{{ row.payable_sum }}</span>
</template>
</el-table-column>
<el-table-column label="净工资(元)" width="120" align="right">
<template #default="{ row }">{{ row.net_amount ?? '—' }}</template>
<el-table-column label="净工资(元)" width="120">
<template #default="{ row }">
<span class="cell-num">{{ row.net_amount ?? '—' }}</span>
</template>
</el-table-column>
</el-table>
</template>
<template v-else-if="adjustmentData">
<el-descriptions :column="2" border size="small" class="content-desc">
<el-descriptions-item label="调整单号">
{{ adjustmentData.order_no }}
</el-descriptions-item>
<el-descriptions-item label="调整月份">
{{ adjustmentData.settlement_month }}
</el-descriptions-item>
<el-descriptions-item label="调整人员">
{{ adjustmentData.personnel_name }}
</el-descriptions-item>
<el-descriptions-item label="方向">
{{ adjustmentData.direction === 1 ? '补发' : '扣减' }}
</el-descriptions-item>
<el-descriptions-item label="金额(元)">
{{ adjustmentData.amount }}
</el-descriptions-item>
<el-descriptions-item label="扣补说明">
{{ adjustmentData.reason }}
</el-descriptions-item>
</el-descriptions>
<dl class="kv">
<dt>调整单号</dt>
<dd>{{ adjustmentData.order_no }}</dd>
<dt>调整月份</dt>
<dd>{{ adjustmentData.settlement_month }}</dd>
<dt>调整人员</dt>
<dd>{{ adjustmentData.personnel_name }}</dd>
<dt>方向</dt>
<dd>{{ adjustmentData.direction === 1 ? '补发' : '扣减' }}</dd>
<dt>金额</dt>
<dd>{{ adjustmentData.amount }} 元</dd>
<dt>扣补说明</dt>
<dd>{{ adjustmentData.reason }}</dd>
</dl>
</template>
</div>
<!-- 签名元素:竖排节点时间线(财务复核 → 领导审核的处理链) -->
<p class="section-title section-title--gap">处理记录</p>
<div class="timeline">
<div
v-for="step in detail.steps"
@ -373,7 +417,10 @@ async function submitAction(): Promise<void> {
class="timeline-dot"
:class="{ 'is-auto': step.action === 3, 'is-reject': step.action === 2 }"
></span>
<span v-if="step.id !== detail.steps[detail.steps.length - 1].id" class="timeline-line"></span>
<span
v-if="step.id !== detail.steps[detail.steps.length - 1].id"
class="timeline-line"
></span>
</div>
<div class="timeline-body">
<div class="timeline-head">
@ -396,7 +443,6 @@ async function submitAction(): Promise<void> {
<div class="action-bar">
<el-button
type="primary"
size="small"
:loading="actionSubmitting && actionForm.action === 1"
@click="openAction(detail, 1)"
>
@ -404,16 +450,17 @@ async function submitAction(): Promise<void> {
</el-button>
<el-button
type="danger"
plain
size="small"
:loading="actionSubmitting && actionForm.action === 2"
@click="openAction(detail, 2)"
>
驳回
</el-button>
</div>
<p class="action-hint">驳回需写明原因,将与审批记录一并留痕</p>
<el-form v-if="actingId === detail.id" label-position="top" class="action-form">
<el-form-item :label="actionForm.action === 2 ? '驳回意见(必填)' : '审批意见(选填)'">
<el-form-item
:label="actionForm.action === 2 ? '驳回意见(必填)' : '审批意见(选填)'"
>
<el-input
v-model="actionForm.opinion"
type="textarea"
@ -435,58 +482,77 @@ async function submitAction(): Promise<void> {
</template>
<style scoped>
.filters {
margin-bottom: 4px;
/* 版本/比例等数值列:全局 .cell-num 是给 td 设计的,行内 span 的
text-align 不生效,变块级才能右对齐(表头仍左对齐,与设计稿一致)。 */
.cell-num {
display: block;
}
.detail-head {
/* 抽屉头部:单号与状态同屏,标题 15px/600 与卡片标题同级 */
.panel-title {
display: flex;
align-items: center;
gap: 10px;
}
.detail-no {
.panel-title__no {
font-size: 15px;
font-weight: 600;
letter-spacing: -0.01em;
color: var(--ys-ink);
}
.detail-meta {
display: flex;
flex-direction: column;
gap: 2px;
padding: 10px 0 4px;
.panel-sub {
margin: 4px 0 0;
font-size: 12px;
color: var(--ys-ink-3);
}
.content-section {
margin-top: 12px;
padding: 12px;
background: #f7f8fa;
border: 1px solid var(--ys-line);
border-radius: 8px;
/* 键值两列:72px 键列放得下四个汉字,值列自适应余宽 */
.kv {
display: grid;
grid-template-columns: 72px 1fr;
gap: 8px 10px;
margin: 14px 0 0;
}
.content-title {
margin-bottom: 10px;
.kv dt {
font-size: 12px;
color: var(--ys-ink-3);
}
.kv dd {
margin: 0;
font-size: 13px;
font-weight: 600;
color: var(--ys-ink);
}
/* 审批内容块内的 kv 紧跟 .section-title,由标题的下边距负责间距,不再叠加 */
.content-section .kv {
margin-top: 0;
}
/* 分块标题之间补上间距;不改全局 .section-title,用局部修饰符叠加 */
.section-title--gap {
margin-top: 18px;
}
.content-section {
margin-top: 16px;
padding: 14px 16px;
background: var(--ys-mist);
border: 1px solid var(--ys-line);
border-radius: var(--ys-radius-sm);
}
.content-captured {
margin-bottom: 10px;
font-size: 12px;
color: var(--ys-ink-2);
}
.content-desc {
margin-bottom: 10px;
}
.content-table {
margin-top: 8px;
margin-top: 10px;
}
.content-total {
@ -502,7 +568,7 @@ async function submitAction(): Promise<void> {
/* 竖排时间线:左侧轨道 + 节点圆点,动作色区分人工/自动/驳回 */
.timeline {
margin-top: 14px;
margin-top: 12px;
}
.timeline-row {
@ -531,14 +597,14 @@ async function submitAction(): Promise<void> {
}
.timeline-dot.is-reject {
background: #c0392b;
background: var(--ys-danger);
}
.timeline-line {
flex: 1;
width: 1px;
min-height: 26px;
background: rgba(28, 35, 51, 0.14);
background: var(--ys-line);
}
.timeline-body {
@ -563,7 +629,7 @@ async function submitAction(): Promise<void> {
}
.timeline-action.is-reject-text {
color: #c0392b;
color: var(--ys-danger);
}
.timeline-meta {
@ -577,8 +643,8 @@ async function submitAction(): Promise<void> {
padding: 6px 10px;
font-size: 12px;
color: var(--ys-ink);
background: #f4f5f8;
border-radius: 6px;
background: var(--ys-mist);
border-radius: var(--ys-radius-sm);
}
.timeline-empty {
@ -590,13 +656,24 @@ async function submitAction(): Promise<void> {
color: var(--ys-ink-3);
}
/* 通过/驳回并排等宽:两个动作权重相同,审批人不必在大小按钮间做偏向性选择 */
.action-bar {
display: flex;
gap: 10px;
padding-top: 6px;
padding-top: 14px;
border-top: 1px solid var(--ys-line);
}
.action-bar :deep(.el-button) {
flex: 1;
}
.action-hint {
margin: 8px 0 0;
font-size: 12px;
color: var(--ys-ink-3);
}
.action-form {
margin-top: 12px;
}

View File

@ -207,42 +207,62 @@ onMounted(() => {
</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"
<div class="page">
<!-- 页面头:点明岗位是「公式模板 + 参数」的绑定关系,改它会牵动后续核算口径 -->
<div class="page-head">
<h1 class="page-title">岗位角色</h1>
<p class="page-desc">
决定人员适用哪套计提公式与参数;新增岗位只需选模板,不用改代码,改动只影响之后的核算。
</p>
<div class="waterline"></div>
</div>
<!--
筛选条:flex 与间距由 .filter-bar 提供,去掉 el-form 的 inline(inline 的行内块
排布会与 gap 叠成不等距);查询/重置由 .filter-bar__actions 推到行尾。
-->
<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 STATUS_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 STATUS_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</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>
<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>
</div>
<div class="card">
<el-table
v-loading="loading"
:data="records"
stripe
empty-text="还没有岗位角色,点「新增岗位」创建第一个"
>
<el-table-column prop="role_code" label="岗位编码" min-width="130">
<template #default="{ row }">
<span class="role-code">{{ row.role_code }}</span>
<!-- 岗位编码是参数维度值与模板绑定的锚点,等宽字体便于逐字符核对 -->
<span class="cell-code">{{ row.role_code }}</span>
</template>
</el-table-column>
<el-table-column prop="role_name" label="岗位名称" min-width="110" />
@ -252,7 +272,10 @@ onMounted(() => {
<el-table-column prop="description" label="说明" min-width="200">
<template #default="{ row }">{{ row.description || '—' }}</template>
</el-table-column>
<el-table-column prop="sort_order" label="排序" width="70" align="center" />
<!-- 排序是用于比较的数字:右对齐 + tabular-nums,纵向扫读时数位对齐 -->
<el-table-column label="排序" width="70" align="right">
<template #default="{ row }"><span class="cell-num">{{ row.sort_order }}</span></template>
</el-table-column>
<el-table-column label="状态" width="90" align="center">
<template #default="{ row }">
<StatusTag :value="row.status" />
@ -290,14 +313,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
@ -355,12 +380,7 @@ onMounted(() => {
</template>
<style scoped>
.role-code {
font-family: 'SFMono-Regular', Consolas, 'Courier New', monospace;
font-size: 12px;
color: var(--ys-ink-2);
}
/* 弹窗里「所选模板的说明与参数清单」:靛蓝浅底把这段提示与表单控件区分开 */
.tpl-info {
width: 100%;
padding: 8px 12px;
@ -371,7 +391,9 @@ onMounted(() => {
line-height: 1.7;
}
/* 参数清单比说明再弱一档,读者先看模板用途再看它要配哪些参数 */
.tpl-params {
color: var(--ys-ink-3);
}
</style>

View File

@ -170,11 +170,24 @@ watch(activeTab, (tab) => {
</script>
<template>
<el-card shadow="never">
<div class="page">
<!-- 页面头:说明日志是只读取证材料,并点明保留期由后端控制 -->
<div class="page-head">
<h1 class="page-title">日志查询</h1>
<p class="page-desc">
检索操作与登录痕迹,用于追责与排障;日志只读,超出保留期的记录由后端自动清理。
</p>
<div class="waterline"></div>
</div>
<el-tabs v-model="activeTab">
<!-- 操作日志 -->
<el-tab-pane label="操作日志" name="operation" lazy>
<el-form class="filter-form" :inline="true" @submit.prevent>
<!--
筛选条:flex 排布与间距由 .filter-bar 提供,故 el-form 去掉 inline;
查询/重置收进 .filter-bar__actions,由 margin-left:auto 推到行尾。
-->
<el-form class="filter-bar" @submit.prevent>
<el-form-item label="操作人">
<el-input
v-model="opFilters.username"
@ -215,77 +228,85 @@ watch(activeTab, (tab) => {
:default-time="RANGE_DEFAULT_TIME"
/>
</el-form-item>
<el-form-item>
<div class="filter-bar__actions">
<el-button type="primary" @click="searchOperationLogs">查询</el-button>
<el-button @click="resetOpFilters">重置</el-button>
</el-form-item>
</div>
</el-form>
<el-table
v-loading="opLoading"
:data="opRecords"
stripe
@expand-change="onExpandChange"
>
<el-table-column type="expand">
<template #default="{ row }">
<div class="log-detail">
<p>
<span class="log-detail__label">请求:</span>
{{ row.method || '—' }} {{ row.path || '—' }}
</p>
<p>
<span class="log-detail__label">User-Agent:</span>
{{ row.user_agent || '—' }}
</p>
<p class="log-detail__label">请求参数(已脱敏):</p>
<pre class="log-detail__data">{{ detailText(row.id) }}</pre>
</div>
</template>
</el-table-column>
<el-table-column label="时间" min-width="165">
<template #default="{ row }">{{ formatDateTime(row.create_time) }}</template>
</el-table-column>
<el-table-column prop="username" label="操作人" min-width="110" />
<el-table-column prop="module" label="模块" width="100" />
<el-table-column prop="action" label="操作" width="90" />
<el-table-column prop="summary" label="摘要" min-width="200" show-overflow-tooltip />
<el-table-column label="结果" width="80" align="center">
<template #default="{ row }">
<el-tooltip
:content="row.error_message ?? ''"
:disabled="row.success !== 0 || !row.error_message"
placement="top"
>
<StatusTag
:value="row.success"
active-text="成功"
inactive-text="失败"
inactive-type="danger"
/>
</el-tooltip>
</template>
</el-table-column>
<el-table-column label="耗时(ms)" width="90" align="right">
<template #default="{ row }">{{ row.duration_ms ?? '—' }}</template>
</el-table-column>
<el-table-column prop="ip" label="IP" min-width="120">
<template #default="{ row }">{{ row.ip || '—' }}</template>
</el-table-column>
</el-table>
<div class="card">
<el-table
v-loading="opLoading"
:data="opRecords"
stripe
empty-text="这段时间还没有操作记录,试试放宽筛选条件"
@expand-change="onExpandChange"
>
<el-table-column type="expand">
<template #default="{ row }">
<div class="log-detail">
<p>
<span class="log-detail__label">请求:</span>
{{ row.method || '—' }} {{ row.path || '—' }}
</p>
<p>
<span class="log-detail__label">User-Agent:</span>
{{ row.user_agent || '—' }}
</p>
<p class="log-detail__label">请求参数(已脱敏):</p>
<pre class="log-detail__data">{{ detailText(row.id) }}</pre>
</div>
</template>
</el-table-column>
<el-table-column label="时间" min-width="165">
<template #default="{ row }">{{ formatDateTime(row.create_time) }}</template>
</el-table-column>
<el-table-column prop="username" label="操作人" min-width="110" />
<el-table-column prop="module" label="模块" width="100" />
<el-table-column prop="action" label="操作" width="90" />
<el-table-column prop="summary" label="摘要" min-width="200" show-overflow-tooltip />
<el-table-column label="结果" width="80" align="center">
<template #default="{ row }">
<el-tooltip
:content="row.error_message ?? ''"
:disabled="row.success !== 0 || !row.error_message"
placement="top"
>
<StatusTag
:value="row.success"
active-text="成功"
inactive-text="失败"
inactive-type="danger"
/>
</el-tooltip>
</template>
</el-table-column>
<!-- 耗时是用于比较的数字:右对齐 + tabular-nums,慢请求一眼能扫出来 -->
<el-table-column label="耗时(ms)" width="90" align="right">
<template #default="{ row }">
<span class="cell-num">{{ row.duration_ms ?? '—' }}</span>
</template>
</el-table-column>
<el-table-column prop="ip" label="IP" min-width="120">
<template #default="{ row }">{{ row.ip || '—' }}</template>
</el-table-column>
</el-table>
<AppPagination
:total="opTotal"
:page-num="opPageNum"
:page-size="opPageSize"
@page-change="onOpPageChange"
@size-change="onOpSizeChange"
/>
<div class="card-foot">
<AppPagination
:total="opTotal"
:page-num="opPageNum"
:page-size="opPageSize"
@page-change="onOpPageChange"
@size-change="onOpSizeChange"
/>
</div>
</div>
</el-tab-pane>
<!-- 登录日志 -->
<el-tab-pane label="登录日志" name="login" lazy>
<el-form class="filter-form" :inline="true" @submit.prevent>
<el-form class="filter-bar" @submit.prevent>
<el-form-item label="用户名">
<el-input
v-model="loginFilters.username"
@ -305,7 +326,12 @@ watch(activeTab, (tab) => {
/>
</el-form-item>
<el-form-item label="结果">
<el-select v-model="loginFilters.success" placeholder="全部" clearable style="width: 110px">
<el-select
v-model="loginFilters.success"
placeholder="全部"
clearable
style="width: 110px"
>
<el-option
v-for="item in RESULT_OPTIONS"
:key="item.value"
@ -325,62 +351,77 @@ watch(activeTab, (tab) => {
:default-time="RANGE_DEFAULT_TIME"
/>
</el-form-item>
<el-form-item>
<div class="filter-bar__actions">
<el-button type="primary" @click="searchLoginLogs">查询</el-button>
<el-button @click="resetLoginFilters">重置</el-button>
</el-form-item>
</div>
</el-form>
<el-table v-loading="loginLoading" :data="loginRecords" stripe>
<el-table-column label="时间" min-width="165">
<template #default="{ row }">{{ formatDateTime(row.create_time) }}</template>
</el-table-column>
<el-table-column prop="username" label="用户名" min-width="130" />
<el-table-column label="结果" width="80" align="center">
<template #default="{ row }">
<StatusTag
:value="row.success"
active-text="成功"
inactive-text="失败"
inactive-type="danger"
/>
</template>
</el-table-column>
<el-table-column prop="fail_reason" label="失败原因" min-width="150">
<template #default="{ row }">{{ row.fail_reason || '—' }}</template>
</el-table-column>
<el-table-column prop="ip" label="IP" min-width="120">
<template #default="{ row }">{{ row.ip || '—' }}</template>
</el-table-column>
<el-table-column label="User-Agent" min-width="200">
<template #default="{ row }">
<span class="ua-cell" :title="row.user_agent || ''">{{ row.user_agent || '—' }}</span>
</template>
</el-table-column>
</el-table>
<div class="card">
<el-table
v-loading="loginLoading"
:data="loginRecords"
stripe
empty-text="这段时间还没有登录记录,试试放宽筛选条件"
>
<el-table-column label="时间" min-width="165">
<template #default="{ row }">{{ formatDateTime(row.create_time) }}</template>
</el-table-column>
<el-table-column prop="username" label="用户名" min-width="130" />
<el-table-column label="结果" width="80" align="center">
<template #default="{ row }">
<StatusTag
:value="row.success"
active-text="成功"
inactive-text="失败"
inactive-type="danger"
/>
</template>
</el-table-column>
<el-table-column prop="fail_reason" label="失败原因" min-width="150">
<template #default="{ row }">{{ row.fail_reason || '—' }}</template>
</el-table-column>
<el-table-column prop="ip" label="IP" min-width="120">
<template #default="{ row }">{{ row.ip || '—' }}</template>
</el-table-column>
<el-table-column label="User-Agent" min-width="200">
<template #default="{ row }">
<span class="ua-cell" :title="row.user_agent || ''">{{ row.user_agent || '—' }}</span>
</template>
</el-table-column>
</el-table>
<AppPagination
:total="loginTotal"
:page-num="loginPageNum"
:page-size="loginPageSize"
@page-change="onLoginPageChange"
@size-change="onLoginSizeChange"
/>
<div class="card-foot">
<AppPagination
:total="loginTotal"
:page-num="loginPageNum"
:page-size="loginPageSize"
@page-change="onLoginPageChange"
@size-change="onLoginSizeChange"
/>
</div>
</div>
</el-tab-pane>
</el-tabs>
</el-card>
</div>
</template>
<style scoped>
/*
* 展开行面板:表格单元格 padding 由全局统一为 12px 16px,这里再套一层容器,
* 所以上下压到 4px 即可,否则展开区会明显比行内容松散;文字用次级墨色,
* 与主行(--ys-ink)区分开,读起来是「附注」而非新的一条记录。
*/
.log-detail {
padding: 4px 16px;
color: var(--ys-text);
color: var(--ys-ink-2);
}
.log-detail__label {
font-weight: 600;
}
/* 脱敏后的 JSON 参数按等宽排版才能看出字段边界;边框用全局线色,避免与主题脱钩 */
.log-detail__data {
padding: 8px 12px;
margin: 6px 0 12px;
@ -388,10 +429,11 @@ watch(activeTab, (tab) => {
font-family: Consolas, 'Courier New', monospace;
font-size: 12px;
background-color: var(--ys-paper);
border: 1px solid #e5e7eb;
border: 1px solid var(--ys-line);
border-radius: 4px;
}
/* UA 串很长,单行省略 + title 悬浮看全文,避免撑高行或被裁成半截 */
.ua-cell {
display: inline-block;
max-width: 100%;
@ -400,4 +442,5 @@ watch(activeTab, (tab) => {
white-space: nowrap;
vertical-align: bottom;
}
</style>

View File

@ -431,94 +431,104 @@ onMounted(() => {
</script>
<template>
<div>
<el-card shadow="never">
<div class="page-toolbar">
<span class="page-desc">
计提比例、系数、阈值、分成与起付比例在此统一维护;变更逐项留痕并支持按日期生效。
</span>
<span>
<el-button @click="openPreviewDrawer">计算预览</el-button>
<el-button @click="openVersionDrawer">版本管理</el-button>
<el-button @click="doExport" :loading="exporting">导出记录</el-button>
</span>
<div class="page">
<!-- 页面头:这句描述原本挤在工具栏左侧当说明,现在归位成页面描述,语义更清楚 -->
<div class="page-head">
<h1 class="page-title">计提参数</h1>
<p class="page-desc">
计提比例、系数、阈值、分成与起付比例在此统一维护;变更逐项留痕并支持按日期生效。
</p>
<div class="waterline"></div>
</div>
<!--
主操作行:这页没有筛选条件(参数按分组浏览而非检索),所以只有工具按钮。
「计算预览」是管理员最常走的一步,用主按钮把它与另两个次要动作区分开。
-->
<div class="toolbar">
<el-button type="primary" @click="openPreviewDrawer">计算预览</el-button>
<el-button @click="openVersionDrawer">版本管理</el-button>
<el-button @click="doExport" :loading="exporting">导出记录</el-button>
</div>
<div class="card">
<div class="card-body">
<el-collapse v-loading="catalogLoading">
<el-collapse-item v-for="group in groupedCatalog" :key="group.name" :name="group.name">
<template #title>
<span class="group-title">{{ group.name }}</span>
</template>
<table class="param-table">
<thead>
<tr>
<th style="width: 190px">参数</th>
<th>当前生效值</th>
<th style="width: 110px">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="item in group.items" :key="item.param_code">
<td>
<div class="param-name">{{ item.param_name }}</div>
<div class="param-desc">{{ item.description || item.param_code }}</div>
</td>
<td>
<template v-if="item.values.length">
<div v-for="v in item.values" :key="v.dimension_value" class="value-row">
<span class="dim-label">{{ dimensionLabel(v.dimension_value) }}</span>
<b class="num">{{ formatValue(item, v.value_text) }}</b>
<span class="eff">{{ v.effective_date }} 起</span>
<el-tag
v-if="pendingOf(item, v.dimension_value)"
size="small"
type="warning"
disable-transitions
>
待改为 {{ pendingOf(item, v.dimension_value)!.new_value }}
</el-tag>
<el-button
v-if="isAdmin"
link
type="primary"
size="small"
@click="openEdit(item, v.dimension_value)"
>
改
</el-button>
</div>
</template>
<span v-else class="no-value">
未设置(默认 {{ item.default_value ?? '—' }})
</span>
</td>
<td>
<el-button
v-if="isAdmin && !item.values.length"
link
type="primary"
size="small"
@click="openEdit(item, '')"
>
设置
</el-button>
<el-button link type="primary" size="small" @click="openHistory(item)">历史</el-button>
</td>
</tr>
</tbody>
</table>
</el-collapse-item>
</el-collapse>
</div>
</div>
<el-collapse v-loading="catalogLoading">
<el-collapse-item v-for="group in groupedCatalog" :key="group.name" :name="group.name">
<template #title>
<span class="group-title">{{ group.name }}</span>
</template>
<table class="param-table">
<thead>
<tr>
<th style="width: 190px">参数</th>
<th>当前生效值</th>
<th style="width: 110px">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="item in group.items" :key="item.param_code">
<td>
<div class="param-name">{{ item.param_name }}</div>
<div class="param-desc">{{ item.description || item.param_code }}</div>
</td>
<td>
<template v-if="item.values.length">
<div v-for="v in item.values" :key="v.dimension_value" class="value-row">
<span class="dim-label">{{ dimensionLabel(v.dimension_value) }}</span>
<b class="num">{{ formatValue(item, v.value_text) }}</b>
<span class="eff">{{ v.effective_date }} 起</span>
<el-tag
v-if="pendingOf(item, v.dimension_value)"
size="small"
type="warning"
disable-transitions
>
待改为 {{ pendingOf(item, v.dimension_value)!.new_value }}
</el-tag>
<el-button
v-if="isAdmin"
link
type="primary"
size="small"
@click="openEdit(item, v.dimension_value)"
>
改
</el-button>
</div>
</template>
<span v-else class="no-value">
未设置(默认 {{ item.default_value ?? '—' }})
</span>
</td>
<td>
<el-button
v-if="isAdmin && !item.values.length"
link
type="primary"
size="small"
@click="openEdit(item, '')"
>
设置
</el-button>
<el-button link type="primary" size="small" @click="openHistory(item)">历史</el-button>
</td>
</tr>
</tbody>
</table>
</el-collapse-item>
</el-collapse>
</el-card>
<!-- 待提交批次(仅管理员可见可操作) -->
<el-card v-if="isAdmin && pendingList.length" shadow="never" class="pending-card">
<template #header>
<!-- 待提交批次(仅管理员可见可操作):提交前只是暂存,故用独立的次级卡片承载 -->
<div v-if="isAdmin && pendingList.length" class="card pending-card">
<div class="card-head">
<div class="pending-head">
<b>待提交变更({{ pendingList.length }} 项)</b>
<span class="pending-hint">提交前不会生效;提交后未生效前可整批撤回</span>
</div>
</template>
</div>
<el-table :data="pendingList" size="small">
<el-table-column label="参数" min-width="160">
<template #default="{ row }">{{ row.item.param_name }}</template>
@ -538,28 +548,30 @@ onMounted(() => {
</template>
</el-table-column>
</el-table>
<div class="submit-row">
<el-radio-group v-model="changeType">
<el-radio-button :value="1">立即生效</el-radio-button>
<el-radio-button :value="2">指定日期生效</el-radio-button>
</el-radio-group>
<el-date-picker
v-if="changeType === 2"
v-model="effectiveDate"
type="date"
value-format="YYYY-MM-DD"
placeholder="生效日期"
style="width: 150px"
/>
<el-input
v-model="remark"
placeholder="变更说明(如:四季度系数调整)"
style="width: 240px"
maxlength="255"
/>
<el-button type="primary" :loading="submitting" @click="submitAll">提交变更</el-button>
<div class="card-foot">
<div class="submit-row">
<el-radio-group v-model="changeType">
<el-radio-button :value="1">立即生效</el-radio-button>
<el-radio-button :value="2">指定日期生效</el-radio-button>
</el-radio-group>
<el-date-picker
v-if="changeType === 2"
v-model="effectiveDate"
type="date"
value-format="YYYY-MM-DD"
placeholder="生效日期"
style="width: 150px"
/>
<el-input
v-model="remark"
placeholder="变更说明(如:四季度系数调整)"
style="width: 240px"
maxlength="255"
/>
<el-button type="primary" :loading="submitting" @click="submitAll">提交变更</el-button>
</div>
</div>
</el-card>
</div>
<!-- 修改参数 -->
<el-dialog v-model="editVisible" title="修改参数" width="440px" destroy-on-close>
@ -809,17 +821,7 @@ onMounted(() => {
</template>
<style scoped>
.page-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 14px;
}
.page-desc {
font-size: 13px;
color: var(--ys-ink-2);
}
/* 页面里已经没有 .page-toolbar/.page-desc —— 描述与工具栏改用全局 .page-head/.toolbar */
.group-title {
font-weight: 600;
@ -885,6 +887,7 @@ onMounted(() => {
font-size: 12px;
}
/* 待提交卡片紧跟目录卡,需要与上方卡片拉开距离(.card 自身不带外边距) */
.pending-card {
margin-top: 14px;
}
@ -900,11 +903,14 @@ onMounted(() => {
color: var(--ys-ink-3);
}
/*
* 提交区已放进 .card-foot:底栏自带 10px 16px 内边距与右对齐,
* 所以这里去掉原先的 margin-top,否则会与底栏留白叠成双份。
*/
.submit-row {
display: flex;
gap: 10px;
align-items: center;
margin-top: 14px;
}
.edit-name {

View File

@ -170,43 +170,69 @@ onMounted(load)
</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: 200px"
@keyup.enter="search"
<div class="page">
<!-- 页面头:说明这页管授权基线,并点出两条会挡住操作的保护规则 -->
<div class="page-head">
<h1 class="page-title">角色管理</h1>
<p class="page-desc">
决定账号能进哪些功能;内置 admin 不可删停,仍有用户绑定的角色需先解绑才能删除。
</p>
<div class="waterline"></div>
</div>
<!--
筛选条:.filter-bar 自带 flex + gap + 内边距,故 el-form 不再写 inline;
查询/重置收进 .filter-bar__actions,靠 margin-left:auto 贴到行尾。
-->
<el-form class="filter-bar" @submit.prevent>
<el-form-item label="关键词">
<el-input
v-model="filters.keyword"
placeholder="编码或名称,模糊匹配"
clearable
style="width: 200px"
@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 STATUS_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 STATUS_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</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>
<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>
</div>
<div class="card">
<el-table
v-loading="loading"
:data="records"
stripe
empty-text="还没有角色,点「新增角色」创建第一个"
>
<el-table-column label="编码" min-width="150">
<template #default="{ row }">
<span class="role-code">{{ row.role_code }}</span>
<el-tag v-if="isBuiltin(row)" class="builtin-tag" size="small" type="warning" disable-transitions>
<!-- 角色编码是授权判定的锚点,等宽字体便于逐字符核对 -->
<span class="cell-code">{{ row.role_code }}</span>
<el-tag
v-if="isBuiltin(row)"
class="builtin-tag"
size="small"
type="warning"
disable-transitions
>
内置
</el-tag>
</template>
@ -215,14 +241,17 @@ onMounted(load)
<el-table-column prop="description" label="说明" min-width="180">
<template #default="{ row }">{{ row.description || '—' }}</template>
</el-table-column>
<el-table-column prop="sort_order" label="排序" width="80" align="center" />
<!-- 排序值与用户数都是用于比较的数字:右对齐 + tabular-nums,纵向扫读时数位对齐 -->
<el-table-column label="排序" width="80" align="right">
<template #default="{ row }"><span class="cell-num">{{ row.sort_order }}</span></template>
</el-table-column>
<el-table-column label="状态" width="90" align="center">
<template #default="{ row }">
<StatusTag :value="row.status" />
</template>
</el-table-column>
<el-table-column label="用户数" width="90" align="center">
<template #default="{ row }">{{ row.user_count }}</template>
<el-table-column label="用户数" width="90" align="right">
<template #default="{ row }"><span class="cell-num">{{ row.user_count }}</span></template>
</el-table-column>
<el-table-column label="操作" width="170" fixed="right" align="center">
<template #default="{ row }">
@ -256,14 +285,16 @@ onMounted(load)
</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
v-model="dialogVisible"
@ -298,11 +329,9 @@ onMounted(load)
</template>
<style scoped>
.role-code {
font-family: Consolas, 'Courier New', monospace;
}
/* 「内置」标记跟在编码后面,需要一个横向间距;.el-tag 自身不带外边距 */
.builtin-tag {
margin-left: 6px;
}
</style>

View File

@ -91,71 +91,89 @@ const previewName = computed(() => form.company_name.trim() || '衍水集团')
<template>
<div class="page">
<!-- 页面头:这是表单页不是列表页,所以没有筛选条,只有标题 + 影响面说明 -->
<div class="page-head">
<h1 class="page-title">公司信息</h1>
<p class="page-desc">维护公司名称与 logo;保存后立即作用于登录页品牌位与导出页眉。</p>
<div class="waterline"></div>
</div>
<div class="settings-grid">
<el-card shadow="never" class="settings-card">
<template #header>
<span class="card-title">公司信息</span>
</template>
<div class="card settings-card">
<!-- 卡片标题刻意不写「公司信息」:页面标题已经说明了,再重复一遍只是噪音 -->
<div class="card-head">
<span class="card-title">基础信息</span>
</div>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-width="88px"
@submit.prevent
>
<el-form-item label="公司名称" prop="company_name">
<el-input
v-model="form.company_name"
maxlength="64"
placeholder="展示在登录页与导出页眉"
clearable
/>
</el-form-item>
<div class="card-body">
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-width="88px"
@submit.prevent
>
<el-form-item label="公司名称" prop="company_name">
<el-input
v-model="form.company_name"
maxlength="64"
placeholder="展示在登录页与导出页眉"
clearable
/>
</el-form-item>
<el-form-item label="logo">
<div class="logo-field">
<el-upload
class="logo-uploader"
:show-file-list="false"
:http-request="handleUpload"
:before-upload="beforeUpload"
accept="image/png,image/jpeg"
>
<div v-loading="uploading" class="logo-box" title="点击上传 png / jpg(≤2MB)">
<img v-if="logoUrl" :src="logoUrl" alt="公司 logo" />
<span v-else class="logo-plus">+</span>
</div>
</el-upload>
<p class="logo-hint">png / jpg,不超过 2MB;<br />展示于登录页品牌位与导出页眉。</p>
</div>
</el-form-item>
<el-form-item label="logo">
<div class="logo-field">
<el-upload
class="logo-uploader"
:show-file-list="false"
:http-request="handleUpload"
:before-upload="beforeUpload"
accept="image/png,image/jpeg"
>
<div v-loading="uploading" class="logo-box" title="点击上传 png / jpg(≤2MB)">
<img v-if="logoUrl" :src="logoUrl" alt="公司 logo" />
<span v-else class="logo-plus">+</span>
</div>
</el-upload>
<p class="logo-hint">png / jpg,不超过 2MB;<br />展示于登录页品牌位与导出页眉。</p>
</div>
</el-form-item>
</el-form>
</div>
<el-form-item>
<el-button type="primary" :loading="submitting" @click="handleSubmit">保存</el-button>
</el-form-item>
</el-form>
</el-card>
<!--
保存按钮移进 card-foot:表单项里的按钮会跟着 label 列缩进、并对齐到输入框左缘,
作为整张表单的收尾动作用卡片底栏右对齐更符合阅读顺序。
按钮在 form 之外仍安全:handleSubmit 走 formRef 校验,且 el-button 默认 type="button",
不会触发浏览器原生提交。
-->
<div class="card-foot">
<el-button type="primary" :loading="submitting" @click="handleSubmit">保存</el-button>
</div>
</div>
<!-- 签名元素:登录页实景预览(缩比登录卡,品牌名/logo 即时反映) -->
<el-card shadow="never" class="preview-card">
<template #header>
<div class="card preview-card">
<div class="card-head">
<span class="card-title">登录页预览</span>
<span class="preview-note">保存后对所有人生效</span>
</template>
<div class="login-mock">
<div class="mock-brand">
<img v-if="logoUrl" :src="logoUrl" alt="" class="mock-logo" />
<div class="mock-name">{{ previewName }}</div>
<p class="mock-slogan">把每一笔回款,算成一份踏实。</p>
<span class="mock-waterline"></span>
</div>
<div class="mock-field"></div>
<div class="mock-field"></div>
<div class="mock-submit">登 录</div>
</div>
<p class="preview-caption">登录页品牌位实景(示意,非交互)</p>
</el-card>
<div class="card-body">
<div class="login-mock">
<div class="mock-brand">
<img v-if="logoUrl" :src="logoUrl" alt="" class="mock-logo" />
<div class="mock-name">{{ previewName }}</div>
<p class="mock-slogan">把每一笔回款,算成一份踏实。</p>
<span class="mock-waterline"></span>
</div>
<div class="mock-field"></div>
<div class="mock-field"></div>
<div class="mock-submit">登 录</div>
</div>
<p class="preview-caption">登录页品牌位实景(示意,非交互)</p>
</div>
</div>
</div>
</div>
</template>
@ -168,12 +186,11 @@ const previewName = computed(() => form.company_name.trim() || '衍水集团')
align-items: start;
}
.card-title {
font-weight: 600;
}
/*
* 说明文字不再用 float:right 顶到卡片头右侧 —— .card-head 已是 flex +
* space-between,第二个子元素自然落在右端,float 反而会破坏行内布局。
*/
.preview-note {
float: right;
font-size: 12px;
color: var(--ys-ink-3);
}

View File

@ -250,58 +250,77 @@ onMounted(() => {
</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.username"
placeholder="模糊匹配"
clearable
style="width: 160px"
@keyup.enter="search"
/>
</el-form-item>
<el-form-item label="姓名">
<el-input
v-model="filters.real_name"
placeholder="模糊匹配"
clearable
style="width: 140px"
@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 STATUS_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="角色">
<el-select v-model="filters.role_code" placeholder="全部" clearable style="width: 150px">
<el-option
v-for="role in roleOptions"
:key="role.role_code"
:label="role.role_name"
:value="role.role_code"
/>
</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="page">
<!-- 页面头:标题 + 一句话说清「管什么、动它会波及什么」+ 签名水线(每页仅此一处) -->
<div class="page-head">
<h1 class="page-title">用户管理</h1>
<p class="page-desc">管登录账号与角色分配;禁用、改密或重置密码都会让该用户已登录的会话立即失效。</p>
<div class="waterline"></div>
</div>
<div class="toolbar">
<el-button type="primary" @click="openCreate">新增用户</el-button>
<!--
筛选条:.filter-bar 自身就是 flex 行(含 gap 与内边距),所以去掉 el-form 的 inline。
inline 模式靠行内块排布,会带基线下沉与固定 right-margin,与 gap 叠成不等距;
查询/重置收进 .filter-bar__actions,由 margin-left:auto 推到行尾。
-->
<el-form class="filter-bar" @submit.prevent>
<el-form-item label="用户名">
<el-input
v-model="filters.username"
placeholder="模糊匹配"
clearable
style="width: 160px"
@keyup.enter="search"
/>
</el-form-item>
<el-form-item label="姓名">
<el-input
v-model="filters.real_name"
placeholder="模糊匹配"
clearable
style="width: 140px"
@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 STATUS_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="角色">
<el-select v-model="filters.role_code" placeholder="全部" clearable style="width: 150px">
<el-option
v-for="role in roleOptions"
:key="role.role_code"
:label="role.role_name"
:value="role.role_code"
/>
</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>
</div>
<!-- 表格卡片:表皮与筛选条同材质但更高一档;分页落进 card-foot,与表体共用同一张卡 -->
<div class="card">
<el-table
v-loading="loading"
:data="records"
stripe
empty-text="还没有用户,点「新增用户」创建第一个账号"
>
<el-table-column prop="username" label="用户名" min-width="120" />
<el-table-column prop="real_name" label="姓名" min-width="100">
<template #default="{ row }">{{ row.real_name || '—' }}</template>
@ -325,6 +344,7 @@ onMounted(() => {
</el-table-column>
<el-table-column label="状态" width="90" align="center">
<template #default="{ row }">
<!-- 状态统一交给 StatusTag(内部已是 ys-pill),页面不再自造标签形态 -->
<StatusTag :value="row.status" />
</template>
</el-table-column>
@ -366,14 +386,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
@ -459,9 +481,11 @@ onMounted(() => {
<style scoped>
.role-tag {
/* 一个用户可能有多个角色,标签并排时需要横向间距(.el-tag 自身不带) */
margin-right: 4px;
}
.reset-tip {
margin: 0 0 16px;
color: var(--ys-text);