Compare commits

...

6 Commits

Author SHA1 Message Date
liuwenxin
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
liuwenxin
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
liuwenxin
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
liuwenxin
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
liuwenxin
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
liuwenxin
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
31 changed files with 1445 additions and 569 deletions

View File

@ -264,6 +264,17 @@ bcrypt 5.0 起对超过 72 字节的密码**直接抛 ValueError**(此前是
- **汇总 = 明细合计**:团队/公司基数由台账分桶结果直接得出,报表侧不另算一遍;未筛选时 `团队基数 + 未分配绩效 = 全公司基数`;集成测试逐项断言。
- **岗位筛选选项**:`GET /api/v1/base/business-roles/options` 仅要求登录(含停用岗位),供财务/领导在报表中按岗位筛选;岗位管理接口仍仅管理员。
### 面向业务人员的文案(2026-10-23 定稿,改文案前先读)
系统由财务与营销人员日常使用,**不是给开发者看的**。凡是会渲染到页面的文本(参数说明、错误提示、口径注释)一律按业务语言写:
- **`param_registry.py` 的 `description` 是唯一显示源**,会直出到「计提参数」页参数名下方。写清「这个参数管什么、钱怎么变、什么时候该调它」,不写英文枚举名(`FULL`/`BALANCE`)、需求编号(`澄清 #10`)、内部阶段号(`M3`/`M4 ⑧`)、实现层精度约束(「禁止按 0.011111 截断存储」留在代码注释与测试里)。
- **枚举值在页面上必须翻译**:前端 `params/index.vue` 的 `ENUM_VALUE_LABELS`(`FULL` 当月全额 / `BALANCE` 余额 / `INCLUDE` 含基本工资 / `DEDUCT` 扣除基本工资 / `EXCLUDE` 不计基本工资 / `1` 开 / `0` 关)与 `DIMENSION_LABELS`(`municipal_water` 市政水利 / `rail_transit` 轨道交通)。后端报错也不许把原始枚举值甩给用户 —— `param_service._preview_rate` 的产品类别校验用 `「市政水利、轨道交通」` 而不是 `municipal_water / rail_transit`。
- **公式模板报错用中文模板名**:`calc_templates.TEMPLATES[code].name`,取不到才回退 code;文案要给出下一步(「请在岗位角色里改用业务员递减或信息员固定计提」),而不是只说「不支持」。
- **`db/init/init-dev.sql` 的种子 description 必须与 registry 同源**:它只在数据卷为空时首次启动执行,已有库要跑 `ai-aux/preview/sync_param_desc.py`(读 `PARAM_REGISTRY` 逐条 UPDATE)才看得到新文案。`db/migration/V*.sql` 里的旧描述是历史记录,**不要回头改** —— 改了会让已部署库与脚本不一致。
- **前端模板层禁止出现开发者语汇**:`澄清 / 待业务 / M3 / M4 / 一期 / 二阶段 / 暂口径 / 待确认 / 待接 / 暂不 / 待补` 只允许出现在代码注释里。改完用 grep 扫一遍 `frontend/src/views`。
- **台账与预览里落库的说明文字(`rate_text` / `rate_note` / 聚合行 `snapshot_json.note`)同样受管**:这些会出现在绩效台账、个人明细、打印稿与计算预览上。写成「这笔为什么是这个比例、什么时候不再降」(如「回款 20 天,从起始 10‰ 按日递减(满 360 天后固定按 4‰ 计算)」),不写 `基数开关=1`、`#12 参数化`、`9.8 方案公式` 这类需求编号。**分数原文(`11/1125`)与百分比拼装(`聚合:20%+5%`)不算说明文字**,那是金额口径的可复核原文,不要为了「口语化」把它改成四舍五入的小数。
## 常见问题
**起服务报 `APP_SECRET_KEY` 相关错误** —— `backend/.env` 未填或不足 32 字符。这是刻意的 fail-fast:空密钥签发 JWT 是安全问题,必须启动即失败。

View File

@ -9,9 +9,9 @@ Create Date: 2026-10-23 10:30:00.000000
两列均可空(null = 未评分,历史客户与新客户无需回填),人工维护字段,
T+ 同步守则不覆盖(见 TPlusSyncMixin 的同步守则说明)。
五项评分明细与命中等级不落库:等级映射是可变判定表,回溯「当时命中的
等级」需要快照设计,超出档案属性的范围;评级时间已足够回答「这个分是
什么时候打的」。
五项评分明细与命中等级都不落库:前者是打分过程的中间量,没有下游消费方;
后者由分数按当前等级映射实时推导(等级映射是可变判定表,改了配置全部客户
的等级随之更新),因此本迁移只落总分与评级时间两列。
"""
from collections.abc import Sequence

View File

@ -1,4 +1,4 @@
"""工作台接口(M4 ⑧):四区块一次性聚合。
"""工作台接口(M4 ⑧):三区块聚合 + 最近动态分页。
准入:管理员/财务/领导(与报表、审批接口一致;FR-033/036 的「全部用户」
待业务权限清单 #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.response import ApiResponse, ok
from app.models import User
from app.schemas.dashboard import DashboardOut
from app.schemas.dashboard import ActivityPageOut, ActivityQuery, DashboardOut
from app.services import dashboard_service
router = APIRouter(prefix="/dashboard", tags=["工作台"])
@ -23,7 +23,7 @@ _DashboardUser = Annotated[
]
@router.get("", response_model=ApiResponse[DashboardOut], summary="工作台四区块")
@router.get("", response_model=ApiResponse[DashboardOut], summary="工作台三区块")
def get_dashboard(
session: DbSession,
current_user: _DashboardUser,
@ -31,8 +31,22 @@ def get_dashboard(
str | None, Query(max_length=7, description="统计月份 YYYY-MM,默认当月")
] = None,
) -> ApiResponse[DashboardOut]:
"""一次返回经营总览/待办/风险预警/最近动态(待办与预警实时派生)。
"""一次返回经营总览/待办/风险预警(待办与预警实时派生)。
待办按当前用户角色只返回其可操作节点(admin 可查看全部在途单)。
最近动态是分页列表,走 `GET /dashboard/activities`。
"""
return ok(dashboard_service.get_dashboard(session, month, current_user=current_user))
@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))

View File

@ -142,6 +142,15 @@ def count_logs(session: Session, query: OperationLogListQuery) -> int:
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:
"""应用筛选条件(公共部分复用 query_utils,与登录日志同口径)。"""
stmt = apply_log_filters(stmt, OperationLog, query.filters)

View File

