wip: 客户评级弹窗化/公司信息精简/参数中文化/品牌站点同步(基于 927592b)

This commit is contained in:
郑博文 2026-10-20 11:06:01 +08:00
parent 927592b472
commit 27533eb465
43 changed files with 2959 additions and 434 deletions

View File

@ -0,0 +1,53 @@
"""V9 图片版本号破缓存字段
Revision ID: 5e8a2d9c7b41
Revises: 9d2f4c7b1e60
Create Date: 2026-10-20 11:00:00.000000
"""
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "5e8a2d9c7b41"
down_revision: str | Sequence[str] | None = "9d2f4c7b1e60"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""应用迁移。
logo/favicon 用固定文件名覆盖落盘,同扩展名替换时路径与 update_time
都不变,无法用它们破浏览器缓存;改为上传即自增的版本号字段,图片 URL
携带 ?v={version}。
"""
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:
"""回滚迁移。"""
op.drop_column("system_settings", "favicon_version")
op.drop_column("system_settings", "logo_version")

View File

@ -0,0 +1,101 @@
"""V8 精简公司档案并增加站点信息
Revision ID: 9d2f4c7b1e60
Revises: c4e2a86f9b13
Create Date: 2026-10-20 10:00:00.000000
"""
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "9d2f4c7b1e60"
down_revision: str | Sequence[str] | None = "c4e2a86f9b13"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""应用迁移(2026-10-20 用户确认的方案)。
- 删除工资系统用不上的档案字段:系统副标题、信用代码、地址、电话、邮箱
- 新增站点信息:站点标题(浏览器标签页/侧边栏副标题)与站点图标
(浏览器标签页 favicon,独立于登录页 logo)
"""
op.drop_column("system_settings", "system_subtitle")
op.drop_column("system_settings", "credit_code")
op.drop_column("system_settings", "company_address")
op.drop_column("system_settings", "contact_phone")
op.drop_column("system_settings", "contact_email")
op.add_column(
"system_settings",
sa.Column(
"site_title",
sa.String(length=64),
nullable=True,
comment="站点标题(浏览器标签页与侧边栏副标题;null=内置默认)",
),
)
op.add_column(
"system_settings",
sa.Column(
"favicon_path",
sa.String(length=255),
nullable=True,
comment="站点图标相对路径(文件存 uploads/favicon/,null=使用内置 favicon.svg)",
),
)
def downgrade() -> None:
"""回滚迁移:恢复 V7 的档案字段并移除站点信息字段。"""
op.drop_column("system_settings", "favicon_path")
op.drop_column("system_settings", "site_title")
op.add_column(
"system_settings",
sa.Column(
"system_subtitle",
sa.String(length=32),
nullable=True,
comment="系统副标题(侧边栏品牌区/浏览器标题;null=内置默认)",
),
)
op.add_column(
"system_settings",
sa.Column(
"credit_code",
sa.String(length=32),
nullable=True,
comment="统一社会信用代码(预留导出页眉/单据)",
),
)
op.add_column(
"system_settings",
sa.Column(
"company_address",
sa.String(length=255),
nullable=True,
comment="公司地址(预留导出页眉)",
),
)
op.add_column(
"system_settings",
sa.Column(
"contact_phone",
sa.String(length=32),
nullable=True,
comment="联系电话(系统支持信息,预留)",
),
)
op.add_column(
"system_settings",
sa.Column(
"contact_email",
sa.String(length=64),
nullable=True,
comment="联系邮箱(系统支持信息,预留)",
),
)

View File

