9 Commits
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
40 changed files with 2527 additions and 1004 deletions

No files matched your search

+13 -1
View File
@@ -83,8 +83,9 @@ app/
| GET / POST | `/api/v1/base/products` | 产品列表(名称/规格模糊、类别/状态筛选)/ 新增(类别值域校验;名称+规格组合唯一) | | GET / POST | `/api/v1/base/products` | 产品列表(名称/规格模糊、类别/状态筛选)/ 新增(类别值域校验;名称+规格组合唯一) |
| GET / PUT / DELETE | `/api/v1/base/products/{id}` | 详情 / 编辑 / 删除(被业务明细引用保护在业务表就位后生效) | | GET / PUT / DELETE | `/api/v1/base/products/{id}` | 详情 / 编辑 / 删除(被业务明细引用保护在业务表就位后生效) |
| PUT | `/api/v1/base/products/{id}/status` | 启用 / 停用 | | 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}` | 详情 / 编辑(**不触归属**)/ 删除 | | 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) | | PUT | `/api/v1/base/customers/{id}/ownership` | 归属变更(同事务写留痕;只影响变更后核算,不回溯历史 #19) |
| GET | `/api/v1/base/customers/{id}/ownership-logs` | 归属变更留痕分页(join 人员出姓名) | | 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` 仅要求登录(含停用岗位),供财务/领导在报表中按岗位筛选;岗位管理接口仍仅管理员。 - **岗位筛选选项**:`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 是安全问题,必须启动即失败。 **起服务报 `APP_SECRET_KEY` 相关错误** —— `backend/.env` 未填或不足 32 字符。这是刻意的 fail-fast:空密钥签发 JWT 是安全问题,必须启动即失败。
@@ -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")
+26
View File
@@ -21,6 +21,7 @@ from app.schemas.customer import (
CustomerOut, CustomerOut,
CustomerOwnershipIn, CustomerOwnershipIn,
CustomerQuery, CustomerQuery,
CustomerRatingIn,
CustomerUpdateIn, CustomerUpdateIn,
OwnershipChangeLogOut, OwnershipChangeLogOut,
) )
@@ -102,6 +103,31 @@ def delete_customer(
return ok(message="客户已删除") 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( @router.put(
"/{customer_id}/ownership", "/{customer_id}/ownership",
response_model=ApiResponse[CustomerOut], response_model=ApiResponse[CustomerOut],
+18 -4
View File
@@ -1,4 +1,4 @@
"""工作台接口(M4 ⑧):四区块一次性聚合。 """工作台接口(M4 ⑧):三区块聚合 + 最近动态分页。
准入:管理员/财务/领导(与报表、审批接口一致;FR-033/036 的「全部用户」 准入:管理员/财务/领导(与报表、审批接口一致;FR-033/036 的「全部用户」
待业务权限清单 #6 答复后再放宽)。只读接口,不写操作日志。 待业务权限清单 #6 答复后再放宽)。只读接口,不写操作日志。
@@ -12,7 +12,7 @@ from app.api.deps import DbSession, require_roles
from app.core.constants import ROLE_ADMIN, ROLE_FINANCE, ROLE_LEADER from app.core.constants import ROLE_ADMIN, ROLE_FINANCE, ROLE_LEADER
from app.core.response import ApiResponse, ok from app.core.response import ApiResponse, ok
from app.models import User 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 from app.services import dashboard_service
router = APIRouter(prefix="/dashboard", tags=["工作台"]) router = APIRouter(prefix="/dashboard", tags=["工作台"])
@@ -23,7 +23,7 @@ _DashboardUser = Annotated[
] ]
@router.get("", response_model=ApiResponse[DashboardOut], summary="工作台四区块") @router.get("", response_model=ApiResponse[DashboardOut], summary="工作台三区块")
def get_dashboard( def get_dashboard(
session: DbSession, session: DbSession,
current_user: _DashboardUser, current_user: _DashboardUser,
@@ -31,8 +31,22 @@ def get_dashboard(
str | None, Query(max_length=7, description="统计月份 YYYY-MM,默认当月") str | None, Query(max_length=7, description="统计月份 YYYY-MM,默认当月")
] = None, ] = None,
) -> ApiResponse[DashboardOut]: ) -> ApiResponse[DashboardOut]:
"""一次返回经营总览/待办/风险预警/最近动态(待办与预警实时派生)。 """一次返回经营总览/待办/风险预警(待办与预警实时派生)。
待办按当前用户角色只返回其可操作节点(admin 可查看全部在途单)。 待办按当前用户角色只返回其可操作节点(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))
+8
View File
@@ -63,6 +63,14 @@ class Customer(Base, TimestampMixin, TPlusSyncMixin):
comment="归属负责营销人员ID(手动修改不回溯历史,变更留痕见 ownership_change_logs)", comment="归属负责营销人员ID(手动修改不回溯历史,变更留痕见 ownership_change_logs)",
) )
remark: Mapped[str | None] = mapped_column(String(255), comment="备注") 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): class OwnershipChangeLog(Base):
@@ -52,10 +52,12 @@ def list_customers(session: Session, query: CustomerQuery) -> list[Row]:
归属人员外键非空但统一用 outerjoin:人员行不可考(被物理删除的脏数据) 归属人员外键非空但统一用 outerjoin:人员行不可考(被物理删除的脏数据)
不应让客户行整个查不出来(同 personnel_repository 的韧性口径)。 不应让客户行整个查不出来(同 personnel_repository 的韧性口径)。
排序按 id 倒序:客户无排序字段,新增的靠前符合列表页习惯。 排序键由查询白名单决定(见 ``CustomerQuery.order_by``):
id 倒序 = 新建档靠前,rating_score 按分数排、未评分垫底。
""" """
stmt = _apply_filters(_base_select(), query) stmt = _apply_filters(_base_select(), query)
stmt = stmt.order_by(Customer.id.desc()).offset(query.offset).limit(query.page_size) stmt = _apply_order(stmt, query)
stmt = stmt.offset(query.offset).limit(query.page_size)
return list(session.execute(stmt).all()) return list(session.execute(stmt).all())
@@ -131,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())
# ==================== 写入 ==================== # ==================== 写入 ====================
@@ -142,6 +142,15 @@ def count_logs(session: Session, query: OperationLogListQuery) -> int:
return int(session.execute(stmt).scalar_one()) return int(session.execute(stmt).scalar_one())
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: def _apply_filters(stmt: Select, query: OperationLogListQuery) -> Select:
"""应用筛选条件(公共部分复用 query_utils,与登录日志同口径)。""" """应用筛选条件(公共部分复用 query_utils,与登录日志同口径)。"""
stmt = apply_log_filters(stmt, OperationLog, query.filters) stmt = apply_log_filters(stmt, OperationLog, query.filters)
@@ -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: 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) return session.get(CustomerRatingGrade, grade_id)
@@ -60,6 +61,10 @@ def create(session: Session, grade: CustomerRatingGrade) -> CustomerRatingGrade:
def delete(session: Session, grade: CustomerRatingGrade) -> None: def delete(session: Session, grade: CustomerRatingGrade) -> None:
"""物理删除等级。等级表无下游引用,调用方无需引用保护校验。""" """物理删除等级。
等级表无外键引用(客户档案只存总分,不存等级 id),调用方无需引用保护
校验:删掉等级只是「以后不再判到它」,已存的总分不受影响。
"""
session.delete(grade) session.delete(grade)
session.flush() session.flush()
+23 -1
View File
@@ -1,6 +1,7 @@
"""客户管理的请求/响应模型(需求 2.1.3)。""" """客户管理的请求/响应模型(需求 2.1.3)。"""
from datetime import datetime from datetime import datetime
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
@@ -28,6 +29,8 @@ class CustomerOut(BaseModel):
owner_personnel_id: int | None = None owner_personnel_id: int | None = None
owner_personnel_name: str | None = None owner_personnel_name: str | None = None
remark: str | None = None remark: str | None = None
rating_score: int | None = None
rating_time: datetime | None = None
class CustomerCreateIn(BaseModel): class CustomerCreateIn(BaseModel):
@@ -90,13 +93,32 @@ class CustomerUpdateIn(BaseModel):
class CustomerQuery(PageQuery): class CustomerQuery(PageQuery):
"""客户列表查询条件(字段名蛇形直出,与前端 api 封装同名对应)。""" """客户列表查询条件(字段名蛇形直出,与前端 api 封装同名对应)。
排序用 ``Literal`` 白名单而不是自由字符串:``order_by`` 直接拼进
``ORDER BY``,自由值等于把注入面开到排序键上;客户列表只有两个
有意义的排序键,白名单足够。
"""
keyword: str | None = Field( keyword: str | None = Field(
default=None, max_length=64, description="公司名称或客户负责人,模糊匹配" default=None, max_length=64, description="公司名称或客户负责人,模糊匹配"
) )
customer_type: str | None = Field(default=None, max_length=32, description="客户分类筛选") customer_type: str | None = Field(default=None, max_length=32, description="客户分类筛选")
owner_personnel_id: int | None = Field(default=None, ge=1, description="归属人员ID筛选") 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): class CustomerOwnershipIn(BaseModel):
+42 -5
View File
@@ -1,9 +1,13 @@
"""工作台的请求/响应模型(M4 ⑧)。 """工作台的请求/响应模型(M4 ⑧)。
四区块一次返回(前端一次请求渲染整页,避免 6-10 次往返): 聚合端点一次返回三区块(前端一次请求渲染整页,避免多次往返):
经营绩效总览 / 待办事项 / 风险预警 / 最近动态。金额一律 Decimal 经营绩效总览 / 待办事项 / 风险预警。金额一律 Decimal
(序列化为字符串),前端不做数值运算。 (序列化为字符串),前端不做数值运算。
**最近动态独立成分页端点**:它可能有很长(操作日志全量),塞进聚合
响应既撑爆页面又只能截断前 20 条;拆开后翻页只重取这张列表,不必
重算总览/待办/预警。列表与模块筛选项同源返回(一次请求给全)。
待办与预警为**查询时实时派生**(不落库、不引调度器,见计划决策); 待办与预警为**查询时实时派生**(不落库、不引调度器,见计划决策);
⑥ T+ 同步未建前,同步相关条目用 `sync_available=false` 显式降级, ⑥ T+ 同步未建前,同步相关条目用 `sync_available=false` 显式降级,
不返回伪造的 0 条。 不返回伪造的 0 条。
@@ -12,7 +16,9 @@
from datetime import date, datetime from datetime import date, datetime
from decimal import Decimal from decimal import Decimal
from pydantic import BaseModel, Field from pydantic import BaseModel, ConfigDict, Field
from app.core.response import PageQuery
class TrendPoint(BaseModel): class TrendPoint(BaseModel):
@@ -116,11 +122,42 @@ class ActivityItem(BaseModel):
create_time: datetime 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): class DashboardOut(BaseModel):
"""工作台四区块一次性响应。""" """工作台三区块一次性响应(最近动态走独立分页端点)。"""
month: str month: str
overview: OverviewBlock overview: OverviewBlock
todos: TodoBlock todos: TodoBlock
warnings: WarningBlock warnings: WarningBlock
activities: list[ActivityItem] = Field(default_factory=list)
+14 -7
View File
@@ -60,7 +60,7 @@ from app.services.param_service import parse_fraction, resolve_snapshot
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# snapshot_json 里的策略点注记(口径检索锚点;#12 已参数化,开关 0 时留此注记) # snapshot_json 里的策略点注记(口径检索锚点;#12 已参数化,开关 0 时留此注记)
_NOTE_MANAGER_PENDING = "经理个人户单笔不计提(基数开关=0):聚合口径在月结时由团队聚合计算" _NOTE_MANAGER_PENDING = "经理个人户单笔不计提(未计入分成基数):绩效在月结时按团队与全公司分成计算"
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@@ -544,7 +544,7 @@ def _personal_rate(
if customer_type == "enterprise": if customer_type == "enterprise":
# 存储口径 ‰ → 纯比例(漏换算放大 1000 倍,与预览同防线) # 存储口径 ‰ → 纯比例(漏换算放大 1000 倍,与预览同防线)
rate = parse_fraction(snapshot.get("enterprise.rate")) / 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" template = role.calc_template if role else "TPL_SALESPERSON_DECAY"
if template == "TPL_MANAGER_SHARE": if template == "TPL_MANAGER_SHARE":
@@ -554,7 +554,7 @@ def _personal_rate(
if snapshot.get("manager.personal.included") != "1": if snapshot.get("manager.personal.included") != "1":
return _RateDecision( return _RateDecision(
Decimal("0"), Decimal("0"),
"经理个人户:12.5%+5% 聚合计提(月结时计算),单笔落 0", "经理个人户:绩效按团队与全公司分成在月结时计算,单笔不计提",
None, None,
True, True,
) )
@@ -563,12 +563,12 @@ def _personal_rate(
floor = parse_fraction(snapshot.get("salesperson.rate.floor")) floor = parse_fraction(snapshot.get("salesperson.rate.floor"))
boundary = int(parse_fraction(snapshot.get("salesperson.days.boundary"))) boundary = int(parse_fraction(snapshot.get("salesperson.days.boundary")))
rate = salesperson_rate(days, start=start, slope=slope, floor=floor, boundary_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) return _RateDecision(rate, note, None, False)
if template == "TPL_INFO_SALES_FIXED": if template == "TPL_INFO_SALES_FIXED":
# 信息员基数=本人销售额(覆盖折算与阈值),‰ 换算同防线 # 信息员基数=本人销售额(覆盖折算与阈值),‰ 换算同防线
rate = parse_fraction(snapshot.get("info.rate")) / 1000 rate = parse_fraction(snapshot.get("info.rate")) / 1000
return _RateDecision(rate, "信息员:本人销售额 × 计提比例", None, False) return _RateDecision(rate, "信息员:按本人销售额乘计提比例,不随回款天数递减", None, False)
if template == "TPL_SALESPERSON_DECAY": if template == "TPL_SALESPERSON_DECAY":
start = parse_fraction(snapshot.get("salesperson.rate.start")) start = parse_fraction(snapshot.get("salesperson.rate.start"))
slope = parse_fraction(snapshot.get("salesperson.rate.slope")) slope = parse_fraction(snapshot.get("salesperson.rate.slope"))
@@ -577,9 +577,16 @@ def _personal_rate(
# 经 Fraction 转换再取整(INT 型参数入库时已校验分母为 1,取整无损) # 经 Fraction 转换再取整(INT 型参数入库时已校验分母为 1,取整无损)
boundary = int(parse_fraction(snapshot.get("salesperson.days.boundary"))) boundary = int(parse_fraction(snapshot.get("salesperson.days.boundary")))
rate = salesperson_rate(days, start=start, slope=slope, floor=floor, boundary_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) 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): def _role_of(session: Session, person):
+51
View File
@@ -15,6 +15,7 @@
import logging import logging
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime
from sqlalchemy import Row from sqlalchemy import Row
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
@@ -38,6 +39,7 @@ from app.schemas.customer import (
CustomerOut, CustomerOut,
CustomerOwnershipIn, CustomerOwnershipIn,
CustomerQuery, CustomerQuery,
CustomerRatingIn,
CustomerUpdateIn, CustomerUpdateIn,
OwnershipChangeLogOut, OwnershipChangeLogOut,
) )
@@ -61,6 +63,8 @@ def _to_out(customer: Customer, owner_name: str | None) -> CustomerOut:
owner_personnel_id=customer.owner_personnel_id, owner_personnel_id=customer.owner_personnel_id,
owner_personnel_name=owner_name, owner_personnel_name=owner_name,
remark=customer.remark, remark=customer.remark,
rating_score=customer.rating_score,
rating_time=customer.rating_time,
) )
@@ -263,6 +267,53 @@ def update_customer(
return out 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( def delete_customer(
session: Session, session: Session,
customer_id: int, customer_id: int,
+37 -25
View File
@@ -1,4 +1,4 @@
"""工作台服务(M4 ⑧):四区块实时聚合编排。 """工作台服务(M4 ⑧):区块实时聚合编排 + 最近动态分页。
设计决策(2026-10-19): 设计决策(2026-10-19):
- **只读**:不落库、不写审计(读操作不入操作日志);总览只做 SQL 聚合, - **只读**:不落库、不写审计(读操作不入操作日志);总览只做 SQL 聚合,
@@ -9,6 +9,8 @@
- **⑥ 同步模块未启用**:同步失败待办、同步连续失败预警以 - **⑥ 同步模块未启用**:同步失败待办、同步连续失败预警以
``sync_available=false`` 显式降级,不返回伪造的 0 条 ``sync_available=false`` 显式降级,不返回伪造的 0 条
- 预警阈值走参数体系(``warning.*``),一次请求只解析一次快照 - 预警阈值走参数体系(``warning.*``),一次请求只解析一次快照
- **最近动态独立分页**(2026-10-23):操作日志可能很长,聚合响应里
只能塞前 20 条;拆成独立端点后翻页只重取这张列表
""" """
from datetime import date, datetime, timedelta 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.core.month import validate_month
from app.repositories import dashboard_repository, operation_log_repository, user_repository from app.repositories import dashboard_repository, operation_log_repository, user_repository
from app.repositories.query_utils import LogFilters from app.repositories.query_utils import LogFilters
from app.schemas.dashboard import ActivityItem, ActivityPageOut, ActivityQuery
from app.services import approval_service from app.services import approval_service
# 工作台只展示摘要,完整列表跳转对应管理页 # 工作台只展示摘要,完整列表跳转对应管理页
_TODO_LIMIT = 5 _TODO_LIMIT = 5
_WARNING_LIMIT = 20 _WARNING_LIMIT = 20
_ACTIVITY_LIMIT = 20
_TREND_MONTHS = 6 _TREND_MONTHS = 6
_ORDER_TYPE_LABELS = {1: "二次分配方案", 2: "月度结算", 3: "调整单"} _ORDER_TYPE_LABELS = {1: "二次分配方案", 2: "月度结算", 3: "调整单"}
def get_dashboard(session: Session, month: str | None = None, *, current_user) -> dict: def get_dashboard(session: Session, month: str | None = None, *, current_user) -> dict:
"""四区块一次性出数(前端一次请求渲染整页)。 """三区块一次性出数(前端一次请求渲染整页)。
待办按当前用户角色只展示其可操作节点(admin 可查看全部在途单); 待办按当前用户角色只展示其可操作节点(admin 可查看全部在途单);
风险预警仍是全局经营视角,不按人裁剪。 风险预警仍是全局经营视角,不按人裁剪。
最近动态不在此返回 —— 它是分页列表,走 ``list_activities``。
""" """
resolved = _normalize_month(month) resolved = _normalize_month(month)
warning_params = _warning_params(session) warning_params = _warning_params(session)
@@ -47,7 +50,6 @@ def get_dashboard(session: Session, month: str | None = None, *, current_user) -
"overview": _overview(session, resolved), "overview": _overview(session, resolved),
"todos": _todos(session, pending_statuses), "todos": _todos(session, pending_statuses),
"warnings": _warnings(session, warning_params), "warnings": _warnings(session, warning_params),
"activities": _activities(session),
} }
@@ -112,8 +114,8 @@ def _overview(session: Session, month: str) -> dict:
for item in months for item in months
], ],
"note": ( "note": (
"绩效按台账归属月(单笔生效行,二次分摊拆行求和不失真);回款按到账月" "绩效按台账归属月统计(二次分配按参与人拆分后求和,不重复不计漏);"
"(含未核算回款);不含经理 12.5%+5% 聚合分成与调整单" "回款按到账月统计(含尚未核算的回款);不含经理团队分成与调整单"
), ),
} }
@@ -247,28 +249,38 @@ def _warnings(session: Session, params: dict) -> dict:
} }
def _activities(session: Session) -> list[dict]: def list_activities(session: Session, query: ActivityQuery) -> ActivityPageOut:
"""最近动态(FR-036):以操作日志为唯一来源(写操作即留痕)。""" """最近动态分页(FR-036):以操作日志为唯一来源(写操作即留痕)。
logs = operation_log_repository.list_logs(
session, 模块筛选项与列表同源返回(全量模块名,不随当前页变化),
operation_log_repository.OperationLogListQuery( 前端模块下拉不必另取一次全量。
"""
repo_query = operation_log_repository.OperationLogListQuery(
filters=LogFilters(), filters=LogFilters(),
offset=0, offset=query.offset,
limit=_ACTIVITY_LIMIT, 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,
) )
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 for log in logs
] ],
total=total,
page_num=query.page_num,
page_size=query.page_size,
modules=operation_log_repository.distinct_modules(session),
)
def _dec(value) -> Decimal | None: def _dec(value) -> Decimal | None:
+26 -38
View File
@@ -3,6 +3,10 @@
注册表是**迁移种子与运行期校验的唯一来源**:编码、默认值、取值域在此定义 注册表是**迁移种子与运行期校验的唯一来源**:编码、默认值、取值域在此定义
一次;新增参数 = 改这里 + 生成迁移种子(两者一致性由测试锁定)。 一次;新增参数 = 改这里 + 生成迁移种子(两者一致性由测试锁定)。
值一律以 Fraction 可解析字符串表示(如 "4/360",禁 0.011111 —— 见算法红线)。 值一律以 Fraction 可解析字符串表示(如 "4/360",禁 0.011111 —— 见算法红线)。
`description` 会直接显示在「计提参数」页的参数名下方,**面向业务人员撰写**:
说清这个参数管什么、钱怎么变、什么时候该调它。不写英文枚举名、需求编号、
内部阶段号,也不写实现层面的精度约束(那些留在代码注释与测试里)。
""" """
from dataclasses import dataclass from dataclasses import dataclass
@@ -43,7 +47,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="10", default_value="10",
value_min="0", value_min="0",
value_max=None, value_max=None,
description="线性递减段起始值:比例 = 起始 − 回款天数 × 斜率(≤分界天数)", description="回款当天能拿到的最高计提比例。回款每晚一天,按日递减斜率往下走,直到分界天数为止。",
sort_order=1, sort_order=1,
), ),
ParamSpec( ParamSpec(
@@ -57,7 +61,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="4/360", default_value="4/360",
value_min="0", value_min="0",
value_max=None, value_max=None,
description="精确分数 4/360‰/天(=1/90),禁止按 0.011111 截断存储", description="回款每延后一天,计提比例下降多少。按分数填写(如 4/360)可保持精确,填小数会引入四舍五入误差。",
sort_order=2, sort_order=2,
), ),
ParamSpec( ParamSpec(
@@ -71,7 +75,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="4", default_value="4",
value_min="0", value_min="0",
value_max=None, value_max=None,
description="回款天数超过分界天数后固定取此比例(≥361 天 = 4‰)", description="回款天数超过分界天数后,计提比例不再下降,固定按这个值计算,即最低计提比例。",
sort_order=3, sort_order=3,
), ),
ParamSpec( ParamSpec(
@@ -85,7 +89,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="360", default_value="360",
value_min="0", value_min="0",
value_max=None, value_max=None,
description="≤分界天数按公式线性递减(360 天=6‰);超过则取下限", description="回款天数在这个范围内按日递减;超过这个天数就固定按下限计提。默认 360 天。",
sort_order=4, sort_order=4,
), ),
ParamSpec( ParamSpec(
@@ -99,7 +103,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="12.5", default_value="12.5",
value_min="0", value_min="0",
value_max="100", value_max="100",
description="∑本人带领业务员绩效 × 此比例(限本团队)", description="经理按本团队业务员的绩效总额提取的分成比例,只统计自己带的业务员。",
sort_order=5, sort_order=5,
), ),
ParamSpec( ParamSpec(
@@ -113,7 +117,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="5", default_value="5",
value_min="0", value_min="0",
value_max="100", value_max="100",
description="∑全部业务员绩效 × 此比例(全公司)", description="经理按全公司业务员的绩效总额提取的分成比例,与本团队分成叠加计算。",
sort_order=6, sort_order=6,
), ),
ParamSpec( ParamSpec(
@@ -127,7 +131,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="3", default_value="3",
value_min="0", value_min="0",
value_max=None, value_max=None,
description="所有企业户适用;2026-08-13 起由 2‰ 提高至 3‰", description="企业户客户的回款统一按这个比例计提,不看回款天数。2026-08-13 起由 2‰ 调整为 3‰。",
sort_order=7, sort_order=7,
), ),
ParamSpec( ParamSpec(
@@ -141,7 +145,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="1", default_value="1",
value_min="0", value_min="0",
value_max=None, value_max=None,
description="信息员绩效 = 本人销售额 × 此比例", description="信息员的绩效按本人销售额乘这个比例计算,不随回款天数递减。",
sort_order=8, sort_order=8,
), ),
ParamSpec( ParamSpec(
@@ -155,7 +159,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="10", default_value="10",
value_min="0", value_min="0",
value_max=None, value_max=None,
description="预付款统一按此比例计提", description="预付款到账即按这个比例计提绩效,不必等到全额回款。",
sort_order=9, sort_order=9,
), ),
ParamSpec( ParamSpec(
@@ -169,7 +173,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="80", default_value="80",
value_min="0", value_min="0",
value_max="100", value_max="100",
description="以物抵顶回款绩效按应发绩效的此比例折算", description="以物抵顶的回款,按应发绩效的这个比例折算,即打折后的金额参与绩效。",
sort_order=10, sort_order=10,
), ),
ParamSpec( ParamSpec(
@@ -183,7 +187,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="60", default_value="60",
value_min="0", value_min="0",
value_max="100", value_max="100",
description="单项业务回款额达销售额(不含运费)此比例后起付;达标前累计", description="单项业务的回款累计达到销售额(不含运费)的这个比例后才开始发放绩效;达标之前只累计、不发放。",
sort_order=11, sort_order=11,
), ),
ParamSpec( ParamSpec(
@@ -197,7 +201,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="FULL", default_value="FULL",
value_min=None, value_min=None,
value_max=None, value_max=None,
description="FULL=按 R×R 乘当月全额应发绩效;BALANCE=乘余额(语义待 M3 前确认)", description="选择支付权重乘在哪个基数上:当月全额 = 乘本月全部应发绩效;余额 = 只乘尚未发放的部分。",
sort_order=12, sort_order=12,
), ),
ParamSpec( ParamSpec(
@@ -211,7 +215,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="30", default_value="30",
value_min="0", value_min="0",
value_max="100", value_max="100",
description="折算毛利润额 = 销售额 ×(毛利润率 − 阈值);市政水利 30%、轨交 20%", description="折算毛利润额 = 销售额 ×(毛利润率 − 阈值),低于阈值的部分不参与绩效。市政水利默认 30%,轨道交通默认 20%。",
sort_order=13, sort_order=13,
), ),
ParamSpec( ParamSpec(
@@ -225,7 +229,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="INCLUDE", default_value="INCLUDE",
value_min=None, value_min=None,
value_max=None, value_max=None,
description="INCLUDE含基本工资 / DEDUCT扣除基本工资 / EXCLUDE不计", description="算月工资时怎么处理基本工资:含基本工资 = 与绩效一起发;扣除基本工资 = 从绩效里扣掉已发的基本工资;不计 = 月工资只发绩效。",
sort_order=14, sort_order=14,
), ),
ParamSpec( ParamSpec(
@@ -239,7 +243,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="18000", default_value="18000",
value_min="0", value_min="0",
value_max=None, value_max=None,
description="试用期年累计绩效超过此值部分年底一次性结算", description="试用期绩效先不发放;年内累计超过这个金额的部分,年底一次性结算。",
sort_order=15, sort_order=15,
), ),
ParamSpec( ParamSpec(
@@ -253,11 +257,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="1", default_value="1",
value_min=None, value_min=None,
value_max=None, value_max=None,
description=( description="二次分配方案提交后是否要审批:开启 = 先经财务复核、再经领导审核,通过后才生效;关闭 = 提交即生效,系统自动记录各节点留痕。",
"0=二次分配方案提交即生效(系统自动通过全节点留痕,留痕不断链);"
"1=提交后进入财务复核→领导审核审批流。"
"默认开启(澄清 #5 业务答复 2026-10-19),业务可关闭"
),
sort_order=17, sort_order=17,
), ),
ParamSpec( ParamSpec(
@@ -271,11 +271,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="1", default_value="1",
value_min=None, value_min=None,
value_max=None, value_max=None,
description=( description="月度结算提交后是否要审批:开启 = 先经财务复核、再经领导审核;关闭 = 生成即生效。月结涉及工资发放,默认开启。",
"月度结算审批链开关(④ 月结提交时消费):"
"0=结算生成即生效,1=走财务复核→领导审核审批流。"
"默认开启(澄清 #7 业务答复 2026-10-19,月结涉及工资发放从严),业务可关闭"
),
sort_order=18, sort_order=18,
), ),
ParamSpec( ParamSpec(
@@ -289,11 +285,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="0", default_value="0",
value_min=None, value_min=None,
value_max=None, value_max=None,
description=( description="调整单提交后是否要审批:开启 = 先经财务复核、再经领导审核;关闭 = 提交即生效。调整单是已锁定月份补发或扣减的唯一通道。",
"0=调整单提交即生效(系统自动通过留痕);1=走财务复核→领导审核审批流。"
"调整单是已锁定月份补发/扣减的唯一通道(澄清 #9:走同一审批链,"
"默认值业务未明确,暂按直接生效,业务可开启)"
),
sort_order=19, sort_order=19,
), ),
ParamSpec( ParamSpec(
@@ -307,11 +299,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="0", default_value="0",
value_min=None, value_min=None,
value_max=None, value_max=None,
description=( description="经理提取团队分成时,是否把经理本人名下的业绩(经理个人户)也算进分成基数。默认不计入。",
"经理 12.5%+5% 聚合时是否计入经理本人名下经理个人户业绩(澄清 #12 "
"业务答复 2026-10-19:作为参数配置、手动调整是否纳入):"
"0=不计入(默认,暂口径) 1=计入"
),
sort_order=20, sort_order=20,
), ),
# ===== 预警规则(M4 ⑧ 工作台,澄清 #10「做成参数化规则便于调整」)===== # ===== 预警规则(M4 ⑧ 工作台,澄清 #10「做成参数化规则便于调整」)=====
@@ -327,7 +315,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="1", default_value="1",
value_min=None, value_min=None,
value_max=None, value_max=None,
description="工作台风险预警区块总开关:0=关闭(不扫描不展示) 1=开启(默认)", description="工作台风险预警区块的总开关:关闭后不再扫描、也不展示预警条目。默认开启。",
sort_order=21, sort_order=21,
), ),
ParamSpec( ParamSpec(
@@ -341,7 +329,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="90", default_value="90",
value_min="1", value_min="1",
value_max=None, value_max=None,
description=("发货后超过该天数仍未回款的发货批次触发预警(澄清 #10 建议默认 90 天)"), description="发货后超过这么多天仍未收到回款的发货批次,会出现在工作台的预警列表里。默认 90 天。",
sort_order=22, sort_order=22,
), ),
ParamSpec( ParamSpec(
@@ -355,7 +343,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="3", default_value="3",
value_min="1", value_min="1",
value_max=None, value_max=None,
description=("待审批单提交后超过该天数未处理触发预警(澄清 #10 建议默认 3 天)"), description="审批单提交后超过这么多天还没处理,会出现在工作台的预警列表里。默认 3 天。",
sort_order=23, sort_order=23,
), ),
] ]
+19 -9
View File
@@ -44,7 +44,7 @@ from app.schemas.param import (
ParamPreviewOut, ParamPreviewOut,
ParamUsedOut, 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.audit_service import AuditRecord, RequestMeta
from app.services.formula import to_decimal from app.services.formula import to_decimal
from app.services.no_generator import next_no from app.services.no_generator import next_no
@@ -557,25 +557,25 @@ def _preview_rate(ctx: _PreviewContext, days: int) -> tuple[Fraction, str, Decim
# 存储口径为 ‰ 数(如 "3"),除以 1000 转纯比例 —— performance_amount # 存储口径为 ‰ 数(如 "3"),除以 1000 转纯比例 —— performance_amount
# 按纯比例相乘,漏换算会把绩效金额放大 1000 倍(与业务员分支同防线) # 按纯比例相乘,漏换算会把绩效金额放大 1000 倍(与业务员分支同防线)
rate = take("enterprise.rate") / 1000 rate = take("enterprise.rate") / 1000
return rate, "企业户固定计提比例", None return rate, "企业户:不按回款天数,统一按固定比例计提", None
role_code = payload.business_role role_code = payload.business_role
template = "TPL_SALESPERSON_DECAY" template = "TPL_SALESPERSON_DECAY"
if role_code: if role_code:
role = business_role_repository.get_by_code(ctx.session, role_code) role = business_role_repository.get_by_code(ctx.session, role_code)
if role is None: if role is None:
raise BadRequestError(f"岗位 {role_code!r} 不存在") raise BadRequestError(f"岗位「{role_code}」不存在,请到岗位角色里确认")
template = role.calc_template template = role.calc_template
if template == "TPL_MANAGER_SHARE": if template == "TPL_MANAGER_SHARE":
raise BadRequestError( raise BadRequestError(
"营销经理绩效为团队聚合口径(本团队 12.5% + 全公司 5%),不支持单笔预览" "营销经理的绩效按团队与全公司分成计算,不单笔试算;请到月度结算里查看"
) )
if template == "TPL_INFO_SALES_FIXED": if template == "TPL_INFO_SALES_FIXED":
# ‰ 存储口径 → 纯比例,同企业户分支(不换算则绩效放大 1000 倍) # ‰ 存储口径 → 纯比例,同企业户分支(不换算则绩效放大 1000 倍)
rate = take("info.rate") / 1000 rate = take("info.rate") / 1000
# 信息员的基数是本人销售额(无折算与阈值),覆盖默认基数 # 信息员的基数是本人销售额(无折算与阈值),覆盖默认基数
return rate, "信息员:本人销售额 × 计提比例", Decimal(payload.revenue) return rate, "信息员:按本人销售额乘计提比例,不随回款天数递减", Decimal(payload.revenue)
if template == "TPL_SALESPERSON_DECAY": if template == "TPL_SALESPERSON_DECAY":
start = take("salesperson.rate.start") start = take("salesperson.rate.start")
slope = take("salesperson.rate.slope") slope = take("salesperson.rate.slope")
@@ -584,20 +584,27 @@ def _preview_rate(ctx: _PreviewContext, days: int) -> tuple[Fraction, str, Decim
rate = formula.salesperson_rate( rate = formula.salesperson_rate(
days, start=start, slope=slope, floor=floor, boundary_days=boundary days, start=start, slope=slope, floor=floor, boundary_days=boundary
) )
# 说明写成业务话:说清这笔为什么是这个比例、什么时候不再降
return ( return (
rate, rate,
f"业务员递减:{to_decimal(start)}‰ 起,{days} 天(分界 {boundary} 天,超界取 {to_decimal(floor)}‰)", f"回款 {days} 天,从起始 {to_decimal(start)}‰ 按日递减"
f"(满 {boundary} 天后固定按 {to_decimal(floor)}‰ 计算)",
None, 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: def _preview_rate_decision(ctx: _PreviewContext) -> _PreviewRateDecision:
"""产品类别校验 + 回款天数 + 计提比例判定。""" """产品类别校验 + 回款天数 + 计提比例判定。"""
payload = ctx.payload payload = ctx.payload
if payload.product_category and payload.product_category not in PRODUCT_CATEGORIES: if payload.product_category and payload.product_category not in PRODUCT_CATEGORIES:
# 提示里用中文类别名:前端下拉显示的是「市政水利」,报错却回英文编码会让人对不上
raise BadRequestError( raise BadRequestError(
f"产品类别 {payload.product_category!r} 不存在,可选:municipal_water / rail_transit" f"产品类别「{payload.product_category}」不存在,可选:市政水利、轨道交通"
) )
days = formula.payment_days(payload.ship_date, payload.receipt_date) days = formula.payment_days(payload.ship_date, payload.receipt_date)
rate, rate_text, base_override = _preview_rate(ctx, days) rate, rate_text, base_override = _preview_rate(ctx, days)
@@ -651,7 +658,10 @@ def _preview_probation_note(ctx: _PreviewContext) -> str | None:
if not ctx.payload.probation: if not ctx.payload.probation:
return None return None
annual = ctx.take("probation.annual.threshold") annual = ctx.take("probation.annual.threshold")
return f"试用期绩效照算不发放;年累计超 {to_decimal(annual)} 元部分年底一次性结算" return (
f"试用期人员:绩效照常计算但不发放;年内累计超过 {to_decimal(annual)} 元的部分,"
"年底一次性结清。"
)
def preview(session: Session, payload: ParamPreviewIn) -> ParamPreviewOut: def preview(session: Session, payload: ParamPreviewIn) -> ParamPreviewOut:
+3 -2
View File
@@ -423,9 +423,10 @@ def _aggregate_row(
"members": member_names, "members": member_names,
"params_as_of": str(share.param_date), "params_as_of": str(share.param_date),
"manager_personal_included": 1 if share.include_manager_personal else 0, "manager_personal_included": 1 if share.include_manager_personal else 0,
# 说明写给业务人员看:说清这一笔的基数是怎么凑出来的
"note": ( "note": (
f"#12 参数化:基数{'含' if share.include_manager_personal else '不含'}" f"团队分成:基数{'含' if share.include_manager_personal else '不含'}"
"经理个人户业绩;9.8 方案公式" "经理本人名下业绩,按本团队与全公司两级比例合计计提"
), ),
}, },
), ),
+5 -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_personnel.py` | 未登录 401、新增默认在职、工资标准 str 直出、经理自引用/不存在或已离职/岗位停用 400、删除保护(团队成员与客户归属引用→引导改离职)、辐射区域与拓展计划全量替换(含清空与详情回显)、在职/离职切换、审计成功与失败双记录 | 20 |
| `tests/integration/test_products.py` | 未登录 401、类别值域 400、名称+规格组合唯一(同名称不同规格放行)、单价负数/非数字 400、启停切换、改类别、审计留痕 | 14 | | `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 完成) ### 业务数据管理(2026-10-13 完成)
@@ -209,6 +209,9 @@ uv run pytest tests/integration -q # 仅集成
| 2026-10-19 | 单元全量 + 报表定点 | 484 | ✅ 484 passed(45.94s);报表单元 11 例 0.70s | **M4 报表模块收尾**(前端四页/打印视图/导出与单元测试补齐)。ruff 绿;前端 `pnpm type-check` 绿。报表集成 7 例记录见上节 | | 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-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%` 断言未动(那是分数原文与百分比拼装,非说明文案) |
## 四、测试发现并修复的缺陷 ## 四、测试发现并修复的缺陷
| 日期 | 缺陷 | 影响 | 处理 | | 日期 | 缺陷 | 影响 | 处理 |
@@ -376,3 +376,73 @@ class TestAudit:
latest = logs[0] latest = logs[0]
assert customer["company_name"] in latest.summary, "摘要应含客户名" assert customer["company_name"] in latest.summary, "摘要应含客户名"
assert "→" 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"], "未评分必须垫底"
+44 -3
View File
@@ -118,10 +118,20 @@ def _dashboard(client: TestClient, headers: dict, month: str | None = None) -> d
return resp.json()["data"] 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): def test_权限_普通用户403文案(client: TestClient, normal: dict):
resp = client.get(_DASHBOARD, headers=normal["headers"]) resp = client.get(_DASHBOARD, headers=normal["headers"])
assert resp.status_code == 403 assert resp.status_code == 403
assert resp.json()["message"] == "无权访问工作台" 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): def test_总览_绩效合计与台账一致且非法月份400(client: TestClient, admin: dict):
@@ -221,9 +231,40 @@ def test_预警_审批超时命中且参数关闭后不产生(client: TestClient
def test_动态_来源操作日志(client: TestClient, admin: dict): def test_动态_来源操作日志(client: TestClient, admin: dict):
_chain(client, admin["headers"]) _chain(client, admin["headers"])
data = _dashboard(client, admin["headers"]) data = _activities(client, admin["headers"])
assert data["activities"], "工作台动态为空:写操作应产生操作日志" assert data["list"], "工作台动态为空:写操作应产生操作日志"
assert {"module", "action", "summary", "create_time"} <= set(data["activities"][0]) 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 修复回归)==== # ==== 角色待办 / 预警阈值 / 趋势(2026-10-19 code-review 修复回归)====
@@ -371,7 +371,11 @@ class TestPreview:
} }
resp = client.post(f"{_PARAMS}/preview", json=payload, headers=admin["headers"]) resp = client.post(f"{_PARAMS}/preview", json=payload, headers=admin["headers"])
assert resp.status_code == 400 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: def test_回款日早于发货日_400(self, client: TestClient, admin: dict) -> None:
payload = { payload = {
+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', customer_type VARCHAR(32) NOT NULL COMMENT '客户分类:personal个人户 manager_personal经理个人户 enterprise企业户(计提规则匹配锚点;T+同步不覆盖)' DEFAULT 'personal',
owner_personnel_id BIGINT UNSIGNED COMMENT '归属负责营销人员ID(手动修改不回溯历史,变更留痕见 ownership_change_logs)', owner_personnel_id BIGINT UNSIGNED COMMENT '归属负责营销人员ID(手动修改不回溯历史,变更留痕见 ownership_change_logs)',
remark VARCHAR(255) COMMENT '备注', 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, create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
source_no VARCHAR(64) COMMENT 'T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)', 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); (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 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), (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), (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, '回款天数超过分界天数后固定取此比例(≥361 天 = 4‰)', 3), (3, 'salesperson.rate.floor', '业务员计提下限', '业务员个人户', 'DECIMAL', '‰', 'BUSINESS_ROLE', NULL, '4', '0', NULL, '回款天数超过分界天数后,计提比例不再下降,固定按这个值计算,即最低计提比例。', 3),
(4, 'salesperson.days.boundary', '线性递减分界天数', '业务员个人户', 'INT', '天', 'BUSINESS_ROLE', NULL, '360', '0', NULL, '≤分界天数按公式线性递减(360 天=6‰);超过则取下限', 4), (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), (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), (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), (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), (8, 'info.rate', '信息员计提比例', '信息员', 'DECIMAL', '‰', 'BUSINESS_ROLE', NULL, '1', '0', NULL, '信息员的绩效按本人销售额乘这个比例计算,不随回款天数递减。', 8),
(9, 'prepay.rate', '预付款计提比例', '支付环节', 'DECIMAL', '‰', 'NONE', NULL, '10', '0', NULL, '预付款统一按此比例计提', 9), (9, 'prepay.rate', '预付款计提比例', '支付环节', 'DECIMAL', '‰', 'NONE', NULL, '10', '0', NULL, '预付款到账即按这个比例计提绩效,不必等到全额回款。', 9),
(10, 'barter.discount', '以物抵顶折算比例', '支付环节', 'DECIMAL', '%', 'NONE', NULL, '80', '0', '100', '以物抵顶回款绩效按应发绩效的此比例折算', 10), (10, 'barter.discount', '以物抵顶折算比例', '支付环节', 'DECIMAL', '%', 'NONE', NULL, '80', '0', '100', '以物抵顶的回款,按应发绩效的这个比例折算,即打折后的金额参与绩效。', 10),
(11, 'payment.start.ratio', '支付起付比例', '支付环节', 'DECIMAL', '%', 'NONE', NULL, '60', '0', '100', '单项业务回款额达销售额(不含运费)此比例后起付;达标前累计', 11), (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), (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), (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), (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), (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), (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, '月度结算审批链开关(④ 月结提交时消费):0=结算生成即生效,1=走财务复核→领导审核审批流。默认开启(澄清 #7 业务答复 2026-10-19,月结涉及工资发放从严),业务可关闭', 18), (18, 'approval.monthly_settlement.enabled', '月度结算审批开关', '审批开关', 'BOOL', NULL, 'NONE', NULL, '1', NULL, NULL, '月度结算提交后是否要审批:开启 = 先经财务复核、再经领导审核;关闭 = 生成即生效。月结涉及工资发放,默认开启。', 18),
(19, 'approval.adjustment.enabled', '调整单审批开关', '审批开关', 'BOOL', NULL, 'NONE', NULL, '0', NULL, NULL, '0=调整单提交即生效(系统自动通过留痕);1=走财务复核→领导审核审批流。调整单是已锁定月份补发/扣减的唯一通道(澄清 #9:走同一审批链,默认值业务未明确,暂按直接生效,业务可开启)', 19), (19, 'approval.adjustment.enabled', '调整单审批开关', '审批开关', 'BOOL', NULL, 'NONE', NULL, '0', NULL, NULL, '调整单提交后是否要审批:开启 = 先经财务复核、再经领导审核;关闭 = 提交即生效。调整单是已锁定月份补发或扣减的唯一通道。', 19),
(20, 'manager.personal.included', '经理个人户计入聚合基数', '经理分成', 'BOOL', NULL, 'NONE', NULL, '0', NULL, NULL, '经理 12.5%+5% 聚合时是否计入经理本人名下经理个人户业绩(澄清 #12 业务答复 2026-10-19:作为参数配置、手动调整是否纳入):0=不计入(默认,暂口径) 1=计入', 20), (20, 'manager.personal.included', '经理个人户计入聚合基数', '经理分成', 'BOOL', NULL, 'NONE', NULL, '0', NULL, NULL, '经理提取团队分成时,是否把经理本人名下的业绩(经理个人户)也算进分成基数。默认不计入。', 20),
-- 预警规则参数(M4 ⑧ 工作台,澄清 #10「做成参数化规则便于调整」) -- 预警规则参数:工作台预警阈值,可按业务需要调整
(21, 'warning.enabled', '风险预警总开关', '预警规则', 'BOOL', NULL, 'NONE', NULL, '1', NULL, NULL, '工作台风险预警区块总开关:0=关闭(不扫描不展示) 1=开启(默认)', 21), (21, 'warning.enabled', '风险预警总开关', '预警规则', 'BOOL', NULL, 'NONE', NULL, '1', NULL, NULL, '工作台风险预警区块的总开关:关闭后不再扫描、也不展示预警条目。默认开启。', 21),
(22, 'warning.receipt.overdue.days', '回款逾期天数阈值', '预警规则', 'INT', '天', 'NONE', NULL, '90', '1', NULL, '发货后超过该天数仍未回款的发货批次触发预警(澄清 #10 建议默认 90 天)', 22), (22, 'warning.receipt.overdue.days', '回款逾期天数阈值', '预警规则', 'INT', '天', 'NONE', NULL, '90', '1', NULL, '发货后超过这么多天仍未收到回款的发货批次,会出现在工作台的预警列表里。默认 90 天。', 22),
(23, 'warning.approval.timeout.days', '审批超时天数阈值', '预警规则', 'INT', '天', 'NONE', NULL, '3', '1', NULL, '待审批单提交后超过该天数未处理触发预警(澄清 #10 建议默认 3 天)', 23); (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 INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES
(1, 1, 'salesperson', '2026-02-17', '10', 1), (1, 1, 'salesperson', '2026-02-17', '10', 1),
@@ -891,4 +893,4 @@ CREATE TABLE alembic_version (
CONSTRAINT alembic_version_pkc PRIMARY KEY (version_num) CONSTRAINT alembic_version_pkc PRIMARY KEY (version_num)
)CHARSET=utf8mb4 ENGINE=InnoDB COLLATE utf8mb4_general_ci; )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`;
+270 -13
View File
@@ -5,12 +5,22 @@
<title>衍水工资系统 · 视觉方向预览</title> <title>衍水工资系统 · 视觉方向预览</title>
<style> <style>
/* ============================================================ /* ============================================================
设计体系 v4(同步已交付模块):中性灰白 · 靛蓝点缀 · 微质感 设计体系 v4.2(侧栏层级口径改版):中性灰白 · 靛蓝点缀 · 微质感
v4.1 → v4.2 变更(2026-10-23):侧栏层级口径改版 + 工作台动态分页,不动色板/字体/组件基调
1) 组标题与菜单项同字号 13px —— 层级靠缩进与留白,不再靠字重压过
2) 去掉组标题的靛蓝竖标与激活项左侧竖条,激活只留白底浮起 + 靛蓝字
3) 子项缩进收到 12px,菜单图标父子统一 16px
4) §05 最近动态改服务端分页(每页 10/20/50 条 + 模块筛选 + 分页条)
v3 → v4 变更(2026-10-22):设计稿与已交付功能对齐 v3 → v4 变更(2026-10-22):设计稿与已交付功能对齐
1) 侧栏菜单改为真实路由分组(5 组 + 首页 = 21 项),组可折叠 1) 侧栏菜单改为真实路由分组(5 组 + 首页 = 21 项),组可折叠
2) 首页工作台按真实四区块重画(总览 / 待办 / 预警 / 动态) 2) 首页工作台按真实四区块重画(总览 / 待办 / 预警 / 动态)
3) 新增 §09:月结、审批、报表、打印稿(v3 缺 M3/M4 页面) 3) 新增 §09:月结、审批、报表、打印稿(v3 缺 M3/M4 页面)
4) token 与 frontend/src/assets/styles/index.css 对齐 4) token 与 frontend/src/assets/styles/index.css 对齐
v4 → v4.1 变更(2026-10-23):浏览器实测补订,不改色板/字体/组件基调
1) §03 表格行高定为 1.5(组件库默认 23px 会把单行数据行撑到 51px)
2) §09 月结生命周期条:四段圆点按阶段着色 + 条右侧操作提示
3) §05 工作台:卡片内空态在剩余高度内居中
4) 新增 §10:表单页版式(公司信息页)
质感来源(而非装饰): 质感来源(而非装饰):
1) 表面微渐变 —— 1–2% 明度差,暗示受光方向 1) 表面微渐变 —— 1–2% 明度差,暗示受光方向
2) 半透明描边 —— 边界在不同底色上自然融合 2) 半透明描边 —— 边界在不同底色上自然融合
@@ -259,10 +269,8 @@
background: linear-gradient(180deg, #ffffff, #fbfcfe); background: linear-gradient(180deg, #ffffff, #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); 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);
} }
.menu-item.active::before { /* 子项缩进一档(12px):层级是位置关系,不是字号关系 */
content: ''; position: absolute; left: -14px; top: 8px; bottom: 8px; width: 2px; .menu-group-head ~ .menu-item { padding-left: 22px; }
border-radius: 1px; background: var(--ys-indigo);
}
.menu-item svg { width: 16px; height: 16px; flex: none; opacity: 0.7; } .menu-item svg { width: 16px; height: 16px; flex: none; opacity: 0.7; }
.menu-item.active svg { opacity: 1; } .menu-item.active svg { opacity: 1; }
.aside-foot { margin-top: auto; padding: 0 10px 2px; } .aside-foot { margin-top: auto; padding: 0 10px 2px; }
@@ -418,14 +426,17 @@
.login-btn { width: 100%; height: 38px; margin-top: 6px; font-size: 14px; } .login-btn { width: 100%; height: 38px; margin-top: 6px; font-size: 14px; }
/* ---------- 菜单分组(路由 meta.group 派生的可折叠组) ---------- */ /* ---------- 菜单分组(路由 meta.group 派生的可折叠组) ---------- */
/*
* 层级口径(v4.2):父子同字号 13px —— 组标题与条目一样大,
* 结构靠「折叠箭头 + 组间留白 + 子项缩进一档」表达,不靠字号压过。
*/
.menu-group-head { .menu-group-head {
display: flex; align-items: center; justify-content: space-between; display: flex; align-items: center; justify-content: space-between;
padding: 9px 10px; margin-bottom: 4px; border-radius: var(--ys-radius-sm); padding: 9px 10px; margin-bottom: 4px; border-radius: var(--ys-radius-sm);
font-size: 11px; letter-spacing: 0.1em; color: var(--ys-ink-3); font-size: 13px; font-weight: 500; color: var(--ys-ink);
} }
.menu-group.open .menu-group-head { color: var(--ys-indigo); background: rgba(255, 255, 255, 0.45); } .menu-group-head .chev { width: 11px; height: 11px; flex: none; opacity: 0.55; color: var(--ys-ink-3); }
.menu-group-head .chev { width: 11px; height: 11px; flex: none; opacity: 0.55; } .menu-group.open .menu-group-head .chev { opacity: 0.9; color: var(--ys-ink-2); transform: rotate(90deg); }
.menu-group.open .menu-group-head .chev { opacity: 0.9; transform: rotate(90deg); }
/* ---------- 工作台卡片内的列表行 ---------- */ /* ---------- 工作台卡片内的列表行 ---------- */
.b-count { .b-count {
@@ -444,7 +455,11 @@
.b-tag-danger { color: var(--ys-danger); background: var(--ys-danger-wash); } .b-tag-danger { color: var(--ys-danger); background: var(--ys-danger-wash); }
.b-tag-info { color: var(--ys-indigo); background: var(--ys-indigo-wash); } .b-tag-info { color: var(--ys-indigo); background: var(--ys-indigo-wash); }
.b-tag-ok { color: var(--ys-ok); background: var(--ys-ok-wash-1); } .b-tag-ok { color: var(--ys-ok); background: var(--ys-ok-wash-1); }
.b-empty { font-size: 12px; color: var(--ys-ink-3); margin-top: 6px; } /* 空态:在卡片剩余高度内居中 —— 全挤在顶部会像「没加载完」 */
.b-empty {
display: flex; flex: 1; align-items: center; justify-content: center;
font-size: 12px; color: var(--ys-ink-3); margin-top: 6px; text-align: center;
}
/* 最近动态:时间 / 模块·动作 / 摘要 / 操作人 —— 四段各自成块,不并排挤在一行 */ /* 最近动态:时间 / 模块·动作 / 摘要 / 操作人 —— 四段各自成块,不并排挤在一行 */
.b-act { .b-act {
display: flex; align-items: center; gap: 10px; display: flex; align-items: center; gap: 10px;
@@ -579,6 +594,83 @@
.states { display: flex; gap: 14px; margin-top: 12px; } .states { display: flex; gap: 14px; margin-top: 12px; }
.state { text-align: center; } .state { text-align: center; }
.state .cap { font-size: 11px; color: var(--ys-ink-3); margin-top: 6px; } .state .cap { font-size: 11px; color: var(--ys-ink-3); margin-top: 6px; }
/* ---------- 表单页(公司信息):§10 ---------- */
.set-layout { display: grid; grid-template-columns: minmax(0, 1fr) 480px; gap: 14px; align-items: start; }
.set-col { display: flex; flex-direction: column; gap: 14px; min-width: 0; }
.set-card-head {
display: flex; align-items: center; justify-content: space-between; gap: 12px;
padding: 13px 16px; border-bottom: 1px solid var(--ys-line-soft);
}
.set-card-title { font-size: 15px; font-weight: 600; color: var(--ys-ink); }
.set-card-note { font-size: 12px; color: var(--ys-ink-3); }
.set-row { display: grid; grid-template-columns: 92px minmax(0, 420px) minmax(0, 1fr); column-gap: 20px; align-items: center; }
.set-row + .set-row { margin-top: 18px; padding-top: 18px; border-top: 1px solid var(--ys-line-soft); }
.set-label { font-size: 13px; color: var(--ys-ink-2); text-align: right; }
.set-label .req { color: var(--ys-danger); margin-right: 4px; }
.set-note { font-size: 12px; color: var(--ys-ink-3); line-height: 1.7; text-align: right; }
.logo-box {
display: flex; align-items: center; justify-content: center;
width: 96px; height: 96px;
background: linear-gradient(180deg, #f7f8fb, #f1f3f8);
border: 1px dashed rgba(28, 35, 51, 0.22); border-radius: 10px;
}
.logo-box .plus { font-size: 26px; font-weight: 300; line-height: 1; color: var(--ys-ink-3); }
.scope-list { display: grid; grid-template-columns: 92px minmax(0, 1fr); column-gap: 20px; }
.scope-list + .scope-list { margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--ys-line-soft); }
.scope-list dt { font-size: 13px; font-weight: 500; color: var(--ys-ink-2); }
.scope-list dd { margin: 0; font-size: 12.5px; color: var(--ys-ink-3); }
/* 实景预览:登录页 1:1 复刻(控件高度 / 字号 / logo 展示位与真实登录页一致) */
.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);
}
.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 rgba(28, 35, 51, 0.09); border-radius: 14px;
box-shadow: 0 2px 4px rgba(28, 35, 51, 0.04), 0 16px 40px rgba(28, 35, 51, 0.1);
}
.mc-brand { text-align: center; }
.mc-logo { display: block; max-width: 56px; max-height: 36px; margin: 0 auto 10px; object-fit: contain; }
.mc-name { font-family: var(--ys-font-brand); font-size: 17px; font-weight: 500; letter-spacing: 0.34em; color: var(--ys-ink); }
.mc-slogan { margin: 8px 0 0; font-size: 12.5px; color: var(--ys-ink-3); }
.mc-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));
}
.mc-head { margin-top: 26px; }
.mc-title { font-size: 15px; font-weight: 600; color: var(--ys-ink); }
.mc-hint { margin-top: 4px; font-size: 12px; color: var(--ys-ink-3); }
.mc-field { margin-top: 16px; }
.mc-field + .mc-field { margin-top: 18px; }
.mc-label { display: block; margin-bottom: 6px; font-size: 13px; line-height: 22px; color: var(--ys-ink-2); }
.mc-box { display: block; height: 38px; background: #f4f5f8; border: 1px solid rgba(28, 35, 51, 0.07); border-radius: 7px; }
.mc-captcha-row { display: flex; gap: 10px; }
.mc-captcha-row .mc-box { flex: 1; }
.mc-captcha {
flex: 0 0 104px; height: 38px; border-radius: 7px;
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);
}
.mc-submit {
height: 38px; margin-top: 20px; font-size: 13px; line-height: 38px;
letter-spacing: 0.4em; text-align: center; color: #fff;
background: linear-gradient(180deg, var(--ys-indigo-hi), var(--ys-indigo));
border-radius: 7px;
}
.mc-foot { margin-top: 16px; font-size: 11px; letter-spacing: 0.08em; color: var(--ys-ink-3); }
.preview-cap { margin: 12px 0 0; font-size: 11px; text-align: center; color: var(--ys-ink-3); }
</style> </style>
</head> </head>
<body> <body>
@@ -590,9 +682,12 @@
<h1>营销人员工资管理系统 · 视觉方向</h1> <h1>营销人员工资管理系统 · 视觉方向</h1>
<div class="sub">企业级扁平 · 轻质感 · Bento 布局 · 品牌色点缀 · 微动效 · 精致文案</div> <div class="sub">企业级扁平 · 轻质感 · Bento 布局 · 品牌色点缀 · 微动效 · 精致文案</div>
<div class="sub" style="margin-top:6px"> <div class="sub" style="margin-top:6px">
<b style="color:var(--ys-ink);font-weight:500">v4(2026-10-22 同步版)</b> <b style="color:var(--ys-ink);font-weight:500">v4.1(2026-10-23 补订)</b>
—— 本稿与已交付功能对齐:菜单按真实路由分组、工作台按真实四区块重画,并补齐月结 / 审批 / 报表 / 打印稿。 —— 本稿与已交付功能对齐:菜单按真实路由分组、工作台按真实四区块重画,并补齐月结 / 审批 / 报表 / 打印稿。
视觉语言沿用 v3 质感版,未改色板、未改字体、未改组件基调。 视觉语言沿用 v3 质感版,未改色板、未改字体、未改组件基调。
<br />
v4.1 补订三处实测偏差与一张表单页:表格行高 1.5(§03)、月结生命周期条按阶段着色 + 操作提示(§09)、
工作台空态在卡内居中(§05)、公司信息页版式(§10 新增)。
</div> </div>
<div class="waterline"></div> <div class="waterline"></div>
<div class="tags"> <div class="tags">
@@ -605,6 +700,8 @@
<span class="tag">120–180ms 轻动效</span> <span class="tag">120–180ms 轻动效</span>
<span class="tag">21 项菜单 · 5 组</span> <span class="tag">21 项菜单 · 5 组</span>
<span class="tag">工作台四区块</span> <span class="tag">工作台四区块</span>
<span class="tag">表格行高 1.5</span>
<span class="tag">表单页两列</span>
</div> </div>
</header> </header>
@@ -759,6 +856,30 @@
动效的目标是「顺滑可信」,不是表演。 动效的目标是「顺滑可信」,不是表演。
</div> </div>
</div> </div>
<div class="comp-card">
<h3>表格 · 行高</h3>
<table class="grid" style="margin-bottom:12px">
<thead>
<tr><th>结算月份</th><th>状态</th><th style="text-align:right">应付合计</th></tr>
</thead>
<tbody>
<tr>
<td>2026-10</td>
<td><span class="pill pill-wait">待审批</span></td>
<td class="cell-num">241,876.40</td>
</tr>
<tr>
<td>2026-09</td>
<td><span class="pill pill-lock">已审定(锁定)</span></td>
<td class="cell-num">226,338.10</td>
</tr>
</tbody>
</table>
<div class="note">
单元格<b>行高 1.5</b>(13px 字 + 12px 上下内距 → 单行数据行 44px)。
组件库默认的 23px 行高(≈1.77 倍)会把单行撑到 51px,一屏少看两行。
</div>
</div>
</div> </div>
</section> </section>
@@ -924,7 +1045,9 @@
<tr><td>不进菜单</td><td>报表打印视图(独立顶层路由)</td><td class="num">浏览器打印只出报表本身</td></tr> <tr><td>不进菜单</td><td>报表打印视图(独立顶层路由)</td><td class="num">浏览器打印只出报表本身</td></tr>
</table> </table>
<div class="note"> <div class="note">
侧栏是<b>单列树</b>:组标题可折叠,当前页所在组默认展开,当前项白底浮起 + 左侧 2px 靛蓝竖条。 侧栏是<b>单列树</b>:组标题可折叠,当前页所在组默认展开,当前项白底浮起 + 靛蓝字(不再叠左侧竖条)。
<b>层级口径(v4.2)</b>:父子<b>同字号 13px</b>,组标题不靠字号/字重压过子项——
结构由「折叠箭头 + 组间留白 + 子项缩进 12px」表达;此前的 11px 目录行版与 14px/600 竖标版都不采用。
菜单图标(16px、静止 0.7 / 激活 1、组标题不加图标)已于 2026-10-22 落地: 菜单图标(16px、静止 0.7 / 激活 1、组标题不加图标)已于 2026-10-22 落地:
路由 <code>meta.icon</code> 只写语义 key,图标注册在 <code>menuIcons.ts</code>。 路由 <code>meta.icon</code> 只写语义 key,图标注册在 <code>menuIcons.ts</code>。
本稿画的是手绘线性稿,实际取自 Element Plus 图标库的线性变体——<b>形状以图标库为准</b>, 本稿画的是手绘线性稿,实际取自 Element Plus 图标库的线性变体——<b>形状以图标库为准</b>,
@@ -1063,9 +1186,14 @@
<div class="b-eyebrow">最近动态 <div class="b-eyebrow">最近动态
<span style="margin-left:auto;font-size:11px;color:var(--ys-ink-3);border:1px solid var(--ys-line);border-radius:var(--ys-radius-sm);padding:1px 8px;background:#fff">全部模块 ▾</span> <span style="margin-left:auto;font-size:11px;color:var(--ys-ink-3);border:1px solid var(--ys-line);border-radius:var(--ys-radius-sm);padding:1px 8px;background:#fff">全部模块 ▾</span>
</div> </div>
<div class="todo-list" style="max-height:40px;margin-top:4px"> <div class="todo-list" style="margin-top:4px">
<div class="b-act"><span class="tm">10-22 09:12</span><span class="b-tag b-tag-info">核算 · 月结</span><span class="sum">生成 2026-10 月结草稿,绩效合计 268,430.16 元</span><span class="us">张岚</span></div> <div class="b-act"><span class="tm">10-22 09:12</span><span class="b-tag b-tag-info">核算 · 月结</span><span class="sum">生成 2026-10 月结草稿,绩效合计 268,430.16 元</span><span class="us">张岚</span></div>
<div class="b-act"><span class="tm">10-21 16:40</span><span class="b-tag b-tag-info">回款 · 新增</span><span class="sum">录入回款 86,400.00 元(业务 BS20261018)</span><span class="us">王浩</span></div> <div class="b-act"><span class="tm">10-21 16:40</span><span class="b-tag b-tag-info">回款 · 新增</span><span class="sum">录入回款 86,400.00 元(业务 BS20261018)</span><span class="us">王浩</span></div>
<div class="b-act"><span class="tm">10-21 14:32</span><span class="b-tag b-tag-info">业务 · 新增</span><span class="sum">创建业务 YW20261022-0001(客户ID=166,明细 1 行)</span><span class="us">王浩</span></div>
</div>
<div class="pager" style="margin-top:10px;justify-content:flex-end">
<span class="pg">‹</span><span class="pg cur">1</span><span class="pg">2</span><span class="pg">›</span>
<span style="margin-left:10px">共 128 条 · 每页 10 条</span>
</div> </div>
</div> </div>
@@ -1089,6 +1217,9 @@
待办与预警并排 2×2 → 最近动态与常用操作整行铺满(<code>b-full</code>)。 待办与预警并排 2×2 → 最近动态与常用操作整行铺满(<code>b-full</code>)。
卡片入场 <b>45ms 交错上浮(320ms)</b>,悬停抬升 2px、影加深。 卡片入场 <b>45ms 交错上浮(320ms)</b>,悬停抬升 2px、影加深。
待办与预警的列表<b>在卡内滚动</b>(上限 178px)——条目再多也不撑开网格,行高恒定。 待办与预警的列表<b>在卡内滚动</b>(上限 178px)——条目再多也不撑开网格,行高恒定。
<b>最近动态走服务端分页</b>(2026-10-23):列表不再靠卡内滚动藏条目,每页 10/20/50 条,
底部接右对齐分页条(与列表页同一套 <code>AppPagination</code>);模块筛选同步到服务端。
空态(<code>b-empty</code>)在卡片剩余高度内<b>垂直居中</b>,不全部挤在顶部留一整块空白。
</div> </div>
</section> </section>
@@ -1272,6 +1403,9 @@
月结是<b>锁点</b>:审定后绩效与应付合计不可改,只能靠调整单纠偏,状态条与列表同步体现这件事。 月结是<b>锁点</b>:审定后绩效与应付合计不可改,只能靠调整单纠偏,状态条与列表同步体现这件事。
状态标签分四档——草稿(中性灰)、待审批(琥珀)、已审定(靛蓝)、已驳回(砖红), 状态标签分四档——草稿(中性灰)、待审批(琥珀)、已审定(靛蓝)、已驳回(砖红),
颜色之外还有文字,不靠颜色单独表意。 颜色之外还有文字,不靠颜色单独表意。
生命周期条上<b>四段圆点按阶段着色</b>(草稿中性 / 待审批琥珀 / 已审定靛蓝 / 已驳回砖红),
与选中与否无关;条右侧挂一行操作提示「点状态只看该阶段,再点一次取消」——
没有这行提示时,未选中态的四段读起来像四颗孤立的小按钮,不像一条流程。
</div> </div>
<!-- 09-2 审批管理 --> <!-- 09-2 审批管理 -->
@@ -1500,6 +1634,129 @@
</div> </div>
</section> </section>
<!-- ============ 表单页 ============ -->
<section class="section" id="sec-form">
<h2>10 · 表单页(公司信息)</h2>
<div class="note" style="margin-top:-6px;margin-bottom:18px">
设置类表单页最容易犯的错是<b>把表单缩在左上角一小块</b>:内容区再宽也只占三分之一,
右侧留下大片空白,页面像没做完。本节定下表单页的版式口径,落地页见
<code>frontend/src/views/system/settings/index.vue</code>。
</div>
<div class="app-shot">
<div class="canvas">
<div class="topbar">
<div class="crumb">系统管理 <span>/</span> <b>公司信息</b></div>
<div class="who"><span class="avatar">管</span><span>管理员</span><span class="logout">退出登录</span></div>
</div>
<div class="content">
<div class="page-head">
<div>
<h1>公司信息</h1>
<div class="desc">维护公司名称与 logo;保存后立即作用于登录页品牌位与导出页眉</div>
<div class="waterline"></div>
</div>
</div>
<div class="set-layout">
<!-- 左列:基础信息 + 生效范围 -->
<div class="set-col">
<div class="card">
<div class="set-card-head"><span class="set-card-title">基础信息</span></div>
<div style="padding:16px">
<div class="set-row">
<div class="set-label"><span class="req">*</span>公司名称</div>
<input class="input" style="width:100%" value="衍水集团" />
<div class="set-note">最多 64 个字符;保存后对所有在线用户立即生效。</div>
</div>
<div class="set-row">
<div class="set-label">logo</div>
<div class="logo-box"><span class="plus">+</span></div>
<div class="set-note">png / jpg,不超过 2MB;仅展示于登录页品牌位,按 ≤56×36 显示。</div>
</div>
</div>
<div class="card-foot"><button class="btn btn-primary">保存</button></div>
</div>
<div class="card">
<div class="set-card-head">
<span class="set-card-title">生效范围</span>
<span class="set-card-note">保存后对所有人生效</span>
</div>
<div style="padding:16px">
<dl class="scope-list">
<dt>公司名称</dt>
<dd>登录页品牌位与版权行 · Excel 导出页眉 · 打印稿页眉</dd>
</dl>
<dl class="scope-list">
<dt>logo</dt>
<dd>登录页品牌位(≤56×36)</dd>
</dl>
</div>
</div>
</div>
<!-- 右列:登录页实景预览(1:1 等比,非缩比示意) -->
<div class="card">
<div class="set-card-head">
<span class="set-card-title">登录页预览</span>
<span class="set-card-note">1:1 等比</span>
</div>
<div style="padding:16px">
<div class="login-stage">
<div class="mock-card">
<div class="mc-brand">
<div class="mc-name">衍水集团</div>
<p class="mc-slogan">把每一笔回款,算成一份踏实。</p>
<span class="mc-waterline"></span>
</div>
<div class="mc-head">
<div class="mc-title">欢迎回来</div>
<div class="mc-hint">请输入账号密码进入系统</div>
</div>
<div class="mc-field">
<span class="mc-label">用户名</span>
<span class="mc-box"></span>
</div>
<div class="mc-field">
<span class="mc-label">密码</span>
<span class="mc-box"></span>
</div>
<div class="mc-field">
<span class="mc-label">验证码</span>
<span class="mc-captcha-row">
<span class="mc-box"></span>
<span class="mc-captcha"></span>
</span>
</div>
<div class="mc-submit">登 录</div>
</div>
<div class="mc-foot">© 衍水集团 · 内部系统</div>
</div>
<p class="preview-cap">登录页品牌位实景(示意,非交互)——显示的是当前编辑值</p>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="note">
<b>两列</b>:左列 <code>minmax(0, 1fr)</code> 放表单与「生效范围」,右列固定 <b>480px</b> 放实景预览;
≤1200px 折成单列。表单不再缩在左上角,内容区左右都能被用上。
<br />
<b>控件与说明同行</b>:主控件 ≤420px,说明跟在后面 20px;≥1600px 时说明改为靠卡片右缘右对齐,
与左侧控件形成两端锚点,整行铺满。放不下时说明整体换行到控件下方。
<br />
<b>实景预览必须是 1:1</b>:登录卡按真实登录页复刻(控件 38px 高、品牌名 17px / 0.34em 字距、
logo ≤56×36、三项输入含验证码位、登录按钮、版权行)。控件高度对不上就不叫「所见即所得」,
只是缩比示意——缩比版看不出 logo 会不会糊、标语会不会挤。
<br />
<b>「生效范围」独立成卡</b>:写清这份设置会出现在哪(登录页品牌位与版权行 / Excel 导出页眉 /
打印稿页眉),用户存完不用再去别处找。
</div>
</section>
</div> </div>
<script src="echarts.min.js"></script> <script src="echarts.min.js"></script>
<script> <script>
+12 -3
View File
@@ -141,6 +141,8 @@ pnpm build # 类型检查 + 生产构建
**菜单图标**(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-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),「衍水」之水的提纯,全站唯一的装饰性元素;登录页右上角另有涟漪同心圆与顶部靛蓝天光作背景(低透明度)。与早期深色面板 + 水纹层理同一意象,品牌连续而非推倒重来。 **签名元素**:**渐隐靛蓝水线**——各页面标题下的细线(56px,左实右虚)与登录页品牌名下的短线(40px),「衍水」之水的提纯,全站唯一的装饰性元素;登录页右上角另有涟漪同心圆与顶部靛蓝天光作背景(低透明度)。与早期深色面板 + 水纹层理同一意象,品牌连续而非推倒重来。
**打印视图**(`views/reports/print/`):纸面产物与屏幕稿不同源——独立顶层路由、不挂侧栏顶栏,白灰画布上一张定宽白稿纸,表格 11px 细实线、表头浅底左对齐(数值列表头右对齐)、章节编号、三栏签字(制表 / 复核 / 审批),无圆角无阴影。屏幕上的操作条带 `.no-print`,打印时整块消失。超出打印行数上限时只打前 N 行并在稿面明确提示,不静默截断。 **打印视图**(`views/reports/print/`):纸面产物与屏幕稿不同源——独立顶层路由、不挂侧栏顶栏,白灰画布上一张定宽白稿纸,表格 11px 细实线、表头浅底左对齐(数值列表头右对齐)、章节编号、三栏签字(制表 / 复核 / 审批),无圆角无阴影。屏幕上的操作条带 `.no-print`,打印时整块消失。超出打印行数上限时只打前 N 行并在稿面明确提示,不静默截断。
@@ -148,7 +150,7 @@ pnpm build # 类型检查 + 生产构建
**构图**:登录页品牌内容(名称 / 标语 / 水线)**垂直居中聚成一块**,配一层柔和径向光晕作落点。 **构图**:登录页品牌内容(名称 / 标语 / 水线)**垂直居中聚成一块**,配一层柔和径向光晕作落点。
早期版本用 `space-between` 把品牌与标语顶到两端,中间空出大半屏,两处都成了孤岛——超宽屏会把这种疏离放大得更明显。**内容块要限宽**(`max-width`),否则宽屏下文字会被拉散。 早期版本用 `space-between` 把品牌与标语顶到两端,中间空出大半屏,两处都成了孤岛——超宽屏会把这种疏离放大得更明显。**内容块要限宽**(`max-width`),否则宽屏下文字会被拉散。
### 写代码时的五条约定 ### 写代码时的六条约定
- 覆盖 Element Plus 主题请改 **CSS 变量**(`--el-button-bg-color` 等),不要直接写 `background-color` —— 后者与 `.el-button--primary` 同权重,会受样式注入顺序影响而不稳定 - 覆盖 Element Plus 主题请改 **CSS 变量**(`--el-button-bg-color` 等),不要直接写 `background-color` —— 后者与 `.el-button--primary` 同权重,会受样式注入顺序影响而不稳定
- 中文可在任意字间断行,**关键文案的断行位置要显式控制**(如主标语拆成两个 `<span>`),交给浏览器自动换行会出现单字独占一行的难看结果 - 中文可在任意字间断行,**关键文案的断行位置要显式控制**(如主标语拆成两个 `<span>`),交给浏览器自动换行会出现单字独占一行的难看结果
@@ -156,6 +158,7 @@ pnpm build # 类型检查 + 生产构建
- **确认框统一加 `customClass: 'ys-confirm-box'`,不要再传 `type: 'warning'`** —— Element Plus 的默认 warning 图标会渲染在正文左侧独立成列(多行正文时图标悬在文字块中间),与 v3 靛蓝体系也冲突。全局样式(`src/assets/styles/index.css`)已把图标改为 18px 描边样式并放在标题行左侧,正文与标题文字对齐 - **确认框统一加 `customClass: 'ys-confirm-box'`,不要再传 `type: 'warning'`** —— Element Plus 的默认 warning 图标会渲染在正文左侧独立成列(多行正文时图标悬在文字块中间),与 v3 靛蓝体系也冲突。全局样式(`src/assets/styles/index.css`)已把图标改为 18px 描边样式并放在标题行左侧,正文与标题文字对齐
- **页面 scoped 里只写本页特有的东西**:表格 / 按钮 / 输入框 / 表头 / 分页的皮肤已经全局覆盖,页面再写一遍与组件库同权重,会受样式注入顺序影响而不稳定;结构用全局类(见上一节表格),样式只补本页独有的块(如展开区、抽屉内的键值表) - **页面 scoped 里只写本页特有的东西**:表格 / 按钮 / 输入框 / 表头 / 分页的皮肤已经全局覆盖,页面再写一遍与组件库同权重,会受样式注入顺序影响而不稳定;结构用全局类(见上一节表格),样式只补本页独有的块(如展开区、抽屉内的键值表)
- **状态 / 结果列的固定宽度要按胶囊内容给足**:`.cell` 左右各 16px 内距(共 32px),2 字胶囊约 55px、3 字约 67px、4 字约 79px,宽度不够时 Element Plus 会在胶囊右侧画一个省略号(看起来像多了个小点)。口径:2 字列 ≥96px(如 启用/禁用、成功/失败)、3 字列 ≥108px(如 已核算/已替代/执行中)、4 字列 ≥120px(如 以物抵顶) - **状态 / 结果列的固定宽度要按胶囊内容给足**:`.cell` 左右各 16px 内距(共 32px),2 字胶囊约 55px、3 字约 67px、4 字约 79px,宽度不够时 Element Plus 会在胶囊右侧画一个省略号(看起来像多了个小点)。口径:2 字列 ≥96px(如 启用/禁用、成功/失败)、3 字列 ≥108px(如 已核算/已替代/执行中)、4 字列 ≥120px(如 以物抵顶)
- **`v-loading` 只罩数据区,不要罩到分页条上**:遮罩会拦截指针事件,盖住分页器后用户点翻页其实点在遮罩上被吞掉,得再点一次,观感就是「数据反复变化」。详见下文「分页与「数据反复变化」的两个坑」
## 验证码图片 ## 验证码图片
@@ -259,14 +262,20 @@ E2E_ADMIN_PASSWORD=<开发库admin密码> python e2e/system_management.py
## 前端页面(2026-10-19 追加 · M4 ⑧ 工作台) ## 前端页面(2026-10-19 追加 · M4 ⑧ 工作台)
`/dashboard` 由 Bento 占位升级为工作台四区块(**一次聚合请求** `/api/v1/dashboard`): `/dashboard` 由 Bento 占位升级为工作台(**总览/待办/预警一次聚合请求** `/api/v1/dashboard`;最近动态是分页列表,走独立端点):
| 区块 | 展示 | | 区块 | 展示 |
| --- | --- | | --- | --- |
| 经营绩效总览 | KPI 四卡(本月绩效/应付/核算人数/团队数 + 月结状态);近 6 个月绩效折线 + 回款柱状(ECharts) | | 经营绩效总览 | KPI 四卡(本月绩效/应付/核算人数/团队数 + 月结状态);近 6 个月绩效折线 + 回款柱状(ECharts) |
| 待办事项 | 待审批(跳审批管理)、回款未指定批次(跳回款管理)、T+ 同步失败(未启用显式占位) | | 待办事项 | 待审批(跳审批管理)、回款未指定批次(跳回款管理)、T+ 同步失败(未启用显式占位) |
| 风险预警 | 回款逾期(danger)/ 审批超时(warning),点击跳处理页;总开关关闭时显示参数提示 | | 风险预警 | 回款逾期(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`(与设计预览一致),只注册用到的模块。 - **ECharts 5.6 按需引入**:`echarts/core` + `BarChart/LineChart/Grid/Tooltip` + `SVGRenderer`(与设计预览一致),只注册用到的模块。
- **字体红线**:图表 `textStyle.fontFamily` 必须显式设为思源黑体栈——ECharts 在 Windows 默认微软雅黑(字体商用合规要求)。 - **字体红线**:图表 `textStyle.fontFamily` 必须显式设为思源黑体栈——ECharts 在 Windows 默认微软雅黑(字体商用合规要求)。
+27
View File
@@ -21,6 +21,10 @@ export interface CustomerItem {
owner_personnel_id: number | null owner_personnel_id: number | null
owner_personnel_name: string | null owner_personnel_name: string | null
remark: string | null remark: string | null
/** 评级总分(0-100,评级计算器保存;null=未评分) */
rating_score: number | null
/** 最近评级时间(评级计算器保存评分时刷新;null=从未评级) */
rating_time: string | null
} }
export interface CustomerQueryParams { export interface CustomerQueryParams {
@@ -30,6 +34,10 @@ export interface CustomerQueryParams {
keyword?: string keyword?: string
customer_type?: string customer_type?: string
owner_personnel_id?: number owner_personnel_id?: number
/** 排序键(后端白名单:id 建档序 / rating_score 评分),默认 id 倒序 */
order_by?: 'id' | 'rating_score'
/** 排序方向,默认 desc */
order?: 'asc' | 'desc'
} }
/** 新增客户(owner_personnel_id 为初始归属,可空:先建档后派归属也合法) */ /** 新增客户(owner_personnel_id 为初始归属,可空:先建档后派归属也合法) */
@@ -57,6 +65,17 @@ export interface CustomerOwnershipParams {
reason?: string | null reason?: string | null
} }
/**
* 保存评级入参:只传总分。
*
* 等级不落库,也不由客户端传入 —— 后端只存总分与评级时间,等级由分数按
* 当前等级映射实时推导(前端 utils/rating.ts)。不收客户端传来的等级,
* 否则改请求体就能把 30 分存成 A 级。
*/
export interface CustomerRatingParams {
score: number
}
/** 归属变更留痕条目(对应后端 `OwnershipChangeLogOut`;姓名可随人员消失,ID 固化) */ /** 归属变更留痕条目(对应后端 `OwnershipChangeLogOut`;姓名可随人员消失,ID 固化) */
export interface OwnershipLogItem { export interface OwnershipLogItem {
id: number id: number
@@ -114,6 +133,14 @@ export function changeOwnership(
return request({ url: `/v1/base/customers/${customerId}/ownership`, method: 'put', data }) 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( export function listOwnershipLogs(
customerId: number, customerId: number,
+22 -1
View File
@@ -1,4 +1,5 @@
import { request } from './request' import { request } from './request'
import type { PageData } from './types'
/** 近 6 个月趋势点(绩效按台账归属月,回款按到账月) */ /** 近 6 个月趋势点(绩效按台账归属月,回款按到账月) */
export interface TrendPoint { export interface TrendPoint {
@@ -91,9 +92,29 @@ export interface DashboardData {
overview: OverviewBlock overview: OverviewBlock
todos: TodoBlock todos: TodoBlock
warnings: WarningBlock 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> { export function getDashboard(params: { month?: string }): Promise<DashboardData> {
return request({ url: '/v1/dashboard', method: 'get', params }) 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 })
}
+86 -59
View File
@@ -10,7 +10,12 @@ const { topLevelMenus, groupedMenus } = useAppMenu()
<template> <template>
<el-menu class="app-menu" :default-active="route.path" router> <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"> <el-icon v-if="item.icon" class="app-menu__icon" aria-hidden="true">
<component :is="item.icon" /> <component :is="item.icon" />
</el-icon> </el-icon>
@@ -32,69 +37,67 @@ const { topLevelMenus, groupedMenus } = useAppMenu()
<style scoped> <style scoped>
/* /*
* 中性灰侧栏上的菜单:覆盖 Element Plus 的主题变量而非直接写颜色 —— * 侧栏菜单:中性灰底 + 靛蓝点缀。
* 后者与组件库规则同权重,受样式注入顺序影响而不稳定。 * 覆盖 Element Plus 的主题变量而非直接写颜色 —— 后者与组件库规则同权重,
* 激活项 = 白底浮起(微渐变 + 内高光 + 分层阴影)+ 左侧 2px 靛蓝条。 * 受样式注入顺序影响而不稳定。
*
* 层级口径:父子**同字号(13px)**,层级不靠字号/字重刻意压过 ——
* 组标题与条目一样大,结构由「组标题带折叠箭头 + 组间留白 + 子项缩进一档」承担。
* 前两版各走偏一次:先把组标题压成 11px 弱墨「目录行」,父级弱于子项;
* 后改成 14px/600 + 靛蓝竖标,父级刻意压过子项、像一排小标题在喊叫。
* 层级是位置关系,不是字号关系 —— 两版都不要。
*
* 激活态:白底浮起(微渐变 + 内高光 + 分层阴影)+ 靛蓝字,不再叠左侧竖条 ——
* 竖条只能落在侧栏内边缘(离白底悬空),是与白底重复的第二个信号。
*/ */
.app-menu { .app-menu {
--el-menu-bg-color: transparent; --el-menu-bg-color: transparent;
--el-menu-text-color: var(--ys-ink-2); --el-menu-text-color: var(--ys-ink-2);
--el-menu-active-color: var(--ys-indigo); --el-menu-active-color: var(--ys-indigo);
--el-menu-hover-bg-color: rgba(255, 255, 255, 0.55); --el-menu-hover-bg-color: rgba(255, 255, 255, 0.55);
--el-menu-item-height: 36px; --el-menu-item-height: 34px;
padding: 0 10px; padding: 2px 10px 12px;
border-right: none; border-right: none;
} }
/* 菜单项:内距 9px 10px、圆角 7px、13px 字 —— 组内层级靠缩进区分,不加分隔线 */ /* 组与组之间留白:分组交给间距,不加分割线 */
.app-menu :deep(.el-menu-item) { .app-menu :deep(.el-sub-menu) {
height: 36px; margin-top: 12px;
}
/*
* 顶层叶子项(首页):与组标题同级 —— 13px / 500 / 主墨。
* 用 scoped 模板 class 定位(权重高于组件库默认样式),
* 不与子项共用 :deep 规则,避免字号被子项规则带走。
*/
.app-menu__top {
height: 34px;
padding: 0 10px; padding: 0 10px;
margin-bottom: 2px; margin-bottom: 2px;
font-size: 13px; font-size: 13px;
line-height: 36px; font-weight: 500;
line-height: 34px;
color: var(--ys-ink);
border-radius: var(--ys-radius-sm); border-radius: var(--ys-radius-sm);
transition: transition:
background-color 150ms var(--ys-ease), background-color 150ms var(--ys-ease),
color 150ms var(--ys-ease); color 150ms var(--ys-ease);
} }
.app-menu :deep(.el-menu-item:hover) { .app-menu__top:hover {
color: var(--ys-ink); color: var(--ys-ink);
background-color: rgba(255, 255, 255, 0.55);
} }
/* 子项向右缩进一档,与顶层项形成「树」的暗示而非并列 */ /* 组标题:与条目同字号,靠折叠箭头表明「可展开」,不靠压重 */
.app-menu :deep(.el-sub-menu .el-menu-item) {
padding-left: 22px !important;
}
.app-menu :deep(.el-menu-item.is-active) {
position: relative;
font-weight: 500;
background: linear-gradient(180deg, var(--ys-paper), #fbfcfe);
box-shadow:
inset 0 1px 0 #fff,
0 1px 2px rgba(28, 35, 51, 0.06),
0 4px 10px rgba(28, 35, 51, 0.06);
}
.app-menu :deep(.el-menu-item.is-active)::before {
content: '';
position: absolute;
top: 9px;
bottom: 9px;
left: -10px;
width: 2px;
border-radius: 1px;
background: var(--ys-indigo);
}
/* 组标题:11px 大字距的「目录行」,不抢子项;展开时转靛蓝并压一层白底 */
.app-menu :deep(.el-sub-menu__title) { .app-menu :deep(.el-sub-menu__title) {
height: 32px; height: 34px;
padding: 0 10px; padding: 0 10px;
line-height: 32px; font-size: 13px;
font-weight: 500;
line-height: 34px;
color: var(--ys-ink);
border-radius: var(--ys-radius-sm); border-radius: var(--ys-radius-sm);
transition: transition:
background-color 150ms var(--ys-ease), background-color 150ms var(--ys-ease),
@@ -102,23 +105,47 @@ const { topLevelMenus, groupedMenus } = useAppMenu()
} }
.app-menu__group { .app-menu__group {
font-size: 11px; font-size: 13px;
font-weight: 500; font-weight: 500;
letter-spacing: 0.1em; color: inherit;
color: var(--ys-ink-3); }
transition: color 150ms var(--ys-ease);
/*
* 子项: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) {
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);
} }
.app-menu :deep(.el-sub-menu__title:hover) { .app-menu :deep(.el-sub-menu__title:hover) {
background-color: rgba(255, 255, 255, 0.55); background-color: rgba(255, 255, 255, 0.5);
}
.app-menu :deep(.el-sub-menu.is-opened > .el-sub-menu__title) {
background-color: rgba(255, 255, 255, 0.45);
}
.app-menu :deep(.el-sub-menu.is-opened > .el-sub-menu__title) .app-menu__group {
color: var(--ys-indigo);
} }
/* /*
@@ -136,21 +163,21 @@ const { topLevelMenus, groupedMenus } = useAppMenu()
} }
.app-menu :deep(.el-sub-menu.is-opened > .el-sub-menu__title .el-sub-menu__icon-arrow) { .app-menu :deep(.el-sub-menu.is-opened > .el-sub-menu__title .el-sub-menu__icon-arrow) {
color: var(--ys-indigo); color: var(--ys-ink-2);
transform: rotate(0deg); transform: rotate(0deg);
} }
/* /*
* 菜单图标:设计稿口径为 16px、透明度 0.7(激活项 1)——Element Plus 默认 * 菜单图标:设计稿口径为 16px、透明度 0.7(激活项 1)——Element Plus 默认
* 给菜单图标 24px 宽 + 5px 间距,直接沿用会让图标比文字更抢眼、字距发散, * 给菜单图标 24px 宽 + 5px 间距,直接沿用会让图标比文字更抢眼、字距发散,
* 故在此显式覆盖。 * 故在此显式覆盖。父子同级,图标尺寸统一,不再按层级分档。
*/ */
.app-menu__icon { .app-menu__icon {
width: 16px; width: 16px;
height: 16px; height: 16px;
margin-right: 9px; margin-right: 9px;
font-size: 16px; font-size: 16px;
opacity: 0.7; opacity: 0.72;
} }
.app-menu :deep(.el-menu-item.is-active) .app-menu__icon { .app-menu :deep(.el-menu-item.is-active) .app-menu__icon {
@@ -158,10 +185,10 @@ const { topLevelMenus, groupedMenus } = useAppMenu()
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.app-menu :deep(.el-menu-item), .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__title),
.app-menu :deep(.el-sub-menu__icon-arrow), .app-menu :deep(.el-sub-menu__icon-arrow) {
.app-menu__group {
transition: none; transition: none;
} }
} }
@@ -155,7 +155,13 @@ async function handleLogout(): Promise<void> {
.app-aside__menu { .app-aside__menu {
flex: 1; flex: 1;
padding: 2px 0 12px; /*
* 只写上下内距:横向 10px 留白由 AppMenu 自己的 .app-menu 提供。
* 这里若用 padding 简写,会与 .app-menu 的 padding 简写同权重、
* 靠样式注入顺序决胜 —— 横向留白可能被整条抹掉,条目贴到侧栏边缘。
*/
padding-top: 2px;
padding-bottom: 12px;
overflow-y: auto; overflow-y: auto;
} }
@@ -17,7 +17,6 @@ import {
SetUp, SetUp,
Shop, Shop,
Stamp, Stamp,
Star,
Tickets, Tickets,
TrendCharts, TrendCharts,
User, User,
@@ -54,7 +53,6 @@ const ICONS = {
businesses: Briefcase, businesses: Briefcase,
shipments: Van, shipments: Van,
receipts: Money, receipts: Money,
rating: Star,
ledger: Notebook, ledger: Notebook,
settlements: Calendar, settlements: Calendar,
'report-personal': DataLine, 'report-personal': DataLine,
+70 -11
View File
@@ -28,42 +28,101 @@ export function usePagedList<T, F extends object, D extends PageData<T> = PageDa
/** 自增请求序号:仅最新请求的响应可写入状态 */ /** 自增请求序号:仅最新请求的响应可写入状态 */
let requestSeq = 0 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 seq = ++requestSeq
const reqPageNum = pageNum.value
const reqPageSize = pageSize.value
loading.value = true loading.value = true
try { try {
const data = await fetcher({ const data = await fetcher({
...getFilters(), ...getFilters(),
pageNum: pageNum.value, pageNum: reqPageNum,
pageSize: pageSize.value, pageSize: reqPageSize,
}) })
if (seq !== requestSeq) return // 已有更新的请求发出,丢弃过期响应 if (seq !== requestSeq) return { ok: false, seq } // 已有更新的请求发出,丢弃过期响应
records.value = data.list records.value = data.list
total.value = data.total total.value = data.total
shownPageNum = reqPageNum
shownPageSize = reqPageSize
onData?.(data) onData?.(data)
return { ok: true, seq }
} catch (error) { } catch (error) {
handledError(error) // 失败提示由请求拦截器统一完成 handledError(error) // 失败提示由请求拦截器统一完成
return { ok: false, seq }
} finally { } finally {
if (seq === requestSeq) loading.value = false 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 pageNum.value = 1
void load() await load()
} }
function onPageChange(page: number): void { /**
* 翻页:失败则退回屏幕上正在显示的那一页。
*
* 不回滚的后果是分页器高亮与列表数据永久错位 —— 用户看到的是
* 「点了没反应,再点一下又跳回去」,且必须连点两次才能对齐。
*/
async function onPageChange(page: number): Promise<void> {
pageNum.value = page 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 pageSize.value = size
pageNum.value = 1 pageNum.value = 1
void load() const { ok, seq } = await fetchPage()
if (!ok) restoreShownPage(seq)
} }
/** 删除后刷新:当前页已被删空且不在第一页时,先回退页码再加载 */ /** 删除后刷新:当前页已被删空且不在第一页时,先回退页码再加载 */
+1 -8
View File
@@ -170,14 +170,7 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/business/receipts/index.vue'), component: () => import('@/views/business/receipts/index.vue'),
meta: { title: '回款管理', icon: 'receipts', group: '业务数据', order: 15 }, meta: { title: '回款管理', icon: 'receipts', group: '业务数据', order: 15 },
}, },
{ // 客户评级计算器已并入客户管理(行内弹窗),独立页移除
// 业务数据组首个页面:评级计算器是打分工具,不影响绩效金额,
// 登录用户即可用,不设 requiresAdmin(区别于系统管理组)
path: '/business/rating',
name: 'business-rating',
component: () => import('@/views/business/rating/index.vue'),
meta: { title: '客户评级计算器', icon: 'rating', group: '业务数据', order: 16 },
},
{ {
// 绩效工资组(M3 ④):核算台账读登录即可,月结写限管理员与营销后勤 // 绩效工资组(M3 ④):核算台账读登录即可,月结写限管理员与营销后勤
// (api 层 require_roles 兜底,页面层不再叠加 requiresAdmin) // (api 层 require_roles 兜底,页面层不再叠加 requiresAdmin)
+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>
+191 -3
View File
@@ -5,12 +5,15 @@ import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import * as customerApi from '@/api/customers' import * as customerApi from '@/api/customers'
import type { CustomerItem, OwnershipLogItem } from '@/api/customers' import type { CustomerItem, OwnershipLogItem } from '@/api/customers'
import * as personnelApi from '@/api/personnel' import * as personnelApi from '@/api/personnel'
import { listRatingGrades, type RatingGradeItem } from '@/api/ratings'
import AppPagination from '@/components/table/AppPagination.vue' import AppPagination from '@/components/table/AppPagination.vue'
import { useConfirmAction } from '@/composables/useConfirmAction' import { useConfirmAction } from '@/composables/useConfirmAction'
import { useDropdownOptions } from '@/composables/useDropdownOptions' import { useDropdownOptions } from '@/composables/useDropdownOptions'
import { usePagedList } from '@/composables/usePagedList' import { usePagedList } from '@/composables/usePagedList'
import { formatDateTime } from '@/utils/format' import { formatDateTime } from '@/utils/format'
import { gradeTone, resolveGrade } from '@/utils/rating'
import { handledError } from '@/utils/error' import { handledError } from '@/utils/error'
import RatingCalculatorDialog from './RatingCalculatorDialog.vue'
/** /**
* 客户管理(需求 2.1.3:客户档案 + 归属人员 + 归属变更留痕)。 * 客户管理(需求 2.1.3:客户档案 + 归属人员 + 归属变更留痕)。
@@ -21,6 +24,9 @@ import { handledError } from '@/utils/error'
* - 归属变更提交前二次确认,文案必须包含「仅影响变更后核算,不回溯历史」 * - 归属变更提交前二次确认,文案必须包含「仅影响变更后核算,不回溯历史」
* (对照表 #19 的业务口径) * (对照表 #19 的业务口径)
* - 归属变更记录为分页留痕表(抽屉内展示,按变更时间倒序) * - 归属变更记录为分页留痕表(抽屉内展示,按变更时间倒序)
* - 评级计算器并入本页(原 /business/rating 独立页已移除):行内弹窗打分,
* 保存评分只写档案字段 rating_score / rating_time;等级不落库,由分数按
* 当前等级映射实时算(utils/rating.ts 与弹窗共用同一判定函数)
*/ */
const { confirmAndRun } = useConfirmAction() const { confirmAndRun } = useConfirmAction()
@@ -39,6 +45,8 @@ const { records, total, pageNum, pageSize, loading, load, search, onPageChange,
keyword: filters.keyword, keyword: filters.keyword,
customer_type: filters.customer_type, customer_type: filters.customer_type,
owner_personnel_id: filters.owner_personnel_id, owner_personnel_id: filters.owner_personnel_id,
order_by: sortState.order_by,
order: sortState.order,
}), }),
) )
@@ -49,6 +57,96 @@ function resetFilters(): void {
search() 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 { interface PersonnelOption {
@@ -311,9 +409,11 @@ async function handleDelete(row: CustomerItem): Promise<void> {
// ==================== 初始化 ==================== // ==================== 初始化 ====================
onMounted(() => { onMounted(() => {
// 两个请求各自容错(load 内部已兜底),互不拖累 // 三个请求各自容错(load 内部已兜底),互不拖累
void load() void load()
void loadOwnerOptions() void loadOwnerOptions()
// 等级徽标要用等级映射把分数翻成等级,先拉一次;失败只影响徽标,不影响列表
void loadGrades()
}) })
</script> </script>
@@ -322,7 +422,7 @@ onMounted(() => {
<div class="page-head"> <div class="page-head">
<h1 class="page-title">客户管理</h1> <h1 class="page-title">客户管理</h1>
<p class="page-desc"> <p class="page-desc">
客户档案与归属人员;归属变更只影响变更后的核算、不回溯历史,每次调整都在「变更记录」留痕 客户档案与归属人员;归属变更只影响变更后的核算、不回溯历史,评分与等级由「评级计算」维护并保存进档案
</p> </p>
<span class="waterline"></span> <span class="waterline"></span>
</div> </div>
@@ -385,8 +485,39 @@ onMounted(() => {
:data="records" :data="records"
stripe stripe
empty-text="还没有客户 —— 点「新增客户」建第一个,或换个关键词再查" empty-text="还没有客户 —— 点「新增客户」建第一个,或换个关键词再查"
@sort-change="onSortChange"
> >
<el-table-column prop="company_name" label="公司名称" min-width="180" /> <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="owner_name" label="负责人" min-width="90" />
<el-table-column prop="contact_person" label="联系人" min-width="90"> <el-table-column prop="contact_person" label="联系人" min-width="90">
<template #default="{ row }"> <template #default="{ row }">
@@ -423,9 +554,10 @@ onMounted(() => {
<span v-else class="cell-muted">(无归属)</span> <span v-else class="cell-muted">(无归属)</span>
</template> </template>
</el-table-column> </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 }"> <template #default="{ row }">
<div class="table-actions"> <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="openEdit(row)">编辑</el-button>
<el-button link type="primary" size="small" @click="openOwnership(row)"> <el-button link type="primary" size="small" @click="openOwnership(row)">
归属变更 归属变更
@@ -585,6 +717,14 @@ onMounted(() => {
@size-change="onLogsSizeChange" @size-change="onLogsSizeChange"
/> />
</el-drawer> </el-drawer>
<!-- 评级计算器(原 /business/rating 独立页并入;保存评分回写档案) -->
<RatingCalculatorDialog
v-model:visible="ratingVisible"
:customer="ratingTarget"
@saved="onRatingSaved"
@grades-changed="loadGrades"
/>
</div> </div>
</template> </template>
@@ -597,4 +737,52 @@ onMounted(() => {
font-size: 12px; font-size: 12px;
line-height: 1.7; 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> </style>
@@ -171,7 +171,7 @@ const allocationHint = computed(() => {
const total = Number(allocationTotal.value.toFixed(2)) const total = Number(allocationTotal.value.toFixed(2))
if (total === 100) return `二次分配合计 ${total.toFixed(2)}%` if (total === 100) return `二次分配合计 ${total.toFixed(2)}%`
const rest = Number((100 - 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 { function addAllocationRow(): void {
@@ -880,7 +880,7 @@ onMounted(() => {
<div class="items-head"> <div class="items-head">
<span class="items-title"> <span class="items-title">
二次分配方案 二次分配方案
<span class="alloc-note">(可选;比例不要求合计 100%,分摊计算待业务确认后另批)</span> <span class="alloc-note">(可选;各参与人比例不要求合计 100%,余额归负责人)</span>
</span> </span>
<el-button size="small" @click="addAllocationRow">添加参与人</el-button> <el-button size="small" @click="addAllocationRow">添加参与人</el-button>
</div> </div>
@@ -1,493 +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">
<!-- 页面头:标题 + 一句说明 + 签名水线(每页只出现一次) -->
<div class="page-head">
<h1 class="page-title">客户评级计算器</h1>
<p class="page-desc">
五项各 0-20 分、总分 0-100,按配置的分值下限实时判定等级;评分只决定评级,不参与绩效金额计算
</p>
<span class="waterline"></span>
</div>
<!-- 对照表 #25:常驻口径提示,防止误认为评分会影响绩效金额 -->
<el-alert
class="page-alert"
title="本页为评分计算工具,评分结果不参与绩效金额计算"
type="warning"
:closable="false"
show-icon
/>
<!-- 计算器是一张卡:卡头放标题与操作,卡体内分「评分项 / 判定结果」两块 -->
<div class="card">
<div class="card-head">
<span class="card-title">评分计算</span>
<span class="card-head__actions">
<el-button @click="resetScores">重置</el-button>
<el-button type="primary" @click="openDrawer">等级映射设置</el-button>
</span>
</div>
<div class="card-body">
<div class="calc-grid">
<div class="calc-form">
<div class="section-title">评分项(各 0-20 分,步进 1)</div>
<div v-for="item in SCORE_ITEMS" :key="item.key" class="score-row">
<span class="score-label">{{ item.label }}</span>
<el-input-number
v-model="scores[item.key]"
:min="0"
:max="20"
:step="1"
controls-position="right"
/>
<span class="score-unit">分</span>
</div>
</div>
<div class="calc-result">
<div class="section-title">判定结果</div>
<div>
<span class="result-label">总分</span>
<b class="total-num">{{ totalScore }}</b>
<span class="total-max">/ 100</span>
</div>
<div>
<span class="result-label">评级等级</span>
<template v-if="grades.length === 0">
<div class="grade-name">—</div>
<div class="grade-hint">未配置等级映射</div>
</template>
<template v-else-if="matchedGrade">
<div class="grade-name">{{ matchedGrade.grade_name }}</div>
<div class="grade-hint">
分值区间 {{ matchedGrade.min_score }} ~ {{ matchedGrade.max_score }}
</div>
</template>
<template v-else>
<div class="grade-name">—</div>
<div class="grade-hint">总分低于全部等级下限,未命中等级</div>
</template>
</div>
</div>
</div>
</div>
</div>
<!-- 等级映射设置抽屉 -->
<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-head/.page-title/.page-desc/.waterline)、.card 与 .section-title
都由全局 index.css 提供,此处只保留本页特有的排版 */
.page-alert {
margin-bottom: 14px;
}
/* 卡头右侧操作组:.card-head 已是 flex + justify-between,这里只补按钮之间的间距 */
.card-head__actions {
display: flex;
gap: 10px;
}
.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>
@@ -479,7 +479,7 @@ onMounted(() => {
v-model="form.receipt_date" v-model="form.receipt_date"
type="date" type="date"
value-format="YYYY-MM-DD" value-format="YYYY-MM-DD"
placeholder="M3 核算按此日匹配参数版本" placeholder="选到账当天;计提比例按这一天的参数计算"
style="width: 100%" style="width: 100%"
/> />
</el-form-item> </el-form-item>
File diff suppressed because it is too large. Load diff
+71 -21
View File
@@ -99,9 +99,24 @@ function valueLabel(value: string | null | undefined): string {
return ENUM_VALUE_LABELS[value] ?? value return ENUM_VALUE_LABELS[value] ?? value
} }
/** 带单位的值展示 */ /** 带单位的值展示:枚举与开关先翻成中文,其余原样加单位 */
function formatValue(item: ParamCatalogItem, value: string): string { 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
} }
// ==================== 修改(待提交) ==================== // ==================== 修改(待提交) ====================
@@ -436,7 +451,7 @@ onMounted(() => {
<div class="page-head"> <div class="page-head">
<h1 class="page-title">计提参数</h1> <h1 class="page-title">计提参数</h1>
<p class="page-desc"> <p class="page-desc">
计提比例、系数、阈值、分成与起付比例在此统一维护;变更逐项留痕并支持按日期生效。 计提比例、系数、阈值、分成与起付比例都在这里统一维护;每次改动留下旧值,可以指定从哪天起生效。
</p> </p>
<div class="waterline"></div> <div class="waterline"></div>
</div> </div>
@@ -484,7 +499,7 @@ onMounted(() => {
type="warning" type="warning"
disable-transitions disable-transitions
> >
待改为 {{ pendingOf(item, v.dimension_value)!.new_value }} 待改为 {{ formatValue(item, pendingOf(item, v.dimension_value)!.new_value) }}
</el-tag> </el-tag>
<el-button <el-button
v-if="isAdmin" v-if="isAdmin"
@@ -498,7 +513,7 @@ onMounted(() => {
</div> </div>
</template> </template>
<span v-else class="no-value"> <span v-else class="no-value">
未设置(默认 {{ item.default_value ?? '—' }}) 未设置(默认 {{ formatByCode(item.param_code, item.default_value) }})
</span> </span>
</td> </td>
<td> <td>
@@ -537,10 +552,14 @@ onMounted(() => {
<template #default="{ row }">{{ dimensionLabel(row.dimension_value) }}</template> <template #default="{ row }">{{ dimensionLabel(row.dimension_value) }}</template>
</el-table-column> </el-table-column>
<el-table-column label="旧值" min-width="110"> <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>
<el-table-column label="新值" min-width="110"> <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>
<el-table-column label="操作" width="80"> <el-table-column label="操作" width="80">
<template #default="{ row }"> <template #default="{ row }">
@@ -590,24 +609,50 @@ onMounted(() => {
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="当前值"> <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>
<el-form-item label="新值"> <el-form-item label="新值">
<!--
枚举与开关都给下拉:让业务人员在「含基本工资」「开」里选,
而不是自己敲 INCLUDE / 1 —— 提交给后端的仍是原始值(v-model 绑的是 value)
-->
<el-select <el-select
v-if="editTarget.item.value_type === 'ENUM'" v-if="editTarget.item.value_type === 'ENUM'"
v-model="editValue" v-model="editValue"
style="width: 100%" 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-select>
<el-input v-else v-model="editValue" placeholder="输入新值(分数式如 4/360 原样保留)" /> <el-input v-else v-model="editValue" placeholder="输入新值(分数式如 4/360 原样保留)" />
</el-form-item> </el-form-item>
</el-form> </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>
<div v-else class="edit-tip">改动提交后按你选择的生效日期起作用,旧值会保留在变更记录里。</div>
</template> </template>
<template #footer> <template #footer>
<el-button @click="editVisible = false">取消</el-button> <el-button @click="editVisible = false">取消</el-button>
@@ -615,7 +660,7 @@ onMounted(() => {
</template> </template>
</el-dialog> </el-dialog>
<!-- 版本管理抽屉 --> <!-- 版本管理抽屉:对比两个日期各自的生效值 + 变更记录 -->
<el-drawer v-model="versionVisible" title="版本管理" size="760px"> <el-drawer v-model="versionVisible" title="版本管理" size="760px">
<div class="drawer-block"> <div class="drawer-block">
<div class="drawer-row"> <div class="drawer-row">
@@ -637,18 +682,18 @@ onMounted(() => {
<el-button :loading="exporting" @click="doExport">导出记录</el-button> <el-button :loading="exporting" @click="doExport">导出记录</el-button>
</div> </div>
<template v-if="diffSearched"> <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 :data="diffResult" size="small" max-height="260">
<el-table-column prop="param_name" label="参数" min-width="130" /> <el-table-column prop="param_name" label="参数" min-width="130" />
<el-table-column label="维度" width="90"> <el-table-column label="维度" width="90">
<template #default="{ row }">{{ dimensionLabel(row.dimension_value) }}</template> <template #default="{ row }">{{ dimensionLabel(row.dimension_value) }}</template>
</el-table-column> </el-table-column>
<el-table-column label="旧值" min-width="90"> <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>
<el-table-column label="新值" min-width="90"> <el-table-column label="新值" min-width="90">
<template #default="{ row }"> <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> </template>
</el-table-column> </el-table-column>
<el-table-column label="变化" width="70" align="center"> <el-table-column label="变化" width="70" align="center">
@@ -670,7 +715,7 @@ onMounted(() => {
<div v-for="item in detailCache[row.id].items" :key="item.param_code + item.dimension_value" class="batch-item"> <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="param-name">{{ item.param_name }}</span>
<span class="dim-label">{{ dimensionLabel(item.dimension_value) }}</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> </div>
</template> </template>
<el-button v-else link size="small" @click="detailOf(row).catch(() => {})"> <el-button v-else link size="small" @click="detailOf(row).catch(() => {})">
@@ -719,7 +764,11 @@ onMounted(() => {
</el-table-column> </el-table-column>
<el-table-column label="旧值 → 新值" min-width="130"> <el-table-column label="旧值 → 新值" min-width="130">
<template #default="{ row }"> <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> </template>
</el-table-column> </el-table-column>
<el-table-column prop="version_no" label="版本" width="115" /> <el-table-column prop="version_no" label="版本" width="115" />
@@ -809,9 +858,10 @@ onMounted(() => {
<div v-if="previewResult.probation_note" class="pr-note">{{ previewResult.probation_note }}</div> <div v-if="previewResult.probation_note" class="pr-note">{{ previewResult.probation_note }}</div>
</div> </div>
<div class="pr-used"> <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"> <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> <span v-if="u.effective_date" class="eff">({{ u.effective_date }} 起)</span>
</div> </div>
</div> </div>
+7 -5
View File
@@ -118,9 +118,13 @@ const previewName = computed(() => form.company_name.trim() || '衍水集团')
label-width="92px" label-width="92px"
@submit.prevent @submit.prevent
> >
<!--
字段自适应分列:卡片够宽时两个字段并排(各占一列),窄时自动折回单列。
说明文字紧贴控件下方 —— 早前把它顶到卡片最右缘,宽屏下与输入框隔了
上千像素,视线关联直接断掉。
-->
<div class="field-grid">
<el-form-item label="公司名称" prop="company_name"> <el-form-item label="公司名称" prop="company_name">
<!-- 控件与说明并排:说明靠行尾对齐,宽屏下整行铺满卡片而非缩在左上角 -->
<div class="field-control">
<el-input <el-input
v-model="form.company_name" v-model="form.company_name"
class="field-control__main" class="field-control__main"
@@ -129,11 +133,9 @@ const previewName = computed(() => form.company_name.trim() || '衍水集团')
clearable clearable
/> />
<p class="field-control__note">最多 64 个字符;保存后对所有在线用户立即生效。</p> <p class="field-control__note">最多 64 个字符;保存后对所有在线用户立即生效。</p>
</div>
</el-form-item> </el-form-item>
<el-form-item label="logo"> <el-form-item label="logo">
<div class="field-control">
<el-upload <el-upload
class="logo-uploader" class="logo-uploader"
:show-file-list="false" :show-file-list="false"
@@ -149,8 +151,8 @@ const previewName = computed(() => form.company_name.trim() || '衍水集团')
<p class="field-control__note"> <p class="field-control__note">
png / jpg,不超过 2MB;仅展示于登录页品牌位,按 ≤56×36 显示。 png / jpg,不超过 2MB;仅展示于登录页品牌位,按 ≤56×36 显示。
</p> </p>
</div>
</el-form-item> </el-form-item>
</div>
</el-form> </el-form>
</div> </div>