@ -32,8 +32,9 @@ def list_all(session: Session, *, for_update: bool = False) -> list[CustomerRati
def get_by_id(session: Session, grade_id: int) -> CustomerRatingGrade | None:
"""按主键查询。
等级表无下游引用(评级是纯计算器不落评分记录),无需 ``for_update``
锁行 —— product_repository 锁行防的是并发引用悬空,此处不存在该风险。
等级表无外键引用(客户档案只存总分,等级由分数按当前映射实时推导,
不存等级 id),无需 ``for_update`` 锁行 —— product_repository 锁行防的
是并发引用悬空,此处不存在该风险:删掉一个等级,只是以后不再判到它。
"""
return session.get(CustomerRatingGrade, grade_id)
@ -60,6 +61,10 @@ def create(session: Session, grade: CustomerRatingGrade) -> CustomerRatingGrade:
def delete(session: Session, grade: CustomerRatingGrade) -> None:
"""物理删除等级。等级表无下游引用,调用方无需引用保护校验。"""
"""物理删除等级。
等级表无外键引用(客户档案只存总分,不存等级 id),调用方无需引用保护
校验:删掉等级只是「以后不再判到它」,已存的总分不受影响。
"""
session.delete(grade)
session.flush()

View File

@ -1,9 +1,13 @@
"""工作台的请求/响应模型(M4 ⑧)。
四区块一次返回(前端一次请求渲染整页,避免 6-10 次往返):
经营绩效总览 / 待办事项 / 风险预警 / 最近动态。金额一律 Decimal
聚合端点一次返回三区块(前端一次请求渲染整页,避免多次往返):
经营绩效总览 / 待办事项 / 风险预警。金额一律 Decimal
(序列化为字符串),前端不做数值运算。
**最近动态独立成分页端点**:它可能有很长(操作日志全量),塞进聚合
响应既撑爆页面又只能截断前 20 条;拆开后翻页只重取这张列表,不必
重算总览/待办/预警。列表与模块筛选项同源返回(一次请求给全)。
待办与预警为**查询时实时派生**(不落库、不引调度器,见计划决策);
⑥ T+ 同步未建前,同步相关条目用 `sync_available=false` 显式降级,
不返回伪造的 0 条。
@ -12,7 +16,9 @@
from datetime import date, datetime
from decimal import Decimal
from pydantic import BaseModel, Field
from pydantic import BaseModel, ConfigDict, Field
from app.core.response import PageQuery
class TrendPoint(BaseModel):
@ -116,11 +122,42 @@ class ActivityItem(BaseModel):
create_time: datetime
class ActivityQuery(PageQuery):
"""最近动态查询条件:分页 + 模块筛选。
只支持模块筛选(工作台只需要这一个维度);其余筛选项见日志查询页。
"""
module: str | None = Field(default=None, max_length=64, description="模块,精确匹配")
class ActivityPageOut(BaseModel):
"""最近动态分页结果:当前页 + 总数 + 模块筛选项。
分页字段名沿用全站规范(list/total/pageNum/pageSize),与
`PageResult` 同形;额外带 `modules`(全量模块名),
供前端模块下拉使用 —— 不随当前页变化,避免翻页后选项增减。
"""
model_config = ConfigDict(populate_by_name=True)
records: list[ActivityItem] = Field(
default_factory=list, alias="list", serialization_alias="list", description="当前页数据"
)
total: int = Field(default=0, description="总记录数")
page_num: int = Field(
default=1, alias="pageNum", serialization_alias="pageNum", description="当前页码"
)
page_size: int = Field(
default=10, alias="pageSize", serialization_alias="pageSize", description="每页条数"
)
modules: list[str] = Field(default_factory=list, description="全部模块名(筛选项)")
class DashboardOut(BaseModel):
"""工作台四区块一次性响应。"""
"""工作台三区块一次性响应(最近动态走独立分页端点)。"""
month: str
overview: OverviewBlock
todos: TodoBlock
warnings: WarningBlock
activities: list[ActivityItem] = Field(default_factory=list)

View File

@ -60,7 +60,7 @@ from app.services.param_service import parse_fraction, resolve_snapshot
logger = logging.getLogger(__name__)
# snapshot_json 里的策略点注记(口径检索锚点;#12 已参数化,开关 0 时留此注记)
_NOTE_MANAGER_PENDING = "经理个人户单笔不计提(基数开关=0):聚合口径在月结时由团队聚合计算"
_NOTE_MANAGER_PENDING = "经理个人户单笔不计提(未计入分成基数):绩效在月结时按团队与全公司分成计算"
@dataclass(frozen=True, slots=True)
@ -544,7 +544,7 @@ def _personal_rate(
if customer_type == "enterprise":
# 存储口径 ‰ → 纯比例(漏换算放大 1000 倍,与预览同防线)
rate = parse_fraction(snapshot.get("enterprise.rate")) / 1000
return _RateDecision(rate, "企业户固定计提比例", None, False)
return _RateDecision(rate, "企业户:不按回款天数,统一按固定比例计提", None, False)
template = role.calc_template if role else "TPL_SALESPERSON_DECAY"
if template == "TPL_MANAGER_SHARE":
@ -554,7 +554,7 @@ def _personal_rate(
if snapshot.get("manager.personal.included") != "1":
return _RateDecision(
Decimal("0"),
"经理个人户:12.5%+5% 聚合计提(月结时计算),单笔落 0",
"经理个人户:绩效按团队与全公司分成在月结时计算,单笔不计提",
None,
True,
)
@ -563,12 +563,12 @@ def _personal_rate(
floor = parse_fraction(snapshot.get("salesperson.rate.floor"))
boundary = int(parse_fraction(snapshot.get("salesperson.days.boundary")))
rate = salesperson_rate(days, start=start, slope=slope, floor=floor, boundary_days=boundary)
note = f"经理个人户(基数开关=1):按业务员递减 {start}‰ 起,{days} 天"
note = f"经理个人户(已开启计入分成基数):按业务员递减,回款 {days} 天,起始 {start}‰"
return _RateDecision(rate, note, None, False)
if template == "TPL_INFO_SALES_FIXED":
# 信息员基数=本人销售额(覆盖折算与阈值),‰ 换算同防线
rate = parse_fraction(snapshot.get("info.rate")) / 1000
return _RateDecision(rate, "信息员:本人销售额 × 计提比例", None, False)
return _RateDecision(rate, "信息员:按本人销售额乘计提比例,不随回款天数递减", None, False)
if template == "TPL_SALESPERSON_DECAY":
start = parse_fraction(snapshot.get("salesperson.rate.start"))
slope = parse_fraction(snapshot.get("salesperson.rate.slope"))
@ -577,9 +577,16 @@ def _personal_rate(
# 经 Fraction 转换再取整(INT 型参数入库时已校验分母为 1,取整无损)
boundary = int(parse_fraction(snapshot.get("salesperson.days.boundary")))
rate = salesperson_rate(days, start=start, slope=slope, floor=floor, boundary_days=boundary)
note = f"业务员递减:{start}‰ 起,{days} 天(分界 {boundary} 天,超界取 {floor}‰)"
note = (
f"回款 {days} 天,从起始 {start}‰ 按日递减"
f"(满 {boundary} 天后固定按 {floor}‰ 计算)"
)
return _RateDecision(rate, note, None, False)
raise BadRequestError(f"公式模板 {template} 暂不支持核算")
# 提示给模板中文名而非编码:业务人员在岗位角色页看到的是「营销经理·团队与全公司分成」
from app.services.calc_templates import TEMPLATES
tpl_name = TEMPLATES[template].name if template in TEMPLATES else template
raise BadRequestError(f"「{tpl_name}」不支持单笔核算,请检查该人员的岗位设置")
def _role_of(session: Session, person):

View File

@ -280,7 +280,7 @@ def rate_customer(
评分是客户档案属性而非审计事件:只存总分与评级时间,五项明细与所用
等级配置不落库(对照表 #25 的定位口径)——等级映射是可变判定表,回溯
「当时命中的等级」需要快照设计,超出档案属性的范围;评级时间已足够
回答「这个分是什么时候打的」。
回答「这个分是什么时候打的」。等级由前端按当前映射实时算并展示。
:raises NotFoundError: 客户不存在
"""

View File

@ -1,4 +1,4 @@
"""工作台服务(M4 ⑧):四区块实时聚合编排。
"""工作台服务(M4 ⑧):区块实时聚合编排 + 最近动态分页。
设计决策(2026-10-19):
- **只读**:不落库、不写审计(读操作不入操作日志);总览只做 SQL 聚合,
@ -9,6 +9,8 @@
- **⑥ 同步模块未启用**:同步失败待办、同步连续失败预警以
``sync_available=false`` 显式降级,不返回伪造的 0 条
- 预警阈值走参数体系(``warning.*``),一次请求只解析一次快照
- **最近动态独立分页**(2026-10-23):操作日志可能很长,聚合响应里
只能塞前 20 条;拆成独立端点后翻页只重取这张列表
"""
from datetime import date, datetime, timedelta
@ -20,22 +22,23 @@ from app.core.exceptions import BadRequestError
from app.core.month import validate_month
from app.repositories import dashboard_repository, operation_log_repository, user_repository
from app.repositories.query_utils import LogFilters
from app.schemas.dashboard import ActivityItem, ActivityPageOut, ActivityQuery
from app.services import approval_service
# 工作台只展示摘要,完整列表跳转对应管理页
_TODO_LIMIT = 5
_WARNING_LIMIT = 20
_ACTIVITY_LIMIT = 20
_TREND_MONTHS = 6
_ORDER_TYPE_LABELS = {1: "二次分配方案", 2: "月度结算", 3: "调整单"}
def get_dashboard(session: Session, month: str | None = None, *, current_user) -> dict:
"""四区块一次性出数(前端一次请求渲染整页)。
"""三区块一次性出数(前端一次请求渲染整页)。
待办按当前用户角色只展示其可操作节点(admin 可查看全部在途单);
风险预警仍是全局经营视角,不按人裁剪。
最近动态不在此返回 —— 它是分页列表,走 ``list_activities``。
"""
resolved = _normalize_month(month)
warning_params = _warning_params(session)
@ -47,7 +50,6 @@ def get_dashboard(session: Session, month: str | None = None, *, current_user) -
"overview": _overview(session, resolved),
"todos": _todos(session, pending_statuses),
"warnings": _warnings(session, warning_params),
"activities": _activities(session),
}
@ -112,8 +114,8 @@ def _overview(session: Session, month: str) -> dict:
for item in months
],
"note": (
"绩效按台账归属月(单笔生效行,二次分摊拆行求和不失真);回款按到账月"
"(含未核算回款);不含经理 12.5%+5% 聚合分成与调整单"
"绩效按台账归属月统计(二次分配按参与人拆分后求和,不重复不计漏);"
"回款按到账月统计(含尚未核算的回款);不含经理团队分成与调整单"
),
}
@ -247,28 +249,38 @@ def _warnings(session: Session, params: dict) -> dict:
}
def _activities(session: Session) -> list[dict]:
"""最近动态(FR-036):以操作日志为唯一来源(写操作即留痕)。"""
logs = operation_log_repository.list_logs(
session,
operation_log_repository.OperationLogListQuery(
filters=LogFilters(),
offset=0,
limit=_ACTIVITY_LIMIT,
),
def list_activities(session: Session, query: ActivityQuery) -> ActivityPageOut:
"""最近动态分页(FR-036):以操作日志为唯一来源(写操作即留痕)。
模块筛选项与列表同源返回(全量模块名,不随当前页变化),
前端模块下拉不必另取一次全量。
"""
repo_query = operation_log_repository.OperationLogListQuery(
filters=LogFilters(),
offset=query.offset,
limit=query.page_size,
module=query.module,
)
logs = operation_log_repository.list_logs(session, repo_query)
total = operation_log_repository.count_logs(session, repo_query)
return ActivityPageOut(
records=[
ActivityItem(
id=log.id,
module=log.module,
action=log.action,
summary=log.summary,
username=log.username,
success=log.success,
create_time=log.create_time,
)
for log in logs
],
total=total,
page_num=query.page_num,
page_size=query.page_size,
modules=operation_log_repository.distinct_modules(session),
)
return [
{
"id": log.id,
"module": log.module,
"action": log.action,
"summary": log.summary,
"username": log.username,
"success": log.success,
"create_time": log.create_time,
}
for log in logs
]
def _dec(value) -> Decimal | None:

View File

@ -3,6 +3,10 @@
注册表是**迁移种子与运行期校验的唯一来源**:编码、默认值、取值域在此定义
一次;新增参数 = 改这里 + 生成迁移种子(两者一致性由测试锁定)。
值一律以 Fraction 可解析字符串表示(如 "4/360",禁 0.011111 —— 见算法红线)。
`description` 会直接显示在「计提参数」页的参数名下方,**面向业务人员撰写**:
说清这个参数管什么、钱怎么变、什么时候该调它。不写英文枚举名、需求编号、
内部阶段号,也不写实现层面的精度约束(那些留在代码注释与测试里)。
"""
from dataclasses import dataclass
@ -43,7 +47,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="10",
value_min="0",
value_max=None,
description="线性递减段起始值:比例 = 起始 − 回款天数 × 斜率(≤分界天数)",
description="回款当天能拿到的最高计提比例。回款每晚一天,按日递减斜率往下走,直到分界天数为止。",
sort_order=1,
),
ParamSpec(
@ -57,7 +61,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="4/360",
value_min="0",
value_max=None,
description="精确分数 4/360‰/天(=1/90),禁止按 0.011111 截断存储",
description="回款每延后一天,计提比例下降多少。按分数填写(如 4/360)可保持精确,填小数会引入四舍五入误差。",
sort_order=2,
),
ParamSpec(
@ -71,7 +75,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="4",
value_min="0",
value_max=None,
description="回款天数超过分界天数后固定取此比例(≥361 天 = 4‰)",
description="回款天数超过分界天数后,计提比例不再下降,固定按这个值计算,即最低计提比例。",
sort_order=3,
),
ParamSpec(
@ -85,7 +89,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="360",
value_min="0",
value_max=None,
description="≤分界天数按公式线性递减(360 天=6‰);超过则取下限",
description="回款天数在这个范围内按日递减;超过这个天数就固定按下限计提。默认 360 天。",
sort_order=4,
),
ParamSpec(
@ -99,7 +103,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="12.5",
value_min="0",
value_max="100",
description="∑本人带领业务员绩效 × 此比例(限本团队)",
description="经理按本团队业务员的绩效总额提取的分成比例,只统计自己带的业务员。",
sort_order=5,
),
ParamSpec(
@ -113,7 +117,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="5",
value_min="0",
value_max="100",
description="∑全部业务员绩效 × 此比例(全公司)",
description="经理按全公司业务员的绩效总额提取的分成比例,与本团队分成叠加计算。",
sort_order=6,
),
ParamSpec(
@ -127,7 +131,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="3",
value_min="0",
value_max=None,
description="所有企业户适用;2026-08-13 起由 2‰ 提高至 3‰",
description="企业户客户的回款统一按这个比例计提,不看回款天数。2026-08-13 起由 2‰ 调整为 3‰。",
sort_order=7,
),
ParamSpec(
@ -141,7 +145,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="1",
value_min="0",
value_max=None,
description="信息员绩效 = 本人销售额 × 此比例",
description="信息员的绩效按本人销售额乘这个比例计算,不随回款天数递减。",
sort_order=8,
),
ParamSpec(
@ -155,7 +159,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="10",
value_min="0",
value_max=None,
description="预付款统一按此比例计提",
description="预付款到账即按这个比例计提绩效,不必等到全额回款。",
sort_order=9,
),
ParamSpec(
@ -169,7 +173,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="80",
value_min="0",
value_max="100",
description="以物抵顶回款绩效按应发绩效的此比例折算",
description="以物抵顶的回款,按应发绩效的这个比例折算,即打折后的金额参与绩效。",
sort_order=10,
),
ParamSpec(
@ -183,7 +187,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="60",
value_min="0",
value_max="100",
description="单项业务回款额达销售额(不含运费)此比例后起付;达标前累计",
description="单项业务的回款累计达到销售额(不含运费)的这个比例后才开始发放绩效;达标之前只累计、不发放。",
sort_order=11,
),
ParamSpec(
@ -197,7 +201,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="FULL",
value_min=None,
value_max=None,
description="FULL=按 R×R 乘当月全额应发绩效;BALANCE=乘余额(语义待 M3 前确认)",
description="选择支付权重乘在哪个基数上:当月全额 = 乘本月全部应发绩效;余额 = 只乘尚未发放的部分。",
sort_order=12,
),
ParamSpec(
@ -211,7 +215,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="30",
value_min="0",
value_max="100",
description="折算毛利润额 = 销售额 ×(毛利润率 − 阈值);市政水利 30%、轨交 20%",
description="折算毛利润额 = 销售额 ×(毛利润率 − 阈值),低于阈值的部分不参与绩效。市政水利默认 30%,轨道交通默认 20%。",
sort_order=13,
),
ParamSpec(
@ -225,7 +229,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="INCLUDE",
value_min=None,
value_max=None,
description="INCLUDE含基本工资 / DEDUCT扣除基本工资 / EXCLUDE不计",
description="算月工资时怎么处理基本工资:含基本工资 = 与绩效一起发;扣除基本工资 = 从绩效里扣掉已发的基本工资;不计 = 月工资只发绩效。",
sort_order=14,
),
ParamSpec(
@ -239,7 +243,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="18000",
value_min="0",
value_max=None,
description="试用期年累计绩效超过此值部分年底一次性结算",
description="试用期绩效先不发放;年内累计超过这个金额的部分,年底一次性结算。",
sort_order=15,
),
ParamSpec(
@ -253,11 +257,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="1",
value_min=None,
value_max=None,
description=(
"0=二次分配方案提交即生效(系统自动通过全节点留痕,留痕不断链);"
"1=提交后进入财务复核→领导审核审批流。"
"默认开启(澄清 #5 业务答复 2026-10-19),业务可关闭"
),
description="二次分配方案提交后是否要审批:开启 = 先经财务复核、再经领导审核,通过后才生效;关闭 = 提交即生效,系统自动记录各节点留痕。",
sort_order=17,
),
ParamSpec(
@ -271,11 +271,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="1",
value_min=None,
value_max=None,
description=(
"月度结算审批链开关(④ 月结提交时消费):"
"0=结算生成即生效,1=走财务复核→领导审核审批流。"
"默认开启(澄清 #7 业务答复 2026-10-19,月结涉及工资发放从严),业务可关闭"
),
description="月度结算提交后是否要审批:开启 = 先经财务复核、再经领导审核;关闭 = 生成即生效。月结涉及工资发放,默认开启。",
sort_order=18,
),
ParamSpec(
@ -289,11 +285,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="0",
value_min=None,
value_max=None,
description=(
"0=调整单提交即生效(系统自动通过留痕);1=走财务复核→领导审核审批流。"
"调整单是已锁定月份补发/扣减的唯一通道(澄清 #9:走同一审批链,"
"默认值业务未明确,暂按直接生效,业务可开启)"
),
description="调整单提交后是否要审批:开启 = 先经财务复核、再经领导审核;关闭 = 提交即生效。调整单是已锁定月份补发或扣减的唯一通道。",
sort_order=19,
),
ParamSpec(
@ -307,11 +299,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="0",
value_min=None,
value_max=None,
description=(
"经理 12.5%+5% 聚合时是否计入经理本人名下经理个人户业绩(澄清 #12 "
"业务答复 2026-10-19:作为参数配置、手动调整是否纳入):"
"0=不计入(默认,暂口径) 1=计入"
),
description="经理提取团队分成时,是否把经理本人名下的业绩(经理个人户)也算进分成基数。默认不计入。",
sort_order=20,
),
# ===== 预警规则(M4 ⑧ 工作台,澄清 #10「做成参数化规则便于调整」)=====
@ -327,7 +315,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="1",
value_min=None,
value_max=None,
description="工作台风险预警区块总开关:0=关闭(不扫描不展示) 1=开启(默认)",
description="工作台风险预警区块的总开关:关闭后不再扫描、也不展示预警条目。默认开启。",
sort_order=21,
),
ParamSpec(
@ -341,7 +329,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="90",
value_min="1",
value_max=None,
description=("发货后超过该天数仍未回款的发货批次触发预警(澄清 #10 建议默认 90 天)"),
description="发货后超过这么多天仍未收到回款的发货批次,会出现在工作台的预警列表里。默认 90 天。",
sort_order=22,
),
ParamSpec(
@ -355,7 +343,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="3",
value_min="1",
value_max=None,
description=("待审批单提交后超过该天数未处理触发预警(澄清 #10 建议默认 3 天)"),
description="审批单提交后超过这么多天还没处理,会出现在工作台的预警列表里。默认 3 天。",
sort_order=23,
),
]

View File

@ -44,7 +44,7 @@ from app.schemas.param import (
ParamPreviewOut,
ParamUsedOut,
)
from app.services import audit_service, formula
from app.services import audit_service, calc_templates, formula
from app.services.audit_service import AuditRecord, RequestMeta
from app.services.formula import to_decimal
from app.services.no_generator import next_no
@ -557,25 +557,25 @@ def _preview_rate(ctx: _PreviewContext, days: int) -> tuple[Fraction, str, Decim
# 存储口径为 ‰ 数(如 "3"),除以 1000 转纯比例 —— performance_amount
# 按纯比例相乘,漏换算会把绩效金额放大 1000 倍(与业务员分支同防线)
rate = take("enterprise.rate") / 1000
return rate, "企业户固定计提比例", None
return rate, "企业户:不按回款天数,统一按固定比例计提", None
role_code = payload.business_role
template = "TPL_SALESPERSON_DECAY"
if role_code:
role = business_role_repository.get_by_code(ctx.session, role_code)
if role is None:
raise BadRequestError(f"岗位 {role_code!r} 不存在")
raise BadRequestError(f"岗位「{role_code}」不存在,请到岗位角色里确认")
template = role.calc_template
if template == "TPL_MANAGER_SHARE":
raise BadRequestError(
"营销经理绩效为团队聚合口径(本团队 12.5% + 全公司 5%),不支持单笔预览"
"营销经理的绩效按团队与全公司分成计算,不单笔试算;请到月度结算里查看"
)
if template == "TPL_INFO_SALES_FIXED":
# ‰ 存储口径 → 纯比例,同企业户分支(不换算则绩效放大 1000 倍)
rate = take("info.rate") / 1000
# 信息员的基数是本人销售额(无折算与阈值),覆盖默认基数
return rate, "信息员:本人销售额 × 计提比例", Decimal(payload.revenue)
return rate, "信息员:按本人销售额乘计提比例,不随回款天数递减", Decimal(payload.revenue)
if template == "TPL_SALESPERSON_DECAY":
start = take("salesperson.rate.start")
slope = take("salesperson.rate.slope")
@ -584,20 +584,27 @@ def _preview_rate(ctx: _PreviewContext, days: int) -> tuple[Fraction, str, Decim
rate = formula.salesperson_rate(
days, start=start, slope=slope, floor=floor, boundary_days=boundary
)
# 说明写成业务话:说清这笔为什么是这个比例、什么时候不再降
return (
rate,
f"业务员递减:{to_decimal(start)}‰ 起,{days} 天(分界 {boundary} 天,超界取 {to_decimal(floor)}‰)",
f"回款 {days} 天,从起始 {to_decimal(start)}‰ 按日递减"
f"(满 {boundary} 天后固定按 {to_decimal(floor)}‰ 计算)",
None,
)
raise BadRequestError(f"公式模板 {template} 暂不支持预览")
# 提示里给模板中文名而不是编码:编码是内部标识,业务人员在岗位角色页看不到它
tpl_name = calc_templates.TEMPLATES[template].name if template in calc_templates.TEMPLATES else template
raise BadRequestError(
f"「{tpl_name}」不支持单笔试算,请在岗位角色里改用业务员递减或信息员固定计提"
)
def _preview_rate_decision(ctx: _PreviewContext) -> _PreviewRateDecision:
"""产品类别校验 + 回款天数 + 计提比例判定。"""
payload = ctx.payload
if payload.product_category and payload.product_category not in PRODUCT_CATEGORIES:
# 提示里用中文类别名:前端下拉显示的是「市政水利」,报错却回英文编码会让人对不上
raise BadRequestError(
f"产品类别 {payload.product_category!r} 不存在,可选:municipal_water / rail_transit"
f"产品类别「{payload.product_category}」不存在,可选:市政水利、轨道交通"
)
days = formula.payment_days(payload.ship_date, payload.receipt_date)
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:
return None
annual = ctx.take("probation.annual.threshold")
return f"试用期绩效照算不发放;年累计超 {to_decimal(annual)} 元部分年底一次性结算"
return (
f"试用期人员:绩效照常计算但不发放;年内累计超过 {to_decimal(annual)} 元的部分,"
"年底一次性结清。"
)
def preview(session: Session, payload: ParamPreviewIn) -> ParamPreviewOut:

View File

@ -423,9 +423,10 @@ def _aggregate_row(
"members": member_names,
"params_as_of": str(share.param_date),
"manager_personal_included": 1 if share.include_manager_personal else 0,
# 说明写给业务人员看:说清这一笔的基数是怎么凑出来的
"note": (
f"#12 参数化:基数{'含' if share.include_manager_personal else '不含'}"
"经理个人户业绩;9.8 方案公式"
f"团队分成:基数{'含' if share.include_manager_personal else '不含'}"
"经理本人名下业绩,按本团队与全公司两级比例合计计提"
),
},
),

View File

@ -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 | 全部 | 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%` 断言未动(那是分数原文与百分比拼装,非说明文案) |
## 四、测试发现并修复的缺陷
| 日期 | 缺陷 | 影响 | 处理 |

View File

@ -118,10 +118,20 @@ def _dashboard(client: TestClient, headers: dict, month: str | None = None) -> d
return resp.json()["data"]
def _activities(client: TestClient, headers: dict, **params) -> dict:
"""最近动态分页端点。"""
resp = client.get(f"{_DASHBOARD}/activities", params=params, headers=headers)
assert resp.status_code == 200, resp.text
return resp.json()["data"]
def test_权限_普通用户403文案(client: TestClient, normal: dict):
resp = client.get(_DASHBOARD, headers=normal["headers"])
assert resp.status_code == 403
assert resp.json()["message"] == "无权访问工作台"
# 最近动态分页端点同属工作台,权限一致
resp = client.get(f"{_DASHBOARD}/activities", headers=normal["headers"])
assert resp.status_code == 403
def test_总览_绩效合计与台账一致且非法月份400(client: TestClient, admin: dict):
@ -221,9 +231,40 @@ def test_预警_审批超时命中且参数关闭后不产生(client: TestClient
def test_动态_来源操作日志(client: TestClient, admin: dict):
_chain(client, admin["headers"])
data = _dashboard(client, admin["headers"])
assert data["activities"], "工作台动态为空:写操作应产生操作日志"
assert {"module", "action", "summary", "create_time"} <= set(data["activities"][0])
data = _activities(client, admin["headers"])
assert data["list"], "工作台动态为空:写操作应产生操作日志"
assert {"module", "action", "summary", "create_time"} <= set(data["list"][0])
def test_动态_分页翻页不重不漏(client: TestClient, admin: dict):
_chain(client, admin["headers"])
first = _activities(client, admin["headers"], pageNum=1, pageSize=2)
assert first["pageNum"] == 1
assert first["pageSize"] == 2
assert len(first["list"]) == 2
assert first["total"] >= 2
second = _activities(client, admin["headers"], pageNum=2, pageSize=2)
assert len(second["list"]) == 2
# 两页不重不漏(倒序取,按 id 倒排)
first_ids = {item["id"] for item in first["list"]}
second_ids = {item["id"] for item in second["list"]}
assert first_ids.isdisjoint(second_ids)
assert first["list"][0]["id"] > second["list"][0]["id"]
def test_动态_模块筛选与筛选项(client: TestClient, admin: dict):
_chain(client, admin["headers"])
page = _activities(client, admin["headers"], pageSize=50)
assert page["modules"], "模块筛选项不应为空"
module = page["modules"][0]
filtered = _activities(client, admin["headers"], module=module, pageSize=50)
assert filtered["total"] >= 1
assert filtered["list"], "按模块筛选后应有数据"
assert all(item["module"] == module for item in filtered["list"])
# 筛选项不随筛选条件变化(下拉里始终是全量模块)
assert filtered["modules"] == page["modules"]
# ==== 角色待办 / 预警阈值 / 趋势(2026-10-19 code-review 修复回归)====

View File

@ -371,7 +371,11 @@ class TestPreview:
}
resp = client.post(f"{_PARAMS}/preview", json=payload, headers=admin["headers"])
assert resp.status_code == 400
assert "聚合" in resp.json()["message"]
# 守两件事:① 聚合口径不允许单笔试算(拒绝而不是算出个错数);
# ② 提示要能指导下一步(点名是谁的口径 + 去哪儿看),不是一句「不支持」。
message = resp.json()["message"]
assert "营销经理" in message
assert "月度结算" in message
def test_回款日早于发货日_400(self, client: TestClient, admin: dict) -> None:
payload = {

View File

@ -439,29 +439,29 @@ INSERT INTO param_change_batches (id, version_no, effective_date, change_type, r
(1, 'P20260101-00', '2026-01-01', 1, '初始版本(系统初始化种子,口径依据 2026-10-12 业务裁决与 2026-10-19 澄清答复)', 'system', 19);
INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES
(1, 'salesperson.rate.start', '业务员起始计提比例', '业务员个人户', 'DECIMAL', '‰', 'BUSINESS_ROLE', NULL, '10', '0', NULL, '线性递减段起始值:比例 = 起始 − 回款天数 × 斜率(≤分界天数)', 1),
(2, 'salesperson.rate.slope', '业务员日递减斜率', '业务员个人户', 'DECIMAL', '‰/天', 'BUSINESS_ROLE', NULL, '4/360', '0', NULL, '精确分数 4/360‰/天(=1/90),禁止按 0.011111 截断存储', 2),
(3, 'salesperson.rate.floor', '业务员计提下限', '业务员个人户', 'DECIMAL', '‰', 'BUSINESS_ROLE', NULL, '4', '0', NULL, '回款天数超过分界天数后固定取此比例(≥361 天 = 4‰)', 3),
(4, 'salesperson.days.boundary', '线性递减分界天数', '业务员个人户', 'INT', '天', 'BUSINESS_ROLE', NULL, '360', '0', NULL, '≤分界天数按公式线性递减(360 天=6‰);超过则取下限', 4),
(5, 'manager.share.team', '经理本团队分成比例', '经理分成', 'DECIMAL', '%', 'BUSINESS_ROLE', NULL, '12.5', '0', '100', '∑本人带领业务员绩效 × 此比例(限本团队)', 5),
(6, 'manager.share.company', '经理全公司分成比例', '经理分成', 'DECIMAL', '%', 'BUSINESS_ROLE', NULL, '5', '0', '100', '∑全部业务员绩效 × 此比例(全公司)', 6),
(7, 'enterprise.rate', '企业户计提比例', '企业户', 'DECIMAL', '‰', 'PRODUCT_CATEGORY', NULL, '3', '0', NULL, '所有企业户适用;2026-08-13 起由 2‰ 提高至 3‰', 7),
(8, 'info.rate', '信息员计提比例', '信息员', 'DECIMAL', '‰', 'BUSINESS_ROLE', NULL, '1', '0', NULL, '信息员绩效 = 本人销售额 × 此比例', 8),
(9, 'prepay.rate', '预付款计提比例', '支付环节', 'DECIMAL', '‰', 'NONE', NULL, '10', '0', NULL, '预付款统一按此比例计提', 9),
(10, 'barter.discount', '以物抵顶折算比例', '支付环节', 'DECIMAL', '%', 'NONE', NULL, '80', '0', '100', '以物抵顶回款绩效按应发绩效的此比例折算', 10),
(11, 'payment.start.ratio', '支付起付比例', '支付环节', 'DECIMAL', '%', 'NONE', NULL, '60', '0', '100', '单项业务回款额达销售额(不含运费)此比例后起付;达标前累计', 11),
(12, 'payment.weight.mode', '支付权重应用方式', '支付环节', 'ENUM', NULL, 'NONE', 'FULL|BALANCE', 'FULL', NULL, NULL, 'FULL=按 R×R 乘当月全额应发绩效;BALANCE=乘余额(语义待 M3 前确认)', 12),
(13, 'product.threshold', '折算毛利润额阈值', '产品阈值', 'DECIMAL', '%', 'PRODUCT_CATEGORY', NULL, '30', '0', '100', '折算毛利润额 = 销售额 ×(毛利润率 − 阈值);市政水利 30%、轨交 20%', 13),
(14, 'salary.basic.mode', '月工资基本工资口径', '月工资口径', 'ENUM', NULL, 'BUSINESS_ROLE', 'INCLUDE|DEDUCT|EXCLUDE', 'INCLUDE', NULL, NULL, 'INCLUDE含基本工资 / DEDUCT扣除基本工资 / EXCLUDE不计', 14),
(15, 'probation.annual.threshold', '试用期年累计结算阈值', '月工资口径', 'DECIMAL', '元', 'NONE', NULL, '18000', '0', NULL, '试用期年累计绩效超过此值部分年底一次性结算', 15),
(17, 'approval.secondary_allocation.enabled', '二次分配审批开关', '审批开关', 'BOOL', NULL, 'NONE', NULL, '1', NULL, NULL, '0=二次分配方案提交即生效(系统自动通过全节点留痕,留痕不断链);1=提交后进入财务复核→领导审核审批流。默认开启(澄清 #5 业务答复 2026-10-19),业务可关闭', 17),
(18, 'approval.monthly_settlement.enabled', '月度结算审批开关', '审批开关', 'BOOL', NULL, 'NONE', NULL, '1', NULL, NULL, '月度结算审批链开关(④ 月结提交时消费):0=结算生成即生效,1=走财务复核→领导审核审批流。默认开启(澄清 #7 业务答复 2026-10-19,月结涉及工资发放从严),业务可关闭', 18),
(19, 'approval.adjustment.enabled', '调整单审批开关', '审批开关', 'BOOL', NULL, 'NONE', NULL, '0', NULL, NULL, '0=调整单提交即生效(系统自动通过留痕);1=走财务复核→领导审核审批流。调整单是已锁定月份补发/扣减的唯一通道(澄清 #9:走同一审批链,默认值业务未明确,暂按直接生效,业务可开启)', 19),
(20, 'manager.personal.included', '经理个人户计入聚合基数', '经理分成', 'BOOL', NULL, 'NONE', NULL, '0', NULL, NULL, '经理 12.5%+5% 聚合时是否计入经理本人名下经理个人户业绩(澄清 #12 业务答复 2026-10-19:作为参数配置、手动调整是否纳入):0=不计入(默认,暂口径) 1=计入', 20),
-- 预警规则参数(M4 ⑧ 工作台,澄清 #10「做成参数化规则便于调整」)
(21, 'warning.enabled', '风险预警总开关', '预警规则', 'BOOL', NULL, 'NONE', NULL, '1', NULL, NULL, '工作台风险预警区块总开关:0=关闭(不扫描不展示) 1=开启(默认)', 21),
(22, 'warning.receipt.overdue.days', '回款逾期天数阈值', '预警规则', 'INT', '天', 'NONE', NULL, '90', '1', NULL, '发货后超过该天数仍未回款的发货批次触发预警(澄清 #10 建议默认 90 天)', 22),
(23, 'warning.approval.timeout.days', '审批超时天数阈值', '预警规则', 'INT', '天', 'NONE', NULL, '3', '1', NULL, '待审批单提交后超过该天数未处理触发预警(澄清 #10 建议默认 3 天)', 23);
(1, 'salesperson.rate.start', '业务员起始计提比例', '业务员个人户', 'DECIMAL', '‰', 'BUSINESS_ROLE', NULL, '10', '0', NULL, '回款当天能拿到的最高计提比例。回款每晚一天,按日递减斜率往下走,直到分界天数为止。', 1),
(2, 'salesperson.rate.slope', '业务员日递减斜率', '业务员个人户', 'DECIMAL', '‰/天', 'BUSINESS_ROLE', NULL, '4/360', '0', NULL, '回款每延后一天,计提比例下降多少。按分数填写(如 4/360)可保持精确,填小数会引入四舍五入误差。', 2),
(3, 'salesperson.rate.floor', '业务员计提下限', '业务员个人户', 'DECIMAL', '‰', 'BUSINESS_ROLE', NULL, '4', '0', NULL, '回款天数超过分界天数后,计提比例不再下降,固定按这个值计算,即最低计提比例。', 3),
(4, 'salesperson.days.boundary', '线性递减分界天数', '业务员个人户', 'INT', '天', 'BUSINESS_ROLE', NULL, '360', '0', NULL, '回款天数在这个范围内按日递减;超过这个天数就固定按下限计提。默认 360 天。', 4),
(5, 'manager.share.team', '经理本团队分成比例', '经理分成', 'DECIMAL', '%', 'BUSINESS_ROLE', NULL, '12.5', '0', '100', '经理按本团队业务员的绩效总额提取的分成比例,只统计自己带的业务员。', 5),
(6, 'manager.share.company', '经理全公司分成比例', '经理分成', 'DECIMAL', '%', 'BUSINESS_ROLE', NULL, '5', '0', '100', '经理按全公司业务员的绩效总额提取的分成比例,与本团队分成叠加计算。', 6),
(7, 'enterprise.rate', '企业户计提比例', '企业户', 'DECIMAL', '‰', 'PRODUCT_CATEGORY', NULL, '3', '0', NULL, '企业户客户的回款统一按这个比例计提,不看回款天数。2026-08-13 起由 2‰ 调整为 3‰。', 7),
(8, 'info.rate', '信息员计提比例', '信息员', 'DECIMAL', '‰', 'BUSINESS_ROLE', NULL, '1', '0', NULL, '信息员的绩效按本人销售额乘这个比例计算,不随回款天数递减。', 8),
(9, 'prepay.rate', '预付款计提比例', '支付环节', 'DECIMAL', '‰', 'NONE', NULL, '10', '0', NULL, '预付款到账即按这个比例计提绩效,不必等到全额回款。', 9),
(10, 'barter.discount', '以物抵顶折算比例', '支付环节', 'DECIMAL', '%', 'NONE', NULL, '80', '0', '100', '以物抵顶的回款,按应发绩效的这个比例折算,即打折后的金额参与绩效。', 10),
(11, 'payment.start.ratio', '支付起付比例', '支付环节', 'DECIMAL', '%', 'NONE', NULL, '60', '0', '100', '单项业务的回款累计达到销售额(不含运费)的这个比例后才开始发放绩效;达标之前只累计、不发放。', 11),
(12, 'payment.weight.mode', '支付权重应用方式', '支付环节', 'ENUM', NULL, 'NONE', 'FULL|BALANCE', 'FULL', NULL, NULL, '选择支付权重乘在哪个基数上:当月全额 = 乘本月全部应发绩效;余额 = 只乘尚未发放的部分。', 12),
(13, 'product.threshold', '折算毛利润额阈值', '产品阈值', 'DECIMAL', '%', 'PRODUCT_CATEGORY', NULL, '30', '0', '100', '折算毛利润额 = 销售额 ×(毛利润率 − 阈值),低于阈值的部分不参与绩效。市政水利默认 30%,轨道交通默认 20%。', 13),
(14, 'salary.basic.mode', '月工资基本工资口径', '月工资口径', 'ENUM', NULL, 'BUSINESS_ROLE', 'INCLUDE|DEDUCT|EXCLUDE', 'INCLUDE', NULL, NULL, '算月工资时怎么处理基本工资:含基本工资 = 与绩效一起发;扣除基本工资 = 从绩效里扣掉已发的基本工资;不计 = 月工资只发绩效。', 14),
(15, 'probation.annual.threshold', '试用期年累计结算阈值', '月工资口径', 'DECIMAL', '元', 'NONE', NULL, '18000', '0', NULL, '试用期绩效先不发放;年内累计超过这个金额的部分,年底一次性结算。', 15),
(17, 'approval.secondary_allocation.enabled', '二次分配审批开关', '审批开关', 'BOOL', NULL, 'NONE', NULL, '1', NULL, NULL, '二次分配方案提交后是否要审批:开启 = 先经财务复核、再经领导审核,通过后才生效;关闭 = 提交即生效,系统自动记录各节点留痕。', 17),
(18, 'approval.monthly_settlement.enabled', '月度结算审批开关', '审批开关', 'BOOL', NULL, 'NONE', NULL, '1', NULL, NULL, '月度结算提交后是否要审批:开启 = 先经财务复核、再经领导审核;关闭 = 生成即生效。月结涉及工资发放,默认开启。', 18),
(19, 'approval.adjustment.enabled', '调整单审批开关', '审批开关', 'BOOL', NULL, 'NONE', NULL, '0', NULL, NULL, '调整单提交后是否要审批:开启 = 先经财务复核、再经领导审核;关闭 = 提交即生效。调整单是已锁定月份补发或扣减的唯一通道。', 19),
(20, 'manager.personal.included', '经理个人户计入聚合基数', '经理分成', 'BOOL', NULL, 'NONE', NULL, '0', NULL, NULL, '经理提取团队分成时,是否把经理本人名下的业绩(经理个人户)也算进分成基数。默认不计入。', 20),
-- 预警规则参数:工作台预警阈值,可按业务需要调整
(21, 'warning.enabled', '风险预警总开关', '预警规则', 'BOOL', NULL, 'NONE', NULL, '1', NULL, NULL, '工作台风险预警区块的总开关:关闭后不再扫描、也不展示预警条目。默认开启。', 21),
(22, 'warning.receipt.overdue.days', '回款逾期天数阈值', '预警规则', 'INT', '天', 'NONE', NULL, '90', '1', NULL, '发货后超过这么多天仍未收到回款的发货批次,会出现在工作台的预警列表里。默认 90 天。', 22),
(23, 'warning.approval.timeout.days', '审批超时天数阈值', '预警规则', 'INT', '天', 'NONE', NULL, '3', '1', NULL, '审批单提交后超过这么多天还没处理,会出现在工作台的预警列表里。默认 3 天。', 23);
INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES
(1, 1, 'salesperson', '2026-02-17', '10', 1),

View File

@ -5,7 +5,12 @@
<title>衍水工资系统 · 视觉方向预览</title>
<style>
/* ============================================================
设计体系 v4.1(同步已交付模块 + 实测补订):中性灰白 · 靛蓝点缀 · 微质感
设计体系 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):设计稿与已交付功能对齐
1) 侧栏菜单改为真实路由分组(5 组 + 首页 = 21 项),组可折叠
2) 首页工作台按真实四区块重画(总览 / 待办 / 预警 / 动态)
@ -264,10 +269,8 @@
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);
}
.menu-item.active::before {
content: ''; position: absolute; left: -14px; top: 8px; bottom: 8px; width: 2px;
border-radius: 1px; background: var(--ys-indigo);
}
/* 子项缩进一档(12px):层级是位置关系,不是字号关系 */
.menu-group-head ~ .menu-item { padding-left: 22px; }
.menu-item svg { width: 16px; height: 16px; flex: none; opacity: 0.7; }
.menu-item.active svg { opacity: 1; }
.aside-foot { margin-top: auto; padding: 0 10px 2px; }
@ -423,14 +426,17 @@
.login-btn { width: 100%; height: 38px; margin-top: 6px; font-size: 14px; }
/* ---------- 菜单分组(路由 meta.group 派生的可折叠组) ---------- */
/*
* 层级口径(v4.2):父子同字号 13px —— 组标题与条目一样大,
* 结构靠「折叠箭头 + 组间留白 + 子项缩进一档」表达,不靠字号压过。
*/
.menu-group-head {
display: flex; align-items: center; justify-content: space-between;
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; }
.menu-group.open .menu-group-head .chev { opacity: 0.9; transform: rotate(90deg); }
.menu-group-head .chev { width: 11px; height: 11px; flex: none; opacity: 0.55; color: var(--ys-ink-3); }
.menu-group.open .menu-group-head .chev { opacity: 0.9; color: var(--ys-ink-2); transform: rotate(90deg); }
/* ---------- 工作台卡片内的列表行 ---------- */
.b-count {
@ -1039,7 +1045,9 @@
<tr><td>不进菜单</td><td>报表打印视图(独立顶层路由)</td><td class="num">浏览器打印只出报表本身</td></tr>
</table>
<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 落地:
路由 <code>meta.icon</code> 只写语义 key,图标注册在 <code>menuIcons.ts</code>。
本稿画的是手绘线性稿,实际取自 Element Plus 图标库的线性变体——<b>形状以图标库为准</b>,
@ -1178,9 +1186,14 @@
<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>
</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-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>
@ -1204,6 +1217,8 @@
待办与预警并排 2×2 → 最近动态与常用操作整行铺满(<code>b-full</code>)。
卡片入场 <b>45ms 交错上浮(320ms)</b>,悬停抬升 2px、影加深。
待办与预警的列表<b>在卡内滚动</b>(上限 178px)——条目再多也不撑开网格,行高恒定。
<b>最近动态走服务端分页</b>(2026-10-23):列表不再靠卡内滚动藏条目,每页 10/20/50 条,
底部接右对齐分页条(与列表页同一套 <code>AppPagination</code>);模块筛选同步到服务端。
空态(<code>b-empty</code>)在卡片剩余高度内<b>垂直居中</b>,不全部挤在顶部留一整块空白。
</div>
</section>

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-23 改版):组标题与菜单项**同字号 13px**——层级靠「折叠箭头 + 组间留白 + 子项缩进 12px」表达,不靠字号/字重压过父级。前两版都不采用:11px 弱墨「目录行」让父级弱于子项,14px/600 加靛蓝竖标又让父级刻意压过子项、像一排小标题在喊叫。激活态只保留**白底浮起 + 靛蓝字**,不再叠左侧 2px 竖条——竖条只能落在侧栏内边缘、与白底悬空,是重复的第二个信号。菜单横向 10px 留白由 `AppMenu.vue` 的 `.app-menu` 提供,`BasicLayout.vue` 的 `.app-aside__menu` 只写上下内距:两处都用 `padding` 简写会同权重靠注入顺序决胜,横向留白会被整条抹掉、条目贴到侧栏边缘。
**签名元素**:**渐隐靛蓝水线**——各页面标题下的细线(56px,左实右虚)与登录页品牌名下的短线(40px),「衍水」之水的提纯,全站唯一的装饰性元素;登录页右上角另有涟漪同心圆与顶部靛蓝天光作背景(低透明度)。与早期深色面板 + 水纹层理同一意象,品牌连续而非推倒重来。
**打印视图**(`views/reports/print/`):纸面产物与屏幕稿不同源——独立顶层路由、不挂侧栏顶栏,白灰画布上一张定宽白稿纸,表格 11px 细实线、表头浅底左对齐(数值列表头右对齐)、章节编号、三栏签字(制表 / 复核 / 审批),无圆角无阴影。屏幕上的操作条带 `.no-print`,打印时整块消失。超出打印行数上限时只打前 N 行并在稿面明确提示,不静默截断。
@ -148,7 +150,7 @@ pnpm build # 类型检查 + 生产构建
**构图**:登录页品牌内容(名称 / 标语 / 水线)**垂直居中聚成一块**,配一层柔和径向光晕作落点。
早期版本用 `space-between` 把品牌与标语顶到两端,中间空出大半屏,两处都成了孤岛——超宽屏会把这种疏离放大得更明显。**内容块要限宽**(`max-width`),否则宽屏下文字会被拉散。
### 写代码时的五条约定
### 写代码时的六条约定
- 覆盖 Element Plus 主题请改 **CSS 变量**(`--el-button-bg-color` 等),不要直接写 `background-color` —— 后者与 `.el-button--primary` 同权重,会受样式注入顺序影响而不稳定
- 中文可在任意字间断行,**关键文案的断行位置要显式控制**(如主标语拆成两个 `<span>`),交给浏览器自动换行会出现单字独占一行的难看结果
@ -156,6 +158,7 @@ pnpm build # 类型检查 + 生产构建
- **确认框统一加 `customClass: 'ys-confirm-box'`,不要再传 `type: 'warning'`** —— Element Plus 的默认 warning 图标会渲染在正文左侧独立成列(多行正文时图标悬在文字块中间),与 v3 靛蓝体系也冲突。全局样式(`src/assets/styles/index.css`)已把图标改为 18px 描边样式并放在标题行左侧,正文与标题文字对齐
- **页面 scoped 里只写本页特有的东西**:表格 / 按钮 / 输入框 / 表头 / 分页的皮肤已经全局覆盖,页面再写一遍与组件库同权重,会受样式注入顺序影响而不稳定;结构用全局类(见上一节表格),样式只补本页独有的块(如展开区、抽屉内的键值表)
- **状态 / 结果列的固定宽度要按胶囊内容给足**:`.cell` 左右各 16px 内距(共 32px),2 字胶囊约 55px、3 字约 67px、4 字约 79px,宽度不够时 Element Plus 会在胶囊右侧画一个省略号(看起来像多了个小点)。口径:2 字列 ≥96px(如 启用/禁用、成功/失败)、3 字列 ≥108px(如 已核算/已替代/执行中)、4 字列 ≥120px(如 以物抵顶)
- **`v-loading` 只罩数据区,不要罩到分页条上**:遮罩会拦截指针事件,盖住分页器后用户点翻页其实点在遮罩上被吞掉,得再点一次,观感就是「数据反复变化」。详见下文「分页与「数据反复变化」的两个坑」
## 验证码图片
@ -259,14 +262,20 @@ E2E_ADMIN_PASSWORD=<开发库admin密码> python e2e/system_management.py
## 前端页面(2026-10-19 追加 · M4 ⑧ 工作台)
`/dashboard` 由 Bento 占位升级为工作台四区块(**一次聚合请求** `/api/v1/dashboard`):
`/dashboard` 由 Bento 占位升级为工作台(**总览/待办/预警一次聚合请求** `/api/v1/dashboard`;最近动态是分页列表,走独立端点):
| 区块 | 展示 |
| --- | --- |
| 经营绩效总览 | KPI 四卡(本月绩效/应付/核算人数/团队数 + 月结状态);近 6 个月绩效折线 + 回款柱状(ECharts) |
| 待办事项 | 待审批(跳审批管理)、回款未指定批次(跳回款管理)、T+ 同步失败(未启用显式占位) |
| 风险预警 | 回款逾期(danger)/ 审批超时(warning),点击跳处理页;总开关关闭时显示参数提示 |
| 最近动态 | 操作日志时间线,按模块本地筛选 |
| 最近动态 | 操作日志列表,**服务端分页**(每页 10/20/50)+ 按模块筛选 |
- **最近动态独立分页端点**(2026-10-23):`GET /api/v1/dashboard/activities?pageNum&pageSize&module`,返回 `list/total/pageNum/pageSize/modules`(`modules` 是全量模块名,供下拉用,不随翻页变化)。与聚合端点拆开的原因:操作日志可能很长,塞进聚合响应只能截断前 20 条;拆开后**翻页只重取这张列表**,不必重算总览/待办/预警。分页字段沿用全站规范(`pageNum`/`pageSize`/`total`),组件用统一的 `AppPagination` + `usePagedList`(含请求序号防竞态)。
- **分页与「数据反复变化」的两个坑(2026-10-23 实测定位并修复,改分页相关代码前先读)**:
1. **`v-loading` 不能挂在包含分页条的容器上**。Element Plus 的遮罩会拦截指针事件:遮罩一出现,用户点的「上一页 / 下一页」其实点在遮罩上被吞掉,必须再点一次才生效,观感就是「页码和数据对不上、来回跳」。工作台最近动态原先 `v-loading` 挂在 `.activity-body`(同时包住列表与分页条),已改为只罩列表的 `.activity-list-wrap`;其余列表页的 `v-loading` 都挂在 `el-table` 上,分页条在表格之外,天然没有这个问题——**新增页面请照此办理**。
2. **翻页失败必须把分页器退回「屏幕上正在显示的那一页」**。`records` 只在请求成功时更新,而分页器高亮绑的是 `pageNum`;失败不回滚就会出现「高亮第 1 页、列表是第 2 页的数据」的永久错位(用户来回点才恢复)。`usePagedList` 里用 `shownPageNum` / `shownPageSize` 记录实际显示的页,失败时 `restoreShownPage(seq)` 退回;**回滚本身也要防竞态**——只有本次请求仍是最新请求(`seq === requestSeq`)才回滚,否则连点时旧请求失败会把用户刚点出的新页码覆盖掉,表现为「页码自己跳回去」。页码与条数在 `await` 之前取快照,避免等待期间被别的点击改动后记错「显示的页」。
- 两个缺陷都用 Playwright 探针实测过:`ai-aux/preview/probe_pager7.py`(8/8:正常连点、请求在飞时命中分页器而非遮罩、失败回滚、解除注入后恢复)、`probe_pager13.py`(旧请求失败不拉回页码)、`probe_pager14.py`(点第 3 页失败退回第 1 页、连续两次失败仍停在当前页、解除注入后可正常翻页)、`probe_pager8.py`(其它 `usePagedList` 列表页回归)。
- **ECharts 5.6 按需引入**:`echarts/core` + `BarChart/LineChart/Grid/Tooltip` + `SVGRenderer`(与设计预览一致),只注册用到的模块。
- **字体红线**:图表 `textStyle.fontFamily` 必须显式设为思源黑体栈——ECharts 在 Windows 默认微软雅黑(字体商用合规要求)。

View File

@ -65,7 +65,13 @@ export interface CustomerOwnershipParams {
reason?: string | null
}
/** 保存评级入参:只存总分(五项明细不落库,评分是档案属性而非审计事件) */
/**
* 保存评级入参:只传总分。
*
* 等级不落库,也不由客户端传入 —— 后端只存总分与评级时间,等级由分数按
* 当前等级映射实时推导(前端 utils/rating.ts)。不收客户端传来的等级,
* 否则改请求体就能把 30 分存成 A 级。
*/
export interface CustomerRatingParams {
score: number
}
@ -127,7 +133,7 @@ export function changeOwnership(
return request({ url: `/v1/base/customers/${customerId}/ownership`, method: 'put', data })
}
/** 保存评级(评级计算器「保存评分」;只存总分与评级时间,评分只走本端点) */
/** 保存评级(评级计算器「保存评分」;后端存总分+评级时间,评分只走本端点) */
export function rateCustomer(
customerId: number,
data: CustomerRatingParams,

View File

@ -1,4 +1,5 @@
import { request } from './request'
import type { PageData } from './types'
/** 近 6 个月趋势点(绩效按台账归属月,回款按到账月) */
export interface TrendPoint {
@ -91,9 +92,29 @@ export interface DashboardData {
overview: OverviewBlock
todos: TodoBlock
warnings: WarningBlock
activities: ActivityItem[]
}
/**
* 最近动态分页结果:分页字段与全站规范同形(list/total/pageNum/pageSize),
* 额外带 `modules` —— 全量模块名(不随当前页变化),供模块下拉使用。
*/
export interface ActivityPage extends PageData<ActivityItem> {
modules: string[]
}
export function getDashboard(params: { month?: string }): Promise<DashboardData> {
return request({ url: '/v1/dashboard', method: 'get', params })
}
/**
* 分页查询最近动态(工作台动态列表)。
*
* 独立于聚合接口:翻页只重取这张列表,不必重算总览/待办/预警。
*/
export function listDashboardActivities(params: {
pageNum?: number
pageSize?: number
module?: string
}): Promise<ActivityPage> {
return request({ url: '/v1/dashboard/activities', method: 'get', params })
}

View File

@ -10,7 +10,12 @@ const { topLevelMenus, groupedMenus } = useAppMenu()
<template>
<el-menu class="app-menu" :default-active="route.path" router>
<el-menu-item v-for="item in topLevelMenus" :key="item.path" :index="item.path">
<el-menu-item
v-for="item in topLevelMenus"
:key="item.path"
class="app-menu__top"
:index="item.path"
>
<el-icon v-if="item.icon" class="app-menu__icon" aria-hidden="true">
<component :is="item.icon" />
</el-icon>
@ -32,69 +37,67 @@ const { topLevelMenus, groupedMenus } = useAppMenu()
<style scoped>
/*
* 中性灰侧栏上的菜单:覆盖 Element Plus 的主题变量而非直接写颜色 ——
* 后者与组件库规则同权重,受样式注入顺序影响而不稳定。
* 激活项 = 白底浮起(微渐变 + 内高光 + 分层阴影)+ 左侧 2px 靛蓝条。
* 侧栏菜单:中性灰底 + 靛蓝点缀。
* 覆盖 Element Plus 的主题变量而非直接写颜色 —— 后者与组件库规则同权重,
* 受样式注入顺序影响而不稳定。
*
* 层级口径:父子**同字号(13px)**,层级不靠字号/字重刻意压过 ——
* 组标题与条目一样大,结构由「组标题带折叠箭头 + 组间留白 + 子项缩进一档」承担。
* 前两版各走偏一次:先把组标题压成 11px 弱墨「目录行」,父级弱于子项;
* 后改成 14px/600 + 靛蓝竖标,父级刻意压过子项、像一排小标题在喊叫。
* 层级是位置关系,不是字号关系 —— 两版都不要。
*
* 激活态:白底浮起(微渐变 + 内高光 + 分层阴影)+ 靛蓝字,不再叠左侧竖条 ——
* 竖条只能落在侧栏内边缘(离白底悬空),是与白底重复的第二个信号。
*/
.app-menu {
--el-menu-bg-color: transparent;
--el-menu-text-color: var(--ys-ink-2);
--el-menu-active-color: var(--ys-indigo);
--el-menu-hover-bg-color: rgba(255, 255, 255, 0.55);
--el-menu-item-height: 36px;
--el-menu-item-height: 34px;
padding: 0 10px;
padding: 2px 10px 12px;
border-right: none;
}
/* 菜单项:内距 9px 10px、圆角 7px、13px 字 —— 组内层级靠缩进区分,不加分隔线 */
.app-menu :deep(.el-menu-item) {
height: 36px;
/* 组与组之间留白:分组交给间距,不加分割线 */
.app-menu :deep(.el-sub-menu) {
margin-top: 12px;
}
/*
* 顶层叶子项(首页):与组标题同级 —— 13px / 500 / 主墨。
* 用 scoped 模板 class 定位(权重高于组件库默认样式),
* 不与子项共用 :deep 规则,避免字号被子项规则带走。
*/
.app-menu__top {
height: 34px;
padding: 0 10px;
margin-bottom: 2px;
font-size: 13px;
line-height: 36px;
font-weight: 500;
line-height: 34px;
color: var(--ys-ink);
border-radius: var(--ys-radius-sm);
transition:
background-color 150ms var(--ys-ease),
color 150ms var(--ys-ease);
}
.app-menu :deep(.el-menu-item:hover) {
.app-menu__top:hover {
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) {
height: 32px;
height: 34px;
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);
transition:
background-color 150ms var(--ys-ease),
@ -102,23 +105,47 @@ const { topLevelMenus, groupedMenus } = useAppMenu()
}
.app-menu__group {
font-size: 11px;
font-size: 13px;
font-weight: 500;
letter-spacing: 0.1em;
color: var(--ys-ink-3);
transition: color 150ms var(--ys-ease);
color: inherit;
}
/*
* 子项:13px 次墨 + 缩进一档(12px)。
* Element Plus 的 `.el-menu--inline .el-menu-item` 带 !important 的 40px 缩进,
* 必须同样用 !important 才能压住,否则缩进档位不受控。
*/
.app-menu :deep(.el-sub-menu .el-menu-item) {
height: 32px;
padding-right: 10px !important;
padding-left: 22px !important;
margin-bottom: 1px;
font-size: 13px;
line-height: 32px;
color: var(--ys-ink-2);
border-radius: var(--ys-radius-sm);
transition:
background-color 150ms var(--ys-ease),
color 150ms var(--ys-ease);
}
.app-menu :deep(.el-sub-menu .el-menu-item:hover) {
color: var(--ys-ink);
}
/* 激活项:白底浮起 + 靛蓝字(阴影分层与设计稿卡片同口径) */
.app-menu :deep(.el-menu-item.is-active) {
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) {
background-color: rgba(255, 255, 255, 0.55);
}
.app-menu :deep(.el-sub-menu.is-opened > .el-sub-menu__title) {
background-color: rgba(255, 255, 255, 0.45);
}
.app-menu :deep(.el-sub-menu.is-opened > .el-sub-menu__title) .app-menu__group {
color: var(--ys-indigo);
background-color: rgba(255, 255, 255, 0.5);
}
/*
@ -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) {
color: var(--ys-indigo);
color: var(--ys-ink-2);
transform: rotate(0deg);
}
/*
* 菜单图标:设计稿口径为 16px、透明度 0.7(激活项 1)——Element Plus 默认
* 给菜单图标 24px 宽 + 5px 间距,直接沿用会让图标比文字更抢眼、字距发散,
* 故在此显式覆盖。
* 故在此显式覆盖。父子同级,图标尺寸统一,不再按层级分档。
*/
.app-menu__icon {
width: 16px;
height: 16px;
margin-right: 9px;
font-size: 16px;
opacity: 0.7;
opacity: 0.72;
}
.app-menu :deep(.el-menu-item.is-active) .app-menu__icon {
@ -158,10 +185,10 @@ const { topLevelMenus, groupedMenus } = useAppMenu()
}
@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__icon-arrow),
.app-menu__group {
.app-menu :deep(.el-sub-menu__icon-arrow) {
transition: none;
}
}

View File

@ -155,7 +155,13 @@ async function handleLogout(): Promise<void> {
.app-aside__menu {
flex: 1;
padding: 2px 0 12px;
/*
* 只写上下内距:横向 10px 留白由 AppMenu 自己的 .app-menu 提供。
* 这里若用 padding 简写,会与 .app-menu 的 padding 简写同权重、
* 靠样式注入顺序决胜 —— 横向留白可能被整条抹掉,条目贴到侧栏边缘。
*/
padding-top: 2px;
padding-bottom: 12px;
overflow-y: auto;
}

View File

@ -28,42 +28,101 @@ export function usePagedList<T, F extends object, D extends PageData<T> = PageDa
/** 自增请求序号:仅最新请求的响应可写入状态 */
let requestSeq = 0
/** 按当前页码与筛选条件加载 */
async function load(): Promise<void> {
/**
* 列表当前**实际显示**的是哪一页(成功写入 records 时同步)。
*
* 这是「分页器高亮」与「列表内容」保持一致的唯一依据:失败回滚要退回
* 这个页码,而不是退回点击前的页码 —— 连点两次时前一次请求的响应会被
* 序号守卫丢弃,退回「点击前」的页码可能与屏幕上的数据对不上。
*/
let shownPageNum = 1
let shownPageSize = 10
/**
* 按当前页码与筛选条件加载,返回是否成功以及本次请求的序号。
*
* 返回值是翻页失败回滚的依据:`records` 只在成功时更新,而分页器
* 高亮绑的是 `pageNum`,两者必须同进同退,否则会出现
* 「页码已跳到第 2 页、列表还停在第 1 页」的错位(用户来回点才恢复)。
*
* 带出 `seq` 是因为**回滚本身也要防竞态**:连点两次时旧请求失败,
* 若无条件把页码退回它那一轮的旧值,就会把用户刚点出的新页码覆盖掉,
* 表现为「页码自己跳回去」。
*
* 页码与条数在 await **之前**取快照:等待期间另一次点击可能已改动
* `pageNum`,用 await 之后的值记录「显示的页」会记错。
*
* 对外不暴露返回值:`load` 被大量页面当 `() => void | Promise<void>`
* 的回调使用,多一个返回值会让那些赋值类型不兼容。
*/
async function fetchPage(): Promise<{ ok: boolean; seq: number }> {
const seq = ++requestSeq
const reqPageNum = pageNum.value
const reqPageSize = pageSize.value
loading.value = true
try {
const data = await fetcher({
...getFilters(),
pageNum: pageNum.value,
pageSize: pageSize.value,
pageNum: reqPageNum,
pageSize: reqPageSize,
})
if (seq !== requestSeq) return // 已有更新的请求发出,丢弃过期响应
if (seq !== requestSeq) return { ok: false, seq } // 已有更新的请求发出,丢弃过期响应
records.value = data.list
total.value = data.total
shownPageNum = reqPageNum
shownPageSize = reqPageSize
onData?.(data)
return { ok: true, seq }
} catch (error) {
handledError(error) // 失败提示由请求拦截器统一完成
return { ok: false, seq }
} finally {
if (seq === requestSeq) loading.value = false
}
}
/**
* 请求失败后把分页状态退回到「屏幕上的数据」。
*
* 只在本次请求仍是最新请求时执行:否则用户已经点了别的页,
* 这里的回滚会把他的点击覆盖掉。
*/
function restoreShownPage(seq: number): void {
if (seq !== requestSeq) return
pageNum.value = shownPageNum
pageSize.value = shownPageSize
}
/** 按当前页码与筛选条件加载(供页面直接调用) */
async function load(): Promise<void> {
const { ok, seq } = await fetchPage()
if (!ok) restoreShownPage(seq)
}
/** 条件查询:回到第一页再加载(停留旧页码会查到空页) */
function search(): void {
async function search(): Promise<void> {
pageNum.value = 1
void load()
await load()
}
function onPageChange(page: number): void {
/**
* 翻页:失败则退回屏幕上正在显示的那一页。
*
* 不回滚的后果是分页器高亮与列表数据永久错位 —— 用户看到的是
* 「点了没反应,再点一下又跳回去」,且必须连点两次才能对齐。
*/
async function onPageChange(page: number): Promise<void> {
pageNum.value = page
void load()
const { ok, seq } = await fetchPage()
if (!ok) restoreShownPage(seq)
}
function onSizeChange(size: number): void {
/** 改每页条数:回到第一页;失败同时退回页码与条数 */
async function onSizeChange(size: number): Promise<void> {
pageSize.value = size
pageNum.value = 1
void load()
const { ok, seq } = await fetchPage()
if (!ok) restoreShownPage(seq)
}
/** 删除后刷新:当前页已被删空且不在第一页时,先回退页码再加载 */

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 ''
}
}

View File

@ -13,6 +13,7 @@ import {
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'
/**
@ -20,9 +21,11 @@ import { handledError } from '@/utils/error'
*
* 原独立页(/business/rating)并入客户管理:在客户行上点「评级计算」打开,
* 五项打分实时判定等级,「保存评分」把总分写入客户档案(rating_score +
* rating_time)。与独立页时代的差异:评分不再是过眼云烟 —— 保存为档案
* 属性;五项明细与命中等级仍不落库(等级映射是可变判定表,回溯「当时
* 命中的等级」需要快照设计,超出档案属性的范围)。
* rating_time)。等级**不落库**,由分数 + 当前等级映射实时算(判定函数在
* utils/rating.ts,与列表页共用同一口径);等级映射日后调整,全部客户的
* 等级随之更新,档案只负责记住「这个分是什么时候打的」。
*
* 五项打分明细同样不落库 —— 那是打分过程的中间量,没有下游消费方。
*
* 等级映射的增删改走 /v1/business/rating-grades 维护接口,配置变更后
* 重载判定用列表 —— 判定与抽屉表格共用同一份数据。
@ -38,27 +41,16 @@ const props = defineProps<{
const emit = defineEmits<{
/** 保存成功,带回最新 CustomerItem(父页原位更新行,不打乱当前浏览位置) */
saved: [item: CustomerItem]
/**
* 等级映射被改动(新增/编辑/删除)。
*
* 父页的等级徽标靠自己缓存的一份映射把分数翻成等级,抽屉里改完配置
* 那份缓存就旧了 —— 必须通知父页重拉,否则「同一分数在弹窗里是 B、
* 在列表里还是 C」,两处判定口径出现可见的分歧。
*/
gradesChanged: []
}>()
// ==================== 等级判定(纯函数) ====================
/**
* 等级判定:按 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) */
@ -111,6 +103,12 @@ async function loadGrades(): Promise<void> {
}
}
/** 配置被改动后的统一收尾:重载本弹窗的判定列表 + 通知父页刷新徽标缓存 */
async function reloadAfterGradeChange(): Promise<void> {
await loadGrades()
emit('gradesChanged')
}
const matchedGrade = computed(() => resolveGrade(grades.value, totalScore.value))
// ==================== 弹窗打开 / 关闭 ====================
@ -130,8 +128,11 @@ 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) : '',
}
})
@ -237,7 +238,7 @@ async function saveGrade(): Promise<void> {
}
editVisible.value = false
ElMessage.success(isCreate.value ? '评级等级已创建' : '已保存')
await loadGrades()
await reloadAfterGradeChange()
} catch (requestError) {
handledError(requestError)
} finally {
@ -253,7 +254,7 @@ function removeGrade(row: RatingGradeItem): void {
confirmButtonText: '删除',
successText: '评级等级已删除',
run: () => deleteRatingGrade(row.id),
after: () => loadGrades(),
after: () => reloadAfterGradeChange(),
})
}
</script>
@ -276,6 +277,9 @@ function removeGrade(row: RatingGradeItem): void {
<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>

View File

@ -5,11 +5,13 @@ import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import * as customerApi from '@/api/customers'
import type { CustomerItem, OwnershipLogItem } from '@/api/customers'
import * as personnelApi from '@/api/personnel'
import { listRatingGrades, type RatingGradeItem } from '@/api/ratings'
import AppPagination from '@/components/table/AppPagination.vue'
import { useConfirmAction } from '@/composables/useConfirmAction'
import { useDropdownOptions } from '@/composables/useDropdownOptions'
import { usePagedList } from '@/composables/usePagedList'
import { formatDateTime } from '@/utils/format'
import { gradeTone, resolveGrade } from '@/utils/rating'
import { handledError } from '@/utils/error'
import RatingCalculatorDialog from './RatingCalculatorDialog.vue'
@ -23,7 +25,8 @@ import RatingCalculatorDialog from './RatingCalculatorDialog.vue'
* (对照表 #19 的业务口径)
* - 归属变更记录为分页留痕表(抽屉内展示,按变更时间倒序)
* - 评级计算器并入本页(原 /business/rating 独立页已移除):行内弹窗打分,
* 保存评分写入档案字段 rating_score/rating_time
* 保存评分只写档案字段 rating_score / rating_time;等级不落库,由分数按
* 当前等级映射实时算(utils/rating.ts 与弹窗共用同一判定函数)
*/
const { confirmAndRun } = useConfirmAction()
@ -102,6 +105,48 @@ function onRatingSaved(item: CustomerItem): void {
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 {
@ -364,9 +409,11 @@ async function handleDelete(row: CustomerItem): Promise<void> {
// ==================== 初始化 ====================
onMounted(() => {
// 两个请求各自容错(load 内部已兜底),互不拖累
// 三个请求各自容错(load 内部已兜底),互不拖累
void load()
void loadOwnerOptions()
// 等级徽标要用等级映射把分数翻成等级,先拉一次;失败只影响徽标,不影响列表
void loadGrades()
})
</script>
@ -375,7 +422,7 @@ onMounted(() => {
<div class="page-head">
<h1 class="page-title">客户管理</h1>
<p class="page-desc">
客户档案与归属人员;归属变更只影响变更后的核算、不回溯历史,评分由「评级计算」维护并保存进档案
客户档案与归属人员;归属变更只影响变更后的核算、不回溯历史,评分与等级由「评级计算」维护并保存进档案
</p>
<span class="waterline"></span>
</div>
@ -441,13 +488,32 @@ onMounted(() => {
@sort-change="onSortChange"
>
<el-table-column prop="company_name" label="公司名称" min-width="180" />
<el-table-column prop="rating_score" label="评分" width="88" align="center" sortable="custom">
<!--
评级列 = 等级 + 分数,纯文字两级层次。
等级不落库,按当前等级映射由已存分数实时算(与评级弹窗同一判定函数
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"
:title="row.rating_time ? `评级时间:${formatDateTime(row.rating_time)}` : undefined"
>
{{ row.rating_score }}
<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>
@ -657,6 +723,7 @@ onMounted(() => {
v-model:visible="ratingVisible"
:customer="ratingTarget"
@saved="onRatingSaved"
@grades-changed="loadGrades"
/>
</div>
</template>
@ -670,4 +737,52 @@ onMounted(() => {
font-size: 12px;
line-height: 1.7;
}
/*
* 评级列 = 等级 + 分数,纯文字两级层次(无底色、无边框、无胶囊)。
*
* 为什么不用胶囊也不用色块:全站胶囊(.ys-pill)是「状态」的语汇 —— 草稿/
* 待审/已锁定/已驳回,都带圆点和胶囊边;评级是档案属性不是流程状态,套状态
* 形态会让两种信息混读。色块方案在实测里会在每行右侧堆一片彩色矩形,表格
* 像贴了标签墙,且「A 档」这类两字自定义等级名一撑就变形。
*
* 改成纯文字分层:等级 13px/600 用语义色立起来(这是几档),分数 13px 弱墨
* 等宽跟在后面(依据多少)。零装饰元素,行高不受影响,等级名多长都不会挤。
*/
.grade {
display: inline-flex;
align-items: baseline;
gap: 6px;
line-height: 1;
}
.grade__name {
font-size: 13px;
font-weight: 600;
color: var(--ys-ink-2);
}
/* 四档语义色:只改字色,不加底色 —— 表格里色块过多会压过数据本身 */
.grade__name--ok {
color: var(--ys-ok);
}
.grade__name--lock {
color: var(--ys-indigo);
}
.grade__name--wait {
color: var(--ys-warn);
}
.grade__name--reject {
color: var(--ys-danger);
}
/* 分数:等宽数字保证整列竖向对齐,弱墨让视线先落在等级上 */
.grade__score {
font-size: 13px;
font-variant-numeric: tabular-nums;
color: var(--ys-ink-3);
}
</style>

View File

@ -171,7 +171,7 @@ const allocationHint = computed(() => {
const total = Number(allocationTotal.value.toFixed(2))
if (total === 100) return `二次分配合计 ${total.toFixed(2)}%`
const rest = Number((100 - total).toFixed(2))
return `二次分配合计 ${total.toFixed(2)}%,余额 ${rest.toFixed(2)}%(归属待业务确认)`
return `二次分配合计 ${total.toFixed(2)}%,余额 ${rest.toFixed(2)}%(归负责人)`
})
function addAllocationRow(): void {
@ -880,7 +880,7 @@ onMounted(() => {
<div class="items-head">
<span class="items-title">
二次分配方案
<span class="alloc-note">(可选;比例不要求合计 100%,分摊计算待业务确认后另批)</span>
<span class="alloc-note">(可选;各参与人比例不要求合计 100%,余额归负责人)</span>
</span>
<el-button size="small" @click="addAllocationRow">添加参与人</el-button>
</div>

View File

@ -479,7 +479,7 @@ onMounted(() => {
v-model="form.receipt_date"
type="date"
value-format="YYYY-MM-DD"
placeholder="M3 核算按此日匹配参数版本"
placeholder="选到账当天;计提比例按这一天的参数计算"
style="width: 100%"
/>
</el-form-item>

File diff suppressed because it is too large Load Diff

View File

@ -99,9 +99,24 @@ function valueLabel(value: string | null | undefined): string {
return ENUM_VALUE_LABELS[value] ?? value
}
/** 带单位的值展示 */
/** 带单位的值展示:枚举与开关先翻成中文,其余原样加单位 */
function formatValue(item: ParamCatalogItem, value: string): string {
return item.unit ? `${value} ${item.unit}` : value
// 枚举/开关的原始值是 INCLUDE、FULL、1、0 —— 直接摆给业务人员看不懂,
// 数字型(比例、天数、金额)保持原样,只补单位
const shown =
item.value_type === 'ENUM' || item.value_type === 'BOOL' ? valueLabel(value) : value
return item.unit ? `${shown} ${item.unit}` : shown
}
/**
* 按参数编码格式化取值:变更记录 / 版本历史 / 快照对比拿到的都是裸 value_text,
* 没有 value_type 与 unit,只能回目录里按编码查定义再格式化。
* 目录还没加载完或编码已下线时原样返回,宁可显示原文也不显示空白。
*/
function formatByCode(code: string, value: string | null | undefined): string {
if (value === null || value === undefined || value === '') return '—'
const item = catalog.value.find((entry) => entry.param_code === code)
return item ? formatValue(item, value) : value
}
// ==================== 修改(待提交) ====================
@ -436,7 +451,7 @@ onMounted(() => {
<div class="page-head">
<h1 class="page-title">计提参数</h1>
<p class="page-desc">
计提比例、系数、阈值、分成与起付比例在此统一维护;变更逐项留痕并支持按日期生效。
计提比例、系数、阈值、分成与起付比例都在这里统一维护;每次改动留下旧值,可以指定从哪天起生效。
</p>
<div class="waterline"></div>
</div>
@ -484,7 +499,7 @@ onMounted(() => {
type="warning"
disable-transitions
>
待改为 {{ pendingOf(item, v.dimension_value)!.new_value }}
待改为 {{ formatValue(item, pendingOf(item, v.dimension_value)!.new_value) }}
</el-tag>
<el-button
v-if="isAdmin"
@ -498,7 +513,7 @@ onMounted(() => {
</div>
</template>
<span v-else class="no-value">
未设置(默认 {{ item.default_value ?? '—' }})
未设置(默认 {{ formatByCode(item.param_code, item.default_value) }})
</span>
</td>
<td>
@ -537,10 +552,14 @@ onMounted(() => {
<template #default="{ row }">{{ dimensionLabel(row.dimension_value) }}</template>
</el-table-column>
<el-table-column label="旧值" min-width="110">
<template #default="{ row }">{{ row.old_value || '—' }}</template>
<template #default="{ row }">
{{ row.old_value ? formatByCode(row.item.param_code, row.old_value) : '—' }}
</template>
</el-table-column>
<el-table-column label="新值" min-width="110">
<template #default="{ row }"><b class="num">{{ row.new_value }}</b></template>
<template #default="{ row }">
<b class="num">{{ formatByCode(row.item.param_code, row.new_value) }}</b>
</template>
</el-table-column>
<el-table-column label="操作" width="80">
<template #default="{ row }">
@ -590,24 +609,50 @@ onMounted(() => {
</el-select>
</el-form-item>
<el-form-item label="当前值">
<span class="num">{{ currentValue(editTarget.item, editTarget.dimension) ?? editTarget.item.default_value ?? '—' }}</span>
<span v-if="editTarget.item.unit" style="color: var(--ys-ink-3)"> {{ editTarget.item.unit }}</span>
<!-- 当前值同目录卡口径:枚举翻中文、数字补单位;编辑框里存的仍是原始值 -->
<span class="num">
{{ formatByCode(editTarget.item.param_code, currentValue(editTarget.item, editTarget.dimension) ?? editTarget.item.default_value) }}
</span>
</el-form-item>
<el-form-item label="新值">
<!--
枚举与开关都给下拉:让业务人员在「含基本工资」「开」里选,
而不是自己敲 INCLUDE / 1 —— 提交给后端的仍是原始值(v-model 绑的是 value)
-->
<el-select
v-if="editTarget.item.value_type === 'ENUM'"
v-model="editValue"
style="width: 100%"
>
<el-option v-for="opt in editTarget.item.options" :key="opt" :label="opt" :value="opt" />
<!-- label 走 valueLabel:下拉里给的是「含基本工资」这类中文,提交值仍是 INCLUDE 原值 -->
<el-option
v-for="opt in editTarget.item.options"
:key="opt"
:label="valueLabel(opt)"
:value="opt"
/>
</el-select>
<el-select
v-else-if="editTarget.item.value_type === 'BOOL'"
v-model="editValue"
style="width: 100%"
>
<el-option label="开启" value="1" />
<el-option label="关闭" value="0" />
</el-select>
<el-input v-else v-model="editValue" placeholder="输入新值(分数式如 4/360 原样保留)" />
</el-form-item>
</el-form>
<div class="edit-tip">
取值范围:{{ editTarget.item.value_min ?? '−' }} ~ {{ editTarget.item.value_max ?? '不限' }};
精确分数(如 4/360)原样存储、不做小数截断。
<!--
取值提示只对数字型参数有意义:枚举与开关是选出来的,摆「取值范围」只会添乱。
分数式(4/360)保留原文是因为截成小数会算出不一样的绩效,这条得讲给业务听。
-->
<div v-if="editTarget.item.value_type !== 'ENUM' && editTarget.item.value_type !== 'BOOL'" class="edit-tip">
取值范围:{{ editTarget.item.value_min ?? '不限' }} ~ {{ editTarget.item.value_max ?? '不限' }}
<template v-if="editTarget.item.unit">(单位:{{ editTarget.item.unit }})</template>
;比例可写成 4/360 这样的分数,系统按精确值计算,不四舍五入。
</div>
<div v-else class="edit-tip">改动提交后按你选择的生效日期起作用,旧值会保留在变更记录里。</div>
</template>
<template #footer>
<el-button @click="editVisible = false">取消</el-button>
@ -615,7 +660,7 @@ onMounted(() => {
</template>
</el-dialog>
<!-- 版本管理抽屉 -->
<!-- 版本管理抽屉:对比两个日期各自的生效值 + 变更记录 -->
<el-drawer v-model="versionVisible" title="版本管理" size="760px">
<div class="drawer-block">
<div class="drawer-row">
@ -637,18 +682,18 @@ onMounted(() => {
<el-button :loading="exporting" @click="doExport">导出记录</el-button>
</div>
<template v-if="diffSearched">
<div class="drawer-title">快照对比({{ compareFrom }} → {{ compareTo }})</div>
<div class="drawer-title">两个日期的参数对比({{ compareFrom }} → {{ compareTo }})</div>
<el-table :data="diffResult" size="small" max-height="260">
<el-table-column prop="param_name" label="参数" min-width="130" />
<el-table-column label="维度" width="90">
<template #default="{ row }">{{ dimensionLabel(row.dimension_value) }}</template>
</el-table-column>
<el-table-column label="旧值" min-width="90">
<template #default="{ row }">{{ row.old_value ?? '—' }}</template>
<template #default="{ row }">{{ formatByCode(row.param_code, row.old_value) }}</template>
</el-table-column>
<el-table-column label="新值" min-width="90">
<template #default="{ row }">
<span :class="{ changed: row.changed }">{{ row.new_value ?? '—' }}</span>
<span :class="{ changed: row.changed }">{{ formatByCode(row.param_code, row.new_value) }}</span>
</template>
</el-table-column>
<el-table-column label="变化" width="70" align="center">
@ -670,7 +715,7 @@ onMounted(() => {
<div v-for="item in detailCache[row.id].items" :key="item.param_code + item.dimension_value" class="batch-item">
<span class="param-name">{{ item.param_name }}</span>
<span class="dim-label">{{ dimensionLabel(item.dimension_value) }}</span>
<span class="num">{{ item.old_value_text ?? '—' }} → <b>{{ item.value_text }}</b></span>
<span class="num">{{ formatByCode(item.param_code, item.old_value_text) }} → <b>{{ formatByCode(item.param_code, item.value_text) }}</b></span>
</div>
</template>
<el-button v-else link size="small" @click="detailOf(row).catch(() => {})">
@ -719,7 +764,11 @@ onMounted(() => {
</el-table-column>
<el-table-column label="旧值 → 新值" min-width="130">
<template #default="{ row }">
<span class="num">{{ row.old_value_text ?? '—' }} → {{ row.value_text }}</span>
<!-- 历史行没有 value_type,按当前参数定义格式化(枚举翻中文、数字补单位) -->
<span class="num">
{{ formatByCode(historyItem?.param_code ?? '', row.old_value_text) }} →
{{ formatByCode(historyItem?.param_code ?? '', row.value_text) }}
</span>
</template>
</el-table-column>
<el-table-column prop="version_no" label="版本" width="115" />
@ -809,9 +858,10 @@ onMounted(() => {
<div v-if="previewResult.probation_note" class="pr-note">{{ previewResult.probation_note }}</div>
</div>
<div class="pr-used">
<div class="drawer-title">所用参数(按回款到账日取值)</div>
<div class="drawer-title">本次用到的参数(按回款到账日取当天生效值)</div>
<!-- 只显示中文参数名与格式化后的值:参数编码是内部标识,业务人员对不上 -->
<div v-for="u in previewResult.params_used" :key="u.param_code" class="pr-used-row">
{{ paramNameByCode(u.param_code) }}({{ u.param_code }})= {{ valueLabel(u.value_text) || u.value_text }}
{{ paramNameByCode(u.param_code) }} = {{ formatByCode(u.param_code, u.value_text) }}
<span v-if="u.effective_date" class="eff">({{ u.effective_date }} 起)</span>
</div>
</div>

View File

@ -118,9 +118,13 @@ const previewName = computed(() => form.company_name.trim() || '衍水集团')
label-width="92px"
@submit.prevent
>
<el-form-item label="公司名称" prop="company_name">
<!-- 控件与说明并排:说明靠行尾对齐,宽屏下整行铺满卡片而非缩在左上角 -->
<div class="field-control">
<!--
字段自适应分列:卡片够宽时两个字段并排(各占一列),窄时自动折回单列。
说明文字紧贴控件下方 —— 早前把它顶到卡片最右缘,宽屏下与输入框隔了
上千像素,视线关联直接断掉。
-->
<div class="field-grid">
<el-form-item label="公司名称" prop="company_name">
<el-input
v-model="form.company_name"
class="field-control__main"
@ -129,11 +133,9 @@ const previewName = computed(() => form.company_name.trim() || '衍水集团')
clearable
/>
<p class="field-control__note">最多 64 个字符;保存后对所有在线用户立即生效。</p>
</div>
</el-form-item>
</el-form-item>
<el-form-item label="logo">
<div class="field-control">
<el-form-item label="logo">
<el-upload
class="logo-uploader"
:show-file-list="false"
@ -149,8 +151,8 @@ const previewName = computed(() => form.company_name.trim() || '衍水集团')
<p class="field-control__note">
png / jpg,不超过 2MB;仅展示于登录页品牌位,按 ≤56×36 显示。
</p>
</div>
</el-form-item>
</el-form-item>
</div>
</el-form>
</div>