Compare commits
9
Commits
8e8bcaa5fb
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cbfd7659fe | ||
|
|
823d9f02ec | ||
|
|
678db0499d | ||
|
|
a00fca942d | ||
|
|
61965e0b60 | ||
|
|
111ac5425a | ||
|
|
61fe5cca98 | ||
|
|
32b7940067 | ||
|
|
b9781f7d3f |
No files matched your search
+13
-1
@@ -83,8 +83,9 @@ app/
|
|||||||
| GET / POST | `/api/v1/base/products` | 产品列表(名称/规格模糊、类别/状态筛选)/ 新增(类别值域校验;名称+规格组合唯一) |
|
| GET / POST | `/api/v1/base/products` | 产品列表(名称/规格模糊、类别/状态筛选)/ 新增(类别值域校验;名称+规格组合唯一) |
|
||||||
| GET / PUT / DELETE | `/api/v1/base/products/{id}` | 详情 / 编辑 / 删除(被业务明细引用保护在业务表就位后生效) |
|
| GET / PUT / DELETE | `/api/v1/base/products/{id}` | 详情 / 编辑 / 删除(被业务明细引用保护在业务表就位后生效) |
|
||||||
| PUT | `/api/v1/base/products/{id}/status` | 启用 / 停用 |
|
| PUT | `/api/v1/base/products/{id}/status` | 启用 / 停用 |
|
||||||
| GET / POST | `/api/v1/base/customers` | 客户列表(公司/负责人模糊、分类/归属筛选)/ 新增(分类值域;公司名+负责人唯一) |
|
| GET / POST | `/api/v1/base/customers` | 客户列表(公司/负责人模糊、分类/归属筛选;`order_by=rating_score` 按评分排序,未评分垫底)/ 新增(分类值域;公司名+负责人唯一) |
|
||||||
| GET / PUT / DELETE | `/api/v1/base/customers/{id}` | 详情 / 编辑(**不触归属**)/ 删除 |
|
| GET / PUT / DELETE | `/api/v1/base/customers/{id}` | 详情 / 编辑(**不触归属**)/ 删除 |
|
||||||
|
| PUT | `/api/v1/base/customers/{id}/rating` | 保存评级(评级计算器「保存评分」;只写 `rating_score`/`rating_time`,五项明细不落库 #25) |
|
||||||
| PUT | `/api/v1/base/customers/{id}/ownership` | 归属变更(同事务写留痕;只影响变更后核算,不回溯历史 #19) |
|
| PUT | `/api/v1/base/customers/{id}/ownership` | 归属变更(同事务写留痕;只影响变更后核算,不回溯历史 #19) |
|
||||||
| GET | `/api/v1/base/customers/{id}/ownership-logs` | 归属变更留痕分页(join 人员出姓名) |
|
| GET | `/api/v1/base/customers/{id}/ownership-logs` | 归属变更留痕分页(join 人员出姓名) |
|
||||||
|
|
||||||
@@ -263,6 +264,17 @@ bcrypt 5.0 起对超过 72 字节的密码**直接抛 ValueError**(此前是
|
|||||||
- **汇总 = 明细合计**:团队/公司基数由台账分桶结果直接得出,报表侧不另算一遍;未筛选时 `团队基数 + 未分配绩效 = 全公司基数`;集成测试逐项断言。
|
- **汇总 = 明细合计**:团队/公司基数由台账分桶结果直接得出,报表侧不另算一遍;未筛选时 `团队基数 + 未分配绩效 = 全公司基数`;集成测试逐项断言。
|
||||||
- **岗位筛选选项**:`GET /api/v1/base/business-roles/options` 仅要求登录(含停用岗位),供财务/领导在报表中按岗位筛选;岗位管理接口仍仅管理员。
|
- **岗位筛选选项**:`GET /api/v1/base/business-roles/options` 仅要求登录(含停用岗位),供财务/领导在报表中按岗位筛选;岗位管理接口仍仅管理员。
|
||||||
|
|
||||||
|
### 面向业务人员的文案(2026-10-23 定稿,改文案前先读)
|
||||||
|
|
||||||
|
系统由财务与营销人员日常使用,**不是给开发者看的**。凡是会渲染到页面的文本(参数说明、错误提示、口径注释)一律按业务语言写:
|
||||||
|
|
||||||
|
- **`param_registry.py` 的 `description` 是唯一显示源**,会直出到「计提参数」页参数名下方。写清「这个参数管什么、钱怎么变、什么时候该调它」,不写英文枚举名(`FULL`/`BALANCE`)、需求编号(`澄清 #10`)、内部阶段号(`M3`/`M4 ⑧`)、实现层精度约束(「禁止按 0.011111 截断存储」留在代码注释与测试里)。
|
||||||
|
- **枚举值在页面上必须翻译**:前端 `params/index.vue` 的 `ENUM_VALUE_LABELS`(`FULL` 当月全额 / `BALANCE` 余额 / `INCLUDE` 含基本工资 / `DEDUCT` 扣除基本工资 / `EXCLUDE` 不计基本工资 / `1` 开 / `0` 关)与 `DIMENSION_LABELS`(`municipal_water` 市政水利 / `rail_transit` 轨道交通)。后端报错也不许把原始枚举值甩给用户 —— `param_service._preview_rate` 的产品类别校验用 `「市政水利、轨道交通」` 而不是 `municipal_water / rail_transit`。
|
||||||
|
- **公式模板报错用中文模板名**:`calc_templates.TEMPLATES[code].name`,取不到才回退 code;文案要给出下一步(「请在岗位角色里改用业务员递减或信息员固定计提」),而不是只说「不支持」。
|
||||||
|
- **`db/init/init-dev.sql` 的种子 description 必须与 registry 同源**:它只在数据卷为空时首次启动执行,已有库要跑 `ai-aux/preview/sync_param_desc.py`(读 `PARAM_REGISTRY` 逐条 UPDATE)才看得到新文案。`db/migration/V*.sql` 里的旧描述是历史记录,**不要回头改** —— 改了会让已部署库与脚本不一致。
|
||||||
|
- **前端模板层禁止出现开发者语汇**:`澄清 / 待业务 / M3 / M4 / 一期 / 二阶段 / 暂口径 / 待确认 / 待接 / 暂不 / 待补` 只允许出现在代码注释里。改完用 grep 扫一遍 `frontend/src/views`。
|
||||||
|
- **台账与预览里落库的说明文字(`rate_text` / `rate_note` / 聚合行 `snapshot_json.note`)同样受管**:这些会出现在绩效台账、个人明细、打印稿与计算预览上。写成「这笔为什么是这个比例、什么时候不再降」(如「回款 20 天,从起始 10‰ 按日递减(满 360 天后固定按 4‰ 计算)」),不写 `基数开关=1`、`#12 参数化`、`9.8 方案公式` 这类需求编号。**分数原文(`11/1125`)与百分比拼装(`聚合:20%+5%`)不算说明文字**,那是金额口径的可复核原文,不要为了「口语化」把它改成四舍五入的小数。
|
||||||
|
|
||||||
## 常见问题
|
## 常见问题
|
||||||
|
|
||||||
**起服务报 `APP_SECRET_KEY` 相关错误** —— `backend/.env` 未填或不足 32 字符。这是刻意的 fail-fast:空密钥签发 JWT 是安全问题,必须启动即失败。
|
**起服务报 `APP_SECRET_KEY` 相关错误** —— `backend/.env` 未填或不足 32 字符。这是刻意的 fail-fast:空密钥签发 JWT 是安全问题,必须启动即失败。
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""v13 客户评级落库
|
||||||
|
|
||||||
|
Revision ID: c3a9e1d7b2f4
|
||||||
|
Revises: 7f4f7b918bc4
|
||||||
|
Create Date: 2026-10-23 10:30:00.000000
|
||||||
|
|
||||||
|
评级计算器从独立工具页并入客户管理(行内弹窗),评分由「不落库」改为
|
||||||
|
保存为客户档案属性:``rating_score`` 总分 + ``rating_time`` 评级时间。
|
||||||
|
两列均可空(null = 未评分,历史客户与新客户无需回填),人工维护字段,
|
||||||
|
T+ 同步守则不覆盖(见 TPlusSyncMixin 的同步守则说明)。
|
||||||
|
|
||||||
|
五项评分明细与命中等级都不落库:前者是打分过程的中间量,没有下游消费方;
|
||||||
|
后者由分数按当前等级映射实时推导(等级映射是可变判定表,改了配置全部客户
|
||||||
|
的等级随之更新),因此本迁移只落总分与评级时间两列。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "c3a9e1d7b2f4"
|
||||||
|
down_revision: str | Sequence[str] | None = "7f4f7b918bc4"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""customers 加评级两列,紧跟 remark 之后保持与 ORM 声明同序。
|
||||||
|
|
||||||
|
用 execute 手写而非 op.add_column:后者不带 AFTER 子句,两列会落到
|
||||||
|
表尾(T+ 预留列之后),与 ORM/init-dev 的声明顺序不一致。
|
||||||
|
"""
|
||||||
|
op.execute(
|
||||||
|
"ALTER TABLE customers "
|
||||||
|
"ADD COLUMN rating_score TINYINT COMMENT "
|
||||||
|
"'评级总分(0-100,评级计算器保存;null=未评分;人工维护字段,T+ 同步不覆盖)' "
|
||||||
|
"AFTER remark"
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"ALTER TABLE customers "
|
||||||
|
"ADD COLUMN rating_time DATETIME COMMENT "
|
||||||
|
"'最近评级时间(评级计算器保存评分时刷新;人工维护字段)' "
|
||||||
|
"AFTER rating_score"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""删除评级两列。评分数据随列删除,评级无下游引用、无悬空风险。"""
|
||||||
|
op.drop_column("customers", "rating_time")
|
||||||
|
op.drop_column("customers", "rating_score")
|
||||||
@@ -21,6 +21,7 @@ from app.schemas.customer import (
|
|||||||
CustomerOut,
|
CustomerOut,
|
||||||
CustomerOwnershipIn,
|
CustomerOwnershipIn,
|
||||||
CustomerQuery,
|
CustomerQuery,
|
||||||
|
CustomerRatingIn,
|
||||||
CustomerUpdateIn,
|
CustomerUpdateIn,
|
||||||
OwnershipChangeLogOut,
|
OwnershipChangeLogOut,
|
||||||
)
|
)
|
||||||
@@ -102,6 +103,31 @@ def delete_customer(
|
|||||||
return ok(message="客户已删除")
|
return ok(message="客户已删除")
|
||||||
|
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/{customer_id}/rating",
|
||||||
|
response_model=ApiResponse[CustomerOut],
|
||||||
|
summary="保存客户评级",
|
||||||
|
)
|
||||||
|
def rate_customer(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
customer_id: Annotated[int, Path(ge=1)],
|
||||||
|
payload: CustomerRatingIn,
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[CustomerOut]:
|
||||||
|
"""保存评级总分(评级计算器「保存评分」)。
|
||||||
|
|
||||||
|
评分是客户档案属性:存总分与评级时间,五项明细不落库(对照表 #25);
|
||||||
|
编辑端点不含评分 —— 评分只走本端点,由计算器统一入口写入。
|
||||||
|
"""
|
||||||
|
return ok(
|
||||||
|
customer_service.rate_customer(
|
||||||
|
session, customer_id, payload, operator=current_user, meta=meta
|
||||||
|
),
|
||||||
|
message="评级已保存",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.put(
|
@router.put(
|
||||||
"/{customer_id}/ownership",
|
"/{customer_id}/ownership",
|
||||||
response_model=ApiResponse[CustomerOut],
|
response_model=ApiResponse[CustomerOut],
|
||||||
|
|||||||
@@ -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))
|
||||||
@@ -63,6 +63,14 @@ class Customer(Base, TimestampMixin, TPlusSyncMixin):
|
|||||||
comment="归属负责营销人员ID(手动修改不回溯历史,变更留痕见 ownership_change_logs)",
|
comment="归属负责营销人员ID(手动修改不回溯历史,变更留痕见 ownership_change_logs)",
|
||||||
)
|
)
|
||||||
remark: Mapped[str | None] = mapped_column(String(255), comment="备注")
|
remark: Mapped[str | None] = mapped_column(String(255), comment="备注")
|
||||||
|
rating_score: Mapped[int | None] = mapped_column(
|
||||||
|
TINYINT_TYPE,
|
||||||
|
comment="评级总分(0-100,评级计算器保存;null=未评分;人工维护字段,T+ 同步不覆盖)",
|
||||||
|
)
|
||||||
|
rating_time: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime,
|
||||||
|
comment="最近评级时间(评级计算器保存评分时刷新;人工维护字段)",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class OwnershipChangeLog(Base):
|
class OwnershipChangeLog(Base):
|
||||||
|
|||||||
@@ -52,10 +52,12 @@ def list_customers(session: Session, query: CustomerQuery) -> list[Row]:
|
|||||||
|
|
||||||
归属人员外键非空但统一用 outerjoin:人员行不可考(被物理删除的脏数据)
|
归属人员外键非空但统一用 outerjoin:人员行不可考(被物理删除的脏数据)
|
||||||
不应让客户行整个查不出来(同 personnel_repository 的韧性口径)。
|
不应让客户行整个查不出来(同 personnel_repository 的韧性口径)。
|
||||||
排序按 id 倒序:客户无排序字段,新增的靠前符合列表页习惯。
|
排序键由查询白名单决定(见 ``CustomerQuery.order_by``):
|
||||||
|
id 倒序 = 新建档靠前,rating_score 按分数排、未评分垫底。
|
||||||
"""
|
"""
|
||||||
stmt = _apply_filters(_base_select(), query)
|
stmt = _apply_filters(_base_select(), query)
|
||||||
stmt = stmt.order_by(Customer.id.desc()).offset(query.offset).limit(query.page_size)
|
stmt = _apply_order(stmt, query)
|
||||||
|
stmt = stmt.offset(query.offset).limit(query.page_size)
|
||||||
return list(session.execute(stmt).all())
|
return list(session.execute(stmt).all())
|
||||||
|
|
||||||
|
|
||||||
@@ -131,6 +133,23 @@ def _keyword_clause(keyword: str) -> ColumnElement[bool]:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_order(stmt: Select, query: CustomerQuery) -> Select:
|
||||||
|
"""应用排序。排序键已由 schema 白名单收口,此处只做键 → 列的映射。
|
||||||
|
|
||||||
|
MySQL 把 NULL 视为最小值:ASC 时未评分(null)会排最前,但「没打分」
|
||||||
|
不该压在「打了低分」之上 —— ASC 分支用 ``is_(None)`` 布尔键把 null
|
||||||
|
挪到末尾;DESC 分支 null 天然在最后,无需处理。尾部再按 id 倒序兜底,
|
||||||
|
保证同分行的次序稳定(分页不跳行)。
|
||||||
|
"""
|
||||||
|
if query.order_by == "rating_score":
|
||||||
|
score = Customer.rating_score
|
||||||
|
if query.order == "asc":
|
||||||
|
stmt = stmt.order_by(score.is_(None), score.asc())
|
||||||
|
else:
|
||||||
|
stmt = stmt.order_by(score.desc())
|
||||||
|
return stmt.order_by(Customer.id.desc())
|
||||||
|
|
||||||
|
|
||||||
# ==================== 写入 ====================
|
# ==================== 写入 ====================
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -142,6 +142,15 @@ def count_logs(session: Session, query: OperationLogListQuery) -> int:
|
|||||||
return int(session.execute(stmt).scalar_one())
|
return int(session.execute(stmt).scalar_one())
|
||||||
|
|
||||||
|
|
||||||
|
def distinct_modules(session: Session) -> list[str]:
|
||||||
|
"""全部模块名(去重、按名称升序):工作台「最近动态」的模块筛选项。
|
||||||
|
|
||||||
|
交给 SQL 的 DISTINCT,不在应用层聚合 —— 应用层去重要把整列拉进内存。
|
||||||
|
"""
|
||||||
|
stmt = select(OperationLog.module).distinct().order_by(OperationLog.module)
|
||||||
|
return [module for module in session.execute(stmt).scalars() if module]
|
||||||
|
|
||||||
|
|
||||||
def _apply_filters(stmt: Select, query: OperationLogListQuery) -> Select:
|
def _apply_filters(stmt: Select, query: OperationLogListQuery) -> Select:
|
||||||
"""应用筛选条件(公共部分复用 query_utils,与登录日志同口径)。"""
|
"""应用筛选条件(公共部分复用 query_utils,与登录日志同口径)。"""
|
||||||
stmt = apply_log_filters(stmt, OperationLog, query.filters)
|
stmt = apply_log_filters(stmt, OperationLog, query.filters)
|
||||||
|
|||||||
@@ -32,8 +32,9 @@ def list_all(session: Session, *, for_update: bool = False) -> list[CustomerRati
|
|||||||
def get_by_id(session: Session, grade_id: int) -> CustomerRatingGrade | None:
|
def get_by_id(session: Session, grade_id: int) -> CustomerRatingGrade | None:
|
||||||
"""按主键查询。
|
"""按主键查询。
|
||||||
|
|
||||||
等级表无下游引用(评级是纯计算器不落评分记录),无需 ``for_update``
|
等级表无外键引用(客户档案只存总分,等级由分数按当前映射实时推导,
|
||||||
锁行 —— product_repository 锁行防的是并发引用悬空,此处不存在该风险。
|
不存等级 id),无需 ``for_update`` 锁行 —— product_repository 锁行防的
|
||||||
|
是并发引用悬空,此处不存在该风险:删掉一个等级,只是以后不再判到它。
|
||||||
"""
|
"""
|
||||||
return session.get(CustomerRatingGrade, grade_id)
|
return session.get(CustomerRatingGrade, grade_id)
|
||||||
|
|
||||||
@@ -60,6 +61,10 @@ def create(session: Session, grade: CustomerRatingGrade) -> CustomerRatingGrade:
|
|||||||
|
|
||||||
|
|
||||||
def delete(session: Session, grade: CustomerRatingGrade) -> None:
|
def delete(session: Session, grade: CustomerRatingGrade) -> None:
|
||||||
"""物理删除等级。等级表无下游引用,调用方无需引用保护校验。"""
|
"""物理删除等级。
|
||||||
|
|
||||||
|
等级表无外键引用(客户档案只存总分,不存等级 id),调用方无需引用保护
|
||||||
|
校验:删掉等级只是「以后不再判到它」,已存的总分不受影响。
|
||||||
|
"""
|
||||||
session.delete(grade)
|
session.delete(grade)
|
||||||
session.flush()
|
session.flush()
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"""客户管理的请求/响应模型(需求 2.1.3)。"""
|
"""客户管理的请求/响应模型(需求 2.1.3)。"""
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
@@ -28,6 +29,8 @@ class CustomerOut(BaseModel):
|
|||||||
owner_personnel_id: int | None = None
|
owner_personnel_id: int | None = None
|
||||||
owner_personnel_name: str | None = None
|
owner_personnel_name: str | None = None
|
||||||
remark: str | None = None
|
remark: str | None = None
|
||||||
|
rating_score: int | None = None
|
||||||
|
rating_time: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
class CustomerCreateIn(BaseModel):
|
class CustomerCreateIn(BaseModel):
|
||||||
@@ -90,13 +93,32 @@ class CustomerUpdateIn(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class CustomerQuery(PageQuery):
|
class CustomerQuery(PageQuery):
|
||||||
"""客户列表查询条件(字段名蛇形直出,与前端 api 封装同名对应)。"""
|
"""客户列表查询条件(字段名蛇形直出,与前端 api 封装同名对应)。
|
||||||
|
|
||||||
|
排序用 ``Literal`` 白名单而不是自由字符串:``order_by`` 直接拼进
|
||||||
|
``ORDER BY``,自由值等于把注入面开到排序键上;客户列表只有两个
|
||||||
|
有意义的排序键,白名单足够。
|
||||||
|
"""
|
||||||
|
|
||||||
keyword: str | None = Field(
|
keyword: str | None = Field(
|
||||||
default=None, max_length=64, description="公司名称或客户负责人,模糊匹配"
|
default=None, max_length=64, description="公司名称或客户负责人,模糊匹配"
|
||||||
)
|
)
|
||||||
customer_type: str | None = Field(default=None, max_length=32, description="客户分类筛选")
|
customer_type: str | None = Field(default=None, max_length=32, description="客户分类筛选")
|
||||||
owner_personnel_id: int | None = Field(default=None, ge=1, description="归属人员ID筛选")
|
owner_personnel_id: int | None = Field(default=None, ge=1, description="归属人员ID筛选")
|
||||||
|
order_by: Literal["id", "rating_score"] = Field(
|
||||||
|
default="id", description="排序键:id 建档序 / rating_score 评级总分"
|
||||||
|
)
|
||||||
|
order: Literal["asc", "desc"] = Field(default="desc", description="排序方向")
|
||||||
|
|
||||||
|
|
||||||
|
class CustomerRatingIn(BaseModel):
|
||||||
|
"""保存客户评级入参(评级计算器「保存评分」)。
|
||||||
|
|
||||||
|
score 是五项之和的整数(0-100);五项明细不落库(评级记录只存总分,
|
||||||
|
见对照表 #25 的定位:评分是档案属性而非审计事件)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
score: int = Field(ge=0, le=100, description="评级总分(0-100 整数)")
|
||||||
|
|
||||||
|
|
||||||
class CustomerOwnershipIn(BaseModel):
|
class CustomerOwnershipIn(BaseModel):
|
||||||
|
|||||||
@@ -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)
|
|
||||||
@@ -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):
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
from sqlalchemy import Row
|
from sqlalchemy import Row
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
@@ -38,6 +39,7 @@ from app.schemas.customer import (
|
|||||||
CustomerOut,
|
CustomerOut,
|
||||||
CustomerOwnershipIn,
|
CustomerOwnershipIn,
|
||||||
CustomerQuery,
|
CustomerQuery,
|
||||||
|
CustomerRatingIn,
|
||||||
CustomerUpdateIn,
|
CustomerUpdateIn,
|
||||||
OwnershipChangeLogOut,
|
OwnershipChangeLogOut,
|
||||||
)
|
)
|
||||||
@@ -61,6 +63,8 @@ def _to_out(customer: Customer, owner_name: str | None) -> CustomerOut:
|
|||||||
owner_personnel_id=customer.owner_personnel_id,
|
owner_personnel_id=customer.owner_personnel_id,
|
||||||
owner_personnel_name=owner_name,
|
owner_personnel_name=owner_name,
|
||||||
remark=customer.remark,
|
remark=customer.remark,
|
||||||
|
rating_score=customer.rating_score,
|
||||||
|
rating_time=customer.rating_time,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -263,6 +267,53 @@ def update_customer(
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def rate_customer(
|
||||||
|
session: Session,
|
||||||
|
customer_id: int,
|
||||||
|
payload: CustomerRatingIn,
|
||||||
|
*,
|
||||||
|
operator: User,
|
||||||
|
meta: RequestMeta | None = None,
|
||||||
|
) -> CustomerOut:
|
||||||
|
"""保存客户评级(评级计算器「保存评分」)。
|
||||||
|
|
||||||
|
评分是客户档案属性而非审计事件:只存总分与评级时间,五项明细与所用
|
||||||
|
等级配置不落库(对照表 #25 的定位口径)——等级映射是可变判定表,回溯
|
||||||
|
「当时命中的等级」需要快照设计,超出档案属性的范围;评级时间已足够
|
||||||
|
回答「这个分是什么时候打的」。等级由前端按当前映射实时算并展示。
|
||||||
|
|
||||||
|
:raises NotFoundError: 客户不存在
|
||||||
|
"""
|
||||||
|
# 客户名进审计摘要,预取放在审计上下文之前(同 change_ownership 口径)
|
||||||
|
row = customer_repository.get_with_owner_name(session, customer_id)
|
||||||
|
customer_name = row[0].company_name if row else f"#{customer_id}"
|
||||||
|
with audit_service.audit(
|
||||||
|
session,
|
||||||
|
operator=operator,
|
||||||
|
record=AuditRecord(
|
||||||
|
module=MODULE_CUSTOMER,
|
||||||
|
action=ACTION_UPDATE,
|
||||||
|
summary=f"保存客户评级 {customer_name}:{payload.score} 分",
|
||||||
|
request_data=payload.model_dump(),
|
||||||
|
),
|
||||||
|
meta=meta,
|
||||||
|
):
|
||||||
|
if row is None:
|
||||||
|
raise NotFoundError("客户不存在")
|
||||||
|
customer, owner_name = row
|
||||||
|
|
||||||
|
customer.rating_score = payload.score
|
||||||
|
# 评级时间取应用层本地时间(库列 TZ=Asia/Shanghai,与 TimestampMixin
|
||||||
|
# 的服务器默认值同一时区口径),不用数据库 NOW():行已在本会话
|
||||||
|
customer.rating_time = datetime.now()
|
||||||
|
|
||||||
|
# 响应在 commit 之前构造(同 user_service 顶部说明)
|
||||||
|
out = _to_out(customer, owner_name)
|
||||||
|
session.commit()
|
||||||
|
logger.info("保存客户评级 | customer_id=%s | score=%s", customer_id, payload.score)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def delete_customer(
|
def delete_customer(
|
||||||
session: Session,
|
session: Session,
|
||||||
customer_id: int,
|
customer_id: int,
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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 方案公式"
|
"经理本人名下业绩,按本团队与全公司两级比例合计计提"
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -98,9 +98,9 @@ uv run pytest tests/integration -q # 仅集成
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `tests/integration/test_personnel.py` | 未登录 401、新增默认在职、工资标准 str 直出、经理自引用/不存在或已离职/岗位停用 400、删除保护(团队成员与客户归属引用→引导改离职)、辐射区域与拓展计划全量替换(含清空与详情回显)、在职/离职切换、审计成功与失败双记录 | 20 |
|
| `tests/integration/test_personnel.py` | 未登录 401、新增默认在职、工资标准 str 直出、经理自引用/不存在或已离职/岗位停用 400、删除保护(团队成员与客户归属引用→引导改离职)、辐射区域与拓展计划全量替换(含清空与详情回显)、在职/离职切换、审计成功与失败双记录 | 20 |
|
||||||
| `tests/integration/test_products.py` | 未登录 401、类别值域 400、名称+规格组合唯一(同名称不同规格放行)、单价负数/非数字 400、启停切换、改类别、审计留痕 | 14 |
|
| `tests/integration/test_products.py` | 未登录 401、类别值域 400、名称+规格组合唯一(同名称不同规格放行)、单价负数/非数字 400、启停切换、改类别、审计留痕 | 14 |
|
||||||
| `tests/integration/test_customers.py` | 未登录 401、客户分类值域 400、「公司名称+负责人」组合唯一(同公司不同负责人放行)、归属变更(主表更新+留痕字段断言)、归属人员离职 400、新旧相同含双方为空 400、留痕分页 join 姓名、编辑端点不触归属、审计留痕 | 14 |
|
| `tests/integration/test_customers.py` | 未登录 401、客户分类值域 400、「公司名称+负责人」组合唯一(同公司不同负责人放行)、归属变更(主表更新+留痕字段断言)、归属人员离职 400、新旧相同含双方为空 400、留痕分页 join 姓名、编辑端点不触归属、审计留痕、评级保存(回显总分/时间、覆盖式保存)、评分 101→400、按评分排序未评分垫底(asc/desc) | 18 |
|
||||||
|
|
||||||
运行:`uv run pytest tests/integration/test_personnel.py tests/integration/test_products.py tests/integration/test_customers.py -q` → **48 passed**(2026-10-13,连跑两次幂等)。
|
运行:`uv run pytest tests/integration/test_personnel.py tests/integration/test_products.py tests/integration/test_customers.py -q` → **48 passed**(2026-10-13,连跑两次幂等);2026-10-23 评级功能后客户单文件 **18 passed**(18.5s)。
|
||||||
|
|
||||||
### 业务数据管理(2026-10-13 完成)
|
### 业务数据管理(2026-10-13 完成)
|
||||||
|
|
||||||
@@ -209,6 +209,9 @@ uv run pytest tests/integration -q # 仅集成
|
|||||||
| 2026-10-19 | 单元全量 + 报表定点 | 484 | ✅ 484 passed(45.94s);报表单元 11 例 0.70s | **M4 报表模块收尾**(前端四页/打印视图/导出与单元测试补齐)。ruff 绿;前端 `pnpm type-check` 绿。报表集成 7 例记录见上节 |
|
| 2026-10-19 | 单元全量 + 报表定点 | 484 | ✅ 484 passed(45.94s);报表单元 11 例 0.70s | **M4 报表模块收尾**(前端四页/打印视图/导出与单元测试补齐)。ruff 绿;前端 `pnpm type-check` 绿。报表集成 7 例记录见上节 |
|
||||||
| 2026-10-19 | 全部 | 806 | ✅ 806 passed(7:59) | **M4 ⑤ 报表统计全量回归**(报表新增 18 例:集成 7 + 单元 11;另含并行会话并入的 M3 前端修复与字体合规)。ruff 绿。e2e:`calc_settlement_flow` 8 步由并行会话跑通;`report_flow.py` 待 `E2E_ADMIN_PASSWORD` 运行 |
|
| 2026-10-19 | 全部 | 806 | ✅ 806 passed(7:59) | **M4 ⑤ 报表统计全量回归**(报表新增 18 例:集成 7 + 单元 11;另含并行会话并入的 M3 前端修复与字体合规)。ruff 绿。e2e:`calc_settlement_flow` 8 步由并行会话跑通;`report_flow.py` 待 `E2E_ADMIN_PASSWORD` 运行 |
|
||||||
|
|
||||||
|
| 2026-10-23 | 参数文案中文化 | 单元 526 + 参数集成 24 | ✅ 526 passed(45.15s)/ 24 passed(10.60s) | **计提参数页全面中文化**:`param_registry` 22 条 `description` 改写为业务语言(去掉「澄清 #10」「M3」「禁止按 0.011111 截断」等开发者语汇),`db/init/init-dev.sql` 种子同源同步;`param_service` 模板报错改中文模板名、产品类别报错去掉英文枚举;`dashboard_service` 概览口径说明重写。**这些文案无测试断言**(`rate_text` 断言 `11/1125`、`聚合:20%+5%` 未动),故用例数不变、全绿即口径未被破坏。ruff 绿;前端 `pnpm type-check` 绿 |
|
||||||
|
| 2026-10-23 | 台账/预览的计提说明口语化 | 单元 526 + 集成 87(calc/settlements/param/reports) | ✅ 526 passed(47.29s)/ 87 passed(7:41) | **`rate_text`/`rate_note`/聚合行 `note` 改业务话**:`企业户固定计提比例`→「企业户:不按回款天数,统一按固定比例计提」、`业务员递减:10‰ 起,20 天(分界 360 天…)`→「回款 20 天,从起始 10‰ 按日递减(满 360 天后固定按 4‰ 计算)」、`信息员:本人销售额 × 计提比例`→「信息员:按本人销售额乘计提比例,不随回款天数递减」、聚合行 `#12 参数化:基数不含经理个人户业绩;9.8 方案公式`→「团队分成:基数不含经理本人名下业绩,按本团队与全公司两级比例合计计提」。**用例同步**:`test_经理聚合口径_预览拒绝` 原断言消息含「聚合」,改为断言含「营销经理」+「月度结算」(守「拒绝且给下一步」,不再绑定措辞);`rate_text` 的 `11/1125`、`聚合:20%+5%` 断言未动(那是分数原文与百分比拼装,非说明文案) |
|
||||||
|
|
||||||
## 四、测试发现并修复的缺陷
|
## 四、测试发现并修复的缺陷
|
||||||
|
|
||||||
| 日期 | 缺陷 | 影响 | 处理 |
|
| 日期 | 缺陷 | 影响 | 处理 |
|
||||||
|
|||||||
@@ -376,3 +376,73 @@ class TestAudit:
|
|||||||
latest = logs[0]
|
latest = logs[0]
|
||||||
assert customer["company_name"] in latest.summary, "摘要应含客户名"
|
assert customer["company_name"] in latest.summary, "摘要应含客户名"
|
||||||
assert "→" in latest.summary, "摘要应体现新旧归属走向"
|
assert "→" in latest.summary, "摘要应体现新旧归属走向"
|
||||||
|
|
||||||
|
|
||||||
|
class TestRating:
|
||||||
|
"""评级保存与列表排序 —— 评分是档案属性,只存总分与评级时间。
|
||||||
|
|
||||||
|
守的是:评分只走专属端点(编辑端点不含评分,旁路写评分会绕开口径);
|
||||||
|
分值红线 0-100 在入口拦截;按评分排序时未评分客户必须垫底,不能因
|
||||||
|
MySQL 把 NULL 视为最小值而在升序里压过打了低分的客户。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_保存评级_回显总分与时间_且编辑端点不含评分(
|
||||||
|
self, client: TestClient, normal: dict
|
||||||
|
) -> None:
|
||||||
|
headers = normal["headers"]
|
||||||
|
customer = _create_customer(client, headers)
|
||||||
|
assert customer["rating_score"] is None, "新客户应无评分"
|
||||||
|
|
||||||
|
resp = client.put(
|
||||||
|
f"{_CUSTOMERS}/{customer['id']}/rating", json={"score": 88}, headers=headers
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
body = resp.json()["data"]
|
||||||
|
assert body["rating_score"] == 88
|
||||||
|
assert body["rating_time"] is not None, "保存评分必须刷新评级时间"
|
||||||
|
|
||||||
|
# 覆盖式保存:新分数替换旧分数
|
||||||
|
resp = client.put(
|
||||||
|
f"{_CUSTOMERS}/{customer['id']}/rating", json={"score": 60}, headers=headers
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert resp.json()["data"]["rating_score"] == 60
|
||||||
|
|
||||||
|
def test_评分越界_400(self, client: TestClient, normal: dict) -> None:
|
||||||
|
headers = normal["headers"]
|
||||||
|
customer = _create_customer(client, headers)
|
||||||
|
resp = client.put(
|
||||||
|
f"{_CUSTOMERS}/{customer['id']}/rating", json={"score": 101}, headers=headers
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400, resp.text
|
||||||
|
|
||||||
|
def test_按评分排序_未评分垫底_升序降序都不压过低分(
|
||||||
|
self, client: TestClient, normal: dict
|
||||||
|
) -> None:
|
||||||
|
headers = normal["headers"]
|
||||||
|
high = _create_customer(client, headers)
|
||||||
|
low = _create_customer(client, headers)
|
||||||
|
unrated = _create_customer(client, headers)
|
||||||
|
for target, score in ((high, 80), (low, 60)):
|
||||||
|
resp = client.put(
|
||||||
|
f"{_CUSTOMERS}/{target['id']}/rating", json={"score": score}, headers=headers
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
|
||||||
|
def _order_names(order: str) -> list[str]:
|
||||||
|
resp = client.get(
|
||||||
|
_CUSTOMERS,
|
||||||
|
params={"pageNum": 1, "pageSize": 50, "order_by": "rating_score", "order": order},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
names = [row["company_name"] for row in resp.json()["data"]["list"]]
|
||||||
|
return names
|
||||||
|
|
||||||
|
# 降序:高分在前,未评分最后;升序:低分在前,未评分仍垫底
|
||||||
|
desc_names = _order_names("desc")
|
||||||
|
assert desc_names.index(high["company_name"]) < desc_names.index(low["company_name"])
|
||||||
|
assert desc_names[-1] == unrated["company_name"]
|
||||||
|
asc_names = _order_names("asc")
|
||||||
|
assert asc_names.index(low["company_name"]) < asc_names.index(high["company_name"])
|
||||||
|
assert asc_names[-1] == unrated["company_name"], "未评分必须垫底"
|
||||||
@@ -118,10 +118,20 @@ def _dashboard(client: TestClient, headers: dict, month: str | None = None) -> d
|
|||||||
return resp.json()["data"]
|
return resp.json()["data"]
|
||||||
|
|
||||||
|
|
||||||
|
def _activities(client: TestClient, headers: dict, **params) -> dict:
|
||||||
|
"""最近动态分页端点。"""
|
||||||
|
resp = client.get(f"{_DASHBOARD}/activities", params=params, headers=headers)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
return resp.json()["data"]
|
||||||
|
|
||||||
|
|
||||||
def test_权限_普通用户403文案(client: TestClient, normal: dict):
|
def test_权限_普通用户403文案(client: TestClient, normal: dict):
|
||||||
resp = client.get(_DASHBOARD, headers=normal["headers"])
|
resp = client.get(_DASHBOARD, headers=normal["headers"])
|
||||||
assert resp.status_code == 403
|
assert resp.status_code == 403
|
||||||
assert resp.json()["message"] == "无权访问工作台"
|
assert resp.json()["message"] == "无权访问工作台"
|
||||||
|
# 最近动态分页端点同属工作台,权限一致
|
||||||
|
resp = client.get(f"{_DASHBOARD}/activities", headers=normal["headers"])
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
def test_总览_绩效合计与台账一致且非法月份400(client: TestClient, admin: dict):
|
def test_总览_绩效合计与台账一致且非法月份400(client: TestClient, admin: dict):
|
||||||
@@ -221,9 +231,40 @@ def test_预警_审批超时命中且参数关闭后不产生(client: TestClient
|
|||||||
|
|
||||||
def test_动态_来源操作日志(client: TestClient, admin: dict):
|
def test_动态_来源操作日志(client: TestClient, admin: dict):
|
||||||
_chain(client, admin["headers"])
|
_chain(client, admin["headers"])
|
||||||
data = _dashboard(client, admin["headers"])
|
data = _activities(client, admin["headers"])
|
||||||
assert data["activities"], "工作台动态为空:写操作应产生操作日志"
|
assert data["list"], "工作台动态为空:写操作应产生操作日志"
|
||||||
assert {"module", "action", "summary", "create_time"} <= set(data["activities"][0])
|
assert {"module", "action", "summary", "create_time"} <= set(data["list"][0])
|
||||||
|
|
||||||
|
|
||||||
|
def test_动态_分页翻页不重不漏(client: TestClient, admin: dict):
|
||||||
|
_chain(client, admin["headers"])
|
||||||
|
first = _activities(client, admin["headers"], pageNum=1, pageSize=2)
|
||||||
|
assert first["pageNum"] == 1
|
||||||
|
assert first["pageSize"] == 2
|
||||||
|
assert len(first["list"]) == 2
|
||||||
|
assert first["total"] >= 2
|
||||||
|
|
||||||
|
second = _activities(client, admin["headers"], pageNum=2, pageSize=2)
|
||||||
|
assert len(second["list"]) == 2
|
||||||
|
# 两页不重不漏(倒序取,按 id 倒排)
|
||||||
|
first_ids = {item["id"] for item in first["list"]}
|
||||||
|
second_ids = {item["id"] for item in second["list"]}
|
||||||
|
assert first_ids.isdisjoint(second_ids)
|
||||||
|
assert first["list"][0]["id"] > second["list"][0]["id"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_动态_模块筛选与筛选项(client: TestClient, admin: dict):
|
||||||
|
_chain(client, admin["headers"])
|
||||||
|
page = _activities(client, admin["headers"], pageSize=50)
|
||||||
|
assert page["modules"], "模块筛选项不应为空"
|
||||||
|
module = page["modules"][0]
|
||||||
|
|
||||||
|
filtered = _activities(client, admin["headers"], module=module, pageSize=50)
|
||||||
|
assert filtered["total"] >= 1
|
||||||
|
assert filtered["list"], "按模块筛选后应有数据"
|
||||||
|
assert all(item["module"] == module for item in filtered["list"])
|
||||||
|
# 筛选项不随筛选条件变化(下拉里始终是全量模块)
|
||||||
|
assert filtered["modules"] == page["modules"]
|
||||||
|
|
||||||
|
|
||||||
# ==== 角色待办 / 预警阈值 / 趋势(2026-10-19 code-review 修复回归)====
|
# ==== 角色待办 / 预警阈值 / 趋势(2026-10-19 code-review 修复回归)====
|
||||||
|
|||||||
@@ -371,7 +371,11 @@ class TestPreview:
|
|||||||
}
|
}
|
||||||
resp = client.post(f"{_PARAMS}/preview", json=payload, headers=admin["headers"])
|
resp = client.post(f"{_PARAMS}/preview", json=payload, headers=admin["headers"])
|
||||||
assert resp.status_code == 400
|
assert resp.status_code == 400
|
||||||
assert "聚合" in resp.json()["message"]
|
# 守两件事:① 聚合口径不允许单笔试算(拒绝而不是算出个错数);
|
||||||
|
# ② 提示要能指导下一步(点名是谁的口径 + 去哪儿看),不是一句「不支持」。
|
||||||
|
message = resp.json()["message"]
|
||||||
|
assert "营销经理" in message
|
||||||
|
assert "月度结算" in message
|
||||||
|
|
||||||
def test_回款日早于发货日_400(self, client: TestClient, admin: dict) -> None:
|
def test_回款日早于发货日_400(self, client: TestClient, admin: dict) -> None:
|
||||||
payload = {
|
payload = {
|
||||||
|
|||||||
+26
-24
@@ -366,6 +366,8 @@ CREATE TABLE customers (
|
|||||||
customer_type VARCHAR(32) NOT NULL COMMENT '客户分类:personal个人户 manager_personal经理个人户 enterprise企业户(计提规则匹配锚点;T+同步不覆盖)' DEFAULT 'personal',
|
customer_type VARCHAR(32) NOT NULL COMMENT '客户分类:personal个人户 manager_personal经理个人户 enterprise企业户(计提规则匹配锚点;T+同步不覆盖)' DEFAULT 'personal',
|
||||||
owner_personnel_id BIGINT UNSIGNED COMMENT '归属负责营销人员ID(手动修改不回溯历史,变更留痕见 ownership_change_logs)',
|
owner_personnel_id BIGINT UNSIGNED COMMENT '归属负责营销人员ID(手动修改不回溯历史,变更留痕见 ownership_change_logs)',
|
||||||
remark VARCHAR(255) COMMENT '备注',
|
remark VARCHAR(255) COMMENT '备注',
|
||||||
|
rating_score TINYINT COMMENT '评级总分(0-100,评级计算器保存;null=未评分;人工维护字段,T+ 同步不覆盖)',
|
||||||
|
rating_time DATETIME COMMENT '最近评级时间(评级计算器保存评分时刷新;人工维护字段)',
|
||||||
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
|
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
|
||||||
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
source_no VARCHAR(64) COMMENT 'T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)',
|
source_no VARCHAR(64) COMMENT 'T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)',
|
||||||
@@ -437,29 +439,29 @@ INSERT INTO param_change_batches (id, version_no, effective_date, change_type, r
|
|||||||
(1, 'P20260101-00', '2026-01-01', 1, '初始版本(系统初始化种子,口径依据 2026-10-12 业务裁决与 2026-10-19 澄清答复)', 'system', 19);
|
(1, 'P20260101-00', '2026-01-01', 1, '初始版本(系统初始化种子,口径依据 2026-10-12 业务裁决与 2026-10-19 澄清答复)', 'system', 19);
|
||||||
|
|
||||||
INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES
|
INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES
|
||||||
(1, 'salesperson.rate.start', '业务员起始计提比例', '业务员个人户', 'DECIMAL', '‰', 'BUSINESS_ROLE', NULL, '10', '0', NULL, '线性递减段起始值:比例 = 起始 − 回款天数 × 斜率(≤分界天数)', 1),
|
(1, 'salesperson.rate.start', '业务员起始计提比例', '业务员个人户', 'DECIMAL', '‰', 'BUSINESS_ROLE', NULL, '10', '0', NULL, '回款当天能拿到的最高计提比例。回款每晚一天,按日递减斜率往下走,直到分界天数为止。', 1),
|
||||||
(2, 'salesperson.rate.slope', '业务员日递减斜率', '业务员个人户', 'DECIMAL', '‰/天', 'BUSINESS_ROLE', NULL, '4/360', '0', NULL, '精确分数 4/360‰/天(=1/90),禁止按 0.011111 截断存储', 2),
|
(2, 'salesperson.rate.slope', '业务员日递减斜率', '业务员个人户', 'DECIMAL', '‰/天', 'BUSINESS_ROLE', NULL, '4/360', '0', NULL, '回款每延后一天,计提比例下降多少。按分数填写(如 4/360)可保持精确,填小数会引入四舍五入误差。', 2),
|
||||||
(3, 'salesperson.rate.floor', '业务员计提下限', '业务员个人户', 'DECIMAL', '‰', 'BUSINESS_ROLE', NULL, '4', '0', NULL, '回款天数超过分界天数后固定取此比例(≥361 天 = 4‰)', 3),
|
(3, 'salesperson.rate.floor', '业务员计提下限', '业务员个人户', 'DECIMAL', '‰', 'BUSINESS_ROLE', NULL, '4', '0', NULL, '回款天数超过分界天数后,计提比例不再下降,固定按这个值计算,即最低计提比例。', 3),
|
||||||
(4, 'salesperson.days.boundary', '线性递减分界天数', '业务员个人户', 'INT', '天', 'BUSINESS_ROLE', NULL, '360', '0', NULL, '≤分界天数按公式线性递减(360 天=6‰);超过则取下限', 4),
|
(4, 'salesperson.days.boundary', '线性递减分界天数', '业务员个人户', 'INT', '天', 'BUSINESS_ROLE', NULL, '360', '0', NULL, '回款天数在这个范围内按日递减;超过这个天数就固定按下限计提。默认 360 天。', 4),
|
||||||
(5, 'manager.share.team', '经理本团队分成比例', '经理分成', 'DECIMAL', '%', 'BUSINESS_ROLE', NULL, '12.5', '0', '100', '∑本人带领业务员绩效 × 此比例(限本团队)', 5),
|
(5, 'manager.share.team', '经理本团队分成比例', '经理分成', 'DECIMAL', '%', 'BUSINESS_ROLE', NULL, '12.5', '0', '100', '经理按本团队业务员的绩效总额提取的分成比例,只统计自己带的业务员。', 5),
|
||||||
(6, 'manager.share.company', '经理全公司分成比例', '经理分成', 'DECIMAL', '%', 'BUSINESS_ROLE', NULL, '5', '0', '100', '∑全部业务员绩效 × 此比例(全公司)', 6),
|
(6, 'manager.share.company', '经理全公司分成比例', '经理分成', 'DECIMAL', '%', 'BUSINESS_ROLE', NULL, '5', '0', '100', '经理按全公司业务员的绩效总额提取的分成比例,与本团队分成叠加计算。', 6),
|
||||||
(7, 'enterprise.rate', '企业户计提比例', '企业户', 'DECIMAL', '‰', 'PRODUCT_CATEGORY', NULL, '3', '0', NULL, '所有企业户适用;2026-08-13 起由 2‰ 提高至 3‰', 7),
|
(7, 'enterprise.rate', '企业户计提比例', '企业户', 'DECIMAL', '‰', 'PRODUCT_CATEGORY', NULL, '3', '0', NULL, '企业户客户的回款统一按这个比例计提,不看回款天数。2026-08-13 起由 2‰ 调整为 3‰。', 7),
|
||||||
(8, 'info.rate', '信息员计提比例', '信息员', 'DECIMAL', '‰', 'BUSINESS_ROLE', NULL, '1', '0', NULL, '信息员绩效 = 本人销售额 × 此比例', 8),
|
(8, 'info.rate', '信息员计提比例', '信息员', 'DECIMAL', '‰', 'BUSINESS_ROLE', NULL, '1', '0', NULL, '信息员的绩效按本人销售额乘这个比例计算,不随回款天数递减。', 8),
|
||||||
(9, 'prepay.rate', '预付款计提比例', '支付环节', 'DECIMAL', '‰', 'NONE', NULL, '10', '0', NULL, '预付款统一按此比例计提', 9),
|
(9, 'prepay.rate', '预付款计提比例', '支付环节', 'DECIMAL', '‰', 'NONE', NULL, '10', '0', NULL, '预付款到账即按这个比例计提绩效,不必等到全额回款。', 9),
|
||||||
(10, 'barter.discount', '以物抵顶折算比例', '支付环节', 'DECIMAL', '%', 'NONE', NULL, '80', '0', '100', '以物抵顶回款绩效按应发绩效的此比例折算', 10),
|
(10, 'barter.discount', '以物抵顶折算比例', '支付环节', 'DECIMAL', '%', 'NONE', NULL, '80', '0', '100', '以物抵顶的回款,按应发绩效的这个比例折算,即打折后的金额参与绩效。', 10),
|
||||||
(11, 'payment.start.ratio', '支付起付比例', '支付环节', 'DECIMAL', '%', 'NONE', NULL, '60', '0', '100', '单项业务回款额达销售额(不含运费)此比例后起付;达标前累计', 11),
|
(11, 'payment.start.ratio', '支付起付比例', '支付环节', 'DECIMAL', '%', 'NONE', NULL, '60', '0', '100', '单项业务的回款累计达到销售额(不含运费)的这个比例后才开始发放绩效;达标之前只累计、不发放。', 11),
|
||||||
(12, 'payment.weight.mode', '支付权重应用方式', '支付环节', 'ENUM', NULL, 'NONE', 'FULL|BALANCE', 'FULL', NULL, NULL, 'FULL=按 R×R 乘当月全额应发绩效;BALANCE=乘余额(语义待 M3 前确认)', 12),
|
(12, 'payment.weight.mode', '支付权重应用方式', '支付环节', 'ENUM', NULL, 'NONE', 'FULL|BALANCE', 'FULL', NULL, NULL, '选择支付权重乘在哪个基数上:当月全额 = 乘本月全部应发绩效;余额 = 只乘尚未发放的部分。', 12),
|
||||||
(13, 'product.threshold', '折算毛利润额阈值', '产品阈值', 'DECIMAL', '%', 'PRODUCT_CATEGORY', NULL, '30', '0', '100', '折算毛利润额 = 销售额 ×(毛利润率 − 阈值);市政水利 30%、轨交 20%', 13),
|
(13, 'product.threshold', '折算毛利润额阈值', '产品阈值', 'DECIMAL', '%', 'PRODUCT_CATEGORY', NULL, '30', '0', '100', '折算毛利润额 = 销售额 ×(毛利润率 − 阈值),低于阈值的部分不参与绩效。市政水利默认 30%,轨道交通默认 20%。', 13),
|
||||||
(14, 'salary.basic.mode', '月工资基本工资口径', '月工资口径', 'ENUM', NULL, 'BUSINESS_ROLE', 'INCLUDE|DEDUCT|EXCLUDE', 'INCLUDE', NULL, NULL, 'INCLUDE含基本工资 / DEDUCT扣除基本工资 / EXCLUDE不计', 14),
|
(14, 'salary.basic.mode', '月工资基本工资口径', '月工资口径', 'ENUM', NULL, 'BUSINESS_ROLE', 'INCLUDE|DEDUCT|EXCLUDE', 'INCLUDE', NULL, NULL, '算月工资时怎么处理基本工资:含基本工资 = 与绩效一起发;扣除基本工资 = 从绩效里扣掉已发的基本工资;不计 = 月工资只发绩效。', 14),
|
||||||
(15, 'probation.annual.threshold', '试用期年累计结算阈值', '月工资口径', 'DECIMAL', '元', 'NONE', NULL, '18000', '0', NULL, '试用期年累计绩效超过此值部分年底一次性结算', 15),
|
(15, 'probation.annual.threshold', '试用期年累计结算阈值', '月工资口径', 'DECIMAL', '元', 'NONE', NULL, '18000', '0', NULL, '试用期绩效先不发放;年内累计超过这个金额的部分,年底一次性结算。', 15),
|
||||||
(17, 'approval.secondary_allocation.enabled', '二次分配审批开关', '审批开关', 'BOOL', NULL, 'NONE', NULL, '1', NULL, NULL, '0=二次分配方案提交即生效(系统自动通过全节点留痕,留痕不断链);1=提交后进入财务复核→领导审核审批流。默认开启(澄清 #5 业务答复 2026-10-19),业务可关闭', 17),
|
(17, 'approval.secondary_allocation.enabled', '二次分配审批开关', '审批开关', 'BOOL', NULL, 'NONE', NULL, '1', NULL, NULL, '二次分配方案提交后是否要审批:开启 = 先经财务复核、再经领导审核,通过后才生效;关闭 = 提交即生效,系统自动记录各节点留痕。', 17),
|
||||||
(18, 'approval.monthly_settlement.enabled', '月度结算审批开关', '审批开关', 'BOOL', NULL, 'NONE', NULL, '1', NULL, NULL, '月度结算审批链开关(④ 月结提交时消费):0=结算生成即生效,1=走财务复核→领导审核审批流。默认开启(澄清 #7 业务答复 2026-10-19,月结涉及工资发放从严),业务可关闭', 18),
|
(18, 'approval.monthly_settlement.enabled', '月度结算审批开关', '审批开关', 'BOOL', NULL, 'NONE', NULL, '1', NULL, NULL, '月度结算提交后是否要审批:开启 = 先经财务复核、再经领导审核;关闭 = 生成即生效。月结涉及工资发放,默认开启。', 18),
|
||||||
(19, 'approval.adjustment.enabled', '调整单审批开关', '审批开关', 'BOOL', NULL, 'NONE', NULL, '0', NULL, NULL, '0=调整单提交即生效(系统自动通过留痕);1=走财务复核→领导审核审批流。调整单是已锁定月份补发/扣减的唯一通道(澄清 #9:走同一审批链,默认值业务未明确,暂按直接生效,业务可开启)', 19),
|
(19, 'approval.adjustment.enabled', '调整单审批开关', '审批开关', 'BOOL', NULL, 'NONE', NULL, '0', NULL, NULL, '调整单提交后是否要审批:开启 = 先经财务复核、再经领导审核;关闭 = 提交即生效。调整单是已锁定月份补发或扣减的唯一通道。', 19),
|
||||||
(20, 'manager.personal.included', '经理个人户计入聚合基数', '经理分成', 'BOOL', NULL, 'NONE', NULL, '0', NULL, NULL, '经理 12.5%+5% 聚合时是否计入经理本人名下经理个人户业绩(澄清 #12 业务答复 2026-10-19:作为参数配置、手动调整是否纳入):0=不计入(默认,暂口径) 1=计入', 20),
|
(20, 'manager.personal.included', '经理个人户计入聚合基数', '经理分成', 'BOOL', NULL, 'NONE', NULL, '0', NULL, NULL, '经理提取团队分成时,是否把经理本人名下的业绩(经理个人户)也算进分成基数。默认不计入。', 20),
|
||||||
-- 预警规则参数(M4 ⑧ 工作台,澄清 #10「做成参数化规则便于调整」)
|
-- 预警规则参数:工作台预警阈值,可按业务需要调整
|
||||||
(21, 'warning.enabled', '风险预警总开关', '预警规则', 'BOOL', NULL, 'NONE', NULL, '1', NULL, NULL, '工作台风险预警区块总开关:0=关闭(不扫描不展示) 1=开启(默认)', 21),
|
(21, 'warning.enabled', '风险预警总开关', '预警规则', 'BOOL', NULL, 'NONE', NULL, '1', NULL, NULL, '工作台风险预警区块的总开关:关闭后不再扫描、也不展示预警条目。默认开启。', 21),
|
||||||
(22, 'warning.receipt.overdue.days', '回款逾期天数阈值', '预警规则', 'INT', '天', 'NONE', NULL, '90', '1', NULL, '发货后超过该天数仍未回款的发货批次触发预警(澄清 #10 建议默认 90 天)', 22),
|
(22, 'warning.receipt.overdue.days', '回款逾期天数阈值', '预警规则', 'INT', '天', 'NONE', NULL, '90', '1', NULL, '发货后超过这么多天仍未收到回款的发货批次,会出现在工作台的预警列表里。默认 90 天。', 22),
|
||||||
(23, 'warning.approval.timeout.days', '审批超时天数阈值', '预警规则', 'INT', '天', 'NONE', NULL, '3', '1', NULL, '待审批单提交后超过该天数未处理触发预警(澄清 #10 建议默认 3 天)', 23);
|
(23, 'warning.approval.timeout.days', '审批超时天数阈值', '预警规则', 'INT', '天', 'NONE', NULL, '3', '1', NULL, '审批单提交后超过这么多天还没处理,会出现在工作台的预警列表里。默认 3 天。', 23);
|
||||||
|
|
||||||
INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES
|
INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES
|
||||||
(1, 1, 'salesperson', '2026-02-17', '10', 1),
|
(1, 1, 'salesperson', '2026-02-17', '10', 1),
|
||||||
@@ -891,4 +893,4 @@ CREATE TABLE alembic_version (
|
|||||||
CONSTRAINT alembic_version_pkc PRIMARY KEY (version_num)
|
CONSTRAINT alembic_version_pkc PRIMARY KEY (version_num)
|
||||||
)CHARSET=utf8mb4 ENGINE=InnoDB COLLATE utf8mb4_general_ci;
|
)CHARSET=utf8mb4 ENGINE=InnoDB COLLATE utf8mb4_general_ci;
|
||||||
|
|
||||||
INSERT INTO alembic_version (version_num) VALUES ('7f4f7b918bc4');
|
INSERT INTO alembic_version (version_num) VALUES ('c3a9e1d7b2f4');
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
-- V10: 客户评级落库(2026-10-23)
|
||||||
|
-- 评级计算器从独立工具页并入客户管理(行内弹窗),评分保存为客户档案属性;
|
||||||
|
-- 两列可空(null=未评分,存量客户无需回填),人工维护字段,T+ 同步不覆盖。
|
||||||
|
-- 对应迁移:alembic/versions/c3a9e1d7b2f4_v13_客户评级落库.py
|
||||||
|
-- 每列一条 ALTER:test_schema_sync 按「ALTER TABLE … ADD COLUMN」逐条解析,
|
||||||
|
-- 一条语句挂多个 ADD 只会解析出第一列,两份 DDL 会假性漂移。
|
||||||
|
ALTER TABLE `customers`
|
||||||
|
ADD COLUMN `rating_score` TINYINT COMMENT '评级总分(0-100,评级计算器保存;null=未评分;人工维护字段,T+ 同步不覆盖)' AFTER `remark`;
|
||||||
|
ALTER TABLE `customers`
|
||||||
|
ADD COLUMN `rating_time` DATETIME COMMENT '最近评级时间(评级计算器保存评分时刷新;人工维护字段)' AFTER `rating_score`;
|
||||||
+270
-13
@@ -5,12 +5,22 @@
|
|||||||
<title>衍水工资系统 · 视觉方向预览</title>
|
<title>衍水工资系统 · 视觉方向预览</title>
|
||||||
<style>
|
<style>
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
设计体系 v4(同步已交付模块):中性灰白 · 靛蓝点缀 · 微质感
|
设计体系 v4.2(侧栏层级口径改版):中性灰白 · 靛蓝点缀 · 微质感
|
||||||
|
v4.1 → v4.2 变更(2026-10-23):侧栏层级口径改版 + 工作台动态分页,不动色板/字体/组件基调
|
||||||
|
1) 组标题与菜单项同字号 13px —— 层级靠缩进与留白,不再靠字重压过
|
||||||
|
2) 去掉组标题的靛蓝竖标与激活项左侧竖条,激活只留白底浮起 + 靛蓝字
|
||||||
|
3) 子项缩进收到 12px,菜单图标父子统一 16px
|
||||||
|
4) §05 最近动态改服务端分页(每页 10/20/50 条 + 模块筛选 + 分页条)
|
||||||
v3 → v4 变更(2026-10-22):设计稿与已交付功能对齐
|
v3 → v4 变更(2026-10-22):设计稿与已交付功能对齐
|
||||||
1) 侧栏菜单改为真实路由分组(5 组 + 首页 = 21 项),组可折叠
|
1) 侧栏菜单改为真实路由分组(5 组 + 首页 = 21 项),组可折叠
|
||||||
2) 首页工作台按真实四区块重画(总览 / 待办 / 预警 / 动态)
|
2) 首页工作台按真实四区块重画(总览 / 待办 / 预警 / 动态)
|
||||||
3) 新增 §09:月结、审批、报表、打印稿(v3 缺 M3/M4 页面)
|
3) 新增 §09:月结、审批、报表、打印稿(v3 缺 M3/M4 页面)
|
||||||
4) token 与 frontend/src/assets/styles/index.css 对齐
|
4) token 与 frontend/src/assets/styles/index.css 对齐
|
||||||
|
v4 → v4.1 变更(2026-10-23):浏览器实测补订,不改色板/字体/组件基调
|
||||||
|
1) §03 表格行高定为 1.5(组件库默认 23px 会把单行数据行撑到 51px)
|
||||||
|
2) §09 月结生命周期条:四段圆点按阶段着色 + 条右侧操作提示
|
||||||
|
3) §05 工作台:卡片内空态在剩余高度内居中
|
||||||
|
4) 新增 §10:表单页版式(公司信息页)
|
||||||
质感来源(而非装饰):
|
质感来源(而非装饰):
|
||||||
1) 表面微渐变 —— 1–2% 明度差,暗示受光方向
|
1) 表面微渐变 —— 1–2% 明度差,暗示受光方向
|
||||||
2) 半透明描边 —— 边界在不同底色上自然融合
|
2) 半透明描边 —— 边界在不同底色上自然融合
|
||||||
@@ -259,10 +269,8 @@
|
|||||||
background: linear-gradient(180deg, #ffffff, #fbfcfe);
|
background: linear-gradient(180deg, #ffffff, #fbfcfe);
|
||||||
box-shadow: inset 0 1px 0 #fff, 0 1px 2px rgba(28, 35, 51, 0.06), 0 4px 10px rgba(28, 35, 51, 0.06);
|
box-shadow: inset 0 1px 0 #fff, 0 1px 2px rgba(28, 35, 51, 0.06), 0 4px 10px rgba(28, 35, 51, 0.06);
|
||||||
}
|
}
|
||||||
.menu-item.active::before {
|
/* 子项缩进一档(12px):层级是位置关系,不是字号关系 */
|
||||||
content: ''; position: absolute; left: -14px; top: 8px; bottom: 8px; width: 2px;
|
.menu-group-head ~ .menu-item { padding-left: 22px; }
|
||||||
border-radius: 1px; background: var(--ys-indigo);
|
|
||||||
}
|
|
||||||
.menu-item svg { width: 16px; height: 16px; flex: none; opacity: 0.7; }
|
.menu-item svg { width: 16px; height: 16px; flex: none; opacity: 0.7; }
|
||||||
.menu-item.active svg { opacity: 1; }
|
.menu-item.active svg { opacity: 1; }
|
||||||
.aside-foot { margin-top: auto; padding: 0 10px 2px; }
|
.aside-foot { margin-top: auto; padding: 0 10px 2px; }
|
||||||
@@ -418,14 +426,17 @@
|
|||||||
.login-btn { width: 100%; height: 38px; margin-top: 6px; font-size: 14px; }
|
.login-btn { width: 100%; height: 38px; margin-top: 6px; font-size: 14px; }
|
||||||
|
|
||||||
/* ---------- 菜单分组(路由 meta.group 派生的可折叠组) ---------- */
|
/* ---------- 菜单分组(路由 meta.group 派生的可折叠组) ---------- */
|
||||||
|
/*
|
||||||
|
* 层级口径(v4.2):父子同字号 13px —— 组标题与条目一样大,
|
||||||
|
* 结构靠「折叠箭头 + 组间留白 + 子项缩进一档」表达,不靠字号压过。
|
||||||
|
*/
|
||||||
.menu-group-head {
|
.menu-group-head {
|
||||||
display: flex; align-items: center; justify-content: space-between;
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
padding: 9px 10px; margin-bottom: 4px; border-radius: var(--ys-radius-sm);
|
padding: 9px 10px; margin-bottom: 4px; border-radius: var(--ys-radius-sm);
|
||||||
font-size: 11px; letter-spacing: 0.1em; color: var(--ys-ink-3);
|
font-size: 13px; font-weight: 500; color: var(--ys-ink);
|
||||||
}
|
}
|
||||||
.menu-group.open .menu-group-head { color: var(--ys-indigo); background: rgba(255, 255, 255, 0.45); }
|
.menu-group-head .chev { width: 11px; height: 11px; flex: none; opacity: 0.55; color: var(--ys-ink-3); }
|
||||||
.menu-group-head .chev { width: 11px; height: 11px; flex: none; opacity: 0.55; }
|
.menu-group.open .menu-group-head .chev { opacity: 0.9; color: var(--ys-ink-2); transform: rotate(90deg); }
|
||||||
.menu-group.open .menu-group-head .chev { opacity: 0.9; transform: rotate(90deg); }
|
|
||||||
|
|
||||||
/* ---------- 工作台卡片内的列表行 ---------- */
|
/* ---------- 工作台卡片内的列表行 ---------- */
|
||||||
.b-count {
|
.b-count {
|
||||||
@@ -444,7 +455,11 @@
|
|||||||
.b-tag-danger { color: var(--ys-danger); background: var(--ys-danger-wash); }
|
.b-tag-danger { color: var(--ys-danger); background: var(--ys-danger-wash); }
|
||||||
.b-tag-info { color: var(--ys-indigo); background: var(--ys-indigo-wash); }
|
.b-tag-info { color: var(--ys-indigo); background: var(--ys-indigo-wash); }
|
||||||
.b-tag-ok { color: var(--ys-ok); background: var(--ys-ok-wash-1); }
|
.b-tag-ok { color: var(--ys-ok); background: var(--ys-ok-wash-1); }
|
||||||
.b-empty { font-size: 12px; color: var(--ys-ink-3); margin-top: 6px; }
|
/* 空态:在卡片剩余高度内居中 —— 全挤在顶部会像「没加载完」 */
|
||||||
|
.b-empty {
|
||||||
|
display: flex; flex: 1; align-items: center; justify-content: center;
|
||||||
|
font-size: 12px; color: var(--ys-ink-3); margin-top: 6px; text-align: center;
|
||||||
|
}
|
||||||
/* 最近动态:时间 / 模块·动作 / 摘要 / 操作人 —— 四段各自成块,不并排挤在一行 */
|
/* 最近动态:时间 / 模块·动作 / 摘要 / 操作人 —— 四段各自成块,不并排挤在一行 */
|
||||||
.b-act {
|
.b-act {
|
||||||
display: flex; align-items: center; gap: 10px;
|
display: flex; align-items: center; gap: 10px;
|
||||||
@@ -579,6 +594,83 @@
|
|||||||
.states { display: flex; gap: 14px; margin-top: 12px; }
|
.states { display: flex; gap: 14px; margin-top: 12px; }
|
||||||
.state { text-align: center; }
|
.state { text-align: center; }
|
||||||
.state .cap { font-size: 11px; color: var(--ys-ink-3); margin-top: 6px; }
|
.state .cap { font-size: 11px; color: var(--ys-ink-3); margin-top: 6px; }
|
||||||
|
|
||||||
|
/* ---------- 表单页(公司信息):§10 ---------- */
|
||||||
|
.set-layout { display: grid; grid-template-columns: minmax(0, 1fr) 480px; gap: 14px; align-items: start; }
|
||||||
|
.set-col { display: flex; flex-direction: column; gap: 14px; min-width: 0; }
|
||||||
|
.set-card-head {
|
||||||
|
display: flex; align-items: center; justify-content: space-between; gap: 12px;
|
||||||
|
padding: 13px 16px; border-bottom: 1px solid var(--ys-line-soft);
|
||||||
|
}
|
||||||
|
.set-card-title { font-size: 15px; font-weight: 600; color: var(--ys-ink); }
|
||||||
|
.set-card-note { font-size: 12px; color: var(--ys-ink-3); }
|
||||||
|
.set-row { display: grid; grid-template-columns: 92px minmax(0, 420px) minmax(0, 1fr); column-gap: 20px; align-items: center; }
|
||||||
|
.set-row + .set-row { margin-top: 18px; padding-top: 18px; border-top: 1px solid var(--ys-line-soft); }
|
||||||
|
.set-label { font-size: 13px; color: var(--ys-ink-2); text-align: right; }
|
||||||
|
.set-label .req { color: var(--ys-danger); margin-right: 4px; }
|
||||||
|
.set-note { font-size: 12px; color: var(--ys-ink-3); line-height: 1.7; text-align: right; }
|
||||||
|
.logo-box {
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
width: 96px; height: 96px;
|
||||||
|
background: linear-gradient(180deg, #f7f8fb, #f1f3f8);
|
||||||
|
border: 1px dashed rgba(28, 35, 51, 0.22); border-radius: 10px;
|
||||||
|
}
|
||||||
|
.logo-box .plus { font-size: 26px; font-weight: 300; line-height: 1; color: var(--ys-ink-3); }
|
||||||
|
.scope-list { display: grid; grid-template-columns: 92px minmax(0, 1fr); column-gap: 20px; }
|
||||||
|
.scope-list + .scope-list { margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--ys-line-soft); }
|
||||||
|
.scope-list dt { font-size: 13px; font-weight: 500; color: var(--ys-ink-2); }
|
||||||
|
.scope-list dd { margin: 0; font-size: 12.5px; color: var(--ys-ink-3); }
|
||||||
|
/* 实景预览:登录页 1:1 复刻(控件高度 / 字号 / logo 展示位与真实登录页一致) */
|
||||||
|
.login-stage {
|
||||||
|
position: relative; display: flex; flex-direction: column; align-items: center;
|
||||||
|
padding: 24px 0 20px; overflow: hidden;
|
||||||
|
background-color: #f2f4f8;
|
||||||
|
background-image: radial-gradient(rgba(28, 35, 51, 0.07) 1px, transparent 1px);
|
||||||
|
background-size: 24px 24px;
|
||||||
|
border-radius: var(--ys-radius-sm);
|
||||||
|
}
|
||||||
|
.login-stage::before {
|
||||||
|
content: ''; position: absolute; top: -180px; left: 50%;
|
||||||
|
width: 860px; height: 460px; transform: translateX(-50%); pointer-events: none;
|
||||||
|
background: radial-gradient(closest-side, rgba(46, 75, 168, 0.08), rgba(46, 75, 168, 0));
|
||||||
|
}
|
||||||
|
.mock-card {
|
||||||
|
position: relative; width: 400px; max-width: 100%; padding: 30px 36px 32px;
|
||||||
|
background: linear-gradient(180deg, #ffffff, #fcfcfe);
|
||||||
|
border: 1px solid rgba(28, 35, 51, 0.09); border-radius: 14px;
|
||||||
|
box-shadow: 0 2px 4px rgba(28, 35, 51, 0.04), 0 16px 40px rgba(28, 35, 51, 0.1);
|
||||||
|
}
|
||||||
|
.mc-brand { text-align: center; }
|
||||||
|
.mc-logo { display: block; max-width: 56px; max-height: 36px; margin: 0 auto 10px; object-fit: contain; }
|
||||||
|
.mc-name { font-family: var(--ys-font-brand); font-size: 17px; font-weight: 500; letter-spacing: 0.34em; color: var(--ys-ink); }
|
||||||
|
.mc-slogan { margin: 8px 0 0; font-size: 12.5px; color: var(--ys-ink-3); }
|
||||||
|
.mc-waterline {
|
||||||
|
display: block; width: 40px; height: 2px; margin: 14px auto 0; border-radius: 1px;
|
||||||
|
background: linear-gradient(90deg, var(--ys-indigo), rgba(46, 75, 168, 0));
|
||||||
|
}
|
||||||
|
.mc-head { margin-top: 26px; }
|
||||||
|
.mc-title { font-size: 15px; font-weight: 600; color: var(--ys-ink); }
|
||||||
|
.mc-hint { margin-top: 4px; font-size: 12px; color: var(--ys-ink-3); }
|
||||||
|
.mc-field { margin-top: 16px; }
|
||||||
|
.mc-field + .mc-field { margin-top: 18px; }
|
||||||
|
.mc-label { display: block; margin-bottom: 6px; font-size: 13px; line-height: 22px; color: var(--ys-ink-2); }
|
||||||
|
.mc-box { display: block; height: 38px; background: #f4f5f8; border: 1px solid rgba(28, 35, 51, 0.07); border-radius: 7px; }
|
||||||
|
.mc-captcha-row { display: flex; gap: 10px; }
|
||||||
|
.mc-captcha-row .mc-box { flex: 1; }
|
||||||
|
.mc-captcha {
|
||||||
|
flex: 0 0 104px; height: 38px; border-radius: 7px;
|
||||||
|
background-color: #f1f3f8;
|
||||||
|
background-image: repeating-linear-gradient(45deg, rgba(28, 35, 51, 0.07) 0 1px, transparent 1px 7px);
|
||||||
|
border: 1px solid var(--ys-line);
|
||||||
|
}
|
||||||
|
.mc-submit {
|
||||||
|
height: 38px; margin-top: 20px; font-size: 13px; line-height: 38px;
|
||||||
|
letter-spacing: 0.4em; text-align: center; color: #fff;
|
||||||
|
background: linear-gradient(180deg, var(--ys-indigo-hi), var(--ys-indigo));
|
||||||
|
border-radius: 7px;
|
||||||
|
}
|
||||||
|
.mc-foot { margin-top: 16px; font-size: 11px; letter-spacing: 0.08em; color: var(--ys-ink-3); }
|
||||||
|
.preview-cap { margin: 12px 0 0; font-size: 11px; text-align: center; color: var(--ys-ink-3); }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -590,9 +682,12 @@
|
|||||||
<h1>营销人员工资管理系统 · 视觉方向</h1>
|
<h1>营销人员工资管理系统 · 视觉方向</h1>
|
||||||
<div class="sub">企业级扁平 · 轻质感 · Bento 布局 · 品牌色点缀 · 微动效 · 精致文案</div>
|
<div class="sub">企业级扁平 · 轻质感 · Bento 布局 · 品牌色点缀 · 微动效 · 精致文案</div>
|
||||||
<div class="sub" style="margin-top:6px">
|
<div class="sub" style="margin-top:6px">
|
||||||
<b style="color:var(--ys-ink);font-weight:500">v4(2026-10-22 同步版)</b>
|
<b style="color:var(--ys-ink);font-weight:500">v4.1(2026-10-23 补订)</b>
|
||||||
—— 本稿与已交付功能对齐:菜单按真实路由分组、工作台按真实四区块重画,并补齐月结 / 审批 / 报表 / 打印稿。
|
—— 本稿与已交付功能对齐:菜单按真实路由分组、工作台按真实四区块重画,并补齐月结 / 审批 / 报表 / 打印稿。
|
||||||
视觉语言沿用 v3 质感版,未改色板、未改字体、未改组件基调。
|
视觉语言沿用 v3 质感版,未改色板、未改字体、未改组件基调。
|
||||||
|
<br />
|
||||||
|
v4.1 补订三处实测偏差与一张表单页:表格行高 1.5(§03)、月结生命周期条按阶段着色 + 操作提示(§09)、
|
||||||
|
工作台空态在卡内居中(§05)、公司信息页版式(§10 新增)。
|
||||||
</div>
|
</div>
|
||||||
<div class="waterline"></div>
|
<div class="waterline"></div>
|
||||||
<div class="tags">
|
<div class="tags">
|
||||||
@@ -605,6 +700,8 @@
|
|||||||
<span class="tag">120–180ms 轻动效</span>
|
<span class="tag">120–180ms 轻动效</span>
|
||||||
<span class="tag">21 项菜单 · 5 组</span>
|
<span class="tag">21 项菜单 · 5 组</span>
|
||||||
<span class="tag">工作台四区块</span>
|
<span class="tag">工作台四区块</span>
|
||||||
|
<span class="tag">表格行高 1.5</span>
|
||||||
|
<span class="tag">表单页两列</span>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -759,6 +856,30 @@
|
|||||||
动效的目标是「顺滑可信」,不是表演。
|
动效的目标是「顺滑可信」,不是表演。
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="comp-card">
|
||||||
|
<h3>表格 · 行高</h3>
|
||||||
|
<table class="grid" style="margin-bottom:12px">
|
||||||
|
<thead>
|
||||||
|
<tr><th>结算月份</th><th>状态</th><th style="text-align:right">应付合计</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>2026-10</td>
|
||||||
|
<td><span class="pill pill-wait">待审批</span></td>
|
||||||
|
<td class="cell-num">241,876.40</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>2026-09</td>
|
||||||
|
<td><span class="pill pill-lock">已审定(锁定)</span></td>
|
||||||
|
<td class="cell-num">226,338.10</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div class="note">
|
||||||
|
单元格<b>行高 1.5</b>(13px 字 + 12px 上下内距 → 单行数据行 44px)。
|
||||||
|
组件库默认的 23px 行高(≈1.77 倍)会把单行撑到 51px,一屏少看两行。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -924,7 +1045,9 @@
|
|||||||
<tr><td>不进菜单</td><td>报表打印视图(独立顶层路由)</td><td class="num">浏览器打印只出报表本身</td></tr>
|
<tr><td>不进菜单</td><td>报表打印视图(独立顶层路由)</td><td class="num">浏览器打印只出报表本身</td></tr>
|
||||||
</table>
|
</table>
|
||||||
<div class="note">
|
<div class="note">
|
||||||
侧栏是<b>单列树</b>:组标题可折叠,当前页所在组默认展开,当前项白底浮起 + 左侧 2px 靛蓝竖条。
|
侧栏是<b>单列树</b>:组标题可折叠,当前页所在组默认展开,当前项白底浮起 + 靛蓝字(不再叠左侧竖条)。
|
||||||
|
<b>层级口径(v4.2)</b>:父子<b>同字号 13px</b>,组标题不靠字号/字重压过子项——
|
||||||
|
结构由「折叠箭头 + 组间留白 + 子项缩进 12px」表达;此前的 11px 目录行版与 14px/600 竖标版都不采用。
|
||||||
菜单图标(16px、静止 0.7 / 激活 1、组标题不加图标)已于 2026-10-22 落地:
|
菜单图标(16px、静止 0.7 / 激活 1、组标题不加图标)已于 2026-10-22 落地:
|
||||||
路由 <code>meta.icon</code> 只写语义 key,图标注册在 <code>menuIcons.ts</code>。
|
路由 <code>meta.icon</code> 只写语义 key,图标注册在 <code>menuIcons.ts</code>。
|
||||||
本稿画的是手绘线性稿,实际取自 Element Plus 图标库的线性变体——<b>形状以图标库为准</b>,
|
本稿画的是手绘线性稿,实际取自 Element Plus 图标库的线性变体——<b>形状以图标库为准</b>,
|
||||||
@@ -1063,9 +1186,14 @@
|
|||||||
<div class="b-eyebrow">最近动态
|
<div class="b-eyebrow">最近动态
|
||||||
<span style="margin-left:auto;font-size:11px;color:var(--ys-ink-3);border:1px solid var(--ys-line);border-radius:var(--ys-radius-sm);padding:1px 8px;background:#fff">全部模块 ▾</span>
|
<span style="margin-left:auto;font-size:11px;color:var(--ys-ink-3);border:1px solid var(--ys-line);border-radius:var(--ys-radius-sm);padding:1px 8px;background:#fff">全部模块 ▾</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="todo-list" style="max-height:40px;margin-top:4px">
|
<div class="todo-list" style="margin-top:4px">
|
||||||
<div class="b-act"><span class="tm">10-22 09:12</span><span class="b-tag b-tag-info">核算 · 月结</span><span class="sum">生成 2026-10 月结草稿,绩效合计 268,430.16 元</span><span class="us">张岚</span></div>
|
<div class="b-act"><span class="tm">10-22 09:12</span><span class="b-tag b-tag-info">核算 · 月结</span><span class="sum">生成 2026-10 月结草稿,绩效合计 268,430.16 元</span><span class="us">张岚</span></div>
|
||||||
<div class="b-act"><span class="tm">10-21 16:40</span><span class="b-tag b-tag-info">回款 · 新增</span><span class="sum">录入回款 86,400.00 元(业务 BS20261018)</span><span class="us">王浩</span></div>
|
<div class="b-act"><span class="tm">10-21 16:40</span><span class="b-tag b-tag-info">回款 · 新增</span><span class="sum">录入回款 86,400.00 元(业务 BS20261018)</span><span class="us">王浩</span></div>
|
||||||
|
<div class="b-act"><span class="tm">10-21 14:32</span><span class="b-tag b-tag-info">业务 · 新增</span><span class="sum">创建业务 YW20261022-0001(客户ID=166,明细 1 行)</span><span class="us">王浩</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="pager" style="margin-top:10px;justify-content:flex-end">
|
||||||
|
<span class="pg">‹</span><span class="pg cur">1</span><span class="pg">2</span><span class="pg">›</span>
|
||||||
|
<span style="margin-left:10px">共 128 条 · 每页 10 条</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1089,6 +1217,9 @@
|
|||||||
待办与预警并排 2×2 → 最近动态与常用操作整行铺满(<code>b-full</code>)。
|
待办与预警并排 2×2 → 最近动态与常用操作整行铺满(<code>b-full</code>)。
|
||||||
卡片入场 <b>45ms 交错上浮(320ms)</b>,悬停抬升 2px、影加深。
|
卡片入场 <b>45ms 交错上浮(320ms)</b>,悬停抬升 2px、影加深。
|
||||||
待办与预警的列表<b>在卡内滚动</b>(上限 178px)——条目再多也不撑开网格,行高恒定。
|
待办与预警的列表<b>在卡内滚动</b>(上限 178px)——条目再多也不撑开网格,行高恒定。
|
||||||
|
<b>最近动态走服务端分页</b>(2026-10-23):列表不再靠卡内滚动藏条目,每页 10/20/50 条,
|
||||||
|
底部接右对齐分页条(与列表页同一套 <code>AppPagination</code>);模块筛选同步到服务端。
|
||||||
|
空态(<code>b-empty</code>)在卡片剩余高度内<b>垂直居中</b>,不全部挤在顶部留一整块空白。
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -1272,6 +1403,9 @@
|
|||||||
月结是<b>锁点</b>:审定后绩效与应付合计不可改,只能靠调整单纠偏,状态条与列表同步体现这件事。
|
月结是<b>锁点</b>:审定后绩效与应付合计不可改,只能靠调整单纠偏,状态条与列表同步体现这件事。
|
||||||
状态标签分四档——草稿(中性灰)、待审批(琥珀)、已审定(靛蓝)、已驳回(砖红),
|
状态标签分四档——草稿(中性灰)、待审批(琥珀)、已审定(靛蓝)、已驳回(砖红),
|
||||||
颜色之外还有文字,不靠颜色单独表意。
|
颜色之外还有文字,不靠颜色单独表意。
|
||||||
|
生命周期条上<b>四段圆点按阶段着色</b>(草稿中性 / 待审批琥珀 / 已审定靛蓝 / 已驳回砖红),
|
||||||
|
与选中与否无关;条右侧挂一行操作提示「点状态只看该阶段,再点一次取消」——
|
||||||
|
没有这行提示时,未选中态的四段读起来像四颗孤立的小按钮,不像一条流程。
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 09-2 审批管理 -->
|
<!-- 09-2 审批管理 -->
|
||||||
@@ -1500,6 +1634,129 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- ============ 表单页 ============ -->
|
||||||
|
<section class="section" id="sec-form">
|
||||||
|
<h2>10 · 表单页(公司信息)</h2>
|
||||||
|
<div class="note" style="margin-top:-6px;margin-bottom:18px">
|
||||||
|
设置类表单页最容易犯的错是<b>把表单缩在左上角一小块</b>:内容区再宽也只占三分之一,
|
||||||
|
右侧留下大片空白,页面像没做完。本节定下表单页的版式口径,落地页见
|
||||||
|
<code>frontend/src/views/system/settings/index.vue</code>。
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="app-shot">
|
||||||
|
<div class="canvas">
|
||||||
|
<div class="topbar">
|
||||||
|
<div class="crumb">系统管理 <span>/</span> <b>公司信息</b></div>
|
||||||
|
<div class="who"><span class="avatar">管</span><span>管理员</span><span class="logout">退出登录</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<div class="page-head">
|
||||||
|
<div>
|
||||||
|
<h1>公司信息</h1>
|
||||||
|
<div class="desc">维护公司名称与 logo;保存后立即作用于登录页品牌位与导出页眉</div>
|
||||||
|
<div class="waterline"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="set-layout">
|
||||||
|
<!-- 左列:基础信息 + 生效范围 -->
|
||||||
|
<div class="set-col">
|
||||||
|
<div class="card">
|
||||||
|
<div class="set-card-head"><span class="set-card-title">基础信息</span></div>
|
||||||
|
<div style="padding:16px">
|
||||||
|
<div class="set-row">
|
||||||
|
<div class="set-label"><span class="req">*</span>公司名称</div>
|
||||||
|
<input class="input" style="width:100%" value="衍水集团" />
|
||||||
|
<div class="set-note">最多 64 个字符;保存后对所有在线用户立即生效。</div>
|
||||||
|
</div>
|
||||||
|
<div class="set-row">
|
||||||
|
<div class="set-label">logo</div>
|
||||||
|
<div class="logo-box"><span class="plus">+</span></div>
|
||||||
|
<div class="set-note">png / jpg,不超过 2MB;仅展示于登录页品牌位,按 ≤56×36 显示。</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-foot"><button class="btn btn-primary">保存</button></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="set-card-head">
|
||||||
|
<span class="set-card-title">生效范围</span>
|
||||||
|
<span class="set-card-note">保存后对所有人生效</span>
|
||||||
|
</div>
|
||||||
|
<div style="padding:16px">
|
||||||
|
<dl class="scope-list">
|
||||||
|
<dt>公司名称</dt>
|
||||||
|
<dd>登录页品牌位与版权行 · Excel 导出页眉 · 打印稿页眉</dd>
|
||||||
|
</dl>
|
||||||
|
<dl class="scope-list">
|
||||||
|
<dt>logo</dt>
|
||||||
|
<dd>登录页品牌位(≤56×36)</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 右列:登录页实景预览(1:1 等比,非缩比示意) -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="set-card-head">
|
||||||
|
<span class="set-card-title">登录页预览</span>
|
||||||
|
<span class="set-card-note">1:1 等比</span>
|
||||||
|
</div>
|
||||||
|
<div style="padding:16px">
|
||||||
|
<div class="login-stage">
|
||||||
|
<div class="mock-card">
|
||||||
|
<div class="mc-brand">
|
||||||
|
<div class="mc-name">衍水集团</div>
|
||||||
|
<p class="mc-slogan">把每一笔回款,算成一份踏实。</p>
|
||||||
|
<span class="mc-waterline"></span>
|
||||||
|
</div>
|
||||||
|
<div class="mc-head">
|
||||||
|
<div class="mc-title">欢迎回来</div>
|
||||||
|
<div class="mc-hint">请输入账号密码进入系统</div>
|
||||||
|
</div>
|
||||||
|
<div class="mc-field">
|
||||||
|
<span class="mc-label">用户名</span>
|
||||||
|
<span class="mc-box"></span>
|
||||||
|
</div>
|
||||||
|
<div class="mc-field">
|
||||||
|
<span class="mc-label">密码</span>
|
||||||
|
<span class="mc-box"></span>
|
||||||
|
</div>
|
||||||
|
<div class="mc-field">
|
||||||
|
<span class="mc-label">验证码</span>
|
||||||
|
<span class="mc-captcha-row">
|
||||||
|
<span class="mc-box"></span>
|
||||||
|
<span class="mc-captcha"></span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="mc-submit">登 录</div>
|
||||||
|
</div>
|
||||||
|
<div class="mc-foot">© 衍水集团 · 内部系统</div>
|
||||||
|
</div>
|
||||||
|
<p class="preview-cap">登录页品牌位实景(示意,非交互)——显示的是当前编辑值</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="note">
|
||||||
|
<b>两列</b>:左列 <code>minmax(0, 1fr)</code> 放表单与「生效范围」,右列固定 <b>480px</b> 放实景预览;
|
||||||
|
≤1200px 折成单列。表单不再缩在左上角,内容区左右都能被用上。
|
||||||
|
<br />
|
||||||
|
<b>控件与说明同行</b>:主控件 ≤420px,说明跟在后面 20px;≥1600px 时说明改为靠卡片右缘右对齐,
|
||||||
|
与左侧控件形成两端锚点,整行铺满。放不下时说明整体换行到控件下方。
|
||||||
|
<br />
|
||||||
|
<b>实景预览必须是 1:1</b>:登录卡按真实登录页复刻(控件 38px 高、品牌名 17px / 0.34em 字距、
|
||||||
|
logo ≤56×36、三项输入含验证码位、登录按钮、版权行)。控件高度对不上就不叫「所见即所得」,
|
||||||
|
只是缩比示意——缩比版看不出 logo 会不会糊、标语会不会挤。
|
||||||
|
<br />
|
||||||
|
<b>「生效范围」独立成卡</b>:写清这份设置会出现在哪(登录页品牌位与版权行 / Excel 导出页眉 /
|
||||||
|
打印稿页眉),用户存完不用再去别处找。
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<script src="echarts.min.js"></script>
|
<script src="echarts.min.js"></script>
|
||||||
<script>
|
<script>
|
||||||
|
|||||||
+12
-3
@@ -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 默认微软雅黑(字体商用合规要求)。
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ export interface CustomerItem {
|
|||||||
owner_personnel_id: number | null
|
owner_personnel_id: number | null
|
||||||
owner_personnel_name: string | null
|
owner_personnel_name: string | null
|
||||||
remark: string | null
|
remark: string | null
|
||||||
|
/** 评级总分(0-100,评级计算器保存;null=未评分) */
|
||||||
|
rating_score: number | null
|
||||||
|
/** 最近评级时间(评级计算器保存评分时刷新;null=从未评级) */
|
||||||
|
rating_time: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CustomerQueryParams {
|
export interface CustomerQueryParams {
|
||||||
@@ -30,6 +34,10 @@ export interface CustomerQueryParams {
|
|||||||
keyword?: string
|
keyword?: string
|
||||||
customer_type?: string
|
customer_type?: string
|
||||||
owner_personnel_id?: number
|
owner_personnel_id?: number
|
||||||
|
/** 排序键(后端白名单:id 建档序 / rating_score 评分),默认 id 倒序 */
|
||||||
|
order_by?: 'id' | 'rating_score'
|
||||||
|
/** 排序方向,默认 desc */
|
||||||
|
order?: 'asc' | 'desc'
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 新增客户(owner_personnel_id 为初始归属,可空:先建档后派归属也合法) */
|
/** 新增客户(owner_personnel_id 为初始归属,可空:先建档后派归属也合法) */
|
||||||
@@ -57,6 +65,17 @@ export interface CustomerOwnershipParams {
|
|||||||
reason?: string | null
|
reason?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存评级入参:只传总分。
|
||||||
|
*
|
||||||
|
* 等级不落库,也不由客户端传入 —— 后端只存总分与评级时间,等级由分数按
|
||||||
|
* 当前等级映射实时推导(前端 utils/rating.ts)。不收客户端传来的等级,
|
||||||
|
* 否则改请求体就能把 30 分存成 A 级。
|
||||||
|
*/
|
||||||
|
export interface CustomerRatingParams {
|
||||||
|
score: number
|
||||||
|
}
|
||||||
|
|
||||||
/** 归属变更留痕条目(对应后端 `OwnershipChangeLogOut`;姓名可随人员消失,ID 固化) */
|
/** 归属变更留痕条目(对应后端 `OwnershipChangeLogOut`;姓名可随人员消失,ID 固化) */
|
||||||
export interface OwnershipLogItem {
|
export interface OwnershipLogItem {
|
||||||
id: number
|
id: number
|
||||||
@@ -114,6 +133,14 @@ export function changeOwnership(
|
|||||||
return request({ url: `/v1/base/customers/${customerId}/ownership`, method: 'put', data })
|
return request({ url: `/v1/base/customers/${customerId}/ownership`, method: 'put', data })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 保存评级(评级计算器「保存评分」;后端存总分+评级时间,评分只走本端点) */
|
||||||
|
export function rateCustomer(
|
||||||
|
customerId: number,
|
||||||
|
data: CustomerRatingParams,
|
||||||
|
): Promise<CustomerItem> {
|
||||||
|
return request({ url: `/v1/base/customers/${customerId}/rating`, method: 'put', data })
|
||||||
|
}
|
||||||
|
|
||||||
/** 查某客户的归属变更留痕(分页,按变更时间倒序) */
|
/** 查某客户的归属变更留痕(分页,按变更时间倒序) */
|
||||||
export function listOwnershipLogs(
|
export function listOwnershipLogs(
|
||||||
customerId: number,
|
customerId: number,
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
}
|
||||||
@@ -10,7 +10,12 @@ const { topLevelMenus, groupedMenus } = useAppMenu()
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<el-menu class="app-menu" :default-active="route.path" router>
|
<el-menu class="app-menu" :default-active="route.path" router>
|
||||||
<el-menu-item v-for="item in topLevelMenus" :key="item.path" :index="item.path">
|
<el-menu-item
|
||||||
|
v-for="item in topLevelMenus"
|
||||||
|
:key="item.path"
|
||||||
|
class="app-menu__top"
|
||||||
|
:index="item.path"
|
||||||
|
>
|
||||||
<el-icon v-if="item.icon" class="app-menu__icon" aria-hidden="true">
|
<el-icon v-if="item.icon" class="app-menu__icon" aria-hidden="true">
|
||||||
<component :is="item.icon" />
|
<component :is="item.icon" />
|
||||||
</el-icon>
|
</el-icon>
|
||||||
@@ -32,69 +37,67 @@ const { topLevelMenus, groupedMenus } = useAppMenu()
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/*
|
/*
|
||||||
* 中性灰侧栏上的菜单:覆盖 Element Plus 的主题变量而非直接写颜色 ——
|
* 侧栏菜单:中性灰底 + 靛蓝点缀。
|
||||||
* 后者与组件库规则同权重,受样式注入顺序影响而不稳定。
|
* 覆盖 Element Plus 的主题变量而非直接写颜色 —— 后者与组件库规则同权重,
|
||||||
* 激活项 = 白底浮起(微渐变 + 内高光 + 分层阴影)+ 左侧 2px 靛蓝条。
|
* 受样式注入顺序影响而不稳定。
|
||||||
|
*
|
||||||
|
* 层级口径:父子**同字号(13px)**,层级不靠字号/字重刻意压过 ——
|
||||||
|
* 组标题与条目一样大,结构由「组标题带折叠箭头 + 组间留白 + 子项缩进一档」承担。
|
||||||
|
* 前两版各走偏一次:先把组标题压成 11px 弱墨「目录行」,父级弱于子项;
|
||||||
|
* 后改成 14px/600 + 靛蓝竖标,父级刻意压过子项、像一排小标题在喊叫。
|
||||||
|
* 层级是位置关系,不是字号关系 —— 两版都不要。
|
||||||
|
*
|
||||||
|
* 激活态:白底浮起(微渐变 + 内高光 + 分层阴影)+ 靛蓝字,不再叠左侧竖条 ——
|
||||||
|
* 竖条只能落在侧栏内边缘(离白底悬空),是与白底重复的第二个信号。
|
||||||
*/
|
*/
|
||||||
.app-menu {
|
.app-menu {
|
||||||
--el-menu-bg-color: transparent;
|
--el-menu-bg-color: transparent;
|
||||||
--el-menu-text-color: var(--ys-ink-2);
|
--el-menu-text-color: var(--ys-ink-2);
|
||||||
--el-menu-active-color: var(--ys-indigo);
|
--el-menu-active-color: var(--ys-indigo);
|
||||||
--el-menu-hover-bg-color: rgba(255, 255, 255, 0.55);
|
--el-menu-hover-bg-color: rgba(255, 255, 255, 0.55);
|
||||||
--el-menu-item-height: 36px;
|
--el-menu-item-height: 34px;
|
||||||
|
|
||||||
padding: 0 10px;
|
padding: 2px 10px 12px;
|
||||||
border-right: none;
|
border-right: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 菜单项:内距 9px 10px、圆角 7px、13px 字 —— 组内层级靠缩进区分,不加分隔线 */
|
/* 组与组之间留白:分组交给间距,不加分割线 */
|
||||||
.app-menu :deep(.el-menu-item) {
|
.app-menu :deep(.el-sub-menu) {
|
||||||
height: 36px;
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 顶层叶子项(首页):与组标题同级 —— 13px / 500 / 主墨。
|
||||||
|
* 用 scoped 模板 class 定位(权重高于组件库默认样式),
|
||||||
|
* 不与子项共用 :deep 规则,避免字号被子项规则带走。
|
||||||
|
*/
|
||||||
|
.app-menu__top {
|
||||||
|
height: 34px;
|
||||||
padding: 0 10px;
|
padding: 0 10px;
|
||||||
margin-bottom: 2px;
|
margin-bottom: 2px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
line-height: 36px;
|
font-weight: 500;
|
||||||
|
line-height: 34px;
|
||||||
|
color: var(--ys-ink);
|
||||||
border-radius: var(--ys-radius-sm);
|
border-radius: var(--ys-radius-sm);
|
||||||
transition:
|
transition:
|
||||||
background-color 150ms var(--ys-ease),
|
background-color 150ms var(--ys-ease),
|
||||||
color 150ms var(--ys-ease);
|
color 150ms var(--ys-ease);
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-menu :deep(.el-menu-item:hover) {
|
.app-menu__top:hover {
|
||||||
color: var(--ys-ink);
|
color: var(--ys-ink);
|
||||||
|
background-color: rgba(255, 255, 255, 0.55);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 子项向右缩进一档,与顶层项形成「树」的暗示而非并列 */
|
/* 组标题:与条目同字号,靠折叠箭头表明「可展开」,不靠压重 */
|
||||||
.app-menu :deep(.el-sub-menu .el-menu-item) {
|
|
||||||
padding-left: 22px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-menu :deep(.el-menu-item.is-active) {
|
|
||||||
position: relative;
|
|
||||||
font-weight: 500;
|
|
||||||
background: linear-gradient(180deg, var(--ys-paper), #fbfcfe);
|
|
||||||
box-shadow:
|
|
||||||
inset 0 1px 0 #fff,
|
|
||||||
0 1px 2px rgba(28, 35, 51, 0.06),
|
|
||||||
0 4px 10px rgba(28, 35, 51, 0.06);
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-menu :deep(.el-menu-item.is-active)::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: 9px;
|
|
||||||
bottom: 9px;
|
|
||||||
left: -10px;
|
|
||||||
width: 2px;
|
|
||||||
border-radius: 1px;
|
|
||||||
background: var(--ys-indigo);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 组标题:11px 大字距的「目录行」,不抢子项;展开时转靛蓝并压一层白底 */
|
|
||||||
.app-menu :deep(.el-sub-menu__title) {
|
.app-menu :deep(.el-sub-menu__title) {
|
||||||
height: 32px;
|
height: 34px;
|
||||||
padding: 0 10px;
|
padding: 0 10px;
|
||||||
line-height: 32px;
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 34px;
|
||||||
|
color: var(--ys-ink);
|
||||||
border-radius: var(--ys-radius-sm);
|
border-radius: var(--ys-radius-sm);
|
||||||
transition:
|
transition:
|
||||||
background-color 150ms var(--ys-ease),
|
background-color 150ms var(--ys-ease),
|
||||||
@@ -102,23 +105,47 @@ const { topLevelMenus, groupedMenus } = useAppMenu()
|
|||||||
}
|
}
|
||||||
|
|
||||||
.app-menu__group {
|
.app-menu__group {
|
||||||
font-size: 11px;
|
font-size: 13px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
letter-spacing: 0.1em;
|
color: inherit;
|
||||||
color: var(--ys-ink-3);
|
}
|
||||||
transition: color 150ms var(--ys-ease);
|
|
||||||
|
/*
|
||||||
|
* 子项:13px 次墨 + 缩进一档(12px)。
|
||||||
|
* Element Plus 的 `.el-menu--inline .el-menu-item` 带 !important 的 40px 缩进,
|
||||||
|
* 必须同样用 !important 才能压住,否则缩进档位不受控。
|
||||||
|
*/
|
||||||
|
.app-menu :deep(.el-sub-menu .el-menu-item) {
|
||||||
|
height: 32px;
|
||||||
|
padding-right: 10px !important;
|
||||||
|
padding-left: 22px !important;
|
||||||
|
margin-bottom: 1px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 32px;
|
||||||
|
color: var(--ys-ink-2);
|
||||||
|
border-radius: var(--ys-radius-sm);
|
||||||
|
transition:
|
||||||
|
background-color 150ms var(--ys-ease),
|
||||||
|
color 150ms var(--ys-ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-menu :deep(.el-sub-menu .el-menu-item:hover) {
|
||||||
|
color: var(--ys-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 激活项:白底浮起 + 靛蓝字(阴影分层与设计稿卡片同口径) */
|
||||||
|
.app-menu :deep(.el-menu-item.is-active) {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--ys-indigo);
|
||||||
|
background: linear-gradient(180deg, var(--ys-paper), #fbfcfe);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 1px 0 #fff,
|
||||||
|
0 1px 2px rgba(28, 35, 51, 0.06),
|
||||||
|
0 4px 10px rgba(28, 35, 51, 0.06);
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-menu :deep(.el-sub-menu__title:hover) {
|
.app-menu :deep(.el-sub-menu__title:hover) {
|
||||||
background-color: rgba(255, 255, 255, 0.55);
|
background-color: rgba(255, 255, 255, 0.5);
|
||||||
}
|
|
||||||
|
|
||||||
.app-menu :deep(.el-sub-menu.is-opened > .el-sub-menu__title) {
|
|
||||||
background-color: rgba(255, 255, 255, 0.45);
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-menu :deep(.el-sub-menu.is-opened > .el-sub-menu__title) .app-menu__group {
|
|
||||||
color: var(--ys-indigo);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -136,21 +163,21 @@ const { topLevelMenus, groupedMenus } = useAppMenu()
|
|||||||
}
|
}
|
||||||
|
|
||||||
.app-menu :deep(.el-sub-menu.is-opened > .el-sub-menu__title .el-sub-menu__icon-arrow) {
|
.app-menu :deep(.el-sub-menu.is-opened > .el-sub-menu__title .el-sub-menu__icon-arrow) {
|
||||||
color: var(--ys-indigo);
|
color: var(--ys-ink-2);
|
||||||
transform: rotate(0deg);
|
transform: rotate(0deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* 菜单图标:设计稿口径为 16px、透明度 0.7(激活项 1)——Element Plus 默认
|
* 菜单图标:设计稿口径为 16px、透明度 0.7(激活项 1)——Element Plus 默认
|
||||||
* 给菜单图标 24px 宽 + 5px 间距,直接沿用会让图标比文字更抢眼、字距发散,
|
* 给菜单图标 24px 宽 + 5px 间距,直接沿用会让图标比文字更抢眼、字距发散,
|
||||||
* 故在此显式覆盖。
|
* 故在此显式覆盖。父子同级,图标尺寸统一,不再按层级分档。
|
||||||
*/
|
*/
|
||||||
.app-menu__icon {
|
.app-menu__icon {
|
||||||
width: 16px;
|
width: 16px;
|
||||||
height: 16px;
|
height: 16px;
|
||||||
margin-right: 9px;
|
margin-right: 9px;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
opacity: 0.7;
|
opacity: 0.72;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-menu :deep(.el-menu-item.is-active) .app-menu__icon {
|
.app-menu :deep(.el-menu-item.is-active) .app-menu__icon {
|
||||||
@@ -158,10 +185,10 @@ const { topLevelMenus, groupedMenus } = useAppMenu()
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.app-menu :deep(.el-menu-item),
|
.app-menu__top,
|
||||||
|
.app-menu :deep(.el-sub-menu .el-menu-item),
|
||||||
.app-menu :deep(.el-sub-menu__title),
|
.app-menu :deep(.el-sub-menu__title),
|
||||||
.app-menu :deep(.el-sub-menu__icon-arrow),
|
.app-menu :deep(.el-sub-menu__icon-arrow) {
|
||||||
.app-menu__group {
|
|
||||||
transition: none;
|
transition: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -155,7 +155,13 @@ async function handleLogout(): Promise<void> {
|
|||||||
|
|
||||||
.app-aside__menu {
|
.app-aside__menu {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 2px 0 12px;
|
/*
|
||||||
|
* 只写上下内距:横向 10px 留白由 AppMenu 自己的 .app-menu 提供。
|
||||||
|
* 这里若用 padding 简写,会与 .app-menu 的 padding 简写同权重、
|
||||||
|
* 靠样式注入顺序决胜 —— 横向留白可能被整条抹掉,条目贴到侧栏边缘。
|
||||||
|
*/
|
||||||
|
padding-top: 2px;
|
||||||
|
padding-bottom: 12px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import {
|
|||||||
SetUp,
|
SetUp,
|
||||||
Shop,
|
Shop,
|
||||||
Stamp,
|
Stamp,
|
||||||
Star,
|
|
||||||
Tickets,
|
Tickets,
|
||||||
TrendCharts,
|
TrendCharts,
|
||||||
User,
|
User,
|
||||||
@@ -54,7 +53,6 @@ const ICONS = {
|
|||||||
businesses: Briefcase,
|
businesses: Briefcase,
|
||||||
shipments: Van,
|
shipments: Van,
|
||||||
receipts: Money,
|
receipts: Money,
|
||||||
rating: Star,
|
|
||||||
ledger: Notebook,
|
ledger: Notebook,
|
||||||
settlements: Calendar,
|
settlements: Calendar,
|
||||||
'report-personal': DataLine,
|
'report-personal': DataLine,
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 删除后刷新:当前页已被删空且不在第一页时,先回退页码再加载 */
|
/** 删除后刷新:当前页已被删空且不在第一页时,先回退页码再加载 */
|
||||||
|
|||||||
@@ -170,14 +170,7 @@ const routes: RouteRecordRaw[] = [
|
|||||||
component: () => import('@/views/business/receipts/index.vue'),
|
component: () => import('@/views/business/receipts/index.vue'),
|
||||||
meta: { title: '回款管理', icon: 'receipts', group: '业务数据', order: 15 },
|
meta: { title: '回款管理', icon: 'receipts', group: '业务数据', order: 15 },
|
||||||
},
|
},
|
||||||
{
|
// 客户评级计算器已并入客户管理(行内弹窗),独立页移除
|
||||||
// 业务数据组首个页面:评级计算器是打分工具,不影响绩效金额,
|
|
||||||
// 登录用户即可用,不设 requiresAdmin(区别于系统管理组)
|
|
||||||
path: '/business/rating',
|
|
||||||
name: 'business-rating',
|
|
||||||
component: () => import('@/views/business/rating/index.vue'),
|
|
||||||
meta: { title: '客户评级计算器', icon: 'rating', group: '业务数据', order: 16 },
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
// 绩效工资组(M3 ④):核算台账读登录即可,月结写限管理员与营销后勤
|
// 绩效工资组(M3 ④):核算台账读登录即可,月结写限管理员与营销后勤
|
||||||
// (api 层 require_roles 兜底,页面层不再叠加 requiresAdmin)
|
// (api 层 require_roles 兜底,页面层不再叠加 requiresAdmin)
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import type { RatingGradeItem } from '@/api/ratings'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 评级等级判定(纯函数,客户列表与评级弹窗共用同一口径)。
|
||||||
|
*
|
||||||
|
* 规则:按 sort_order 升序取首个「min_score ≤ 总分」的等级,max_score 仅作
|
||||||
|
* 展示 —— 单边判定避免区间边界(如 75 分)在「≥75 且 ≤89」与「>74 且 <75」
|
||||||
|
* 两套写法间的重叠/遗漏歧义。
|
||||||
|
*
|
||||||
|
* 后端 min_score 是字符串直出(分值红线:分值全程不进 float),比较前必须
|
||||||
|
* parseFloat 转数值,禁止字符串直接比大小("90.0" 的字典序比较会得出错误
|
||||||
|
* 结果)。无命中(总分低于全部下限)返回 null,由调用方展示未命中提示。
|
||||||
|
*/
|
||||||
|
export function resolveGrade(
|
||||||
|
grades: RatingGradeItem[],
|
||||||
|
totalScore: number,
|
||||||
|
): RatingGradeItem | null {
|
||||||
|
const ordered = [...grades].sort((a, b) => a.sort_order - b.sort_order)
|
||||||
|
for (const grade of ordered) {
|
||||||
|
if (totalScore >= parseFloat(grade.min_score)) {
|
||||||
|
return grade
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 等级名称 → 色调后缀(客户列表的等级徽标用)。
|
||||||
|
*
|
||||||
|
* 按名称首字母映射到体系语义色:A 优秀(绿)、B 良好(靛蓝)、C 合格
|
||||||
|
* (琥珀)、D 待改进(砖红);自定义等级名不在此列,落回中性灰 —— 等级
|
||||||
|
* 名称是业务可配置的,不能靠写死的枚举判定语义,未知即中性最安全。
|
||||||
|
*/
|
||||||
|
export function gradeTone(gradeName: string): '' | 'ok' | 'lock' | 'wait' | 'reject' {
|
||||||
|
const initial = gradeName.trim().charAt(0).toUpperCase()
|
||||||
|
switch (initial) {
|
||||||
|
case 'A':
|
||||||
|
return 'ok'
|
||||||
|
case 'B':
|
||||||
|
return 'lock'
|
||||||
|
case 'C':
|
||||||
|
return 'wait'
|
||||||
|
case 'D':
|
||||||
|
return 'reject'
|
||||||
|
default:
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,557 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, reactive, ref, watch } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
|
||||||
|
import {
|
||||||
|
createRatingGrade,
|
||||||
|
deleteRatingGrade,
|
||||||
|
listRatingGrades,
|
||||||
|
updateRatingGrade,
|
||||||
|
type RatingGradeItem,
|
||||||
|
type RatingGradeParams,
|
||||||
|
} from '@/api/ratings'
|
||||||
|
import { rateCustomer, type CustomerItem } from '@/api/customers'
|
||||||
|
import { useConfirmAction } from '@/composables/useConfirmAction'
|
||||||
|
import { formatDateTime } from '@/utils/format'
|
||||||
|
import { resolveGrade } from '@/utils/rating'
|
||||||
|
import { handledError } from '@/utils/error'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 客户评级计算器(需求 2.2.5 / 对照表 #25)· 客户管理行内弹窗版。
|
||||||
|
*
|
||||||
|
* 原独立页(/business/rating)并入客户管理:在客户行上点「评级计算」打开,
|
||||||
|
* 五项打分实时判定等级,「保存评分」把总分写入客户档案(rating_score +
|
||||||
|
* rating_time)。等级**不落库**,由分数 + 当前等级映射实时算(判定函数在
|
||||||
|
* utils/rating.ts,与列表页共用同一口径);等级映射日后调整,全部客户的
|
||||||
|
* 等级随之更新,档案只负责记住「这个分是什么时候打的」。
|
||||||
|
*
|
||||||
|
* 五项打分明细同样不落库 —— 那是打分过程的中间量,没有下游消费方。
|
||||||
|
*
|
||||||
|
* 等级映射的增删改走 /v1/business/rating-grades 维护接口,配置变更后
|
||||||
|
* 重载判定用列表 —— 判定与抽屉表格共用同一份数据。
|
||||||
|
*/
|
||||||
|
|
||||||
|
const visible = defineModel<boolean>('visible', { default: false })
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
/** 目标客户;弹窗打开期间必非空(由父页在 open 时赋值) */
|
||||||
|
customer: CustomerItem | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
/** 保存成功,带回最新 CustomerItem(父页原位更新行,不打乱当前浏览位置) */
|
||||||
|
saved: [item: CustomerItem]
|
||||||
|
/**
|
||||||
|
* 等级映射被改动(新增/编辑/删除)。
|
||||||
|
*
|
||||||
|
* 父页的等级徽标靠自己缓存的一份映射把分数翻成等级,抽屉里改完配置
|
||||||
|
* 那份缓存就旧了 —— 必须通知父页重拉,否则「同一分数在弹窗里是 B、
|
||||||
|
* 在列表里还是 C」,两处判定口径出现可见的分歧。
|
||||||
|
*/
|
||||||
|
gradesChanged: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// ==================== 评分计算(纯前端实时计算) ====================
|
||||||
|
|
||||||
|
/** 五项评分项:各 0-20 分、步进 1,总分 = 五项之和(0-100) */
|
||||||
|
const SCORE_ITEMS = [
|
||||||
|
{ key: 'ship_amount', label: '发货额' },
|
||||||
|
{ key: 'receipt_rate', label: '回款率' },
|
||||||
|
{ key: 'profit_rate', label: '毛利率' },
|
||||||
|
{ key: 'contract_cooperation', label: '合同履约配合能力' },
|
||||||
|
{ key: 'site_construction', label: '现场规范施工能力' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
type ScoreKey = (typeof SCORE_ITEMS)[number]['key']
|
||||||
|
|
||||||
|
/** el-input-number 清空后值为 undefined,字段用宽松类型,求和处 ?? 0 兜底 */
|
||||||
|
const scores = reactive<Record<ScoreKey, number | undefined>>({
|
||||||
|
ship_amount: 0,
|
||||||
|
receipt_rate: 0,
|
||||||
|
profit_rate: 0,
|
||||||
|
contract_cooperation: 0,
|
||||||
|
site_construction: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const totalScore = computed(() =>
|
||||||
|
SCORE_ITEMS.reduce((sum, item) => sum + (scores[item.key] ?? 0), 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 重置即把五项清零(不动档案里已保存的评分) */
|
||||||
|
function resetScores(): void {
|
||||||
|
for (const item of SCORE_ITEMS) {
|
||||||
|
scores[item.key] = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 等级映射(判定用列表) ====================
|
||||||
|
|
||||||
|
const { confirmAndRun } = useConfirmAction()
|
||||||
|
|
||||||
|
const grades = ref<RatingGradeItem[]>([])
|
||||||
|
const gradesLoading = ref(false)
|
||||||
|
|
||||||
|
/** 判定与抽屉表格共用同一份全量列表,配置变更后重载一次即同时生效 */
|
||||||
|
async function loadGrades(): Promise<void> {
|
||||||
|
gradesLoading.value = true
|
||||||
|
try {
|
||||||
|
grades.value = await listRatingGrades()
|
||||||
|
} catch (error) {
|
||||||
|
handledError(error)
|
||||||
|
} finally {
|
||||||
|
gradesLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 配置被改动后的统一收尾:重载本弹窗的判定列表 + 通知父页刷新徽标缓存 */
|
||||||
|
async function reloadAfterGradeChange(): Promise<void> {
|
||||||
|
await loadGrades()
|
||||||
|
emit('gradesChanged')
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchedGrade = computed(() => resolveGrade(grades.value, totalScore.value))
|
||||||
|
|
||||||
|
// ==================== 弹窗打开 / 关闭 ====================
|
||||||
|
|
||||||
|
/** 打开时重置五项并重载等级配置,避免上一次打分的残留 */
|
||||||
|
watch(visible, (isOpen) => {
|
||||||
|
if (!isOpen) return
|
||||||
|
resetScores()
|
||||||
|
void loadGrades()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ==================== 保存评分 ====================
|
||||||
|
|
||||||
|
const saving = ref(false)
|
||||||
|
|
||||||
|
/** 档案里已保存的评分(打开时的快照,仅展示用;保存成功后由父页更新行数据) */
|
||||||
|
const archivedScore = computed(() => {
|
||||||
|
const target = props.customer
|
||||||
|
if (!target || target.rating_score === null) return null
|
||||||
|
// 等级不落库,按当前映射由已存分数实时算(与列表页同一口径)
|
||||||
|
const grade = resolveGrade(grades.value, target.rating_score)
|
||||||
|
return {
|
||||||
|
score: target.rating_score,
|
||||||
|
grade: grade?.grade_name ?? null,
|
||||||
|
time: target.rating_time ? formatDateTime(target.rating_time) : '',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function handleSave(): Promise<void> {
|
||||||
|
const target = props.customer
|
||||||
|
if (!target) return
|
||||||
|
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
const item = await rateCustomer(target.id, { score: totalScore.value })
|
||||||
|
ElMessage.success('评级已保存')
|
||||||
|
emit('saved', item)
|
||||||
|
visible.value = false
|
||||||
|
} catch (error) {
|
||||||
|
handledError(error) // 失败提示由请求拦截器统一完成
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 等级映射设置抽屉 ====================
|
||||||
|
|
||||||
|
const drawerVisible = ref(false)
|
||||||
|
|
||||||
|
/** 打开时重新拉取,避免展示陈旧数据 */
|
||||||
|
function openDrawer(): void {
|
||||||
|
drawerVisible.value = true
|
||||||
|
void loadGrades()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 新增 / 编辑等级弹窗 ====================
|
||||||
|
|
||||||
|
const editVisible = ref(false)
|
||||||
|
/** null = 新增,否则为编辑目标 id */
|
||||||
|
const editingId = ref<number | null>(null)
|
||||||
|
const editSaving = ref(false)
|
||||||
|
const isCreate = computed(() => editingId.value === null)
|
||||||
|
|
||||||
|
const editForm = reactive({
|
||||||
|
grade_name: '',
|
||||||
|
min_score: undefined as number | undefined,
|
||||||
|
max_score: undefined as number | undefined,
|
||||||
|
sort_order: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
function openCreate(): void {
|
||||||
|
editingId.value = null
|
||||||
|
editForm.grade_name = ''
|
||||||
|
editForm.min_score = undefined
|
||||||
|
editForm.max_score = undefined
|
||||||
|
editForm.sort_order = 0
|
||||||
|
editVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(row: RatingGradeItem): void {
|
||||||
|
editingId.value = row.id
|
||||||
|
editForm.grade_name = row.grade_name
|
||||||
|
// 后端字符串直出,回填 parseFloat 转数值,保存时再 String() 转回字符串
|
||||||
|
editForm.min_score = parseFloat(row.min_score)
|
||||||
|
editForm.max_score = parseFloat(row.max_score)
|
||||||
|
editForm.sort_order = row.sort_order
|
||||||
|
editVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 前端值域校验(0≤min≤max≤100);后端在审计上下文内兜底同规则与区间重叠 */
|
||||||
|
function editFormError(minScore: number | undefined, maxScore: number | undefined): string | null {
|
||||||
|
if (!editForm.grade_name.trim()) return '请输入等级名称'
|
||||||
|
if (minScore === undefined || maxScore === undefined) return '请输入分值区间'
|
||||||
|
if (minScore < 0 || maxScore > 100 || minScore > maxScore) {
|
||||||
|
return '分值区间须满足 0 ≤ 下限 ≤ 上限 ≤ 100'
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveGrade(): Promise<void> {
|
||||||
|
// 局部变量收窄类型:reactive 属性在异步流程中不保证保持窄化
|
||||||
|
const minScore = editForm.min_score
|
||||||
|
const maxScore = editForm.max_score
|
||||||
|
const error = editFormError(minScore, maxScore)
|
||||||
|
if (error) {
|
||||||
|
ElMessage.warning(error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload: RatingGradeParams = {
|
||||||
|
grade_name: editForm.grade_name.trim(),
|
||||||
|
min_score: String(minScore),
|
||||||
|
max_score: String(maxScore),
|
||||||
|
sort_order: editForm.sort_order ?? 0,
|
||||||
|
}
|
||||||
|
// 后端为全量替换语义;remark 本组件不维护,编辑时原样带回避免误清
|
||||||
|
if (editingId.value !== null) {
|
||||||
|
payload.remark = grades.value.find((g) => g.id === editingId.value)?.remark ?? undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
editSaving.value = true
|
||||||
|
try {
|
||||||
|
if (editingId.value === null) {
|
||||||
|
await createRatingGrade(payload)
|
||||||
|
} else {
|
||||||
|
await updateRatingGrade(editingId.value, payload)
|
||||||
|
}
|
||||||
|
editVisible.value = false
|
||||||
|
ElMessage.success(isCreate.value ? '评级等级已创建' : '已保存')
|
||||||
|
await reloadAfterGradeChange()
|
||||||
|
} catch (requestError) {
|
||||||
|
handledError(requestError)
|
||||||
|
} finally {
|
||||||
|
editSaving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除走确认动作流;文案明确后果(该区间不再能映射等级) */
|
||||||
|
function removeGrade(row: RatingGradeItem): void {
|
||||||
|
void confirmAndRun({
|
||||||
|
confirmText: `确定删除等级「${row.grade_name}」(${row.min_score} ~ ${row.max_score})?删除后该分数区间将无法映射等级。`,
|
||||||
|
title: '删除确认',
|
||||||
|
confirmButtonText: '删除',
|
||||||
|
successText: '评级等级已删除',
|
||||||
|
run: () => deleteRatingGrade(row.id),
|
||||||
|
after: () => reloadAfterGradeChange(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<el-dialog
|
||||||
|
v-model="visible"
|
||||||
|
:title="`客户评级 · ${customer?.company_name ?? ''}`"
|
||||||
|
width="680px"
|
||||||
|
destroy-on-close
|
||||||
|
>
|
||||||
|
<!-- 对照表 #25:口径提示改写 —— 评分现在会落库,但仍不参与绩效金额 -->
|
||||||
|
<el-alert
|
||||||
|
class="dialog-alert"
|
||||||
|
title="评分保存为客户档案,只用于客户评级展示,不参与绩效金额计算"
|
||||||
|
type="warning"
|
||||||
|
:closable="false"
|
||||||
|
show-icon
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div v-if="archivedScore" class="archived-line">
|
||||||
|
档案评分:<b>{{ archivedScore.score }}</b> 分
|
||||||
|
<template v-if="archivedScore.grade">
|
||||||
|
· 等级 <b>{{ archivedScore.grade }}</b>
|
||||||
|
</template>
|
||||||
|
<span v-if="archivedScore.time" class="archived-time">({{ archivedScore.time }} 保存)</span>
|
||||||
|
<span class="archived-tip">重新保存将覆盖</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="calc-grid">
|
||||||
|
<div class="calc-form">
|
||||||
|
<div class="section-title">评分项(各 0-20 分,步进 1)</div>
|
||||||
|
<div v-for="item in SCORE_ITEMS" :key="item.key" class="score-row">
|
||||||
|
<span class="score-label">{{ item.label }}</span>
|
||||||
|
<el-input-number
|
||||||
|
v-model="scores[item.key]"
|
||||||
|
:min="0"
|
||||||
|
:max="20"
|
||||||
|
:step="1"
|
||||||
|
controls-position="right"
|
||||||
|
/>
|
||||||
|
<span class="score-unit">分</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="calc-result">
|
||||||
|
<div class="section-title">判定结果</div>
|
||||||
|
<div>
|
||||||
|
<span class="result-label">总分</span>
|
||||||
|
<b class="total-num">{{ totalScore }}</b>
|
||||||
|
<span class="total-max">/ 100</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="result-label">评级等级</span>
|
||||||
|
<template v-if="grades.length === 0">
|
||||||
|
<div class="grade-name">—</div>
|
||||||
|
<div class="grade-hint">未配置等级映射,仍可保存总分</div>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="matchedGrade">
|
||||||
|
<div class="grade-name">{{ matchedGrade.grade_name }}</div>
|
||||||
|
<div class="grade-hint">
|
||||||
|
分值区间 {{ matchedGrade.min_score }} ~ {{ matchedGrade.max_score }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<div class="grade-name">—</div>
|
||||||
|
<div class="grade-hint">总分低于全部等级下限,未命中等级</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="resetScores">重置</el-button>
|
||||||
|
<el-button @click="openDrawer">等级映射设置</el-button>
|
||||||
|
<el-button type="primary" :loading="saving" @click="handleSave">保存评分</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- 等级映射设置抽屉(评级弹窗的二级层,append-to-body 保证遮罩层级正确) -->
|
||||||
|
<el-drawer v-model="drawerVisible" title="等级映射设置" size="640px" append-to-body>
|
||||||
|
<div class="drawer-hint">
|
||||||
|
判定规则:按排序升序取首个「下限 ≤ 总分」的等级,上限仅作展示;修改后评级区立即按新配置判定。
|
||||||
|
</div>
|
||||||
|
<div class="drawer-toolbar">
|
||||||
|
<el-button type="primary" size="small" @click="openCreate">新增等级</el-button>
|
||||||
|
</div>
|
||||||
|
<el-table v-loading="gradesLoading" :data="grades" size="small">
|
||||||
|
<el-table-column prop="grade_name" label="等级名称" min-width="110" />
|
||||||
|
<el-table-column prop="min_score" label="下限(含)" width="100" align="center" />
|
||||||
|
<el-table-column prop="max_score" label="上限" width="90" align="center" />
|
||||||
|
<el-table-column prop="sort_order" label="排序" width="70" align="center" />
|
||||||
|
<el-table-column label="操作" width="110" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="table-actions">
|
||||||
|
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
|
||||||
|
<span class="table-actions__divider" aria-hidden="true"></span>
|
||||||
|
<el-button link type="danger" size="small" @click="removeGrade(row)">删除</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-drawer>
|
||||||
|
|
||||||
|
<!-- 新增 / 编辑等级 -->
|
||||||
|
<el-dialog
|
||||||
|
v-model="editVisible"
|
||||||
|
:title="isCreate ? '新增评级等级' : '编辑评级等级'"
|
||||||
|
width="440px"
|
||||||
|
destroy-on-close
|
||||||
|
append-to-body
|
||||||
|
@closed="editingId = null"
|
||||||
|
>
|
||||||
|
<el-form label-width="90px" @submit.prevent>
|
||||||
|
<el-form-item label="等级名称" required>
|
||||||
|
<el-input v-model="editForm.grade_name" maxlength="32" placeholder="如:A / 优秀" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="下限(含)" required>
|
||||||
|
<el-input-number
|
||||||
|
v-model="editForm.min_score"
|
||||||
|
:min="0"
|
||||||
|
:max="100"
|
||||||
|
:step="5"
|
||||||
|
controls-position="right"
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="上限" required>
|
||||||
|
<el-input-number
|
||||||
|
v-model="editForm.max_score"
|
||||||
|
:min="0"
|
||||||
|
:max="100"
|
||||||
|
:step="5"
|
||||||
|
controls-position="right"
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="排序">
|
||||||
|
<el-input-number
|
||||||
|
v-model="editForm.sort_order"
|
||||||
|
:min="0"
|
||||||
|
:step="10"
|
||||||
|
controls-position="right"
|
||||||
|
/>
|
||||||
|
<span class="form-tip">升序判定,越小越先判定</span>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<div class="form-tip-block">
|
||||||
|
分值区间须满足 0 ≤ 下限 ≤ 上限 ≤ 100;区间重叠、名称重复由后端校验兜底。
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="editVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="editSaving" @click="saveGrade">保存</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* 版式皮肤(.card/.section-title/按钮/表格)由全局 index.css 提供,这里只留弹窗特有排版 */
|
||||||
|
|
||||||
|
.dialog-alert {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 档案评分快照行:一眼看出这是覆盖式保存 */
|
||||||
|
.archived-line {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--ys-ink-2);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archived-time {
|
||||||
|
color: var(--ys-ink-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.archived-tip {
|
||||||
|
margin-left: 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ys-ink-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calc-grid {
|
||||||
|
display: flex;
|
||||||
|
gap: 28px;
|
||||||
|
align-items: stretch;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calc-form {
|
||||||
|
flex: 1 1 320px;
|
||||||
|
min-width: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
line-height: 2.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-label {
|
||||||
|
width: 140px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--ys-ink);
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-unit {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ys-ink-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calc-result {
|
||||||
|
flex: 0 0 220px;
|
||||||
|
min-width: 200px;
|
||||||
|
border: 1px solid var(--ys-line);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 18px 20px;
|
||||||
|
background: linear-gradient(180deg, #ffffff, #fcfcfe);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 12px;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--ys-ink-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.total-num {
|
||||||
|
font-size: 36px;
|
||||||
|
color: var(--ys-indigo);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.total-max {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--ys-ink-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grade-name {
|
||||||
|
font-family: var(--ys-font-brand);
|
||||||
|
font-size: 26px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ys-ink);
|
||||||
|
line-height: 1.3;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grade-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ys-ink-3);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ys-ink-3);
|
||||||
|
background: var(--ys-mist);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-toolbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-tip {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ys-ink-3);
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-tip-block {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ys-ink-3);
|
||||||
|
background: var(--ys-mist);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 移动端:标签左对齐、结果块自然换行到表单下方 */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.score-label {
|
||||||
|
width: auto;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calc-result {
|
||||||
|
flex: 1 1 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -5,12 +5,15 @@ import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
|||||||
import * as customerApi from '@/api/customers'
|
import * as customerApi from '@/api/customers'
|
||||||
import type { CustomerItem, OwnershipLogItem } from '@/api/customers'
|
import type { CustomerItem, OwnershipLogItem } from '@/api/customers'
|
||||||
import * as personnelApi from '@/api/personnel'
|
import * as personnelApi from '@/api/personnel'
|
||||||
|
import { listRatingGrades, type RatingGradeItem } from '@/api/ratings'
|
||||||
import AppPagination from '@/components/table/AppPagination.vue'
|
import AppPagination from '@/components/table/AppPagination.vue'
|
||||||
import { useConfirmAction } from '@/composables/useConfirmAction'
|
import { useConfirmAction } from '@/composables/useConfirmAction'
|
||||||
import { useDropdownOptions } from '@/composables/useDropdownOptions'
|
import { useDropdownOptions } from '@/composables/useDropdownOptions'
|
||||||
import { usePagedList } from '@/composables/usePagedList'
|
import { usePagedList } from '@/composables/usePagedList'
|
||||||
import { formatDateTime } from '@/utils/format'
|
import { formatDateTime } from '@/utils/format'
|
||||||
|
import { gradeTone, resolveGrade } from '@/utils/rating'
|
||||||
import { handledError } from '@/utils/error'
|
import { handledError } from '@/utils/error'
|
||||||
|
import RatingCalculatorDialog from './RatingCalculatorDialog.vue'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 客户管理(需求 2.1.3:客户档案 + 归属人员 + 归属变更留痕)。
|
* 客户管理(需求 2.1.3:客户档案 + 归属人员 + 归属变更留痕)。
|
||||||
@@ -21,6 +24,9 @@ import { handledError } from '@/utils/error'
|
|||||||
* - 归属变更提交前二次确认,文案必须包含「仅影响变更后核算,不回溯历史」
|
* - 归属变更提交前二次确认,文案必须包含「仅影响变更后核算,不回溯历史」
|
||||||
* (对照表 #19 的业务口径)
|
* (对照表 #19 的业务口径)
|
||||||
* - 归属变更记录为分页留痕表(抽屉内展示,按变更时间倒序)
|
* - 归属变更记录为分页留痕表(抽屉内展示,按变更时间倒序)
|
||||||
|
* - 评级计算器并入本页(原 /business/rating 独立页已移除):行内弹窗打分,
|
||||||
|
* 保存评分只写档案字段 rating_score / rating_time;等级不落库,由分数按
|
||||||
|
* 当前等级映射实时算(utils/rating.ts 与弹窗共用同一判定函数)
|
||||||
*/
|
*/
|
||||||
const { confirmAndRun } = useConfirmAction()
|
const { confirmAndRun } = useConfirmAction()
|
||||||
|
|
||||||
@@ -39,6 +45,8 @@ const { records, total, pageNum, pageSize, loading, load, search, onPageChange,
|
|||||||
keyword: filters.keyword,
|
keyword: filters.keyword,
|
||||||
customer_type: filters.customer_type,
|
customer_type: filters.customer_type,
|
||||||
owner_personnel_id: filters.owner_personnel_id,
|
owner_personnel_id: filters.owner_personnel_id,
|
||||||
|
order_by: sortState.order_by,
|
||||||
|
order: sortState.order,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -49,6 +57,96 @@ function resetFilters(): void {
|
|||||||
search()
|
search()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== 排序(评分列) ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 表头排序是查询参数而非本地排序:分页下本地排序只能排当前页。
|
||||||
|
* 目前唯一可排序列是评分;el-table 第三次点击取消排序,回到建档序(id 倒序)。
|
||||||
|
*/
|
||||||
|
const sortState = reactive<{ order_by: 'id' | 'rating_score'; order: 'asc' | 'desc' }>({
|
||||||
|
order_by: 'id',
|
||||||
|
order: 'desc',
|
||||||
|
})
|
||||||
|
|
||||||
|
function onSortChange({
|
||||||
|
prop,
|
||||||
|
order,
|
||||||
|
}: {
|
||||||
|
prop: string
|
||||||
|
order: 'ascending' | 'descending' | null
|
||||||
|
}): void {
|
||||||
|
if (prop === 'rating_score' && order) {
|
||||||
|
sortState.order_by = 'rating_score'
|
||||||
|
sortState.order = order === 'ascending' ? 'asc' : 'desc'
|
||||||
|
} else {
|
||||||
|
sortState.order_by = 'id'
|
||||||
|
sortState.order = 'desc'
|
||||||
|
}
|
||||||
|
search()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 评级计算 ====================
|
||||||
|
|
||||||
|
const ratingVisible = ref(false)
|
||||||
|
const ratingTarget = ref<CustomerItem | null>(null)
|
||||||
|
|
||||||
|
function openRating(row: CustomerItem): void {
|
||||||
|
ratingTarget.value = row
|
||||||
|
ratingVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 保存评分后回写列表:按评分排序时刷新(位置随分变化),否则原位更新不打乱浏览 */
|
||||||
|
function onRatingSaved(item: CustomerItem): void {
|
||||||
|
if (sortState.order_by === 'rating_score') {
|
||||||
|
void load()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const index = records.value.findIndex((record) => record.id === item.id)
|
||||||
|
if (index !== -1) records.value[index] = item
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 评级列(等级 + 分数) ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 等级映射只读列表:列表页的等级徽标要靠它把分数翻成等级。
|
||||||
|
*
|
||||||
|
* 等级不落库(用户口径:等级在前端算),所以每行渲染都要用当前映射;
|
||||||
|
* 拉一次缓存在内存里,避免逐行请求。配置在评级弹窗的抽屉里改完会回调
|
||||||
|
* reloadGrades,列表徽标随之更新。
|
||||||
|
*/
|
||||||
|
const grades = ref<RatingGradeItem[]>([])
|
||||||
|
|
||||||
|
async function loadGrades(): Promise<void> {
|
||||||
|
try {
|
||||||
|
grades.value = await listRatingGrades()
|
||||||
|
} catch (error) {
|
||||||
|
// 等级配置拉取失败不该挡住客户列表:徽标退化为只显示分数
|
||||||
|
handledError(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 分数 → 等级名(未命中等级时返回「未定级」,不显示空白) */
|
||||||
|
function gradeLabel(score: number): string {
|
||||||
|
return resolveGrade(grades.value, score)?.grade_name ?? '未定级'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 等级色调后缀(A 绿 / B 靛蓝 / C 琥珀 / D 砖红 / 自定义中性) */
|
||||||
|
function gradeNameClass(score: number): string {
|
||||||
|
const grade = resolveGrade(grades.value, score)
|
||||||
|
if (!grade) return ''
|
||||||
|
const tone = gradeTone(grade.grade_name)
|
||||||
|
return tone ? `grade__name--${tone}` : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 悬浮提示:等级区间 + 评级时间,回答「这个等级是怎么来的、什么时候打的」 */
|
||||||
|
function ratingTitle(row: CustomerItem): string {
|
||||||
|
if (row.rating_score === null) return '尚未评级'
|
||||||
|
const grade = resolveGrade(grades.value, row.rating_score)
|
||||||
|
const range = grade ? `${grade.grade_name} 档(${grade.min_score} ~ ${grade.max_score} 分)` : '未命中任何等级档'
|
||||||
|
const time = row.rating_time ? `,${formatDateTime(row.rating_time)} 评定` : ''
|
||||||
|
return `${range}${time};重新评级请点行内「评级计算」`
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== 归属人员下拉数据源 ====================
|
// ==================== 归属人员下拉数据源 ====================
|
||||||
|
|
||||||
interface PersonnelOption {
|
interface PersonnelOption {
|
||||||
@@ -311,9 +409,11 @@ async function handleDelete(row: CustomerItem): Promise<void> {
|
|||||||
// ==================== 初始化 ====================
|
// ==================== 初始化 ====================
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
// 两个请求各自容错(load 内部已兜底),互不拖累
|
// 三个请求各自容错(load 内部已兜底),互不拖累
|
||||||
void load()
|
void load()
|
||||||
void loadOwnerOptions()
|
void loadOwnerOptions()
|
||||||
|
// 等级徽标要用等级映射把分数翻成等级,先拉一次;失败只影响徽标,不影响列表
|
||||||
|
void loadGrades()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -322,7 +422,7 @@ onMounted(() => {
|
|||||||
<div class="page-head">
|
<div class="page-head">
|
||||||
<h1 class="page-title">客户管理</h1>
|
<h1 class="page-title">客户管理</h1>
|
||||||
<p class="page-desc">
|
<p class="page-desc">
|
||||||
客户档案与归属人员;归属变更只影响变更后的核算、不回溯历史,每次调整都在「变更记录」留痕
|
客户档案与归属人员;归属变更只影响变更后的核算、不回溯历史,评分与等级由「评级计算」维护并保存进档案
|
||||||
</p>
|
</p>
|
||||||
<span class="waterline"></span>
|
<span class="waterline"></span>
|
||||||
</div>
|
</div>
|
||||||
@@ -385,8 +485,39 @@ onMounted(() => {
|
|||||||
:data="records"
|
:data="records"
|
||||||
stripe
|
stripe
|
||||||
empty-text="还没有客户 —— 点「新增客户」建第一个,或换个关键词再查"
|
empty-text="还没有客户 —— 点「新增客户」建第一个,或换个关键词再查"
|
||||||
|
@sort-change="onSortChange"
|
||||||
>
|
>
|
||||||
<el-table-column prop="company_name" label="公司名称" min-width="180" />
|
<el-table-column prop="company_name" label="公司名称" min-width="180" />
|
||||||
|
<!--
|
||||||
|
评级列 = 等级 + 分数,纯文字两级层次。
|
||||||
|
等级不落库,按当前等级映射由已存分数实时算(与评级弹窗同一判定函数
|
||||||
|
utils/rating.ts)。
|
||||||
|
|
||||||
|
形态刻意既不用 .ys-pill 也不用色块:全站胶囊是「状态」的语汇(草稿/
|
||||||
|
待审/已锁定/已驳回,都带圆点和胶囊边),评级是档案属性,套状态形态
|
||||||
|
会让两种信息混读;色块则会在每行右侧堆一片彩色矩形,表格看起来像
|
||||||
|
贴了标签墙。这里只用字重与语义色分层 —— 等级立起来(13px/600/语义色),
|
||||||
|
分数退到弱墨等宽(13px/tabular-nums),一行读完「B 级 82 分」,
|
||||||
|
整列数字竖向对齐可扫。
|
||||||
|
排序仍挂在 rating_score 上。
|
||||||
|
-->
|
||||||
|
<el-table-column
|
||||||
|
prop="rating_score"
|
||||||
|
label="评级"
|
||||||
|
width="96"
|
||||||
|
align="center"
|
||||||
|
sortable="custom"
|
||||||
|
>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span v-if="row.rating_score !== null" class="grade" :title="ratingTitle(row)">
|
||||||
|
<span class="grade__name" :class="gradeNameClass(row.rating_score)">
|
||||||
|
{{ gradeLabel(row.rating_score) }}
|
||||||
|
</span>
|
||||||
|
<span class="grade__score">{{ row.rating_score }}</span>
|
||||||
|
</span>
|
||||||
|
<span v-else class="cell-muted" title="尚未评级">—</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column prop="owner_name" label="负责人" min-width="90" />
|
<el-table-column prop="owner_name" label="负责人" min-width="90" />
|
||||||
<el-table-column prop="contact_person" label="联系人" min-width="90">
|
<el-table-column prop="contact_person" label="联系人" min-width="90">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
@@ -423,9 +554,10 @@ onMounted(() => {
|
|||||||
<span v-else class="cell-muted">(无归属)</span>
|
<span v-else class="cell-muted">(无归属)</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="260" fixed="right" align="center">
|
<el-table-column label="操作" width="340" fixed="right" align="center">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div class="table-actions">
|
<div class="table-actions">
|
||||||
|
<el-button link type="primary" size="small" @click="openRating(row)">评级计算</el-button>
|
||||||
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
|
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
|
||||||
<el-button link type="primary" size="small" @click="openOwnership(row)">
|
<el-button link type="primary" size="small" @click="openOwnership(row)">
|
||||||
归属变更
|
归属变更
|
||||||
@@ -585,6 +717,14 @@ onMounted(() => {
|
|||||||
@size-change="onLogsSizeChange"
|
@size-change="onLogsSizeChange"
|
||||||
/>
|
/>
|
||||||
</el-drawer>
|
</el-drawer>
|
||||||
|
|
||||||
|
<!-- 评级计算器(原 /business/rating 独立页并入;保存评分回写档案) -->
|
||||||
|
<RatingCalculatorDialog
|
||||||
|
v-model:visible="ratingVisible"
|
||||||
|
:customer="ratingTarget"
|
||||||
|
@saved="onRatingSaved"
|
||||||
|
@grades-changed="loadGrades"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -597,4 +737,52 @@ onMounted(() => {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 1.7;
|
line-height: 1.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 评级列 = 等级 + 分数,纯文字两级层次(无底色、无边框、无胶囊)。
|
||||||
|
*
|
||||||
|
* 为什么不用胶囊也不用色块:全站胶囊(.ys-pill)是「状态」的语汇 —— 草稿/
|
||||||
|
* 待审/已锁定/已驳回,都带圆点和胶囊边;评级是档案属性不是流程状态,套状态
|
||||||
|
* 形态会让两种信息混读。色块方案在实测里会在每行右侧堆一片彩色矩形,表格
|
||||||
|
* 像贴了标签墙,且「A 档」这类两字自定义等级名一撑就变形。
|
||||||
|
*
|
||||||
|
* 改成纯文字分层:等级 13px/600 用语义色立起来(这是几档),分数 13px 弱墨
|
||||||
|
* 等宽跟在后面(依据多少)。零装饰元素,行高不受影响,等级名多长都不会挤。
|
||||||
|
*/
|
||||||
|
.grade {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 6px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grade__name {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ys-ink-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 四档语义色:只改字色,不加底色 —— 表格里色块过多会压过数据本身 */
|
||||||
|
.grade__name--ok {
|
||||||
|
color: var(--ys-ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grade__name--lock {
|
||||||
|
color: var(--ys-indigo);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grade__name--wait {
|
||||||
|
color: var(--ys-warn);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grade__name--reject {
|
||||||
|
color: var(--ys-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 分数:等宽数字保证整列竖向对齐,弱墨让视线先落在等级上 */
|
||||||
|
.grade__score {
|
||||||
|
font-size: 13px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: var(--ys-ink-3);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -171,7 +171,7 @@ const allocationHint = computed(() => {
|
|||||||
const total = Number(allocationTotal.value.toFixed(2))
|
const total = Number(allocationTotal.value.toFixed(2))
|
||||||
if (total === 100) return `二次分配合计 ${total.toFixed(2)}%`
|
if (total === 100) return `二次分配合计 ${total.toFixed(2)}%`
|
||||||
const rest = Number((100 - total).toFixed(2))
|
const rest = Number((100 - total).toFixed(2))
|
||||||
return `二次分配合计 ${total.toFixed(2)}%,余额 ${rest.toFixed(2)}%(归属待业务确认)`
|
return `二次分配合计 ${total.toFixed(2)}%,余额 ${rest.toFixed(2)}%(归负责人)`
|
||||||
})
|
})
|
||||||
|
|
||||||
function addAllocationRow(): void {
|
function addAllocationRow(): void {
|
||||||
@@ -880,7 +880,7 @@ onMounted(() => {
|
|||||||
<div class="items-head">
|
<div class="items-head">
|
||||||
<span class="items-title">
|
<span class="items-title">
|
||||||
二次分配方案
|
二次分配方案
|
||||||
<span class="alloc-note">(可选;比例不要求合计 100%,分摊计算待业务确认后另批)</span>
|
<span class="alloc-note">(可选;各参与人比例不要求合计 100%,余额归负责人)</span>
|
||||||
</span>
|
</span>
|
||||||
<el-button size="small" @click="addAllocationRow">添加参与人</el-button>
|
<el-button size="small" @click="addAllocationRow">添加参与人</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,493 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed, onMounted, reactive, ref } from 'vue'
|
|
||||||
import { ElMessage } from 'element-plus'
|
|
||||||
|
|
||||||
import {
|
|
||||||
createRatingGrade,
|
|
||||||
deleteRatingGrade,
|
|
||||||
listRatingGrades,
|
|
||||||
updateRatingGrade,
|
|
||||||
type RatingGradeItem,
|
|
||||||
type RatingGradeParams,
|
|
||||||
} from '@/api/ratings'
|
|
||||||
import { useConfirmAction } from '@/composables/useConfirmAction'
|
|
||||||
import { handledError } from '@/utils/error'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 客户评级计算器(需求 2.2.5 / 对照表 #25)。
|
|
||||||
*
|
|
||||||
* 评级计算器仅是**打分工具**:五项打分求总分、按等级映射实时判定等级,
|
|
||||||
* 评分记录不落库、不参与绩效金额计算,因此页面没有保存动作(仅重置)。
|
|
||||||
* 等级映射的增删改走 /v1/business/rating-grades 维护接口,配置变更后
|
|
||||||
* 重载判定用列表——判定与抽屉表格共用同一份数据。
|
|
||||||
*/
|
|
||||||
|
|
||||||
// ==================== 等级判定(纯函数) ====================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 等级判定:按 sort_order 升序取首个 min_score ≤ 总分的等级。
|
|
||||||
*
|
|
||||||
* 后端 min_score 是字符串直出(分值红线),比较前必须 parseFloat 转数值,
|
|
||||||
* 禁止字符串直接比大小("90.0" 这类字典序比较会得出错误结果)。
|
|
||||||
* 无命中(总分低于全部下限)返回 null,由调用方展示未命中提示。
|
|
||||||
*/
|
|
||||||
function resolveGrade(grades: RatingGradeItem[], totalScore: number): RatingGradeItem | null {
|
|
||||||
const ordered = [...grades].sort((a, b) => a.sort_order - b.sort_order)
|
|
||||||
for (const grade of ordered) {
|
|
||||||
if (totalScore >= parseFloat(grade.min_score)) {
|
|
||||||
return grade
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== 评分计算(纯前端实时计算) ====================
|
|
||||||
|
|
||||||
/** 五项评分项:各 0-20 分、步进 1,总分 = 五项之和(0-100) */
|
|
||||||
const SCORE_ITEMS = [
|
|
||||||
{ key: 'ship_amount', label: '发货额' },
|
|
||||||
{ key: 'receipt_rate', label: '回款率' },
|
|
||||||
{ key: 'profit_rate', label: '毛利率' },
|
|
||||||
{ key: 'contract_cooperation', label: '合同履约配合能力' },
|
|
||||||
{ key: 'site_construction', label: '现场规范施工能力' },
|
|
||||||
] as const
|
|
||||||
|
|
||||||
type ScoreKey = (typeof SCORE_ITEMS)[number]['key']
|
|
||||||
|
|
||||||
/** el-input-number 清空后值为 undefined,字段用宽松类型,求和处 ?? 0 兜底 */
|
|
||||||
const scores = reactive<Record<ScoreKey, number | undefined>>({
|
|
||||||
ship_amount: 0,
|
|
||||||
receipt_rate: 0,
|
|
||||||
profit_rate: 0,
|
|
||||||
contract_cooperation: 0,
|
|
||||||
site_construction: 0,
|
|
||||||
})
|
|
||||||
|
|
||||||
const totalScore = computed(() =>
|
|
||||||
SCORE_ITEMS.reduce((sum, item) => sum + (scores[item.key] ?? 0), 0),
|
|
||||||
)
|
|
||||||
|
|
||||||
/** 无保存动作(不落库),重置即把五项清零 */
|
|
||||||
function resetScores(): void {
|
|
||||||
for (const item of SCORE_ITEMS) {
|
|
||||||
scores[item.key] = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== 等级映射(判定用列表) ====================
|
|
||||||
|
|
||||||
const { confirmAndRun } = useConfirmAction()
|
|
||||||
|
|
||||||
const grades = ref<RatingGradeItem[]>([])
|
|
||||||
const gradesLoading = ref(false)
|
|
||||||
|
|
||||||
/** 判定与抽屉表格共用同一份全量列表,配置变更后重载一次即同时生效 */
|
|
||||||
async function loadGrades(): Promise<void> {
|
|
||||||
gradesLoading.value = true
|
|
||||||
try {
|
|
||||||
grades.value = await listRatingGrades()
|
|
||||||
} catch (error) {
|
|
||||||
handledError(error)
|
|
||||||
} finally {
|
|
||||||
gradesLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const matchedGrade = computed(() => resolveGrade(grades.value, totalScore.value))
|
|
||||||
|
|
||||||
// ==================== 等级映射设置抽屉 ====================
|
|
||||||
|
|
||||||
const drawerVisible = ref(false)
|
|
||||||
|
|
||||||
/** 打开时重新拉取,避免展示陈旧数据 */
|
|
||||||
function openDrawer(): void {
|
|
||||||
drawerVisible.value = true
|
|
||||||
void loadGrades()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== 新增 / 编辑等级弹窗 ====================
|
|
||||||
|
|
||||||
const editVisible = ref(false)
|
|
||||||
/** null = 新增,否则为编辑目标 id */
|
|
||||||
const editingId = ref<number | null>(null)
|
|
||||||
const editSaving = ref(false)
|
|
||||||
const isCreate = computed(() => editingId.value === null)
|
|
||||||
|
|
||||||
const editForm = reactive({
|
|
||||||
grade_name: '',
|
|
||||||
min_score: undefined as number | undefined,
|
|
||||||
max_score: undefined as number | undefined,
|
|
||||||
sort_order: 0,
|
|
||||||
})
|
|
||||||
|
|
||||||
function openCreate(): void {
|
|
||||||
editingId.value = null
|
|
||||||
editForm.grade_name = ''
|
|
||||||
editForm.min_score = undefined
|
|
||||||
editForm.max_score = undefined
|
|
||||||
editForm.sort_order = 0
|
|
||||||
editVisible.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function openEdit(row: RatingGradeItem): void {
|
|
||||||
editingId.value = row.id
|
|
||||||
editForm.grade_name = row.grade_name
|
|
||||||
// 后端字符串直出,回填 parseFloat 转数值,保存时再 String() 转回字符串
|
|
||||||
editForm.min_score = parseFloat(row.min_score)
|
|
||||||
editForm.max_score = parseFloat(row.max_score)
|
|
||||||
editForm.sort_order = row.sort_order
|
|
||||||
editVisible.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 前端值域校验(0≤min≤max≤100);后端在审计上下文内兜底同规则与区间重叠 */
|
|
||||||
function editFormError(minScore: number | undefined, maxScore: number | undefined): string | null {
|
|
||||||
if (!editForm.grade_name.trim()) return '请输入等级名称'
|
|
||||||
if (minScore === undefined || maxScore === undefined) return '请输入分值区间'
|
|
||||||
if (minScore < 0 || maxScore > 100 || minScore > maxScore) {
|
|
||||||
return '分值区间须满足 0 ≤ 下限 ≤ 上限 ≤ 100'
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveGrade(): Promise<void> {
|
|
||||||
// 局部变量收窄类型:reactive 属性在异步流程中不保证保持窄化
|
|
||||||
const minScore = editForm.min_score
|
|
||||||
const maxScore = editForm.max_score
|
|
||||||
const error = editFormError(minScore, maxScore)
|
|
||||||
if (error) {
|
|
||||||
ElMessage.warning(error)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload: RatingGradeParams = {
|
|
||||||
grade_name: editForm.grade_name.trim(),
|
|
||||||
min_score: String(minScore),
|
|
||||||
max_score: String(maxScore),
|
|
||||||
sort_order: editForm.sort_order ?? 0,
|
|
||||||
}
|
|
||||||
// 后端为全量替换语义;remark 本页不维护,编辑时原样带回避免误清
|
|
||||||
if (editingId.value !== null) {
|
|
||||||
payload.remark = grades.value.find((g) => g.id === editingId.value)?.remark ?? undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
editSaving.value = true
|
|
||||||
try {
|
|
||||||
if (editingId.value === null) {
|
|
||||||
await createRatingGrade(payload)
|
|
||||||
} else {
|
|
||||||
await updateRatingGrade(editingId.value, payload)
|
|
||||||
}
|
|
||||||
editVisible.value = false
|
|
||||||
ElMessage.success(isCreate.value ? '评级等级已创建' : '已保存')
|
|
||||||
await loadGrades()
|
|
||||||
} catch (requestError) {
|
|
||||||
handledError(requestError)
|
|
||||||
} finally {
|
|
||||||
editSaving.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 删除走确认动作流;文案明确后果(该区间不再能映射等级) */
|
|
||||||
function removeGrade(row: RatingGradeItem): void {
|
|
||||||
void confirmAndRun({
|
|
||||||
confirmText: `确定删除等级「${row.grade_name}」(${row.min_score} ~ ${row.max_score})?删除后该分数区间将无法映射等级。`,
|
|
||||||
title: '删除确认',
|
|
||||||
confirmButtonText: '删除',
|
|
||||||
successText: '评级等级已删除',
|
|
||||||
run: () => deleteRatingGrade(row.id),
|
|
||||||
after: () => loadGrades(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
void loadGrades()
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="rating-page">
|
|
||||||
<!-- 页面头:标题 + 一句说明 + 签名水线(每页只出现一次) -->
|
|
||||||
<div class="page-head">
|
|
||||||
<h1 class="page-title">客户评级计算器</h1>
|
|
||||||
<p class="page-desc">
|
|
||||||
五项各 0-20 分、总分 0-100,按配置的分值下限实时判定等级;评分只决定评级,不参与绩效金额计算
|
|
||||||
</p>
|
|
||||||
<span class="waterline"></span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 对照表 #25:常驻口径提示,防止误认为评分会影响绩效金额 -->
|
|
||||||
<el-alert
|
|
||||||
class="page-alert"
|
|
||||||
title="本页为评分计算工具,评分结果不参与绩效金额计算"
|
|
||||||
type="warning"
|
|
||||||
:closable="false"
|
|
||||||
show-icon
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- 计算器是一张卡:卡头放标题与操作,卡体内分「评分项 / 判定结果」两块 -->
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-head">
|
|
||||||
<span class="card-title">评分计算</span>
|
|
||||||
<span class="card-head__actions">
|
|
||||||
<el-button @click="resetScores">重置</el-button>
|
|
||||||
<el-button type="primary" @click="openDrawer">等级映射设置</el-button>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="calc-grid">
|
|
||||||
<div class="calc-form">
|
|
||||||
<div class="section-title">评分项(各 0-20 分,步进 1)</div>
|
|
||||||
<div v-for="item in SCORE_ITEMS" :key="item.key" class="score-row">
|
|
||||||
<span class="score-label">{{ item.label }}</span>
|
|
||||||
<el-input-number
|
|
||||||
v-model="scores[item.key]"
|
|
||||||
:min="0"
|
|
||||||
:max="20"
|
|
||||||
:step="1"
|
|
||||||
controls-position="right"
|
|
||||||
/>
|
|
||||||
<span class="score-unit">分</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="calc-result">
|
|
||||||
<div class="section-title">判定结果</div>
|
|
||||||
<div>
|
|
||||||
<span class="result-label">总分</span>
|
|
||||||
<b class="total-num">{{ totalScore }}</b>
|
|
||||||
<span class="total-max">/ 100</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span class="result-label">评级等级</span>
|
|
||||||
<template v-if="grades.length === 0">
|
|
||||||
<div class="grade-name">—</div>
|
|
||||||
<div class="grade-hint">未配置等级映射</div>
|
|
||||||
</template>
|
|
||||||
<template v-else-if="matchedGrade">
|
|
||||||
<div class="grade-name">{{ matchedGrade.grade_name }}</div>
|
|
||||||
<div class="grade-hint">
|
|
||||||
分值区间 {{ matchedGrade.min_score }} ~ {{ matchedGrade.max_score }}
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<div class="grade-name">—</div>
|
|
||||||
<div class="grade-hint">总分低于全部等级下限,未命中等级</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 等级映射设置抽屉 -->
|
|
||||||
<el-drawer v-model="drawerVisible" title="等级映射设置" size="640px">
|
|
||||||
<div class="drawer-hint">
|
|
||||||
判定规则:按排序升序取首个「下限 ≤ 总分」的等级,上限仅作展示;修改后评级区立即按新配置判定。
|
|
||||||
</div>
|
|
||||||
<div class="drawer-toolbar">
|
|
||||||
<el-button type="primary" size="small" @click="openCreate">新增等级</el-button>
|
|
||||||
</div>
|
|
||||||
<el-table v-loading="gradesLoading" :data="grades" size="small">
|
|
||||||
<el-table-column prop="grade_name" label="等级名称" min-width="110" />
|
|
||||||
<el-table-column prop="min_score" label="下限(含)" width="100" align="center" />
|
|
||||||
<el-table-column prop="max_score" label="上限" width="90" align="center" />
|
|
||||||
<el-table-column prop="sort_order" label="排序" width="70" align="center" />
|
|
||||||
<el-table-column label="操作" width="110" align="center">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<div class="table-actions">
|
|
||||||
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
|
|
||||||
<span class="table-actions__divider" aria-hidden="true"></span>
|
|
||||||
<el-button link type="danger" size="small" @click="removeGrade(row)">删除</el-button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
</el-table>
|
|
||||||
</el-drawer>
|
|
||||||
|
|
||||||
<!-- 新增 / 编辑等级 -->
|
|
||||||
<el-dialog
|
|
||||||
v-model="editVisible"
|
|
||||||
:title="isCreate ? '新增评级等级' : '编辑评级等级'"
|
|
||||||
width="440px"
|
|
||||||
destroy-on-close
|
|
||||||
@closed="editingId = null"
|
|
||||||
>
|
|
||||||
<el-form label-width="90px" @submit.prevent>
|
|
||||||
<el-form-item label="等级名称" required>
|
|
||||||
<el-input v-model="editForm.grade_name" maxlength="32" placeholder="如:A / 优秀" />
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="下限(含)" required>
|
|
||||||
<el-input-number
|
|
||||||
v-model="editForm.min_score"
|
|
||||||
:min="0"
|
|
||||||
:max="100"
|
|
||||||
:step="5"
|
|
||||||
controls-position="right"
|
|
||||||
style="width: 100%"
|
|
||||||
/>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="上限" required>
|
|
||||||
<el-input-number
|
|
||||||
v-model="editForm.max_score"
|
|
||||||
:min="0"
|
|
||||||
:max="100"
|
|
||||||
:step="5"
|
|
||||||
controls-position="right"
|
|
||||||
style="width: 100%"
|
|
||||||
/>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="排序">
|
|
||||||
<el-input-number
|
|
||||||
v-model="editForm.sort_order"
|
|
||||||
:min="0"
|
|
||||||
:step="10"
|
|
||||||
controls-position="right"
|
|
||||||
/>
|
|
||||||
<span class="form-tip">升序判定,越小越先判定</span>
|
|
||||||
</el-form-item>
|
|
||||||
</el-form>
|
|
||||||
<div class="form-tip-block">
|
|
||||||
分值区间须满足 0 ≤ 下限 ≤ 上限 ≤ 100;区间重叠、名称重复由后端校验兜底。
|
|
||||||
</div>
|
|
||||||
<template #footer>
|
|
||||||
<el-button @click="editVisible = false">取消</el-button>
|
|
||||||
<el-button type="primary" :loading="editSaving" @click="saveGrade">保存</el-button>
|
|
||||||
</template>
|
|
||||||
</el-dialog>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
/* 页面头(.page-head/.page-title/.page-desc/.waterline)、.card 与 .section-title
|
|
||||||
都由全局 index.css 提供,此处只保留本页特有的排版 */
|
|
||||||
|
|
||||||
.page-alert {
|
|
||||||
margin-bottom: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 卡头右侧操作组:.card-head 已是 flex + justify-between,这里只补按钮之间的间距 */
|
|
||||||
.card-head__actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.calc-grid {
|
|
||||||
display: flex;
|
|
||||||
gap: 28px;
|
|
||||||
align-items: stretch;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.calc-form {
|
|
||||||
flex: 1 1 380px;
|
|
||||||
min-width: 280px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.score-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
line-height: 2.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.score-label {
|
|
||||||
width: 140px;
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--ys-ink);
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
.score-unit {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--ys-ink-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.calc-result {
|
|
||||||
flex: 0 0 250px;
|
|
||||||
min-width: 220px;
|
|
||||||
border: 1px solid var(--ys-line);
|
|
||||||
border-radius: 10px;
|
|
||||||
padding: 18px 20px;
|
|
||||||
background: linear-gradient(180deg, #ffffff, #fcfcfe);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.result-label {
|
|
||||||
display: block;
|
|
||||||
font-size: 12px;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
color: var(--ys-ink-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.total-num {
|
|
||||||
font-size: 40px;
|
|
||||||
color: var(--ys-indigo);
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
margin-right: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.total-max {
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--ys-ink-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.grade-name {
|
|
||||||
font-family: var(--ys-font-brand);
|
|
||||||
font-size: 28px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--ys-ink);
|
|
||||||
line-height: 1.3;
|
|
||||||
margin-top: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.grade-hint {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--ys-ink-3);
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.drawer-hint {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--ys-ink-3);
|
|
||||||
background: var(--ys-mist);
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 8px 12px;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.drawer-toolbar {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-tip {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--ys-ink-3);
|
|
||||||
margin-left: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-tip-block {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--ys-ink-3);
|
|
||||||
background: var(--ys-mist);
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 8px 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 移动端:标签左对齐、结果块自然换行到表单下方 */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.score-label {
|
|
||||||
width: auto;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.calc-result {
|
|
||||||
flex: 1 1 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -479,7 +479,7 @@ onMounted(() => {
|
|||||||
v-model="form.receipt_date"
|
v-model="form.receipt_date"
|
||||||
type="date"
|
type="date"
|
||||||
value-format="YYYY-MM-DD"
|
value-format="YYYY-MM-DD"
|
||||||
placeholder="M3 核算按此日匹配参数版本"
|
placeholder="选到账当天;计提比例按这一天的参数计算"
|
||||||
style="width: 100%"
|
style="width: 100%"
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user