Compare commits
2 Commits
8e8bcaa5fb
...
32b7940067
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32b7940067 | ||
|
|
b9781f7d3f |
@ -83,8 +83,9 @@ app/
|
||||
| GET / POST | `/api/v1/base/products` | 产品列表(名称/规格模糊、类别/状态筛选)/ 新增(类别值域校验;名称+规格组合唯一) |
|
||||
| GET / PUT / DELETE | `/api/v1/base/products/{id}` | 详情 / 编辑 / 删除(被业务明细引用保护在业务表就位后生效) |
|
||||
| PUT | `/api/v1/base/products/{id}/status` | 启用 / 停用 |
|
||||
| GET / POST | `/api/v1/base/customers` | 客户列表(公司/负责人模糊、分类/归属筛选)/ 新增(分类值域;公司名+负责人唯一) |
|
||||
| GET / POST | `/api/v1/base/customers` | 客户列表(公司/负责人模糊、分类/归属筛选;`order_by=rating_score` 按评分排序,未评分垫底)/ 新增(分类值域;公司名+负责人唯一) |
|
||||
| GET / PUT / DELETE | `/api/v1/base/customers/{id}` | 详情 / 编辑(**不触归属**)/ 删除 |
|
||||
| PUT | `/api/v1/base/customers/{id}/rating` | 保存评级(评级计算器「保存评分」;只写 `rating_score`/`rating_time`,五项明细不落库 #25) |
|
||||
| PUT | `/api/v1/base/customers/{id}/ownership` | 归属变更(同事务写留痕;只影响变更后核算,不回溯历史 #19) |
|
||||
| GET | `/api/v1/base/customers/{id}/ownership-logs` | 归属变更留痕分页(join 人员出姓名) |
|
||||
|
||||
|
||||
51
backend/alembic/versions/c3a9e1d7b2f4_v13_客户评级落库.py
Normal file
51
backend/alembic/versions/c3a9e1d7b2f4_v13_客户评级落库.py
Normal file
@ -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,
|
||||
CustomerOwnershipIn,
|
||||
CustomerQuery,
|
||||
CustomerRatingIn,
|
||||
CustomerUpdateIn,
|
||||
OwnershipChangeLogOut,
|
||||
)
|
||||
@ -102,6 +103,31 @@ def delete_customer(
|
||||
return ok(message="客户已删除")
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{customer_id}/rating",
|
||||
response_model=ApiResponse[CustomerOut],
|
||||
summary="保存客户评级",
|
||||
)
|
||||
def rate_customer(
|
||||
session: DbSession,
|
||||
current_user: CurrentUser,
|
||||
customer_id: Annotated[int, Path(ge=1)],
|
||||
payload: CustomerRatingIn,
|
||||
meta: RequestMetaDep,
|
||||
) -> ApiResponse[CustomerOut]:
|
||||
"""保存评级总分(评级计算器「保存评分」)。
|
||||
|
||||
评分是客户档案属性:存总分与评级时间,五项明细不落库(对照表 #25);
|
||||
编辑端点不含评分 —— 评分只走本端点,由计算器统一入口写入。
|
||||
"""
|
||||
return ok(
|
||||
customer_service.rate_customer(
|
||||
session, customer_id, payload, operator=current_user, meta=meta
|
||||
),
|
||||
message="评级已保存",
|
||||
)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{customer_id}/ownership",
|
||||
response_model=ApiResponse[CustomerOut],
|
||||
|
||||
@ -63,6 +63,14 @@ class Customer(Base, TimestampMixin, TPlusSyncMixin):
|
||||
comment="归属负责营销人员ID(手动修改不回溯历史,变更留痕见 ownership_change_logs)",
|
||||
)
|
||||
remark: Mapped[str | None] = mapped_column(String(255), comment="备注")
|
||||
rating_score: Mapped[int | None] = mapped_column(
|
||||
TINYINT_TYPE,
|
||||
comment="评级总分(0-100,评级计算器保存;null=未评分;人工维护字段,T+ 同步不覆盖)",
|
||||
)
|
||||
rating_time: Mapped[datetime | None] = mapped_column(
|
||||
DateTime,
|
||||
comment="最近评级时间(评级计算器保存评分时刷新;人工维护字段)",
|
||||
)
|
||||
|
||||
|
||||
class OwnershipChangeLog(Base):
|
||||
|
||||
@ -52,10 +52,12 @@ def list_customers(session: Session, query: CustomerQuery) -> list[Row]:
|
||||
|
||||
归属人员外键非空但统一用 outerjoin:人员行不可考(被物理删除的脏数据)
|
||||
不应让客户行整个查不出来(同 personnel_repository 的韧性口径)。
|
||||
排序按 id 倒序:客户无排序字段,新增的靠前符合列表页习惯。
|
||||
排序键由查询白名单决定(见 ``CustomerQuery.order_by``):
|
||||
id 倒序 = 新建档靠前,rating_score 按分数排、未评分垫底。
|
||||
"""
|
||||
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())
|
||||
|
||||
|
||||
@ -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())
|
||||
|
||||
|
||||
# ==================== 写入 ====================
|
||||
|
||||
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
"""客户管理的请求/响应模型(需求 2.1.3)。"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
@ -28,6 +29,8 @@ class CustomerOut(BaseModel):
|
||||
owner_personnel_id: int | None = None
|
||||
owner_personnel_name: str | None = None
|
||||
remark: str | None = None
|
||||
rating_score: int | None = None
|
||||
rating_time: datetime | None = None
|
||||
|
||||
|
||||
class CustomerCreateIn(BaseModel):
|
||||
@ -90,13 +93,32 @@ class CustomerUpdateIn(BaseModel):
|
||||
|
||||
|
||||
class CustomerQuery(PageQuery):
|
||||
"""客户列表查询条件(字段名蛇形直出,与前端 api 封装同名对应)。"""
|
||||
"""客户列表查询条件(字段名蛇形直出,与前端 api 封装同名对应)。
|
||||
|
||||
排序用 ``Literal`` 白名单而不是自由字符串:``order_by`` 直接拼进
|
||||
``ORDER BY``,自由值等于把注入面开到排序键上;客户列表只有两个
|
||||
有意义的排序键,白名单足够。
|
||||
"""
|
||||
|
||||
keyword: str | None = Field(
|
||||
default=None, max_length=64, description="公司名称或客户负责人,模糊匹配"
|
||||
)
|
||||
customer_type: str | None = Field(default=None, max_length=32, description="客户分类筛选")
|
||||
owner_personnel_id: int | None = Field(default=None, ge=1, description="归属人员ID筛选")
|
||||
order_by: Literal["id", "rating_score"] = Field(
|
||||
default="id", description="排序键:id 建档序 / rating_score 评级总分"
|
||||
)
|
||||
order: Literal["asc", "desc"] = Field(default="desc", description="排序方向")
|
||||
|
||||
|
||||
class CustomerRatingIn(BaseModel):
|
||||
"""保存客户评级入参(评级计算器「保存评分」)。
|
||||
|
||||
score 是五项之和的整数(0-100);五项明细不落库(评级记录只存总分,
|
||||
见对照表 #25 的定位:评分是档案属性而非审计事件)。
|
||||
"""
|
||||
|
||||
score: int = Field(ge=0, le=100, description="评级总分(0-100 整数)")
|
||||
|
||||
|
||||
class CustomerOwnershipIn(BaseModel):
|
||||
|
||||
@ -15,6 +15,7 @@
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Row
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
@ -38,6 +39,7 @@ from app.schemas.customer import (
|
||||
CustomerOut,
|
||||
CustomerOwnershipIn,
|
||||
CustomerQuery,
|
||||
CustomerRatingIn,
|
||||
CustomerUpdateIn,
|
||||
OwnershipChangeLogOut,
|
||||
)
|
||||
@ -61,6 +63,8 @@ def _to_out(customer: Customer, owner_name: str | None) -> CustomerOut:
|
||||
owner_personnel_id=customer.owner_personnel_id,
|
||||
owner_personnel_name=owner_name,
|
||||
remark=customer.remark,
|
||||
rating_score=customer.rating_score,
|
||||
rating_time=customer.rating_time,
|
||||
)
|
||||
|
||||
|
||||
@ -263,6 +267,53 @@ def update_customer(
|
||||
return out
|
||||
|
||||
|
||||
def rate_customer(
|
||||
session: Session,
|
||||
customer_id: int,
|
||||
payload: CustomerRatingIn,
|
||||
*,
|
||||
operator: User,
|
||||
meta: RequestMeta | None = None,
|
||||
) -> CustomerOut:
|
||||
"""保存客户评级(评级计算器「保存评分」)。
|
||||
|
||||
评分是客户档案属性而非审计事件:只存总分与评级时间,五项明细与所用
|
||||
等级配置不落库(对照表 #25 的定位口径)——等级映射是可变判定表,回溯
|
||||
「当时命中的等级」需要快照设计,超出档案属性的范围;评级时间已足够
|
||||
回答「这个分是什么时候打的」。
|
||||
|
||||
:raises NotFoundError: 客户不存在
|
||||
"""
|
||||
# 客户名进审计摘要,预取放在审计上下文之前(同 change_ownership 口径)
|
||||
row = customer_repository.get_with_owner_name(session, customer_id)
|
||||
customer_name = row[0].company_name if row else f"#{customer_id}"
|
||||
with audit_service.audit(
|
||||
session,
|
||||
operator=operator,
|
||||
record=AuditRecord(
|
||||
module=MODULE_CUSTOMER,
|
||||
action=ACTION_UPDATE,
|
||||
summary=f"保存客户评级 {customer_name}:{payload.score} 分",
|
||||
request_data=payload.model_dump(),
|
||||
),
|
||||
meta=meta,
|
||||
):
|
||||
if row is None:
|
||||
raise NotFoundError("客户不存在")
|
||||
customer, owner_name = row
|
||||
|
||||
customer.rating_score = payload.score
|
||||
# 评级时间取应用层本地时间(库列 TZ=Asia/Shanghai,与 TimestampMixin
|
||||
# 的服务器默认值同一时区口径),不用数据库 NOW():行已在本会话
|
||||
customer.rating_time = datetime.now()
|
||||
|
||||
# 响应在 commit 之前构造(同 user_service 顶部说明)
|
||||
out = _to_out(customer, owner_name)
|
||||
session.commit()
|
||||
logger.info("保存客户评级 | customer_id=%s | score=%s", customer_id, payload.score)
|
||||
return out
|
||||
|
||||
|
||||
def delete_customer(
|
||||
session: Session,
|
||||
customer_id: int,
|
||||
|
||||
@ -98,9 +98,9 @@ uv run pytest tests/integration -q # 仅集成
|
||||
| --- | --- | --- |
|
||||
| `tests/integration/test_personnel.py` | 未登录 401、新增默认在职、工资标准 str 直出、经理自引用/不存在或已离职/岗位停用 400、删除保护(团队成员与客户归属引用→引导改离职)、辐射区域与拓展计划全量替换(含清空与详情回显)、在职/离职切换、审计成功与失败双记录 | 20 |
|
||||
| `tests/integration/test_products.py` | 未登录 401、类别值域 400、名称+规格组合唯一(同名称不同规格放行)、单价负数/非数字 400、启停切换、改类别、审计留痕 | 14 |
|
||||
| `tests/integration/test_customers.py` | 未登录 401、客户分类值域 400、「公司名称+负责人」组合唯一(同公司不同负责人放行)、归属变更(主表更新+留痕字段断言)、归属人员离职 400、新旧相同含双方为空 400、留痕分页 join 姓名、编辑端点不触归属、审计留痕 | 14 |
|
||||
| `tests/integration/test_customers.py` | 未登录 401、客户分类值域 400、「公司名称+负责人」组合唯一(同公司不同负责人放行)、归属变更(主表更新+留痕字段断言)、归属人员离职 400、新旧相同含双方为空 400、留痕分页 join 姓名、编辑端点不触归属、审计留痕、评级保存(回显总分/时间、覆盖式保存)、评分 101→400、按评分排序未评分垫底(asc/desc) | 18 |
|
||||
|
||||
运行:`uv run pytest tests/integration/test_personnel.py tests/integration/test_products.py tests/integration/test_customers.py -q` → **48 passed**(2026-10-13,连跑两次幂等)。
|
||||
运行:`uv run pytest tests/integration/test_personnel.py tests/integration/test_products.py tests/integration/test_customers.py -q` → **48 passed**(2026-10-13,连跑两次幂等);2026-10-23 评级功能后客户单文件 **18 passed**(18.5s)。
|
||||
|
||||
### 业务数据管理(2026-10-13 完成)
|
||||
|
||||
|
||||
@ -376,3 +376,73 @@ class TestAudit:
|
||||
latest = logs[0]
|
||||
assert customer["company_name"] in latest.summary, "摘要应含客户名"
|
||||
assert "→" in latest.summary, "摘要应体现新旧归属走向"
|
||||
|
||||
|
||||
class TestRating:
|
||||
"""评级保存与列表排序 —— 评分是档案属性,只存总分与评级时间。
|
||||
|
||||
守的是:评分只走专属端点(编辑端点不含评分,旁路写评分会绕开口径);
|
||||
分值红线 0-100 在入口拦截;按评分排序时未评分客户必须垫底,不能因
|
||||
MySQL 把 NULL 视为最小值而在升序里压过打了低分的客户。
|
||||
"""
|
||||
|
||||
def test_保存评级_回显总分与时间_且编辑端点不含评分(
|
||||
self, client: TestClient, normal: dict
|
||||
) -> None:
|
||||
headers = normal["headers"]
|
||||
customer = _create_customer(client, headers)
|
||||
assert customer["rating_score"] is None, "新客户应无评分"
|
||||
|
||||
resp = client.put(
|
||||
f"{_CUSTOMERS}/{customer['id']}/rating", json={"score": 88}, headers=headers
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()["data"]
|
||||
assert body["rating_score"] == 88
|
||||
assert body["rating_time"] is not None, "保存评分必须刷新评级时间"
|
||||
|
||||
# 覆盖式保存:新分数替换旧分数
|
||||
resp = client.put(
|
||||
f"{_CUSTOMERS}/{customer['id']}/rating", json={"score": 60}, headers=headers
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["data"]["rating_score"] == 60
|
||||
|
||||
def test_评分越界_400(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
customer = _create_customer(client, headers)
|
||||
resp = client.put(
|
||||
f"{_CUSTOMERS}/{customer['id']}/rating", json={"score": 101}, headers=headers
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
|
||||
def test_按评分排序_未评分垫底_升序降序都不压过低分(
|
||||
self, client: TestClient, normal: dict
|
||||
) -> None:
|
||||
headers = normal["headers"]
|
||||
high = _create_customer(client, headers)
|
||||
low = _create_customer(client, headers)
|
||||
unrated = _create_customer(client, headers)
|
||||
for target, score in ((high, 80), (low, 60)):
|
||||
resp = client.put(
|
||||
f"{_CUSTOMERS}/{target['id']}/rating", json={"score": score}, headers=headers
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
def _order_names(order: str) -> list[str]:
|
||||
resp = client.get(
|
||||
_CUSTOMERS,
|
||||
params={"pageNum": 1, "pageSize": 50, "order_by": "rating_score", "order": order},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
names = [row["company_name"] for row in resp.json()["data"]["list"]]
|
||||
return names
|
||||
|
||||
# 降序:高分在前,未评分最后;升序:低分在前,未评分仍垫底
|
||||
desc_names = _order_names("desc")
|
||||
assert desc_names.index(high["company_name"]) < desc_names.index(low["company_name"])
|
||||
assert desc_names[-1] == unrated["company_name"]
|
||||
asc_names = _order_names("asc")
|
||||
assert asc_names.index(low["company_name"]) < asc_names.index(high["company_name"])
|
||||
assert asc_names[-1] == unrated["company_name"], "未评分必须垫底"
|
||||
|
||||
@ -366,6 +366,8 @@ CREATE TABLE customers (
|
||||
customer_type VARCHAR(32) NOT NULL COMMENT '客户分类:personal个人户 manager_personal经理个人户 enterprise企业户(计提规则匹配锚点;T+同步不覆盖)' DEFAULT 'personal',
|
||||
owner_personnel_id BIGINT UNSIGNED COMMENT '归属负责营销人员ID(手动修改不回溯历史,变更留痕见 ownership_change_logs)',
|
||||
remark VARCHAR(255) COMMENT '备注',
|
||||
rating_score TINYINT COMMENT '评级总分(0-100,评级计算器保存;null=未评分;人工维护字段,T+ 同步不覆盖)',
|
||||
rating_time DATETIME COMMENT '最近评级时间(评级计算器保存评分时刷新;人工维护字段)',
|
||||
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
|
||||
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
source_no VARCHAR(64) COMMENT 'T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)',
|
||||
@ -891,4 +893,4 @@ CREATE TABLE alembic_version (
|
||||
CONSTRAINT alembic_version_pkc PRIMARY KEY (version_num)
|
||||
)CHARSET=utf8mb4 ENGINE=InnoDB COLLATE utf8mb4_general_ci;
|
||||
|
||||
INSERT INTO alembic_version (version_num) VALUES ('7f4f7b918bc4');
|
||||
INSERT INTO alembic_version (version_num) VALUES ('c3a9e1d7b2f4');
|
||||
|
||||
10
db/migration/V10__add_customer_rating.sql
Normal file
10
db/migration/V10__add_customer_rating.sql
Normal file
@ -0,0 +1,10 @@
|
||||
-- V10: 客户评级落库(2026-10-23)
|
||||
-- 评级计算器从独立工具页并入客户管理(行内弹窗),评分保存为客户档案属性;
|
||||
-- 两列可空(null=未评分,存量客户无需回填),人工维护字段,T+ 同步不覆盖。
|
||||
-- 对应迁移:alembic/versions/c3a9e1d7b2f4_v13_客户评级落库.py
|
||||
-- 每列一条 ALTER:test_schema_sync 按「ALTER TABLE … ADD COLUMN」逐条解析,
|
||||
-- 一条语句挂多个 ADD 只会解析出第一列,两份 DDL 会假性漂移。
|
||||
ALTER TABLE `customers`
|
||||
ADD COLUMN `rating_score` TINYINT COMMENT '评级总分(0-100,评级计算器保存;null=未评分;人工维护字段,T+ 同步不覆盖)' AFTER `remark`;
|
||||
ALTER TABLE `customers`
|
||||
ADD COLUMN `rating_time` DATETIME COMMENT '最近评级时间(评级计算器保存评分时刷新;人工维护字段)' AFTER `rating_score`;
|
||||
@ -5,12 +5,17 @@
|
||||
<title>衍水工资系统 · 视觉方向预览</title>
|
||||
<style>
|
||||
/* ============================================================
|
||||
设计体系 v4(同步已交付模块):中性灰白 · 靛蓝点缀 · 微质感
|
||||
设计体系 v4.1(同步已交付模块 + 实测补订):中性灰白 · 靛蓝点缀 · 微质感
|
||||
v3 → v4 变更(2026-10-22):设计稿与已交付功能对齐
|
||||
1) 侧栏菜单改为真实路由分组(5 组 + 首页 = 21 项),组可折叠
|
||||
2) 首页工作台按真实四区块重画(总览 / 待办 / 预警 / 动态)
|
||||
3) 新增 §09:月结、审批、报表、打印稿(v3 缺 M3/M4 页面)
|
||||
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% 明度差,暗示受光方向
|
||||
2) 半透明描边 —— 边界在不同底色上自然融合
|
||||
@ -444,7 +449,11 @@
|
||||
.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-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 {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
@ -579,6 +588,83 @@
|
||||
.states { display: flex; gap: 14px; margin-top: 12px; }
|
||||
.state { text-align: center; }
|
||||
.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>
|
||||
</head>
|
||||
<body>
|
||||
@ -590,9 +676,12 @@
|
||||
<h1>营销人员工资管理系统 · 视觉方向</h1>
|
||||
<div class="sub">企业级扁平 · 轻质感 · Bento 布局 · 品牌色点缀 · 微动效 · 精致文案</div>
|
||||
<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 质感版,未改色板、未改字体、未改组件基调。
|
||||
<br />
|
||||
v4.1 补订三处实测偏差与一张表单页:表格行高 1.5(§03)、月结生命周期条按阶段着色 + 操作提示(§09)、
|
||||
工作台空态在卡内居中(§05)、公司信息页版式(§10 新增)。
|
||||
</div>
|
||||
<div class="waterline"></div>
|
||||
<div class="tags">
|
||||
@ -605,6 +694,8 @@
|
||||
<span class="tag">120–180ms 轻动效</span>
|
||||
<span class="tag">21 项菜单 · 5 组</span>
|
||||
<span class="tag">工作台四区块</span>
|
||||
<span class="tag">表格行高 1.5</span>
|
||||
<span class="tag">表单页两列</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@ -759,6 +850,30 @@
|
||||
动效的目标是「顺滑可信」,不是表演。
|
||||
</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>
|
||||
</section>
|
||||
|
||||
@ -1089,6 +1204,7 @@
|
||||
待办与预警并排 2×2 → 最近动态与常用操作整行铺满(<code>b-full</code>)。
|
||||
卡片入场 <b>45ms 交错上浮(320ms)</b>,悬停抬升 2px、影加深。
|
||||
待办与预警的列表<b>在卡内滚动</b>(上限 178px)——条目再多也不撑开网格,行高恒定。
|
||||
空态(<code>b-empty</code>)在卡片剩余高度内<b>垂直居中</b>,不全部挤在顶部留一整块空白。
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@ -1272,6 +1388,9 @@
|
||||
月结是<b>锁点</b>:审定后绩效与应付合计不可改,只能靠调整单纠偏,状态条与列表同步体现这件事。
|
||||
状态标签分四档——草稿(中性灰)、待审批(琥珀)、已审定(靛蓝)、已驳回(砖红),
|
||||
颜色之外还有文字,不靠颜色单独表意。
|
||||
生命周期条上<b>四段圆点按阶段着色</b>(草稿中性 / 待审批琥珀 / 已审定靛蓝 / 已驳回砖红),
|
||||
与选中与否无关;条右侧挂一行操作提示「点状态只看该阶段,再点一次取消」——
|
||||
没有这行提示时,未选中态的四段读起来像四颗孤立的小按钮,不像一条流程。
|
||||
</div>
|
||||
|
||||
<!-- 09-2 审批管理 -->
|
||||
@ -1500,6 +1619,129 @@
|
||||
</div>
|
||||
</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>
|
||||
<script src="echarts.min.js"></script>
|
||||
<script>
|
||||
|
||||
@ -21,6 +21,10 @@ export interface CustomerItem {
|
||||
owner_personnel_id: number | null
|
||||
owner_personnel_name: string | null
|
||||
remark: string | null
|
||||
/** 评级总分(0-100,评级计算器保存;null=未评分) */
|
||||
rating_score: number | null
|
||||
/** 最近评级时间(评级计算器保存评分时刷新;null=从未评级) */
|
||||
rating_time: string | null
|
||||
}
|
||||
|
||||
export interface CustomerQueryParams {
|
||||
@ -30,6 +34,10 @@ export interface CustomerQueryParams {
|
||||
keyword?: string
|
||||
customer_type?: string
|
||||
owner_personnel_id?: number
|
||||
/** 排序键(后端白名单:id 建档序 / rating_score 评分),默认 id 倒序 */
|
||||
order_by?: 'id' | 'rating_score'
|
||||
/** 排序方向,默认 desc */
|
||||
order?: 'asc' | 'desc'
|
||||
}
|
||||
|
||||
/** 新增客户(owner_personnel_id 为初始归属,可空:先建档后派归属也合法) */
|
||||
@ -57,6 +65,11 @@ export interface CustomerOwnershipParams {
|
||||
reason?: string | null
|
||||
}
|
||||
|
||||
/** 保存评级入参:只存总分(五项明细不落库,评分是档案属性而非审计事件) */
|
||||
export interface CustomerRatingParams {
|
||||
score: number
|
||||
}
|
||||
|
||||
/** 归属变更留痕条目(对应后端 `OwnershipChangeLogOut`;姓名可随人员消失,ID 固化) */
|
||||
export interface OwnershipLogItem {
|
||||
id: number
|
||||
@ -114,6 +127,14 @@ export function changeOwnership(
|
||||
return request({ url: `/v1/base/customers/${customerId}/ownership`, method: 'put', data })
|
||||
}
|
||||
|
||||
/** 保存评级(评级计算器「保存评分」;只存总分与评级时间,评分只走本端点) */
|
||||
export function rateCustomer(
|
||||
customerId: number,
|
||||
data: CustomerRatingParams,
|
||||
): Promise<CustomerItem> {
|
||||
return request({ url: `/v1/base/customers/${customerId}/rating`, method: 'put', data })
|
||||
}
|
||||
|
||||
/** 查某客户的归属变更留痕(分页,按变更时间倒序) */
|
||||
export function listOwnershipLogs(
|
||||
customerId: number,
|
||||
|
||||
@ -17,7 +17,6 @@ import {
|
||||
SetUp,
|
||||
Shop,
|
||||
Stamp,
|
||||
Star,
|
||||
Tickets,
|
||||
TrendCharts,
|
||||
User,
|
||||
@ -54,7 +53,6 @@ const ICONS = {
|
||||
businesses: Briefcase,
|
||||
shipments: Van,
|
||||
receipts: Money,
|
||||
rating: Star,
|
||||
ledger: Notebook,
|
||||
settlements: Calendar,
|
||||
'report-personal': DataLine,
|
||||
|
||||
@ -170,14 +170,7 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import('@/views/business/receipts/index.vue'),
|
||||
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 ④):核算台账读登录即可,月结写限管理员与营销后勤
|
||||
// (api 层 require_roles 兜底,页面层不再叠加 requiresAdmin)
|
||||
|
||||
553
frontend/src/views/base/customers/RatingCalculatorDialog.vue
Normal file
553
frontend/src/views/base/customers/RatingCalculatorDialog.vue
Normal file
@ -0,0 +1,553 @@
|
||||
<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 { handledError } from '@/utils/error'
|
||||
|
||||
/**
|
||||
* 客户评级计算器(需求 2.2.5 / 对照表 #25)· 客户管理行内弹窗版。
|
||||
*
|
||||
* 原独立页(/business/rating)并入客户管理:在客户行上点「评级计算」打开,
|
||||
* 五项打分实时判定等级,「保存评分」把总分写入客户档案(rating_score +
|
||||
* rating_time)。与独立页时代的差异:评分不再是过眼云烟 —— 保存为档案
|
||||
* 属性;五项明细与命中等级仍不落库(等级映射是可变判定表,回溯「当时
|
||||
* 命中的等级」需要快照设计,超出档案属性的范围)。
|
||||
*
|
||||
* 等级映射的增删改走 /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]
|
||||
}>()
|
||||
|
||||
// ==================== 等级判定(纯函数) ====================
|
||||
|
||||
/**
|
||||
* 等级判定:按 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))
|
||||
|
||||
// ==================== 弹窗打开 / 关闭 ====================
|
||||
|
||||
/** 打开时重置五项并重载等级配置,避免上一次打分的残留 */
|
||||
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
|
||||
return {
|
||||
score: target.rating_score,
|
||||
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 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(),
|
||||
})
|
||||
}
|
||||
</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> 分
|
||||
<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>
|
||||
@ -11,6 +11,7 @@ import { useDropdownOptions } from '@/composables/useDropdownOptions'
|
||||
import { usePagedList } from '@/composables/usePagedList'
|
||||
import { formatDateTime } from '@/utils/format'
|
||||
import { handledError } from '@/utils/error'
|
||||
import RatingCalculatorDialog from './RatingCalculatorDialog.vue'
|
||||
|
||||
/**
|
||||
* 客户管理(需求 2.1.3:客户档案 + 归属人员 + 归属变更留痕)。
|
||||
@ -21,6 +22,8 @@ import { handledError } from '@/utils/error'
|
||||
* - 归属变更提交前二次确认,文案必须包含「仅影响变更后核算,不回溯历史」
|
||||
* (对照表 #19 的业务口径)
|
||||
* - 归属变更记录为分页留痕表(抽屉内展示,按变更时间倒序)
|
||||
* - 评级计算器并入本页(原 /business/rating 独立页已移除):行内弹窗打分,
|
||||
* 保存评分写入档案字段 rating_score/rating_time
|
||||
*/
|
||||
const { confirmAndRun } = useConfirmAction()
|
||||
|
||||
@ -39,6 +42,8 @@ const { records, total, pageNum, pageSize, loading, load, search, onPageChange,
|
||||
keyword: filters.keyword,
|
||||
customer_type: filters.customer_type,
|
||||
owner_personnel_id: filters.owner_personnel_id,
|
||||
order_by: sortState.order_by,
|
||||
order: sortState.order,
|
||||
}),
|
||||
)
|
||||
|
||||
@ -49,6 +54,54 @@ function resetFilters(): void {
|
||||
search()
|
||||
}
|
||||
|
||||
// ==================== 排序(评分列) ====================
|
||||
|
||||
/**
|
||||
* 表头排序是查询参数而非本地排序:分页下本地排序只能排当前页。
|
||||
* 目前唯一可排序列是评分;el-table 第三次点击取消排序,回到建档序(id 倒序)。
|
||||
*/
|
||||
const sortState = reactive<{ order_by: 'id' | 'rating_score'; order: 'asc' | 'desc' }>({
|
||||
order_by: 'id',
|
||||
order: 'desc',
|
||||
})
|
||||
|
||||
function onSortChange({
|
||||
prop,
|
||||
order,
|
||||
}: {
|
||||
prop: string
|
||||
order: 'ascending' | 'descending' | null
|
||||
}): void {
|
||||
if (prop === 'rating_score' && order) {
|
||||
sortState.order_by = 'rating_score'
|
||||
sortState.order = order === 'ascending' ? 'asc' : 'desc'
|
||||
} else {
|
||||
sortState.order_by = 'id'
|
||||
sortState.order = 'desc'
|
||||
}
|
||||
search()
|
||||
}
|
||||
|
||||
// ==================== 评级计算 ====================
|
||||
|
||||
const ratingVisible = ref(false)
|
||||
const ratingTarget = ref<CustomerItem | null>(null)
|
||||
|
||||
function openRating(row: CustomerItem): void {
|
||||
ratingTarget.value = row
|
||||
ratingVisible.value = true
|
||||
}
|
||||
|
||||
/** 保存评分后回写列表:按评分排序时刷新(位置随分变化),否则原位更新不打乱浏览 */
|
||||
function onRatingSaved(item: CustomerItem): void {
|
||||
if (sortState.order_by === 'rating_score') {
|
||||
void load()
|
||||
return
|
||||
}
|
||||
const index = records.value.findIndex((record) => record.id === item.id)
|
||||
if (index !== -1) records.value[index] = item
|
||||
}
|
||||
|
||||
// ==================== 归属人员下拉数据源 ====================
|
||||
|
||||
interface PersonnelOption {
|
||||
@ -322,7 +375,7 @@ onMounted(() => {
|
||||
<div class="page-head">
|
||||
<h1 class="page-title">客户管理</h1>
|
||||
<p class="page-desc">
|
||||
客户档案与归属人员;归属变更只影响变更后的核算、不回溯历史,每次调整都在「变更记录」留痕
|
||||
客户档案与归属人员;归属变更只影响变更后的核算、不回溯历史,评分由「评级计算」维护并保存进档案
|
||||
</p>
|
||||
<span class="waterline"></span>
|
||||
</div>
|
||||
@ -385,8 +438,20 @@ onMounted(() => {
|
||||
:data="records"
|
||||
stripe
|
||||
empty-text="还没有客户 —— 点「新增客户」建第一个,或换个关键词再查"
|
||||
@sort-change="onSortChange"
|
||||
>
|
||||
<el-table-column prop="company_name" label="公司名称" min-width="180" />
|
||||
<el-table-column prop="rating_score" label="评分" width="88" align="center" sortable="custom">
|
||||
<template #default="{ row }">
|
||||
<span
|
||||
v-if="row.rating_score !== null"
|
||||
:title="row.rating_time ? `评级时间:${formatDateTime(row.rating_time)}` : undefined"
|
||||
>
|
||||
{{ row.rating_score }}
|
||||
</span>
|
||||
<span v-else class="cell-muted" title="尚未评级">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="owner_name" label="负责人" min-width="90" />
|
||||
<el-table-column prop="contact_person" label="联系人" min-width="90">
|
||||
<template #default="{ row }">
|
||||
@ -423,9 +488,10 @@ onMounted(() => {
|
||||
<span v-else class="cell-muted">(无归属)</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="260" fixed="right" align="center">
|
||||
<el-table-column label="操作" width="340" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<div class="table-actions">
|
||||
<el-button link type="primary" size="small" @click="openRating(row)">评级计算</el-button>
|
||||
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button link type="primary" size="small" @click="openOwnership(row)">
|
||||
归属变更
|
||||
@ -585,6 +651,13 @@ onMounted(() => {
|
||||
@size-change="onLogsSizeChange"
|
||||
/>
|
||||
</el-drawer>
|
||||
|
||||
<!-- 评级计算器(原 /business/rating 独立页并入;保存评分回写档案) -->
|
||||
<RatingCalculatorDialog
|
||||
v-model:visible="ratingVisible"
|
||||
:customer="ratingTarget"
|
||||
@saved="onRatingSaved"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@ -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>
|
||||
Loading…
Reference in New Issue
Block a user