Compare commits

...

2 Commits

Author SHA1 Message Date
郑博文
ac4ba7514b merge: 集成客户评级弹窗、公司信息精简与参数中文化
将 feat/settings-rating-brand(基于 927592b)合并到最新 main(d03ddad):
- 冲突收口:系统设置服务/契约、集成测试夹具、布局/路由/登录页、客户列表
- 迁移重编号:本分支未发布的三个迁移压成 V11(客户评级 + 站点信息 + 版本号),
  down_revision 接到 main 的 90653814a421,db/migration 相应改为 V11
- 站点图标上传与 logo 上传统一为同步 def,超限先按 file.size 拦截
2026-10-20 11:42:49 +08:00
郑博文
27533eb465 wip: 客户评级弹窗化/公司信息精简/参数中文化/品牌站点同步(基于 927592b) 2026-10-20 11:06:01 +08:00
39 changed files with 2781 additions and 468 deletions

View File

@ -0,0 +1,212 @@
"""V11 客户评级落库与站点信息
Revision ID: c4e2a86f9b13
Revises: 90653814a421
Create Date: 2026-10-19 10:00:00.000000
与上游 V7–V10(台账/月结/调整单/口径/唯一键/预警参数)合并时重编号为 V11:
本分支三个迁移(原 V7 客户评级 + V8 公司档案精简 + V9 图片版本号)从未发布,
压成单条,最终结构 = 评级表 + 最终版站点/公司信息字段(不含中途增删的
副标题/信用代码/地址/电话/邮箱)。
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision: str = "c4e2a86f9b13"
down_revision: str | Sequence[str] | None = "90653814a421"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""应用迁移(双主题一次交付)。
- 客户评级弹窗化:评分每客户落库(customer_ratings,一行一客户快照)
- 公司信息补充与精简:标语/页脚/备案号/站点标题/站点图标 + 图片版本号
"""
op.create_table(
"customer_ratings",
sa.Column(
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
),
sa.Column(
"customer_id",
mysql.BIGINT(unsigned=True),
nullable=False,
comment="客户ID(唯一:一客户一份当前评级)",
),
sa.Column(
"score_ship_amount",
sa.Numeric(precision=5, scale=1),
server_default="0",
nullable=False,
comment="发货额评分(0-20)",
),
sa.Column(
"score_receipt_rate",
sa.Numeric(precision=5, scale=1),
server_default="0",
nullable=False,
comment="回款率评分(0-20)",
),
sa.Column(
"score_profit_rate",
sa.Numeric(precision=5, scale=1),
server_default="0",
nullable=False,
comment="毛利率评分(0-20)",
),
sa.Column(
"score_contract_cooperation",
sa.Numeric(precision=5, scale=1),
server_default="0",
nullable=False,
comment="合同履约配合能力评分(0-20)",
),
sa.Column(
"score_site_construction",
sa.Numeric(precision=5, scale=1),
server_default="0",
nullable=False,
comment="现场规范施工能力评分(0-20)",
),
sa.Column(
"total_score",
sa.Numeric(precision=5, scale=1),
nullable=False,
comment="总分(服务端重算,五项之和 0-100)",
),
sa.Column(
"grade_name",
sa.String(length=32),
nullable=True,
comment="评级等级(保存时按等级映射快照固化,无外键;未命中/等级表空为 NULL)",
),
sa.Column("remark", sa.String(length=255), nullable=True, comment="备注"),
sa.Column(
"scorer_id",
mysql.BIGINT(unsigned=True),
nullable=True,
comment="评级操作人ID(账号删除后置空)",
),
sa.Column(
"scorer_name",
sa.String(length=64),
nullable=False,
comment="评级操作人用户名(冗余固化)",
),
sa.Column(
"create_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP"),
nullable=False,
comment="创建时间",
),
sa.Column(
"update_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
nullable=False,
comment="更新时间",
),
sa.ForeignKeyConstraint(
["customer_id"],
["customers.id"],
name=op.f("fk_customer_ratings_customer_id"),
ondelete="RESTRICT",
),
sa.ForeignKeyConstraint(
["scorer_id"],
["users.id"],
name=op.f("fk_customer_ratings_scorer_id"),
ondelete="SET NULL",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_customer_ratings")),
sa.UniqueConstraint("customer_id", name=op.f("uk_customer_ratings_customer_id")),
comment="客户评级表(每客户一行;等级名为快照固化无外键,删等级不伤已评级记录)",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_general_ci",
mysql_engine="InnoDB",
)
op.add_column(
"system_settings",
sa.Column(
"site_title",
sa.String(length=64),
nullable=True,
comment="站点标题(浏览器标签页与侧边栏副标题;null=内置默认)",
),
)
op.add_column(
"system_settings",
sa.Column(
"brand_slogan",
sa.String(length=64),
nullable=True,
comment="品牌标语(登录页与品牌预览;null=内置默认)",
),
)
op.add_column(
"system_settings",
sa.Column(
"footer_text",
sa.String(length=64),
nullable=True,
comment="页脚版权文本(登录页;null=自动生成 © 公司名 · 内部系统)",
),
)
op.add_column(
"system_settings",
sa.Column(
"icp_no",
sa.String(length=32),
nullable=True,
comment="ICP 备案号(登录页页脚,空=不显示)",
),
)
op.add_column(
"system_settings",
sa.Column(
"favicon_path",
sa.String(length=255),
nullable=True,
comment="站点图标相对路径(文件存 uploads/favicon/,null=使用内置 favicon.svg)",
),
)
op.add_column(
"system_settings",
sa.Column(
"logo_version",
sa.Integer(),
server_default="0",
nullable=False,
comment="logo 版本号(每次上传 +1;图片 URL 用它破缓存——同扩展名覆盖时路径不变,update_time 也不变)",
),
)
op.add_column(
"system_settings",
sa.Column(
"favicon_version",
sa.Integer(),
server_default="0",
nullable=False,
comment="站点图标版本号(每次上传 +1;图片 URL 用它破缓存)",
),
)
def downgrade() -> None:
"""回滚迁移:先删评级表(引用 customers/users),再逆序移除站点信息列。"""
op.drop_table("customer_ratings")
op.drop_column("system_settings", "favicon_version")
op.drop_column("system_settings", "logo_version")
op.drop_column("system_settings", "favicon_path")
op.drop_column("system_settings", "icp_no")
op.drop_column("system_settings", "footer_text")
op.drop_column("system_settings", "brand_slogan")
op.drop_column("system_settings", "site_title")

View File

@ -24,7 +24,8 @@ from app.schemas.customer import (
CustomerUpdateIn, CustomerUpdateIn,
OwnershipChangeLogOut, OwnershipChangeLogOut,
) )
from app.services import customer_service from app.schemas.rating import CustomerRatingOut, CustomerRatingSaveIn
from app.services import customer_service, rating_service
router = APIRouter(prefix="/customers", tags=["客户管理"]) router = APIRouter(prefix="/customers", tags=["客户管理"])
@ -140,3 +141,45 @@ def list_ownership_logs(
) -> ApiResponse[PageResult[OwnershipChangeLogOut]]: ) -> ApiResponse[PageResult[OwnershipChangeLogOut]]:
"""查某客户的归属变更留痕(分页,按变更时间倒序,附新旧归属人员姓名)。""" """查某客户的归属变更留痕(分页,按变更时间倒序,附新旧归属人员姓名)。"""
return ok(customer_service.list_ownership_logs(session, customer_id, query)) return ok(customer_service.list_ownership_logs(session, customer_id, query))
@router.get(
"/{customer_id}/rating",
response_model=ApiResponse[CustomerRatingOut],
summary="客户当前评级",
)
def get_customer_rating(
session: DbSession,
_: CurrentUser,
customer_id: Annotated[int, Path(ge=1)],
) -> ApiResponse[CustomerRatingOut]:
"""查某客户当前评级。
未评级返回 ``data: null``(这是正常态而非错误);客户不存在才 404。
"""
return ok(rating_service.get_customer_rating(session, customer_id))
@router.put(
"/{customer_id}/rating",
response_model=ApiResponse[CustomerRatingOut],
summary="保存客户评级",
)
def save_customer_rating(
session: DbSession,
current_user: CurrentUser,
customer_id: Annotated[int, Path(ge=1)],
payload: CustomerRatingSaveIn,
meta: RequestMetaDep,
) -> ApiResponse[CustomerRatingOut]:
"""保存客户评级(首次创建、再次保存整体覆盖)。
五项分值 0-20 由服务层在审计上下文内校验,总分与等级由服务端重算;
等级按保存时点的映射快照固化,等级表后续增删改不影响该客户结论。
"""
return ok(
rating_service.save_customer_rating(
session, customer_id, payload, operator=current_user, meta=meta
),
message="评级已保存",
)

View File

@ -1,11 +1,11 @@
"""系统设置接口(公司信息)。 """系统设置接口(公司信息 + 站点信息)。
`GET /public` 与 `GET /logo/public` **无鉴权**:登录页渲染公司名/logo 时 `GET /public`、`GET /logo/public`、`GET /favicon/public` **无鉴权**:登录页
尚无令牌。响应经 `SettingPublicOut` 白名单收敛(见 schemas/setting.py), 渲染公司名/logo、浏览器加载标签页图标时尚无令牌。响应经
仅暴露两个非敏感展示字段。 `SettingPublicOut` 白名单收敛(见 schemas/setting.py),仅暴露展示必需字段。
`/logo/public` 返回文件流而非统一信封——与参数导出同一例外口径 `/logo/public` 与 `/favicon/public` 返回文件流而非统一信封——与参数导出
(二进制响应套 JSON 信封无意义)。 同一例外口径(二进制响应套 JSON 信封无意义)。
""" """
from fastapi import APIRouter, Response, UploadFile from fastapi import APIRouter, Response, UploadFile
@ -20,6 +20,7 @@ from app.services import system_setting_service as service
router = APIRouter(prefix="/settings", tags=["系统管理 · 公司信息"]) router = APIRouter(prefix="/settings", tags=["系统管理 · 公司信息"])
_LOGO_MEDIA_TYPE = {"png": "image/png", "jpg": "image/jpeg"} _LOGO_MEDIA_TYPE = {"png": "image/png", "jpg": "image/jpeg"}
_FAVICON_MEDIA_TYPE = {"png": "image/png", "jpg": "image/jpeg", "ico": "image/x-icon"}
@router.get("", response_model=ApiResponse[SettingOut], summary="公司信息") @router.get("", response_model=ApiResponse[SettingOut], summary="公司信息")
@ -28,14 +29,14 @@ def get_settings(session: DbSession, _: AdminUser) -> ApiResponse[SettingOut]:
return ok(service.to_out(service.get_setting(session))) return ok(service.to_out(service.get_setting(session)))
@router.put("", response_model=ApiResponse[SettingOut], summary="更新公司名称") @router.put("", response_model=ApiResponse[SettingOut], summary="更新公司信息")
def update_settings( def update_settings(
payload: SettingUpdateIn, payload: SettingUpdateIn,
session: DbSession, session: DbSession,
current_user: AdminUser, current_user: AdminUser,
meta: RequestMetaDep, meta: RequestMetaDep,
) -> ApiResponse[SettingOut]: ) -> ApiResponse[SettingOut]:
"""更新公司名称(审计留痕)。""" """更新公司信息(部分更新;审计留痕)。"""
return ok( return ok(
service.update_company(session, payload, operator=current_user, meta=meta), service.update_company(session, payload, operator=current_user, meta=meta),
message="已保存", message="已保存",
@ -55,7 +56,7 @@ def upload_logo(
它们卡死事件循环(项目红线:全栈同步路由,FastAPI 自动进线程池)。 它们卡死事件循环(项目红线:全栈同步路由,FastAPI 自动进线程池)。
大小先用 `file.size` 拦截,避免超限文件整体读进内存。 大小先用 `file.size` 拦截,避免超限文件整体读进内存。
""" """
if file.size is not None and file.size > service.LOGO_MAX_BYTES: if file.size is not None and file.size > service.ASSET_MAX_BYTES:
raise BadRequestError("logo 文件不能超过 2MB") raise BadRequestError("logo 文件不能超过 2MB")
content = file.file.read() content = file.file.read()
ext = (file.filename or "").rsplit(".", 1)[-1] ext = (file.filename or "").rsplit(".", 1)[-1]
@ -65,6 +66,27 @@ def upload_logo(
) )
@router.post("/favicon", response_model=ApiResponse[SettingOut], summary="上传站点图标")
def upload_favicon(
file: UploadFile,
session: DbSession,
current_user: AdminUser,
meta: RequestMetaDep,
) -> ApiResponse[SettingOut]:
"""上传站点图标(png/jpg/ico ≤2MB;固定名覆盖,旧扩展名文件清理)。
同步 `def` 与 logo 上传同口径:磁盘写与审计提交均为阻塞操作。
"""
if file.size is not None and file.size > service.ASSET_MAX_BYTES:
raise BadRequestError("站点图标文件不能超过 2MB")
content = file.file.read()
ext = (file.filename or "").rsplit(".", 1)[-1]
return ok(
service.save_favicon(session, content, ext, operator=current_user, meta=meta),
message="站点图标已更新",
)
@router.get("/public", response_model=ApiResponse[SettingPublicOut], summary="公司信息(匿名)") @router.get("/public", response_model=ApiResponse[SettingPublicOut], summary="公司信息(匿名)")
def get_settings_public(session: DbSession, response: Response) -> ApiResponse[SettingPublicOut]: def get_settings_public(session: DbSession, response: Response) -> ApiResponse[SettingPublicOut]:
"""公司信息(登录页消费,无鉴权)。短缓存减轻匿名端点压力。""" """公司信息(登录页消费,无鉴权)。短缓存减轻匿名端点压力。"""
@ -81,3 +103,14 @@ def get_logo_public(session: DbSession) -> FileResponse:
media_type=_LOGO_MEDIA_TYPE.get(ext, "application/octet-stream"), media_type=_LOGO_MEDIA_TYPE.get(ext, "application/octet-stream"),
headers={"Cache-Control": "no-cache"}, headers={"Cache-Control": "no-cache"},
) )
@router.get("/favicon/public", summary="站点图标(匿名)")
def get_favicon_public(session: DbSession) -> FileResponse:
"""站点图标文件流(无鉴权)。no-cache:图标替换后由版本参数破缓存兜底。"""
path, ext = service.read_favicon(session)
return FileResponse(
path,
media_type=_FAVICON_MEDIA_TYPE.get(ext, "application/octet-stream"),
headers={"Cache-Control": "no-cache"},
)

View File

@ -14,6 +14,7 @@ from app.models.base import Base, TimestampMixin
from app.models.business import Business, BusinessItem, BusinessStatusLog, SecondaryAllocation from app.models.business import Business, BusinessItem, BusinessStatusLog, SecondaryAllocation
from app.models.business_role import BusinessRole from app.models.business_role import BusinessRole
from app.models.customer import Customer, OwnershipChangeLog from app.models.customer import Customer, OwnershipChangeLog
from app.models.customer_rating import CustomerRating
from app.models.login_log import LoginLog from app.models.login_log import LoginLog
from app.models.operation_log import OperationLog from app.models.operation_log import OperationLog
from app.models.param import ParamChangeBatch, ParamDefinition, ParamValue from app.models.param import ParamChangeBatch, ParamDefinition, ParamValue
@ -38,6 +39,7 @@ __all__ = [
"BusinessRole", "BusinessRole",
"BusinessStatusLog", "BusinessStatusLog",
"Customer", "Customer",
"CustomerRating",
"CustomerRatingGrade", "CustomerRatingGrade",
"LoginLog", "LoginLog",
"MonthlySettlement", "MonthlySettlement",

View File

@ -0,0 +1,77 @@
"""客户评级模型(需求 2.2.5)。
评级从「纯计算器不落库」升级为**每客户一行落库**(2026-10-19 确认):
客户管理页要罗列评级列并按其排序,评分必须可回溯。设计要点:
- ``customer_id`` 唯一键 + RESTRICT 外键:一客户一份当前评级(快照语义,
非流水),删除客户由服务层先删评级行再删主行(RESTRICT 兜底并发窗口)
- ``grade_name`` 为**快照固化、无外键**:等级映射表仍允许自由增删清空
(rating_service 的既有前提),删等级不能伤及已评级客户的历史结论 ——
与归属留痕「ID/人名固化、不回溯」同一论证口径;等级未命中时为 NULL
- ``scorer_id`` 外键 SET NULL + ``scorer_name`` 冗余固化:账号删除后
记录仍可读(同 ownership_change_logs 的操作人口径)
- 分值列 DECIMAL(5,1):UI 整数 0-20,DECIMAL 留十进制余地;总分由
**服务端重算**,不采信前端回传
"""
from decimal import Decimal
from sqlalchemy import ForeignKey, Numeric, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import BIGINT_UNSIGNED, Base, TimestampMixin, mysql_table_args
class CustomerRating(Base, TimestampMixin):
"""客户评级表:每客户一行(uk_customer_ratings_customer_id)。
五项各 0-20 分、总分 = 五项之和(0-100),判定规则同等级映射表
(按 sort_order 升序首个 min_score ≤ 总分,见 rating_service.resolve_grade)。
"""
__tablename__ = "customer_ratings"
__table_args__ = mysql_table_args(
"客户评级表(每客户一行;等级名为快照固化无外键,删等级不伤已评级记录)",
UniqueConstraint("customer_id", name="uk_customer_ratings_customer_id"),
)
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
customer_id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("customers.id", ondelete="RESTRICT"),
nullable=False,
comment="客户ID(唯一:一客户一份当前评级)",
)
score_ship_amount: Mapped[Decimal] = mapped_column(
Numeric(5, 1), nullable=False, server_default="0", comment="发货额评分(0-20)"
)
score_receipt_rate: Mapped[Decimal] = mapped_column(
Numeric(5, 1), nullable=False, server_default="0", comment="回款率评分(0-20)"
)
score_profit_rate: Mapped[Decimal] = mapped_column(
Numeric(5, 1), nullable=False, server_default="0", comment="毛利率评分(0-20)"
)
score_contract_cooperation: Mapped[Decimal] = mapped_column(
Numeric(5, 1), nullable=False, server_default="0", comment="合同履约配合能力评分(0-20)"
)
score_site_construction: Mapped[Decimal] = mapped_column(
Numeric(5, 1), nullable=False, server_default="0", comment="现场规范施工能力评分(0-20)"
)
total_score: Mapped[Decimal] = mapped_column(
Numeric(5, 1), nullable=False, comment="总分(服务端重算,五项之和 0-100)"
)
grade_name: Mapped[str | None] = mapped_column(
String(32),
comment="评级等级(保存时按等级映射快照固化,无外键;未命中/等级表空为 NULL)",
)
remark: Mapped[str | None] = mapped_column(String(255), comment="备注")
scorer_id: Mapped[int | None] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("users.id", ondelete="SET NULL"),
comment="评级操作人ID(账号删除后置空)",
)
scorer_name: Mapped[str] = mapped_column(
String(64), nullable=False, comment="评级操作人用户名(冗余固化)"
)

View File

@ -1,9 +1,10 @@
"""客户评级等级配置模型(需求 2.2.5)。 """客户评级等级配置模型(需求 2.2.5)。
评级计算器定位为「仅打分工具,不影响绩效金额」(对照表 #25):评分记录 评级定位为「仅打分工具,不影响绩效金额」(对照表 #25):评分自 2026-10-19
不落库,等级**映射关系**可配置由本表承载。判定规则:按 sort_order 升序取 起每客户落库于 customer_ratings(快照固化等级名),本表承载可配置的等级
第一个 min_score ≤ 总分的等级 —— 单边判定避免区间边界(如 75 分)在 **映射关系**。判定规则:按 sort_order 升序取第一个 min_score ≤ 总分的
「≥75 且 ≤89」与「>74 且 <75」两套写法间的重叠/遗漏歧义;max_score 仅展示。 等级 —— 单边判定避免区间边界(如 75 分)在「≥75 且 ≤89」与「>74 且 <75」
两套写法间的重叠/遗漏歧义;max_score 仅展示。
""" """
from decimal import Decimal from decimal import Decimal

View File

@ -1,31 +1,67 @@
"""系统设置模型:公司信息单例行。 """系统设置模型:公司信息 + 站点信息单例行。
与 M1 计提参数体系刻意分离:公司名称/logo 是「展示事实」,没有按生效日期 与 M1 计提参数体系刻意分离:公司名称/logo 是「展示事实」,没有按生效日期
时点化演算的语义(参数体系按 effective_date 取值快照),且参数值列 时点化演算的语义(参数体系按 effective_date 取值快照),且参数值列
value_text 为 String(64) 装不下;故独立单行表。logo 只存相对路径——文件在 value_text 为 String(64) 装不下;故独立单行表。logo/favicon 只存相对路径——
`backend/uploads/logo/`,独立于数据库备份体积且可单独替换(见 文件在 `backend/uploads/{logo,favicon}/`,独立于数据库备份体积且可单独替换
system_setting_service)。整个系统恒为单行(迁移种子 id=1 保底,upsert 维护)。 (见 system_setting_service)。整个系统恒为单行(迁移种子 id=1 保底)。
2026-10-20 精简(用户确认):企业档案字段(信用代码/地址/电话/邮箱)与
系统副标题删除,新增**站点标题**(浏览器标签页/侧边栏副标题)与**站点图标**
(浏览器标签页 favicon,独立于登录页 logo)。
""" """
from sqlalchemy import String from sqlalchemy import Integer, String
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import BIGINT_UNSIGNED, Base, TimestampMixin, mysql_table_args from app.models.base import BIGINT_UNSIGNED, Base, TimestampMixin, mysql_table_args
class SystemSetting(Base, TimestampMixin): class SystemSetting(Base, TimestampMixin):
"""系统设置表:本期仅公司信息(公司名称 / logo 路径),全系统单行 id=1。""" """系统设置表:公司信息 + 站点信息,全系统单行 id=1。
brand_slogan/footer_text/icp_no/site_title 均可空:null 表示前端回退
内置默认值,存量行为零变化。
"""
__tablename__ = "system_settings" __tablename__ = "system_settings"
__table_args__ = mysql_table_args("系统设置表(公司信息,全系统单行)") __table_args__ = mysql_table_args("系统设置表(公司信息与站点信息,全系统单行)")
id: Mapped[int] = mapped_column( id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID" BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
) )
company_name: Mapped[str] = mapped_column( company_name: Mapped[str] = mapped_column(
String(64), nullable=False, comment="公司名称(登录页与导出页眉展示)" String(64), nullable=False, comment="公司名称(登录页/侧边栏/浏览器标题展示)"
) )
logo_path: Mapped[str | None] = mapped_column( logo_path: Mapped[str | None] = mapped_column(
String(255), String(255),
comment="logo 相对路径(文件存 uploads/logo/,null=未上传使用默认)", comment="公司 logo 相对路径(文件存 uploads/logo/,null=未上传使用默认)",
)
logo_version: Mapped[int] = mapped_column(
Integer,
nullable=False,
server_default="0",
comment="logo 版本号(每次上传 +1;图片 URL 用它破缓存——同扩展名覆盖时路径不变,update_time 也不变)",
)
site_title: Mapped[str | None] = mapped_column(
String(64), comment="站点标题(浏览器标签页与侧边栏副标题;null=内置默认)"
)
brand_slogan: Mapped[str | None] = mapped_column(
String(64), comment="品牌标语(登录页与品牌预览;null=内置默认)"
)
footer_text: Mapped[str | None] = mapped_column(
String(64), comment="页脚版权文本(登录页;null=自动生成 © 公司名 · 内部系统)"
)
icp_no: Mapped[str | None] = mapped_column(
String(32), comment="ICP 备案号(登录页页脚,空=不显示)"
)
favicon_path: Mapped[str | None] = mapped_column(
String(255),
comment="站点图标相对路径(文件存 uploads/favicon/,null=跟随公司 logo / 内置 favicon.svg)",
)
favicon_version: Mapped[int] = mapped_column(
Integer,
nullable=False,
server_default="0",
comment="站点图标版本号(每次上传 +1;图片 URL 用它破缓存)",
) )