@ -0,0 +1,220 @@
"""V7 客户评级落库与公司信息档案字段
Revision ID: c4e2a86f9b13
Revises: 79bcc5aa98d4
Create Date: 2026-10-19 10:00:00.000000
"""
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision: str = "c4e2a86f9b13"
down_revision: str | Sequence[str] | None = "79bcc5aa98d4"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""应用迁移。
双主题合并一次交付(2026-10-19):
- 客户评级弹窗化:评分每客户落库(customer_ratings,一行一客户快照)
- 公司信息补充:system_settings 增加副标题/标语/页脚等企业档案字段
"""
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(
"system_subtitle",
sa.String(length=32),
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(
"credit_code",
sa.String(length=32),
nullable=True,
comment="统一社会信用代码(预留导出页眉/单据)",
),
)
op.add_column(
"system_settings",
sa.Column(
"company_address",
sa.String(length=255),
nullable=True,
comment="公司地址(预留导出页眉)",
),
)
op.add_column(
"system_settings",
sa.Column(
"contact_phone",
sa.String(length=32),
nullable=True,
comment="联系电话(系统支持信息,预留)",
),
)
op.add_column(
"system_settings",
sa.Column(
"contact_email",
sa.String(length=64),
nullable=True,
comment="联系邮箱(系统支持信息,预留)",
),
)
def downgrade() -> None:
"""回滚迁移。
先删评级表(引用 customers/users),再逆序移除公司档案列。
"""
op.drop_table("customer_ratings")
op.drop_column("system_settings", "contact_email")
op.drop_column("system_settings", "contact_phone")
op.drop_column("system_settings", "company_address")
op.drop_column("system_settings", "credit_code")
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", "system_subtitle")

View File

@ -24,7 +24,8 @@ from app.schemas.customer import (
CustomerUpdateIn,
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=["客户管理"])
@ -140,3 +141,45 @@ def list_ownership_logs(
) -> ApiResponse[PageResult[OwnershipChangeLogOut]]:
"""查某客户的归属变更留痕(分页,按变更时间倒序,附新旧归属人员姓名)。"""
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 时
尚无令牌。响应经 `SettingPublicOut` 白名单收敛(见 schemas/setting.py),
仅暴露两个非敏感展示字段。
`GET /public`、`GET /logo/public`、`GET /favicon/public` **无鉴权**:登录页
渲染公司名/logo、浏览器加载标签页图标时尚无令牌。响应经
`SettingPublicOut` 白名单收敛(见 schemas/setting.py),仅暴露展示必需字段。
`/logo/public` 返回文件流而非统一信封——与参数导出同一例外口径
(二进制响应套 JSON 信封无意义)。
`/logo/public` 与 `/favicon/public` 返回文件流而非统一信封——与参数导出
同一例外口径(二进制响应套 JSON 信封无意义)。
"""
from fastapi import APIRouter, Response, UploadFile
@ -19,6 +19,7 @@ from app.services import system_setting_service as service
router = APIRouter(prefix="/settings", tags=["系统管理 · 公司信息"])
_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="公司信息")
@ -27,14 +28,14 @@ def get_settings(session: DbSession, _: AdminUser) -> ApiResponse[SettingOut]:
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(
payload: SettingUpdateIn,
session: DbSession,
current_user: AdminUser,
meta: RequestMetaDep,
) -> ApiResponse[SettingOut]:
"""更新公司名称(审计留痕)。"""
"""更新公司信息(部分更新;审计留痕)。"""
return ok(
service.update_company(session, payload, operator=current_user, meta=meta),
message="已保存",
@ -57,6 +58,22 @@ async def upload_logo(
)
@router.post("/favicon", response_model=ApiResponse[SettingOut], summary="上传站点图标")
async def upload_favicon(
file: UploadFile,
session: DbSession,
current_user: AdminUser,
meta: RequestMetaDep,
) -> ApiResponse[SettingOut]:
"""上传站点图标(png/jpg/ico ≤2MB;固定名覆盖,旧扩展名文件清理)。"""
content = await 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="公司信息(匿名)")
def get_settings_public(session: DbSession, response: Response) -> ApiResponse[SettingPublicOut]:
"""公司信息(登录页消费,无鉴权)。短缓存减轻匿名端点压力。"""
@ -73,3 +90,14 @@ def get_logo_public(session: DbSession) -> FileResponse:
media_type=_LOGO_MEDIA_TYPE.get(ext, "application/octet-stream"),
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_role import BusinessRole
from app.models.customer import Customer, OwnershipChangeLog
from app.models.customer_rating import CustomerRating
from app.models.login_log import LoginLog
from app.models.operation_log import OperationLog
from app.models.param import ParamChangeBatch, ParamDefinition, ParamValue
@ -35,6 +36,7 @@ __all__ = [
"BusinessRole",
"BusinessStatusLog",
"Customer",
"CustomerRating",
"CustomerRatingGrade",
"LoginLog",
"OperationLog",

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

View File

@ -1,31 +1,67 @@
"""系统设置模型:公司信息单例行。
"""系统设置模型:公司信息 + 站点信息单例行。
与 M1 计提参数体系刻意分离:公司名称/logo 是「展示事实」,没有按生效日期
时点化演算的语义(参数体系按 effective_date 取值快照),且参数值列
value_text 为 String(64) 装不下;故独立单行表。logo 只存相对路径——文件在
`backend/uploads/logo/`,独立于数据库备份体积且可单独替换(见
system_setting_service)。整个系统恒为单行(迁移种子 id=1 保底,upsert 维护)。
value_text 为 String(64) 装不下;故独立单行表。logo/favicon 只存相对路径——
文件在 `backend/uploads/{logo,favicon}/`,独立于数据库备份体积且可单独替换
(见 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 app.models.base import BIGINT_UNSIGNED, Base, TimestampMixin, mysql_table_args
class SystemSetting(Base, TimestampMixin):
"""系统设置表:本期仅公司信息(公司名称 / logo 路径),全系统单行 id=1。"""
"""系统设置表:公司信息 + 站点信息,全系统单行 id=1。
brand_slogan/footer_text/icp_no/site_title 均可空:null 表示前端回退
内置默认值,存量行为零变化。
"""
__tablename__ = "system_settings"
__table_args__ = mysql_table_args("系统设置表(公司信息,全系统单行)")
__table_args__ = mysql_table_args("系统设置表(公司信息与站点信息,全系统单行)")
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
company_name: Mapped[str] = mapped_column(
String(64), nullable=False, comment="公司名称(登录页与导出页眉展示)"
String(64), nullable=False, comment="公司名称(登录页/侧边栏/浏览器标题展示)"
)
logo_path: Mapped[str | None] = mapped_column(
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.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.repositories import ownership_log_repository
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:
"""查单个客户并带出归属人员姓名(详情、编辑回显与归属变更共用)。"""
"""查单个客户并带出归属人员姓名与评级快照(详情/编辑回显/归属变更共用)。"""
stmt = _base_select().where(Customer.id == customer_id)
return session.execute(stmt).first()
@ -53,12 +53,14 @@ def list_customers(
keyword: str | None = None,
customer_type: str | None = None,
owner_personnel_id: int | None = None,
order_field: str | None = None,
order_type: str | None = None,
) -> list[Row]:
"""分页查询客户,行内容为 (客户, 归属人员姓名)。
"""分页查询客户,行内容为 (客户, 归属人员姓名, 评级总分, 评级等级)。
归属人员外键非空但统一用 outerjoin:人员行不可考(被物理删除的脏数据)
不应让客户行整个查不出来(同 personnel_repository 的韧性口径)。
排序按 id 倒序:客户无排序字段,新增的靠前符合列表页习惯。
归属人员/评级行均用 outerjoin:关联行不可考(人员被物理删除、客户
未评级)不应让客户行整个查不出来(同 personnel_repository 韧性口径)。
默认排序按 id 倒序:新增的靠前符合列表页习惯;评级排序见 ``_apply_order``。
"""
stmt = _apply_filters(
_base_select(),
@ -66,7 +68,8 @@ def list_customers(
customer_type=customer_type,
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())
@ -120,12 +123,38 @@ def count_ownership_logs(session: Session, *, customer_id: int) -> int:
def _base_select() -> Select:
"""客户主表外联归属人员姓名。"""
return select(Customer, Personnel.personnel_name).outerjoin(
Personnel, Customer.owner_personnel_id == Personnel.id
"""客户主表外联归属人员姓名与评级快照(等级名/总分,列表评级列免 N+1)。"""
return (
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(
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:
"""按主键查询。
等级表无下游引用(评级是纯计算器不落评分记录),无需 ``for_update``
锁行 —— product_repository 锁行防的是并发引用悬空,此处不存在该风险。
等级表对客户评级无**外键**引用(评级行快照固化等级名),无需
``for_update`` 锁行 —— product_repository 锁行防的是并发引用悬空,
此处不存在该风险。
"""
return session.get(CustomerRatingGrade, grade_id)
@ -60,6 +61,6 @@ def create(session: Session, grade: CustomerRatingGrade) -> CustomerRatingGrade:
def delete(session: Session, grade: CustomerRatingGrade) -> None:
"""物理删除等级。等级表无下游引用,调用方无需引用保护校验。"""
"""物理删除等级。等级表对客户评级无外键引用,调用方无需引用保护校验。"""
session.delete(grade)
session.flush()

View File

@ -1,6 +1,7 @@
"""客户管理的请求/响应模型(需求 2.1.3)。"""
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field
@ -13,6 +14,10 @@ class CustomerOut(BaseModel):
owner_personnel_name 由 LEFT JOIN 带出:归属人员不可考(被物理删除的
脏数据)时为 null,owner_personnel_id 仍返回原值供追溯 —— 与归属留痕
「ID 固化、姓名随联表」同一口径。
rating_total / rating_grade_name 由 LEFT JOIN customer_ratings 带出
(列表评级列免 N+1):未评级时均为 null;总分按分值红线以字符串出参,
等级为保存时点的快照固化值(见 models/customer_rating.py)。
"""
model_config = ConfigDict(from_attributes=True)
@ -28,6 +33,8 @@ class CustomerOut(BaseModel):
owner_personnel_id: int | None = None
owner_personnel_name: str | None = None
remark: str | None = None
rating_total: str | None = None
rating_grade_name: str | None = None
class CustomerCreateIn(BaseModel):
@ -97,6 +104,14 @@ class CustomerQuery(PageQuery):
)
customer_type: str | None = Field(default=None, max_length=32, description="客户分类筛选")
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):

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,28 +1,56 @@
"""系统设置(公司信息)请求/响应模型。
"""系统设置(公司信息 + 站点信息)请求/响应模型。
`SettingPublicOut` 与 `SettingOut` **刻意不共用**:public 端点无鉴权,
响应字段必须白名单显式列出——将来往 SettingOut 加敏感字段时不会
顺着复用链泄漏到匿名端点。
2026-10-20 精简(用户确认):删除系统副标题与企业档案字段(信用代码/
地址/电话/邮箱);新增站点标题(浏览器标签页/侧边栏副标题)与站点图标
(浏览器标签页 favicon)。public 端点只需登录页/标签页展示字段,两者
恰好同集合,但仍保留独立 schema 作为白名单防线。
"""
from pydantic import BaseModel, Field
from pydantic import BaseModel, ConfigDict, Field
class SettingOut(BaseModel):
"""公司信息(管理端)。"""
"""公司信息与站点信息(管理端)。"""
company_name: str
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):
"""公司信息(匿名端点白名单):登录页展示所需的两个字段。"""
"""公司信息与站点信息(匿名端点白名单):登录页/浏览器标签页展示所需字段。"""
company_name: str
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):
"""公司信息更新。"""
"""公司信息与站点信息更新(**部分更新**语义)。
company_name: str = Field(min_length=1, max_length=64, description="公司名称")
仅提交的字段生效(``exclude_unset``),未提交或显式 null 的字段保持
不变;可选文本传空串等价清空(落库为 null、前端回退内置默认)。
company_name 若提交必须非空(DB 非空约束)。
"""
model_config = ConfigDict(str_strip_whitespace=True)
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.models import Customer, OwnershipChangeLog, Personnel, User
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 (
CustomerCreateIn,
CustomerOut,
@ -46,8 +46,17 @@ from app.services.audit_service import AuditRecord, RequestMeta
logger = logging.getLogger(__name__)
def _to_out(customer: Customer, owner_name: str | None) -> CustomerOut:
"""ORM 对象转响应模型。禁止把 ORM 对象直接当 response_model。"""
def _to_out(
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(
id=customer.id,
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_name=owner_name,
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]:
"""分页查询客户(附归属人员姓名)。"""
"""分页查询客户(附归属人员姓名与评级快照;支持评级列排序)。"""
rows = customer_repository.list_customers(
session,
offset=query.offset,
@ -114,6 +125,8 @@ def list_customers(session: Session, query: CustomerQuery) -> PageResult[Custome
keyword=query.keyword,
customer_type=query.customer_type,
owner_personnel_id=query.owner_personnel_id,
order_field=query.order_field,
order_type=query.order_type,
)
total = customer_repository.count_customers(
session,
@ -121,8 +134,12 @@ def list_customers(session: Session, query: CustomerQuery) -> PageResult[Custome
customer_type=query.customer_type,
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](
records=[_to_out(customer, owner_name) for customer, owner_name in rows],
records=records,
total=total,
page_num=query.page_num,
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)
if row is None:
raise NotFoundError("客户不存在")
customer, owner_name = row
return _to_out(customer, owner_name)
customer, owner_name, rating_total, rating_grade = row
return _to_out(customer, owner_name, rating_total, rating_grade)
def create_customer(
@ -241,7 +258,7 @@ def update_customer(
_ensure_customer_type(payload.customer_type)
if row is None:
raise NotFoundError("客户不存在")
customer, owner_name = row
customer, owner_name, rating_total, rating_grade = row
duplicate = customer_repository.get_by_company_owner(
session, payload.company_name, payload.owner_name
@ -260,7 +277,7 @@ def update_customer(
try:
# 响应在 commit 之前构造(同 user_service 顶部说明)
out = _to_out(customer, owner_name)
out = _to_out(customer, owner_name, rating_total, rating_grade)
session.commit()
except IntegrityError as exc:
# 把自身以外的组合改成已存在的「公司名称+负责人」时,先查后写
@ -308,6 +325,11 @@ def delete_customer(
company_name = customer.company_name # 固化:对象随删除失效
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)
session.commit()
except IntegrityError as exc:
@ -362,7 +384,7 @@ def change_ownership(
):
if row is None:
raise NotFoundError("客户不存在")
customer, _ = row
customer, _, rating_total, rating_grade = row
if payload.new_owner_personnel_id is not None:
_ensure_owner_personnel(session, payload.new_owner_personnel_id)
@ -385,7 +407,12 @@ def change_ownership(
),
)
# 响应在 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()
logger.info(
"调整客户归属 | customer_id=%s | old=%s | new=%s",

View File

@ -1,12 +1,14 @@
"""客户评级等级配置业务编排。
"""客户评级业务编排(等级映射配置 + 每客户评级落库)。
评级计算器定位(对照表 #25):仅打分工具、不影响绩效金额,评分记录不落库
——本表承载的等级映射是**判定表**,判定规则为按 sort_order 升序取首个
min_score ≤ 总分的等级,max_score 仅展示。两条硬约束(违反会算错等级):
评级定位(对照表 #25):**不影响绩效金额**。自 2026-10-19 起评分每客户
落库(customer_ratings,一行一客户快照),等级映射仍是本服务承载的
**判定表**,判定规则为按 sort_order 升序取首个 min_score ≤ 总分的等级,
max_score 仅展示。两条硬约束(违反会算错等级):
- 分值全程 ``Decimal``:入参出参 str、schema 拦格式,service 层负责字符串
到 Decimal 的转换与落库;0≤min≤max≤100 的区间校验在**审计上下文内**
完成,失败的 400 同样留痕(2026-10-13 定稿口径)
到 Decimal 的转换与落库;值域校验(等级区间 0≤min≤max≤100、单项评分
0≤分≤20)在**审计上下文内**完成,失败的 400 同样留痕(2026-10-13 定稿
口径)
- 区间互不重叠:重叠会让同一总分命中多个等级(或两段都跳过),判定表
失效。判定为**闭区间两两相交**:任一其他等级满足
``other.min_score <= 新 max 且 新 min <= other.max_score`` 即重叠。
@ -14,9 +16,9 @@ min_score ≤ 总分的等级,max_score 仅展示。两条硬约束(违反
相邻铺满 0-100,且判定本身是单边 min_score ≤ 总分,边界归 sort_order
靠前的等级,相邻无歧义
删除允许清空:等级表无下游引用(评分记录不落库,判定只读本表现值、不
回溯历史快照),物理删除无悬空风险;全部删空后计算器只显示总分、不出
等级(前端已对应提示),故不强制保留任何一条。
删除允许清空:等级表对客户评级**无外键引用**(评级快照固化等级名、不
回指本表),物理删除无悬空风险,也不伤及已评级客户的历史结论;全部删空
后新评级只记总分、等级为 NULL(前端已对应提示),故不强制保留任何一条。
"""
import logging
@ -33,9 +35,15 @@ from app.core.constants import (
)
from app.core.db_errors import is_duplicate_entry
from app.core.exceptions import BadRequestError, NotFoundError
from app.models import CustomerRatingGrade, User
from app.repositories import rating_repository
from app.schemas.rating import RatingGradeCreateIn, RatingGradeOut, RatingGradeUpdateIn
from app.models import CustomerRating, CustomerRatingGrade, User
from app.repositories import customer_rating_repository, customer_repository, rating_repository
from app.schemas.rating import (
CustomerRatingOut,
CustomerRatingSaveIn,
RatingGradeCreateIn,
RatingGradeOut,
RatingGradeUpdateIn,
)
from app.services import audit_service
from app.services.audit_service import AuditRecord, RequestMeta
@ -44,6 +52,8 @@ logger = logging.getLogger(__name__)
# 分值域上下界(Decimal 常量,禁用 float 参与比较)
_SCORE_FLOOR = Decimal("0")
_SCORE_CEIL = Decimal("100")
# 单项评分上界(五项各 0-20,总分 0-100)
_ITEM_SCORE_CEIL = Decimal("20")
def _to_out(grade: CustomerRatingGrade) -> RatingGradeOut:
@ -252,10 +262,11 @@ def delete_grade(
) -> None:
"""物理删除评级等级。
等级表无下游引用 —— 评级是纯计算器不落评分记录(对照表 #25),判定
只读本表现值、不回溯历史快照,故可物理删除且无需锁行防并发引用。
等级表对客户评级**无外键引用** —— 评级行快照固化等级名(见
``models/customer_rating.py`` 的论证),判定只读本表现值,故可物理删除
且无需锁行防并发引用;删除等级不改变已评级客户的历史结论。
**允许清空**:全部删空后计算器只显示总分、不出等级(前端已对应提示),
**允许清空**:全部删空后新评级只记总分、等级为 NULL(前端已对应提示),
不强制保留任何一条。
:raises NotFoundError: 评级等级不存在
@ -278,3 +289,164 @@ def delete_grade(
rating_repository.delete(session, grade)
session.commit()
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 只存**相对路径**
(uploads/logo/ 下固定名覆盖),文件不入库的理由:图片进数据库会让每次
读取与备份放大传输,且无法独立替换;DB 只持有一行指针。
单例行语义:整个系统恒一行(id=1,迁移种子保底)。logo/favicon 只存**相对
路径**(uploads/logo、uploads/favicon 下固定名覆盖),文件不入库的理由:
图片进数据库会让每次读取与备份放大传输,且无法独立替换;DB 只持有指针。
logo 安全:后缀白名单 + magic bytes 双重校验(上传内容不可信,仅看文件名
图像安全:后缀白名单 + magic bytes 双重校验(上传内容不可信,仅看文件名
后缀可被伪造);固定文件名落盘,**不使用上传方文件名**——杜绝路径拼接与
覆盖任意文件。
logo 与 favicon 相互独立(2026-10-20):
- logo 用于登录页品牌位;
- favicon 用于浏览器标签页图标,未上传时前端使用内置 favicon.svg
(不再跟随 logo——公司 logo 是横版,作标签页图标常被裁切)。
"""
import logging
@ -24,15 +29,26 @@ from app.services.audit_service import AuditRecord, RequestMeta
logger = logging.getLogger(__name__)
# magic bytes:PNG 固定 8 字节签名;JPEG 以 SOI + APP0/JFIF 起始
# magic bytes:PNG 8 字节签名;JPEG 以 SOI 起始;ICO 以 00 00 01 00 起始
_MAGIC = {
"png": b"\x89PNG\r\n\x1a\n",
"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 # 全系统单行主键
# 公司档案中的可选文本字段:提交空串等价清空(落库 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:
"""取公司信息单行(种子保底 id=1;防御式兜底:极端环境下缺行则补建)。"""
@ -45,10 +61,15 @@ def get_setting(session: Session) -> SystemSetting:
def to_out(setting: SystemSetting) -> SettingOut:
"""管理端出参(logo_url 带更新时间版本参数,替换图片后浏览器不再用旧缓存)。"""
"""管理端出参(图片地址带更新时间版本参数,替换后浏览器不再用旧缓存)。"""
return SettingOut(
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 见模块说明)。"""
return SettingPublicOut(
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:
if not setting.logo_path:
def _asset_url(setting: SystemSetting, kind: str) -> str | None:
"""资源 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
version = int(setting.update_time.timestamp()) if setting.update_time else 0
return f"/api/v1/system/settings/logo/public?v={version}"
version = getattr(setting, f"{kind}_version") or 0
return f"/api/v1/system/settings/{kind}/public?v={version}"
def update_company(
@ -74,20 +106,34 @@ def update_company(
operator: User,
meta: RequestMeta | None = None,
) -> 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(
session,
operator=operator,
record=AuditRecord(
module=MODULE_SYSTEM_SETTINGS,
action=ACTION_UPDATE,
summary=f"修改公司名称为「{payload.company_name}」",
request_data=payload.model_dump(),
summary=summary,
request_data=changes or None,
),
meta=meta,
):
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()
return to_out(setting)
@ -100,17 +146,39 @@ def save_logo(
operator: User,
meta: RequestMeta | None = None,
) -> 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 后残留文件会继续占盘)。
"""
label = _ASSET_LABELS[kind]
allowed = _ASSET_EXTS[kind]
ext = ext.lower()
if ext not in _MAGIC:
raise BadRequestError("logo 仅支持 png/jpg 格式")
if len(content) > LOGO_MAX_BYTES:
raise BadRequestError("logo 文件不能超过 2MB")
if not content.startswith(_MAGIC[ext]):
raise BadRequestError("文件内容与扩展名不符(仅接受真实图片)")
_validate_upload(content, ext, allowed=allowed, label=label)
with audit_service.audit(
session,
@ -118,48 +186,71 @@ def save_logo(
record=AuditRecord(
module=MODULE_SYSTEM_SETTINGS,
action=ACTION_UPDATE,
summary="更新公司 logo",
summary=f"更新{label}",
request_data={"ext": ext, "size": len(content)},
),
meta=meta,
):
setting = get_setting(session)
old_path = setting.logo_path
old_path = getattr(setting, f"{kind}_path")
# 旧扩展名清理必须在写新文件**之前**:同扩展名覆盖时先写后删会把
# 刚落盘的新文件一并删掉(回读 404)
old_ext = Path(old_path).suffix.lstrip(".") if old_path else None
if old_ext and old_ext != ext:
_cleanup_old_logo(old_ext)
if old_ext and old_ext != ext and old_ext in allowed:
_cleanup_old_asset(kind, old_ext)
target = _logo_dir() / f"logo.{ext}"
target = _asset_dir(kind) / f"{kind}.{ext}"
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()
logger.info("更新公司 logo | size=%s ext=%s", len(content), ext)
logger.info("更新%s | size=%s ext=%s", label, len(content), ext)
return to_out(setting)
def read_logo(session: Session) -> tuple[Path, str]:
"""按库中记录定位 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)
if not setting.logo_path:
raise NotFoundError("尚未上传 logo")
path = get_settings().upload_dir / setting.logo_path
path_str = getattr(setting, f"{kind}_path")
if not path_str:
raise NotFoundError(f"尚未上传{label}")
path = get_settings().upload_dir / path_str
if not path.is_file():
raise NotFoundError("logo 文件不存在,请重新上传")
raise NotFoundError(f"{label}文件不存在,请重新上传")
return path, path.suffix.lstrip(".")
def _logo_dir() -> Path:
directory = get_settings().upload_dir / "logo"
def _validate_upload(content: bytes, ext: str, *, allowed: tuple[str, ...], label: str) -> None:
"""后缀白名单 + 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)
return directory
def _cleanup_old_logo(old_ext: str) -> None:
if old_ext in _MAGIC:
old_file = _logo_dir() / f"logo.{old_ext}"
def _cleanup_old_asset(kind: str, old_ext: str) -> None:
if old_ext in _ASSET_EXTS[kind]:
old_file = _asset_dir(kind) / f"{kind}.{old_ext}"
if old_file.exists():
old_file.unlink(missing_ok=True)

View File

@ -35,6 +35,7 @@ from app.models import (
BusinessRole,
BusinessStatusLog,
Customer,
CustomerRating,
CustomerRatingGrade,
OperationLog,
OwnershipChangeLog,
@ -150,6 +151,10 @@ def _purge_test_data() -> None:
session.execute(
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)))
# 客户被物理删除后其 customer_id 已无从收集,按操作人用户名兜底一条
session.execute(
@ -176,13 +181,18 @@ def _purge_test_data() -> None:
# 审批单无 t_ 前缀可用,按 AP 编号前缀全量清(steps 外键 CASCADE 随删;
# ref_id 固化无外键,业务已在上一步删除,无悬挂影响)
session.execute(delete(ApprovalOrder).where(ApprovalOrder.order_no.like("AP%")))
# 开关参数恢复种子基线:测试提交的变更批次条目 id 均大于种子终值 21
# (V3 十九条 + V6 两条),删新条目与新批次后解析回退到种子值(开关=0)
stray_values = list(session.execute(select(ParamValue).where(ParamValue.id > 21)).scalars())
if stray_values:
stray_batches = {v.batch_id for v in stray_values}
session.execute(delete(ParamValue).where(ParamValue.id > 21))
session.execute(delete(ParamChangeBatch).where(ParamChangeBatch.id.in_(stray_batches)))
# 开关参数恢复种子基线:测试提交的变更批次由 t_ 前缀测试账号创建,
# 按**批次操作人**删除本轮的参数条目与批次,解析后回退到种子值(开关=0)。
# 不用 id 阈值:种子条目的主键在不同环境的漂移库中并不相同(历史库
# 可能补过种子行),按操作人识别测试产物与环境数据分布无关。
test_batch_ids = list(
session.execute(
select(ParamChangeBatch.id).where(ParamChangeBatch.operator_name.like("t\\_%"))
).scalars()
)
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()

View File

@ -11,6 +11,7 @@ append-only 完整性;「公司名称+负责人」组合唯一缺失会放行
"""
import uuid
from decimal import Decimal
import pytest
from fastapi.testclient import TestClient
@ -376,3 +377,104 @@ class TestAudit:
latest = logs[0]
assert customer["company_name"] in latest.summary, "摘要应含客户名"
assert "→" in latest.summary, "摘要应体现新旧归属走向"
# ==================== 客户评级列(V7) ====================
_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
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.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
_GRADES = "/api/v1/business/rating-grades"
_CUSTOMERS = "/api/v1/base/customers"
# 种子基线(与 V5 迁移一致):分数字符串经 DB round-trip 后一位小数(Numeric(5,1))
_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"]]
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]:
"""取某操作人的审计行(按 id 倒序,最新在前)。"""
factory = get_session_factory()
@ -258,8 +295,8 @@ class TestEdit:
class TestDelete:
"""删除允许清空(评级是纯计算器、评分记录不落库)—— 删除后判定表
立即反映,二次删除 404。"""
"""删除允许清空(客户评级快照固化等级名、无外键引用)—— 删除后判定表
立即反映,二次删除 404,已评级客户的历史等级快照不受影响。"""
def test_删除等级后判定表更新_二次删除404(self, client: TestClient, normal: dict) -> None:
headers = normal["headers"]
@ -313,3 +350,146 @@ class TestAudit:
]
assert failed, "失败操作未留痕"
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:
"""客户评级落库(V7):每客户一行、服务端重算总分与等级、等级快照固化。
五项各 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
_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"
@ -31,6 +32,81 @@ def client() -> TestClient:
return TestClient(app)
_ASSET_KINDS = ("logo", "favicon")
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:
"""还原上传目录:删除测试新增文件、回写被测试覆盖的原文件。
上传用例会用固定文件名覆盖 logo/favicon,并清理旧扩展名文件 —— 只还原
数据库路径而不还原文件,会把开发环境里真实上传的 logo 永久删掉。
"""
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)
def _restore_company_info():
"""系统设置是**真实单行数据 + 真实上传文件**:用例前快照、结束后还原。
与其他表的测试数据不同,公司信息没有 t_ 前缀可供兜底清理 —— 不还原
的话,跑完测试后开发环境的侧边栏品牌/登录页/浏览器标题会被改成
「测试公司_xxx」,上传用例还会覆盖/删除真实 logo 与站点图标。
"""
from app.core.db import get_session_factory
from app.models import SystemSetting
factory = get_session_factory()
with factory() as session:
row = session.get(SystemSetting, 1)
snapshot = (
{column.name: getattr(row, column.name) for column in SystemSetting.__table__.columns}
if row is not None
else None
)
asset_snapshot = _snapshot_assets()
yield
# 文件先还原:数据库路径随后回指同一批文件
_restore_assets(asset_snapshot)
if snapshot is None:
return
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()
class TestCompanyInfo:
"""公司名称读取与更新。"""
@ -84,10 +160,30 @@ class TestPublicEndpoint:
resp = client.get(f"{_SETTINGS}/public")
assert resp.status_code == 200
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 resp.headers.get("cache-control") is not None
def test_已删除的档案字段不再返回(self, client: TestClient) -> None:
"""V8 精简:副标题/信用代码/地址/电话/邮箱已从模型与契约中删除。"""
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:
assert client.put(f"{_SETTINGS}/public", json={"company_name": "x"}).status_code in {
401,
@ -95,6 +191,41 @@ class TestPublicEndpoint:
}
class TestCompanyProfile:
"""站点标题/品牌信息(V8):部分更新语义与空串清空。"""
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:
"""logo 上传校验链。"""
@ -135,3 +266,65 @@ class TestLogoUpload:
files={"file": ("logo.png", _PNG_BYTES, "application/octet-stream")},
)
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",
"receipts",
"customer_rating_grades",
"customer_ratings",
}
# 单复数同形的集合名词:本身即复数语义,不受「以 s 结尾」检查约束

View File

@ -19,6 +19,7 @@ SET NAMES utf8mb4;
DROP TABLE IF EXISTS `approval_steps`;
DROP TABLE IF EXISTS `approval_orders`;
DROP TABLE IF EXISTS `business_secondary_allocations`;
DROP TABLE IF EXISTS `customer_ratings`;
DROP TABLE IF EXISTS `system_settings`;
DROP TABLE IF EXISTS `param_values`;
DROP TABLE IF EXISTS `param_change_batches`;
@ -679,17 +680,50 @@ INSERT INTO customer_rating_grades (grade_name, min_score, max_score, sort_order
('C', 60, 74, 3, '合格'),
('D', 0, 59, 4, '待改进');
-- ---------------------------------------------------------------------------
-- 客户评级落库(V7 迁移;每客户一行,等级名为快照固化无外键)
-- ---------------------------------------------------------------------------
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 迁移;公司信息单行;分摊计算待澄清另批)
-- V7 迁移:增加公司档案字段;V8 迁移:删除用不上的档案字段(副标题/信用代码/
-- 地址/电话/邮箱),保留名称/标语/页脚/备案号/logo,并新增站点标题与站点图标
-- ---------------------------------------------------------------------------
CREATE TABLE system_settings (
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
company_name VARCHAR(64) NOT NULL COMMENT '公司名称(登录页与导出页眉展示)',
logo_path VARCHAR(255) COMMENT 'logo 相对路径(文件存 uploads/logo/,null=未上传使用默认)',
company_name VARCHAR(64) NOT NULL COMMENT '公司名称(登录页/侧边栏/浏览器标题展示)',
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,
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
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 (
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
@ -753,4 +787,4 @@ CREATE TABLE alembic_version (
CONSTRAINT alembic_version_pkc PRIMARY KEY (version_num)
)CHARSET=utf8mb4 ENGINE=InnoDB COLLATE utf8mb4_general_ci;
INSERT INTO alembic_version (version_num) VALUES ('79bcc5aa98d4');
INSERT INTO alembic_version (version_num) VALUES ('5e8a2d9c7b41');

View File

@ -0,0 +1,41 @@
-- Running upgrade 79bcc5aa98d4 -> 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 system_subtitle VARCHAR(32) 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 credit_code VARCHAR(32) COMMENT '统一社会信用代码(预留导出页眉/单据)';
ALTER TABLE system_settings ADD COLUMN company_address VARCHAR(255) COMMENT '公司地址(预留导出页眉)';
ALTER TABLE system_settings ADD COLUMN contact_phone VARCHAR(32) COMMENT '联系电话(系统支持信息,预留)';
ALTER TABLE system_settings ADD COLUMN contact_email VARCHAR(64) COMMENT '联系邮箱(系统支持信息,预留)';
UPDATE alembic_version SET version_num='c4e2a86f9b13' WHERE alembic_version.version_num = '79bcc5aa98d4';

View File

@ -0,0 +1,18 @@
-- Running upgrade c4e2a86f9b13 -> 9d2f4c7b1e60
ALTER TABLE system_settings DROP COLUMN system_subtitle;
ALTER TABLE system_settings DROP COLUMN credit_code;
ALTER TABLE system_settings DROP COLUMN company_address;
ALTER TABLE system_settings DROP COLUMN contact_phone;
ALTER TABLE system_settings DROP COLUMN contact_email;
ALTER TABLE system_settings ADD COLUMN site_title VARCHAR(64) COMMENT '站点标题(浏览器标签页与侧边栏副标题;null=内置默认)';
ALTER TABLE system_settings ADD COLUMN favicon_path VARCHAR(255) COMMENT '站点图标相对路径(文件存 uploads/favicon/,null=使用内置 favicon.svg)';
UPDATE alembic_version SET version_num='9d2f4c7b1e60' WHERE alembic_version.version_num = 'c4e2a86f9b13';

View File

@ -0,0 +1,8 @@
-- Running upgrade 9d2f4c7b1e60 -> 5e8a2d9c7b41
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='5e8a2d9c7b41' WHERE alembic_version.version_num = '9d2f4c7b1e60';

View File

@ -3,6 +3,8 @@
<head>
<meta charset="UTF-8" />
<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>
</head>
<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">
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>
<template>

View File

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

View File

@ -23,6 +23,26 @@ export interface ParamCatalogItem {
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 缺省为今日) */
export function getCatalog(asOf?: string): Promise<ParamCatalogItem[]> {
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 })
}
/** 删除评级等级(物理删除,等级表无下游引用,允许清空后由列表空态提示) */
/**
* 删除评级等级(物理删除;客户评级行快照固化等级名、无外键引用,删等级
* 不伤已评级客户的历史结论;允许清空,新评级等级记为 null)。
*/
export function deleteRatingGrade(gradeId: number): Promise<null> {
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 {
company_name: string
/** 未上传时为 null */
/** 登录页品牌位 logo;未上传时为 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 白名单一致) */
export interface PublicSettings {
company_name: string
logo_url: string | null
/** 匿名端点出参(登录页/浏览器标签页消费;与后端 SettingPublicOut 白名单一致) */
export type PublicSettings = CompanySettings
/**
* 更新入参:**部分更新**语义 —— 仅携带的字段生效,未携带字段保持不变。
* 可选文本传空串等价清空(后端落库 null、前端回退默认)。
*/
export interface SettingsUpdatePayload {
company_name?: string
site_title?: string
brand_slogan?: string
footer_text?: string
icp_no?: string
}
export function getSettings(): Promise<CompanySettings> {
return request({ url: '/v1/system/settings', method: 'get' })
}
export function updateSettings(companyName: string): Promise<CompanySettings> {
return request({
url: '/v1/system/settings',
method: 'put',
data: { company_name: companyName },
})
export function updateSettings(payload: SettingsUpdatePayload): Promise<CompanySettings> {
return request({ url: '/v1/system/settings', method: 'put', data: payload })
}
/** logo 上传走独立的 FormData(multipart,非 JSON) */
/**
* 图片上传走独立的 FormData(multipart,非 JSON)。
*
* 经统一 request 封装解包信封(响应形如 {code,message,data});Content-Type
* 交给浏览器/axios 为 FormData 自动生成(含 boundary),不手工设置。
*/
export function uploadLogo(file: File): Promise<CompanySettings> {
const form = new FormData()
form.append('file', file)
return service.post('/v1/system/settings/logo', form, {
headers: { 'Content-Type': 'multipart/form-data' },
})
return request({ url: '/v1/system/settings/logo', method: 'post', data: form })
}
/** 站点图标上传(浏览器标签页;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,15 +1,22 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import AppMenu from '@/components/layout/AppMenu.vue'
import { useSettingsStore } from '@/store/settings'
import { useUserStore } from '@/store/user'
/** 基础布局:左侧品牌导航(窄屏收为抽屉)+ 顶栏(面包屑/用户/退出)+ 内容区。 */
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const settingsStore = useSettingsStore()
// 品牌区(名称/副标题/logo)来自公司信息设置;App 已全局载入一次,这里兜底
onMounted(() => {
void settingsStore.ensureLoaded()
})
const drawerVisible = ref(false)
@ -40,8 +47,8 @@ async function handleLogout(): Promise<void> {
<el-container class="app-layout">
<el-aside class="app-aside" width="220px">
<div class="app-brand">
<span class="app-brand__name">衍水集团</span>
<span class="app-brand__sub">营销人员工资管理系统</span>
<span class="app-brand__name">{{ settingsStore.companyName }}</span>
<span class="app-brand__sub">{{ settingsStore.siteTitle }}</span>
<span class="app-brand__line"></span>
</div>
<AppMenu class="app-aside__menu" />
@ -95,8 +102,8 @@ async function handleLogout(): Promise<void> {
:with-header="false"
>
<div class="app-brand">
<span class="app-brand__name">衍水集团</span>
<span class="app-brand__sub">营销人员工资管理系统</span>
<span class="app-brand__name">{{ settingsStore.companyName }}</span>
<span class="app-brand__sub">{{ settingsStore.siteTitle }}</span>
<span class="app-brand__line"></span>
</div>
<AppMenu />

View File

@ -129,14 +129,8 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/business/receipts/index.vue'),
meta: { title: '回款管理', group: '业务数据', order: 15 },
},
{
// 业务数据组首个页面:评级计算器是打分工具,不影响绩效金额,
// 登录用户即可用,不设 requiresAdmin(区别于系统管理组)
path: '/business/rating',
name: 'business-rating',
component: () => import('@/views/business/rating/index.vue'),
meta: { title: '客户评级计算器', group: '业务数据', order: 16 },
},
// 客户评级已改为「客户管理」页行内弹窗(2026-10-19):删除独立页面与
// 菜单入口(菜单由路由表派生,删路由即删菜单);旧 URL 落入兜底重定向
],
},
// 未匹配路由回首页;若未登录会被守卫拦到登录页
@ -193,9 +187,7 @@ router.beforeEach(async (to) => {
return true
})
router.afterEach((to) => {
const title = to.meta.title as string | undefined
document.title = title ? `${title} - ${import.meta.env.VITE_APP_TITLE}` : import.meta.env.VITE_APP_TITLE
})
// 浏览器标题在 App.vue 中由「当前页 + 公司品牌」响应式计算
// (品牌异步载入/管理端改名后要自动更新,afterEach 只能取到旧值)
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">
import { computed, onMounted, reactive, ref } from 'vue'
import { computed, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import {
createRatingGrade,
deleteRatingGrade,
getCustomerRating,
listRatingGrades,
saveCustomerRating as saveCustomerRatingApi,
updateRatingGrade,
type RatingGradeItem,
type RatingGradeParams,
@ -14,13 +16,29 @@ import { useConfirmAction } from '@/composables/useConfirmAction'
import { handledError } from '@/utils/error'
/**
* 客户评级计算器(需求 2.2.5 / 对照表 #25)。
* 客户评级弹窗(2026-10-19 由独立「客户评级计算器」页面迁移而来)。
*
* 评级计算器仅是**打分工具**:五项打分求总分、按等级映射实时判定等级,
* 评分记录不落库、不参与绩效金额计算,因此页面没有保存动作(仅重置)。
* 等级映射的增删改走 /v1/business/rating-grades 维护接口,配置变更后
* 重载判定用列表——判定与抽屉表格共用同一份数据。
* 双 Tab 设计避免三层弹层:
* - Tab1「客户评级」:五项打分 → 总分/等级实时判定 → 保存落库(每客户一行,
* 等级按保存时点的映射快照固化);口径提示「评分不参与绩效金额计算」
* (对照表 #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 转数值,
* 禁止字符串直接比大小("90.0" 这类字典序比较会得出错误结果)。
* 无命中(总分低于全部下限)返回 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) {
if (totalScore >= parseFloat(grade.min_score)) {
return grade
@ -41,7 +58,7 @@ function resolveGrade(grades: RatingGradeItem[], totalScore: number): RatingGrad
return null
}
// ==================== 评分计算(纯前端实时计算) ====================
// ==================== 评分计算 ====================
/** 五项评分项:各 0-20 分、步进 1,总分 = 五项之和(0-100) */
const SCORE_ITEMS = [
@ -67,21 +84,49 @@ const totalScore = computed(() =>
SCORE_ITEMS.reduce((sum, item) => sum + (scores[item.key] ?? 0), 0),
)
/** 无保存动作(不落库),重置即把五项清零 */
function resetScores(): void {
for (const item of SCORE_ITEMS) {
scores[item.key] = 0
}
}
// ==================== 等级映射(判定用列表) ====================
const 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 grades = ref<RatingGradeItem[]>([])
const gradesLoading = ref(false)
/** 判定与抽屉表格共用同一份全量列表,配置变更后重载一次即同时生效 */
async function loadGrades(): Promise<void> {
gradesLoading.value = true
try {
@ -95,17 +140,47 @@ async function loadGrades(): Promise<void> {
const matchedGrade = computed(() => resolveGrade(grades.value, totalScore.value))
// ==================== 等级映射设置抽屉 ====================
// ==================== 打开即初始化 ====================
const drawerVisible = ref(false)
/** 打开时重新拉取,避免展示陈旧数据 */
function openDrawer(): void {
drawerVisible.value = true
void loadGrades()
async function init(): Promise<void> {
activeTab.value = 'score'
hasExisting.value = false
existingInfo.value = null
resetScores()
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)
/** null = 新增,否则为编辑目标 id */
@ -150,7 +225,6 @@ function editFormError(minScore: number | undefined, maxScore: number | undefine
}
async function saveGrade(): Promise<void> {
// 局部变量收窄类型:reactive 属性在异步流程中不保证保持窄化
const minScore = editForm.min_score
const maxScore = editForm.max_score
const error = editFormError(minScore, maxScore)
@ -165,7 +239,7 @@ async function saveGrade(): Promise<void> {
max_score: String(maxScore),
sort_order: editForm.sort_order ?? 0,
}
// 后端为全量替换语义;remark 本页不维护,编辑时原样带回避免误清
// 后端为全量替换语义;remark 本弹窗不维护,编辑时原样带回避免误清
if (editingId.value !== null) {
payload.remark = grades.value.find((g) => g.id === editingId.value)?.remark ?? undefined
}
@ -180,6 +254,7 @@ async function saveGrade(): Promise<void> {
editVisible.value = false
ElMessage.success(isCreate.value ? '评级等级已创建' : '已保存')
await loadGrades()
emit('saved')
} catch (requestError) {
handledError(requestError)
} finally {
@ -187,113 +262,134 @@ async function saveGrade(): Promise<void> {
}
}
/** 删除走确认动作流;文案明确后果(该区间不再能映射等级) */
/** 删除走确认动作流;文案明确后果(该区间不再能映射等级,已评级客户快照不受影响) */
function removeGrade(row: RatingGradeItem): void {
void confirmAndRun({
confirmText: `确定删除等级「${row.grade_name}」(${row.min_score} ~ ${row.max_score})?删除后该分数区间将无法映射等级。`,
confirmText: `确定删除等级「${row.grade_name}」(${row.min_score} ~ ${row.max_score})?删除后新评级在该分数区间将无法映射等级,已评级客户的等级快照不受影响。`,
title: '删除确认',
confirmButtonText: '删除',
successText: '评级等级已删除',
run: () => deleteRatingGrade(row.id),
after: () => loadGrades(),
after: async () => {
await loadGrades()
emit('saved')
},
})
}
onMounted(() => {
void loadGrades()
})
</script>
<template>
<div class="rating-page">
<el-dialog
v-model="visible"
:title="`客户评级 · ${customerName}`"
width="680px"
destroy-on-close
>
<!-- 对照表 #25:常驻口径提示,防止误认为评分会影响绩效金额 -->
<el-alert
class="page-alert"
title="本页为评分计算工具,评分结果不参与绩效金额计算"
class="rating-alert"
title="评级为评分工具,评分结果不参与绩效金额计算"
type="warning"
:closable="false"
show-icon
/>
<!-- 评分计算区:无保存按钮,纯前端实时计算 -->
<el-card shadow="never">
<div class="page-toolbar">
<span class="page-desc">五项各 0-20 分,总分 0-100;评级等级按配置的下限实时判定。</span>
<span>
<el-button @click="resetScores">重置</el-button>
<el-button type="primary" @click="openDrawer">等级映射设置</el-button>
</span>
</div>
<el-tabs v-model="activeTab">
<el-tab-pane label="客户评级" name="score">
<div class="calc-grid">
<div class="calc-form">
<div v-for="item in SCORE_ITEMS" :key="item.key" class="score-row">
<span class="score-label">{{ item.label }}</span>
<el-input-number
v-model="scores[item.key]"
:min="0"
:max="20"
:step="1"
controls-position="right"
/>
<span class="score-unit">分</span>
</div>
<p v-if="existingInfo" class="existing-hint">{{ existingInfo }}</p>
</div>
<div class="calc-grid">
<div class="calc-form">
<div v-for="item in SCORE_ITEMS" :key="item.key" class="score-row">
<span class="score-label">{{ item.label }}</span>
<el-input-number
v-model="scores[item.key]"
:min="0"
:max="20"
:step="1"
controls-position="right"
<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
/>
<span class="score-unit">分</span>
</div>
</el-form-item>
</el-form>
<div class="score-tip">
五项各 0-20 分,总分 0-100;保存后等级按当前映射快照固化,
之后调整等级映射不影响该客户已保存的等级。
</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 class="dialog-actions">
<el-button @click="resetScores">重置</el-button>
<el-button type="primary" :loading="saving" @click="handleSave">
{{ hasExisting ? '更新评级' : '保存评级' }}
</el-button>
</div>
</div>
</el-card>
</el-tab-pane>
<!-- 等级映射设置抽屉 -->
<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 }">
<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-drawer>
<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
v-model="editVisible"
:title="isCreate ? '新增评级等级' : '编辑评级等级'"
width="440px"
append-to-body
destroy-on-close
@closed="editingId = null"
>
@ -339,49 +435,36 @@ onMounted(() => {
<el-button type="primary" :loading="editSaving" @click="saveGrade">保存</el-button>
</template>
</el-dialog>
</div>
</el-dialog>
</template>
<style scoped>
.page-alert {
margin-bottom: 14px;
}
.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);
.rating-alert {
margin-bottom: 6px;
}
.calc-grid {
display: flex;
gap: 28px;
gap: 24px;
align-items: stretch;
flex-wrap: wrap;
margin-top: 8px;
}
.calc-form {
flex: 1 1 380px;
min-width: 280px;
flex: 1 1 320px;
min-width: 260px;
}
.score-row {
display: flex;
align-items: center;
gap: 12px;
line-height: 2.7;
line-height: 2.6;
}
.score-label {
width: 140px;
width: 132px;
font-size: 13px;
color: var(--ys-ink);
text-align: right;
@ -392,17 +475,24 @@ onMounted(() => {
color: var(--ys-ink-3);
}
.existing-hint {
margin: 8px 0 0;
font-size: 12px;
text-align: right;
color: var(--ys-ink-3);
}
.calc-result {
flex: 0 0 250px;
min-width: 220px;
flex: 0 0 220px;
min-width: 200px;
border: 1px solid var(--ys-line);
border-radius: 10px;
padding: 18px 20px;
padding: 16px 18px;
background: linear-gradient(180deg, #ffffff, #fcfcfe);
display: flex;
flex-direction: column;
justify-content: center;
gap: 18px;
gap: 16px;
}
.result-label {
@ -413,7 +503,7 @@ onMounted(() => {
}
.total-num {
font-size: 40px;
font-size: 36px;
color: var(--ys-indigo);
font-variant-numeric: tabular-nums;
margin-right: 4px;
@ -426,7 +516,7 @@ onMounted(() => {
.grade-name {
font-family: var(--ys-font-brand);
font-size: 28px;
font-size: 26px;
font-weight: 600;
color: var(--ys-ink);
line-height: 1.3;
@ -439,6 +529,25 @@ onMounted(() => {
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 {
font-size: 12px;
color: var(--ys-ink-3);
@ -448,7 +557,7 @@ onMounted(() => {
margin-bottom: 12px;
}
.drawer-toolbar {
.grades-toolbar {
display: flex;
justify-content: flex-end;
margin-bottom: 10px;
@ -468,7 +577,6 @@ onMounted(() => {
padding: 8px 12px;
}
/* 移动端:标签左对齐、结果块自然换行到表单下方 */
@media (max-width: 768px) {
.score-label {
width: auto;

View File

@ -1,15 +1,17 @@
<script setup lang="ts">
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 type { CustomerItem, OwnershipLogItem } from '@/api/customers'
import * as personnelApi from '@/api/personnel'
import { listRatingGrades } from '@/api/ratings'
import AppPagination from '@/components/table/AppPagination.vue'
import { useConfirmAction } from '@/composables/useConfirmAction'
import { usePagedList } from '@/composables/usePagedList'
import { formatDateTime } from '@/utils/format'
import { handledError } from '@/utils/error'
import CustomerRatingDialog from './CustomerRatingDialog.vue'
/**
* 客户管理(需求 2.1.3:客户档案 + 归属人员 + 归属变更留痕)。
@ -20,6 +22,9 @@ import { handledError } from '@/utils/error'
* - 归属变更提交前二次确认,文案必须包含「仅影响变更后核算,不回溯历史」
* (对照表 #19 的业务口径)
* - 归属变更记录为分页留痕表(抽屉内展示,按变更时间倒序)
* - 客户评级列(2026-10-19):每客户一行快照落库;评级配置走行内弹窗
* (原独立评级页删除);列头支持正序/倒序 —— 列表是服务端分页,
* 排序必须带参数回后端(`order_field=rating`),且未评级恒在末尾
*/
const { confirmAndRun } = useConfirmAction()
@ -31,6 +36,10 @@ const filters = reactive({
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 } =
usePagedList<CustomerItem, customerApi.CustomerQueryParams>(
customerApi.listCustomers,
@ -38,16 +47,58 @@ const { records, total, pageNum, pageSize, loading, load, search, onPageChange,
keyword: filters.keyword,
customer_type: filters.customer_type,
owner_personnel_id: filters.owner_personnel_id,
order_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 {
filters.keyword = ''
filters.customer_type = undefined
filters.owner_personnel_id = undefined
ratingOrder.value = undefined
tableRef.value?.clearSort()
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 {
@ -302,7 +353,7 @@ function ownerTransition(row: OwnershipLogItem): string {
async function handleDelete(row: CustomerItem): Promise<void> {
await confirmAndRun({
confirmText: `确定删除客户「${row.company_name}」?删除为物理删除、不可恢复;归属变更留痕将一并失去展示主体。`,
confirmText: `确定删除客户「${row.company_name}」?删除为物理删除、不可恢复;归属变更留痕与评级记录将一并失去展示主体。`,
title: '删除确认',
confirmButtonText: '删除',
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(() => {
// 两个请求各自容错(load 内部已兜底),互不拖累
// 三个请求各自容错(内部已兜底),互不拖累
void load()
void loadOwnerOptions()
void loadGradeNames()
})
</script>
@ -369,16 +437,54 @@ onMounted(() => {
<el-button type="primary" @click="openCreate">新增客户</el-button>
</div>
<el-table v-loading="loading" :data="records" stripe>
<el-table-column prop="company_name" label="公司名称" min-width="180" />
<el-table-column prop="owner_name" label="负责人" min-width="90" />
<el-table-column prop="contact_person" label="联系人" min-width="90">
<el-table
ref="tableRef"
v-loading="loading"
: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>
</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>
</el-table-column>
<el-table-column label="分类" width="110" align="center">
<el-table-column label="分类" min-width="140" align="center">
<template #default="{ row }">
<el-tag
:type="
@ -395,15 +501,25 @@ onMounted(() => {
</el-tag>
</template>
</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>
</el-table-column>
<el-table-column label="操作" width="230" fixed="right">
<el-table-column label="操作" min-width="140" align="center">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
<el-button link type="primary" size="small" @click="openOwnership(row)">归属变更</el-button>
<el-button link type="primary" size="small" @click="openLogs(row)">变更记录</el-button>
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
<!-- 五项按钮单行均分:两端对齐让相邻按钮间距处处相等;
「配置客户评级」最长(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="openRating(row)">
配置客户评级
</el-button>
<el-button link type="primary" size="small" @click="openOwnership(row)">
归属变更
</el-button>
<el-button link type="primary" size="small" @click="openLogs(row)">变更记录</el-button>
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
</div>
</template>
</el-table-column>
</el-table>
@ -525,6 +641,14 @@ onMounted(() => {
</template>
</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-table v-loading="logsLoading" :data="logRecords" stripe size="small">
@ -559,4 +683,31 @@ onMounted(() => {
font-size: 12px;
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>

View File

@ -4,12 +4,13 @@ import { onMounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { getCaptcha } from '@/api/auth'
import { getPublicSettings } from '@/api/settings'
import { useSettingsStore } from '@/store/settings'
import { useUserStore } from '@/store/user'
const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
const settingsStore = useSettingsStore()
const formRef = ref<FormInstance>()
const submitting = ref(false)
@ -24,20 +25,6 @@ const form = reactive({
const captchaId = ref('')
const captchaImage = ref('')
/** 品牌名:默认衍水集团,公司信息设置页改了以后登录页同步生效(public 端点) */
const brandName = ref('衍水集团')
const brandLogo = ref<string | null>(null)
async function loadBrand(): Promise<void> {
try {
const data = await getPublicSettings()
if (data.company_name) brandName.value = data.company_name
brandLogo.value = data.logo_url
} catch {
// 登录页必须可用:公司信息接口失败时静默回退默认品牌名,不阻断登录
}
}
const rules: FormRules = {
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
@ -89,7 +76,8 @@ async function handleSubmit(): Promise<void> {
onMounted(() => {
refreshCaptcha()
loadBrand()
// 品牌信息失败静默回退内置默认值(不阻断登录;内部去重/缓存)
void settingsStore.ensureLoaded()
})
</script>
@ -103,9 +91,14 @@ onMounted(() => {
<div class="login-wrap">
<div class="login-card">
<div class="card-brand">
<img v-if="brandLogo" :src="brandLogo" alt="" class="card-brand__logo" />
<div class="card-brand__name">{{ brandName }}</div>
<p class="card-brand__slogan">把每一笔回款,算成一份踏实。</p>
<img
v-if="settingsStore.logoUrl"
: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>
</div>
@ -170,7 +163,10 @@ onMounted(() => {
</el-form>
</div>
<p class="login-foot">© {{ brandName }} · 内部系统</p>
<p class="login-foot">
{{ settingsStore.footerLine
}}<template v-if="settingsStore.icpNo"> · {{ settingsStore.icpNo }}</template>
</p>
</div>
</div>
</template>

View File

@ -4,6 +4,7 @@ import { ElMessage, ElMessageBox } from 'element-plus'
import * as businessRoleApi from '@/api/businessRoles'
import * as paramsApi from '@/api/params'
import { paramEnumLabel } from '@/api/params'
import type {
ParamBatch,
ParamBatchDetail,
@ -37,9 +38,10 @@ async function loadCatalog(): Promise<void> {
catalogLoading.value = true
try {
catalog.value = await paramsApi.getCatalog()
// 预览「所用参数」按编码取中文名;目录加载后填充映射
// 目录加载后填充映射:预览「所用参数」按编码取中文名;值翻译按编码查类型
for (const item of catalog.value) {
PARAM_NAME_BY_CODE[item.param_code] = item.param_name
PARAM_VALUE_TYPES[item.param_code] = item.value_type
}
} catch (error) {
handledError(error)
@ -77,20 +79,24 @@ function paramNameByCode(code: string): string {
return PARAM_NAME_BY_CODE[code] ?? code
}
/** 枚举值 → 中文(FULL/BALANCE、INCLUDE/DEDUCT/EXCLUDE、0/1 开关) */
const ENUM_VALUE_LABELS: Record<string, string> = {
FULL: '当月全额',
BALANCE: '余额',
INCLUDE: '含基本工资',
DEDUCT: '扣除基本工资',
EXCLUDE: '不计基本工资',
'0': '关',
'1': '开',
/** 参数编码 → 值类型(目录加载后填充;未知参数不翻译,避免误译数值) */
const PARAM_VALUE_TYPES: Record<string, string> = {}
/**
* 按参数编码翻译枚举值(旧值/新值等宽表用)。
*
* **必须先判参数类型**:枚举映射含 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 {
if (value === null || value === undefined) return ''
return ENUM_VALUE_LABELS[value] ?? value
/** ENUM 下拉选项文案:中文主显 + 括号保留原码(当月全额(FULL)),便于与审计记录对上 */
function enumOptionLabel(option: string): string {
const label = paramEnumLabel(option)
return label === option ? option : `${label}(${option})`
}
/** 带单位的值展示 */
@ -461,7 +467,14 @@ onMounted(() => {
<template v-if="item.values.length">
<div v-for="v in item.values" :key="v.dimension_value" class="value-row">
<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>
<el-tag
v-if="pendingOf(item, v.dimension_value)"
@ -469,7 +482,7 @@ onMounted(() => {
type="warning"
disable-transitions
>
待改为 {{ pendingOf(item, v.dimension_value)!.new_value }}
待改为 {{ enumValueLabel(item.param_code, pendingOf(item, v.dimension_value)!.new_value) }}
</el-tag>
<el-button
link
@ -520,10 +533,12 @@ onMounted(() => {
<template #default="{ row }">{{ dimensionLabel(row.dimension_value) }}</template>
</el-table-column>
<el-table-column label="旧值" min-width="110">
<template #default="{ row }">{{ row.old_value || '—' }}</template>
<template #default="{ row }">{{ enumValueLabel(row.item.param_code, row.old_value) || '—' }}</template>
</el-table-column>
<el-table-column label="新值" min-width="110">
<template #default="{ row }"><b class="num">{{ row.new_value }}</b></template>
<template #default="{ row }">
<b class="num">{{ enumValueLabel(row.item.param_code, row.new_value) }}</b>
</template>
</el-table-column>
<el-table-column label="操作" width="80">
<template #default="{ row }">
@ -571,7 +586,9 @@ onMounted(() => {
</el-select>
</el-form-item>
<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>
</el-form-item>
<el-form-item label="新值">
@ -580,7 +597,12 @@ onMounted(() => {
v-model="editValue"
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-input v-else v-model="editValue" placeholder="输入新值(分数式如 4/360 原样保留)" />
</el-form-item>
@ -625,11 +647,15 @@ onMounted(() => {
<template #default="{ row }">{{ dimensionLabel(row.dimension_value) }}</template>
</el-table-column>
<el-table-column label="旧值" min-width="90">
<template #default="{ row }">{{ row.old_value ?? '—' }}</template>
<template #default="{ row }">
{{ enumValueLabel(row.param_code, row.old_value) || '—' }}
</template>
</el-table-column>
<el-table-column label="新值" min-width="90">
<template #default="{ row }">
<span :class="{ changed: row.changed }">{{ row.new_value ?? '—' }}</span>
<span :class="{ changed: row.changed }">
{{ enumValueLabel(row.param_code, row.new_value) || '—' }}
</span>
</template>
</el-table-column>
<el-table-column label="变化" width="70" align="center">
@ -651,7 +677,10 @@ onMounted(() => {
<div v-for="item in detailCache[row.id].items" :key="item.param_code + item.dimension_value" class="batch-item">
<span class="param-name">{{ item.param_name }}</span>
<span class="dim-label">{{ dimensionLabel(item.dimension_value) }}</span>
<span class="num">{{ item.old_value_text ?? '—' }} → <b>{{ item.value_text }}</b></span>
<span class="num">
{{ enumValueLabel(item.param_code, item.old_value_text) || '—' }} →
<b>{{ enumValueLabel(item.param_code, item.value_text) }}</b>
</span>
</div>
</template>
<el-button v-else link size="small" @click="detailOf(row).catch(() => {})">
@ -700,7 +729,10 @@ onMounted(() => {
</el-table-column>
<el-table-column label="旧值 → 新值" min-width="130">
<template #default="{ row }">
<span class="num">{{ row.old_value_text ?? '—' }} → {{ row.value_text }}</span>
<span class="num">
{{ enumValueLabel(historyItem?.param_code ?? '', row.old_value_text) || '—' }} →
{{ enumValueLabel(historyItem?.param_code ?? '', row.value_text) }}
</span>
</template>
</el-table-column>
<el-table-column prop="version_no" label="版本" width="115" />
@ -792,7 +824,7 @@ onMounted(() => {
<div class="pr-used">
<div class="drawer-title">所用参数(按回款到账日取值)</div>
<div v-for="u in previewResult.params_used" :key="u.param_code" class="pr-used-row">
{{ paramNameByCode(u.param_code) }}({{ u.param_code }})= {{ valueLabel(u.value_text) || u.value_text }}
{{ paramNameByCode(u.param_code) }}({{ u.param_code }})= {{ enumValueLabel(u.param_code, u.value_text) }}
<span v-if="u.effective_date" class="eff">({{ u.effective_date }} 起)</span>
</div>
</div>
@ -868,6 +900,13 @@ onMounted(() => {
font-variant-numeric: tabular-nums;
}
/* 枚举值旁的灰色原始码(如「当月全额 FULL」的 FULL),与审计记录对账用 */
.enum-code {
font-size: 11px;
font-weight: 400;
color: var(--ys-ink-3);
}
.eff {
font-size: 12px;
color: var(--ys-ink-3);

View File

@ -1,42 +1,85 @@
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import type { UploadRawFile } from 'element-plus'
import * as settingsApi from '@/api/settings'
import type { CompanySettings } from '@/api/settings'
import { useSettingsStore } from '@/store/settings'
import { handledError } from '@/utils/error'
/**
* 公司信息设置(2026-10-15 补充需求:公司名称、logo)。
* 公司信息与站点信息设置(2026-10-20 定稿)。
*
* 签名元素是右侧「登录页实景预览」:品牌名与 logo 改动即时反映在一张
* 缩比登录卡上 —— 所见即所得,避免「保存后去登录页核对」的往返。
* logo 校验(png/jpg ≤2MB)与后端三重校验同口径,前端先拦一道。
* 页面按「左侧设置 → 右侧预览」成对排列,四张卡两行:
* - 第一行:登录页信息(名称/标语/页脚/备案号/logo)→ 登录页预览
* - 第二行:网页站点信息(站点标题/站点图标)→ 浏览器标签页预览
*
* 进入页面即加载当前设置(设置页即所见即所得,无需手动刷新)。
* 两张设置卡**各自独立保存**(后端为部分更新语义):
* 改登录页信息不必触碰站点标题,反之亦然;保存后把回包写入 settings store,
* 侧边栏/浏览器标题/favicon/登录页立即同步(不重新请求 public 端点 ——
* 匿名端点带 300s 浏览器缓存)。
*/
const formRef = ref<FormInstance>()
const submitting = ref(false)
const route = useRoute()
const settingsStore = useSettingsStore()
const form = reactive({
// ==================== 登录页信息 ====================
const loginFormRef = ref<FormInstance>()
const savingLogin = ref(false)
const loginForm = reactive({
company_name: '',
brand_slogan: '',
footer_text: '',
icp_no: '',
})
const rules: FormRules = {
const loginRules: FormRules = {
company_name: [
{ required: true, message: '请输入公司名称', 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 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> {
try {
const data = await settingsApi.getSettings()
form.company_name = data.company_name
logoUrl.value = data.logo_url
fillAll(data)
// 管理端打开设置页即用最新值校准全局品牌/站点信息
settingsStore.apply(data)
} catch (error) {
handledError(error)
}
@ -44,118 +87,309 @@ async function loadSettings(): Promise<void> {
loadSettings()
async function handleSubmit(): Promise<void> {
const valid = await formRef.value?.validate().catch(() => false)
function fillAll(data: CompanySettings): void {
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
submitting.value = true
savingLogin.value = true
try {
const data = await settingsApi.updateSettings(form.company_name.trim())
form.company_name = data.company_name
ElMessage.success('已保存')
} catch (error) {
handledError(error)
} finally {
submitting.value = false
}
}
function beforeUpload(file: UploadRawFile): boolean {
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)
const data = await settingsApi.updateSettings({
company_name: loginForm.company_name.trim(),
brand_slogan: loginForm.brand_slogan.trim(),
footer_text: loginForm.footer_text.trim(),
icp_no: loginForm.icp_no.trim(),
})
// 只回填本卡字段:另一张卡未保存的编辑不被覆盖
loginForm.company_name = data.company_name
loginForm.brand_slogan = data.brand_slogan ?? ''
loginForm.footer_text = data.footer_text ?? ''
loginForm.icp_no = data.icp_no ?? ''
logoUrl.value = data.logo_url
ElMessage.success('logo 已更新')
faviconUrl.value = data.favicon_url
settingsStore.apply(data)
ElMessage.success('登录页信息已保存')
} catch (error) {
handledError(error)
} 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>
<template>
<div class="page">
<div class="settings-grid">
<el-card shadow="never" class="settings-card">
<template #header>
<span class="card-title">公司信息</span>
</template>
<!-- 左一:登录页信息 -->
<el-form
ref="loginFormRef"
: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-input
v-model="form.company_name"
v-model="loginForm.company_name"
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
/>
</el-form-item>
<el-form-item label="logo">
<div class="logo-field">
<div class="asset-field">
<el-upload
class="logo-uploader"
:show-file-list="false"
:http-request="handleUpload"
:before-upload="beforeUpload"
:http-request="handleLogoUpload"
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" />
<span v-else class="logo-plus">+</span>
<span v-else class="asset-plus">+</span>
</div>
</el-upload>
<p class="logo-hint">png / jpg,不超过 2MB;<br />展示于登录页品牌位与导出页眉。</p>
<p class="asset-hint">
png / jpg,不超过 2MB;<br />展示于登录页品牌位。
</p>
</div>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="submitting" @click="handleSubmit">保存</el-button>
</el-form-item>
</el-form>
</el-card>
<div class="card-actions">
<el-button type="primary" :loading="savingLogin" @click="saveLoginInfo">
保存
</el-button>
</div>
</el-card>
</el-form>
<!-- 签名元素:登录页实景预览(缩比登录卡,品牌名/logo 即时反映) -->
<el-card shadow="never" class="preview-card">
<!-- 右一:登录页预览(由左侧「登录页信息」控制) -->
<el-card shadow="never" class="settings-card preview-card">
<template #header>
<span class="card-title">登录页预览</span>
<span class="preview-note">保存后对所有人生效</span>
<span class="preview-note">对应左侧「登录页信息」</span>
</template>
<div class="login-mock">
<div class="mock-brand">
<img v-if="logoUrl" :src="logoUrl" alt="" class="mock-logo" />
<div class="mock-name">{{ previewName }}</div>
<p class="mock-slogan">把每一笔回款,算成一份踏实。</p>
<p class="mock-slogan">{{ previewSlogan }}</p>
<span class="mock-waterline"></span>
</div>
<div class="mock-field"></div>
<div class="mock-field"></div>
<div class="mock-submit">登 录</div>
</div>
<p class="mock-foot">
{{ previewFooter }}<template v-if="previewIcp"> · {{ previewIcp }}</template>
</p>
<p class="preview-caption">登录页品牌位实景(示意,非交互)</p>
</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>
</template>
@ -163,9 +397,41 @@ const previewName = computed(() => form.company_name.trim() || '衍水集团')
<style scoped>
.settings-grid {
display: grid;
grid-template-columns: minmax(360px, 460px) minmax(300px, 380px);
grid-template-columns: minmax(420px, 560px) minmax(300px, 380px);
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 {
@ -178,13 +444,19 @@ const previewName = computed(() => form.company_name.trim() || '衍水集团')
color: var(--ys-ink-3);
}
.logo-field {
.card-actions {
display: flex;
justify-content: flex-end;
margin-top: 4px;
}
.asset-field {
display: flex;
align-items: flex-start;
gap: 14px;
}
.logo-box {
.asset-box {
display: flex;
align-items: center;
justify-content: center;
@ -198,24 +470,29 @@ const previewName = computed(() => form.company_name.trim() || '衍水集团')
transition: border-color 150ms ease;
}
.logo-box:hover {
.asset-box--small {
width: 64px;
height: 64px;
}
.asset-box:hover {
border-color: var(--ys-indigo);
}
.logo-box img {
.asset-box img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.logo-plus {
.asset-plus {
font-size: 26px;
font-weight: 300;
line-height: 1;
color: var(--ys-ink-3);
}
.logo-hint {
.asset-hint {
margin: 4px 0 0;
font-size: 12px;
line-height: 1.7;
@ -268,7 +545,8 @@ const previewName = computed(() => form.company_name.trim() || '衍水集团')
}
.mock-field {
height: 32px;
flex: 1;
min-height: 32px;
margin-top: 12px;
background: #f4f5f8;
border: 1px solid rgba(28, 35, 51, 0.07);
@ -287,13 +565,74 @@ const previewName = computed(() => form.company_name.trim() || '衍水集团')
border-radius: 6px;
}
.preview-caption {
margin: 10px 0 0;
.mock-foot {
margin: 12px 0 0;
font-size: 11px;
text-align: center;
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) {
.settings-grid {
grid-template-columns: 1fr;