merge: 收口工作台分页、客户等级徽标与文案中文化至 main

This commit is contained in:
liuwenxin 2026-10-23 16:39:06 +08:00
commit cbfd7659fe
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` 仅要求登录(含停用岗位),供财务/领导在报表中按岗位筛选;岗位管理接口仍仅管理员。 - **岗位筛选选项**:`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 是安全问题,必须启动即失败。

View File

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

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))

View File

@ -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)

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: 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()

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)

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):

View File

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

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( 前端模块下拉不必另取一次全量。
filters=LogFilters(), """
offset=0, repo_query = operation_log_repository.OperationLogListQuery(
limit=_ACTIVITY_LIMIT, 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: def _dec(value) -> Decimal | None:

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,
), ),
] ]

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:

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 方案公式" "经理本人名下业绩,按本团队与全公司两级比例合计计提"
), ),
}, },
), ),

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

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 修复回归)====

View File

@ -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 = {

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); (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),

View File

@ -5,7 +5,12 @@
<title>衍水工资系统 · 视觉方向预览</title> <title>衍水工资系统 · 视觉方向预览</title>
<style> <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):设计稿与已交付功能对齐 v3 → v4 变更(2026-10-22):设计稿与已交付功能对齐
1) 侧栏菜单改为真实路由分组(5 组 + 首页 = 21 项),组可折叠 1) 侧栏菜单改为真实路由分组(5 组 + 首页 = 21 项),组可折叠
2) 首页工作台按真实四区块重画(总览 / 待办 / 预警 / 动态) 2) 首页工作台按真实四区块重画(总览 / 待办 / 预警 / 动态)
@ -264,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; }
@ -423,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 {
@ -1039,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>,
@ -1178,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>
@ -1204,6 +1217,8 @@
待办与预警并排 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>,不全部挤在顶部留一整块空白。 空态(<code>b-empty</code>)在卡片剩余高度内<b>垂直居中</b>,不全部挤在顶部留一整块空白。
</div> </div>
</section> </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-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 默认微软雅黑(字体商用合规要求)。

View File

@ -65,7 +65,13 @@ export interface CustomerOwnershipParams {
reason?: string | null reason?: string | null
} }
/** 保存评级入参:只存总分(五项明细不落库,评分是档案属性而非审计事件) */ /**
* 保存评级入参:只传总分。
*
* 等级不落库,也不由客户端传入 —— 后端只存总分与评级时间,等级由分数按
* 当前等级映射实时推导(前端 utils/rating.ts)。不收客户端传来的等级,
* 否则改请求体就能把 30 分存成 A 级。
*/
export interface CustomerRatingParams { export interface CustomerRatingParams {
score: number score: number
} }
@ -127,7 +133,7 @@ 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( export function rateCustomer(
customerId: number, customerId: number,
data: CustomerRatingParams, data: CustomerRatingParams,

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

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

View File

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

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)
} }
/** 删除后刷新:当前页已被删空且不在第一页时,先回退页码再加载 */ /** 删除后刷新:当前页已被删空且不在第一页时,先回退页码再加载 */

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 { rateCustomer, type CustomerItem } from '@/api/customers'
import { useConfirmAction } from '@/composables/useConfirmAction' import { useConfirmAction } from '@/composables/useConfirmAction'
import { formatDateTime } from '@/utils/format' import { formatDateTime } from '@/utils/format'
import { resolveGrade } from '@/utils/rating'
import { handledError } from '@/utils/error' import { handledError } from '@/utils/error'
/** /**
@ -20,9 +21,11 @@ import { handledError } from '@/utils/error'
* *
* 原独立页(/business/rating)并入客户管理:在客户行上点「评级计算」打开, * 原独立页(/business/rating)并入客户管理:在客户行上点「评级计算」打开,
* 五项打分实时判定等级,「保存评分」把总分写入客户档案(rating_score + * 五项打分实时判定等级,「保存评分」把总分写入客户档案(rating_score +
* rating_time)。与独立页时代的差异:评分不再是过眼云烟 —— 保存为档案 * rating_time)。等级**不落库**,由分数 + 当前等级映射实时算(判定函数在
* 属性;五项明细与命中等级仍不落库(等级映射是可变判定表,回溯「当时 * utils/rating.ts,与列表页共用同一口径);等级映射日后调整,全部客户的
* 命中的等级」需要快照设计,超出档案属性的范围)。 * 等级随之更新,档案只负责记住「这个分是什么时候打的」。
*
* 五项打分明细同样不落库 —— 那是打分过程的中间量,没有下游消费方。
* *
* 等级映射的增删改走 /v1/business/rating-grades 维护接口,配置变更后 * 等级映射的增删改走 /v1/business/rating-grades 维护接口,配置变更后
* 重载判定用列表 —— 判定与抽屉表格共用同一份数据。 * 重载判定用列表 —— 判定与抽屉表格共用同一份数据。
@ -38,27 +41,16 @@ const props = defineProps<{
const emit = defineEmits<{ const emit = defineEmits<{
/** 保存成功,带回最新 CustomerItem(父页原位更新行,不打乱当前浏览位置) */ /** 保存成功,带回最新 CustomerItem(父页原位更新行,不打乱当前浏览位置) */
saved: [item: 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) */ /** 五项评分项:各 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)) const matchedGrade = computed(() => resolveGrade(grades.value, totalScore.value))
// ==================== 弹窗打开 / 关闭 ==================== // ==================== 弹窗打开 / 关闭 ====================
@ -130,8 +128,11 @@ const saving = ref(false)
const archivedScore = computed(() => { const archivedScore = computed(() => {
const target = props.customer const target = props.customer
if (!target || target.rating_score === null) return null if (!target || target.rating_score === null) return null
// 等级不落库,按当前映射由已存分数实时算(与列表页同一口径)
const grade = resolveGrade(grades.value, target.rating_score)
return { return {
score: target.rating_score, score: target.rating_score,
grade: grade?.grade_name ?? null,
time: target.rating_time ? formatDateTime(target.rating_time) : '', time: target.rating_time ? formatDateTime(target.rating_time) : '',
} }
}) })
@ -237,7 +238,7 @@ async function saveGrade(): Promise<void> {
} }
editVisible.value = false editVisible.value = false
ElMessage.success(isCreate.value ? '评级等级已创建' : '已保存') ElMessage.success(isCreate.value ? '评级等级已创建' : '已保存')
await loadGrades() await reloadAfterGradeChange()
} catch (requestError) { } catch (requestError) {
handledError(requestError) handledError(requestError)
} finally { } finally {
@ -253,7 +254,7 @@ function removeGrade(row: RatingGradeItem): void {
confirmButtonText: '删除', confirmButtonText: '删除',
successText: '评级等级已删除', successText: '评级等级已删除',
run: () => deleteRatingGrade(row.id), run: () => deleteRatingGrade(row.id),
after: () => loadGrades(), after: () => reloadAfterGradeChange(),
}) })
} }
</script> </script>
@ -276,6 +277,9 @@ function removeGrade(row: RatingGradeItem): void {
<div v-if="archivedScore" class="archived-line"> <div v-if="archivedScore" class="archived-line">
档案评分:<b>{{ archivedScore.score }}</b> 分 档案评分:<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 v-if="archivedScore.time" class="archived-time">({{ archivedScore.time }} 保存)</span>
<span class="archived-tip">重新保存将覆盖</span> <span class="archived-tip">重新保存将覆盖</span>
</div> </div>

View File

@ -5,11 +5,13 @@ 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' import RatingCalculatorDialog from './RatingCalculatorDialog.vue'
@ -23,7 +25,8 @@ import RatingCalculatorDialog from './RatingCalculatorDialog.vue'
* (对照表 #19 的业务口径) * (对照表 #19 的业务口径)
* - 归属变更记录为分页留痕表(抽屉内展示,按变更时间倒序) * - 归属变更记录为分页留痕表(抽屉内展示,按变更时间倒序)
* - 评级计算器并入本页(原 /business/rating 独立页已移除):行内弹窗打分, * - 评级计算器并入本页(原 /business/rating 独立页已移除):行内弹窗打分,
* 保存评分写入档案字段 rating_score/rating_time * 保存评分只写档案字段 rating_score / rating_time;等级不落库,由分数按
* 当前等级映射实时算(utils/rating.ts 与弹窗共用同一判定函数)
*/ */
const { confirmAndRun } = useConfirmAction() const { confirmAndRun } = useConfirmAction()
@ -102,6 +105,48 @@ function onRatingSaved(item: CustomerItem): void {
if (index !== -1) records.value[index] = item 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 {
@ -364,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>
@ -375,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>
@ -441,13 +488,32 @@ onMounted(() => {
@sort-change="onSortChange" @sort-change="onSortChange"
> >
<el-table-column prop="company_name" label="公司名称" min-width="180" /> <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 }"> <template #default="{ row }">
<span <span v-if="row.rating_score !== null" class="grade" :title="ratingTitle(row)">
v-if="row.rating_score !== null" <span class="grade__name" :class="gradeNameClass(row.rating_score)">
:title="row.rating_time ? `评级时间:${formatDateTime(row.rating_time)}` : undefined" {{ gradeLabel(row.rating_score) }}
> </span>
{{ row.rating_score }} <span class="grade__score">{{ row.rating_score }}</span>
</span> </span>
<span v-else class="cell-muted" title="尚未评级">—</span> <span v-else class="cell-muted" title="尚未评级">—</span>
</template> </template>
@ -657,6 +723,7 @@ onMounted(() => {
v-model:visible="ratingVisible" v-model:visible="ratingVisible"
:customer="ratingTarget" :customer="ratingTarget"
@saved="onRatingSaved" @saved="onRatingSaved"
@grades-changed="loadGrades"
/> />
</div> </div>
</template> </template>
@ -670,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>

View File

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

View File

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

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>

View File

@ -118,9 +118,13 @@ const previewName = computed(() => form.company_name.trim() || '衍水集团')
label-width="92px" label-width="92px"
@submit.prevent @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 <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>