fix(核算): 对齐抵顶口径并完善界面细节
回款确认时间收敛到秒级;抵顶回款不再自动折算并保留标记。补齐公式模板角色标签与提示、指导成本登记入口及基础布局、分页、设置等响应式细节。同步忽略本地排查产物。
This commit is contained in:
1 parent
35fea3802f
commit
b3de35c2ec
53 files changed
+1381
-362
No files matched your search
@@ -1,5 +1,43 @@
|
||||
# 前端
|
||||
|
||||
## 工作台、同步页与进度动效(2026-11-03)
|
||||
|
||||
工作台与 T+ 数据同步页沿用应用内容区全宽,桌面保持 22px 常规留白,与用户管理等页面对齐。工作台改为连续指标区,收紧待办与风险空态,采集状态集中展示,图表缺失月份保留空缺。同步页整理计划、记录、资料导入与关联核验,核验明细与单据分布分开呈现。
|
||||
|
||||
进度区分排队、读取或核验、处理结束三个阶段。已知总量按实际百分比以 `scaleX` 过渡;未知总量的轨道保持静止,仅执行指示点淡入淡出。核验没有数量时显示“核验中”。完成、异常或状态更新中断后停止执行动画,减少动态效果模式下停用动画和过渡;任务详情继续只显示所选任务的依据,不重复大进度条。
|
||||
|
||||
从项目根执行 `node frontend/e2e/operations_pages.mjs`。本轮 8 组合成 API 回归通过,覆盖两页 320 至 2560px 十二档宽度、68 组页面与弹层布局、七种任务状态、动态独立分页、关联核验、四类工作区、创建范围、计划开关、只读限制及失联恢复。实测比例由 40% 更新至 70%,执行点透明度变化且在减少动态效果时停用;页面错误和布局溢出均为 0,类型检查及生产构建通过。
|
||||
|
||||
证据在 `.ai/operations-pages-20261103/`,设计约定见 `docs/design/operations/DESIGN.md`。接口全部拦截为合成响应,未触发真实采集、自动计划设置、资料导入或工资生成。可用 `OPS_UI_URL`、`OPS_UI_OUTPUT` 和 `PLAYWRIGHT_MODULE` 调整验收环境。
|
||||
|
||||
## 计提参数整页打磨(2026-11-03)
|
||||
|
||||
页面按产品系数、计提规则、待提交变更组织,版本管理、导出记录和计算预览入口放到页头。系数表按连续的产品类别与户型合并重复单元格,精确分数仍直接展示;工具栏与表格保持 16px 间距。
|
||||
|
||||
规则支持按名称、编码、说明或分组搜索,以及全部展开/收起。窄屏纵向展示规则详情,待提交变更直接呈现原值和新值;版本、历史与试算抽屉提供独立关闭区,试算操作固定在底部。规则与系数加载失败时分别提示并支持重试,读取和修改权限沿用现有约定。
|
||||
|
||||
从项目根执行 `node frontend/e2e/params_page.mjs`。合成 API 回归通过,覆盖 320 至 2560px 九档宽度、21 组页面与弹层布局检查,以及搜索、暂存不写入、版本对比、试算、中文导入预览、故障恢复和只读限制。页面错误 0,类型检查及生产构建通过。截图与结果位于 `.ai/params-page-20261103/`;未写真实参数或工资。
|
||||
|
||||
## 全站界面打磨(2026-11-03)
|
||||
|
||||
沿用灰白底与靛蓝重点色,统一页面标题、说明、筛选区、表单分组、状态对比度和键盘焦点。产品、人员、客户、业务和用户档案按内容分组;弹窗明确区分标题、可滚动正文和固定操作区,常用编辑弹窗提交中禁止关闭。
|
||||
|
||||
表格补齐长字段提示、金额与数量右对齐,修正操作列宽度。窄屏筛选控件铺满行,分页的前后按钮与页码保持同行,表格操作列随表格横向滚动;日期范围双月改为纵向排列并在视口内滚动。14 个列表页接入持续错误提示和重新加载。工作台手机指标采用两列,月结主流程位于年度结清之前,参数目录保留可读列宽,四种打印预览支持独立横向查看。
|
||||
|
||||
登录页支持验证码加载失败后重试;无权限页面重新检查成功后跳转可访问页面。产品与客户的唯一组合条件保留为字段下方说明,金额仍按接口字符串展示。
|
||||
|
||||
回归命令从项目根执行:
|
||||
|
||||
```bash
|
||||
node frontend/e2e/ui_polish.mjs
|
||||
node frontend/e2e/ledger-dialog-regression.mjs
|
||||
node frontend/e2e/authorization_roles.mjs
|
||||
```
|
||||
|
||||
`ui_polish.mjs` 使用合成 API,覆盖 22 个主页面的 1440/768/390/320px 布局、档案与专项弹层、产品提取、四类打印预览、列表失败重试、授权刷新和验证码故障恢复。可通过 `UI_POLISH_URL`、`UI_POLISH_OUTPUT`、`PLAYWRIGHT_MODULE` 指定前端地址、证据目录与浏览器依赖。本轮三组浏览器回归通过,页面脚本错误和检测到的横向溢出均为 0;类型检查、生产构建与 Impeccable 静态扫描通过。证据在 `.ai/ui-polish-20261103/`。
|
||||
|
||||
本轮浏览器验收拦截全部业务 API,不修改真实账号、业务记录或工资,不替代真实金额联调。当前本机 Rolldown 可选依赖缺少包入口文件,验证时临时设置 `NAPI_RS_NATIVE_LIBRARY_PATH` 指向已有原生二进制,未改依赖版本或锁文件;构建仍有部分分包超过 500kB 的体积提示。
|
||||
|
||||
## 产品、人员及首页数据体验(2026-11-02)
|
||||
|
||||
产品提取入口移到操作栏,来源空值改为明确说明;产品和人员长标签、日期控件及手机布局已修复。人员经理参照只列在职且绑定启用经理计提模板的人员,旧参考关系保留;实际绩效仍按业务分管。岗位编辑说明内置模板与参数维护的区别,提供参数入口。首页以趋势金额是否为 `null` 区分尚无台账/回款与真实零值,并说明正式导入和核算步骤。
|
||||
@@ -332,3 +370,26 @@ E2E_ADMIN_PASSWORD=<开发库admin密码> python e2e/system_management.py
|
||||
业务列表增加正式核算,台账可看逐段依据和创建当月分配调整;T+页面增加正式业务导入预览/执行。会话返回can_write_payroll控制按钮,后端仍逐请求校验指定制作人。审核能力默认关闭,不展示提交审核步骤或审批单列;开启后原节点可用。
|
||||
|
||||
本轮vue-tsc与Vite构建通过;真实浏览器使用合成接口验证资料表单、封存确认请求及768宽弹窗、默认无审核入口,控制台错误0。界面检查不替代后端真实金额与客户样本验收;详情见 `docs/正式工资闭环开发记录_2026-10-30.md`。
|
||||
|
||||
|
||||
## 更正单成对处理(2026-11-03)
|
||||
|
||||
T+ 数据应用工作区的补充资料增加“更正单成对处理”。运行 `node frontend/e2e/correction-pairs.mjs`(项目根)隔离验证真实组件,合成接口不访问业务库;4 组检查覆盖必填、完整成对提交、只读和 1440/768/390 布局,脚本错误 0,类型及构建通过。后台真实链路验证另见交付记录。
|
||||
|
||||
|
||||
## 产品提取按钮禁用原因(2026-11-03)
|
||||
|
||||
“确认执行提取”须在勾选明细并成功预览后启用,管理员同样遵循该流程。弹窗已增加操作步骤及按钮旁的具体原因,区分未选、未预览、处理中、已完成与真实权限不足。前端构建通过;隔离 Edge 的 5 组检查通过,覆盖管理员预览后可执行、执行后防重、权限说明及 1440/768/390 三宽度,脚本错误 0。证据 `.ai/product-extract-disabled-20261103/result.json`;仅合成接口,未修改真实产品。
|
||||
|
||||
## 基础导入明细展示(2026-11-03)
|
||||
|
||||
人员、产品、客户基础任务显示源编号、名称、处理结果及原因,产品额外显示规格和单位;金额、发货单、回款天数仅在核算类明细显示。跳过明确区分名单外记录与已导入无变化,旧任务缺少名称时由后端从原始快照补齐展示。过期导入提供“重新生成预览”,沿用原范围,不自动执行导入。
|
||||
|
||||
运行 `node frontend/e2e/workbench-details.mjs` 验证真实组件;8 组隔离接口检查覆盖人员/产品/客户、核算列、筛选、原范围重建、过期恢复、明细失败恢复及 1440/768/390 三宽度,结果位于 `.ai/workbench-details-browser/`。已另在实际 T+ 页面只读确认 2026-10-29 历史人员任务的姓名、工号和跳过原因。
|
||||
|
||||
|
||||
## 历史岗位资料(2026-11-03)
|
||||
|
||||
T+ 数据同步 → 资料与导入 → 补充资料 → 人员名单,支持保留当前岗位并按开始、结束月份追加已确认历史岗位;起止月份均包含在内,区间重叠由后端拒绝。月份未填完整时前端阻止提交,保存沿用资料版本和权限。客户公司负责人姓名在源导入资料中可暂缺,人工新建客户的必填规则不变。
|
||||
|
||||
浏览器隔离组件验证通过:月份回显、完整性校验、实际提交内容、只读保护和 1440/390 宽度,页面错误 0;类型与生产构建通过。真实样本 MySQL 全链路与界面合成接口验证分别记录于 `docs/真实样本业务闭环验收_2026-11-03.md`,证据 `.ai/liaoning-full-chain-20261103/`。
|
||||
@@ -123,6 +123,10 @@ export interface FormulaOption {
|
||||
name: string
|
||||
description: string
|
||||
roles: string[]
|
||||
/** 角色 → 中文标签(模板表单展示) */
|
||||
role_labels: Record<string, string>
|
||||
/** 角色 → 业务口径提示(placeholder) */
|
||||
role_hints: Record<string, string>
|
||||
}
|
||||
|
||||
export interface CalcTemplateCreateParams {
|
||||
|
||||
@@ -143,6 +143,10 @@ export function confirmMissingSourcePrice(id: number, documentId: number, data:
|
||||
return request({ url: `/v1/business/businesses/${id}/source-documents/${documentId}/missing-price`, method: 'put', data })
|
||||
}
|
||||
|
||||
export function confirmGuidance(id: number, data: { shipment_item_id: number; amount: string; evidence: string }): Promise<{ status: 'CREATED' | 'REUSED'; fact_id: number; product_id: string; month: string; unit: string; amount: string }> {
|
||||
return request({ url: `/v1/business/businesses/${id}/guidance`, method: 'put', data })
|
||||
}
|
||||
|
||||
export interface SourceQuantity {
|
||||
product_id: number | null
|
||||
product_name: string
|
||||
|
||||
@@ -211,15 +211,16 @@ body {
|
||||
|
||||
.page-title {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--ys-ink);
|
||||
}
|
||||
|
||||
.page-desc {
|
||||
margin: 6px 0 0;
|
||||
font-size: 12px;
|
||||
margin: 8px 0 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
letter-spacing: 0.01em;
|
||||
color: var(--ys-ink-3);
|
||||
}
|
||||
@@ -249,6 +250,7 @@ body {
|
||||
|
||||
/* 卡片:纸白微渐变 + 半透明描边 + 双层柔影 */
|
||||
.card {
|
||||
min-width: 0;
|
||||
background: linear-gradient(180deg, var(--ys-paper), #fcfcfe);
|
||||
border: 1px solid var(--ys-line);
|
||||
border-radius: var(--ys-radius);
|
||||
@@ -257,6 +259,7 @@ body {
|
||||
|
||||
.card-head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
@@ -276,6 +279,8 @@ body {
|
||||
|
||||
.card-foot {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding: 10px 16px;
|
||||
@@ -299,6 +304,7 @@ body {
|
||||
}
|
||||
|
||||
.filter-bar .el-form-item {
|
||||
min-width: 0;
|
||||
margin-right: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
@@ -319,10 +325,20 @@ body {
|
||||
}
|
||||
|
||||
.toolbar-note {
|
||||
line-height: 1.7;
|
||||
font-size: 12px;
|
||||
color: var(--ys-ink-3);
|
||||
}
|
||||
|
||||
.summary-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 8px 20px;
|
||||
line-height: 1.7;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* 编号/代码类单元格:等宽字体,弱一档颜色 */
|
||||
.cell-code {
|
||||
font-family: var(--ys-font-mono);
|
||||
@@ -388,13 +404,13 @@ span.cell-num {
|
||||
}
|
||||
|
||||
.ys-pill--ok {
|
||||
color: var(--ys-ok);
|
||||
color: #18764f;
|
||||
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);
|
||||
color: #895d12;
|
||||
background: linear-gradient(180deg, var(--ys-warn-wash), var(--ys-warn-wash-2));
|
||||
border-color: rgba(201, 138, 27, 0.22);
|
||||
}
|
||||
@@ -406,7 +422,7 @@ span.cell-num {
|
||||
}
|
||||
|
||||
.ys-pill--reject {
|
||||
color: var(--ys-danger);
|
||||
color: #ac3d3d;
|
||||
background: linear-gradient(180deg, var(--ys-danger-wash), var(--ys-danger-wash-2));
|
||||
border-color: rgba(201, 79, 79, 0.2);
|
||||
}
|
||||
@@ -569,7 +585,7 @@ span.cell-num {
|
||||
padding: 10px 0;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.04em;
|
||||
letter-spacing: 0.01em;
|
||||
background-color: transparent;
|
||||
background-image: linear-gradient(180deg, #fafbfc, #f5f6f9);
|
||||
}
|
||||
@@ -589,6 +605,39 @@ span.cell-num {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.el-table th.el-table__cell .cell {
|
||||
word-break: normal;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.el-table td.is-right .cell,
|
||||
.el-descriptions__content,
|
||||
.el-statistic__content {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.el-table .el-input__inner {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.el-table .el-table__empty-block {
|
||||
min-height: 144px;
|
||||
}
|
||||
|
||||
.el-table .el-table__empty-text {
|
||||
width: min(100%, 440px);
|
||||
padding: 24px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.el-table .el-scrollbar__bar.is-horizontal { opacity: 1; height: 8px; }
|
||||
|
||||
.el-alert--info.is-light { color: var(--ys-ink-2); }
|
||||
.el-alert--warning.is-light { color: #895d12; }
|
||||
.el-alert--success.is-light { color: #18764f; }
|
||||
.el-alert--error.is-light { color: #ac3d3d; }
|
||||
.el-alert .el-alert__description { color: inherit; line-height: 1.7; }
|
||||
|
||||
.el-table__body tr:hover > td.el-table__cell {
|
||||
transition: background-color 120ms var(--ys-ease);
|
||||
}
|
||||
@@ -634,7 +683,19 @@ span.cell-num {
|
||||
}
|
||||
|
||||
.el-drawer__body {
|
||||
min-height: 0;
|
||||
padding: 18px 20px;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.el-drawer__footer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
padding: 16px 20px;
|
||||
border-top: 1px solid var(--ys-line-soft);
|
||||
background: var(--ys-mist);
|
||||
}
|
||||
|
||||
/* 弹窗共享同一组留白;长表单在正文内滚动,操作按钮始终可见。 */
|
||||
@@ -654,7 +715,8 @@ span.cell-num {
|
||||
.el-dialog__header {
|
||||
flex-shrink: 0;
|
||||
margin-right: 0;
|
||||
padding: 20px 52px 16px 24px;
|
||||
padding: 20px 56px 18px 24px;
|
||||
border-bottom: 1px solid var(--ys-line-soft);
|
||||
}
|
||||
|
||||
.el-dialog__title {
|
||||
@@ -662,6 +724,7 @@ span.cell-num {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
line-height: 1.5;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.el-dialog__headerbtn {
|
||||
@@ -673,10 +736,11 @@ span.cell-num {
|
||||
|
||||
.el-dialog__body {
|
||||
min-height: 0;
|
||||
padding: 4px 24px 20px;
|
||||
padding: 20px 24px;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
color: var(--ys-ink-2);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.el-dialog__footer {
|
||||
@@ -703,6 +767,13 @@ span.cell-num {
|
||||
|
||||
.el-message-box {
|
||||
max-width: min(var(--el-messagebox-width, 420px), calc(100vw - 32px));
|
||||
padding: 22px;
|
||||
border-radius: var(--ys-radius);
|
||||
}
|
||||
|
||||
.el-message-box__message {
|
||||
overflow-wrap: anywhere;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.el-message-box__btns {
|
||||
@@ -726,6 +797,72 @@ span.cell-num {
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
/* 表单分组用于长档案;说明保持在控件外,输入后仍可查阅。 */
|
||||
.form-section-title {
|
||||
margin: 24px 0 16px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid var(--ys-line-soft);
|
||||
color: var(--ys-ink);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.form-section-title:first-child {
|
||||
margin-top: 0;
|
||||
padding-top: 0;
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.el-dialog__body > .el-form .el-form-item__label {
|
||||
height: auto;
|
||||
min-height: 32px;
|
||||
align-items: center;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.el-dialog__body .el-form-item__content {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.el-dialog__body .form-hint { flex-basis: 100%; }
|
||||
|
||||
.el-dialog__body .el-form-item .el-select,
|
||||
.el-dialog__body .el-form-item .el-date-editor,
|
||||
.el-drawer__body .el-form-item .el-select {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.el-dialog__body .el-checkbox,
|
||||
.el-drawer__body .el-checkbox {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.el-dialog__body .el-checkbox__label,
|
||||
.el-drawer__body .el-checkbox__label {
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
::selection {
|
||||
color: var(--ys-ink);
|
||||
background: var(--el-color-primary-light-8);
|
||||
}
|
||||
|
||||
input, textarea { caret-color: var(--ys-indigo); }
|
||||
|
||||
:where(button, a, [tabindex]):focus-visible {
|
||||
outline: 2px solid var(--ys-indigo);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.el-button.is-link.el-button--warning { --el-button-text-color: #895d12; }
|
||||
.el-button.is-link.el-button--success { --el-button-text-color: #18764f; }
|
||||
.el-button.is-link.el-button--danger { --el-button-text-color: #ac3d3d; }
|
||||
.el-tag--success { --el-tag-text-color: #18764f; }
|
||||
.el-tag--warning { --el-tag-text-color: #895d12; }
|
||||
.el-tag--danger { --el-tag-text-color: #ac3d3d; }
|
||||
|
||||
/* 长字段标签由本行撑开高度,窄屏切到控件上方,避免换行侵入下一行。 */
|
||||
.record-form .el-form-item__label {
|
||||
height: auto;
|
||||
@@ -754,7 +891,7 @@ span.cell-num {
|
||||
}
|
||||
|
||||
.el-dialog__body {
|
||||
padding: 4px 16px 16px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.el-dialog__footer {
|
||||
@@ -764,6 +901,74 @@ span.cell-num {
|
||||
.el-dialog__footer > .el-button {
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
align-items: stretch;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.filter-bar .el-form-item {
|
||||
display: block;
|
||||
flex: 1 1 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.filter-bar .el-form-item__label,
|
||||
.el-dialog__body .el-form:not(.el-form--inline) .el-form-item__label {
|
||||
display: block;
|
||||
width: auto !important;
|
||||
min-height: 0;
|
||||
height: auto;
|
||||
padding: 0 0 6px;
|
||||
line-height: 1.5;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.filter-bar .el-form-item__content,
|
||||
.el-dialog__body .el-form:not(.el-form--inline) .el-form-item__content {
|
||||
margin-left: 0 !important;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.filter-bar .el-form-item__content > .el-input,
|
||||
.filter-bar .el-form-item__content > .el-select,
|
||||
.filter-bar .el-form-item__content > .el-date-editor {
|
||||
width: 100% !important;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.filter-bar__actions { flex-wrap: wrap; width: 100%; margin-left: 0; }
|
||||
.filter-bar__actions > .el-button { flex: 1 1 auto; min-height: 38px; }
|
||||
.toolbar { gap: 8px; }
|
||||
.toolbar-note { flex-basis: 100%; }
|
||||
.card-foot { padding: 12px; }
|
||||
.el-dialog__body .el-form:not(.el-form--inline) .el-form-item { display: block; }
|
||||
.el-dialog__body .el-col-12 { max-width: 100%; flex: 0 0 100%; }
|
||||
.el-dialog__body .el-checkbox { height: auto; min-height: 34px; }
|
||||
.el-drawer__header { padding: 16px; }
|
||||
.el-drawer__body { padding: 16px; }
|
||||
.el-drawer__footer { padding: 12px 16px; }
|
||||
|
||||
.el-picker__popper.el-popper { max-width: calc(100vw - 24px); }
|
||||
.el-picker__popper.el-popper:has(.el-date-range-picker) {
|
||||
position: fixed !important;
|
||||
inset: auto 12px 12px !important;
|
||||
transform: none !important;
|
||||
}
|
||||
.el-picker__popper:has(.el-date-range-picker) .el-popper__arrow { display: none; }
|
||||
.el-date-range-picker { width: min(646px, calc(100vw - 24px)); max-height: 70dvh; overflow-y: auto; }
|
||||
.el-date-range-picker .el-picker-panel__body { min-width: 0; display: block; }
|
||||
.el-date-range-picker__content { display: block; width: 100%; padding: 12px; }
|
||||
.el-date-range-picker__content.is-left { border-right: 0; border-bottom: 1px solid var(--ys-line-soft); }
|
||||
.el-date-range-picker__time-header { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.el-date-range-picker__time-header > .el-date-range-picker__editors-wrap { width: 100%; }
|
||||
|
||||
/* 手机依旧横向查看完整数据,操作列随表滚动,避免遮住整张表。 */
|
||||
.el-table .el-table__cell.el-table-fixed-column--right,
|
||||
.el-table .el-table__cell.el-table-fixed-column--left { position: static !important; }
|
||||
.el-table .el-table-fixed-column--right::before,
|
||||
.el-table .el-table-fixed-column--left::before { display: none; }
|
||||
}
|
||||
|
||||
/* 空状态:弱墨、留白,不喊叫 */
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Download, EditPen, Upload } from '@element-plus/icons-vue'
|
||||
import { request } from '@/api/request'
|
||||
import service from '@/api/request'
|
||||
import { useUserStore } from '@/store/user'
|
||||
@@ -11,6 +12,7 @@ interface Catalog { as_of?:string; rows: Row[]; fingerprint: string; average_rul
|
||||
const user = useUserStore()
|
||||
const today = () => { const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}` }
|
||||
const asOf = ref(today()), effective = ref(today()), loading = ref(false), saving = ref(false)
|
||||
const failed = ref(false)
|
||||
const data = ref<Catalog>({ rows: [], fingerprint: '', average_rule: '' })
|
||||
const editing = ref(false), edited = ref<Row[]>([]), pending = ref<Row[]>([]), errors = ref<{row:number;message:string}[]>([])
|
||||
const baseDate=ref(today())
|
||||
@@ -19,7 +21,16 @@ const importFileName = ref('')
|
||||
const categories: Record<string,string> = { municipal_water:'市政水利', rail_transit:'轨道交通' }
|
||||
const customers: Record<string,string> = { personal:'个人户', enterprise:'企业户', manager_personal:'经理个人户' }
|
||||
const stages: Record<string,string> = { first:'首日', linear:'线性递减', after:'超期', fixed:'固定', excluded:'不单笔计提' }
|
||||
async function load() { loading.value = true; try { data.value = await request<Catalog>({url:'/v1/system/coefficients',params:{as_of:asOf.value}}) } catch(e) { handledError(e) } finally { loading.value=false } }
|
||||
async function load() { loading.value = true; failed.value = false; try { data.value = await request<Catalog>({url:'/v1/system/coefficients',params:{as_of:asOf.value}}) } catch(e) { failed.value = true; handledError(e) } finally { loading.value=false } }
|
||||
function spanMethod({ rowIndex, columnIndex }: { rowIndex: number; columnIndex: number }): [number, number] {
|
||||
if (columnIndex > 1) return [1, 1]
|
||||
const rows = data.value.rows
|
||||
const same = (a: Row, b: Row) => a.product_category === b.product_category && (columnIndex === 0 || a.customer_type === b.customer_type)
|
||||
if (rowIndex > 0 && same(rows[rowIndex - 1]!, rows[rowIndex]!)) return [0, 0]
|
||||
let count = 1
|
||||
while (rowIndex + count < rows.length && same(rows[rowIndex]!, rows[rowIndex + count]!)) count++
|
||||
return [count, 1]
|
||||
}
|
||||
function edit() { edited.value = data.value.rows.filter(r=>!r.readonly).map(r=>({...r})); fingerprint.value=data.value.fingerprint; baseDate.value=asOf.value; editing.value=true }
|
||||
async function apply(rows: Row[]) {
|
||||
try { await ElMessageBox.confirm(`共 ${rows.length} 项阶段规则,${effective.value} 起生效。历史工资保持原快照。`, '确认系数变更') } catch { return }
|
||||
@@ -37,42 +48,115 @@ onMounted(load)
|
||||
</script>
|
||||
<template>
|
||||
<section class="coefficient-panel" aria-label="完整绩效系数表">
|
||||
<div class="panel-head"><div><h2>产品系数表</h2><p>按产品类别、户型和回款阶段配置计提系数,页面展示与正式核算使用同一版本。</p></div></div>
|
||||
<el-form inline label-position="top">
|
||||
<el-form-item label="查询生效日期"><el-date-picker v-model="asOf" value-format="YYYY-MM-DD" @change="load" /></el-form-item>
|
||||
<el-form-item label="表格操作"><el-button :disabled="!user.can('system:params:update') || asOf!==today()" @click="edit">维护系数表</el-button><el-button :disabled="!user.can('system:params:export')" @click="download">下载 Excel</el-button></el-form-item>
|
||||
<el-form-item v-if="user.can('system:params:update')" label="Excel 导入">
|
||||
<label class="file-button">
|
||||
<div class="panel-head"><div><h2>产品系数表</h2><p>按产品类别、户型和回款阶段核对计提系数,正式核算使用对应生效版本。</p></div><span v-if="!loading && !failed" class="panel-count">{{ data.rows.length }} 项阶段规则</span></div>
|
||||
<div class="coefficient-toolbar" role="group" aria-label="产品系数表工具栏">
|
||||
<div class="coefficient-toolbar__query">
|
||||
<label for="coefficient-as-of">查询日期</label>
|
||||
<el-date-picker
|
||||
id="coefficient-as-of"
|
||||
v-model="asOf"
|
||||
value-format="YYYY-MM-DD"
|
||||
placeholder="生效日期"
|
||||
style="width: 160px"
|
||||
aria-label="查询生效日期"
|
||||
@change="load"
|
||||
/>
|
||||
</div>
|
||||
<div class="coefficient-toolbar__actions" role="group" aria-label="系数表操作">
|
||||
<el-button type="primary" :icon="EditPen" :disabled="loading || failed || !user.can('system:params:update') || asOf!==today()" :title="asOf!==today() ? '仅可维护当天生效的系数' : ''" @click="edit">维护系数表</el-button>
|
||||
<el-button :icon="Download" :disabled="!user.can('system:params:export')" @click="download">下载 Excel</el-button>
|
||||
<label v-if="user.can('system:params:update')" class="file-button">
|
||||
<input aria-label="选择系数表 Excel" type="file" accept=".xlsx" @change="upload" />
|
||||
<span>选择文件</span>
|
||||
<el-icon aria-hidden="true"><Upload /></el-icon>
|
||||
<span>导入 Excel</span>
|
||||
</label>
|
||||
<span v-if="importFileName" class="file-name">{{ importFileName }}</span>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<el-table v-loading="loading" :data="data.rows" border>
|
||||
<el-table-column label="产品类别" width="110"><template #default="{row}">{{categories[row.product_category]}}</template></el-table-column>
|
||||
<el-table-column label="户型" width="110"><template #default="{row}">{{customers[row.customer_type]}}</template></el-table-column>
|
||||
</div>
|
||||
<span v-if="importFileName" class="file-name">{{ importFileName }}</span>
|
||||
</div>
|
||||
<el-alert v-if="failed" class="coefficient-error" title="系数表加载失败,下方记录可能不是当前查询结果" type="error" :closable="false" show-icon><el-button :loading="loading" @click="load">重新加载系数</el-button></el-alert>
|
||||
<el-table v-loading="loading" :data="data.rows" :span-method="spanMethod" class="coefficient-table" border empty-text="当前日期没有可显示的产品系数">
|
||||
<el-table-column label="产品类别" width="120" class-name="coefficient-category"><template #default="{row}">{{categories[row.product_category]}}</template></el-table-column>
|
||||
<el-table-column label="户型" width="120"><template #default="{row}">{{customers[row.customer_type]}}</template></el-table-column>
|
||||
<el-table-column label="阶段" width="120"><template #default="{row}">{{stages[row.stage]}}</template></el-table-column>
|
||||
<el-table-column prop="value" label="给付标准/起点(‰)" min-width="165" />
|
||||
<el-table-column prop="slope" label="日斜率(‰)" min-width="125" />
|
||||
<el-table-column prop="boundary" label="截止天数" width="95" />
|
||||
<el-table-column label="系统系数" min-width="150"><template #default="{row}">{{row.system_coefficient ?? '不适用'}}</template></el-table-column>
|
||||
<el-table-column label="360 天平均系数" min-width="180"><template #default="{row}">{{row.average_coefficient_360 ?? '不适用'}}</template></el-table-column>
|
||||
<el-table-column prop="value" label="给付标准/起点(‰)" min-width="165" align="right" />
|
||||
<el-table-column prop="slope" label="日斜率(‰)" min-width="125" align="right"><template #default="{row}">{{ row.slope ?? '—' }}</template></el-table-column>
|
||||
<el-table-column prop="boundary" label="截止天数" width="100" align="right"><template #default="{row}">{{ row.boundary ?? '—' }}</template></el-table-column>
|
||||
<el-table-column label="系统系数" min-width="150" align="right"><template #default="{row}">{{row.system_coefficient ?? '不适用'}}</template></el-table-column>
|
||||
<el-table-column label="360 天平均系数" min-width="180" align="right"><template #default="{row}">{{row.average_coefficient_360 ?? '不适用'}}</template></el-table-column>
|
||||
</el-table>
|
||||
<p class="coefficient-note">{{ data.average_rule }}。线性阶段的系统系数按第 2 天口径展示;比例和斜率支持精确分数,导入前请先下载 Excel 核对格式。</p>
|
||||
<el-dialog v-model="editing" title="维护绩效系数表" width="min(1050px, 96vw)">
|
||||
<p class="coefficient-note"><template v-if="data.average_rule">{{ data.average_rule }}。</template>线性阶段的系统系数按第 2 天口径展示;比例和斜率支持精确分数,导入前请先下载 Excel 核对格式。</p>
|
||||
<el-dialog v-model="editing" title="维护绩效系数表" width="min(1050px, 96vw)" :close-on-click-modal="!saving" :close-on-press-escape="!saving" :show-close="!saving">
|
||||
<p class="dialog-intro">逐项修改给付标准、日斜率和截止天数,再确认版本生效日期。</p>
|
||||
<el-table :data="edited" border><el-table-column label="产品/户型/阶段" min-width="210"><template #default="{row}">{{categories[row.product_category]}} · {{customers[row.customer_type]}} · {{stages[row.stage]}}</template></el-table-column><el-table-column label="给付标准/起点(‰)" min-width="150"><template #default="{row}"><el-input v-model="row.value" aria-label="给付标准" /></template></el-table-column><el-table-column label="日斜率(‰)" min-width="140"><template #default="{row}"><el-input v-if="row.stage==='linear'" v-model="row.slope" aria-label="日斜率" /></template></el-table-column><el-table-column label="截止天数" min-width="130"><template #default="{row}"><el-input-number v-if="row.stage==='linear'" v-model="row.boundary" :min="1" :max="3660" :controls="false" aria-label="截止天数" /></template></el-table-column></el-table>
|
||||
<el-form label-position="top"><el-form-item label="生效日期"><el-date-picker v-model="effective" value-format="YYYY-MM-DD" /></el-form-item><el-form-item label="变更说明"><el-input v-model="remark" maxlength="255" /></el-form-item></el-form>
|
||||
<template #footer><el-button @click="editing=false">取消</el-button><el-button type="primary" :loading="saving" @click="apply(edited)">确认保存版本</el-button></template>
|
||||
<el-form label-position="top" class="coefficient-version-form" :disabled="saving"><el-form-item label="生效日期"><el-date-picker v-model="effective" value-format="YYYY-MM-DD" /></el-form-item><el-form-item label="变更说明"><el-input v-model="remark" maxlength="255" /></el-form-item></el-form>
|
||||
<template #footer><el-button :disabled="saving" @click="editing=false">取消</el-button><el-button type="primary" :loading="saving" @click="apply(edited)">确认保存版本</el-button></template>
|
||||
</el-dialog>
|
||||
<el-dialog v-model="importVisible" title="系数表导入预览" width="min(1000px, 96vw)"><p>工作簿基准日期:{{baseDate}}。确认后另存为所选生效日期的新版本。</p>
|
||||
<el-dialog v-model="importVisible" title="系数表导入预览" width="min(1000px, 96vw)" :close-on-click-modal="!saving" :close-on-press-escape="!saving" :show-close="!saving"><p class="dialog-intro">工作簿基准日期:{{baseDate}}。确认后另存为所选生效日期的新版本。</p>
|
||||
<el-alert v-if="errors.length" type="error" :closable="false" title="校验未通过,整批不能写入" /><el-table v-if="errors.length" :data="errors"><el-table-column prop="row" label="Excel 行" width="100" /><el-table-column prop="message" label="问题" /></el-table>
|
||||
<el-table :data="pending"><el-table-column v-for="key in ['product_category','customer_type','stage','value','slope','boundary']" :key="key" :prop="key" :label="({product_category:'产品类别',customer_type:'户型',stage:'阶段',value:'给付标准(‰)',slope:'日斜率',boundary:'截止天数'} as Record<string,string>)[key]" min-width="120" /></el-table>
|
||||
<el-form label-position="top"><el-form-item label="导入生效日期"><el-date-picker v-model="effective" value-format="YYYY-MM-DD" /></el-form-item><el-form-item label="变更说明"><el-input v-model="remark" /></el-form-item></el-form>
|
||||
<template #footer><el-button @click="importVisible=false">取消</el-button><el-button type="primary" :disabled="errors.length>0 || !pending.length" :loading="saving" @click="apply(pending)">确认导入版本</el-button></template>
|
||||
<el-table :data="pending"><el-table-column label="产品类别" min-width="120"><template #default="{row}">{{ categories[row.product_category] ?? row.product_category }}</template></el-table-column><el-table-column label="户型" min-width="120"><template #default="{row}">{{ customers[row.customer_type] ?? row.customer_type }}</template></el-table-column><el-table-column label="阶段" min-width="120"><template #default="{row}">{{ stages[row.stage] ?? row.stage }}</template></el-table-column><el-table-column prop="value" label="给付标准(‰)" min-width="140" align="right" /><el-table-column prop="slope" label="日斜率" min-width="120" align="right" /><el-table-column prop="boundary" label="截止天数" min-width="120" align="right" /></el-table>
|
||||
<el-form label-position="top" class="coefficient-version-form" :disabled="saving"><el-form-item label="导入生效日期"><el-date-picker v-model="effective" value-format="YYYY-MM-DD" /></el-form-item><el-form-item label="变更说明"><el-input v-model="remark" /></el-form-item></el-form>
|
||||
<template #footer><el-button :disabled="saving" @click="importVisible=false">取消</el-button><el-button type="primary" :disabled="errors.length>0 || !pending.length" :loading="saving" @click="apply(pending)">确认导入版本</el-button></template>
|
||||
</el-dialog>
|
||||
</section>
|
||||
</template>
|
||||
<style scoped>
|
||||
.coefficient-panel{margin-block:24px;padding:20px;border:1px solid var(--el-border-color);background:var(--el-bg-color)}.panel-head{display:flex;justify-content:space-between;align-items:start;gap:16px}.panel-head h2{margin:0;font-size:18px}.panel-head p,.coefficient-note{color:var(--el-text-color-secondary);font-size:13px;line-height:1.7}.coefficient-panel .el-form{margin-top:16px}.file-button{display:inline-flex;align-items:center;gap:6px;padding:7px 14px;border:1px solid var(--ys-indigo);border-radius:6px;color:var(--ys-indigo);background:var(--ys-paper);font-size:13px;line-height:1.5;cursor:pointer;transition:background-color 120ms var(--ys-ease), color 120ms var(--ys-ease)}.file-button:hover{background:var(--ys-indigo-wash)}.file-button:focus-within{outline:2px solid var(--ys-indigo);outline-offset:2px}.file-button input{position:absolute;width:1px;height:1px;margin:-1px;clip:rect(0 0 0 0);overflow:hidden}.file-name{color:var(--ys-ink-2);font-size:12px;line-height:1.6}@media(max-width:768px){.coefficient-panel{padding:12px}.panel-head{flex-direction:column}}
|
||||
.coefficient-panel { margin-block: 0; padding: 20px; border: 1px solid var(--ys-line); border-radius: var(--ys-radius); background: var(--ys-paper); box-shadow: var(--ys-shadow-card); }
|
||||
.panel-head { display: flex; justify-content: space-between; align-items: start; gap: 16px; }
|
||||
.panel-head h2 { margin: 0; font-size: 16px; font-weight: 600; line-height: 1.6; }
|
||||
.panel-head p { margin: 6px 0 0; }
|
||||
.panel-count { flex: none; color: var(--ys-ink-3); font-size: 12px; line-height: 1.7; font-variant-numeric: tabular-nums; }
|
||||
.coefficient-table :deep(.coefficient-category) { background: #fafbfd; font-weight: 600; }
|
||||
.coefficient-table :deep(.cell) { font-variant-numeric: tabular-nums; }
|
||||
.coefficient-note { margin: 16px 0 0; padding-top: 12px; border-top: 1px solid var(--ys-line-soft); }
|
||||
.coefficient-error { margin-bottom: 16px; }
|
||||
.dialog-intro { margin: 0 0 18px; color: var(--ys-ink-2); font-size: 13px; line-height: 1.7; }
|
||||
.coefficient-version-form { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 2fr); gap: 16px; margin-top: 20px; }
|
||||
.coefficient-version-form :deep(.el-date-editor) { width: 100%; }
|
||||
.panel-head p, .coefficient-note { color: var(--el-text-color-secondary); font-size: 13px; line-height: 1.7; }
|
||||
|
||||
.coefficient-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px 24px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
.coefficient-toolbar__query { display: flex; align-items: center; gap: 10px; min-width: 0; }
|
||||
.coefficient-toolbar__query > label { color: var(--ys-ink-2); font-size: 13px; white-space: nowrap; }
|
||||
.coefficient-toolbar__actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-left: auto; }
|
||||
.file-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
height: var(--el-component-size);
|
||||
padding: 0 14px;
|
||||
border: 1px solid var(--ys-line);
|
||||
border-radius: var(--ys-radius-sm);
|
||||
color: var(--ys-ink-2);
|
||||
background: var(--ys-paper);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition: background-color 120ms var(--ys-ease), color 120ms var(--ys-ease), border-color 120ms var(--ys-ease);
|
||||
}
|
||||
.file-button:hover { color: var(--ys-indigo); border-color: var(--ys-indigo); background: var(--ys-indigo-wash); }
|
||||
.file-button:focus-within { outline: 2px solid var(--ys-indigo); outline-offset: 2px; }
|
||||
.file-button input { position: absolute; width: 1px; height: 1px; margin: -1px; clip: rect(0 0 0 0); overflow: hidden; }
|
||||
.file-name { flex-basis: 100%; min-width: 0; color: var(--ys-ink-2); font-size: 12px; line-height: 1.6; overflow-wrap: anywhere; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.coefficient-panel { padding: 12px; }
|
||||
.panel-head { flex-direction: column; }
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.coefficient-version-form { grid-template-columns: minmax(0, 1fr); gap: 0; }
|
||||
.coefficient-toolbar__query { width: 100%; }
|
||||
.coefficient-toolbar__query :deep(.el-date-editor) { flex: 1; min-width: 0; }
|
||||
.coefficient-toolbar__actions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); width: 100%; }
|
||||
.coefficient-toolbar__actions > .el-button:first-child { grid-column: 1 / -1; }
|
||||
.coefficient-toolbar__actions > .el-button, .file-button { min-height: 38px; }
|
||||
}
|
||||
</style>
|
||||
@@ -9,7 +9,7 @@ const { topLevelMenus, groupedMenus } = useAppMenu()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-menu class="app-menu" :default-active="route.path" router>
|
||||
<el-menu class="app-menu" :default-active="route.path" aria-label="主导航" router>
|
||||
<el-menu-item
|
||||
v-for="item in topLevelMenus"
|
||||
:key="item.path"
|
||||
|
||||
@@ -69,6 +69,7 @@ async function handleLogout(): Promise<void> {
|
||||
|
||||
<template>
|
||||
<el-container class="app-layout">
|
||||
<a class="skip-link" href="#main-content">跳到页面内容</a>
|
||||
<el-aside class="app-aside" width="220px">
|
||||
<div class="app-brand">
|
||||
<span class="app-brand__name">衍水集团</span>
|
||||
@@ -115,7 +116,7 @@ async function handleLogout(): Promise<void> {
|
||||
</div>
|
||||
</el-header>
|
||||
|
||||
<el-main class="app-main">
|
||||
<el-main id="main-content" class="app-main" tabindex="-1">
|
||||
<router-view v-slot="{ Component }">
|
||||
<Transition name="ys-page" mode="out-in">
|
||||
<div :key="route.path" class="app-page-view">
|
||||
@@ -129,6 +130,8 @@ async function handleLogout(): Promise<void> {
|
||||
<el-drawer
|
||||
v-model="drawerVisible"
|
||||
class="app-drawer"
|
||||
title="功能导航"
|
||||
aria-label="功能导航"
|
||||
direction="ltr"
|
||||
size="240px"
|
||||
:with-header="false"
|
||||
@@ -148,6 +151,11 @@ async function handleLogout(): Promise<void> {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.app-body { min-width: 0; }
|
||||
|
||||
.skip-link { position: fixed; top: -80px; left: 16px; z-index: 4000; padding: 12px 18px; color: var(--ys-indigo); background: var(--ys-paper); border: 1px solid var(--ys-indigo); border-radius: var(--ys-radius-sm); }
|
||||
.skip-link:focus { top: 12px; }
|
||||
|
||||
.app-aside {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -283,6 +291,7 @@ async function handleLogout(): Promise<void> {
|
||||
*/
|
||||
padding: 22px;
|
||||
background-color: var(--ys-mist);
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
.app-page-view {
|
||||
@@ -321,6 +330,7 @@ async function handleLogout(): Promise<void> {
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.app-main { padding: 16px 12px; }
|
||||
.app-header {
|
||||
gap: 12px;
|
||||
padding: 0 16px;
|
||||
@@ -374,6 +384,7 @@ async function handleLogout(): Promise<void> {
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.app-main { scroll-behavior: auto; }
|
||||
.app-header__menu-toggle,
|
||||
.ys-page-enter-active,
|
||||
.ys-page-leave-active {
|
||||
|
||||
@@ -17,14 +17,21 @@ onMounted(load)
|
||||
<template>
|
||||
<section class="probation-panel" aria-label="试用期缓发与年度结清">
|
||||
<div class="panel-head"><div><h2>试用期缓发与年度结清</h2><p>{{rule}}</p></div><el-button :disabled="!user.can('reports:personal:export')" @click="download">导出年度余额及明细</el-button></div>
|
||||
<el-form inline label-position="top"><el-form-item label="工资月份/结清预览"><el-date-picker v-model="month" type="month" value-format="YYYY-MM" @change="load" /></el-form-item><el-form-item label="人员筛选"><el-input v-model="keyword" placeholder="姓名" clearable /></el-form-item><el-form-item label="原累计年度"><el-input v-model="year" placeholder="如 2026" clearable /></el-form-item><el-form-item label="查询"><el-button :loading="loading" @click="load">刷新结清预览</el-button></el-form-item></el-form>
|
||||
<el-form class="filter-bar" label-position="top" @submit.prevent="load"><el-form-item label="工资月份/结清预览"><el-date-picker v-model="month" type="month" value-format="YYYY-MM" :clearable="false" @change="load" /></el-form-item><el-form-item label="人员筛选"><el-input v-model="keyword" placeholder="姓名" clearable /></el-form-item><el-form-item label="原累计年度"><el-input v-model="year" placeholder="如 2026" clearable /></el-form-item><div class="filter-bar__actions"><el-button :loading="loading" native-type="submit">刷新结清预览</el-button></div></el-form>
|
||||
<el-table v-loading="loading" :data="rows.filter(r=>(!keyword||r.personnel_name.includes(keyword))&&(!year||r.year===year))" border empty-text="该月没有试用期缓发记录">
|
||||
<el-table-column prop="personnel_name" label="人员" width="110" /><el-table-column prop="year" label="原年度" width="90" />
|
||||
<el-table-column v-for="[key,label] in [['accrued','试用期累计计提'],['eligible','符合支付条件'],['threshold','年度门槛'],['entitlement','超额权益'],['paid','已补发'],['reserved','待审核占用'],['delta','本月补发/扣回'],['remaining','剩余权益']]" :key="key" :prop="key" :label="label" min-width="140" align="right" />
|
||||
<el-table-column label="依据/结清" min-width="210"><template #default="{row}"><el-button link type="primary" @click="detail=row;visible=true">台账明细</el-button><el-button link type="primary" :disabled="!user.can('adjustments:create') || row.delta==='0.00' || row.delta==='0'" @click="adjust(row)">生成当月调整</el-button></template></el-table-column>
|
||||
<el-table-column label="依据/结清" min-width="230"><template #default="{row}"><div class="table-actions"><el-button link type="primary" @click="detail=row;visible=true">台账明细</el-button><el-button link type="primary" :disabled="!user.can('adjustments:create') || row.delta==='0.00' || row.delta==='0'" @click="adjust(row)">生成当月调整</el-button></div></template></el-table-column>
|
||||
</el-table>
|
||||
<p class="panel-note">这里只查询及预览。实际补发随月结在同一事务中固化;生成失败不占余额,已生成工资保留历史结果。</p>
|
||||
<el-dialog v-model="visible" :title="`${detail?.personnel_name ?? ''} · ${detail?.year ?? ''} 年缓发依据`" width="min(950px, 96vw)"><el-table :data="detail?.details ?? []"><el-table-column prop="ledger_id" label="台账 ID" width="100" /><el-table-column prop="business_no" label="业务编号" min-width="160" /><el-table-column prop="month" label="归属月" width="110" /><el-table-column prop="receipt_date" label="到账日" width="120" /><el-table-column prop="accrued" label="计提金额" /><el-table-column prop="eligible" label="应付变化" /></el-table></el-dialog>
|
||||
<el-dialog v-model="visible" :title="`${detail?.personnel_name ?? ''} · ${detail?.year ?? ''} 年缓发依据`" width="min(950px, 96vw)"><el-table :data="detail?.details ?? []"><el-table-column prop="ledger_id" label="台账 ID" width="100" /><el-table-column prop="business_no" label="业务编号" min-width="160" show-overflow-tooltip /><el-table-column prop="month" label="归属月" width="110" /><el-table-column prop="receipt_date" label="到账日" width="120" /><el-table-column prop="accrued" label="计提金额(元)" min-width="140" align="right" /><el-table-column prop="eligible" label="应付变化(元)" min-width="140" align="right" /></el-table><template #footer><el-button @click="visible=false">关闭</el-button></template></el-dialog>
|
||||
</section>
|
||||
</template>
|
||||
<style scoped>.probation-panel{margin:24px 0;padding:20px;border:1px solid var(--el-border-color);background:var(--el-bg-color)}.panel-head{display:flex;justify-content:space-between;gap:16px}.panel-head h2{font-size:18px;margin:0}.panel-head p,.panel-note{font-size:13px;color:var(--el-text-color-secondary);line-height:1.7}.el-form{margin-top:16px}@media(max-width:768px){.probation-panel{padding:12px}.panel-head{flex-direction:column}}</style>
|
||||
<style scoped>
|
||||
.probation-panel { margin: 24px 0; padding: 20px; min-width: 0; border: 1px solid var(--ys-line); border-radius: var(--ys-radius); background: var(--ys-paper); }
|
||||
.panel-head { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 16px; }
|
||||
.panel-head h2 { font-size: 16px; margin: 0; }
|
||||
.panel-head p, .panel-note { font-size: 13px; color: var(--ys-ink-2); line-height: 1.7; }
|
||||
.filter-bar { margin-top: 16px; padding: 0 0 16px; border: 0; border-radius: 0; border-bottom: 1px solid var(--ys-line-soft); box-shadow: none; background: none; align-items: flex-end; }
|
||||
@media (max-width: 768px) { .probation-panel { padding: 16px; } .panel-head { flex-direction: column; } .panel-head > .el-button { align-self: flex-start; } }
|
||||
</style>
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { request } from '@/api/request'
|
||||
import { handledError } from '@/utils/error'
|
||||
@@ -7,6 +7,14 @@ import { useUserStore } from '@/store/user'
|
||||
interface Candidate {id:number;business_no:string;product_name:string;spec_model:string;unit:string;product_category:string;quantity:string;sale_price:string}
|
||||
interface PlanRow {product_name:string;spec_model:string;unit:string;product_category:string;quantity:string;sale_price:string;action:string;reason:string|null;target_id:number|null}
|
||||
const emit=defineEmits<{done:[]}>(),user=useUserStore(),visible=ref(false),loading=ref(false),keyword=ref(''),page=ref(1),more=ref(false),rows=ref<Candidate[]>([]),selected=ref<Candidate[]>([]),result=ref<{rows:PlanRow[];fingerprint:string}|null>(null),executed=ref(false)
|
||||
const confirmDisabledReason = computed(() => {
|
||||
if (!user.can('base:products:create')) return user.permissionReason('base:products:create')
|
||||
if (loading.value) return '正在处理,请稍候。'
|
||||
if (!selected.value.length) return '请先勾选业务明细,再点击“预览所选”。'
|
||||
if (!result.value) return `已选 ${selected.value.length} 条,请先点击“预览所选 ${selected.value.length} 条”,核对结果后再执行提取。`
|
||||
if (executed.value) return '本次提取已完成;如需继续,请重新勾选明细并预览。'
|
||||
return ''
|
||||
})
|
||||
async function load(){loading.value=true;result.value=null;try{const data=await request<{rows:Candidate[];has_more:boolean}>({url:'/v1/base/products/extract/candidates',params:{keyword:keyword.value,page_num:page.value}});rows.value=data.rows;more.value=data.has_more}catch(e){handledError(e)}finally{loading.value=false}}
|
||||
async function open(){visible.value=true;page.value=1;selected.value=[];await load()}
|
||||
async function preview(){loading.value=true;try{result.value=await request({url:'/v1/base/products/extract/preview',method:'post',data:{item_ids:selected.value.map(r=>r.id)}});executed.value=false}catch(e){handledError(e)}finally{loading.value=false}}
|
||||
@@ -14,14 +22,60 @@ async function apply(){if(!result.value)return;try{await ElMessageBox.confirm('
|
||||
defineExpose({open})
|
||||
</script>
|
||||
<template>
|
||||
<el-dialog v-model="visible" title="从业务明细提取产品" width="min(1180px, 96vw)">
|
||||
<el-form inline><el-form-item label="名称/业务编号"><el-input v-model="keyword" clearable @keyup.enter="page=1;load()" /></el-form-item><el-form-item><el-button @click="page=1;load()">查询</el-button></el-form-item></el-form>
|
||||
<p>先在业务管理录入产品明细,或将 T+ 数据正式导入业务,再勾选未关联产品档案的明细,点击“预览所选”并确认提取。T+ 原始采集成功不会自动建产品档案。</p>
|
||||
<p>同名同规格合并,数量与单价取所选最新明细;已有档案只建立关联,不覆盖人工信息。</p>
|
||||
<el-table v-loading="loading" :data="rows" row-key="id" empty-text="暂无可提取明细。请先创建含未关联产品的业务,或完成 T+ 正式业务导入。" @selection-change="(values:Candidate[])=>{selected=values;result=null}"><el-table-column type="selection" width="50" /><el-table-column prop="business_no" label="业务编号" min-width="155" /><el-table-column prop="product_name" label="产品名称" min-width="150" /><el-table-column prop="spec_model" label="规格" min-width="110" /><el-table-column prop="unit" label="单位" width="80" /><el-table-column label="类别" width="110"><template #default="{row}">{{row.product_category==='municipal_water'?'市政水利':'轨道交通'}}</template></el-table-column><el-table-column prop="quantity" label="数量" width="100" /><el-table-column prop="sale_price" label="成交单价" width="120" /></el-table>
|
||||
<div class="extract-actions"><el-button :disabled="page<=1" @click="page--;load()">上一页</el-button><span>第 {{page}} 页</span><el-button :disabled="!more" @click="page++;load()">下一页</el-button><el-button :disabled="!selected.length" :loading="loading" @click="preview">预览所选 {{selected.length}} 条</el-button></div>
|
||||
<el-table v-if="result" :data="result.rows" border><el-table-column prop="product_name" label="产品" /><el-table-column prop="spec_model" label="规格" /><el-table-column label="处理结果" width="130"><template #default="{row}">{{({CREATE:executed?'已创建':'将创建',LINK:executed?'已关联':'关联已有',CONFLICT:'冲突待处理'} as Record<string,string>)[row.action]}}</template></el-table-column><el-table-column prop="reason" label="说明" min-width="210" /><el-table-column prop="target_id" label="产品 ID" width="100" /></el-table>
|
||||
<template #footer><el-button @click="visible=false">关闭</el-button><el-button type="primary" :disabled="!result||executed||!user.can('base:products:create')" :loading="loading" @click="apply">确认执行提取</el-button></template>
|
||||
<el-dialog v-model="visible" title="从业务明细提取产品" width="min(1180px, 96vw)"
|
||||
:close-on-click-modal="!loading" :close-on-press-escape="!loading" :show-close="!loading">
|
||||
<p class="extract-intro">操作步骤:勾选业务明细 → 点击“预览所选” → 核对预览结果 → 确认执行提取。</p>
|
||||
<el-form class="filter-bar" @submit.prevent="page=1;load()">
|
||||
<el-form-item label="名称/业务编号">
|
||||
<el-input v-model="keyword" placeholder="搜索产品名称或业务编号" clearable />
|
||||
</el-form-item>
|
||||
<div class="filter-bar__actions"><el-button :loading="loading" native-type="submit">查询</el-button></div>
|
||||
</el-form>
|
||||
<el-table v-loading="loading" :data="rows" row-key="id"
|
||||
empty-text="暂无可提取明细。请先创建含未关联产品的业务,或完成 T+ 正式业务导入。"
|
||||
@selection-change="(values:Candidate[])=>{selected=values;result=null}">
|
||||
<el-table-column type="selection" width="50" />
|
||||
<el-table-column prop="business_no" label="业务编号" min-width="155" show-overflow-tooltip />
|
||||
<el-table-column prop="product_name" label="产品名称" min-width="150" show-overflow-tooltip />
|
||||
<el-table-column prop="spec_model" label="规格" min-width="110" show-overflow-tooltip />
|
||||
<el-table-column prop="unit" label="单位" width="80" />
|
||||
<el-table-column label="类别" width="110"><template #default="{row}">{{row.product_category==='municipal_water'?'市政水利':'轨道交通'}}</template></el-table-column>
|
||||
<el-table-column prop="quantity" label="数量" width="110" align="right" />
|
||||
<el-table-column prop="sale_price" label="成交单价(元)" width="140" align="right" />
|
||||
</el-table>
|
||||
<div class="extract-actions">
|
||||
<span class="selection-note" role="status">已选 {{selected.length}} 条</span>
|
||||
<el-button :disabled="page<=1 || loading" @click="page--;load()">上一页</el-button>
|
||||
<span>第 {{page}} 页</span>
|
||||
<el-button :disabled="!more || loading" @click="page++;load()">下一页</el-button>
|
||||
<el-button :disabled="!selected.length" :loading="loading" @click="preview">预览所选 {{selected.length}} 条</el-button>
|
||||
</div>
|
||||
<template v-if="result">
|
||||
<h3 class="form-section-title">{{ executed ? '提取结果' : '提取预览' }}</h3>
|
||||
<el-table :data="result.rows" border>
|
||||
<el-table-column prop="product_name" label="产品" min-width="150" show-overflow-tooltip />
|
||||
<el-table-column prop="spec_model" label="规格" min-width="110" show-overflow-tooltip />
|
||||
<el-table-column label="处理结果" width="130"><template #default="{row}"><el-tag :type="row.action==='CONFLICT'?'warning':'info'">{{({CREATE:executed?'已创建':'将创建',LINK:executed?'已关联':'关联已有',CONFLICT:'冲突待处理'} as Record<string,string>)[row.action]}}</el-tag></template></el-table-column>
|
||||
<el-table-column prop="reason" label="说明" min-width="210" />
|
||||
<el-table-column prop="target_id" label="产品 ID" width="100" />
|
||||
</el-table>
|
||||
</template>
|
||||
<p class="form-hint">同名同规格的产品合并,数量和单价取所选最新明细;已有档案只建立关联,保留人工信息。T+ 原始数据需正式导入业务后才能提取。</p>
|
||||
<template #footer>
|
||||
<div class="extract-footer">
|
||||
<span class="extract-footer__reason" role="status">{{ confirmDisabledReason || '预览已就绪,请核对处理结果后确认执行。' }}</span>
|
||||
<div class="extract-footer__buttons"><el-button :disabled="loading" @click="visible=false">关闭</el-button><el-button type="primary" :disabled="!!confirmDisabledReason" :title="confirmDisabledReason" :loading="loading" @click="apply">确认执行提取</el-button></div>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
<style scoped>.extract-actions{display:flex;gap:12px;align-items:center;justify-content:flex-end;margin:16px 0}p{font-size:13px;line-height:1.7;color:var(--el-text-color-secondary)}@media(max-width:600px){.extract-actions{flex-wrap:wrap}}</style>
|
||||
<style scoped>
|
||||
.extract-intro { margin: 0 0 16px; color: var(--ys-ink-2); font-size: 13px; }
|
||||
.extract-actions { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; justify-content: flex-end; margin: 16px 0; font-size: 13px; }
|
||||
.selection-note { margin-right: auto; color: var(--ys-ink-2); }
|
||||
.extract-footer { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 12px; text-align: left; }
|
||||
.extract-footer__reason { flex: 1 1 260px; color: var(--ys-ink-2); font-size: 13px; line-height: 1.6; }
|
||||
.extract-footer__buttons { display: flex; flex-wrap: wrap; gap: 12px; margin-left: auto; }
|
||||
.extract-footer__buttons .el-button + .el-button { margin-left: 0; }
|
||||
@media (max-width: 600px) { .selection-note { flex-basis: 100%; } }
|
||||
</style>
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onBeforeUnmount, ref } from 'vue'
|
||||
|
||||
/**
|
||||
* 分页条:统一各列表页的布局与页码尺寸选项。
|
||||
* 与 `usePagedList` 的分页字段(pageNum/pageSize/total)配套使用。
|
||||
@@ -16,13 +18,26 @@ defineEmits<{
|
||||
(e: 'page-change', page: number): void
|
||||
(e: 'size-change', size: number): void
|
||||
}>()
|
||||
|
||||
const compact = ref(false)
|
||||
let query: MediaQueryList | undefined
|
||||
const layout = computed(() => compact.value ? 'total, prev, pager, next' : 'total, sizes, prev, pager, next, jumper')
|
||||
function updateLayout(): void { compact.value = query?.matches ?? false }
|
||||
onMounted(() => {
|
||||
query = window.matchMedia('(max-width: 600px)')
|
||||
updateLayout()
|
||||
query.addEventListener('change', updateLayout)
|
||||
})
|
||||
onBeforeUnmount(() => query?.removeEventListener('change', updateLayout))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-pagination
|
||||
class="app-pager"
|
||||
background
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
:layout="layout"
|
||||
:pager-count="5"
|
||||
:size="compact ? 'small' : 'default'"
|
||||
:total="total"
|
||||
:current-page="pageNum"
|
||||
:page-size="pageSize"
|
||||
@@ -34,7 +49,18 @@ defineEmits<{
|
||||
|
||||
<style scoped>
|
||||
.app-pager {
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
max-width: 100%;
|
||||
justify-content: flex-end;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.app-pager { display: grid; grid-template-columns: auto auto auto; width: 100%; justify-content: center; gap: 8px 4px; }
|
||||
.app-pager :deep(.el-pagination__total) { grid-column: 1 / -1; justify-content: center; margin: 0; }
|
||||
.app-pager.el-pagination.is-background :deep(button),
|
||||
.app-pager.el-pagination.is-background :deep(.el-pager li) { min-width: 28px; width: 28px; height: 36px; margin: 0; }
|
||||
.app-pager :deep(.el-pager) { display: flex; gap: 2px; }
|
||||
}
|
||||
</style>
|
||||
@@ -547,6 +547,9 @@ function removeGrade(row: RatingGradeItem): void {
|
||||
|
||||
/* 移动端:标签左对齐、结果块自然换行到表单下方 */
|
||||
@media (max-width: 768px) {
|
||||
.calc-form { min-width: 0; }
|
||||
.score-row { gap: 8px; justify-content: space-between; }
|
||||
.score-row :deep(.el-input-number) { width: 120px; flex: none; }
|
||||
.score-label {
|
||||
width: auto;
|
||||
text-align: left;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import ListLoadError from '@/components/feedback/ListLoadError.vue'
|
||||
import { optionPage } from '@/api/metadata'
|
||||
import { useUserStore as usePermissionUser } from '@/store/user'
|
||||
const permissionUser = usePermissionUser()
|
||||
@@ -47,7 +48,7 @@ const filters = reactive({
|
||||
business_role_id: undefined as number | undefined,
|
||||
})
|
||||
|
||||
const { records, total, pageNum, pageSize, loading, load, search, onPageChange, onSizeChange, reloadAfterDelete } =
|
||||
const { records, total, pageNum, pageSize, loadFailed, loading, load, search, onPageChange, onSizeChange, reloadAfterDelete } =
|
||||
usePagedList<PersonnelItem, personnelApi.PersonnelQueryParams>(
|
||||
personnelApi.listPersonnel,
|
||||
() => ({
|
||||
@@ -403,7 +404,7 @@ onMounted(() => {
|
||||
<div class="page-head">
|
||||
<h1 class="page-title">人员管理</h1>
|
||||
<p class="page-desc">
|
||||
人员岗位与适用计提参数在这里定;改岗位会改变后续回款的绩效口径,设为离职后不可再被业务与客户归属引用
|
||||
维护人员档案、任职日期与工资标准。岗位变更影响后续回款的绩效核算,离职人员不能再分配到业务或客户。
|
||||
</p>
|
||||
<span class="waterline"></span>
|
||||
</div>
|
||||
@@ -412,6 +413,7 @@ onMounted(() => {
|
||||
.filter-bar 直接挂在 el-form 上:el-form 不是 flex 透明容器,
|
||||
外面再包一层 div 会让 el-form-item 丢掉 gap 排版,筛选条退化成竖排。
|
||||
-->
|
||||
<ListLoadError v-if="loadFailed" :loading="loading" @retry="load" />
|
||||
<el-form class="filter-bar" @submit.prevent>
|
||||
<el-form-item label="姓名">
|
||||
<el-input
|
||||
@@ -456,7 +458,7 @@ onMounted(() => {
|
||||
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" @click="openCreate" :disabled="!permissionUser.can('base:personnel:create')" :title="permissionUser.permissionReason('base:personnel:create')">新增人员</el-button>
|
||||
<span class="toolbar-note">辐射区域与拓展计划在行内「区域与计划」维护,保存即全量替换</span>
|
||||
<span class="toolbar-note">在“区域与计划”维护辐射区域和拓展计划,保存后替换原有内容。</span>
|
||||
</div>
|
||||
|
||||
<!-- ys-rise:整页只给列表卡一次入场上浮,筛选与页头立即呈现,避免整屏一起动 -->
|
||||
@@ -465,10 +467,10 @@ onMounted(() => {
|
||||
v-loading="loading"
|
||||
:data="records"
|
||||
stripe
|
||||
empty-text="还没有人员 —— 点「新增人员」建第一个,或换个关键词再查"
|
||||
empty-text="暂无符合条件的人员,可调整筛选条件。"
|
||||
>
|
||||
<el-table-column prop="personnel_name" label="姓名" min-width="100" />
|
||||
<el-table-column prop="role_name" label="岗位" min-width="100">
|
||||
<el-table-column prop="personnel_name" label="姓名" min-width="100" show-overflow-tooltip />
|
||||
<el-table-column prop="role_name" label="岗位" min-width="100" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.role_name ?? '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="manager_name" label="直属经理" min-width="100">
|
||||
@@ -499,7 +501,7 @@ onMounted(() => {
|
||||
<StatusTag :value="row.status" active-text="在职" inactive-text="离职" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="270" fixed="right" align="center">
|
||||
<el-table-column label="操作" width="350" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<div class="table-actions">
|
||||
<el-button link type="primary" size="small" @click="openEdit(row)">详情</el-button>
|
||||
@@ -531,11 +533,15 @@ onMounted(() => {
|
||||
<!-- 新增 / 编辑(基本信息;不含状态与子表) -->
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:close-on-click-modal="!submitting"
|
||||
:close-on-press-escape="!submitting"
|
||||
:show-close="!submitting"
|
||||
:title="isEdit ? '编辑人员' : '新增人员'"
|
||||
width="640px"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form ref="formRef" class="record-form" :disabled="!permissionUser.can(isEdit ? 'base:personnel:update' : 'base:personnel:create')" :model="form" :rules="rules" label-width="132px">
|
||||
<h2 class="form-section-title">人员与岗位</h2>
|
||||
<el-form-item label="姓名" prop="personnel_name">
|
||||
<el-input v-model="form.personnel_name" placeholder="营销人员姓名" />
|
||||
</el-form-item>
|
||||
@@ -575,6 +581,7 @@ onMounted(() => {
|
||||
<el-form-item label="联系电话" prop="phone">
|
||||
<el-input v-model="form.phone" maxlength="20" placeholder="选填" />
|
||||
</el-form-item>
|
||||
<h2 class="form-section-title">任职与试用期</h2>
|
||||
<el-form-item label="入职日期" prop="hire_date">
|
||||
<el-date-picker
|
||||
v-model="form.hire_date"
|
||||
@@ -588,20 +595,21 @@ onMounted(() => {
|
||||
<el-form-item label="转正生效日"><el-date-picker v-model="form.regularization_date" value-format="YYYY-MM-DD" placeholder="当日按正式人员计算" /></el-form-item>
|
||||
<el-form-item label="试用期" prop="is_probation">
|
||||
<el-switch v-model="form.is_probation" :active-value="1" :inactive-value="0" />
|
||||
<span class="switch-hint">试用期绩效照算但不发放,年累计超 18000 元部分年底一次性结算</span>
|
||||
<p class="form-hint">试用期绩效照算但不发放,年累计超过 18000 元的部分在年底一次性结算。</p>
|
||||
</el-form-item>
|
||||
<el-form-item label="离职生效日"><el-date-picker v-model="form.resignation_date" value-format="YYYY-MM-DD" placeholder="当日起不计基本工资" /></el-form-item>
|
||||
<h2 class="form-section-title">工资资料</h2>
|
||||
<el-form-item label="工资资料生效日"><el-date-picker v-model="form.salary_effective_date" value-format="YYYY-MM-DD" placeholder="选择生效日期" /><div class="form-hint">新增默认入职日期;编辑默认今天,历史资料保留。</div></el-form-item>
|
||||
<el-form-item label="工资标准" prop="salary_standard">
|
||||
<el-form-item label="月工资标准(元)" prop="salary_standard">
|
||||
<el-input v-model="form.salary_standard" placeholder="如 5000.50(月,元),选填" />
|
||||
</el-form-item>
|
||||
<el-form-item label="备注" prop="remark">
|
||||
<el-input v-model="form.remark" type="textarea" :rows="2" maxlength="255" placeholder="选填" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<el-collapse v-if="salaryHistory.length"><el-collapse-item title="工资标准与任职资料生效记录" name="salary-history"><el-table :data="salaryHistory"><el-table-column prop="effective_date" label="生效日" width="115" /><el-table-column prop="salary_standard" label="工资标准" /><el-table-column prop="business_role_id" label="岗位 ID" /><el-table-column prop="regularization_date" label="转正日" width="115" /><el-table-column prop="resignation_date" label="离职日" width="115" /></el-table></el-collapse-item></el-collapse>
|
||||
<el-collapse v-if="salaryHistory.length"><el-collapse-item title="工资标准与任职资料生效记录" name="salary-history"><el-table :data="salaryHistory"><el-table-column prop="effective_date" label="生效日" width="115" /><el-table-column prop="salary_standard" label="工资标准" align="right" /><el-table-column prop="business_role_id" label="岗位 ID" /><el-table-column prop="regularization_date" label="转正日" width="115" /><el-table-column prop="resignation_date" label="离职日" width="115" /></el-table></el-collapse-item></el-collapse>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button :disabled="submitting" @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSubmit" :disabled="!permissionUser.can((isEdit ? 'base:personnel:update' : 'base:personnel:create'))" :title="permissionUser.permissionReason((isEdit ? 'base:personnel:update' : 'base:personnel:create'))">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { computed, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Delete, Plus } from '@element-plus/icons-vue'
|
||||
import * as payroll from '@/api/payroll'
|
||||
import * as businesses from '@/api/businesses'
|
||||
import { request } from '@/api/request'
|
||||
|
||||
import { handledError } from '@/utils/error'
|
||||
@@ -18,7 +19,7 @@ const monthChecked = ref(false), monthLoading = ref(false)
|
||||
let monthRequest = 0
|
||||
const input = ref<payroll.PayrollInput | null>(null), result = ref<payroll.CalculationResult | null>(null)
|
||||
const people = ref<{id: number; personnel_name: string}[]>([])
|
||||
const rows = ref<(payroll.Ownership & {name: string; ship_date: string; amount: string; source_personnel_name?: string | null; matched_personnel_id?: number | null; guidance_status?: string; guidance_line_total?: string | null; frozen?: boolean})[]>([])
|
||||
const rows = ref<(payroll.Ownership & {name: string; ship_date: string; amount: string; unit?: string | null; source_personnel_name?: string | null; matched_personnel_id?: number | null; guidance_status?: string; guidance_line_total?: string | null; frozen?: boolean})[]>([])
|
||||
const rowPage = ref(1)
|
||||
const businessKey = ref('')
|
||||
const handovers = ref<payroll.Handover[]>([])
|
||||
@@ -27,12 +28,7 @@ const handoversChanged = computed(() => JSON.stringify(handovers.value) !== save
|
||||
const visibleRows = computed(() => rows.value.slice((rowPage.value - 1) * 50, rowPage.value * 50))
|
||||
|
||||
type PayrollMetadata = { business_key?: string | null; personnel: typeof people.value; personnel_total?: number; shipments_total?: number;
|
||||
shipments: {id: number; shipment_no: string; product_name: string; ship_date: string; amount: string; source_personnel_name?: string | null; matched_personnel_id?: number | null; guidance_status?: string; guidance_unit_cost?: string | null; guidance_line_total?: string | null}[] }
|
||||
|
||||
function guidanceHint(row: {guidance_status?: string; guidance_line_total?: string | null; frozen?: boolean}): string {
|
||||
const labels: Record<string, string> = {FINALIZED_UNIT: row.frozen ? '指导价已封存,历史成本以原版本为准' : '使用已封存的月度指导单价', FINALIZED_LINE_TOTAL: `已封存明细总成本:${row.guidance_line_total ?? '待核对'} 元`, AMBIGUOUS: '存在多条指导成本,请先核对', UNIT_MISMATCH: '指导成本单位不一致,请先核对', UNFINALIZED: '指导成本尚未封存'}
|
||||
return labels[row.guidance_status ?? ''] ?? ''
|
||||
}
|
||||
shipments: {id: number; shipment_no: string; product_name: string; ship_date: string; amount: string; unit?: string | null; source_personnel_name?: string | null; matched_personnel_id?: number | null; guidance_status?: string; guidance_unit_cost?: string | null; guidance_line_total?: string | null}[] }
|
||||
|
||||
async function loadMetadata(): Promise<PayrollMetadata> {
|
||||
const pageSize = 200
|
||||
@@ -62,7 +58,7 @@ async function load(): Promise<void> {
|
||||
const data = await loadMetadata()
|
||||
people.value = data.personnel
|
||||
businessKey.value = data.business_key ?? ''
|
||||
rows.value = data.shipments.map(s => ({shipment_item_id: s.id, name: `${s.shipment_no} · ${s.product_name}`, ship_date: s.ship_date, amount: s.amount, source_personnel_name: s.source_personnel_name, matched_personnel_id: s.matched_personnel_id, personnel_id: s.matched_personnel_id ?? undefined, guidance_status: s.guidance_status, guidance_line_total: s.guidance_line_total, unit_cost: s.guidance_unit_cost ?? '', freight_amount: ''}))
|
||||
rows.value = data.shipments.map(s => ({shipment_item_id: s.id, name: `${s.shipment_no} · ${s.product_name}`, ship_date: s.ship_date, amount: s.amount, unit: s.unit, source_personnel_name: s.source_personnel_name, matched_personnel_id: s.matched_personnel_id, guidance_status: s.guidance_status, guidance_line_total: s.guidance_line_total, personnel_id: s.matched_personnel_id ?? undefined, unit_cost: s.guidance_unit_cost ?? '', freight_amount: ''}))
|
||||
rowPage.value = 1
|
||||
if (input.value) {
|
||||
const data = input.value.data as {group?: {opening_balance?: string}; facts?: {handovers?: payroll.Handover[]}; shipments?: {line_id: string; personnel_id: string; manager_id: string | null; cost_total: string; quantity: string; unit_cost?: string; freight_amount?: string}[]; evidence?: string}
|
||||
@@ -112,6 +108,22 @@ async function save(): Promise<void> {
|
||||
ElMessage.success('核算资料已保存为新版本')
|
||||
} catch (e) { handledError(e) } finally { busy.value = false }
|
||||
}
|
||||
async function registerGuidance(row: typeof rows.value[number]): Promise<void> {
|
||||
if (!row.unit_cost || !evidence.value.trim()) {
|
||||
ElMessage.warning('请先填写单位指导成本和核对依据')
|
||||
return
|
||||
}
|
||||
busy.value = true
|
||||
try {
|
||||
await businesses.confirmGuidance(props.businessId, {
|
||||
shipment_item_id: row.shipment_item_id,
|
||||
amount: row.unit_cost,
|
||||
evidence: evidence.value.trim(),
|
||||
})
|
||||
await load()
|
||||
ElMessage.success('指导成本已登记并封存')
|
||||
} catch (e) { handledError(e) } finally { busy.value = false }
|
||||
}
|
||||
function addHandover(): void {
|
||||
handovers.value.push({business_key: businessKey.value, mode: 'RESIGNATION', from_personnel_id: '', to_personnel_id: '', effective_date: '', shipping_ownership_confirmed: false})
|
||||
}
|
||||
@@ -146,8 +158,9 @@ watch(open, value => { monthRequest++; if (value) { month.value = ''; opening.va
|
||||
<el-table-column v-if="sourceManaged" label="来源经办人" min-width="140"><template #default="{row}">{{ row.source_personnel_name || '待确认' }}{{ row.source_personnel_name && !row.matched_personnel_id ? '(待匹配)' : '' }}</template></el-table-column>
|
||||
<el-table-column label="对应业务员" min-width="160"><template #default="{row}"><el-select v-model="row.personnel_id" filterable :disabled="!permissionUser.can('payroll:prepare') || locked || row.frozen" aria-label="发货归属人员"><el-option v-for="p in people" :key="p.id" :value="p.id" :label="p.personnel_name" /></el-select></template></el-table-column>
|
||||
<el-table-column label="当时分管经理" min-width="160"><template #default="{row}"><el-select v-model="row.manager_id" filterable :clearable="sourceManaged" :disabled="!permissionUser.can('payroll:prepare') || locked || row.frozen" aria-label="发货时分管经理"><el-option v-for="p in people" :key="p.id" :value="p.id" :label="p.personnel_name" /></el-select></template></el-table-column>
|
||||
<el-table-column label="含税单位指导成本" min-width="170"><template #default="{row}"><el-input v-model="row.unit_cost" :disabled="!permissionUser.can('payroll:prepare') || locked || row.frozen || row.guidance_status === 'FINALIZED_UNIT'" aria-label="含税单位指导成本" /><small v-if="guidanceHint(row)">{{ guidanceHint(row) }}</small></template></el-table-column>
|
||||
<el-table-column v-if="sourceManaged" label="运费(已核实无运费填 0)" min-width="170"><template #default="{row}"><el-input v-model="row.freight_amount" :disabled="!permissionUser.can('payroll:prepare') || locked || row.frozen" aria-label="发货明细运费" /></template></el-table-column>
|
||||
<el-table-column label="含税单位指导成本" min-width="170"><template #default="{row}"><el-input v-model="row.unit_cost" :disabled="!permissionUser.can('payroll:prepare') || locked || row.frozen || row.guidance_status === 'FINALIZED_UNIT'" aria-label="含税单位指导成本" /></template></el-table-column>
|
||||
<el-table-column v-if="sourceManaged" label="运费(已核实无运费填 0)" min-width="170"><template #default="{row}"><el-input v-model="row.freight_amount" :disabled="!permissionUser.can('payroll:prepare') || locked || row.frozen" aria-label="发货明细运费" /></template></el-table-column>
|
||||
<el-table-column v-if="sourceManaged" label="指导成本登记" width="130"><template #default="{row}"><el-button v-if="row.guidance_status !== 'FINALIZED_UNIT'" link :disabled="!permissionUser.can('payroll:prepare') || locked || busy" @click="registerGuidance(row)">登记并封存</el-button><span v-else class="form-hint">已封存</span></template></el-table-column>
|
||||
</el-table>
|
||||
<el-pagination v-if="rows.length > 50" v-model:current-page="rowPage" :page-size="50" :total="rows.length" layout="prev, pager, next, total" />
|
||||
<section v-if="sourceManaged" class="handover-section" aria-label="业务交接">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import ListLoadError from '@/components/feedback/ListLoadError.vue'
|
||||
import { optionPage } from '@/api/metadata'
|
||||
import { useUserStore as usePermissionUser } from '@/store/user'
|
||||
const permissionUser = usePermissionUser()
|
||||
@@ -32,7 +33,7 @@ const filters = reactive({
|
||||
is_active: 1 as number | undefined,
|
||||
})
|
||||
|
||||
const { records, total, pageNum, pageSize, loading, load, search, onPageChange, onSizeChange } =
|
||||
const { records, total, pageNum, pageSize, loadFailed, loading, load, search, onPageChange, onSizeChange } =
|
||||
usePagedList<LedgerRow, { settlement_month?: string; is_active?: number }>(calcApi.listLedger, () => ({
|
||||
settlement_month: filters.settlement_month.trim() || undefined,
|
||||
is_active: filters.is_active,
|
||||
@@ -132,6 +133,7 @@ async function submitRecalc(): Promise<void> {
|
||||
.filter-bar 直接挂在 el-form 上(去掉 inline):el-form 不是 flex 透明容器,
|
||||
外面再包一层 div 会让 el-form-item 丢掉 gap 排版,筛选条退化成竖排。
|
||||
-->
|
||||
<ListLoadError v-if="loadFailed" :loading="loading" @retry="load" />
|
||||
<el-form class="filter-bar" @submit.prevent>
|
||||
<el-form-item label="归属月">
|
||||
<el-input
|
||||
@@ -179,7 +181,7 @@ async function submitRecalc(): Promise<void> {
|
||||
<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="personnel_name" label="归属人员" width="100" show-overflow-tooltip />
|
||||
<el-table-column label="岗位" width="110"><template #default="{ row }">{{ ({salesperson: '业务员', sales_manager: '营销经理', info_officer: '信息员'} as Record<string, string>)[row.role_code] ?? '自定义岗位' }}</template></el-table-column>
|
||||
<el-table-column prop="receipt_date" label="回款日" width="100" />
|
||||
<el-table-column label="天数" width="70" align="right">
|
||||
@@ -265,8 +267,8 @@ async function submitRecalc(): Promise<void> {
|
||||
<el-table v-loading="allocationLoading" :data="allocationRows" empty-text="暂无正式分配明细">
|
||||
<el-table-column prop="receipt_no" label="回款单" min-width="130" /><el-table-column prop="shipment_no" label="发货单" min-width="130" />
|
||||
<el-table-column prop="shipment_date" label="发货日期" width="110" /><el-table-column prop="receipt_date" label="到账日期" width="110" />
|
||||
<el-table-column prop="amount" label="本次分配金额" width="120" /><el-table-column prop="allocated_cost" label="指导成本分摊" width="120" />
|
||||
<el-table-column prop="performance_delta" label="本次计提" width="100" /><el-table-column prop="payable_delta" label="本期应付差额" width="120" />
|
||||
<el-table-column prop="amount" label="本次分配金额" width="120" align="right" /><el-table-column prop="allocated_cost" label="指导成本分摊" width="120" align="right" />
|
||||
<el-table-column prop="performance_delta" label="本次计提" width="100" align="right" /><el-table-column prop="payable_delta" label="本期应付差额" width="120" align="right" />
|
||||
<el-table-column prop="input_id" label="资料版本编号" width="120" />
|
||||
</el-table>
|
||||
</el-dialog>
|
||||
|
||||
@@ -6,10 +6,12 @@ import { BarChart, LineChart } from 'echarts/charts'
|
||||
import { GridComponent, TooltipComponent } from 'echarts/components'
|
||||
import { SVGRenderer } from 'echarts/renderers'
|
||||
import type { ECharts, EChartsCoreOption } from 'echarts/core'
|
||||
import { Refresh, ArrowRight } from '@element-plus/icons-vue'
|
||||
|
||||
import * as dashboardApi from '@/api/dashboard'
|
||||
import type { ActivityItem, ActivityPage, DashboardData, TrendPoint } from '@/api/dashboard'
|
||||
import AppPagination from '@/components/table/AppPagination.vue'
|
||||
import ListLoadError from '@/components/feedback/ListLoadError.vue'
|
||||
import { usePagedList } from '@/composables/usePagedList'
|
||||
import { handledError } from '@/utils/error'
|
||||
import { formatDateTime } from '@/utils/format'
|
||||
@@ -66,6 +68,7 @@ const data = ref<DashboardData | null>(null)
|
||||
const hasPerformanceTrend = computed(() => data.value?.overview.trend.some(point => point.performance_total != null) ?? false)
|
||||
const hasReceiptTrend = computed(() => data.value?.overview.trend.some(point => point.receipt_total != null) ?? false)
|
||||
const loading = ref(false)
|
||||
const failed = ref(false)
|
||||
/** 快速切换月份时,只允许最新请求写入数据与 loading */
|
||||
let loadSeq = 0
|
||||
|
||||
@@ -78,6 +81,7 @@ let receiptChart: ECharts | null = null
|
||||
* 用 ResizeObserver 监听图表容器本身,布局一变就同步图表尺寸。
|
||||
*/
|
||||
let chartObserver: ResizeObserver | null = null
|
||||
let resizeFrame: number | undefined
|
||||
|
||||
function currentMonth(): string {
|
||||
const now = new Date()
|
||||
@@ -87,6 +91,7 @@ function currentMonth(): string {
|
||||
async function load(): Promise<void> {
|
||||
const seq = ++loadSeq
|
||||
loading.value = true
|
||||
failed.value = false
|
||||
try {
|
||||
const result = await dashboardApi.getDashboard({ month: month.value })
|
||||
if (seq !== loadSeq) return // 已有更新的请求,丢弃过期响应
|
||||
@@ -94,7 +99,7 @@ async function load(): Promise<void> {
|
||||
await nextTick()
|
||||
renderCharts()
|
||||
} catch (error) {
|
||||
if (seq === loadSeq) handledError(error)
|
||||
if (seq === loadSeq) { failed.value = true; handledError(error) }
|
||||
} finally {
|
||||
if (seq === loadSeq) loading.value = false
|
||||
}
|
||||
@@ -124,12 +129,13 @@ function axisCommon(trend: TrendPoint[]) {
|
||||
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 },
|
||||
axisLabel: { fontFamily: CHART_FONT, color: '#657086', fontSize: 11, margin: 8 },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value' as const,
|
||||
splitLine: { show: false },
|
||||
axisLabel: { fontFamily: CHART_FONT, color: '#9aa3b2', fontSize: 9, margin: 6 },
|
||||
splitNumber: 3,
|
||||
splitLine: { show: true, lineStyle: { color: '#edf0f5', type: 'dashed' as const } },
|
||||
axisLabel: { fontFamily: CHART_FONT, color: '#657086', fontSize: 11, margin: 8 },
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -153,11 +159,18 @@ function verticalGradient(top: string, bottom: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function chartValue(value: unknown): string {
|
||||
if (value == null || value === '-') return '暂无记录'
|
||||
const number = Number(value)
|
||||
return Number.isFinite(number) ? number.toFixed(2) : '暂无记录'
|
||||
}
|
||||
|
||||
function performanceOption(trend: TrendPoint[]): EChartsCoreOption {
|
||||
return {
|
||||
animation: false,
|
||||
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) },
|
||||
tooltip: { trigger: 'axis', valueFormatter: chartValue },
|
||||
...axisCommon(trend),
|
||||
series: [
|
||||
{
|
||||
@@ -165,7 +178,7 @@ function performanceOption(trend: TrendPoint[]): EChartsCoreOption {
|
||||
type: 'line',
|
||||
smooth: 0.4,
|
||||
symbol: 'none',
|
||||
data: trend.map((item) => Number(item.performance_total ?? 0)),
|
||||
data: trend.map((item) => item.performance_total == null ? null : Number(item.performance_total)),
|
||||
lineStyle: { color: '#2e4ba8', width: 2 },
|
||||
areaStyle: { color: verticalGradient('rgba(46,75,168,.22)', 'rgba(46,75,168,0)') },
|
||||
},
|
||||
@@ -177,9 +190,10 @@ function receiptOption(trend: TrendPoint[]): EChartsCoreOption {
|
||||
// 最近一个月用饱和靛蓝强调,其余压成浅靛蓝:一眼就能读出「本月」在哪
|
||||
const lastIndex = trend.length - 1
|
||||
return {
|
||||
animation: false,
|
||||
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) },
|
||||
tooltip: { trigger: 'axis', valueFormatter: chartValue },
|
||||
...axisCommon(trend),
|
||||
series: [
|
||||
{
|
||||
@@ -187,7 +201,7 @@ function receiptOption(trend: TrendPoint[]): EChartsCoreOption {
|
||||
type: 'bar',
|
||||
barMaxWidth: 22,
|
||||
data: trend.map((item, index) => ({
|
||||
value: Number(item.receipt_total ?? 0),
|
||||
value: item.receipt_total == null ? null : Number(item.receipt_total),
|
||||
itemStyle: {
|
||||
color:
|
||||
index === lastIndex
|
||||
@@ -202,27 +216,28 @@ function receiptOption(trend: TrendPoint[]): EChartsCoreOption {
|
||||
}
|
||||
|
||||
function handleResize(): void {
|
||||
perfChart?.resize()
|
||||
receiptChart?.resize()
|
||||
if (resizeFrame !== undefined) cancelAnimationFrame(resizeFrame)
|
||||
resizeFrame = requestAnimationFrame(() => {
|
||||
resizeFrame = undefined
|
||||
perfChart?.resize()
|
||||
receiptChart?.resize()
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void load()
|
||||
void loadActivities()
|
||||
window.addEventListener('resize', handleResize)
|
||||
if (perfChartRef.value) {
|
||||
chartObserver = new ResizeObserver(() => {
|
||||
perfChart?.resize()
|
||||
receiptChart?.resize()
|
||||
})
|
||||
chartObserver.observe(perfChartRef.value)
|
||||
}
|
||||
chartObserver = new ResizeObserver(handleResize)
|
||||
if (perfChartRef.value) chartObserver.observe(perfChartRef.value)
|
||||
if (receiptChartRef.value) chartObserver.observe(receiptChartRef.value)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
chartObserver?.disconnect()
|
||||
chartObserver = null
|
||||
if (resizeFrame !== undefined) cancelAnimationFrame(resizeFrame)
|
||||
perfChart?.dispose()
|
||||
receiptChart?.dispose()
|
||||
})
|
||||
@@ -239,6 +254,7 @@ const {
|
||||
pageNum: activityPageNum,
|
||||
pageSize: activityPageSize,
|
||||
loading: activityLoading,
|
||||
loadFailed: activityFailed,
|
||||
load: loadActivities,
|
||||
search: searchActivities,
|
||||
onPageChange: onActivityPageChange,
|
||||
@@ -269,24 +285,28 @@ function formatTime(value: string): string {
|
||||
|
||||
<template>
|
||||
<div v-loading="loading" class="workbench">
|
||||
<div class="page-head">
|
||||
<header class="page-head dashboard-head">
|
||||
<div>
|
||||
<h1 class="page-title">工作台</h1>
|
||||
<p class="page-desc">经营绩效总览 · 待办与风险集中处置 · 最近动态</p>
|
||||
<p class="page-desc">查看本月绩效与回款,处理待办并核对业务风险。</p>
|
||||
<span class="waterline"></span>
|
||||
</div>
|
||||
|
||||
<div class="toolbar">
|
||||
</div>
|
||||
<div class="dashboard-filter">
|
||||
<span>统计月份</span>
|
||||
<el-date-picker
|
||||
v-model="month"
|
||||
type="month"
|
||||
value-format="YYYY-MM"
|
||||
:clearable="false"
|
||||
style="width: 140px"
|
||||
aria-label="统计月份"
|
||||
@change="load"
|
||||
/>
|
||||
<el-button type="primary" :loading="loading" @click="load">刷新</el-button>
|
||||
<span class="toolbar-note">{{ data?.overview.note ?? '' }}</span>
|
||||
<el-button :icon="Refresh" :loading="loading" @click="load">刷新</el-button>
|
||||
</div>
|
||||
</header>
|
||||
<el-alert v-if="failed" class="dashboard-notice" title="工作台更新失败,显示内容可能不是最新结果" type="warning" :closable="false" show-icon><el-button link @click="load">重新加载工作台</el-button></el-alert>
|
||||
<p v-if="data?.overview.note" class="overview-note">{{ data.overview.note }}</p>
|
||||
|
||||
<!-- ① 经营总览:KPI 四格 -->
|
||||
<section class="kpi-row" aria-label="经营总览指标">
|
||||
@@ -333,7 +353,7 @@ function formatTime(value: string): string {
|
||||
<span class="panel-title">近 6 个月绩效</span>
|
||||
<span class="panel-hint">按台账归属月</span>
|
||||
</div>
|
||||
<div v-show="hasPerformanceTrend" ref="perfChartRef" class="chart"></div>
|
||||
<div v-show="hasPerformanceTrend" ref="perfChartRef" class="chart" role="img" aria-label="近六个月绩效趋势,按台账归属月统计"></div>
|
||||
<div v-if="data && !hasPerformanceTrend" class="chart-empty" role="status"><strong>该时段还没有绩效台账</strong><p>采集成功后,仍需导入正式业务、准备核算资料并执行核算,才会形成绩效统计。</p><el-button v-if="user.hasPermission('business:businesses:view')" link type="primary" @click="router.push('/business/businesses')">查看业务与核算</el-button></div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
@@ -341,7 +361,7 @@ function formatTime(value: string): string {
|
||||
<span class="panel-title">近 6 个月回款</span>
|
||||
<span class="panel-hint">按到账月</span>
|
||||
</div>
|
||||
<div v-show="hasReceiptTrend" ref="receiptChartRef" class="chart"></div>
|
||||
<div v-show="hasReceiptTrend" ref="receiptChartRef" class="chart" role="img" aria-label="近六个月回款趋势,按到账月统计"></div>
|
||||
<div v-if="data && !hasReceiptTrend" class="chart-empty" role="status"><strong>该时段还没有正式回款记录</strong><p>T+ 原始回款需正式导入,或在回款管理登记后,才会进入这里的统计。</p><el-button v-if="user.hasPermission('business:receipts:view')" link type="primary" @click="router.push('/business/receipts')">查看回款管理</el-button></div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -358,7 +378,7 @@ function formatTime(value: string): string {
|
||||
<div class="list-block__head">
|
||||
<span class="list-block__title">待审批</span>
|
||||
<span class="list-block__count">{{ data?.todos.pending_approval_total ?? 0 }}</span>
|
||||
<button class="list-block__more" type="button" @click="go('/system/approvals')">
|
||||
<button v-if="data?.todos.pending_approval_total" class="list-block__more" type="button" @click="go('/system/approvals')">
|
||||
全部 →
|
||||
</button>
|
||||
</div>
|
||||
@@ -389,7 +409,7 @@ function formatTime(value: string): string {
|
||||
<div class="list-block__head">
|
||||
<span class="list-block__title">未指定批次的回款(供核对)</span>
|
||||
<span class="list-block__count">{{ data?.todos.unassigned_receipt_total ?? 0 }}</span>
|
||||
<button class="list-block__more" type="button" @click="go('/business/receipts')">
|
||||
<button v-if="data?.todos.unassigned_receipt_total" class="list-block__more" type="button" @click="go('/business/receipts')">
|
||||
全部 →
|
||||
</button>
|
||||
</div>
|
||||
@@ -410,17 +430,6 @@ function formatTime(value: string): string {
|
||||
<div v-else class="list-empty">无未指定批次的回款</div>
|
||||
</div>
|
||||
|
||||
<div class="status-strip">
|
||||
<span class="status-strip__label">T+ 数据采集</span>
|
||||
<span class="status-strip__value">
|
||||
{{
|
||||
!data ? '正在加载采集状态…' : !data.sync.available ? '采集记录暂不可查询'
|
||||
: data.sync.last_success_at
|
||||
? `最近成功:${formatDateTime(data.sync.last_success_at)}`
|
||||
: '尚无成功采集记录'
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel panel--list">
|
||||
@@ -465,21 +474,22 @@ function formatTime(value: string): string {
|
||||
仅显示前 {{ data.warnings.items.length }} 条,共 {{ data.warnings.total }} 条
|
||||
</div>
|
||||
|
||||
<div class="status-strip">
|
||||
<span class="status-strip__label">采集运行状态</span>
|
||||
<span class="status-strip__value">
|
||||
<template v-if="data?.sync.available">
|
||||
{{ SYNC_STATUS_LABELS[data.sync.latest_status ?? ''] ?? '尚无采集任务' }};
|
||||
{{ data.sync.enabled ? '源读取已启用' : '源读取未启用' }};
|
||||
{{ data.sync.automation_enabled ? '自动采集已开启' : '自动采集未开启' }}
|
||||
<span v-if="data.sync.historical_failure_total">;历史异常 {{ data.sync.historical_failure_total }} 批次</span>
|
||||
</template>
|
||||
<template v-else>{{ data ? '采集记录暂不可查询' : '正在加载采集状态…' }}</template>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="sync-context" aria-label="T+ 数据采集状态">
|
||||
<strong>T+ 数据采集</strong>
|
||||
<template v-if="data?.sync.available">
|
||||
<span class="sync-context__state">{{ SYNC_STATUS_LABELS[data.sync.latest_status ?? ''] ?? '尚无采集任务' }}</span>
|
||||
<span>{{ data.sync.enabled ? '源读取已启用' : '源读取未启用' }}</span>
|
||||
<span>{{ data.sync.automation_enabled ? '自动采集已开启' : '自动采集未开启' }}</span>
|
||||
<span>最近成功:{{ formatDateTime(data.sync.last_success_at, '尚无成功记录') }}</span>
|
||||
<span v-if="data.sync.historical_failure_total">历史异常 {{ data.sync.historical_failure_total }} 批次</span>
|
||||
</template>
|
||||
<span v-else>{{ data ? '采集记录暂不可查询' : '正在加载采集状态…' }}</span>
|
||||
<el-button v-if="user.hasPermission('tplus:view')" link type="primary" :icon="ArrowRight" @click="go('/system/tplus')">查看同步</el-button>
|
||||
</section>
|
||||
|
||||
<!-- ④ 最近动态(服务端分页:列表有边界,翻页只重取这张表) -->
|
||||
<section class="panel panel--activity" aria-label="最近动态">
|
||||
<div class="panel-head">
|
||||
@@ -490,6 +500,7 @@ function formatTime(value: string): string {
|
||||
clearable
|
||||
placeholder="全部模块"
|
||||
class="activity-filter"
|
||||
aria-label="动态模块"
|
||||
@change="searchActivities"
|
||||
>
|
||||
<el-option v-for="item in activityModules" :key="item" :label="item" :value="item" />
|
||||
@@ -503,6 +514,7 @@ function formatTime(value: string): string {
|
||||
观感就是「数据反复变化」。
|
||||
-->
|
||||
<div v-loading="activityLoading" class="activity-list-wrap">
|
||||
<ListLoadError v-if="activityFailed" :loading="activityLoading" @retry="loadActivities" />
|
||||
<div v-if="activities.length" class="activity-list">
|
||||
<div class="activity-row activity-row--head">
|
||||
<span>时间</span>
|
||||
@@ -529,7 +541,7 @@ function formatTime(value: string): string {
|
||||
<span class="activity-cell activity-cell--user">{{ item.username ?? '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="list-empty list-empty--block">暂无动态</div>
|
||||
<div v-else-if="!activityLoading && !activityFailed" class="list-empty list-empty--block">暂无动态</div>
|
||||
</div>
|
||||
|
||||
<AppPagination
|
||||
@@ -550,10 +562,10 @@ function formatTime(value: string): string {
|
||||
<span class="panel-title">常用操作</span>
|
||||
</div>
|
||||
<div class="quick-actions">
|
||||
<button class="quick-btn" type="button" @click="go('/business/receipts')">录入回款</button>
|
||||
<button class="quick-btn" type="button" @click="go('/calc/ledger')">绩效台账</button>
|
||||
<button class="quick-btn" type="button" @click="go('/settlements')">月度结算</button>
|
||||
<button class="quick-btn" type="button" @click="go('/reports/personal-detail')">
|
||||
<button v-if="user.hasPermission('business:receipts:view')" class="quick-btn" type="button" @click="go('/business/receipts')">录入回款</button>
|
||||
<button v-if="user.hasPermission('payroll:view')" class="quick-btn" type="button" @click="go('/calc/ledger')">绩效台账</button>
|
||||
<button v-if="user.hasPermission('settlements:view')" class="quick-btn" type="button" @click="go('/settlements')">月度结算</button>
|
||||
<button v-if="user.hasPermission('reports:personal:view')" class="quick-btn" type="button" @click="go('/reports/personal-detail')">
|
||||
报表统计
|
||||
</button>
|
||||
</div>
|
||||
@@ -562,6 +574,16 @@ function formatTime(value: string): string {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.workbench { min-width: 0; }
|
||||
.dashboard-head { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: flex-start; gap: 16px 24px; }
|
||||
.dashboard-filter { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; }
|
||||
.dashboard-filter > span { font-size: 13px; color: var(--ys-ink-2); }
|
||||
.overview-note { margin: 0 0 14px; color: var(--ys-ink-3); font-size: 12px; line-height: 1.7; }
|
||||
.dashboard-notice { margin-bottom: 16px; }
|
||||
.sync-context { display: flex; flex-wrap: wrap; align-items: baseline; gap: 8px 20px; padding: 16px 0; border-top: 1px solid var(--ys-line); border-bottom: 1px solid var(--ys-line); color: var(--ys-ink-2); font-size: 12px; line-height: 1.7; }
|
||||
.sync-context strong { color: var(--ys-ink); font-size: 13px; font-weight: 600; }
|
||||
.sync-context__state { color: var(--ys-indigo); }
|
||||
.sync-context .el-button { margin-left: auto; }
|
||||
/* 页面头(.page-head/.page-title/.page-desc/.waterline)、.toolbar 均来自全局 index.css */
|
||||
|
||||
/*
|
||||
@@ -581,7 +603,10 @@ function formatTime(value: string): string {
|
||||
.kpi-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
gap: 0;
|
||||
background: var(--ys-paper);
|
||||
border: 1px solid var(--ys-line);
|
||||
border-radius: var(--ys-radius);
|
||||
}
|
||||
|
||||
.kpi-card {
|
||||
@@ -589,22 +614,11 @@ function formatTime(value: string): string {
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
min-width: 0;
|
||||
min-height: 116px;
|
||||
min-height: 108px;
|
||||
padding: 16px 18px;
|
||||
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);
|
||||
animation: ys-rise 320ms var(--ys-ease) backwards;
|
||||
}
|
||||
|
||||
.kpi-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--ys-shadow-raise);
|
||||
background: none;
|
||||
}
|
||||
.kpi-card + .kpi-card { border-left: 1px solid var(--ys-line-soft); }
|
||||
|
||||
.kpi-label {
|
||||
font-size: 12px;
|
||||
@@ -647,36 +661,10 @@ function formatTime(value: string): string {
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
padding: 16px 18px;
|
||||
background: linear-gradient(180deg, var(--ys-paper), #fcfcfe);
|
||||
background: var(--ys-paper);
|
||||
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);
|
||||
animation: ys-rise 320ms var(--ys-ease) backwards;
|
||||
}
|
||||
|
||||
.panel:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--ys-shadow-raise);
|
||||
}
|
||||
|
||||
/* 交错入场:区块之间延后 60ms,避免整屏同时闪一下 */
|
||||
.workbench > section:nth-of-type(2) .panel {
|
||||
animation-delay: 60ms;
|
||||
}
|
||||
|
||||
.workbench > section:nth-of-type(3) .panel {
|
||||
animation-delay: 120ms;
|
||||
}
|
||||
|
||||
.workbench > section:nth-of-type(4) .panel {
|
||||
animation-delay: 180ms;
|
||||
}
|
||||
|
||||
.workbench > section:nth-of-type(5) .panel {
|
||||
animation-delay: 240ms;
|
||||
}
|
||||
|
||||
.panel-head {
|
||||
@@ -690,14 +678,14 @@ function formatTime(value: string): string {
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-size: 13px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--ys-ink-2);
|
||||
color: var(--ys-ink);
|
||||
}
|
||||
|
||||
.panel-hint {
|
||||
font-size: 11px;
|
||||
font-size: 12px;
|
||||
color: var(--ys-ink-3);
|
||||
}
|
||||
|
||||
@@ -724,12 +712,12 @@ function formatTime(value: string): string {
|
||||
/* ── ③ 待办 / 预警 ────────────────────────────────────────────────────── */
|
||||
/* 高度下限保证列表区有 4~5 行可视;内容更长时由内部滚动消化 */
|
||||
.panel--list {
|
||||
min-height: 420px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.list-block {
|
||||
display: flex;
|
||||
flex: 1 1 0;
|
||||
flex: none;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
@@ -740,6 +728,7 @@ function formatTime(value: string): string {
|
||||
|
||||
.list-block__head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: none;
|
||||
@@ -784,6 +773,7 @@ function formatTime(value: string): string {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
max-height: 260px;
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -824,15 +814,17 @@ function formatTime(value: string): string {
|
||||
.row__main {
|
||||
font-size: 13px;
|
||||
color: var(--ys-ink);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow-wrap: anywhere;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.row__meta {
|
||||
font-size: 11px;
|
||||
color: var(--ys-ink-3);
|
||||
white-space: nowrap;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
max-width: 28ch;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* 预警行:标签 | 标题 | 详情 三列并排 */
|
||||
@@ -840,6 +832,7 @@ function formatTime(value: string): string {
|
||||
grid-template-columns: auto minmax(0, 1fr) minmax(0, 0.85fr);
|
||||
align-items: center;
|
||||
}
|
||||
.row--warn .row__meta { white-space: normal; line-height: 1.7; }
|
||||
|
||||
.row__tag {
|
||||
display: inline-flex;
|
||||
@@ -848,13 +841,15 @@ function formatTime(value: string): string {
|
||||
/* 空态在卡片剩余高度里居中,避免内容全挤在顶部、下方留一大片空白 */
|
||||
.list-empty {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 12px 0;
|
||||
justify-content: flex-start;
|
||||
min-height: 56px;
|
||||
padding: 16px 8px;
|
||||
font-size: 12px;
|
||||
color: var(--ys-ink-3);
|
||||
text-align: center;
|
||||
text-align: left;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
/* 面板级空态(预警/动态无数据时独占一屏高度) */
|
||||
@@ -873,6 +868,7 @@ function formatTime(value: string): string {
|
||||
.status-strip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
flex: none;
|
||||
margin-top: 12px;
|
||||
@@ -895,7 +891,7 @@ function formatTime(value: string): string {
|
||||
|
||||
/* ── ④ 最近动态 ───────────────────────────────────────────────────────── */
|
||||
.panel--activity {
|
||||
min-height: 320px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.activity-filter {
|
||||
@@ -969,6 +965,8 @@ function formatTime(value: string): string {
|
||||
|
||||
.activity-cell--summary {
|
||||
color: var(--ys-ink-2);
|
||||
white-space: normal;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.activity-cell--user {
|
||||
@@ -1015,6 +1013,8 @@ function formatTime(value: string): string {
|
||||
.kpi-row {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.kpi-card:nth-child(3) { border-left: 0; }
|
||||
.kpi-card:nth-child(n+3) { border-top: 1px solid var(--ys-line-soft); }
|
||||
}
|
||||
|
||||
@media (max-width: 1080px) {
|
||||
@@ -1024,13 +1024,13 @@ function formatTime(value: string): string {
|
||||
}
|
||||
|
||||
.panel--list {
|
||||
min-height: 360px;
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.kpi-row {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.activity-row {
|
||||
@@ -1043,6 +1043,14 @@ function formatTime(value: string): string {
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.row:not(.row--warn) { grid-template-columns: minmax(0, 1fr); gap: 4px; }
|
||||
.row__meta { max-width: none; }
|
||||
.dashboard-filter { width: 100%; }
|
||||
.sync-context { gap: 8px 16px; }
|
||||
.sync-context strong { flex-basis: 100%; }
|
||||
.kpi-row { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.kpi-card { padding: 16px; }
|
||||
.kpi-value { font-size: clamp(18px, 5vw, 26px); overflow-wrap: anywhere; }
|
||||
.panel {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ const userStore = useUserStore()
|
||||
const formRef = ref<FormInstance>()
|
||||
const submitting = ref(false)
|
||||
const captchaLoading = ref(false)
|
||||
const captchaFailed = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
username: '',
|
||||
@@ -61,7 +62,9 @@ const rules: FormRules = {
|
||||
|
||||
/** 加载或刷新验证码。点击图片亦可触发。 */
|
||||
async function refreshCaptcha(): Promise<void> {
|
||||
if (captchaLoading.value) return
|
||||
captchaLoading.value = true
|
||||
captchaFailed.value = false
|
||||
try {
|
||||
const data = await getCaptcha()
|
||||
captchaId.value = data.captcha_id
|
||||
@@ -69,6 +72,9 @@ async function refreshCaptcha(): Promise<void> {
|
||||
captchaImage.value = `data:image/png;base64,${data.image_base64}`
|
||||
form.captcha_code = ''
|
||||
} catch {
|
||||
captchaFailed.value = true
|
||||
captchaId.value = ''
|
||||
captchaImage.value = ''
|
||||
// 失败提示已由 axios 拦截器统一处理,这里只需保证界面回到可用状态
|
||||
} finally {
|
||||
captchaLoading.value = false
|
||||
@@ -76,7 +82,11 @@ async function refreshCaptcha(): Promise<void> {
|
||||
}
|
||||
|
||||
async function handleSubmit(): Promise<void> {
|
||||
if (!formRef.value) return
|
||||
if (!formRef.value || submitting.value || captchaLoading.value) return
|
||||
if (!captchaId.value) {
|
||||
await refreshCaptcha()
|
||||
return
|
||||
}
|
||||
const valid = await formRef.value.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
|
||||
@@ -172,17 +182,19 @@ onMounted(() => {
|
||||
type="button"
|
||||
class="captcha__image"
|
||||
:aria-busy="captchaLoading"
|
||||
:disabled="captchaLoading || submitting"
|
||||
:aria-label="captchaFailed ? '验证码加载失败,点击重试' : '换一张验证码'"
|
||||
title="点击换一张"
|
||||
@click="refreshCaptcha"
|
||||
>
|
||||
<img v-if="captchaImage" :src="captchaImage" alt="验证码" draggable="false" />
|
||||
<span v-else class="captcha__placeholder">加载中</span>
|
||||
<span v-else class="captcha__placeholder">{{ captchaFailed ? '点击重试' : '加载中' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item class="form-actions">
|
||||
<el-button type="primary" class="submit" :loading="submitting" @click="handleSubmit">
|
||||
<el-button type="primary" class="submit" :loading="submitting" :disabled="captchaLoading" @click="handleSubmit">
|
||||
登 录
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
|
||||
@@ -1,5 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import { useUserStore } from '@/store/user'
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { firstAccessiblePath } from '@/router/access'
|
||||
import { handledError } from '@/utils/error'
|
||||
const user = useUserStore()
|
||||
const router = useRouter()
|
||||
const checking = ref(false)
|
||||
async function checkAccess(): Promise<void> {
|
||||
if (checking.value) return
|
||||
checking.value = true
|
||||
try {
|
||||
await user.fetchUserInfo()
|
||||
const target = firstAccessiblePath(router, user.roles)
|
||||
if (target !== '/no-access') await router.replace(target)
|
||||
} catch (error) { handledError(error) }
|
||||
finally { checking.value = false }
|
||||
}
|
||||
</script>
|
||||
<template><section class="card card-body"><h1 class="page-title">暂无可访问功能</h1><p>当前账号尚未获得功能权限,请联系系统管理员在用户管理中授权。</p><el-button @click="user.fetchUserInfo">重新检查权限</el-button></section></template>
|
||||
<template>
|
||||
<section class="card access-empty" aria-labelledby="access-title">
|
||||
<h1 id="access-title" class="page-title">暂无可访问功能</h1>
|
||||
<p>当前账号尚未分配功能权限。请联系管理员分配角色,授权后可在这里重新检查。</p>
|
||||
<el-button type="primary" :loading="checking" @click="checkAccess">重新检查权限</el-button>
|
||||
</section>
|
||||
</template>
|
||||
<style scoped>
|
||||
.access-empty { max-width: 640px; margin: 48px auto; padding: 40px 24px; text-align: center; }
|
||||
.access-empty p { max-width: 38em; margin: 16px auto 24px; color: var(--ys-ink-2); line-height: 1.8; }
|
||||
</style>
|
||||
@@ -215,7 +215,7 @@ function pct(value: string): string {
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<section v-if="data?.salary_rows?.length" class="salary-snapshot"><h2 class="section-title">已生成工资分项(冻结快照)</h2><el-table :data="data.salary_rows" border><el-table-column prop="personnel_name" label="人员" width="120" /><el-table-column v-for="[key,label] in [['basic_amount','基本工资'],['normal_payable_sum','正常绩效'],['deferred_sum','试用期缓发'],['probation_payment_sum','年终/续付差额'],['payable_sum','本月实付绩效'],['net_amount','净工资']]" :key="key" :prop="key" :label="label" min-width="140" align="right" /></el-table></section>
|
||||
<section v-if="data?.salary_rows?.length" class="salary-snapshot"><h2 class="section-title">已生成工资分项(冻结快照)</h2><el-table :data="data.salary_rows" border><el-table-column prop="personnel_name" label="人员" width="120" show-overflow-tooltip /><el-table-column v-for="[key,label] in [['basic_amount','基本工资'],['normal_payable_sum','正常绩效'],['deferred_sum','试用期缓发'],['probation_payment_sum','年终/续付差额'],['payable_sum','本月实付绩效'],['net_amount','净工资']]" :key="key" :prop="key" :label="label" min-width="140" align="right" /></el-table></section>
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
class="dense"
|
||||
@@ -228,7 +228,7 @@ function pct(value: string): string {
|
||||
<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="personnel_name" label="成员" width="120" show-overflow-tooltip />
|
||||
<el-table-column prop="role_code" label="岗位" width="120" />
|
||||
<el-table-column prop="performance_sum" label="绩效金额" width="120" align="right">
|
||||
<template #default="{ row: member }">
|
||||
@@ -325,7 +325,7 @@ function pct(value: string): string {
|
||||
<div v-if="data && data.unassigned.length" class="unassigned">
|
||||
<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="personnel_name" label="人员" width="130" show-overflow-tooltip />
|
||||
<el-table-column prop="role_code" label="岗位" width="120" />
|
||||
<el-table-column prop="performance_sum" label="绩效金额" width="130" align="right">
|
||||
<template #default="{ row }">
|
||||
@@ -363,6 +363,7 @@ function pct(value: string): string {
|
||||
|
||||
.company-card {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
margin-bottom: 12px;
|
||||
@@ -399,4 +400,9 @@ function pct(value: string): string {
|
||||
.unassigned {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.company-card { gap: 10px 18px; }
|
||||
.company-note { width: 100%; line-height: 1.7; }
|
||||
}
|
||||
</style>
|
||||
@@ -249,11 +249,11 @@ function today(): string {
|
||||
size="small"
|
||||
stripe
|
||||
>
|
||||
<el-table-column prop="business_no" label="合同编号" min-width="150" />
|
||||
<el-table-column prop="business_no" label="合同编号" min-width="150" show-overflow-tooltip />
|
||||
<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="personnel_name" label="归属人员" width="110" show-overflow-tooltip />
|
||||
<el-table-column prop="payment_days" label="天数" width="80" align="right" />
|
||||
<el-table-column label="客户类型" width="100">
|
||||
<template #default="{ row: detail }">
|
||||
@@ -402,7 +402,7 @@ function today(): string {
|
||||
|
||||
.dimension-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(420px, 1fr));
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 420px), 1fr));
|
||||
gap: 20px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import ListLoadError from '@/components/feedback/ListLoadError.vue'
|
||||
import { optionPage } from '@/api/metadata'
|
||||
import { useUserStore as usePermissionUser } from '@/store/user'
|
||||
const permissionUser = usePermissionUser()
|
||||
@@ -85,7 +86,7 @@ function fetchPage(params: ReportFilterParams) {
|
||||
}
|
||||
|
||||
// summary 只能由最新请求写入:fetcher 内的副作用不受 usePagedList 序号保护
|
||||
const { records, total, pageNum, pageSize, loading, search, onPageChange, onSizeChange } =
|
||||
const { records, total, pageNum, pageSize, loadFailed, loading, search, onPageChange, onSizeChange } =
|
||||
usePagedList(fetchPage, getFilters, (data) => {
|
||||
summary.value = data.summary
|
||||
})
|
||||
@@ -193,6 +194,7 @@ function today(): string {
|
||||
.filter-bar 直接挂在 el-form 上(去掉 inline):el-form 本身就是 flex 容器,
|
||||
外面再包一层 div 会让 el-form-item 的 gap 排版失效。
|
||||
-->
|
||||
<ListLoadError v-if="loadFailed" :loading="loading" @retry="search" />
|
||||
<el-form class="filter-bar" @submit.prevent>
|
||||
<el-form-item label="月份">
|
||||
<el-date-picker
|
||||
@@ -278,7 +280,7 @@ function today(): string {
|
||||
<LedgerCalculationDetails v-else :ledger="snapshots[row.ledger_id]" :loading="snapshotLoading[row.ledger_id]" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="business_no" label="合同编号" min-width="160">
|
||||
<el-table-column prop="business_no" label="合同编号" min-width="160" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<span class="cell-code">{{ row.business_no }}</span>
|
||||
</template>
|
||||
@@ -298,7 +300,7 @@ function today(): string {
|
||||
<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="role_name" label="岗位" width="100" show-overflow-tooltip />
|
||||
<el-table-column prop="rate_text" label="计提比例" width="110">
|
||||
<template #default="{ row }">
|
||||
<span class="cell-num">{{ row.rate_text }}</span>
|
||||
|
||||
@@ -187,6 +187,7 @@ onMounted(load)
|
||||
</div>
|
||||
|
||||
<!-- 稿纸:与屏幕稿不同源 —— 白底、实线表格、章节编号、签字栏,无圆角与阴影 -->
|
||||
<div class="sheet-viewport" role="region" aria-label="报表预览,可横向滚动" tabindex="0">
|
||||
<div class="sheet">
|
||||
<header class="sheet-header">
|
||||
<img v-if="logoUrl" :src="logoUrl" class="logo" alt="公司 logo" />
|
||||
@@ -489,6 +490,7 @@ onMounted(load)
|
||||
<div>审批:____________</div>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -563,6 +565,14 @@ onMounted(load)
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.sheet-viewport { overflow-x: auto; overscroll-behavior-x: contain; }
|
||||
|
||||
@media screen and (max-width: 800px) {
|
||||
.print-bar { height: auto; min-height: 52px; padding: 14px 16px; gap: 12px; flex-wrap: wrap; }
|
||||
.print-bar__actions { flex-wrap: wrap; gap: 8px; }
|
||||
.sheet { min-width: 1000px; margin: 0 16px; }
|
||||
}
|
||||
|
||||
.sheet-header {
|
||||
text-align: center;
|
||||
}
|
||||
@@ -661,6 +671,7 @@ onMounted(load)
|
||||
}
|
||||
|
||||
@media print {
|
||||
.sheet-viewport { overflow: visible; }
|
||||
.no-print {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import ListLoadError from '@/components/feedback/ListLoadError.vue'
|
||||
import httpService from '@/api/request'
|
||||
import ProbationPanel from '@/components/payroll/ProbationPanel.vue'
|
||||
import { useUserStore as usePermissionUser } from '@/store/user'
|
||||
@@ -35,7 +36,7 @@ const filters = reactive({
|
||||
status: undefined as number | undefined,
|
||||
})
|
||||
|
||||
const { records, total, pageNum, pageSize, loading, load, search, onPageChange, onSizeChange } =
|
||||
const { records, total, pageNum, pageSize, loadFailed, loading, load, search, onPageChange, onSizeChange } =
|
||||
usePagedList<Settlement, { status?: number }>(listSettlements, () => ({ status: filters.status }))
|
||||
|
||||
onMounted(() => {
|
||||
@@ -129,7 +130,6 @@ async function handleSubmit(row: Settlement): Promise<void> {
|
||||
|
||||
<template>
|
||||
<div class="requirements-surface">
|
||||
<ProbationPanel />
|
||||
<div class="page">
|
||||
<div class="page-head">
|
||||
<h1 class="page-title">月度结算</h1>
|
||||
@@ -178,6 +178,7 @@ async function handleSubmit(row: Settlement): Promise<void> {
|
||||
.filter-bar 直接挂到 el-form 上:el-form 不能当 flex 透明容器,
|
||||
外面再包一层 div 会让 el-form-item 的 gap 排版失效。
|
||||
-->
|
||||
<ListLoadError v-if="loadFailed" :loading="loading" @retry="load" />
|
||||
<el-form class="filter-bar" @submit.prevent>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="filters.status" placeholder="全部" clearable style="width: 150px">
|
||||
@@ -250,6 +251,8 @@ async function handleSubmit(row: Settlement): Promise<void> {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ProbationPanel />
|
||||
|
||||
<el-drawer v-model="detailVisible" title="月结详情" size="min(1180px, 96vw)">
|
||||
<div v-loading="detailLoading">
|
||||
<template v-if="detail">
|
||||
@@ -275,7 +278,7 @@ async function handleSubmit(row: Settlement): Promise<void> {
|
||||
|
||||
<p class="section-title section-title--gap">按人明细</p><el-button :disabled="!permissionUser.can('reports:personal:export')" @click="exportSalary(detail.settlement_month)">导出工资分项 Excel</el-button>
|
||||
<el-table :data="detail.items" size="small" stripe>
|
||||
<el-table-column prop="personnel_name" label="人员" min-width="90" />
|
||||
<el-table-column prop="personnel_name" label="人员" min-width="90" show-overflow-tooltip />
|
||||
<el-table-column label="类型" width="90">
|
||||
<template #default="{ row }">
|
||||
{{ row.calc_type === 1 ? '单笔回款' : '经理聚合' }}
|
||||
@@ -296,7 +299,7 @@ async function handleSubmit(row: Settlement): Promise<void> {
|
||||
<span class="cell-num">{{ row.net_amount ?? '—' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="basic_amount" label="基本工资" min-width="110" /><el-table-column prop="normal_payable_sum" label="正常绩效" min-width="110" /><el-table-column prop="deferred_sum" label="试用期缓发" min-width="120" /><el-table-column prop="probation_payment_sum" label="年终/续付差额" min-width="140" />
|
||||
<el-table-column prop="basic_amount" label="基本工资" min-width="110" align="right" /><el-table-column prop="normal_payable_sum" label="正常绩效" min-width="110" /><el-table-column prop="deferred_sum" label="试用期缓发" min-width="120" /><el-table-column prop="probation_payment_sum" label="年终/续付差额" min-width="140" />
|
||||
<el-table-column label="试用期" width="70">
|
||||
<template #default="{ row }">{{ row.is_probation === 1 ? '是' : '否' }}</template>
|
||||
</el-table-column>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import ListLoadError from '@/components/feedback/ListLoadError.vue'
|
||||
import { useUserStore as usePermissionUser } from '@/store/user'
|
||||
const permissionUser = usePermissionUser()
|
||||
|
||||
@@ -43,7 +44,7 @@ const filters = reactive({
|
||||
order_type: undefined as number | undefined,
|
||||
})
|
||||
|
||||
const { records, total, pageNum, pageSize, loading, load, search, onPageChange, onSizeChange } =
|
||||
const { records, total, pageNum, pageSize, loadFailed, loading, load, search, onPageChange, onSizeChange } =
|
||||
usePagedList<ApprovalOrder, { keyword?: string; status?: number; order_type?: number }>(
|
||||
listApprovals,
|
||||
() => ({
|
||||
@@ -185,6 +186,7 @@ async function submitAction(): Promise<void> {
|
||||
.filter-bar 直接挂到 el-form 上:再包一层 div 会让 el-form-item
|
||||
失去 gap 排版(el-form 不是 flex 透明容器)。
|
||||
-->
|
||||
<ListLoadError v-if="loadFailed" :loading="loading" @retry="load" />
|
||||
<el-form class="filter-bar" @submit.prevent>
|
||||
<el-form-item label="关键词">
|
||||
<el-input
|
||||
@@ -337,7 +339,7 @@ async function submitAction(): Promise<void> {
|
||||
<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 prop="personnel_name" label="参与业务员" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column label="分配比例" width="120">
|
||||
<template #default="{ row }">
|
||||
<span class="cell-num">{{ row.ratio }}%</span>
|
||||
@@ -371,7 +373,7 @@ async function submitAction(): Promise<void> {
|
||||
</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="personnel_name" label="人员" min-width="110" show-overflow-tooltip />
|
||||
<el-table-column prop="role_code" label="岗位" width="110" />
|
||||
<el-table-column label="绩效(元)" width="120">
|
||||
<template #default="{ row }">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import ListLoadError from '@/components/feedback/ListLoadError.vue'
|
||||
import { useUserStore as usePermissionUser } from '@/store/user'
|
||||
const permissionUser = usePermissionUser()
|
||||
|
||||
@@ -33,7 +34,7 @@ const filters = reactive({
|
||||
status: undefined as number | undefined,
|
||||
})
|
||||
|
||||
const { records, total, pageNum, pageSize, loading, load, search, onPageChange, onSizeChange, reloadAfterDelete } =
|
||||
const { records, total, pageNum, pageSize, loadFailed, loading, load, search, onPageChange, onSizeChange, reloadAfterDelete } =
|
||||
usePagedList<BusinessRoleItem, businessRoleApi.BusinessRoleQueryParams>(
|
||||
businessRoleApi.listBusinessRoles,
|
||||
() => ({
|
||||
@@ -67,7 +68,7 @@ const templateLoading = ref(false)
|
||||
const templateSubmitting = ref(false)
|
||||
const templateDialogVisible = ref(false)
|
||||
const templateEditingId = ref<number | null>(null)
|
||||
const paramOptions = ref<{ code: string; name: string; group: string }[]>([])
|
||||
const paramOptions = ref<{ code: string; name: string; group: string; current: string }[]>([])
|
||||
|
||||
const templateFormRef = ref<FormInstance>()
|
||||
const templateForm = reactive({
|
||||
@@ -99,10 +100,18 @@ function formulaName(code: string): string {
|
||||
return formulas.value.find((f) => f.code === code)?.name ?? code
|
||||
}
|
||||
|
||||
function formulaRoleLabel(formulaType: string, role: string): string {
|
||||
return formulas.value.find((f) => f.code === formulaType)?.role_labels[role] ?? role
|
||||
}
|
||||
|
||||
function paramName(code: string): string {
|
||||
return paramOptions.value.find((item) => item.code === code)?.name ?? code
|
||||
}
|
||||
|
||||
function paramCurrentValue(code: string): string {
|
||||
return paramOptions.value.find((item) => item.code === code)?.current ?? '—'
|
||||
}
|
||||
|
||||
async function loadTemplateManager(): Promise<void> {
|
||||
templateLoading.value = true
|
||||
try {
|
||||
@@ -111,7 +120,12 @@ async function loadTemplateManager(): Promise<void> {
|
||||
formulas.value = formulaList
|
||||
if (paramOptions.value.length === 0) {
|
||||
const catalog = await paramsApi.getCatalog()
|
||||
paramOptions.value = catalog.map((item) => ({ code: item.param_code, name: item.param_name, group: item.param_group }))
|
||||
paramOptions.value = catalog.map((item) => ({
|
||||
code: item.param_code,
|
||||
name: item.param_name,
|
||||
group: item.param_group,
|
||||
current: item.values[0]?.value_text ?? item.default_value ?? '未设置',
|
||||
}))
|
||||
}
|
||||
} catch (error) {
|
||||
handledError(error)
|
||||
@@ -360,6 +374,7 @@ onMounted(() => {
|
||||
筛选条:flex 与间距由 .filter-bar 提供,去掉 el-form 的 inline(inline 的行内块
|
||||
排布会与 gap 叠成不等距);查询/重置由 .filter-bar__actions 推到行尾。
|
||||
-->
|
||||
<ListLoadError v-if="loadFailed" :loading="loading" @retry="load" />
|
||||
<el-form class="filter-bar" @submit.prevent>
|
||||
<el-form-item label="关键词">
|
||||
<el-input
|
||||
@@ -405,11 +420,11 @@ onMounted(() => {
|
||||
<span class="cell-code">{{ row.role_code }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="role_name" label="岗位名称" min-width="110" />
|
||||
<el-table-column prop="role_name" label="岗位名称" min-width="110" show-overflow-tooltip />
|
||||
<el-table-column label="公式模板" min-width="170">
|
||||
<template #default="{ row }">{{ templateName(row.calc_template) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="description" label="说明" min-width="200">
|
||||
<el-table-column prop="description" label="说明" min-width="200" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.description || '—' }}</template>
|
||||
</el-table-column>
|
||||
<!-- 排序是用于比较的数字:右对齐 + tabular-nums,纵向扫读时数位对齐 -->
|
||||
@@ -467,6 +482,9 @@ onMounted(() => {
|
||||
<!-- 新增 / 编辑 -->
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:close-on-click-modal="!submitting"
|
||||
:close-on-press-escape="!submitting"
|
||||
:show-close="!submitting"
|
||||
:title="isEdit ? '编辑岗位' : '新增岗位'"
|
||||
width="540px"
|
||||
destroy-on-close
|
||||
@@ -498,11 +516,11 @@ onMounted(() => {
|
||||
</el-form-item>
|
||||
<el-form-item v-if="selectedTemplate" label-width="90px">
|
||||
<div class="tpl-info">
|
||||
<p>公式模板由系统内置,这里选择适用公式。比例、递减斜率和天数等数值在“计提参数”维护。</p>
|
||||
<router-link v-if="permissionUser.hasPermission('system:params:view')" to="/system/params" target="_blank" rel="noopener">打开计提参数</router-link>
|
||||
<p>选择岗位适用的计提公式。比例、斜率和天数等数值在“计提参数”中维护;公式结构可在“公式模板管理”中调整。</p>
|
||||
<router-link v-if="permissionUser.hasPermission('system:params:view')" to="/system/params">打开计提参数</router-link>
|
||||
<div>{{ selectedTemplate.description }}</div>
|
||||
<div v-if="selectedTemplate.params.length" class="tpl-params">
|
||||
涉及参数:{{ selectedTemplate.params.map((p) => p.name).join('、') }}(在「计提参数」中维护)
|
||||
涉及参数:{{ selectedTemplate.params.map((p) => `${p.name} = ${paramCurrentValue(p.code)}`).join(';') }}(在「计提参数」中维护)
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
@@ -514,7 +532,7 @@ onMounted(() => {
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button :disabled="submitting" @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSubmit" :disabled="!permissionUser.can('system:business_roles:update')" :title="permissionUser.permissionReason('system:business_roles:update')">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
@@ -527,9 +545,9 @@ onMounted(() => {
|
||||
</div>
|
||||
<el-table v-loading="templateLoading" :data="templates" stripe empty-text="暂无模板定义">
|
||||
<el-table-column prop="template_code" label="编码" min-width="180"><template #default="{ row }"><span class="cell-code">{{ row.template_code }}</span></template></el-table-column>
|
||||
<el-table-column prop="template_name" label="名称" min-width="160" />
|
||||
<el-table-column prop="template_name" label="名称" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column label="骨架" min-width="130"><template #default="{ row }">{{ formulaName(row.formula_type) }}</template></el-table-column>
|
||||
<el-table-column label="参数绑定" min-width="240"><template #default="{ row }">{{ (Object.entries(row.params) as [string, string][]).map(([role, code]) => `${role}:${paramName(code)}`).join(';') || '—' }}</template></el-table-column>
|
||||
<el-table-column label="参数绑定" min-width="240"><template #default="{ row }">{{ (Object.entries(row.params) as [string, string][]).map(([role, code]) => `${formulaRoleLabel(row.formula_type, role)}:${paramName(code)}`).join(';') || '—' }}</template></el-table-column>
|
||||
<el-table-column label="岗位引用" width="90" align="right"><template #default="{ row }"><span class="cell-num">{{ row.role_count }}</span></template></el-table-column>
|
||||
<el-table-column label="状态" width="90" align="center"><template #default="{ row }">{{ row.is_system === 1 ? '系统' : '自定义' }}</template></el-table-column>
|
||||
<el-table-column label="操作" width="130" fixed="right" align="center">
|
||||
@@ -564,8 +582,18 @@ onMounted(() => {
|
||||
<p v-if="selectedFormula" class="form-hint">{{ selectedFormula.description }}</p>
|
||||
</el-form-item>
|
||||
<template v-if="selectedFormula">
|
||||
<el-form-item v-for="role in selectedFormula.roles" :key="role" :label="`参数·${role}`">
|
||||
<el-select v-model="templateForm.params[role]" :disabled="editingTemplate?.is_system === 1" placeholder="选择计提参数" style="width: 100%" filterable>
|
||||
<el-form-item
|
||||
v-for="role in selectedFormula.roles"
|
||||
:key="role"
|
||||
:label="selectedFormula.role_labels[role] ?? role"
|
||||
>
|
||||
<el-select
|
||||
v-model="templateForm.params[role]"
|
||||
:disabled="editingTemplate?.is_system === 1"
|
||||
:placeholder="selectedFormula.role_hints[role] ?? '选择计提参数'"
|
||||
style="width: 100%"
|
||||
filterable
|
||||
>
|
||||
<el-option v-for="item in paramOptions" :key="item.code" :label="`${item.name}(${item.group})`" :value="item.code" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
@@ -415,6 +415,7 @@ watch(activeTab, (tab) => {
|
||||
.log-detail {
|
||||
padding: 4px 16px;
|
||||
color: var(--ys-ink-2);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.log-detail__label {
|
||||
@@ -426,6 +427,8 @@ watch(activeTab, (tab) => {
|
||||
padding: 8px 12px;
|
||||
margin: 6px 0 12px;
|
||||
overflow-x: auto;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
font-family: Consolas, 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
background-color: var(--ys-paper);
|
||||
|
||||
@@ -6,6 +6,7 @@ const permissionUser = usePermissionUser()
|
||||
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox, type InputInstance, type SelectInstance } from 'element-plus'
|
||||
import { Clock, DataAnalysis, Download, Search } from '@element-plus/icons-vue'
|
||||
|
||||
import * as businessRoleApi from '@/api/businessRoles'
|
||||
import * as paramsApi from '@/api/params'
|
||||
@@ -18,6 +19,7 @@ import type {
|
||||
ParamPreviewResult,
|
||||
} from '@/api/params'
|
||||
import AppPagination from '@/components/table/AppPagination.vue'
|
||||
import ListLoadError from '@/components/feedback/ListLoadError.vue'
|
||||
import { useConfirmAction } from '@/composables/useConfirmAction'
|
||||
import { usePagedList } from '@/composables/usePagedList'
|
||||
import { useUserStore } from '@/store/user'
|
||||
@@ -43,10 +45,13 @@ const isAdmin = computed(() => userStore.can('system:params:update'))
|
||||
|
||||
const catalog = ref<ParamCatalogItem[]>([])
|
||||
const catalogLoading = ref(false)
|
||||
const catalogFailed = ref(false)
|
||||
const catalogKeyword = ref('')
|
||||
const expandedGroups = ref(['业务员个人户'])
|
||||
|
||||
async function loadCatalog(): Promise<void> {
|
||||
catalogLoading.value = true
|
||||
catalogFailed.value = false
|
||||
try {
|
||||
catalog.value = await paramsApi.getCatalog()
|
||||
// 预览「所用参数」按编码取中文名;目录加载后填充映射
|
||||
@@ -54,6 +59,7 @@ async function loadCatalog(): Promise<void> {
|
||||
PARAM_NAME_BY_CODE[item.param_code] = item.param_name
|
||||
}
|
||||
} catch (error) {
|
||||
catalogFailed.value = true
|
||||
handledError(error)
|
||||
} finally {
|
||||
catalogLoading.value = false
|
||||
@@ -72,6 +78,24 @@ const groupedCatalog = computed(() => {
|
||||
return [...groups.entries()].map(([name, items]) => ({ name, items }))
|
||||
})
|
||||
|
||||
const ruleCount = computed(() => groupedCatalog.value.reduce((total, group) => total + group.items.length, 0))
|
||||
const visibleGroups = computed(() => {
|
||||
const keyword = catalogKeyword.value.trim().toLowerCase()
|
||||
if (!keyword) return groupedCatalog.value
|
||||
return groupedCatalog.value.map(group => ({
|
||||
...group,
|
||||
items: group.name.toLowerCase().includes(keyword) ? group.items : group.items.filter(item =>
|
||||
`${item.param_name} ${item.param_code} ${item.description ?? ''}`.toLowerCase().includes(keyword)),
|
||||
})).filter(group => group.items.length)
|
||||
})
|
||||
const allGroupsExpanded = computed(() => visibleGroups.value.length > 0 && visibleGroups.value.every(group => expandedGroups.value.includes(group.name)))
|
||||
function toggleGroups(): void {
|
||||
expandedGroups.value = allGroupsExpanded.value ? [] : visibleGroups.value.map(group => group.name)
|
||||
}
|
||||
watch(catalogKeyword, keyword => {
|
||||
if (keyword.trim()) expandedGroups.value = visibleGroups.value.map(group => group.name)
|
||||
})
|
||||
|
||||
/** 维度值 → 展示名(NONE 维度为空串;角色维度给出中文岗位名) */
|
||||
const DIMENSION_LABELS: Record<string, string> = {
|
||||
'': '默认',
|
||||
@@ -283,6 +307,7 @@ const {
|
||||
pageNum: batchesPage,
|
||||
pageSize: batchesPageSize,
|
||||
loading: batchesLoading,
|
||||
loadFailed: batchesFailed,
|
||||
load: loadBatches,
|
||||
onPageChange: onBatchesPageChange,
|
||||
onSizeChange: onBatchesSizeChange,
|
||||
@@ -386,6 +411,7 @@ const {
|
||||
pageNum: historyPage,
|
||||
pageSize: historyPageSize,
|
||||
loading: historyLoading,
|
||||
loadFailed: historyFailed,
|
||||
load: loadHistory,
|
||||
onPageChange: onHistoryPageChange,
|
||||
onSizeChange: onHistorySizeChange,
|
||||
@@ -480,34 +506,47 @@ onMounted(() => {
|
||||
|
||||
<template>
|
||||
<div class="requirements-surface">
|
||||
<CoefficientPanel />
|
||||
<div class="page">
|
||||
<!-- 页面头:这句描述原本挤在工具栏左侧当说明,现在归位成页面描述,语义更清楚 -->
|
||||
<div class="page-head">
|
||||
<h1 class="page-title">计提参数</h1>
|
||||
<p class="page-desc">
|
||||
维护绩效计算规则与产品系数,查看当前生效值和变更历史。修改先暂存,提交时可选择生效日期。
|
||||
</p>
|
||||
<div class="waterline"></div>
|
||||
</div>
|
||||
<div class="page params-page">
|
||||
<header class="page-head params-head">
|
||||
<div>
|
||||
<h1 class="page-title">计提参数</h1>
|
||||
<p class="page-desc">核对产品系数、维护计提规则,查看变更历史与计算预览。</p>
|
||||
<div class="waterline"></div>
|
||||
</div>
|
||||
<div class="params-actions">
|
||||
<el-button :icon="Clock" @click="openVersionDrawer">版本管理</el-button>
|
||||
<el-button :icon="Download" @click="doExport" :loading="exporting" :disabled="!permissionUser.can('system:params:export')" :title="permissionUser.permissionReason('system:params:export')">导出记录</el-button>
|
||||
<el-button type="primary" :icon="DataAnalysis" @click="openPreviewDrawer" :disabled="!permissionUser.can('system:params:preview')" :title="permissionUser.permissionReason('system:params:preview')">计算预览</el-button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!--
|
||||
主操作行:这页没有筛选条件(参数按分组浏览而非检索),所以只有工具按钮。
|
||||
「计算预览」是管理员最常走的一步,用主按钮把它与另两个次要动作区分开。
|
||||
-->
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" @click="openPreviewDrawer" :disabled="!permissionUser.can('system:params:preview')" :title="permissionUser.permissionReason('system:params:preview')">计算预览</el-button>
|
||||
<el-button @click="openVersionDrawer">版本管理</el-button>
|
||||
<el-button @click="doExport" :loading="exporting" :disabled="!permissionUser.can('system:params:export')" :title="permissionUser.permissionReason('system:params:export')">导出记录</el-button>
|
||||
<el-button v-if="pendingList.length" @click="showPending">待提交变更({{ pendingList.length }})</el-button>
|
||||
</div>
|
||||
<nav class="params-outline" aria-label="计提参数页内导航">
|
||||
<a href="#coefficient-section">产品系数</a>
|
||||
<a href="#rules-section">计提规则</a>
|
||||
<button v-if="pendingList.length" type="button" class="pending-anchor" @click="showPending">待提交变更 <b>{{ pendingList.length }}</b></button>
|
||||
<span>规则参数修改先暂存,提交时确认生效日期。</span>
|
||||
</nav>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<el-collapse v-model="expandedGroups" v-loading="catalogLoading">
|
||||
<el-collapse-item v-for="group in groupedCatalog" :key="group.name" :name="group.name">
|
||||
<CoefficientPanel id="coefficient-section" />
|
||||
|
||||
<section id="rules-section" class="card rules-section" aria-labelledby="rules-title">
|
||||
<div class="card-head rules-head">
|
||||
<div>
|
||||
<h2 id="rules-title">计提规则 <span v-if="!catalogLoading && !catalogFailed">{{ ruleCount }} 项 · {{ groupedCatalog.length }} 组</span></h2>
|
||||
<p>按业务分组查看当前生效值,修改记录可逐项追溯。</p>
|
||||
</div>
|
||||
<div class="rule-tools">
|
||||
<el-input v-model="catalogKeyword" :prefix-icon="Search" placeholder="搜索参数名称、说明或分组" aria-label="搜索计提参数" clearable />
|
||||
<el-button :disabled="!visibleGroups.length" @click="toggleGroups">{{ allGroupsExpanded ? '收起全部' : '展开全部' }}</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body rule-catalog" v-loading="catalogLoading">
|
||||
<el-alert v-if="catalogFailed" type="error" title="计提规则加载失败,请重试" :closable="false" show-icon><el-button :loading="catalogLoading" @click="loadCatalog">重新加载</el-button></el-alert>
|
||||
<p v-else-if="!catalogLoading && !visibleGroups.length" class="catalog-empty" role="status">{{ catalogKeyword ? '没有匹配的参数,请调整关键词。' : '暂无计提规则。' }}</p>
|
||||
<el-collapse v-else v-model="expandedGroups">
|
||||
<el-collapse-item v-for="group in visibleGroups" :key="group.name" :name="group.name">
|
||||
<template #title>
|
||||
<span class="group-title">{{ group.name }}</span>
|
||||
<span class="group-title">{{ group.name }} <span>{{ group.items.length }} 项参数</span></span>
|
||||
</template>
|
||||
<p v-if="group.name === '审批开关'" class="approval-notice">
|
||||
{{ approvalEnabled ? '系统审核已开启,以下设置决定各类单据是否进入审核流程。' : '系统审核当前关闭。以下为保留的审核设置;生成工资后直接完成,不产生审核流程记录。' }}
|
||||
@@ -523,16 +562,17 @@ onMounted(() => {
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in group.items" :key="item.param_code">
|
||||
<td>
|
||||
<td class="param-definition-cell">
|
||||
<div class="param-name">{{ item.param_name }}</div>
|
||||
<div class="param-desc">{{ item.description || item.param_code }}</div>
|
||||
</td>
|
||||
<td>
|
||||
<td class="param-value-cell">
|
||||
<span class="mobile-value-label">当前生效值</span>
|
||||
<template v-if="item.values.length">
|
||||
<div v-for="v in item.values" :key="v.dimension_value" class="value-row">
|
||||
<div class="value-info">
|
||||
<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"
|
||||
@@ -541,6 +581,9 @@ onMounted(() => {
|
||||
>
|
||||
待改为 {{ formatValue(item, pendingOf(item, v.dimension_value)!.new_value) }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="value-meta">
|
||||
<span v-if="v.effective_date" class="eff">{{ v.effective_date }} 起</span>
|
||||
<el-button
|
||||
v-if="isAdmin"
|
||||
link
|
||||
@@ -550,13 +593,14 @@ onMounted(() => {
|
||||
:disabled="!permissionUser.can('system:params:update')" :title="permissionUser.permissionReason('system:params:update')">
|
||||
修改
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<span v-else class="no-value">
|
||||
未设置(默认 {{ formatByCode(item.param_code, item.default_value) }})
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<td class="param-actions-cell">
|
||||
<el-button
|
||||
v-if="isAdmin && !item.values.length"
|
||||
link
|
||||
@@ -575,7 +619,7 @@ onMounted(() => {
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 待提交批次(仅管理员可见可操作):提交前只是暂存,故用独立的次级卡片承载 -->
|
||||
<div v-if="isAdmin && pendingList.length" ref="pendingCard" class="card pending-card">
|
||||
@@ -585,7 +629,7 @@ onMounted(() => {
|
||||
<span class="pending-hint">暂存内容尚未生效。请确认参数值与生效日期后提交。</span>
|
||||
</div>
|
||||
</div>
|
||||
<el-table :data="pendingList" size="small">
|
||||
<el-table :data="pendingList" size="small" class="pending-table">
|
||||
<el-table-column label="参数" min-width="160">
|
||||
<template #default="{ row }">{{ row.item.param_name }}</template>
|
||||
</el-table-column>
|
||||
@@ -608,26 +652,46 @@ onMounted(() => {
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="card-foot">
|
||||
<div class="submit-row">
|
||||
<ul class="pending-list" aria-label="待提交参数核对">
|
||||
<li v-for="row in pendingList" :key="row.key">
|
||||
<div class="pending-line-head">
|
||||
<div><strong>{{ row.item.param_name }}</strong><span>{{ dimensionLabel(row.dimension_value) }}</span></div>
|
||||
<el-button link type="danger" @click="removePending(row.key)">移除</el-button>
|
||||
</div>
|
||||
<dl class="pending-values">
|
||||
<div><dt>原值</dt><dd>{{ row.old_value ? formatByCode(row.item.param_code, row.old_value) : '—' }}</dd></div>
|
||||
<div><dt>调整为</dt><dd class="pending-new-value">{{ formatByCode(row.item.param_code, row.new_value) }}</dd></div>
|
||||
</dl>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="card-foot pending-footer">
|
||||
<el-form label-position="top" class="submit-options" :disabled="submitting" @submit.prevent>
|
||||
<el-form-item label="生效方式">
|
||||
<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-form-item>
|
||||
<el-form-item v-if="changeType === 2" label="生效日期">
|
||||
<el-date-picker
|
||||
v-if="changeType === 2"
|
||||
v-model="effectiveDate"
|
||||
type="date"
|
||||
value-format="YYYY-MM-DD"
|
||||
placeholder="生效日期"
|
||||
style="width: 150px"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="变更说明(选填)" class="submit-remark">
|
||||
<el-input
|
||||
v-model="remark"
|
||||
placeholder="变更说明(选填,如:调整第四季度计提比例)"
|
||||
style="width: 240px"
|
||||
placeholder="例如:调整第四季度计提比例"
|
||||
style="width: 100%"
|
||||
maxlength="255"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div class="submit-commit">
|
||||
<span>请核对以上新值与生效方式,提交后生成变更版本。</span>
|
||||
<el-button type="primary" :loading="submitting" @click="submitAll" :disabled="!permissionUser.can('system:params:update')" :title="permissionUser.permissionReason('system:params:update')">提交变更</el-button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -708,24 +772,32 @@ onMounted(() => {
|
||||
<!-- 版本管理抽屉:对比两个日期各自的生效值 + 变更记录 -->
|
||||
<el-drawer v-model="versionVisible" title="版本管理" size="760px">
|
||||
<div class="drawer-block">
|
||||
<div class="drawer-row">
|
||||
<h3 class="drawer-section-title">按日期对比</h3>
|
||||
<p class="drawer-section-note">对比两个日期各自生效的参数值,核对调整范围。</p>
|
||||
<el-form class="compare-form" label-position="top" @submit.prevent="doCompare">
|
||||
<el-form-item label="起始日期">
|
||||
<el-date-picker
|
||||
v-model="compareFrom"
|
||||
type="date"
|
||||
value-format="YYYY-MM-DD"
|
||||
placeholder="对比起始日期"
|
||||
style="width: 150px"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="截止日期">
|
||||
<el-date-picker
|
||||
v-model="compareTo"
|
||||
type="date"
|
||||
value-format="YYYY-MM-DD"
|
||||
placeholder="对比截止日期"
|
||||
style="width: 150px"
|
||||
style="width: 100%"
|
||||
/>
|
||||
<el-button :loading="comparing" @click="doCompare">对比</el-button>
|
||||
</el-form-item>
|
||||
<div class="compare-actions">
|
||||
<el-button type="primary" :loading="comparing" native-type="submit">对比</el-button>
|
||||
<el-button :loading="exporting" @click="doExport" :disabled="!permissionUser.can('system:params:export')" :title="permissionUser.permissionReason('system:params:export')">导出记录</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-form>
|
||||
<template v-if="diffSearched">
|
||||
<div class="drawer-title">两个日期的参数对比({{ compareFrom }} → {{ compareTo }})</div>
|
||||
<el-table :data="diffResult" size="small" max-height="260">
|
||||
@@ -751,8 +823,9 @@ onMounted(() => {
|
||||
</div>
|
||||
|
||||
<div class="drawer-block">
|
||||
<div class="drawer-title">变更记录</div>
|
||||
<el-table v-loading="batchesLoading" :data="batches" size="small">
|
||||
<h3 class="drawer-section-title">变更记录</h3>
|
||||
<ListLoadError v-if="batchesFailed" :loading="batchesLoading" @retry="loadBatches" />
|
||||
<el-table v-loading="batchesLoading" :data="batches" size="small" empty-text="暂无参数变更记录">
|
||||
<el-table-column type="expand">
|
||||
<template #default="{ row }">
|
||||
<div class="batch-items">
|
||||
@@ -798,11 +871,14 @@ onMounted(() => {
|
||||
@size-change="onBatchesSizeChange"
|
||||
/>
|
||||
</div>
|
||||
<template #footer><el-button @click="versionVisible=false">关闭</el-button></template>
|
||||
</el-drawer>
|
||||
|
||||
<!-- 历史抽屉 -->
|
||||
<el-drawer v-model="historyVisible" :title="`参数历史 · ${historyItem?.param_name ?? ''}`" size="560px">
|
||||
<el-table v-loading="historyLoading" :data="historyRows" size="small">
|
||||
<p class="drawer-section-note">按生效日期查看历次调整,保留修改前后的参数值。</p>
|
||||
<ListLoadError v-if="historyFailed" :loading="historyLoading" @retry="loadHistory" />
|
||||
<el-table v-loading="historyLoading" :data="historyRows" size="small" empty-text="该参数暂无变更记录">
|
||||
<el-table-column prop="effective_date" label="生效日期" width="105" />
|
||||
<el-table-column label="适用范围" width="90">
|
||||
<template #default="{ row }">{{ dimensionLabel(row.dimension_value) }}</template>
|
||||
@@ -826,12 +902,14 @@ onMounted(() => {
|
||||
@page-change="onHistoryPageChange"
|
||||
@size-change="onHistorySizeChange"
|
||||
/>
|
||||
<template #footer><el-button @click="historyVisible=false">关闭</el-button></template>
|
||||
</el-drawer>
|
||||
|
||||
<!-- 计算预览抽屉 -->
|
||||
<el-drawer v-model="previewVisible" title="计算预览" size="480px">
|
||||
<p class="preview-scope-note">按所选日期的参数试算单笔公式,不生成工资或修改业务数据。正式工资请在业务管理中按月核算。</p>
|
||||
<el-form label-width="90px" @submit.prevent>
|
||||
<el-form label-position="top" class="preview-form" @submit.prevent>
|
||||
<h3 class="form-section-title">业务条件</h3>
|
||||
<el-form-item label="客户类型">
|
||||
<el-select v-model="previewForm.customer_type" style="width: 100%">
|
||||
<el-option v-for="o in CUSTOMER_OPTIONS" :key="o.value" :label="o.label" :value="o.value" />
|
||||
@@ -868,24 +946,24 @@ onMounted(() => {
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="销售额">
|
||||
<h3 class="form-section-title">金额信息</h3>
|
||||
<el-form-item label="销售额(元)">
|
||||
<el-input v-model="previewForm.revenue" placeholder="不含运费" />
|
||||
</el-form-item>
|
||||
<el-form-item label="毛利润额">
|
||||
<el-form-item label="毛利润额(元)">
|
||||
<el-input v-model="previewForm.profit" placeholder="可为负" />
|
||||
</el-form-item>
|
||||
<el-form-item label="本笔回款">
|
||||
<el-form-item label="本笔回款(元)">
|
||||
<el-input v-model="previewForm.received_amount" />
|
||||
</el-form-item>
|
||||
<div class="preview-flags">
|
||||
<el-form-item label="以物抵顶">
|
||||
<el-switch v-model="previewForm.barter" />
|
||||
</el-form-item>
|
||||
<el-form-item label="试用期">
|
||||
<el-switch v-model="previewForm.probation" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="previewLoading" @click="runPreview" :disabled="!permissionUser.can('system:params:preview')" :title="permissionUser.permissionReason('system:params:preview')">开始试算</el-button>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
|
||||
<template v-if="previewResult">
|
||||
@@ -912,21 +990,55 @@ onMounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<el-button @click="previewVisible=false">关闭</el-button>
|
||||
<el-button type="primary" :loading="previewLoading" @click="runPreview" :disabled="!permissionUser.can('system:params:preview')" :title="permissionUser.permissionReason('system:params:preview')">开始试算</el-button>
|
||||
</template>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 页面里已经没有 .page-toolbar/.page-desc —— 描述与工具栏改用全局 .page-head/.toolbar */
|
||||
.params-page { --params-section-gap: 24px; }
|
||||
.params-head { display: flex; flex-wrap: wrap; align-items: flex-start; justify-content: space-between; gap: 16px 24px; }
|
||||
.params-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
|
||||
.params-outline { display: flex; flex-wrap: wrap; align-items: center; gap: 12px 24px; padding: 0 0 16px; margin-bottom: var(--params-section-gap); border-bottom: 1px solid var(--ys-line); font-size: 13px; line-height: 1.7; }
|
||||
.params-outline a, .params-outline button { color: var(--ys-ink-2); text-decoration: none; }
|
||||
.params-outline a:hover, .params-outline button:hover { color: var(--ys-indigo); }
|
||||
.params-outline button { border: 0; padding: 0; background: none; font: inherit; cursor: pointer; }
|
||||
.params-outline > span { margin-left: auto; color: var(--ys-ink-3); font-size: 12px; }
|
||||
.params-outline .pending-anchor { color: #895d12; }
|
||||
.pending-anchor b { margin-left: 4px; font-variant-numeric: tabular-nums; }
|
||||
#coefficient-section, #rules-section, .pending-card { scroll-margin-top: 20px; }
|
||||
.rules-section { container-type: inline-size; margin-top: var(--params-section-gap); }
|
||||
.rules-head { align-items: center; padding: 20px; }
|
||||
.rules-head h2 { margin: 0; color: var(--ys-ink); font-size: 16px; font-weight: 600; line-height: 1.6; }
|
||||
.rules-head h2 > span { margin-left: 10px; color: var(--ys-ink-3); font-size: 12px; font-weight: 400; }
|
||||
.rules-head p { margin: 6px 0 0; color: var(--ys-ink-2); font-size: 13px; line-height: 1.7; }
|
||||
.rule-tools { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
|
||||
.rule-tools .el-input { width: 260px; }
|
||||
.rule-catalog { padding: 0 20px; min-height: 120px; }
|
||||
.catalog-empty { margin: 0; padding: 40px 16px; color: var(--ys-ink-3); text-align: center; line-height: 1.8; }
|
||||
.rule-catalog :deep(.el-collapse) { border-top: 0; border-bottom: 0; }
|
||||
.rule-catalog :deep(.el-collapse-item:last-child .el-collapse-item__header) { border-bottom: 0; }
|
||||
.rule-catalog :deep(.el-collapse-item__header) { min-height: 56px; height: auto; padding: 12px 0; line-height: 1.6; }
|
||||
.rule-catalog :deep(.el-collapse-item__content) { padding-bottom: 16px; }
|
||||
.rule-catalog :deep(.el-collapse-item__wrap) { border-bottom: 0; }
|
||||
|
||||
.group-title {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--ys-ink);
|
||||
}
|
||||
.group-title > span { color: var(--ys-ink-3); font-size: 12px; font-weight: 400; }
|
||||
|
||||
.param-table {
|
||||
width: 100%;
|
||||
min-width: 640px;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
@@ -950,22 +1062,27 @@ onMounted(() => {
|
||||
text-align: left;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.04em;
|
||||
letter-spacing: 0.01em;
|
||||
color: var(--ys-ink-2);
|
||||
padding: 6px 8px;
|
||||
padding: 10px 12px;
|
||||
background: var(--ys-mist);
|
||||
border-bottom: 1px solid var(--ys-line);
|
||||
}
|
||||
|
||||
.param-table td {
|
||||
padding: 10px 8px;
|
||||
padding: 16px 12px;
|
||||
border-bottom: 1px solid var(--ys-line);
|
||||
font-size: 13px;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.param-name {
|
||||
font-weight: 500;
|
||||
font-weight: 600;
|
||||
}
|
||||
.param-actions-cell { white-space: nowrap; }
|
||||
.param-actions-cell .el-button { min-height: 32px; }
|
||||
.param-actions-cell .el-button + .el-button { margin-left: 8px; }
|
||||
.mobile-value-label { display: none; }
|
||||
|
||||
.param-desc {
|
||||
font-size: 12px;
|
||||
@@ -976,12 +1093,17 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.value-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 8px 16px;
|
||||
line-height: 1.9;
|
||||
}
|
||||
.value-row + .value-row { margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--ys-line-soft); }
|
||||
.value-info, .value-meta { display: flex; flex-wrap: wrap; align-items: baseline; gap: 8px 12px; min-width: 0; }
|
||||
.value-info .num { color: var(--ys-ink); font-size: 14px; font-weight: 600; }
|
||||
.value-meta { justify-content: flex-end; }
|
||||
.value-meta .el-button { min-height: 32px; }
|
||||
|
||||
.dim-label {
|
||||
font-size: 12px;
|
||||
@@ -1005,11 +1127,22 @@ onMounted(() => {
|
||||
|
||||
/* 待提交卡片紧跟目录卡,需要与上方卡片拉开距离(.card 自身不带外边距) */
|
||||
.pending-card {
|
||||
margin-top: 14px;
|
||||
container-type: inline-size;
|
||||
margin-top: var(--params-section-gap);
|
||||
}
|
||||
.pending-list { display: none; margin: 0; padding: 0 16px; list-style: none; }
|
||||
.pending-list li { padding: 16px 0; border-bottom: 1px solid var(--ys-line-soft); }
|
||||
.pending-line-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
|
||||
.pending-line-head strong { color: var(--ys-ink); font-size: 13px; font-weight: 600; }
|
||||
.pending-line-head span { display: block; margin-top: 6px; color: var(--ys-ink-3); font-size: 12px; }
|
||||
.pending-values { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; margin: 16px 0 0; }
|
||||
.pending-values dt { color: var(--ys-ink-3); font-size: 12px; }
|
||||
.pending-values dd { margin: 6px 0 0; color: var(--ys-ink-2); font-size: 14px; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
|
||||
.pending-values .pending-new-value { color: var(--ys-indigo); font-weight: 600; }
|
||||
|
||||
.pending-head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
}
|
||||
@@ -1019,16 +1152,12 @@ onMounted(() => {
|
||||
color: var(--ys-ink-3);
|
||||
}
|
||||
|
||||
/*
|
||||
* 提交区已放进 .card-foot:底栏自带 10px 16px 内边距与右对齐,
|
||||
* 所以这里去掉原先的 margin-top,否则会与底栏留白叠成双份。
|
||||
*/
|
||||
.submit-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
.pending-footer { display: block; padding: 20px; }
|
||||
.submit-options { display: flex; flex-wrap: wrap; gap: 0 20px; }
|
||||
.submit-options .el-form-item { flex: 0 1 auto; min-width: 0; margin-bottom: 16px; }
|
||||
.submit-options .submit-remark { flex: 1 1 280px; }
|
||||
.submit-commit { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px; padding-top: 16px; border-top: 1px solid var(--ys-line); }
|
||||
.submit-commit > span { color: var(--ys-ink-2); font-size: 12px; line-height: 1.7; }
|
||||
|
||||
.edit-name {
|
||||
font-size: 15px;
|
||||
@@ -1073,14 +1202,11 @@ onMounted(() => {
|
||||
.drawer-block {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.drawer-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.drawer-section-title { margin: 0 0 12px; color: var(--ys-ink); font-size: 15px; font-weight: 600; }
|
||||
.drawer-section-note { margin: 0 0 20px; color: var(--ys-ink-2); font-size: 13px; line-height: 1.7; }
|
||||
.compare-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 16px; }
|
||||
.compare-actions { display: flex; flex-wrap: wrap; gap: 8px; grid-column: 1 / -1; margin-bottom: 8px; }
|
||||
.preview-flags { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
|
||||
|
||||
.drawer-title {
|
||||
font-size: 13px;
|
||||
@@ -1097,6 +1223,7 @@ onMounted(() => {
|
||||
|
||||
.batch-item {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
align-items: baseline;
|
||||
font-size: 12.5px;
|
||||
@@ -1125,6 +1252,7 @@ onMounted(() => {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
padding: 6px 0;
|
||||
font-size: 13px;
|
||||
color: var(--ys-ink-2);
|
||||
@@ -1144,12 +1272,14 @@ onMounted(() => {
|
||||
.pr-row b {
|
||||
color: var(--ys-ink);
|
||||
font-weight: 600;
|
||||
text-align: right;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.pr-note {
|
||||
margin-top: 10px;
|
||||
font-size: 12px;
|
||||
color: var(--ys-warn);
|
||||
color: #895d12;
|
||||
background: #fbf4e4;
|
||||
border-radius: 6px;
|
||||
padding: 8px 12px;
|
||||
@@ -1165,4 +1295,36 @@ onMounted(() => {
|
||||
line-height: 2;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
@container (max-width: 650px) {
|
||||
.pending-table { display: none; }
|
||||
.pending-list { display: block; }
|
||||
.rules-head { gap: 16px; }
|
||||
.rule-tools { width: 100%; }
|
||||
.rule-tools .el-input { flex: 1; width: auto; min-width: 140px; }
|
||||
.param-table { min-width: 0; }
|
||||
.param-table thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
|
||||
.param-table, .param-table tbody, .param-table tr, .param-table td { display: block; width: 100%; }
|
||||
.param-table tr { padding: 16px 0; border-bottom: 1px solid var(--ys-line); }
|
||||
.param-table tr:last-child { border-bottom: 0; }
|
||||
.param-table td { padding: 0; border-bottom: 0; }
|
||||
.param-value-cell { margin-top: 14px; }
|
||||
.mobile-value-label { display: block; margin-bottom: 8px; color: var(--ys-ink-3); font-size: 12px; }
|
||||
.param-actions-cell { margin-top: 8px; text-align: right; }
|
||||
.value-row { grid-template-columns: minmax(0, 1fr); gap: 2px; }
|
||||
.value-meta { justify-content: space-between; }
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.params-page { --params-section-gap: 20px; }
|
||||
.params-actions { width: 100%; }
|
||||
.params-outline { gap: 10px 20px; }
|
||||
.params-outline > span { width: 100%; margin-left: 0; }
|
||||
.rules-head { padding: 16px; }
|
||||
.rule-catalog { padding: 0 16px; }
|
||||
.pending-head { gap: 8px; }
|
||||
.pending-footer { padding: 16px; }
|
||||
.submit-options .el-form-item { flex-basis: 100%; }
|
||||
.compare-form { grid-template-columns: minmax(0, 1fr); }
|
||||
}
|
||||
</style>
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import ListLoadError from '@/components/feedback/ListLoadError.vue'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
|
||||
@@ -35,7 +36,7 @@ const filters = reactive({
|
||||
status: undefined as number | undefined,
|
||||
})
|
||||
|
||||
const { records, total, pageNum, pageSize, loading, load, search, onPageChange, onSizeChange, reloadAfterDelete } =
|
||||
const { records, total, pageNum, pageSize, loadFailed, loading, load, search, onPageChange, onSizeChange, reloadAfterDelete } =
|
||||
usePagedList<RoleItem, RoleQueryParams>(roleApi.listRoles, () => ({
|
||||
keyword: filters.keyword,
|
||||
status: filters.status,
|
||||
@@ -177,6 +178,7 @@ onMounted(load)
|
||||
筛选条:.filter-bar 自带 flex + gap + 内边距,故 el-form 不再写 inline;
|
||||
查询/重置收进 .filter-bar__actions,靠 margin-left:auto 贴到行尾。
|
||||
-->
|
||||
<ListLoadError v-if="loadFailed" :loading="loading" @retry="load" />
|
||||
<el-form class="filter-bar" @submit.prevent>
|
||||
<el-form-item label="关键词">
|
||||
<el-input
|
||||
@@ -230,8 +232,8 @@ onMounted(load)
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="role_name" label="名称" min-width="120" />
|
||||
<el-table-column prop="description" label="说明" min-width="180">
|
||||
<el-table-column prop="role_name" label="名称" min-width="120" show-overflow-tooltip />
|
||||
<el-table-column prop="description" label="说明" min-width="180" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.description || '—' }}</template>
|
||||
</el-table-column>
|
||||
<!-- 排序值与用户数都是用于比较的数字:右对齐 + tabular-nums,纵向扫读时数位对齐 -->
|
||||
@@ -295,6 +297,9 @@ onMounted(load)
|
||||
<CreateRoleDrawer v-model="createOpen" @saved="load" />
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:close-on-click-modal="!submitting"
|
||||
:close-on-press-escape="!submitting"
|
||||
:show-close="!submitting"
|
||||
:title="isEdit ? '编辑角色' : '新增角色'"
|
||||
width="480px"
|
||||
destroy-on-close
|
||||
@@ -318,7 +323,7 @@ onMounted(load)
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button :disabled="submitting" @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSubmit">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
@@ -540,4 +540,12 @@ const previewName = computed(() => form.company_name.trim() || '衍水集团')
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.field-grid { grid-template-columns: minmax(0, 1fr); gap: 8px; }
|
||||
.field-control__main, .field-control__note { min-width: 0; flex-basis: 100%; }
|
||||
.mock-card { padding: 24px 18px; }
|
||||
.mock-captcha { flex-basis: 80px; }
|
||||
.preview-note { line-height: 1.6; }
|
||||
}
|
||||
</style>
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import ListLoadError from '@/components/feedback/ListLoadError.vue'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
|
||||
@@ -42,7 +43,7 @@ const filters = reactive({
|
||||
role_code: undefined as string | undefined,
|
||||
})
|
||||
|
||||
const { records, total, pageNum, pageSize, loading, load, search, onPageChange, onSizeChange, reloadAfterDelete } =
|
||||
const { records, total, pageNum, pageSize, loadFailed, loading, load, search, onPageChange, onSizeChange, reloadAfterDelete } =
|
||||
usePagedList<UserItem, UserQueryParams>(userApi.listUsers, () => ({
|
||||
username: filters.username,
|
||||
real_name: filters.real_name,
|
||||
@@ -266,6 +267,7 @@ onMounted(() => {
|
||||
inline 模式靠行内块排布,会带基线下沉与固定 right-margin,与 gap 叠成不等距;
|
||||
查询/重置收进 .filter-bar__actions,由 margin-left:auto 推到行尾。
|
||||
-->
|
||||
<ListLoadError v-if="loadFailed" :loading="loading" @retry="load" />
|
||||
<el-form class="filter-bar" @submit.prevent>
|
||||
<el-form-item label="用户名">
|
||||
<el-input
|
||||
@@ -322,7 +324,7 @@ onMounted(() => {
|
||||
v-loading="loading"
|
||||
:data="records"
|
||||
stripe
|
||||
empty-text="还没有用户,点「新增用户」创建第一个账号"
|
||||
empty-text="暂无符合条件的用户,可调整筛选条件。"
|
||||
>
|
||||
<el-table-column prop="username" label="用户名" min-width="120" />
|
||||
<el-table-column prop="real_name" label="姓名" min-width="100">
|
||||
@@ -405,11 +407,15 @@ onMounted(() => {
|
||||
<!-- 新增 / 编辑 -->
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:close-on-click-modal="!submitting"
|
||||
:close-on-press-escape="!submitting"
|
||||
:show-close="!submitting"
|
||||
:title="isEdit ? '编辑用户' : '新增用户'"
|
||||
width="520px"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px">
|
||||
<h2 class="form-section-title">登录账号</h2>
|
||||
<el-form-item label="用户名" prop="username">
|
||||
<el-input
|
||||
v-model="form.username"
|
||||
@@ -425,6 +431,7 @@ onMounted(() => {
|
||||
placeholder="8–72 字节(中文一个字符约 3 字节)"
|
||||
/>
|
||||
</el-form-item>
|
||||
<h2 class="form-section-title">人员信息</h2>
|
||||
<el-form-item label="姓名" prop="real_name">
|
||||
<el-input v-model="form.real_name" placeholder="真实姓名" />
|
||||
</el-form-item>
|
||||
@@ -449,7 +456,7 @@ onMounted(() => {
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button :disabled="submitting" @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSubmit">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
Reference in new issue
Block a user