Author SHA1 Message Date
liuwenxin cbfd7659fe merge: 收口工作台分页、客户等级徽标与文案中文化至 main 2026-10-23 16:39:06 +08:00
liuwenxinandClaude Code 823d9f02ec docs: 补充文案约定与侧栏层级说明
- backend/README 新增「面向业务人员的文案」约定:param_registry 的
  description 是唯一显示源、枚举值在页面上必须翻译、报错要给下一步、
  init-dev.sql 种子必须与 registry 同源、模板层禁止出现开发者语汇
- frontend/README 增补侧栏层级口径、最近动态独立分页端点,以及分页的
  两个坑(v-loading 不要罩到分页条上、翻页失败必须退回实际显示页)
- backend/tests/README 记录本轮运行结果

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-23 16:23:14 +08:00
liuwenxinandClaude Code 678db0499d fix(frontend): 分页失败回滚到实际显示页
records 只在请求成功时更新,而分页器高亮绑的是 pageNum,失败不回滚就会
出现「高亮第 1 页、列表是第 2 页的数据」的永久错位,用户来回点才恢复。
- 用 shownPageNum / shownPageSize 记录实际显示的页,失败时 restoreShownPage
  退回该页
- 回滚本身也要防竞态:只有本次请求仍是最新请求(seq === requestSeq)才回滚,
  否则连点时旧请求失败会把用户刚点出的新页码覆盖掉,表现为页码自己跳回去
- 页码与条数在 await 之前取快照,避免等待期间被别的点击改动后记错「显示的页」

对外接口不变(load / search / onPageChange / onSizeChange / reloadAfterDelete),
现有调用方无需改动。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-23 16:22:19 +08:00
liuwenxinandClaude Code a00fca942d refactor(frontend): 侧栏层级与表单页版式调整
侧栏层级口径改版(视觉稿 v4.2):组标题与菜单项同字号 13px,层级改由
「折叠箭头 + 组间留白 + 子项缩进」表达,不靠字号/字重压过父级。
- 去掉组标题的靛蓝竖标与激活项左侧 2px 竖条(竖条只能落在侧栏内边缘、
  与白底悬空,是重复的第二个信号),激活只留白底浮起 + 靛蓝字
- 子项缩进 30px 收到 22px,图标父子统一 16px
- AppMenu 的 .app-menu 提供横向 10px 留白,BasicLayout 的 .app-aside__menu
  只写上下内距——两处都用 padding 简写会同权重靠注入顺序决胜,横向留白
  会被整条抹掉、条目贴到侧栏边缘

公司信息页字段自适应分列:卡片够宽时两字段并排,窄时折回单列;说明文字
紧贴控件下方,不再顶到卡片最右缘(宽屏下与输入框隔上千像素,视线关联断掉)。

设计稿同步升 v4.2。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-23 16:21:19 +08:00
liuwenxinandClaude Code 61965e0b60 feat(ui): 面向客户的文案中文化
系统由财务与营销人员日常使用,不是给开发者看的。本批把会渲染到页面上的
文本统一改成业务语言:
- param_registry 的 22 条参数说明重写为「管什么、钱怎么变、何时该调它」,
  去掉英文枚举名、需求编号、内部阶段号与精度约束
- 前端补 formatByCode:变更记录、版本历史、快照对比只给裸 value_text,
  需回目录按编码补 value_type 与 unit 才能翻译枚举,否则这些位置仍显示
  INCLUDE/FULL/1;编辑弹窗 BOOL 改为开启/关闭下拉
- 后端错误文案:模板报错用中文模板名并给出下一步,产品类别不再甩
  municipal_water / rail_transit
- 台账与预览落库的计提说明口语化(rate_note / strategy_note / 聚合行 note),
  去掉「基数开关=1」「#12 参数化」「9.8 方案公式」

分数原文 11/1125 与「聚合:20%+5%」不动——那是金额口径的可复核原文,
不是说明文案。init-dev.sql 种子 description 与 registry 同源。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-23 16:20:05 +08:00
liuwenxinandClaude Code 111ac5425a feat(customer): 客户列表展示等级徽标并统一等级不落库口径
评级计算器并入客户管理后,列表只有分数看不出档位,本批补上等级徽标:
- 抽出 utils/rating.ts 的 resolveGrade(按 sort_order 升序取首个
  min_score ≤ 总分的等级,单边判定避免区间边界歧义),列表页与弹窗共用
- 等级映射按需拉取并缓存在内存,等级配置改动后回调父页重拉,避免
  「同一分数在弹窗是 B、在列表是 C」的两处口径分歧
- 徽标色调按等级名首字母映射,自定义等级名落回中性灰(等级名可配置,
  不能靠写死枚举判语义色)
- 悬浮提示给出等级区间与评级时间

同时订正三处与实现不符的注释:rate_customer 实际只写 rating_score 与
rating_time,等级由分数按当前映射实时推导,三处却写成「后端判定并固化等级
名快照」,v13 迁移 docstring 还引用了并不存在的 v14(d5b8f2a46c71)。
纯注释改动,无逻辑变更。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-23 16:18:47 +08:00
liuwenxinandClaude Code 61fe5cca98 feat(dashboard): 最近动态改服务端分页
工作台「最近动态」原为聚合端点内嵌、固定截断前 20 条,操作日志变长后
既撑大响应又只能看到头部。改为独立分页端点:
- GET /api/v1/dashboard/activities,支持 pageNum/pageSize/module 筛选
- 模块筛选项与列表同源返回(全量模块名,不随翻页变化),前端不必再取一次全量
- 聚合端点去掉内嵌 activities,避免同一份数据两处查询

前端改用 usePagedList + 统一 AppPagination(每页 10/20/50),模块筛选由
本地过滤改为服务端筛选。集成测试 13 例(含翻页不重不漏 / 模块筛选 / 越权 403)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-23 16:15:09 +08:00
liuwenxin 32b7940067 feat(customer): 客户评级并入客户管理并落库
- customers 表新增 rating_score/rating_time(V13 迁移 + V10 SQL + init-dev 同步)
- 新端点 PUT /customers/{id}/rating:评分是档案属性,只存总分与评级时间(五项明细不落库)
- 列表排序 order_by=rating_score,未评分客户升序/降序都垫底,id desc 兜底稳定分页
- 前端 RatingCalculatorDialog 并入客户管理(评分列 + 评级计算按钮),删除独立页与路由
- 测试:评级保存/越界/排序垫底 3 例,test_customers.py 18 passed;schema 同步 216 passed
2026-10-23 10:59:33 +08:00
liuwenxinandClaude Code b9781f7d3f docs(design): 视觉稿升 v4.1 并新增表单页章节
承接 10-23 浏览器实测验收,把三处实测偏差与新增的表单页口径写回设计稿(代码为视觉权威,稿为描述):

- §03 新增「表格 · 行高」卡:单元格行高定 1.5(组件库默认 23px 会把单行数据行撑到 51px)
- §05 工作台:卡片内空态改为在剩余高度内垂直居中
- §09 月结:生命周期条四段圆点改为按阶段着色(草稿中性 / 待审批琥珀 / 已审定靛蓝 / 已驳回砖红,与选中态解耦),并补条右侧操作提示
- §10 新增「表单页(公司信息)」:两列版式(左 1fr + 右 480px 实景预览,≤1200px 折单列)、控件与说明同行(≥1600px 说明靠卡片右缘右对齐)、实景预览按真实登录页 1:1 复刻、独立的「生效范围」卡
- 封面与文件头注释同步 v4.1 变更说明

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-23 10:24:41 +08:00
liuwenxin 8e8bcaa5fb feat(frontend): 视觉规范 v4 全站落地与实测修复
token 补齐与 EP 形体基准、全站版式工具类(.page-head/.card 系/.filter-bar/.ys-pill 等)、布局与登录页、工作台 Bento 与图表主题、打印稿纸、全部业务页统一到全局类;StatusTag 改 pill、AppPagination 加 background。

浏览器实测修复:表格行高收到 1.5;月结生命周期条圆点按阶段着色并加操作提示;工作台空态垂直居中;状态/结果列宽按胶囊内容给足(96/108/120),消除 Element Plus 省略号伪『小点』。设计稿反向同步圆点着色与流程条提示。
2026-10-23 09:58:48 +08:00
liuwenxinandClaude Code 1f65913002 docs: 更新视觉方向预览页与部署组件需求说明
- 设计/视觉方向预览.html 同步当前视觉体系(品牌色、语义三色、菜单
  图标),作为前端视觉落地的对照稿
- 部署组件需求说明.docx 精简冗余章节

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

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

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-22 19:45:54 +08:00
liuwenxinandClaude Code f88857e026 style(backend): ruff format 统一迁移脚本格式
存量迁移脚本未纳入格式化,与项目代码风格不一致(引号不统一、行宽未
收敛)。仅格式调整,无逻辑变更,不影响任何库的升级路径。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-22 19:44:29 +08:00
liuwenxinandClaude Code 5ece9f6dab refactor(backend): 仓储层散参数收敛为查询 DTO
仓储层的 list_*/count_* 此前以 keyword/status/offset/limit 四五个散参数
传参,调用点重复铺开、易漏传且不可扩展;按项目「参数 >4 封装 DTO」的
约定统一收敛为各模块的 Query 对象(如 RoleQuery、BusinessQuery),
服务层与测试调用同步调整,行为不变。

- repositories 层查询方法签名改为 (session, query),分页与筛选条件
  由 Query 承载,_apply_filters 同步收口
- schemas 层补齐各模块 Query 模型(approval/business_role/report 等)
- services 层调用点同步改为传 Query
- tests 随之调整;新增用例:待办角色流转(财务通过后领导出现待办)、
  参数预览顺序稳定

验证:全量 pytest 900 passed(11:08);单元 526 passed;ruff check 绿。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-22 19:43:20 +08:00
liuwenxin c48d24ab6d merge: 合并 V12 审批版本号唯一键与 T+ 清单 v1.7 至 main
- V12:审批单 (order_type, ref_id, version_no) 唯一键 + 取号改锁读
- 审批详情展示快照采集时间
- T+ 对接清单升至 v1.7,清理过期版本文件;补充部署硬件需求
- 全量回归 897 passed
2026-10-22 13:42:25 +08:00
101 changed files with 8396 additions and 4092 deletions

No files matched your search

+13 -1
View File
@@ -83,8 +83,9 @@ app/
| GET / POST | `/api/v1/base/products` | 产品列表(名称/规格模糊、类别/状态筛选)/ 新增(类别值域校验;名称+规格组合唯一) |
| GET / PUT / DELETE | `/api/v1/base/products/{id}` | 详情 / 编辑 / 删除(被业务明细引用保护在业务表就位后生效) |
| PUT | `/api/v1/base/products/{id}/status` | 启用 / 停用 |
| GET / POST | `/api/v1/base/customers` | 客户列表(公司/负责人模糊、分类/归属筛选)/ 新增(分类值域;公司名+负责人唯一) |
| GET / POST | `/api/v1/base/customers` | 客户列表(公司/负责人模糊、分类/归属筛选;`order_by=rating_score` 按评分排序,未评分垫底)/ 新增(分类值域;公司名+负责人唯一) |
| GET / PUT / DELETE | `/api/v1/base/customers/{id}` | 详情 / 编辑(**不触归属**)/ 删除 |
| PUT | `/api/v1/base/customers/{id}/rating` | 保存评级(评级计算器「保存评分」;只写 `rating_score`/`rating_time`,五项明细不落库 #25) |
| PUT | `/api/v1/base/customers/{id}/ownership` | 归属变更(同事务写留痕;只影响变更后核算,不回溯历史 #19) |
| GET | `/api/v1/base/customers/{id}/ownership-logs` | 归属变更留痕分页(join 人员出姓名) |
@@ -263,6 +264,17 @@ bcrypt 5.0 起对超过 72 字节的密码**直接抛 ValueError**(此前是
- **汇总 = 明细合计**:团队/公司基数由台账分桶结果直接得出,报表侧不另算一遍;未筛选时 `团队基数 + 未分配绩效 = 全公司基数`;集成测试逐项断言。
- **岗位筛选选项**:`GET /api/v1/base/business-roles/options` 仅要求登录(含停用岗位),供财务/领导在报表中按岗位筛选;岗位管理接口仍仅管理员。
### 面向业务人员的文案(2026-10-23 定稿,改文案前先读)
系统由财务与营销人员日常使用,**不是给开发者看的**。凡是会渲染到页面的文本(参数说明、错误提示、口径注释)一律按业务语言写:
- **`param_registry.py` 的 `description` 是唯一显示源**,会直出到「计提参数」页参数名下方。写清「这个参数管什么、钱怎么变、什么时候该调它」,不写英文枚举名(`FULL`/`BALANCE`)、需求编号(`澄清 #10`)、内部阶段号(`M3`/`M4 ⑧`)、实现层精度约束(「禁止按 0.011111 截断存储」留在代码注释与测试里)。
- **枚举值在页面上必须翻译**:前端 `params/index.vue` 的 `ENUM_VALUE_LABELS`(`FULL` 当月全额 / `BALANCE` 余额 / `INCLUDE` 含基本工资 / `DEDUCT` 扣除基本工资 / `EXCLUDE` 不计基本工资 / `1` 开 / `0` 关)与 `DIMENSION_LABELS`(`municipal_water` 市政水利 / `rail_transit` 轨道交通)。后端报错也不许把原始枚举值甩给用户 —— `param_service._preview_rate` 的产品类别校验用 `「市政水利、轨道交通」` 而不是 `municipal_water / rail_transit`。
- **公式模板报错用中文模板名**:`calc_templates.TEMPLATES[code].name`,取不到才回退 code;文案要给出下一步(「请在岗位角色里改用业务员递减或信息员固定计提」),而不是只说「不支持」。
- **`db/init/init-dev.sql` 的种子 description 必须与 registry 同源**:它只在数据卷为空时首次启动执行,已有库要跑 `ai-aux/preview/sync_param_desc.py`(读 `PARAM_REGISTRY` 逐条 UPDATE)才看得到新文案。`db/migration/V*.sql` 里的旧描述是历史记录,**不要回头改** —— 改了会让已部署库与脚本不一致。
- **前端模板层禁止出现开发者语汇**:`澄清 / 待业务 / M3 / M4 / 一期 / 二阶段 / 暂口径 / 待确认 / 待接 / 暂不 / 待补` 只允许出现在代码注释里。改完用 grep 扫一遍 `frontend/src/views`。
- **台账与预览里落库的说明文字(`rate_text` / `rate_note` / 聚合行 `snapshot_json.note`)同样受管**:这些会出现在绩效台账、个人明细、打印稿与计算预览上。写成「这笔为什么是这个比例、什么时候不再降」(如「回款 20 天,从起始 10‰ 按日递减(满 360 天后固定按 4‰ 计算)」),不写 `基数开关=1`、`#12 参数化`、`9.8 方案公式` 这类需求编号。**分数原文(`11/1125`)与百分比拼装(`聚合:20%+5%`)不算说明文字**,那是金额口径的可复核原文,不要为了「口语化」把它改成四舍五入的小数。
## 常见问题
**起服务报 `APP_SECRET_KEY` 相关错误** —— `backend/.env` 未填或不足 32 字符。这是刻意的 fail-fast:空密钥签发 JWT 是安全问题,必须启动即失败。
@@ -11,6 +11,7 @@ Create Date: 2026-10-19 17:55:48.892998
「同步连续失败」规则依赖 ⑥ T+ 同步,模块未建前不设参数、不产生条目。
"""
from collections.abc import Sequence
from datetime import date
@@ -19,8 +20,8 @@ import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '90653814a421'
down_revision: str | Sequence[str] | None = 'ba0217f5091b'
revision: str = "90653814a421"
down_revision: str | Sequence[str] | None = "ba0217f5091b"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
@@ -0,0 +1,51 @@
"""v13 客户评级落库
Revision ID: c3a9e1d7b2f4
Revises: 7f4f7b918bc4
Create Date: 2026-10-23 10:30:00.000000
评级计算器从独立工具页并入客户管理(行内弹窗),评分由「不落库」改为
保存为客户档案属性:``rating_score`` 总分 + ``rating_time`` 评级时间。
两列均可空(null = 未评分,历史客户与新客户无需回填),人工维护字段,
T+ 同步守则不覆盖(见 TPlusSyncMixin 的同步守则说明)。
五项评分明细与命中等级都不落库:前者是打分过程的中间量,没有下游消费方;
后者由分数按当前等级映射实时推导(等级映射是可变判定表,改了配置全部客户
的等级随之更新),因此本迁移只落总分与评级时间两列。
"""
from collections.abc import Sequence
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "c3a9e1d7b2f4"
down_revision: str | Sequence[str] | None = "7f4f7b918bc4"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""customers 加评级两列,紧跟 remark 之后保持与 ORM 声明同序。
用 execute 手写而非 op.add_column:后者不带 AFTER 子句,两列会落到
表尾(T+ 预留列之后),与 ORM/init-dev 的声明顺序不一致。
"""
op.execute(
"ALTER TABLE customers "
"ADD COLUMN rating_score TINYINT COMMENT "
"'评级总分(0-100,评级计算器保存;null=未评分;人工维护字段,T+ 同步不覆盖)' "
"AFTER remark"
)
op.execute(
"ALTER TABLE customers "
"ADD COLUMN rating_time DATETIME COMMENT "
"'最近评级时间(评级计算器保存评分时刷新;人工维护字段)' "
"AFTER rating_score"
)
def downgrade() -> None:
"""删除评级两列。评分数据随列删除,评级无下游引用、无悬空风险。"""
op.drop_column("customers", "rating_time")
op.drop_column("customers", "rating_score")
@@ -34,10 +34,7 @@ depends_on = None
def upgrade() -> None:
"""种子行级变更 + 台账唯一键扩展(无表结构新增/删除)。"""
# 1. 审批开关默认值翻转(#5/#7:默认开启,业务可关闭)
op.execute(
"UPDATE param_definitions SET default_value = '1' "
"WHERE id IN (17, 18)"
)
op.execute("UPDATE param_definitions SET default_value = '1' WHERE id IN (17, 18)")
# 时点化生效行:插新行不改历史(10-19 前仍按 0 解析)
op.bulk_insert(
sa.table(
@@ -188,6 +185,4 @@ def downgrade() -> None:
)
op.execute("DELETE FROM param_values WHERE id IN (23, 24)")
op.execute(
"UPDATE param_definitions SET default_value = '0' WHERE id IN (17, 18)"
)
op.execute("UPDATE param_definitions SET default_value = '0' WHERE id IN (17, 18)")
@@ -43,9 +43,7 @@ def upgrade() -> None:
"COMMENT '提交时内容快照(信封:schema_version/order_type/captured_at/data;历史单可空)' "
"AFTER version_no"
)
op.execute(
"CREATE INDEX idx_approval_orders_type_ref ON approval_orders (order_type, ref_id)"
)
op.execute("CREATE INDEX idx_approval_orders_type_ref ON approval_orders (order_type, ref_id)")
op.execute(
"ALTER TABLE business_secondary_allocations "
+10 -16
View File
@@ -12,7 +12,8 @@ from fastapi import APIRouter, Depends
from app.api.deps import DbSession, get_current_user
from app.core.response import ApiResponse, ok
from app.models import User
from app.repositories import business_role_repository
from app.schemas.business_role import BusinessRoleOptionOut
from app.services import business_role_service
router = APIRouter(prefix="/business-roles", tags=["人员管理"])
@@ -20,21 +21,14 @@ router = APIRouter(prefix="/business-roles", tags=["人员管理"])
CurrentUser = Annotated[User, Depends(get_current_user)]
@router.get("/options", response_model=ApiResponse[list[dict]], summary="岗位下拉选项")
def list_options(session: DbSession, _: CurrentUser) -> ApiResponse[list[dict]]:
@router.get(
"/options",
response_model=ApiResponse[list[BusinessRoleOptionOut]],
summary="岗位下拉选项",
)
def list_options(session: DbSession, _: CurrentUser) -> ApiResponse[list[BusinessRoleOptionOut]]:
"""岗位选项(登录即可;含停用——人员档案可能仍引用停用岗位)。
岗位规模有限(内置 + 自定义),一次取足够上限,不做分页。
岗位规模有限(内置 + 自定义),一次取全量,不做分页。
"""
roles = business_role_repository.list_roles(session, offset=0, limit=1000)
return ok(
[
{
"id": role.id,
"role_code": role.role_code,
"role_name": role.role_name,
"status": role.status,
}
for role in roles
]
)
return ok(business_role_service.list_options(session))
+26
View File
@@ -21,6 +21,7 @@ from app.schemas.customer import (
CustomerOut,
CustomerOwnershipIn,
CustomerQuery,
CustomerRatingIn,
CustomerUpdateIn,
OwnershipChangeLogOut,
)
@@ -102,6 +103,31 @@ def delete_customer(
return ok(message="客户已删除")
@router.put(
"/{customer_id}/rating",
response_model=ApiResponse[CustomerOut],
summary="保存客户评级",
)
def rate_customer(
session: DbSession,
current_user: CurrentUser,
customer_id: Annotated[int, Path(ge=1)],
payload: CustomerRatingIn,
meta: RequestMetaDep,
) -> ApiResponse[CustomerOut]:
"""保存评级总分(评级计算器「保存评分」)。
评分是客户档案属性:存总分与评级时间,五项明细不落库(对照表 #25);
编辑端点不含评分 —— 评分只走本端点,由计算器统一入口写入。
"""
return ok(
customer_service.rate_customer(
session, customer_id, payload, operator=current_user, meta=meta
),
message="评级已保存",
)
@router.put(
"/{customer_id}/ownership",
response_model=ApiResponse[CustomerOut],
+20 -12
View File
@@ -1,4 +1,4 @@
"""工作台接口(M4 ⑧):四区块一次性聚合。
"""工作台接口(M4 ⑧):三区块聚合 + 最近动态分页。
准入:管理员/财务/领导(与报表、审批接口一致;FR-033/036 的「全部用户」
待业务权限清单 #6 答复后再放宽)。只读接口,不写操作日志。
@@ -12,22 +12,18 @@ from app.api.deps import DbSession, require_roles
from app.core.constants import ROLE_ADMIN, ROLE_FINANCE, ROLE_LEADER
from app.core.response import ApiResponse, ok
from app.models import User
from app.schemas.dashboard import DashboardOut
from app.schemas.dashboard import ActivityPageOut, ActivityQuery, DashboardOut
from app.services import dashboard_service
router = APIRouter(prefix="/dashboard", tags=["工作台"])
_DashboardUser = Annotated[
User,
Depends(
require_roles(
ROLE_ADMIN, ROLE_FINANCE, ROLE_LEADER, message="无权访问工作台"
)
),
Depends(require_roles(ROLE_ADMIN, ROLE_FINANCE, ROLE_LEADER, message="无权访问工作台")),
]
@router.get("", response_model=ApiResponse[DashboardOut], summary="工作台四区块")
@router.get("", response_model=ApiResponse[DashboardOut], summary="工作台三区块")
def get_dashboard(
session: DbSession,
current_user: _DashboardUser,
@@ -35,10 +31,22 @@ def get_dashboard(
str | None, Query(max_length=7, description="统计月份 YYYY-MM,默认当月")
] = None,
) -> ApiResponse[DashboardOut]:
"""一次返回经营总览/待办/风险预警/最近动态(待办与预警实时派生)。
"""一次返回经营总览/待办/风险预警(待办与预警实时派生)。
待办按当前用户角色只返回其可操作节点(admin 可查看全部在途单)。
最近动态是分页列表,走 `GET /dashboard/activities`。
"""
return ok(
dashboard_service.get_dashboard(session, month, current_user=current_user)
)
return ok(dashboard_service.get_dashboard(session, month, current_user=current_user))
@router.get("/activities", response_model=ApiResponse[ActivityPageOut], summary="最近动态(分页)")
def list_dashboard_activities(
session: DbSession,
current_user: _DashboardUser,
query: Annotated[ActivityQuery, Query()],
) -> ApiResponse[ActivityPageOut]:
"""分页查询最近动态,可按模块筛选。
与聚合端点拆开:翻页只重取这张列表,不必重算总览/待办/预警。
"""
return ok(dashboard_service.list_activities(session, query))
+1 -3
View File
@@ -79,9 +79,7 @@ class SecondaryAllocation(Base, TimestampMixin):
Index("idx_business_secondary_allocations_business_id", "business_id"),
Index("idx_business_secondary_allocations_personnel_id", "personnel_id"),
# 取当前生效行的唯一查询形态:WHERE business_id=? AND is_active=1
Index(
"idx_business_secondary_allocations_business_active", "business_id", "is_active"
),
Index("idx_business_secondary_allocations_business_active", "business_id", "is_active"),
)
id: Mapped[int] = mapped_column(
+8
View File
@@ -63,6 +63,14 @@ class Customer(Base, TimestampMixin, TPlusSyncMixin):
comment="归属负责营销人员ID(手动修改不回溯历史,变更留痕见 ownership_change_logs)",
)
remark: Mapped[str | None] = mapped_column(String(255), comment="备注")
rating_score: Mapped[int | None] = mapped_column(
TINYINT_TYPE,
comment="评级总分(0-100,评级计算器保存;null=未评分;人工维护字段,T+ 同步不覆盖)",
)
rating_time: Mapped[datetime | None] = mapped_column(
DateTime,
comment="最近评级时间(评级计算器保存评分时刷新;人工维护字段)",
)
class OwnershipChangeLog(Base):
+29 -73
View File
@@ -5,8 +5,6 @@
models/customer.py 定义)。
"""
from datetime import date
from sqlalchemy import Row, Select, delete, exists, func, or_, select, update
from sqlalchemy.orm import Session, aliased
@@ -23,6 +21,8 @@ from app.models import (
)
from app.models.customer import OWNER_TYPE_BUSINESS
from app.repositories import ownership_log_repository
from app.repositories.ownership_log_repository import OwnershipLogRef
from app.schemas.business import BusinessQuery
def get_by_id(session: Session, business_id: int, *, for_update: bool = False) -> Business | None:
@@ -77,50 +77,32 @@ def get_with_names(
return session.execute(stmt).first()
def _apply_filters(
stmt: Select,
*,
keyword: str | None,
status: int | None,
customer_id: int | None,
owner_personnel_id: int | None,
date_from: date | None,
date_to: date | None,
) -> Select:
def _apply_filters(stmt: Select, query: BusinessQuery) -> Select:
"""业务列表的筛选条件(调用方须已 join Customer)。"""
if keyword:
if query.keyword:
# 业务编号/公司名称双列 OR 模糊;contains(autoescape) 同 query_utils.fuzzy
# 的转义口径(fuzzy 为单列 where 语义,双列 OR 在此直接组合)
stmt = stmt.where(
or_(
Business.business_no.contains(keyword, autoescape=True),
Customer.company_name.contains(keyword, autoescape=True),
Business.business_no.contains(query.keyword, autoescape=True),
Customer.company_name.contains(query.keyword, autoescape=True),
)
)
if status is not None:
stmt = stmt.where(Business.status == status)
if customer_id is not None:
stmt = stmt.where(Business.customer_id == customer_id)
if owner_personnel_id is not None:
stmt = stmt.where(Business.owner_personnel_id == owner_personnel_id)
if date_from is not None:
stmt = stmt.where(Business.business_date >= date_from)
if date_to is not None:
stmt = stmt.where(Business.business_date <= date_to)
if query.status is not None:
stmt = stmt.where(Business.status == query.status)
if query.customer_id is not None:
stmt = stmt.where(Business.customer_id == query.customer_id)
if query.owner_personnel_id is not None:
stmt = stmt.where(Business.owner_personnel_id == query.owner_personnel_id)
if query.date_from is not None:
stmt = stmt.where(Business.business_date >= query.date_from)
if query.date_to is not None:
stmt = stmt.where(Business.business_date <= query.date_to)
return stmt
def list_businesses(
session: Session,
*,
offset: int,
limit: int,
keyword: str | None,
status: int | None,
customer_id: int | None,
owner_personnel_id: int | None,
date_from: date | None,
date_to: date | None,
session: Session, query: BusinessQuery
) -> list[tuple[Business, str, str | None, str | None]]:
"""分页查询业务(附客户名/负责人名/协助人名),按 id 倒序。"""
owner = aliased(Personnel)
@@ -128,49 +110,24 @@ def list_businesses(
stmt = select(
Business, Customer.company_name, owner.personnel_name, assistant.personnel_name
).join(Customer, Customer.id == Business.customer_id)
stmt = _apply_filters(
stmt,
keyword=keyword,
status=status,
customer_id=customer_id,
owner_personnel_id=owner_personnel_id,
date_from=date_from,
date_to=date_to,
)
stmt = _apply_filters(stmt, query)
stmt = (
stmt.outerjoin(owner, owner.id == Business.owner_personnel_id)
.outerjoin(assistant, assistant.id == Business.assistant_personnel_id)
.order_by(Business.id.desc())
.offset(offset)
.limit(limit)
.offset(query.offset)
.limit(query.page_size)
)
return list(session.execute(stmt).all())
def count_businesses(
session: Session,
*,
keyword: str | None,
status: int | None,
customer_id: int | None,
owner_personnel_id: int | None,
date_from: date | None,
date_to: date | None,
) -> int:
def count_businesses(session: Session, query: BusinessQuery) -> int:
stmt = (
select(func.count())
.select_from(Business)
.join(Customer, Customer.id == Business.customer_id)
)
stmt = _apply_filters(
stmt,
keyword=keyword,
status=status,
customer_id=customer_id,
owner_personnel_id=owner_personnel_id,
date_from=date_from,
date_to=date_to,
)
stmt = _apply_filters(stmt, query)
return int(session.execute(stmt).scalar_one())
@@ -313,18 +270,17 @@ def list_ownership_logs(
) -> list[Row]:
"""某业务的归属变更留痕(共享实现,owner_type/business_id 参数化)。"""
return ownership_log_repository.list_by_ref(
session,
owner_type=OWNER_TYPE_BUSINESS,
ref_column=OwnershipChangeLog.business_id,
ref_id=business_id,
offset=offset,
limit=limit,
session, _business_log_ref(business_id), offset=offset, limit=limit
)
def count_ownership_logs(session: Session, business_id: int) -> int:
return ownership_log_repository.count_by_ref(
session,
return ownership_log_repository.count_by_ref(session, _business_log_ref(business_id))
def _business_log_ref(business_id: int) -> OwnershipLogRef:
"""业务维度的归属留痕锚点。"""
return OwnershipLogRef(
owner_type=OWNER_TYPE_BUSINESS,
ref_column=OwnershipChangeLog.business_id,
ref_id=business_id,
@@ -7,6 +7,7 @@ from sqlalchemy import Select, func, or_, select
from sqlalchemy.orm import Session
from app.models import BusinessRole
from app.schemas.business_role import BusinessRoleQuery
# ==================== 读取 ====================
@@ -39,41 +40,48 @@ def list_by_codes(session: Session, role_codes: list[str]) -> dict[str, Business
return {role.role_code: role for role in rows}
def list_roles(
session: Session,
*,
offset: int,
limit: int,
keyword: str | None = None,
status: int | None = None,
) -> list[BusinessRole]:
def list_roles(session: Session, query: BusinessRoleQuery) -> list[BusinessRole]:
"""分页查询岗位(含停用)。排序 (sort_order, id),与系统角色口径一致。"""
stmt = _apply_filters(select(BusinessRole), keyword=keyword, status=status)
stmt = stmt.order_by(BusinessRole.sort_order, BusinessRole.id).offset(offset).limit(limit)
stmt = _apply_filters(select(BusinessRole), query)
stmt = (
stmt.order_by(BusinessRole.sort_order, BusinessRole.id)
.offset(query.offset)
.limit(query.page_size)
)
return list(session.execute(stmt).scalars())
def count_roles(session: Session, *, keyword: str | None = None, status: int | None = None) -> int:
def count_roles(session: Session, query: BusinessRoleQuery) -> int:
"""统计符合条件的岗位总数,供分页使用。"""
stmt = _apply_filters(select(func.count(BusinessRole.id)), keyword=keyword, status=status)
stmt = _apply_filters(select(func.count(BusinessRole.id)), query)
return int(session.execute(stmt).scalar_one())
def _apply_filters(stmt: Select, *, keyword: str | None, status: int | None) -> Select:
def list_all(session: Session) -> list[BusinessRole]:
"""全部岗位(含停用,不分页)——岗位下拉选项的取数口径。
岗位规模有限(内置 + 自定义),一次取全量,不受分页上限约束;
排序与 list_roles 一致(sort_order, id),下拉与列表相对次序相同。
"""
stmt = select(BusinessRole).order_by(BusinessRole.sort_order, BusinessRole.id)
return list(session.execute(stmt).scalars())
def _apply_filters(stmt: Select, query: BusinessRoleQuery) -> Select:
"""应用通用筛选条件。列表与计数共用,避免两处口径不一致。
关键词走 ``contains(autoescape=True)``(转义 LIKE 通配符)——岗位编码含下划线,
不转义时 ``_`` 会当单字符通配执行。
"""
if keyword:
if query.keyword:
stmt = stmt.where(
or_(
BusinessRole.role_code.contains(keyword, autoescape=True),
BusinessRole.role_name.contains(keyword, autoescape=True),
BusinessRole.role_code.contains(query.keyword, autoescape=True),
BusinessRole.role_name.contains(query.keyword, autoescape=True),
)
)
if status is not None:
stmt = stmt.where(BusinessRole.status == status)
if query.status is not None:
stmt = stmt.where(BusinessRole.status == query.status)
return stmt
+41 -48
View File
@@ -9,7 +9,9 @@ from sqlalchemy.orm import Session
from app.models import Customer, OwnershipChangeLog, Personnel
from app.models.customer import OWNER_TYPE_CUSTOMER
from app.repositories import ownership_log_repository
from app.repositories.ownership_log_repository import OwnershipLogRef
from app.repositories.query_utils import fuzzy
from app.schemas.customer import CustomerQuery
# ==================== 读取 ====================
@@ -45,49 +47,27 @@ def get_by_company_owner(session: Session, company_name: str, owner_name: str) -
return session.execute(stmt).scalar_one_or_none()
def list_customers(
session: Session,
*,
offset: int,
limit: int,
keyword: str | None = None,
customer_type: str | None = None,
owner_personnel_id: int | None = None,
) -> list[Row]:
def list_customers(session: Session, query: CustomerQuery) -> list[Row]:
"""分页查询客户,行内容为 (客户, 归属人员姓名)。
归属人员外键非空但统一用 outerjoin:人员行不可考(被物理删除的脏数据)
不应让客户行整个查不出来(同 personnel_repository 的韧性口径)。
排序按 id 倒序:客户无排序字段,新增的靠前符合列表页习惯。
排序键由查询白名单决定(见 ``CustomerQuery.order_by``):
id 倒序 = 新建档靠前,rating_score 按分数排、未评分垫底。
"""
stmt = _apply_filters(
_base_select(),
keyword=keyword,
customer_type=customer_type,
owner_personnel_id=owner_personnel_id,
)
stmt = stmt.order_by(Customer.id.desc()).offset(offset).limit(limit)
stmt = _apply_filters(_base_select(), query)
stmt = _apply_order(stmt, query)
stmt = stmt.offset(query.offset).limit(query.page_size)
return list(session.execute(stmt).all())
def count_customers(
session: Session,
*,
keyword: str | None = None,
customer_type: str | None = None,
owner_personnel_id: int | None = None,
) -> int:
def count_customers(session: Session, query: CustomerQuery) -> int:
"""统计符合条件的客户总数,供分页使用。
筛选条件全部落在主表上,计数无需 join(列表与计数的筛选口径由
``_apply_filters`` 单点保证)。
"""
stmt = _apply_filters(
select(func.count(Customer.id)),
keyword=keyword,
customer_type=customer_type,
owner_personnel_id=owner_personnel_id,
)
stmt = _apply_filters(select(func.count(Customer.id)), query)
return int(session.execute(stmt).scalar_one())
@@ -101,9 +81,7 @@ def list_ownership_logs(
"""查某客户的归属变更留痕(共享实现,owner_type/customer_id 参数化)。"""
return ownership_log_repository.list_by_ref(
session,
owner_type=OWNER_TYPE_CUSTOMER,
ref_column=OwnershipChangeLog.customer_id,
ref_id=customer_id,
_customer_log_ref(customer_id),
offset=offset,
limit=limit,
)
@@ -111,8 +89,12 @@ def list_ownership_logs(
def count_ownership_logs(session: Session, *, customer_id: int) -> int:
"""统计某客户的归属留痕总数,供分页使用。"""
return ownership_log_repository.count_by_ref(
session,
return ownership_log_repository.count_by_ref(session, _customer_log_ref(customer_id))
def _customer_log_ref(customer_id: int) -> OwnershipLogRef:
"""客户维度的归属留痕锚点。"""
return OwnershipLogRef(
owner_type=OWNER_TYPE_CUSTOMER,
ref_column=OwnershipChangeLog.customer_id,
ref_id=customer_id,
@@ -126,20 +108,14 @@ def _base_select() -> Select:
)
def _apply_filters(
stmt: Select,
*,
keyword: str | None,
customer_type: str | None,
owner_personnel_id: int | None,
) -> Select:
def _apply_filters(stmt: Select, query: CustomerQuery) -> Select:
"""应用通用筛选条件。列表与计数共用,避免两处口径不一致。"""
if keyword:
stmt = stmt.where(_keyword_clause(keyword))
if customer_type:
stmt = stmt.where(Customer.customer_type == customer_type)
if owner_personnel_id is not None:
stmt = stmt.where(Customer.owner_personnel_id == owner_personnel_id)
if query.keyword:
stmt = stmt.where(_keyword_clause(query.keyword))
if query.customer_type:
stmt = stmt.where(Customer.customer_type == query.customer_type)
if query.owner_personnel_id is not None:
stmt = stmt.where(Customer.owner_personnel_id == query.owner_personnel_id)
return stmt
@@ -157,6 +133,23 @@ def _keyword_clause(keyword: str) -> ColumnElement[bool]:
)
def _apply_order(stmt: Select, query: CustomerQuery) -> Select:
"""应用排序。排序键已由 schema 白名单收口,此处只做键 → 列的映射。
MySQL 把 NULL 视为最小值:ASC 时未评分(null)会排最前,但「没打分」
不该压在「打了低分」之上 —— ASC 分支用 ``is_(None)`` 布尔键把 null
挪到末尾;DESC 分支 null 天然在最后,无需处理。尾部再按 id 倒序兜底,
保证同分行的次序稳定(分页不跳行)。
"""
if query.order_by == "rating_score":
score = Customer.rating_score
if query.order == "asc":
stmt = stmt.order_by(score.is_(None), score.asc())
else:
stmt = stmt.order_by(score.desc())
return stmt.order_by(Customer.id.desc())
# ==================== 写入 ====================
@@ -34,20 +34,25 @@ _PENDING_STATUSES = (
# ==== 经营绩效总览 ====
def sum_month_performance(session: Session, month: str) -> dict:
"""当月单笔生效行合计与人员数(拆行求和不失真,人数需 DISTINCT)。"""
return session.execute(
select(
func.coalesce(func.sum(PerformanceLedger.performance_amount), 0).label(
"performance_total"
),
func.coalesce(func.sum(PerformanceLedger.payable_amount), 0).label("payable_total"),
func.count(func.distinct(PerformanceLedger.personnel_id)).label("person_count"),
).where(
PerformanceLedger.settlement_month == month,
*_ACTIVE_SINGLE,
return (
session.execute(
select(
func.coalesce(func.sum(PerformanceLedger.performance_amount), 0).label(
"performance_total"
),
func.coalesce(func.sum(PerformanceLedger.payable_amount), 0).label("payable_total"),
func.count(func.distinct(PerformanceLedger.personnel_id)).label("person_count"),
).where(
PerformanceLedger.settlement_month == month,
*_ACTIVE_SINGLE,
)
)
).mappings().one()
.mappings()
.one()
)
def performance_trend(session: Session, months: list[str]) -> dict[str, object]:
@@ -66,9 +71,7 @@ def performance_trend(session: Session, months: list[str]) -> dict[str, object]:
return dict(rows)
def receipt_trend(
session: Session, *, start_date: date, end_date: date
) -> dict[str, object]:
def receipt_trend(session: Session, *, start_date: date, end_date: date) -> dict[str, object]:
"""近 N 个月回款合计(按到账月,含未核算回款——与台账口径不同,页面注明)。
过滤走 ``receipt_date`` 原始列范围(可用索引);``DATE_FORMAT`` 只用于
@@ -107,6 +110,7 @@ def get_month_settlement(session: Session, month: str) -> MonthlySettlement | No
# ==== 待办事项 ====
def list_pending_approvals(
session: Session, *, statuses: tuple[int, ...], limit: int
) -> list[ApprovalOrder]:
@@ -160,15 +164,14 @@ def list_unassigned_receipts(session: Session, *, limit: int) -> list:
def count_unassigned_receipts(session: Session) -> int:
return int(
session.execute(
select(func.count())
.select_from(Receipt)
.where(Receipt.shipment_id.is_(None))
select(func.count()).select_from(Receipt).where(Receipt.shipment_id.is_(None))
).scalar_one()
)
# ==== 风险预警 ====
def list_overdue_shipments(session: Session, *, cutoff: date, limit: int) -> list:
"""发货后超过阈值且该批次无任何回款的发货单(回款逾期规则)。
@@ -1,5 +1,7 @@
"""登录日志数据访问。"""
from dataclasses import dataclass
from sqlalchemy import Select, func, select
from sqlalchemy.orm import Session
@@ -10,68 +12,73 @@ from app.repositories.query_utils import LogFilters, apply_log_filters
_USER_AGENT_MAX_LEN = 255
def add(
session: Session,
*,
user_id: int | None,
username: str,
ip: str | None,
user_agent: str | None,
success: bool,
fail_reason: str | None = None,
) -> None:
@dataclass(frozen=True, slots=True)
class LoginLogEntry:
"""一条待写入的登录日志(全字段 DTO)。
与 ``OperationLogEntry`` 同款做法:写入字段平铺传递时,任何一处漏传
都只表现为日志字段缺失、不报错(success 默认 0 的语义尤其隐蔽)。
"""
user_id: int | None
username: str
success: bool
ip: str | None = None
user_agent: str | None = None
fail_reason: str | None = None
@dataclass(frozen=True, slots=True)
class LoginLogListQuery:
"""登录日志列表的查询条件(筛选 + 分页)。"""
filters: LogFilters
offset: int = 0
limit: int = 20
ip: str | None = None
def add(session: Session, entry: LoginLogEntry) -> None:
"""写入一条登录日志。只 flush 不 commit。
**绝不记录密码** —— 明文与哈希都不写。日志是最容易泄漏凭据的地方。
:param user_id: 账号不存在时传 None(列本身可空)
:param username: 用户名原样记录,便于账号不存在时也能追溯
:param entry.user_id: 账号不存在时为 None(列本身可空)
:param entry.username: 用户名原样记录,便于账号不存在时也能追溯
"""
session.add(
LoginLog(
user_id=user_id,
username=username,
ip=ip,
user_agent=user_agent[:_USER_AGENT_MAX_LEN] if user_agent else None,
success=1 if success else 0,
fail_reason=fail_reason,
user_id=entry.user_id,
username=entry.username,
ip=entry.ip,
user_agent=entry.user_agent[:_USER_AGENT_MAX_LEN] if entry.user_agent else None,
success=1 if entry.success else 0,
fail_reason=entry.fail_reason,
)
)
session.flush()
def list_logs(
session: Session,
*,
offset: int,
limit: int,
filters: LogFilters,
ip: str | None = None,
) -> list[LoginLog]:
def list_logs(session: Session, query: LoginLogListQuery) -> list[LoginLog]:
"""分页查询登录日志。
**按 id 倒序而非 create_time**:后者是秒级精度,同一秒内的多条记录排序
不确定,翻页会重复或遗漏(与用户列表同一理由)。
"""
stmt = _apply_filters(select(LoginLog), filters=filters, ip=ip)
stmt = stmt.order_by(LoginLog.id.desc()).offset(offset).limit(limit)
stmt = _apply_filters(select(LoginLog), query)
stmt = stmt.order_by(LoginLog.id.desc()).offset(query.offset).limit(query.limit)
return list(session.execute(stmt).scalars())
def count_logs(
session: Session,
*,
filters: LogFilters,
ip: str | None = None,
) -> int:
def count_logs(session: Session, query: LoginLogListQuery) -> int:
"""统计符合条件的登录日志总数,供分页使用。"""
stmt = _apply_filters(select(func.count(LoginLog.id)), filters=filters, ip=ip)
stmt = _apply_filters(select(func.count(LoginLog.id)), query)
return int(session.execute(stmt).scalar_one())
def _apply_filters(stmt: Select, *, filters: LogFilters, ip: str | None) -> Select:
def _apply_filters(stmt: Select, query: LoginLogListQuery) -> Select:
"""应用筛选条件(公共部分复用 query_utils,与操作日志同口径)。"""
stmt = apply_log_filters(stmt, LoginLog, filters)
if ip:
stmt = stmt.where(LoginLog.ip == ip)
stmt = apply_log_filters(stmt, LoginLog, query.filters)
if query.ip:
stmt = stmt.where(LoginLog.ip == query.ip)
return stmt
@@ -107,15 +107,18 @@ def get_by_id(session: Session, log_id: int) -> OperationLog | None:
return session.get(OperationLog, log_id)
def list_logs(
session: Session,
*,
offset: int,
limit: int,
filters: LogFilters,
module: str | None = None,
action: str | None = None,
) -> list[OperationLog]:
@dataclass(frozen=True, slots=True)
class OperationLogListQuery:
"""操作日志列表的查询条件(筛选 + 分页)。"""
filters: LogFilters
offset: int = 0
limit: int = 20
module: str | None = None
action: str | None = None
def list_logs(session: Session, query: OperationLogListQuery) -> list[OperationLog]:
"""分页查询操作日志。
**按 id 倒序而非 create_time**:后者是秒级精度,同一秒内的多条记录排序
@@ -127,41 +130,34 @@ def list_logs(
"""
stmt = _apply_filters(
select(OperationLog).options(defer(OperationLog.request_data)),
filters=filters,
module=module,
action=action,
query,
)
stmt = stmt.order_by(OperationLog.id.desc()).offset(offset).limit(limit)
stmt = stmt.order_by(OperationLog.id.desc()).offset(query.offset).limit(query.limit)
return list(session.execute(stmt).scalars())
def count_logs(
session: Session,
*,
filters: LogFilters,
module: str | None = None,
action: str | None = None,
) -> int:
def count_logs(session: Session, query: OperationLogListQuery) -> int:
"""统计符合条件的日志总数,供分页使用。"""
stmt = _apply_filters(
select(func.count(OperationLog.id)), filters=filters, module=module, action=action
)
stmt = _apply_filters(select(func.count(OperationLog.id)), query)
return int(session.execute(stmt).scalar_one())
def _apply_filters(
stmt: Select,
*,
filters: LogFilters,
module: str | None,
action: str | None,
) -> Select:
def distinct_modules(session: Session) -> list[str]:
"""全部模块名(去重、按名称升序):工作台「最近动态」的模块筛选项。
交给 SQL 的 DISTINCT,不在应用层聚合 —— 应用层去重要把整列拉进内存。
"""
stmt = select(OperationLog.module).distinct().order_by(OperationLog.module)
return [module for module in session.execute(stmt).scalars() if module]
def _apply_filters(stmt: Select, query: OperationLogListQuery) -> Select:
"""应用筛选条件(公共部分复用 query_utils,与登录日志同口径)。"""
stmt = apply_log_filters(stmt, OperationLog, filters)
if module:
stmt = stmt.where(OperationLog.module == module)
if action:
stmt = stmt.where(OperationLog.action == action)
stmt = apply_log_filters(stmt, OperationLog, query.filters)
if query.module:
stmt = stmt.where(OperationLog.module == query.module)
if query.action:
stmt = stmt.where(OperationLog.action == query.action)
return stmt
@@ -5,18 +5,32 @@ owner_type 与引用列由调用方参数化 —— 此前客户、业务两侧
「被删人员留痕仍可读」「翻页稳定双键倒序」等口径单点维护。
"""
from dataclasses import dataclass
from typing import Any
from sqlalchemy import Row, func, select
from sqlalchemy.orm import Session, aliased
from app.models import OwnershipChangeLog, Personnel
@dataclass(frozen=True, slots=True)
class OwnershipLogRef:
"""归属留痕的引用锚点(维度类型 + 引用列 + 引用 ID)。
引用列是 SQLAlchemy 列表达式(如 ``OwnershipChangeLog.customer_id``),
不是可序列化值 —— 故用冻结 dataclass 而非 Pydantic 模型。
"""
owner_type: int
ref_column: Any
ref_id: int
def list_by_ref(
session: Session,
ref: OwnershipLogRef,
*,
owner_type: int,
ref_column,
ref_id: int,
offset: int,
limit: int,
) -> list[Row]:
@@ -32,7 +46,7 @@ def list_by_ref(
select(OwnershipChangeLog, old_person.personnel_name, new_person.personnel_name)
.outerjoin(old_person, OwnershipChangeLog.old_owner_personnel_id == old_person.id)
.outerjoin(new_person, OwnershipChangeLog.new_owner_personnel_id == new_person.id)
.where(OwnershipChangeLog.owner_type == owner_type, ref_column == ref_id)
.where(OwnershipChangeLog.owner_type == ref.owner_type, ref.ref_column == ref.ref_id)
.order_by(OwnershipChangeLog.create_time.desc(), OwnershipChangeLog.id.desc())
.offset(offset)
.limit(limit)
@@ -40,11 +54,11 @@ def list_by_ref(
return list(session.execute(stmt).all())
def count_by_ref(session: Session, *, owner_type: int, ref_column, ref_id: int) -> int:
def count_by_ref(session: Session, ref: OwnershipLogRef) -> int:
"""统计某引用对象的归属留痕总数,供分页使用。"""
stmt = select(func.count(OwnershipChangeLog.id)).where(
OwnershipChangeLog.owner_type == owner_type,
ref_column == ref_id,
OwnershipChangeLog.owner_type == ref.owner_type,
ref.ref_column == ref.ref_id,
)
return int(session.execute(stmt).scalar_one())
+28 -38
View File
@@ -10,6 +10,7 @@ from sqlalchemy import Select, delete, func, select
from sqlalchemy.orm import Session
from app.models import ParamChangeBatch, ParamDefinition, ParamValue
from app.schemas.param import ParamBatchQuery, ParamHistoryQuery
# ==================== 读取 ====================
@@ -97,27 +98,27 @@ def list_definitions_by_codes(
def list_history(
session: Session,
param_id: int,
*,
offset: int,
limit: int,
dimension_value: str | None = None,
session: Session, param_id: int, query: ParamHistoryQuery
) -> list[tuple[ParamValue, ParamChangeBatch]]:
"""单参数的版本历史(含批次信息),最新在前。"""
stmt = _history_stmt(session, param_id, dimension_value=dimension_value)
return [(value, batch) for value, batch in session.execute(stmt.offset(offset).limit(limit))]
"""单参数的版本历史(含批次信息),最新在前。
``param_id`` 是「查哪个参数」的主锚点(由路径参数 param_code 解析而来),
不属于列表筛选项,故仍以位置参数传入;筛选与分页走 ``query``。
"""
stmt = _history_stmt(param_id, query)
return [
(value, batch)
for value, batch in session.execute(stmt.offset(query.offset).limit(query.page_size))
]
def count_history(session: Session, param_id: int, *, dimension_value: str | None = None) -> int:
def count_history(session: Session, param_id: int, query: ParamHistoryQuery) -> int:
"""统计单参数的历史条目数。"""
stmt = select(func.count()).select_from(
_history_stmt(session, param_id, dimension_value=dimension_value).subquery()
)
stmt = select(func.count()).select_from(_history_stmt(param_id, query).subquery())
return int(session.execute(stmt).scalar_one())
def _history_stmt(session: Session, param_id: int, *, dimension_value: str | None) -> Select:
def _history_stmt(param_id: int, query: ParamHistoryQuery) -> Select:
"""历史查询基语句:条目 join 批次(版本号/操作人),最新在前。"""
stmt = (
select(ParamValue, ParamChangeBatch)
@@ -125,8 +126,8 @@ def _history_stmt(session: Session, param_id: int, *, dimension_value: str | Non
.where(ParamValue.param_id == param_id)
.order_by(ParamValue.effective_date.desc(), ParamValue.id.desc())
)
if dimension_value is not None:
stmt = stmt.where(ParamValue.dimension_value == dimension_value)
if query.dimension_value is not None:
stmt = stmt.where(ParamValue.dimension_value == query.dimension_value)
return stmt
@@ -150,41 +151,30 @@ def get_batch_by_id(
return session.get(ParamChangeBatch, batch_id)
def list_batches(
session: Session,
*,
offset: int,
limit: int,
start_date: date | None = None,
end_date: date | None = None,
) -> list[ParamChangeBatch]:
def list_batches(session: Session, query: ParamBatchQuery) -> list[ParamChangeBatch]:
"""分页查询变更批次(按创建时间倒序)。"""
stmt = _batch_filters(select(ParamChangeBatch), start_date=start_date, end_date=end_date)
stmt = _batch_filters(select(ParamChangeBatch), query)
return list(
session.execute(
stmt.order_by(ParamChangeBatch.create_time.desc(), ParamChangeBatch.id.desc())
.offset(offset)
.limit(limit)
.offset(query.offset)
.limit(query.page_size)
).scalars()
)
def count_batches(
session: Session, *, start_date: date | None = None, end_date: date | None = None
) -> int:
def count_batches(session: Session, query: ParamBatchQuery) -> int:
"""统计符合条件的批次总数。"""
stmt = _batch_filters(
select(func.count(ParamChangeBatch.id)), start_date=start_date, end_date=end_date
)
stmt = _batch_filters(select(func.count(ParamChangeBatch.id)), query)
return int(session.execute(stmt).scalar_one())
def _batch_filters(stmt: Select, *, start_date: date | None, end_date: date | None) -> Select:
def _batch_filters(stmt: Select, query: ParamBatchQuery) -> Select:
"""批次筛选(生效日期区间,含边界)。列表与计数共用。"""
if start_date is not None:
stmt = stmt.where(ParamChangeBatch.effective_date >= start_date)
if end_date is not None:
stmt = stmt.where(ParamChangeBatch.effective_date <= end_date)
if query.start_date is not None:
stmt = stmt.where(ParamChangeBatch.effective_date >= query.start_date)
if query.end_date is not None:
stmt = stmt.where(ParamChangeBatch.effective_date <= query.end_date)
return stmt
@@ -9,6 +9,7 @@ from sqlalchemy.orm import Session, aliased
from app.models import BusinessRole, Customer, Personnel, PersonnelPlan, PersonnelRegion
from app.repositories.query_utils import fuzzy
from app.schemas.personnel import PersonnelQuery
# 自引用别名:manager_id 指向 personnel.id,同一张表自联取「直属经理姓名」
_manager = aliased(Personnel)
@@ -30,44 +31,23 @@ def get_with_names(session: Session, personnel_id: int) -> Row | None:
return session.execute(stmt).first()
def list_personnel(
session: Session,
*,
offset: int,
limit: int,
keyword: str | None = None,
status: int | None = None,
business_role_id: int | None = None,
) -> list[Row]:
def list_personnel(session: Session, query: PersonnelQuery) -> list[Row]:
"""分页查询人员,行内容为 (人员, 岗位名称, 直属经理姓名)。
排序按 id 倒序:人员无排序字段,新增的靠前符合列表页习惯。
"""
stmt = _apply_filters(
_base_select(), keyword=keyword, status=status, business_role_id=business_role_id
)
stmt = stmt.order_by(Personnel.id.desc()).offset(offset).limit(limit)
stmt = _apply_filters(_base_select(), query)
stmt = stmt.order_by(Personnel.id.desc()).offset(query.offset).limit(query.page_size)
return list(session.execute(stmt).all())
def count_personnel(
session: Session,
*,
keyword: str | None = None,
status: int | None = None,
business_role_id: int | None = None,
) -> int:
def count_personnel(session: Session, query: PersonnelQuery) -> int:
"""统计符合条件的人员总数,供分页使用。
筛选条件全部落在主表上,计数无需 join(列表与计数的筛选口径由
``_apply_filters`` 单点保证)。
"""
stmt = _apply_filters(
select(func.count(Personnel.id)),
keyword=keyword,
status=status,
business_role_id=business_role_id,
)
stmt = _apply_filters(select(func.count(Personnel.id)), query)
return int(session.execute(stmt).scalar_one())
@@ -154,20 +134,14 @@ def _base_select() -> Select:
)
def _apply_filters(
stmt: Select,
*,
keyword: str | None,
status: int | None,
business_role_id: int | None,
) -> Select:
def _apply_filters(stmt: Select, query: PersonnelQuery) -> Select:
"""应用通用筛选条件。列表与计数共用,避免两处口径不一致。"""
if keyword:
stmt = fuzzy(stmt, Personnel.personnel_name, keyword)
if status is not None:
stmt = stmt.where(Personnel.status == status)
if business_role_id is not None:
stmt = stmt.where(Personnel.business_role_id == business_role_id)
if query.keyword:
stmt = fuzzy(stmt, Personnel.personnel_name, query.keyword)
if query.status is not None:
stmt = stmt.where(Personnel.status == query.status)
if query.business_role_id is not None:
stmt = stmt.where(Personnel.business_role_id == query.business_role_id)
return stmt
+14 -43
View File
@@ -7,6 +7,7 @@ from sqlalchemy import Select, func, or_, select
from sqlalchemy.orm import Session
from app.models import BusinessItem, Product, ShipmentItem
from app.schemas.product import ProductQuery
# ==================== 读取 ====================
@@ -34,50 +35,20 @@ def get_by_name_spec(session: Session, product_name: str, spec_model: str) -> Pr
return session.execute(stmt).scalar_one_or_none()
def list_products(
session: Session,
*,
offset: int,
limit: int,
keyword: str | None = None,
product_category: str | None = None,
status: int | None = None,
) -> list[Product]:
def list_products(session: Session, query: ProductQuery) -> list[Product]:
"""分页查询产品(含停用)。按 id 升序,与 M1 岗位列表的次序口径一致。"""
stmt = _apply_filters(
select(Product),
keyword=keyword,
product_category=product_category,
status=status,
)
stmt = stmt.order_by(Product.id).offset(offset).limit(limit)
stmt = _apply_filters(select(Product), query)
stmt = stmt.order_by(Product.id).offset(query.offset).limit(query.page_size)
return list(session.execute(stmt).scalars())
def count_products(
session: Session,
*,
keyword: str | None = None,
product_category: str | None = None,
status: int | None = None,
) -> int:
def count_products(session: Session, query: ProductQuery) -> int:
"""统计符合条件的产品总数,供分页使用。"""
stmt = _apply_filters(
select(func.count(Product.id)),
keyword=keyword,
product_category=product_category,
status=status,
)
stmt = _apply_filters(select(func.count(Product.id)), query)
return int(session.execute(stmt).scalar_one())
def _apply_filters(
stmt: Select,
*,
keyword: str | None,
product_category: str | None,
status: int | None,
) -> Select:
def _apply_filters(stmt: Select, query: ProductQuery) -> Select:
"""应用通用筛选条件。列表与计数共用,避免两处口径不一致。
关键词对名称/规格两列取 OR 命中。转义与 query_utils.fuzzy 同一机制
@@ -86,17 +57,17 @@ def _apply_filters(
写 contains —— M1 business_role_repository 的双列关键词同款写法。
规格型号常含 ``_``(如 DN300_II),不转义会当单字符通配执行。
"""
if keyword:
if query.keyword:
stmt = stmt.where(
or_(
Product.product_name.contains(keyword, autoescape=True),
Product.spec_model.contains(keyword, autoescape=True),
Product.product_name.contains(query.keyword, autoescape=True),
Product.spec_model.contains(query.keyword, autoescape=True),
)
)
if product_category:
stmt = stmt.where(Product.product_category == product_category)
if status is not None:
stmt = stmt.where(Product.status == status)
if query.product_category:
stmt = stmt.where(Product.product_category == query.product_category)
if query.status is not None:
stmt = stmt.where(Product.status == query.status)
return stmt
@@ -32,8 +32,9 @@ def list_all(session: Session, *, for_update: bool = False) -> list[CustomerRati
def get_by_id(session: Session, grade_id: int) -> CustomerRatingGrade | None:
"""按主键查询。
等级表无下游引用(评级是纯计算器不落评分记录),无需 ``for_update``
锁行 —— product_repository 锁行防的是并发引用悬空,此处不存在该风险。
等级表无外键引用(客户档案只存总分,等级由分数按当前映射实时推导,
不存等级 id),无需 ``for_update`` 锁行 —— product_repository 锁行防的
是并发引用悬空,此处不存在该风险:删掉一个等级,只是以后不再判到它。
"""
return session.get(CustomerRatingGrade, grade_id)
@@ -60,6 +61,10 @@ def create(session: Session, grade: CustomerRatingGrade) -> CustomerRatingGrade:
def delete(session: Session, grade: CustomerRatingGrade) -> None:
"""物理删除等级。等级表无下游引用,调用方无需引用保护校验。"""
"""物理删除等级。
等级表无外键引用(客户档案只存总分,不存等级 id),调用方无需引用保护
校验:删掉等级只是「以后不再判到它」,已存的总分不受影响。
"""
session.delete(grade)
session.flush()
+22 -51
View File
@@ -14,6 +14,7 @@ from sqlalchemy import Select, func, select
from sqlalchemy.orm import Session
from app.models import Business, Receipt, Shipment
from app.schemas.receipt import ReceiptQuery
def get_by_id(session: Session, receipt_id: int) -> Receipt | None:
@@ -43,72 +44,42 @@ def get_shipment(session: Session, shipment_id: int) -> Shipment | None:
return session.get(Shipment, shipment_id)
def _apply_filters(
stmt: Select,
*,
business_id: int | None,
date_from: date | None,
date_to: date | None,
is_barter: int | None,
settlement_status: int | None,
) -> Select:
def _apply_filters(stmt: Select, query: ReceiptQuery) -> Select:
"""回款列表的筛选条件(全部落在 Receipt 自身列上)。"""
if business_id is not None:
stmt = stmt.where(Receipt.business_id == business_id)
if date_from is not None:
stmt = stmt.where(Receipt.receipt_date >= date_from)
if date_to is not None:
stmt = stmt.where(Receipt.receipt_date <= date_to)
if is_barter is not None:
stmt = stmt.where(Receipt.is_barter == is_barter)
if settlement_status is not None:
stmt = stmt.where(Receipt.settlement_status == settlement_status)
if query.business_id is not None:
stmt = stmt.where(Receipt.business_id == query.business_id)
if query.date_from is not None:
stmt = stmt.where(Receipt.receipt_date >= query.date_from)
if query.date_to is not None:
stmt = stmt.where(Receipt.receipt_date <= query.date_to)
if query.is_barter is not None:
stmt = stmt.where(Receipt.is_barter == query.is_barter)
# settlement_status 是 M3 核算防重锚点(需规 2.3.3),必须保持具名字段
if query.settlement_status is not None:
stmt = stmt.where(Receipt.settlement_status == query.settlement_status)
return stmt
def list_receipts(
session: Session,
*,
offset: int,
limit: int,
business_id: int | None,
date_from: date | None,
date_to: date | None,
is_barter: int | None,
settlement_status: int | None,
session: Session, query: ReceiptQuery
) -> list[tuple[Receipt, str, str | None, date | None]]:
"""分页查询回款(附业务号/发货单号/发货日),按 id 倒序。"""
stmt = _apply_filters(
select(Receipt, Business.business_no, Shipment.shipment_no, Shipment.ship_date)
.join(Business, Business.id == Receipt.business_id)
.outerjoin(Shipment, Shipment.id == Receipt.shipment_id),
business_id=business_id,
date_from=date_from,
date_to=date_to,
is_barter=is_barter,
settlement_status=settlement_status,
query,
)
return list(
session.execute(
stmt.order_by(Receipt.id.desc()).offset(query.offset).limit(query.page_size)
).all()
)
return list(session.execute(stmt.order_by(Receipt.id.desc()).offset(offset).limit(limit)).all())
def count_receipts(
session: Session,
*,
business_id: int | None,
date_from: date | None,
date_to: date | None,
is_barter: int | None,
settlement_status: int | None,
) -> int:
def count_receipts(session: Session, query: ReceiptQuery) -> int:
"""回款总数(筛选条件与列表共用同一 apply_filters,防两侧口径漂移)。"""
stmt = _apply_filters(
select(func.count()).select_from(Receipt),
business_id=business_id,
date_from=date_from,
date_to=date_to,
is_barter=is_barter,
settlement_status=settlement_status,
)
stmt = _apply_filters(select(func.count()).select_from(Receipt), query)
return int(session.execute(stmt).scalar_one())
@@ -389,9 +389,7 @@ def _bucket_condition(bucket: DayBucket) -> tuple:
return tuple(conds)
def list_payment_detail(
session: Session, query: PaymentDetailQuery, bucket: DayBucket, *, offset: int, limit: int
) -> list:
def list_payment_detail(session: Session, query: PaymentDetailQuery, bucket: DayBucket) -> list:
"""区间下钻:单笔生效行联回款表(行级展示回款金额与批次号)。
bucket 由服务层解析(未注册编码 400),仓储只按区间拼条件。
@@ -408,7 +406,7 @@ def list_payment_detail(
.where(*_payment_filters(query), *_bucket_condition(bucket))
)
stmt = stmt.order_by(PerformanceLedger.receipt_date.desc(), PerformanceLedger.id.desc())
stmt = stmt.offset(offset).limit(limit)
stmt = stmt.offset(query.offset).limit(query.page_size)
return list(session.execute(stmt).all())
+12 -18
View File
@@ -10,6 +10,7 @@ from sqlalchemy import Select, func, or_, select
from sqlalchemy.orm import Session
from app.models import Role, UserRole
from app.schemas.role import RoleQuery
# ==================== 读取 ====================
@@ -47,44 +48,37 @@ def get_all_roles(session: Session) -> list[Role]:
return list(session.execute(stmt).scalars())
def list_roles(
session: Session,
*,
offset: int,
limit: int,
keyword: str | None = None,
status: int | None = None,
) -> list[Role]:
def list_roles(session: Session, query: RoleQuery) -> list[Role]:
"""分页查询角色(管理列表:含停用角色)。
排序与下拉口径一致(sort_order, id),保持同一套相对次序。
"""
stmt = _apply_filters(select(Role), keyword=keyword, status=status)
stmt = stmt.order_by(Role.sort_order, Role.id).offset(offset).limit(limit)
stmt = _apply_filters(select(Role), query)
stmt = stmt.order_by(Role.sort_order, Role.id).offset(query.offset).limit(query.page_size)
return list(session.execute(stmt).scalars())
def count_roles(session: Session, *, keyword: str | None = None, status: int | None = None) -> int:
def count_roles(session: Session, query: RoleQuery) -> int:
"""统计符合条件的角色总数,供分页使用。"""
stmt = _apply_filters(select(func.count(Role.id)), keyword=keyword, status=status)
stmt = _apply_filters(select(func.count(Role.id)), query)
return int(session.execute(stmt).scalar_one())
def _apply_filters(stmt: Select, *, keyword: str | None, status: int | None) -> Select:
def _apply_filters(stmt: Select, query: RoleQuery) -> Select:
"""应用通用筛选条件。列表与计数共用,避免两处口径不一致。
关键词匹配走 `contains(autoescape=True)`(转义 LIKE 通配符)——
角色编码普遍含下划线,直接拼接会让 `_` 当单字符通配执行。
"""
if keyword:
if query.keyword:
stmt = stmt.where(
or_(
Role.role_code.contains(keyword, autoescape=True),
Role.role_name.contains(keyword, autoescape=True),
Role.role_code.contains(query.keyword, autoescape=True),
Role.role_name.contains(query.keyword, autoescape=True),
)
)
if status is not None:
stmt = stmt.where(Role.status == status)
if query.status is not None:
stmt = stmt.where(Role.status == query.status)
return stmt
+15 -37
View File
@@ -8,13 +8,13 @@
业务级汇总仍收口 business_service._recalc_totals
"""
from datetime import date
from decimal import Decimal
from sqlalchemy import Select, delete, func, select
from sqlalchemy.orm import Session
from app.models import Business, Receipt, Shipment, ShipmentItem
from app.schemas.shipment import ShipmentQuery
def get_by_id(session: Session, shipment_id: int) -> Shipment | None:
@@ -31,59 +31,37 @@ def get_with_business_no(session: Session, shipment_id: int) -> tuple[Shipment,
).first()
def _apply_filters(
stmt: Select,
*,
business_id: int | None,
ship_date_from: date | None,
ship_date_to: date | None,
) -> Select:
def _apply_filters(stmt: Select, query: ShipmentQuery) -> Select:
"""发货列表的筛选条件(business_id 空=全量,一期按业务维度查看为主)。"""
if business_id is not None:
stmt = stmt.where(Shipment.business_id == business_id)
if ship_date_from is not None:
stmt = stmt.where(Shipment.ship_date >= ship_date_from)
if ship_date_to is not None:
stmt = stmt.where(Shipment.ship_date <= ship_date_to)
if query.business_id is not None:
stmt = stmt.where(Shipment.business_id == query.business_id)
if query.ship_date_from is not None:
stmt = stmt.where(Shipment.ship_date >= query.ship_date_from)
if query.ship_date_to is not None:
stmt = stmt.where(Shipment.ship_date <= query.ship_date_to)
return stmt
def list_shipments(
session: Session,
*,
offset: int,
limit: int,
business_id: int | None,
ship_date_from: date | None,
ship_date_to: date | None,
) -> list[tuple[Shipment, str]]:
def list_shipments(session: Session, query: ShipmentQuery) -> list[tuple[Shipment, str]]:
"""分页查询发货单(附业务号),按 id 倒序。"""
stmt = _apply_filters(
select(Shipment, Business.business_no).join(Business, Business.id == Shipment.business_id),
business_id=business_id,
ship_date_from=ship_date_from,
ship_date_to=ship_date_to,
query,
)
return list(
session.execute(stmt.order_by(Shipment.id.desc()).offset(offset).limit(limit)).all()
session.execute(
stmt.order_by(Shipment.id.desc()).offset(query.offset).limit(query.page_size)
).all()
)
def count_shipments(
session: Session,
*,
business_id: int | None,
ship_date_from: date | None,
ship_date_to: date | None,
) -> int:
def count_shipments(session: Session, query: ShipmentQuery) -> int:
"""发货单总数(筛选条件与列表共用同一 apply_filters,防两侧口径漂移)。"""
stmt = _apply_filters(
select(func.count())
.select_from(Shipment)
.join(Business, Business.id == Shipment.business_id),
business_id=business_id,
ship_date_from=ship_date_from,
ship_date_to=ship_date_to,
query,
)
return int(session.execute(stmt).scalar_one())
+17 -38
View File
@@ -10,6 +10,7 @@ from sqlalchemy.orm import Session, selectinload
from app.models import Role, User, UserRole
from app.repositories.query_utils import fuzzy
from app.schemas.user import UserQuery
def _base_query() -> Select[tuple[User]]:
@@ -48,16 +49,7 @@ def touch_last_login(session: Session, user: User, moment: datetime) -> None:
# ==================== 列表查询 ====================
def list_users(
session: Session,
*,
offset: int,
limit: int,
username: str | None = None,
real_name: str | None = None,
status: int | None = None,
role_code: str | None = None,
) -> list[User]:
def list_users(session: Session, query: UserQuery) -> list[User]:
"""分页查询用户列表。
**按 id 倒序而非 create_time**:create_time 是秒级精度,批量导入的用户
@@ -65,48 +57,35 @@ def list_users(
id 是自增主键,天然唯一且与创建顺序一致。
"""
stmt = _base_query()
stmt = _apply_filters(stmt, username=username, real_name=real_name, status=status)
if role_code:
stmt = _apply_filters(stmt, query)
if query.role_code:
# 按角色筛选需 join 关联表;角色是字典数据,数量极少,join 代价可忽略
stmt = stmt.join(UserRole).join(Role).where(Role.role_code == role_code)
stmt = stmt.order_by(User.id.desc()).offset(offset).limit(limit)
stmt = stmt.join(UserRole).join(Role).where(Role.role_code == query.role_code)
stmt = stmt.order_by(User.id.desc()).offset(query.offset).limit(query.page_size)
return list(session.execute(stmt).scalars().unique())
def count_users(
session: Session,
*,
username: str | None = None,
real_name: str | None = None,
status: int | None = None,
role_code: str | None = None,
) -> int:
def count_users(session: Session, query: UserQuery) -> int:
"""统计符合条件的用户总数,供分页使用。"""
stmt = select(func.count(User.id))
stmt = _apply_filters(stmt, username=username, real_name=real_name, status=status)
if role_code:
stmt = stmt.join(UserRole).join(Role).where(Role.role_code == role_code)
stmt = _apply_filters(stmt, query)
if query.role_code:
stmt = stmt.join(UserRole).join(Role).where(Role.role_code == query.role_code)
return int(session.execute(stmt).scalar_one())
def _apply_filters(
stmt: Select,
*,
username: str | None,
real_name: str | None,
status: int | None,
) -> Select:
def _apply_filters(stmt: Select, query: UserQuery) -> Select:
"""应用通用筛选条件。列表与计数共用,避免两处口径不一致。
模糊匹配统一走 `query_utils.fuzzy`(转义 LIKE 通配符)—— 用户名普遍
含下划线,直接拼接会让 `_` 当单字符通配执行、误配无关账号。
"""
if username:
stmt = fuzzy(stmt, User.username, username)
if real_name:
stmt = fuzzy(stmt, User.real_name, real_name)
if status is not None:
stmt = stmt.where(User.status == status)
if query.username:
stmt = fuzzy(stmt, User.username, query.username)
if query.real_name:
stmt = fuzzy(stmt, User.real_name, query.real_name)
if query.status is not None:
stmt = stmt.where(User.status == query.status)
return stmt
+2 -1
View File
@@ -49,7 +49,8 @@ class ApprovalOrderDetailOut(ApprovalOrderOut):
steps: list[ApprovalStepOut] = Field(default_factory=list)
snapshot: dict[str, Any] | None = Field(
default=None, description="提交时内容快照(信封:schema_version/order_type/captured_at/data)"
default=None,
description="提交时内容快照(信封:schema_version/order_type/captured_at/data)",
)
content_source: Literal["snapshot", "live", "missing"] = Field(
default="missing", description="审批内容来源:提交时快照/当前内容回退/缺失"
+13
View File
@@ -27,6 +27,19 @@ class BusinessRoleOut(BaseModel):
is_builtin: int = 0
class BusinessRoleOptionOut(BaseModel):
"""岗位下拉选项(人员档案与报表筛选共用)。
与 ``BusinessRoleOut`` 分开:下拉只需渲染用的最小字段集,不必顺带
暴露计提模板等管理字段。字段名直出蛇形(前端按同名取值)。
"""
id: int = Field(description="岗位ID")
role_code: str = Field(description="岗位编码")
role_name: str = Field(description="岗位名称")
status: int = Field(description="状态:1启用 0禁用")
class TemplateParamOut(BaseModel):
"""模板声明的参数(供岗位表单展示,含当前口径说明)。"""
+23 -1
View File
@@ -1,6 +1,7 @@
"""客户管理的请求/响应模型(需求 2.1.3)。"""
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field
@@ -28,6 +29,8 @@ class CustomerOut(BaseModel):
owner_personnel_id: int | None = None
owner_personnel_name: str | None = None
remark: str | None = None
rating_score: int | None = None
rating_time: datetime | None = None
class CustomerCreateIn(BaseModel):
@@ -90,13 +93,32 @@ class CustomerUpdateIn(BaseModel):
class CustomerQuery(PageQuery):
"""客户列表查询条件(字段名蛇形直出,与前端 api 封装同名对应)。"""
"""客户列表查询条件(字段名蛇形直出,与前端 api 封装同名对应)。
排序用 ``Literal`` 白名单而不是自由字符串:``order_by`` 直接拼进
``ORDER BY``,自由值等于把注入面开到排序键上;客户列表只有两个
有意义的排序键,白名单足够。
"""
keyword: str | None = Field(
default=None, max_length=64, description="公司名称或客户负责人,模糊匹配"
)
customer_type: str | None = Field(default=None, max_length=32, description="客户分类筛选")
owner_personnel_id: int | None = Field(default=None, ge=1, description="归属人员ID筛选")
order_by: Literal["id", "rating_score"] = Field(
default="id", description="排序键:id 建档序 / rating_score 评级总分"
)
order: Literal["asc", "desc"] = Field(default="desc", description="排序方向")
class CustomerRatingIn(BaseModel):
"""保存客户评级入参(评级计算器「保存评分」)。
score 是五项之和的整数(0-100);五项明细不落库(评级记录只存总分,
见对照表 #25 的定位:评分是档案属性而非审计事件)。
"""
score: int = Field(ge=0, le=100, description="评级总分(0-100 整数)")
class CustomerOwnershipIn(BaseModel):
+42 -5
View File
@@ -1,9 +1,13 @@
"""工作台的请求/响应模型(M4 ⑧)。
四区块一次返回(前端一次请求渲染整页,避免 6-10 次往返):
经营绩效总览 / 待办事项 / 风险预警 / 最近动态。金额一律 Decimal
聚合端点一次返回三区块(前端一次请求渲染整页,避免多次往返):
经营绩效总览 / 待办事项 / 风险预警。金额一律 Decimal
(序列化为字符串),前端不做数值运算。
**最近动态独立成分页端点**:它可能有很长(操作日志全量),塞进聚合
响应既撑爆页面又只能截断前 20 条;拆开后翻页只重取这张列表,不必
重算总览/待办/预警。列表与模块筛选项同源返回(一次请求给全)。
待办与预警为**查询时实时派生**(不落库、不引调度器,见计划决策);
⑥ T+ 同步未建前,同步相关条目用 `sync_available=false` 显式降级,
不返回伪造的 0 条。
@@ -12,7 +16,9 @@
from datetime import date, datetime
from decimal import Decimal
from pydantic import BaseModel, Field
from pydantic import BaseModel, ConfigDict, Field
from app.core.response import PageQuery
class TrendPoint(BaseModel):
@@ -116,11 +122,42 @@ class ActivityItem(BaseModel):
create_time: datetime
class ActivityQuery(PageQuery):
"""最近动态查询条件:分页 + 模块筛选。
只支持模块筛选(工作台只需要这一个维度);其余筛选项见日志查询页。
"""
module: str | None = Field(default=None, max_length=64, description="模块,精确匹配")
class ActivityPageOut(BaseModel):
"""最近动态分页结果:当前页 + 总数 + 模块筛选项。
分页字段名沿用全站规范(list/total/pageNum/pageSize),与
`PageResult` 同形;额外带 `modules`(全量模块名),
供前端模块下拉使用 —— 不随当前页变化,避免翻页后选项增减。
"""
model_config = ConfigDict(populate_by_name=True)
records: list[ActivityItem] = Field(
default_factory=list, alias="list", serialization_alias="list", description="当前页数据"
)
total: int = Field(default=0, description="总记录数")
page_num: int = Field(
default=1, alias="pageNum", serialization_alias="pageNum", description="当前页码"
)
page_size: int = Field(
default=10, alias="pageSize", serialization_alias="pageSize", description="每页条数"
)
modules: list[str] = Field(default_factory=list, description="全部模块名(筛选项)")
class DashboardOut(BaseModel):
"""工作台四区块一次性响应。"""
"""工作台三区块一次性响应(最近动态走独立分页端点)。"""
month: str
overview: OverviewBlock
todos: TodoBlock
warnings: WarningBlock
activities: list[ActivityItem] = Field(default_factory=list)
+3 -1
View File
@@ -264,7 +264,9 @@ class DepartmentReconciliation(BaseModel):
description="聚合额 − 团队分成合计 − 全公司分成合计(分列舍入差)",
)
adjust_net_total: Decimal = Field(description="明细层调整净额合计(成员 + 未分配)")
payable_total: Decimal = Field(description="应发合计 = 成员应付 + 未分配应付 + 聚合行 + 调整净额")
payable_total: Decimal = Field(
description="应发合计 = 成员应付 + 未分配应付 + 聚合行 + 调整净额"
)
team_base_total: Decimal = Field(description="展示团队基数合计")
unassigned_performance_total: Decimal = Field(description="展示未分配人员绩效合计")
other_team_base: Decimal = Field(
+8 -6
View File
@@ -37,12 +37,14 @@ def _record(
"""写登录日志并立即提交。单独提交是刻意为之,见模块 docstring。"""
login_log_repository.add(
session,
user_id=user_id,
username=username,
ip=ip,
user_agent=user_agent,
success=success,
fail_reason=fail_reason,
login_log_repository.LoginLogEntry(
user_id=user_id,
username=username,
ip=ip,
user_agent=user_agent,
success=success,
fail_reason=fail_reason,
),
)
session.commit()
+20 -10
View File
@@ -31,6 +31,7 @@ from app.models import BusinessRole, User
from app.repositories import business_role_repository
from app.schemas.business_role import (
BusinessRoleCreateIn,
BusinessRoleOptionOut,
BusinessRoleOut,
BusinessRoleQuery,
BusinessRoleUpdateIn,
@@ -67,16 +68,8 @@ def _ensure_template(calc_template: str) -> None:
def list_roles(session: Session, query: BusinessRoleQuery) -> PageResult[BusinessRoleOut]:
"""分页查询岗位(含停用)。"""
roles = business_role_repository.list_roles(
session,
offset=query.offset,
limit=query.page_size,
keyword=query.keyword,
status=query.status,
)
total = business_role_repository.count_roles(
session, keyword=query.keyword, status=query.status
)
roles = business_role_repository.list_roles(session, query)
total = business_role_repository.count_roles(session, query)
return PageResult[BusinessRoleOut](
records=[_to_out(role) for role in roles],
total=total,
@@ -85,6 +78,23 @@ def list_roles(session: Session, query: BusinessRoleQuery) -> PageResult[Busines
)
def list_options(session: Session) -> list[BusinessRoleOptionOut]:
"""岗位下拉选项(含停用,不分页)。
含停用是因为人员档案可能仍引用停用岗位(停用只是禁止新增引用,
不改历史归属);岗位规模有限(内置 + 自定义),一次取全量。
"""
return [
BusinessRoleOptionOut(
id=role.id,
role_code=role.role_code,
role_name=role.role_name,
status=role.status,
)
for role in business_role_repository.list_all(session)
]
def get_role(session: Session, role_id: int) -> BusinessRoleOut:
"""岗位详情。
+166 -109
View File
@@ -15,6 +15,8 @@
"""
import logging
from collections.abc import Callable
from dataclasses import dataclass
from datetime import date
from decimal import ROUND_HALF_UP, Decimal
from fractions import Fraction
@@ -234,26 +236,8 @@ def _is_duplicate_entry(exc: IntegrityError) -> bool:
def list_businesses(session: Session, query: BusinessQuery) -> PageResult[BusinessOut]:
"""分页查询业务(附客户名与归属人名)。"""
rows = business_repository.list_businesses(
session,
offset=query.offset,
limit=query.page_size,
keyword=query.keyword,
status=query.status,
customer_id=query.customer_id,
owner_personnel_id=query.owner_personnel_id,
date_from=query.date_from,
date_to=query.date_to,
)
total = business_repository.count_businesses(
session,
keyword=query.keyword,
status=query.status,
customer_id=query.customer_id,
owner_personnel_id=query.owner_personnel_id,
date_from=query.date_from,
date_to=query.date_to,
)
rows = business_repository.list_businesses(session, query)
total = business_repository.count_businesses(session, query)
return PageResult[BusinessOut](
records=[_to_out(row) for row in rows],
total=total,
@@ -748,6 +732,67 @@ def change_status(
return out
@dataclass(frozen=True, slots=True)
class _BusinessOwnershipResolution:
"""业务归属变更的解析结果(新归属人行 + 变更前固化 ID)。"""
owner: Personnel | None
assistant: Personnel | None
old_owner_id: int | None
old_assistant_id: int | None
def _resolve_business_ownership(
session: Session, business: Business, payload: BusinessOwnershipIn
) -> _BusinessOwnershipResolution:
"""校验新归属人(须在职)并判定归属是否真的变化。
校验在审计上下文内执行 —— 失败也留痕。两者均未变化 → 400「归属未变化」。
"""
# 归属变更的出参姓名来自校验函数的返回行(锁定读):首查姓名是变更前
# 固化标量,新归属人名必须按新 ID 重取
owner = (
_ensure_personnel_active(session, payload.new_owner_personnel_id, "负责")
if payload.new_owner_personnel_id is not None
else None
)
assistant = (
_ensure_personnel_active(session, payload.new_assistant_personnel_id, "协助")
if payload.new_assistant_personnel_id is not None
else None
)
owner_changed = business.owner_personnel_id != payload.new_owner_personnel_id
assistant_changed = business.assistant_personnel_id != payload.new_assistant_personnel_id
if not owner_changed and not assistant_changed:
raise BadRequestError("归属未变化")
return _BusinessOwnershipResolution(
owner=owner,
assistant=assistant,
old_owner_id=business.owner_personnel_id,
old_assistant_id=business.assistant_personnel_id,
)
def _business_ownership_log(
business: Business,
payload: BusinessOwnershipIn,
resolution: _BusinessOwnershipResolution,
operator: User,
) -> OwnershipChangeLog:
"""归属变更留痕行(负责/协助两个字段的前后值都记)。"""
return OwnershipChangeLog(
owner_type=OWNER_TYPE_BUSINESS,
business_id=business.id,
old_owner_personnel_id=resolution.old_owner_id,
new_owner_personnel_id=payload.new_owner_personnel_id,
old_assistant_personnel_id=resolution.old_assistant_id,
new_assistant_personnel_id=payload.new_assistant_personnel_id,
change_reason=payload.reason,
operator_id=operator.id,
operator_name=operator.username,
)
def change_ownership(
session: Session,
business_id: int,
@@ -779,41 +824,12 @@ def change_ownership(
if row is None:
raise NotFoundError("业务不存在")
business, _, old_owner_name, old_assistant_name = row
# 归属变更的出参姓名来自校验函数的返回行(锁定读):首查姓名是变更前
# 固化标量,新归属人名必须按新 ID 重取
new_owner = (
_ensure_personnel_active(session, payload.new_owner_personnel_id, "负责")
if payload.new_owner_personnel_id is not None
else None
)
new_assistant = (
_ensure_personnel_active(session, payload.new_assistant_personnel_id, "协助")
if payload.new_assistant_personnel_id is not None
else None
)
resolution = _resolve_business_ownership(session, business, payload)
owner_changed = business.owner_personnel_id != payload.new_owner_personnel_id
assistant_changed = business.assistant_personnel_id != payload.new_assistant_personnel_id
if not owner_changed and not assistant_changed:
raise BadRequestError("归属未变化")
old_owner_id = business.owner_personnel_id
old_assistant_id = business.assistant_personnel_id
business.owner_personnel_id = payload.new_owner_personnel_id
business.assistant_personnel_id = payload.new_assistant_personnel_id
business_repository.add_ownership_log(
session,
OwnershipChangeLog(
owner_type=OWNER_TYPE_BUSINESS,
business_id=business_id,
old_owner_personnel_id=old_owner_id,
new_owner_personnel_id=payload.new_owner_personnel_id,
old_assistant_personnel_id=old_assistant_id,
new_assistant_personnel_id=payload.new_assistant_personnel_id,
change_reason=payload.reason,
operator_id=operator.id,
operator_name=operator.username,
),
session, _business_ownership_log(business, payload, resolution, operator)
)
# 出参组装:ID 在锁定行上已赋值(_to_out 读内存值即反映新值),姓名
# 一侧「变更项用新 ID 的人名、未变项沿用首查名」——首查名是变更前快照
@@ -821,17 +837,19 @@ def change_ownership(
(
business,
row[1],
new_owner.personnel_name if new_owner is not None else old_owner_name,
new_assistant.personnel_name if new_assistant is not None else old_assistant_name,
resolution.owner.personnel_name if resolution.owner is not None else old_owner_name,
resolution.assistant.personnel_name
if resolution.assistant is not None
else old_assistant_name,
)
)
session.commit()
logger.info(
"调整业务归属 | business_id=%s | owner %s->%s | assistant %s->%s",
business_id,
old_owner_id,
resolution.old_owner_id,
payload.new_owner_personnel_id,
old_assistant_id,
resolution.old_assistant_id,
payload.new_assistant_personnel_id,
)
return out
@@ -897,6 +915,94 @@ def list_status_logs(
)
@dataclass(frozen=True, slots=True)
class _GrossProfitScan:
"""毛利润逐行扫描结果(行 DTO、类别聚合桶、成本缺失计数)。"""
rows: list[GrossProfitRowOut]
category_agg: dict[str, list[Decimal]]
missing_count: int
def _gross_profit_row(
item: BusinessItem, threshold: Fraction
) -> tuple[GrossProfitRowOut, tuple[Decimal, Decimal] | None]:
"""单行毛利润派生值(行 DTO + 类别聚合桶)。
聚合桶为 None 表示成本缺失行 —— 这类行不计入类别汇总,但仍要输出
派生列为 None 的行与缺失原因。阈值对两类行都要取(成本缺失也展示阈值)。
"""
row_kwargs: dict = {
"item_id": item.id,
"product_category": item.product_category,
"product_name": item.product_name,
"spec_model": item.spec_model,
"quantity": str(item.quantity),
"sale_amount": str(item.sale_amount),
"threshold": str(threshold),
}
if item.cost_amount is None:
row_kwargs.update(
cost_amount=None,
profit_amount=None,
profit_rate=None,
converted_profit=None,
missing_reason="成本缺失,无法计算毛利润",
)
return GrossProfitRowOut(**row_kwargs), None
profit = item.sale_amount - item.cost_amount
rate = profit_rate(profit, item.sale_amount)
conv = converted_profit(item.sale_amount, rate, threshold)
row_kwargs.update(
cost_amount=str(item.cost_amount),
profit_amount=str(profit),
profit_rate=str(rate),
converted_profit=str(conv),
missing_reason=None,
)
return GrossProfitRowOut(**row_kwargs), (item.sale_amount, item.cost_amount)
def _scan_gross_profit_rows(
session: Session, business_id: int, threshold_of: Callable[[str], Fraction]
) -> _GrossProfitScan:
"""逐行扫描业务明细:出行 DTO、类别聚合桶与成本缺失计数。
类别顺序沿用明细行的首次出现顺序(不排序)—— 前端汇总展示依赖该顺序。
"""
rows: list[GrossProfitRowOut] = []
agg: dict[str, list[Decimal]] = {}
missing_count = 0
for item in business_repository.list_items(session, business_id):
row, bucket = _gross_profit_row(item, threshold_of(item.product_category))
rows.append(row)
if bucket is None:
missing_count += 1
continue
current = agg.setdefault(item.product_category, [Decimal("0"), Decimal("0")])
current[0] += bucket[0]
current[1] += bucket[1]
return _GrossProfitScan(rows=rows, category_agg=agg, missing_count=missing_count)
def _gross_profit_category(
category: str, sale: Decimal, cost: Decimal, threshold: Fraction
) -> GrossProfitCategoryOut:
"""类别汇总行(派生口径与行级一致:利润率/折算复用 formula 纯函数)。"""
profit = sale - cost
rate = profit_rate(profit, sale)
return GrossProfitCategoryOut(
product_category=category,
sale_amount=str(sale),
cost_amount=str(cost),
profit_amount=str(profit),
profit_rate=str(rate),
threshold=str(threshold),
converted_profit=str(converted_profit(sale, rate, threshold)),
)
def gross_profit(session: Session, business_id: int, as_of: date | None) -> GrossProfitOut:
"""毛利润数据视图(需求 2.2.4):行级派生值 + 类别汇总,实时计算不落库。
@@ -915,64 +1021,15 @@ def gross_profit(session: Session, business_id: int, as_of: date | None) -> Gros
def threshold_of(category: str) -> Fraction:
return snapshot.fraction("product.threshold", product_category=category)
rows_out: list[GrossProfitRowOut] = []
missing_count = 0
# 类别聚合容器:{类别: [sale, cost]},成本缺失行不计入该类别聚合
agg: dict[str, list[Decimal]] = {}
for item in business_repository.list_items(session, business_id):
threshold = threshold_of(item.product_category)
row_kwargs: dict = {
"item_id": item.id,
"product_category": item.product_category,
"product_name": item.product_name,
"spec_model": item.spec_model,
"quantity": str(item.quantity),
"sale_amount": str(item.sale_amount),
"threshold": str(threshold),
}
if item.cost_amount is None:
missing_count += 1
row_kwargs.update(
cost_amount=None,
profit_amount=None,
profit_rate=None,
converted_profit=None,
missing_reason="成本缺失,无法计算毛利润",
)
else:
profit = item.sale_amount - item.cost_amount
rate = profit_rate(profit, item.sale_amount)
conv = converted_profit(item.sale_amount, rate, threshold)
row_kwargs.update(
cost_amount=str(item.cost_amount),
profit_amount=str(profit),
profit_rate=str(rate),
converted_profit=str(conv),
missing_reason=None,
)
bucket = agg.setdefault(item.product_category, [Decimal("0"), Decimal("0")])
bucket[0] += item.sale_amount
bucket[1] += item.cost_amount
rows_out.append(GrossProfitRowOut(**row_kwargs))
scan = _scan_gross_profit_rows(session, business_id, threshold_of)
categories = [
GrossProfitCategoryOut(
product_category=category,
sale_amount=str(sale),
cost_amount=str(cost),
profit_amount=str(sale - cost),
profit_rate=str(profit_rate(sale - cost, sale)),
threshold=str(threshold_of(category)),
converted_profit=str(
converted_profit(sale, profit_rate(sale - cost, sale), threshold_of(category))
),
)
for category, [sale, cost] in agg.items()
_gross_profit_category(category, sale, cost, threshold_of(category))
for category, [sale, cost] in scan.category_agg.items()
]
return GrossProfitOut(
business_id=business_id,
as_of=as_of,
rows=rows_out,
rows=scan.rows,
categories=categories,
missing_count=missing_count,
missing_count=scan.missing_count,
)
+14 -7
View File
@@ -60,7 +60,7 @@ from app.services.param_service import parse_fraction, resolve_snapshot
logger = logging.getLogger(__name__)
# snapshot_json 里的策略点注记(口径检索锚点;#12 已参数化,开关 0 时留此注记)
_NOTE_MANAGER_PENDING = "经理个人户单笔不计提(基数开关=0):聚合口径在月结时由团队聚合计算"
_NOTE_MANAGER_PENDING = "经理个人户单笔不计提(未计入分成基数):绩效在月结时按团队与全公司分成计算"
@dataclass(frozen=True, slots=True)
@@ -544,7 +544,7 @@ def _personal_rate(
if customer_type == "enterprise":
# 存储口径 ‰ → 纯比例(漏换算放大 1000 倍,与预览同防线)
rate = parse_fraction(snapshot.get("enterprise.rate")) / 1000
return _RateDecision(rate, "企业户固定计提比例", None, False)
return _RateDecision(rate, "企业户:不按回款天数,统一按固定比例计提", None, False)
template = role.calc_template if role else "TPL_SALESPERSON_DECAY"
if template == "TPL_MANAGER_SHARE":
@@ -554,7 +554,7 @@ def _personal_rate(
if snapshot.get("manager.personal.included") != "1":
return _RateDecision(
Decimal("0"),
"经理个人户:12.5%+5% 聚合计提(月结时计算),单笔落 0",
"经理个人户:绩效按团队与全公司分成在月结时计算,单笔不计提",
None,
True,
)
@@ -563,12 +563,12 @@ def _personal_rate(
floor = parse_fraction(snapshot.get("salesperson.rate.floor"))
boundary = int(parse_fraction(snapshot.get("salesperson.days.boundary")))
rate = salesperson_rate(days, start=start, slope=slope, floor=floor, boundary_days=boundary)
note = f"经理个人户(基数开关=1):按业务员递减 {start}‰ 起,{days} 天"
note = f"经理个人户(已开启计入分成基数):按业务员递减,回款 {days} 天,起始 {start}‰"
return _RateDecision(rate, note, None, False)
if template == "TPL_INFO_SALES_FIXED":
# 信息员基数=本人销售额(覆盖折算与阈值),‰ 换算同防线
rate = parse_fraction(snapshot.get("info.rate")) / 1000
return _RateDecision(rate, "信息员:本人销售额 × 计提比例", None, False)
return _RateDecision(rate, "信息员:按本人销售额乘计提比例,不随回款天数递减", None, False)
if template == "TPL_SALESPERSON_DECAY":
start = parse_fraction(snapshot.get("salesperson.rate.start"))
slope = parse_fraction(snapshot.get("salesperson.rate.slope"))
@@ -577,9 +577,16 @@ def _personal_rate(
# 经 Fraction 转换再取整(INT 型参数入库时已校验分母为 1,取整无损)
boundary = int(parse_fraction(snapshot.get("salesperson.days.boundary")))
rate = salesperson_rate(days, start=start, slope=slope, floor=floor, boundary_days=boundary)
note = f"业务员递减:{start}‰ 起,{days} 天(分界 {boundary} 天,超界取 {floor}‰)"
note = (
f"回款 {days} 天,从起始 {start}‰ 按日递减"
f"(满 {boundary} 天后固定按 {floor}‰ 计算)"
)
return _RateDecision(rate, note, None, False)
raise BadRequestError(f"公式模板 {template} 暂不支持核算")
# 提示给模板中文名而非编码:业务人员在岗位角色页看到的是「营销经理·团队与全公司分成」
from app.services.calc_templates import TEMPLATES
tpl_name = TEMPLATES[template].name if template in TEMPLATES else template
raise BadRequestError(f"「{tpl_name}」不支持单笔核算,请检查该人员的岗位设置")
def _role_of(session: Session, person):
+129 -47
View File
@@ -14,6 +14,8 @@
"""
import logging
from dataclasses import dataclass
from datetime import datetime
from sqlalchemy import Row
from sqlalchemy.exc import IntegrityError
@@ -37,6 +39,7 @@ from app.schemas.customer import (
CustomerOut,
CustomerOwnershipIn,
CustomerQuery,
CustomerRatingIn,
CustomerUpdateIn,
OwnershipChangeLogOut,
)
@@ -60,6 +63,8 @@ def _to_out(customer: Customer, owner_name: str | None) -> CustomerOut:
owner_personnel_id=customer.owner_personnel_id,
owner_personnel_name=owner_name,
remark=customer.remark,
rating_score=customer.rating_score,
rating_time=customer.rating_time,
)
@@ -107,20 +112,8 @@ def _is_duplicate_entry(exc: IntegrityError) -> bool:
def list_customers(session: Session, query: CustomerQuery) -> PageResult[CustomerOut]:
"""分页查询客户(附归属人员姓名)。"""
rows = customer_repository.list_customers(
session,
offset=query.offset,
limit=query.page_size,
keyword=query.keyword,
customer_type=query.customer_type,
owner_personnel_id=query.owner_personnel_id,
)
total = customer_repository.count_customers(
session,
keyword=query.keyword,
customer_type=query.customer_type,
owner_personnel_id=query.owner_personnel_id,
)
rows = customer_repository.list_customers(session, query)
total = customer_repository.count_customers(session, query)
return PageResult[CustomerOut](
records=[_to_out(customer, owner_name) for customer, owner_name in rows],
total=total,
@@ -274,6 +267,53 @@ def update_customer(
return out
def rate_customer(
session: Session,
customer_id: int,
payload: CustomerRatingIn,
*,
operator: User,
meta: RequestMeta | None = None,
) -> CustomerOut:
"""保存客户评级(评级计算器「保存评分」)。
评分是客户档案属性而非审计事件:只存总分与评级时间,五项明细与所用
等级配置不落库(对照表 #25 的定位口径)——等级映射是可变判定表,回溯
「当时命中的等级」需要快照设计,超出档案属性的范围;评级时间已足够
回答「这个分是什么时候打的」。等级由前端按当前映射实时算并展示。
:raises NotFoundError: 客户不存在
"""
# 客户名进审计摘要,预取放在审计上下文之前(同 change_ownership 口径)
row = customer_repository.get_with_owner_name(session, customer_id)
customer_name = row[0].company_name if row else f"#{customer_id}"
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_CUSTOMER,
action=ACTION_UPDATE,
summary=f"保存客户评级 {customer_name}:{payload.score} 分",
request_data=payload.model_dump(),
),
meta=meta,
):
if row is None:
raise NotFoundError("客户不存在")
customer, owner_name = row
customer.rating_score = payload.score
# 评级时间取应用层本地时间(库列 TZ=Asia/Shanghai,与 TimestampMixin
# 的服务器默认值同一时区口径),不用数据库 NOW():行已在本会话
customer.rating_time = datetime.now()
# 响应在 commit 之前构造(同 user_service 顶部说明)
out = _to_out(customer, owner_name)
session.commit()
logger.info("保存客户评级 | customer_id=%s | score=%s", customer_id, payload.score)
return out
def delete_customer(
session: Session,
customer_id: int,
@@ -317,6 +357,71 @@ def delete_customer(
logger.info("删除客户 | customer_id=%s | company=%s", customer_id, company_name)
@dataclass(frozen=True, slots=True)
class _CustomerOwnershipPrefetch:
"""客户归属变更的审计前只读预取(摘要要客户名与新旧归属人名)。"""
row: Row | None
new_person: Personnel | None
old_label: str
new_label: str
customer_name: str
def _prefetch_customer_ownership(
session: Session, customer_id: int, payload: CustomerOwnershipIn
) -> _CustomerOwnershipPrefetch:
"""审计上下文之前的只读预取(预取不写库,失败路径也要有可读摘要)。"""
row = customer_repository.get_with_owner_name(session, customer_id)
old_person = (
personnel_repository.get_by_id(session, row[0].owner_personnel_id)
if row is not None and row[0].owner_personnel_id is not None
else None
)
new_person = (
personnel_repository.get_by_id(session, payload.new_owner_personnel_id)
if payload.new_owner_personnel_id is not None
else None
)
return _CustomerOwnershipPrefetch(
row=row,
new_person=new_person,
old_label=old_person.personnel_name if old_person else "无",
new_label=new_person.personnel_name if new_person else "无",
customer_name=row[0].company_name if row else f"#{customer_id}",
)
def _validate_customer_ownership(
session: Session, customer: Customer, payload: CustomerOwnershipIn
) -> int | None:
"""校验新归属人(须在职)并判定归属是否变化,返回变更前归属人 ID。"""
if payload.new_owner_personnel_id is not None:
_ensure_owner_personnel(session, payload.new_owner_personnel_id)
old_owner_id = customer.owner_personnel_id
if old_owner_id == payload.new_owner_personnel_id:
raise BadRequestError("归属未变化")
return old_owner_id
def _customer_ownership_log(
customer: Customer,
payload: CustomerOwnershipIn,
old_owner_id: int | None,
operator: User,
) -> OwnershipChangeLog:
"""客户归属变更留痕行。"""
return OwnershipChangeLog(
owner_type=OWNER_TYPE_CUSTOMER,
customer_id=customer.id,
old_owner_personnel_id=old_owner_id,
new_owner_personnel_id=payload.new_owner_personnel_id,
change_reason=payload.reason,
operator_id=operator.id,
operator_name=operator.username,
)
def change_ownership(
session: Session,
customer_id: int,
@@ -335,57 +440,34 @@ def change_ownership(
"""
# 只读预取在审计上下文之前:摘要需要客户名与新旧归属人名;校验仍在
# 上下文内进行,失败同样要留下可追溯的记录
row = customer_repository.get_with_owner_name(session, customer_id)
old_person = (
personnel_repository.get_by_id(session, row[0].owner_personnel_id)
if row is not None and row[0].owner_personnel_id is not None
else None
)
new_person = (
personnel_repository.get_by_id(session, payload.new_owner_personnel_id)
if payload.new_owner_personnel_id is not None
else None
)
old_label = old_person.personnel_name if old_person else "无"
new_label = new_person.personnel_name if new_person else "无"
customer_name = row[0].company_name if row else f"#{customer_id}"
prefetch = _prefetch_customer_ownership(session, customer_id, payload)
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_CUSTOMER,
action=ACTION_UPDATE,
summary=f"调整客户归属 {customer_name}:{old_label} → {new_label}",
summary=(
f"调整客户归属 {prefetch.customer_name}:"
f"{prefetch.old_label} → {prefetch.new_label}"
),
request_data=payload.model_dump(exclude_none=True),
),
meta=meta,
):
if row is None:
if prefetch.row is None:
raise NotFoundError("客户不存在")
customer, _ = row
customer, _ = prefetch.row
if payload.new_owner_personnel_id is not None:
_ensure_owner_personnel(session, payload.new_owner_personnel_id)
old_owner_id = customer.owner_personnel_id
if old_owner_id == payload.new_owner_personnel_id:
raise BadRequestError("归属未变化")
old_owner_id = _validate_customer_ownership(session, customer, payload)
# 主表更新与留痕插入同事务:只在本方法内一次 commit(见模块 docstring)
customer.owner_personnel_id = payload.new_owner_personnel_id
customer_repository.add_ownership_log(
session,
OwnershipChangeLog(
owner_type=OWNER_TYPE_CUSTOMER,
customer_id=customer.id,
old_owner_personnel_id=old_owner_id,
new_owner_personnel_id=payload.new_owner_personnel_id,
change_reason=payload.reason,
operator_id=operator.id,
operator_name=operator.username,
),
session, _customer_ownership_log(customer, payload, old_owner_id, operator)
)
# 响应在 commit 之前构造(同 user_service 顶部说明);归属人名以新值为准
out = _to_out(customer, new_person.personnel_name if new_person else None)
out = _to_out(customer, prefetch.new_person.personnel_name if prefetch.new_person else None)
session.commit()
logger.info(
"调整客户归属 | customer_id=%s | old=%s | new=%s",
+39 -27
View File
@@ -1,4 +1,4 @@
"""工作台服务(M4 ⑧):四区块实时聚合编排。
"""工作台服务(M4 ⑧):区块实时聚合编排 + 最近动态分页。
设计决策(2026-10-19):
- **只读**:不落库、不写审计(读操作不入操作日志);总览只做 SQL 聚合,
@@ -9,6 +9,8 @@
- **⑥ 同步模块未启用**:同步失败待办、同步连续失败预警以
``sync_available=false`` 显式降级,不返回伪造的 0 条
- 预警阈值走参数体系(``warning.*``),一次请求只解析一次快照
- **最近动态独立分页**(2026-10-23):操作日志可能很长,聚合响应里
只能塞前 20 条;拆成独立端点后翻页只重取这张列表
"""
from datetime import date, datetime, timedelta
@@ -20,22 +22,23 @@ from app.core.exceptions import BadRequestError
from app.core.month import validate_month
from app.repositories import dashboard_repository, operation_log_repository, user_repository
from app.repositories.query_utils import LogFilters
from app.schemas.dashboard import ActivityItem, ActivityPageOut, ActivityQuery
from app.services import approval_service
# 工作台只展示摘要,完整列表跳转对应管理页
_TODO_LIMIT = 5
_WARNING_LIMIT = 20
_ACTIVITY_LIMIT = 20
_TREND_MONTHS = 6
_ORDER_TYPE_LABELS = {1: "二次分配方案", 2: "月度结算", 3: "调整单"}
def get_dashboard(session: Session, month: str | None = None, *, current_user) -> dict:
"""四区块一次性出数(前端一次请求渲染整页)。
"""三区块一次性出数(前端一次请求渲染整页)。
待办按当前用户角色只展示其可操作节点(admin 可查看全部在途单);
风险预警仍是全局经营视角,不按人裁剪。
最近动态不在此返回 —— 它是分页列表,走 ``list_activities``。
"""
resolved = _normalize_month(month)
warning_params = _warning_params(session)
@@ -47,7 +50,6 @@ def get_dashboard(session: Session, month: str | None = None, *, current_user) -
"overview": _overview(session, resolved),
"todos": _todos(session, pending_statuses),
"warnings": _warnings(session, warning_params),
"activities": _activities(session),
}
@@ -112,8 +114,8 @@ def _overview(session: Session, month: str) -> dict:
for item in months
],
"note": (
"绩效按台账归属月(单笔生效行,二次分摊拆行求和不失真);回款按到账月"
"(含未核算回款);不含经理 12.5%+5% 聚合分成与调整单"
"绩效按台账归属月统计(二次分配按参与人拆分后求和,不重复不计漏);"
"回款按到账月统计(含尚未核算的回款);不含经理团队分成与调整单"
),
}
@@ -168,9 +170,7 @@ def _warning_params(session: Session) -> dict:
return {
"enabled": snapshot.get("warning.enabled") == "1",
"receipt_overdue_days": int(parse_fraction(snapshot.get("warning.receipt.overdue.days"))),
"approval_timeout_days": int(
parse_fraction(snapshot.get("warning.approval.timeout.days"))
),
"approval_timeout_days": int(parse_fraction(snapshot.get("warning.approval.timeout.days"))),
}
@@ -249,26 +249,38 @@ def _warnings(session: Session, params: dict) -> dict:
}
def _activities(session: Session) -> list[dict]:
"""最近动态(FR-036):以操作日志为唯一来源(写操作即留痕)。"""
logs = operation_log_repository.list_logs(
session,
offset=0,
limit=_ACTIVITY_LIMIT,
def list_activities(session: Session, query: ActivityQuery) -> ActivityPageOut:
"""最近动态分页(FR-036):以操作日志为唯一来源(写操作即留痕)。
模块筛选项与列表同源返回(全量模块名,不随当前页变化),
前端模块下拉不必另取一次全量。
"""
repo_query = operation_log_repository.OperationLogListQuery(
filters=LogFilters(),
offset=query.offset,
limit=query.page_size,
module=query.module,
)
logs = operation_log_repository.list_logs(session, repo_query)
total = operation_log_repository.count_logs(session, repo_query)
return ActivityPageOut(
records=[
ActivityItem(
id=log.id,
module=log.module,
action=log.action,
summary=log.summary,
username=log.username,
success=log.success,
create_time=log.create_time,
)
for log in logs
],
total=total,
page_num=query.page_num,
page_size=query.page_size,
modules=operation_log_repository.distinct_modules(session),
)
return [
{
"id": log.id,
"module": log.module,
"action": log.action,
"summary": log.summary,
"username": log.username,
"success": log.success,
"create_time": log.create_time,
}
for log in logs
]
def _dec(value) -> Decimal | None:
+8 -12
View File
@@ -22,18 +22,15 @@ from app.schemas.log import (
def list_operation_logs(session: Session, query: OperationLogQuery) -> PageResult[OperationLogOut]:
"""分页查询操作日志(列表不含 request_data,详情走 `get_operation_log`)。"""
filters = _filters_from(query)
logs = operation_log_repository.list_logs(
session,
repo_query = operation_log_repository.OperationLogListQuery(
filters=_filters_from(query),
offset=query.offset,
limit=query.page_size,
filters=filters,
module=query.module,
action=query.action,
)
total = operation_log_repository.count_logs(
session, filters=filters, module=query.module, action=query.action
)
logs = operation_log_repository.list_logs(session, repo_query)
total = operation_log_repository.count_logs(session, repo_query)
return PageResult[OperationLogOut](
records=[OperationLogOut.model_validate(log) for log in logs],
total=total,
@@ -55,15 +52,14 @@ def get_operation_log(session: Session, log_id: int) -> OperationLogDetailOut:
def list_login_logs(session: Session, query: LoginLogQuery) -> PageResult[LoginLogOut]:
"""分页查询登录日志。"""
filters = _filters_from(query)
logs = login_log_repository.list_logs(
session,
repo_query = login_log_repository.LoginLogListQuery(
filters=_filters_from(query),
offset=query.offset,
limit=query.page_size,
filters=filters,
ip=query.ip,
)
total = login_log_repository.count_logs(session, filters=filters, ip=query.ip)
logs = login_log_repository.list_logs(session, repo_query)
total = login_log_repository.count_logs(session, repo_query)
return PageResult[LoginLogOut](
records=[LoginLogOut.model_validate(log) for log in logs],
total=total,
+26 -42
View File
@@ -3,6 +3,10 @@
注册表是**迁移种子与运行期校验的唯一来源**:编码、默认值、取值域在此定义
一次;新增参数 = 改这里 + 生成迁移种子(两者一致性由测试锁定)。
值一律以 Fraction 可解析字符串表示(如 "4/360",禁 0.011111 —— 见算法红线)。
`description` 会直接显示在「计提参数」页的参数名下方,**面向业务人员撰写**:
说清这个参数管什么、钱怎么变、什么时候该调它。不写英文枚举名、需求编号、
内部阶段号,也不写实现层面的精度约束(那些留在代码注释与测试里)。
"""
from dataclasses import dataclass
@@ -43,7 +47,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="10",
value_min="0",
value_max=None,
description="线性递减段起始值:比例 = 起始 − 回款天数 × 斜率(≤分界天数)",
description="回款当天能拿到的最高计提比例。回款每晚一天,按日递减斜率往下走,直到分界天数为止。",
sort_order=1,
),
ParamSpec(
@@ -57,7 +61,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="4/360",
value_min="0",
value_max=None,
description="精确分数 4/360‰/天(=1/90),禁止按 0.011111 截断存储",
description="回款每延后一天,计提比例下降多少。按分数填写(如 4/360)可保持精确,填小数会引入四舍五入误差。",
sort_order=2,
),
ParamSpec(
@@ -71,7 +75,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="4",
value_min="0",
value_max=None,
description="回款天数超过分界天数后固定取此比例(≥361 天 = 4‰)",
description="回款天数超过分界天数后,计提比例不再下降,固定按这个值计算,即最低计提比例。",
sort_order=3,
),
ParamSpec(
@@ -85,7 +89,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="360",
value_min="0",
value_max=None,
description="≤分界天数按公式线性递减(360 天=6‰);超过则取下限",
description="回款天数在这个范围内按日递减;超过这个天数就固定按下限计提。默认 360 天。",
sort_order=4,
),
ParamSpec(
@@ -99,7 +103,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="12.5",
value_min="0",
value_max="100",
description="∑本人带领业务员绩效 × 此比例(限本团队)",
description="经理按本团队业务员的绩效总额提取的分成比例,只统计自己带的业务员。",
sort_order=5,
),
ParamSpec(
@@ -113,7 +117,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="5",
value_min="0",
value_max="100",
description="∑全部业务员绩效 × 此比例(全公司)",
description="经理按全公司业务员的绩效总额提取的分成比例,与本团队分成叠加计算。",
sort_order=6,
),
ParamSpec(
@@ -127,7 +131,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="3",
value_min="0",
value_max=None,
description="所有企业户适用;2026-08-13 起由 2‰ 提高至 3‰",
description="企业户客户的回款统一按这个比例计提,不看回款天数。2026-08-13 起由 2‰ 调整为 3‰。",
sort_order=7,
),
ParamSpec(
@@ -141,7 +145,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="1",
value_min="0",
value_max=None,
description="信息员绩效 = 本人销售额 × 此比例",
description="信息员的绩效按本人销售额乘这个比例计算,不随回款天数递减。",
sort_order=8,
),
ParamSpec(
@@ -155,7 +159,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="10",
value_min="0",
value_max=None,
description="预付款统一按此比例计提",
description="预付款到账即按这个比例计提绩效,不必等到全额回款。",
sort_order=9,
),
ParamSpec(
@@ -169,7 +173,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="80",
value_min="0",
value_max="100",
description="以物抵顶回款绩效按应发绩效的此比例折算",
description="以物抵顶的回款,按应发绩效的这个比例折算,即打折后的金额参与绩效。",
sort_order=10,
),
ParamSpec(
@@ -183,7 +187,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="60",
value_min="0",
value_max="100",
description="单项业务回款额达销售额(不含运费)此比例后起付;达标前累计",
description="单项业务的回款累计达到销售额(不含运费)的这个比例后才开始发放绩效;达标之前只累计、不发放。",
sort_order=11,
),
ParamSpec(
@@ -197,7 +201,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="FULL",
value_min=None,
value_max=None,
description="FULL=按 R×R 乘当月全额应发绩效;BALANCE=乘余额(语义待 M3 前确认)",
description="选择支付权重乘在哪个基数上:当月全额 = 乘本月全部应发绩效;余额 = 只乘尚未发放的部分。",
sort_order=12,
),
ParamSpec(
@@ -211,7 +215,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="30",
value_min="0",
value_max="100",
description="折算毛利润额 = 销售额 ×(毛利润率 − 阈值);市政水利 30%、轨交 20%",
description="折算毛利润额 = 销售额 ×(毛利润率 − 阈值),低于阈值的部分不参与绩效。市政水利默认 30%,轨道交通默认 20%。",
sort_order=13,
),
ParamSpec(
@@ -225,7 +229,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="INCLUDE",
value_min=None,
value_max=None,
description="INCLUDE含基本工资 / DEDUCT扣除基本工资 / EXCLUDE不计",
description="算月工资时怎么处理基本工资:含基本工资 = 与绩效一起发;扣除基本工资 = 从绩效里扣掉已发的基本工资;不计 = 月工资只发绩效。",
sort_order=14,
),
ParamSpec(
@@ -239,7 +243,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="18000",
value_min="0",
value_max=None,
description="试用期年累计绩效超过此值部分年底一次性结算",
description="试用期绩效先不发放;年内累计超过这个金额的部分,年底一次性结算。",
sort_order=15,
),
ParamSpec(
@@ -253,11 +257,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="1",
value_min=None,
value_max=None,
description=(
"0=二次分配方案提交即生效(系统自动通过全节点留痕,留痕不断链);"
"1=提交后进入财务复核→领导审核审批流。"
"默认开启(澄清 #5 业务答复 2026-10-19),业务可关闭"
),
description="二次分配方案提交后是否要审批:开启 = 先经财务复核、再经领导审核,通过后才生效;关闭 = 提交即生效,系统自动记录各节点留痕。",
sort_order=17,
),
ParamSpec(
@@ -271,11 +271,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="1",
value_min=None,
value_max=None,
description=(
"月度结算审批链开关(④ 月结提交时消费):"
"0=结算生成即生效,1=走财务复核→领导审核审批流。"
"默认开启(澄清 #7 业务答复 2026-10-19,月结涉及工资发放从严),业务可关闭"
),
description="月度结算提交后是否要审批:开启 = 先经财务复核、再经领导审核;关闭 = 生成即生效。月结涉及工资发放,默认开启。",
sort_order=18,
),
ParamSpec(
@@ -289,11 +285,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="0",
value_min=None,
value_max=None,
description=(
"0=调整单提交即生效(系统自动通过留痕);1=走财务复核→领导审核审批流。"
"调整单是已锁定月份补发/扣减的唯一通道(澄清 #9:走同一审批链,"
"默认值业务未明确,暂按直接生效,业务可开启)"
),
description="调整单提交后是否要审批:开启 = 先经财务复核、再经领导审核;关闭 = 提交即生效。调整单是已锁定月份补发或扣减的唯一通道。",
sort_order=19,
),
ParamSpec(
@@ -307,11 +299,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="0",
value_min=None,
value_max=None,
description=(
"经理 12.5%+5% 聚合时是否计入经理本人名下经理个人户业绩(澄清 #12 "
"业务答复 2026-10-19:作为参数配置、手动调整是否纳入):"
"0=不计入(默认,暂口径) 1=计入"
),
description="经理提取团队分成时,是否把经理本人名下的业绩(经理个人户)也算进分成基数。默认不计入。",
sort_order=20,
),
# ===== 预警规则(M4 ⑧ 工作台,澄清 #10「做成参数化规则便于调整」)=====
@@ -327,7 +315,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="1",
value_min=None,
value_max=None,
description="工作台风险预警区块总开关:0=关闭(不扫描不展示) 1=开启(默认)",
description="工作台风险预警区块的总开关:关闭后不再扫描、也不展示预警条目。默认开启。",
sort_order=21,
),
ParamSpec(
@@ -341,9 +329,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="90",
value_min="1",
value_max=None,
description=(
"发货后超过该天数仍未回款的发货批次触发预警(澄清 #10 建议默认 90 天)"
),
description="发货后超过这么多天仍未收到回款的发货批次,会出现在工作台的预警列表里。默认 90 天。",
sort_order=22,
),
ParamSpec(
@@ -357,9 +343,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="3",
value_min="1",
value_max=None,
description=(
"待审批单提交后超过该天数未处理触发预警(澄清 #10 建议默认 3 天)"
),
description="审批单提交后超过这么多天还没处理,会出现在工作台的预警列表里。默认 3 天。",
sort_order=23,
),
]
+160 -84
View File
@@ -6,7 +6,7 @@ ParamSnapshot 是 M3 核算与计算预览共用的输入契约:按业务日
"""
import logging
from collections.abc import Iterable
from collections.abc import Callable, Iterable
from dataclasses import dataclass
from datetime import date
from decimal import Decimal
@@ -44,7 +44,7 @@ from app.schemas.param import (
ParamPreviewOut,
ParamUsedOut,
)
from app.services import audit_service, formula
from app.services import audit_service, calc_templates, formula
from app.services.audit_service import AuditRecord, RequestMeta
from app.services.formula import to_decimal
from app.services.no_generator import next_no
@@ -379,16 +379,8 @@ def get_history(
definition = param_repository.get_definition_by_code(session, param_code)
if definition is None:
raise NotFoundError("参数不存在")
rows = param_repository.list_history(
session,
definition.id,
offset=query.offset,
limit=query.page_size,
dimension_value=query.dimension_value,
)
total = param_repository.count_history(
session, definition.id, dimension_value=query.dimension_value
)
rows = param_repository.list_history(session, definition.id, query)
total = param_repository.count_history(session, definition.id, query)
return PageResult[ParamHistoryOut](
records=[
ParamHistoryOut(
@@ -410,16 +402,8 @@ def get_history(
def list_batches(session: Session, query: ParamBatchQuery) -> PageResult[ParamBatchOut]:
"""变更批次分页(按创建时间倒序)。"""
batches = param_repository.list_batches(
session,
offset=query.offset,
limit=query.page_size,
start_date=query.start_date,
end_date=query.end_date,
)
total = param_repository.count_batches(
session, start_date=query.start_date, end_date=query.end_date
)
batches = param_repository.list_batches(session, query)
total = param_repository.count_batches(session, query)
return PageResult[ParamBatchOut](
records=[
ParamBatchOut(
@@ -509,42 +493,89 @@ def compare(session: Session, *, from_date: date, to_date: date) -> list[ParamDi
return out
def _preview_rate(
session: Session,
payload: ParamPreviewIn,
snapshot: ParamSnapshot,
days: int,
take,
) -> tuple[Fraction, str, Decimal | None]:
@dataclass(frozen=True, slots=True)
class _PreviewContext:
"""预览上下文(会话、入参、参数取值闭包)。
``take`` 闭包同时负责取数与记录「用了哪些参数」——**调用顺序即
``params_used`` 的展示顺序**,拆分时不得调换各段的先后。
"""
session: Session
payload: ParamPreviewIn
take: Callable[[str], Fraction]
@dataclass(frozen=True, slots=True)
class _PreviewRateDecision:
"""计提比例判定(回款天数、纯比例、人读说明、绩效基数覆盖值)。"""
days: int
rate: Fraction
rate_text: str
base_override: Decimal | None
@dataclass(frozen=True, slots=True)
class _PreviewEarning:
"""折算与绩效(毛利润率、折算额、绩效额)。"""
profit_rate: Fraction
converted: Decimal
performance: Decimal
@dataclass(frozen=True, slots=True)
class _PreviewBarter:
"""以物抵顶(是否适用、折算后绩效、计发基数)。"""
applied: bool
after_barter: Decimal | None
base_amount: Decimal
@dataclass(frozen=True, slots=True)
class _PreviewPayment:
"""支付环节(起付判定、R 权重、权重、应付金额)。"""
start_ok: bool
weight_r: int
weight: Fraction
payable: Decimal
def _preview_rate(ctx: _PreviewContext, days: int) -> tuple[Fraction, str, Decimal | None]:
"""按岗位模板分派计提比例,返回(纯比例, 人读说明, 绩效基数覆盖值)。
基数覆盖值非 None 表示该模板的绩效基数不是折算毛利润额(如信息员按
「本人销售额」计提),预览层直接采用并跳过折算与阈值。
聚合模板(营销经理 12.5%+5%)依赖月度团队数据,不支持单笔预览。
"""
payload = ctx.payload
take = ctx.take
if payload.customer_type == "enterprise":
# 存储口径为 ‰ 数(如 "3"),除以 1000 转纯比例 —— performance_amount
# 按纯比例相乘,漏换算会把绩效金额放大 1000 倍(与业务员分支同防线)
rate = take("enterprise.rate") / 1000
return rate, "企业户固定计提比例", None
return rate, "企业户:不按回款天数,统一按固定比例计提", None
role_code = payload.business_role
template = "TPL_SALESPERSON_DECAY"
if role_code:
role = business_role_repository.get_by_code(session, role_code)
role = business_role_repository.get_by_code(ctx.session, role_code)
if role is None:
raise BadRequestError(f"岗位 {role_code!r} 不存在")
raise BadRequestError(f"岗位「{role_code}」不存在,请到岗位角色里确认")
template = role.calc_template
if template == "TPL_MANAGER_SHARE":
raise BadRequestError(
"营销经理绩效为团队聚合口径(本团队 12.5% + 全公司 5%),不支持单笔预览"
"营销经理的绩效按团队与全公司分成计算,不单笔试算;请到月度结算里查看"
)
if template == "TPL_INFO_SALES_FIXED":
# ‰ 存储口径 → 纯比例,同企业户分支(不换算则绩效放大 1000 倍)
rate = take("info.rate") / 1000
# 信息员的基数是本人销售额(无折算与阈值),覆盖默认基数
return rate, "信息员:本人销售额 × 计提比例", Decimal(payload.revenue)
return rate, "信息员:按本人销售额乘计提比例,不随回款天数递减", Decimal(payload.revenue)
if template == "TPL_SALESPERSON_DECAY":
start = take("salesperson.rate.start")
slope = take("salesperson.rate.slope")
@@ -553,12 +584,84 @@ def _preview_rate(
rate = formula.salesperson_rate(
days, start=start, slope=slope, floor=floor, boundary_days=boundary
)
# 说明写成业务话:说清这笔为什么是这个比例、什么时候不再降
return (
rate,
f"业务员递减:{to_decimal(start)}‰ 起,{days} 天(分界 {boundary} 天,超界取 {to_decimal(floor)}‰)",
f"回款 {days} 天,从起始 {to_decimal(start)}‰ 按日递减"
f"(满 {boundary} 天后固定按 {to_decimal(floor)}‰ 计算)",
None,
)
raise BadRequestError(f"公式模板 {template} 暂不支持预览")
# 提示里给模板中文名而不是编码:编码是内部标识,业务人员在岗位角色页看不到它
tpl_name = calc_templates.TEMPLATES[template].name if template in calc_templates.TEMPLATES else template
raise BadRequestError(
f"「{tpl_name}」不支持单笔试算,请在岗位角色里改用业务员递减或信息员固定计提"
)
def _preview_rate_decision(ctx: _PreviewContext) -> _PreviewRateDecision:
"""产品类别校验 + 回款天数 + 计提比例判定。"""
payload = ctx.payload
if payload.product_category and payload.product_category not in PRODUCT_CATEGORIES:
# 提示里用中文类别名:前端下拉显示的是「市政水利」,报错却回英文编码会让人对不上
raise BadRequestError(
f"产品类别「{payload.product_category}」不存在,可选:市政水利、轨道交通"
)
days = formula.payment_days(payload.ship_date, payload.receipt_date)
rate, rate_text, base_override = _preview_rate(ctx, days)
return _PreviewRateDecision(
days=days, rate=rate, rate_text=rate_text, base_override=base_override
)
def _preview_earning(ctx: _PreviewContext, decision: _PreviewRateDecision) -> _PreviewEarning:
"""毛利润率 → 折算额(阈值)→ 绩效额(比例)。"""
payload = ctx.payload
revenue = Decimal(payload.revenue)
profit = Decimal(payload.profit)
profit_rate = formula.profit_rate(profit, revenue)
if decision.base_override is not None:
# 基数覆盖(信息员按本人销售额计提):不走折算与阈值
converted = decision.base_override
else:
threshold = ctx.take("product.threshold")
converted = formula.converted_profit(revenue, profit_rate, threshold)
performance = formula.performance_amount(converted, decision.rate)
return _PreviewEarning(profit_rate=profit_rate, converted=converted, performance=performance)
def _preview_barter(ctx: _PreviewContext, performance: Decimal) -> _PreviewBarter:
"""以物抵顶折算(按应发绩效的固定比例折算,比例来自参数)。"""
applied = ctx.payload.barter
after_barter: Decimal | None = None
if applied:
discount = ctx.take("barter.discount")
after_barter = formula.apply_barter(performance, discount)
base_amount = after_barter if applied else performance
return _PreviewBarter(applied=applied, after_barter=after_barter, base_amount=base_amount)
def _preview_payment(
ctx: _PreviewContext, received: Decimal, revenue: Decimal, base_amount: Decimal
) -> _PreviewPayment:
"""起付比例判定 + R 权重 + 应付金额。"""
start_ratio = ctx.take("payment.start.ratio")
start_ok = formula.start_payment_ok(received, revenue, start_ratio)
# R 钳制已提取为纯函数 clamp_weight_r(④ 核算与预览共用同一实现)
weight_r = formula.clamp_weight_r(received, revenue)
weight = formula.payment_weight(weight_r)
payable = to_decimal(Fraction(base_amount) * weight)
return _PreviewPayment(start_ok=start_ok, weight_r=weight_r, weight=weight, payable=payable)
def _preview_probation_note(ctx: _PreviewContext) -> str | None:
"""试用期说明(绩效照算不发放,年累计超阈值的部分年底一次性结算)。"""
if not ctx.payload.probation:
return None
annual = ctx.take("probation.annual.threshold")
return (
f"试用期人员:绩效照常计算但不发放;年内累计超过 {to_decimal(annual)} 元的部分,"
"年底一次性结清。"
)
def preview(session: Session, payload: ParamPreviewIn) -> ParamPreviewOut:
@@ -566,7 +669,6 @@ def preview(session: Session, payload: ParamPreviewIn) -> ParamPreviewOut:
# 参数按回款到账日取值(对照表 #11)
snapshot = resolve_snapshot(session, payload.receipt_date)
revenue = Decimal(payload.revenue)
profit = Decimal(payload.profit)
received = Decimal(payload.received_amount)
used: list[ParamUsedOut] = []
@@ -589,55 +691,29 @@ def preview(session: Session, payload: ParamPreviewIn) -> ParamPreviewOut:
)
return parse_fraction(raw)
if payload.product_category and payload.product_category not in PRODUCT_CATEGORIES:
raise BadRequestError(
f"产品类别 {payload.product_category!r} 不存在,可选:municipal_water / rail_transit"
)
days = formula.payment_days(payload.ship_date, payload.receipt_date)
rate, rate_text, base_override = _preview_rate(session, payload, snapshot, days, take)
profit_rate = formula.profit_rate(profit, revenue)
if base_override is not None:
# 基数覆盖(信息员按本人销售额计提):不走折算与阈值
converted = base_override
else:
threshold = take("product.threshold")
converted = formula.converted_profit(revenue, profit_rate, threshold)
performance = formula.performance_amount(converted, rate)
barter_applied = payload.barter
after_barter: Decimal | None = None
if barter_applied:
discount = take("barter.discount")
after_barter = formula.apply_barter(performance, discount)
start_ratio = take("payment.start.ratio")
start_ok = formula.start_payment_ok(received, revenue, start_ratio)
# R 钳制已提取为纯函数 clamp_weight_r(④ 核算与预览共用同一实现)
weight_r = formula.clamp_weight_r(received, revenue)
weight = formula.payment_weight(weight_r)
base_amount = after_barter if barter_applied else performance
payable = to_decimal(Fraction(base_amount) * weight)
probation_note: str | None = None
if payload.probation:
annual = take("probation.annual.threshold")
probation_note = f"试用期绩效照算不发放;年累计超 {to_decimal(annual)} 元部分年底一次性结算"
ctx = _PreviewContext(session=session, payload=payload, take=take)
# 各段顺序固定 —— take 的调用顺序即 params_used 的展示顺序
decision = _preview_rate_decision(ctx)
earning = _preview_earning(ctx, decision)
barter = _preview_barter(ctx, earning.performance)
payment = _preview_payment(ctx, received, revenue, barter.base_amount)
probation_note = _preview_probation_note(ctx)
return ParamPreviewOut(
payment_days=days,
rate=str(rate),
rate_text=rate_text,
profit_rate=str(profit_rate),
converted_profit=str(converted),
performance_amount=str(performance),
barter_applied=barter_applied,
performance_after_barter=str(after_barter) if after_barter is not None else None,
start_payment_ok=start_ok,
payment_weight_r=weight_r,
payment_weight=str(weight),
payable_amount=str(payable),
payment_days=decision.days,
rate=str(decision.rate),
rate_text=decision.rate_text,
profit_rate=str(earning.profit_rate),
converted_profit=str(earning.converted),
performance_amount=str(earning.performance),
barter_applied=barter.applied,
performance_after_barter=(
str(barter.after_barter) if barter.after_barter is not None else None
),
start_payment_ok=payment.start_ok,
payment_weight_r=payment.weight_r,
payment_weight=str(payment.weight),
payable_amount=str(payment.payable),
probation_note=probation_note,
params_used=used,
)
+2 -14
View File
@@ -70,20 +70,8 @@ def ensure_active(session: Session, personnel_id: int, *, label: str) -> Personn
def list_personnel(session: Session, query: PersonnelQuery) -> PageResult[PersonnelOut]:
"""分页查询人员(含离职)。"""
rows = personnel_repository.list_personnel(
session,
offset=query.offset,
limit=query.page_size,
keyword=query.keyword,
status=query.status,
business_role_id=query.business_role_id,
)
total = personnel_repository.count_personnel(
session,
keyword=query.keyword,
status=query.status,
business_role_id=query.business_role_id,
)
rows = personnel_repository.list_personnel(session, query)
total = personnel_repository.count_personnel(session, query)
return PageResult[PersonnelOut](
records=[
_to_out(personnel, role_name, manager_name)
+2 -14
View File
@@ -99,20 +99,8 @@ def _raise_if_duplicate(exc: IntegrityError) -> None:
def list_products(session: Session, query: ProductQuery) -> PageResult[ProductOut]:
"""分页查询产品(含停用)。"""
products = product_repository.list_products(
session,
offset=query.offset,
limit=query.page_size,
keyword=query.keyword,
product_category=query.product_category,
status=query.status,
)
total = product_repository.count_products(
session,
keyword=query.keyword,
product_category=query.product_category,
status=query.status,
)
products = product_repository.list_products(session, query)
total = product_repository.count_products(session, query)
return PageResult[ProductOut](
records=[_to_out(product) for product in products],
total=total,
+59 -51
View File
@@ -23,6 +23,7 @@
"""
import logging
from dataclasses import dataclass
from datetime import date
from decimal import Decimal
@@ -39,7 +40,7 @@ from app.core.constants import (
from app.core.db_errors import is_duplicate_entry
from app.core.exceptions import BadRequestError, NotFoundError
from app.core.response import PageResult
from app.models import Receipt
from app.models import Business, Receipt
from app.models.business import BUSINESS_STATUS_ACTIVE
from app.models.receipt import SETTLEMENT_DONE, SETTLEMENT_PENDING
from app.repositories import business_repository, receipt_repository
@@ -129,24 +130,8 @@ def _ensure_business_writable(session: Session, business_id: int, action: str):
def list_receipts(session: Session, query: ReceiptQuery) -> PageResult[ReceiptOut]:
"""分页查询回款(附业务号/发货单号与 payment_days 派生值)。"""
rows = receipt_repository.list_receipts(
session,
offset=query.offset,
limit=query.page_size,
business_id=query.business_id,
date_from=query.date_from,
date_to=query.date_to,
is_barter=query.is_barter,
settlement_status=query.settlement_status,
)
total = receipt_repository.count_receipts(
session,
business_id=query.business_id,
date_from=query.date_from,
date_to=query.date_to,
is_barter=query.is_barter,
settlement_status=query.settlement_status,
)
rows = receipt_repository.list_receipts(session, query)
total = receipt_repository.count_receipts(session, query)
return PageResult[ReceiptOut](
records=[_to_out(row) for row in rows],
total=total,
@@ -166,6 +151,57 @@ def get_detail(session: Session, receipt_id: int) -> ReceiptOut:
return _to_out(row)
@dataclass(frozen=True, slots=True)
class _CreatedReceipt:
"""建行结果(出参 + 固化的 ID/批次号,commit 后不再触碰 ORM 属性)。"""
out: ReceiptOut
receipt_id: int
batch_no: str
def _create_receipt_once(
session: Session,
payload: ReceiptCreateIn,
business: Business | None,
batch_no: str,
) -> _CreatedReceipt:
"""一次建行尝试:准入校验 → 建行 → 重算合计 → 出参。
**不 commit**:提交与回滚由调用方(重试循环)统一控制,保证「一次尝试
= 一个事务边界」。
"""
if business is None:
raise NotFoundError("业务不存在")
if business.status != BUSINESS_STATUS_ACTIVE:
raise BadRequestError("业务已完成或已终止,禁止新增回款")
# 月结锁定拦截(2026-10-15 补充需求):比需求原文「提示」取硬,
# 一期安全侧(策略点注释);未指定日期时以当天月近似
from app.services import settlement_service
_lock_month = (payload.receipt_date or date.today()).strftime("%Y-%m")
settlement_service.ensure_month_not_locked(session, _lock_month)
shipment_no, ship_date = _ensure_shipment_of_business(
session, payload.shipment_id, payload.business_id
)
receipt = receipt_repository.create(
session,
Receipt(
receipt_batch_no=batch_no,
business_id=payload.business_id,
shipment_id=payload.shipment_id,
receipt_date=payload.receipt_date,
amount=Decimal(payload.amount),
is_barter=payload.is_barter,
settlement_status=SETTLEMENT_PENDING,
remark=payload.remark,
),
)
_recalc_totals(session, business)
out = _to_out((receipt, business.business_no, shipment_no, ship_date))
return _CreatedReceipt(out=out, receipt_id=receipt.id, batch_no=batch_no)
def create_receipt(
session: Session,
payload: ReceiptCreateIn,
@@ -206,35 +242,7 @@ def create_receipt(
# 锁业务行与生成批次号——成功/最终失败各只记一条审计,无递归
for attempt in range(_NO_RETRY + 1):
try:
if business is None:
raise NotFoundError("业务不存在")
if business.status != BUSINESS_STATUS_ACTIVE:
raise BadRequestError("业务已完成或已终止,禁止新增回款")
# 月结锁定拦截(2026-10-15 补充需求):比需求原文「提示」取硬,
# 一期安全侧(策略点注释);未指定日期时以当天月近似
from app.services import settlement_service
_lock_month = (payload.receipt_date or date.today()).strftime("%Y-%m")
settlement_service.ensure_month_not_locked(session, _lock_month)
shipment_no, ship_date = _ensure_shipment_of_business(
session, payload.shipment_id, payload.business_id
)
receipt = receipt_repository.create(
session,
Receipt(
receipt_batch_no=batch_no,
business_id=payload.business_id,
shipment_id=payload.shipment_id,
receipt_date=payload.receipt_date,
amount=Decimal(payload.amount),
is_barter=payload.is_barter,
settlement_status=SETTLEMENT_PENDING,
remark=payload.remark,
),
)
_recalc_totals(session, business)
out = _to_out((receipt, business.business_no, shipment_no, ship_date))
new_id, new_no = receipt.id, batch_no # 固化:commit 后不碰 ORM
created = _create_receipt_once(session, payload, business, batch_no)
session.commit()
break
except IntegrityError as exc:
@@ -249,11 +257,11 @@ def create_receipt(
logger.info(
"新增回款 | receipt_id=%s | no=%s | business_id=%s",
new_id,
new_no,
created.receipt_id,
created.batch_no,
payload.business_id,
)
return out
return created.out
def update_receipt(
+9 -8
View File
@@ -36,7 +36,6 @@ _DATE_FORMAT = "YYYY-MM-DD"
_FORMULA_PREFIXES = ("=", "+", "-", "@", "\t")
@dataclass(frozen=True, slots=True)
class _Column:
"""列定义:key 取行 dict 字段,kind 决定单元格类型与格式。"""
@@ -403,7 +402,13 @@ def _write_department_document(
company_share_count = int(company.get("company_share_count") or 0)
_write_document_header(
sheet, len(_DEPARTMENT_WIDTHS), company_name, title, filters, template_version, operator_name
sheet,
len(_DEPARTMENT_WIDTHS),
company_name,
title,
filters,
template_version,
operator_name,
)
_write_block(
@@ -439,16 +444,12 @@ def _write_department_document(
"manager_name": "合计",
"member_count": sum(team["member_count"] for team in teams),
"team_base": _sum_amount(team["team_base"] for team in teams),
"team_share_amount": _sum_amount(
team["team_share_amount"] for team in teams
),
"team_share_amount": _sum_amount(team["team_share_amount"] for team in teams),
"team_subtotal": _sum_amount(team["team_subtotal"] for team in teams),
# 未生成月结时全部为 None,合计也留空(不能显示 0.00 误导)
"frozen_amount": (
_sum_amount(
team["frozen_amount"]
for team in teams
if team["frozen_amount"] is not None
team["frozen_amount"] for team in teams if team["frozen_amount"] is not None
)
if any(team["frozen_amount"] is not None for team in teams)
else None
+9 -30
View File
@@ -103,10 +103,7 @@ def _person_row(row: PerformanceLedger, role_names: dict[str, str]) -> dict:
def _role_names(session) -> dict[str, str]:
"""岗位编码 → 名称一次查询(明细与部门行共用,避免 N+1)。"""
return {
r.role_code: r.role_name
for r in session.execute(select(BusinessRole)).scalars()
}
return {r.role_code: r.role_name for r in session.execute(select(BusinessRole)).scalars()}
def _dec(value) -> Decimal | None:
@@ -178,11 +175,7 @@ def department_summary(session, query) -> dict:
extra_ids = {
target
for pid in adjustments
if (
target := _adjustment_target(
pid, member_map, manager_of, manager_role, include_mp
)
)
if (target := _adjustment_target(pid, member_map, manager_of, manager_role, include_mp))
is not None
} - set(manager_ids)
if extra_ids:
@@ -193,9 +186,7 @@ def department_summary(session, query) -> dict:
for team in teams_all
if query.manager_id is None or team["manager_id"] == query.manager_id
]
unassigned_all = _unassigned_rows(
member_map, manager_of, manager_role, adjustments, include_mp
)
unassigned_all = _unassigned_rows(member_map, manager_of, manager_role, adjustments, include_mp)
# 「仅看某经理团队」时未分配桶不随展示:全公司无经理人员与该团队无关
unassigned = [] if query.manager_id is not None else unassigned_all
company_share_amount = _company_share(company_base, share.company_pct)
@@ -335,9 +326,7 @@ def _reconciliation(
(m["adjust_net"] for team in teams for m in team["members"]), Decimal("0")
) + sum((row["adjust_net"] for row in unassigned), Decimal("0"))
team_base_total = sum((team["team_base"] for team in teams), Decimal("0"))
unassigned_performance = sum(
(row["performance_sum"] for row in unassigned), Decimal("0")
)
unassigned_performance = sum((row["performance_sum"] for row in unassigned), Decimal("0"))
payable_total = (
team_member_payable + unassigned_payable + aggregate_payable_total + adjust_net_total
)
@@ -378,9 +367,7 @@ def _reconciliation(
def _team_share(team_base: Decimal, team_pct: Fraction) -> Decimal:
"""12.5% 团队分成:全公司部分传 0,仅取 team_pct 项。"""
return manager_team_share(
team_base, Decimal("0"), team_pct=team_pct, company_pct=Fraction(0)
)
return manager_team_share(team_base, Decimal("0"), team_pct=team_pct, company_pct=Fraction(0))
def _company_share(company_base: Decimal, company_pct: Fraction) -> Decimal:
@@ -483,12 +470,8 @@ def _team_relations(
referenced = {p.manager_id for p in people if p.manager_id is not None}
missing = referenced - seed
if missing:
people.extend(
session.execute(select(Personnel).where(Personnel.id.in_(missing))).scalars()
)
role_by_id = {
r.id: r.role_code for r in session.execute(select(BusinessRole)).scalars()
}
people.extend(session.execute(select(Personnel).where(Personnel.id.in_(missing))).scalars())
role_by_id = {r.id: r.role_code for r in session.execute(select(BusinessRole)).scalars()}
manager_of = {p.id: p.manager_id for p in people}
manager_role = {p.id: role_by_id.get(p.business_role_id, "") for p in people}
person_info = {
@@ -619,9 +602,7 @@ def _member_rows(
)
)
personal_in_team = (
pid == manager_id
and include_manager_personal
and member["has_manager_personal"]
pid == manager_id and include_manager_personal and member["has_manager_personal"]
)
if personal_in_team:
rows.append(
@@ -893,9 +874,7 @@ def _group_rows(rows) -> list[dict]:
def payment_detail(session, query) -> dict:
"""区间下钻分页(bucket 未注册 → 400)。"""
bucket = _bucket_by_code(query.bucket)
rows = report_repository.list_payment_detail(
session, query, bucket, offset=query.offset, limit=query.page_size
)
rows = report_repository.list_payment_detail(session, query, bucket)
return {
"list": [_payment_row(r) for r in rows],
"total": report_repository.count_payment_detail(session, query, bucket),
+2 -8
View File
@@ -68,14 +68,8 @@ def list_roles(session: Session, query: RoleQuery) -> PageResult[RoleManageOut]:
与用户表单下拉(`/users/meta/roles`)口径不同:**管理列表含停用角色**,
下拉只给启用项。
"""
roles = role_repository.list_roles(
session,
offset=query.offset,
limit=query.page_size,
keyword=query.keyword,
status=query.status,
)
total = role_repository.count_roles(session, keyword=query.keyword, status=query.status)
roles = role_repository.list_roles(session, query)
total = role_repository.count_roles(session, query)
counts = role_repository.count_users_by_role(session, [role.id for role in roles])
return PageResult[RoleManageOut](
records=[_to_out(role, counts.get(role.id, 0)) for role in roles],
+3 -2
View File
@@ -423,9 +423,10 @@ def _aggregate_row(
"members": member_names,
"params_as_of": str(share.param_date),
"manager_personal_included": 1 if share.include_manager_personal else 0,
# 说明写给业务人员看:说清这一笔的基数是怎么凑出来的
"note": (
f"#12 参数化:基数{'含' if share.include_manager_personal else '不含'}"
"经理个人户业绩;9.8 方案公式"
f"团队分成:基数{'含' if share.include_manager_personal else '不含'}"
"经理本人名下业绩,按本团队与全公司两级比例合计计提"
),
},
),
+2 -14
View File
@@ -157,20 +157,8 @@ def _is_duplicate_entry(exc: IntegrityError) -> bool:
def list_shipments(session: Session, query: ShipmentQuery) -> PageResult[ShipmentOut]:
"""分页查询发货单(附业务号;business_id 可空=全量,一期按业务维度查看为主)。"""
rows = shipment_repository.list_shipments(
session,
offset=query.offset,
limit=query.page_size,
business_id=query.business_id,
ship_date_from=query.ship_date_from,
ship_date_to=query.ship_date_to,
)
total = shipment_repository.count_shipments(
session,
business_id=query.business_id,
ship_date_from=query.ship_date_from,
ship_date_to=query.ship_date_to,
)
rows = shipment_repository.list_shipments(session, query)
total = shipment_repository.count_shipments(session, query)
return PageResult[ShipmentOut](
records=[_to_out(row) for row in rows],
total=total,
+2 -16
View File
@@ -68,22 +68,8 @@ def _to_out(user: User) -> UserOut:
def list_users(session: Session, query: UserQuery) -> PageResult[UserOut]:
"""分页查询用户列表。"""
users = user_repository.list_users(
session,
offset=query.offset,
limit=query.page_size,
username=query.username,
real_name=query.real_name,
status=query.status,
role_code=query.role_code,
)
total = user_repository.count_users(
session,
username=query.username,
real_name=query.real_name,
status=query.status,
role_code=query.role_code,
)
users = user_repository.list_users(session, query)
total = user_repository.count_users(session, query)
return PageResult[UserOut](
records=[_to_out(user) for user in users],
total=total,
+6 -2
View File
@@ -98,9 +98,9 @@ uv run pytest tests/integration -q # 仅集成
| --- | --- | --- |
| `tests/integration/test_personnel.py` | 未登录 401、新增默认在职、工资标准 str 直出、经理自引用/不存在或已离职/岗位停用 400、删除保护(团队成员与客户归属引用→引导改离职)、辐射区域与拓展计划全量替换(含清空与详情回显)、在职/离职切换、审计成功与失败双记录 | 20 |
| `tests/integration/test_products.py` | 未登录 401、类别值域 400、名称+规格组合唯一(同名称不同规格放行)、单价负数/非数字 400、启停切换、改类别、审计留痕 | 14 |
| `tests/integration/test_customers.py` | 未登录 401、客户分类值域 400、「公司名称+负责人」组合唯一(同公司不同负责人放行)、归属变更(主表更新+留痕字段断言)、归属人员离职 400、新旧相同含双方为空 400、留痕分页 join 姓名、编辑端点不触归属、审计留痕 | 14 |
| `tests/integration/test_customers.py` | 未登录 401、客户分类值域 400、「公司名称+负责人」组合唯一(同公司不同负责人放行)、归属变更(主表更新+留痕字段断言)、归属人员离职 400、新旧相同含双方为空 400、留痕分页 join 姓名、编辑端点不触归属、审计留痕、评级保存(回显总分/时间、覆盖式保存)、评分 101→400、按评分排序未评分垫底(asc/desc) | 18 |
运行:`uv run pytest tests/integration/test_personnel.py tests/integration/test_products.py tests/integration/test_customers.py -q` → **48 passed**(2026-10-13,连跑两次幂等)。
运行:`uv run pytest tests/integration/test_personnel.py tests/integration/test_products.py tests/integration/test_customers.py -q` → **48 passed**(2026-10-13,连跑两次幂等);2026-10-23 评级功能后客户单文件 **18 passed**(18.5s)。
### 业务数据管理(2026-10-13 完成)
@@ -205,9 +205,13 @@ uv run pytest tests/integration -q # 仅集成
| 2026-10-20 | 全量回归 | 862 | ✅ 862 passed(11:32) | **工作台/报表修复提交后的全量复核**;e2e 三脚本复跑:role_home 4/4、report 12/12、calc_settlement 8/8;calc 审批开关已还原为 1,无 `e2e_role_*` 残留 |
| 2026-10-20 | code-review 遗留 2 项 | 全量 875 | ✅ 875 passed(12:13) | **方案版本化 + 审批内容快照**(V11 迁移):审批单 `version_no`/`snapshot_json`,方案行 `is_active` 版本化;编辑/驳回不再物理删方案;历史单无快照时 live 回退并显式标注。新增集成 7 + 单元 6;定点审批/月结/核算/业务/报表全绿;ruff 与 type-check/build 绿 |
| 2026-10-20 | 遗留项自评审收口 | 全量 891 | ✅ 891 passed(10:23) | **列表 defer 快照列**(审批列表/工作台待办/超时预警)+ **送审内容未变不重提、方案未变不新增版本**(两个 version_no 语义解耦)+ **月结提交加行锁**。新增单元 10 + 集成 5;单元 525、集成定点 96 全绿 |
| 2026-10-22 | 清理批次(散参数 DTO 化) | 全量 900 | ✅ 900 passed(11:08) | **仓储层散参数收敛为查询 DTO**(`list_*(session, query)` 取代 keyword/status/offset/limit 四参数),同步调整服务层与测试调用;单元 526 绿、ruff 绿、前端 type-check 与 build 绿 |
| 2026-10-19 | 单元全量 + 报表定点 | 484 | ✅ 484 passed(45.94s);报表单元 11 例 0.70s | **M4 报表模块收尾**(前端四页/打印视图/导出与单元测试补齐)。ruff 绿;前端 `pnpm type-check` 绿。报表集成 7 例记录见上节 |
| 2026-10-19 | 全部 | 806 | ✅ 806 passed(7:59) | **M4 ⑤ 报表统计全量回归**(报表新增 18 例:集成 7 + 单元 11;另含并行会话并入的 M3 前端修复与字体合规)。ruff 绿。e2e:`calc_settlement_flow` 8 步由并行会话跑通;`report_flow.py` 待 `E2E_ADMIN_PASSWORD` 运行 |
| 2026-10-23 | 参数文案中文化 | 单元 526 + 参数集成 24 | ✅ 526 passed(45.15s)/ 24 passed(10.60s) | **计提参数页全面中文化**:`param_registry` 22 条 `description` 改写为业务语言(去掉「澄清 #10」「M3」「禁止按 0.011111 截断」等开发者语汇),`db/init/init-dev.sql` 种子同源同步;`param_service` 模板报错改中文模板名、产品类别报错去掉英文枚举;`dashboard_service` 概览口径说明重写。**这些文案无测试断言**(`rate_text` 断言 `11/1125`、`聚合:20%+5%` 未动),故用例数不变、全绿即口径未被破坏。ruff 绿;前端 `pnpm type-check` 绿 |
| 2026-10-23 | 台账/预览的计提说明口语化 | 单元 526 + 集成 87(calc/settlements/param/reports) | ✅ 526 passed(47.29s)/ 87 passed(7:41) | **`rate_text`/`rate_note`/聚合行 `note` 改业务话**:`企业户固定计提比例`→「企业户:不按回款天数,统一按固定比例计提」、`业务员递减:10‰ 起,20 天(分界 360 天…)`→「回款 20 天,从起始 10‰ 按日递减(满 360 天后固定按 4‰ 计算)」、`信息员:本人销售额 × 计提比例`→「信息员:按本人销售额乘计提比例,不随回款天数递减」、聚合行 `#12 参数化:基数不含经理个人户业绩;9.8 方案公式`→「团队分成:基数不含经理本人名下业绩,按本团队与全公司两级比例合计计提」。**用例同步**:`test_经理聚合口径_预览拒绝` 原断言消息含「聚合」,改为断言含「营销经理」+「月度结算」(守「拒绝且给下一步」,不再绑定措辞);`rate_text` 的 `11/1125`、`聚合:20%+5%` 断言未动(那是分数原文与百分比拼装,非说明文案) |
## 四、测试发现并修复的缺陷
| 日期 | 缺陷 | 影响 | 处理 |
@@ -451,7 +451,9 @@ class TestContentSnapshot:
row.snapshot_json = None
session.commit()
detail = client.get(f"{_APPROVALS}/{order['id']}", headers=finance["headers"]).json()["data"]
detail = client.get(f"{_APPROVALS}/{order['id']}", headers=finance["headers"]).json()[
"data"
]
assert detail["content_source"] == "live"
assert detail["snapshot"]["captured_at"] is None
assert detail["snapshot"]["data"]["business_no"] == data["business_no"]
@@ -474,7 +476,9 @@ class TestContentSnapshot:
row.ref_id = 999_999_999
session.commit()
detail = client.get(f"{_APPROVALS}/{order['id']}", headers=finance["headers"]).json()["data"]
detail = client.get(f"{_APPROVALS}/{order['id']}", headers=finance["headers"]).json()[
"data"
]
assert detail["content_source"] == "missing"
assert detail["snapshot"] is None
@@ -679,9 +683,7 @@ class TestAllocationVersions:
data = _create_with_plan(client, admin["headers"], p["id"])
detail = client.get(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"]).json()["data"]
assert [(h["version_no"], h["is_active"]) for h in detail["allocation_history"]] == [
(1, 1)
]
assert [(h["version_no"], h["is_active"]) for h in detail["allocation_history"]] == [(1, 1)]
payload = business_payload(
detail["customer_id"],
@@ -691,7 +693,9 @@ class TestAllocationVersions:
assert resp.status_code == 200, resp.text
detail = client.get(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"]).json()["data"]
history = [(h["version_no"], h["is_active"], h["ratio"]) for h in detail["allocation_history"]]
history = [
(h["version_no"], h["is_active"], h["ratio"]) for h in detail["allocation_history"]
]
assert history == [(1, 0, "50.00"), (2, 1, "80.00")]
assert [a["ratio"] for a in detail["secondary_allocations"]] == ["80.00"]
+96 -2
View File
@@ -13,21 +13,24 @@ audit_service.py「上下文内不得吞掉异常后正常退出」的约定相
"""
import uuid
from datetime import date
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import func, select
from app.core.constants import ACTION_CREATE, MODULE_BUSINESS
from app.core.constants import ACTION_CREATE, MODULE_BUSINESS, MODULE_RECEIPT
from app.core.db import get_session_factory
from app.main import app
from app.models import OperationLog
from app.services import business_service
from app.services import business_service, receipt_service
from tests.integration.factories import create_business
pytestmark = pytest.mark.integration
_BUSINESSES = "/api/v1/business/businesses"
_CUSTOMERS = "/api/v1/base/customers"
_RECEIPTS = "/api/v1/business/receipts"
@pytest.fixture
@@ -160,3 +163,94 @@ class TestAuditRetry:
# 每层各写一条记录;修复后重试有上限,最终失败仅一条失败日志
assert _log_count(normal["username"], success=False) == base_fail + 1
assert _log_count(normal["username"], success=True) == base_ok
def _receipt_log_count(username: str, *, success: bool) -> int:
"""按操作人统计「新增回款」操作日志条数。"""
factory = get_session_factory()
with factory() as session:
stmt = (
select(func.count())
.select_from(OperationLog)
.where(
OperationLog.username == username,
OperationLog.module == MODULE_RECEIPT,
OperationLog.action == ACTION_CREATE,
OperationLog.summary.like("新增回款%"),
OperationLog.success == int(success),
)
)
return int(session.execute(stmt).scalar_one())
def _receipt_payload(business_id: int) -> dict:
return {
"business_id": business_id,
"receipt_date": str(date.today()),
"amount": "1200.00",
}
class TestReceiptAuditRetry:
"""回款新增的 1062 重试路径(回款与业务同构,但走独立实现)。
回款侧的重试循环本次从 create 函数体内移入 helper 调用,重试边界、
审计条数、批次号重取都必须保持原样 —— 用例会盯住这三条。
"""
def test_首次撞号重试成功_审计恰一条真成功(
self, quiet_client: TestClient, normal: dict, monkeypatch
) -> None:
client = quiet_client
business = create_business(client, normal["headers"])
first = client.post(
_RECEIPTS, json=_receipt_payload(business["id"]), headers=normal["headers"]
)
assert first.status_code == 200, first.text
existing_no = first.json()["data"]["receipt_batch_no"]
real_generate = receipt_service._generate_no
calls = {"n": 0}
def flaky_generate(session):
calls["n"] += 1
if calls["n"] == 1:
return existing_no # 首次返回库内已存在批次号 → flush 撞 1062
return real_generate(session)
monkeypatch.setattr(receipt_service, "_generate_no", flaky_generate)
base_ok = _receipt_log_count(normal["username"], success=True)
base_fail = _receipt_log_count(normal["username"], success=False)
second = client.post(
_RECEIPTS, json=_receipt_payload(business["id"]), headers=normal["headers"]
)
assert second.status_code == 200, second.text
assert calls["n"] == 2, "重试应恰好再取一次号"
assert _receipt_log_count(normal["username"], success=True) == base_ok + 1
assert _receipt_log_count(normal["username"], success=False) == base_fail
def test_持续冲突_最终失败且审计恰一条失败(
self, quiet_client: TestClient, normal: dict, monkeypatch
) -> None:
client = quiet_client
business = create_business(client, normal["headers"])
first = client.post(
_RECEIPTS, json=_receipt_payload(business["id"]), headers=normal["headers"]
)
assert first.status_code == 200, first.text
existing_no = first.json()["data"]["receipt_batch_no"]
def always_conflict(session):
return existing_no
monkeypatch.setattr(receipt_service, "_generate_no", always_conflict)
base_ok = _receipt_log_count(normal["username"], success=True)
base_fail = _receipt_log_count(normal["username"], success=False)
second = client.post(
_RECEIPTS, json=_receipt_payload(business["id"]), headers=normal["headers"]
)
assert second.status_code == 400, second.text
assert _receipt_log_count(normal["username"], success=False) == base_fail + 1
assert _receipt_log_count(normal["username"], success=True) == base_ok
+1 -3
View File
@@ -452,9 +452,7 @@ class TestEdit:
assert resp.status_code == 400, resp.text
assert "含合同负责营销人员" in resp.json()["message"]
def test_二次分配方案合法比例_保存成功且回显(
self, client: TestClient, normal: dict
) -> None:
def test_二次分配方案合法比例_保存成功且回显(self, client: TestClient, normal: dict) -> None:
headers = normal["headers"]
customer = _customer(client, headers)
owner = _personnel(client, headers)
@@ -376,3 +376,73 @@ class TestAudit:
latest = logs[0]
assert customer["company_name"] in latest.summary, "摘要应含客户名"
assert "→" in latest.summary, "摘要应体现新旧归属走向"
class TestRating:
"""评级保存与列表排序 —— 评分是档案属性,只存总分与评级时间。
守的是:评分只走专属端点(编辑端点不含评分,旁路写评分会绕开口径);
分值红线 0-100 在入口拦截;按评分排序时未评分客户必须垫底,不能因
MySQL 把 NULL 视为最小值而在升序里压过打了低分的客户。
"""
def test_保存评级_回显总分与时间_且编辑端点不含评分(
self, client: TestClient, normal: dict
) -> None:
headers = normal["headers"]
customer = _create_customer(client, headers)
assert customer["rating_score"] is None, "新客户应无评分"
resp = client.put(
f"{_CUSTOMERS}/{customer['id']}/rating", json={"score": 88}, headers=headers
)
assert resp.status_code == 200, resp.text
body = resp.json()["data"]
assert body["rating_score"] == 88
assert body["rating_time"] is not None, "保存评分必须刷新评级时间"
# 覆盖式保存:新分数替换旧分数
resp = client.put(
f"{_CUSTOMERS}/{customer['id']}/rating", json={"score": 60}, headers=headers
)
assert resp.status_code == 200, resp.text
assert resp.json()["data"]["rating_score"] == 60
def test_评分越界_400(self, client: TestClient, normal: dict) -> None:
headers = normal["headers"]
customer = _create_customer(client, headers)
resp = client.put(
f"{_CUSTOMERS}/{customer['id']}/rating", json={"score": 101}, headers=headers
)
assert resp.status_code == 400, resp.text
def test_按评分排序_未评分垫底_升序降序都不压过低分(
self, client: TestClient, normal: dict
) -> None:
headers = normal["headers"]
high = _create_customer(client, headers)
low = _create_customer(client, headers)
unrated = _create_customer(client, headers)
for target, score in ((high, 80), (low, 60)):
resp = client.put(
f"{_CUSTOMERS}/{target['id']}/rating", json={"score": score}, headers=headers
)
assert resp.status_code == 200, resp.text
def _order_names(order: str) -> list[str]:
resp = client.get(
_CUSTOMERS,
params={"pageNum": 1, "pageSize": 50, "order_by": "rating_score", "order": order},
headers=headers,
)
assert resp.status_code == 200, resp.text
names = [row["company_name"] for row in resp.json()["data"]["list"]]
return names
# 降序:高分在前,未评分最后;升序:低分在前,未评分仍垫底
desc_names = _order_names("desc")
assert desc_names.index(high["company_name"]) < desc_names.index(low["company_name"])
assert desc_names[-1] == unrated["company_name"]
asc_names = _order_names("asc")
assert asc_names.index(low["company_name"]) < asc_names.index(high["company_name"])
assert asc_names[-1] == unrated["company_name"], "未评分必须垫底"
+53 -20
View File
@@ -118,10 +118,20 @@ def _dashboard(client: TestClient, headers: dict, month: str | None = None) -> d
return resp.json()["data"]
def _activities(client: TestClient, headers: dict, **params) -> dict:
"""最近动态分页端点。"""
resp = client.get(f"{_DASHBOARD}/activities", params=params, headers=headers)
assert resp.status_code == 200, resp.text
return resp.json()["data"]
def test_权限_普通用户403文案(client: TestClient, normal: dict):
resp = client.get(_DASHBOARD, headers=normal["headers"])
assert resp.status_code == 403
assert resp.json()["message"] == "无权访问工作台"
# 最近动态分页端点同属工作台,权限一致
resp = client.get(f"{_DASHBOARD}/activities", headers=normal["headers"])
assert resp.status_code == 403
def test_总览_绩效合计与台账一致且非法月份400(client: TestClient, admin: dict):
@@ -138,9 +148,7 @@ def test_总览_绩效合计与台账一致且非法月份400(client: TestClient
expected = Decimal(
str(
session.execute(
select(
func.coalesce(func.sum(PerformanceLedger.performance_amount), 0)
).where(
select(func.coalesce(func.sum(PerformanceLedger.performance_amount), 0)).where(
PerformanceLedger.settlement_month == _month(),
PerformanceLedger.calc_type == 1,
PerformanceLedger.is_active == 1,
@@ -166,16 +174,12 @@ def test_待办_待审批与未指定批次与同步降级(client: TestClient, a
)
assert resp.status_code == 200, resp.text
settlement_id = resp.json()["data"]["id"]
resp = client.post(
f"{_SETTLEMENTS}/{settlement_id}/submit", headers=admin["headers"]
)
resp = client.post(f"{_SETTLEMENTS}/{settlement_id}/submit", headers=admin["headers"])
assert resp.status_code == 200, resp.text
data = _dashboard(client, admin["headers"])
assert data["todos"]["pending_approval_total"] >= 1
assert any(
item["order_type"] == 2 for item in data["todos"]["pending_approvals"]
)
assert any(item["order_type"] == 2 for item in data["todos"]["pending_approvals"])
assert data["todos"]["unassigned_receipt_total"] >= 1
assert data["todos"]["unassigned_receipts"][0]["receipt_id"] == ctx["receipt"]["id"]
# ⑥ 未建:显式降级
@@ -227,9 +231,40 @@ def test_预警_审批超时命中且参数关闭后不产生(client: TestClient
def test_动态_来源操作日志(client: TestClient, admin: dict):
_chain(client, admin["headers"])
data = _dashboard(client, admin["headers"])
assert data["activities"], "工作台动态为空:写操作应产生操作日志"
assert {"module", "action", "summary", "create_time"} <= set(data["activities"][0])
data = _activities(client, admin["headers"])
assert data["list"], "工作台动态为空:写操作应产生操作日志"
assert {"module", "action", "summary", "create_time"} <= set(data["list"][0])
def test_动态_分页翻页不重不漏(client: TestClient, admin: dict):
_chain(client, admin["headers"])
first = _activities(client, admin["headers"], pageNum=1, pageSize=2)
assert first["pageNum"] == 1
assert first["pageSize"] == 2
assert len(first["list"]) == 2
assert first["total"] >= 2
second = _activities(client, admin["headers"], pageNum=2, pageSize=2)
assert len(second["list"]) == 2
# 两页不重不漏(倒序取,按 id 倒排)
first_ids = {item["id"] for item in first["list"]}
second_ids = {item["id"] for item in second["list"]}
assert first_ids.isdisjoint(second_ids)
assert first["list"][0]["id"] > second["list"][0]["id"]
def test_动态_模块筛选与筛选项(client: TestClient, admin: dict):
_chain(client, admin["headers"])
page = _activities(client, admin["headers"], pageSize=50)
assert page["modules"], "模块筛选项不应为空"
module = page["modules"][0]
filtered = _activities(client, admin["headers"], module=module, pageSize=50)
assert filtered["total"] >= 1
assert filtered["list"], "按模块筛选后应有数据"
assert all(item["module"] == module for item in filtered["list"])
# 筛选项不随筛选条件变化(下拉里始终是全量模块)
assert filtered["modules"] == page["modules"]
# ==== 角色待办 / 预警阈值 / 趋势(2026-10-19 code-review 修复回归)====
@@ -335,17 +370,17 @@ def test_待办_按角色仅显示可操作节点(
assert _dashboard(client, both["headers"])["todos"]["pending_approval_total"] == 2
finance_orders = {
item["order_no"] for item in _dashboard(client, finance["headers"])["todos"]["pending_approvals"]
item["order_no"]
for item in _dashboard(client, finance["headers"])["todos"]["pending_approvals"]
}
leader_orders = {
item["order_no"] for item in _dashboard(client, leader["headers"])["todos"]["pending_approvals"]
item["order_no"]
for item in _dashboard(client, leader["headers"])["todos"]["pending_approvals"]
}
assert finance_orders.isdisjoint(leader_orders)
def test_待办_财务通过后领导出现待办(
client: TestClient, admin: dict, finance: dict, leader: dict
):
def test_待办_财务通过后领导出现待办(client: TestClient, admin: dict, finance: dict, leader: dict):
"""真实流转:财务节点通过后,条目从财务待办消失并进入领导待办。"""
_chain(client, admin["headers"])
resp = client.post(
@@ -353,9 +388,7 @@ def test_待办_财务通过后领导出现待办(
)
assert resp.status_code == 200, resp.text
settlement_id = resp.json()["data"]["id"]
resp = client.post(
f"{_SETTLEMENTS}/{settlement_id}/submit", headers=admin["headers"]
)
resp = client.post(f"{_SETTLEMENTS}/{settlement_id}/submit", headers=admin["headers"])
assert resp.status_code == 200, resp.text
order = _dashboard(client, admin["headers"])["todos"]["pending_approvals"][0]
@@ -326,6 +326,38 @@ class TestPreview:
# Fraction 自动约分:4/1000 存储为 1/250,按数值断言
assert Fraction(data["rate"]) == Fraction(4, 1000)
def test_预览参数使用顺序稳定(self, client: TestClient, admin: dict) -> None:
"""params_used 的顺序即前端「用了哪些参数」的展示顺序。
预览实现拆成多段后,各段的调用先后决定参数读取先后;顺序漂移会让
复核者看到的参数清单与核算口径对不上(不报错,但会误导判读)。
"""
payload = {
"business_role": "salesperson",
"customer_type": "personal",
"product_category": "municipal_water",
"ship_date": "2025-12-15",
"receipt_date": "2026-01-10",
"revenue": "100000",
"profit": "45000",
"received_amount": "60000",
"barter": True,
"probation": True,
}
data = client.post(f"{_PARAMS}/preview", json=payload, headers=admin["headers"]).json()[
"data"
]
assert [p["param_code"] for p in data["params_used"]] == [
"salesperson.rate.start",
"salesperson.rate.slope",
"salesperson.rate.floor",
"salesperson.days.boundary",
"product.threshold",
"barter.discount",
"payment.start.ratio",
"probation.annual.threshold",
]
def test_经理聚合口径_预览拒绝(self, client: TestClient, admin: dict) -> None:
payload = {
"business_role": "sales_manager",
@@ -339,7 +371,11 @@ class TestPreview:
}
resp = client.post(f"{_PARAMS}/preview", json=payload, headers=admin["headers"])
assert resp.status_code == 400
assert "聚合" in resp.json()["message"]
# 守两件事:① 聚合口径不允许单笔试算(拒绝而不是算出个错数);
# ② 提示要能指导下一步(点名是谁的口径 + 去哪儿看),不是一句「不支持」。
message = resp.json()["message"]
assert "营销经理" in message
assert "月度结算" in message
def test_回款日早于发货日_400(self, client: TestClient, admin: dict) -> None:
payload = {
+36 -33
View File
@@ -44,7 +44,9 @@ def _role_id(code: str) -> int:
)
def _person(client: TestClient, headers: dict, *, role: str = "salesperson", manager_id=None) -> dict:
def _person(
client: TestClient, headers: dict, *, role: str = "salesperson", manager_id=None
) -> dict:
body = {
"personnel_name": f"t_报表_{uuid.uuid4().hex[:8]}",
"business_role_id": _role_id(role),
@@ -194,9 +196,7 @@ def test_部门汇总_无成员经理出零值行(admin, client: TestClient):
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
team = next(
(t for t in resp.json()["data"]["teams"] if t["manager_id"] == manager["id"]), None
)
team = next((t for t in resp.json()["data"]["teams"] if t["manager_id"] == manager["id"]), None)
assert team is not None
assert Decimal(str(team["team_base"])) == Decimal("0")
assert team["member_count"] == 0
@@ -224,7 +224,14 @@ def test_回款统计_空桶恒在且分摊拆行金额只计一次(admin, clien
assert len(data["buckets"]) == 8
assert [b["code"] for b in data["buckets"]] == [
"le0", "1-30", "31-60", "61-90", "91-180", "181-360", "361+", "none",
"le0",
"1-30",
"31-60",
"61-90",
"91-180",
"181-360",
"361+",
"none",
]
# 60 天前发货 → 落在 61-90 桶(发货日首日计第 1 天)
bucket = next(b for b in data["buckets"] if b["code"] == "61-90")
@@ -293,11 +300,15 @@ def test_导出_excel含签字栏且入操作日志(admin, client: TestClient):
factory = get_session_factory()
with factory() as session:
log = session.execute(
select(OperationLog)
.where(OperationLog.module == "报表统计", OperationLog.action == "导出")
.order_by(OperationLog.id.desc())
).scalars().first()
log = (
session.execute(
select(OperationLog)
.where(OperationLog.module == "报表统计", OperationLog.action == "导出")
.order_by(OperationLog.id.desc())
)
.scalars()
.first()
)
assert log is not None
@@ -389,19 +400,21 @@ def _latest_export_log():
factory = get_session_factory()
with factory() as session:
return session.execute(
select(OperationLog)
.where(OperationLog.module == "报表统计", OperationLog.action == "导出")
.order_by(OperationLog.id.desc())
).scalars().first()
return (
session.execute(
select(OperationLog)
.where(OperationLog.module == "报表统计", OperationLog.action == "导出")
.order_by(OperationLog.id.desc())
)
.scalars()
.first()
)
def test_部门汇总_无台账经理挂上级不报500(admin, client: TestClient):
"""经理 A 无台账且上级是经理 B:不能因 member_map 直接索引而 KeyError。"""
manager_b = _person(client, admin["headers"], role="sales_manager")
manager_a = _person(
client, admin["headers"], role="sales_manager", manager_id=manager_b["id"]
)
manager_a = _person(client, admin["headers"], role="sales_manager", manager_id=manager_b["id"])
resp = client.get(
f"{_REPORTS}/department-summary",
@@ -467,9 +480,7 @@ def test_部门汇总_经理个人户计入时基数等于成员合计(admin, cl
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
team = next(
t for t in resp.json()["data"]["teams"] if t["manager_id"] == manager["id"]
)
team = next(t for t in resp.json()["data"]["teams"] if t["manager_id"] == manager["id"])
assert Decimal(str(team["team_base"])) == Decimal("100.00")
assert sum(
(Decimal(str(m["performance_sum"])) for m in team["members"]), Decimal("0")
@@ -516,9 +527,7 @@ def test_部门汇总_按经理筛选不误判其他团队下属(admin, client:
assert [t["manager_id"] for t in data["teams"]] == [manager_1["id"]]
assert data["unassigned"] == []
assert all(
m["personnel_id"] != salesperson_2["id"]
for team in data["teams"]
for m in team["members"]
m["personnel_id"] != salesperson_2["id"] for team in data["teams"] for m in team["members"]
)
assert Decimal(str(data["reconciliation"]["other_team_base"])) > 0
@@ -557,9 +566,7 @@ def test_部门汇总_对账恒等式(admin, client: TestClient):
)
def _two_manager_scene(
client: TestClient, headers: dict
) -> tuple[dict, dict, dict, dict, dict]:
def _two_manager_scene(client: TestClient, headers: dict) -> tuple[dict, dict, dict, dict, dict]:
"""两位经理各有下属与业务 + 一位零成员经理(无聚合行)。"""
manager_1 = _person(client, headers, role="sales_manager")
manager_2 = _person(client, headers, role="sales_manager")
@@ -777,9 +784,7 @@ def test_导出取数异常_失败审计留痕(admin, client: TestClient, monkey
assert "导出个人绩效工资明细表" in (log.summary or "")
@pytest.mark.parametrize(
"path", ["/personal-detail", "/department-summary", "/payment-stats"]
)
@pytest.mark.parametrize("path", ["/personal-detail", "/department-summary", "/payment-stats"])
def test_非法月份_查询返回400(admin, client: TestClient, path: str):
"""非法月份必须是参数错误 400,不能落 500。"""
resp = client.get(
@@ -790,9 +795,7 @@ def test_非法月份_查询返回400(admin, client: TestClient, path: str):
assert resp.status_code == 400, resp.text
@pytest.mark.parametrize(
"code", ["personal-detail", "department-summary", "payment-stats"]
)
@pytest.mark.parametrize("code", ["personal-detail", "department-summary", "payment-stats"])
def test_非法月份_导出返回400(admin, client: TestClient, code: str):
"""导出手工构造查询模型同样要转 400。"""
resp = client.get(
+3 -6
View File
@@ -127,8 +127,7 @@ class TestExcel模板:
cell
for row in sheet.iter_rows()
for cell in row
if isinstance(cell.value, (int, float))
and Decimal(str(cell.value)) == Decimal("39.11")
if isinstance(cell.value, (int, float)) and Decimal(str(cell.value)) == Decimal("39.11")
]
assert amount_cells, "未找到金额数值单元格(Decimal 被转成了文本?)"
assert amount_cells[0].number_format == "#,##0.00"
@@ -142,16 +141,14 @@ class TestExcel模板:
workbook = load_workbook(io.BytesIO(content))
cells = [cell for row in workbook.active.iter_rows() for cell in row]
values = [
Decimal(str(cell.value))
for cell in cells
if isinstance(cell.value, (int, float))
Decimal(str(cell.value)) for cell in cells if isinstance(cell.value, (int, float))
]
assert Decimal("15.50") in values
# 「合计」标签只写一次(写满每个文本列是回归缺陷)
assert sum(1 for cell in cells if cell.value == "合计") == 1
def test_文本公式注入被前置单引号(self):
evil = "=HYPERLINK(\"http://evil\")"
evil = '=HYPERLINK("http://evil")'
content = _build("personal-detail", [_personal_row(business_no=evil)])
workbook = load_workbook(io.BytesIO(content))
values = [
+26 -24
View File
@@ -366,6 +366,8 @@ CREATE TABLE customers (
customer_type VARCHAR(32) NOT NULL COMMENT '客户分类:personal个人户 manager_personal经理个人户 enterprise企业户(计提规则匹配锚点;T+同步不覆盖)' DEFAULT 'personal',
owner_personnel_id BIGINT UNSIGNED COMMENT '归属负责营销人员ID(手动修改不回溯历史,变更留痕见 ownership_change_logs)',
remark VARCHAR(255) COMMENT '备注',
rating_score TINYINT COMMENT '评级总分(0-100,评级计算器保存;null=未评分;人工维护字段,T+ 同步不覆盖)',
rating_time DATETIME COMMENT '最近评级时间(评级计算器保存评分时刷新;人工维护字段)',
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
source_no VARCHAR(64) COMMENT 'T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)',
@@ -437,29 +439,29 @@ INSERT INTO param_change_batches (id, version_no, effective_date, change_type, r
(1, 'P20260101-00', '2026-01-01', 1, '初始版本(系统初始化种子,口径依据 2026-10-12 业务裁决与 2026-10-19 澄清答复)', 'system', 19);
INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES
(1, 'salesperson.rate.start', '业务员起始计提比例', '业务员个人户', 'DECIMAL', '‰', 'BUSINESS_ROLE', NULL, '10', '0', NULL, '线性递减段起始值:比例 = 起始 − 回款天数 × 斜率(≤分界天数)', 1),
(2, 'salesperson.rate.slope', '业务员日递减斜率', '业务员个人户', 'DECIMAL', '‰/天', 'BUSINESS_ROLE', NULL, '4/360', '0', NULL, '精确分数 4/360‰/天(=1/90),禁止按 0.011111 截断存储', 2),
(3, 'salesperson.rate.floor', '业务员计提下限', '业务员个人户', 'DECIMAL', '‰', 'BUSINESS_ROLE', NULL, '4', '0', NULL, '回款天数超过分界天数后固定取此比例(≥361 天 = 4‰)', 3),
(4, 'salesperson.days.boundary', '线性递减分界天数', '业务员个人户', 'INT', '天', 'BUSINESS_ROLE', NULL, '360', '0', NULL, '≤分界天数按公式线性递减(360 天=6‰);超过则取下限', 4),
(5, 'manager.share.team', '经理本团队分成比例', '经理分成', 'DECIMAL', '%', 'BUSINESS_ROLE', NULL, '12.5', '0', '100', '∑本人带领业务员绩效 × 此比例(限本团队)', 5),
(6, 'manager.share.company', '经理全公司分成比例', '经理分成', 'DECIMAL', '%', 'BUSINESS_ROLE', NULL, '5', '0', '100', '∑全部业务员绩效 × 此比例(全公司)', 6),
(7, 'enterprise.rate', '企业户计提比例', '企业户', 'DECIMAL', '‰', 'PRODUCT_CATEGORY', NULL, '3', '0', NULL, '所有企业户适用;2026-08-13 起由 2‰ 提高至 3‰', 7),
(8, 'info.rate', '信息员计提比例', '信息员', 'DECIMAL', '‰', 'BUSINESS_ROLE', NULL, '1', '0', NULL, '信息员绩效 = 本人销售额 × 此比例', 8),
(9, 'prepay.rate', '预付款计提比例', '支付环节', 'DECIMAL', '‰', 'NONE', NULL, '10', '0', NULL, '预付款统一按此比例计提', 9),
(10, 'barter.discount', '以物抵顶折算比例', '支付环节', 'DECIMAL', '%', 'NONE', NULL, '80', '0', '100', '以物抵顶回款绩效按应发绩效的此比例折算', 10),
(11, 'payment.start.ratio', '支付起付比例', '支付环节', 'DECIMAL', '%', 'NONE', NULL, '60', '0', '100', '单项业务回款额达销售额(不含运费)此比例后起付;达标前累计', 11),
(12, 'payment.weight.mode', '支付权重应用方式', '支付环节', 'ENUM', NULL, 'NONE', 'FULL|BALANCE', 'FULL', NULL, NULL, 'FULL=按 R×R 乘当月全额应发绩效;BALANCE=乘余额(语义待 M3 前确认)', 12),
(13, 'product.threshold', '折算毛利润额阈值', '产品阈值', 'DECIMAL', '%', 'PRODUCT_CATEGORY', NULL, '30', '0', '100', '折算毛利润额 = 销售额 ×(毛利润率 − 阈值);市政水利 30%、轨交 20%', 13),
(14, 'salary.basic.mode', '月工资基本工资口径', '月工资口径', 'ENUM', NULL, 'BUSINESS_ROLE', 'INCLUDE|DEDUCT|EXCLUDE', 'INCLUDE', NULL, NULL, 'INCLUDE含基本工资 / DEDUCT扣除基本工资 / EXCLUDE不计', 14),
(15, 'probation.annual.threshold', '试用期年累计结算阈值', '月工资口径', 'DECIMAL', '元', 'NONE', NULL, '18000', '0', NULL, '试用期年累计绩效超过此值部分年底一次性结算', 15),
(17, 'approval.secondary_allocation.enabled', '二次分配审批开关', '审批开关', 'BOOL', NULL, 'NONE', NULL, '1', NULL, NULL, '0=二次分配方案提交即生效(系统自动通过全节点留痕,留痕不断链);1=提交后进入财务复核→领导审核审批流。默认开启(澄清 #5 业务答复 2026-10-19),业务可关闭', 17),
(18, 'approval.monthly_settlement.enabled', '月度结算审批开关', '审批开关', 'BOOL', NULL, 'NONE', NULL, '1', NULL, NULL, '月度结算审批链开关(④ 月结提交时消费):0=结算生成即生效,1=走财务复核→领导审核审批流。默认开启(澄清 #7 业务答复 2026-10-19,月结涉及工资发放从严),业务可关闭', 18),
(19, 'approval.adjustment.enabled', '调整单审批开关', '审批开关', 'BOOL', NULL, 'NONE', NULL, '0', NULL, NULL, '0=调整单提交即生效(系统自动通过留痕);1=走财务复核→领导审核审批流。调整单是已锁定月份补发/扣减的唯一通道(澄清 #9:走同一审批链,默认值业务未明确,暂按直接生效,业务可开启)', 19),
(20, 'manager.personal.included', '经理个人户计入聚合基数', '经理分成', 'BOOL', NULL, 'NONE', NULL, '0', NULL, NULL, '经理 12.5%+5% 聚合时是否计入经理本人名下经理个人户业绩(澄清 #12 业务答复 2026-10-19:作为参数配置、手动调整是否纳入):0=不计入(默认,暂口径) 1=计入', 20),
-- 预警规则参数(M4 ⑧ 工作台,澄清 #10「做成参数化规则便于调整」)
(21, 'warning.enabled', '风险预警总开关', '预警规则', 'BOOL', NULL, 'NONE', NULL, '1', NULL, NULL, '工作台风险预警区块总开关:0=关闭(不扫描不展示) 1=开启(默认)', 21),
(22, 'warning.receipt.overdue.days', '回款逾期天数阈值', '预警规则', 'INT', '天', 'NONE', NULL, '90', '1', NULL, '发货后超过该天数仍未回款的发货批次触发预警(澄清 #10 建议默认 90 天)', 22),
(23, 'warning.approval.timeout.days', '审批超时天数阈值', '预警规则', 'INT', '天', 'NONE', NULL, '3', '1', NULL, '待审批单提交后超过该天数未处理触发预警(澄清 #10 建议默认 3 天)', 23);
(1, 'salesperson.rate.start', '业务员起始计提比例', '业务员个人户', 'DECIMAL', '‰', 'BUSINESS_ROLE', NULL, '10', '0', NULL, '回款当天能拿到的最高计提比例。回款每晚一天,按日递减斜率往下走,直到分界天数为止。', 1),
(2, 'salesperson.rate.slope', '业务员日递减斜率', '业务员个人户', 'DECIMAL', '‰/天', 'BUSINESS_ROLE', NULL, '4/360', '0', NULL, '回款每延后一天,计提比例下降多少。按分数填写(如 4/360)可保持精确,填小数会引入四舍五入误差。', 2),
(3, 'salesperson.rate.floor', '业务员计提下限', '业务员个人户', 'DECIMAL', '‰', 'BUSINESS_ROLE', NULL, '4', '0', NULL, '回款天数超过分界天数后,计提比例不再下降,固定按这个值计算,即最低计提比例。', 3),
(4, 'salesperson.days.boundary', '线性递减分界天数', '业务员个人户', 'INT', '天', 'BUSINESS_ROLE', NULL, '360', '0', NULL, '回款天数在这个范围内按日递减;超过这个天数就固定按下限计提。默认 360 天。', 4),
(5, 'manager.share.team', '经理本团队分成比例', '经理分成', 'DECIMAL', '%', 'BUSINESS_ROLE', NULL, '12.5', '0', '100', '经理按本团队业务员的绩效总额提取的分成比例,只统计自己带的业务员。', 5),
(6, 'manager.share.company', '经理全公司分成比例', '经理分成', 'DECIMAL', '%', 'BUSINESS_ROLE', NULL, '5', '0', '100', '经理按全公司业务员的绩效总额提取的分成比例,与本团队分成叠加计算。', 6),
(7, 'enterprise.rate', '企业户计提比例', '企业户', 'DECIMAL', '‰', 'PRODUCT_CATEGORY', NULL, '3', '0', NULL, '企业户客户的回款统一按这个比例计提,不看回款天数。2026-08-13 起由 2‰ 调整为 3‰。', 7),
(8, 'info.rate', '信息员计提比例', '信息员', 'DECIMAL', '‰', 'BUSINESS_ROLE', NULL, '1', '0', NULL, '信息员的绩效按本人销售额乘这个比例计算,不随回款天数递减。', 8),
(9, 'prepay.rate', '预付款计提比例', '支付环节', 'DECIMAL', '‰', 'NONE', NULL, '10', '0', NULL, '预付款到账即按这个比例计提绩效,不必等到全额回款。', 9),
(10, 'barter.discount', '以物抵顶折算比例', '支付环节', 'DECIMAL', '%', 'NONE', NULL, '80', '0', '100', '以物抵顶的回款,按应发绩效的这个比例折算,即打折后的金额参与绩效。', 10),
(11, 'payment.start.ratio', '支付起付比例', '支付环节', 'DECIMAL', '%', 'NONE', NULL, '60', '0', '100', '单项业务的回款累计达到销售额(不含运费)的这个比例后才开始发放绩效;达标之前只累计、不发放。', 11),
(12, 'payment.weight.mode', '支付权重应用方式', '支付环节', 'ENUM', NULL, 'NONE', 'FULL|BALANCE', 'FULL', NULL, NULL, '选择支付权重乘在哪个基数上:当月全额 = 乘本月全部应发绩效;余额 = 只乘尚未发放的部分。', 12),
(13, 'product.threshold', '折算毛利润额阈值', '产品阈值', 'DECIMAL', '%', 'PRODUCT_CATEGORY', NULL, '30', '0', '100', '折算毛利润额 = 销售额 ×(毛利润率 − 阈值),低于阈值的部分不参与绩效。市政水利默认 30%,轨道交通默认 20%。', 13),
(14, 'salary.basic.mode', '月工资基本工资口径', '月工资口径', 'ENUM', NULL, 'BUSINESS_ROLE', 'INCLUDE|DEDUCT|EXCLUDE', 'INCLUDE', NULL, NULL, '算月工资时怎么处理基本工资:含基本工资 = 与绩效一起发;扣除基本工资 = 从绩效里扣掉已发的基本工资;不计 = 月工资只发绩效。', 14),
(15, 'probation.annual.threshold', '试用期年累计结算阈值', '月工资口径', 'DECIMAL', '元', 'NONE', NULL, '18000', '0', NULL, '试用期绩效先不发放;年内累计超过这个金额的部分,年底一次性结算。', 15),
(17, 'approval.secondary_allocation.enabled', '二次分配审批开关', '审批开关', 'BOOL', NULL, 'NONE', NULL, '1', NULL, NULL, '二次分配方案提交后是否要审批:开启 = 先经财务复核、再经领导审核,通过后才生效;关闭 = 提交即生效,系统自动记录各节点留痕。', 17),
(18, 'approval.monthly_settlement.enabled', '月度结算审批开关', '审批开关', 'BOOL', NULL, 'NONE', NULL, '1', NULL, NULL, '月度结算提交后是否要审批:开启 = 先经财务复核、再经领导审核;关闭 = 生成即生效。月结涉及工资发放,默认开启。', 18),
(19, 'approval.adjustment.enabled', '调整单审批开关', '审批开关', 'BOOL', NULL, 'NONE', NULL, '0', NULL, NULL, '调整单提交后是否要审批:开启 = 先经财务复核、再经领导审核;关闭 = 提交即生效。调整单是已锁定月份补发或扣减的唯一通道。', 19),
(20, 'manager.personal.included', '经理个人户计入聚合基数', '经理分成', 'BOOL', NULL, 'NONE', NULL, '0', NULL, NULL, '经理提取团队分成时,是否把经理本人名下的业绩(经理个人户)也算进分成基数。默认不计入。', 20),
-- 预警规则参数:工作台预警阈值,可按业务需要调整
(21, 'warning.enabled', '风险预警总开关', '预警规则', 'BOOL', NULL, 'NONE', NULL, '1', NULL, NULL, '工作台风险预警区块的总开关:关闭后不再扫描、也不展示预警条目。默认开启。', 21),
(22, 'warning.receipt.overdue.days', '回款逾期天数阈值', '预警规则', 'INT', '天', 'NONE', NULL, '90', '1', NULL, '发货后超过这么多天仍未收到回款的发货批次,会出现在工作台的预警列表里。默认 90 天。', 22),
(23, 'warning.approval.timeout.days', '审批超时天数阈值', '预警规则', 'INT', '天', 'NONE', NULL, '3', '1', NULL, '审批单提交后超过这么多天还没处理,会出现在工作台的预警列表里。默认 3 天。', 23);
INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES
(1, 1, 'salesperson', '2026-02-17', '10', 1),
@@ -891,4 +893,4 @@ CREATE TABLE alembic_version (
CONSTRAINT alembic_version_pkc PRIMARY KEY (version_num)
)CHARSET=utf8mb4 ENGINE=InnoDB COLLATE utf8mb4_general_ci;
INSERT INTO alembic_version (version_num) VALUES ('7f4f7b918bc4');
INSERT INTO alembic_version (version_num) VALUES ('c3a9e1d7b2f4');
+10
View File
@@ -0,0 +1,10 @@
-- V10: 客户评级落库(2026-10-23)
-- 评级计算器从独立工具页并入客户管理(行内弹窗),评分保存为客户档案属性;
-- 两列可空(null=未评分,存量客户无需回填),人工维护字段,T+ 同步不覆盖。
-- 对应迁移:alembic/versions/c3a9e1d7b2f4_v13_客户评级落库.py
-- 每列一条 ALTER:test_schema_sync 按「ALTER TABLE … ADD COLUMN」逐条解析,
-- 一条语句挂多个 ADD 只会解析出第一列,两份 DDL 会假性漂移。
ALTER TABLE `customers`
ADD COLUMN `rating_score` TINYINT COMMENT '评级总分(0-100,评级计算器保存;null=未评分;人工维护字段,T+ 同步不覆盖)' AFTER `remark`;
ALTER TABLE `customers`
ADD COLUMN `rating_time` DATETIME COMMENT '最近评级时间(评级计算器保存评分时刷新;人工维护字段)' AFTER `rating_score`;
File diff suppressed because it is too large. Load diff
Binary file not shown.
+59 -5
View File
@@ -79,7 +79,7 @@ pnpm build # 类型检查 + 生产构建
## 视觉设计
**设计立意**:公司名「衍水」是水,而中文「流水账」字面就是**流水之账**——系统的全部工作正是记录回款怎么流、绩效怎么算。视觉方向由此而生:中性灰白的水面纸感 + 靛蓝点缀,安静、克制,长时间核数不累眼。完整规范见 `docs/design/视觉方向预览.html`(2026-10-12 起为 v3「质感版」)。
**设计立意**:公司名「衍水」是水,而中文「流水账」字面就是**流水之账**——系统的全部工作正是记录回款怎么流、绩效怎么算。视觉方向由此而生:中性灰白的水面纸感 + 靛蓝点缀,安静、克制,长时间核数不累眼。完整规范见 `docs/design/视觉方向预览.html`(2026-10-12 起为 v3「质感版」;2026-10-22 升 v4「同步版」——设计稿与已交付的月结 / 审批 / 报表 / 工作台对齐,色板、字体与组件基调未变)。
**配色**(靛蓝只作重点点缀——主按钮 / 链接 / 焦点 / 当前菜单,大面积用中性灰白)
@@ -100,17 +100,65 @@ pnpm build # 类型检查 + 生产构建
**落地方式**:品牌色已提升为全局 CSS 变量(`src/assets/styles/index.css` 的 `:root`,`--ys-*` 前缀),登录页、布局与各业务页面共用;全局把 `--el-color-primary` 指向靛蓝,并按 Element Plus 公式预计算了 light-3/5/7/8/9 与 dark-2 色阶(不用 `color-mix()`,避免依赖较新浏览器特性)。旧 token 名(深水青 / 金色系列)在 `:root` 中保留映射,存量页面迁移完成后移除。
**签名元素**:**渐隐靛蓝水线**——仪表盘等页面标题下的细线(56px,左实右虚)与登录页品牌名下的短线(40px),「衍水」之水的提纯,全站唯一的装饰性元素;登录页右上角另有涟漪同心圆与顶部靛蓝天光作背景(低透明度)。与早期深色面板 + 水纹层理同一意象,品牌连续而非推倒重来。
**语义三色**(2026-10-22 落地):成功 `#1e9e6a` / 警示 `#c98a1b` / 危险 `#c94f4f`,已覆盖 `--el-color-success/warning/danger/error` 及其 `-rgb`、`light-3/5/7/8/9`、`dark-2` 全阶。Element Plus 默认的绿黄红饱和度过高,放在灰底卡片上会跳色。**`-rgb` 变体必须一并覆盖**——组件库的半透明底色用 `rgba(var(--el-color-success-rgb), .1)` 取色,漏改会出现「文字是新的、底色还是旧的」。色阶按 Element Plus 公式预计算,不用 `color-mix()`。
**组件基调**(同样写在 `src/assets/styles/index.css`,页面不要再重复):按钮高 34px / 圆角 7px / 字号 13px,主按钮是 `#3a58bc → #2e4ba8` 微渐变 + 内高光 + 悬停上抬 1px 并加深阴影;输入框高 34px,焦点为靛蓝描边加 3px 柔光外环;表头 12px/500/字距 `0.04em` + 极浅渐变底,单元格内距 12px 16px、行高 1.5,行悬停 120ms;分页需 `background` 模式,当前页与主按钮同材质;`el-tag` 压到 11px 方角。动效统一 120–180ms,只做颜色、阴影与 1px 位移,并尊重 `prefers-reduced-motion`。
**表格行高必须是 1.5**(全局 `.el-table .cell`):Element Plus 默认给单元格 23px 行高(≈1.77 倍),加上 12px 上下内距,单行数据行会到 51px——一屏少看两行。收到 1.5 后单行数据行 44px、纯文本行 44px,与设计稿「12px 内距 + 13px 字」对齐;多行单元格仍按内容撑开。
**页面版式:只用全局类**(新页面不再各写一套)
| 类 | 用途 |
| --- | --- |
| `.page-head` / `.page-title` / `.page-desc` / `.waterline` | 页面头:18px 标题 + 一句 12px 描述 + 水线(**每页一条,不重复**) |
| `.card` / `.card-head` / `.card-title` / `.card-body` / `.card-foot` | 内容卡片;卡脚右对齐,承载分页或合计条 |
| `.filter-bar` / `.filter-bar__actions` | 筛选条(横向 flex);按钮组靠右 |
| `.toolbar` / `.toolbar-note` | 主操作行 / 右侧提示文字 |
| `.section-title` | 区块小标题(13px/600) |
| `.cell-code` / `.cell-num` / `.cell-muted` | 表格列语义:等宽编号 / 数字右对齐 + 等宽数字 / 弱化文本 |
| `.ys-pill` + `--ok` `--wait` `--lock` `--reject` | 状态胶囊(浅底 + 6px 圆点),四档语义与月结生命周期一致 |
| `.ys-rise` | 入场上浮动画(卡片默认已带) |
`.cell-num` 有两种用法:直接挂在 `td` 上,或包一层 `<span class="cell-num">` 处理「一行里既文本又数字」。给后者单独撑满一行是必要的——行内元素的 `text-align` 无效;但**不能**给 `.cell-num` 统一加 `display`,那会把 `td` 从 `table-cell` 变成 `inline-block`,表格列宽算法随即失效。
**表单页版式(公司信息页为样板)**
设置类表单页不要把表单缩在左上角一小块——内容区再宽也只占三分之一,页面看着像没做完。`views/system/settings/index.vue` 是基准:
| 做法 | 口径 |
| --- | --- |
| 网格 | 左列 `minmax(0, 1fr)` + 右列固定 480px(实景预览),`align-items: start`;≤1200px 折成单列 |
| 控件行 | 主控件与说明同一行(`.field-control`):控件 ≤420px,说明跟在后面 20px;≥1600px 说明改右对齐贴卡片右缘,整行铺满而不是缩在左上角 |
| 实景预览 | 右侧「登录页预览」按**真实登录页 1:1 复刻**(控件 38px 高、品牌名 17px / 0.34em 字距、logo ≤56×36、三项输入含验证码位、登录按钮、版权行),不是缩比示意——控件高度对不上就等于没有所见即所得 |
| 生效范围 | 独立一张卡列出这份设置会出现在哪(登录页品牌位与版权行 / Excel 导出页眉 / 打印稿页眉),用户不用存完再去别处找 |
| 预览态 | 预览卡 `aria-hidden` + 标注「示意,非交互」,避免被当成可点的登录框 |
**状态与流程表达(2026-10-23 实测补)**
- 月结生命周期条四段圆点按阶段着色:草稿中性 / 待审批琥珀 / 已审定靛蓝 / 已驳回砖红。颜色之外每段仍有文字,不靠颜色单独表意。
- 流程条这类分段控件旁要挂一行操作提示(如「点状态只看该阶段,再点一次取消」)。没有提示时,未选中态的四段读起来像四颗孤立的小按钮,不像一条流程。
- 卡片内空态(工作台 `.b-empty-mini`)在卡片剩余高度内居中,不要全挤在顶部留一整块空白。
**菜单图标**(2026-10-22 落地,M1 遗留项):路由 `meta.icon` 只写**语义 key**,图标实现集中在 `src/components/layout/menuIcons.ts` 的注册表(`@element-plus/icons-vue` 具名导入,可摇树)。换图标库或换风格只改注册表,路由表不动。视觉口径沿用设计稿:16px、透明度 0.7(激活项 1)、右间距 9px;分组标题**不加图标**,只有菜单项有。**新增页面必须补 `meta.icon`**,否则菜单项只有文字。注册表是 **UTF-8** 文件——批量转码脚本会把中文注释转成乱码(2026-10-23 已在该文件踩过一次,已修回),改这个文件不要过任何编码转换。
**侧栏层级**(2026-10-23 改版):组标题与菜单项**同字号 13px**——层级靠「折叠箭头 + 组间留白 + 子项缩进 12px」表达,不靠字号/字重压过父级。前两版都不采用:11px 弱墨「目录行」让父级弱于子项,14px/600 加靛蓝竖标又让父级刻意压过子项、像一排小标题在喊叫。激活态只保留**白底浮起 + 靛蓝字**,不再叠左侧 2px 竖条——竖条只能落在侧栏内边缘、与白底悬空,是重复的第二个信号。菜单横向 10px 留白由 `AppMenu.vue` 的 `.app-menu` 提供,`BasicLayout.vue` 的 `.app-aside__menu` 只写上下内距:两处都用 `padding` 简写会同权重靠注入顺序决胜,横向留白会被整条抹掉、条目贴到侧栏边缘。
**签名元素**:**渐隐靛蓝水线**——各页面标题下的细线(56px,左实右虚)与登录页品牌名下的短线(40px),「衍水」之水的提纯,全站唯一的装饰性元素;登录页右上角另有涟漪同心圆与顶部靛蓝天光作背景(低透明度)。与早期深色面板 + 水纹层理同一意象,品牌连续而非推倒重来。
**打印视图**(`views/reports/print/`):纸面产物与屏幕稿不同源——独立顶层路由、不挂侧栏顶栏,白灰画布上一张定宽白稿纸,表格 11px 细实线、表头浅底左对齐(数值列表头右对齐)、章节编号、三栏签字(制表 / 复核 / 审批),无圆角无阴影。屏幕上的操作条带 `.no-print`,打印时整块消失。超出打印行数上限时只打前 N 行并在稿面明确提示,不静默截断。
**构图**:登录页品牌内容(名称 / 标语 / 水线)**垂直居中聚成一块**,配一层柔和径向光晕作落点。
早期版本用 `space-between` 把品牌与标语顶到两端,中间空出大半屏,两处都成了孤岛——超宽屏会把这种疏离放大得更明显。**内容块要限宽**(`max-width`),否则宽屏下文字会被拉散。
### 写代码时的四条约定
### 写代码时的六条约定
- 覆盖 Element Plus 主题请改 **CSS 变量**(`--el-button-bg-color` 等),不要直接写 `background-color` —— 后者与 `.el-button--primary` 同权重,会受样式注入顺序影响而不稳定
- 中文可在任意字间断行,**关键文案的断行位置要显式控制**(如主标语拆成两个 `<span>`),交给浏览器自动换行会出现单字独占一行的难看结果
- 新页面需响应式到移动端、保留键盘焦点可见、并尊重 `prefers-reduced-motion`
- **确认框统一加 `customClass: 'ys-confirm-box'`,不要再传 `type: 'warning'`** —— Element Plus 的默认 warning 图标会渲染在正文左侧独立成列(多行正文时图标悬在文字块中间),与 v3 靛蓝体系也冲突。全局样式(`src/assets/styles/index.css`)已把图标改为 18px 描边样式并放在标题行左侧,正文与标题文字对齐
- **页面 scoped 里只写本页特有的东西**:表格 / 按钮 / 输入框 / 表头 / 分页的皮肤已经全局覆盖,页面再写一遍与组件库同权重,会受样式注入顺序影响而不稳定;结构用全局类(见上一节表格),样式只补本页独有的块(如展开区、抽屉内的键值表)
- **状态 / 结果列的固定宽度要按胶囊内容给足**:`.cell` 左右各 16px 内距(共 32px),2 字胶囊约 55px、3 字约 67px、4 字约 79px,宽度不够时 Element Plus 会在胶囊右侧画一个省略号(看起来像多了个小点)。口径:2 字列 ≥96px(如 启用/禁用、成功/失败)、3 字列 ≥108px(如 已核算/已替代/执行中)、4 字列 ≥120px(如 以物抵顶)
- **`v-loading` 只罩数据区,不要罩到分页条上**:遮罩会拦截指针事件,盖住分页器后用户点翻页其实点在遮罩上被吞掉,得再点一次,观感就是「数据反复变化」。详见下文「分页与「数据反复变化」的两个坑」
## 验证码图片
@@ -214,14 +262,20 @@ E2E_ADMIN_PASSWORD=<开发库admin密码> python e2e/system_management.py
## 前端页面(2026-10-19 追加 · M4 ⑧ 工作台)
`/dashboard` 由 Bento 占位升级为工作台四区块(**一次聚合请求** `/api/v1/dashboard`):
`/dashboard` 由 Bento 占位升级为工作台(**总览/待办/预警一次聚合请求** `/api/v1/dashboard`;最近动态是分页列表,走独立端点):
| 区块 | 展示 |
| --- | --- |
| 经营绩效总览 | KPI 四卡(本月绩效/应付/核算人数/团队数 + 月结状态);近 6 个月绩效折线 + 回款柱状(ECharts) |
| 待办事项 | 待审批(跳审批管理)、回款未指定批次(跳回款管理)、T+ 同步失败(未启用显式占位) |
| 风险预警 | 回款逾期(danger)/ 审批超时(warning),点击跳处理页;总开关关闭时显示参数提示 |
| 最近动态 | 操作日志时间线,按模块本地筛选 |
| 最近动态 | 操作日志列表,**服务端分页**(每页 10/20/50)+ 按模块筛选 |
- **最近动态独立分页端点**(2026-10-23):`GET /api/v1/dashboard/activities?pageNum&pageSize&module`,返回 `list/total/pageNum/pageSize/modules`(`modules` 是全量模块名,供下拉用,不随翻页变化)。与聚合端点拆开的原因:操作日志可能很长,塞进聚合响应只能截断前 20 条;拆开后**翻页只重取这张列表**,不必重算总览/待办/预警。分页字段沿用全站规范(`pageNum`/`pageSize`/`total`),组件用统一的 `AppPagination` + `usePagedList`(含请求序号防竞态)。
- **分页与「数据反复变化」的两个坑(2026-10-23 实测定位并修复,改分页相关代码前先读)**:
1. **`v-loading` 不能挂在包含分页条的容器上**。Element Plus 的遮罩会拦截指针事件:遮罩一出现,用户点的「上一页 / 下一页」其实点在遮罩上被吞掉,必须再点一次才生效,观感就是「页码和数据对不上、来回跳」。工作台最近动态原先 `v-loading` 挂在 `.activity-body`(同时包住列表与分页条),已改为只罩列表的 `.activity-list-wrap`;其余列表页的 `v-loading` 都挂在 `el-table` 上,分页条在表格之外,天然没有这个问题——**新增页面请照此办理**。
2. **翻页失败必须把分页器退回「屏幕上正在显示的那一页」**。`records` 只在请求成功时更新,而分页器高亮绑的是 `pageNum`;失败不回滚就会出现「高亮第 1 页、列表是第 2 页的数据」的永久错位(用户来回点才恢复)。`usePagedList` 里用 `shownPageNum` / `shownPageSize` 记录实际显示的页,失败时 `restoreShownPage(seq)` 退回;**回滚本身也要防竞态**——只有本次请求仍是最新请求(`seq === requestSeq`)才回滚,否则连点时旧请求失败会把用户刚点出的新页码覆盖掉,表现为「页码自己跳回去」。页码与条数在 `await` 之前取快照,避免等待期间被别的点击改动后记错「显示的页」。
- 两个缺陷都用 Playwright 探针实测过:`ai-aux/preview/probe_pager7.py`(8/8:正常连点、请求在飞时命中分页器而非遮罩、失败回滚、解除注入后恢复)、`probe_pager13.py`(旧请求失败不拉回页码)、`probe_pager14.py`(点第 3 页失败退回第 1 页、连续两次失败仍停在当前页、解除注入后可正常翻页)、`probe_pager8.py`(其它 `usePagedList` 列表页回归)。
- **ECharts 5.6 按需引入**:`echarts/core` + `BarChart/LineChart/Grid/Tooltip` + `SVGRenderer`(与设计预览一致),只注册用到的模块。
- **字体红线**:图表 `textStyle.fontFamily` 必须显式设为思源黑体栈——ECharts 在 Windows 默认微软雅黑(字体商用合规要求)。
+1
View File
@@ -11,6 +11,7 @@
"type-check": "vue-tsc --noEmit"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"@vue/devtools-api": "^8.1.5",
"axios": "^1.20.0",
"echarts": "^5.6.0",
+3
View File
@@ -8,6 +8,9 @@ importers:
.:
dependencies:
'@element-plus/icons-vue':
specifier: ^2.3.2
version: 2.3.2(vue@3.5.43(typescript@5.9.3))
'@vue/devtools-api':
specifier: ^8.1.5
version: 8.2.1
+27
View File
@@ -21,6 +21,10 @@ export interface CustomerItem {
owner_personnel_id: number | null
owner_personnel_name: string | null
remark: string | null
/** 评级总分(0-100,评级计算器保存;null=未评分) */
rating_score: number | null
/** 最近评级时间(评级计算器保存评分时刷新;null=从未评级) */
rating_time: string | null
}
export interface CustomerQueryParams {
@@ -30,6 +34,10 @@ export interface CustomerQueryParams {
keyword?: string
customer_type?: string
owner_personnel_id?: number
/** 排序键(后端白名单:id 建档序 / rating_score 评分),默认 id 倒序 */
order_by?: 'id' | 'rating_score'
/** 排序方向,默认 desc */
order?: 'asc' | 'desc'
}
/** 新增客户(owner_personnel_id 为初始归属,可空:先建档后派归属也合法) */
@@ -57,6 +65,17 @@ export interface CustomerOwnershipParams {
reason?: string | null
}
/**
* 保存评级入参:只传总分。
*
* 等级不落库,也不由客户端传入 —— 后端只存总分与评级时间,等级由分数按
* 当前等级映射实时推导(前端 utils/rating.ts)。不收客户端传来的等级,
* 否则改请求体就能把 30 分存成 A 级。
*/
export interface CustomerRatingParams {
score: number
}
/** 归属变更留痕条目(对应后端 `OwnershipChangeLogOut`;姓名可随人员消失,ID 固化) */
export interface OwnershipLogItem {
id: number
@@ -114,6 +133,14 @@ export function changeOwnership(
return request({ url: `/v1/base/customers/${customerId}/ownership`, method: 'put', data })
}
/** 保存评级(评级计算器「保存评分」;后端存总分+评级时间,评分只走本端点) */
export function rateCustomer(
customerId: number,
data: CustomerRatingParams,
): Promise<CustomerItem> {
return request({ url: `/v1/base/customers/${customerId}/rating`, method: 'put', data })
}
/** 查某客户的归属变更留痕(分页,按变更时间倒序) */
export function listOwnershipLogs(
customerId: number,
+22 -1
View File
@@ -1,4 +1,5 @@
import { request } from './request'
import type { PageData } from './types'
/** 近 6 个月趋势点(绩效按台账归属月,回款按到账月) */
export interface TrendPoint {
@@ -91,9 +92,29 @@ export interface DashboardData {
overview: OverviewBlock
todos: TodoBlock
warnings: WarningBlock
activities: ActivityItem[]
}
/**
* 最近动态分页结果:分页字段与全站规范同形(list/total/pageNum/pageSize),
* 额外带 `modules` —— 全量模块名(不随当前页变化),供模块下拉使用。
*/
export interface ActivityPage extends PageData<ActivityItem> {
modules: string[]
}
export function getDashboard(params: { month?: string }): Promise<DashboardData> {
return request({ url: '/v1/dashboard', method: 'get', params })
}
/**
* 分页查询最近动态(工作台动态列表)。
*
* 独立于聚合接口:翻页只重取这张列表,不必重算总览/待办/预警。
*/
export function listDashboardActivities(params: {
pageNum?: number
pageSize?: number
module?: string
}): Promise<ActivityPage> {
return request({ url: '/v1/dashboard/activities', method: 'get', params })
}
+637 -16
View File
@@ -1,24 +1,60 @@
/* 全局样式:重置 + 品牌 token + 基础版式 */
/*
* 全局样式:品牌 token + Element Plus 组件基调 + 全站版式工具类
*
* 设计来源:docs/design/视觉方向预览.html(v4,2026-10-22)。
* 落实口径:token 与设计稿 :root 同名同值;组件基调按设计稿 §03;
* 版式工具类(.page-head / .card / .filter-bar / .data-table 等)按 §04、§09,
* 各页面只写业务差异,不再重复定义这些类。
*
* 加载顺序:main.ts 里排在 element-plus/dist/index.css 之后,
* 同权重选择器靠后者生效 —— 覆盖组件库样式必须与此保持一致。
*/
:root {
/*
* 品牌色(2026-10-12 视觉方向:企业级扁平 · 轻质感 · 靛蓝点缀)。
* 靛蓝只作重点点缀(主按钮/链接/焦点/当前菜单),大面积用中性灰白。
* 设计预览与规范:docs/design/视觉方向预览.html
*/
/* ── 品牌色 ─────────────────────────────────────────────────────────── */
/* 靛蓝只作重点点缀(主按钮/链接/焦点/当前菜单),大面积用中性灰白。 */
--ys-indigo: #2e4ba8;
--ys-indigo-hi: #3a58bc; /* 主按钮渐变亮端 */
--ys-indigo-deep: #223a85;
--ys-indigo-wash: #edf1fa;
--ys-paper: #ffffff;
--ys-mist: #f4f5f8;
--ys-line: rgba(28, 35, 51, 0.09);
--ys-aside-1: #f2f4f8;
--ys-aside-2: #ecedf3;
--ys-ink: #1c2333;
--ys-ink-2: #5b6577;
--ys-ink-3: #9aa3b2;
--ys-line: rgba(28, 35, 51, 0.09);
--ys-line-soft: rgba(28, 35, 51, 0.065);
/* 语义三色:饱和度压到与靛蓝同级,保证灰底卡片上不跳色 */
--ys-ok: #1e9e6a;
--ys-ok-wash: #ecf8f2;
--ys-ok-wash-2: #e4f3ec;
--ys-warn: #c98a1b;
--ys-warn-wash: #fbf4e4;
--ys-warn-wash-2: #f8efd9;
--ys-danger: #c94f4f;
--ys-danger-wash: #fbeded;
--ys-danger-wash-2: #f6e3e3;
/* 形体与材质 */
--ys-radius: 10px;
--ys-radius-sm: 7px;
--ys-ease: cubic-bezier(0.25, 0.1, 0.25, 1);
--ys-shadow-card:
0 1px 2px rgba(28, 35, 51, 0.04),
0 3px 10px rgba(28, 35, 51, 0.03);
--ys-shadow-raise:
0 1px 2px rgba(28, 35, 51, 0.05),
0 6px 18px rgba(28, 35, 51, 0.07);
/*
* 旧 token 名保留映射(BasicLayout / 登录页存量引用,值迁移到新体系:
* 深水青 → 冷墨系、金色 → 靛蓝系),存量页面迁移完成后移除。
* 旧 token 名保留映射(存量页面引用,值已迁移到新体系):
* 深水青 → 冷墨系、金色 → 靛蓝系。存量页面迁移完成后移除。
*/
--ys-ink-900: #16203a;
--ys-ink-800: #1c2333;
@@ -26,7 +62,6 @@
--ys-ink-600: #3a4a6e;
--ys-gold-bright: #6d81c2;
--ys-gold-deep: #2e4ba8;
--ys-text: #1c2333;
--ys-muted: #5b6577;
@@ -36,11 +71,12 @@
*/
--ys-font-brand: 'Noto Sans SC', 'Source Han Sans SC', 'HarmonyOS Sans SC', 'PingFang SC',
sans-serif;
--ys-font-mono: 'SF Mono', 'Cascadia Mono', Consolas, 'Courier New', monospace;
/* ── Element Plus 主色 ──────────────────────────────────────────────── */
/*
* Element Plus 主色 = 靛蓝(白字对比约 7.5:1)。
* 色阶为按 Element Plus 公式「主色与白色混合」预先算好的静态值 ——
* 不用 color-mix(),避免依赖较新的浏览器特性。
* 白字对比约 7.5:1。色阶为按 Element Plus 公式「主色与白色混合」预先算好的
* 静态值 —— 不用 color-mix(),避免依赖较新的浏览器特性。
*/
--el-color-primary: #2e4ba8;
--el-color-primary-light-3: #6d81c2;
@@ -49,6 +85,72 @@
--el-color-primary-light-8: #d5dbee;
--el-color-primary-light-9: #eaedf6;
--el-color-primary-dark-2: #253c86;
/*
* 语义三色(设计稿规范值):成功 #1E9E6A / 警示 #C98A1B / 危险 #C94F4F。
*
* Element Plus 默认是绿 #67C23A / 黄 #E6A23C / 红 #F56C6C,饱和度明显高于
* 本系统「中性灰白 + 靛蓝点缀」基调,状态标签放在灰底卡片上会跳色。
* 色阶同样按 Element Plus 公式预计算(light-N = N×10% 白 + 其余主色,
* dark-2 = 20% 黑 + 80% 主色),不用 color-mix(),与主色色阶口径一致。
* -rgb 变体一并覆盖:组件库的半透明底色(rgba(var(--el-color-success-rgb), .1))
* 依赖它,漏改会出现「文字是新的、底色还是旧的」。
* error 与 danger 同值:表单校验错误与危险操作在本系统语义一致。
*/
--el-color-success: #1e9e6a;
--el-color-success-rgb: 30, 158, 106;
--el-color-success-light-3: #62bb97;
--el-color-success-light-5: #8eceb4;
--el-color-success-light-7: #bce2d2;
--el-color-success-light-8: #d2ece1;
--el-color-success-light-9: #e8f5f0;
--el-color-success-dark-2: #187e55;
--el-color-warning: #c98a1b;
--el-color-warning-rgb: 201, 138, 27;
--el-color-warning-light-3: #d9ad5f;
--el-color-warning-light-5: #e4c48d;
--el-color-warning-light-7: #efdcbb;
--el-color-warning-light-8: #f4e8d1;
--el-color-warning-light-9: #faf3e8;
--el-color-warning-dark-2: #a16e16;
--el-color-danger: #c94f4f;
--el-color-danger-rgb: 201, 79, 79;
--el-color-danger-light-3: #d98484;
--el-color-danger-light-5: #e4a7a7;
--el-color-danger-light-7: #efcaca;
--el-color-danger-light-8: #f4dcdc;
--el-color-danger-light-9: #faeded;
--el-color-danger-dark-2: #a13f3f;
--el-color-error: #c94f4f;
--el-color-error-rgb: 201, 79, 79;
--el-color-error-light-3: #d98484;
--el-color-error-light-5: #e4a7a7;
--el-color-error-light-7: #efcaca;
--el-color-error-light-8: #f4dcdc;
--el-color-error-light-9: #faeded;
--el-color-error-dark-2: #a13f3f;
/* ── Element Plus 形体基准(设计稿 §03:控件高 34px、圆角 7px)─────── */
--el-component-size: 34px;
--el-component-size-small: 28px;
--el-component-size-large: 38px;
--el-border-radius-base: 7px;
--el-border-radius-small: 6px;
/* ── Element Plus 表格 ──────────────────────────────────────────────── */
--el-table-border-color: var(--ys-line-soft);
--el-table-text-color: var(--ys-ink);
--el-table-header-text-color: var(--ys-ink-3);
--el-table-row-hover-bg-color: #f7f9fd;
/* 斑马纹压到几乎不可见:设计稿列表只有行悬停,没有斑马带 */
--el-fill-color-lighter: #fafbfd;
/* ── 字体 ───────────────────────────────────────────────────────────── */
--el-font-family: 'Noto Sans SC', 'Source Han Sans SC', 'HarmonyOS Sans SC', 'PingFang SC',
'Helvetica Neue', Arial, sans-serif;
}
* {
@@ -73,15 +175,534 @@ body {
-webkit-font-smoothing: antialiased;
}
/* 系统管理列表页共用的版式(原在三个页面 scoped 中各自重复) */
.filter-form {
margin-bottom: 4px;
/* 滚动条:细、低对比,避免在密集表格页抢视线 */
*::-webkit-scrollbar {
width: 8px;
height: 8px;
}
*::-webkit-scrollbar-thumb {
background-color: rgba(28, 35, 51, 0.16);
border: 2px solid transparent;
border-radius: 6px;
background-clip: padding-box;
}
*::-webkit-scrollbar-thumb:hover {
background-color: rgba(28, 35, 51, 0.28);
}
*::-webkit-scrollbar-track {
background: transparent;
}
/* ══════════════════════════════════════════════════════════════════════
* 全站版式工具类(设计稿 §04 / §09):页面不再各自重复定义
* ══════════════════════════════════════════════════════════════════════ */
/* 页面头:标题 + 描述 + 签名水线,每页出现一次 */
.page-head {
margin-bottom: 18px;
}
.page-title {
margin: 0;
font-size: 18px;
font-weight: 600;
letter-spacing: -0.01em;
color: var(--ys-ink);
}
.page-desc {
margin: 6px 0 0;
font-size: 12px;
letter-spacing: 0.01em;
color: var(--ys-ink-3);
}
/*
* 签名元素:渐隐靛蓝水线。
* 全站唯一的装饰性元素 —— 其余层次靠底色、描边与阴影区分,
* 所以每页只允许出现一次(页面标题下,或登录卡品牌区)。
*/
.waterline {
display: block;
width: 56px;
height: 2px;
margin-top: 10px;
border-radius: 1px;
background: linear-gradient(90deg, var(--ys-indigo), rgba(46, 75, 168, 0));
}
/* 区块小标题(抽屉内、卡片内分组) */
.section-title {
margin: 0 0 10px;
font-size: 13px;
font-weight: 600;
letter-spacing: 0.02em;
color: var(--ys-ink-2);
}
/* 卡片:纸白微渐变 + 半透明描边 + 双层柔影 */
.card {
background: linear-gradient(180deg, var(--ys-paper), #fcfcfe);
border: 1px solid var(--ys-line);
border-radius: var(--ys-radius);
box-shadow: var(--ys-shadow-card);
}
.card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 13px 16px;
border-bottom: 1px solid var(--ys-line-soft);
}
.card-title {
font-size: 15px;
font-weight: 600;
color: var(--ys-ink);
}
.card-body {
padding: 16px;
}
.card-foot {
display: flex;
align-items: center;
justify-content: flex-end;
padding: 10px 16px;
background: linear-gradient(180deg, #fdfdfe, #fbfcfe);
border-top: 1px solid var(--ys-line-soft);
border-radius: 0 0 var(--ys-radius) var(--ys-radius);
}
/* 筛选条:与卡片同材质,比表格低一档体量 */
.filter-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
padding: 14px 16px;
margin-bottom: 14px;
background: linear-gradient(180deg, var(--ys-paper), #fcfcfe);
border: 1px solid var(--ys-line);
border-radius: var(--ys-radius);
box-shadow: var(--ys-shadow-card);
}
.filter-bar .el-form-item {
margin-right: 0;
margin-bottom: 0;
}
.filter-bar__actions {
display: flex;
gap: 10px;
margin-left: auto;
}
/* 工具栏:主操作行,与筛选条/表格区分(无底色,只占位) */
.toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
margin-bottom: 12px;
}
.toolbar-note {
font-size: 12px;
color: var(--ys-ink-3);
}
/* 编号/代码类单元格:等宽字体,弱一档颜色 */
.cell-code {
font-family: var(--ys-font-mono);
font-size: 12px;
color: var(--ys-ink-2);
}
/*
* 金额/数量类单元格:等宽数字右对齐(对齐是工资表的可读性前提)。
*
* 两种用法都要成立:
* 1) 直接挂在 td 上 —— 单元格本身就是右对齐的容器;
* 2) 包在单元格里当 <span class="cell-num"> —— 一行里既有文本又有数字时用。
* 行内元素的 text-align 无效,所以要给 span 单独撑满一行再右对齐;
* 反过来不能给 .cell-num 统一加 display,那会把 td 从 table-cell 变成
* inline-block,整个表格的列宽算法随即失效。
*/
.cell-num {
font-variant-numeric: tabular-nums;
}
td.cell-num,
th.cell-num {
text-align: right;
}
span.cell-num {
display: block;
text-align: right;
}
.cell-muted {
color: var(--ys-ink-3);
}
/*
* 状态 pill(设计稿 §03):浅底 + 细边 + 6px 圆点。
* 四档语义与月结生命周期一致:草稿/待处理(中性)、待办(琥珀)、
* 已锁定/完成(靛蓝)、驳回(砖红)。
*/
.ys-pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 2px 10px;
font-size: 12px;
line-height: 18px;
white-space: nowrap;
color: var(--ys-ink-2);
background: linear-gradient(180deg, #f4f5f8, #eff1f5);
border: 1px solid var(--ys-line);
border-radius: 999px;
}
.ys-pill::before {
content: '';
flex: none;
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
opacity: 0.65;
}
.ys-pill--ok {
color: var(--ys-ok);
background: linear-gradient(180deg, var(--ys-ok-wash), var(--ys-ok-wash-2));
border-color: rgba(30, 158, 106, 0.2);
}
.ys-pill--wait {
color: var(--ys-warn);
background: linear-gradient(180deg, var(--ys-warn-wash), var(--ys-warn-wash-2));
border-color: rgba(201, 138, 27, 0.22);
}
.ys-pill--lock {
color: var(--ys-indigo);
background: linear-gradient(180deg, #eef2fc, #e5eaf8);
border-color: rgba(46, 75, 168, 0.2);
}
.ys-pill--reject {
color: var(--ys-danger);
background: linear-gradient(180deg, var(--ys-danger-wash), var(--ys-danger-wash-2));
border-color: rgba(201, 79, 79, 0.2);
}
/* ══════════════════════════════════════════════════════════════════════
* Element Plus 组件基调(设计稿 §02 字体与数字 / §03 组件 / §04 表格)
* ══════════════════════════════════════════════════════════════════════ */
/* 按钮:高 34px、圆角 7px、字号 13px、150ms 过渡 */
.el-button {
height: var(--el-component-size);
padding: 0 15px;
font-size: 13px;
font-weight: 400;
border-radius: var(--ys-radius-sm);
transition:
background-color 150ms var(--ys-ease),
background-image 150ms var(--ys-ease),
border-color 150ms var(--ys-ease),
color 150ms var(--ys-ease),
box-shadow 150ms var(--ys-ease),
transform 150ms var(--ys-ease);
}
.el-button--large {
font-size: 14px;
}
.el-button--small {
height: var(--el-component-size-small);
padding: 0 11px;
font-size: 12px;
}
.el-button + .el-button {
margin-left: 0;
}
.el-button.is-text,
.el-button.is-link {
height: auto;
padding: 2px 0;
font-size: 13px;
}
/* 主按钮:靛蓝微渐变 + 顶部内高光,悬停抬高 1px 并扩散柔影 */
.el-button--primary:not(.is-plain):not(.is-text):not(.is-link):not(.is-disabled) {
background-color: var(--ys-indigo);
background-image: linear-gradient(180deg, var(--ys-indigo-hi), var(--ys-indigo));
border-color: var(--ys-indigo);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.18),
0 1px 2px rgba(46, 75, 168, 0.22);
}
.el-button--primary:not(.is-plain):not(.is-text):not(.is-link):not(.is-disabled):hover {
background-image: linear-gradient(180deg, #31509f, var(--ys-indigo-deep));
border-color: var(--ys-indigo-deep);
transform: translateY(-1px);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.2),
0 4px 12px rgba(46, 75, 168, 0.3);
}
.el-button--primary:not(.is-plain):not(.is-text):not(.is-link):not(.is-disabled):active {
transform: translateY(0);
background-image: linear-gradient(180deg, #2b4699, #1f3478);
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.18);
}
.el-button--primary.is-disabled,
.el-button--primary.is-loading {
background-image: none;
box-shadow: none;
}
/*
* 默认按钮:纸白微渐变 + 细描边,悬停只加靛蓝描边与文字色,不做位移
* —— 筛选条里可能并排四五个默认按钮,集体位移会让整行抖动。
*/
.el-button:not(
.el-button--primary,
.el-button--success,
.el-button--warning,
.el-button--danger,
.el-button--info,
.is-link,
.is-text,
.is-plain
) {
background-image: linear-gradient(180deg, var(--ys-paper), #fbfcfe);
border-color: var(--ys-line);
box-shadow: 0 1px 2px rgba(28, 35, 51, 0.03);
}
.el-button:not(
.el-button--primary,
.el-button--success,
.el-button--warning,
.el-button--danger,
.el-button--info,
.is-link,
.is-text,
.is-plain
):hover {
color: var(--ys-indigo);
background-image: linear-gradient(180deg, var(--ys-paper), #f8fafd);
border-color: rgba(46, 75, 168, 0.45);
}
/* 危险按钮:白底 + 砖红文字与描边(红色只提示边界,不做大面积填充) */
.el-button--danger:not(.is-link):not(.is-text):not(.is-plain):not(.is-disabled) {
color: var(--ys-danger);
background-color: var(--ys-paper);
background-image: linear-gradient(180deg, var(--ys-paper), #fdfbfb);
border-color: rgba(201, 79, 79, 0.28);
box-shadow: 0 1px 2px rgba(28, 35, 51, 0.03);
}
.el-button--danger:not(.is-link):not(.is-text):not(.is-plain):not(.is-disabled):hover {
color: #b23f3f;
background-color: var(--ys-danger-wash);
border-color: rgba(201, 79, 79, 0.5);
}
/* 输入框 / 选择器 / 文本域:微内阴影 + 靛蓝焦点环 */
.el-input__wrapper,
.el-select__wrapper,
.el-textarea__inner {
border-radius: var(--ys-radius-sm);
box-shadow:
0 0 0 1px rgba(28, 35, 51, 0.13) inset,
inset 0 1px 2px rgba(28, 35, 51, 0.035);
transition: box-shadow 150ms var(--ys-ease);
}
.el-input__wrapper:hover,
.el-select__wrapper:hover,
.el-textarea__inner:hover {
box-shadow:
0 0 0 1px rgba(28, 35, 51, 0.24) inset,
inset 0 1px 2px rgba(28, 35, 51, 0.035);
}
.el-input__wrapper.is-focus,
.el-select__wrapper.is-focused,
.el-textarea__inner:focus {
box-shadow:
0 0 0 1px var(--ys-indigo) inset,
inset 0 1px 2px rgba(28, 35, 51, 0.03),
0 0 0 3px rgba(46, 75, 168, 0.13);
}
.el-input__inner,
.el-textarea__inner {
font-size: 13px;
}
/* 表单标签:13px 次墨,不抢输入框的注意力 */
.el-form-item__label {
font-size: 13px;
color: var(--ys-ink-2);
}
/* 表格容器:与卡片同材质;裸表格(未套 .card)也不会贴在雾灰底上 */
.el-table {
border-radius: var(--ys-radius);
background-color: transparent;
}
.el-table th.el-table__cell {
padding: 10px 0;
font-size: 12px;
font-weight: 500;
letter-spacing: 0.04em;
background-color: transparent;
background-image: linear-gradient(180deg, #fafbfc, #f5f6f9);
}
.el-table td.el-table__cell {
padding: 12px 0;
font-size: 13px;
}
.el-table .cell {
padding: 0 16px;
/*
* Element Plus 默认给单元格 23px 行高(≈1.77 倍),加上 12px 上下内距,
* 单行数据行会到 51px —— 一屏少看两行,也与设计稿的「12px 内距 + 13px 字」
* 不符。收到 1.5(中文正文常用行高)后单行约 45px;多行单元格仍按内容撑开。
*/
line-height: 1.5;
}
.el-table__body tr:hover > td.el-table__cell {
transition: background-color 120ms var(--ys-ease);
}
/* 分页:当前页与主按钮同材质(靛蓝渐变 + 内高光) */
.el-pagination button,
.el-pagination .el-pager li {
border-radius: 6px;
font-variant-numeric: tabular-nums;
}
.el-pagination.is-background .el-pager li.is-active {
color: #fff;
background-color: var(--ys-indigo);
background-image: linear-gradient(180deg, var(--ys-indigo-hi), var(--ys-indigo));
border: 1px solid var(--ys-indigo);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.18),
0 1px 2px rgba(46, 75, 168, 0.22);
}
/* 标签:默认压成 11px 方角小签(状态列请改用 .ys-pill) */
.el-tag {
font-size: 11px;
border-radius: 4px;
}
.el-tag--small {
height: 20px;
padding: 0 7px;
font-size: 11px;
}
/* 弹层:淡入 + 上移 4px(160ms),与设计稿 §08 一致 */
.el-popper.is-light {
border-radius: var(--ys-radius-sm);
box-shadow: var(--ys-shadow-raise);
}
.el-drawer__header {
margin-bottom: 0;
padding: 16px 20px;
font-size: 15px;
font-weight: 600;
color: var(--ys-ink);
border-bottom: 1px solid var(--ys-line-soft);
}
.el-drawer__body {
padding: 18px 20px;
}
/* 空状态:弱墨、留白,不喊叫 */
.el-table__empty-text {
font-size: 13px;
color: var(--ys-ink-3);
}
/* 入场上浮:卡片/面板通用(设计稿 §08 320ms,可被页面用 animation-delay 交错) */
@keyframes ys-rise {
from {
opacity: 0;
transform: translateY(6px);
}
}
.ys-rise {
animation: ys-rise 320ms var(--ys-ease) backwards;
}
/* 不可再补装饰的场合(打印)统一去动效 */
@media print {
.ys-rise {
animation: none;
}
}
@media (prefers-reduced-motion: reduce) {
.el-button,
.el-input__wrapper,
.el-select__wrapper,
.el-textarea__inner,
.el-table__body tr:hover > td.el-table__cell {
transition: none;
}
.el-button--primary:not(.is-disabled):hover,
.el-button--primary:not(.is-disabled):active {
transform: none;
}
.ys-rise {
animation: none;
}
}
/*
* 分页放进卡片页脚时去掉自身的上外边距 —— 页脚已有 padding,
* 再加 14px 会让卡片底部比顶部厚一档(各页原本各写一遍 :deep 覆盖)。
* 连 .app-pager 一起写:AppPagination 组件自己的 scoped 样式里有
* margin-top,同权重下后注入的 scoped 会赢,这里多一级类压住它。
*/
.card-foot .el-pagination.app-pager {
margin-top: 0;
}
/*
* 确认框(ElMessageBox)统一视觉
*
+151 -21
View File
@@ -10,12 +10,25 @@ const { topLevelMenus, groupedMenus } = useAppMenu()
<template>
<el-menu class="app-menu" :default-active="route.path" router>
<el-menu-item v-for="item in topLevelMenus" :key="item.path" :index="item.path">
<el-menu-item
v-for="item in topLevelMenus"
:key="item.path"
class="app-menu__top"
:index="item.path"
>
<el-icon v-if="item.icon" class="app-menu__icon" aria-hidden="true">
<component :is="item.icon" />
</el-icon>
{{ item.title }}
</el-menu-item>
<el-sub-menu v-for="group in groupedMenus" :key="group.name" :index="group.name">
<template #title>{{ group.name }}</template>
<template #title>
<span class="app-menu__group">{{ group.name }}</span>
</template>
<el-menu-item v-for="item in group.items" :key="item.path" :index="item.path">
<el-icon v-if="item.icon" class="app-menu__icon" aria-hidden="true">
<component :is="item.icon" />
</el-icon>
{{ item.title }}
</el-menu-item>
</el-sub-menu>
@@ -24,42 +37,159 @@ const { topLevelMenus, groupedMenus } = useAppMenu()
<style scoped>
/*
* 中性灰侧栏上的菜单:覆盖 Element Plus 的主题变量而非直接写颜色 ——
* 后者与组件库规则同权重,受样式注入顺序影响而不稳定。
* 激活项 = 白底浮起(微渐变 + 内高光 + 分层阴影)+ 左侧 2px 靛蓝条。
* 侧栏菜单:中性灰底 + 靛蓝点缀。
* 覆盖 Element Plus 的主题变量而非直接写颜色 —— 后者与组件库规则同权重,
* 受样式注入顺序影响而不稳定。
*
* 层级口径:父子**同字号(13px)**,层级不靠字号/字重刻意压过 ——
* 组标题与条目一样大,结构由「组标题带折叠箭头 + 组间留白 + 子项缩进一档」承担。
* 前两版各走偏一次:先把组标题压成 11px 弱墨「目录行」,父级弱于子项;
* 后改成 14px/600 + 靛蓝竖标,父级刻意压过子项、像一排小标题在喊叫。
* 层级是位置关系,不是字号关系 —— 两版都不要。
*
* 激活态:白底浮起(微渐变 + 内高光 + 分层阴影)+ 靛蓝字,不再叠左侧竖条 ——
* 竖条只能落在侧栏内边缘(离白底悬空),是与白底重复的第二个信号。
*/
.app-menu {
--el-menu-bg-color: transparent;
--el-menu-text-color: var(--ys-ink-2);
--el-menu-active-color: var(--ys-indigo);
--el-menu-hover-bg-color: rgba(255, 255, 255, 0.6);
--el-menu-item-height: 40px;
--el-menu-hover-bg-color: rgba(255, 255, 255, 0.55);
--el-menu-item-height: 34px;
padding: 2px 10px 12px;
border-right: none;
}
/* 组与组之间留白:分组交给间距,不加分割线 */
.app-menu :deep(.el-sub-menu) {
margin-top: 12px;
}
/*
* 顶层叶子项(首页):与组标题同级 —— 13px / 500 / 主墨。
* 用 scoped 模板 class 定位(权重高于组件库默认样式),
* 不与子项共用 :deep 规则,避免字号被子项规则带走。
*/
.app-menu__top {
height: 34px;
padding: 0 10px;
margin-bottom: 2px;
font-size: 13px;
font-weight: 500;
line-height: 34px;
color: var(--ys-ink);
border-radius: var(--ys-radius-sm);
transition:
background-color 150ms var(--ys-ease),
color 150ms var(--ys-ease);
}
.app-menu__top:hover {
color: var(--ys-ink);
background-color: rgba(255, 255, 255, 0.55);
}
/* 组标题:与条目同字号,靠折叠箭头表明「可展开」,不靠压重 */
.app-menu :deep(.el-sub-menu__title) {
height: 34px;
padding: 0 10px;
font-size: 13px;
font-weight: 500;
line-height: 34px;
color: var(--ys-ink);
border-radius: var(--ys-radius-sm);
transition:
background-color 150ms var(--ys-ease),
color 150ms var(--ys-ease);
}
.app-menu__group {
font-size: 13px;
font-weight: 500;
color: inherit;
}
/*
* 子项:13px 次墨 + 缩进一档(12px)。
* Element Plus 的 `.el-menu--inline .el-menu-item` 带 !important 的 40px 缩进,
* 必须同样用 !important 才能压住,否则缩进档位不受控。
*/
.app-menu :deep(.el-sub-menu .el-menu-item) {
height: 32px;
padding-right: 10px !important;
padding-left: 22px !important;
margin-bottom: 1px;
font-size: 13px;
line-height: 32px;
color: var(--ys-ink-2);
border-radius: var(--ys-radius-sm);
transition:
background-color 150ms var(--ys-ease),
color 150ms var(--ys-ease);
}
.app-menu :deep(.el-sub-menu .el-menu-item:hover) {
color: var(--ys-ink);
}
/* 激活项:白底浮起 + 靛蓝字(阴影分层与设计稿卡片同口径) */
.app-menu :deep(.el-menu-item.is-active) {
position: relative;
background: linear-gradient(180deg, #ffffff, #fbfcfe);
font-weight: 500;
color: var(--ys-indigo);
background: linear-gradient(180deg, var(--ys-paper), #fbfcfe);
box-shadow:
inset 0 1px 0 #fff,
0 1px 2px rgba(28, 35, 51, 0.06),
0 4px 10px rgba(28, 35, 51, 0.06);
font-weight: 500;
}
.app-menu :deep(.el-menu-item.is-active)::before {
content: '';
position: absolute;
left: -14px;
top: 9px;
bottom: 9px;
width: 2px;
border-radius: 1px;
background: var(--ys-indigo);
.app-menu :deep(.el-sub-menu__title:hover) {
background-color: rgba(255, 255, 255, 0.5);
}
.app-menu :deep(.el-sub-menu.is-active > .el-sub-menu__title) {
color: var(--ys-indigo);
/*
* 展开/收起箭头:Element Plus 默认「闭合向下、展开转 180°」,
* 设计稿口径是「闭合向右、展开向下」——先统一掰到向右(-90°),
* 展开时回到 0°。选择器权重高于组件库默认值,且 scoped 属性使其稳定生效。
*/
.app-menu :deep(.el-sub-menu__icon-arrow) {
width: 11px;
height: 11px;
margin-top: -6px;
color: var(--ys-ink-3);
transform: rotate(-90deg);
transition: transform 150ms var(--ys-ease);
}
.app-menu :deep(.el-sub-menu.is-opened > .el-sub-menu__title .el-sub-menu__icon-arrow) {
color: var(--ys-ink-2);
transform: rotate(0deg);
}
/*
* 菜单图标:设计稿口径为 16px、透明度 0.7(激活项 1)——Element Plus 默认
* 给菜单图标 24px 宽 + 5px 间距,直接沿用会让图标比文字更抢眼、字距发散,
* 故在此显式覆盖。父子同级,图标尺寸统一,不再按层级分档。
*/
.app-menu__icon {
width: 16px;
height: 16px;
margin-right: 9px;
font-size: 16px;
opacity: 0.72;
}
.app-menu :deep(.el-menu-item.is-active) .app-menu__icon {
opacity: 1;
}
@media (prefers-reduced-motion: reduce) {
.app-menu__top,
.app-menu :deep(.el-sub-menu .el-menu-item),
.app-menu :deep(.el-sub-menu__title),
.app-menu :deep(.el-sub-menu__icon-arrow) {
transition: none;
}
}
</style>
+63 -23
View File
@@ -17,6 +17,9 @@ const drawerVisible = ref(false)
/** 「首页」按角色解析:普通用户没有工作台权限,固定链到业务数据页 */
const homePath = computed(() => firstAccessiblePath(router, userStore.roles))
/** 头像取显示名首字:没有头像上传能力,首字比图标更有辨识度 */
const avatarInitial = computed(() => userStore.displayName.trim().charAt(0) || '衍')
/** 面包屑:首页 + 分组(如有)+ 当前页标题(首页自身不重复) */
const breadcrumbs = computed<string[]>(() => {
const items: string[] = []
@@ -46,13 +49,13 @@ async function handleLogout(): Promise<void> {
<div class="app-brand">
<span class="app-brand__name">衍水集团</span>
<span class="app-brand__sub">营销人员工资管理系统</span>
<span class="app-brand__line"></span>
<span class="waterline app-brand__line"></span>
</div>
<AppMenu class="app-aside__menu" />
</el-aside>
<el-container class="app-body">
<el-header class="app-header">
<el-header class="app-header" height="52px">
<div class="app-header__left">
<button
type="button"
@@ -81,7 +84,9 @@ async function handleLogout(): Promise<void> {
<el-tag v-for="role in userStore.roles" :key="role" size="small" type="info">
{{ role }}
</el-tag>
<span class="app-header__avatar" aria-hidden="true">{{ avatarInitial }}</span>
<span class="app-header__username">{{ userStore.displayName }}</span>
<span class="app-header__divider" aria-hidden="true"></span>
<el-button link type="primary" @click="handleLogout">退出登录</el-button>
</div>
</el-header>
@@ -101,7 +106,7 @@ async function handleLogout(): Promise<void> {
<div class="app-brand">
<span class="app-brand__name">衍水集团</span>
<span class="app-brand__sub">营销人员工资管理系统</span>
<span class="app-brand__line"></span>
<span class="waterline app-brand__line"></span>
</div>
<AppMenu />
</el-drawer>
@@ -117,7 +122,7 @@ async function handleLogout(): Promise<void> {
display: flex;
flex-direction: column;
/* 中性灰侧栏:比页面底沉一档,与纸白卡片拉开层次 */
background: linear-gradient(180deg, #f2f4f8 0%, #ecedf3 100%);
background: linear-gradient(180deg, var(--ys-aside-1) 0%, var(--ys-aside-2) 100%);
border-right: 1px solid var(--ys-line);
}
@@ -130,9 +135,9 @@ async function handleLogout(): Promise<void> {
.app-brand__name {
font-family: var(--ys-font-brand);
font-size: 16px;
font-size: 15px;
font-weight: 500;
letter-spacing: 4px;
letter-spacing: 0.32em;
color: var(--ys-ink);
}
@@ -142,17 +147,21 @@ async function handleLogout(): Promise<void> {
color: var(--ys-ink-3);
}
/* 品牌签名:logo 下的渐隐水线(全站唯一装饰元素,页面标题处同款) */
/* 品牌签名:logo 下的渐隐水线(全站唯一装饰元素,页面标题处同款,此处收窄) */
.app-brand__line {
width: 24px;
height: 2px;
border-radius: 1px;
margin-top: 8px;
background: linear-gradient(90deg, var(--ys-indigo), rgba(46, 75, 168, 0));
}
.app-aside__menu {
flex: 1;
/*
* 只写上下内距:横向 10px 留白由 AppMenu 自己的 .app-menu 提供。
* 这里若用 padding 简写,会与 .app-menu 的 padding 简写同权重、
* 靠样式注入顺序决胜 —— 横向留白可能被整条抹掉,条目贴到侧栏边缘。
*/
padding-top: 2px;
padding-bottom: 12px;
overflow-y: auto;
}
@@ -160,8 +169,8 @@ async function handleLogout(): Promise<void> {
display: flex;
align-items: center;
justify-content: space-between;
height: 56px;
background: linear-gradient(180deg, #ffffff, #fdfdfe);
padding: 0 18px;
background: linear-gradient(180deg, var(--ys-paper), #fdfdfe);
border-bottom: 1px solid var(--ys-line);
}
@@ -179,15 +188,16 @@ async function handleLogout(): Promise<void> {
width: 32px;
height: 32px;
padding: 0;
color: var(--ys-text);
color: var(--ys-ink-2);
cursor: pointer;
background: none;
border: none;
border-radius: 6px;
border-radius: 7px;
}
.app-header__menu-toggle:hover {
background-color: #f2f3f5;
color: var(--ys-indigo);
background-color: var(--ys-indigo-wash);
}
.app-header__menu-toggle:focus-visible {
@@ -198,20 +208,46 @@ async function handleLogout(): Promise<void> {
.app-header__user {
display: flex;
align-items: center;
gap: 10px;
gap: 9px;
}
/* 首字头像:靛蓝洗底,26px —— 与菜单图标同为「轻质感」而非实心色块 */
.app-header__avatar {
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
font-size: 12px;
font-weight: 600;
color: var(--ys-indigo);
background: linear-gradient(180deg, #f1f4fb, var(--ys-indigo-wash));
border: 1px solid rgba(46, 75, 168, 0.14);
border-radius: 50%;
}
.app-header__username {
font-size: 14px;
color: var(--ys-text);
font-size: 13px;
color: var(--ys-ink);
}
.app-header__divider {
width: 1px;
height: 14px;
background-color: var(--ys-line);
}
.app-main {
padding: 20px;
/* 雾灰底 + 点阵网格:内容区的秩序与密度 */
/*
* 内容区:雾灰底 + 顶部受光(靛蓝微光叠一层高光)。
* 受光用固定像素半径而非百分比:百分比会随窗口高度变化,
* 窄高三列布局时天光会被拉成竖条。
*/
padding: 22px;
background-color: var(--ys-mist);
background-image: radial-gradient(rgba(28, 35, 51, 0.05) 1px, transparent 1px);
background-size: 24px 24px;
background-image:
radial-gradient(900px 320px at 50% -60px, rgba(46, 75, 168, 0.055), rgba(46, 75, 168, 0) 70%),
radial-gradient(1200px 420px at 50% -140px, rgba(255, 255, 255, 0.85), rgba(255, 255, 255, 0) 75%);
}
@media (max-width: 768px) {
@@ -222,6 +258,10 @@ async function handleLogout(): Promise<void> {
.app-header__menu-toggle {
display: inline-flex;
}
.app-main {
padding: 14px;
}
}
@media (prefers-reduced-motion: reduce) {
@@ -235,6 +275,6 @@ async function handleLogout(): Promise<void> {
/* 抽屉内容铺满侧栏同款灰(非 scoped:目标元素由 el-drawer 渲染在组件根之外) */
.app-drawer .el-drawer__body {
padding: 0;
background: linear-gradient(180deg, #f2f4f8 0%, #ecedf3 100%);
background: linear-gradient(180deg, var(--ys-aside-1) 0%, var(--ys-aside-2) 100%);
}
</style>
@@ -0,0 +1,70 @@
import type { Component } from 'vue'
import {
Avatar,
Box,
Briefcase,
Calendar,
DataLine,
Histogram,
House,
Management,
Money,
Notebook,
OfficeBuilding,
PieChart,
Postcard,
SetUp,
Shop,
Stamp,
Tickets,
TrendCharts,
User,
Van,
} from '@element-plus/icons-vue'
/**
* 菜单图标注册表。
*
* **路由只写语义 key(meta.icon),不直接引用图标组件**:路由表保持轻量、
* 序列化,图标实现换库或换风格时只改本文件,路由与菜单组件不动。
*
* 具名导入(不用 `import * as`)以保证摇树 —— 整包引入会把 200+ 图标打进
* 产物。注册表里多余的图标同样会被摇掉,但会让「用了哪些」难以核对,
* 只保留实际用到的。
*
* **全部取线性(outline)图标,不用 *-filled 变体**:侧栏同一层级混用实心与
* 线性图标会让菜单看着不是一套语汇(设计稿 §04 的图标族:16px 线性)。
* 首页用 House(线性房屋)而非 HomeFilled,人员管理用 Postcard(人员档案卡)
* 而非 UserFilled —— 用户管理已占 User,人员改用档案卡以免两个「人」撞形。
*/
const ICONS = {
home: House,
users: User,
roles: Avatar,
'business-roles': Management,
params: SetUp,
settings: OfficeBuilding,
approvals: Stamp,
logs: Tickets,
personnel: Postcard,
products: Box,
customers: Shop,
businesses: Briefcase,
shipments: Van,
receipts: Money,
ledger: Notebook,
settlements: Calendar,
'report-personal': DataLine,
'report-department': Histogram,
'report-business': TrendCharts,
'report-payment': PieChart,
} satisfies Record<string, Component>
/** 菜单图标的语义 key(路由 meta.icon 的取值范围)。 */
export type MenuIconName = keyof typeof ICONS
/** 按语义 key 取图标;未配置时返回 undefined(菜单项只显示文字)。 */
export function menuIcon(name?: string): Component | undefined {
return name ? (ICONS[name as MenuIconName] ?? undefined) : undefined
}
@@ -1,7 +1,10 @@
<script setup lang="ts">
/**
* 分页条:统一四个列表页的布局与页码尺寸选项。
* 分页条:统一各列表页的布局与页码尺寸选项。
* 与 `usePagedList` 的分页字段(pageNum/pageSize/total)配套使用。
*
* 开 background:设计稿 §03 要求当前页与主按钮同材质(靛蓝渐变 + 内高光),
* 该效果只在 is-background 模式下由 .el-pager li 承载,样式见全局 index.css。
*/
defineProps<{
total: number
@@ -18,6 +21,7 @@ defineEmits<{
<template>
<el-pagination
class="app-pager"
background
layout="total, sizes, prev, pager, next, jumper"
:total="total"
:current-page="pageNum"
@@ -31,6 +35,6 @@ defineEmits<{
<style scoped>
.app-pager {
justify-content: flex-end;
margin-top: 16px;
margin-top: 14px;
}
</style>
+24 -4
View File
@@ -4,8 +4,15 @@
*
* 用户状态、角色状态、操作结果、登录结果四处共用,故提取;
* 语义文字由调用方通过 props 定制(如同一组件展示「启用/禁用」与「成功/失败」)。
*
* 视觉按设计稿 §03 的状态 pill 落地(浅底 + 6px 圆点 + 999px 圆角),
* 用 span 而非 el-tag:el-tag 的实心/描边形态与四档色阶对不上,
* 强行覆盖会牵连其它用 el-tag 的地方(角色标签、日志模块标签)。
* activeType/inactiveType 保留为语义到 pill 色调的映射,调用方无需改动。
*/
withDefaults(
import { computed } from 'vue'
const props = withDefaults(
defineProps<{
value: number
activeText?: string
@@ -20,10 +27,23 @@ withDefaults(
inactiveType: 'info',
},
)
/** Element Plus 的语义类型 → 设计稿的四档 pill 色调 */
const TONE_BY_TYPE: Record<string, string> = {
success: 'ok',
warning: 'wait',
danger: 'reject',
primary: 'lock',
info: '',
}
const isActive = computed(() => props.value === 1)
const tone = computed(() =>
TONE_BY_TYPE[isActive.value ? props.activeType : props.inactiveType] ?? '',
)
const text = computed(() => (isActive.value ? props.activeText : props.inactiveText))
</script>
<template>
<el-tag :type="value === 1 ? activeType : inactiveType" size="small" disable-transitions>
{{ value === 1 ? activeText : inactiveText }}
</el-tag>
<span class="ys-pill" :class="tone ? `ys-pill--${tone}` : ''">{{ text }}</span>
</template>
+5
View File
@@ -1,6 +1,8 @@
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import type { Component } from 'vue'
import { menuIcon } from '@/components/layout/menuIcons'
import { canAccessRoute, isMenuRecord } from '@/router/access'
import { useUserStore } from '@/store/user'
@@ -10,6 +12,8 @@ export interface MenuNode {
title: string
order: number
group?: string
/** 图标组件(由 meta.icon 语义 key 解析;未配置则为 undefined) */
icon?: Component
}
export interface MenuGroup {
@@ -42,6 +46,7 @@ export function useAppMenu() {
title: record.meta.title ?? '',
order: record.meta.order ?? 99,
group: record.meta.group,
icon: menuIcon(record.meta.icon),
}))
.sort((a, b) => a.order - b.order),
)
+70 -11
View File
@@ -28,42 +28,101 @@ export function usePagedList<T, F extends object, D extends PageData<T> = PageDa
/** 自增请求序号:仅最新请求的响应可写入状态 */
let requestSeq = 0
/** 按当前页码与筛选条件加载 */
async function load(): Promise<void> {
/**
* 列表当前**实际显示**的是哪一页(成功写入 records 时同步)。
*
* 这是「分页器高亮」与「列表内容」保持一致的唯一依据:失败回滚要退回
* 这个页码,而不是退回点击前的页码 —— 连点两次时前一次请求的响应会被
* 序号守卫丢弃,退回「点击前」的页码可能与屏幕上的数据对不上。
*/
let shownPageNum = 1
let shownPageSize = 10
/**
* 按当前页码与筛选条件加载,返回是否成功以及本次请求的序号。
*
* 返回值是翻页失败回滚的依据:`records` 只在成功时更新,而分页器
* 高亮绑的是 `pageNum`,两者必须同进同退,否则会出现
* 「页码已跳到第 2 页、列表还停在第 1 页」的错位(用户来回点才恢复)。
*
* 带出 `seq` 是因为**回滚本身也要防竞态**:连点两次时旧请求失败,
* 若无条件把页码退回它那一轮的旧值,就会把用户刚点出的新页码覆盖掉,
* 表现为「页码自己跳回去」。
*
* 页码与条数在 await **之前**取快照:等待期间另一次点击可能已改动
* `pageNum`,用 await 之后的值记录「显示的页」会记错。
*
* 对外不暴露返回值:`load` 被大量页面当 `() => void | Promise<void>`
* 的回调使用,多一个返回值会让那些赋值类型不兼容。
*/
async function fetchPage(): Promise<{ ok: boolean; seq: number }> {
const seq = ++requestSeq
const reqPageNum = pageNum.value
const reqPageSize = pageSize.value
loading.value = true
try {
const data = await fetcher({
...getFilters(),
pageNum: pageNum.value,
pageSize: pageSize.value,
pageNum: reqPageNum,
pageSize: reqPageSize,
})
if (seq !== requestSeq) return // 已有更新的请求发出,丢弃过期响应
if (seq !== requestSeq) return { ok: false, seq } // 已有更新的请求发出,丢弃过期响应
records.value = data.list
total.value = data.total
shownPageNum = reqPageNum
shownPageSize = reqPageSize
onData?.(data)
return { ok: true, seq }
} catch (error) {
handledError(error) // 失败提示由请求拦截器统一完成
return { ok: false, seq }
} finally {
if (seq === requestSeq) loading.value = false
}
}
/**
* 请求失败后把分页状态退回到「屏幕上的数据」。
*
* 只在本次请求仍是最新请求时执行:否则用户已经点了别的页,
* 这里的回滚会把他的点击覆盖掉。
*/
function restoreShownPage(seq: number): void {
if (seq !== requestSeq) return
pageNum.value = shownPageNum
pageSize.value = shownPageSize
}
/** 按当前页码与筛选条件加载(供页面直接调用) */
async function load(): Promise<void> {
const { ok, seq } = await fetchPage()
if (!ok) restoreShownPage(seq)
}
/** 条件查询:回到第一页再加载(停留旧页码会查到空页) */
function search(): void {
async function search(): Promise<void> {
pageNum.value = 1
void load()
await load()
}
function onPageChange(page: number): void {
/**
* 翻页:失败则退回屏幕上正在显示的那一页。
*
* 不回滚的后果是分页器高亮与列表数据永久错位 —— 用户看到的是
* 「点了没反应,再点一下又跳回去」,且必须连点两次才能对齐。
*/
async function onPageChange(page: number): Promise<void> {
pageNum.value = page
void load()
const { ok, seq } = await fetchPage()
if (!ok) restoreShownPage(seq)
}
function onSizeChange(size: number): void {
/** 改每页条数:回到第一页;失败同时退回页码与条数 */
async function onSizeChange(size: number): Promise<void> {
pageSize.value = size
pageNum.value = 1
void load()
const { ok, seq } = await fetchPage()
if (!ok) restoreShownPage(seq)
}
/** 删除后刷新:当前页已被删空且不在第一页时,先回退页码再加载 */
+45 -21
View File
@@ -1,6 +1,7 @@
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
import { ElMessage } from 'element-plus'
import type { MenuIconName } from '@/components/layout/menuIcons'
import { firstAccessiblePath } from '@/router/access'
import { useUserStore } from '@/store/user'
import { ROLE_ADMIN } from '@/utils/constants'
@@ -20,6 +21,11 @@ declare module 'vue-router' {
menu?: boolean
/** 菜单分组名(同名聚合为一个子菜单);不设则为顶层菜单项 */
group?: string
/**
* 菜单图标语义 key —— 取值见 components/layout/menuIcons.ts 的注册表。
* 路由只写 key 不写图标组件:换图标库/换风格只改注册表。
*/
icon?: MenuIconName
/** 菜单排序,越小越靠前 */
order?: number
}
@@ -46,6 +52,7 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/dashboard/index.vue'),
meta: {
title: '首页',
icon: 'home',
order: 1,
allowedRoles: [ROLE_ADMIN, 'finance', 'leader'],
},
@@ -54,19 +61,25 @@ const routes: RouteRecordRaw[] = [
path: '/system/users',
name: 'system-users',
component: () => import('@/views/system/users/index.vue'),
meta: { title: '用户管理', group: '系统管理', requiresAdmin: true, order: 2 },
meta: { title: '用户管理', icon: 'users', group: '系统管理', requiresAdmin: true, order: 2 },
},
{
path: '/system/roles',
name: 'system-roles',
component: () => import('@/views/system/roles/index.vue'),
meta: { title: '角色管理', group: '系统管理', requiresAdmin: true, order: 3 },
meta: { title: '角色管理', icon: 'roles', group: '系统管理', requiresAdmin: true, order: 3 },
},
{
path: '/system/business-roles',
name: 'system-business-roles',
component: () => import('@/views/system/business-roles/index.vue'),
meta: { title: '岗位角色', group: '系统管理', requiresAdmin: true, order: 5 },
meta: {
title: '岗位角色',
icon: 'business-roles',
group: '系统管理',
requiresAdmin: true,
order: 5,
},
},
{
// 计提参数:查看放开到管理员/财务/领导(#17 答复「查看可、修改仅
@@ -76,6 +89,7 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/system/params/index.vue'),
meta: {
title: '计提参数',
icon: 'params',
group: '系统管理',
allowedRoles: [ROLE_ADMIN, 'finance', 'leader'],
order: 6,
@@ -85,7 +99,13 @@ const routes: RouteRecordRaw[] = [
path: '/system/settings',
name: 'system-settings',
component: () => import('@/views/system/settings/index.vue'),
meta: { title: '公司信息', group: '系统管理', requiresAdmin: true, order: 7 },
meta: {
title: '公司信息',
icon: 'settings',
group: '系统管理',
requiresAdmin: true,
order: 7,
},
},
{
// 审批管理:管理员/财务/领导可见(营销后勤待权限清单答复后放开)
@@ -94,6 +114,7 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/system/approvals/index.vue'),
meta: {
title: '审批管理',
icon: 'approvals',
group: '系统管理',
allowedRoles: [ROLE_ADMIN, 'finance', 'leader'],
order: 8,
@@ -103,67 +124,66 @@ const routes: RouteRecordRaw[] = [
path: '/system/logs',
name: 'system-logs',
component: () => import('@/views/system/logs/index.vue'),
meta: { title: '日志查询', group: '系统管理', requiresAdmin: true, order: 9 },
meta: {
title: '日志查询',
icon: 'logs',
group: '系统管理',
requiresAdmin: true,
order: 9,
},
},
{
// 基础数据属业务操作:登录即可访问,不设 requiresAdmin(对照后端 api/v1/base)
path: '/base/personnel',
name: 'base-personnel',
component: () => import('@/views/base/personnel/index.vue'),
meta: { title: '人员管理', group: '基础数据', order: 10 },
meta: { title: '人员管理', icon: 'personnel', group: '基础数据', order: 10 },
},
{
path: '/base/products',
name: 'base-products',
component: () => import('@/views/base/products/index.vue'),
meta: { title: '产品管理', group: '基础数据', order: 11 },
meta: { title: '产品管理', icon: 'products', group: '基础数据', order: 11 },
},
{
path: '/base/customers',
name: 'base-customers',
component: () => import('@/views/base/customers/index.vue'),
meta: { title: '客户管理', group: '基础数据', order: 12 },
meta: { title: '客户管理', icon: 'customers', group: '基础数据', order: 12 },
},
{
// 业务数据组一律为业务操作:登录即可访问,不设 requiresAdmin
path: '/business/businesses',
name: 'business-businesses',
component: () => import('@/views/business/businesses/index.vue'),
meta: { title: '业务管理', group: '业务数据', order: 13 },
meta: { title: '业务管理', icon: 'businesses', group: '业务数据', order: 13 },
},
{
path: '/business/shipments',
name: 'business-shipments',
component: () => import('@/views/business/shipments/index.vue'),
meta: { title: '发货管理', group: '业务数据', order: 14 },
meta: { title: '发货管理', icon: 'shipments', group: '业务数据', order: 14 },
},
{
path: '/business/receipts',
name: 'business-receipts',
component: () => import('@/views/business/receipts/index.vue'),
meta: { title: '回款管理', group: '业务数据', order: 15 },
},
{
// 业务数据组首个页面:评级计算器是打分工具,不影响绩效金额,
// 登录用户即可用,不设 requiresAdmin(区别于系统管理组)
path: '/business/rating',
name: 'business-rating',
component: () => import('@/views/business/rating/index.vue'),
meta: { title: '客户评级计算器', group: '业务数据', order: 16 },
meta: { title: '回款管理', icon: 'receipts', group: '业务数据', order: 15 },
},
// 客户评级计算器已并入客户管理(行内弹窗),独立页移除
{
// 绩效工资组(M3 ④):核算台账读登录即可,月结写限管理员与营销后勤
// (api 层 require_roles 兜底,页面层不再叠加 requiresAdmin)
path: '/calc/ledger',
name: 'calc-ledger',
component: () => import('@/views/calc/ledger/index.vue'),
meta: { title: '绩效台账', group: '绩效工资', order: 17 },
meta: { title: '绩效台账', icon: 'ledger', group: '绩效工资', order: 17 },
},
{
path: '/settlements',
name: 'settlements',
component: () => import('@/views/settlements/index.vue'),
meta: { title: '月度结算', group: '绩效工资', order: 18 },
meta: { title: '月度结算', icon: 'settlements', group: '绩效工资', order: 18 },
},
// 报表统计组(M4 ⑤):FR-030/031 限领导/财务/管理员;admin 须显式
// 列入 allowedRoles(项目既定规则,admin 不天然放行)
@@ -173,6 +193,7 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/reports/personal-detail/index.vue'),
meta: {
title: '个人绩效明细',
icon: 'report-personal',
group: '报表统计',
order: 19,
allowedRoles: [ROLE_ADMIN, 'finance', 'leader'],
@@ -184,6 +205,7 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/reports/department-summary/index.vue'),
meta: {
title: '部门绩效汇总',
icon: 'report-department',
group: '报表统计',
order: 20,
allowedRoles: [ROLE_ADMIN, 'finance', 'leader'],
@@ -195,6 +217,7 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/reports/business-stats/index.vue'),
meta: {
title: '业务统计',
icon: 'report-business',
group: '报表统计',
order: 21,
allowedRoles: [ROLE_ADMIN, 'finance', 'leader'],
@@ -206,6 +229,7 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/reports/payment-stats/index.vue'),
meta: {
title: '回款统计',
icon: 'report-payment',
group: '报表统计',
order: 22,
allowedRoles: [ROLE_ADMIN, 'finance', 'leader'],
+48
View File
@@ -0,0 +1,48 @@
import type { RatingGradeItem } from '@/api/ratings'
/**
* 评级等级判定(纯函数,客户列表与评级弹窗共用同一口径)。
*
* 规则:按 sort_order 升序取首个「min_score ≤ 总分」的等级,max_score 仅作
* 展示 —— 单边判定避免区间边界(如 75 分)在「≥75 且 ≤89」与「>74 且 <75」
* 两套写法间的重叠/遗漏歧义。
*
* 后端 min_score 是字符串直出(分值红线:分值全程不进 float),比较前必须
* parseFloat 转数值,禁止字符串直接比大小("90.0" 的字典序比较会得出错误
* 结果)。无命中(总分低于全部下限)返回 null,由调用方展示未命中提示。
*/
export function resolveGrade(
grades: RatingGradeItem[],
totalScore: number,
): RatingGradeItem | null {
const ordered = [...grades].sort((a, b) => a.sort_order - b.sort_order)
for (const grade of ordered) {
if (totalScore >= parseFloat(grade.min_score)) {
return grade
}
}
return null
}
/**
* 等级名称 → 色调后缀(客户列表的等级徽标用)。
*
* 按名称首字母映射到体系语义色:A 优秀(绿)、B 良好(靛蓝)、C 合格
* (琥珀)、D 待改进(砖红);自定义等级名不在此列,落回中性灰 —— 等级
* 名称是业务可配置的,不能靠写死的枚举判定语义,未知即中性最安全。
*/
export function gradeTone(gradeName: string): '' | 'ok' | 'lock' | 'wait' | 'reject' {
const initial = gradeName.trim().charAt(0).toUpperCase()
switch (initial) {
case 'A':
return 'ok'
case 'B':
return 'lock'
case 'C':
return 'wait'
case 'D':
return 'reject'
default:
return ''
}
}
@@ -0,0 +1,557 @@
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import {
createRatingGrade,
deleteRatingGrade,
listRatingGrades,
updateRatingGrade,
type RatingGradeItem,
type RatingGradeParams,
} from '@/api/ratings'
import { rateCustomer, type CustomerItem } from '@/api/customers'
import { useConfirmAction } from '@/composables/useConfirmAction'
import { formatDateTime } from '@/utils/format'
import { resolveGrade } from '@/utils/rating'
import { handledError } from '@/utils/error'
/**
* 客户评级计算器(需求 2.2.5 / 对照表 #25)· 客户管理行内弹窗版。
*
* 原独立页(/business/rating)并入客户管理:在客户行上点「评级计算」打开,
* 五项打分实时判定等级,「保存评分」把总分写入客户档案(rating_score +
* rating_time)。等级**不落库**,由分数 + 当前等级映射实时算(判定函数在
* utils/rating.ts,与列表页共用同一口径);等级映射日后调整,全部客户的
* 等级随之更新,档案只负责记住「这个分是什么时候打的」。
*
* 五项打分明细同样不落库 —— 那是打分过程的中间量,没有下游消费方。
*
* 等级映射的增删改走 /v1/business/rating-grades 维护接口,配置变更后
* 重载判定用列表 —— 判定与抽屉表格共用同一份数据。
*/
const visible = defineModel<boolean>('visible', { default: false })
const props = defineProps<{
/** 目标客户;弹窗打开期间必非空(由父页在 open 时赋值) */
customer: CustomerItem | null
}>()
const emit = defineEmits<{
/** 保存成功,带回最新 CustomerItem(父页原位更新行,不打乱当前浏览位置) */
saved: [item: CustomerItem]
/**
* 等级映射被改动(新增/编辑/删除)。
*
* 父页的等级徽标靠自己缓存的一份映射把分数翻成等级,抽屉里改完配置
* 那份缓存就旧了 —— 必须通知父页重拉,否则「同一分数在弹窗里是 B、
* 在列表里还是 C」,两处判定口径出现可见的分歧。
*/
gradesChanged: []
}>()
// ==================== 评分计算(纯前端实时计算) ====================
/** 五项评分项:各 0-20 分、步进 1,总分 = 五项之和(0-100) */
const SCORE_ITEMS = [
{ key: 'ship_amount', label: '发货额' },
{ key: 'receipt_rate', label: '回款率' },
{ key: 'profit_rate', label: '毛利率' },
{ key: 'contract_cooperation', label: '合同履约配合能力' },
{ key: 'site_construction', label: '现场规范施工能力' },
] as const
type ScoreKey = (typeof SCORE_ITEMS)[number]['key']
/** el-input-number 清空后值为 undefined,字段用宽松类型,求和处 ?? 0 兜底 */
const scores = reactive<Record<ScoreKey, number | undefined>>({
ship_amount: 0,
receipt_rate: 0,
profit_rate: 0,
contract_cooperation: 0,
site_construction: 0,
})
const totalScore = computed(() =>
SCORE_ITEMS.reduce((sum, item) => sum + (scores[item.key] ?? 0), 0),
)
/** 重置即把五项清零(不动档案里已保存的评分) */
function resetScores(): void {
for (const item of SCORE_ITEMS) {
scores[item.key] = 0
}
}
// ==================== 等级映射(判定用列表) ====================
const { confirmAndRun } = useConfirmAction()
const grades = ref<RatingGradeItem[]>([])
const gradesLoading = ref(false)
/** 判定与抽屉表格共用同一份全量列表,配置变更后重载一次即同时生效 */
async function loadGrades(): Promise<void> {
gradesLoading.value = true
try {
grades.value = await listRatingGrades()
} catch (error) {
handledError(error)
} finally {
gradesLoading.value = false
}
}
/** 配置被改动后的统一收尾:重载本弹窗的判定列表 + 通知父页刷新徽标缓存 */
async function reloadAfterGradeChange(): Promise<void> {
await loadGrades()
emit('gradesChanged')
}
const matchedGrade = computed(() => resolveGrade(grades.value, totalScore.value))
// ==================== 弹窗打开 / 关闭 ====================
/** 打开时重置五项并重载等级配置,避免上一次打分的残留 */
watch(visible, (isOpen) => {
if (!isOpen) return
resetScores()
void loadGrades()
})
// ==================== 保存评分 ====================
const saving = ref(false)
/** 档案里已保存的评分(打开时的快照,仅展示用;保存成功后由父页更新行数据) */
const archivedScore = computed(() => {
const target = props.customer
if (!target || target.rating_score === null) return null
// 等级不落库,按当前映射由已存分数实时算(与列表页同一口径)
const grade = resolveGrade(grades.value, target.rating_score)
return {
score: target.rating_score,
grade: grade?.grade_name ?? null,
time: target.rating_time ? formatDateTime(target.rating_time) : '',
}
})
async function handleSave(): Promise<void> {
const target = props.customer
if (!target) return
saving.value = true
try {
const item = await rateCustomer(target.id, { score: totalScore.value })
ElMessage.success('评级已保存')
emit('saved', item)
visible.value = false
} catch (error) {
handledError(error) // 失败提示由请求拦截器统一完成
} finally {
saving.value = false
}
}
// ==================== 等级映射设置抽屉 ====================
const drawerVisible = ref(false)
/** 打开时重新拉取,避免展示陈旧数据 */
function openDrawer(): void {
drawerVisible.value = true
void loadGrades()
}
// ==================== 新增 / 编辑等级弹窗 ====================
const editVisible = ref(false)
/** null = 新增,否则为编辑目标 id */
const editingId = ref<number | null>(null)
const editSaving = ref(false)
const isCreate = computed(() => editingId.value === null)
const editForm = reactive({
grade_name: '',
min_score: undefined as number | undefined,
max_score: undefined as number | undefined,
sort_order: 0,
})
function openCreate(): void {
editingId.value = null
editForm.grade_name = ''
editForm.min_score = undefined
editForm.max_score = undefined
editForm.sort_order = 0
editVisible.value = true
}
function openEdit(row: RatingGradeItem): void {
editingId.value = row.id
editForm.grade_name = row.grade_name
// 后端字符串直出,回填 parseFloat 转数值,保存时再 String() 转回字符串
editForm.min_score = parseFloat(row.min_score)
editForm.max_score = parseFloat(row.max_score)
editForm.sort_order = row.sort_order
editVisible.value = true
}
/** 前端值域校验(0≤min≤max≤100);后端在审计上下文内兜底同规则与区间重叠 */
function editFormError(minScore: number | undefined, maxScore: number | undefined): string | null {
if (!editForm.grade_name.trim()) return '请输入等级名称'
if (minScore === undefined || maxScore === undefined) return '请输入分值区间'
if (minScore < 0 || maxScore > 100 || minScore > maxScore) {
return '分值区间须满足 0 ≤ 下限 ≤ 上限 ≤ 100'
}
return null
}
async function saveGrade(): Promise<void> {
// 局部变量收窄类型:reactive 属性在异步流程中不保证保持窄化
const minScore = editForm.min_score
const maxScore = editForm.max_score
const error = editFormError(minScore, maxScore)
if (error) {
ElMessage.warning(error)
return
}
const payload: RatingGradeParams = {
grade_name: editForm.grade_name.trim(),
min_score: String(minScore),
max_score: String(maxScore),
sort_order: editForm.sort_order ?? 0,
}
// 后端为全量替换语义;remark 本组件不维护,编辑时原样带回避免误清
if (editingId.value !== null) {
payload.remark = grades.value.find((g) => g.id === editingId.value)?.remark ?? undefined
}
editSaving.value = true
try {
if (editingId.value === null) {
await createRatingGrade(payload)
} else {
await updateRatingGrade(editingId.value, payload)
}
editVisible.value = false
ElMessage.success(isCreate.value ? '评级等级已创建' : '已保存')
await reloadAfterGradeChange()
} catch (requestError) {
handledError(requestError)
} finally {
editSaving.value = false
}
}
/** 删除走确认动作流;文案明确后果(该区间不再能映射等级) */
function removeGrade(row: RatingGradeItem): void {
void confirmAndRun({
confirmText: `确定删除等级「${row.grade_name}」(${row.min_score} ~ ${row.max_score})?删除后该分数区间将无法映射等级。`,
title: '删除确认',
confirmButtonText: '删除',
successText: '评级等级已删除',
run: () => deleteRatingGrade(row.id),
after: () => reloadAfterGradeChange(),
})
}
</script>
<template>
<el-dialog
v-model="visible"
:title="`客户评级 · ${customer?.company_name ?? ''}`"
width="680px"
destroy-on-close
>
<!-- 对照表 #25:口径提示改写 —— 评分现在会落库,但仍不参与绩效金额 -->
<el-alert
class="dialog-alert"
title="评分保存为客户档案,只用于客户评级展示,不参与绩效金额计算"
type="warning"
:closable="false"
show-icon
/>
<div v-if="archivedScore" class="archived-line">
档案评分:<b>{{ archivedScore.score }}</b> 分
<template v-if="archivedScore.grade">
· 等级 <b>{{ archivedScore.grade }}</b>
</template>
<span v-if="archivedScore.time" class="archived-time">({{ archivedScore.time }} 保存)</span>
<span class="archived-tip">重新保存将覆盖</span>
</div>
<div class="calc-grid">
<div class="calc-form">
<div class="section-title">评分项(各 0-20 分,步进 1)</div>
<div v-for="item in SCORE_ITEMS" :key="item.key" class="score-row">
<span class="score-label">{{ item.label }}</span>
<el-input-number
v-model="scores[item.key]"
:min="0"
:max="20"
:step="1"
controls-position="right"
/>
<span class="score-unit">分</span>
</div>
</div>
<div class="calc-result">
<div class="section-title">判定结果</div>
<div>
<span class="result-label">总分</span>
<b class="total-num">{{ totalScore }}</b>
<span class="total-max">/ 100</span>
</div>
<div>
<span class="result-label">评级等级</span>
<template v-if="grades.length === 0">
<div class="grade-name">—</div>
<div class="grade-hint">未配置等级映射,仍可保存总分</div>
</template>
<template v-else-if="matchedGrade">
<div class="grade-name">{{ matchedGrade.grade_name }}</div>
<div class="grade-hint">
分值区间 {{ matchedGrade.min_score }} ~ {{ matchedGrade.max_score }}
</div>
</template>
<template v-else>
<div class="grade-name">—</div>
<div class="grade-hint">总分低于全部等级下限,未命中等级</div>
</template>
</div>
</div>
</div>
<template #footer>
<el-button @click="resetScores">重置</el-button>
<el-button @click="openDrawer">等级映射设置</el-button>
<el-button type="primary" :loading="saving" @click="handleSave">保存评分</el-button>
</template>
</el-dialog>
<!-- 等级映射设置抽屉(评级弹窗的二级层,append-to-body 保证遮罩层级正确) -->
<el-drawer v-model="drawerVisible" title="等级映射设置" size="640px" append-to-body>
<div class="drawer-hint">
判定规则:按排序升序取首个「下限 ≤ 总分」的等级,上限仅作展示;修改后评级区立即按新配置判定。
</div>
<div class="drawer-toolbar">
<el-button type="primary" size="small" @click="openCreate">新增等级</el-button>
</div>
<el-table v-loading="gradesLoading" :data="grades" size="small">
<el-table-column prop="grade_name" label="等级名称" min-width="110" />
<el-table-column prop="min_score" label="下限(含)" width="100" align="center" />
<el-table-column prop="max_score" label="上限" width="90" align="center" />
<el-table-column prop="sort_order" label="排序" width="70" align="center" />
<el-table-column label="操作" width="110" align="center">
<template #default="{ row }">
<div class="table-actions">
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
<span class="table-actions__divider" aria-hidden="true"></span>
<el-button link type="danger" size="small" @click="removeGrade(row)">删除</el-button>
</div>
</template>
</el-table-column>
</el-table>
</el-drawer>
<!-- 新增 / 编辑等级 -->
<el-dialog
v-model="editVisible"
:title="isCreate ? '新增评级等级' : '编辑评级等级'"
width="440px"
destroy-on-close
append-to-body
@closed="editingId = null"
>
<el-form label-width="90px" @submit.prevent>
<el-form-item label="等级名称" required>
<el-input v-model="editForm.grade_name" maxlength="32" placeholder="如:A / 优秀" />
</el-form-item>
<el-form-item label="下限(含)" required>
<el-input-number
v-model="editForm.min_score"
:min="0"
:max="100"
:step="5"
controls-position="right"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="上限" required>
<el-input-number
v-model="editForm.max_score"
:min="0"
:max="100"
:step="5"
controls-position="right"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="排序">
<el-input-number
v-model="editForm.sort_order"
:min="0"
:step="10"
controls-position="right"
/>
<span class="form-tip">升序判定,越小越先判定</span>
</el-form-item>
</el-form>
<div class="form-tip-block">
分值区间须满足 0 ≤ 下限 ≤ 上限 ≤ 100;区间重叠、名称重复由后端校验兜底。
</div>
<template #footer>
<el-button @click="editVisible = false">取消</el-button>
<el-button type="primary" :loading="editSaving" @click="saveGrade">保存</el-button>
</template>
</el-dialog>
</template>
<style scoped>
/* 版式皮肤(.card/.section-title/按钮/表格)由全局 index.css 提供,这里只留弹窗特有排版 */
.dialog-alert {
margin-bottom: 12px;
}
/* 档案评分快照行:一眼看出这是覆盖式保存 */
.archived-line {
font-size: 13px;
color: var(--ys-ink-2);
margin-bottom: 12px;
}
.archived-time {
color: var(--ys-ink-3);
}
.archived-tip {
margin-left: 10px;
font-size: 12px;
color: var(--ys-ink-3);
}
.calc-grid {
display: flex;
gap: 28px;
align-items: stretch;
flex-wrap: wrap;
}
.calc-form {
flex: 1 1 320px;
min-width: 280px;
}
.score-row {
display: flex;
align-items: center;
gap: 12px;
line-height: 2.7;
}
.score-label {
width: 140px;
font-size: 13px;
color: var(--ys-ink);
text-align: right;
}
.score-unit {
font-size: 12px;
color: var(--ys-ink-3);
}
.calc-result {
flex: 0 0 220px;
min-width: 200px;
border: 1px solid var(--ys-line);
border-radius: 10px;
padding: 18px 20px;
background: linear-gradient(180deg, #ffffff, #fcfcfe);
display: flex;
flex-direction: column;
justify-content: center;
gap: 18px;
}
.result-label {
display: block;
font-size: 12px;
letter-spacing: 0.04em;
color: var(--ys-ink-3);
}
.total-num {
font-size: 36px;
color: var(--ys-indigo);
font-variant-numeric: tabular-nums;
margin-right: 4px;
}
.total-max {
font-size: 13px;
color: var(--ys-ink-3);
}
.grade-name {
font-family: var(--ys-font-brand);
font-size: 26px;
font-weight: 600;
color: var(--ys-ink);
line-height: 1.3;
margin-top: 2px;
}
.grade-hint {
font-size: 12px;
color: var(--ys-ink-3);
margin-top: 4px;
}
.drawer-hint {
font-size: 12px;
color: var(--ys-ink-3);
background: var(--ys-mist);
border-radius: 6px;
padding: 8px 12px;
margin-bottom: 12px;
}
.drawer-toolbar {
display: flex;
justify-content: flex-end;
margin-bottom: 10px;
}
.form-tip {
font-size: 12px;
color: var(--ys-ink-3);
margin-left: 8px;
}
.form-tip-block {
font-size: 12px;
color: var(--ys-ink-3);
background: var(--ys-mist);
border-radius: 6px;
padding: 8px 12px;
}
/* 移动端:标签左对齐、结果块自然换行到表单下方 */
@media (max-width: 768px) {
.score-label {
width: auto;
text-align: left;
}
.calc-result {
flex: 1 1 100%;
}
}
</style>
+289 -68
View File
@@ -5,11 +5,15 @@ import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import * as customerApi from '@/api/customers'
import type { CustomerItem, OwnershipLogItem } from '@/api/customers'
import * as personnelApi from '@/api/personnel'
import { listRatingGrades, type RatingGradeItem } from '@/api/ratings'
import AppPagination from '@/components/table/AppPagination.vue'
import { useConfirmAction } from '@/composables/useConfirmAction'
import { useDropdownOptions } from '@/composables/useDropdownOptions'
import { usePagedList } from '@/composables/usePagedList'
import { formatDateTime } from '@/utils/format'
import { gradeTone, resolveGrade } from '@/utils/rating'
import { handledError } from '@/utils/error'
import RatingCalculatorDialog from './RatingCalculatorDialog.vue'
/**
* 客户管理(需求 2.1.3:客户档案 + 归属人员 + 归属变更留痕)。
@@ -20,6 +24,9 @@ import { handledError } from '@/utils/error'
* - 归属变更提交前二次确认,文案必须包含「仅影响变更后核算,不回溯历史」
* (对照表 #19 的业务口径)
* - 归属变更记录为分页留痕表(抽屉内展示,按变更时间倒序)
* - 评级计算器并入本页(原 /business/rating 独立页已移除):行内弹窗打分,
* 保存评分只写档案字段 rating_score / rating_time;等级不落库,由分数按
* 当前等级映射实时算(utils/rating.ts 与弹窗共用同一判定函数)
*/
const { confirmAndRun } = useConfirmAction()
@@ -38,6 +45,8 @@ const { records, total, pageNum, pageSize, loading, load, search, onPageChange,
keyword: filters.keyword,
customer_type: filters.customer_type,
owner_personnel_id: filters.owner_personnel_id,
order_by: sortState.order_by,
order: sortState.order,
}),
)
@@ -48,6 +57,96 @@ function resetFilters(): void {
search()
}
// ==================== 排序(评分列) ====================
/**
* 表头排序是查询参数而非本地排序:分页下本地排序只能排当前页。
* 目前唯一可排序列是评分;el-table 第三次点击取消排序,回到建档序(id 倒序)。
*/
const sortState = reactive<{ order_by: 'id' | 'rating_score'; order: 'asc' | 'desc' }>({
order_by: 'id',
order: 'desc',
})
function onSortChange({
prop,
order,
}: {
prop: string
order: 'ascending' | 'descending' | null
}): void {
if (prop === 'rating_score' && order) {
sortState.order_by = 'rating_score'
sortState.order = order === 'ascending' ? 'asc' : 'desc'
} else {
sortState.order_by = 'id'
sortState.order = 'desc'
}
search()
}
// ==================== 评级计算 ====================
const ratingVisible = ref(false)
const ratingTarget = ref<CustomerItem | null>(null)
function openRating(row: CustomerItem): void {
ratingTarget.value = row
ratingVisible.value = true
}
/** 保存评分后回写列表:按评分排序时刷新(位置随分变化),否则原位更新不打乱浏览 */
function onRatingSaved(item: CustomerItem): void {
if (sortState.order_by === 'rating_score') {
void load()
return
}
const index = records.value.findIndex((record) => record.id === item.id)
if (index !== -1) records.value[index] = item
}
// ==================== 评级列(等级 + 分数) ====================
/**
* 等级映射只读列表:列表页的等级徽标要靠它把分数翻成等级。
*
* 等级不落库(用户口径:等级在前端算),所以每行渲染都要用当前映射;
* 拉一次缓存在内存里,避免逐行请求。配置在评级弹窗的抽屉里改完会回调
* reloadGrades,列表徽标随之更新。
*/
const grades = ref<RatingGradeItem[]>([])
async function loadGrades(): Promise<void> {
try {
grades.value = await listRatingGrades()
} catch (error) {
// 等级配置拉取失败不该挡住客户列表:徽标退化为只显示分数
handledError(error)
}
}
/** 分数 → 等级名(未命中等级时返回「未定级」,不显示空白) */
function gradeLabel(score: number): string {
return resolveGrade(grades.value, score)?.grade_name ?? '未定级'
}
/** 等级色调后缀(A 绿 / B 靛蓝 / C 琥珀 / D 砖红 / 自定义中性) */
function gradeNameClass(score: number): string {
const grade = resolveGrade(grades.value, score)
if (!grade) return ''
const tone = gradeTone(grade.grade_name)
return tone ? `grade__name--${tone}` : ''
}
/** 悬浮提示:等级区间 + 评级时间,回答「这个等级是怎么来的、什么时候打的」 */
function ratingTitle(row: CustomerItem): string {
if (row.rating_score === null) return '尚未评级'
const grade = resolveGrade(grades.value, row.rating_score)
const range = grade ? `${grade.grade_name} 档(${grade.min_score} ~ ${grade.max_score} 分)` : '未命中任何等级档'
const time = row.rating_time ? `,${formatDateTime(row.rating_time)} 评定` : ''
return `${range}${time};重新评级请点行内「评级计算」`
}
// ==================== 归属人员下拉数据源 ====================
interface PersonnelOption {
@@ -56,18 +155,14 @@ interface PersonnelOption {
status: number
}
const ownerOptions = ref<PersonnelOption[]>([])
async function loadOwnerOptions(): Promise<void> {
try {
const { options: ownerOptions, load: loadOwnerOptions } = useDropdownOptions<PersonnelOption>(
async () => {
// 归属下拉的近似全量数据源:内部系统人员规模有限,200 条足够;
// 极端场景由后端对归属合法性的校验兜底
const data = await personnelApi.listPersonnel({ pageNum: 1, pageSize: 200 })
ownerOptions.value = data.list.map((p) => ({ id: p.id, name: p.personnel_name, status: p.status }))
} catch (error) {
handledError(error) // 下拉为空不阻断列表使用
}
}
return data.list.map((p) => ({ id: p.id, name: p.personnel_name, status: p.status }))
},
)
/** 下拉项展示名:离职人员加后缀标注(仍可被选作归属吗?由后端校验兜底) */
function ownerLabel(option: PersonnelOption): string {
@@ -314,69 +409,127 @@ async function handleDelete(row: CustomerItem): Promise<void> {
// ==================== 初始化 ====================
onMounted(() => {
// 两个请求各自容错(load 内部已兜底),互不拖累
// 三个请求各自容错(load 内部已兜底),互不拖累
void load()
void loadOwnerOptions()
// 等级徽标要用等级映射把分数翻成等级,先拉一次;失败只影响徽标,不影响列表
void loadGrades()
})
</script>
<template>
<div>
<el-card shadow="never">
<el-form class="filter-form" :inline="true" @submit.prevent>
<el-form-item label="关键词">
<el-input
v-model="filters.keyword"
placeholder="公司名称或客户负责人"
clearable
style="width: 190px"
@keyup.enter="search"
<div class="page-head">
<h1 class="page-title">客户管理</h1>
<p class="page-desc">
客户档案与归属人员;归属变更只影响变更后的核算、不回溯历史,评分与等级由「评级计算」维护并保存进档案
</p>
<span class="waterline"></span>
</div>
<!--
.filter-bar 直接挂在 el-form 上:el-form 不是 flex 透明容器,
外面再包一层 div 会让 el-form-item 丢掉 gap 排版,筛选条退化成竖排。
-->
<el-form class="filter-bar" @submit.prevent>
<el-form-item label="关键词">
<el-input
v-model="filters.keyword"
placeholder="公司名称或客户负责人"
clearable
style="width: 190px"
@keyup.enter="search"
/>
</el-form-item>
<el-form-item label="分类">
<el-select v-model="filters.customer_type" placeholder="全部" clearable style="width: 130px">
<el-option
v-for="item in customerApi.CUSTOMER_TYPE_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-form-item>
<el-form-item label="分类">
<el-select v-model="filters.customer_type" placeholder="全部" clearable style="width: 130px">
<el-option
v-for="item in customerApi.CUSTOMER_TYPE_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="归属人员">
<el-select
v-model="filters.owner_personnel_id"
placeholder="全部"
clearable
filterable
style="width: 160px"
>
<el-option
v-for="item in ownerOptions"
:key="item.id"
:label="ownerLabel(item)"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="search">查询</el-button>
<el-button @click="resetFilters">重置</el-button>
</el-form-item>
</el-form>
<div class="toolbar">
<el-button type="primary" @click="openCreate">新增客户</el-button>
</el-select>
</el-form-item>
<el-form-item label="归属人员">
<el-select
v-model="filters.owner_personnel_id"
placeholder="全部"
clearable
filterable
style="width: 160px"
>
<el-option
v-for="item in ownerOptions"
:key="item.id"
:label="ownerLabel(item)"
:value="item.id"
/>
</el-select>
</el-form-item>
<div class="filter-bar__actions">
<el-button type="primary" @click="search">查询</el-button>
<el-button @click="resetFilters">重置</el-button>
</div>
</el-form>
<el-table v-loading="loading" :data="records" stripe>
<div class="toolbar">
<el-button type="primary" @click="openCreate">新增客户</el-button>
<span class="toolbar-note">归属调整走行内「归属变更」,系统自动留痕</span>
</div>
<!-- ys-rise:整页只给列表卡一次入场上浮,筛选与页头立即呈现,避免整屏一起动 -->
<div class="card ys-rise">
<el-table
v-loading="loading"
:data="records"
stripe
empty-text="还没有客户 —— 点「新增客户」建第一个,或换个关键词再查"
@sort-change="onSortChange"
>
<el-table-column prop="company_name" label="公司名称" min-width="180" />
<!--
评级列 = 等级 + 分数,纯文字两级层次。
等级不落库,按当前等级映射由已存分数实时算(与评级弹窗同一判定函数
utils/rating.ts)。
形态刻意既不用 .ys-pill 也不用色块:全站胶囊是「状态」的语汇(草稿/
待审/已锁定/已驳回,都带圆点和胶囊边),评级是档案属性,套状态形态
会让两种信息混读;色块则会在每行右侧堆一片彩色矩形,表格看起来像
贴了标签墙。这里只用字重与语义色分层 —— 等级立起来(13px/600/语义色),
分数退到弱墨等宽(13px/tabular-nums),一行读完「B 级 82 分」,
整列数字竖向对齐可扫。
排序仍挂在 rating_score 上。
-->
<el-table-column
prop="rating_score"
label="评级"
width="96"
align="center"
sortable="custom"
>
<template #default="{ row }">
<span v-if="row.rating_score !== null" class="grade" :title="ratingTitle(row)">
<span class="grade__name" :class="gradeNameClass(row.rating_score)">
{{ gradeLabel(row.rating_score) }}
</span>
<span class="grade__score">{{ row.rating_score }}</span>
</span>
<span v-else class="cell-muted" title="尚未评级">—</span>
</template>
</el-table-column>
<el-table-column prop="owner_name" label="负责人" min-width="90" />
<el-table-column prop="contact_person" label="联系人" min-width="90">
<template #default="{ row }">{{ row.contact_person ?? '—' }}</template>
<template #default="{ row }">
<span v-if="row.contact_person">{{ row.contact_person }}</span>
<span v-else class="cell-muted">—</span>
</template>
</el-table-column>
<el-table-column prop="contact_phone" label="电话" min-width="120">
<template #default="{ row }">{{ row.contact_phone ?? '—' }}</template>
<template #default="{ row }">
<span v-if="row.contact_phone">{{ row.contact_phone }}</span>
<span v-else class="cell-muted">—</span>
</template>
</el-table-column>
<el-table-column label="分类" width="110" align="center">
<template #default="{ row }">
@@ -396,11 +549,15 @@ onMounted(() => {
</template>
</el-table-column>
<el-table-column label="归属人员" min-width="110">
<template #default="{ row }">{{ row.owner_personnel_name ?? '(无归属)' }}</template>
<template #default="{ row }">
<span v-if="row.owner_personnel_name">{{ row.owner_personnel_name }}</span>
<span v-else class="cell-muted">(无归属)</span>
</template>
</el-table-column>
<el-table-column label="操作" width="260" fixed="right" align="center">
<el-table-column label="操作" width="340" fixed="right" align="center">
<template #default="{ row }">
<div class="table-actions">
<el-button link type="primary" size="small" @click="openRating(row)">评级计算</el-button>
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
<el-button link type="primary" size="small" @click="openOwnership(row)">
归属变更
@@ -413,14 +570,16 @@ onMounted(() => {
</el-table-column>
</el-table>
<AppPagination
:total="total"
:page-num="pageNum"
:page-size="pageSize"
@page-change="onPageChange"
@size-change="onSizeChange"
/>
</el-card>
<div class="card-foot">
<AppPagination
:total="total"
:page-num="pageNum"
:page-size="pageSize"
@page-change="onPageChange"
@size-change="onSizeChange"
/>
</div>
</div>
<!-- 新增 / 编辑(编辑不含归属,归属走独立按钮保证留痕) -->
<el-dialog
@@ -532,7 +691,13 @@ onMounted(() => {
<!-- 归属变更记录(分页留痕,按变更时间倒序) -->
<el-drawer v-model="logsVisible" :title="logsTitle" size="760px" destroy-on-close>
<el-table v-loading="logsLoading" :data="logRecords" stripe size="small">
<el-table
v-loading="logsLoading"
:data="logRecords"
stripe
size="small"
empty-text="这个客户还没有归属变更记录 —— 调整一次归属后这里就会出现留痕"
>
<el-table-column label="变更时间" width="160">
<template #default="{ row }">{{ formatDateTime(row.create_time) }}</template>
</el-table-column>
@@ -552,6 +717,14 @@ onMounted(() => {
@size-change="onLogsSizeChange"
/>
</el-drawer>
<!-- 评级计算器(原 /business/rating 独立页并入;保存评分回写档案) -->
<RatingCalculatorDialog
v-model:visible="ratingVisible"
:customer="ratingTarget"
@saved="onRatingSaved"
@grades-changed="loadGrades"
/>
</div>
</template>
@@ -564,4 +737,52 @@ onMounted(() => {
font-size: 12px;
line-height: 1.7;
}
/*
* 评级列 = 等级 + 分数,纯文字两级层次(无底色、无边框、无胶囊)。
*
* 为什么不用胶囊也不用色块:全站胶囊(.ys-pill)是「状态」的语汇 —— 草稿/
* 待审/已锁定/已驳回,都带圆点和胶囊边;评级是档案属性不是流程状态,套状态
* 形态会让两种信息混读。色块方案在实测里会在每行右侧堆一片彩色矩形,表格
* 像贴了标签墙,且「A 档」这类两字自定义等级名一撑就变形。
*
* 改成纯文字分层:等级 13px/600 用语义色立起来(这是几档),分数 13px 弱墨
* 等宽跟在后面(依据多少)。零装饰元素,行高不受影响,等级名多长都不会挤。
*/
.grade {
display: inline-flex;
align-items: baseline;
gap: 6px;
line-height: 1;
}
.grade__name {
font-size: 13px;
font-weight: 600;
color: var(--ys-ink-2);
}
/* 四档语义色:只改字色,不加底色 —— 表格里色块过多会压过数据本身 */
.grade__name--ok {
color: var(--ys-ok);
}
.grade__name--lock {
color: var(--ys-indigo);
}
.grade__name--wait {
color: var(--ys-warn);
}
.grade__name--reject {
color: var(--ys-danger);
}
/* 分数:等宽数字保证整列竖向对齐,弱墨让视线先落在等级上 */
.grade__score {
font-size: 13px;
font-variant-numeric: tabular-nums;
color: var(--ys-ink-3);
}
</style>
+105 -84
View File
@@ -8,6 +8,7 @@ import type { PersonnelItem } from '@/api/personnel'
import AppPagination from '@/components/table/AppPagination.vue'
import StatusTag from '@/components/table/StatusTag.vue'
import { useConfirmAction } from '@/composables/useConfirmAction'
import { useDropdownOptions } from '@/composables/useDropdownOptions'
import { usePagedList } from '@/composables/usePagedList'
import { useEditingRow } from '@/composables/useEditingRow'
import { AMOUNT_PATTERN } from '@/utils/validate'
@@ -64,29 +65,22 @@ interface SelectOption {
status: number
}
const roleOptions = ref<SelectOption[]>([])
const managerOptions = ref<SelectOption[]>([])
async function loadRoleOptions(): Promise<void> {
try {
const { options: roleOptions, load: loadRoleOptions } = useDropdownOptions<SelectOption>(
async () => {
// 岗位规模有限(内置 3 个 + 自定义),选项接口一次取全
const data = await businessRoleApi.listBusinessRoleOptions()
roleOptions.value = data.map((r) => ({ id: r.id, name: r.role_name, status: r.status }))
} catch (error) {
handledError(error) // 下拉为空不阻断列表使用
}
}
return data.map((r) => ({ id: r.id, name: r.role_name, status: r.status }))
},
)
async function loadManagerOptions(): Promise<void> {
try {
const { options: managerOptions, load: loadManagerOptions } = useDropdownOptions<SelectOption>(
async () => {
// 经理下拉的近似全量数据源:内部系统人员规模有限,200 条足够;
// 超出规模的极端场景由后端对经理合法性的校验兜底
const data = await personnelApi.listPersonnel({ pageNum: 1, pageSize: 200 })
managerOptions.value = data.list.map((p) => ({ id: p.id, name: p.personnel_name, status: p.status }))
} catch (error) {
handledError(error)
}
}
return data.list.map((p) => ({ id: p.id, name: p.personnel_name, status: p.status }))
},
)
// ==================== 新增 / 编辑(基本信息) ====================
@@ -372,54 +366,73 @@ onMounted(() => {
<template>
<div>
<el-card shadow="never">
<el-form class="filter-form" :inline="true" @submit.prevent>
<el-form-item label="姓名">
<el-input
v-model="filters.keyword"
placeholder="姓名,模糊匹配"
clearable
style="width: 180px"
@keyup.enter="search"
<div class="page-head">
<h1 class="page-title">人员管理</h1>
<p class="page-desc">
人员岗位与适用计提参数在这里定;改岗位会改变后续回款的绩效口径,设为离职后不可再被业务与客户归属引用
</p>
<span class="waterline"></span>
</div>
<!--
.filter-bar 直接挂在 el-form 上:el-form 不是 flex 透明容器,
外面再包一层 div 会让 el-form-item 丢掉 gap 排版,筛选条退化成竖排。
-->
<el-form class="filter-bar" @submit.prevent>
<el-form-item label="姓名">
<el-input
v-model="filters.keyword"
placeholder="姓名,模糊匹配"
clearable
style="width: 180px"
@keyup.enter="search"
/>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="filters.status" placeholder="全部" clearable style="width: 110px">
<el-option
v-for="item in EMPLOYMENT_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="filters.status" placeholder="全部" clearable style="width: 110px">
<el-option
v-for="item in EMPLOYMENT_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="岗位">
<el-select
v-model="filters.business_role_id"
placeholder="全部"
clearable
filterable
style="width: 160px"
>
<el-option
v-for="item in searchRoleOptions"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="search">查询</el-button>
<el-button @click="resetFilters">重置</el-button>
</el-form-item>
</el-form>
<div class="toolbar">
<el-button type="primary" @click="openCreate">新增人员</el-button>
</el-select>
</el-form-item>
<el-form-item label="岗位">
<el-select
v-model="filters.business_role_id"
placeholder="全部"
clearable
filterable
style="width: 160px"
>
<el-option
v-for="item in searchRoleOptions"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
<div class="filter-bar__actions">
<el-button type="primary" @click="search">查询</el-button>
<el-button @click="resetFilters">重置</el-button>
</div>
</el-form>
<el-table v-loading="loading" :data="records" stripe>
<div class="toolbar">
<el-button type="primary" @click="openCreate">新增人员</el-button>
<span class="toolbar-note">辐射区域与拓展计划在行内「区域与计划」维护,保存即全量替换</span>
</div>
<!-- ys-rise:整页只给列表卡一次入场上浮,筛选与页头立即呈现,避免整屏一起动 -->
<div class="card ys-rise">
<el-table
v-loading="loading"
:data="records"
stripe
empty-text="还没有人员 —— 点「新增人员」建第一个,或换个关键词再查"
>
<el-table-column prop="personnel_name" label="姓名" min-width="100" />
<el-table-column prop="role_name" label="岗位" min-width="100">
<template #default="{ row }">{{ row.role_name ?? '—' }}</template>
@@ -444,10 +457,10 @@ onMounted(() => {
<el-table-column label="工资标准(元)" min-width="120" align="right">
<template #default="{ row }">
<!-- 字符串直出:金额红线,禁 toFixed / float 格式化 -->
<span class="cell-amount">{{ row.salary_standard ?? '—' }}</span>
<span class="cell-num">{{ row.salary_standard ?? '—' }}</span>
</template>
</el-table-column>
<el-table-column label="状态" width="90" align="center">
<el-table-column label="状态" width="96" align="center">
<template #default="{ row }">
<StatusTag :value="row.status" active-text="在职" inactive-text="离职" />
</template>
@@ -469,14 +482,16 @@ onMounted(() => {
</el-table-column>
</el-table>
<AppPagination
:total="total"
:page-num="pageNum"
:page-size="pageSize"
@page-change="onPageChange"
@size-change="onSizeChange"
/>
</el-card>
<div class="card-foot">
<AppPagination
:total="total"
:page-num="pageNum"
:page-size="pageSize"
@page-change="onPageChange"
@size-change="onSizeChange"
/>
</div>
</div>
<!-- 新增 / 编辑(基本信息;不含状态与子表) -->
<el-dialog
@@ -567,7 +582,12 @@ onMounted(() => {
</el-button>
</span>
</div>
<el-table :data="regionRows" stripe size="small">
<el-table
:data="regionRows"
stripe
size="small"
empty-text="还没有辐射区域 —— 点「添加区域」加一条"
>
<el-table-column label="区域名称" min-width="200">
<template #default="{ row }">
<el-input v-model="row.region_name" maxlength="128" placeholder="如 沈阳市铁西区" />
@@ -598,7 +618,12 @@ onMounted(() => {
</el-button>
</span>
</div>
<el-table :data="planRows" stripe size="small">
<el-table
:data="planRows"
stripe
size="small"
empty-text="还没有拓展计划 —— 点「添加计划」加一条"
>
<el-table-column label="期间" min-width="110">
<template #default="{ row }">
<el-input v-model="row.plan_period" maxlength="32" placeholder="如 2026Q1" />
@@ -629,23 +654,19 @@ onMounted(() => {
</template>
<style scoped>
.cell-muted {
color: var(--ys-ink-3);
font-size: 12px;
}
.cell-amount {
font-family: 'SFMono-Regular', Consolas, 'Courier New', monospace;
font-size: 12px;
color: var(--ys-ink-2);
}
/* .cell-muted / .cell-num 由全局 index.css 提供,本页不再重复定义 */
/*
* 试用期的计算口径必须写在开关旁:它不是状态而是计提规则,
* 单靠「试用」二字看不出「照算不照发、超 18000 年底结」。
*/
.switch-hint {
margin-left: 10px;
color: var(--ys-ink-3);
font-size: 12px;
}
/* 抽屉内的子表分组:区域与计划各自成块,块内标题与操作按钮同行 */
.sub-section {
margin-bottom: 20px;
}
+75 -57
View File
@@ -185,6 +185,14 @@ onMounted(() => {
<template>
<div>
<div class="page-head">
<h1 class="page-title">产品管理</h1>
<p class="page-desc">
产品主数据;类别是折算阈值的行级锚点,改类别会改变该产品成交回款的绩效口径
</p>
<span class="waterline"></span>
</div>
<el-alert
class="page-alert"
type="info"
@@ -193,48 +201,59 @@ onMounted(() => {
title="产品类别决定折算阈值:市政水利 30% / 轨道交通 20%;阈值在「系统设置-计提参数」维护。"
/>
<el-card shadow="never">
<el-form class="filter-form" :inline="true" @submit.prevent>
<el-form-item label="关键词">
<el-input
v-model="filters.keyword"
placeholder="名称或规格"
clearable
style="width: 180px"
@keyup.enter="search"
<!--
.filter-bar 直接挂在 el-form 上:el-form 不是 flex 透明容器,
外面再包一层 div 会让 el-form-item 丢掉 gap 排版,筛选条退化成竖排。
-->
<el-form class="filter-bar" @submit.prevent>
<el-form-item label="关键词">
<el-input
v-model="filters.keyword"
placeholder="名称或规格"
clearable
style="width: 180px"
@keyup.enter="search"
/>
</el-form-item>
<el-form-item label="类别">
<el-select v-model="filters.product_category" placeholder="全部" clearable style="width: 130px">
<el-option
v-for="item in productApi.PRODUCT_CATEGORY_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-form-item>
<el-form-item label="类别">
<el-select v-model="filters.product_category" placeholder="全部" clearable style="width: 130px">
<el-option
v-for="item in productApi.PRODUCT_CATEGORY_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="filters.status" placeholder="全部" clearable style="width: 110px">
<el-option
v-for="item in STATUS_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="search">查询</el-button>
<el-button @click="resetFilters">重置</el-button>
</el-form-item>
</el-form>
<div class="toolbar">
<el-button type="primary" @click="openCreate">新增产品</el-button>
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="filters.status" placeholder="全部" clearable style="width: 110px">
<el-option
v-for="item in STATUS_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<div class="filter-bar__actions">
<el-button type="primary" @click="search">查询</el-button>
<el-button @click="resetFilters">重置</el-button>
</div>
</el-form>
<el-table v-loading="loading" :data="records" stripe>
<div class="toolbar">
<el-button type="primary" @click="openCreate">新增产品</el-button>
<span class="toolbar-note">常规下架用「停用」,删除是物理删除、不可恢复</span>
</div>
<!-- ys-rise:整页只给列表卡一次入场上浮,筛选与页头立即呈现,避免整屏一起动 -->
<div class="card ys-rise">
<el-table
v-loading="loading"
:data="records"
stripe
empty-text="还没有产品 —— 点「新增产品」建第一个,或换个关键词再查"
>
<el-table-column prop="product_name" label="产品名称" min-width="150" />
<el-table-column prop="spec_model" label="规格型号" min-width="140">
<template #default="{ row }">{{ row.spec_model || '—' }}</template>
@@ -245,12 +264,12 @@ onMounted(() => {
<el-table-column label="数量" min-width="100" align="right">
<template #default="{ row }">
<!-- 字符串直出:金额红线,禁 toFixed / float 格式化 -->
<span class="cell-amount">{{ row.stock_quantity ?? '—' }}</span>
<span class="cell-num">{{ row.stock_quantity ?? '—' }}</span>
</template>
</el-table-column>
<el-table-column label="单价(元)" min-width="110" align="right">
<template #default="{ row }">
<span class="cell-amount">{{ row.sale_price ?? '—' }}</span>
<span class="cell-num">{{ row.sale_price ?? '—' }}</span>
</template>
</el-table-column>
<el-table-column label="类别" width="110" align="center">
@@ -264,7 +283,7 @@ onMounted(() => {
</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="90" align="center">
<el-table-column label="状态" width="96" align="center">
<template #default="{ row }">
<StatusTag :value="row.status" />
</template>
@@ -283,14 +302,16 @@ onMounted(() => {
</el-table-column>
</el-table>
<AppPagination
:total="total"
:page-num="pageNum"
:page-size="pageSize"
@page-change="onPageChange"
@size-change="onSizeChange"
/>
</el-card>
<div class="card-foot">
<AppPagination
:total="total"
:page-num="pageNum"
:page-size="pageSize"
@page-change="onPageChange"
@size-change="onSizeChange"
/>
</div>
</div>
<!-- 新增 / 编辑 -->
<el-dialog
@@ -342,13 +363,10 @@ onMounted(() => {
</template>
<style scoped>
.page-alert {
margin-bottom: 12px;
}
/* .cell-num 由全局 index.css 提供(等宽数字 + 右对齐),本页不再重复定义 */
.cell-amount {
font-family: 'SFMono-Regular', Consolas, 'Courier New', monospace;
font-size: 12px;
color: var(--ys-ink-2);
/* 阈值提示条:与卡片同宽,下距由这里控制,不占卡的内部留白 */
.page-alert {
margin-bottom: 14px;
}
</style>
+146 -113
View File
@@ -10,6 +10,7 @@ import * as personnelApi from '@/api/personnel'
import { productCategoryLabel } from '@/api/products'
import AppPagination from '@/components/table/AppPagination.vue'
import { useConfirmAction } from '@/composables/useConfirmAction'
import { useDropdownOptions } from '@/composables/useDropdownOptions'
import { usePagedList } from '@/composables/usePagedList'
import { formatDateTime } from '@/utils/format'
import { AMOUNT_PATTERN, PRICE_PATTERN, ZERO_PATTERN } from '@/utils/validate'
@@ -69,37 +70,51 @@ interface PersonnelOption {
status: number
}
const customerOptions = ref<CustomerOption[]>([])
const personnelOptions = ref<PersonnelOption[]>([])
async function loadCustomerOptions(): Promise<void> {
try {
const { options: customerOptions, load: loadCustomerOptions } = useDropdownOptions<CustomerOption>(
async () => {
// 客户下拉的近似全量数据源:内部系统规模有限,200 条足够;超出由后端校验兜底
const data = await customerApi.listCustomers({ pageNum: 1, pageSize: 200 })
customerOptions.value = data.list.map((c) => ({ id: c.id, name: c.company_name }))
} catch (error) {
handledError(error) // 下拉为空不阻断列表使用
}
}
return data.list.map((c) => ({ id: c.id, name: c.company_name }))
},
)
async function loadPersonnelOptions(): Promise<void> {
try {
const { options: personnelOptions, load: loadPersonnelOptions } = useDropdownOptions<PersonnelOption>(
async () => {
const data = await personnelApi.listPersonnel({ pageNum: 1, pageSize: 200 })
personnelOptions.value = data.list.map((p) => ({
return data.list.map((p) => ({
id: p.id,
name: p.personnel_name,
status: p.status,
}))
} catch (error) {
handledError(error)
}
}
},
)
/** 下拉项展示名:离职人员加后缀标注(可否被选由后端归属校验兜底) */
function personnelLabel(option: PersonnelOption): string {
return option.status === 1 ? option.name : `${option.name}(离职)`
}
// ==================== 状态呈现(只做样式映射,不改判定) ====================
/**
* Element Plus 语义类型 → 设计稿四档 pill。
*
* 状态判定沿用 businessApi.businessStatusTagType(三色语义已在接口层定死),
* 这里只把语义翻成 pill 的浅底 + 圆点形态:列表的状态列统一成 pill 之后,
* el-tag 的实心块状形态会与相邻的编号/金额列打架。
*/
const STATUS_PILL_TONE: Record<string, string> = {
success: 'ys-pill--ok',
danger: 'ys-pill--reject',
primary: 'ys-pill--lock',
info: '',
}
function statusPillClass(status: number): string {
const tone = STATUS_PILL_TONE[businessApi.businessStatusTagType(status)] ?? ''
return tone ? `ys-pill ${tone}` : 'ys-pill'
}
// ==================== 新增 / 编辑(含明细行编辑器) ====================
const dialogVisible = ref(false)
@@ -156,7 +171,7 @@ const allocationHint = computed(() => {
const total = Number(allocationTotal.value.toFixed(2))
if (total === 100) return `二次分配合计 ${total.toFixed(2)}%`
const rest = Number((100 - total).toFixed(2))
return `二次分配合计 ${total.toFixed(2)}%,余额 ${rest.toFixed(2)}%(归属待业务确认)`
return `二次分配合计 ${total.toFixed(2)}%,余额 ${rest.toFixed(2)}%(归负责人)`
})
function addAllocationRow(): void {
@@ -602,65 +617,87 @@ onMounted(() => {
<template>
<div>
<el-card shadow="never">
<el-form class="filter-form" :inline="true" @submit.prevent>
<el-form-item label="关键词">
<el-input
v-model="filters.keyword"
placeholder="业务编号或公司名称"
clearable
style="width: 190px"
@keyup.enter="search"
/>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="filters.status" placeholder="全部" clearable style="width: 110px">
<el-option
v-for="item in businessApi.BUSINESS_STATUS_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="业务日期">
<el-date-picker
v-model="dateRange"
type="daterange"
value-format="YYYY-MM-DD"
range-separator="至"
start-placeholder="起"
end-placeholder="止"
style="width: 240px"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="search">查询</el-button>
<el-button @click="resetFilters">重置</el-button>
</el-form-item>
</el-form>
<!-- 页面头:标题 + 一句口径说明 + 签名水线(每页只出现一次) -->
<div class="page-head">
<h1 class="page-title">业务管理</h1>
<p class="page-desc">
业务台账是发货、回款与绩效的源头:客户、归属人员与产品类别在这里定下来,后面的核算才有依据
</p>
<span class="waterline"></span>
</div>
<div class="toolbar">
<el-button type="primary" @click="openCreate">新增业务</el-button>
<!--
.filter-bar 直接挂在 el-form 上(去掉 inline):el-form 不是 flex 透明容器,
外面再包一层 div 会让 el-form-item 丢掉 gap 排版,筛选条退化成竖排;
查询/重置收进 .filter-bar__actions,由 margin-left:auto 推到行尾。
-->
<el-form class="filter-bar" @submit.prevent>
<el-form-item label="关键词">
<el-input
v-model="filters.keyword"
placeholder="业务编号或公司名称"
clearable
style="width: 190px"
@keyup.enter="search"
/>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="filters.status" placeholder="全部" clearable style="width: 110px">
<el-option
v-for="item in businessApi.BUSINESS_STATUS_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="业务日期">
<el-date-picker
v-model="dateRange"
type="daterange"
value-format="YYYY-MM-DD"
range-separator="至"
start-placeholder="起"
end-placeholder="止"
style="width: 240px"
/>
</el-form-item>
<div class="filter-bar__actions">
<el-button type="primary" @click="search">查询</el-button>
<el-button @click="resetFilters">重置</el-button>
</div>
</el-form>
<el-table v-loading="loading" :data="records" stripe>
<el-table-column prop="business_no" label="业务编号" min-width="150" />
<div class="toolbar">
<el-button type="primary" @click="openCreate">新增业务</el-button>
<span class="toolbar-note">状态流转与归属调整走行内操作,每次变更都写留痕</span>
</div>
<!-- 表格与分页共用一张卡:分页落进 card-foot,表体与表尾同一材质 -->
<div class="card">
<el-table
v-loading="loading"
:data="records"
stripe
empty-text="还没有业务记录 —— 点「新增业务」建第一笔,或换个关键词再查"
>
<el-table-column label="业务编号" min-width="150">
<template #default="{ row }">
<span class="cell-code">{{ row.business_no }}</span>
</template>
</el-table-column>
<el-table-column prop="customer_name" label="客户名称" min-width="160">
<template #default="{ row }">{{ row.customer_name ?? '—' }}</template>
</el-table-column>
<el-table-column prop="business_date" label="业务日期" min-width="110">
<template #default="{ row }">{{ row.business_date ?? '—' }}</template>
</el-table-column>
<el-table-column label="状态" width="90" align="center">
<el-table-column label="状态" width="108" align="center">
<template #default="{ row }">
<el-tag
:type="businessApi.businessStatusTagType(row.status)"
size="small"
disable-transitions
>
<!-- 状态列统一用 pill:颜色之外必有文字,色觉差异不影响判读 -->
<span :class="statusPillClass(row.status)">
{{ businessApi.businessStatusLabel(row.status) }}
</el-tag>
</span>
</template>
</el-table-column>
<el-table-column prop="owner_personnel_name" label="负责人员" min-width="100">
@@ -672,22 +709,22 @@ onMounted(() => {
<el-table-column label="销售额(元)" min-width="120" align="right">
<template #default="{ row }">
<!-- 字符串直出:金额红线,禁 toFixed / float 格式化 -->
<span class="cell-amount">{{ row.total_sale_amount ?? '—' }}</span>
<span class="cell-num">{{ row.total_sale_amount ?? '—' }}</span>
</template>
</el-table-column>
<el-table-column label="成本(元)" min-width="120" align="right">
<template #default="{ row }">
<span class="cell-amount">{{ row.total_cost_amount ?? '—' }}</span>
<span class="cell-num">{{ row.total_cost_amount ?? '—' }}</span>
</template>
</el-table-column>
<el-table-column label="发货额(元)" min-width="120" align="right">
<template #default="{ row }">
<span class="cell-amount">{{ row.total_ship_amount ?? '—' }}</span>
<span class="cell-num">{{ row.total_ship_amount ?? '—' }}</span>
</template>
</el-table-column>
<el-table-column label="回款额(元)" min-width="120" align="right">
<template #default="{ row }">
<span class="cell-amount">{{ row.total_received_amount ?? '—' }}</span>
<span class="cell-num">{{ row.total_received_amount ?? '—' }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="300" fixed="right" align="center">
@@ -734,14 +771,17 @@ onMounted(() => {
</el-table-column>
</el-table>
<AppPagination
:total="total"
:page-num="pageNum"
:page-size="pageSize"
@page-change="onPageChange"
@size-change="onSizeChange"
/>
</el-card>
<!-- 分页落进 card-foot:与表体同一张卡,卡底微渐变收边 -->
<div class="card-foot">
<AppPagination
:total="total"
:page-num="pageNum"
:page-size="pageSize"
@page-change="onPageChange"
@size-change="onSizeChange"
/>
</div>
</div>
<!-- 新增 / 编辑(含明细行编辑器;编辑不含归属,归属走独立按钮保证留痕) -->
<el-dialog
@@ -840,7 +880,7 @@ onMounted(() => {
<div class="items-head">
<span class="items-title">
二次分配方案
<span class="alloc-note">(可选;比例不要求合计 100%,分摊计算待业务确认后另批)</span>
<span class="alloc-note">(可选;各参与人比例不要求合计 100%,余额归负责人)</span>
</span>
<el-button size="small" @click="addAllocationRow">添加参与人</el-button>
</div>
@@ -1077,19 +1117,19 @@ onMounted(() => {
<span v-else class="cell-muted">否</span>
</el-descriptions-item>
<el-descriptions-item label="销售额(元)">
<span class="cell-amount">{{ detail.total_sale_amount ?? '—' }}</span>
<span class="cell-num">{{ detail.total_sale_amount ?? '—' }}</span>
</el-descriptions-item>
<el-descriptions-item label="成本(元)">
<span class="cell-amount">{{ detail.total_cost_amount ?? '—' }}</span>
<span class="cell-num">{{ detail.total_cost_amount ?? '—' }}</span>
</el-descriptions-item>
<el-descriptions-item label="发货额(元)">
<span class="cell-amount">{{ detail.total_ship_amount ?? '—' }}</span>
<span class="cell-num">{{ detail.total_ship_amount ?? '—' }}</span>
</el-descriptions-item>
<el-descriptions-item label="回款额(元)">
<span class="cell-amount">{{ detail.total_received_amount ?? '—' }}</span>
<span class="cell-num">{{ detail.total_received_amount ?? '—' }}</span>
</el-descriptions-item>
<el-descriptions-item label="运费额(元)">
<span class="cell-amount">{{ detail.total_freight_amount ?? '—' }}</span>
<span class="cell-num">{{ detail.total_freight_amount ?? '—' }}</span>
</el-descriptions-item>
<el-descriptions-item label="备注">
{{ detail.remark ?? '—' }}
@@ -1115,27 +1155,27 @@ onMounted(() => {
</el-table-column>
<el-table-column label="数量" min-width="80" align="right">
<template #default="{ row }">
<span class="cell-amount">{{ row.quantity }}</span>
<span class="cell-num">{{ row.quantity }}</span>
</template>
</el-table-column>
<el-table-column label="销售单价" min-width="100" align="right">
<template #default="{ row }">
<span class="cell-amount">{{ row.sale_price ?? '—' }}</span>
<span class="cell-num">{{ row.sale_price ?? '—' }}</span>
</template>
</el-table-column>
<el-table-column label="行销售额" min-width="110" align="right">
<template #default="{ row }">
<span class="cell-amount">{{ row.sale_amount }}</span>
<span class="cell-num">{{ row.sale_amount }}</span>
</template>
</el-table-column>
<el-table-column label="成本额" min-width="100" align="right">
<template #default="{ row }">
<span class="cell-amount">{{ row.cost_amount ?? '—' }}</span>
<span class="cell-num">{{ row.cost_amount ?? '—' }}</span>
</template>
</el-table-column>
<el-table-column label="指导价" min-width="100" align="right">
<template #default="{ row }">
<span class="cell-amount">{{ row.guided_price ?? '—' }}</span>
<span class="cell-num">{{ row.guided_price ?? '—' }}</span>
</template>
</el-table-column>
</el-table>
@@ -1155,7 +1195,7 @@ onMounted(() => {
<el-table-column prop="personnel_name" label="参与业务员" min-width="140" />
<el-table-column label="分配比例" width="110" align="right">
<template #default="{ row }">
<span class="cell-amount">{{ row.ratio }}%</span>
<span class="cell-num">{{ row.ratio }}%</span>
</template>
</el-table-column>
<el-table-column label="状态" width="90">
@@ -1259,18 +1299,18 @@ onMounted(() => {
</div>
<div class="gp-grid">
<span class="gp-label">销售额</span>
<span class="gp-value cell-amount">{{ cat.sale_amount }}</span>
<span class="gp-value cell-num">{{ cat.sale_amount }}</span>
<span class="gp-label">成本额</span>
<span class="gp-value cell-amount">{{ cat.cost_amount }}</span>
<span class="gp-value cell-num">{{ cat.cost_amount }}</span>
<span class="gp-label">毛利润额</span>
<span class="gp-value cell-amount">{{ cat.profit_amount }}</span>
<span class="gp-value cell-num">{{ cat.profit_amount }}</span>
<span class="gp-label">毛利润率</span>
<!-- 分数字符串原样展示(如 997/1000),禁止数值化 -->
<span class="gp-value cell-amount">{{ cat.profit_rate }}</span>
<span class="gp-value cell-num">{{ cat.profit_rate }}</span>
<span class="gp-label">折算阈值</span>
<span class="gp-value cell-amount">{{ cat.threshold }}</span>
<span class="gp-value cell-num">{{ cat.threshold }}</span>
<span class="gp-label">折算毛利润额</span>
<span class="gp-value cell-amount">{{ cat.converted_profit }}</span>
<span class="gp-value cell-num">{{ cat.converted_profit }}</span>
</div>
</div>
</el-col>
@@ -1287,38 +1327,38 @@ onMounted(() => {
</el-table-column>
<el-table-column label="数量" min-width="80" align="right">
<template #default="{ row }">
<span class="cell-amount">{{ row.quantity }}</span>
<span class="cell-num">{{ row.quantity }}</span>
</template>
</el-table-column>
<el-table-column label="销售额" min-width="100" align="right">
<template #default="{ row }">
<span class="cell-amount">{{ row.sale_amount }}</span>
<span class="cell-num">{{ row.sale_amount }}</span>
</template>
</el-table-column>
<el-table-column label="成本额" min-width="120" align="right">
<template #default="{ row }">
<span v-if="row.cost_amount" class="cell-amount">{{ row.cost_amount }}</span>
<span v-if="row.cost_amount" class="cell-num">{{ row.cost_amount }}</span>
<span v-else class="cell-missing">{{ row.missing_reason ?? '成本缺失' }}</span>
</template>
</el-table-column>
<el-table-column label="毛利润额" min-width="100" align="right">
<template #default="{ row }">
<span class="cell-amount">{{ row.profit_amount ?? '—' }}</span>
<span class="cell-num">{{ row.profit_amount ?? '—' }}</span>
</template>
</el-table-column>
<el-table-column label="毛利润率" min-width="100" align="right">
<template #default="{ row }">
<span class="cell-amount">{{ row.profit_rate ?? '—' }}</span>
<span class="cell-num">{{ row.profit_rate ?? '—' }}</span>
</template>
</el-table-column>
<el-table-column label="折算阈值" min-width="90" align="right">
<template #default="{ row }">
<span class="cell-amount">{{ row.threshold }}</span>
<span class="cell-num">{{ row.threshold }}</span>
</template>
</el-table-column>
<el-table-column label="折算毛利润额" min-width="110" align="right">
<template #default="{ row }">
<span class="cell-amount">{{ row.converted_profit ?? '—' }}</span>
<span class="cell-num">{{ row.converted_profit ?? '—' }}</span>
</template>
</el-table-column>
</el-table>
@@ -1333,16 +1373,8 @@ onMounted(() => {
</template>
<style scoped>
.cell-muted {
color: var(--ys-ink-3);
font-size: 12px;
}
.cell-amount {
font-family: 'SFMono-Regular', Consolas, 'Courier New', monospace;
font-size: 12px;
color: var(--ys-ink-2);
}
/* 单元格口径(.cell-code / .cell-num / .cell-muted)与表头、按钮、输入框皮肤
已由全局 index.css 提供,此处不再重复定义,避免两处口径打架 */
.cell-missing {
font-size: 12px;
@@ -1512,4 +1544,5 @@ onMounted(() => {
color: var(--ys-ink-3);
font-size: 12px;
}
</style>
@@ -1,485 +0,0 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import {
createRatingGrade,
deleteRatingGrade,
listRatingGrades,
updateRatingGrade,
type RatingGradeItem,
type RatingGradeParams,
} from '@/api/ratings'
import { useConfirmAction } from '@/composables/useConfirmAction'
import { handledError } from '@/utils/error'
/**
* 客户评级计算器(需求 2.2.5 / 对照表 #25)。
*
* 评级计算器仅是**打分工具**:五项打分求总分、按等级映射实时判定等级,
* 评分记录不落库、不参与绩效金额计算,因此页面没有保存动作(仅重置)。
* 等级映射的增删改走 /v1/business/rating-grades 维护接口,配置变更后
* 重载判定用列表——判定与抽屉表格共用同一份数据。
*/
// ==================== 等级判定(纯函数) ====================
/**
* 等级判定:按 sort_order 升序取首个 min_score ≤ 总分的等级。
*
* 后端 min_score 是字符串直出(分值红线),比较前必须 parseFloat 转数值,
* 禁止字符串直接比大小("90.0" 这类字典序比较会得出错误结果)。
* 无命中(总分低于全部下限)返回 null,由调用方展示未命中提示。
*/
function resolveGrade(grades: RatingGradeItem[], totalScore: number): RatingGradeItem | null {
const ordered = [...grades].sort((a, b) => a.sort_order - b.sort_order)
for (const grade of ordered) {
if (totalScore >= parseFloat(grade.min_score)) {
return grade
}
}
return null
}
// ==================== 评分计算(纯前端实时计算) ====================
/** 五项评分项:各 0-20 分、步进 1,总分 = 五项之和(0-100) */
const SCORE_ITEMS = [
{ key: 'ship_amount', label: '发货额' },
{ key: 'receipt_rate', label: '回款率' },
{ key: 'profit_rate', label: '毛利率' },
{ key: 'contract_cooperation', label: '合同履约配合能力' },
{ key: 'site_construction', label: '现场规范施工能力' },
] as const
type ScoreKey = (typeof SCORE_ITEMS)[number]['key']
/** el-input-number 清空后值为 undefined,字段用宽松类型,求和处 ?? 0 兜底 */
const scores = reactive<Record<ScoreKey, number | undefined>>({
ship_amount: 0,
receipt_rate: 0,
profit_rate: 0,
contract_cooperation: 0,
site_construction: 0,
})
const totalScore = computed(() =>
SCORE_ITEMS.reduce((sum, item) => sum + (scores[item.key] ?? 0), 0),
)
/** 无保存动作(不落库),重置即把五项清零 */
function resetScores(): void {
for (const item of SCORE_ITEMS) {
scores[item.key] = 0
}
}
// ==================== 等级映射(判定用列表) ====================
const { confirmAndRun } = useConfirmAction()
const grades = ref<RatingGradeItem[]>([])
const gradesLoading = ref(false)
/** 判定与抽屉表格共用同一份全量列表,配置变更后重载一次即同时生效 */
async function loadGrades(): Promise<void> {
gradesLoading.value = true
try {
grades.value = await listRatingGrades()
} catch (error) {
handledError(error)
} finally {
gradesLoading.value = false
}
}
const matchedGrade = computed(() => resolveGrade(grades.value, totalScore.value))
// ==================== 等级映射设置抽屉 ====================
const drawerVisible = ref(false)
/** 打开时重新拉取,避免展示陈旧数据 */
function openDrawer(): void {
drawerVisible.value = true
void loadGrades()
}
// ==================== 新增 / 编辑等级弹窗 ====================
const editVisible = ref(false)
/** null = 新增,否则为编辑目标 id */
const editingId = ref<number | null>(null)
const editSaving = ref(false)
const isCreate = computed(() => editingId.value === null)
const editForm = reactive({
grade_name: '',
min_score: undefined as number | undefined,
max_score: undefined as number | undefined,
sort_order: 0,
})
function openCreate(): void {
editingId.value = null
editForm.grade_name = ''
editForm.min_score = undefined
editForm.max_score = undefined
editForm.sort_order = 0
editVisible.value = true
}
function openEdit(row: RatingGradeItem): void {
editingId.value = row.id
editForm.grade_name = row.grade_name
// 后端字符串直出,回填 parseFloat 转数值,保存时再 String() 转回字符串
editForm.min_score = parseFloat(row.min_score)
editForm.max_score = parseFloat(row.max_score)
editForm.sort_order = row.sort_order
editVisible.value = true
}
/** 前端值域校验(0≤min≤max≤100);后端在审计上下文内兜底同规则与区间重叠 */
function editFormError(minScore: number | undefined, maxScore: number | undefined): string | null {
if (!editForm.grade_name.trim()) return '请输入等级名称'
if (minScore === undefined || maxScore === undefined) return '请输入分值区间'
if (minScore < 0 || maxScore > 100 || minScore > maxScore) {
return '分值区间须满足 0 ≤ 下限 ≤ 上限 ≤ 100'
}
return null
}
async function saveGrade(): Promise<void> {
// 局部变量收窄类型:reactive 属性在异步流程中不保证保持窄化
const minScore = editForm.min_score
const maxScore = editForm.max_score
const error = editFormError(minScore, maxScore)
if (error) {
ElMessage.warning(error)
return
}
const payload: RatingGradeParams = {
grade_name: editForm.grade_name.trim(),
min_score: String(minScore),
max_score: String(maxScore),
sort_order: editForm.sort_order ?? 0,
}
// 后端为全量替换语义;remark 本页不维护,编辑时原样带回避免误清
if (editingId.value !== null) {
payload.remark = grades.value.find((g) => g.id === editingId.value)?.remark ?? undefined
}
editSaving.value = true
try {
if (editingId.value === null) {
await createRatingGrade(payload)
} else {
await updateRatingGrade(editingId.value, payload)
}
editVisible.value = false
ElMessage.success(isCreate.value ? '评级等级已创建' : '已保存')
await loadGrades()
} catch (requestError) {
handledError(requestError)
} finally {
editSaving.value = false
}
}
/** 删除走确认动作流;文案明确后果(该区间不再能映射等级) */
function removeGrade(row: RatingGradeItem): void {
void confirmAndRun({
confirmText: `确定删除等级「${row.grade_name}」(${row.min_score} ~ ${row.max_score})?删除后该分数区间将无法映射等级。`,
title: '删除确认',
confirmButtonText: '删除',
successText: '评级等级已删除',
run: () => deleteRatingGrade(row.id),
after: () => loadGrades(),
})
}
onMounted(() => {
void loadGrades()
})
</script>
<template>
<div class="rating-page">
<!-- 对照表 #25:常驻口径提示,防止误认为评分会影响绩效金额 -->
<el-alert
class="page-alert"
title="本页为评分计算工具,评分结果不参与绩效金额计算"
type="warning"
:closable="false"
show-icon
/>
<!-- 评分计算区:无保存按钮,纯前端实时计算 -->
<el-card shadow="never">
<div class="page-toolbar">
<span class="page-desc">五项各 0-20 分,总分 0-100;评级等级按配置的下限实时判定。</span>
<span>
<el-button @click="resetScores">重置</el-button>
<el-button type="primary" @click="openDrawer">等级映射设置</el-button>
</span>
</div>
<div class="calc-grid">
<div class="calc-form">
<div v-for="item in SCORE_ITEMS" :key="item.key" class="score-row">
<span class="score-label">{{ item.label }}</span>
<el-input-number
v-model="scores[item.key]"
:min="0"
:max="20"
:step="1"
controls-position="right"
/>
<span class="score-unit">分</span>
</div>
</div>
<div class="calc-result">
<div>
<span class="result-label">总分</span>
<b class="total-num">{{ totalScore }}</b>
<span class="total-max">/ 100</span>
</div>
<div>
<span class="result-label">评级等级</span>
<template v-if="grades.length === 0">
<div class="grade-name">—</div>
<div class="grade-hint">未配置等级映射</div>
</template>
<template v-else-if="matchedGrade">
<div class="grade-name">{{ matchedGrade.grade_name }}</div>
<div class="grade-hint">
分值区间 {{ matchedGrade.min_score }} ~ {{ matchedGrade.max_score }}
</div>
</template>
<template v-else>
<div class="grade-name">—</div>
<div class="grade-hint">总分低于全部等级下限,未命中等级</div>
</template>
</div>
</div>
</div>
</el-card>
<!-- 等级映射设置抽屉 -->
<el-drawer v-model="drawerVisible" title="等级映射设置" size="640px">
<div class="drawer-hint">
判定规则:按排序升序取首个「下限 ≤ 总分」的等级,上限仅作展示;修改后评级区立即按新配置判定。
</div>
<div class="drawer-toolbar">
<el-button type="primary" size="small" @click="openCreate">新增等级</el-button>
</div>
<el-table v-loading="gradesLoading" :data="grades" size="small">
<el-table-column prop="grade_name" label="等级名称" min-width="110" />
<el-table-column prop="min_score" label="下限(含)" width="100" align="center" />
<el-table-column prop="max_score" label="上限" width="90" align="center" />
<el-table-column prop="sort_order" label="排序" width="70" align="center" />
<el-table-column label="操作" width="110" align="center">
<template #default="{ row }">
<div class="table-actions">
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
<span class="table-actions__divider" aria-hidden="true"></span>
<el-button link type="danger" size="small" @click="removeGrade(row)">删除</el-button>
</div>
</template>
</el-table-column>
</el-table>
</el-drawer>
<!-- 新增 / 编辑等级 -->
<el-dialog
v-model="editVisible"
:title="isCreate ? '新增评级等级' : '编辑评级等级'"
width="440px"
destroy-on-close
@closed="editingId = null"
>
<el-form label-width="90px" @submit.prevent>
<el-form-item label="等级名称" required>
<el-input v-model="editForm.grade_name" maxlength="32" placeholder="如:A / 优秀" />
</el-form-item>
<el-form-item label="下限(含)" required>
<el-input-number
v-model="editForm.min_score"
:min="0"
:max="100"
:step="5"
controls-position="right"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="上限" required>
<el-input-number
v-model="editForm.max_score"
:min="0"
:max="100"
:step="5"
controls-position="right"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="排序">
<el-input-number
v-model="editForm.sort_order"
:min="0"
:step="10"
controls-position="right"
/>
<span class="form-tip">升序判定,越小越先判定</span>
</el-form-item>
</el-form>
<div class="form-tip-block">
分值区间须满足 0 ≤ 下限 ≤ 上限 ≤ 100;区间重叠、名称重复由后端校验兜底。
</div>
<template #footer>
<el-button @click="editVisible = false">取消</el-button>
<el-button type="primary" :loading="editSaving" @click="saveGrade">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.page-alert {
margin-bottom: 14px;
}
.page-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
gap: 12px;
flex-wrap: wrap;
}
.page-desc {
font-size: 13px;
color: var(--ys-ink-2);
}
.calc-grid {
display: flex;
gap: 28px;
align-items: stretch;
flex-wrap: wrap;
}
.calc-form {
flex: 1 1 380px;
min-width: 280px;
}
.score-row {
display: flex;
align-items: center;
gap: 12px;
line-height: 2.7;
}
.score-label {
width: 140px;
font-size: 13px;
color: var(--ys-ink);
text-align: right;
}
.score-unit {
font-size: 12px;
color: var(--ys-ink-3);
}
.calc-result {
flex: 0 0 250px;
min-width: 220px;
border: 1px solid var(--ys-line);
border-radius: 10px;
padding: 18px 20px;
background: linear-gradient(180deg, #ffffff, #fcfcfe);
display: flex;
flex-direction: column;
justify-content: center;
gap: 18px;
}
.result-label {
display: block;
font-size: 12px;
letter-spacing: 0.04em;
color: var(--ys-ink-3);
}
.total-num {
font-size: 40px;
color: var(--ys-indigo);
font-variant-numeric: tabular-nums;
margin-right: 4px;
}
.total-max {
font-size: 13px;
color: var(--ys-ink-3);
}
.grade-name {
font-family: var(--ys-font-brand);
font-size: 28px;
font-weight: 600;
color: var(--ys-ink);
line-height: 1.3;
margin-top: 2px;
}
.grade-hint {
font-size: 12px;
color: var(--ys-ink-3);
margin-top: 4px;
}
.drawer-hint {
font-size: 12px;
color: var(--ys-ink-3);
background: var(--ys-mist);
border-radius: 6px;
padding: 8px 12px;
margin-bottom: 12px;
}
.drawer-toolbar {
display: flex;
justify-content: flex-end;
margin-bottom: 10px;
}
.form-tip {
font-size: 12px;
color: var(--ys-ink-3);
margin-left: 8px;
}
.form-tip-block {
font-size: 12px;
color: var(--ys-ink-3);
background: var(--ys-mist);
border-radius: 6px;
padding: 8px 12px;
}
/* 移动端:标签左对齐、结果块自然换行到表单下方 */
@media (max-width: 768px) {
.score-label {
width: auto;
text-align: left;
}
.calc-result {
flex: 1 1 100%;
}
}
</style>
+118 -98
View File
@@ -242,108 +242,134 @@ onMounted(() => {
<template>
<div>
<el-card shadow="never">
<el-form class="filter-form" :inline="true" @submit.prevent>
<el-form-item label="业务">
<el-select
v-model="filters.business_id"
placeholder="全部"
clearable
filterable
style="width: 230px"
>
<el-option
v-for="item in businessOptions"
:key="item.id"
:label="businessLabel(item)"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="回款日期">
<el-date-picker
v-model="dateRange"
type="daterange"
value-format="YYYY-MM-DD"
range-separator="至"
start-placeholder="起"
end-placeholder="止"
style="width: 240px"
<!-- 页面头:标题 + 一句口径说明(回款是绩效源头)+ 签名水线 -->
<div class="page-head">
<h1 class="page-title">回款管理</h1>
<p class="page-desc">回款是绩效的源头:批次、到账日与产品类别决定计提口径</p>
<span class="waterline"></span>
</div>
<!--
.filter-bar 直接挂在 el-form 上(去掉 inline):el-form 不是 flex 透明容器,
外面再包一层 div 会让 el-form-item 丢掉 gap 排版,筛选条退化成竖排。
-->
<el-form class="filter-bar" @submit.prevent>
<el-form-item label="业务">
<el-select
v-model="filters.business_id"
placeholder="全部"
clearable
filterable
style="width: 230px"
>
<el-option
v-for="item in businessOptions"
:key="item.id"
:label="businessLabel(item)"
:value="item.id"
/>
</el-form-item>
<el-form-item label="以物抵顶">
<el-select v-model="filters.is_barter" placeholder="全部" clearable style="width: 120px">
<el-option
v-for="item in receiptApi.BARTER_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="核算状态">
<el-select
v-model="filters.settlement_status"
placeholder="全部"
clearable
style="width: 110px"
>
<el-option
v-for="item in receiptApi.SETTLEMENT_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="search">查询</el-button>
<el-button @click="resetFilters">重置</el-button>
</el-form-item>
</el-form>
<div class="toolbar">
<el-button type="primary" @click="openCreate">新增回款</el-button>
</el-select>
</el-form-item>
<el-form-item label="回款日期">
<el-date-picker
v-model="dateRange"
type="daterange"
value-format="YYYY-MM-DD"
range-separator="至"
start-placeholder="起"
end-placeholder="止"
style="width: 240px"
/>
</el-form-item>
<el-form-item label="以物抵顶">
<el-select v-model="filters.is_barter" placeholder="全部" clearable style="width: 120px">
<el-option
v-for="item in receiptApi.BARTER_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="核算状态">
<el-select
v-model="filters.settlement_status"
placeholder="全部"
clearable
style="width: 110px"
>
<el-option
v-for="item in receiptApi.SETTLEMENT_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<div class="filter-bar__actions">
<el-button type="primary" @click="search">查询</el-button>
<el-button @click="resetFilters">重置</el-button>
</div>
</el-form>
<el-table v-loading="loading" :data="records" stripe>
<el-table-column prop="receipt_batch_no" label="批次号" min-width="150" />
<el-table-column prop="business_no" label="业务编号" min-width="140">
<template #default="{ row }">{{ row.business_no }}</template>
<div class="toolbar">
<el-button type="primary" @click="openCreate">新增回款</el-button>
<span class="toolbar-note">已核算的回款不可变更;批次一旦指定,回款天数即按发货日派生</span>
</div>
<div class="card">
<el-table
v-loading="loading"
:data="records"
stripe
empty-text="还没有回款记录 —— 点「新增回款」登记第一笔,或换个业务再查"
>
<el-table-column label="批次号" min-width="150">
<template #default="{ row }">
<span class="cell-code">{{ row.receipt_batch_no }}</span>
</template>
</el-table-column>
<el-table-column label="业务编号" min-width="140">
<template #default="{ row }">
<span v-if="row.business_no" class="cell-code">{{ row.business_no }}</span>
<span v-else class="cell-muted">—</span>
</template>
</el-table-column>
<el-table-column label="发货单号" min-width="140">
<template #default="{ row }">{{ row.shipment_no ?? '—' }}</template>
<template #default="{ row }">
<span v-if="row.shipment_no" class="cell-code">{{ row.shipment_no }}</span>
<span v-else class="cell-muted">—</span>
</template>
</el-table-column>
<el-table-column prop="receipt_date" label="回款日期" min-width="110" />
<el-table-column label="金额(元)" min-width="120" align="right">
<template #default="{ row }">
<!-- 字符串直出:金额红线,禁 toFixed / float 格式化 -->
<span class="cell-amount">{{ row.amount }}</span>
<span class="cell-num">{{ row.amount }}</span>
</template>
</el-table-column>
<el-table-column label="以物抵顶" width="100" align="center">
<el-table-column label="以物抵顶" width="120" align="center">
<template #default="{ row }">
<el-tag v-if="row.is_barter === 1" type="warning" size="small" disable-transitions>
以物抵顶
</el-tag>
<!-- 以物抵顶影响折算比例,属需要一眼看见的分支,用 pill 而非普通文字 -->
<span v-if="row.is_barter === 1" class="ys-pill ys-pill--wait">以物抵顶</span>
<span v-else class="cell-muted">否</span>
</template>
</el-table-column>
<el-table-column label="回款天数" width="120" align="center">
<el-table-column label="回款天数" width="120" align="right">
<template #default="{ row }">
<span v-if="row.payment_days !== null" class="cell-amount">{{ row.payment_days }} 天</span>
<span v-if="row.payment_days !== null" class="cell-num">{{ row.payment_days }} 天</span>
<span v-else class="cell-muted">未指定批次</span>
</template>
</el-table-column>
<el-table-column label="核算状态" width="90" align="center">
<el-table-column label="核算状态" width="108" align="center">
<template #default="{ row }">
<el-tag
:type="row.settlement_status === 1 ? 'success' : 'info'"
size="small"
disable-transitions
<!-- 已核算=已确认(靛绿)、未核算=待处理(琥珀);已核算行禁改禁删 -->
<span
class="ys-pill"
:class="row.settlement_status === 1 ? 'ys-pill--ok' : 'ys-pill--wait'"
>
{{ row.settlement_status === 1 ? '已核算' : '未核算' }}
</el-tag>
</span>
</template>
</el-table-column>
<el-table-column label="操作" width="130" fixed="right" align="center">
@@ -389,14 +415,17 @@ onMounted(() => {
</el-table-column>
</el-table>
<AppPagination
:total="total"
:page-num="pageNum"
:page-size="pageSize"
@page-change="onPageChange"
@size-change="onSizeChange"
/>
</el-card>
<!-- 分页落进 card-foot:与表体同一张卡,卡底微渐变收边 -->
<div class="card-foot">
<AppPagination
:total="total"
:page-num="pageNum"
:page-size="pageSize"
@page-change="onPageChange"
@size-change="onSizeChange"
/>
</div>
</div>
<!-- 新增 / 编辑(业务必选 + 发货批次级联指定,可清空=暂不指定) -->
<el-dialog
@@ -450,7 +479,7 @@ onMounted(() => {
v-model="form.receipt_date"
type="date"
value-format="YYYY-MM-DD"
placeholder="M3 核算按此日匹配参数版本"
placeholder="选到账当天;计提比例按这一天的参数计算"
style="width: 100%"
/>
</el-form-item>
@@ -474,16 +503,7 @@ onMounted(() => {
</template>
<style scoped>
.cell-muted {
color: var(--ys-ink-3);
font-size: 12px;
}
.cell-amount {
font-family: 'SFMono-Regular', Consolas, 'Courier New', monospace;
font-size: 12px;
color: var(--ys-ink-2);
}
/* 单元格口径(.cell-code / .cell-num / .cell-muted)由全局 index.css 提供,此处不再重复定义 */
.op-wrap {
display: inline-block;
+81 -60
View File
@@ -273,49 +273,75 @@ onMounted(() => {
<template>
<div>
<el-card shadow="never">
<el-form class="filter-form" :inline="true" @submit.prevent>
<el-form-item label="业务">
<el-select
v-model="filters.business_id"
placeholder="全部"
clearable
filterable
style="width: 240px"
>
<el-option
v-for="item in businessOptions"
:key="item.id"
:label="businessLabel(item)"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="发货日期">
<el-date-picker
v-model="dateRange"
type="daterange"
value-format="YYYY-MM-DD"
range-separator="至"
start-placeholder="起"
end-placeholder="止"
style="width: 240px"
<!-- 页面头:标题 + 一句口径说明(发货日是回款天数的起算点)+ 签名水线 -->
<div class="page-head">
<h1 class="page-title">发货管理</h1>
<p class="page-desc">
发货单挂靠执行中的业务,发货日就是回款天数的起算点(发货日计第 1 天);批次与金额经回款指定后决定计提比例与绩效
</p>
<span class="waterline"></span>
</div>
<!--
.filter-bar 直接挂在 el-form 上(去掉 inline):el-form 不是 flex 透明容器,
外面再包一层 div 会让 el-form-item 丢掉 gap 排版,筛选条退化成竖排。
-->
<el-form class="filter-bar" @submit.prevent>
<el-form-item label="业务">
<el-select
v-model="filters.business_id"
placeholder="全部"
clearable
filterable
style="width: 240px"
>
<el-option
v-for="item in businessOptions"
:key="item.id"
:label="businessLabel(item)"
:value="item.id"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="search">查询</el-button>
<el-button @click="resetFilters">重置</el-button>
</el-form-item>
</el-form>
<div class="toolbar">
<el-button type="primary" @click="openCreate">新增发货单</el-button>
</el-select>
</el-form-item>
<el-form-item label="发货日期">
<el-date-picker
v-model="dateRange"
type="daterange"
value-format="YYYY-MM-DD"
range-separator="至"
start-placeholder="起"
end-placeholder="止"
style="width: 240px"
/>
</el-form-item>
<div class="filter-bar__actions">
<el-button type="primary" @click="search">查询</el-button>
<el-button @click="resetFilters">重置</el-button>
</div>
</el-form>
<el-table v-loading="loading" :data="records" stripe>
<el-table-column prop="shipment_no" label="发货单号" min-width="150" />
<el-table-column prop="business_no" label="业务编号" min-width="150">
<template #default="{ row }">{{ row.business_no ?? '—' }}</template>
<div class="toolbar">
<el-button type="primary" @click="openCreate">新增发货单</el-button>
<span class="toolbar-note">发货明细与运费在弹窗内维护,提交后自动回写业务发货额</span>
</div>
<div class="card">
<el-table
v-loading="loading"
:data="records"
stripe
empty-text="还没有发货单 —— 点「新增发货单」登记第一批,或换个业务再查"
>
<el-table-column label="发货单号" min-width="150">
<template #default="{ row }">
<span class="cell-code">{{ row.shipment_no }}</span>
</template>
</el-table-column>
<el-table-column label="业务编号" min-width="150">
<template #default="{ row }">
<span v-if="row.business_no" class="cell-code">{{ row.business_no }}</span>
<span v-else class="cell-muted">—</span>
</template>
</el-table-column>
<el-table-column label="发货日期" min-width="140">
<template #default="{ row }">
@@ -329,12 +355,12 @@ onMounted(() => {
<el-table-column label="运费(元)" min-width="110" align="right">
<template #default="{ row }">
<!-- 字符串直出:金额红线,禁 toFixed / float 格式化 -->
<span class="cell-amount">{{ row.freight_amount }}</span>
<span class="cell-num">{{ row.freight_amount }}</span>
</template>
</el-table-column>
<el-table-column label="发货总额(元)" min-width="130" align="right">
<template #default="{ row }">
<span class="cell-amount">{{ row.total_amount }}</span>
<span class="cell-num">{{ row.total_amount }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="130" fixed="right" align="center">
@@ -348,14 +374,17 @@ onMounted(() => {
</el-table-column>
</el-table>
<AppPagination
:total="total"
:page-num="pageNum"
:page-size="pageSize"
@page-change="onPageChange"
@size-change="onSizeChange"
/>
</el-card>
<!-- 分页落进 card-foot:与表体同一张卡,卡底微渐变收边 -->
<div class="card-foot">
<AppPagination
:total="total"
:page-num="pageNum"
:page-size="pageSize"
@page-change="onPageChange"
@size-change="onSizeChange"
/>
</div>
</div>
<!-- 新增 / 编辑(明细行编辑器;编辑不含 business_id,挂靠业务不可改) -->
<el-dialog
@@ -450,16 +479,7 @@ onMounted(() => {
</template>
<style scoped>
.cell-amount {
font-family: 'SFMono-Regular', Consolas, 'Courier New', monospace;
font-size: 12px;
color: var(--ys-ink-2);
}
.cell-muted {
color: var(--ys-ink-3);
font-size: 12px;
}
/* 单元格口径(.cell-code / .cell-num / .cell-muted)由全局 index.css 提供,此处不再重复定义 */
.field-hint {
display: block;
@@ -489,4 +509,5 @@ onMounted(() => {
.tail-form {
margin-top: 4px;
}
</style>
+96 -50
View File
@@ -70,7 +70,20 @@ async function submitRecalc(): Promise<void> {
<template>
<div class="page">
<el-form inline class="filters" @submit.prevent>
<!-- 页面头:标题 + 一句口径说明(台账是核算事实的固化点)+ 签名水线 -->
<div class="page-head">
<h1 class="page-title">绩效台账</h1>
<p class="page-desc">
每笔回款只核算一次;重算作废旧版本、按当前参数落新版本并留痕,展开行可看当次核算的参数快照
</p>
<span class="waterline"></span>
</div>
<!--
.filter-bar 直接挂在 el-form 上(去掉 inline):el-form 不是 flex 透明容器,
外面再包一层 div 会让 el-form-item 丢掉 gap 排版,筛选条退化成竖排。
-->
<el-form class="filter-bar" @submit.prevent>
<el-form-item label="归属月">
<el-input
v-model="filters.settlement_month"
@@ -93,57 +106,90 @@ async function submitRecalc(): Promise<void> {
<el-option label="仅历史版本" :value="0" />
</el-select>
</el-form-item>
<el-form-item>
<div class="filter-bar__actions">
<el-button type="primary" @click="search">查询</el-button>
<el-button @click="resetFilters">重置</el-button>
</el-form-item>
</div>
</el-form>
<el-table v-loading="loading" :data="records" stripe>
<el-table-column type="expand">
<template #default="{ row }">
<pre class="snapshot">{{ JSON.stringify(row.snapshot_json ?? {}, null, 2) }}</pre>
</template>
</el-table-column>
<el-table-column prop="settlement_month" label="归属月" width="90" />
<el-table-column prop="business_no" label="业务编号" min-width="150" />
<el-table-column prop="personnel_name" label="归属人员" width="100" />
<el-table-column prop="role_code" label="岗位" width="110" />
<el-table-column prop="receipt_date" label="回款日" width="100" />
<el-table-column prop="payment_days" label="天数" width="70" />
<el-table-column prop="rate_text" label="计提比例" min-width="90" />
<el-table-column prop="converted_profit" label="折算毛利" min-width="100" />
<el-table-column prop="performance_amount" label="绩效金额" min-width="100" />
<el-table-column prop="payable_amount" label="应付绩效" min-width="100" />
<el-table-column label="版本" width="80">
<template #default="{ row }">
v{{ row.calc_version }}
<el-tag v-if="row.is_active === 0" size="small" type="info" disable-transitions>
已替代
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="90" fixed="right" align="center">
<template #default="{ row }">
<el-button
v-if="row.is_active === 1 && row.calc_type === 1"
link
type="primary"
@click="openRecalc(row)"
>
重算
</el-button>
</template>
</el-table-column>
</el-table>
<div class="card">
<el-table
v-loading="loading"
:data="records"
stripe
empty-text="台账还没有核算行 —— 换个归属月看看,或先在回款管理里登记回款后再核算"
>
<el-table-column type="expand">
<template #default="{ row }">
<pre class="snapshot">{{ JSON.stringify(row.snapshot_json ?? {}, null, 2) }}</pre>
</template>
</el-table-column>
<el-table-column prop="settlement_month" label="归属月" width="90" />
<el-table-column label="业务编号" min-width="150">
<template #default="{ row }">
<span class="cell-code">{{ row.business_no }}</span>
</template>
</el-table-column>
<el-table-column prop="personnel_name" label="归属人员" width="100" />
<el-table-column prop="role_code" label="岗位" width="110" />
<el-table-column prop="receipt_date" label="回款日" width="100" />
<el-table-column label="天数" width="70" align="right">
<template #default="{ row }">
<span class="cell-num">{{ row.payment_days }}</span>
</template>
</el-table-column>
<el-table-column label="计提比例" min-width="90" align="right">
<template #default="{ row }">
<span class="cell-num">{{ row.rate_text }}</span>
</template>
</el-table-column>
<el-table-column label="折算毛利" min-width="100" align="right">
<template #default="{ row }">
<span class="cell-num">{{ row.converted_profit }}</span>
</template>
</el-table-column>
<el-table-column label="绩效金额" min-width="100" align="right">
<template #default="{ row }">
<span class="cell-num">{{ row.performance_amount }}</span>
</template>
</el-table-column>
<el-table-column label="应付绩效" min-width="100" align="right">
<template #default="{ row }">
<span class="cell-num">{{ row.payable_amount }}</span>
</template>
</el-table-column>
<el-table-column label="版本" width="108">
<template #default="{ row }">
<span class="cell-code">v{{ row.calc_version }}</span>
<!-- 历史版本用中性 pill:被替代不是异常,只是不再是生效版本 -->
<span v-if="row.is_active === 0" class="ys-pill">已替代</span>
</template>
</el-table-column>
<el-table-column label="操作" width="90" fixed="right" align="center">
<template #default="{ row }">
<el-button
v-if="row.is_active === 1 && row.calc_type === 1"
link
type="primary"
@click="openRecalc(row)"
>
重算
</el-button>
</template>
</el-table-column>
</el-table>
<AppPagination
:total="total"
:page-num="pageNum"
:page-size="pageSize"
@page-change="onPageChange"
@size-change="onSizeChange"
/>
<!-- 分页落进 card-foot:与表体同一张卡,卡底微渐变收边 -->
<div class="card-foot">
<AppPagination
:total="total"
:page-num="pageNum"
:page-size="pageSize"
@page-change="onPageChange"
@size-change="onSizeChange"
/>
</div>
</div>
<el-dialog v-model="recalcVisible" title="重算确认" width="460px">
<p class="recalc-warn">
@@ -166,9 +212,8 @@ async function submitRecalc(): Promise<void> {
</template>
<style scoped>
.filters {
margin-bottom: 4px;
}
/* 筛选条(.filter-bar)、卡片与表尾(.card/.card-foot)的皮肤来自全局 index.css,
此处只保留本页特有的快照块与重算提示 */
.snapshot {
margin: 0;
@@ -186,4 +231,5 @@ async function submitRecalc(): Promise<void> {
font-size: 12px;
color: var(--ys-ink-3);
}
</style>
File diff suppressed because it is too large. Load diff
+20 -27
View File
@@ -217,11 +217,11 @@ onMounted(() => {
position: absolute;
top: -180px;
left: 50%;
width: 900px;
height: 480px;
width: 860px;
height: 460px;
transform: translateX(-50%);
pointer-events: none;
background: radial-gradient(closest-side, rgba(46, 75, 168, 0.09), rgba(46, 75, 168, 0));
background: radial-gradient(closest-side, rgba(46, 75, 168, 0.08), rgba(46, 75, 168, 0));
}
/* 右上涟漪:同心圆水波,部分出画 */
@@ -229,8 +229,8 @@ onMounted(() => {
position: absolute;
top: -190px;
right: -150px;
width: 560px;
height: 560px;
width: 520px;
height: 520px;
pointer-events: none;
}
@@ -242,17 +242,17 @@ onMounted(() => {
}
.login-ripple i:nth-child(2) {
inset: 70px;
inset: 65px;
border-color: rgba(46, 75, 168, 0.075);
}
.login-ripple i:nth-child(3) {
inset: 140px;
inset: 130px;
border-color: rgba(46, 75, 168, 0.055);
}
.login-ripple i:nth-child(4) {
inset: 210px;
inset: 195px;
border: none;
background: radial-gradient(circle, rgba(46, 75, 168, 0.05), rgba(46, 75, 168, 0.015));
}
@@ -266,6 +266,12 @@ onMounted(() => {
/* 登录卡:纸白微渐变 + 半透明描边 + 分层阴影 + 入场上浮 */
.login-card {
/*
* 卡内控件比列表页高一档(38px):登录是单任务场景,输入框更宽敞可减少误触;
* 覆盖组件基准变量,让输入框/按钮/验证码框一起长高,避免逐个子选择器硬编码。
*/
--el-component-size: 38px;
width: 400px;
max-width: 100%;
padding: 30px 36px 32px;
@@ -307,20 +313,16 @@ onMounted(() => {
}
.card-brand__slogan {
margin-top: 8px;
margin: 8px 0 0;
font-size: 12.5px;
letter-spacing: 0.02em;
color: var(--ys-ink-3);
}
/* 签名元素:渐隐靛蓝水线 */
/* 签名元素:渐变来自全局 .waterline,此处只收窄到 40px 并水平居中 */
.waterline {
display: block;
width: 40px;
height: 2px;
margin: 14px auto 0;
border-radius: 1px;
background: linear-gradient(90deg, var(--ys-indigo), rgba(46, 75, 168, 0));
}
.card-head {
@@ -361,14 +363,14 @@ onMounted(() => {
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 120px;
height: 40px;
width: 104px;
height: 38px;
padding: 0;
overflow: hidden;
cursor: pointer;
background: linear-gradient(180deg, #f7f8fb, #f1f3f8);
border: 1px solid var(--ys-line);
border-radius: 7px;
border-radius: var(--ys-radius-sm);
box-shadow: inset 0 1px 2px rgba(28, 35, 51, 0.04);
transition: border-color 150ms var(--ys-ease, ease);
}
@@ -405,16 +407,7 @@ onMounted(() => {
color: var(--ys-ink-3);
}
/* 输入聚焦落在体系主色上(Element Plus 主色已随全局 token 换为靛蓝) */
.login :deep(.el-input__wrapper) {
box-shadow: 0 0 0 1px rgba(28, 35, 51, 0.13) inset;
}
.login :deep(.el-input__wrapper.is-focus) {
box-shadow:
0 0 0 1px var(--ys-indigo) inset,
0 0 0 3px rgba(46, 75, 168, 0.13);
}
/* 输入框的描边/焦点环已由全局 index.css 统一(登录卡只把控件高度调到 38px) */
@media (max-width: 640px) {
.login {
@@ -155,7 +155,20 @@ function today(): string {
<template>
<div class="page">
<el-form inline class="filters" @submit.prevent>
<!-- 页面头:一句话说清这张报表回答什么问题、口径是什么;水线每页只出现一次 -->
<div class="page-head">
<h1 class="page-title">业务统计</h1>
<p class="page-desc">
按业务看发货、回款与绩效的累计进度;回款与绩效是累计值,不随业务日期筛选变化
</p>
<span class="waterline"></span>
</div>
<!--
.filter-bar 直接挂在 el-form 上(去掉 inline):el-form 本身就是 flex 容器,
外面再包一层 div 会让 el-form-item 的 gap 排版失效。
-->
<el-form class="filter-bar" @submit.prevent>
<el-form-item label="关键字">
<el-input
v-model="filters.keyword"
@@ -218,103 +231,151 @@ function today(): string {
style="width: 240px"
/>
</el-form-item>
<el-form-item>
<div class="filter-bar__actions">
<el-button type="primary" @click="search">查询</el-button>
<el-button @click="resetFilters">重置</el-button>
<el-button :loading="exporting" @click="doExport">导出 Excel</el-button>
<el-button @click="doPrint">打印</el-button>
</el-form-item>
</div>
</el-form>
<div class="hint">回款额与绩效为业务累计值,不随业务日期筛选变化</div>
<!-- 表格包进卡片:报表页的信息密度靠卡片边界收拢,分页条共用卡脚材质 -->
<div class="card">
<el-table
v-loading="loading"
class="dense"
:data="records"
stripe
empty-text="还没有符合条件的业务 —— 先清空筛选看全量,或点「重置」重来"
@expand-change="loadDrill"
>
<el-table-column type="expand">
<template #default="{ row }">
<div class="drill-wrap">
<p class="section-title">回款明细</p>
<el-table :data="receiptsMap[row.business_id] ?? []" size="small" stripe>
<el-table-column prop="receipt_batch_no" label="回款批次号" width="160" />
<el-table-column prop="receipt_date" label="回款日" width="110" />
<el-table-column prop="amount" label="回款金额" width="120" align="right" />
<el-table-column prop="settlement_status" label="入结状态" width="100" />
<el-table-column prop="remark" label="备注" min-width="140" />
</el-table>
<p class="section-title">台账明细</p>
<el-table :data="ledgersMap[row.business_id] ?? []" size="small" stripe>
<el-table-column prop="personnel_name" label="归属人员" width="120" />
<el-table-column prop="receipt_date" label="回款日" width="110" />
<el-table-column prop="payment_days" label="天数" width="80" align="right" />
<el-table-column prop="rate_text" label="计提比例" width="110" />
<el-table-column
prop="performance_amount"
label="绩效金额"
width="120"
align="right"
/>
<el-table-column prop="payable_amount" label="应付绩效" width="120" align="right" />
</el-table>
</div>
</template>
</el-table-column>
<el-table-column prop="business_no" label="业务编号" min-width="160">
<template #default="{ row }">
<span class="cell-code">{{ row.business_no }}</span>
</template>
</el-table-column>
<el-table-column prop="business_date" label="业务日期" width="110" />
<el-table-column prop="customer_name" label="客户" min-width="170" />
<el-table-column prop="owner_personnel_name" label="归属人" width="110" />
<el-table-column label="状态" width="100">
<template #default="{ row }">{{ businessStatusLabel(row.status) }}</template>
</el-table-column>
<el-table-column prop="total_sale_amount" label="销售额" width="120" align="right">
<template #default="{ row }">
<span class="cell-num">{{ row.total_sale_amount }}</span>
</template>
</el-table-column>
<el-table-column prop="total_ship_amount" label="发货额" width="120" align="right">
<template #default="{ row }">
<span class="cell-num">{{ row.total_ship_amount }}</span>
</template>
</el-table-column>
<el-table-column prop="total_received_amount" label="回款额" width="120" align="right">
<template #default="{ row }">
<span class="cell-num">{{ row.total_received_amount }}</span>
</template>
</el-table-column>
<el-table-column prop="performance_amount" label="绩效金额" width="120" align="right">
<template #default="{ row }">
<!-- 绩效列加粗:一屏几十行时,加粗是读者定位「钱」的唯一线索 -->
<span class="cell-num cell-strong">{{ row.performance_amount }}</span>
</template>
</el-table-column>
<el-table-column prop="payable_amount" label="应付绩效" width="120" align="right">
<template #default="{ row }">
<span class="cell-num">{{ row.payable_amount }}</span>
</template>
</el-table-column>
</el-table>
<el-table v-loading="loading" :data="records" stripe @expand-change="loadDrill">
<el-table-column type="expand">
<template #default="{ row }">
<div class="drill-wrap">
<h4>回款明细</h4>
<el-table :data="receiptsMap[row.business_id] ?? []" size="small" stripe>
<el-table-column prop="receipt_batch_no" label="回款批次号" width="160" />
<el-table-column prop="receipt_date" label="回款日" width="110" />
<el-table-column prop="amount" label="回款金额" width="120" align="right" />
<el-table-column prop="settlement_status" label="入结状态" width="100" />
<el-table-column prop="remark" label="备注" min-width="140" />
</el-table>
<h4>台账明细</h4>
<el-table :data="ledgersMap[row.business_id] ?? []" size="small" stripe>
<el-table-column prop="personnel_name" label="归属人员" width="120" />
<el-table-column prop="receipt_date" label="回款日" width="110" />
<el-table-column prop="payment_days" label="天数" width="80" align="right" />
<el-table-column prop="rate_text" label="计提比例" width="110" />
<el-table-column prop="performance_amount" label="绩效金额" width="120" align="right" />
<el-table-column prop="payable_amount" label="应付绩效" width="120" align="right" />
</el-table>
</div>
</template>
</el-table-column>
<el-table-column prop="business_no" label="业务编号" min-width="160" />
<el-table-column prop="business_date" label="业务日期" width="110" />
<el-table-column prop="customer_name" label="客户" min-width="170" />
<el-table-column prop="owner_personnel_name" label="归属人" width="110" />
<el-table-column label="状态" width="100">
<template #default="{ row }">{{ businessStatusLabel(row.status) }}</template>
</el-table-column>
<el-table-column prop="total_sale_amount" label="销售额" width="120" align="right" />
<el-table-column prop="total_ship_amount" label="发货额" width="120" align="right" />
<el-table-column prop="total_received_amount" label="回款额" width="120" align="right" />
<el-table-column prop="performance_amount" label="绩效金额" width="120" align="right" />
<el-table-column prop="payable_amount" label="应付绩效" width="120" align="right" />
</el-table>
<!-- 合计与分页共用卡脚:合计靠 margin-right:auto 占到左侧,分页恒靠右 -->
<div class="card-foot">
<div v-if="summary" class="summary-bar">
全部筛选结果:{{ summary.row_count }} 笔业务 | 发货合计
{{ summary.ship_total ?? '0.00' }} | 回款合计 {{ summary.received_total ?? '0.00' }} |
绩效合计 {{ summary.performance_total ?? '0.00' }}
<span class="summary-note">(合计为全部筛选结果,非当前页)</span>
</div>
<div v-if="summary" class="summary-bar">
全部筛选结果:{{ summary.row_count }} 笔业务 | 发货合计 {{ summary.ship_total ?? '0.00' }}
| 回款合计 {{ summary.received_total ?? '0.00' }} | 绩效合计
{{ summary.performance_total ?? '0.00' }}
<span class="summary-note">(合计为全部筛选结果,非当前页)</span>
<!-- AppPagination 只派发 page-change/size-change(无 update:pageNum) -->
<AppPagination
:total="total"
:page-num="pageNum"
:page-size="pageSize"
@page-change="onPageChange"
@size-change="onSizeChange"
/>
</div>
</div>
<!-- AppPagination 只派发 page-change/size-change(无 update:pageNum) -->
<AppPagination
:total="total"
:page-num="pageNum"
:page-size="pageSize"
@page-change="onPageChange"
@size-change="onSizeChange"
/>
</div>
</template>
<style scoped>
.filters {
margin-bottom: 4px;
/*
* 密集表(设计稿 §09-3):报表要的是信息密度,行高与字号各降半档。
* 只写在页面 scoped 里 —— 写进全局会波及其它列表页的行高。
*/
.dense :deep(.el-table__cell) {
padding: 7px 0;
}
.hint {
margin-bottom: 8px;
font-size: 12px;
color: var(--ys-ink-3);
.dense :deep(.el-table__cell .cell) {
font-size: 12.5px;
}
.drill-wrap {
padding: 8px 16px;
}
.drill-wrap h4 {
margin: 8px 0;
font-size: 13px;
/* 绩效列加粗:设计稿要求「密集不等于看不清」 */
.cell-strong {
font-weight: 600;
}
/* 展开区:两块下钻表跟着密集表收内距,字号由 size="small" 与 .dense 决定 */
.drill-wrap {
padding: 10px 14px 6px;
}
.drill-wrap .section-title {
margin-top: 8px;
}
/* 合计条:比正文加粗、字号放大半档,读者一眼能把「合计」与数据行分开 */
.summary-bar {
margin-top: 10px;
padding: 8px 12px;
font-size: 13px;
background: var(--ys-indigo-wash);
border-radius: 6px;
margin-right: auto;
font-size: 13.5px;
font-weight: 600;
color: var(--ys-ink);
}
.summary-note {
color: var(--ys-ink-3);
font-size: 12px;
font-weight: 400;
color: var(--ys-ink-3);
}
</style>
@@ -135,7 +135,20 @@ function pct(value: string): string {
<template>
<div class="page">
<el-form inline class="filters" @submit.prevent>
<!-- 页面头:一句话说清这张报表回答什么问题、口径是什么;水线每页只出现一次 -->
<div class="page-head">
<h1 class="page-title">部门绩效汇总</h1>
<p class="page-desc">
按经理团队汇总当月绩效与应付;团队/全公司分成为月结同源口径,可与冻结额对账
</p>
<span class="waterline"></span>
</div>
<!--
.filter-bar 直接挂在 el-form 上(去掉 inline):el-form 本身就是 flex 容器,
外面再包一层 div 会让 el-form-item 的 gap 排版失效。
-->
<el-form class="filter-bar" @submit.prevent>
<el-form-item label="统计月份" required>
<el-date-picker
v-model="filters.settlement_month"
@@ -164,17 +177,20 @@ function pct(value: string): string {
/>
</el-select>
</el-form-item>
<el-form-item>
<div class="filter-bar__actions">
<el-button type="primary" :loading="loading" @click="load">查询</el-button>
<el-button :loading="exporting" @click="doExport">导出 Excel</el-button>
<el-button @click="doPrint">打印</el-button>
</el-form-item>
</div>
</el-form>
<div v-if="data?.params" class="params-hint">
分成参数:团队 {{ data.params.team_text }}% | 全公司 {{ data.params.company_text }}% |
经理个人户{{ data.params.manager_personal_included === 1 ? '计入' : '不计入' }}基数 |
取值时点 {{ data.params.params_as_of }}
<!-- 参数口径行:动态值,不能并进页面描述(描述是稳定的,参数随在线配置变) -->
<div v-if="data?.params" class="toolbar">
<span class="toolbar-note">
分成参数:团队 {{ data.params.team_text }}% | 全公司 {{ data.params.company_text }}% |
经理个人户{{ data.params.manager_personal_included === 1 ? '计入' : '不计入' }}基数 |
取值时点 {{ data.params.params_as_of }}
</span>
</div>
<div v-loading="loading" class="company-card">
@@ -191,83 +207,149 @@ function pct(value: string): string {
</span>
</div>
<el-table v-loading="loading" :data="data?.teams ?? []" row-key="manager_id" stripe>
<el-table-column type="expand">
<template #default="{ row }">
<div class="member-wrap">
<el-table :data="row.members" size="small" stripe>
<el-table-column prop="personnel_name" label="成员" width="120" />
<el-table-column prop="role_code" label="岗位" width="120" />
<el-table-column prop="performance_sum" label="绩效金额" width="120" align="right" />
<el-table-column prop="payable_sum" label="应付绩效" width="120" align="right" />
<el-table-column label="调整补发" width="110" align="right">
<template #default="{ row: member }">{{ member.adjust_extra }}</template>
</el-table-column>
<el-table-column label="调整扣减" width="110" align="right">
<template #default="{ row: member }">{{ member.adjust_deduct }}</template>
</el-table-column>
<el-table-column prop="net_total" label="净合计" width="120" align="right" />
<el-table-column label="操作" width="100" fixed="right">
<template #default="{ row: member }">
<el-button link type="primary" @click="openPersonalDetail(member)">
查看明细
</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-if="row.members.length === 0" description="该经理本月无团队成员业绩" />
</div>
</template>
</el-table-column>
<el-table-column prop="manager_name" label="经理团队" width="130">
<template #default="{ row }">
{{ row.manager_name }}
<el-tag v-if="row.manager_status === 0" size="small" type="info" disable-transitions>
离职
</el-tag>
</template>
</el-table-column>
<el-table-column prop="member_count" label="团队人数" width="90" align="right" />
<el-table-column prop="team_base" label="团队基数" width="120" align="right" />
<el-table-column prop="team_share_amount" :label="teamShareLabel" width="150" align="right" />
<el-table-column prop="company_share_amount" :label="companyShareLabel" width="150" align="right" />
<el-table-column label="占全公司比" width="110" align="right">
<template #default="{ row }">{{ pct(row.company_base_ratio) }}</template>
</el-table-column>
<el-table-column prop="adjust_net" label="调整净额" width="110" align="right" />
<el-table-column prop="net_total" label="净合计" width="120" align="right" />
<el-table-column label="月结冻结额" width="130" align="right">
<template #default="{ row }">
<span v-if="row.frozen_amount !== null" class="frozen">{{ row.frozen_amount }}</span>
<span v-else class="frozen-none">未生成月结</span>
</template>
</el-table-column>
</el-table>
<div class="card">
<el-table
v-loading="loading"
class="dense"
:data="data?.teams ?? []"
row-key="manager_id"
stripe
empty-text="还没有这个月的团队绩效 —— 先确认统计月份,再点查询"
>
<el-table-column type="expand">
<template #default="{ row }">
<div class="member-wrap">
<el-table :data="row.members" size="small" stripe>
<el-table-column prop="personnel_name" label="成员" width="120" />
<el-table-column prop="role_code" label="岗位" width="120" />
<el-table-column prop="performance_sum" label="绩效金额" width="120" align="right">
<template #default="{ row: member }">
<span class="cell-num cell-strong">{{ member.performance_sum }}</span>
</template>
</el-table-column>
<el-table-column prop="payable_sum" label="应付绩效" width="120" align="right">
<template #default="{ row: member }">
<span class="cell-num">{{ member.payable_sum }}</span>
</template>
</el-table-column>
<el-table-column label="调整补发" width="110" align="right">
<template #default="{ row: member }">
<span class="cell-num">{{ member.adjust_extra }}</span>
</template>
</el-table-column>
<el-table-column label="调整扣减" width="110" align="right">
<template #default="{ row: member }">
<span class="cell-num">{{ member.adjust_deduct }}</span>
</template>
</el-table-column>
<el-table-column prop="net_total" label="净合计" width="120" align="right">
<template #default="{ row: member }">
<span class="cell-num">{{ member.net_total }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="100" fixed="right">
<template #default="{ row: member }">
<el-button link type="primary" @click="openPersonalDetail(member)">
查看明细
</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-if="row.members.length === 0" description="该经理本月无团队成员业绩" />
</div>
</template>
</el-table-column>
<el-table-column prop="manager_name" label="经理团队" width="130">
<template #default="{ row }">
{{ row.manager_name }}
<el-tag v-if="row.manager_status === 0" size="small" type="info" disable-transitions>
离职
</el-tag>
</template>
</el-table-column>
<el-table-column prop="member_count" label="团队人数" width="90" align="right">
<template #default="{ row }">
<span class="cell-num">{{ row.member_count }}</span>
</template>
</el-table-column>
<el-table-column prop="team_base" label="团队基数" width="120" align="right">
<template #default="{ row }">
<span class="cell-num">{{ row.team_base }}</span>
</template>
</el-table-column>
<el-table-column :label="teamShareLabel" width="150" align="right">
<template #default="{ row }">
<span class="cell-num">{{ row.team_share_amount }}</span>
</template>
</el-table-column>
<el-table-column :label="companyShareLabel" width="150" align="right">
<template #default="{ row }">
<span class="cell-num">{{ row.company_share_amount }}</span>
</template>
</el-table-column>
<el-table-column label="占全公司比" width="110" align="right">
<template #default="{ row }">
<span class="cell-num">{{ pct(row.company_base_ratio) }}</span>
</template>
</el-table-column>
<el-table-column prop="adjust_net" label="调整净额" width="110" align="right">
<template #default="{ row }">
<span class="cell-num">{{ row.adjust_net }}</span>
</template>
</el-table-column>
<!-- 本表没有单列绩效列,「钱」的落点就是净合计:按设计稿对绩效列的加粗处理 -->
<el-table-column prop="net_total" label="净合计" width="120" align="right">
<template #default="{ row }">
<span class="cell-num cell-strong">{{ row.net_total }}</span>
</template>
</el-table-column>
<el-table-column label="月结冻结额" width="130" align="right">
<template #default="{ row }">
<span v-if="row.frozen_amount !== null" class="cell-num frozen">
{{ row.frozen_amount }}
</span>
<span v-else class="cell-num frozen-none">未生成月结</span>
</template>
</el-table-column>
</el-table>
</div>
<div v-if="data && data.unassigned.length" class="unassigned">
<h4>未纳入团队基数(无经理 / 非营销经理上级的下属)</h4>
<p class="section-title">未纳入团队基数(无经理 / 非营销经理上级的下属)</p>
<el-table :data="data.unassigned" size="small" stripe>
<el-table-column prop="personnel_name" label="人员" width="130" />
<el-table-column prop="role_code" label="岗位" width="120" />
<el-table-column prop="performance_sum" label="绩效金额" width="130" align="right" />
<el-table-column prop="net_total" label="净合计" width="130" align="right" />
<el-table-column prop="performance_sum" label="绩效金额" width="130" align="right">
<template #default="{ row }">
<span class="cell-num">{{ row.performance_sum }}</span>
</template>
</el-table-column>
<el-table-column prop="net_total" label="净合计" width="130" align="right">
<template #default="{ row }">
<span class="cell-num">{{ row.net_total }}</span>
</template>
</el-table-column>
</el-table>
</div>
</div>
</template>
<style scoped>
.filters {
margin-bottom: 4px;
/*
* 密集表(设计稿 §09-3):报表要的是信息密度,行高与字号各降半档。
* 只写在页面 scoped 里 —— 写进全局会波及其它列表页的行高。
*/
.dense :deep(.el-table__cell) {
padding: 7px 0;
}
.params-hint {
margin-bottom: 10px;
padding: 8px 12px;
font-size: 12px;
color: var(--ys-ink-2);
background: var(--ys-mist);
border-radius: 6px;
.dense :deep(.el-table__cell .cell) {
font-size: 12.5px;
}
/* 绩效列加粗:设计稿要求「密集不等于看不清」 */
.cell-strong {
font-weight: 600;
}
.company-card {
@@ -277,7 +359,8 @@ function pct(value: string): string {
margin-bottom: 12px;
padding: 12px 16px;
background: var(--ys-indigo-wash);
border-radius: 8px;
border: 1px solid var(--ys-line);
border-radius: var(--ys-radius);
font-size: 13px;
}
@@ -290,8 +373,9 @@ function pct(value: string): string {
font-size: 12px;
}
/* 展开区:内容字号与内距跟着密集表再收半档,不与主表抢注意力 */
.member-wrap {
padding: 8px 16px;
padding: 8px 14px;
}
.frozen {
@@ -306,10 +390,4 @@ function pct(value: string): string {
.unassigned {
margin-top: 20px;
}
.unassigned h4 {
margin: 0 0 8px;
font-size: 14px;
font-weight: 600;
}
</style>
@@ -161,7 +161,20 @@ function today(): string {
<template>
<div class="page">
<el-form inline class="filters" @submit.prevent>
<!-- 页面头:一句话说清这张报表回答什么问题、口径是什么;水线每页只出现一次 -->
<div class="page-head">
<h1 class="page-title">回款统计</h1>
<p class="page-desc">
按回款天数区间看回款结构与绩效分布;只统计已计提(有台账行)的回款,空区间记 0
</p>
<span class="waterline"></span>
</div>
<!--
.filter-bar 直接挂在 el-form 上(去掉 inline):el-form 本身就是 flex 容器,
外面再包一层 div 会让 el-form-item 的 gap 排版失效。
-->
<el-form class="filter-bar" @submit.prevent>
<el-form-item label="月份">
<el-date-picker
v-model="filters.settlement_month"
@@ -205,94 +218,148 @@ function today(): string {
<el-option label="混合业务" value="mixed" />
</el-select>
</el-form-item>
<el-form-item>
<div class="filter-bar__actions">
<el-button type="primary" :loading="loading" @click="load">查询</el-button>
<el-button :loading="exporting" @click="doExport">导出 Excel</el-button>
<el-button @click="doPrint">打印</el-button>
</el-form-item>
</div>
</el-form>
<div class="hint">
统计口径:已计提回款(有台账行)的金额与绩效;进度登记回款不计入。空区间显示 0。
</div>
<!-- 表格包进卡片:报表页的信息密度靠卡片边界收拢,合计行共用卡脚材质 -->
<div class="card">
<el-table
v-loading="loading"
class="bucket-table dense"
data-testid="payment-bucket-table"
:data="data?.buckets ?? []"
row-key="code"
stripe
empty-text="所选条件下还没有回款记录 —— 先选择月份再查一次"
@expand-change="loadDrill"
>
<el-table-column type="expand">
<template #default="{ row }">
<div class="drill-wrap">
<el-table
v-loading="drillOf(row.code).loading"
:data="drillOf(row.code).rows"
size="small"
stripe
>
<el-table-column prop="business_no" label="合同编号" min-width="150" />
<el-table-column prop="receipt_batch_no" label="回款批次号" width="160" />
<el-table-column prop="receipt_date" label="回款日" width="110" />
<el-table-column prop="amount" label="回款金额" width="120" align="right" />
<el-table-column prop="personnel_name" label="归属人员" width="110" />
<el-table-column prop="payment_days" label="天数" width="80" align="right" />
<el-table-column label="客户类型" width="100">
<template #default="{ row: detail }">
{{ customerTypeLabel(detail.customer_type) }}
</template>
</el-table-column>
<el-table-column label="产品类别" width="100">
<template #default="{ row: detail }">
{{ reportApi.reportCategoryLabel(detail.product_category) }}
</template>
</el-table-column>
<el-table-column
prop="performance_amount"
label="绩效金额"
width="120"
align="right"
/>
</el-table>
<AppPagination
:total="drillOf(row.code).total"
:page-num="drillOf(row.code).pageNum"
:page-size="drillOf(row.code).pageSize"
@page-change="onDrillPage(row.code, $event)"
@size-change="onDrillSize(row.code, $event)"
/>
</div>
</template>
</el-table-column>
<el-table-column prop="label" label="回款天数区间" min-width="150" />
<el-table-column prop="receipt_amount" label="回款金额" width="140" align="right">
<template #default="{ row }">
<span class="cell-num">{{ row.receipt_amount }}</span>
</template>
</el-table-column>
<el-table-column prop="receipt_count" label="回款笔数" width="100" align="right">
<template #default="{ row }">
<span class="cell-num">{{ row.receipt_count }}</span>
</template>
</el-table-column>
<el-table-column prop="performance_amount" label="绩效金额" width="130" align="right">
<template #default="{ row }">
<!-- 绩效列加粗:一屏几十行时,加粗是读者定位「钱」的唯一线索 -->
<span class="cell-num cell-strong">{{ row.performance_amount }}</span>
</template>
</el-table-column>
<el-table-column prop="payable_amount" label="应付绩效" width="130" align="right">
<template #default="{ row }">
<span class="cell-num">{{ row.payable_amount }}</span>
</template>
</el-table-column>
<el-table-column prop="ledger_count" label="台账行数" width="100" align="right">
<template #default="{ row }">
<span class="cell-num">{{ row.ledger_count }}</span>
</template>
</el-table-column>
</el-table>
<el-table
v-loading="loading"
class="bucket-table"
data-testid="payment-bucket-table"
:data="data?.buckets ?? []"
row-key="code"
stripe
@expand-change="loadDrill"
>
<el-table-column type="expand">
<template #default="{ row }">
<div class="drill-wrap">
<el-table
v-loading="drillOf(row.code).loading"
:data="drillOf(row.code).rows"
size="small"
stripe
>
<el-table-column prop="business_no" label="合同编号" min-width="150" />
<el-table-column prop="receipt_batch_no" label="回款批次号" width="160" />
<el-table-column prop="receipt_date" label="回款日" width="110" />
<el-table-column prop="amount" label="回款金额" width="120" align="right" />
<el-table-column prop="personnel_name" label="归属人员" width="110" />
<el-table-column prop="payment_days" label="天数" width="80" align="right" />
<el-table-column label="客户类型" width="100">
<template #default="{ row }">
{{ customerTypeLabel(row.customer_type) }}
</template>
</el-table-column>
<el-table-column label="产品类别" width="100">
<template #default="{ row }">
{{ reportApi.reportCategoryLabel(row.product_category) }}
</template>
</el-table-column>
<el-table-column prop="performance_amount" label="绩效金额" width="120" align="right" />
</el-table>
<AppPagination
:total="drillOf(row.code).total"
:page-num="drillOf(row.code).pageNum"
:page-size="drillOf(row.code).pageSize"
@page-change="onDrillPage(row.code, $event)"
@size-change="onDrillSize(row.code, $event)"
/>
</div>
</template>
</el-table-column>
<el-table-column prop="label" label="回款天数区间" min-width="150" />
<el-table-column prop="receipt_amount" label="回款金额" width="140" align="right" />
<el-table-column prop="receipt_count" label="回款笔数" width="100" align="right" />
<el-table-column prop="performance_amount" label="绩效金额" width="130" align="right" />
<el-table-column prop="payable_amount" label="应付绩效" width="130" align="right" />
<el-table-column prop="ledger_count" label="台账行数" width="100" align="right" />
</el-table>
<div v-if="data" class="summary-bar">
合计:回款 {{ data.totals.receipt_amount ?? '0.00' }} | 笔数
{{ data.totals.receipt_count ?? 0 }} | 绩效 {{ data.totals.performance_amount ?? '0.00' }}
| 应付 {{ data.totals.payable_amount ?? '0.00' }}
<!-- 合计行:本表无分页,卡脚只承载合计;加粗并放大半档,与数据行区分 -->
<div class="card-foot">
<div v-if="data" class="summary-bar">
合计:回款 {{ data.totals.receipt_amount ?? '0.00' }} | 笔数
{{ data.totals.receipt_count ?? 0 }} | 绩效
{{ data.totals.performance_amount ?? '0.00' }} | 应付
{{ data.totals.payable_amount ?? '0.00' }}
</div>
</div>
</div>
<div v-if="data" class="dimension-grid">
<div>
<h4>客户类型对比</h4>
<p class="section-title">客户类型对比</p>
<el-table :data="data.by_customer_type" size="small" stripe>
<el-table-column prop="label" label="客户类型" width="120" />
<el-table-column prop="receipt_amount" label="回款金额" width="130" align="right" />
<el-table-column prop="performance_amount" label="绩效金额" width="130" align="right" />
<el-table-column prop="ledger_count" label="台账行数" width="100" align="right" />
<el-table-column prop="receipt_amount" label="回款金额" width="130" align="right">
<template #default="{ row }">
<span class="cell-num">{{ row.receipt_amount }}</span>
</template>
</el-table-column>
<el-table-column prop="performance_amount" label="绩效金额" width="130" align="right">
<template #default="{ row }">
<span class="cell-num">{{ row.performance_amount }}</span>
</template>
</el-table-column>
<el-table-column prop="ledger_count" label="台账行数" width="100" align="right">
<template #default="{ row }">
<span class="cell-num">{{ row.ledger_count }}</span>
</template>
</el-table-column>
</el-table>
</div>
<div>
<h4>产品类别对比</h4>
<p class="section-title">产品类别对比</p>
<el-table :data="data.by_product_category" size="small" stripe>
<el-table-column prop="label" label="产品类别" width="120" />
<el-table-column prop="receipt_amount" label="回款金额" width="130" align="right" />
<el-table-column prop="performance_amount" label="绩效金额" width="130" align="right" />
<el-table-column prop="ledger_count" label="台账行数" width="100" align="right" />
<el-table-column prop="receipt_amount" label="回款金额" width="130" align="right">
<template #default="{ row }">
<span class="cell-num">{{ row.receipt_amount }}</span>
</template>
</el-table-column>
<el-table-column prop="performance_amount" label="绩效金额" width="130" align="right">
<template #default="{ row }">
<span class="cell-num">{{ row.performance_amount }}</span>
</template>
</el-table-column>
<el-table-column prop="ledger_count" label="台账行数" width="100" align="right">
<template #default="{ row }">
<span class="cell-num">{{ row.ledger_count }}</span>
</template>
</el-table-column>
</el-table>
</div>
</div>
@@ -300,26 +367,34 @@ function today(): string {
</template>
<style scoped>
.filters {
margin-bottom: 4px;
/*
* 密集表(设计稿 §09-3):报表要的是信息密度,行高与字号各降半档。
* 只写在页面 scoped 里 —— 写进全局会波及其它列表页的行高。
*/
.dense :deep(.el-table__cell) {
padding: 7px 0;
}
.hint {
margin-bottom: 8px;
font-size: 12px;
color: var(--ys-ink-3);
.dense :deep(.el-table__cell .cell) {
font-size: 12.5px;
}
/* 绩效列加粗:设计稿要求「密集不等于看不清」 */
.cell-strong {
font-weight: 600;
}
/* 展开区:内距跟着密集表收半档,字号由 size="small" 与 .dense 决定 */
.drill-wrap {
padding: 8px 16px;
padding: 10px 14px 6px;
}
/* 合计条:比正文加粗、字号放大半档,读者一眼能把「合计」与数据行分开 */
.summary-bar {
margin-top: 10px;
padding: 8px 12px;
font-size: 13px;
background: var(--ys-indigo-wash);
border-radius: 6px;
margin-right: auto;
font-size: 13.5px;
font-weight: 600;
color: var(--ys-ink);
}
.dimension-grid {
@@ -328,10 +403,4 @@ function today(): string {
gap: 20px;
margin-top: 20px;
}
.dimension-grid h4 {
margin: 0 0 8px;
font-size: 14px;
font-weight: 600;
}
</style>
@@ -170,7 +170,18 @@ function today(): string {
<template>
<div class="page">
<el-form inline class="filters" @submit.prevent>
<!-- 页面头:一句话说清这张报表回答什么问题、口径是什么;水线每页只出现一次 -->
<div class="page-head">
<h1 class="page-title">个人绩效明细</h1>
<p class="page-desc">按人拆到每一笔回款;绩效 = 回款 × 计提比例 × 权重</p>
<span class="waterline"></span>
</div>
<!--
.filter-bar 直接挂在 el-form 上(去掉 inline):el-form 本身就是 flex 容器,
外面再包一层 div 会让 el-form-item 的 gap 排版失效。
-->
<el-form class="filter-bar" @submit.prevent>
<el-form-item label="月份">
<el-date-picker
v-model="filters.settlement_month"
@@ -229,103 +240,165 @@ function today(): string {
/>
</el-select>
</el-form-item>
<el-form-item>
<div class="filter-bar__actions">
<el-button type="primary" @click="search">查询</el-button>
<el-button @click="resetFilters">重置</el-button>
<el-button :loading="exporting" @click="doExport">导出 Excel</el-button>
<el-button @click="doPrint">打印</el-button>
</el-form-item>
</div>
</el-form>
<el-table v-loading="loading" :data="records" stripe @expand-change="loadSnapshot">
<el-table-column type="expand">
<template #default="{ row }">
<pre class="snapshot">{{
JSON.stringify(snapshots[row.ledger_id]?.snapshot_json ?? '加载中…', null, 2)
}}</pre>
</template>
</el-table-column>
<el-table-column prop="business_no" label="合同编号" min-width="160" />
<el-table-column prop="ship_date" label="发货日" width="105" />
<el-table-column prop="receipt_date" label="回款日" width="105" />
<el-table-column prop="payment_days" label="回款天数" width="90" align="right" />
<el-table-column label="产品类别" width="100">
<template #default="{ row }">{{ reportApi.reportCategoryLabel(row.product_category) }}</template>
</el-table-column>
<el-table-column label="客户类型" width="100">
<template #default="{ row }">{{ customerTypeLabel(row.customer_type) }}</template>
</el-table-column>
<el-table-column prop="role_name" label="岗位" width="100" />
<el-table-column prop="rate_text" label="计提比例" width="110" />
<el-table-column prop="weight_r" label="支付权重R" width="95" align="right" />
<el-table-column prop="performance_amount" label="绩效金额" width="110" align="right">
<template #default="{ row }">
<span :class="{ negative: Number(row.performance_amount) < 0 }">
{{ row.performance_amount ?? '—' }}
</span>
</template>
</el-table-column>
<el-table-column prop="payable_amount" label="应付绩效" width="110" align="right" />
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag v-if="row.is_probation === 1" size="small" type="warning" disable-transitions>
试用期
</el-tag>
<el-tag v-if="row.missing_cost === 1" size="small" type="info" disable-transitions>
缺成本
</el-tag>
</template>
</el-table-column>
</el-table>
<!-- 表格包进卡片:报表页的信息密度靠卡片边界收拢,分页条共用卡脚材质 -->
<div class="card">
<el-table
v-loading="loading"
class="dense"
:data="records"
stripe
empty-text="还没有符合条件的明细 —— 换个月份或放宽人员再查一次"
@expand-change="loadSnapshot"
>
<el-table-column type="expand">
<template #default="{ row }">
<pre class="snapshot">{{
JSON.stringify(snapshots[row.ledger_id]?.snapshot_json ?? '加载中…', null, 2)
}}</pre>
</template>
</el-table-column>
<el-table-column prop="business_no" label="合同编号" min-width="160">
<template #default="{ row }">
<span class="cell-code">{{ row.business_no }}</span>
</template>
</el-table-column>
<el-table-column prop="ship_date" label="发货日" width="105" />
<el-table-column prop="receipt_date" label="回款日" width="105" />
<el-table-column prop="payment_days" label="回款天数" width="90" align="right">
<template #default="{ row }">
<span class="cell-num">{{ row.payment_days }}</span>
</template>
</el-table-column>
<el-table-column label="产品类别" width="100">
<template #default="{ row }">{{
reportApi.reportCategoryLabel(row.product_category)
}}</template>
</el-table-column>
<el-table-column label="客户类型" width="100">
<template #default="{ row }">{{ customerTypeLabel(row.customer_type) }}</template>
</el-table-column>
<el-table-column prop="role_name" label="岗位" width="100" />
<el-table-column prop="rate_text" label="计提比例" width="110">
<template #default="{ row }">
<span class="cell-num">{{ row.rate_text }}</span>
</template>
</el-table-column>
<el-table-column prop="weight_r" label="支付权重R" width="95" align="right">
<template #default="{ row }">
<span class="cell-num">{{ row.weight_r }}</span>
</template>
</el-table-column>
<el-table-column prop="performance_amount" label="绩效金额" width="110" align="right">
<template #default="{ row }">
<!-- 绩效列加粗:一屏几十行时,加粗是读者定位「钱」的唯一线索 -->
<span
class="cell-num cell-strong"
:class="{ negative: Number(row.performance_amount) < 0 }"
>
{{ row.performance_amount ?? '—' }}
</span>
</template>
</el-table-column>
<el-table-column prop="payable_amount" label="应付绩效" width="110" align="right">
<template #default="{ row }">
<span class="cell-num">{{ row.payable_amount }}</span>
</template>
</el-table-column>
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag v-if="row.is_probation === 1" size="small" type="warning" disable-transitions>
试用期
</el-tag>
<el-tag v-if="row.missing_cost === 1" size="small" type="info" disable-transitions>
缺成本
</el-tag>
</template>
</el-table-column>
</el-table>
<div v-if="summary" class="summary-bar">
全部筛选结果:{{ summary.row_count }} 行 | 绩效合计 {{ summary.performance_total ?? '0.00' }}
| 应付合计 {{ summary.payable_total ?? '0.00' }}
<span class="summary-note">(合计为全部筛选结果,非当前页)</span>
<!-- 合计与分页共用卡脚:合计靠 margin-right:auto 占到左侧,分页恒靠右 -->
<div class="card-foot">
<div v-if="summary" class="summary-bar">
全部筛选结果:{{ summary.row_count }} 行 | 绩效合计
{{ summary.performance_total ?? '0.00' }} | 应付合计
{{ summary.payable_total ?? '0.00' }}
<span class="summary-note">(合计为全部筛选结果,非当前页)</span>
</div>
<!-- AppPagination 只派发 page-change/size-change(无 update:pageNum),
不能用 v-model 或 @change——翻了页不会加载 -->
<AppPagination
:total="total"
:page-num="pageNum"
:page-size="pageSize"
@page-change="onPageChange"
@size-change="onSizeChange"
/>
</div>
</div>
<!-- AppPagination 只派发 page-change/size-change(无 update:pageNum),
不能用 v-model 或 @change——翻了页不会加载 -->
<AppPagination
:total="total"
:page-num="pageNum"
:page-size="pageSize"
@page-change="onPageChange"
@size-change="onSizeChange"
/>
</div>
</template>
<style scoped>
.filters {
margin-bottom: 4px;
/*
* 密集表(设计稿 §09-3):报表要的是信息密度,行高与字号各降半档。
* 只写在页面 scoped 里 —— 写进全局会波及其它列表页的行高。
*/
.dense :deep(.el-table__cell) {
padding: 7px 0;
}
.dense :deep(.el-table__cell .cell) {
font-size: 12.5px;
}
/*
* 金额/比例 span 变块级:行内元素的 text-align 无效,只有块级才真正右对齐;
* 表头保持左对齐(若改用列 align 会连表头一起右移,与设计稿不符)。
*/
.cell-num {
display: block;
}
/* 绩效列加粗:设计稿要求「密集不等于看不清」 */
.cell-strong {
font-weight: 600;
}
.snapshot {
margin: 0;
padding: 10px;
font-size: 12px;
padding: 12px 14px;
font-size: 12.5px;
line-height: 1.6;
background: #f4f5f8;
border-radius: 6px;
background: var(--ys-mist);
border-radius: var(--ys-radius-sm);
white-space: pre-wrap;
word-break: break-all;
}
.negative {
color: #c45656;
color: var(--ys-danger);
}
/* 合计条:比正文加粗、字号放大半档,读者一眼能把「合计」与数据行分开 */
.summary-bar {
margin-top: 10px;
padding: 8px 12px;
font-size: 13px;
background: var(--ys-indigo-wash);
border-radius: 6px;
margin-right: auto;
font-size: 13.5px;
font-weight: 600;
color: var(--ys-ink);
}
.summary-note {
color: var(--ys-ink-3);
font-size: 12px;
font-weight: 400;
color: var(--ys-ink-3);
}
</style>
+418 -306
View File
@@ -171,387 +171,485 @@ onMounted(load)
<template>
<div class="print-page" v-loading="loading">
<div class="no-print toolbar">
<el-button type="primary" @click="doPrint">打印 / 另存为 PDF</el-button>
<span class="toolbar-hint">在打印对话框选择「另存为 PDF」即可导出 PDF</span>
</div>
<header class="sheet-header">
<img v-if="logoUrl" :src="logoUrl" class="logo" alt="公司 logo" />
<div class="company">{{ companyName }}</div>
<h1 class="title">{{ title }}</h1>
<div class="meta">
<span>筛选条件:{{ filterText }}</span>
<span>打印时间:{{ printedAt.toLocaleString('zh-CN') }} | 模板版本 V1.2</span>
<!-- 屏幕操作条:打印时随 .no-print 一起消失,纸面上不出现任何按钮 -->
<div class="no-print print-bar">
<div class="print-bar__crumb">
<b>报表打印</b>
<span class="print-bar__sep">/</span>
<span>{{ title }}</span>
</div>
<div class="print-bar__actions">
<span class="print-bar__hint">在打印对话框选择「另存为 PDF」即可导出</span>
<el-button type="primary" class="print-bar__btn" @click="doPrint">
打印 / 另存为 PDF
</el-button>
</div>
</header>
<div v-if="truncated" class="truncated">
数据量超过打印上限({{ PRINT_MAX_ROWS }} 行),仅打印前 {{ PRINT_MAX_ROWS }}
行;请缩小筛选范围后重新打印。
</div>
<template v-if="code === 'personal-detail'">
<table class="sheet-table">
<thead>
<tr>
<th>合同编号</th><th>发货日</th><th>回款日</th><th>回款天数</th>
<th>产品类别</th><th>客户类型</th><th>岗位</th><th>计提比例</th>
<th>支付权重R</th><th>绩效金额</th><th>应付绩效</th>
</tr>
</thead>
<tbody>
<tr v-for="row in rows" :key="String(row.ledger_id)">
<td>{{ row.business_no }}</td>
<td>{{ row.ship_date }}</td>
<td>{{ row.receipt_date }}</td>
<td class="num">{{ row.payment_days ?? '—' }}</td>
<td>{{ row.product_category }}</td>
<td>{{ row.customer_type }}</td>
<td>{{ row.role_name }}</td>
<td>{{ row.rate_text }}</td>
<td class="num">{{ row.weight_r ?? '—' }}</td>
<td class="num">{{ row.performance_amount ?? '—' }}</td>
<td class="num">{{ row.payable_amount ?? '—' }}</td>
</tr>
</tbody>
</table>
</template>
<!-- 稿纸:与屏幕稿不同源 —— 白底、实线表格、章节编号、签字栏,无圆角与阴影 -->
<div class="sheet">
<header class="sheet-header">
<img v-if="logoUrl" :src="logoUrl" class="logo" alt="公司 logo" />
<div class="company">{{ companyName }}</div>
<h1 class="title">{{ title }}</h1>
<div class="sheet-meta">筛选条件:{{ filterText }}</div>
<div class="sheet-meta">
打印时间:{{ printedAt.toLocaleString('zh-CN') }} | 模板版本 V1.2
</div>
</header>
<template v-else-if="code === 'department-summary' && department">
<h2 class="block-title">一、团队分成汇总</h2>
<table class="sheet-table" data-testid="print-department-teams">
<thead>
<tr>
<th>序号</th><th>经理团队</th><th>状态</th><th>团队人数</th><th>团队基数</th>
<th>团队分成({{ department.params?.team_text ?? '—' }}%)</th>
<th>调整净额(仅核对)</th><th>团队应发小计(含聚合分成)</th><th>月结冻结额</th>
</tr>
</thead>
<tbody>
<tr v-for="(team, index) in department.teams" :key="team.manager_id">
<td>{{ index + 1 }}</td>
<td>{{ team.manager_name }}</td>
<td>{{ team.manager_status === 1 ? '在职' : '离职' }}</td>
<td class="num">{{ team.member_count }}</td>
<td class="num">{{ team.team_base }}</td>
<td class="num">{{ team.team_share_amount }}</td>
<td class="num">{{ team.adjust_net }}</td>
<td class="num">{{ team.team_subtotal }}</td>
<td class="num">{{ team.frozen_amount ?? '—' }}</td>
</tr>
</tbody>
</table>
<div v-if="truncated" class="truncated">
数据量超过打印上限({{ PRINT_MAX_ROWS }} 行),本页只含前 {{ PRINT_MAX_ROWS }}
行;请缩小筛选范围后重新打印。
</div>
<h2 class="block-title">
二、全公司分成(每位有聚合行的经理各计一次,共
{{ department.company?.company_share_count ?? 0 }} 次)
</h2>
<table class="sheet-table" data-testid="print-company-block">
<thead>
<tr>
<th>全公司基数</th><th>分成比例</th><th>单经理分成</th>
<th>计入次数</th><th>分成合计</th>
</tr>
</thead>
<tbody>
<tr>
<td class="num">{{ department.company?.company_base ?? '—' }}</td>
<td class="num">{{ department.params?.company_text ?? '—' }}%</td>
<td class="num">{{ department.company?.company_share_amount ?? '—' }}</td>
<td class="num">{{ department.company?.company_share_count ?? 0 }}</td>
<td class="num">{{ department.company?.company_share_total ?? '—' }}</td>
</tr>
</tbody>
</table>
<h2 class="block-title">三、对账汇总</h2>
<table class="sheet-table" data-testid="print-reconciliation">
<thead>
<tr><th>项目</th><th>金额</th><th>说明</th></tr>
</thead>
<tbody>
<tr>
<td>团队成员应付合计</td>
<td class="num">{{ department.reconciliation?.team_member_payable_total ?? '—' }}</td>
<td />
</tr>
<tr>
<td>未分配人员应付合计</td>
<td class="num">{{ department.reconciliation?.unassigned_payable_total ?? '—' }}</td>
<td />
</tr>
<tr>
<td>团队分成合计(分列)</td>
<td class="num">{{ department.reconciliation?.team_share_total ?? '—' }}</td>
<td />
</tr>
<tr>
<td>
全公司分成合计({{ department.company?.company_share_count ?? 0 }} 次,分列)
</td>
<td class="num">{{ department.reconciliation?.company_share_total ?? '—' }}</td>
<td>每位有聚合行的经理各计一次</td>
</tr>
<tr>
<td>聚合行舍入差</td>
<td class="num">{{ department.reconciliation?.aggregate_rounding_diff ?? '—' }}</td>
<td>分列舍入与聚合行一次舍入的差</td>
</tr>
<tr>
<td>调整净额合计(明细层)</td>
<td class="num">{{ department.reconciliation?.adjust_net_total ?? '—' }}</td>
<td />
</tr>
<tr>
<td>应发合计</td>
<td class="num">{{ department.reconciliation?.payable_total ?? '—' }}</td>
<td>上列各项之和</td>
</tr>
<tr>
<td>团队基数合计</td>
<td class="num">{{ department.reconciliation?.team_base_total ?? '—' }}</td>
<td />
</tr>
<tr>
<td>未分配绩效合计</td>
<td class="num">{{ department.reconciliation?.unassigned_performance_total ?? '—' }}</td>
<td />
</tr>
<tr>
<td>其他未展示基数</td>
<td class="num">{{ department.reconciliation?.other_team_base ?? '—' }}</td>
<td>筛选时含其他团队与全公司未分配</td>
</tr>
<tr>
<td>全公司基数</td>
<td class="num">{{ department.reconciliation?.company_base ?? '—' }}</td>
<td />
</tr>
</tbody>
</table>
<h2 class="block-title">四、团队成员明细</h2>
<table class="sheet-table" data-testid="print-department-members">
<thead>
<tr>
<th>经理团队</th><th>成员</th><th>岗位</th><th>来源</th><th>绩效金额</th>
<th>应付绩效</th><th>调整补发</th><th>调整扣减</th><th>调整净额</th><th>净合计</th>
</tr>
</thead>
<tbody>
<tr
v-for="member in departmentMembers"
:key="`${member.personnel_id}-${member.member_kind}`"
>
<td>{{ member.manager_name }}</td>
<td>{{ member.personnel_name }}</td>
<td>{{ member.role_code }}</td>
<td>{{ memberKindLabel(member.member_kind) }}</td>
<td class="num">{{ member.performance_sum }}</td>
<td class="num">{{ member.payable_sum }}</td>
<td class="num">{{ member.adjust_extra }}</td>
<td class="num">{{ member.adjust_deduct }}</td>
<td class="num">{{ member.adjust_net }}</td>
<td class="num">{{ member.net_total }}</td>
</tr>
</tbody>
</table>
<h2 class="block-title">五、未分配人员明细</h2>
<table class="sheet-table" data-testid="print-unassigned">
<thead>
<tr>
<th>人员</th><th>岗位</th><th>未纳入原因</th><th>绩效金额</th><th>应付绩效</th>
<th>调整补发</th><th>调整扣减</th><th>调整净额</th><th>净合计</th>
</tr>
</thead>
<tbody>
<tr
v-for="member in department.unassigned"
:key="`${member.personnel_id}-${member.member_kind}`"
>
<td>{{ member.personnel_name }}</td>
<td>{{ member.role_code }}</td>
<td>{{ member.unassigned_reason ?? '—' }}</td>
<td class="num">{{ member.performance_sum }}</td>
<td class="num">{{ member.payable_sum }}</td>
<td class="num">{{ member.adjust_extra }}</td>
<td class="num">{{ member.adjust_deduct }}</td>
<td class="num">{{ member.adjust_net }}</td>
<td class="num">{{ member.net_total }}</td>
</tr>
<tr v-if="department.unassigned.length === 0">
<td colspan="9">(无)</td>
</tr>
</tbody>
</table>
</template>
<template v-else-if="code === 'business-stats'">
<table class="sheet-table">
<thead>
<tr>
<th>业务编号</th><th>业务日期</th><th>客户</th><th>归属人</th><th>状态</th>
<th>销售额</th><th>发货额</th><th>回款额</th><th>绩效金额</th><th>应付绩效</th>
</tr>
</thead>
<tbody>
<tr v-for="row in rows" :key="String(row.business_id)">
<td>{{ row.business_no }}</td>
<td>{{ row.business_date }}</td>
<td>{{ row.customer_name }}</td>
<td>{{ row.owner_personnel_name }}</td>
<td>{{ row.status }}</td>
<td class="num">{{ row.total_sale_amount }}</td>
<td class="num">{{ row.total_ship_amount }}</td>
<td class="num">{{ row.total_received_amount }}</td>
<td class="num">{{ row.performance_amount }}</td>
<td class="num">{{ row.payable_amount }}</td>
</tr>
</tbody>
</table>
</template>
<template v-else>
<table class="sheet-table">
<thead>
<tr>
<th>回款天数区间</th><th>回款金额</th><th>回款笔数</th>
<th>绩效金额</th><th>应付绩效</th><th>台账行数</th>
</tr>
</thead>
<tbody>
<tr v-for="row in buckets" :key="String(row.code)">
<td>{{ row.label }}</td>
<td class="num">{{ row.receipt_amount }}</td>
<td class="num">{{ row.receipt_count }}</td>
<td class="num">{{ row.performance_amount }}</td>
<td class="num">{{ row.payable_amount }}</td>
<td class="num">{{ row.ledger_count }}</td>
</tr>
</tbody>
</table>
<div v-for="group in groups" :key="group.title" class="group-block">
<h3>{{ group.title }}</h3>
<template v-if="code === 'personal-detail'">
<table class="sheet-table">
<thead>
<tr><th>维度</th><th>回款金额</th><th>绩效金额</th><th>台账行数</th></tr>
<tr>
<th>合同编号</th><th>发货日</th><th>回款日</th><th class="num">回款天数</th>
<th>产品类别</th><th>客户类型</th><th>岗位</th><th class="num">计提比例</th>
<th class="num">支付权重R</th><th class="num">绩效金额</th><th class="num">应付绩效</th>
</tr>
</thead>
<tbody>
<tr v-for="row in group.rows" :key="String(row.code)">
<tr v-for="row in rows" :key="String(row.ledger_id)">
<td>{{ row.business_no }}</td>
<td>{{ row.ship_date }}</td>
<td>{{ row.receipt_date }}</td>
<td class="num">{{ row.payment_days ?? '—' }}</td>
<td>{{ row.product_category }}</td>
<td>{{ row.customer_type }}</td>
<td>{{ row.role_name }}</td>
<td>{{ row.rate_text }}</td>
<td class="num">{{ row.weight_r ?? '—' }}</td>
<td class="num">{{ row.performance_amount ?? '—' }}</td>
<td class="num">{{ row.payable_amount ?? '—' }}</td>
</tr>
</tbody>
</table>
</template>
<template v-else-if="code === 'department-summary' && department">
<h2 class="block-title">一、团队分成汇总</h2>
<table class="sheet-table" data-testid="print-department-teams">
<thead>
<tr>
<th>序号</th><th>经理团队</th><th>状态</th>
<th class="num">团队人数</th><th class="num">团队基数</th>
<th class="num">团队分成({{ department.params?.team_text ?? '—' }}%)</th>
<th class="num">调整净额(仅核对)</th>
<th class="num">团队应发小计(含聚合分成)</th>
<th class="num">月结冻结额</th>
</tr>
</thead>
<tbody>
<tr v-for="(team, index) in department.teams" :key="team.manager_id">
<td>{{ index + 1 }}</td>
<td>{{ team.manager_name }}</td>
<td>{{ team.manager_status === 1 ? '在职' : '离职' }}</td>
<td class="num">{{ team.member_count }}</td>
<td class="num">{{ team.team_base }}</td>
<td class="num">{{ team.team_share_amount }}</td>
<td class="num">{{ team.adjust_net }}</td>
<td class="num">{{ team.team_subtotal }}</td>
<td class="num">{{ team.frozen_amount ?? '—' }}</td>
</tr>
</tbody>
</table>
<h2 class="block-title">
二、全公司分成(每位有聚合行的经理各计一次,共
{{ department.company?.company_share_count ?? 0 }} 次)
</h2>
<table class="sheet-table" data-testid="print-company-block">
<thead>
<tr>
<th class="num">全公司基数</th><th class="num">分成比例</th>
<th class="num">单经理分成</th>
<th class="num">计入次数</th><th class="num">分成合计</th>
</tr>
</thead>
<tbody>
<tr>
<td class="num">{{ department.company?.company_base ?? '—' }}</td>
<td class="num">{{ department.params?.company_text ?? '—' }}%</td>
<td class="num">{{ department.company?.company_share_amount ?? '—' }}</td>
<td class="num">{{ department.company?.company_share_count ?? 0 }}</td>
<td class="num">{{ department.company?.company_share_total ?? '—' }}</td>
</tr>
</tbody>
</table>
<h2 class="block-title">三、对账汇总</h2>
<table class="sheet-table" data-testid="print-reconciliation">
<thead>
<tr><th>项目</th><th class="num">金额</th><th>说明</th></tr>
</thead>
<tbody>
<tr>
<td>团队成员应付合计</td>
<td class="num">{{ department.reconciliation?.team_member_payable_total ?? '—' }}</td>
<td />
</tr>
<tr>
<td>未分配人员应付合计</td>
<td class="num">{{ department.reconciliation?.unassigned_payable_total ?? '—' }}</td>
<td />
</tr>
<tr>
<td>团队分成合计(分列)</td>
<td class="num">{{ department.reconciliation?.team_share_total ?? '—' }}</td>
<td />
</tr>
<tr>
<td>
全公司分成合计({{ department.company?.company_share_count ?? 0 }} 次,分列)
</td>
<td class="num">{{ department.reconciliation?.company_share_total ?? '—' }}</td>
<td>每位有聚合行的经理各计一次</td>
</tr>
<tr>
<td>聚合行舍入差</td>
<td class="num">{{ department.reconciliation?.aggregate_rounding_diff ?? '—' }}</td>
<td>分列舍入与聚合行一次舍入的差</td>
</tr>
<tr>
<td>调整净额合计(明细层)</td>
<td class="num">{{ department.reconciliation?.adjust_net_total ?? '—' }}</td>
<td />
</tr>
<tr>
<td>应发合计</td>
<td class="num">{{ department.reconciliation?.payable_total ?? '—' }}</td>
<td>上列各项之和</td>
</tr>
<tr>
<td>团队基数合计</td>
<td class="num">{{ department.reconciliation?.team_base_total ?? '—' }}</td>
<td />
</tr>
<tr>
<td>未分配绩效合计</td>
<td class="num">{{ department.reconciliation?.unassigned_performance_total ?? '—' }}</td>
<td />
</tr>
<tr>
<td>其他未展示基数</td>
<td class="num">{{ department.reconciliation?.other_team_base ?? '—' }}</td>
<td>筛选时含其他团队与全公司未分配</td>
</tr>
<tr>
<td>全公司基数</td>
<td class="num">{{ department.reconciliation?.company_base ?? '—' }}</td>
<td />
</tr>
</tbody>
</table>
<h2 class="block-title">四、团队成员明细</h2>
<table class="sheet-table" data-testid="print-department-members">
<thead>
<tr>
<th>经理团队</th><th>成员</th><th>岗位</th><th>来源</th>
<th class="num">绩效金额</th><th class="num">应付绩效</th>
<th class="num">调整补发</th><th class="num">调整扣减</th>
<th class="num">调整净额</th><th class="num">净合计</th>
</tr>
</thead>
<tbody>
<tr
v-for="member in departmentMembers"
:key="`${member.personnel_id}-${member.member_kind}`"
>
<td>{{ member.manager_name }}</td>
<td>{{ member.personnel_name }}</td>
<td>{{ member.role_code }}</td>
<td>{{ memberKindLabel(member.member_kind) }}</td>
<td class="num">{{ member.performance_sum }}</td>
<td class="num">{{ member.payable_sum }}</td>
<td class="num">{{ member.adjust_extra }}</td>
<td class="num">{{ member.adjust_deduct }}</td>
<td class="num">{{ member.adjust_net }}</td>
<td class="num">{{ member.net_total }}</td>
</tr>
</tbody>
</table>
<h2 class="block-title">五、未分配人员明细</h2>
<table class="sheet-table" data-testid="print-unassigned">
<thead>
<tr>
<th>人员</th><th>岗位</th><th>未纳入原因</th>
<th class="num">绩效金额</th><th class="num">应付绩效</th>
<th class="num">调整补发</th><th class="num">调整扣减</th>
<th class="num">调整净额</th><th class="num">净合计</th>
</tr>
</thead>
<tbody>
<tr
v-for="member in department.unassigned"
:key="`${member.personnel_id}-${member.member_kind}`"
>
<td>{{ member.personnel_name }}</td>
<td>{{ member.role_code }}</td>
<td>{{ member.unassigned_reason ?? '—' }}</td>
<td class="num">{{ member.performance_sum }}</td>
<td class="num">{{ member.payable_sum }}</td>
<td class="num">{{ member.adjust_extra }}</td>
<td class="num">{{ member.adjust_deduct }}</td>
<td class="num">{{ member.adjust_net }}</td>
<td class="num">{{ member.net_total }}</td>
</tr>
<tr v-if="department.unassigned.length === 0">
<td colspan="9">(无)</td>
</tr>
</tbody>
</table>
</template>
<template v-else-if="code === 'business-stats'">
<table class="sheet-table">
<thead>
<tr>
<th>业务编号</th><th>业务日期</th><th>客户</th><th>归属人</th><th>状态</th>
<th class="num">销售额</th><th class="num">发货额</th><th class="num">回款额</th>
<th class="num">绩效金额</th><th class="num">应付绩效</th>
</tr>
</thead>
<tbody>
<tr v-for="row in rows" :key="String(row.business_id)">
<td>{{ row.business_no }}</td>
<td>{{ row.business_date }}</td>
<td>{{ row.customer_name }}</td>
<td>{{ row.owner_personnel_name }}</td>
<td>{{ row.status }}</td>
<td class="num">{{ row.total_sale_amount }}</td>
<td class="num">{{ row.total_ship_amount }}</td>
<td class="num">{{ row.total_received_amount }}</td>
<td class="num">{{ row.performance_amount }}</td>
<td class="num">{{ row.payable_amount }}</td>
</tr>
</tbody>
</table>
</template>
<template v-else>
<table class="sheet-table">
<thead>
<tr>
<th>回款天数区间</th><th class="num">回款金额</th><th class="num">回款笔数</th>
<th class="num">绩效金额</th><th class="num">应付绩效</th><th class="num">台账行数</th>
</tr>
</thead>
<tbody>
<tr v-for="row in buckets" :key="String(row.code)">
<td>{{ row.label }}</td>
<td class="num">{{ row.receipt_amount }}</td>
<td class="num">{{ row.receipt_count }}</td>
<td class="num">{{ row.performance_amount }}</td>
<td class="num">{{ row.payable_amount }}</td>
<td class="num">{{ row.ledger_count }}</td>
</tr>
</tbody>
</table>
</div>
</template>
<div v-for="group in groups" :key="group.title" class="group-block">
<h3>{{ group.title }}</h3>
<table class="sheet-table">
<thead>
<tr>
<th>维度</th><th class="num">回款金额</th>
<th class="num">绩效金额</th><th class="num">台账行数</th>
</tr>
</thead>
<tbody>
<tr v-for="row in group.rows" :key="String(row.code)">
<td>{{ row.label }}</td>
<td class="num">{{ row.receipt_amount }}</td>
<td class="num">{{ row.performance_amount }}</td>
<td class="num">{{ row.ledger_count }}</td>
</tr>
</tbody>
</table>
</div>
</template>
<footer class="signature">
<div>财务复核:____________________</div>
<div>领导签字:____________________</div>
<div>日期:______年__月__日</div>
</footer>
<!-- 三栏签字:制表 / 复核 / 审批,栏位用上横线表示手写位置 -->
<footer class="sign-row">
<div>制表:____________</div>
<div>复核:____________</div>
<div>审批:____________</div>
</footer>
</div>
</div>
</template>
<style scoped>
/*
* 打印视图:屏幕上是「灰画布 + 白稿纸」,打印时退化为纯纸面。
* 画布灰比内容页沉一档,让白稿纸一眼是「一张纸」而不是页面里的一个区块。
*/
.print-page {
min-height: 100vh;
padding: 24px;
background: #fff;
color: #1c2333;
padding-bottom: 28px;
background: #eceef2;
color: var(--ys-ink);
}
.toolbar {
/* 屏幕操作条:只在屏幕上出现,纸面上不出现任何按钮 */
.print-bar {
display: flex;
align-items: center;
justify-content: space-between;
height: 52px;
padding: 0 22px;
margin-bottom: 22px;
background: linear-gradient(180deg, #ffffff, #fdfdfe);
border-bottom: 1px solid var(--ys-line);
}
.print-bar__crumb {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--ys-ink-3);
}
.print-bar__crumb b {
font-weight: 600;
color: var(--ys-ink);
}
.print-bar__sep {
color: var(--ys-line);
}
.print-bar__actions {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 20px;
}
.toolbar-hint {
.print-bar__hint {
font-size: 12px;
color: var(--ys-ink-3);
}
/* 顶栏内的按钮压到 30px/12px:与页面顶栏的节奏一致(全局按钮是 34px) */
.print-bar__btn {
height: 30px;
padding: 0 14px;
font-size: 12px;
}
/*
* 稿纸:按 A4 横向版心宽度定宽,屏幕预览与打印同宽 ——
* 两套宽度会让「预览时一页放得下、打出来却折行」。
*/
.sheet {
width: 1040px;
max-width: calc(100% - 44px);
margin: 0 auto;
padding: 26px 30px 30px;
background: #fff;
}
.sheet-header {
margin-bottom: 16px;
text-align: center;
}
.logo {
max-height: 48px;
margin-bottom: 6px;
max-height: 44px;
margin-bottom: 8px;
}
.company {
font-size: 18px;
font-weight: 700;
font-size: 13px;
font-weight: 500;
letter-spacing: 0.18em;
color: var(--ys-ink-2);
}
.title {
margin: 6px 0;
font-size: 20px;
margin: 4px 0 0;
font-size: 15px;
font-weight: 600;
}
.meta {
display: flex;
justify-content: space-between;
font-size: 12px;
color: #5b6577;
.sheet-meta {
margin-top: 4px;
font-size: 11px;
color: var(--ys-ink-3);
}
/* 截断提示:纸面上也要让人一眼看出「这不是全部数据」 */
.truncated {
margin-bottom: 10px;
padding: 8px 12px;
font-size: 12px;
margin-top: 12px;
padding: 6px 10px;
font-size: 11px;
color: #8a5a00;
background: #fdf6e3;
border-radius: 4px;
background: #fbf4e4;
border: 1px solid #f0e2c4;
}
/*
* 稿面表格:纸面口径 —— 11px、细实线 #d9dde5、表头浅底左对齐。
* th 一律左对齐,只有数值列右对齐(与数据行同向),否则表头与数字错位。
*/
.sheet-table {
width: 100%;
margin-top: 8px;
border-collapse: collapse;
font-size: 12px;
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.sheet-table th,
.sheet-table td {
padding: 6px 8px;
border: 1px solid #b0b7c3;
padding: 4px 7px;
text-align: left;
vertical-align: top;
border: 1px solid #d9dde5;
}
.sheet-table th {
background: #dde3f0;
font-weight: 600;
background: #f6f7f9;
font-weight: 500;
color: var(--ys-ink-2);
white-space: nowrap;
}
.sheet-table .num {
text-align: right;
font-variant-numeric: tabular-nums;
}
.block-title {
margin: 18px 0 6px;
font-size: 14px;
margin: 14px 0 6px;
font-size: 12px;
font-weight: 600;
}
.group-block {
margin-top: 18px;
.group-block h3 {
margin: 12px 0 6px;
font-size: 12px;
font-weight: 500;
color: var(--ys-ink-2);
}
.signature {
display: flex;
justify-content: space-between;
margin-top: 40px;
padding-top: 16px;
font-size: 13px;
/* 三栏签字:上横线代替手写下划线,栏宽等分 */
.sign-row {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 18px;
margin-top: 22px;
font-size: 11px;
color: var(--ys-ink-3);
}
.sign-row div {
padding-top: 5px;
border-top: 1px solid var(--ys-line);
}
@media print {
@@ -559,8 +657,17 @@ onMounted(load)
display: none !important;
}
/* 打印时画布与稿纸合并成一张纸:外层灰底、定宽、内距都是屏幕专有 */
.print-page {
padding: 0;
background: #fff;
}
.sheet {
width: auto;
max-width: none;
margin: 0;
padding: 0;
}
@page {
@@ -575,5 +682,10 @@ onMounted(load)
.sheet-table tr {
page-break-inside: avoid;
}
/* 浏览器默认不打印背景色,表头浅底会丢失,这里强制保留 */
.sheet-table th {
print-color-adjust: exact;
}
}
</style>
+292 -84
View File
@@ -7,7 +7,6 @@ import {
getSettlement,
listSettlements,
settlementStatusLabel,
settlementStatusTagType,
submitSettlement,
type Settlement,
type SettlementDetail,
@@ -39,6 +38,39 @@ function resetFilters(): void {
search()
}
/**
* 月结生命周期四段(设计稿 §09-1)。
* 状态条同时是快捷筛选:与下拉读同一个 filters.status,因此下拉变化时
* 状态条自动跟随、点击状态条时下拉也自动跟随,不需要额外同步逻辑;
* 复用 search() 意味着**不新增任何后端请求口径**。
*/
const FLOW_STEPS = [
{ value: 1, label: '草稿', tone: 'draft' },
{ value: 2, label: '待审批', tone: 'wait' },
{ value: 3, label: '已审定(锁定)', tone: 'lock' },
{ value: 4, label: '已驳回', tone: 'reject' },
] as const
/** 点当前段再点一次即取消筛选(idle 态),避免「点了没反应」的死角 */
function pickStatus(status: number): void {
filters.status = filters.status === status ? undefined : status
search()
}
/**
* 状态 → pill 修饰符。
* 草稿是中性态(无修饰符);待审批琥珀、已审定靛蓝、已驳回砖红,
* 与设计稿四档语义一致,颜色之外还有文字,不单靠颜色表意。
*/
function statusPillClass(status: number): string {
return (
({ 1: '', 2: 'ys-pill--wait', 3: 'ys-pill--lock', 4: 'ys-pill--reject' } as Record<
number,
string
>)[status] ?? ''
)
}
const generateMonth = ref('')
const generating = ref(false)
@@ -96,21 +128,51 @@ async function handleSubmit(row: Settlement): Promise<void> {
<template>
<div class="page">
<div class="toolbar">
<div class="gen-row">
<el-input
v-model="generateMonth"
placeholder="结算月份 YYYY-MM"
style="width: 160px"
maxlength="7"
/>
<el-button type="primary" :loading="generating" @click="handleGenerate">
生成月结快照
</el-button>
</div>
<div class="page-head">
<h1 class="page-title">月度结算</h1>
<p class="page-desc">
按月生成绩效结算批次;送审后锁定,人数与金额不再随源数据变化
</p>
<span class="waterline"></span>
</div>
<el-form inline class="filters" @submit.prevent>
<!--
月结生命周期条:用 button 而非 div —— 它可点击、可聚焦,
键盘 Tab + Enter 也能筛选(div 做不到),且不会有默认提交行为。
-->
<div class="flow-step">
<button
v-for="step in FLOW_STEPS"
:key="step.value"
type="button"
class="flow-step__item"
:class="{ 'flow-step__item--cur': filters.status === step.value }"
@click="pickStatus(step.value)"
>
<span class="flow-step__dot" :class="`flow-step__dot--${step.tone}`"></span>
{{ step.label }}
</button>
<span class="flow-step__hint">点状态只看该阶段,再点一次取消</span>
</div>
<!-- 生成行:文案保留「生成月结快照」,对应真实动作(生成的是快照批次) -->
<div class="toolbar">
<el-input
v-model="generateMonth"
placeholder="结算月份 YYYY-MM"
style="width: 160px"
maxlength="7"
/>
<el-button type="primary" :loading="generating" @click="handleGenerate">
生成月结快照
</el-button>
</div>
<!--
.filter-bar 直接挂到 el-form 上:el-form 不能当 flex 透明容器,
外面再包一层 div 会让 el-form-item 的 gap 排版失效。
-->
<el-form class="filter-bar" @submit.prevent>
<el-form-item label="状态">
<el-select v-model="filters.status" placeholder="全部" clearable style="width: 150px">
<el-option label="草稿" :value="1" />
@@ -119,68 +181,102 @@ async function handleSubmit(row: Settlement): Promise<void> {
<el-option label="已驳回" :value="4" />
</el-select>
</el-form-item>
<el-form-item>
<div class="filter-bar__actions">
<el-button type="primary" @click="search">查询</el-button>
<el-button @click="resetFilters">重置</el-button>
</el-form-item>
</div>
</el-form>
<el-table v-loading="loading" :data="records" stripe>
<el-table-column prop="settlement_month" label="结算月份" width="100" />
<el-table-column label="状态" width="130">
<template #default="{ row }">
<el-tag :type="settlementStatusTagType(row.status)" size="small" disable-transitions>
{{ settlementStatusLabel(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="total_performance" label="绩效合计" min-width="110" />
<el-table-column prop="total_payable" label="应付合计" min-width="110" />
<el-table-column prop="person_count" label="人数" width="70" />
<el-table-column prop="approval_order_no" label="审批单号" min-width="150" />
<el-table-column label="提交时间" min-width="150">
<template #default="{ row }">{{ formatDateTime(row.create_time) }}</template>
</el-table-column>
<el-table-column label="操作" width="140" fixed="right" align="center">
<template #default="{ row }">
<div class="table-actions">
<el-button link type="primary" @click="openDetail(row)">详情</el-button>
<el-button
v-if="row.status === 1 || row.status === 4"
link
type="success"
:loading="submitting"
@click="handleSubmit(row)"
>
提交审批
</el-button>
</div>
</template>
</el-table-column>
</el-table>
<div class="card">
<el-table v-loading="loading" :data="records" stripe>
<el-table-column prop="settlement_month" label="结算月份" width="110" />
<el-table-column label="状态" width="150">
<template #default="{ row }">
<span class="ys-pill" :class="statusPillClass(row.status)">
{{ settlementStatusLabel(row.status) }}
</span>
</template>
</el-table-column>
<el-table-column label="绩效合计" min-width="115">
<template #default="{ row }">
<span class="cell-num">{{ row.total_performance ?? '—' }}</span>
</template>
</el-table-column>
<el-table-column label="应付合计" min-width="115">
<template #default="{ row }">
<span class="cell-num">{{ row.total_payable ?? '—' }}</span>
</template>
</el-table-column>
<el-table-column label="人数" width="80">
<template #default="{ row }">
<span class="cell-num">{{ row.person_count }}</span>
</template>
</el-table-column>
<el-table-column label="审批单号" min-width="140">
<template #default="{ row }">
<span v-if="row.approval_order_no" class="cell-code">{{ row.approval_order_no }}</span>
<span v-else class="cell-muted">—</span>
</template>
</el-table-column>
<el-table-column label="提交时间" min-width="150">
<template #default="{ row }">
<span class="cell-muted">{{ formatDateTime(row.create_time) }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="140" fixed="right" align="center">
<template #default="{ row }">
<div class="table-actions">
<el-button link type="primary" @click="openDetail(row)">详情</el-button>
<el-button
v-if="row.status === 1 || row.status === 4"
link
type="success"
:loading="submitting"
@click="handleSubmit(row)"
>
提交审批
</el-button>
</div>
</template>
</el-table-column>
</el-table>
<AppPagination
:total="total"
:page-num="pageNum"
:page-size="pageSize"
@page-change="onPageChange"
@size-change="onSizeChange"
/>
<div class="card-foot">
<AppPagination
:total="total"
:page-num="pageNum"
:page-size="pageSize"
@page-change="onPageChange"
@size-change="onSizeChange"
/>
</div>
</div>
<el-drawer v-model="detailVisible" title="月结详情" size="560px">
<div v-loading="detailLoading">
<template v-if="detail">
<div class="detail-head">
<span class="detail-month">{{ detail.settlement_month }}</span>
<el-tag :type="settlementStatusTagType(detail.status)" size="small" disable-transitions>
<span class="ys-pill" :class="statusPillClass(detail.status)">
{{ settlementStatusLabel(detail.status) }}
</el-tag>
</div>
<div class="detail-meta">
<span>审批单号:{{ detail.approval_order_no || '—' }}</span>
<span>提交人:{{ detail.submitter_name }}</span>
<span v-if="detail.locked_by">锁定人:{{ detail.locked_by }}</span>
</span>
</div>
<p class="section-title section-title--gap">基本信息</p>
<!--
dl.kv 两列网格:值缺省时统一显示 — 而不是整行隐藏,
审批人才能区分「没有这个字段」与「有字段但为空」。
-->
<dl class="kv">
<dt>审批单号</dt>
<dd>{{ detail.approval_order_no || '—' }}</dd>
<dt>提交人</dt>
<dd>{{ detail.submitter_name || '—' }}</dd>
<dt>锁定人</dt>
<dd>{{ detail.locked_by || '—' }}</dd>
</dl>
<p class="section-title section-title--gap">按人明细</p>
<el-table :data="detail.items" size="small" stripe>
<el-table-column prop="personnel_name" label="人员" min-width="90" />
<el-table-column label="类型" width="90">
@@ -188,17 +284,32 @@ async function handleSubmit(row: Settlement): Promise<void> {
{{ row.calc_type === 1 ? '单笔回款' : '经理聚合' }}
</template>
</el-table-column>
<el-table-column prop="performance_sum" label="绩效" min-width="90" />
<el-table-column prop="payable_sum" label="应付" min-width="90" />
<el-table-column prop="net_amount" label="净工资" min-width="90" />
<el-table-column label="绩效" min-width="90">
<template #default="{ row }">
<span class="cell-num">{{ row.performance_sum }}</span>
</template>
</el-table-column>
<el-table-column label="应付" min-width="90">
<template #default="{ row }">
<span class="cell-num">{{ row.payable_sum }}</span>
</template>
</el-table-column>
<el-table-column label="净工资" min-width="90">
<template #default="{ row }">
<span class="cell-num">{{ row.net_amount ?? '—' }}</span>
</template>
</el-table-column>
<el-table-column label="试用期" width="70">
<template #default="{ row }">{{ row.is_probation === 1 ? '是' : '否' }}</template>
</el-table-column>
</el-table>
<p v-if="detail.status === 1 || detail.status === 4" class="hint">
<p v-if="detail.status === 1 || detail.status === 4" class="drawer-hint">
草稿/已驳回状态可重新生成快照后再次提交。
</p>
<p v-else-if="detail.status === 3" class="hint">已锁定:源数据改动只产生提示与调整单。</p>
<p v-else-if="detail.status === 3" class="drawer-hint">
已锁定:源数据改动只产生提示与调整单。
</p>
</template>
</div>
</el-drawer>
@@ -206,19 +317,100 @@ async function handleSubmit(row: Settlement): Promise<void> {
</template>
<style scoped>
.toolbar {
display: flex;
justify-content: flex-end;
margin-bottom: 12px;
/*
* 金额列右对齐:全局 .cell-num 是给 td 设计的,这里只能包 span,
* 行内元素的 text-align 无效,所以把 span 变块级让对齐真正生效;
* 表头保持左对齐,与设计稿一致(列 align="right" 会连表头一起右移)。
*/
.cell-num {
display: block;
}
.gen-row {
/* 生命周期条:横向分段,相邻段共用描边(border-left:0)避免双线 */
.flow-step {
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: center;
margin-bottom: 14px;
}
.filters {
margin-bottom: 4px;
.flow-step__item {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 14px;
font-family: inherit;
font-size: 12.5px;
color: var(--ys-ink-2);
white-space: nowrap;
/* 白底而非设计稿的极淡渐变:色阶靠全局 token 表达,页面不引入新色值 */
background: var(--ys-paper);
border: 1px solid var(--ys-line);
cursor: pointer;
transition:
color 150ms var(--ys-ease),
background-color 150ms var(--ys-ease),
border-color 150ms var(--ys-ease);
}
/* 未选中时四段必须读起来仍是「一条流程」:给右侧挂一行说明,
高度与分段齐平,避免出现四颗孤立小按钮的观感 */
.flow-step__hint {
margin-left: 10px;
font-size: 12px;
color: var(--ys-ink-3);
}
.flow-step__item:first-child {
border-radius: var(--ys-radius-sm) 0 0 var(--ys-radius-sm);
}
.flow-step__item:last-child {
border-radius: 0 var(--ys-radius-sm) var(--ys-radius-sm) 0;
}
.flow-step__item + .flow-step__item {
border-left: 0;
}
.flow-step__item:hover {
color: var(--ys-ink);
}
.flow-step__item:focus-visible {
outline: 2px solid rgba(46, 75, 168, 0.3);
outline-offset: 2px;
}
.flow-step__dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--ys-ink-3);
}
/* 圆点按阶段着色:未选中时也能看出四段各自代表什么(颜色之外仍有文字) */
.flow-step__dot--wait {
background: var(--ys-warn);
}
.flow-step__dot--lock {
background: var(--ys-indigo);
}
.flow-step__dot--reject {
background: var(--ys-danger);
}
.flow-step__item--cur {
font-weight: 500;
color: var(--ys-indigo);
background: var(--ys-indigo-wash);
border-color: rgba(46, 75, 168, 0.3);
}
.flow-step__item--cur .flow-step__dot {
background: var(--ys-indigo);
}
.detail-head {
@@ -228,22 +420,38 @@ async function handleSubmit(row: Settlement): Promise<void> {
}
.detail-month {
font-size: 15px;
font-size: 16px;
font-weight: 600;
letter-spacing: -0.01em;
color: var(--ys-ink);
}
.detail-meta {
display: flex;
flex-direction: column;
gap: 2px;
padding: 10px 0;
/* 分块标题之间需要上间距;不改全局 .section-title,用局部修饰符叠加 */
.section-title--gap {
margin-top: 18px;
}
/* 键值两列:72px 的键列足够放四个汉字,值列自适应余宽(抽屉 560px 内可读) */
.kv {
display: grid;
grid-template-columns: 72px 1fr;
gap: 8px 10px;
margin: 0;
}
.kv dt {
font-size: 12px;
color: var(--ys-ink-3);
}
.hint {
margin-top: 12px;
.kv dd {
margin: 0;
font-size: 13px;
color: var(--ys-ink);
}
.drawer-hint {
margin: 14px 0 0;
font-size: 12px;
color: var(--ys-ink-3);
}
+226 -149
View File
@@ -5,7 +5,6 @@ import { ElMessage } from 'element-plus'
import {
actApproval,
approvalStatusLabel,
approvalStatusTagType,
getApproval,
listApprovals,
orderTypeLabel,
@@ -62,6 +61,18 @@ function resetFilters(): void {
search()
}
/**
* 状态 → pill 修饰符(设计稿 §03 四档语义)。
* 在途单(待财务复核/待领导审核)统一琥珀「待办」;已通过绿、已驳回砖红;
* 已撤回是中性终态,不带修饰符,避免与「待办」抢注意力。
*/
function approvalPillClass(status: number): string {
return (
({ 3: 'ys-pill--ok', 4: 'ys-pill--reject' } as Record<number, string>)[status] ??
(status === 1 || status === 2 ? 'ys-pill--wait' : '')
)
}
// ==================== 详情抽屉(节点时间线) ====================
const detailVisible = ref(false)
@@ -156,7 +167,19 @@ async function submitAction(): Promise<void> {
<template>
<div class="page">
<el-form inline class="filters" @submit.prevent>
<div class="page-head">
<h1 class="page-title">审批管理</h1>
<p class="page-desc">
二次分配方案与月度结算的送审复核;送审内容为快照,与源单据当前值无关
</p>
<span class="waterline"></span>
</div>
<!--
.filter-bar 直接挂到 el-form 上:再包一层 div 会让 el-form-item
失去 gap 排版(el-form 不是 flex 透明容器)。
-->
<el-form class="filter-bar" @submit.prevent>
<el-form-item label="关键词">
<el-input
v-model="filters.keyword"
@@ -182,70 +205,92 @@ async function submitAction(): Promise<void> {
<el-option label="调整单" :value="3" />
</el-select>
</el-form-item>
<el-form-item>
<div class="filter-bar__actions">
<el-button type="primary" @click="search">查询</el-button>
<el-button @click="resetFilters">重置</el-button>
</el-form-item>
</div>
</el-form>
<el-table v-loading="loading" :data="records" stripe>
<el-table-column prop="order_no" label="审批单号" min-width="150" />
<el-table-column label="类型" width="120">
<template #default="{ row }">{{ orderTypeLabel(row.order_type) }}</template>
</el-table-column>
<el-table-column label="版本" width="70" align="center">
<template #default="{ row }">V{{ row.version_no }}</template>
</el-table-column>
<el-table-column prop="refer_no" label="引用单号" min-width="140" />
<el-table-column label="状态" width="110">
<template #default="{ row }">
<el-tag :type="approvalStatusTagType(row.status)" size="small" disable-transitions>
{{ approvalStatusLabel(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="submitter_name" label="提交人" width="110" />
<el-table-column label="提交时间" min-width="150">
<template #default="{ row }">{{ formatDateTime(row.create_time) }}</template>
</el-table-column>
<el-table-column label="操作" width="90" fixed="right" align="center">
<template #default="{ row }">
<el-button link type="primary" @click="openDetail(row)">详情</el-button>
</template>
</el-table-column>
</el-table>
<div class="card">
<el-table v-loading="loading" :data="records" stripe>
<el-table-column label="审批单号" min-width="140">
<template #default="{ row }">
<span class="cell-code">{{ row.order_no }}</span>
</template>
</el-table-column>
<el-table-column label="类型" width="130">
<template #default="{ row }">{{ orderTypeLabel(row.order_type) }}</template>
</el-table-column>
<!-- 版本号与状态同屏:审批人必须能判断自己批的是哪一版,不能藏进 tooltip -->
<el-table-column label="版本" width="80">
<template #default="{ row }">
<span class="cell-num">v{{ row.version_no }}</span>
</template>
</el-table-column>
<el-table-column label="引用单号" min-width="140">
<template #default="{ row }">
<span v-if="row.refer_no" class="cell-code">{{ row.refer_no }}</span>
<span v-else class="cell-muted">—</span>
</template>
</el-table-column>
<el-table-column label="状态" width="130">
<template #default="{ row }">
<span class="ys-pill" :class="approvalPillClass(row.status)">
{{ approvalStatusLabel(row.status) }}
</span>
</template>
</el-table-column>
<el-table-column prop="submitter_name" label="提交人" width="110" />
<el-table-column label="提交时间" min-width="150">
<template #default="{ row }">
<span class="cell-muted">{{ formatDateTime(row.create_time) }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="90" fixed="right" align="center">
<template #default="{ row }">
<el-button link type="primary" @click="openDetail(row)">详情</el-button>
</template>
</el-table-column>
</el-table>
<AppPagination
:total="total"
:page-num="pageNum"
:page-size="pageSize"
@page-change="onPageChange"
@size-change="onSizeChange"
/>
<div class="card-foot">
<AppPagination
:total="total"
:page-num="pageNum"
:page-size="pageSize"
@page-change="onPageChange"
@size-change="onSizeChange"
/>
</div>
</div>
<el-drawer v-model="detailVisible" title="审批详情" size="720px">
<div v-loading="detailLoading">
<template v-if="detail">
<div class="detail-head">
<span class="detail-no">{{ detail.order_no }}</span>
<el-tag
:type="approvalStatusTagType(detail.status)"
size="small"
disable-transitions
>
<div class="panel-title">
<span class="panel-title__no">{{ detail.order_no }}</span>
<span class="ys-pill" :class="approvalPillClass(detail.status)">
{{ approvalStatusLabel(detail.status) }}
</el-tag>
</div>
<div class="detail-meta">
<span>类型:{{ orderTypeLabel(detail.order_type) }} · 版本 V{{ detail.version_no }}</span>
<span>引用单号:{{ detail.refer_no }}</span>
<span>提交人:{{ detail.submitter_name }}</span>
<span>提交时间:{{ formatDateTime(detail.create_time) }}</span>
</span>
</div>
<p class="panel-sub">送审内容为快照,与源单据当前值无关</p>
<dl class="kv">
<dt>类型</dt>
<dd>{{ orderTypeLabel(detail.order_type) }}</dd>
<dt>版本</dt>
<dd>v{{ detail.version_no }}</dd>
<dt>引用单号</dt>
<dd>{{ detail.refer_no || '—' }}</dd>
<dt>提交人</dt>
<dd>{{ detail.submitter_name }}</dd>
<dt>提交时间</dt>
<dd>{{ formatDateTime(detail.create_time) }}</dd>
</dl>
<!-- 审批内容:优先提交时快照;历史单缺快照时读当前并显式标注 -->
<div class="content-section">
<div class="content-title">审批内容</div>
<p class="section-title">审批内容</p>
<!-- 采集时间让复核者明确「看到的是哪个时点的内容」:快照=送审时刻;
live=打开详情时的当前内容(不做时刻展示——渲染函数里的
new Date() 非响应式且 toISOString 是 UTC,会差 8 小时) -->
@@ -271,30 +316,26 @@ async function submitAction(): Promise<void> {
/>
<template v-if="allocationData">
<el-descriptions :column="2" border size="small" class="content-desc">
<el-descriptions-item label="业务编号">
{{ allocationData.business_no }}
</el-descriptions-item>
<el-descriptions-item label="业务日期">
{{ allocationData.business_date ?? '—' }}
</el-descriptions-item>
<el-descriptions-item label="客户名称">
{{ allocationData.customer_name ?? '—' }}
</el-descriptions-item>
<el-descriptions-item label="负责人员">
{{ allocationData.owner_personnel_name ?? '(待指定)' }}
</el-descriptions-item>
<el-descriptions-item label="销售额(元)">
{{ allocationData.sale_amount ?? '—' }}
</el-descriptions-item>
<el-descriptions-item label="成本(元)">
{{ allocationData.cost_amount ?? '—' }}
</el-descriptions-item>
</el-descriptions>
<dl class="kv">
<dt>业务编号</dt>
<dd>{{ allocationData.business_no }}</dd>
<dt>业务日期</dt>
<dd>{{ allocationData.business_date ?? '—' }}</dd>
<dt>客户名称</dt>
<dd>{{ allocationData.customer_name ?? '—' }}</dd>
<dt>负责人员</dt>
<dd>{{ allocationData.owner_personnel_name ?? '(待指定)' }}</dd>
<dt>销售额</dt>
<dd>{{ allocationData.sale_amount ? `${allocationData.sale_amount} 元` : '—' }}</dd>
<dt>成本</dt>
<dd>{{ allocationData.cost_amount ? `${allocationData.cost_amount} 元` : '—' }}</dd>
</dl>
<el-table :data="allocationData.allocations" stripe size="small" class="content-table">
<el-table-column prop="personnel_name" label="参与业务员" min-width="160" />
<el-table-column label="分配比例" width="120" align="right">
<template #default="{ row }">{{ row.ratio }}%</template>
<el-table-column label="分配比例" width="120">
<template #default="{ row }">
<span class="cell-num">{{ row.ratio }}%</span>
</template>
</el-table-column>
<template #empty>
<span class="content-empty">未设置二次分配 —— 全部绩效归负责营销人员</span>
@@ -307,60 +348,63 @@ async function submitAction(): Promise<void> {
</template>
<template v-else-if="settlementData">
<el-descriptions :column="2" border size="small" class="content-desc">
<el-descriptions-item label="结算月份">
{{ settlementData.settlement_month }}
</el-descriptions-item>
<el-descriptions-item label="结算人数">
{{ settlementData.person_count }}
</el-descriptions-item>
<el-descriptions-item label="绩效合计(元)">
{{ settlementData.total_performance ?? '—' }}
</el-descriptions-item>
<el-descriptions-item label="应付合计(元)">
{{ settlementData.total_payable ?? '—' }}
</el-descriptions-item>
</el-descriptions>
<dl class="kv">
<dt>结算月份</dt>
<dd>{{ settlementData.settlement_month }}</dd>
<dt>核算人数</dt>
<dd>{{ settlementData.person_count }} 人</dd>
<dt>绩效合计</dt>
<dd>
{{
settlementData.total_performance ? `${settlementData.total_performance} 元` : '—'
}}
</dd>
<dt>应付合计</dt>
<dd>
{{ settlementData.total_payable ? `${settlementData.total_payable} 元` : '—' }}
</dd>
</dl>
<el-table :data="settlementData.items" stripe size="small" class="content-table">
<el-table-column prop="personnel_name" label="人员" min-width="110" />
<el-table-column prop="role_code" label="岗位" width="110" />
<el-table-column label="绩效(元)" width="110" align="right">
<template #default="{ row }">{{ row.performance_sum }}</template>
<el-table-column label="绩效(元)" width="120">
<template #default="{ row }">
<span class="cell-num">{{ row.performance_sum }}</span>
</template>
</el-table-column>
<el-table-column label="应付(元)" width="110" align="right">
<template #default="{ row }">{{ row.payable_sum }}</template>
<el-table-column label="应付(元)" width="120">
<template #default="{ row }">
<span class="cell-num">{{ row.payable_sum }}</span>
</template>
</el-table-column>
<el-table-column label="净工资(元)" width="120" align="right">
<template #default="{ row }">{{ row.net_amount ?? '—' }}</template>
<el-table-column label="净工资(元)" width="120">
<template #default="{ row }">
<span class="cell-num">{{ row.net_amount ?? '—' }}</span>
</template>
</el-table-column>
</el-table>
</template>
<template v-else-if="adjustmentData">
<el-descriptions :column="2" border size="small" class="content-desc">
<el-descriptions-item label="调整单号">
{{ adjustmentData.order_no }}
</el-descriptions-item>
<el-descriptions-item label="调整月份">
{{ adjustmentData.settlement_month }}
</el-descriptions-item>
<el-descriptions-item label="调整人员">
{{ adjustmentData.personnel_name }}
</el-descriptions-item>
<el-descriptions-item label="方向">
{{ adjustmentData.direction === 1 ? '补发' : '扣减' }}
</el-descriptions-item>
<el-descriptions-item label="金额(元)">
{{ adjustmentData.amount }}
</el-descriptions-item>
<el-descriptions-item label="扣补说明">
{{ adjustmentData.reason }}
</el-descriptions-item>
</el-descriptions>
<dl class="kv">
<dt>调整单号</dt>
<dd>{{ adjustmentData.order_no }}</dd>
<dt>调整月份</dt>
<dd>{{ adjustmentData.settlement_month }}</dd>
<dt>调整人员</dt>
<dd>{{ adjustmentData.personnel_name }}</dd>
<dt>方向</dt>
<dd>{{ adjustmentData.direction === 1 ? '补发' : '扣减' }}</dd>
<dt>金额</dt>
<dd>{{ adjustmentData.amount }} 元</dd>
<dt>扣补说明</dt>
<dd>{{ adjustmentData.reason }}</dd>
</dl>
</template>
</div>
<!-- 签名元素:竖排节点时间线(财务复核 → 领导审核的处理链) -->
<p class="section-title section-title--gap">处理记录</p>
<div class="timeline">
<div
v-for="step in detail.steps"
@@ -373,7 +417,10 @@ async function submitAction(): Promise<void> {
class="timeline-dot"
:class="{ 'is-auto': step.action === 3, 'is-reject': step.action === 2 }"
></span>
<span v-if="step.id !== detail.steps[detail.steps.length - 1].id" class="timeline-line"></span>
<span
v-if="step.id !== detail.steps[detail.steps.length - 1].id"
class="timeline-line"
></span>
</div>
<div class="timeline-body">
<div class="timeline-head">
@@ -396,7 +443,6 @@ async function submitAction(): Promise<void> {
<div class="action-bar">
<el-button
type="primary"
size="small"
:loading="actionSubmitting && actionForm.action === 1"
@click="openAction(detail, 1)"
>
@@ -404,16 +450,17 @@ async function submitAction(): Promise<void> {
</el-button>
<el-button
type="danger"
plain
size="small"
:loading="actionSubmitting && actionForm.action === 2"
@click="openAction(detail, 2)"
>
驳回
</el-button>
</div>
<p class="action-hint">驳回需写明原因,将与审批记录一并留痕</p>
<el-form v-if="actingId === detail.id" label-position="top" class="action-form">
<el-form-item :label="actionForm.action === 2 ? '驳回意见(必填)' : '审批意见(选填)'">
<el-form-item
:label="actionForm.action === 2 ? '驳回意见(必填)' : '审批意见(选填)'"
>
<el-input
v-model="actionForm.opinion"
type="textarea"
@@ -435,58 +482,77 @@ async function submitAction(): Promise<void> {
</template>
<style scoped>
.filters {
margin-bottom: 4px;
/* 版本/比例等数值列:全局 .cell-num 是给 td 设计的,行内 span 的
text-align 不生效,变块级才能右对齐(表头仍左对齐,与设计稿一致)。 */
.cell-num {
display: block;
}
.detail-head {
/* 抽屉头部:单号与状态同屏,标题 15px/600 与卡片标题同级 */
.panel-title {
display: flex;
align-items: center;
gap: 10px;
}
.detail-no {
.panel-title__no {
font-size: 15px;
font-weight: 600;
letter-spacing: -0.01em;
color: var(--ys-ink);
}
.detail-meta {
display: flex;
flex-direction: column;
gap: 2px;
padding: 10px 0 4px;
.panel-sub {
margin: 4px 0 0;
font-size: 12px;
color: var(--ys-ink-3);
}
.content-section {
margin-top: 12px;
padding: 12px;
background: #f7f8fa;
border: 1px solid var(--ys-line);
border-radius: 8px;
/* 键值两列:72px 键列放得下四个汉字,值列自适应余宽 */
.kv {
display: grid;
grid-template-columns: 72px 1fr;
gap: 8px 10px;
margin: 14px 0 0;
}
.content-title {
margin-bottom: 10px;
.kv dt {
font-size: 12px;
color: var(--ys-ink-3);
}
.kv dd {
margin: 0;
font-size: 13px;
font-weight: 600;
color: var(--ys-ink);
}
/* 审批内容块内的 kv 紧跟 .section-title,由标题的下边距负责间距,不再叠加 */
.content-section .kv {
margin-top: 0;
}
/* 分块标题之间补上间距;不改全局 .section-title,用局部修饰符叠加 */
.section-title--gap {
margin-top: 18px;
}
.content-section {
margin-top: 16px;
padding: 14px 16px;
background: var(--ys-mist);
border: 1px solid var(--ys-line);
border-radius: var(--ys-radius-sm);
}
.content-captured {
margin-bottom: 10px;
font-size: 12px;
color: var(--ys-ink-2);
}
.content-desc {
margin-bottom: 10px;
}
.content-table {
margin-top: 8px;
margin-top: 10px;
}
.content-total {
@@ -502,7 +568,7 @@ async function submitAction(): Promise<void> {
/* 竖排时间线:左侧轨道 + 节点圆点,动作色区分人工/自动/驳回 */
.timeline {
margin-top: 14px;
margin-top: 12px;
}
.timeline-row {
@@ -531,14 +597,14 @@ async function submitAction(): Promise<void> {
}
.timeline-dot.is-reject {
background: #c0392b;
background: var(--ys-danger);
}
.timeline-line {
flex: 1;
width: 1px;
min-height: 26px;
background: rgba(28, 35, 51, 0.14);
background: var(--ys-line);
}
.timeline-body {
@@ -563,7 +629,7 @@ async function submitAction(): Promise<void> {
}
.timeline-action.is-reject-text {
color: #c0392b;
color: var(--ys-danger);
}
.timeline-meta {
@@ -577,8 +643,8 @@ async function submitAction(): Promise<void> {
padding: 6px 10px;
font-size: 12px;
color: var(--ys-ink);
background: #f4f5f8;
border-radius: 6px;
background: var(--ys-mist);
border-radius: var(--ys-radius-sm);
}
.timeline-empty {
@@ -590,13 +656,24 @@ async function submitAction(): Promise<void> {
color: var(--ys-ink-3);
}
/* 通过/驳回并排等宽:两个动作权重相同,审批人不必在大小按钮间做偏向性选择 */
.action-bar {
display: flex;
gap: 10px;
padding-top: 6px;
padding-top: 14px;
border-top: 1px solid var(--ys-line);
}
.action-bar :deep(.el-button) {
flex: 1;
}
.action-hint {
margin: 8px 0 0;
font-size: 12px;
color: var(--ys-ink-3);
}
.action-form {
margin-top: 12px;
}
@@ -207,42 +207,62 @@ onMounted(() => {
</script>
<template>
<div>
<el-card shadow="never">
<el-form class="filter-form" :inline="true" @submit.prevent>
<el-form-item label="关键词">
<el-input
v-model="filters.keyword"
placeholder="岗位编码或名称"
clearable
style="width: 180px"
@keyup.enter="search"
<div class="page">
<!-- 页面头:点明岗位是「公式模板 + 参数」的绑定关系,改它会牵动后续核算口径 -->
<div class="page-head">
<h1 class="page-title">岗位角色</h1>
<p class="page-desc">
决定人员适用哪套计提公式与参数;新增岗位只需选模板,不用改代码,改动只影响之后的核算。
</p>
<div class="waterline"></div>
</div>
<!--
筛选条:flex 与间距由 .filter-bar 提供,去掉 el-form 的 inline(inline 的行内块
排布会与 gap 叠成不等距);查询/重置由 .filter-bar__actions 推到行尾。
-->
<el-form class="filter-bar" @submit.prevent>
<el-form-item label="关键词">
<el-input
v-model="filters.keyword"
placeholder="岗位编码或名称"
clearable
style="width: 180px"
@keyup.enter="search"
/>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="filters.status" placeholder="全部" clearable style="width: 110px">
<el-option
v-for="item in STATUS_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="filters.status" placeholder="全部" clearable style="width: 110px">
<el-option
v-for="item in STATUS_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="search">查询</el-button>
<el-button @click="resetFilters">重置</el-button>
</el-form-item>
</el-form>
<div class="toolbar">
<el-button type="primary" @click="openCreate">新增岗位</el-button>
</el-select>
</el-form-item>
<div class="filter-bar__actions">
<el-button type="primary" @click="search">查询</el-button>
<el-button @click="resetFilters">重置</el-button>
</div>
</el-form>
<el-table v-loading="loading" :data="records" stripe>
<!-- 主操作行:新增岗位单独成行,与筛选动作分开 -->
<div class="toolbar">
<el-button type="primary" @click="openCreate">新增岗位</el-button>
</div>
<div class="card">
<el-table
v-loading="loading"
:data="records"
stripe
empty-text="还没有岗位角色,点「新增岗位」创建第一个"
>
<el-table-column prop="role_code" label="岗位编码" min-width="130">
<template #default="{ row }">
<span class="role-code">{{ row.role_code }}</span>
<!-- 岗位编码是参数维度值与模板绑定的锚点,等宽字体便于逐字符核对 -->
<span class="cell-code">{{ row.role_code }}</span>
</template>
</el-table-column>
<el-table-column prop="role_name" label="岗位名称" min-width="110" />
@@ -252,8 +272,11 @@ onMounted(() => {
<el-table-column prop="description" label="说明" min-width="200">
<template #default="{ row }">{{ row.description || '—' }}</template>
</el-table-column>
<el-table-column prop="sort_order" label="排序" width="70" align="center" />
<el-table-column label="状态" width="90" align="center">
<!-- 排序是用于比较的数字:右对齐 + tabular-nums,纵向扫读时数位对齐 -->
<el-table-column label="排序" width="70" align="right">
<template #default="{ row }"><span class="cell-num">{{ row.sort_order }}</span></template>
</el-table-column>
<el-table-column label="状态" width="96" align="center">
<template #default="{ row }">
<StatusTag :value="row.status" />
</template>
@@ -290,14 +313,16 @@ onMounted(() => {
</el-table-column>
</el-table>
<AppPagination
:total="total"
:page-num="pageNum"
:page-size="pageSize"
@page-change="onPageChange"
@size-change="onSizeChange"
/>
</el-card>
<div class="card-foot">
<AppPagination
:total="total"
:page-num="pageNum"
:page-size="pageSize"
@page-change="onPageChange"
@size-change="onSizeChange"
/>
</div>
</div>
<!-- 新增 / 编辑 -->
<el-dialog
@@ -355,12 +380,7 @@ onMounted(() => {
</template>
<style scoped>
.role-code {
font-family: 'SFMono-Regular', Consolas, 'Courier New', monospace;
font-size: 12px;
color: var(--ys-ink-2);
}
/* 弹窗里「所选模板的说明与参数清单」:靛蓝浅底把这段提示与表单控件区分开 */
.tpl-info {
width: 100%;
padding: 8px 12px;
@@ -371,7 +391,9 @@ onMounted(() => {
line-height: 1.7;
}
/* 参数清单比说明再弱一档,读者先看模板用途再看它要配哪些参数 */
.tpl-params {
color: var(--ys-ink-3);
}
</style>
+147 -104
View File
@@ -170,11 +170,24 @@ watch(activeTab, (tab) => {
</script>
<template>
<el-card shadow="never">
<div class="page">
<!-- 页面头:说明日志是只读取证材料,并点明保留期由后端控制 -->
<div class="page-head">
<h1 class="page-title">日志查询</h1>
<p class="page-desc">
检索操作与登录痕迹,用于追责与排障;日志只读,超出保留期的记录由后端自动清理。
</p>
<div class="waterline"></div>
</div>
<el-tabs v-model="activeTab">
<!-- 操作日志 -->
<el-tab-pane label="操作日志" name="operation" lazy>
<el-form class="filter-form" :inline="true" @submit.prevent>
<!--
筛选条:flex 排布与间距由 .filter-bar 提供,故 el-form 去掉 inline;
查询/重置收进 .filter-bar__actions,由 margin-left:auto 推到行尾。
-->
<el-form class="filter-bar" @submit.prevent>
<el-form-item label="操作人">
<el-input
v-model="opFilters.username"
@@ -215,77 +228,85 @@ watch(activeTab, (tab) => {
:default-time="RANGE_DEFAULT_TIME"
/>
</el-form-item>
<el-form-item>
<div class="filter-bar__actions">
<el-button type="primary" @click="searchOperationLogs">查询</el-button>
<el-button @click="resetOpFilters">重置</el-button>
</el-form-item>
</div>
</el-form>
<el-table
v-loading="opLoading"
:data="opRecords"
stripe
@expand-change="onExpandChange"
>
<el-table-column type="expand">
<template #default="{ row }">
<div class="log-detail">
<p>
<span class="log-detail__label">请求:</span>
{{ row.method || '—' }} {{ row.path || '—' }}
</p>
<p>
<span class="log-detail__label">User-Agent:</span>
{{ row.user_agent || '—' }}
</p>
<p class="log-detail__label">请求参数(已脱敏):</p>
<pre class="log-detail__data">{{ detailText(row.id) }}</pre>
</div>
</template>
</el-table-column>
<el-table-column label="时间" min-width="165">
<template #default="{ row }">{{ formatDateTime(row.create_time) }}</template>
</el-table-column>
<el-table-column prop="username" label="操作人" min-width="110" />
<el-table-column prop="module" label="模块" width="100" />
<el-table-column prop="action" label="操作" width="90" />
<el-table-column prop="summary" label="摘要" min-width="200" show-overflow-tooltip />
<el-table-column label="结果" width="80" align="center">
<template #default="{ row }">
<el-tooltip
:content="row.error_message ?? ''"
:disabled="row.success !== 0 || !row.error_message"
placement="top"
>
<StatusTag
:value="row.success"
active-text="成功"
inactive-text="失败"
inactive-type="danger"
/>
</el-tooltip>
</template>
</el-table-column>
<el-table-column label="耗时(ms)" width="90" align="right">
<template #default="{ row }">{{ row.duration_ms ?? '—' }}</template>
</el-table-column>
<el-table-column prop="ip" label="IP" min-width="120">
<template #default="{ row }">{{ row.ip || '—' }}</template>
</el-table-column>
</el-table>
<div class="card">
<el-table
v-loading="opLoading"
:data="opRecords"
stripe
empty-text="这段时间还没有操作记录,试试放宽筛选条件"
@expand-change="onExpandChange"
>
<el-table-column type="expand">
<template #default="{ row }">
<div class="log-detail">
<p>
<span class="log-detail__label">请求:</span>
{{ row.method || '—' }} {{ row.path || '—' }}
</p>
<p>
<span class="log-detail__label">User-Agent:</span>
{{ row.user_agent || '—' }}
</p>
<p class="log-detail__label">请求参数(已脱敏):</p>
<pre class="log-detail__data">{{ detailText(row.id) }}</pre>
</div>
</template>
</el-table-column>
<el-table-column label="时间" min-width="165">
<template #default="{ row }">{{ formatDateTime(row.create_time) }}</template>
</el-table-column>
<el-table-column prop="username" label="操作人" min-width="110" />
<el-table-column prop="module" label="模块" width="100" />
<el-table-column prop="action" label="操作" width="90" />
<el-table-column prop="summary" label="摘要" min-width="200" show-overflow-tooltip />
<el-table-column label="结果" width="96" align="center">
<template #default="{ row }">
<el-tooltip
:content="row.error_message ?? ''"
:disabled="row.success !== 0 || !row.error_message"
placement="top"
>
<StatusTag
:value="row.success"
active-text="成功"
inactive-text="失败"
inactive-type="danger"
/>
</el-tooltip>
</template>
</el-table-column>
<!-- 耗时是用于比较的数字:右对齐 + tabular-nums,慢请求一眼能扫出来 -->
<el-table-column label="耗时(ms)" width="90" align="right">
<template #default="{ row }">
<span class="cell-num">{{ row.duration_ms ?? '—' }}</span>
</template>
</el-table-column>
<el-table-column prop="ip" label="IP" min-width="120">
<template #default="{ row }">{{ row.ip || '—' }}</template>
</el-table-column>
</el-table>
<AppPagination
:total="opTotal"
:page-num="opPageNum"
:page-size="opPageSize"
@page-change="onOpPageChange"
@size-change="onOpSizeChange"
/>
<div class="card-foot">
<AppPagination
:total="opTotal"
:page-num="opPageNum"
:page-size="opPageSize"
@page-change="onOpPageChange"
@size-change="onOpSizeChange"
/>
</div>
</div>
</el-tab-pane>
<!-- 登录日志 -->
<el-tab-pane label="登录日志" name="login" lazy>
<el-form class="filter-form" :inline="true" @submit.prevent>
<el-form class="filter-bar" @submit.prevent>
<el-form-item label="用户名">
<el-input
v-model="loginFilters.username"
@@ -305,7 +326,12 @@ watch(activeTab, (tab) => {
/>
</el-form-item>
<el-form-item label="结果">
<el-select v-model="loginFilters.success" placeholder="全部" clearable style="width: 110px">
<el-select
v-model="loginFilters.success"
placeholder="全部"
clearable
style="width: 110px"
>
<el-option
v-for="item in RESULT_OPTIONS"
:key="item.value"
@@ -325,62 +351,77 @@ watch(activeTab, (tab) => {
:default-time="RANGE_DEFAULT_TIME"
/>
</el-form-item>
<el-form-item>
<div class="filter-bar__actions">
<el-button type="primary" @click="searchLoginLogs">查询</el-button>
<el-button @click="resetLoginFilters">重置</el-button>
</el-form-item>
</div>
</el-form>
<el-table v-loading="loginLoading" :data="loginRecords" stripe>
<el-table-column label="时间" min-width="165">
<template #default="{ row }">{{ formatDateTime(row.create_time) }}</template>
</el-table-column>
<el-table-column prop="username" label="用户名" min-width="130" />
<el-table-column label="结果" width="80" align="center">
<template #default="{ row }">
<StatusTag
:value="row.success"
active-text="成功"
inactive-text="失败"
inactive-type="danger"
/>
</template>
</el-table-column>
<el-table-column prop="fail_reason" label="失败原因" min-width="150">
<template #default="{ row }">{{ row.fail_reason || '—' }}</template>
</el-table-column>
<el-table-column prop="ip" label="IP" min-width="120">
<template #default="{ row }">{{ row.ip || '—' }}</template>
</el-table-column>
<el-table-column label="User-Agent" min-width="200">
<template #default="{ row }">
<span class="ua-cell" :title="row.user_agent || ''">{{ row.user_agent || '—' }}</span>
</template>
</el-table-column>
</el-table>
<div class="card">
<el-table
v-loading="loginLoading"
:data="loginRecords"
stripe
empty-text="这段时间还没有登录记录,试试放宽筛选条件"
>
<el-table-column label="时间" min-width="165">
<template #default="{ row }">{{ formatDateTime(row.create_time) }}</template>
</el-table-column>
<el-table-column prop="username" label="用户名" min-width="130" />
<el-table-column label="结果" width="96" align="center">
<template #default="{ row }">
<StatusTag
:value="row.success"
active-text="成功"
inactive-text="失败"
inactive-type="danger"
/>
</template>
</el-table-column>
<el-table-column prop="fail_reason" label="失败原因" min-width="150">
<template #default="{ row }">{{ row.fail_reason || '—' }}</template>
</el-table-column>
<el-table-column prop="ip" label="IP" min-width="120">
<template #default="{ row }">{{ row.ip || '—' }}</template>
</el-table-column>
<el-table-column label="User-Agent" min-width="200">
<template #default="{ row }">
<span class="ua-cell" :title="row.user_agent || ''">{{ row.user_agent || '—' }}</span>
</template>
</el-table-column>
</el-table>
<AppPagination
:total="loginTotal"
:page-num="loginPageNum"
:page-size="loginPageSize"
@page-change="onLoginPageChange"
@size-change="onLoginSizeChange"
/>
<div class="card-foot">
<AppPagination
:total="loginTotal"
:page-num="loginPageNum"
:page-size="loginPageSize"
@page-change="onLoginPageChange"
@size-change="onLoginSizeChange"
/>
</div>
</div>
</el-tab-pane>
</el-tabs>
</el-card>
</div>
</template>
<style scoped>
/*
* 展开行面板:表格单元格 padding 由全局统一为 12px 16px,这里再套一层容器,
* 所以上下压到 4px 即可,否则展开区会明显比行内容松散;文字用次级墨色,
* 与主行(--ys-ink)区分开,读起来是「附注」而非新的一条记录。
*/
.log-detail {
padding: 4px 16px;
color: var(--ys-text);
color: var(--ys-ink-2);
}
.log-detail__label {
font-weight: 600;
}
/* 脱敏后的 JSON 参数按等宽排版才能看出字段边界;边框用全局线色,避免与主题脱钩 */
.log-detail__data {
padding: 8px 12px;
margin: 6px 0 12px;
@@ -388,10 +429,11 @@ watch(activeTab, (tab) => {
font-family: Consolas, 'Courier New', monospace;
font-size: 12px;
background-color: var(--ys-paper);
border: 1px solid #e5e7eb;
border: 1px solid var(--ys-line);
border-radius: 4px;
}
/* UA 串很长,单行省略 + title 悬浮看全文,避免撑高行或被裁成半截 */
.ua-cell {
display: inline-block;
max-width: 100%;
@@ -400,4 +442,5 @@ watch(activeTab, (tab) => {
white-space: nowrap;
vertical-align: bottom;
}
</style>
+189 -133
View File
@@ -99,9 +99,24 @@ function valueLabel(value: string | null | undefined): string {
return ENUM_VALUE_LABELS[value] ?? value
}
/** 带单位的值展示 */
/** 带单位的值展示:枚举与开关先翻成中文,其余原样加单位 */
function formatValue(item: ParamCatalogItem, value: string): string {
return item.unit ? `${value} ${item.unit}` : value
// 枚举/开关的原始值是 INCLUDE、FULL、1、0 —— 直接摆给业务人员看不懂,
// 数字型(比例、天数、金额)保持原样,只补单位
const shown =
item.value_type === 'ENUM' || item.value_type === 'BOOL' ? valueLabel(value) : value
return item.unit ? `${shown} ${item.unit}` : shown
}
/**
* 按参数编码格式化取值:变更记录 / 版本历史 / 快照对比拿到的都是裸 value_text,
* 没有 value_type 与 unit,只能回目录里按编码查定义再格式化。
* 目录还没加载完或编码已下线时原样返回,宁可显示原文也不显示空白。
*/
function formatByCode(code: string, value: string | null | undefined): string {
if (value === null || value === undefined || value === '') return '—'
const item = catalog.value.find((entry) => entry.param_code === code)
return item ? formatValue(item, value) : value
}
// ==================== 修改(待提交) ====================
@@ -431,94 +446,104 @@ onMounted(() => {
</script>
<template>
<div>
<el-card shadow="never">
<div class="page-toolbar">
<span class="page-desc">
计提比例、系数、阈值、分成与起付比例在此统一维护;变更逐项留痕并支持按日期生效。
</span>
<span>
<el-button @click="openPreviewDrawer">计算预览</el-button>
<el-button @click="openVersionDrawer">版本管理</el-button>
<el-button @click="doExport" :loading="exporting">导出记录</el-button>
</span>
<div class="page">
<!-- 页面头:这句描述原本挤在工具栏左侧当说明,现在归位成页面描述,语义更清楚 -->
<div class="page-head">
<h1 class="page-title">计提参数</h1>
<p class="page-desc">
计提比例、系数、阈值、分成与起付比例都在这里统一维护;每次改动留下旧值,可以指定从哪天起生效。
</p>
<div class="waterline"></div>
</div>
<!--
主操作行:这页没有筛选条件(参数按分组浏览而非检索),所以只有工具按钮。
「计算预览」是管理员最常走的一步,用主按钮把它与另两个次要动作区分开。
-->
<div class="toolbar">
<el-button type="primary" @click="openPreviewDrawer">计算预览</el-button>
<el-button @click="openVersionDrawer">版本管理</el-button>
<el-button @click="doExport" :loading="exporting">导出记录</el-button>
</div>
<div class="card">
<div class="card-body">
<el-collapse v-loading="catalogLoading">
<el-collapse-item v-for="group in groupedCatalog" :key="group.name" :name="group.name">
<template #title>
<span class="group-title">{{ group.name }}</span>
</template>
<table class="param-table">
<thead>
<tr>
<th style="width: 190px">参数</th>
<th>当前生效值</th>
<th style="width: 110px">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="item in group.items" :key="item.param_code">
<td>
<div class="param-name">{{ item.param_name }}</div>
<div class="param-desc">{{ item.description || item.param_code }}</div>
</td>
<td>
<template v-if="item.values.length">
<div v-for="v in item.values" :key="v.dimension_value" class="value-row">
<span class="dim-label">{{ dimensionLabel(v.dimension_value) }}</span>
<b class="num">{{ formatValue(item, v.value_text) }}</b>
<span class="eff">{{ v.effective_date }} 起</span>
<el-tag
v-if="pendingOf(item, v.dimension_value)"
size="small"
type="warning"
disable-transitions
>
待改为 {{ formatValue(item, pendingOf(item, v.dimension_value)!.new_value) }}
</el-tag>
<el-button
v-if="isAdmin"
link
type="primary"
size="small"
@click="openEdit(item, v.dimension_value)"
>
改
</el-button>
</div>
</template>
<span v-else class="no-value">
未设置(默认 {{ formatByCode(item.param_code, item.default_value) }})
</span>
</td>
<td>
<el-button
v-if="isAdmin && !item.values.length"
link
type="primary"
size="small"
@click="openEdit(item, '')"
>
设置
</el-button>
<el-button link type="primary" size="small" @click="openHistory(item)">历史</el-button>
</td>
</tr>
</tbody>
</table>
</el-collapse-item>
</el-collapse>
</div>
</div>
<el-collapse v-loading="catalogLoading">
<el-collapse-item v-for="group in groupedCatalog" :key="group.name" :name="group.name">
<template #title>
<span class="group-title">{{ group.name }}</span>
</template>
<table class="param-table">
<thead>
<tr>
<th style="width: 190px">参数</th>
<th>当前生效值</th>
<th style="width: 110px">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="item in group.items" :key="item.param_code">
<td>
<div class="param-name">{{ item.param_name }}</div>
<div class="param-desc">{{ item.description || item.param_code }}</div>
</td>
<td>
<template v-if="item.values.length">
<div v-for="v in item.values" :key="v.dimension_value" class="value-row">
<span class="dim-label">{{ dimensionLabel(v.dimension_value) }}</span>
<b class="num">{{ formatValue(item, v.value_text) }}</b>
<span class="eff">{{ v.effective_date }} 起</span>
<el-tag
v-if="pendingOf(item, v.dimension_value)"
size="small"
type="warning"
disable-transitions
>
待改为 {{ pendingOf(item, v.dimension_value)!.new_value }}
</el-tag>
<el-button
v-if="isAdmin"
link
type="primary"
size="small"
@click="openEdit(item, v.dimension_value)"
>
改
</el-button>
</div>
</template>
<span v-else class="no-value">
未设置(默认 {{ item.default_value ?? '—' }})
</span>
</td>
<td>
<el-button
v-if="isAdmin && !item.values.length"
link
type="primary"
size="small"
@click="openEdit(item, '')"
>
设置
</el-button>
<el-button link type="primary" size="small" @click="openHistory(item)">历史</el-button>
</td>
</tr>
</tbody>
</table>
</el-collapse-item>
</el-collapse>
</el-card>
<!-- 待提交批次(仅管理员可见可操作) -->
<el-card v-if="isAdmin && pendingList.length" shadow="never" class="pending-card">
<template #header>
<!-- 待提交批次(仅管理员可见可操作):提交前只是暂存,故用独立的次级卡片承载 -->
<div v-if="isAdmin && pendingList.length" class="card pending-card">
<div class="card-head">
<div class="pending-head">
<b>待提交变更({{ pendingList.length }} 项)</b>
<span class="pending-hint">提交前不会生效;提交后未生效前可整批撤回</span>
</div>
</template>
</div>
<el-table :data="pendingList" size="small">
<el-table-column label="参数" min-width="160">
<template #default="{ row }">{{ row.item.param_name }}</template>
@@ -527,10 +552,14 @@ onMounted(() => {
<template #default="{ row }">{{ dimensionLabel(row.dimension_value) }}</template>
</el-table-column>
<el-table-column label="旧值" min-width="110">
<template #default="{ row }">{{ row.old_value || '—' }}</template>
<template #default="{ row }">
{{ row.old_value ? formatByCode(row.item.param_code, row.old_value) : '—' }}
</template>
</el-table-column>
<el-table-column label="新值" min-width="110">
<template #default="{ row }"><b class="num">{{ row.new_value }}</b></template>
<template #default="{ row }">
<b class="num">{{ formatByCode(row.item.param_code, row.new_value) }}</b>
</template>
</el-table-column>
<el-table-column label="操作" width="80">
<template #default="{ row }">
@@ -538,28 +567,30 @@ onMounted(() => {
</template>
</el-table-column>
</el-table>
<div class="submit-row">
<el-radio-group v-model="changeType">
<el-radio-button :value="1">立即生效</el-radio-button>
<el-radio-button :value="2">指定日期生效</el-radio-button>
</el-radio-group>
<el-date-picker
v-if="changeType === 2"
v-model="effectiveDate"
type="date"
value-format="YYYY-MM-DD"
placeholder="生效日期"
style="width: 150px"
/>
<el-input
v-model="remark"
placeholder="变更说明(如:四季度系数调整)"
style="width: 240px"
maxlength="255"
/>
<el-button type="primary" :loading="submitting" @click="submitAll">提交变更</el-button>
<div class="card-foot">
<div class="submit-row">
<el-radio-group v-model="changeType">
<el-radio-button :value="1">立即生效</el-radio-button>
<el-radio-button :value="2">指定日期生效</el-radio-button>
</el-radio-group>
<el-date-picker
v-if="changeType === 2"
v-model="effectiveDate"
type="date"
value-format="YYYY-MM-DD"
placeholder="生效日期"
style="width: 150px"
/>
<el-input
v-model="remark"
placeholder="变更说明(如:四季度系数调整)"
style="width: 240px"
maxlength="255"
/>
<el-button type="primary" :loading="submitting" @click="submitAll">提交变更</el-button>
</div>
</div>
</el-card>
</div>
<!-- 修改参数 -->
<el-dialog v-model="editVisible" title="修改参数" width="440px" destroy-on-close>
@@ -578,24 +609,50 @@ onMounted(() => {
</el-select>
</el-form-item>
<el-form-item label="当前值">
<span class="num">{{ currentValue(editTarget.item, editTarget.dimension) ?? editTarget.item.default_value ?? '—' }}</span>
<span v-if="editTarget.item.unit" style="color: var(--ys-ink-3)"> {{ editTarget.item.unit }}</span>
<!-- 当前值同目录卡口径:枚举翻中文、数字补单位;编辑框里存的仍是原始值 -->
<span class="num">
{{ formatByCode(editTarget.item.param_code, currentValue(editTarget.item, editTarget.dimension) ?? editTarget.item.default_value) }}
</span>
</el-form-item>
<el-form-item label="新值">
<!--
枚举与开关都给下拉:让业务人员在「含基本工资」「开」里选,
而不是自己敲 INCLUDE / 1 —— 提交给后端的仍是原始值(v-model 绑的是 value)
-->
<el-select
v-if="editTarget.item.value_type === 'ENUM'"
v-model="editValue"
style="width: 100%"
>
<el-option v-for="opt in editTarget.item.options" :key="opt" :label="opt" :value="opt" />
<!-- label 走 valueLabel:下拉里给的是「含基本工资」这类中文,提交值仍是 INCLUDE 原值 -->
<el-option
v-for="opt in editTarget.item.options"
:key="opt"
:label="valueLabel(opt)"
:value="opt"
/>
</el-select>
<el-select
v-else-if="editTarget.item.value_type === 'BOOL'"
v-model="editValue"
style="width: 100%"
>
<el-option label="开启" value="1" />
<el-option label="关闭" value="0" />
</el-select>
<el-input v-else v-model="editValue" placeholder="输入新值(分数式如 4/360 原样保留)" />
</el-form-item>
</el-form>
<div class="edit-tip">
取值范围:{{ editTarget.item.value_min ?? '−' }} ~ {{ editTarget.item.value_max ?? '不限' }};
精确分数(如 4/360)原样存储、不做小数截断。
<!--
取值提示只对数字型参数有意义:枚举与开关是选出来的,摆「取值范围」只会添乱。
分数式(4/360)保留原文是因为截成小数会算出不一样的绩效,这条得讲给业务听。
-->
<div v-if="editTarget.item.value_type !== 'ENUM' && editTarget.item.value_type !== 'BOOL'" class="edit-tip">
取值范围:{{ editTarget.item.value_min ?? '不限' }} ~ {{ editTarget.item.value_max ?? '不限' }}
<template v-if="editTarget.item.unit">(单位:{{ editTarget.item.unit }})</template>
;比例可写成 4/360 这样的分数,系统按精确值计算,不四舍五入。
</div>
<div v-else class="edit-tip">改动提交后按你选择的生效日期起作用,旧值会保留在变更记录里。</div>
</template>
<template #footer>
<el-button @click="editVisible = false">取消</el-button>
@@ -603,7 +660,7 @@ onMounted(() => {
</template>
</el-dialog>
<!-- 版本管理抽屉 -->
<!-- 版本管理抽屉:对比两个日期各自的生效值 + 变更记录 -->
<el-drawer v-model="versionVisible" title="版本管理" size="760px">
<div class="drawer-block">
<div class="drawer-row">
@@ -625,18 +682,18 @@ onMounted(() => {
<el-button :loading="exporting" @click="doExport">导出记录</el-button>
</div>
<template v-if="diffSearched">
<div class="drawer-title">快照对比({{ compareFrom }} → {{ compareTo }})</div>
<div class="drawer-title">两个日期的参数对比({{ compareFrom }} → {{ compareTo }})</div>
<el-table :data="diffResult" size="small" max-height="260">
<el-table-column prop="param_name" label="参数" min-width="130" />
<el-table-column label="维度" width="90">
<template #default="{ row }">{{ dimensionLabel(row.dimension_value) }}</template>
</el-table-column>
<el-table-column label="旧值" min-width="90">
<template #default="{ row }">{{ row.old_value ?? '—' }}</template>
<template #default="{ row }">{{ formatByCode(row.param_code, row.old_value) }}</template>
</el-table-column>
<el-table-column label="新值" min-width="90">
<template #default="{ row }">
<span :class="{ changed: row.changed }">{{ row.new_value ?? '—' }}</span>
<span :class="{ changed: row.changed }">{{ formatByCode(row.param_code, row.new_value) }}</span>
</template>
</el-table-column>
<el-table-column label="变化" width="70" align="center">
@@ -658,7 +715,7 @@ onMounted(() => {
<div v-for="item in detailCache[row.id].items" :key="item.param_code + item.dimension_value" class="batch-item">
<span class="param-name">{{ item.param_name }}</span>
<span class="dim-label">{{ dimensionLabel(item.dimension_value) }}</span>
<span class="num">{{ item.old_value_text ?? '—' }} → <b>{{ item.value_text }}</b></span>
<span class="num">{{ formatByCode(item.param_code, item.old_value_text) }} → <b>{{ formatByCode(item.param_code, item.value_text) }}</b></span>
</div>
</template>
<el-button v-else link size="small" @click="detailOf(row).catch(() => {})">
@@ -707,7 +764,11 @@ onMounted(() => {
</el-table-column>
<el-table-column label="旧值 → 新值" min-width="130">
<template #default="{ row }">
<span class="num">{{ row.old_value_text ?? '—' }} → {{ row.value_text }}</span>
<!-- 历史行没有 value_type,按当前参数定义格式化(枚举翻中文、数字补单位) -->
<span class="num">
{{ formatByCode(historyItem?.param_code ?? '', row.old_value_text) }} →
{{ formatByCode(historyItem?.param_code ?? '', row.value_text) }}
</span>
</template>
</el-table-column>
<el-table-column prop="version_no" label="版本" width="115" />
@@ -797,9 +858,10 @@ onMounted(() => {
<div v-if="previewResult.probation_note" class="pr-note">{{ previewResult.probation_note }}</div>
</div>
<div class="pr-used">
<div class="drawer-title">所用参数(按回款到账日取值)</div>
<div class="drawer-title">本次用到的参数(按回款到账日取当天生效值)</div>
<!-- 只显示中文参数名与格式化后的值:参数编码是内部标识,业务人员对不上 -->
<div v-for="u in previewResult.params_used" :key="u.param_code" class="pr-used-row">
{{ paramNameByCode(u.param_code) }}({{ u.param_code }})= {{ valueLabel(u.value_text) || u.value_text }}
{{ paramNameByCode(u.param_code) }} = {{ formatByCode(u.param_code, u.value_text) }}
<span v-if="u.effective_date" class="eff">({{ u.effective_date }} 起)</span>
</div>
</div>
@@ -809,17 +871,7 @@ onMounted(() => {
</template>
<style scoped>
.page-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 14px;
}
.page-desc {
font-size: 13px;
color: var(--ys-ink-2);
}
/* 页面里已经没有 .page-toolbar/.page-desc —— 描述与工具栏改用全局 .page-head/.toolbar */
.group-title {
font-weight: 600;
@@ -885,6 +937,7 @@ onMounted(() => {
font-size: 12px;
}
/* 待提交卡片紧跟目录卡,需要与上方卡片拉开距离(.card 自身不带外边距) */
.pending-card {
margin-top: 14px;
}
@@ -900,11 +953,14 @@ onMounted(() => {
color: var(--ys-ink-3);
}
/*
* 提交区已放进 .card-foot:底栏自带 10px 16px 内边距与右对齐,
* 所以这里去掉原先的 margin-top,否则会与底栏留白叠成双份。
*/
.submit-row {
display: flex;
gap: 10px;
align-items: center;
margin-top: 14px;
}
.edit-name {
+77 -48
View File
@@ -170,43 +170,69 @@ onMounted(load)
</script>
<template>
<div>
<el-card shadow="never">
<el-form class="filter-form" :inline="true" @submit.prevent>
<el-form-item label="关键词">
<el-input
v-model="filters.keyword"
placeholder="编码或名称,模糊匹配"
clearable
style="width: 200px"
@keyup.enter="search"
<div class="page">
<!-- 页面头:说明这页管授权基线,并点出两条会挡住操作的保护规则 -->
<div class="page-head">
<h1 class="page-title">角色管理</h1>
<p class="page-desc">
决定账号能进哪些功能;内置 admin 不可删停,仍有用户绑定的角色需先解绑才能删除。
</p>
<div class="waterline"></div>
</div>
<!--
筛选条:.filter-bar 自带 flex + gap + 内边距,故 el-form 不再写 inline;
查询/重置收进 .filter-bar__actions,靠 margin-left:auto 贴到行尾。
-->
<el-form class="filter-bar" @submit.prevent>
<el-form-item label="关键词">
<el-input
v-model="filters.keyword"
placeholder="编码或名称,模糊匹配"
clearable
style="width: 200px"
@keyup.enter="search"
/>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="filters.status" placeholder="全部" clearable style="width: 110px">
<el-option
v-for="item in STATUS_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="filters.status" placeholder="全部" clearable style="width: 110px">
<el-option
v-for="item in STATUS_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="search">查询</el-button>
<el-button @click="resetFilters">重置</el-button>
</el-form-item>
</el-form>
<div class="toolbar">
<el-button type="primary" @click="openCreate">新增角色</el-button>
</el-select>
</el-form-item>
<div class="filter-bar__actions">
<el-button type="primary" @click="search">查询</el-button>
<el-button @click="resetFilters">重置</el-button>
</div>
</el-form>
<el-table v-loading="loading" :data="records" stripe>
<!-- 主操作行:新增是这页唯一的高频动作,单独成行且用主按钮 -->
<div class="toolbar">
<el-button type="primary" @click="openCreate">新增角色</el-button>
</div>
<div class="card">
<el-table
v-loading="loading"
:data="records"
stripe
empty-text="还没有角色,点「新增角色」创建第一个"
>
<el-table-column label="编码" min-width="150">
<template #default="{ row }">
<span class="role-code">{{ row.role_code }}</span>
<el-tag v-if="isBuiltin(row)" class="builtin-tag" size="small" type="warning" disable-transitions>
<!-- 角色编码是授权判定的锚点,等宽字体便于逐字符核对 -->
<span class="cell-code">{{ row.role_code }}</span>
<el-tag
v-if="isBuiltin(row)"
class="builtin-tag"
size="small"
type="warning"
disable-transitions
>
内置
</el-tag>
</template>
@@ -215,14 +241,17 @@ onMounted(load)
<el-table-column prop="description" label="说明" min-width="180">
<template #default="{ row }">{{ row.description || '—' }}</template>
</el-table-column>
<el-table-column prop="sort_order" label="排序" width="80" align="center" />
<el-table-column label="状态" width="90" align="center">
<!-- 排序值与用户数都是用于比较的数字:右对齐 + tabular-nums,纵向扫读时数位对齐 -->
<el-table-column label="排序" width="80" align="right">
<template #default="{ row }"><span class="cell-num">{{ row.sort_order }}</span></template>
</el-table-column>
<el-table-column label="状态" width="96" align="center">
<template #default="{ row }">
<StatusTag :value="row.status" />
</template>
</el-table-column>
<el-table-column label="用户数" width="90" align="center">
<template #default="{ row }">{{ row.user_count }}</template>
<el-table-column label="用户数" width="90" align="right">
<template #default="{ row }"><span class="cell-num">{{ row.user_count }}</span></template>
</el-table-column>
<el-table-column label="操作" width="170" fixed="right" align="center">
<template #default="{ row }">
@@ -256,14 +285,16 @@ onMounted(load)
</el-table-column>
</el-table>
<AppPagination
:total="total"
:page-num="pageNum"
:page-size="pageSize"
@page-change="onPageChange"
@size-change="onSizeChange"
/>
</el-card>
<div class="card-foot">
<AppPagination
:total="total"
:page-num="pageNum"
:page-size="pageSize"
@page-change="onPageChange"
@size-change="onSizeChange"
/>
</div>
</div>
<el-dialog
v-model="dialogVisible"
@@ -298,11 +329,9 @@ onMounted(load)
</template>
<style scoped>
.role-code {
font-family: Consolas, 'Courier New', monospace;
}
/* 「内置」标记跟在编码后面,需要一个横向间距;.el-tag 自身不带外边距 */
.builtin-tag {
margin-left: 6px;
}
</style>
+335 -99
View File
@@ -10,7 +10,10 @@ import { handledError } from '@/utils/error'
* 公司信息设置(2026-10-15 补充需求:公司名称、logo)。
*
* 签名元素是右侧「登录页实景预览」:品牌名与 logo 改动即时反映在一张
* 缩比登录卡上 —— 所见即所得,避免「保存后去登录页核对」的往返。
* **1:1 等比**的登录卡上 —— 所见即所得,避免「保存后去登录页核对」的往返。
* 预览按登录页的真实版式复刻(品牌区 / 欢迎回来 / 三项输入 / 登录按钮 /
* 版权行),控件尺寸与登录页一致,logo 展示位也按登录页的 ≤56×36 约束。
*
* logo 校验(png/jpg ≤2MB)与后端三重校验同口径,前端先拦一道。
*
* 进入页面即加载当前设置(设置页即所见即所得,无需手动刷新)。
@@ -91,71 +94,143 @@ const previewName = computed(() => form.company_name.trim() || '衍水集团')
<template>
<div class="page">
<!-- 页面头:这是表单页不是列表页,所以没有筛选条,只有标题 + 影响面说明 -->
<div class="page-head">
<h1 class="page-title">公司信息</h1>
<p class="page-desc">维护公司名称与 logo;保存后立即作用于登录页品牌位与导出页眉。</p>
<div class="waterline"></div>
</div>
<div class="settings-grid">
<el-card shadow="never" class="settings-card">
<template #header>
<span class="card-title">公司信息</span>
</template>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-width="88px"
@submit.prevent
>
<el-form-item label="公司名称" prop="company_name">
<el-input
v-model="form.company_name"
maxlength="64"
placeholder="展示在登录页与导出页眉"
clearable
/>
</el-form-item>
<el-form-item label="logo">
<div class="logo-field">
<el-upload
class="logo-uploader"
:show-file-list="false"
:http-request="handleUpload"
:before-upload="beforeUpload"
accept="image/png,image/jpeg"
>
<div v-loading="uploading" class="logo-box" title="点击上传 png / jpg(≤2MB)">
<img v-if="logoUrl" :src="logoUrl" alt="公司 logo" />
<span v-else class="logo-plus">+</span>
</div>
</el-upload>
<p class="logo-hint">png / jpg,不超过 2MB;<br />展示于登录页品牌位与导出页眉。</p>
</div>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="submitting" @click="handleSubmit">保存</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 签名元素:登录页实景预览(缩比登录卡,品牌名/logo 即时反映) -->
<el-card shadow="never" class="preview-card">
<template #header>
<span class="card-title">登录页预览</span>
<span class="preview-note">保存后对所有人生效</span>
</template>
<div class="login-mock">
<div class="mock-brand">
<img v-if="logoUrl" :src="logoUrl" alt="" class="mock-logo" />
<div class="mock-name">{{ previewName }}</div>
<p class="mock-slogan">把每一笔回款,算成一份踏实。</p>
<span class="mock-waterline"></span>
<!-- 左列:基础信息(表单)+ 生效范围(这份设置会出现在哪里) -->
<div class="settings-col">
<div class="card">
<!-- 卡片标题刻意不写「公司信息」:页面标题已经说明了,再重复一遍只是噪音 -->
<div class="card-head">
<span class="card-title">基础信息</span>
</div>
<div class="card-body">
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-width="92px"
@submit.prevent
>
<!--
字段自适应分列:卡片够宽时两个字段并排(各占一列),窄时自动折回单列。
说明文字紧贴控件下方 —— 早前把它顶到卡片最右缘,宽屏下与输入框隔了
上千像素,视线关联直接断掉。
-->
<div class="field-grid">
<el-form-item label="公司名称" prop="company_name">
<el-input
v-model="form.company_name"
class="field-control__main"
maxlength="64"
placeholder="展示在登录页与导出页眉"
clearable
/>
<p class="field-control__note">最多 64 个字符;保存后对所有在线用户立即生效。</p>
</el-form-item>
<el-form-item label="logo">
<el-upload
class="logo-uploader"
:show-file-list="false"
:http-request="handleUpload"
:before-upload="beforeUpload"
accept="image/png,image/jpeg"
>
<div v-loading="uploading" class="logo-box" title="点击上传 png / jpg(≤2MB)">
<img v-if="logoUrl" :src="logoUrl" alt="公司 logo" />
<span v-else class="logo-plus">+</span>
</div>
</el-upload>
<p class="field-control__note">
png / jpg,不超过 2MB;仅展示于登录页品牌位,按 ≤56×36 显示。
</p>
</el-form-item>
</div>
</el-form>
</div>
<!--
保存按钮移进 card-foot:表单项里的按钮会跟着 label 列缩进、并对齐到输入框左缘,
作为整张表单的收尾动作用卡片底栏右对齐更符合阅读顺序。
按钮在 form 之外仍安全:handleSubmit 走 formRef 校验,且 el-button 默认 type="button",
不会触发浏览器原生提交。
-->
<div class="card-foot">
<el-button type="primary" :loading="submitting" @click="handleSubmit">保存</el-button>
</div>
<div class="mock-field"></div>
<div class="mock-field"></div>
<div class="mock-submit">登 录</div>
</div>
<p class="preview-caption">登录页品牌位实景(示意,非交互)</p>
</el-card>
<div class="card">
<div class="card-head">
<span class="card-title">生效范围</span>
<span class="preview-note">保存后对所有人生效</span>
</div>
<div class="card-body">
<dl class="scope-list">
<div class="scope-item">
<dt>公司名称</dt>
<dd>登录页品牌位与版权行 · Excel 导出页眉 · 打印稿页眉</dd>
</div>
<div class="scope-item">
<dt>logo</dt>
<dd>登录页品牌位(≤56×36)</dd>
</div>
</dl>
</div>
</div>
</div>
<!-- 签名元素:登录页实景预览(1:1 等比登录卡,非 iframe 截图) -->
<div class="card preview-card">
<div class="card-head">
<span class="card-title">登录页预览</span>
<span class="preview-note">1:1 等比</span>
</div>
<div class="card-body">
<div class="login-stage" aria-hidden="true">
<div class="mock-card">
<div class="mock-brand">
<img v-if="logoUrl" :src="logoUrl" alt="" class="mock-logo" />
<div class="mock-name">{{ previewName }}</div>
<p class="mock-slogan">把每一笔回款,算成一份踏实。</p>
<span class="mock-waterline"></span>
</div>
<div class="mock-head">
<div class="mock-title">欢迎回来</div>
<div class="mock-hint">请输入账号密码进入系统</div>
</div>
<div class="mock-field">
<span class="mock-label">用户名</span>
<span class="mock-box"></span>
</div>
<div class="mock-field">
<span class="mock-label">密码</span>
<span class="mock-box"></span>
</div>
<div class="mock-field">
<span class="mock-label">验证码</span>
<span class="mock-captcha-row">
<span class="mock-box"></span>
<span class="mock-captcha"></span>
</span>
</div>
<div class="mock-submit">登 录</div>
</div>
<div class="mock-foot">© {{ previewName }} · 内部系统</div>
</div>
<p class="preview-caption">登录页品牌位实景(示意,非交互)——显示的是当前编辑值</p>
</div>
</div>
</div>
</div>
</template>
@@ -163,25 +238,58 @@ const previewName = computed(() => form.company_name.trim() || '衍水集团')
<style scoped>
.settings-grid {
display: grid;
grid-template-columns: minmax(360px, 460px) minmax(300px, 380px);
grid-template-columns: minmax(0, 1fr) 480px;
gap: 16px;
align-items: start;
}
.card-title {
font-weight: 600;
.settings-col {
display: flex;
flex-direction: column;
gap: 16px;
min-width: 0;
}
/*
* 说明文字不再用 float:right 顶到卡片头右侧 —— .card-head 已是 flex +
* space-between,第二个子元素自然落在右端,float 反而会破坏行内布局。
*/
.preview-note {
float: right;
font-size: 12px;
color: var(--ys-ink-3);
}
.logo-field {
/*
* 控件行:主控件在左、说明在右,两者共享一行高度。
* 说明用 flex-grow 撑满剩余宽度并右对齐 —— 宽屏下整行铺到卡片右缘,
* 不会出现「输入框缩在左上角、右侧留一整块空白」的观感;
* 窄屏放不下时自动换行,说明退回左对齐。
*/
.field-control {
display: flex;
align-items: flex-start;
gap: 14px;
flex-wrap: wrap;
align-items: center;
gap: 8px 20px;
width: 100%;
}
.field-control__main {
flex: 0 1 420px;
min-width: 240px;
}
.field-control__note {
flex: 1 1 240px;
min-width: 240px;
margin: 0;
font-size: 12px;
line-height: 1.7;
color: var(--ys-ink-3);
text-align: left;
}
.logo-uploader {
flex: 0 0 auto;
}
.logo-box {
@@ -215,22 +323,76 @@ const previewName = computed(() => form.company_name.trim() || '衍水集团')
color: var(--ys-ink-3);
}
.logo-hint {
margin: 4px 0 0;
font-size: 12px;
line-height: 1.7;
/* ── 生效范围:定义列表(项名 / 取值) ── */
.scope-list {
margin: 0;
}
.scope-item {
display: grid;
grid-template-columns: 92px minmax(0, 1fr);
column-gap: 20px;
align-items: baseline;
}
.scope-item + .scope-item {
padding-top: 10px;
margin-top: 10px;
border-top: 1px solid var(--ys-line-soft);
}
.scope-item dt {
font-size: 13px;
font-weight: 500;
color: var(--ys-ink-2);
}
.scope-item dd {
margin: 0;
font-size: 12.5px;
color: var(--ys-ink-3);
}
/* ── 登录页实景预览:按登录卡视觉 0.8 缩比重建(非 iframe 截图) ── */
.login-mock {
padding: 22px 26px 24px;
/* ── 登录页实景预览:按登录页真实版式 1:1 复刻 ── */
.login-stage {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
padding: 24px 0 20px;
overflow: hidden;
background-color: #f2f4f8;
/* 点阵网格:与登录页同一套秩序感 */
background-image: radial-gradient(rgba(28, 35, 51, 0.07) 1px, transparent 1px);
background-size: 24px 24px;
border-radius: var(--ys-radius-sm);
user-select: none;
}
/* 顶部天光:登录页的落点光源,等比搬过来 */
.login-stage::before {
content: '';
position: absolute;
top: -180px;
left: 50%;
width: 860px;
height: 460px;
transform: translateX(-50%);
pointer-events: none;
background: radial-gradient(closest-side, rgba(46, 75, 168, 0.08), rgba(46, 75, 168, 0));
}
.mock-card {
position: relative;
width: 400px;
max-width: 100%;
padding: 30px 36px 32px;
background: linear-gradient(180deg, #ffffff, #fcfcfe);
border: 1px solid var(--ys-line);
border-radius: 12px;
border: 1px solid rgba(28, 35, 51, 0.09);
border-radius: 14px;
box-shadow:
0 2px 4px rgba(28, 35, 51, 0.04),
0 10px 26px rgba(28, 35, 51, 0.08);
0 16px 40px rgba(28, 35, 51, 0.1);
}
.mock-brand {
@@ -239,62 +401,136 @@ const previewName = computed(() => form.company_name.trim() || '衍水集团')
.mock-logo {
display: block;
max-width: 64px;
max-height: 40px;
margin: 0 auto 8px;
max-width: 56px;
max-height: 36px;
margin: 0 auto 10px;
object-fit: contain;
}
.mock-name {
font-family: var(--ys-font-brand);
font-size: 14px;
font-size: 17px;
font-weight: 500;
letter-spacing: 0.34em;
color: var(--ys-ink);
}
.mock-slogan {
margin: 6px 0 0;
font-size: 11px;
margin: 8px 0 0;
font-size: 12.5px;
color: var(--ys-ink-3);
}
.mock-waterline {
display: block;
width: 34px;
width: 40px;
height: 2px;
margin: 10px auto 0;
margin: 14px auto 0;
border-radius: 1px;
background: linear-gradient(90deg, var(--ys-indigo), rgba(46, 75, 168, 0));
}
.mock-head {
margin-top: 26px;
}
.mock-title {
font-size: 15px;
font-weight: 600;
color: var(--ys-ink);
}
.mock-hint {
margin-top: 4px;
font-size: 12px;
color: var(--ys-ink-3);
}
.mock-field {
height: 32px;
margin-top: 12px;
margin-top: 16px;
}
.mock-field + .mock-field {
margin-top: 18px;
}
.mock-label {
display: block;
margin-bottom: 6px;
font-size: 13px;
line-height: 22px;
color: var(--ys-ink-2);
}
.mock-box {
display: block;
height: 38px;
background: #f4f5f8;
border: 1px solid rgba(28, 35, 51, 0.07);
border-radius: 6px;
border-radius: 7px;
}
.mock-captcha-row {
display: flex;
gap: 10px;
}
.mock-captcha-row .mock-box {
flex: 1;
}
/* 验证码位:留空壳 + 斜纹质感,不伪造字符,避免与真实验证码混淆 */
.mock-captcha {
flex: 0 0 104px;
height: 38px;
background-color: #f1f3f8;
background-image: repeating-linear-gradient(
45deg,
rgba(28, 35, 51, 0.07) 0 1px,
transparent 1px 7px
);
border: 1px solid var(--ys-line);
border-radius: 7px;
}
.mock-submit {
height: 32px;
margin-top: 14px;
font-size: 12px;
line-height: 32px;
height: 38px;
margin-top: 20px;
font-size: 13px;
line-height: 38px;
letter-spacing: 0.4em;
text-align: center;
color: #fff;
background: var(--ys-indigo);
border-radius: 6px;
background: linear-gradient(180deg, var(--ys-indigo-hi), var(--ys-indigo));
border-radius: 7px;
}
.mock-foot {
margin-top: 16px;
font-size: 11px;
letter-spacing: 0.08em;
color: var(--ys-ink-3);
}
.preview-caption {
margin: 10px 0 0;
margin: 12px 0 0;
font-size: 11px;
text-align: center;
color: var(--ys-ink-3);
}
@media (max-width: 900px) {
/*
* 宽屏(≥1600px)说明文字靠右缘对齐,与左侧输入框形成「两端锚点」,
* 整行铺满卡片而不是缩在左上角;1600px 以下说明跟着输入框走,
* 放不下时整体换行到控件下方。
*/
@media (min-width: 1600px) {
.field-control__note {
text-align: right;
}
}
@media (max-width: 1200px) {
.settings-grid {
grid-template-columns: 1fr;
}
Loaded 100 of 101 files, more files were not shown because too many files have changed in this diff. Show more