Compare commits
2 Commits
main
...
feat/setti
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac4ba7514b | ||
|
|
27533eb465 |
212
backend/alembic/versions/c4e2a86f9b13_v11_客户评级落库与站点信息.py
Normal file
212
backend/alembic/versions/c4e2a86f9b13_v11_客户评级落库与站点信息.py
Normal file
@ -0,0 +1,212 @@
|
||||
"""V11 客户评级落库与站点信息
|
||||
|
||||
Revision ID: c4e2a86f9b13
|
||||
Revises: 90653814a421
|
||||
Create Date: 2026-10-19 10:00:00.000000
|
||||
|
||||
与上游 V7–V10(台账/月结/调整单/口径/唯一键/预警参数)合并时重编号为 V11:
|
||||
本分支三个迁移(原 V7 客户评级 + V8 公司档案精简 + V9 图片版本号)从未发布,
|
||||
压成单条,最终结构 = 评级表 + 最终版站点/公司信息字段(不含中途增删的
|
||||
副标题/信用代码/地址/电话/邮箱)。
|
||||
|
||||
"""
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import mysql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "c4e2a86f9b13"
|
||||
down_revision: str | Sequence[str] | None = "90653814a421"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""应用迁移(双主题一次交付)。
|
||||
|
||||
- 客户评级弹窗化:评分每客户落库(customer_ratings,一行一客户快照)
|
||||
- 公司信息补充与精简:标语/页脚/备案号/站点标题/站点图标 + 图片版本号
|
||||
"""
|
||||
op.create_table(
|
||||
"customer_ratings",
|
||||
sa.Column(
|
||||
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
|
||||
),
|
||||
sa.Column(
|
||||
"customer_id",
|
||||
mysql.BIGINT(unsigned=True),
|
||||
nullable=False,
|
||||
comment="客户ID(唯一:一客户一份当前评级)",
|
||||
),
|
||||
sa.Column(
|
||||
"score_ship_amount",
|
||||
sa.Numeric(precision=5, scale=1),
|
||||
server_default="0",
|
||||
nullable=False,
|
||||
comment="发货额评分(0-20)",
|
||||
),
|
||||
sa.Column(
|
||||
"score_receipt_rate",
|
||||
sa.Numeric(precision=5, scale=1),
|
||||
server_default="0",
|
||||
nullable=False,
|
||||
comment="回款率评分(0-20)",
|
||||
),
|
||||
sa.Column(
|
||||
"score_profit_rate",
|
||||
sa.Numeric(precision=5, scale=1),
|
||||
server_default="0",
|
||||
nullable=False,
|
||||
comment="毛利率评分(0-20)",
|
||||
),
|
||||
sa.Column(
|
||||
"score_contract_cooperation",
|
||||
sa.Numeric(precision=5, scale=1),
|
||||
server_default="0",
|
||||
nullable=False,
|
||||
comment="合同履约配合能力评分(0-20)",
|
||||
),
|
||||
sa.Column(
|
||||
"score_site_construction",
|
||||
sa.Numeric(precision=5, scale=1),
|
||||
server_default="0",
|
||||
nullable=False,
|
||||
comment="现场规范施工能力评分(0-20)",
|
||||
),
|
||||
sa.Column(
|
||||
"total_score",
|
||||
sa.Numeric(precision=5, scale=1),
|
||||
nullable=False,
|
||||
comment="总分(服务端重算,五项之和 0-100)",
|
||||
),
|
||||
sa.Column(
|
||||
"grade_name",
|
||||
sa.String(length=32),
|
||||
nullable=True,
|
||||
comment="评级等级(保存时按等级映射快照固化,无外键;未命中/等级表空为 NULL)",
|
||||
),
|
||||
sa.Column("remark", sa.String(length=255), nullable=True, comment="备注"),
|
||||
sa.Column(
|
||||
"scorer_id",
|
||||
mysql.BIGINT(unsigned=True),
|
||||
nullable=True,
|
||||
comment="评级操作人ID(账号删除后置空)",
|
||||
),
|
||||
sa.Column(
|
||||
"scorer_name",
|
||||
sa.String(length=64),
|
||||
nullable=False,
|
||||
comment="评级操作人用户名(冗余固化)",
|
||||
),
|
||||
sa.Column(
|
||||
"create_time",
|
||||
sa.DateTime(),
|
||||
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||
nullable=False,
|
||||
comment="创建时间",
|
||||
),
|
||||
sa.Column(
|
||||
"update_time",
|
||||
sa.DateTime(),
|
||||
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
|
||||
nullable=False,
|
||||
comment="更新时间",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["customer_id"],
|
||||
["customers.id"],
|
||||
name=op.f("fk_customer_ratings_customer_id"),
|
||||
ondelete="RESTRICT",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["scorer_id"],
|
||||
["users.id"],
|
||||
name=op.f("fk_customer_ratings_scorer_id"),
|
||||
ondelete="SET NULL",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_customer_ratings")),
|
||||
sa.UniqueConstraint("customer_id", name=op.f("uk_customer_ratings_customer_id")),
|
||||
comment="客户评级表(每客户一行;等级名为快照固化无外键,删等级不伤已评级记录)",
|
||||
mysql_charset="utf8mb4",
|
||||
mysql_collate="utf8mb4_general_ci",
|
||||
mysql_engine="InnoDB",
|
||||
)
|
||||
op.add_column(
|
||||
"system_settings",
|
||||
sa.Column(
|
||||
"site_title",
|
||||
sa.String(length=64),
|
||||
nullable=True,
|
||||
comment="站点标题(浏览器标签页与侧边栏副标题;null=内置默认)",
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"system_settings",
|
||||
sa.Column(
|
||||
"brand_slogan",
|
||||
sa.String(length=64),
|
||||
nullable=True,
|
||||
comment="品牌标语(登录页与品牌预览;null=内置默认)",
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"system_settings",
|
||||
sa.Column(
|
||||
"footer_text",
|
||||
sa.String(length=64),
|
||||
nullable=True,
|
||||
comment="页脚版权文本(登录页;null=自动生成 © 公司名 · 内部系统)",
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"system_settings",
|
||||
sa.Column(
|
||||
"icp_no",
|
||||
sa.String(length=32),
|
||||
nullable=True,
|
||||
comment="ICP 备案号(登录页页脚,空=不显示)",
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"system_settings",
|
||||
sa.Column(
|
||||
"favicon_path",
|
||||
sa.String(length=255),
|
||||
nullable=True,
|
||||
comment="站点图标相对路径(文件存 uploads/favicon/,null=使用内置 favicon.svg)",
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"system_settings",
|
||||
sa.Column(
|
||||
"logo_version",
|
||||
sa.Integer(),
|
||||
server_default="0",
|
||||
nullable=False,
|
||||
comment="logo 版本号(每次上传 +1;图片 URL 用它破缓存——同扩展名覆盖时路径不变,update_time 也不变)",
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"system_settings",
|
||||
sa.Column(
|
||||
"favicon_version",
|
||||
sa.Integer(),
|
||||
server_default="0",
|
||||
nullable=False,
|
||||
comment="站点图标版本号(每次上传 +1;图片 URL 用它破缓存)",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""回滚迁移:先删评级表(引用 customers/users),再逆序移除站点信息列。"""
|
||||
op.drop_table("customer_ratings")
|
||||
op.drop_column("system_settings", "favicon_version")
|
||||
op.drop_column("system_settings", "logo_version")
|
||||
op.drop_column("system_settings", "favicon_path")
|
||||
op.drop_column("system_settings", "icp_no")
|
||||
op.drop_column("system_settings", "footer_text")
|
||||
op.drop_column("system_settings", "brand_slogan")
|
||||
op.drop_column("system_settings", "site_title")
|
||||
@ -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="评级已保存",
|
||||
)
|
||||
|
||||
@ -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
|
||||
@ -20,6 +20,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="公司信息")
|
||||
@ -28,14 +29,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="已保存",
|
||||
@ -55,7 +56,7 @@ def upload_logo(
|
||||
它们卡死事件循环(项目红线:全栈同步路由,FastAPI 自动进线程池)。
|
||||
大小先用 `file.size` 拦截,避免超限文件整体读进内存。
|
||||
"""
|
||||
if file.size is not None and file.size > service.LOGO_MAX_BYTES:
|
||||
if file.size is not None and file.size > service.ASSET_MAX_BYTES:
|
||||
raise BadRequestError("logo 文件不能超过 2MB")
|
||||
content = file.file.read()
|
||||
ext = (file.filename or "").rsplit(".", 1)[-1]
|
||||
@ -65,6 +66,27 @@ def upload_logo(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/favicon", response_model=ApiResponse[SettingOut], summary="上传站点图标")
|
||||
def upload_favicon(
|
||||
file: UploadFile,
|
||||
session: DbSession,
|
||||
current_user: AdminUser,
|
||||
meta: RequestMetaDep,
|
||||
) -> ApiResponse[SettingOut]:
|
||||
"""上传站点图标(png/jpg/ico ≤2MB;固定名覆盖,旧扩展名文件清理)。
|
||||
|
||||
同步 `def` 与 logo 上传同口径:磁盘写与审计提交均为阻塞操作。
|
||||
"""
|
||||
if file.size is not None and file.size > service.ASSET_MAX_BYTES:
|
||||
raise BadRequestError("站点图标文件不能超过 2MB")
|
||||
content = file.file.read()
|
||||
ext = (file.filename or "").rsplit(".", 1)[-1]
|
||||
return ok(
|
||||
service.save_favicon(session, content, ext, operator=current_user, meta=meta),
|
||||
message="站点图标已更新",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/public", response_model=ApiResponse[SettingPublicOut], summary="公司信息(匿名)")
|
||||
def get_settings_public(session: DbSession, response: Response) -> ApiResponse[SettingPublicOut]:
|
||||
"""公司信息(登录页消费,无鉴权)。短缓存减轻匿名端点压力。"""
|
||||
@ -81,3 +103,14 @@ def get_logo_public(session: DbSession) -> FileResponse:
|
||||
media_type=_LOGO_MEDIA_TYPE.get(ext, "application/octet-stream"),
|
||||
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"},
|
||||
)
|
||||
|
||||
@ -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
|
||||
@ -38,6 +39,7 @@ __all__ = [
|
||||
"BusinessRole",
|
||||
"BusinessStatusLog",
|
||||
"Customer",
|
||||
"CustomerRating",
|
||||
"CustomerRatingGrade",
|
||||
"LoginLog",
|
||||
"MonthlySettlement",
|
||||
|
||||
77
backend/app/models/customer_rating.py
Normal file
77
backend/app/models/customer_rating.py
Normal 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="评级操作人用户名(冗余固化)"
|
||||
)
|
||||
@ -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
|
||||
|
||||
@ -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 用它破缓存)",
|
||||
)
|
||||
|
||||
33
backend/app/repositories/customer_rating_repository.py
Normal file
33
backend/app/repositories/customer_rating_repository.py
Normal 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()
|
||||
@ -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,
|
||||
*,
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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="评分")
|
||||
|
||||
@ -1,34 +1,59 @@
|
||||
"""系统设置(公司信息)请求/响应模型。
|
||||
"""系统设置(公司信息 + 站点信息)请求/响应模型。
|
||||
|
||||
`SettingPublicOut` 与 `SettingOut` **刻意不共用**:public 端点无鉴权,
|
||||
响应字段必须白名单显式列出——将来往 SettingOut 加敏感字段时不会
|
||||
顺着复用链泄漏到匿名端点。
|
||||
|
||||
2026-10-20 精简(用户确认):删除系统副标题与企业档案字段(信用代码/
|
||||
地址/电话/邮箱);新增站点标题(浏览器标签页/侧边栏副标题)与站点图标
|
||||
(浏览器标签页 favicon)。public 端点只需登录页/标签页展示字段,两者
|
||||
恰好同集合,但仍保留独立 schema 作为白名单防线。
|
||||
"""
|
||||
|
||||
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):
|
||||
"""公司信息更新。
|
||||
"""公司信息与站点信息更新(**部分更新**语义)。
|
||||
|
||||
str_strip_whitespace:全空白名称先剥再校验——否则 `" "` 过 min_length=1
|
||||
后由服务层 strip 成空串入库(200 成功但 public 端点返回空公司名)。
|
||||
|
||||
仅提交的字段生效(``exclude_unset``),未提交或显式 null 的字段保持
|
||||
不变;可选文本传空串等价清空(落库为 null、前端回退内置默认)。
|
||||
company_name 若提交必须非空(DB 非空约束)。
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(str_strip_whitespace=True)
|
||||
|
||||
company_name: str = Field(min_length=1, max_length=64, description="公司名称")
|
||||
company_name: str | None = Field(
|
||||
default=None, min_length=1, max_length=64, description="公司名称(提交则须非空)"
|
||||
)
|
||||
site_title: str | None = Field(default=None, max_length=64, description="站点标题")
|
||||
brand_slogan: str | None = Field(default=None, max_length=64, description="品牌标语")
|
||||
footer_text: str | None = Field(default=None, max_length=64, description="页脚版权文本")
|
||||
icp_no: str | None = Field(default=None, max_length=32, description="ICP 备案号")
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,54 +186,79 @@ 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")
|
||||
|
||||
# 先写新文件、commit 成功后再清旧扩展名(若把清理放在 commit 之前,
|
||||
# commit 失败会让库指旧路径而旧文件已删——断链无补偿);同扩展名
|
||||
# 由写入覆盖,无需也不会误删新文件
|
||||
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()
|
||||
|
||||
old_ext = Path(old_path).suffix.lstrip(".") if old_path else None
|
||||
if old_ext and old_ext != ext:
|
||||
if old_ext and old_ext != ext and old_ext in allowed:
|
||||
try:
|
||||
_cleanup_old_logo(old_ext)
|
||||
_cleanup_old_asset(kind, old_ext)
|
||||
except OSError:
|
||||
# 清理失败不影响本次更新(库与新文件均已生效);若异常外抛,
|
||||
# 成功的 logo 更新会被记成失败审计并 500 —— 审计与事实相反
|
||||
logger.warning("旧 logo 清理失败(不影响本次更新)| ext=%s", old_ext, exc_info=True)
|
||||
logger.info("更新公司 logo | size=%s ext=%s", len(content), ext)
|
||||
# 成功的更新会被记成失败审计并 500 —— 审计与事实相反
|
||||
logger.warning(
|
||||
"旧%s清理失败(不影响本次更新)| ext=%s", label, old_ext, exc_info=True
|
||||
)
|
||||
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)
|
||||
|
||||
@ -21,7 +21,6 @@
|
||||
|
||||
import uuid
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import delete, select, update
|
||||
@ -37,6 +36,7 @@ from app.models import (
|
||||
BusinessRole,
|
||||
BusinessStatusLog,
|
||||
Customer,
|
||||
CustomerRating,
|
||||
CustomerRatingGrade,
|
||||
MonthlySettlement,
|
||||
OperationLog,
|
||||
@ -164,6 +164,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(
|
||||
@ -190,32 +194,103 @@ def _purge_test_data() -> None:
|
||||
# 审批单无 t_ 前缀可用,按 AP 编号前缀全量清(steps 外键 CASCADE 随删;
|
||||
# ref_id 固化无外键,业务已在上一步删除,无悬挂影响)
|
||||
session.execute(delete(ApprovalOrder).where(ApprovalOrder.order_no.like("AP%")))
|
||||
# 开关参数恢复种子基线:测试提交的变更批次条目 id 均大于种子终值 28
|
||||
# (V3 十九条 + V6 两条 + V7 调整单一条 + V8 经理基数开关一条;V8 另
|
||||
# 插入两个审批开关的 10-19 生效行 id 23/24,V10 插入预警规则参数
|
||||
# id 26/27/28,均属种子),删新条目与新批次后解析回退到种子值
|
||||
stray_values = list(session.execute(select(ParamValue).where(ParamValue.id > 28)).scalars())
|
||||
if stray_values:
|
||||
stray_batches = {v.batch_id for v in stray_values}
|
||||
session.execute(delete(ParamValue).where(ParamValue.id > 28))
|
||||
session.execute(delete(ParamChangeBatch).where(ParamChangeBatch.id.in_(stray_batches)))
|
||||
# 系统设置恢复种子单行(test_settings 会改公司名/传 logo——残留会让
|
||||
# 后续 e2e 的品牌断言在污染状态下运行);uploads 下的测试 logo 一并删
|
||||
session.execute(
|
||||
update(SystemSetting)
|
||||
.where(SystemSetting.id == 1)
|
||||
.values(company_name="衍水集团", logo_path=None)
|
||||
# 开关参数恢复种子基线:测试提交的变更批次由 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()
|
||||
logo_dir = Path(__file__).resolve().parents[2] / "uploads" / "logo"
|
||||
if logo_dir.is_dir():
|
||||
for f in logo_dir.iterdir():
|
||||
f.unlink(missing_ok=True)
|
||||
|
||||
|
||||
# ==================== 系统设置与上传文件的快照还原 ====================
|
||||
#
|
||||
# 系统设置是**真实单行数据**,logo/favicon 是真实上传文件,没有 t_ 前缀可供
|
||||
# 兜底清理。测试(尤其 logo 上传用例)会改公司名、覆盖/删除真实图片 ——
|
||||
# 必须在用例结束后还原,否则跑一次测试就会破坏开发环境的品牌与 logo。
|
||||
|
||||
_ASSET_KINDS = ("logo", "favicon")
|
||||
|
||||
|
||||
def _snapshot_company_info() -> dict | None:
|
||||
"""快照系统设置单行(无行时返回 None)。"""
|
||||
factory = get_session_factory()
|
||||
with factory() as session:
|
||||
row = session.get(SystemSetting, 1)
|
||||
if row is None:
|
||||
return None
|
||||
return {column.name: getattr(row, column.name) for column in SystemSetting.__table__.columns}
|
||||
|
||||
|
||||
def _restore_company_info(snapshot: dict | None) -> None:
|
||||
if snapshot is None:
|
||||
return
|
||||
factory = get_session_factory()
|
||||
with factory() as session:
|
||||
row = session.get(SystemSetting, 1)
|
||||
if row is None:
|
||||
return
|
||||
for key, value in snapshot.items():
|
||||
if key != "update_time": # 更新时间由数据库 ON UPDATE 维护
|
||||
setattr(row, key, value)
|
||||
session.commit()
|
||||
|
||||
|
||||
def _snapshot_assets() -> dict[str, dict[str, bytes]]:
|
||||
"""快照上传目录里的真实图片(logo/favicon)。"""
|
||||
from app.core.config import get_settings
|
||||
|
||||
root = get_settings().upload_dir
|
||||
snapshot: dict[str, dict[str, bytes]] = {}
|
||||
for kind in _ASSET_KINDS:
|
||||
directory = root / kind
|
||||
snapshot[kind] = (
|
||||
{path.name: path.read_bytes() for path in directory.glob("*") if path.is_file()}
|
||||
if directory.is_dir()
|
||||
else {}
|
||||
)
|
||||
return snapshot
|
||||
|
||||
|
||||
def _restore_assets(snapshot: dict[str, dict[str, bytes]]) -> None:
|
||||
"""还原上传目录:删除测试新增文件、回写被测试覆盖的原文件。"""
|
||||
from app.core.config import get_settings
|
||||
|
||||
root = get_settings().upload_dir
|
||||
for kind, files in snapshot.items():
|
||||
directory = root / kind
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
for path in directory.glob("*"):
|
||||
if path.is_file() and path.name not in files:
|
||||
path.unlink(missing_ok=True)
|
||||
for name, content in files.items():
|
||||
(directory / name).write_bytes(content)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _purge_after_each_test():
|
||||
"""每个集成用例结束后兜底清理,避免残留影响后续用例的计数断言。"""
|
||||
def _preserve_company_info():
|
||||
"""每个用例前快照系统设置与上传图片,结束后还原(见上方说明)。"""
|
||||
info = _snapshot_company_info()
|
||||
assets = _snapshot_assets()
|
||||
yield
|
||||
# 先还原文件:数据库路径随后回指同一批文件
|
||||
_restore_assets(assets)
|
||||
_restore_company_info(info)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _purge_after_each_test(_preserve_company_info):
|
||||
"""每个集成用例结束后兜底清理,避免残留影响后续用例的计数断言。
|
||||
|
||||
依赖 ``_preserve_company_info``:保证快照在该夹具之前建立、在该夹具
|
||||
清理**之后**还原(夹具 teardown 为逆序)。
|
||||
"""
|
||||
yield
|
||||
_purge_test_data()
|
||||
|
||||
|
||||
@ -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, "摘要应体现新旧归属走向"
|
||||
|
||||
|
||||
# ==================== 客户评级列(V11) ====================
|
||||
|
||||
_RATING_KEYS = (
|
||||
"score_ship_amount",
|
||||
"score_receipt_rate",
|
||||
"score_profit_rate",
|
||||
"score_contract_cooperation",
|
||||
"score_site_construction",
|
||||
)
|
||||
|
||||
|
||||
def _rate(client: TestClient, headers: dict, customer_id: int, scores: list[str]) -> dict:
|
||||
"""保存客户评级(五项 0-20),返回响应 data。"""
|
||||
resp = client.put(
|
||||
f"{_CUSTOMERS}/{customer_id}/rating",
|
||||
json=dict(zip(_RATING_KEYS, scores, strict=True)),
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
return resp.json()["data"]
|
||||
|
||||
|
||||
def _list_by_keyword(client: TestClient, headers: dict, keyword: str, **params) -> list[dict]:
|
||||
query: dict = {"keyword": keyword, "pageNum": 1, "pageSize": 50, **params}
|
||||
resp = client.get(_CUSTOMERS, params=query, headers=headers)
|
||||
assert resp.status_code == 200, resp.text
|
||||
return resp.json()["data"]["list"]
|
||||
|
||||
|
||||
class TestRatingColumn:
|
||||
"""客户评级列:列表带出评级快照,列头排序走服务端(未评级恒在末尾)。
|
||||
|
||||
列表是服务端分页,排序必须在 SQL 层完成 —— 前端只传
|
||||
``order_field=rating`` + ``order_type``;MySQL 无 NULLS LAST,
|
||||
未评级行用布尔键模拟,正序/倒序都在末尾。
|
||||
"""
|
||||
|
||||
def _seed_three(self, client: TestClient, headers: dict) -> tuple[str, dict, dict, dict]:
|
||||
"""建三名同批次客户:90 分(A)、0 分(D)、未评级。"""
|
||||
token = uuid.uuid4().hex[:8]
|
||||
keyword = f"t_公司_{token}"
|
||||
high = _create_customer(client, headers, company_name=f"{keyword}_甲")
|
||||
low = _create_customer(client, headers, company_name=f"{keyword}_乙")
|
||||
none = _create_customer(client, headers, company_name=f"{keyword}_丙")
|
||||
_rate(client, headers, high["id"], ["18"] * 5)
|
||||
_rate(client, headers, low["id"], ["0"] * 5)
|
||||
return keyword, high, low, none
|
||||
|
||||
def test_列表带出评级字段(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
keyword, high, low, none = self._seed_three(client, headers)
|
||||
rows = _list_by_keyword(client, headers, keyword)
|
||||
by_id = {row["id"]: row for row in rows}
|
||||
|
||||
assert Decimal(by_id[high["id"]]["rating_total"]) == Decimal("90")
|
||||
assert by_id[high["id"]]["rating_grade_name"] == "A"
|
||||
assert Decimal(by_id[low["id"]]["rating_total"]) == Decimal("0")
|
||||
assert by_id[low["id"]]["rating_grade_name"] == "D"
|
||||
assert by_id[none["id"]]["rating_total"] is None
|
||||
assert by_id[none["id"]]["rating_grade_name"] is None
|
||||
|
||||
def test_评级列正序倒序_未评级恒末尾(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
keyword, high, low, none = self._seed_three(client, headers)
|
||||
|
||||
desc = _list_by_keyword(client, headers, keyword, order_field="rating", order_type="desc")
|
||||
assert [row["id"] for row in desc] == [high["id"], low["id"], none["id"]]
|
||||
|
||||
asc = _list_by_keyword(client, headers, keyword, order_field="rating", order_type="asc")
|
||||
assert [row["id"] for row in asc] == [low["id"], high["id"], none["id"]]
|
||||
|
||||
def test_非法排序字段_400(self, client: TestClient, normal: dict) -> None:
|
||||
"""排序字段是白名单(Literal):不透传给 SQL,非法值 400。"""
|
||||
resp = client.get(
|
||||
_CUSTOMERS,
|
||||
params={"order_field": "company_name", "order_type": "asc"},
|
||||
headers=normal["headers"],
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
|
||||
def test_非法排序方向_400(self, client: TestClient, normal: dict) -> None:
|
||||
resp = client.get(
|
||||
_CUSTOMERS,
|
||||
params={"order_field": "rating", "order_type": "sideways"},
|
||||
headers=normal["headers"],
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
|
||||
def test_删除客户联动删评级行(self, client: TestClient, normal: dict) -> None:
|
||||
"""评级行 FK RESTRICT:删除客户须先清评级行,否则 500。"""
|
||||
headers = normal["headers"]
|
||||
customer = _create_customer(client, headers)
|
||||
_rate(client, headers, customer["id"], ["20"] * 5)
|
||||
|
||||
resp = client.delete(f"{_CUSTOMERS}/{customer['id']}", headers=headers)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert (
|
||||
client.get(f"{_CUSTOMERS}/{customer['id']}/rating", headers=headers).status_code == 404
|
||||
)
|
||||
|
||||
@ -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:
|
||||
"""客户评级落库(V11):每客户一行、服务端重算总分与等级、等级快照固化。
|
||||
|
||||
五项各 0-20 → 总分 0-100;等级按保存时点判定并写入 grade_name(无外键)。
|
||||
未评级返回 data=null(正常态);分数越界 400 且审计留痕。
|
||||
"""
|
||||
|
||||
def test_未评级返回null(self, client: TestClient, normal: dict) -> None:
|
||||
customer = _create_customer(client, normal["headers"])
|
||||
resp = client.get(_rating_url(customer["id"]), headers=normal["headers"])
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["data"] is None
|
||||
|
||||
def test_保存评级_服务端重算总分等级并留审计(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
customer = _create_customer(client, headers)
|
||||
put = client.put(
|
||||
_rating_url(customer["id"]),
|
||||
json=_rating_payload(["18", "20", "19", "17", "16"], remark="t_评级依据"),
|
||||
headers=headers,
|
||||
)
|
||||
assert put.status_code == 200, put.text
|
||||
data = put.json()["data"]
|
||||
assert Decimal(data["total_score"]) == Decimal("90")
|
||||
assert data["grade_name"] == "A"
|
||||
assert data["remark"] == "t_评级依据"
|
||||
assert data["scorer_name"] == normal["username"]
|
||||
|
||||
# 回读一致(列表评级列与弹窗回显同源)
|
||||
got = client.get(_rating_url(customer["id"]), headers=headers).json()["data"]
|
||||
assert Decimal(got["total_score"]) == Decimal("90")
|
||||
assert got["grade_name"] == "A"
|
||||
assert Decimal(got["score_ship_amount"]) == Decimal("18")
|
||||
|
||||
logs = [log for log in _logs_of(normal["username"]) if log.module == MODULE_RATING]
|
||||
assert logs, "保存客户评级未留操作日志"
|
||||
assert logs[0].success == 1
|
||||
assert customer["company_name"] in logs[0].summary
|
||||
assert "评级" in logs[0].summary
|
||||
|
||||
def test_重复保存覆盖同一客户仅一行(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
customer = _create_customer(client, headers)
|
||||
first = client.put(
|
||||
_rating_url(customer["id"]), json=_rating_payload(["0"] * 5), headers=headers
|
||||
)
|
||||
assert first.status_code == 200, first.text
|
||||
assert first.json()["data"]["grade_name"] == "D"
|
||||
|
||||
second = client.put(
|
||||
_rating_url(customer["id"]), json=_rating_payload(["20"] * 5), headers=headers
|
||||
)
|
||||
assert second.status_code == 200, second.text
|
||||
assert Decimal(second.json()["data"]["total_score"]) == Decimal("100")
|
||||
assert second.json()["data"]["grade_name"] == "A"
|
||||
|
||||
factory = get_session_factory()
|
||||
with factory() as session:
|
||||
count = session.execute(
|
||||
select(func.count())
|
||||
.select_from(CustomerRating)
|
||||
.where(CustomerRating.customer_id == customer["id"])
|
||||
).scalar_one()
|
||||
assert count == 1, "同一客户出现多行评级(upsert 失效)"
|
||||
|
||||
def test_单项分数越界_400(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
customer = _create_customer(client, headers)
|
||||
for bad in (["21", "0", "0", "0", "0"], ["0", "-1", "0", "0", "0"]):
|
||||
resp = client.put(
|
||||
_rating_url(customer["id"]), json=_rating_payload(bad), headers=headers
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "0~20" in resp.json()["message"], resp.text
|
||||
|
||||
def test_客户不存在_404(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
assert client.get(_rating_url(999_999_999), headers=headers).status_code == 404
|
||||
put = client.put(_rating_url(999_999_999), json=_rating_payload(["0"] * 5), headers=headers)
|
||||
assert put.status_code == 404, put.text
|
||||
|
||||
def test_等级表清空后保存_等级为null(self, client: TestClient, normal: dict) -> None:
|
||||
"""等级映射允许清空(快照设计前提):清空后评分照常保存,仅等级为 NULL。"""
|
||||
headers = normal["headers"]
|
||||
for name in ("A", "B", "C", "D"):
|
||||
assert (
|
||||
client.delete(
|
||||
f"{_GRADES}/{_seed_id(client, headers, name)}", headers=headers
|
||||
).status_code
|
||||
== 200
|
||||
)
|
||||
|
||||
customer = _create_customer(client, headers)
|
||||
data = client.put(
|
||||
_rating_url(customer["id"]), json=_rating_payload(["20"] * 5), headers=headers
|
||||
).json()["data"]
|
||||
assert Decimal(data["total_score"]) == Decimal("100")
|
||||
assert data["grade_name"] is None
|
||||
|
||||
@ -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,10 @@ def client() -> TestClient:
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
# 系统设置单行与上传文件的快照/还原已上移到 integration/conftest.py,
|
||||
# 对所有集成用例生效(不再只保护本模块)。
|
||||
|
||||
|
||||
class TestCompanyInfo:
|
||||
"""公司名称读取与更新。"""
|
||||
|
||||
@ -84,10 +89,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:
|
||||
"""V11 精简:副标题/信用代码/地址/电话/邮箱已从模型与契约中删除。"""
|
||||
data = client.get(f"{_SETTINGS}/public").json()["data"]
|
||||
for field in (
|
||||
"system_subtitle",
|
||||
"credit_code",
|
||||
"company_address",
|
||||
"contact_phone",
|
||||
"contact_email",
|
||||
):
|
||||
assert field not in data, f"已删除字段仍出现在匿名端点:{field}"
|
||||
|
||||
def test_写操作匿名不可用(self, client: TestClient) -> None:
|
||||
assert client.put(f"{_SETTINGS}/public", json={"company_name": "x"}).status_code in {
|
||||
401,
|
||||
@ -95,6 +120,41 @@ class TestPublicEndpoint:
|
||||
}
|
||||
|
||||
|
||||
class TestCompanyProfile:
|
||||
"""站点标题/品牌信息(V11):部分更新语义与空串清空。"""
|
||||
|
||||
def test_部分更新_未提交字段保持不变(self, client: TestClient, admin: dict) -> None:
|
||||
name = f"站点公司_{uuid.uuid4().hex[:6]}"
|
||||
client.put(_SETTINGS, json={"company_name": name}, headers=admin["headers"])
|
||||
|
||||
put = client.put(_SETTINGS, json={"site_title": "测试站点标题"}, headers=admin["headers"])
|
||||
assert put.status_code == 200, put.text
|
||||
assert put.json()["data"]["site_title"] == "测试站点标题"
|
||||
# 未提交的 company_name 保持不变(部分更新语义)
|
||||
assert put.json()["data"]["company_name"] == name
|
||||
|
||||
got = client.get(_SETTINGS, headers=admin["headers"]).json()["data"]
|
||||
assert got["company_name"] == name
|
||||
assert got["site_title"] == "测试站点标题"
|
||||
|
||||
def test_可选文本传空串_清空为null(self, client: TestClient, admin: dict) -> None:
|
||||
client.put(
|
||||
_SETTINGS,
|
||||
json={"brand_slogan": "t_临时标语", "icp_no": "t_备案号"},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
put = client.put(
|
||||
_SETTINGS, json={"brand_slogan": "", "icp_no": ""}, headers=admin["headers"]
|
||||
)
|
||||
assert put.status_code == 200, put.text
|
||||
assert put.json()["data"]["brand_slogan"] is None
|
||||
assert put.json()["data"]["icp_no"] is None
|
||||
|
||||
def test_超长字段被拒(self, client: TestClient, admin: dict) -> None:
|
||||
resp = client.put(_SETTINGS, json={"site_title": "x" * 65}, headers=admin["headers"])
|
||||
assert resp.status_code == 400, resp.text
|
||||
|
||||
|
||||
class TestLogoUpload:
|
||||
"""logo 上传校验链。"""
|
||||
|
||||
@ -135,3 +195,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
|
||||
|
||||
@ -40,6 +40,7 @@ EXPECTED_TABLES = {
|
||||
"shipment_items",
|
||||
"receipts",
|
||||
"customer_rating_grades",
|
||||
"customer_ratings",
|
||||
"performance_ledgers",
|
||||
"monthly_settlements",
|
||||
"settlement_items",
|
||||
|
||||
@ -23,6 +23,7 @@ DROP TABLE IF EXISTS `monthly_settlements`;
|
||||
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`;
|
||||
@ -695,17 +696,50 @@ INSERT INTO customer_rating_grades (grade_name, min_score, max_score, sort_order
|
||||
('C', 60, 74, 3, '合格'),
|
||||
('D', 0, 59, 4, '待改进');
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 客户评级落库(V11 迁移;每客户一行,等级名为快照固化无外键)
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE customer_ratings (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
|
||||
customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID(唯一:一客户一份当前评级)',
|
||||
score_ship_amount NUMERIC(5, 1) NOT NULL COMMENT '发货额评分(0-20)' DEFAULT '0',
|
||||
score_receipt_rate NUMERIC(5, 1) NOT NULL COMMENT '回款率评分(0-20)' DEFAULT '0',
|
||||
score_profit_rate NUMERIC(5, 1) NOT NULL COMMENT '毛利率评分(0-20)' DEFAULT '0',
|
||||
score_contract_cooperation NUMERIC(5, 1) NOT NULL COMMENT '合同履约配合能力评分(0-20)' DEFAULT '0',
|
||||
score_site_construction NUMERIC(5, 1) NOT NULL COMMENT '现场规范施工能力评分(0-20)' DEFAULT '0',
|
||||
total_score NUMERIC(5, 1) NOT NULL COMMENT '总分(服务端重算,五项之和 0-100)',
|
||||
grade_name VARCHAR(32) COMMENT '评级等级(保存时按等级映射快照固化,无外键;未命中/等级表空为 NULL)',
|
||||
remark VARCHAR(255) COMMENT '备注',
|
||||
scorer_id BIGINT UNSIGNED COMMENT '评级操作人ID(账号删除后置空)',
|
||||
scorer_name VARCHAR(64) NOT NULL COMMENT '评级操作人用户名(冗余固化)',
|
||||
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
|
||||
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT pk_customer_ratings PRIMARY KEY (id),
|
||||
CONSTRAINT fk_customer_ratings_customer_id FOREIGN KEY(customer_id) REFERENCES customers (id) ON DELETE RESTRICT,
|
||||
CONSTRAINT fk_customer_ratings_scorer_id FOREIGN KEY(scorer_id) REFERENCES users (id) ON DELETE SET NULL,
|
||||
CONSTRAINT uk_customer_ratings_customer_id UNIQUE (customer_id)
|
||||
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='客户评级表(每客户一行;等级名为快照固化无外键,删等级不伤已评级记录)' COLLATE utf8mb4_general_ci;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 系统设置/二次分配/审批(V6 迁移;公司信息单行;分摊计算待澄清另批)
|
||||
-- V11 迁移:增加品牌标语/页脚/备案号/站点标题/站点图标与图片版本号
|
||||
-- (公司名称/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,
|
||||
@ -882,4 +916,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 ('90653814a421');
|
||||
INSERT INTO alembic_version (version_num) VALUES ('c4e2a86f9b13');
|
||||
|
||||
38
db/migration/V11__add_customer_ratings_and_site_info.sql
Normal file
38
db/migration/V11__add_customer_ratings_and_site_info.sql
Normal file
@ -0,0 +1,38 @@
|
||||
-- Running upgrade 90653814a421 -> c4e2a86f9b13
|
||||
|
||||
CREATE TABLE customer_ratings (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
|
||||
customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID(唯一:一客户一份当前评级)',
|
||||
score_ship_amount NUMERIC(5, 1) NOT NULL COMMENT '发货额评分(0-20)' DEFAULT '0',
|
||||
score_receipt_rate NUMERIC(5, 1) NOT NULL COMMENT '回款率评分(0-20)' DEFAULT '0',
|
||||
score_profit_rate NUMERIC(5, 1) NOT NULL COMMENT '毛利率评分(0-20)' DEFAULT '0',
|
||||
score_contract_cooperation NUMERIC(5, 1) NOT NULL COMMENT '合同履约配合能力评分(0-20)' DEFAULT '0',
|
||||
score_site_construction NUMERIC(5, 1) NOT NULL COMMENT '现场规范施工能力评分(0-20)' DEFAULT '0',
|
||||
total_score NUMERIC(5, 1) NOT NULL COMMENT '总分(服务端重算,五项之和 0-100)',
|
||||
grade_name VARCHAR(32) COMMENT '评级等级(保存时按等级映射快照固化,无外键;未命中/等级表空为 NULL)',
|
||||
remark VARCHAR(255) COMMENT '备注',
|
||||
scorer_id BIGINT UNSIGNED COMMENT '评级操作人ID(账号删除后置空)',
|
||||
scorer_name VARCHAR(64) NOT NULL COMMENT '评级操作人用户名(冗余固化)',
|
||||
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
|
||||
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT pk_customer_ratings PRIMARY KEY (id),
|
||||
CONSTRAINT fk_customer_ratings_customer_id FOREIGN KEY(customer_id) REFERENCES customers (id) ON DELETE RESTRICT,
|
||||
CONSTRAINT fk_customer_ratings_scorer_id FOREIGN KEY(scorer_id) REFERENCES users (id) ON DELETE SET NULL,
|
||||
CONSTRAINT uk_customer_ratings_customer_id UNIQUE (customer_id)
|
||||
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='客户评级表(每客户一行;等级名为快照固化无外键,删等级不伤已评级记录)' COLLATE utf8mb4_general_ci;
|
||||
|
||||
ALTER TABLE system_settings ADD COLUMN site_title VARCHAR(64) COMMENT '站点标题(浏览器标签页与侧边栏副标题;null=内置默认)';
|
||||
|
||||
ALTER TABLE system_settings ADD COLUMN brand_slogan VARCHAR(64) COMMENT '品牌标语(登录页与品牌预览;null=内置默认)';
|
||||
|
||||
ALTER TABLE system_settings ADD COLUMN footer_text VARCHAR(64) COMMENT '页脚版权文本(登录页;null=自动生成 © 公司名 · 内部系统)';
|
||||
|
||||
ALTER TABLE system_settings ADD COLUMN icp_no VARCHAR(32) COMMENT 'ICP 备案号(登录页页脚,空=不显示)';
|
||||
|
||||
ALTER TABLE system_settings ADD COLUMN favicon_path VARCHAR(255) COMMENT '站点图标相对路径(文件存 uploads/favicon/,null=使用内置 favicon.svg)';
|
||||
|
||||
ALTER TABLE system_settings ADD COLUMN logo_version INT NOT NULL COMMENT 'logo 版本号(每次上传 +1;图片 URL 用它破缓存——同扩展名覆盖时路径不变,update_time 也不变)' DEFAULT '0';
|
||||
|
||||
ALTER TABLE system_settings ADD COLUMN favicon_version INT NOT NULL COMMENT '站点图标版本号(每次上传 +1;图片 URL 用它破缓存)' DEFAULT '0';
|
||||
|
||||
UPDATE alembic_version SET version_num='c4e2a86f9b13' WHERE alembic_version.version_num = '90653814a421';
|
||||
@ -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>
|
||||
|
||||
18
frontend/public/favicon.svg
Normal file
18
frontend/public/favicon.svg
Normal 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 |
@ -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>
|
||||
|
||||
@ -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 为初始归属,可空:先建档后派归属也合法) */
|
||||
|
||||
@ -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 } : {} })
|
||||
|
||||
@ -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 })
|
||||
}
|
||||
|
||||
@ -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 })
|
||||
}
|
||||
|
||||
/** 登录页消费:无令牌也可调用(后端白名单端点)。
|
||||
|
||||
@ -1,16 +1,23 @@
|
||||
<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 { firstAccessiblePath } from '@/router/access'
|
||||
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)
|
||||
|
||||
@ -44,8 +51,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" />
|
||||
@ -99,8 +106,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 />
|
||||
|
||||
@ -143,14 +143,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 落入兜底重定向
|
||||
{
|
||||
// 绩效工资组(M3 ④):核算台账读登录即可,月结写限管理员与营销后勤
|
||||
// (api 层 require_roles 兜底,页面层不再叠加 requiresAdmin)
|
||||
@ -282,9 +276,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
|
||||
|
||||
125
frontend/src/store/settings.ts
Normal file
125
frontend/src/store/settings.ts
Normal 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,
|
||||
}
|
||||
})
|
||||
21
frontend/src/utils/favicon.ts
Normal file
21
frontend/src/utils/favicon.ts
Normal 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
|
||||
}
|
||||
@ -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,116 +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>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div class="calc-result">
|
||||
<div>
|
||||
<span class="result-label">总分</span>
|
||||
<b class="total-num">{{ totalScore }}</b>
|
||||
<span class="total-max">/ 100</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="result-label">评级等级</span>
|
||||
<template v-if="grades.length === 0">
|
||||
<div class="grade-name">—</div>
|
||||
<div class="grade-hint">未配置等级映射</div>
|
||||
</template>
|
||||
<template v-else-if="matchedGrade">
|
||||
<div class="grade-name">{{ matchedGrade.grade_name }}</div>
|
||||
<div class="grade-hint">
|
||||
分值区间 {{ matchedGrade.min_score }} ~ {{ matchedGrade.max_score }}
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="grade-name">—</div>
|
||||
<div class="grade-hint">总分低于全部等级下限,未命中等级</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 等级映射设置抽屉 -->
|
||||
<el-drawer v-model="drawerVisible" title="等级映射设置" size="640px">
|
||||
<div class="drawer-hint">
|
||||
判定规则:按排序升序取首个「下限 ≤ 总分」的等级,上限仅作展示;修改后评级区立即按新配置判定。
|
||||
</div>
|
||||
<div class="drawer-toolbar">
|
||||
<el-button type="primary" size="small" @click="openCreate">新增等级</el-button>
|
||||
</div>
|
||||
<el-table v-loading="gradesLoading" :data="grades" size="small">
|
||||
<el-table-column prop="grade_name" label="等级名称" min-width="110" />
|
||||
<el-table-column prop="min_score" label="下限(含)" width="100" align="center" />
|
||||
<el-table-column prop="max_score" label="上限" width="90" align="center" />
|
||||
<el-table-column prop="sort_order" label="排序" width="70" align="center" />
|
||||
<el-table-column label="操作" width="110" align="center">
|
||||
<template #default="{ row }">
|
||||
<div class="table-actions">
|
||||
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
|
||||
<span class="table-actions__divider" aria-hidden="true"></span>
|
||||
<el-button link type="danger" size="small" @click="removeGrade(row)">删除</el-button>
|
||||
<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>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-drawer>
|
||||
<p v-if="existingInfo" class="existing-hint">{{ existingInfo }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 新增 / 编辑等级 -->
|
||||
<div class="calc-result">
|
||||
<div>
|
||||
<span class="result-label">总分</span>
|
||||
<b class="total-num">{{ totalScore }}</b>
|
||||
<span class="total-max">/ 100</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="result-label">评级等级</span>
|
||||
<template v-if="grades.length === 0">
|
||||
<div class="grade-name">—</div>
|
||||
<div class="grade-hint">未配置等级映射,保存后等级为空</div>
|
||||
</template>
|
||||
<template v-else-if="matchedGrade">
|
||||
<div class="grade-name">{{ matchedGrade.grade_name }}</div>
|
||||
<div class="grade-hint">
|
||||
分值区间 {{ matchedGrade.min_score }} ~ {{ matchedGrade.max_score }}
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="grade-name">—</div>
|
||||
<div class="grade-hint">总分低于全部等级下限,未命中等级</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-form label-width="70px" class="remark-form" @submit.prevent>
|
||||
<el-form-item label="备注">
|
||||
<el-input
|
||||
v-model="remark"
|
||||
maxlength="255"
|
||||
placeholder="选填,如评级依据说明"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div class="score-tip">
|
||||
五项各 0-20 分,总分 0-100;保存后等级按当前映射快照固化,
|
||||
之后调整等级映射不影响该客户已保存的等级。
|
||||
</div>
|
||||
|
||||
<div class="dialog-actions">
|
||||
<el-button @click="resetScores">重置</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="handleSave">
|
||||
{{ hasExisting ? '更新评级' : '保存评级' }}
|
||||
</el-button>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="等级映射设置" name="grades">
|
||||
<div class="drawer-hint">
|
||||
判定规则:按排序升序取首个「下限 ≤ 总分」的等级,上限仅作展示;修改后评级区立即按新配置判定。
|
||||
</div>
|
||||
<div class="grades-toolbar">
|
||||
<el-button type="primary" size="small" @click="openCreate">新增等级</el-button>
|
||||
</div>
|
||||
<el-table v-loading="gradesLoading" :data="grades" size="small">
|
||||
<el-table-column prop="grade_name" label="等级名称" min-width="110" />
|
||||
<el-table-column prop="min_score" label="下限(含)" width="100" align="center" />
|
||||
<el-table-column prop="max_score" label="上限" width="90" align="center" />
|
||||
<el-table-column prop="sort_order" label="排序" width="70" align="center" />
|
||||
<el-table-column label="操作" width="110" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button link type="danger" size="small" @click="removeGrade(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<!-- 新增 / 编辑等级(二层弹窗;append-to-body 避免嵌套弹层层级与裁剪问题) -->
|
||||
<el-dialog
|
||||
v-model="editVisible"
|
||||
:title="isCreate ? '新增评级等级' : '编辑评级等级'"
|
||||
width="440px"
|
||||
append-to-body
|
||||
destroy-on-close
|
||||
@closed="editingId = null"
|
||||
>
|
||||
@ -342,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;
|
||||
@ -395,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 {
|
||||
@ -416,7 +503,7 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.total-num {
|
||||
font-size: 40px;
|
||||
font-size: 36px;
|
||||
color: var(--ys-indigo);
|
||||
font-variant-numeric: tabular-nums;
|
||||
margin-right: 4px;
|
||||
@ -429,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;
|
||||
@ -442,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);
|
||||
@ -451,7 +557,7 @@ onMounted(() => {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.drawer-toolbar {
|
||||
.grades-toolbar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: 10px;
|
||||
@ -471,7 +577,6 @@ onMounted(() => {
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
/* 移动端:标签左对齐、结果块自然换行到表单下方 */
|
||||
@media (max-width: 768px) {
|
||||
.score-label {
|
||||
width: auto;
|
||||
@ -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,18 +501,23 @@ 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="260" fixed="right" align="center">
|
||||
<el-table-column label="操作" min-width="140" align="center">
|
||||
<template #default="{ row }">
|
||||
<div class="table-actions">
|
||||
<!-- 五项按钮单行均分:两端对齐让相邻按钮间距处处相等;
|
||||
「配置客户评级」最长(6 字),在等宽列内无法再做 5 等分不换行,
|
||||
故用等间距平分整列宽度 -->
|
||||
<div class="ops-actions">
|
||||
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button link type="primary" size="small" @click="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>
|
||||
<span class="table-actions__divider" aria-hidden="true"></span>
|
||||
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
|
||||
</div>
|
||||
</template>
|
||||
@ -530,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">
|
||||
@ -564,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>
|
||||
|
||||
@ -5,11 +5,13 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { getCaptcha } from '@/api/auth'
|
||||
import { canAccessPath, firstAccessiblePath } from '@/router/access'
|
||||
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,32 +26,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 {
|
||||
// 登录页必须拿到最新公司名(保存后立即生效):public 端点带 300s
|
||||
// 响应缓存,axios 可能命中浏览器缓存副本。这里用带时间戳的原生
|
||||
// fetch 绕过磁盘缓存,手动解包统一信封(与 request 拦截器同口径)。
|
||||
const resp = await fetch(
|
||||
`/api/v1/system/settings/public?_=${Date.now()}`,
|
||||
{ cache: 'no-store' },
|
||||
)
|
||||
if (!resp.ok) throw new Error(String(resp.status))
|
||||
const body = (await resp.json()) as {
|
||||
code: number
|
||||
data: { company_name?: string; logo_url?: string | null }
|
||||
}
|
||||
const data = body.data
|
||||
if (data.company_name) brandName.value = data.company_name
|
||||
brandLogo.value = data.logo_url ?? null
|
||||
} catch {
|
||||
// 登录页必须可用:公司信息接口失败时静默回退默认品牌名,不阻断登录
|
||||
}
|
||||
}
|
||||
|
||||
const rules: FormRules = {
|
||||
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
|
||||
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
|
||||
@ -108,7 +84,8 @@ async function handleSubmit(): Promise<void> {
|
||||
|
||||
onMounted(() => {
|
||||
refreshCaptcha()
|
||||
loadBrand()
|
||||
// 品牌信息失败静默回退内置默认值(不阻断登录;内部去重/缓存)
|
||||
void settingsStore.ensureLoaded()
|
||||
})
|
||||
</script>
|
||||
|
||||
@ -122,9 +99,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>
|
||||
|
||||
@ -189,7 +171,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>
|
||||
|
||||
@ -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,
|
||||
@ -43,9 +44,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)
|
||||
@ -83,20 +85,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})`
|
||||
}
|
||||
|
||||
/** 带单位的值展示 */
|
||||
@ -467,7 +473,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)"
|
||||
@ -475,7 +488,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
|
||||
v-if="isAdmin"
|
||||
@ -527,10 +540,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 }">
|
||||
@ -578,7 +593,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="新值">
|
||||
@ -587,7 +604,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>
|
||||
@ -632,11 +654,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">
|
||||
@ -658,7 +684,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(() => {})">
|
||||
@ -707,7 +736,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" />
|
||||
@ -799,7 +831,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>
|
||||
@ -875,6 +907,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);
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user