View File

@ -0,0 +1,33 @@
"""客户评级数据访问(每客户一行)。
本层只 flush 不 commit —— 事务边界由 services 层控制。
"""
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models import CustomerRating
# ==================== 读取 ====================
def get_by_customer_id(session: Session, customer_id: int) -> CustomerRating | None:
"""查某客户的当前评级(uk_customer_ratings_customer_id 保证至多一行)。"""
stmt = select(CustomerRating).where(CustomerRating.customer_id == customer_id)
return session.execute(stmt).scalar_one_or_none()
# ==================== 写入 ====================
def create(session: Session, rating: CustomerRating) -> CustomerRating:
"""新增评级行。只 flush 以取得自增 id。"""
session.add(rating)
session.flush()
return rating
def delete(session: Session, rating: CustomerRating) -> None:
"""删除评级行(客户物理删除前的联动清理,调用方先校验业务引用)。"""
session.delete(rating)
session.flush()

View File

@ -6,7 +6,7 @@
from sqlalchemy import ColumnElement, Row, Select, func, or_, select from sqlalchemy import ColumnElement, Row, Select, func, or_, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.models import Customer, OwnershipChangeLog, Personnel from app.models import Customer, CustomerRating, OwnershipChangeLog, Personnel
from app.models.customer import OWNER_TYPE_CUSTOMER from app.models.customer import OWNER_TYPE_CUSTOMER
from app.repositories import ownership_log_repository from app.repositories import ownership_log_repository
from app.repositories.query_utils import fuzzy from app.repositories.query_utils import fuzzy
@ -31,7 +31,7 @@ def count_business_refs(session: Session, customer_id: int) -> int:
def get_with_owner_name(session: Session, customer_id: int) -> Row | None: def get_with_owner_name(session: Session, customer_id: int) -> Row | None:
"""查单个客户并带出归属人员姓名(详情、编辑回显与归属变更共用)。""" """查单个客户并带出归属人员姓名与评级快照(详情/编辑回显/归属变更共用)。"""
stmt = _base_select().where(Customer.id == customer_id) stmt = _base_select().where(Customer.id == customer_id)
return session.execute(stmt).first() return session.execute(stmt).first()
@ -53,12 +53,14 @@ def list_customers(
keyword: str | None = None, keyword: str | None = None,
customer_type: str | None = None, customer_type: str | None = None,
owner_personnel_id: int | None = None, owner_personnel_id: int | None = None,
order_field: str | None = None,
order_type: str | None = None,
) -> list[Row]: ) -> list[Row]:
"""分页查询客户,行内容为 (客户, 归属人员姓名)。 """分页查询客户,行内容为 (客户, 归属人员姓名, 评级总分, 评级等级)。
归属人员外键非空但统一用 outerjoin:人员行不可考(被物理删除的脏数据) 归属人员/评级行均用 outerjoin:关联行不可考(人员被物理删除、客户
不应让客户行整个查不出来(同 personnel_repository 的韧性口径)。 未评级)不应让客户行整个查不出来(同 personnel_repository 韧性口径)。
排序按 id 倒序:客户无排序字段,新增的靠前符合列表页习惯。 默认排序按 id 倒序:新增的靠前符合列表页习惯;评级排序见 ``_apply_order``。
""" """
stmt = _apply_filters( stmt = _apply_filters(
_base_select(), _base_select(),
@ -66,7 +68,8 @@ def list_customers(
customer_type=customer_type, customer_type=customer_type,
owner_personnel_id=owner_personnel_id, owner_personnel_id=owner_personnel_id,
) )
stmt = stmt.order_by(Customer.id.desc()).offset(offset).limit(limit) stmt = _apply_order(stmt, order_field=order_field, order_type=order_type)
stmt = stmt.offset(offset).limit(limit)
return list(session.execute(stmt).all()) return list(session.execute(stmt).all())
@ -120,12 +123,38 @@ def count_ownership_logs(session: Session, *, customer_id: int) -> int:
def _base_select() -> Select: def _base_select() -> Select:
"""客户主表外联归属人员姓名。""" """客户主表外联归属人员姓名与评级快照(等级名/总分,列表评级列免 N+1)。"""
return select(Customer, Personnel.personnel_name).outerjoin( return (
Personnel, Customer.owner_personnel_id == Personnel.id select(
Customer,
Personnel.personnel_name,
CustomerRating.total_score,
CustomerRating.grade_name,
)
.outerjoin(Personnel, Customer.owner_personnel_id == Personnel.id)
.outerjoin(CustomerRating, CustomerRating.customer_id == Customer.id)
) )
def _apply_order(stmt: Select, *, order_field: str | None, order_type: str | None) -> Select:
"""应用列表排序。默认 id 倒序;order_field=rating 按评级总分排序。
等级由总分判定、与总分同向,故按总分即等价按等级排序。MySQL 没有
``NULLS LAST`` 语法,用布尔键 ``total_score IS NULL`` 升序模拟 ——
未评级行恒在末尾,正序/倒序一致;同分以 id 倒序兜底保证次序稳定。
非法字段/方向不落到 SQL(schema Literal 已在入口拦 400),此处静默
回退默认排序作为纵深防御。
"""
if order_field == "rating" and order_type in ("asc", "desc"):
score_order = (
CustomerRating.total_score.asc()
if order_type == "asc"
else CustomerRating.total_score.desc()
)
return stmt.order_by(CustomerRating.total_score.is_(None), score_order, Customer.id.desc())
return stmt.order_by(Customer.id.desc())
def _apply_filters( def _apply_filters(
stmt: Select, stmt: Select,
*, *,

View File

@ -32,8 +32,9 @@ def list_all(session: Session, *, for_update: bool = False) -> list[CustomerRati
def get_by_id(session: Session, grade_id: int) -> CustomerRatingGrade | None: def get_by_id(session: Session, grade_id: int) -> CustomerRatingGrade | None:
"""按主键查询。 """按主键查询。
等级表无下游引用(评级是纯计算器不落评分记录),无需 ``for_update`` 等级表对客户评级无**外键**引用(评级行快照固化等级名),无需
锁行 —— product_repository 锁行防的是并发引用悬空,此处不存在该风险。 ``for_update`` 锁行 —— product_repository 锁行防的是并发引用悬空,
此处不存在该风险。
""" """
return session.get(CustomerRatingGrade, grade_id) return session.get(CustomerRatingGrade, grade_id)
@ -60,6 +61,6 @@ def create(session: Session, grade: CustomerRatingGrade) -> CustomerRatingGrade:
def delete(session: Session, grade: CustomerRatingGrade) -> None: def delete(session: Session, grade: CustomerRatingGrade) -> None:
"""物理删除等级。等级表无下游引用,调用方无需引用保护校验。""" """物理删除等级。等级表对客户评级无外键引用,调用方无需引用保护校验。"""
session.delete(grade) session.delete(grade)
session.flush() session.flush()

View File

@ -1,6 +1,7 @@
"""客户管理的请求/响应模型(需求 2.1.3)。""" """客户管理的请求/响应模型(需求 2.1.3)。"""
from datetime import datetime from datetime import datetime
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
@ -13,6 +14,10 @@ class CustomerOut(BaseModel):
owner_personnel_name 由 LEFT JOIN 带出:归属人员不可考(被物理删除的 owner_personnel_name 由 LEFT JOIN 带出:归属人员不可考(被物理删除的
脏数据)时为 null,owner_personnel_id 仍返回原值供追溯 —— 与归属留痕 脏数据)时为 null,owner_personnel_id 仍返回原值供追溯 —— 与归属留痕
「ID 固化、姓名随联表」同一口径。 「ID 固化、姓名随联表」同一口径。
rating_total / rating_grade_name 由 LEFT JOIN customer_ratings 带出
(列表评级列免 N+1):未评级时均为 null;总分按分值红线以字符串出参,
等级为保存时点的快照固化值(见 models/customer_rating.py)。
""" """
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
@ -28,6 +33,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_total: str | None = None
rating_grade_name: str | None = None
class CustomerCreateIn(BaseModel): class CustomerCreateIn(BaseModel):
@ -97,6 +104,14 @@ class CustomerQuery(PageQuery):
) )
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筛选")
# 排序白名单(全项目首个列表排序参数):非法值由 Literal 拦下、经全局
# 异常处理器转 400 —— 不把排序字段透传给 SQL,避免注入面
order_field: Literal["rating"] | None = Field(
default=None, description="排序字段白名单:rating=客户评级总分(等级由总分判定、同向)"
)
order_type: Literal["asc", "desc"] | None = Field(
default=None, description="排序方向:asc 正序 / desc 倒序(未评级恒在末尾)"
)
class CustomerOwnershipIn(BaseModel): class CustomerOwnershipIn(BaseModel):

View File

@ -77,3 +77,55 @@ class RatingGradeUpdateIn(RatingGradeCreateIn):
前端表单全量提交,故为全量替换语义;等级表无启停状态列,字段与新增一致。 前端表单全量提交,故为全量替换语义;等级表无启停状态列,字段与新增一致。
""" """
class CustomerRatingOut(BaseModel):
"""客户评级响应(每客户一行;总分与分值按红线以字符串出参)。
grade_name 是保存时点按等级映射**快照固化**的值(无外键):等级表后续
增删改不影响已保存的评级结论;为 NULL 表示保存时未命中任何等级
(总分低于全部下限或等级表被清空)。
"""
customer_id: int
score_ship_amount: str
score_receipt_rate: str
score_profit_rate: str
score_contract_cooperation: str
score_site_construction: str
total_score: str
grade_name: str | None = None
remark: str | None = None
scorer_name: str
create_time: datetime
update_time: datetime
class CustomerRatingSaveIn(BaseModel):
"""保存客户评级(每客户一行,整体替换语义)。
五项分值为 0-20 的数字字符串(schema 只拦格式;0≤分≤20 与总分等级
判定在 service 层审计上下文内完成 —— 失败的 400 同样留痕)。总分由
服务端按五项重算,**不采信前端回传的总分**。
"""
model_config = ConfigDict(str_strip_whitespace=True)
score_ship_amount: str = Field(description="发货额评分(0-20 数字字符串)")
score_receipt_rate: str = Field(description="回款率评分(0-20 数字字符串)")
score_profit_rate: str = Field(description="毛利率评分(0-20 数字字符串)")
score_contract_cooperation: str = Field(description="合同履约配合能力评分(0-20 数字字符串)")
score_site_construction: str = Field(description="现场规范施工能力评分(0-20 数字字符串)")
remark: str | None = Field(default=None, max_length=255, description="备注")
@field_validator(
"score_ship_amount",
"score_receipt_rate",
"score_profit_rate",
"score_contract_cooperation",
"score_site_construction",
)
@classmethod
def _check_score(cls, value: str) -> str:
"""单项分值须为有限数字(值域 0-20 由 service 层判定)。"""
return _parse_score(value, label="评分")

View File

@ -1,34 +1,59 @@
"""系统设置(公司信息)请求/响应模型。 """系统设置(公司信息 + 站点信息)请求/响应模型。
`SettingPublicOut` 与 `SettingOut` **刻意不共用**:public 端点无鉴权, `SettingPublicOut` 与 `SettingOut` **刻意不共用**:public 端点无鉴权,
响应字段必须白名单显式列出——将来往 SettingOut 加敏感字段时不会 响应字段必须白名单显式列出——将来往 SettingOut 加敏感字段时不会
顺着复用链泄漏到匿名端点。 顺着复用链泄漏到匿名端点。
2026-10-20 精简(用户确认):删除系统副标题与企业档案字段(信用代码/
地址/电话/邮箱);新增站点标题(浏览器标签页/侧边栏副标题)与站点图标
(浏览器标签页 favicon)。public 端点只需登录页/标签页展示字段,两者
恰好同集合,但仍保留独立 schema 作为白名单防线。
""" """
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
class SettingOut(BaseModel): class SettingOut(BaseModel):
"""公司信息(管理端)。""" """公司信息与站点信息(管理端)。"""
company_name: str company_name: str
logo_url: str | None = None logo_url: str | None = None
site_title: str | None = None
brand_slogan: str | None = None
footer_text: str | None = None
icp_no: str | None = None
favicon_url: str | None = None
class SettingPublicOut(BaseModel): class SettingPublicOut(BaseModel):
"""公司信息(匿名端点白名单):登录页展示所需的两个字段。""" """公司信息与站点信息(匿名端点白名单):登录页/浏览器标签页展示所需字段。"""
company_name: str company_name: str
logo_url: str | None = None logo_url: str | None = None
site_title: str | None = None
brand_slogan: str | None = None
footer_text: str | None = None
icp_no: str | None = None
favicon_url: str | None = None
class SettingUpdateIn(BaseModel): class SettingUpdateIn(BaseModel):
"""公司信息更新。 """公司信息与站点信息更新(**部分更新**语义)。
str_strip_whitespace:全空白名称先剥再校验——否则 `" "` 过 min_length=1 str_strip_whitespace:全空白名称先剥再校验——否则 `" "` 过 min_length=1
后由服务层 strip 成空串入库(200 成功但 public 端点返回空公司名)。 后由服务层 strip 成空串入库(200 成功但 public 端点返回空公司名)。
仅提交的字段生效(``exclude_unset``),未提交或显式 null 的字段保持
不变;可选文本传空串等价清空(落库为 null、前端回退内置默认)。
company_name 若提交必须非空(DB 非空约束)。
""" """
model_config = ConfigDict(str_strip_whitespace=True) model_config = ConfigDict(str_strip_whitespace=True)
company_name: str = Field(min_length=1, max_length=64, description="公司名称") company_name: str | None = Field(
default=None, min_length=1, max_length=64, description="公司名称(提交则须非空)"
)
site_title: str | None = Field(default=None, max_length=64, description="站点标题")
brand_slogan: str | None = Field(default=None, max_length=64, description="品牌标语")
footer_text: str | None = Field(default=None, max_length=64, description="页脚版权文本")
icp_no: str | None = Field(default=None, max_length=32, description="ICP 备案号")

View File

@ -31,7 +31,7 @@ from app.core.exceptions import BadRequestError, NotFoundError
from app.core.response import PageQuery, PageResult from app.core.response import PageQuery, PageResult
from app.models import Customer, OwnershipChangeLog, Personnel, User from app.models import Customer, OwnershipChangeLog, Personnel, User
from app.models.customer import OWNER_TYPE_CUSTOMER from app.models.customer import OWNER_TYPE_CUSTOMER
from app.repositories import customer_repository, personnel_repository from app.repositories import customer_rating_repository, customer_repository, personnel_repository
from app.schemas.customer import ( from app.schemas.customer import (
CustomerCreateIn, CustomerCreateIn,
CustomerOut, CustomerOut,
@ -46,8 +46,17 @@ from app.services.audit_service import AuditRecord, RequestMeta
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _to_out(customer: Customer, owner_name: str | None) -> CustomerOut: def _to_out(
"""ORM 对象转响应模型。禁止把 ORM 对象直接当 response_model。""" customer: Customer,
owner_name: str | None,
rating_total: object | None = None,
rating_grade: str | None = None,
) -> CustomerOut:
"""ORM 对象转响应模型。禁止把 ORM 对象直接当 response_model。
rating_total 由 LEFT JOIN 带出(Decimal 或 None),按分值红线转字符串
出参;新建客户/未评级客户不传,保持 null。
"""
return CustomerOut( return CustomerOut(
id=customer.id, id=customer.id,
company_name=customer.company_name, company_name=customer.company_name,
@ -60,6 +69,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_total=str(rating_total) if rating_total is not None else None,
rating_grade_name=rating_grade,
) )
@ -106,7 +117,7 @@ def _is_duplicate_entry(exc: IntegrityError) -> bool:
def list_customers(session: Session, query: CustomerQuery) -> PageResult[CustomerOut]: def list_customers(session: Session, query: CustomerQuery) -> PageResult[CustomerOut]:
"""分页查询客户(附归属人员姓名)。""" """分页查询客户(附归属人员姓名与评级快照;支持评级列排序)。"""
rows = customer_repository.list_customers( rows = customer_repository.list_customers(
session, session,
offset=query.offset, offset=query.offset,
@ -114,6 +125,8 @@ def list_customers(session: Session, query: CustomerQuery) -> PageResult[Custome
keyword=query.keyword, keyword=query.keyword,
customer_type=query.customer_type, customer_type=query.customer_type,
owner_personnel_id=query.owner_personnel_id, owner_personnel_id=query.owner_personnel_id,
order_field=query.order_field,
order_type=query.order_type,
) )
total = customer_repository.count_customers( total = customer_repository.count_customers(
session, session,
@ -121,8 +134,12 @@ def list_customers(session: Session, query: CustomerQuery) -> PageResult[Custome
customer_type=query.customer_type, customer_type=query.customer_type,
owner_personnel_id=query.owner_personnel_id, owner_personnel_id=query.owner_personnel_id,
) )
records = [
_to_out(customer, owner_name, rating_total, rating_grade)
for customer, owner_name, rating_total, rating_grade in rows
]
return PageResult[CustomerOut]( return PageResult[CustomerOut](
records=[_to_out(customer, owner_name) for customer, owner_name in rows], records=records,
total=total, total=total,
page_num=query.page_num, page_num=query.page_num,
page_size=query.page_size, page_size=query.page_size,
@ -137,8 +154,8 @@ def get_customer(session: Session, customer_id: int) -> CustomerOut:
row = customer_repository.get_with_owner_name(session, customer_id) row = customer_repository.get_with_owner_name(session, customer_id)
if row is None: if row is None:
raise NotFoundError("客户不存在") raise NotFoundError("客户不存在")
customer, owner_name = row customer, owner_name, rating_total, rating_grade = row
return _to_out(customer, owner_name) return _to_out(customer, owner_name, rating_total, rating_grade)
def create_customer( def create_customer(
@ -241,7 +258,7 @@ def update_customer(
_ensure_customer_type(payload.customer_type) _ensure_customer_type(payload.customer_type)
if row is None: if row is None:
raise NotFoundError("客户不存在") raise NotFoundError("客户不存在")
customer, owner_name = row customer, owner_name, rating_total, rating_grade = row
duplicate = customer_repository.get_by_company_owner( duplicate = customer_repository.get_by_company_owner(
session, payload.company_name, payload.owner_name session, payload.company_name, payload.owner_name
@ -260,7 +277,7 @@ def update_customer(
try: try:
# 响应在 commit 之前构造(同 user_service 顶部说明) # 响应在 commit 之前构造(同 user_service 顶部说明)
out = _to_out(customer, owner_name) out = _to_out(customer, owner_name, rating_total, rating_grade)
session.commit() session.commit()
except IntegrityError as exc: except IntegrityError as exc:
# 把自身以外的组合改成已存在的「公司名称+负责人」时,先查后写 # 把自身以外的组合改成已存在的「公司名称+负责人」时,先查后写
@ -308,6 +325,11 @@ def delete_customer(
company_name = customer.company_name # 固化:对象随删除失效 company_name = customer.company_name # 固化:对象随删除失效
try: try:
# 评级行 FK RESTRICT:先删评级行再删客户主行(同事务一次 commit;
# 评级是附属快照,不构成「有关联业务」的删除阻碍)
rating = customer_rating_repository.get_by_customer_id(session, customer_id)
if rating is not None:
customer_rating_repository.delete(session, rating)
customer_repository.delete(session, customer) customer_repository.delete(session, customer)
session.commit() session.commit()
except IntegrityError as exc: except IntegrityError as exc:
@ -362,7 +384,7 @@ def change_ownership(
): ):
if row is None: if row is None:
raise NotFoundError("客户不存在") raise NotFoundError("客户不存在")
customer, _ = row customer, _, rating_total, rating_grade = row
if payload.new_owner_personnel_id is not None: if payload.new_owner_personnel_id is not None:
_ensure_owner_personnel(session, payload.new_owner_personnel_id) _ensure_owner_personnel(session, payload.new_owner_personnel_id)
@ -385,7 +407,12 @@ def change_ownership(
), ),
) )
# 响应在 commit 之前构造(同 user_service 顶部说明);归属人名以新值为准 # 响应在 commit 之前构造(同 user_service 顶部说明);归属人名以新值为准
out = _to_out(customer, new_person.personnel_name if new_person else None) out = _to_out(
customer,
new_person.personnel_name if new_person else None,
rating_total,
rating_grade,
)
session.commit() session.commit()
logger.info( logger.info(
"调整客户归属 | customer_id=%s | old=%s | new=%s", "调整客户归属 | customer_id=%s | old=%s | new=%s",

View File

@ -1,12 +1,14 @@
"""客户评级等级配置业务编排。 """客户评级业务编排(等级映射配置 + 每客户评级落库)。
评级计算器定位(对照表 #25):仅打分工具、不影响绩效金额,评分记录不落库 评级定位(对照表 #25):**不影响绩效金额**。自 2026-10-19 起评分每客户
——本表承载的等级映射是**判定表**,判定规则为按 sort_order 升序取首个 落库(customer_ratings,一行一客户快照),等级映射仍是本服务承载的
min_score ≤ 总分的等级,max_score 仅展示。两条硬约束(违反会算错等级): **判定表**,判定规则为按 sort_order 升序取首个 min_score ≤ 总分的等级,
max_score 仅展示。两条硬约束(违反会算错等级):
- 分值全程 ``Decimal``:入参出参 str、schema 拦格式,service 层负责字符串 - 分值全程 ``Decimal``:入参出参 str、schema 拦格式,service 层负责字符串
到 Decimal 的转换与落库;0≤min≤max≤100 的区间校验在**审计上下文内** 到 Decimal 的转换与落库;值域校验(等级区间 0≤min≤max≤100、单项评分
完成,失败的 400 同样留痕(2026-10-13 定稿口径) 0≤分≤20)在**审计上下文内**完成,失败的 400 同样留痕(2026-10-13 定稿
口径)
- 区间互不重叠:重叠会让同一总分命中多个等级(或两段都跳过),判定表 - 区间互不重叠:重叠会让同一总分命中多个等级(或两段都跳过),判定表
失效。判定为**闭区间两两相交**:任一其他等级满足 失效。判定为**闭区间两两相交**:任一其他等级满足
``other.min_score <= 新 max 且 新 min <= other.max_score`` 即重叠。 ``other.min_score <= 新 max 且 新 min <= other.max_score`` 即重叠。
@ -14,9 +16,9 @@ min_score ≤ 总分的等级,max_score 仅展示。两条硬约束(违反
相邻铺满 0-100,且判定本身是单边 min_score ≤ 总分,边界归 sort_order 相邻铺满 0-100,且判定本身是单边 min_score ≤ 总分,边界归 sort_order
靠前的等级,相邻无歧义 靠前的等级,相邻无歧义
删除允许清空:等级表无下游引用(评分记录不落库,判定只读本表现值、不 删除允许清空:等级表对客户评级**无外键引用**(评级快照固化等级名、不
回溯历史快照),物理删除无悬空风险;全部删空后计算器只显示总分、不出 回指本表),物理删除无悬空风险,也不伤及已评级客户的历史结论;全部删空
等级(前端已对应提示),故不强制保留任何一条。 后新评级只记总分、等级为 NULL(前端已对应提示),故不强制保留任何一条。
""" """
import logging import logging
@ -33,9 +35,15 @@ from app.core.constants import (
) )
from app.core.db_errors import is_duplicate_entry from app.core.db_errors import is_duplicate_entry
from app.core.exceptions import BadRequestError, NotFoundError from app.core.exceptions import BadRequestError, NotFoundError
from app.models import CustomerRatingGrade, User from app.models import CustomerRating, CustomerRatingGrade, User
from app.repositories import rating_repository from app.repositories import customer_rating_repository, customer_repository, rating_repository
from app.schemas.rating import RatingGradeCreateIn, RatingGradeOut, RatingGradeUpdateIn from app.schemas.rating import (
CustomerRatingOut,
CustomerRatingSaveIn,
RatingGradeCreateIn,
RatingGradeOut,
RatingGradeUpdateIn,
)
from app.services import audit_service from app.services import audit_service
from app.services.audit_service import AuditRecord, RequestMeta from app.services.audit_service import AuditRecord, RequestMeta
@ -44,6 +52,8 @@ logger = logging.getLogger(__name__)
# 分值域上下界(Decimal 常量,禁用 float 参与比较) # 分值域上下界(Decimal 常量,禁用 float 参与比较)
_SCORE_FLOOR = Decimal("0") _SCORE_FLOOR = Decimal("0")
_SCORE_CEIL = Decimal("100") _SCORE_CEIL = Decimal("100")
# 单项评分上界(五项各 0-20,总分 0-100)
_ITEM_SCORE_CEIL = Decimal("20")
def _to_out(grade: CustomerRatingGrade) -> RatingGradeOut: def _to_out(grade: CustomerRatingGrade) -> RatingGradeOut:
@ -252,10 +262,11 @@ def delete_grade(
) -> None: ) -> None:
"""物理删除评级等级。 """物理删除评级等级。
等级表无下游引用 —— 评级是纯计算器不落评分记录(对照表 #25),判定 等级表对客户评级**无外键引用** —— 评级行快照固化等级名(见
只读本表现值、不回溯历史快照,故可物理删除且无需锁行防并发引用。 ``models/customer_rating.py`` 的论证),判定只读本表现值,故可物理删除
且无需锁行防并发引用;删除等级不改变已评级客户的历史结论。
**允许清空**:全部删空后计算器只显示总分、不出等级(前端已对应提示), **允许清空**:全部删空后新评级只记总分、等级为 NULL(前端已对应提示),
不强制保留任何一条。 不强制保留任何一条。
:raises NotFoundError: 评级等级不存在 :raises NotFoundError: 评级等级不存在
@ -278,3 +289,164 @@ def delete_grade(
rating_repository.delete(session, grade) rating_repository.delete(session, grade)
session.commit() session.commit()
logger.info("删除评级等级 | grade_name=%s", grade_name) logger.info("删除评级等级 | grade_name=%s", grade_name)
# ==================== 客户评级(每客户一行,落库) ====================
def resolve_grade(
grades: list[CustomerRatingGrade], total_score: Decimal
) -> CustomerRatingGrade | None:
"""等级判定纯函数:按 sort_order 升序取首个 min_score ≤ 总分的等级。
与前端打分卡的实时判定同规则同序(前端按数值比较,此处 Decimal 比较
——同一规则的两端实现,测试锁定边界行为一致)。无命中(总分低于全部
下限或等级表为空)返回 None,由调用方决定展示与落库(快照记 NULL)。
"""
# id 兜底让同序等级次序稳定(纯函数也接受未持久化对象:id 可能为 None)
for grade in sorted(grades, key=lambda g: (g.sort_order, g.id or 0)):
if grade.min_score <= total_score:
return grade
return None
def _to_rating_out(rating: CustomerRating) -> CustomerRatingOut:
"""客户评级行转响应模型。分值 Decimal → str(分值红线出参口径)。"""
return CustomerRatingOut(
customer_id=rating.customer_id,
score_ship_amount=str(rating.score_ship_amount),
score_receipt_rate=str(rating.score_receipt_rate),
score_profit_rate=str(rating.score_profit_rate),
score_contract_cooperation=str(rating.score_contract_cooperation),
score_site_construction=str(rating.score_site_construction),
total_score=str(rating.total_score),
grade_name=rating.grade_name,
remark=rating.remark,
scorer_name=rating.scorer_name,
create_time=rating.create_time,
update_time=rating.update_time,
)
def _ensure_item_scores(scores: list[Decimal]) -> None:
"""单项评分值域:0 ≤ 分 ≤ 20(总分由五项求和自然落在 0-100)。
须在审计上下文内调用,失败也要留痕(同 _ensure_score_range 口径)。
"""
for score in scores:
if not (_SCORE_FLOOR <= score <= _ITEM_SCORE_CEIL):
raise BadRequestError("单项评分须在 0~20 之间")
def get_customer_rating(session: Session, customer_id: int) -> CustomerRatingOut | None:
"""查某客户当前评级;未评级返回 None(接口信封 data 为 null)。
「未评级」是正常态而非错误,不用 404 表达;客户不存在才是 NotFound。
:raises NotFoundError: 客户不存在
"""
customer = customer_repository.get_by_id(session, customer_id)
if customer is None:
raise NotFoundError("客户不存在")
rating = customer_rating_repository.get_by_customer_id(session, customer_id)
return _to_rating_out(rating) if rating is not None else None
def save_customer_rating(
session: Session,
customer_id: int,
payload: CustomerRatingSaveIn,
*,
operator: User,
meta: RequestMeta | None = None,
) -> CustomerRatingOut:
"""保存客户评级(upsert:首次创建,再次保存整体覆盖)。
总分与等级**服务端重算**:总分按五项求和(不采信前端回传),等级按
保存时点的等级映射判定后**快照固化**进评级行 —— 等级表后续增删改
不影响该行(见 ``models/customer_rating.py``)。
:raises NotFoundError: 客户不存在
:raises BadRequestError: 单项评分须在 0~20 之间(审计上下文内校验,留痕)
"""
# 只读预取在审计上下文之前:客户不存在(NotFound 失败)也能留下可读记录,
# 摘要需要客户名
customer = customer_repository.get_by_id(session, customer_id)
existing = customer_rating_repository.get_by_customer_id(session, customer_id)
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_RATING,
action=ACTION_CREATE if existing is None else ACTION_UPDATE,
summary=f"评定客户「{customer.company_name if customer else f'#{customer_id}'}」评级",
request_data=payload.model_dump(exclude_none=True),
),
meta=meta,
):
# 值域校验在审计上下文内:分值非法的 400 也要留痕(同等级配置口径)
item_scores = [
_to_decimal(payload.score_ship_amount),
_to_decimal(payload.score_receipt_rate),
_to_decimal(payload.score_profit_rate),
_to_decimal(payload.score_contract_cooperation),
_to_decimal(payload.score_site_construction),
]
_ensure_item_scores(item_scores)
if customer is None:
raise NotFoundError("客户不存在")
total_score = sum(item_scores, _SCORE_FLOOR)
grade = resolve_grade(rating_repository.list_all(session), total_score)
def _apply(row: CustomerRating) -> None:
"""把五项分值/总分/等级快照写入评级行(创建与并发兜底共用)。"""
(
row.score_ship_amount,
row.score_receipt_rate,
row.score_profit_rate,
row.score_contract_cooperation,
row.score_site_construction,
) = item_scores
row.total_score = total_score
row.grade_name = grade.grade_name if grade is not None else None
row.remark = payload.remark
row.scorer_id = operator.id
row.scorer_name = operator.username
try:
if existing is None:
rating = CustomerRating(
customer_id=customer_id,
scorer_id=operator.id,
scorer_name=operator.username,
)
_apply(rating)
customer_rating_repository.create(session, rating)
else:
_apply(existing)
rating = existing
out = _to_rating_out(rating)
session.commit()
except IntegrityError as exc:
# 先查后写不是原子操作:并发为同一客户首次评级会撞
# uk_customer_ratings_customer_id,回滚后转为更新兜底
session.rollback()
if not is_duplicate_entry(exc):
raise
raced = customer_rating_repository.get_by_customer_id(session, customer_id)
if raced is None:
# 1062 但行不在:数据态异常,交全局 IntegrityError 兜底
raise
_apply(raced)
rating = raced
out = _to_rating_out(rating)
session.commit()
logger.info(
"保存客户评级 | customer_id=%s | total=%s | grade=%s",
customer_id,
out.total_score,
out.grade_name,
)
return out

View File

@ -1,12 +1,17 @@
"""系统设置(公司信息)业务编排。 """系统设置(公司信息 + 站点信息)业务编排。
单例行语义:整个系统恒一行(id=1,迁移种子保底)。logo 只存**相对路径** 单例行语义:整个系统恒一行(id=1,迁移种子保底)。logo/favicon 只存**相对
(uploads/logo/ 下固定名覆盖),文件不入库的理由:图片进数据库会让每次 路径**(uploads/logo、uploads/favicon 下固定名覆盖),文件不入库的理由:
读取与备份放大传输,且无法独立替换;DB 只持有一行指针。 图片进数据库会让每次读取与备份放大传输,且无法独立替换;DB 只持有指针。
logo 安全:后缀白名单 + magic bytes 双重校验(上传内容不可信,仅看文件名 图像安全:后缀白名单 + magic bytes 双重校验(上传内容不可信,仅看文件名
后缀可被伪造);固定文件名落盘,**不使用上传方文件名**——杜绝路径拼接与 后缀可被伪造);固定文件名落盘,**不使用上传方文件名**——杜绝路径拼接与
覆盖任意文件。 覆盖任意文件。
logo 与 favicon 相互独立(2026-10-20):
- logo 用于登录页品牌位;
- favicon 用于浏览器标签页图标,未上传时前端使用内置 favicon.svg
(不再跟随 logo——公司 logo 是横版,作标签页图标常被裁切)。
""" """
import logging import logging
@ -24,15 +29,26 @@ from app.services.audit_service import AuditRecord, RequestMeta
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# magic bytes:PNG 固定 8 字节签名;JPEG 以 SOI + APP0/JFIF 起始 # magic bytes:PNG 8 字节签名;JPEG 以 SOI 起始;ICO 以 00 00 01 00 起始
_MAGIC = { _MAGIC = {
"png": b"\x89PNG\r\n\x1a\n", "png": b"\x89PNG\r\n\x1a\n",
"jpg": b"\xff\xd8\xff", "jpg": b"\xff\xd8\xff",
"ico": b"\x00\x00\x01\x00",
} }
LOGO_MAX_BYTES = 2 * 1024 * 1024 # 2MB:页眉/登录页展示尺寸上限,防上传滥用 # 允许的后缀集合:logo 沿用 png/jpg(页眉/登录页展示);favicon 额外接受 ico
LOGO_EXTS = ("png", "jpg")
FAVICON_EXTS = ("png", "jpg", "ico")
ASSET_MAX_BYTES = 2 * 1024 * 1024 # 2MB:展示尺寸上限,防上传滥用
SETTINGS_ID = 1 # 全系统单行主键 SETTINGS_ID = 1 # 全系统单行主键
# 公司档案中的可选文本字段:提交空串等价清空(落库 null,前端回退内置默认)
_OPTIONAL_TEXT_FIELDS = ("site_title", "brand_slogan", "footer_text", "icp_no")
# 资源种类 → 中文名(错误提示与审计摘要用)
_ASSET_LABELS = {"logo": "logo", "favicon": "站点图标"}
_ASSET_EXTS = {"logo": LOGO_EXTS, "favicon": FAVICON_EXTS}
def get_setting(session: Session) -> SystemSetting: def get_setting(session: Session) -> SystemSetting:
"""取公司信息单行(种子保底 id=1;防御式兜底:极端环境下缺行则补建)。""" """取公司信息单行(种子保底 id=1;防御式兜底:极端环境下缺行则补建)。"""
@ -45,10 +61,15 @@ def get_setting(session: Session) -> SystemSetting:
def to_out(setting: SystemSetting) -> SettingOut: def to_out(setting: SystemSetting) -> SettingOut:
"""管理端出参(logo_url 带更新时间版本参数,替换图片后浏览器不再用旧缓存)。""" """管理端出参(图片地址带更新时间版本参数,替换后浏览器不再用旧缓存)。"""
return SettingOut( return SettingOut(
company_name=setting.company_name, company_name=setting.company_name,
logo_url=_logo_url(setting), logo_url=_asset_url(setting, "logo"),
site_title=setting.site_title,
brand_slogan=setting.brand_slogan,
footer_text=setting.footer_text,
icp_no=setting.icp_no,
favicon_url=_asset_url(setting, "favicon"),
) )
@ -56,15 +77,26 @@ def to_public_out(setting: SystemSetting) -> SettingPublicOut:
"""匿名端点出参(白名单字段,独立 schema 见模块说明)。""" """匿名端点出参(白名单字段,独立 schema 见模块说明)。"""
return SettingPublicOut( return SettingPublicOut(
company_name=setting.company_name, company_name=setting.company_name,
logo_url=_logo_url(setting), logo_url=_asset_url(setting, "logo"),
site_title=setting.site_title,
brand_slogan=setting.brand_slogan,
footer_text=setting.footer_text,
icp_no=setting.icp_no,
favicon_url=_asset_url(setting, "favicon"),
) )
def _logo_url(setting: SystemSetting) -> str | None: def _asset_url(setting: SystemSetting, kind: str) -> str | None:
if not setting.logo_path: """资源 URL(kind=logo/favicon);未上传返回 None。
版本号取**上传计数器**而非 update_time:同扩展名替换时固定文件名不变
(logo_path 值不变),MySQL 的 ON UPDATE 不会刷新 update_time,用它做
?v= 会导致浏览器一直用旧图(2026-10-20 用户反馈的换图不刷新问题)。
"""
if not getattr(setting, f"{kind}_path"):
return None return None
version = int(setting.update_time.timestamp()) if setting.update_time else 0 version = getattr(setting, f"{kind}_version") or 0
return f"/api/v1/system/settings/logo/public?v={version}" return f"/api/v1/system/settings/{kind}/public?v={version}"
def update_company( def update_company(
@ -74,20 +106,34 @@ def update_company(
operator: User, operator: User,
meta: RequestMeta | None = None, meta: RequestMeta | None = None,
) -> SettingOut: ) -> SettingOut:
"""更新公司名称(审计留痕)。""" """更新公司信息与站点信息(部分更新 + 审计留痕)。
仅提交的字段生效(``exclude_unset``:未提交/显式 null 保持原值),
便于「只改名称」「只改站点标题」等场景不覆盖其他字段;可选文本传
空串等价清空(落库 null)。
"""
changes = payload.model_dump(exclude_unset=True, exclude_none=True)
summary = "修改公司信息"
if "company_name" in changes:
summary = f"修改公司信息(公司名称「{changes['company_name']}」)"
with audit_service.audit( with audit_service.audit(
session, session,
operator=operator, operator=operator,
record=AuditRecord( record=AuditRecord(
module=MODULE_SYSTEM_SETTINGS, module=MODULE_SYSTEM_SETTINGS,
action=ACTION_UPDATE, action=ACTION_UPDATE,
summary=f"修改公司名称为「{payload.company_name}」", summary=summary,
request_data=payload.model_dump(), request_data=changes or None,
), ),
meta=meta, meta=meta,
): ):
setting = get_setting(session) setting = get_setting(session)
setting.company_name = payload.company_name.strip() if "company_name" in changes:
# schema 已保证提交值非空(min_length=1)
setting.company_name = changes["company_name"]
for field in _OPTIONAL_TEXT_FIELDS:
if field in changes:
setattr(setting, field, changes[field].strip() or None)
session.commit() session.commit()
return to_out(setting) return to_out(setting)
@ -100,17 +146,39 @@ def save_logo(
operator: User, operator: User,
meta: RequestMeta | None = None, meta: RequestMeta | None = None,
) -> SettingOut: ) -> SettingOut:
"""上传 logo:校验 → 固定名覆盖落盘 → 更新相对路径(审计留痕)。 """上传公司 logo(登录页品牌位;png/jpg ≤2MB)。"""
return _save_asset(session, content, ext, kind="logo", operator=operator, meta=meta)
def save_favicon(
session: Session,
content: bytes,
ext: str,
*,
operator: User,
meta: RequestMeta | None = None,
) -> SettingOut:
"""上传站点图标(浏览器标签页;png/jpg/ico ≤2MB)。"""
return _save_asset(session, content, ext, kind="favicon", operator=operator, meta=meta)
def _save_asset(
session: Session,
content: bytes,
ext: str,
*,
kind: str,
operator: User,
meta: RequestMeta | None,
) -> SettingOut:
"""校验 → 固定名覆盖落盘 → 更新相对路径(审计留痕)。
旧扩展名文件一并清理(logo.png 换 jpg 后残留文件会继续占盘)。 旧扩展名文件一并清理(logo.png 换 jpg 后残留文件会继续占盘)。
""" """
label = _ASSET_LABELS[kind]
allowed = _ASSET_EXTS[kind]
ext = ext.lower() ext = ext.lower()
if ext not in _MAGIC: _validate_upload(content, ext, allowed=allowed, label=label)
raise BadRequestError("logo 仅支持 png/jpg 格式")
if len(content) > LOGO_MAX_BYTES:
raise BadRequestError("logo 文件不能超过 2MB")
if not content.startswith(_MAGIC[ext]):
raise BadRequestError("文件内容与扩展名不符(仅接受真实图片)")
with audit_service.audit( with audit_service.audit(
session, session,
@ -118,54 +186,79 @@ def save_logo(
record=AuditRecord( record=AuditRecord(
module=MODULE_SYSTEM_SETTINGS, module=MODULE_SYSTEM_SETTINGS,
action=ACTION_UPDATE, action=ACTION_UPDATE,
summary="更新公司 logo", summary=f"更新{label}",
request_data={"ext": ext, "size": len(content)}, request_data={"ext": ext, "size": len(content)},
), ),
meta=meta, meta=meta,
): ):
setting = get_setting(session) setting = get_setting(session)
old_path = setting.logo_path old_path = getattr(setting, f"{kind}_path")
# 先写新文件、commit 成功后再清旧扩展名(若把清理放在 commit 之前, # 先写新文件、commit 成功后再清旧扩展名(若把清理放在 commit 之前,
# commit 失败会让库指旧路径而旧文件已删——断链无补偿);同扩展名 # commit 失败会让库指旧路径而旧文件已删——断链无补偿);同扩展名
# 由写入覆盖,无需也不会误删新文件 # 由写入覆盖,无需也不会误删新文件
target = _logo_dir() / f"logo.{ext}" target = _asset_dir(kind) / f"{kind}.{ext}"
target.write_bytes(content) target.write_bytes(content)
setting.logo_path = f"logo/logo.{ext}" setattr(setting, f"{kind}_path", f"{kind}/{kind}.{ext}")
# 版本号每次上传必增:即使同扩展名覆盖(路径不变)也能破浏览器缓存
setattr(setting, f"{kind}_version", (getattr(setting, f"{kind}_version") or 0) + 1)
session.commit() session.commit()
old_ext = Path(old_path).suffix.lstrip(".") if old_path else None old_ext = Path(old_path).suffix.lstrip(".") if old_path else None
if old_ext and old_ext != ext: if old_ext and old_ext != ext and old_ext in allowed:
try: try:
_cleanup_old_logo(old_ext) _cleanup_old_asset(kind, old_ext)
except OSError: except OSError:
# 清理失败不影响本次更新(库与新文件均已生效);若异常外抛, # 清理失败不影响本次更新(库与新文件均已生效);若异常外抛,
# 成功的 logo 更新会被记成失败审计并 500 —— 审计与事实相反 # 成功的更新会被记成失败审计并 500 —— 审计与事实相反
logger.warning("旧 logo 清理失败(不影响本次更新)| ext=%s", old_ext, exc_info=True) logger.warning(
logger.info("更新公司 logo | size=%s ext=%s", len(content), ext) "旧%s清理失败(不影响本次更新)| ext=%s", label, old_ext, exc_info=True
)
logger.info("更新%s | size=%s ext=%s", label, len(content), ext)
return to_out(setting) return to_out(setting)
def read_logo(session: Session) -> tuple[Path, str]: def read_logo(session: Session) -> tuple[Path, str]:
"""按库中记录定位 logo 文件,返回 (路径, 扩展名)。未上传抛 404。""" """按库中记录定位 logo 文件,返回 (路径, 扩展名)。未上传抛 404。"""
return _read_asset(session, "logo")
def read_favicon(session: Session) -> tuple[Path, str]:
"""按库中记录定位站点图标文件,返回 (路径, 扩展名)。未上传抛 404。"""
return _read_asset(session, "favicon")
def _read_asset(session: Session, kind: str) -> tuple[Path, str]:
label = _ASSET_LABELS[kind]
setting = get_setting(session) setting = get_setting(session)
if not setting.logo_path: path_str = getattr(setting, f"{kind}_path")
raise NotFoundError("尚未上传 logo") if not path_str:
path = get_settings().upload_dir / setting.logo_path raise NotFoundError(f"尚未上传{label}")
path = get_settings().upload_dir / path_str
if not path.is_file(): if not path.is_file():
raise NotFoundError("logo 文件不存在,请重新上传") raise NotFoundError(f"{label}文件不存在,请重新上传")
return path, path.suffix.lstrip(".") return path, path.suffix.lstrip(".")
def _logo_dir() -> Path: def _validate_upload(content: bytes, ext: str, *, allowed: tuple[str, ...], label: str) -> None:
directory = get_settings().upload_dir / "logo" """后缀白名单 + magic bytes + 大小三重校验(同口径供 logo/favicon 复用)。"""
if ext not in allowed:
raise BadRequestError(f"{label}仅支持 {'/'.join(allowed)} 格式")
if len(content) > ASSET_MAX_BYTES:
raise BadRequestError(f"{label}文件不能超过 2MB")
if not content.startswith(_MAGIC[ext]):
raise BadRequestError("文件内容与扩展名不符(仅接受真实图片)")
def _asset_dir(kind: str) -> Path:
directory = get_settings().upload_dir / kind
directory.mkdir(parents=True, exist_ok=True) directory.mkdir(parents=True, exist_ok=True)
return directory return directory
def _cleanup_old_logo(old_ext: str) -> None: def _cleanup_old_asset(kind: str, old_ext: str) -> None:
if old_ext in _MAGIC: if old_ext in _ASSET_EXTS[kind]:
old_file = _logo_dir() / f"logo.{old_ext}" old_file = _asset_dir(kind) / f"{kind}.{old_ext}"
if old_file.exists(): if old_file.exists():
old_file.unlink(missing_ok=True) old_file.unlink(missing_ok=True)

View File

@ -21,7 +21,6 @@
import uuid import uuid
from decimal import Decimal from decimal import Decimal
from pathlib import Path
import pytest import pytest
from sqlalchemy import delete, select, update from sqlalchemy import delete, select, update
@ -37,6 +36,7 @@ from app.models import (
BusinessRole, BusinessRole,
BusinessStatusLog, BusinessStatusLog,
Customer, Customer,
CustomerRating,
CustomerRatingGrade, CustomerRatingGrade,
MonthlySettlement, MonthlySettlement,
OperationLog, OperationLog,
@ -164,6 +164,10 @@ def _purge_test_data() -> None:
session.execute( session.execute(
delete(OwnershipChangeLog).where(OwnershipChangeLog.customer_id.in_(customer_ids)) delete(OwnershipChangeLog).where(OwnershipChangeLog.customer_id.in_(customer_ids))
) )
# 评级行 FK RESTRICT:必须先于客户删除(V7 起客户评级每客户一行)
session.execute(
delete(CustomerRating).where(CustomerRating.customer_id.in_(customer_ids))
)
session.execute(delete(Customer).where(Customer.id.in_(customer_ids))) session.execute(delete(Customer).where(Customer.id.in_(customer_ids)))
# 客户被物理删除后其 customer_id 已无从收集,按操作人用户名兜底一条 # 客户被物理删除后其 customer_id 已无从收集,按操作人用户名兜底一条
session.execute( session.execute(
@ -190,32 +194,103 @@ def _purge_test_data() -> None:
# 审批单无 t_ 前缀可用,按 AP 编号前缀全量清(steps 外键 CASCADE 随删; # 审批单无 t_ 前缀可用,按 AP 编号前缀全量清(steps 外键 CASCADE 随删;
# ref_id 固化无外键,业务已在上一步删除,无悬挂影响) # ref_id 固化无外键,业务已在上一步删除,无悬挂影响)
session.execute(delete(ApprovalOrder).where(ApprovalOrder.order_no.like("AP%"))) session.execute(delete(ApprovalOrder).where(ApprovalOrder.order_no.like("AP%")))
# 开关参数恢复种子基线:测试提交的变更批次条目 id 均大于种子终值 28 # 开关参数恢复种子基线:测试提交的变更批次由 t_ 前缀测试账号创建,
# (V3 十九条 + V6 两条 + V7 调整单一条 + V8 经理基数开关一条;V8 另 # 按**批次操作人**删除本轮的参数条目与批次,解析后回退到种子值(开关=0)。
# 插入两个审批开关的 10-19 生效行 id 23/24,V10 插入预警规则参数 # 不用 id 阈值:种子条目的主键在不同环境的漂移库中并不相同(历史库
# id 26/27/28,均属种子),删新条目与新批次后解析回退到种子值 # 可能补过种子行),按操作人识别测试产物与环境数据分布无关。
stray_values = list(session.execute(select(ParamValue).where(ParamValue.id > 28)).scalars()) test_batch_ids = list(
if stray_values: session.execute(
stray_batches = {v.batch_id for v in stray_values} select(ParamChangeBatch.id).where(ParamChangeBatch.operator_name.like("t\\_%"))
session.execute(delete(ParamValue).where(ParamValue.id > 28)) ).scalars()
session.execute(delete(ParamChangeBatch).where(ParamChangeBatch.id.in_(stray_batches)))
# 系统设置恢复种子单行(test_settings 会改公司名/传 logo——残留会让
# 后续 e2e 的品牌断言在污染状态下运行);uploads 下的测试 logo 一并删
session.execute(
update(SystemSetting)
.where(SystemSetting.id == 1)
.values(company_name="衍水集团", logo_path=None)
) )
if test_batch_ids:
session.execute(delete(ParamValue).where(ParamValue.batch_id.in_(test_batch_ids)))
session.execute(delete(ParamChangeBatch).where(ParamChangeBatch.id.in_(test_batch_ids)))
session.commit() session.commit()
logo_dir = Path(__file__).resolve().parents[2] / "uploads" / "logo"
if logo_dir.is_dir():
for f in logo_dir.iterdir(): # ==================== 系统设置与上传文件的快照还原 ====================
f.unlink(missing_ok=True) #
# 系统设置是**真实单行数据**,logo/favicon 是真实上传文件,没有 t_ 前缀可供
# 兜底清理。测试(尤其 logo 上传用例)会改公司名、覆盖/删除真实图片 ——
# 必须在用例结束后还原,否则跑一次测试就会破坏开发环境的品牌与 logo。
_ASSET_KINDS = ("logo", "favicon")
def _snapshot_company_info() -> dict | None:
"""快照系统设置单行(无行时返回 None)。"""
factory = get_session_factory()
with factory() as session:
row = session.get(SystemSetting, 1)
if row is None:
return None
return {column.name: getattr(row, column.name) for column in SystemSetting.__table__.columns}
def _restore_company_info(snapshot: dict | None) -> None:
if snapshot is None:
return
factory = get_session_factory()
with factory() as session:
row = session.get(SystemSetting, 1)
if row is None:
return
for key, value in snapshot.items():
if key != "update_time": # 更新时间由数据库 ON UPDATE 维护
setattr(row, key, value)
session.commit()
def _snapshot_assets() -> dict[str, dict[str, bytes]]:
"""快照上传目录里的真实图片(logo/favicon)。"""
from app.core.config import get_settings
root = get_settings().upload_dir
snapshot: dict[str, dict[str, bytes]] = {}
for kind in _ASSET_KINDS:
directory = root / kind
snapshot[kind] = (
{path.name: path.read_bytes() for path in directory.glob("*") if path.is_file()}
if directory.is_dir()
else {}
)
return snapshot
def _restore_assets(snapshot: dict[str, dict[str, bytes]]) -> None:
"""还原上传目录:删除测试新增文件、回写被测试覆盖的原文件。"""
from app.core.config import get_settings
root = get_settings().upload_dir
for kind, files in snapshot.items():
directory = root / kind
directory.mkdir(parents=True, exist_ok=True)
for path in directory.glob("*"):
if path.is_file() and path.name not in files:
path.unlink(missing_ok=True)
for name, content in files.items():
(directory / name).write_bytes(content)
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def _purge_after_each_test(): def _preserve_company_info():
"""每个集成用例结束后兜底清理,避免残留影响后续用例的计数断言。""" """每个用例前快照系统设置与上传图片,结束后还原(见上方说明)。"""
info = _snapshot_company_info()
assets = _snapshot_assets()
yield
# 先还原文件:数据库路径随后回指同一批文件
_restore_assets(assets)
_restore_company_info(info)
@pytest.fixture(autouse=True)
def _purge_after_each_test(_preserve_company_info):
"""每个集成用例结束后兜底清理,避免残留影响后续用例的计数断言。
依赖 ``_preserve_company_info``:保证快照在该夹具之前建立、在该夹具
清理**之后**还原(夹具 teardown 为逆序)。
"""
yield yield
_purge_test_data() _purge_test_data()

View File

@ -11,6 +11,7 @@ append-only 完整性;「公司名称+负责人」组合唯一缺失会放行
""" """
import uuid import uuid
from decimal import Decimal
import pytest import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
@ -376,3 +377,104 @@ 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, "摘要应体现新旧归属走向"
# ==================== 客户评级列(V11) ====================
_RATING_KEYS = (
"score_ship_amount",
"score_receipt_rate",
"score_profit_rate",
"score_contract_cooperation",
"score_site_construction",
)
def _rate(client: TestClient, headers: dict, customer_id: int, scores: list[str]) -> dict:
"""保存客户评级(五项 0-20),返回响应 data。"""
resp = client.put(
f"{_CUSTOMERS}/{customer_id}/rating",
json=dict(zip(_RATING_KEYS, scores, strict=True)),
headers=headers,
)
assert resp.status_code == 200, resp.text
return resp.json()["data"]
def _list_by_keyword(client: TestClient, headers: dict, keyword: str, **params) -> list[dict]:
query: dict = {"keyword": keyword, "pageNum": 1, "pageSize": 50, **params}
resp = client.get(_CUSTOMERS, params=query, headers=headers)
assert resp.status_code == 200, resp.text
return resp.json()["data"]["list"]
class TestRatingColumn:
"""客户评级列:列表带出评级快照,列头排序走服务端(未评级恒在末尾)。
列表是服务端分页,排序必须在 SQL 层完成 —— 前端只传
``order_field=rating`` + ``order_type``;MySQL 无 NULLS LAST,
未评级行用布尔键模拟,正序/倒序都在末尾。
"""
def _seed_three(self, client: TestClient, headers: dict) -> tuple[str, dict, dict, dict]:
"""建三名同批次客户:90 分(A)、0 分(D)、未评级。"""
token = uuid.uuid4().hex[:8]
keyword = f"t_公司_{token}"
high = _create_customer(client, headers, company_name=f"{keyword}_甲")
low = _create_customer(client, headers, company_name=f"{keyword}_乙")
none = _create_customer(client, headers, company_name=f"{keyword}_丙")
_rate(client, headers, high["id"], ["18"] * 5)
_rate(client, headers, low["id"], ["0"] * 5)
return keyword, high, low, none
def test_列表带出评级字段(self, client: TestClient, normal: dict) -> None:
headers = normal["headers"]
keyword, high, low, none = self._seed_three(client, headers)
rows = _list_by_keyword(client, headers, keyword)
by_id = {row["id"]: row for row in rows}
assert Decimal(by_id[high["id"]]["rating_total"]) == Decimal("90")
assert by_id[high["id"]]["rating_grade_name"] == "A"
assert Decimal(by_id[low["id"]]["rating_total"]) == Decimal("0")
assert by_id[low["id"]]["rating_grade_name"] == "D"
assert by_id[none["id"]]["rating_total"] is None
assert by_id[none["id"]]["rating_grade_name"] is None
def test_评级列正序倒序_未评级恒末尾(self, client: TestClient, normal: dict) -> None:
headers = normal["headers"]
keyword, high, low, none = self._seed_three(client, headers)
desc = _list_by_keyword(client, headers, keyword, order_field="rating", order_type="desc")
assert [row["id"] for row in desc] == [high["id"], low["id"], none["id"]]
asc = _list_by_keyword(client, headers, keyword, order_field="rating", order_type="asc")
assert [row["id"] for row in asc] == [low["id"], high["id"], none["id"]]
def test_非法排序字段_400(self, client: TestClient, normal: dict) -> None:
"""排序字段是白名单(Literal):不透传给 SQL,非法值 400。"""
resp = client.get(
_CUSTOMERS,
params={"order_field": "company_name", "order_type": "asc"},
headers=normal["headers"],
)
assert resp.status_code == 400, resp.text
def test_非法排序方向_400(self, client: TestClient, normal: dict) -> None:
resp = client.get(
_CUSTOMERS,
params={"order_field": "rating", "order_type": "sideways"},
headers=normal["headers"],
)
assert resp.status_code == 400, resp.text
def test_删除客户联动删评级行(self, client: TestClient, normal: dict) -> None:
"""评级行 FK RESTRICT:删除客户须先清评级行,否则 500。"""
headers = normal["headers"]
customer = _create_customer(client, headers)
_rate(client, headers, customer["id"], ["20"] * 5)
resp = client.delete(f"{_CUSTOMERS}/{customer['id']}", headers=headers)
assert resp.status_code == 200, resp.text
assert (
client.get(f"{_CUSTOMERS}/{customer['id']}/rating", headers=headers).status_code == 404
)

View File

@ -21,15 +21,17 @@ from decimal import Decimal
import pytest import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from sqlalchemy import select from sqlalchemy import func, select
from app.core.constants import ACTION_CREATE, MODULE_RATING from app.core.constants import ACTION_CREATE, MODULE_RATING
from app.core.db import get_session_factory from app.core.db import get_session_factory
from app.models import OperationLog from app.models import CustomerRating, CustomerRatingGrade, OperationLog
from app.services.rating_service import resolve_grade
pytestmark = pytest.mark.integration pytestmark = pytest.mark.integration
_GRADES = "/api/v1/business/rating-grades" _GRADES = "/api/v1/business/rating-grades"
_CUSTOMERS = "/api/v1/base/customers"
# 种子基线(与 V5 迁移一致):分数字符串经 DB round-trip 后一位小数(Numeric(5,1)) # 种子基线(与 V5 迁移一致):分数字符串经 DB round-trip 后一位小数(Numeric(5,1))
_SEED = [ _SEED = [
@ -77,6 +79,41 @@ def _names(client: TestClient, headers: dict) -> list[str]:
return [row["grade_name"] for row in client.get(_GRADES, headers=headers).json()["data"]] return [row["grade_name"] for row in client.get(_GRADES, headers=headers).json()["data"]]
def _create_customer(client: TestClient, headers: dict) -> dict:
"""建一名 t_ 前缀测试客户(评级落库的被引用方,conftest 兜底清理)。"""
resp = client.post(
_CUSTOMERS,
json={
"company_name": f"t_评级客户_{uuid.uuid4().hex[:8]}",
"owner_name": "t_老板",
"customer_type": "personal",
},
headers=headers,
)
assert resp.status_code == 200, resp.text
return resp.json()["data"]
_RATING_KEYS = (
"score_ship_amount",
"score_receipt_rate",
"score_profit_rate",
"score_contract_cooperation",
"score_site_construction",
)
def _rating_url(customer_id: int) -> str:
return f"{_CUSTOMERS}/{customer_id}/rating"
def _rating_payload(scores: list[str], remark: str | None = None) -> dict:
body: dict = dict(zip(_RATING_KEYS, scores, strict=True))
if remark is not None:
body["remark"] = remark
return body
def _logs_of(username: str) -> list[OperationLog]: def _logs_of(username: str) -> list[OperationLog]:
"""取某操作人的审计行(按 id 倒序,最新在前)。""" """取某操作人的审计行(按 id 倒序,最新在前)。"""
factory = get_session_factory() factory = get_session_factory()
@ -258,8 +295,8 @@ class TestEdit:
class TestDelete: class TestDelete:
"""删除允许清空(评级是纯计算器、评分记录不落库)—— 删除后判定表 """删除允许清空(客户评级快照固化等级名、无外键引用)—— 删除后判定表
立即反映,二次删除 404。""" 立即反映,二次删除 404,已评级客户的历史等级快照不受影响。"""
def test_删除等级后判定表更新_二次删除404(self, client: TestClient, normal: dict) -> None: def test_删除等级后判定表更新_二次删除404(self, client: TestClient, normal: dict) -> None:
headers = normal["headers"] headers = normal["headers"]
@ -313,3 +350,146 @@ class TestAudit:
] ]
assert failed, "失败操作未留痕" assert failed, "失败操作未留痕"
assert "分值区间与等级 A 重叠" in (failed[0].error_message or ""), failed[0] assert "分值区间与等级 A 重叠" in (failed[0].error_message or ""), failed[0]
class TestResolveGrade:
"""判定纯函数边界:与前端打分卡同规则(sort_order 升序首个 min_score ≤ 总分)。
单边判定让边界分归 sort_order 靠前的等级:90→A、75→B、60→C、59.9→D;
等级表为空或总分低于全部下限时返回 None(落库等级为 NULL)。
"""
@staticmethod
def _seed_grades() -> list[CustomerRatingGrade]:
return [
CustomerRatingGrade(
grade_name=name,
min_score=Decimal(min_score),
max_score=Decimal(max_score),
sort_order=order,
)
for name, min_score, max_score, order in _SEED
]
def test_边界分归高档(self) -> None:
grades = self._seed_grades()
assert resolve_grade(grades, Decimal("90")).grade_name == "A"
assert resolve_grade(grades, Decimal("75")).grade_name == "B"
assert resolve_grade(grades, Decimal("60")).grade_name == "C"
def test_下限下方归最低档(self) -> None:
assert resolve_grade(self._seed_grades(), Decimal("59.9")).grade_name == "D"
def test_等级表为空_未命中(self) -> None:
assert resolve_grade([], Decimal("80")) is None
def test_低于全部下限_未命中(self) -> None:
grades = [
CustomerRatingGrade(
grade_name="A",
min_score=Decimal("90"),
max_score=Decimal("100"),
sort_order=1,
)
]
assert resolve_grade(grades, Decimal("50")) is None
class TestCustomerRating:
"""客户评级落库(V11):每客户一行、服务端重算总分与等级、等级快照固化。
五项各 0-20 → 总分 0-100;等级按保存时点判定并写入 grade_name(无外键)。
未评级返回 data=null(正常态);分数越界 400 且审计留痕。
"""
def test_未评级返回null(self, client: TestClient, normal: dict) -> None:
customer = _create_customer(client, normal["headers"])
resp = client.get(_rating_url(customer["id"]), headers=normal["headers"])
assert resp.status_code == 200, resp.text
assert resp.json()["data"] is None
def test_保存评级_服务端重算总分等级并留审计(self, client: TestClient, normal: dict) -> None:
headers = normal["headers"]
customer = _create_customer(client, headers)
put = client.put(
_rating_url(customer["id"]),
json=_rating_payload(["18", "20", "19", "17", "16"], remark="t_评级依据"),
headers=headers,
)
assert put.status_code == 200, put.text
data = put.json()["data"]
assert Decimal(data["total_score"]) == Decimal("90")
assert data["grade_name"] == "A"
assert data["remark"] == "t_评级依据"
assert data["scorer_name"] == normal["username"]
# 回读一致(列表评级列与弹窗回显同源)
got = client.get(_rating_url(customer["id"]), headers=headers).json()["data"]
assert Decimal(got["total_score"]) == Decimal("90")
assert got["grade_name"] == "A"
assert Decimal(got["score_ship_amount"]) == Decimal("18")
logs = [log for log in _logs_of(normal["username"]) if log.module == MODULE_RATING]
assert logs, "保存客户评级未留操作日志"
assert logs[0].success == 1
assert customer["company_name"] in logs[0].summary
assert "评级" in logs[0].summary
def test_重复保存覆盖同一客户仅一行(self, client: TestClient, normal: dict) -> None:
headers = normal["headers"]
customer = _create_customer(client, headers)
first = client.put(
_rating_url(customer["id"]), json=_rating_payload(["0"] * 5), headers=headers
)
assert first.status_code == 200, first.text
assert first.json()["data"]["grade_name"] == "D"
second = client.put(
_rating_url(customer["id"]), json=_rating_payload(["20"] * 5), headers=headers
)
assert second.status_code == 200, second.text
assert Decimal(second.json()["data"]["total_score"]) == Decimal("100")
assert second.json()["data"]["grade_name"] == "A"
factory = get_session_factory()
with factory() as session:
count = session.execute(
select(func.count())
.select_from(CustomerRating)
.where(CustomerRating.customer_id == customer["id"])
).scalar_one()
assert count == 1, "同一客户出现多行评级(upsert 失效)"
def test_单项分数越界_400(self, client: TestClient, normal: dict) -> None:
headers = normal["headers"]
customer = _create_customer(client, headers)
for bad in (["21", "0", "0", "0", "0"], ["0", "-1", "0", "0", "0"]):
resp = client.put(
_rating_url(customer["id"]), json=_rating_payload(bad), headers=headers
)
assert resp.status_code == 400, resp.text
assert "0~20" in resp.json()["message"], resp.text
def test_客户不存在_404(self, client: TestClient, normal: dict) -> None:
headers = normal["headers"]
assert client.get(_rating_url(999_999_999), headers=headers).status_code == 404
put = client.put(_rating_url(999_999_999), json=_rating_payload(["0"] * 5), headers=headers)
assert put.status_code == 404, put.text
def test_等级表清空后保存_等级为null(self, client: TestClient, normal: dict) -> None:
"""等级映射允许清空(快照设计前提):清空后评分照常保存,仅等级为 NULL。"""
headers = normal["headers"]
for name in ("A", "B", "C", "D"):
assert (
client.delete(
f"{_GRADES}/{_seed_id(client, headers, name)}", headers=headers
).status_code
== 200
)
customer = _create_customer(client, headers)
data = client.put(
_rating_url(customer["id"]), json=_rating_payload(["20"] * 5), headers=headers
).json()["data"]
assert Decimal(data["total_score"]) == Decimal("100")
assert data["grade_name"] is None

View File

@ -23,6 +23,7 @@ _SETTINGS = "/api/v1/system/settings"
_PNG_BYTES = b"\x89PNG\r\n\x1a\n" + b"\x00" * 32 _PNG_BYTES = b"\x89PNG\r\n\x1a\n" + b"\x00" * 32
_JPG_BYTES = b"\xff\xd8\xff\xe0" + b"\x00" * 32 _JPG_BYTES = b"\xff\xd8\xff\xe0" + b"\x00" * 32
_ICO_BYTES = b"\x00\x00\x01\x00" + b"\x00" * 32
_TXT_BYTES = b"not an image at all" _TXT_BYTES = b"not an image at all"
@ -31,6 +32,10 @@ def client() -> TestClient:
return TestClient(app) return TestClient(app)
# 系统设置单行与上传文件的快照/还原已上移到 integration/conftest.py,
# 对所有集成用例生效(不再只保护本模块)。
class TestCompanyInfo: class TestCompanyInfo:
"""公司名称读取与更新。""" """公司名称读取与更新。"""
@ -84,10 +89,30 @@ class TestPublicEndpoint:
resp = client.get(f"{_SETTINGS}/public") resp = client.get(f"{_SETTINGS}/public")
assert resp.status_code == 200 assert resp.status_code == 200
data = resp.json()["data"] data = resp.json()["data"]
assert set(data) == {"company_name", "logo_url"}, "public 端点字段越界" assert set(data) == {
"company_name",
"logo_url",
"site_title",
"brand_slogan",
"footer_text",
"icp_no",
"favicon_url",
}, "public 端点字段越界"
assert data["company_name"] == name assert data["company_name"] == name
assert resp.headers.get("cache-control") is not None assert resp.headers.get("cache-control") is not None
def test_已删除的档案字段不再返回(self, client: TestClient) -> None:
"""V11 精简:副标题/信用代码/地址/电话/邮箱已从模型与契约中删除。"""
data = client.get(f"{_SETTINGS}/public").json()["data"]
for field in (
"system_subtitle",
"credit_code",
"company_address",
"contact_phone",
"contact_email",
):
assert field not in data, f"已删除字段仍出现在匿名端点:{field}"
def test_写操作匿名不可用(self, client: TestClient) -> None: def test_写操作匿名不可用(self, client: TestClient) -> None:
assert client.put(f"{_SETTINGS}/public", json={"company_name": "x"}).status_code in { assert client.put(f"{_SETTINGS}/public", json={"company_name": "x"}).status_code in {
401, 401,
@ -95,6 +120,41 @@ class TestPublicEndpoint:
} }
class TestCompanyProfile:
"""站点标题/品牌信息(V11):部分更新语义与空串清空。"""
def test_部分更新_未提交字段保持不变(self, client: TestClient, admin: dict) -> None:
name = f"站点公司_{uuid.uuid4().hex[:6]}"
client.put(_SETTINGS, json={"company_name": name}, headers=admin["headers"])
put = client.put(_SETTINGS, json={"site_title": "测试站点标题"}, headers=admin["headers"])
assert put.status_code == 200, put.text
assert put.json()["data"]["site_title"] == "测试站点标题"
# 未提交的 company_name 保持不变(部分更新语义)
assert put.json()["data"]["company_name"] == name
got = client.get(_SETTINGS, headers=admin["headers"]).json()["data"]
assert got["company_name"] == name
assert got["site_title"] == "测试站点标题"
def test_可选文本传空串_清空为null(self, client: TestClient, admin: dict) -> None:
client.put(
_SETTINGS,
json={"brand_slogan": "t_临时标语", "icp_no": "t_备案号"},
headers=admin["headers"],
)
put = client.put(
_SETTINGS, json={"brand_slogan": "", "icp_no": ""}, headers=admin["headers"]
)
assert put.status_code == 200, put.text
assert put.json()["data"]["brand_slogan"] is None
assert put.json()["data"]["icp_no"] is None
def test_超长字段被拒(self, client: TestClient, admin: dict) -> None:
resp = client.put(_SETTINGS, json={"site_title": "x" * 65}, headers=admin["headers"])
assert resp.status_code == 400, resp.text
class TestLogoUpload: class TestLogoUpload:
"""logo 上传校验链。""" """logo 上传校验链。"""
@ -135,3 +195,65 @@ class TestLogoUpload:
files={"file": ("logo.png", _PNG_BYTES, "application/octet-stream")}, files={"file": ("logo.png", _PNG_BYTES, "application/octet-stream")},
) )
assert resp.status_code == 401 assert resp.status_code == 401
def test_同扩展名替换_url版本号变化(self, client: TestClient, admin: dict) -> None:
"""同扩展名覆盖时路径与 update_time 都不变 —— 版本号必须递增,否则浏览器用旧图。"""
first = self._upload(client, admin, "logo.png", _PNG_BYTES).json()["data"]["logo_url"]
second = self._upload(client, admin, "logo.png", _PNG_BYTES).json()["data"]["logo_url"]
assert first and second
assert first != second, "同扩展名替换后图片 URL 未变化,浏览器会继续使用旧缓存"
class TestFaviconUpload:
"""站点图标上传校验链(独立于 logo;favicon 额外接受 ico)。"""
def _upload(self, client: TestClient, admin: dict, filename: str, content: bytes):
return client.post(
f"{_SETTINGS}/favicon",
files={"file": (filename, content, "application/octet-stream")},
headers=admin["headers"],
)
def test_合法ico上传成功且回读(self, client: TestClient, admin: dict) -> None:
resp = self._upload(client, admin, "favicon.ico", _ICO_BYTES)
assert resp.status_code == 200, resp.text
out = resp.json()["data"]
assert out["favicon_url"] and "/favicon/public" in out["favicon_url"]
img = client.get(f"{_SETTINGS}/favicon/public")
assert img.status_code == 200
assert img.headers["content-type"] == "image/x-icon"
def test_合法png上传成功(self, client: TestClient, admin: dict) -> None:
resp = self._upload(client, admin, "favicon.png", _PNG_BYTES)
assert resp.status_code == 200, resp.text
assert "/favicon/public" in resp.json()["data"]["favicon_url"]
def test_伪造扩展名被magic_bytes拦截(self, client: TestClient, admin: dict) -> None:
resp = self._upload(client, admin, "fake.ico", _TXT_BYTES)
assert resp.status_code == 400, resp.text
assert "不符" in resp.json()["message"]
def test_同扩展名替换_url版本号变化(self, client: TestClient, admin: dict) -> None:
first = self._upload(client, admin, "favicon.png", _PNG_BYTES).json()["data"]["favicon_url"]
second = self._upload(client, admin, "favicon.png", _PNG_BYTES).json()["data"][
"favicon_url"
]
assert first and second
assert first != second, "同扩展名替换后站点图标 URL 未变化"
def test_非法格式被拒(self, client: TestClient, admin: dict) -> None:
assert self._upload(client, admin, "x.gif", _PNG_BYTES).status_code == 400
def test_超限被拒(self, client: TestClient, admin: dict) -> None:
big = _PNG_BYTES + b"\x00" * (2 * 1024 * 1024)
resp = self._upload(client, admin, "big.png", big)
assert resp.status_code == 400
assert "2MB" in resp.json()["message"]
def test_匿名上传被拒(self, client: TestClient) -> None:
resp = client.post(
f"{_SETTINGS}/favicon",
files={"file": ("favicon.png", _PNG_BYTES, "application/octet-stream")},
)
assert resp.status_code == 401

View File

@ -40,6 +40,7 @@ EXPECTED_TABLES = {
"shipment_items", "shipment_items",
"receipts", "receipts",
"customer_rating_grades", "customer_rating_grades",
"customer_ratings",
"performance_ledgers", "performance_ledgers",
"monthly_settlements", "monthly_settlements",
"settlement_items", "settlement_items",

View File

@ -23,6 +23,7 @@ DROP TABLE IF EXISTS `monthly_settlements`;
DROP TABLE IF EXISTS `approval_steps`; DROP TABLE IF EXISTS `approval_steps`;
DROP TABLE IF EXISTS `approval_orders`; DROP TABLE IF EXISTS `approval_orders`;
DROP TABLE IF EXISTS `business_secondary_allocations`; DROP TABLE IF EXISTS `business_secondary_allocations`;
DROP TABLE IF EXISTS `customer_ratings`;
DROP TABLE IF EXISTS `system_settings`; DROP TABLE IF EXISTS `system_settings`;
DROP TABLE IF EXISTS `param_values`; DROP TABLE IF EXISTS `param_values`;
DROP TABLE IF EXISTS `param_change_batches`; DROP TABLE IF EXISTS `param_change_batches`;
@ -695,17 +696,50 @@ INSERT INTO customer_rating_grades (grade_name, min_score, max_score, sort_order
('C', 60, 74, 3, '合格'), ('C', 60, 74, 3, '合格'),
('D', 0, 59, 4, '待改进'); ('D', 0, 59, 4, '待改进');
-- ---------------------------------------------------------------------------
-- 客户评级落库(V11 迁移;每客户一行,等级名为快照固化无外键)
-- ---------------------------------------------------------------------------
CREATE TABLE customer_ratings (
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID(唯一:一客户一份当前评级)',
score_ship_amount NUMERIC(5, 1) NOT NULL COMMENT '发货额评分(0-20)' DEFAULT '0',
score_receipt_rate NUMERIC(5, 1) NOT NULL COMMENT '回款率评分(0-20)' DEFAULT '0',
score_profit_rate NUMERIC(5, 1) NOT NULL COMMENT '毛利率评分(0-20)' DEFAULT '0',
score_contract_cooperation NUMERIC(5, 1) NOT NULL COMMENT '合同履约配合能力评分(0-20)' DEFAULT '0',
score_site_construction NUMERIC(5, 1) NOT NULL COMMENT '现场规范施工能力评分(0-20)' DEFAULT '0',
total_score NUMERIC(5, 1) NOT NULL COMMENT '总分(服务端重算,五项之和 0-100)',
grade_name VARCHAR(32) COMMENT '评级等级(保存时按等级映射快照固化,无外键;未命中/等级表空为 NULL)',
remark VARCHAR(255) COMMENT '备注',
scorer_id BIGINT UNSIGNED COMMENT '评级操作人ID(账号删除后置空)',
scorer_name VARCHAR(64) NOT NULL COMMENT '评级操作人用户名(冗余固化)',
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT pk_customer_ratings PRIMARY KEY (id),
CONSTRAINT fk_customer_ratings_customer_id FOREIGN KEY(customer_id) REFERENCES customers (id) ON DELETE RESTRICT,
CONSTRAINT fk_customer_ratings_scorer_id FOREIGN KEY(scorer_id) REFERENCES users (id) ON DELETE SET NULL,
CONSTRAINT uk_customer_ratings_customer_id UNIQUE (customer_id)
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='客户评级表(每客户一行;等级名为快照固化无外键,删等级不伤已评级记录)' COLLATE utf8mb4_general_ci;
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
-- 系统设置/二次分配/审批(V6 迁移;公司信息单行;分摊计算待澄清另批) -- 系统设置/二次分配/审批(V6 迁移;公司信息单行;分摊计算待澄清另批)
-- V11 迁移:增加品牌标语/页脚/备案号/站点标题/站点图标与图片版本号
-- (公司名称/logo/标语/页脚/备案号 + 站点信息,企业档案字段方案已废弃未发布)
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
CREATE TABLE system_settings ( CREATE TABLE system_settings (
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
company_name VARCHAR(64) NOT NULL COMMENT '公司名称(登录页与导出页眉展示)', company_name VARCHAR(64) NOT NULL COMMENT '公司名称(登录页/侧边栏/浏览器标题展示)',
logo_path VARCHAR(255) COMMENT 'logo 相对路径(文件存 uploads/logo/,null=未上传使用默认)', logo_path VARCHAR(255) COMMENT '公司 logo 相对路径(文件存 uploads/logo/,null=未上传使用默认)',
logo_version INT NOT NULL COMMENT 'logo 版本号(每次上传 +1;图片 URL 用它破缓存——同扩展名覆盖时路径不变,update_time 也不变)' DEFAULT '0',
site_title VARCHAR(64) COMMENT '站点标题(浏览器标签页与侧边栏副标题;null=内置默认)',
brand_slogan VARCHAR(64) COMMENT '品牌标语(登录页与品牌预览;null=内置默认)',
footer_text VARCHAR(64) COMMENT '页脚版权文本(登录页;null=自动生成 © 公司名 · 内部系统)',
icp_no VARCHAR(32) COMMENT 'ICP 备案号(登录页页脚,空=不显示)',
favicon_path VARCHAR(255) COMMENT '站点图标相对路径(文件存 uploads/favicon/,null=跟随公司 logo / 内置 favicon.svg)',
favicon_version INT NOT NULL COMMENT '站点图标版本号(每次上传 +1;图片 URL 用它破缓存)' DEFAULT '0',
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,
CONSTRAINT pk_system_settings PRIMARY KEY (id) CONSTRAINT pk_system_settings PRIMARY KEY (id)
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='系统设置表(公司信息,全系统单行)' COLLATE utf8mb4_general_ci; )CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='系统设置表(公司信息与站点信息,全系统单行)' COLLATE utf8mb4_general_ci;
CREATE TABLE approval_orders ( CREATE TABLE approval_orders (
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
@ -882,4 +916,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 ('90653814a421'); INSERT INTO alembic_version (version_num) VALUES ('c4e2a86f9b13');

View File

@ -0,0 +1,38 @@
-- Running upgrade 90653814a421 -> c4e2a86f9b13
CREATE TABLE customer_ratings (
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID(唯一:一客户一份当前评级)',
score_ship_amount NUMERIC(5, 1) NOT NULL COMMENT '发货额评分(0-20)' DEFAULT '0',
score_receipt_rate NUMERIC(5, 1) NOT NULL COMMENT '回款率评分(0-20)' DEFAULT '0',
score_profit_rate NUMERIC(5, 1) NOT NULL COMMENT '毛利率评分(0-20)' DEFAULT '0',
score_contract_cooperation NUMERIC(5, 1) NOT NULL COMMENT '合同履约配合能力评分(0-20)' DEFAULT '0',
score_site_construction NUMERIC(5, 1) NOT NULL COMMENT '现场规范施工能力评分(0-20)' DEFAULT '0',
total_score NUMERIC(5, 1) NOT NULL COMMENT '总分(服务端重算,五项之和 0-100)',
grade_name VARCHAR(32) COMMENT '评级等级(保存时按等级映射快照固化,无外键;未命中/等级表空为 NULL)',
remark VARCHAR(255) COMMENT '备注',
scorer_id BIGINT UNSIGNED COMMENT '评级操作人ID(账号删除后置空)',
scorer_name VARCHAR(64) NOT NULL COMMENT '评级操作人用户名(冗余固化)',
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT pk_customer_ratings PRIMARY KEY (id),
CONSTRAINT fk_customer_ratings_customer_id FOREIGN KEY(customer_id) REFERENCES customers (id) ON DELETE RESTRICT,
CONSTRAINT fk_customer_ratings_scorer_id FOREIGN KEY(scorer_id) REFERENCES users (id) ON DELETE SET NULL,
CONSTRAINT uk_customer_ratings_customer_id UNIQUE (customer_id)
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='客户评级表(每客户一行;等级名为快照固化无外键,删等级不伤已评级记录)' COLLATE utf8mb4_general_ci;
ALTER TABLE system_settings ADD COLUMN site_title VARCHAR(64) COMMENT '站点标题(浏览器标签页与侧边栏副标题;null=内置默认)';
ALTER TABLE system_settings ADD COLUMN brand_slogan VARCHAR(64) COMMENT '品牌标语(登录页与品牌预览;null=内置默认)';
ALTER TABLE system_settings ADD COLUMN footer_text VARCHAR(64) COMMENT '页脚版权文本(登录页;null=自动生成 © 公司名 · 内部系统)';
ALTER TABLE system_settings ADD COLUMN icp_no VARCHAR(32) COMMENT 'ICP 备案号(登录页页脚,空=不显示)';
ALTER TABLE system_settings ADD COLUMN favicon_path VARCHAR(255) COMMENT '站点图标相对路径(文件存 uploads/favicon/,null=使用内置 favicon.svg)';
ALTER TABLE system_settings ADD COLUMN logo_version INT NOT NULL COMMENT 'logo 版本号(每次上传 +1;图片 URL 用它破缓存——同扩展名覆盖时路径不变,update_time 也不变)' DEFAULT '0';
ALTER TABLE system_settings ADD COLUMN favicon_version INT NOT NULL COMMENT '站点图标版本号(每次上传 +1;图片 URL 用它破缓存)' DEFAULT '0';
UPDATE alembic_version SET version_num='c4e2a86f9b13' WHERE alembic_version.version_num = '90653814a421';

View File

@ -3,6 +3,8 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- 默认站点图标:未配置公司 logo 时使用(配置后由 settings store 动态替换) -->
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>衍水集团营销人员工资管理系统</title> <title>衍水集团营销人员工资管理系统</title>
</head> </head>
<body> <body>

View File

@ -0,0 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<defs>
<linearGradient id="brand" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#4a66c4" />
<stop offset="1" stop-color="#2e4ba8" />
</linearGradient>
</defs>
<rect width="64" height="64" rx="14" fill="url(#brand)" />
<text
x="32"
y="43"
text-anchor="middle"
font-family="'Noto Sans SC', 'Microsoft YaHei', 'PingFang SC', sans-serif"
font-size="32"
font-weight="600"
fill="#ffffff"
>衍</text>
</svg>

After

Width:  |  Height:  |  Size: 532 B

View File

@ -1,5 +1,31 @@
<script setup lang="ts"> <script setup lang="ts">
import { watch } from 'vue'
import { useRoute } from 'vue-router'
import { useSettingsStore } from '@/store/settings'
// 根组件只承载路由出口;布局由各页面自行决定 // 根组件只承载路由出口;布局由各页面自行决定
const route = useRoute()
const settingsStore = useSettingsStore()
/**
* 浏览器标题 =「页面名 - 公司名 系统副标题」,跟随公司信息设置动态变化。
*
* 用 watch 而非 router.afterEach:品牌信息是异步载入的(匿名端点),
* afterEach 执行时只能取到当时的旧值;watch 在公司信息载入/设置页保存
* 后自动重算标题,无需等下一次路由跳转。
*/
watch(
() => [route.meta.title, settingsStore.brandTitle] as const,
() => {
const title = route.meta.title as string | undefined
document.title = title ? `${title} - ${settingsStore.brandTitle}` : settingsStore.brandTitle
},
{ immediate: true },
)
// 品牌信息全局只载入一次(登录页/布局另有调用,内部去重)
void settingsStore.ensureLoaded()
</script> </script>
<template> <template>

View File

@ -21,6 +21,10 @@ export interface CustomerItem {
owner_personnel_id: number | null owner_personnel_id: number | null
owner_personnel_name: string | null owner_personnel_name: string | null
remark: string | null remark: string | null
/** 评级总分(字符串直出,未评级为 null;等级由总分判定、同向) */
rating_total: string | null
/** 评级等级快照(保存时点固化;未评级/未命中为 null) */
rating_grade_name: 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
/** 排序字段白名单:rating = 客户评级总分(后端校验,非法值 400) */
order_field?: 'rating'
/** 排序方向;未评级恒在末尾(后端 NULLS LAST 模拟) */
order_type?: 'asc' | 'desc'
} }
/** 新增客户(owner_personnel_id 为初始归属,可空:先建档后派归属也合法) */ /** 新增客户(owner_personnel_id 为初始归属,可空:先建档后派归属也合法) */

View File

@ -23,6 +23,26 @@ export interface ParamCatalogItem {
values: ParamDimensionValue[] values: ParamDimensionValue[]
} }
/**
* 枚举参数值 → 中文(展示层翻译;库与接口仍存英文码,审计口径不变)。
*
* 业界惯例(RuoYi 字典标签同款):映射单一出处,避免页内复制漂移;
* 未知码原样回显 —— 后端新增枚举项而前端未跟上时不会显示成空。
*/
export const PARAM_ENUM_LABELS: Record<string, string> = {
FULL: '当月全额',
BALANCE: '余额',
INCLUDE: '含基本工资',
DEDUCT: '扣除基本工资',
EXCLUDE: '不计基本工资',
'0': '关',
'1': '开',
}
export function paramEnumLabel(code: string): string {
return PARAM_ENUM_LABELS[code] ?? code
}
/** 参数目录(asOf 缺省为今日) */ /** 参数目录(asOf 缺省为今日) */
export function getCatalog(asOf?: string): Promise<ParamCatalogItem[]> { export function getCatalog(asOf?: string): Promise<ParamCatalogItem[]> {
return request({ url: '/v1/system/params', method: 'get', params: asOf ? { as_of: asOf } : {} }) return request({ url: '/v1/system/params', method: 'get', params: asOf ? { as_of: asOf } : {} })

View File

@ -54,7 +54,57 @@ export function updateRatingGrade(
return request({ url: `/v1/business/rating-grades/${gradeId}`, method: 'put', data }) return request({ url: `/v1/business/rating-grades/${gradeId}`, method: 'put', data })
} }
/** 删除评级等级(物理删除,等级表无下游引用,允许清空后由列表空态提示) */ /**
* 删除评级等级(物理删除;客户评级行快照固化等级名、无外键引用,删等级
* 不伤已评级客户的历史结论;允许清空,新评级等级记为 null)。
*/
export function deleteRatingGrade(gradeId: number): Promise<null> { export function deleteRatingGrade(gradeId: number): Promise<null> {
return request({ url: `/v1/business/rating-grades/${gradeId}`, method: 'delete' }) return request({ url: `/v1/business/rating-grades/${gradeId}`, method: 'delete' })
} }
// ==================== 客户评级(每客户一行,落库) ====================
/**
* 客户当前评级(对应后端 `CustomerRatingOut`)。
*
* 分值按红线以字符串直出(比较前显式 parseFloat);total_score 由服务端
* 按五项重算。grade_name 是保存时点的等级**快照**(无外键),可能为 null
* (总分未命中任何等级 / 当时等级表为空)。
*/
export interface CustomerRatingItem {
customer_id: number
score_ship_amount: string
score_receipt_rate: string
score_profit_rate: string
score_contract_cooperation: string
score_site_construction: string
total_score: string
grade_name: string | null
remark: string | null
scorer_name: string
create_time: string
update_time: string
}
/** 保存评级入参:五项 0-20 数字字符串;总分与等级由服务端重算 */
export interface CustomerRatingParams {
score_ship_amount: string
score_receipt_rate: string
score_profit_rate: string
score_contract_cooperation: string
score_site_construction: string
remark?: string
}
/** 查某客户当前评级;未评级时 data 为 null(正常态,不是错误) */
export function getCustomerRating(customerId: number): Promise<CustomerRatingItem | null> {
return request({ url: `/v1/base/customers/${customerId}/rating`, method: 'get' })
}
/** 保存客户评级(upsert:首次创建、再次保存整体覆盖) */
export function saveCustomerRating(
customerId: number,
data: CustomerRatingParams,
): Promise<CustomerRatingItem> {
return request({ url: `/v1/base/customers/${customerId}/rating`, method: 'put', data })
}

View File

@ -1,37 +1,64 @@
import service, { request } from './request' import { request } from './request'
/** 公司信息(管理端) */ /**
* 公司信息与站点信息(管理端)。
*
* 2026-10-20 精简:删除系统副标题与企业档案字段(信用代码/地址/电话/邮箱),
* 新增站点标题(浏览器标签页/侧边栏副标题)与站点图标(标签页 favicon)。
* 展示字段全部可空:null 表示未配置,前端回退内置默认值。
*/
export interface CompanySettings { export interface CompanySettings {
company_name: string company_name: string
/** 未上传时为 null */ /** 登录页品牌位 logo;未上传时为 null */
logo_url: string | null logo_url: string | null
site_title: string | null
brand_slogan: string | null
footer_text: string | null
icp_no: string | null
/** 浏览器标签页图标;未上传时为 null(前端用内置 favicon.svg) */
favicon_url: string | null
} }
/** 匿名端点出参(登录页消费;与后端 SettingPublicOut 白名单一致) */ /** 匿名端点出参(登录页/浏览器标签页消费;与后端 SettingPublicOut 白名单一致) */
export interface PublicSettings { export type PublicSettings = CompanySettings
company_name: string
logo_url: string | null /**
* 更新入参:**部分更新**语义 —— 仅携带的字段生效,未携带字段保持不变。
* 可选文本传空串等价清空(后端落库 null、前端回退默认)。
*/
export interface SettingsUpdatePayload {
company_name?: string
site_title?: string
brand_slogan?: string
footer_text?: string
icp_no?: string
} }
export function getSettings(): Promise<CompanySettings> { export function getSettings(): Promise<CompanySettings> {
return request({ url: '/v1/system/settings', method: 'get' }) return request({ url: '/v1/system/settings', method: 'get' })
} }
export function updateSettings(companyName: string): Promise<CompanySettings> { export function updateSettings(payload: SettingsUpdatePayload): Promise<CompanySettings> {
return request({ return request({ url: '/v1/system/settings', method: 'put', data: payload })
url: '/v1/system/settings',
method: 'put',
data: { company_name: companyName },
})
} }
/** logo 上传走独立的 FormData(multipart,非 JSON) */ /**
* 图片上传走独立的 FormData(multipart,非 JSON)。
*
* 经统一 request 封装解包信封(响应形如 {code,message,data});Content-Type
* 交给浏览器/axios 为 FormData 自动生成(含 boundary),不手工设置。
*/
export function uploadLogo(file: File): Promise<CompanySettings> { export function uploadLogo(file: File): Promise<CompanySettings> {
const form = new FormData() const form = new FormData()
form.append('file', file) form.append('file', file)
return service.post('/v1/system/settings/logo', form, { return request({ url: '/v1/system/settings/logo', method: 'post', data: form })
headers: { 'Content-Type': 'multipart/form-data' }, }
})
/** 站点图标上传(浏览器标签页;png/jpg/ico ≤2MB) */
export function uploadFavicon(file: File): Promise<CompanySettings> {
const form = new FormData()
form.append('file', file)
return request({ url: '/v1/system/settings/favicon', method: 'post', data: form })
} }
/** 登录页消费:无令牌也可调用(后端白名单端点)。 /** 登录页消费:无令牌也可调用(后端白名单端点)。

View File

@ -1,16 +1,23 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch } from 'vue' import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import AppMenu from '@/components/layout/AppMenu.vue' import AppMenu from '@/components/layout/AppMenu.vue'
import { firstAccessiblePath } from '@/router/access' import { firstAccessiblePath } from '@/router/access'
import { useSettingsStore } from '@/store/settings'
import { useUserStore } from '@/store/user' import { useUserStore } from '@/store/user'
/** 基础布局:左侧品牌导航(窄屏收为抽屉)+ 顶栏(面包屑/用户/退出)+ 内容区。 */ /** 基础布局:左侧品牌导航(窄屏收为抽屉)+ 顶栏(面包屑/用户/退出)+ 内容区。 */
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const userStore = useUserStore() const userStore = useUserStore()
const settingsStore = useSettingsStore()
// 品牌区(名称/副标题/logo)来自公司信息设置;App 已全局载入一次,这里兜底
onMounted(() => {
void settingsStore.ensureLoaded()
})
const drawerVisible = ref(false) const drawerVisible = ref(false)
@ -44,8 +51,8 @@ async function handleLogout(): Promise<void> {
<el-container class="app-layout"> <el-container class="app-layout">
<el-aside class="app-aside" width="220px"> <el-aside class="app-aside" width="220px">
<div class="app-brand"> <div class="app-brand">
<span class="app-brand__name">衍水集团</span> <span class="app-brand__name">{{ settingsStore.companyName }}</span>
<span class="app-brand__sub">营销人员工资管理系统</span> <span class="app-brand__sub">{{ settingsStore.siteTitle }}</span>
<span class="app-brand__line"></span> <span class="app-brand__line"></span>
</div> </div>
<AppMenu class="app-aside__menu" /> <AppMenu class="app-aside__menu" />
@ -99,8 +106,8 @@ async function handleLogout(): Promise<void> {
:with-header="false" :with-header="false"
> >
<div class="app-brand"> <div class="app-brand">
<span class="app-brand__name">衍水集团</span> <span class="app-brand__name">{{ settingsStore.companyName }}</span>
<span class="app-brand__sub">营销人员工资管理系统</span> <span class="app-brand__sub">{{ settingsStore.siteTitle }}</span>
<span class="app-brand__line"></span> <span class="app-brand__line"></span>
</div> </div>
<AppMenu /> <AppMenu />

View File

@ -143,14 +143,8 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/business/receipts/index.vue'), component: () => import('@/views/business/receipts/index.vue'),
meta: { title: '回款管理', group: '业务数据', order: 15 }, meta: { title: '回款管理', group: '业务数据', order: 15 },
}, },
{ // 客户评级已改为「客户管理」页行内弹窗(2026-10-19):删除独立页面与
// 业务数据组首个页面:评级计算器是打分工具,不影响绩效金额, // 菜单入口(菜单由路由表派生,删路由即删菜单);旧 URL 落入兜底重定向
// 登录用户即可用,不设 requiresAdmin(区别于系统管理组)
path: '/business/rating',
name: 'business-rating',
component: () => import('@/views/business/rating/index.vue'),
meta: { title: '客户评级计算器', group: '业务数据', order: 16 },
},
{ {
// 绩效工资组(M3 ④):核算台账读登录即可,月结写限管理员与营销后勤 // 绩效工资组(M3 ④):核算台账读登录即可,月结写限管理员与营销后勤
// (api 层 require_roles 兜底,页面层不再叠加 requiresAdmin) // (api 层 require_roles 兜底,页面层不再叠加 requiresAdmin)
@ -282,9 +276,7 @@ router.beforeEach(async (to) => {
return true return true
}) })
router.afterEach((to) => { // 浏览器标题在 App.vue 中由「当前页 + 公司品牌」响应式计算
const title = to.meta.title as string | undefined // (品牌异步载入/管理端改名后要自动更新,afterEach 只能取到旧值)
document.title = title ? `${title} - ${import.meta.env.VITE_APP_TITLE}` : import.meta.env.VITE_APP_TITLE
})
export default router export default router

View File

@ -0,0 +1,125 @@
import { computed, ref, watch } from 'vue'
import { defineStore } from 'pinia'
import { getPublicSettings } from '@/api/settings'
import { setFavicon } from '@/utils/favicon'
/**
* 品牌与站点信息全局状态。
*
* 数据来自匿名 public 端点(登录页无令牌也需要),设置未配置/接口失败时
* 回退内置默认值 —— 与改造前硬编码的品牌文案一致,存量行为零变化。
*
* 两个关键设计:
* - **乐观更新**:管理端保存/上传后直接用回包调 ``apply``,而不是重新请求
* public 端点 —— 匿名端点带 300s 浏览器缓存,管理员改完自己刷新会先看到
* 旧值(与登录页历史口径一致)
* - **站点图标优先使用自定义图标,未配置时跟随公司 logo**(2026-10-20 用户
* 确认):换 logo 后标签页图标随之变化;两者都没有时才用内置 SVG
*/
export const DEFAULT_COMPANY_NAME = '衍水集团'
export const DEFAULT_SITE_TITLE = '营销人员工资管理系统'
export const DEFAULT_BRAND_SLOGAN = '把每一笔回款,算成一份踏实。'
const DEFAULT_FAVICON = '/favicon.svg'
/** 品牌/站点相关字段(public 白名单 / 管理端出参的公共子集) */
interface BrandInfo {
company_name?: string
logo_url?: string | null
site_title?: string | null
brand_slogan?: string | null
footer_text?: string | null
icp_no?: string | null
favicon_url?: string | null
}
export const useSettingsStore = defineStore('settings', () => {
const companyName = ref(DEFAULT_COMPANY_NAME)
const siteTitle = ref(DEFAULT_SITE_TITLE)
const brandSlogan = ref(DEFAULT_BRAND_SLOGAN)
const footerText = ref<string | null>(null)
const icpNo = ref<string | null>(null)
const logoUrl = ref<string | null>(null)
const faviconUrl = ref<string | null>(null)
/** public 端点是否已成功载入(失败不置位,下次挂载自然重试) */
const loaded = ref(false)
let pending: Promise<void> | null = null
/** 浏览器标题后缀:公司名 + 站点标题(站点标题为空则只有公司名) */
const brandTitle = computed(() =>
siteTitle.value ? `${companyName.value} ${siteTitle.value}` : companyName.value,
)
/** 登录页页脚:未配置版权文本时用「© 公司名 · 内部系统」 */
const footerLine = computed(() => footerText.value || `© ${companyName.value} · 内部系统`)
/**
* 生效的站点图标:自定义图标 → 公司 logo → 内置 SVG。
*
* 未单独上传站点图标时跟随 logo —— 管理员换 logo,浏览器标签页图标同步变化。
*/
const effectiveFavicon = computed(
() => faviconUrl.value ?? logoUrl.value ?? DEFAULT_FAVICON,
)
/**
* 应用品牌/站点信息(public 回包与设置页保存回包共用)。
*
* 可选字段为 null/空串表示**清空**(回退默认);undefined 表示回包未
* 携带该字段(保持不变)。company_name 为必填,空值忽略。
*/
function apply(info: BrandInfo): void {
if (info.company_name) companyName.value = info.company_name
if (info.logo_url !== undefined) logoUrl.value = info.logo_url
if (info.favicon_url !== undefined) faviconUrl.value = info.favicon_url
if (info.site_title !== undefined) {
siteTitle.value = info.site_title || DEFAULT_SITE_TITLE
}
if (info.brand_slogan !== undefined) {
brandSlogan.value = info.brand_slogan || DEFAULT_BRAND_SLOGAN
}
if (info.footer_text !== undefined) footerText.value = info.footer_text || null
if (info.icp_no !== undefined) icpNo.value = info.icp_no || null
loaded.value = true
}
/**
* 确保品牌/站点信息已载入(App/布局/登录页启动时调用;并发去重)。
*
* 失败静默回退内置默认值 —— 品牌不是关键路径,不能阻断登录或业务页面。
*/
function ensureLoaded(): Promise<void> {
if (loaded.value) return Promise.resolve()
if (pending) return pending
pending = (async () => {
try {
apply(await getPublicSettings())
} catch {
// 静默:保持内置默认品牌(登录页必须可用)
} finally {
pending = null
}
})()
return pending
}
// favicon 跟随生效图标(自定义 → logo → 内置);换图由 ?v= 版本参数破缓存
watch(effectiveFavicon, (url) => setFavicon(url), { immediate: true })
return {
companyName,
siteTitle,
brandSlogan,
footerText,
icpNo,
logoUrl,
faviconUrl,
effectiveFavicon,
loaded,
brandTitle,
footerLine,
apply,
ensureLoaded,
}
})

View File

@ -0,0 +1,21 @@
/**
* 动态站点图标。
*
* 手写同款实现(参照 vueuse `useFavicon`):修改已有 `<link rel="icon">`
* 的 href 即可让浏览器重新加载图标;没有 link 时补建一个。项目为保持
* 直接依赖克制(5 个)未引入 @vueuse/core,故按同口径手写。
*
* 缓存:logo 地址自带 `?v=更新时间戳` 版本参数,换图后不会被浏览器缓存拖住。
*/
const FAVICON_SELECTOR = 'link[rel~="icon"]'
export function setFavicon(href: string): void {
if (typeof document === 'undefined') return
let link = document.querySelector<HTMLLinkElement>(FAVICON_SELECTOR)
if (!link) {
link = document.createElement('link')
link.rel = 'icon'
document.head.appendChild(link)
}
link.href = href
}

View File

@ -1,11 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue' import { computed, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { import {
createRatingGrade, createRatingGrade,
deleteRatingGrade, deleteRatingGrade,
getCustomerRating,
listRatingGrades, listRatingGrades,
saveCustomerRating as saveCustomerRatingApi,
updateRatingGrade, updateRatingGrade,
type RatingGradeItem, type RatingGradeItem,
type RatingGradeParams, type RatingGradeParams,
@ -14,13 +16,29 @@ import { useConfirmAction } from '@/composables/useConfirmAction'
import { handledError } from '@/utils/error' import { handledError } from '@/utils/error'
/** /**
* 客户评级计算器(需求 2.2.5 / 对照表 #25)。 * 客户评级弹窗(2026-10-19 由独立「客户评级计算器」页面迁移而来)。
* *
* 评级计算器仅是**打分工具**:五项打分求总分、按等级映射实时判定等级, * 双 Tab 设计避免三层弹层:
* 评分记录不落库、不参与绩效金额计算,因此页面没有保存动作(仅重置)。 * - Tab1「客户评级」:五项打分 → 总分/等级实时判定 → 保存落库(每客户一行,
* 等级映射的增删改走 /v1/business/rating-grades 维护接口,配置变更后 * 等级按保存时点的映射快照固化);口径提示「评分不参与绩效金额计算」
* 重载判定用列表——判定与抽屉表格共用同一份数据。 * (对照表 #25)常驻
* - Tab2「等级映射设置」:等级表增删改(原页面抽屉整体迁入),判定规则与
* 打分区共用同一份列表,配置变更后实时生效
*
* 判定与后端 ``rating_service.resolve_grade`` 同规则:按 sort_order 升序取
* 首个 min_score ≤ 总分的等级;无命中显示未命中提示、保存后等级为 null。
*/ */
const props = defineProps<{
/** 目标客户(未打开弹窗时可为 0,仅打开后读取) */
customerId: number
customerName: string
}>()
const visible = defineModel<boolean>({ required: true })
const emit = defineEmits<{ saved: [] }>()
const activeTab = ref<'score' | 'grades'>('score')
// ==================== 等级判定(纯函数) ==================== // ==================== 等级判定(纯函数) ====================
@ -29,10 +47,9 @@ import { handledError } from '@/utils/error'
* *
* 后端 min_score 是字符串直出(分值红线),比较前必须 parseFloat 转数值, * 后端 min_score 是字符串直出(分值红线),比较前必须 parseFloat 转数值,
* 禁止字符串直接比大小("90.0" 这类字典序比较会得出错误结果)。 * 禁止字符串直接比大小("90.0" 这类字典序比较会得出错误结果)。
* 无命中(总分低于全部下限)返回 null,由调用方展示未命中提示。
*/ */
function resolveGrade(grades: RatingGradeItem[], totalScore: number): RatingGradeItem | null { function resolveGrade(grades: RatingGradeItem[], totalScore: number): RatingGradeItem | null {
const ordered = [...grades].sort((a, b) => a.sort_order - b.sort_order) const ordered = [...grades].sort((a, b) => a.sort_order - b.sort_order || a.id - b.id)
for (const grade of ordered) { for (const grade of ordered) {
if (totalScore >= parseFloat(grade.min_score)) { if (totalScore >= parseFloat(grade.min_score)) {
return grade return grade
@ -41,7 +58,7 @@ function resolveGrade(grades: RatingGradeItem[], totalScore: number): RatingGrad
return null return null
} }
// ==================== 评分计算(纯前端实时计算) ==================== // ==================== 评分计算 ====================
/** 五项评分项:各 0-20 分、步进 1,总分 = 五项之和(0-100) */ /** 五项评分项:各 0-20 分、步进 1,总分 = 五项之和(0-100) */
const SCORE_ITEMS = [ const SCORE_ITEMS = [
@ -67,21 +84,49 @@ const totalScore = computed(() =>
SCORE_ITEMS.reduce((sum, item) => sum + (scores[item.key] ?? 0), 0), SCORE_ITEMS.reduce((sum, item) => sum + (scores[item.key] ?? 0), 0),
) )
/** 无保存动作(不落库),重置即把五项清零 */
function resetScores(): void { function resetScores(): void {
for (const item of SCORE_ITEMS) { for (const item of SCORE_ITEMS) {
scores[item.key] = 0 scores[item.key] = 0
} }
} }
// ==================== 等级映射(判定用列表) ==================== const remark = ref('')
/** 已存在的评级(用于回显打分与「上次评级」信息) */
const hasExisting = ref(false)
const existingInfo = ref<string | null>(null)
const saving = ref(false)
async function loadRating(): Promise<void> {
try {
const rating = await getCustomerRating(props.customerId)
if (!rating) {
hasExisting.value = false
existingInfo.value = null
resetScores()
remark.value = ''
return
}
hasExisting.value = true
// 后端字符串直出,回填 parseFloat;保存时再 String() 转回
scores.ship_amount = parseFloat(rating.score_ship_amount)
scores.receipt_rate = parseFloat(rating.score_receipt_rate)
scores.profit_rate = parseFloat(rating.score_profit_rate)
scores.contract_cooperation = parseFloat(rating.score_contract_cooperation)
scores.site_construction = parseFloat(rating.score_site_construction)
remark.value = rating.remark ?? ''
existingInfo.value = `上次由 ${rating.scorer_name} 评定于 ${rating.update_time.replace('T', ' ').slice(0, 16)}`
} catch (error) {
handledError(error)
}
}
// ==================== 等级映射(判定用列表 + 设置表共用) ====================
const { confirmAndRun } = useConfirmAction() const { confirmAndRun } = useConfirmAction()
const grades = ref<RatingGradeItem[]>([]) const grades = ref<RatingGradeItem[]>([])
const gradesLoading = ref(false) const gradesLoading = ref(false)
/** 判定与抽屉表格共用同一份全量列表,配置变更后重载一次即同时生效 */
async function loadGrades(): Promise<void> { async function loadGrades(): Promise<void> {
gradesLoading.value = true gradesLoading.value = true
try { try {
@ -95,17 +140,47 @@ async function loadGrades(): Promise<void> {
const matchedGrade = computed(() => resolveGrade(grades.value, totalScore.value)) const matchedGrade = computed(() => resolveGrade(grades.value, totalScore.value))
// ==================== 等级映射设置抽屉 ==================== // ==================== 打开即初始化 ====================
const drawerVisible = ref(false) async function init(): Promise<void> {
activeTab.value = 'score'
/** 打开时重新拉取,避免展示陈旧数据 */ hasExisting.value = false
function openDrawer(): void { existingInfo.value = null
drawerVisible.value = true resetScores()
void loadGrades() remark.value = ''
if (!props.customerId) return
await Promise.all([loadGrades(), loadRating()])
} }
// ==================== 新增 / 编辑等级弹窗 ==================== watch(visible, (open) => {
if (open) void init()
})
// ==================== 保存评级 ====================
async function handleSave(): Promise<void> {
if (!props.customerId) return
saving.value = true
try {
await saveCustomerRatingApi(props.customerId, {
score_ship_amount: String(scores.ship_amount ?? 0),
score_receipt_rate: String(scores.receipt_rate ?? 0),
score_profit_rate: String(scores.profit_rate ?? 0),
score_contract_cooperation: String(scores.contract_cooperation ?? 0),
score_site_construction: String(scores.site_construction ?? 0),
remark: remark.value.trim() || undefined,
})
ElMessage.success('评级已保存')
visible.value = false
emit('saved')
} catch (error) {
handledError(error)
} finally {
saving.value = false
}
}
// ==================== 新增 / 编辑等级(二层弹窗) ====================
const editVisible = ref(false) const editVisible = ref(false)
/** null = 新增,否则为编辑目标 id */ /** null = 新增,否则为编辑目标 id */
@ -150,7 +225,6 @@ function editFormError(minScore: number | undefined, maxScore: number | undefine
} }
async function saveGrade(): Promise<void> { async function saveGrade(): Promise<void> {
// 局部变量收窄类型:reactive 属性在异步流程中不保证保持窄化
const minScore = editForm.min_score const minScore = editForm.min_score
const maxScore = editForm.max_score const maxScore = editForm.max_score
const error = editFormError(minScore, maxScore) const error = editFormError(minScore, maxScore)
@ -165,7 +239,7 @@ async function saveGrade(): Promise<void> {
max_score: String(maxScore), max_score: String(maxScore),
sort_order: editForm.sort_order ?? 0, sort_order: editForm.sort_order ?? 0,
} }
// 后端为全量替换语义;remark 本页不维护,编辑时原样带回避免误清 // 后端为全量替换语义;remark 本弹窗不维护,编辑时原样带回避免误清
if (editingId.value !== null) { if (editingId.value !== null) {
payload.remark = grades.value.find((g) => g.id === editingId.value)?.remark ?? undefined payload.remark = grades.value.find((g) => g.id === editingId.value)?.remark ?? undefined
} }
@ -180,6 +254,7 @@ async function saveGrade(): Promise<void> {
editVisible.value = false editVisible.value = false
ElMessage.success(isCreate.value ? '评级等级已创建' : '已保存') ElMessage.success(isCreate.value ? '评级等级已创建' : '已保存')
await loadGrades() await loadGrades()
emit('saved')
} catch (requestError) { } catch (requestError) {
handledError(requestError) handledError(requestError)
} finally { } finally {
@ -187,116 +262,134 @@ async function saveGrade(): Promise<void> {
} }
} }
/** 删除走确认动作流;文案明确后果(该区间不再能映射等级) */ /** 删除走确认动作流;文案明确后果(该区间不再能映射等级,已评级客户快照不受影响) */
function removeGrade(row: RatingGradeItem): void { function removeGrade(row: RatingGradeItem): void {
void confirmAndRun({ void confirmAndRun({
confirmText: `确定删除等级「${row.grade_name}」(${row.min_score} ~ ${row.max_score})?删除后该分数区间将无法映射等级。`, confirmText: `确定删除等级「${row.grade_name}」(${row.min_score} ~ ${row.max_score})?删除后新评级在该分数区间将无法映射等级,已评级客户的等级快照不受影响。`,
title: '删除确认', title: '删除确认',
confirmButtonText: '删除', confirmButtonText: '删除',
successText: '评级等级已删除', successText: '评级等级已删除',
run: () => deleteRatingGrade(row.id), run: () => deleteRatingGrade(row.id),
after: () => loadGrades(), after: async () => {
await loadGrades()
emit('saved')
},
}) })
} }
onMounted(() => {
void loadGrades()
})
</script> </script>
<template> <template>
<div class="rating-page"> <el-dialog
v-model="visible"
:title="`客户评级 · ${customerName}`"
width="680px"
destroy-on-close
>
<!-- 对照表 #25:常驻口径提示,防止误认为评分会影响绩效金额 --> <!-- 对照表 #25:常驻口径提示,防止误认为评分会影响绩效金额 -->
<el-alert <el-alert
class="page-alert" class="rating-alert"
title="本页为评分计算工具,评分结果不参与绩效金额计算" title="评级为评分工具,评分结果不参与绩效金额计算"
type="warning" type="warning"
:closable="false" :closable="false"
show-icon show-icon
/> />
<!-- 评分计算区:无保存按钮,纯前端实时计算 --> <el-tabs v-model="activeTab">
<el-card shadow="never"> <el-tab-pane label="客户评级" name="score">
<div class="page-toolbar"> <div class="calc-grid">
<span class="page-desc">五项各 0-20 分,总分 0-100;评级等级按配置的下限实时判定。</span> <div class="calc-form">
<span> <div v-for="item in SCORE_ITEMS" :key="item.key" class="score-row">
<el-button @click="resetScores">重置</el-button> <span class="score-label">{{ item.label }}</span>
<el-button type="primary" @click="openDrawer">等级映射设置</el-button> <el-input-number
</span> v-model="scores[item.key]"
</div> :min="0"
:max="20"
<div class="calc-grid"> :step="1"
<div class="calc-form"> controls-position="right"
<div v-for="item in SCORE_ITEMS" :key="item.key" class="score-row"> />
<span class="score-label">{{ item.label }}</span> <span class="score-unit">分</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>
<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>
</el-card>
<!-- 等级映射设置抽屉 -->
<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> </div>
</template> <p v-if="existingInfo" class="existing-hint">{{ existingInfo }}</p>
</el-table-column> </div>
</el-table>
</el-drawer>
<!-- 新增 / 编辑等级 --> <div class="calc-result">
<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>
<el-form label-width="70px" class="remark-form" @submit.prevent>
<el-form-item label="备注">
<el-input
v-model="remark"
maxlength="255"
placeholder="选填,如评级依据说明"
clearable
/>
</el-form-item>
</el-form>
<div class="score-tip">
五项各 0-20 分,总分 0-100;保存后等级按当前映射快照固化,
之后调整等级映射不影响该客户已保存的等级。
</div>
<div class="dialog-actions">
<el-button @click="resetScores">重置</el-button>
<el-button type="primary" :loading="saving" @click="handleSave">
{{ hasExisting ? '更新评级' : '保存评级' }}
</el-button>
</div>
</el-tab-pane>
<el-tab-pane label="等级映射设置" name="grades">
<div class="drawer-hint">
判定规则:按排序升序取首个「下限 ≤ 总分」的等级,上限仅作展示;修改后评级区立即按新配置判定。
</div>
<div class="grades-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 }">
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
<el-button link type="danger" size="small" @click="removeGrade(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
</el-tabs>
<!-- 新增 / 编辑等级(二层弹窗;append-to-body 避免嵌套弹层层级与裁剪问题) -->
<el-dialog <el-dialog
v-model="editVisible" v-model="editVisible"
:title="isCreate ? '新增评级等级' : '编辑评级等级'" :title="isCreate ? '新增评级等级' : '编辑评级等级'"
width="440px" width="440px"
append-to-body
destroy-on-close destroy-on-close
@closed="editingId = null" @closed="editingId = null"
> >
@ -342,49 +435,36 @@ onMounted(() => {
<el-button type="primary" :loading="editSaving" @click="saveGrade">保存</el-button> <el-button type="primary" :loading="editSaving" @click="saveGrade">保存</el-button>
</template> </template>
</el-dialog> </el-dialog>
</div> </el-dialog>
</template> </template>
<style scoped> <style scoped>
.page-alert { .rating-alert {
margin-bottom: 14px; margin-bottom: 6px;
}
.page-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
gap: 12px;
flex-wrap: wrap;
}
.page-desc {
font-size: 13px;
color: var(--ys-ink-2);
} }
.calc-grid { .calc-grid {
display: flex; display: flex;
gap: 28px; gap: 24px;
align-items: stretch; align-items: stretch;
flex-wrap: wrap; flex-wrap: wrap;
margin-top: 8px;
} }
.calc-form { .calc-form {
flex: 1 1 380px; flex: 1 1 320px;
min-width: 280px; min-width: 260px;
} }
.score-row { .score-row {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
line-height: 2.7; line-height: 2.6;
} }
.score-label { .score-label {
width: 140px; width: 132px;
font-size: 13px; font-size: 13px;
color: var(--ys-ink); color: var(--ys-ink);
text-align: right; text-align: right;
@ -395,17 +475,24 @@ onMounted(() => {
color: var(--ys-ink-3); color: var(--ys-ink-3);
} }
.existing-hint {
margin: 8px 0 0;
font-size: 12px;
text-align: right;
color: var(--ys-ink-3);
}
.calc-result { .calc-result {
flex: 0 0 250px; flex: 0 0 220px;
min-width: 220px; min-width: 200px;
border: 1px solid var(--ys-line); border: 1px solid var(--ys-line);
border-radius: 10px; border-radius: 10px;
padding: 18px 20px; padding: 16px 18px;
background: linear-gradient(180deg, #ffffff, #fcfcfe); background: linear-gradient(180deg, #ffffff, #fcfcfe);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
gap: 18px; gap: 16px;
} }
.result-label { .result-label {
@ -416,7 +503,7 @@ onMounted(() => {
} }
.total-num { .total-num {
font-size: 40px; font-size: 36px;
color: var(--ys-indigo); color: var(--ys-indigo);
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
margin-right: 4px; margin-right: 4px;
@ -429,7 +516,7 @@ onMounted(() => {
.grade-name { .grade-name {
font-family: var(--ys-font-brand); font-family: var(--ys-font-brand);
font-size: 28px; font-size: 26px;
font-weight: 600; font-weight: 600;
color: var(--ys-ink); color: var(--ys-ink);
line-height: 1.3; line-height: 1.3;
@ -442,6 +529,25 @@ onMounted(() => {
margin-top: 4px; margin-top: 4px;
} }
.remark-form {
margin-top: 10px;
}
.score-tip {
font-size: 12px;
color: var(--ys-ink-3);
background: var(--ys-mist);
border-radius: 6px;
padding: 8px 12px;
}
.dialog-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 16px;
}
.drawer-hint { .drawer-hint {
font-size: 12px; font-size: 12px;
color: var(--ys-ink-3); color: var(--ys-ink-3);
@ -451,7 +557,7 @@ onMounted(() => {
margin-bottom: 12px; margin-bottom: 12px;
} }
.drawer-toolbar { .grades-toolbar {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
margin-bottom: 10px; margin-bottom: 10px;
@ -471,7 +577,6 @@ onMounted(() => {
padding: 8px 12px; padding: 8px 12px;
} }
/* 移动端:标签左对齐、结果块自然换行到表单下方 */
@media (max-width: 768px) { @media (max-width: 768px) {
.score-label { .score-label {
width: auto; width: auto;

View File

@ -1,15 +1,17 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus' import { ElMessage, type FormInstance, type FormRules, type TableInstance } 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 } 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 { usePagedList } from '@/composables/usePagedList' import { usePagedList } from '@/composables/usePagedList'
import { formatDateTime } from '@/utils/format' import { formatDateTime } from '@/utils/format'
import { handledError } from '@/utils/error' import { handledError } from '@/utils/error'
import CustomerRatingDialog from './CustomerRatingDialog.vue'
/** /**
* 客户管理(需求 2.1.3:客户档案 + 归属人员 + 归属变更留痕)。 * 客户管理(需求 2.1.3:客户档案 + 归属人员 + 归属变更留痕)。
@ -20,6 +22,9 @@ import { handledError } from '@/utils/error'
* - 归属变更提交前二次确认,文案必须包含「仅影响变更后核算,不回溯历史」 * - 归属变更提交前二次确认,文案必须包含「仅影响变更后核算,不回溯历史」
* (对照表 #19 的业务口径) * (对照表 #19 的业务口径)
* - 归属变更记录为分页留痕表(抽屉内展示,按变更时间倒序) * - 归属变更记录为分页留痕表(抽屉内展示,按变更时间倒序)
* - 客户评级列(2026-10-19):每客户一行快照落库;评级配置走行内弹窗
* (原独立评级页删除);列头支持正序/倒序 —— 列表是服务端分页,
* 排序必须带参数回后端(`order_field=rating`),且未评级恒在末尾
*/ */
const { confirmAndRun } = useConfirmAction() const { confirmAndRun } = useConfirmAction()
@ -31,6 +36,10 @@ const filters = reactive({
owner_personnel_id: undefined as number | undefined, owner_personnel_id: undefined as number | undefined,
}) })
/** 评级列排序方向(undefined = 默认 id 倒序;后端白名单只接受 rating) */
const ratingOrder = ref<'asc' | 'desc' | undefined>(undefined)
const tableRef = ref<TableInstance>()
const { records, total, pageNum, pageSize, loading, load, search, onPageChange, onSizeChange, reloadAfterDelete } = const { records, total, pageNum, pageSize, loading, load, search, onPageChange, onSizeChange, reloadAfterDelete } =
usePagedList<CustomerItem, customerApi.CustomerQueryParams>( usePagedList<CustomerItem, customerApi.CustomerQueryParams>(
customerApi.listCustomers, customerApi.listCustomers,
@ -38,16 +47,58 @@ 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_field: ratingOrder.value ? 'rating' : undefined,
order_type: ratingOrder.value,
}), }),
) )
/** 评级列头排序:custom 模式只触发事件,排序交给后端(服务端分页必须如此) */
function onRatingSortChange({
prop,
order,
}: {
prop: string
order: 'ascending' | 'descending' | null
}): void {
if (prop !== 'rating_total') return
ratingOrder.value = order === 'ascending' ? 'asc' : order === 'descending' ? 'desc' : undefined
search()
}
function resetFilters(): void { function resetFilters(): void {
filters.keyword = '' filters.keyword = ''
filters.customer_type = undefined filters.customer_type = undefined
filters.owner_personnel_id = undefined filters.owner_personnel_id = undefined
ratingOrder.value = undefined
tableRef.value?.clearSort()
search() search()
} }
// ==================== 评级列展示 ====================
/** 等级名按判定位次(sort_order 升序)排列,用于 tag 语义色映射 */
const gradeNames = ref<string[]>([])
async function loadGradeNames(): Promise<void> {
try {
const grades = await listRatingGrades()
gradeNames.value = grades.map((grade) => grade.grade_name)
} catch {
// 配色不是关键路径:失败时所有等级走默认 info 色,列表照常可用
gradeNames.value = []
}
}
const RATING_TAG_TYPES = ['success', 'primary', 'warning', 'danger'] as const
/** 第 1 档 success → 第 2 档 primary → 第 3 档 warning → 其余 danger;未知等级 info */
function ratingTagType(gradeName: string | null): (typeof RATING_TAG_TYPES)[number] | 'info' {
if (!gradeName) return 'info'
const index = gradeNames.value.indexOf(gradeName)
if (index === -1) return 'info'
return RATING_TAG_TYPES[Math.min(index, RATING_TAG_TYPES.length - 1)]
}
// ==================== 归属人员下拉数据源 ==================== // ==================== 归属人员下拉数据源 ====================
interface PersonnelOption { interface PersonnelOption {
@ -302,7 +353,7 @@ function ownerTransition(row: OwnershipLogItem): string {
async function handleDelete(row: CustomerItem): Promise<void> { async function handleDelete(row: CustomerItem): Promise<void> {
await confirmAndRun({ await confirmAndRun({
confirmText: `确定删除客户「${row.company_name}」?删除为物理删除、不可恢复;归属变更留痕将一并失去展示主体。`, confirmText: `确定删除客户「${row.company_name}」?删除为物理删除、不可恢复;归属变更留痕与评级记录将一并失去展示主体。`,
title: '删除确认', title: '删除确认',
confirmButtonText: '删除', confirmButtonText: '删除',
successText: '客户已删除', successText: '客户已删除',
@ -311,12 +362,29 @@ async function handleDelete(row: CustomerItem): Promise<void> {
}) })
} }
// ==================== 客户评级弹窗 ====================
const ratingVisible = ref(false)
const ratingTarget = ref<CustomerItem | null>(null)
function openRating(row: CustomerItem): void {
ratingTarget.value = row
ratingVisible.value = true
}
/** 弹窗内保存评级/调整等级映射后:刷新列表行与 tag 配色 */
async function handleRatingSaved(): Promise<void> {
await load()
void loadGradeNames()
}
// ==================== 初始化 ==================== // ==================== 初始化 ====================
onMounted(() => { onMounted(() => {
// 两个请求各自容错(load 内部已兜底),互不拖累 // 三个请求各自容错(内部已兜底),互不拖累
void load() void load()
void loadOwnerOptions() void loadOwnerOptions()
void loadGradeNames()
}) })
</script> </script>
@ -369,16 +437,54 @@ onMounted(() => {
<el-button type="primary" @click="openCreate">新增客户</el-button> <el-button type="primary" @click="openCreate">新增客户</el-button>
</div> </div>
<el-table v-loading="loading" :data="records" stripe> <el-table
<el-table-column prop="company_name" label="公司名称" min-width="180" /> ref="tableRef"
<el-table-column prop="owner_name" label="负责人" min-width="90" /> v-loading="loading"
<el-table-column prop="contact_person" label="联系人" min-width="90"> :data="records"
stripe
@sort-change="onRatingSortChange"
>
<!-- 列宽约定(2026-10-20 用户确认):8 列**等宽平分** —— 所有列共用同一
min-width,el-table 按 min-width 比例分配剩余宽度,等值即等宽;
全列居中后相邻两列的间距处处一致。不设 width/fixed:固定列会打破
等分(min-width 与 width 混排是按比例瓜分的根源)。窄屏下列宽收缩,
操作按钮自然换行,优先保证间距一致 -->
<el-table-column
prop="company_name"
label="公司名称"
min-width="140"
align="center"
show-overflow-tooltip
/>
<el-table-column
prop="rating_total"
label="客户评级"
min-width="140"
align="center"
sortable="custom"
>
<template #default="{ row }">
<template v-if="row.rating_total !== null">
<el-tag
:type="ratingTagType(row.rating_grade_name)"
size="small"
disable-transitions
>
{{ row.rating_grade_name ?? '未定级' }}
</el-tag>
<span class="rating-score">{{ row.rating_total }} 分</span>
</template>
<span v-else class="rating-none">未评级</span>
</template>
</el-table-column>
<el-table-column prop="owner_name" label="负责人" min-width="140" align="center" />
<el-table-column prop="contact_person" label="联系人" min-width="140" align="center">
<template #default="{ row }">{{ row.contact_person ?? '—' }}</template> <template #default="{ row }">{{ row.contact_person ?? '—' }}</template>
</el-table-column> </el-table-column>
<el-table-column prop="contact_phone" label="电话" min-width="120"> <el-table-column prop="contact_phone" label="电话" min-width="140" align="center">
<template #default="{ row }">{{ row.contact_phone ?? '—' }}</template> <template #default="{ row }">{{ row.contact_phone ?? '—' }}</template>
</el-table-column> </el-table-column>
<el-table-column label="分类" width="110" align="center"> <el-table-column label="分类" min-width="140" align="center">
<template #default="{ row }"> <template #default="{ row }">
<el-tag <el-tag
:type=" :type="
@ -395,18 +501,23 @@ onMounted(() => {
</el-tag> </el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="归属人员" min-width="110"> <el-table-column label="归属人员" min-width="140" align="center">
<template #default="{ row }">{{ row.owner_personnel_name ?? '(无归属)' }}</template> <template #default="{ row }">{{ row.owner_personnel_name ?? '(无归属)' }}</template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="260" fixed="right" align="center"> <el-table-column label="操作" min-width="140" align="center">
<template #default="{ row }"> <template #default="{ row }">
<div class="table-actions"> <!-- 五项按钮单行均分:两端对齐让相邻按钮间距处处相等;
「配置客户评级」最长(6 字),在等宽列内无法再做 5 等分不换行,
故用等间距平分整列宽度 -->
<div class="ops-actions">
<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="openRating(row)">
配置客户评级
</el-button>
<el-button link type="primary" size="small" @click="openOwnership(row)"> <el-button link type="primary" size="small" @click="openOwnership(row)">
归属变更 归属变更
</el-button> </el-button>
<el-button link type="primary" size="small" @click="openLogs(row)">变更记录</el-button> <el-button link type="primary" size="small" @click="openLogs(row)">变更记录</el-button>
<span class="table-actions__divider" aria-hidden="true"></span>
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button> <el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
</div> </div>
</template> </template>
@ -530,6 +641,14 @@ onMounted(() => {
</template> </template>
</el-dialog> </el-dialog>
<!-- 客户评级(打分 + 等级映射设置双 Tab;原独立评级页已删除) -->
<CustomerRatingDialog
v-model="ratingVisible"
:customer-id="ratingTarget?.id ?? 0"
:customer-name="ratingTarget?.company_name ?? ''"
@saved="handleRatingSaved"
/>
<!-- 归属变更记录(分页留痕,按变更时间倒序) --> <!-- 归属变更记录(分页留痕,按变更时间倒序) -->
<el-drawer v-model="logsVisible" :title="logsTitle" size="760px" destroy-on-close> <el-drawer v-model="logsVisible" :title="logsTitle" size="760px" destroy-on-close>
<el-table v-loading="logsLoading" :data="logRecords" stripe size="small"> <el-table v-loading="logsLoading" :data="logRecords" stripe size="small">
@ -564,4 +683,31 @@ onMounted(() => {
font-size: 12px; font-size: 12px;
line-height: 1.7; line-height: 1.7;
} }
/* 操作列:五个按钮单行、等间距平分整列(两端对齐);
去掉 Element Plus 相邻按钮的默认 12px 外边距,间距完全由 flex 控制 */
.ops-actions {
display: flex;
justify-content: space-between;
align-items: center;
white-space: nowrap;
}
.ops-actions :deep(.el-button) {
margin: 0;
padding: 0;
}
/* 评级列:等级 tag + 总分灰字;未评级灰字 */
.rating-score {
margin-left: 6px;
font-size: 12px;
color: var(--ys-ink-3);
font-variant-numeric: tabular-nums;
}
.rating-none {
font-size: 12px;
color: var(--ys-ink-3);
}
</style> </style>

View File

@ -5,11 +5,13 @@ import { useRoute, useRouter } from 'vue-router'
import { getCaptcha } from '@/api/auth' import { getCaptcha } from '@/api/auth'
import { canAccessPath, firstAccessiblePath } from '@/router/access' import { canAccessPath, firstAccessiblePath } from '@/router/access'
import { useSettingsStore } from '@/store/settings'
import { useUserStore } from '@/store/user' import { useUserStore } from '@/store/user'
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
const userStore = useUserStore() const userStore = useUserStore()
const settingsStore = useSettingsStore()
const formRef = ref<FormInstance>() const formRef = ref<FormInstance>()
const submitting = ref(false) const submitting = ref(false)
@ -24,32 +26,6 @@ const form = reactive({
const captchaId = ref('') const captchaId = ref('')
const captchaImage = ref('') const captchaImage = ref('')
/** 品牌名:默认衍水集团,公司信息设置页改了以后登录页同步生效(public 端点) */
const brandName = ref('衍水集团')
const brandLogo = ref<string | null>(null)
async function loadBrand(): Promise<void> {
try {
// 登录页必须拿到最新公司名(保存后立即生效):public 端点带 300s
// 响应缓存,axios 可能命中浏览器缓存副本。这里用带时间戳的原生
// fetch 绕过磁盘缓存,手动解包统一信封(与 request 拦截器同口径)。
const resp = await fetch(
`/api/v1/system/settings/public?_=${Date.now()}`,
{ cache: 'no-store' },
)
if (!resp.ok) throw new Error(String(resp.status))
const body = (await resp.json()) as {
code: number
data: { company_name?: string; logo_url?: string | null }
}
const data = body.data
if (data.company_name) brandName.value = data.company_name
brandLogo.value = data.logo_url ?? null
} catch {
// 登录页必须可用:公司信息接口失败时静默回退默认品牌名,不阻断登录
}
}
const rules: FormRules = { const rules: FormRules = {
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }], username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }], password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
@ -108,7 +84,8 @@ async function handleSubmit(): Promise<void> {
onMounted(() => { onMounted(() => {
refreshCaptcha() refreshCaptcha()
loadBrand() // 品牌信息失败静默回退内置默认值(不阻断登录;内部去重/缓存)
void settingsStore.ensureLoaded()
}) })
</script> </script>
@ -122,9 +99,14 @@ onMounted(() => {
<div class="login-wrap"> <div class="login-wrap">
<div class="login-card"> <div class="login-card">
<div class="card-brand"> <div class="card-brand">
<img v-if="brandLogo" :src="brandLogo" alt="" class="card-brand__logo" /> <img
<div class="card-brand__name">{{ brandName }}</div> v-if="settingsStore.logoUrl"
<p class="card-brand__slogan">把每一笔回款,算成一份踏实。</p> :src="settingsStore.logoUrl"
alt=""
class="card-brand__logo"
/>
<div class="card-brand__name">{{ settingsStore.companyName }}</div>
<p class="card-brand__slogan">{{ settingsStore.brandSlogan }}</p>
<span class="waterline"></span> <span class="waterline"></span>
</div> </div>
@ -189,7 +171,10 @@ onMounted(() => {
</el-form> </el-form>
</div> </div>
<p class="login-foot">© {{ brandName }} · 内部系统</p> <p class="login-foot">
{{ settingsStore.footerLine
}}<template v-if="settingsStore.icpNo"> · {{ settingsStore.icpNo }}</template>
</p>
</div> </div>
</div> </div>
</template> </template>

View File

@ -4,6 +4,7 @@ import { ElMessage, ElMessageBox } from 'element-plus'
import * as businessRoleApi from '@/api/businessRoles' import * as businessRoleApi from '@/api/businessRoles'
import * as paramsApi from '@/api/params' import * as paramsApi from '@/api/params'
import { paramEnumLabel } from '@/api/params'
import type { import type {
ParamBatch, ParamBatch,
ParamBatchDetail, ParamBatchDetail,
@ -43,9 +44,10 @@ async function loadCatalog(): Promise<void> {
catalogLoading.value = true catalogLoading.value = true
try { try {
catalog.value = await paramsApi.getCatalog() catalog.value = await paramsApi.getCatalog()
// 预览「所用参数」按编码取中文名;目录加载后填充映射 // 目录加载后填充映射:预览「所用参数」按编码取中文名;值翻译按编码查类型
for (const item of catalog.value) { for (const item of catalog.value) {
PARAM_NAME_BY_CODE[item.param_code] = item.param_name PARAM_NAME_BY_CODE[item.param_code] = item.param_name
PARAM_VALUE_TYPES[item.param_code] = item.value_type
} }
} catch (error) { } catch (error) {
handledError(error) handledError(error)
@ -83,20 +85,24 @@ function paramNameByCode(code: string): string {
return PARAM_NAME_BY_CODE[code] ?? code return PARAM_NAME_BY_CODE[code] ?? code
} }
/** 枚举值 → 中文(FULL/BALANCE、INCLUDE/DEDUCT/EXCLUDE、0/1 开关) */ /** 参数编码 → 值类型(目录加载后填充;未知参数不翻译,避免误译数值) */
const ENUM_VALUE_LABELS: Record<string, string> = { const PARAM_VALUE_TYPES: Record<string, string> = {}
FULL: '当月全额',
BALANCE: '余额', /**
INCLUDE: '含基本工资', * 按参数编码翻译枚举值(旧值/新值等宽表用)。
DEDUCT: '扣除基本工资', *
EXCLUDE: '不计基本工资', * **必须先判参数类型**:枚举映射含 0=关/1=开,若无差别套用,值为 0 或 1
'0': '关', * 的普通数值参数会被误译成「关/开」。非枚举(或目录尚未加载)原样返回。
'1': '开', */
function enumValueLabel(paramCode: string, value: string | null | undefined): string {
if (value === null || value === undefined) return ''
return PARAM_VALUE_TYPES[paramCode] === 'ENUM' ? paramEnumLabel(value) : value
} }
function valueLabel(value: string | null | undefined): string { /** ENUM 下拉选项文案:中文主显 + 括号保留原码(当月全额(FULL)),便于与审计记录对上 */
if (value === null || value === undefined) return '' function enumOptionLabel(option: string): string {
return ENUM_VALUE_LABELS[value] ?? value const label = paramEnumLabel(option)
return label === option ? option : `${label}(${option})`
} }
/** 带单位的值展示 */ /** 带单位的值展示 */
@ -467,7 +473,14 @@ onMounted(() => {
<template v-if="item.values.length"> <template v-if="item.values.length">
<div v-for="v in item.values" :key="v.dimension_value" class="value-row"> <div v-for="v in item.values" :key="v.dimension_value" class="value-row">
<span class="dim-label">{{ dimensionLabel(v.dimension_value) }}</span> <span class="dim-label">{{ dimensionLabel(v.dimension_value) }}</span>
<b class="num">{{ formatValue(item, v.value_text) }}</b> <b class="num">
{{ item.value_type === 'ENUM' ? paramEnumLabel(v.value_text) : formatValue(item, v.value_text) }}
</b>
<!-- 灰色小码:与库内/审计记录里的原始码对上;未知码(中文缺映射)不重复显示 -->
<span
v-if="item.value_type === 'ENUM' && paramEnumLabel(v.value_text) !== v.value_text"
class="enum-code"
>{{ v.value_text }}</span>
<span class="eff">{{ v.effective_date }} 起</span> <span class="eff">{{ v.effective_date }} 起</span>
<el-tag <el-tag
v-if="pendingOf(item, v.dimension_value)" v-if="pendingOf(item, v.dimension_value)"
@ -475,7 +488,7 @@ onMounted(() => {
type="warning" type="warning"
disable-transitions disable-transitions
> >
待改为 {{ pendingOf(item, v.dimension_value)!.new_value }} 待改为 {{ enumValueLabel(item.param_code, pendingOf(item, v.dimension_value)!.new_value) }}
</el-tag> </el-tag>
<el-button <el-button
v-if="isAdmin" v-if="isAdmin"
@ -527,10 +540,12 @@ 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 }">{{ enumValueLabel(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">{{ enumValueLabel(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 }">
@ -578,7 +593,9 @@ 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 class="num">
{{ enumValueLabel(editTarget.item.param_code, 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 v-if="editTarget.item.unit" style="color: var(--ys-ink-3)"> {{ editTarget.item.unit }}</span>
</el-form-item> </el-form-item>
<el-form-item label="新值"> <el-form-item label="新值">
@ -587,7 +604,12 @@ onMounted(() => {
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" /> <el-option
v-for="opt in editTarget.item.options"
:key="opt"
:label="enumOptionLabel(opt)"
:value="opt"
/>
</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>
@ -632,11 +654,15 @@ 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="90"> <el-table-column label="旧值" min-width="90">
<template #default="{ row }">{{ row.old_value ?? '—' }}</template> <template #default="{ row }">
{{ enumValueLabel(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 }">
{{ enumValueLabel(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">
@ -658,7 +684,10 @@ 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">
{{ enumValueLabel(item.param_code, item.old_value_text) || '—' }} →
<b>{{ enumValueLabel(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(() => {})">
@ -707,7 +736,10 @@ 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> <span class="num">
{{ enumValueLabel(historyItem?.param_code ?? '', row.old_value_text) || '—' }} →
{{ enumValueLabel(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" />
@ -799,7 +831,7 @@ onMounted(() => {
<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) }}({{ u.param_code }})= {{ enumValueLabel(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>
@ -875,6 +907,13 @@ onMounted(() => {
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
/* 枚举值旁的灰色原始码(如「当月全额 FULL」的 FULL),与审计记录对账用 */
.enum-code {
font-size: 11px;
font-weight: 400;
color: var(--ys-ink-3);
}
.eff { .eff {
font-size: 12px; font-size: 12px;
color: var(--ys-ink-3); color: var(--ys-ink-3);

View File

@ -1,42 +1,85 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, reactive, ref } from 'vue' import { computed, reactive, ref } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus' import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import type { UploadRawFile } from 'element-plus' import type { UploadRawFile } from 'element-plus'
import * as settingsApi from '@/api/settings' import * as settingsApi from '@/api/settings'
import type { CompanySettings } from '@/api/settings'
import { useSettingsStore } from '@/store/settings'
import { handledError } from '@/utils/error' import { handledError } from '@/utils/error'
/** /**
* 公司信息设置(2026-10-15 补充需求:公司名称、logo)。 * 公司信息与站点信息设置(2026-10-20 定稿)。
* *
* 签名元素是右侧「登录页实景预览」:品牌名与 logo 改动即时反映在一张 * 页面按「左侧设置 → 右侧预览」成对排列,四张卡两行:
* 缩比登录卡上 —— 所见即所得,避免「保存后去登录页核对」的往返。 * - 第一行:登录页信息(名称/标语/页脚/备案号/logo)→ 登录页预览
* logo 校验(png/jpg ≤2MB)与后端三重校验同口径,前端先拦一道。 * - 第二行:网页站点信息(站点标题/站点图标)→ 浏览器标签页预览
* *
* 进入页面即加载当前设置(设置页即所见即所得,无需手动刷新)。 * 两张设置卡**各自独立保存**(后端为部分更新语义):
* 改登录页信息不必触碰站点标题,反之亦然;保存后把回包写入 settings store,
* 侧边栏/浏览器标题/favicon/登录页立即同步(不重新请求 public 端点 ——
* 匿名端点带 300s 浏览器缓存)。
*/ */
const formRef = ref<FormInstance>() const route = useRoute()
const submitting = ref(false) const settingsStore = useSettingsStore()
const form = reactive({ // ==================== 登录页信息 ====================
const loginFormRef = ref<FormInstance>()
const savingLogin = ref(false)
const loginForm = reactive({
company_name: '', company_name: '',
brand_slogan: '',
footer_text: '',
icp_no: '',
}) })
const rules: FormRules = { const loginRules: FormRules = {
company_name: [ company_name: [
{ required: true, message: '请输入公司名称', trigger: 'blur' }, { required: true, message: '请输入公司名称', trigger: 'blur' },
{ max: 64, message: '公司名称最长 64 个字符', trigger: 'blur' }, { max: 64, message: '公司名称最长 64 个字符', trigger: 'blur' },
], ],
brand_slogan: [{ max: 64, message: '标语最长 64 个字符', trigger: 'blur' }],
footer_text: [{ max: 64, message: '页脚文本最长 64 个字符', trigger: 'blur' }],
icp_no: [{ max: 32, message: '备案号最长 32 个字符', trigger: 'blur' }],
} }
// ==================== 网页站点信息 ====================
const siteFormRef = ref<FormInstance>()
const savingSite = ref(false)
const siteForm = reactive({
site_title: '',
})
const siteRules: FormRules = {
site_title: [{ max: 64, message: '站点标题最长 64 个字符', trigger: 'blur' }],
}
// ==================== 图片资源 ====================
const logoUrl = ref<string | null>(null) const logoUrl = ref<string | null>(null)
const uploading = ref(false) const faviconUrl = ref<string | null>(null)
const uploadingLogo = ref(false)
const uploadingFavicon = ref(false)
const MAX_IMAGE_BYTES = 2 * 1024 * 1024
/**
* 生效的站点图标:自定义图标 → 公司 logo → 内置 SVG。
* 与 settings store 同口径 —— 未单独上传图标时,换 logo 标签页图标跟着变。
*/
const effectiveFavicon = computed(() => faviconUrl.value ?? logoUrl.value ?? '/favicon.svg')
async function loadSettings(): Promise<void> { async function loadSettings(): Promise<void> {
try { try {
const data = await settingsApi.getSettings() const data = await settingsApi.getSettings()
form.company_name = data.company_name fillAll(data)
logoUrl.value = data.logo_url // 管理端打开设置页即用最新值校准全局品牌/站点信息
settingsStore.apply(data)
} catch (error) { } catch (error) {
handledError(error) handledError(error)
} }
@ -44,118 +87,309 @@ async function loadSettings(): Promise<void> {
loadSettings() loadSettings()
async function handleSubmit(): Promise<void> { function fillAll(data: CompanySettings): void {
const valid = await formRef.value?.validate().catch(() => false) loginForm.company_name = data.company_name
loginForm.brand_slogan = data.brand_slogan ?? ''
loginForm.footer_text = data.footer_text ?? ''
loginForm.icp_no = data.icp_no ?? ''
siteForm.site_title = data.site_title ?? ''
logoUrl.value = data.logo_url
faviconUrl.value = data.favicon_url
}
/** 保存登录页信息(只提交本卡字段,不触碰站点标题) */
async function saveLoginInfo(): Promise<void> {
const valid = await loginFormRef.value?.validate().catch(() => false)
if (!valid) return if (!valid) return
submitting.value = true savingLogin.value = true
try { try {
const data = await settingsApi.updateSettings(form.company_name.trim()) const data = await settingsApi.updateSettings({
form.company_name = data.company_name company_name: loginForm.company_name.trim(),
ElMessage.success('已保存') brand_slogan: loginForm.brand_slogan.trim(),
} catch (error) { footer_text: loginForm.footer_text.trim(),
handledError(error) icp_no: loginForm.icp_no.trim(),
} finally { })
submitting.value = false // 只回填本卡字段:另一张卡未保存的编辑不被覆盖
} loginForm.company_name = data.company_name
} loginForm.brand_slogan = data.brand_slogan ?? ''
loginForm.footer_text = data.footer_text ?? ''
function beforeUpload(file: UploadRawFile): boolean { loginForm.icp_no = data.icp_no ?? ''
const okType = ['image/png', 'image/jpeg'].includes(file.type)
if (!okType) {
ElMessage.warning('logo 仅支持 png / jpg 格式')
return false
}
if (file.size > 2 * 1024 * 1024) {
ElMessage.warning('logo 文件不能超过 2MB')
return false
}
return true
}
async function handleUpload(options: { file: UploadRawFile }): Promise<void> {
uploading.value = true
try {
const data = await settingsApi.uploadLogo(options.file)
logoUrl.value = data.logo_url logoUrl.value = data.logo_url
ElMessage.success('logo 已更新') faviconUrl.value = data.favicon_url
settingsStore.apply(data)
ElMessage.success('登录页信息已保存')
} catch (error) { } catch (error) {
handledError(error) handledError(error)
} finally { } finally {
uploading.value = false savingLogin.value = false
} }
} }
/** 预览品牌名:跟随输入(空时回退占位,与登录页默认一致) */ /** 保存网页站点信息(只提交站点标题;图标由上传接口即时保存) */
const previewName = computed(() => form.company_name.trim() || '衍水集团') async function saveSiteInfo(): Promise<void> {
const valid = await siteFormRef.value?.validate().catch(() => false)
if (!valid) return
savingSite.value = true
try {
const data = await settingsApi.updateSettings({
site_title: siteForm.site_title.trim(),
})
siteForm.site_title = data.site_title ?? ''
logoUrl.value = data.logo_url
faviconUrl.value = data.favicon_url
settingsStore.apply(data)
ElMessage.success('站点信息已保存')
} catch (error) {
handledError(error)
} finally {
savingSite.value = false
}
}
// ==================== 图片上传(logo / 站点图标) ====================
function handleLogoUpload(options: { file: UploadRawFile }): Promise<void> {
const file = options.file
if (!['image/png', 'image/jpeg'].includes(file.type)) {
ElMessage.warning('logo 仅支持 png / jpg 格式')
return Promise.resolve()
}
if (file.size > MAX_IMAGE_BYTES) {
ElMessage.warning('logo 文件不能超过 2MB')
return Promise.resolve()
}
uploadingLogo.value = true
return settingsApi
.uploadLogo(file)
.then((data) => {
logoUrl.value = data.logo_url
faviconUrl.value = data.favicon_url
settingsStore.apply(data)
ElMessage.success('logo 已更新')
})
.catch(handledError)
.finally(() => {
uploadingLogo.value = false
})
}
/** 浏览器对 .ico 的 MIME 常为空,改用扩展名判断(后端仍有 magic bytes 兜底) */
function handleFaviconUpload(options: { file: UploadRawFile }): Promise<void> {
const file = options.file
if (!/\.(png|jpe?g|ico)$/i.test(file.name)) {
ElMessage.warning('站点图标仅支持 png / jpg / ico 格式')
return Promise.resolve()
}
if (file.size > MAX_IMAGE_BYTES) {
ElMessage.warning('站点图标文件不能超过 2MB')
return Promise.resolve()
}
uploadingFavicon.value = true
return settingsApi
.uploadFavicon(file)
.then((data) => {
faviconUrl.value = data.favicon_url
logoUrl.value = data.logo_url
settingsStore.apply(data)
ElMessage.success('站点图标已更新')
})
.catch(handledError)
.finally(() => {
uploadingFavicon.value = false
})
}
// ==================== 预览 ====================
/** 当前页面名(本页路由标题为「公司信息」),用于标签页标题预览 */
const previewPageTitle = computed(() => (route.meta.title as string | undefined) ?? '公司信息')
const previewName = computed(() => loginForm.company_name.trim() || '衍水集团')
const previewSlogan = computed(
() => loginForm.brand_slogan.trim() || '把每一笔回款,算成一份踏实。',
)
const previewFooter = computed(
() => loginForm.footer_text.trim() || `© ${previewName.value} · 内部系统`,
)
const previewIcp = computed(() => loginForm.icp_no.trim())
const previewSiteTitle = computed(
() => siteForm.site_title.trim() || '营销人员工资管理系统',
)
const previewTabTitle = computed(
() => `${previewPageTitle.value} - ${previewName.value} ${previewSiteTitle.value}`,
)
</script> </script>
<template> <template>
<div class="page"> <div class="page">
<div class="settings-grid"> <div class="settings-grid">
<el-card shadow="never" class="settings-card"> <!-- 左一:登录页信息 -->
<template #header> <el-form
<span class="card-title">公司信息</span> ref="loginFormRef"
</template> :model="loginForm"
:rules="loginRules"
label-width="110px"
class="settings-form"
@submit.prevent
>
<el-card shadow="never" class="settings-card">
<template #header>
<span class="card-title">登录页信息</span>
</template>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-width="88px"
@submit.prevent
>
<el-form-item label="公司名称" prop="company_name"> <el-form-item label="公司名称" prop="company_name">
<el-input <el-input
v-model="form.company_name" v-model="loginForm.company_name"
maxlength="64" maxlength="64"
placeholder="展示在登录页与导出页眉" placeholder="展示在登录页、侧边栏与浏览器标题"
clearable
/>
</el-form-item>
<el-form-item label="品牌标语" prop="brand_slogan">
<el-input
v-model="loginForm.brand_slogan"
maxlength="64"
placeholder="留空使用默认:把每一笔回款,算成一份踏实。"
clearable
/>
</el-form-item>
<el-form-item label="页脚版权" prop="footer_text">
<el-input
v-model="loginForm.footer_text"
maxlength="64"
placeholder="留空自动生成:© 公司名 · 内部系统"
clearable
/>
</el-form-item>
<el-form-item label="ICP 备案号" prop="icp_no">
<el-input
v-model="loginForm.icp_no"
maxlength="32"
placeholder="选填,展示在登录页页脚"
clearable clearable
/> />
</el-form-item> </el-form-item>
<el-form-item label="logo"> <el-form-item label="logo">
<div class="logo-field"> <div class="asset-field">
<el-upload <el-upload
class="logo-uploader"
:show-file-list="false" :show-file-list="false"
:http-request="handleUpload" :http-request="handleLogoUpload"
:before-upload="beforeUpload"
accept="image/png,image/jpeg" accept="image/png,image/jpeg"
> >
<div v-loading="uploading" class="logo-box" title="点击上传 png / jpg(≤2MB)"> <div v-loading="uploadingLogo" class="asset-box" title="点击上传 png / jpg(≤2MB)">
<img v-if="logoUrl" :src="logoUrl" alt="公司 logo" /> <img v-if="logoUrl" :src="logoUrl" alt="公司 logo" />
<span v-else class="logo-plus">+</span> <span v-else class="asset-plus">+</span>
</div> </div>
</el-upload> </el-upload>
<p class="logo-hint">png / jpg,不超过 2MB;<br />展示于登录页品牌位与导出页眉。</p> <p class="asset-hint">
png / jpg,不超过 2MB;<br />展示于登录页品牌位。
</p>
</div> </div>
</el-form-item> </el-form-item>
<el-form-item> <div class="card-actions">
<el-button type="primary" :loading="submitting" @click="handleSubmit">保存</el-button> <el-button type="primary" :loading="savingLogin" @click="saveLoginInfo">
</el-form-item> 保存
</el-form> </el-button>
</el-card> </div>
</el-card>
</el-form>
<!-- 签名元素:登录页实景预览(缩比登录卡,品牌名/logo 即时反映) --> <!-- 右一:登录页预览(由左侧「登录页信息」控制) -->
<el-card shadow="never" class="preview-card"> <el-card shadow="never" class="settings-card preview-card">
<template #header> <template #header>
<span class="card-title">登录页预览</span> <span class="card-title">登录页预览</span>
<span class="preview-note">保存后对所有人生效</span> <span class="preview-note">对应左侧「登录页信息」</span>
</template> </template>
<div class="login-mock"> <div class="login-mock">
<div class="mock-brand"> <div class="mock-brand">
<img v-if="logoUrl" :src="logoUrl" alt="" class="mock-logo" /> <img v-if="logoUrl" :src="logoUrl" alt="" class="mock-logo" />
<div class="mock-name">{{ previewName }}</div> <div class="mock-name">{{ previewName }}</div>
<p class="mock-slogan">把每一笔回款,算成一份踏实。</p> <p class="mock-slogan">{{ previewSlogan }}</p>
<span class="mock-waterline"></span> <span class="mock-waterline"></span>
</div> </div>
<div class="mock-field"></div> <div class="mock-field"></div>
<div class="mock-field"></div> <div class="mock-field"></div>
<div class="mock-submit">登 录</div> <div class="mock-submit">登 录</div>
</div> </div>
<p class="mock-foot">
{{ previewFooter }}<template v-if="previewIcp"> · {{ previewIcp }}</template>
</p>
<p class="preview-caption">登录页品牌位实景(示意,非交互)</p> <p class="preview-caption">登录页品牌位实景(示意,非交互)</p>
</el-card> </el-card>
<!-- 左二:网页站点信息 -->
<el-form
ref="siteFormRef"
:model="siteForm"
:rules="siteRules"
label-width="110px"
class="settings-form"
@submit.prevent
>
<el-card shadow="never" class="settings-card">
<template #header>
<span class="card-title">网页站点信息</span>
</template>
<el-form-item label="站点标题" prop="site_title">
<el-input
v-model="siteForm.site_title"
maxlength="64"
placeholder="留空使用默认:营销人员工资管理系统"
clearable
/>
</el-form-item>
<el-form-item label="站点图标">
<div class="asset-field">
<el-upload
:show-file-list="false"
:http-request="handleFaviconUpload"
accept="image/png,image/jpeg,image/x-icon,image/vnd.microsoft.icon,.ico"
>
<div
v-loading="uploadingFavicon"
class="asset-box asset-box--small"
title="点击上传 png / jpg / ico(≤2MB)"
>
<!-- 未自定义时显示公司 logo(跟随 logo 变化) -->
<img :src="effectiveFavicon" alt="站点图标" />
</div>
</el-upload>
<p class="asset-hint">
png / jpg / ico,不超过 2MB;<br />
未上传时跟随公司 logo,展示于浏览器标签页。
</p>
</div>
</el-form-item>
<div class="card-actions">
<el-button type="primary" :loading="savingSite" @click="saveSiteInfo">保存</el-button>
</div>
</el-card>
</el-form>
<!-- 右二:浏览器标签页预览(由左侧「网页站点信息」控制) -->
<el-card shadow="never" class="settings-card preview-card tab-preview-card">
<template #header>
<span class="card-title">浏览器标签页预览</span>
<span class="preview-note">对应左侧「网页站点信息」</span>
</template>
<div class="tab-bar">
<div class="tab-bar__tab">
<img :src="effectiveFavicon" alt="" class="tab-bar__icon" />
<span class="tab-bar__title">{{ previewTabTitle }}</span>
<span class="tab-bar__close">×</span>
</div>
<span class="tab-bar__plus">+</span>
</div>
<p class="preview-caption">浏览器标签页实景(标题与图标)</p>
</el-card>
</div> </div>
</div> </div>
</template> </template>
@ -163,9 +397,41 @@ const previewName = computed(() => form.company_name.trim() || '衍水集团')
<style scoped> <style scoped>
.settings-grid { .settings-grid {
display: grid; display: grid;
grid-template-columns: minmax(360px, 460px) minmax(300px, 380px); grid-template-columns: minmax(420px, 560px) minmax(300px, 380px);
gap: 16px; gap: 16px;
align-items: start; /* 同一行左右卡片等高:右侧预览卡拉伸到与左侧设置卡相同的底边 */
align-items: stretch;
}
.settings-form {
display: flex;
min-width: 0;
}
.settings-form :deep(.el-card) {
width: 100%;
}
/* 预览卡铺满行高,内容区可纵向伸展 */
.preview-card,
.tab-preview-card {
display: flex;
flex-direction: column;
}
.preview-card :deep(.el-card__body),
.tab-preview-card :deep(.el-card__body) {
display: flex;
flex: 1;
flex-direction: column;
}
/* 登录页实景卡随可用高度放大:两个模拟输入框吃掉多余高度,
整张登录卡纵向撑满预览卡,底边与左侧「登录页信息」对齐 */
.login-mock {
display: flex;
flex: 1;
flex-direction: column;
} }
.card-title { .card-title {
@ -178,13 +444,19 @@ const previewName = computed(() => form.company_name.trim() || '衍水集团')
color: var(--ys-ink-3); color: var(--ys-ink-3);
} }
.logo-field { .card-actions {
display: flex;
justify-content: flex-end;
margin-top: 4px;
}
.asset-field {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
gap: 14px; gap: 14px;
} }
.logo-box { .asset-box {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@ -198,24 +470,29 @@ const previewName = computed(() => form.company_name.trim() || '衍水集团')
transition: border-color 150ms ease; transition: border-color 150ms ease;
} }
.logo-box:hover { .asset-box--small {
width: 64px;
height: 64px;
}
.asset-box:hover {
border-color: var(--ys-indigo); border-color: var(--ys-indigo);
} }
.logo-box img { .asset-box img {
max-width: 100%; max-width: 100%;
max-height: 100%; max-height: 100%;
object-fit: contain; object-fit: contain;
} }
.logo-plus { .asset-plus {
font-size: 26px; font-size: 26px;
font-weight: 300; font-weight: 300;
line-height: 1; line-height: 1;
color: var(--ys-ink-3); color: var(--ys-ink-3);
} }
.logo-hint { .asset-hint {
margin: 4px 0 0; margin: 4px 0 0;
font-size: 12px; font-size: 12px;
line-height: 1.7; line-height: 1.7;
@ -268,7 +545,8 @@ const previewName = computed(() => form.company_name.trim() || '衍水集团')
} }
.mock-field { .mock-field {
height: 32px; flex: 1;
min-height: 32px;
margin-top: 12px; margin-top: 12px;
background: #f4f5f8; background: #f4f5f8;
border: 1px solid rgba(28, 35, 51, 0.07); border: 1px solid rgba(28, 35, 51, 0.07);
@ -287,13 +565,74 @@ const previewName = computed(() => form.company_name.trim() || '衍水集团')
border-radius: 6px; border-radius: 6px;
} }
.preview-caption { .mock-foot {
margin: 10px 0 0; margin: 12px 0 0;
font-size: 11px; font-size: 11px;
text-align: center; text-align: center;
color: var(--ys-ink-3); color: var(--ys-ink-3);
} }
.preview-caption {
margin: 8px 0 0;
font-size: 11px;
text-align: center;
color: var(--ys-ink-3);
}
/* ── 浏览器标签页预览(示意;与登录页预览分成两张卡,对应左侧第二张卡) ── */
.tab-preview-card :deep(.el-card__body) {
/* 行高由左侧「网页站点信息」决定:标签页示意在卡内垂直居中 */
justify-content: center;
}
.tab-bar {
display: flex;
align-items: flex-end;
gap: 6px;
padding: 10px 12px 0;
background: #e9ebf1;
border-radius: 10px;
}
.tab-bar__tab {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
padding: 9px 12px;
background: #ffffff;
border-radius: 8px 8px 0 0;
box-shadow: 0 -1px 2px rgba(28, 35, 51, 0.06);
}
.tab-bar__icon {
width: 16px;
height: 16px;
flex-shrink: 0;
object-fit: contain;
}
.tab-bar__title {
overflow: hidden;
font-size: 12px;
white-space: nowrap;
text-overflow: ellipsis;
color: var(--ys-ink-2);
}
.tab-bar__close {
flex-shrink: 0;
font-size: 12px;
line-height: 1;
color: var(--ys-ink-3);
}
.tab-bar__plus {
padding: 0 6px 10px;
font-size: 14px;
color: var(--ys-ink-3);
}
@media (max-width: 900px) { @media (max-width: 900px) {
.settings-grid { .settings-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;