Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
823d9f02ec | ||
|
|
678db0499d | ||
|
|
a00fca942d | ||
|
|
61965e0b60 | ||
|
|
111ac5425a | ||
|
|
61fe5cca98 | ||
|
|
32b7940067 | ||
|
|
b9781f7d3f | ||
|
|
8e8bcaa5fb | ||
|
|
1f65913002 | ||
|
|
e61800dcdc | ||
|
|
f88857e026 | ||
|
|
5ece9f6dab | ||
|
|
c48d24ab6d |
No files matched your search
+13
-1
@@ -83,8 +83,9 @@ app/
|
|||||||
| GET / POST | `/api/v1/base/products` | 产品列表(名称/规格模糊、类别/状态筛选)/ 新增(类别值域校验;名称+规格组合唯一) |
|
| GET / POST | `/api/v1/base/products` | 产品列表(名称/规格模糊、类别/状态筛选)/ 新增(类别值域校验;名称+规格组合唯一) |
|
||||||
| GET / PUT / DELETE | `/api/v1/base/products/{id}` | 详情 / 编辑 / 删除(被业务明细引用保护在业务表就位后生效) |
|
| GET / PUT / DELETE | `/api/v1/base/products/{id}` | 详情 / 编辑 / 删除(被业务明细引用保护在业务表就位后生效) |
|
||||||
| PUT | `/api/v1/base/products/{id}/status` | 启用 / 停用 |
|
| PUT | `/api/v1/base/products/{id}/status` | 启用 / 停用 |
|
||||||
| GET / POST | `/api/v1/base/customers` | 客户列表(公司/负责人模糊、分类/归属筛选)/ 新增(分类值域;公司名+负责人唯一) |
|
| GET / POST | `/api/v1/base/customers` | 客户列表(公司/负责人模糊、分类/归属筛选;`order_by=rating_score` 按评分排序,未评分垫底)/ 新增(分类值域;公司名+负责人唯一) |
|
||||||
| GET / PUT / DELETE | `/api/v1/base/customers/{id}` | 详情 / 编辑(**不触归属**)/ 删除 |
|
| GET / PUT / DELETE | `/api/v1/base/customers/{id}` | 详情 / 编辑(**不触归属**)/ 删除 |
|
||||||
|
| PUT | `/api/v1/base/customers/{id}/rating` | 保存评级(评级计算器「保存评分」;只写 `rating_score`/`rating_time`,五项明细不落库 #25) |
|
||||||
| PUT | `/api/v1/base/customers/{id}/ownership` | 归属变更(同事务写留痕;只影响变更后核算,不回溯历史 #19) |
|
| PUT | `/api/v1/base/customers/{id}/ownership` | 归属变更(同事务写留痕;只影响变更后核算,不回溯历史 #19) |
|
||||||
| GET | `/api/v1/base/customers/{id}/ownership-logs` | 归属变更留痕分页(join 人员出姓名) |
|
| GET | `/api/v1/base/customers/{id}/ownership-logs` | 归属变更留痕分页(join 人员出姓名) |
|
||||||
|
|
||||||
@@ -263,6 +264,17 @@ bcrypt 5.0 起对超过 72 字节的密码**直接抛 ValueError**(此前是
|
|||||||
- **汇总 = 明细合计**:团队/公司基数由台账分桶结果直接得出,报表侧不另算一遍;未筛选时 `团队基数 + 未分配绩效 = 全公司基数`;集成测试逐项断言。
|
- **汇总 = 明细合计**:团队/公司基数由台账分桶结果直接得出,报表侧不另算一遍;未筛选时 `团队基数 + 未分配绩效 = 全公司基数`;集成测试逐项断言。
|
||||||
- **岗位筛选选项**:`GET /api/v1/base/business-roles/options` 仅要求登录(含停用岗位),供财务/领导在报表中按岗位筛选;岗位管理接口仍仅管理员。
|
- **岗位筛选选项**:`GET /api/v1/base/business-roles/options` 仅要求登录(含停用岗位),供财务/领导在报表中按岗位筛选;岗位管理接口仍仅管理员。
|
||||||
|
|
||||||
|
### 面向业务人员的文案(2026-10-23 定稿,改文案前先读)
|
||||||
|
|
||||||
|
系统由财务与营销人员日常使用,**不是给开发者看的**。凡是会渲染到页面的文本(参数说明、错误提示、口径注释)一律按业务语言写:
|
||||||
|
|
||||||
|
- **`param_registry.py` 的 `description` 是唯一显示源**,会直出到「计提参数」页参数名下方。写清「这个参数管什么、钱怎么变、什么时候该调它」,不写英文枚举名(`FULL`/`BALANCE`)、需求编号(`澄清 #10`)、内部阶段号(`M3`/`M4 ⑧`)、实现层精度约束(「禁止按 0.011111 截断存储」留在代码注释与测试里)。
|
||||||
|
- **枚举值在页面上必须翻译**:前端 `params/index.vue` 的 `ENUM_VALUE_LABELS`(`FULL` 当月全额 / `BALANCE` 余额 / `INCLUDE` 含基本工资 / `DEDUCT` 扣除基本工资 / `EXCLUDE` 不计基本工资 / `1` 开 / `0` 关)与 `DIMENSION_LABELS`(`municipal_water` 市政水利 / `rail_transit` 轨道交通)。后端报错也不许把原始枚举值甩给用户 —— `param_service._preview_rate` 的产品类别校验用 `「市政水利、轨道交通」` 而不是 `municipal_water / rail_transit`。
|
||||||
|
- **公式模板报错用中文模板名**:`calc_templates.TEMPLATES[code].name`,取不到才回退 code;文案要给出下一步(「请在岗位角色里改用业务员递减或信息员固定计提」),而不是只说「不支持」。
|
||||||
|
- **`db/init/init-dev.sql` 的种子 description 必须与 registry 同源**:它只在数据卷为空时首次启动执行,已有库要跑 `ai-aux/preview/sync_param_desc.py`(读 `PARAM_REGISTRY` 逐条 UPDATE)才看得到新文案。`db/migration/V*.sql` 里的旧描述是历史记录,**不要回头改** —— 改了会让已部署库与脚本不一致。
|
||||||
|
- **前端模板层禁止出现开发者语汇**:`澄清 / 待业务 / M3 / M4 / 一期 / 二阶段 / 暂口径 / 待确认 / 待接 / 暂不 / 待补` 只允许出现在代码注释里。改完用 grep 扫一遍 `frontend/src/views`。
|
||||||
|
- **台账与预览里落库的说明文字(`rate_text` / `rate_note` / 聚合行 `snapshot_json.note`)同样受管**:这些会出现在绩效台账、个人明细、打印稿与计算预览上。写成「这笔为什么是这个比例、什么时候不再降」(如「回款 20 天,从起始 10‰ 按日递减(满 360 天后固定按 4‰ 计算)」),不写 `基数开关=1`、`#12 参数化`、`9.8 方案公式` 这类需求编号。**分数原文(`11/1125`)与百分比拼装(`聚合:20%+5%`)不算说明文字**,那是金额口径的可复核原文,不要为了「口语化」把它改成四舍五入的小数。
|
||||||
|
|
||||||
## 常见问题
|
## 常见问题
|
||||||
|
|
||||||
**起服务报 `APP_SECRET_KEY` 相关错误** —— `backend/.env` 未填或不足 32 字符。这是刻意的 fail-fast:空密钥签发 JWT 是安全问题,必须启动即失败。
|
**起服务报 `APP_SECRET_KEY` 相关错误** —— `backend/.env` 未填或不足 32 字符。这是刻意的 fail-fast:空密钥签发 JWT 是安全问题,必须启动即失败。
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ Create Date: 2026-10-19 17:55:48.892998
|
|||||||
|
|
||||||
「同步连续失败」规则依赖 ⑥ T+ 同步,模块未建前不设参数、不产生条目。
|
「同步连续失败」规则依赖 ⑥ T+ 同步,模块未建前不设参数、不产生条目。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
from datetime import date
|
from datetime import date
|
||||||
|
|
||||||
@@ -19,8 +20,8 @@ import sqlalchemy as sa
|
|||||||
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
# revision identifiers, used by Alembic.
|
||||||
revision: str = '90653814a421'
|
revision: str = "90653814a421"
|
||||||
down_revision: str | Sequence[str] | None = 'ba0217f5091b'
|
down_revision: str | Sequence[str] | None = "ba0217f5091b"
|
||||||
branch_labels: str | Sequence[str] | None = None
|
branch_labels: str | Sequence[str] | None = None
|
||||||
depends_on: str | Sequence[str] | None = None
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""v13 客户评级落库
|
||||||
|
|
||||||
|
Revision ID: c3a9e1d7b2f4
|
||||||
|
Revises: 7f4f7b918bc4
|
||||||
|
Create Date: 2026-10-23 10:30:00.000000
|
||||||
|
|
||||||
|
评级计算器从独立工具页并入客户管理(行内弹窗),评分由「不落库」改为
|
||||||
|
保存为客户档案属性:``rating_score`` 总分 + ``rating_time`` 评级时间。
|
||||||
|
两列均可空(null = 未评分,历史客户与新客户无需回填),人工维护字段,
|
||||||
|
T+ 同步守则不覆盖(见 TPlusSyncMixin 的同步守则说明)。
|
||||||
|
|
||||||
|
五项评分明细与命中等级都不落库:前者是打分过程的中间量,没有下游消费方;
|
||||||
|
后者由分数按当前等级映射实时推导(等级映射是可变判定表,改了配置全部客户
|
||||||
|
的等级随之更新),因此本迁移只落总分与评级时间两列。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "c3a9e1d7b2f4"
|
||||||
|
down_revision: str | Sequence[str] | None = "7f4f7b918bc4"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""customers 加评级两列,紧跟 remark 之后保持与 ORM 声明同序。
|
||||||
|
|
||||||
|
用 execute 手写而非 op.add_column:后者不带 AFTER 子句,两列会落到
|
||||||
|
表尾(T+ 预留列之后),与 ORM/init-dev 的声明顺序不一致。
|
||||||
|
"""
|
||||||
|
op.execute(
|
||||||
|
"ALTER TABLE customers "
|
||||||
|
"ADD COLUMN rating_score TINYINT COMMENT "
|
||||||
|
"'评级总分(0-100,评级计算器保存;null=未评分;人工维护字段,T+ 同步不覆盖)' "
|
||||||
|
"AFTER remark"
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"ALTER TABLE customers "
|
||||||
|
"ADD COLUMN rating_time DATETIME COMMENT "
|
||||||
|
"'最近评级时间(评级计算器保存评分时刷新;人工维护字段)' "
|
||||||
|
"AFTER rating_score"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""删除评级两列。评分数据随列删除,评级无下游引用、无悬空风险。"""
|
||||||
|
op.drop_column("customers", "rating_time")
|
||||||
|
op.drop_column("customers", "rating_score")
|
||||||
@@ -34,10 +34,7 @@ depends_on = None
|
|||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
"""种子行级变更 + 台账唯一键扩展(无表结构新增/删除)。"""
|
"""种子行级变更 + 台账唯一键扩展(无表结构新增/删除)。"""
|
||||||
# 1. 审批开关默认值翻转(#5/#7:默认开启,业务可关闭)
|
# 1. 审批开关默认值翻转(#5/#7:默认开启,业务可关闭)
|
||||||
op.execute(
|
op.execute("UPDATE param_definitions SET default_value = '1' WHERE id IN (17, 18)")
|
||||||
"UPDATE param_definitions SET default_value = '1' "
|
|
||||||
"WHERE id IN (17, 18)"
|
|
||||||
)
|
|
||||||
# 时点化生效行:插新行不改历史(10-19 前仍按 0 解析)
|
# 时点化生效行:插新行不改历史(10-19 前仍按 0 解析)
|
||||||
op.bulk_insert(
|
op.bulk_insert(
|
||||||
sa.table(
|
sa.table(
|
||||||
@@ -188,6 +185,4 @@ def downgrade() -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
op.execute("DELETE FROM param_values WHERE id IN (23, 24)")
|
op.execute("DELETE FROM param_values WHERE id IN (23, 24)")
|
||||||
op.execute(
|
op.execute("UPDATE param_definitions SET default_value = '0' WHERE id IN (17, 18)")
|
||||||
"UPDATE param_definitions SET default_value = '0' WHERE id IN (17, 18)"
|
|
||||||
)
|
|
||||||
@@ -43,9 +43,7 @@ def upgrade() -> None:
|
|||||||
"COMMENT '提交时内容快照(信封:schema_version/order_type/captured_at/data;历史单可空)' "
|
"COMMENT '提交时内容快照(信封:schema_version/order_type/captured_at/data;历史单可空)' "
|
||||||
"AFTER version_no"
|
"AFTER version_no"
|
||||||
)
|
)
|
||||||
op.execute(
|
op.execute("CREATE INDEX idx_approval_orders_type_ref ON approval_orders (order_type, ref_id)")
|
||||||
"CREATE INDEX idx_approval_orders_type_ref ON approval_orders (order_type, ref_id)"
|
|
||||||
)
|
|
||||||
|
|
||||||
op.execute(
|
op.execute(
|
||||||
"ALTER TABLE business_secondary_allocations "
|
"ALTER TABLE business_secondary_allocations "
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ from fastapi import APIRouter, Depends
|
|||||||
from app.api.deps import DbSession, get_current_user
|
from app.api.deps import DbSession, get_current_user
|
||||||
from app.core.response import ApiResponse, ok
|
from app.core.response import ApiResponse, ok
|
||||||
from app.models import User
|
from app.models import User
|
||||||
from app.repositories import business_role_repository
|
from app.schemas.business_role import BusinessRoleOptionOut
|
||||||
|
from app.services import business_role_service
|
||||||
|
|
||||||
router = APIRouter(prefix="/business-roles", tags=["人员管理"])
|
router = APIRouter(prefix="/business-roles", tags=["人员管理"])
|
||||||
|
|
||||||
@@ -20,21 +21,14 @@ router = APIRouter(prefix="/business-roles", tags=["人员管理"])
|
|||||||
CurrentUser = Annotated[User, Depends(get_current_user)]
|
CurrentUser = Annotated[User, Depends(get_current_user)]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/options", response_model=ApiResponse[list[dict]], summary="岗位下拉选项")
|
@router.get(
|
||||||
def list_options(session: DbSession, _: CurrentUser) -> ApiResponse[list[dict]]:
|
"/options",
|
||||||
|
response_model=ApiResponse[list[BusinessRoleOptionOut]],
|
||||||
|
summary="岗位下拉选项",
|
||||||
|
)
|
||||||
|
def list_options(session: DbSession, _: CurrentUser) -> ApiResponse[list[BusinessRoleOptionOut]]:
|
||||||
"""岗位选项(登录即可;含停用——人员档案可能仍引用停用岗位)。
|
"""岗位选项(登录即可;含停用——人员档案可能仍引用停用岗位)。
|
||||||
|
|
||||||
岗位规模有限(内置 + 自定义),一次取足够上限,不做分页。
|
岗位规模有限(内置 + 自定义),一次取全量,不做分页。
|
||||||
"""
|
"""
|
||||||
roles = business_role_repository.list_roles(session, offset=0, limit=1000)
|
return ok(business_role_service.list_options(session))
|
||||||
return ok(
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"id": role.id,
|
|
||||||
"role_code": role.role_code,
|
|
||||||
"role_name": role.role_name,
|
|
||||||
"status": role.status,
|
|
||||||
}
|
|
||||||
for role in roles
|
|
||||||
]
|
|
||||||
)
|
|
||||||
@@ -21,6 +21,7 @@ from app.schemas.customer import (
|
|||||||
CustomerOut,
|
CustomerOut,
|
||||||
CustomerOwnershipIn,
|
CustomerOwnershipIn,
|
||||||
CustomerQuery,
|
CustomerQuery,
|
||||||
|
CustomerRatingIn,
|
||||||
CustomerUpdateIn,
|
CustomerUpdateIn,
|
||||||
OwnershipChangeLogOut,
|
OwnershipChangeLogOut,
|
||||||
)
|
)
|
||||||
@@ -102,6 +103,31 @@ def delete_customer(
|
|||||||
return ok(message="客户已删除")
|
return ok(message="客户已删除")
|
||||||
|
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/{customer_id}/rating",
|
||||||
|
response_model=ApiResponse[CustomerOut],
|
||||||
|
summary="保存客户评级",
|
||||||
|
)
|
||||||
|
def rate_customer(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
customer_id: Annotated[int, Path(ge=1)],
|
||||||
|
payload: CustomerRatingIn,
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[CustomerOut]:
|
||||||
|
"""保存评级总分(评级计算器「保存评分」)。
|
||||||
|
|
||||||
|
评分是客户档案属性:存总分与评级时间,五项明细不落库(对照表 #25);
|
||||||
|
编辑端点不含评分 —— 评分只走本端点,由计算器统一入口写入。
|
||||||
|
"""
|
||||||
|
return ok(
|
||||||
|
customer_service.rate_customer(
|
||||||
|
session, customer_id, payload, operator=current_user, meta=meta
|
||||||
|
),
|
||||||
|
message="评级已保存",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.put(
|
@router.put(
|
||||||
"/{customer_id}/ownership",
|
"/{customer_id}/ownership",
|
||||||
response_model=ApiResponse[CustomerOut],
|
response_model=ApiResponse[CustomerOut],
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""工作台接口(M4 ⑧):四区块一次性聚合。
|
"""工作台接口(M4 ⑧):三区块聚合 + 最近动态分页。
|
||||||
|
|
||||||
准入:管理员/财务/领导(与报表、审批接口一致;FR-033/036 的「全部用户」
|
准入:管理员/财务/领导(与报表、审批接口一致;FR-033/036 的「全部用户」
|
||||||
待业务权限清单 #6 答复后再放宽)。只读接口,不写操作日志。
|
待业务权限清单 #6 答复后再放宽)。只读接口,不写操作日志。
|
||||||
@@ -12,22 +12,18 @@ from app.api.deps import DbSession, require_roles
|
|||||||
from app.core.constants import ROLE_ADMIN, ROLE_FINANCE, ROLE_LEADER
|
from app.core.constants import ROLE_ADMIN, ROLE_FINANCE, ROLE_LEADER
|
||||||
from app.core.response import ApiResponse, ok
|
from app.core.response import ApiResponse, ok
|
||||||
from app.models import User
|
from app.models import User
|
||||||
from app.schemas.dashboard import DashboardOut
|
from app.schemas.dashboard import ActivityPageOut, ActivityQuery, DashboardOut
|
||||||
from app.services import dashboard_service
|
from app.services import dashboard_service
|
||||||
|
|
||||||
router = APIRouter(prefix="/dashboard", tags=["工作台"])
|
router = APIRouter(prefix="/dashboard", tags=["工作台"])
|
||||||
|
|
||||||
_DashboardUser = Annotated[
|
_DashboardUser = Annotated[
|
||||||
User,
|
User,
|
||||||
Depends(
|
Depends(require_roles(ROLE_ADMIN, ROLE_FINANCE, ROLE_LEADER, message="无权访问工作台")),
|
||||||
require_roles(
|
|
||||||
ROLE_ADMIN, ROLE_FINANCE, ROLE_LEADER, message="无权访问工作台"
|
|
||||||
)
|
|
||||||
),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=ApiResponse[DashboardOut], summary="工作台四区块")
|
@router.get("", response_model=ApiResponse[DashboardOut], summary="工作台三区块")
|
||||||
def get_dashboard(
|
def get_dashboard(
|
||||||
session: DbSession,
|
session: DbSession,
|
||||||
current_user: _DashboardUser,
|
current_user: _DashboardUser,
|
||||||
@@ -35,10 +31,22 @@ def get_dashboard(
|
|||||||
str | None, Query(max_length=7, description="统计月份 YYYY-MM,默认当月")
|
str | None, Query(max_length=7, description="统计月份 YYYY-MM,默认当月")
|
||||||
] = None,
|
] = None,
|
||||||
) -> ApiResponse[DashboardOut]:
|
) -> ApiResponse[DashboardOut]:
|
||||||
"""一次返回经营总览/待办/风险预警/最近动态(待办与预警实时派生)。
|
"""一次返回经营总览/待办/风险预警(待办与预警实时派生)。
|
||||||
|
|
||||||
待办按当前用户角色只返回其可操作节点(admin 可查看全部在途单)。
|
待办按当前用户角色只返回其可操作节点(admin 可查看全部在途单)。
|
||||||
|
最近动态是分页列表,走 `GET /dashboard/activities`。
|
||||||
"""
|
"""
|
||||||
return ok(
|
return ok(dashboard_service.get_dashboard(session, month, current_user=current_user))
|
||||||
dashboard_service.get_dashboard(session, month, current_user=current_user)
|
|
||||||
)
|
|
||||||
|
@router.get("/activities", response_model=ApiResponse[ActivityPageOut], summary="最近动态(分页)")
|
||||||
|
def list_dashboard_activities(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: _DashboardUser,
|
||||||
|
query: Annotated[ActivityQuery, Query()],
|
||||||
|
) -> ApiResponse[ActivityPageOut]:
|
||||||
|
"""分页查询最近动态,可按模块筛选。
|
||||||
|
|
||||||
|
与聚合端点拆开:翻页只重取这张列表,不必重算总览/待办/预警。
|
||||||
|
"""
|
||||||
|
return ok(dashboard_service.list_activities(session, query))
|
||||||
@@ -79,9 +79,7 @@ class SecondaryAllocation(Base, TimestampMixin):
|
|||||||
Index("idx_business_secondary_allocations_business_id", "business_id"),
|
Index("idx_business_secondary_allocations_business_id", "business_id"),
|
||||||
Index("idx_business_secondary_allocations_personnel_id", "personnel_id"),
|
Index("idx_business_secondary_allocations_personnel_id", "personnel_id"),
|
||||||
# 取当前生效行的唯一查询形态:WHERE business_id=? AND is_active=1
|
# 取当前生效行的唯一查询形态:WHERE business_id=? AND is_active=1
|
||||||
Index(
|
Index("idx_business_secondary_allocations_business_active", "business_id", "is_active"),
|
||||||
"idx_business_secondary_allocations_business_active", "business_id", "is_active"
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(
|
id: Mapped[int] = mapped_column(
|
||||||
|
|||||||
@@ -63,6 +63,14 @@ class Customer(Base, TimestampMixin, TPlusSyncMixin):
|
|||||||
comment="归属负责营销人员ID(手动修改不回溯历史,变更留痕见 ownership_change_logs)",
|
comment="归属负责营销人员ID(手动修改不回溯历史,变更留痕见 ownership_change_logs)",
|
||||||
)
|
)
|
||||||
remark: Mapped[str | None] = mapped_column(String(255), comment="备注")
|
remark: Mapped[str | None] = mapped_column(String(255), comment="备注")
|
||||||
|
rating_score: Mapped[int | None] = mapped_column(
|
||||||
|
TINYINT_TYPE,
|
||||||
|
comment="评级总分(0-100,评级计算器保存;null=未评分;人工维护字段,T+ 同步不覆盖)",
|
||||||
|
)
|
||||||
|
rating_time: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime,
|
||||||
|
comment="最近评级时间(评级计算器保存评分时刷新;人工维护字段)",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class OwnershipChangeLog(Base):
|
class OwnershipChangeLog(Base):
|
||||||
|
|||||||
@@ -5,8 +5,6 @@
|
|||||||
models/customer.py 定义)。
|
models/customer.py 定义)。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import date
|
|
||||||
|
|
||||||
from sqlalchemy import Row, Select, delete, exists, func, or_, select, update
|
from sqlalchemy import Row, Select, delete, exists, func, or_, select, update
|
||||||
from sqlalchemy.orm import Session, aliased
|
from sqlalchemy.orm import Session, aliased
|
||||||
|
|
||||||
@@ -23,6 +21,8 @@ from app.models import (
|
|||||||
)
|
)
|
||||||
from app.models.customer import OWNER_TYPE_BUSINESS
|
from app.models.customer import OWNER_TYPE_BUSINESS
|
||||||
from app.repositories import ownership_log_repository
|
from app.repositories import ownership_log_repository
|
||||||
|
from app.repositories.ownership_log_repository import OwnershipLogRef
|
||||||
|
from app.schemas.business import BusinessQuery
|
||||||
|
|
||||||
|
|
||||||
def get_by_id(session: Session, business_id: int, *, for_update: bool = False) -> Business | None:
|
def get_by_id(session: Session, business_id: int, *, for_update: bool = False) -> Business | None:
|
||||||
@@ -77,50 +77,32 @@ def get_with_names(
|
|||||||
return session.execute(stmt).first()
|
return session.execute(stmt).first()
|
||||||
|
|
||||||
|
|
||||||
def _apply_filters(
|
def _apply_filters(stmt: Select, query: BusinessQuery) -> Select:
|
||||||
stmt: Select,
|
|
||||||
*,
|
|
||||||
keyword: str | None,
|
|
||||||
status: int | None,
|
|
||||||
customer_id: int | None,
|
|
||||||
owner_personnel_id: int | None,
|
|
||||||
date_from: date | None,
|
|
||||||
date_to: date | None,
|
|
||||||
) -> Select:
|
|
||||||
"""业务列表的筛选条件(调用方须已 join Customer)。"""
|
"""业务列表的筛选条件(调用方须已 join Customer)。"""
|
||||||
if keyword:
|
if query.keyword:
|
||||||
# 业务编号/公司名称双列 OR 模糊;contains(autoescape) 同 query_utils.fuzzy
|
# 业务编号/公司名称双列 OR 模糊;contains(autoescape) 同 query_utils.fuzzy
|
||||||
# 的转义口径(fuzzy 为单列 where 语义,双列 OR 在此直接组合)
|
# 的转义口径(fuzzy 为单列 where 语义,双列 OR 在此直接组合)
|
||||||
stmt = stmt.where(
|
stmt = stmt.where(
|
||||||
or_(
|
or_(
|
||||||
Business.business_no.contains(keyword, autoescape=True),
|
Business.business_no.contains(query.keyword, autoescape=True),
|
||||||
Customer.company_name.contains(keyword, autoescape=True),
|
Customer.company_name.contains(query.keyword, autoescape=True),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if status is not None:
|
if query.status is not None:
|
||||||
stmt = stmt.where(Business.status == status)
|
stmt = stmt.where(Business.status == query.status)
|
||||||
if customer_id is not None:
|
if query.customer_id is not None:
|
||||||
stmt = stmt.where(Business.customer_id == customer_id)
|
stmt = stmt.where(Business.customer_id == query.customer_id)
|
||||||
if owner_personnel_id is not None:
|
if query.owner_personnel_id is not None:
|
||||||
stmt = stmt.where(Business.owner_personnel_id == owner_personnel_id)
|
stmt = stmt.where(Business.owner_personnel_id == query.owner_personnel_id)
|
||||||
if date_from is not None:
|
if query.date_from is not None:
|
||||||
stmt = stmt.where(Business.business_date >= date_from)
|
stmt = stmt.where(Business.business_date >= query.date_from)
|
||||||
if date_to is not None:
|
if query.date_to is not None:
|
||||||
stmt = stmt.where(Business.business_date <= date_to)
|
stmt = stmt.where(Business.business_date <= query.date_to)
|
||||||
return stmt
|
return stmt
|
||||||
|
|
||||||
|
|
||||||
def list_businesses(
|
def list_businesses(
|
||||||
session: Session,
|
session: Session, query: BusinessQuery
|
||||||
*,
|
|
||||||
offset: int,
|
|
||||||
limit: int,
|
|
||||||
keyword: str | None,
|
|
||||||
status: int | None,
|
|
||||||
customer_id: int | None,
|
|
||||||
owner_personnel_id: int | None,
|
|
||||||
date_from: date | None,
|
|
||||||
date_to: date | None,
|
|
||||||
) -> list[tuple[Business, str, str | None, str | None]]:
|
) -> list[tuple[Business, str, str | None, str | None]]:
|
||||||
"""分页查询业务(附客户名/负责人名/协助人名),按 id 倒序。"""
|
"""分页查询业务(附客户名/负责人名/协助人名),按 id 倒序。"""
|
||||||
owner = aliased(Personnel)
|
owner = aliased(Personnel)
|
||||||
@@ -128,49 +110,24 @@ def list_businesses(
|
|||||||
stmt = select(
|
stmt = select(
|
||||||
Business, Customer.company_name, owner.personnel_name, assistant.personnel_name
|
Business, Customer.company_name, owner.personnel_name, assistant.personnel_name
|
||||||
).join(Customer, Customer.id == Business.customer_id)
|
).join(Customer, Customer.id == Business.customer_id)
|
||||||
stmt = _apply_filters(
|
stmt = _apply_filters(stmt, query)
|
||||||
stmt,
|
|
||||||
keyword=keyword,
|
|
||||||
status=status,
|
|
||||||
customer_id=customer_id,
|
|
||||||
owner_personnel_id=owner_personnel_id,
|
|
||||||
date_from=date_from,
|
|
||||||
date_to=date_to,
|
|
||||||
)
|
|
||||||
stmt = (
|
stmt = (
|
||||||
stmt.outerjoin(owner, owner.id == Business.owner_personnel_id)
|
stmt.outerjoin(owner, owner.id == Business.owner_personnel_id)
|
||||||
.outerjoin(assistant, assistant.id == Business.assistant_personnel_id)
|
.outerjoin(assistant, assistant.id == Business.assistant_personnel_id)
|
||||||
.order_by(Business.id.desc())
|
.order_by(Business.id.desc())
|
||||||
.offset(offset)
|
.offset(query.offset)
|
||||||
.limit(limit)
|
.limit(query.page_size)
|
||||||
)
|
)
|
||||||
return list(session.execute(stmt).all())
|
return list(session.execute(stmt).all())
|
||||||
|
|
||||||
|
|
||||||
def count_businesses(
|
def count_businesses(session: Session, query: BusinessQuery) -> int:
|
||||||
session: Session,
|
|
||||||
*,
|
|
||||||
keyword: str | None,
|
|
||||||
status: int | None,
|
|
||||||
customer_id: int | None,
|
|
||||||
owner_personnel_id: int | None,
|
|
||||||
date_from: date | None,
|
|
||||||
date_to: date | None,
|
|
||||||
) -> int:
|
|
||||||
stmt = (
|
stmt = (
|
||||||
select(func.count())
|
select(func.count())
|
||||||
.select_from(Business)
|
.select_from(Business)
|
||||||
.join(Customer, Customer.id == Business.customer_id)
|
.join(Customer, Customer.id == Business.customer_id)
|
||||||
)
|
)
|
||||||
stmt = _apply_filters(
|
stmt = _apply_filters(stmt, query)
|
||||||
stmt,
|
|
||||||
keyword=keyword,
|
|
||||||
status=status,
|
|
||||||
customer_id=customer_id,
|
|
||||||
owner_personnel_id=owner_personnel_id,
|
|
||||||
date_from=date_from,
|
|
||||||
date_to=date_to,
|
|
||||||
)
|
|
||||||
return int(session.execute(stmt).scalar_one())
|
return int(session.execute(stmt).scalar_one())
|
||||||
|
|
||||||
|
|
||||||
@@ -313,18 +270,17 @@ def list_ownership_logs(
|
|||||||
) -> list[Row]:
|
) -> list[Row]:
|
||||||
"""某业务的归属变更留痕(共享实现,owner_type/business_id 参数化)。"""
|
"""某业务的归属变更留痕(共享实现,owner_type/business_id 参数化)。"""
|
||||||
return ownership_log_repository.list_by_ref(
|
return ownership_log_repository.list_by_ref(
|
||||||
session,
|
session, _business_log_ref(business_id), offset=offset, limit=limit
|
||||||
owner_type=OWNER_TYPE_BUSINESS,
|
|
||||||
ref_column=OwnershipChangeLog.business_id,
|
|
||||||
ref_id=business_id,
|
|
||||||
offset=offset,
|
|
||||||
limit=limit,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def count_ownership_logs(session: Session, business_id: int) -> int:
|
def count_ownership_logs(session: Session, business_id: int) -> int:
|
||||||
return ownership_log_repository.count_by_ref(
|
return ownership_log_repository.count_by_ref(session, _business_log_ref(business_id))
|
||||||
session,
|
|
||||||
|
|
||||||
|
def _business_log_ref(business_id: int) -> OwnershipLogRef:
|
||||||
|
"""业务维度的归属留痕锚点。"""
|
||||||
|
return OwnershipLogRef(
|
||||||
owner_type=OWNER_TYPE_BUSINESS,
|
owner_type=OWNER_TYPE_BUSINESS,
|
||||||
ref_column=OwnershipChangeLog.business_id,
|
ref_column=OwnershipChangeLog.business_id,
|
||||||
ref_id=business_id,
|
ref_id=business_id,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from sqlalchemy import Select, func, or_, select
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.models import BusinessRole
|
from app.models import BusinessRole
|
||||||
|
from app.schemas.business_role import BusinessRoleQuery
|
||||||
|
|
||||||
# ==================== 读取 ====================
|
# ==================== 读取 ====================
|
||||||
|
|
||||||
@@ -39,41 +40,48 @@ def list_by_codes(session: Session, role_codes: list[str]) -> dict[str, Business
|
|||||||
return {role.role_code: role for role in rows}
|
return {role.role_code: role for role in rows}
|
||||||
|
|
||||||
|
|
||||||
def list_roles(
|
def list_roles(session: Session, query: BusinessRoleQuery) -> list[BusinessRole]:
|
||||||
session: Session,
|
|
||||||
*,
|
|
||||||
offset: int,
|
|
||||||
limit: int,
|
|
||||||
keyword: str | None = None,
|
|
||||||
status: int | None = None,
|
|
||||||
) -> list[BusinessRole]:
|
|
||||||
"""分页查询岗位(含停用)。排序 (sort_order, id),与系统角色口径一致。"""
|
"""分页查询岗位(含停用)。排序 (sort_order, id),与系统角色口径一致。"""
|
||||||
stmt = _apply_filters(select(BusinessRole), keyword=keyword, status=status)
|
stmt = _apply_filters(select(BusinessRole), query)
|
||||||
stmt = stmt.order_by(BusinessRole.sort_order, BusinessRole.id).offset(offset).limit(limit)
|
stmt = (
|
||||||
|
stmt.order_by(BusinessRole.sort_order, BusinessRole.id)
|
||||||
|
.offset(query.offset)
|
||||||
|
.limit(query.page_size)
|
||||||
|
)
|
||||||
return list(session.execute(stmt).scalars())
|
return list(session.execute(stmt).scalars())
|
||||||
|
|
||||||
|
|
||||||
def count_roles(session: Session, *, keyword: str | None = None, status: int | None = None) -> int:
|
def count_roles(session: Session, query: BusinessRoleQuery) -> int:
|
||||||
"""统计符合条件的岗位总数,供分页使用。"""
|
"""统计符合条件的岗位总数,供分页使用。"""
|
||||||
stmt = _apply_filters(select(func.count(BusinessRole.id)), keyword=keyword, status=status)
|
stmt = _apply_filters(select(func.count(BusinessRole.id)), query)
|
||||||
return int(session.execute(stmt).scalar_one())
|
return int(session.execute(stmt).scalar_one())
|
||||||
|
|
||||||
|
|
||||||
def _apply_filters(stmt: Select, *, keyword: str | None, status: int | None) -> Select:
|
def list_all(session: Session) -> list[BusinessRole]:
|
||||||
|
"""全部岗位(含停用,不分页)——岗位下拉选项的取数口径。
|
||||||
|
|
||||||
|
岗位规模有限(内置 + 自定义),一次取全量,不受分页上限约束;
|
||||||
|
排序与 list_roles 一致(sort_order, id),下拉与列表相对次序相同。
|
||||||
|
"""
|
||||||
|
stmt = select(BusinessRole).order_by(BusinessRole.sort_order, BusinessRole.id)
|
||||||
|
return list(session.execute(stmt).scalars())
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_filters(stmt: Select, query: BusinessRoleQuery) -> Select:
|
||||||
"""应用通用筛选条件。列表与计数共用,避免两处口径不一致。
|
"""应用通用筛选条件。列表与计数共用,避免两处口径不一致。
|
||||||
|
|
||||||
关键词走 ``contains(autoescape=True)``(转义 LIKE 通配符)——岗位编码含下划线,
|
关键词走 ``contains(autoescape=True)``(转义 LIKE 通配符)——岗位编码含下划线,
|
||||||
不转义时 ``_`` 会当单字符通配执行。
|
不转义时 ``_`` 会当单字符通配执行。
|
||||||
"""
|
"""
|
||||||
if keyword:
|
if query.keyword:
|
||||||
stmt = stmt.where(
|
stmt = stmt.where(
|
||||||
or_(
|
or_(
|
||||||
BusinessRole.role_code.contains(keyword, autoescape=True),
|
BusinessRole.role_code.contains(query.keyword, autoescape=True),
|
||||||
BusinessRole.role_name.contains(keyword, autoescape=True),
|
BusinessRole.role_name.contains(query.keyword, autoescape=True),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if status is not None:
|
if query.status is not None:
|
||||||
stmt = stmt.where(BusinessRole.status == status)
|
stmt = stmt.where(BusinessRole.status == query.status)
|
||||||
return stmt
|
return stmt
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ from sqlalchemy.orm import Session
|
|||||||
from app.models import Customer, OwnershipChangeLog, Personnel
|
from app.models import Customer, OwnershipChangeLog, Personnel
|
||||||
from app.models.customer import OWNER_TYPE_CUSTOMER
|
from app.models.customer import OWNER_TYPE_CUSTOMER
|
||||||
from app.repositories import ownership_log_repository
|
from app.repositories import ownership_log_repository
|
||||||
|
from app.repositories.ownership_log_repository import OwnershipLogRef
|
||||||
from app.repositories.query_utils import fuzzy
|
from app.repositories.query_utils import fuzzy
|
||||||
|
from app.schemas.customer import CustomerQuery
|
||||||
|
|
||||||
# ==================== 读取 ====================
|
# ==================== 读取 ====================
|
||||||
|
|
||||||
@@ -45,49 +47,27 @@ def get_by_company_owner(session: Session, company_name: str, owner_name: str) -
|
|||||||
return session.execute(stmt).scalar_one_or_none()
|
return session.execute(stmt).scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
def list_customers(
|
def list_customers(session: Session, query: CustomerQuery) -> list[Row]:
|
||||||
session: Session,
|
|
||||||
*,
|
|
||||||
offset: int,
|
|
||||||
limit: int,
|
|
||||||
keyword: str | None = None,
|
|
||||||
customer_type: str | None = None,
|
|
||||||
owner_personnel_id: int | None = None,
|
|
||||||
) -> list[Row]:
|
|
||||||
"""分页查询客户,行内容为 (客户, 归属人员姓名)。
|
"""分页查询客户,行内容为 (客户, 归属人员姓名)。
|
||||||
|
|
||||||
归属人员外键非空但统一用 outerjoin:人员行不可考(被物理删除的脏数据)
|
归属人员外键非空但统一用 outerjoin:人员行不可考(被物理删除的脏数据)
|
||||||
不应让客户行整个查不出来(同 personnel_repository 的韧性口径)。
|
不应让客户行整个查不出来(同 personnel_repository 的韧性口径)。
|
||||||
排序按 id 倒序:客户无排序字段,新增的靠前符合列表页习惯。
|
排序键由查询白名单决定(见 ``CustomerQuery.order_by``):
|
||||||
|
id 倒序 = 新建档靠前,rating_score 按分数排、未评分垫底。
|
||||||
"""
|
"""
|
||||||
stmt = _apply_filters(
|
stmt = _apply_filters(_base_select(), query)
|
||||||
_base_select(),
|
stmt = _apply_order(stmt, query)
|
||||||
keyword=keyword,
|
stmt = stmt.offset(query.offset).limit(query.page_size)
|
||||||
customer_type=customer_type,
|
|
||||||
owner_personnel_id=owner_personnel_id,
|
|
||||||
)
|
|
||||||
stmt = stmt.order_by(Customer.id.desc()).offset(offset).limit(limit)
|
|
||||||
return list(session.execute(stmt).all())
|
return list(session.execute(stmt).all())
|
||||||
|
|
||||||
|
|
||||||
def count_customers(
|
def count_customers(session: Session, query: CustomerQuery) -> int:
|
||||||
session: Session,
|
|
||||||
*,
|
|
||||||
keyword: str | None = None,
|
|
||||||
customer_type: str | None = None,
|
|
||||||
owner_personnel_id: int | None = None,
|
|
||||||
) -> int:
|
|
||||||
"""统计符合条件的客户总数,供分页使用。
|
"""统计符合条件的客户总数,供分页使用。
|
||||||
|
|
||||||
筛选条件全部落在主表上,计数无需 join(列表与计数的筛选口径由
|
筛选条件全部落在主表上,计数无需 join(列表与计数的筛选口径由
|
||||||
``_apply_filters`` 单点保证)。
|
``_apply_filters`` 单点保证)。
|
||||||
"""
|
"""
|
||||||
stmt = _apply_filters(
|
stmt = _apply_filters(select(func.count(Customer.id)), query)
|
||||||
select(func.count(Customer.id)),
|
|
||||||
keyword=keyword,
|
|
||||||
customer_type=customer_type,
|
|
||||||
owner_personnel_id=owner_personnel_id,
|
|
||||||
)
|
|
||||||
return int(session.execute(stmt).scalar_one())
|
return int(session.execute(stmt).scalar_one())
|
||||||
|
|
||||||
|
|
||||||
@@ -101,9 +81,7 @@ def list_ownership_logs(
|
|||||||
"""查某客户的归属变更留痕(共享实现,owner_type/customer_id 参数化)。"""
|
"""查某客户的归属变更留痕(共享实现,owner_type/customer_id 参数化)。"""
|
||||||
return ownership_log_repository.list_by_ref(
|
return ownership_log_repository.list_by_ref(
|
||||||
session,
|
session,
|
||||||
owner_type=OWNER_TYPE_CUSTOMER,
|
_customer_log_ref(customer_id),
|
||||||
ref_column=OwnershipChangeLog.customer_id,
|
|
||||||
ref_id=customer_id,
|
|
||||||
offset=offset,
|
offset=offset,
|
||||||
limit=limit,
|
limit=limit,
|
||||||
)
|
)
|
||||||
@@ -111,8 +89,12 @@ def list_ownership_logs(
|
|||||||
|
|
||||||
def count_ownership_logs(session: Session, *, customer_id: int) -> int:
|
def count_ownership_logs(session: Session, *, customer_id: int) -> int:
|
||||||
"""统计某客户的归属留痕总数,供分页使用。"""
|
"""统计某客户的归属留痕总数,供分页使用。"""
|
||||||
return ownership_log_repository.count_by_ref(
|
return ownership_log_repository.count_by_ref(session, _customer_log_ref(customer_id))
|
||||||
session,
|
|
||||||
|
|
||||||
|
def _customer_log_ref(customer_id: int) -> OwnershipLogRef:
|
||||||
|
"""客户维度的归属留痕锚点。"""
|
||||||
|
return OwnershipLogRef(
|
||||||
owner_type=OWNER_TYPE_CUSTOMER,
|
owner_type=OWNER_TYPE_CUSTOMER,
|
||||||
ref_column=OwnershipChangeLog.customer_id,
|
ref_column=OwnershipChangeLog.customer_id,
|
||||||
ref_id=customer_id,
|
ref_id=customer_id,
|
||||||
@@ -126,20 +108,14 @@ def _base_select() -> Select:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _apply_filters(
|
def _apply_filters(stmt: Select, query: CustomerQuery) -> Select:
|
||||||
stmt: Select,
|
|
||||||
*,
|
|
||||||
keyword: str | None,
|
|
||||||
customer_type: str | None,
|
|
||||||
owner_personnel_id: int | None,
|
|
||||||
) -> Select:
|
|
||||||
"""应用通用筛选条件。列表与计数共用,避免两处口径不一致。"""
|
"""应用通用筛选条件。列表与计数共用,避免两处口径不一致。"""
|
||||||
if keyword:
|
if query.keyword:
|
||||||
stmt = stmt.where(_keyword_clause(keyword))
|
stmt = stmt.where(_keyword_clause(query.keyword))
|
||||||
if customer_type:
|
if query.customer_type:
|
||||||
stmt = stmt.where(Customer.customer_type == customer_type)
|
stmt = stmt.where(Customer.customer_type == query.customer_type)
|
||||||
if owner_personnel_id is not None:
|
if query.owner_personnel_id is not None:
|
||||||
stmt = stmt.where(Customer.owner_personnel_id == owner_personnel_id)
|
stmt = stmt.where(Customer.owner_personnel_id == query.owner_personnel_id)
|
||||||
return stmt
|
return stmt
|
||||||
|
|
||||||
|
|
||||||
@@ -157,6 +133,23 @@ def _keyword_clause(keyword: str) -> ColumnElement[bool]:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_order(stmt: Select, query: CustomerQuery) -> Select:
|
||||||
|
"""应用排序。排序键已由 schema 白名单收口,此处只做键 → 列的映射。
|
||||||
|
|
||||||
|
MySQL 把 NULL 视为最小值:ASC 时未评分(null)会排最前,但「没打分」
|
||||||
|
不该压在「打了低分」之上 —— ASC 分支用 ``is_(None)`` 布尔键把 null
|
||||||
|
挪到末尾;DESC 分支 null 天然在最后,无需处理。尾部再按 id 倒序兜底,
|
||||||
|
保证同分行的次序稳定(分页不跳行)。
|
||||||
|
"""
|
||||||
|
if query.order_by == "rating_score":
|
||||||
|
score = Customer.rating_score
|
||||||
|
if query.order == "asc":
|
||||||
|
stmt = stmt.order_by(score.is_(None), score.asc())
|
||||||
|
else:
|
||||||
|
stmt = stmt.order_by(score.desc())
|
||||||
|
return stmt.order_by(Customer.id.desc())
|
||||||
|
|
||||||
|
|
||||||
# ==================== 写入 ====================
|
# ==================== 写入 ====================
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -34,9 +34,11 @@ _PENDING_STATUSES = (
|
|||||||
|
|
||||||
# ==== 经营绩效总览 ====
|
# ==== 经营绩效总览 ====
|
||||||
|
|
||||||
|
|
||||||
def sum_month_performance(session: Session, month: str) -> dict:
|
def sum_month_performance(session: Session, month: str) -> dict:
|
||||||
"""当月单笔生效行合计与人员数(拆行求和不失真,人数需 DISTINCT)。"""
|
"""当月单笔生效行合计与人员数(拆行求和不失真,人数需 DISTINCT)。"""
|
||||||
return session.execute(
|
return (
|
||||||
|
session.execute(
|
||||||
select(
|
select(
|
||||||
func.coalesce(func.sum(PerformanceLedger.performance_amount), 0).label(
|
func.coalesce(func.sum(PerformanceLedger.performance_amount), 0).label(
|
||||||
"performance_total"
|
"performance_total"
|
||||||
@@ -47,7 +49,10 @@ def sum_month_performance(session: Session, month: str) -> dict:
|
|||||||
PerformanceLedger.settlement_month == month,
|
PerformanceLedger.settlement_month == month,
|
||||||
*_ACTIVE_SINGLE,
|
*_ACTIVE_SINGLE,
|
||||||
)
|
)
|
||||||
).mappings().one()
|
)
|
||||||
|
.mappings()
|
||||||
|
.one()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def performance_trend(session: Session, months: list[str]) -> dict[str, object]:
|
def performance_trend(session: Session, months: list[str]) -> dict[str, object]:
|
||||||
@@ -66,9 +71,7 @@ def performance_trend(session: Session, months: list[str]) -> dict[str, object]:
|
|||||||
return dict(rows)
|
return dict(rows)
|
||||||
|
|
||||||
|
|
||||||
def receipt_trend(
|
def receipt_trend(session: Session, *, start_date: date, end_date: date) -> dict[str, object]:
|
||||||
session: Session, *, start_date: date, end_date: date
|
|
||||||
) -> dict[str, object]:
|
|
||||||
"""近 N 个月回款合计(按到账月,含未核算回款——与台账口径不同,页面注明)。
|
"""近 N 个月回款合计(按到账月,含未核算回款——与台账口径不同,页面注明)。
|
||||||
|
|
||||||
过滤走 ``receipt_date`` 原始列范围(可用索引);``DATE_FORMAT`` 只用于
|
过滤走 ``receipt_date`` 原始列范围(可用索引);``DATE_FORMAT`` 只用于
|
||||||
@@ -107,6 +110,7 @@ def get_month_settlement(session: Session, month: str) -> MonthlySettlement | No
|
|||||||
|
|
||||||
# ==== 待办事项 ====
|
# ==== 待办事项 ====
|
||||||
|
|
||||||
|
|
||||||
def list_pending_approvals(
|
def list_pending_approvals(
|
||||||
session: Session, *, statuses: tuple[int, ...], limit: int
|
session: Session, *, statuses: tuple[int, ...], limit: int
|
||||||
) -> list[ApprovalOrder]:
|
) -> list[ApprovalOrder]:
|
||||||
@@ -160,15 +164,14 @@ def list_unassigned_receipts(session: Session, *, limit: int) -> list:
|
|||||||
def count_unassigned_receipts(session: Session) -> int:
|
def count_unassigned_receipts(session: Session) -> int:
|
||||||
return int(
|
return int(
|
||||||
session.execute(
|
session.execute(
|
||||||
select(func.count())
|
select(func.count()).select_from(Receipt).where(Receipt.shipment_id.is_(None))
|
||||||
.select_from(Receipt)
|
|
||||||
.where(Receipt.shipment_id.is_(None))
|
|
||||||
).scalar_one()
|
).scalar_one()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# ==== 风险预警 ====
|
# ==== 风险预警 ====
|
||||||
|
|
||||||
|
|
||||||
def list_overdue_shipments(session: Session, *, cutoff: date, limit: int) -> list:
|
def list_overdue_shipments(session: Session, *, cutoff: date, limit: int) -> list:
|
||||||
"""发货后超过阈值且该批次无任何回款的发货单(回款逾期规则)。
|
"""发货后超过阈值且该批次无任何回款的发货单(回款逾期规则)。
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
"""登录日志数据访问。"""
|
"""登录日志数据访问。"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from sqlalchemy import Select, func, select
|
from sqlalchemy import Select, func, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
@@ -10,68 +12,73 @@ from app.repositories.query_utils import LogFilters, apply_log_filters
|
|||||||
_USER_AGENT_MAX_LEN = 255
|
_USER_AGENT_MAX_LEN = 255
|
||||||
|
|
||||||
|
|
||||||
def add(
|
@dataclass(frozen=True, slots=True)
|
||||||
session: Session,
|
class LoginLogEntry:
|
||||||
*,
|
"""一条待写入的登录日志(全字段 DTO)。
|
||||||
user_id: int | None,
|
|
||||||
username: str,
|
与 ``OperationLogEntry`` 同款做法:写入字段平铺传递时,任何一处漏传
|
||||||
ip: str | None,
|
都只表现为日志字段缺失、不报错(success 默认 0 的语义尤其隐蔽)。
|
||||||
user_agent: str | None,
|
"""
|
||||||
success: bool,
|
|
||||||
fail_reason: str | None = None,
|
user_id: int | None
|
||||||
) -> None:
|
username: str
|
||||||
|
success: bool
|
||||||
|
ip: str | None = None
|
||||||
|
user_agent: str | None = None
|
||||||
|
fail_reason: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class LoginLogListQuery:
|
||||||
|
"""登录日志列表的查询条件(筛选 + 分页)。"""
|
||||||
|
|
||||||
|
filters: LogFilters
|
||||||
|
offset: int = 0
|
||||||
|
limit: int = 20
|
||||||
|
ip: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def add(session: Session, entry: LoginLogEntry) -> None:
|
||||||
"""写入一条登录日志。只 flush 不 commit。
|
"""写入一条登录日志。只 flush 不 commit。
|
||||||
|
|
||||||
**绝不记录密码** —— 明文与哈希都不写。日志是最容易泄漏凭据的地方。
|
**绝不记录密码** —— 明文与哈希都不写。日志是最容易泄漏凭据的地方。
|
||||||
|
|
||||||
:param user_id: 账号不存在时传 None(列本身可空)
|
:param entry.user_id: 账号不存在时为 None(列本身可空)
|
||||||
:param username: 用户名原样记录,便于账号不存在时也能追溯
|
:param entry.username: 用户名原样记录,便于账号不存在时也能追溯
|
||||||
"""
|
"""
|
||||||
session.add(
|
session.add(
|
||||||
LoginLog(
|
LoginLog(
|
||||||
user_id=user_id,
|
user_id=entry.user_id,
|
||||||
username=username,
|
username=entry.username,
|
||||||
ip=ip,
|
ip=entry.ip,
|
||||||
user_agent=user_agent[:_USER_AGENT_MAX_LEN] if user_agent else None,
|
user_agent=entry.user_agent[:_USER_AGENT_MAX_LEN] if entry.user_agent else None,
|
||||||
success=1 if success else 0,
|
success=1 if entry.success else 0,
|
||||||
fail_reason=fail_reason,
|
fail_reason=entry.fail_reason,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
session.flush()
|
session.flush()
|
||||||
|
|
||||||
|
|
||||||
def list_logs(
|
def list_logs(session: Session, query: LoginLogListQuery) -> list[LoginLog]:
|
||||||
session: Session,
|
|
||||||
*,
|
|
||||||
offset: int,
|
|
||||||
limit: int,
|
|
||||||
filters: LogFilters,
|
|
||||||
ip: str | None = None,
|
|
||||||
) -> list[LoginLog]:
|
|
||||||
"""分页查询登录日志。
|
"""分页查询登录日志。
|
||||||
|
|
||||||
**按 id 倒序而非 create_time**:后者是秒级精度,同一秒内的多条记录排序
|
**按 id 倒序而非 create_time**:后者是秒级精度,同一秒内的多条记录排序
|
||||||
不确定,翻页会重复或遗漏(与用户列表同一理由)。
|
不确定,翻页会重复或遗漏(与用户列表同一理由)。
|
||||||
"""
|
"""
|
||||||
stmt = _apply_filters(select(LoginLog), filters=filters, ip=ip)
|
stmt = _apply_filters(select(LoginLog), query)
|
||||||
stmt = stmt.order_by(LoginLog.id.desc()).offset(offset).limit(limit)
|
stmt = stmt.order_by(LoginLog.id.desc()).offset(query.offset).limit(query.limit)
|
||||||
return list(session.execute(stmt).scalars())
|
return list(session.execute(stmt).scalars())
|
||||||
|
|
||||||
|
|
||||||
def count_logs(
|
def count_logs(session: Session, query: LoginLogListQuery) -> int:
|
||||||
session: Session,
|
|
||||||
*,
|
|
||||||
filters: LogFilters,
|
|
||||||
ip: str | None = None,
|
|
||||||
) -> int:
|
|
||||||
"""统计符合条件的登录日志总数,供分页使用。"""
|
"""统计符合条件的登录日志总数,供分页使用。"""
|
||||||
stmt = _apply_filters(select(func.count(LoginLog.id)), filters=filters, ip=ip)
|
stmt = _apply_filters(select(func.count(LoginLog.id)), query)
|
||||||
return int(session.execute(stmt).scalar_one())
|
return int(session.execute(stmt).scalar_one())
|
||||||
|
|
||||||
|
|
||||||
def _apply_filters(stmt: Select, *, filters: LogFilters, ip: str | None) -> Select:
|
def _apply_filters(stmt: Select, query: LoginLogListQuery) -> Select:
|
||||||
"""应用筛选条件(公共部分复用 query_utils,与操作日志同口径)。"""
|
"""应用筛选条件(公共部分复用 query_utils,与操作日志同口径)。"""
|
||||||
stmt = apply_log_filters(stmt, LoginLog, filters)
|
stmt = apply_log_filters(stmt, LoginLog, query.filters)
|
||||||
if ip:
|
if query.ip:
|
||||||
stmt = stmt.where(LoginLog.ip == ip)
|
stmt = stmt.where(LoginLog.ip == query.ip)
|
||||||
return stmt
|
return stmt
|
||||||
@@ -107,15 +107,18 @@ def get_by_id(session: Session, log_id: int) -> OperationLog | None:
|
|||||||
return session.get(OperationLog, log_id)
|
return session.get(OperationLog, log_id)
|
||||||
|
|
||||||
|
|
||||||
def list_logs(
|
@dataclass(frozen=True, slots=True)
|
||||||
session: Session,
|
class OperationLogListQuery:
|
||||||
*,
|
"""操作日志列表的查询条件(筛选 + 分页)。"""
|
||||||
offset: int,
|
|
||||||
limit: int,
|
filters: LogFilters
|
||||||
filters: LogFilters,
|
offset: int = 0
|
||||||
module: str | None = None,
|
limit: int = 20
|
||||||
action: str | None = None,
|
module: str | None = None
|
||||||
) -> list[OperationLog]:
|
action: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def list_logs(session: Session, query: OperationLogListQuery) -> list[OperationLog]:
|
||||||
"""分页查询操作日志。
|
"""分页查询操作日志。
|
||||||
|
|
||||||
**按 id 倒序而非 create_time**:后者是秒级精度,同一秒内的多条记录排序
|
**按 id 倒序而非 create_time**:后者是秒级精度,同一秒内的多条记录排序
|
||||||
@@ -127,41 +130,34 @@ def list_logs(
|
|||||||
"""
|
"""
|
||||||
stmt = _apply_filters(
|
stmt = _apply_filters(
|
||||||
select(OperationLog).options(defer(OperationLog.request_data)),
|
select(OperationLog).options(defer(OperationLog.request_data)),
|
||||||
filters=filters,
|
query,
|
||||||
module=module,
|
|
||||||
action=action,
|
|
||||||
)
|
)
|
||||||
stmt = stmt.order_by(OperationLog.id.desc()).offset(offset).limit(limit)
|
stmt = stmt.order_by(OperationLog.id.desc()).offset(query.offset).limit(query.limit)
|
||||||
return list(session.execute(stmt).scalars())
|
return list(session.execute(stmt).scalars())
|
||||||
|
|
||||||
|
|
||||||
def count_logs(
|
def count_logs(session: Session, query: OperationLogListQuery) -> int:
|
||||||
session: Session,
|
|
||||||
*,
|
|
||||||
filters: LogFilters,
|
|
||||||
module: str | None = None,
|
|
||||||
action: str | None = None,
|
|
||||||
) -> int:
|
|
||||||
"""统计符合条件的日志总数,供分页使用。"""
|
"""统计符合条件的日志总数,供分页使用。"""
|
||||||
stmt = _apply_filters(
|
stmt = _apply_filters(select(func.count(OperationLog.id)), query)
|
||||||
select(func.count(OperationLog.id)), filters=filters, module=module, action=action
|
|
||||||
)
|
|
||||||
return int(session.execute(stmt).scalar_one())
|
return int(session.execute(stmt).scalar_one())
|
||||||
|
|
||||||
|
|
||||||
def _apply_filters(
|
def distinct_modules(session: Session) -> list[str]:
|
||||||
stmt: Select,
|
"""全部模块名(去重、按名称升序):工作台「最近动态」的模块筛选项。
|
||||||
*,
|
|
||||||
filters: LogFilters,
|
交给 SQL 的 DISTINCT,不在应用层聚合 —— 应用层去重要把整列拉进内存。
|
||||||
module: str | None,
|
"""
|
||||||
action: str | None,
|
stmt = select(OperationLog.module).distinct().order_by(OperationLog.module)
|
||||||
) -> Select:
|
return [module for module in session.execute(stmt).scalars() if module]
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_filters(stmt: Select, query: OperationLogListQuery) -> Select:
|
||||||
"""应用筛选条件(公共部分复用 query_utils,与登录日志同口径)。"""
|
"""应用筛选条件(公共部分复用 query_utils,与登录日志同口径)。"""
|
||||||
stmt = apply_log_filters(stmt, OperationLog, filters)
|
stmt = apply_log_filters(stmt, OperationLog, query.filters)
|
||||||
if module:
|
if query.module:
|
||||||
stmt = stmt.where(OperationLog.module == module)
|
stmt = stmt.where(OperationLog.module == query.module)
|
||||||
if action:
|
if query.action:
|
||||||
stmt = stmt.where(OperationLog.action == action)
|
stmt = stmt.where(OperationLog.action == query.action)
|
||||||
return stmt
|
return stmt
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,18 +5,32 @@ owner_type 与引用列由调用方参数化 —— 此前客户、业务两侧
|
|||||||
「被删人员留痕仍可读」「翻页稳定双键倒序」等口径单点维护。
|
「被删人员留痕仍可读」「翻页稳定双键倒序」等口径单点维护。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy import Row, func, select
|
from sqlalchemy import Row, func, select
|
||||||
from sqlalchemy.orm import Session, aliased
|
from sqlalchemy.orm import Session, aliased
|
||||||
|
|
||||||
from app.models import OwnershipChangeLog, Personnel
|
from app.models import OwnershipChangeLog, Personnel
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class OwnershipLogRef:
|
||||||
|
"""归属留痕的引用锚点(维度类型 + 引用列 + 引用 ID)。
|
||||||
|
|
||||||
|
引用列是 SQLAlchemy 列表达式(如 ``OwnershipChangeLog.customer_id``),
|
||||||
|
不是可序列化值 —— 故用冻结 dataclass 而非 Pydantic 模型。
|
||||||
|
"""
|
||||||
|
|
||||||
|
owner_type: int
|
||||||
|
ref_column: Any
|
||||||
|
ref_id: int
|
||||||
|
|
||||||
|
|
||||||
def list_by_ref(
|
def list_by_ref(
|
||||||
session: Session,
|
session: Session,
|
||||||
|
ref: OwnershipLogRef,
|
||||||
*,
|
*,
|
||||||
owner_type: int,
|
|
||||||
ref_column,
|
|
||||||
ref_id: int,
|
|
||||||
offset: int,
|
offset: int,
|
||||||
limit: int,
|
limit: int,
|
||||||
) -> list[Row]:
|
) -> list[Row]:
|
||||||
@@ -32,7 +46,7 @@ def list_by_ref(
|
|||||||
select(OwnershipChangeLog, old_person.personnel_name, new_person.personnel_name)
|
select(OwnershipChangeLog, old_person.personnel_name, new_person.personnel_name)
|
||||||
.outerjoin(old_person, OwnershipChangeLog.old_owner_personnel_id == old_person.id)
|
.outerjoin(old_person, OwnershipChangeLog.old_owner_personnel_id == old_person.id)
|
||||||
.outerjoin(new_person, OwnershipChangeLog.new_owner_personnel_id == new_person.id)
|
.outerjoin(new_person, OwnershipChangeLog.new_owner_personnel_id == new_person.id)
|
||||||
.where(OwnershipChangeLog.owner_type == owner_type, ref_column == ref_id)
|
.where(OwnershipChangeLog.owner_type == ref.owner_type, ref.ref_column == ref.ref_id)
|
||||||
.order_by(OwnershipChangeLog.create_time.desc(), OwnershipChangeLog.id.desc())
|
.order_by(OwnershipChangeLog.create_time.desc(), OwnershipChangeLog.id.desc())
|
||||||
.offset(offset)
|
.offset(offset)
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
@@ -40,11 +54,11 @@ def list_by_ref(
|
|||||||
return list(session.execute(stmt).all())
|
return list(session.execute(stmt).all())
|
||||||
|
|
||||||
|
|
||||||
def count_by_ref(session: Session, *, owner_type: int, ref_column, ref_id: int) -> int:
|
def count_by_ref(session: Session, ref: OwnershipLogRef) -> int:
|
||||||
"""统计某引用对象的归属留痕总数,供分页使用。"""
|
"""统计某引用对象的归属留痕总数,供分页使用。"""
|
||||||
stmt = select(func.count(OwnershipChangeLog.id)).where(
|
stmt = select(func.count(OwnershipChangeLog.id)).where(
|
||||||
OwnershipChangeLog.owner_type == owner_type,
|
OwnershipChangeLog.owner_type == ref.owner_type,
|
||||||
ref_column == ref_id,
|
ref.ref_column == ref.ref_id,
|
||||||
)
|
)
|
||||||
return int(session.execute(stmt).scalar_one())
|
return int(session.execute(stmt).scalar_one())
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from sqlalchemy import Select, delete, func, select
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.models import ParamChangeBatch, ParamDefinition, ParamValue
|
from app.models import ParamChangeBatch, ParamDefinition, ParamValue
|
||||||
|
from app.schemas.param import ParamBatchQuery, ParamHistoryQuery
|
||||||
|
|
||||||
# ==================== 读取 ====================
|
# ==================== 读取 ====================
|
||||||
|
|
||||||
@@ -97,27 +98,27 @@ def list_definitions_by_codes(
|
|||||||
|
|
||||||
|
|
||||||
def list_history(
|
def list_history(
|
||||||
session: Session,
|
session: Session, param_id: int, query: ParamHistoryQuery
|
||||||
param_id: int,
|
|
||||||
*,
|
|
||||||
offset: int,
|
|
||||||
limit: int,
|
|
||||||
dimension_value: str | None = None,
|
|
||||||
) -> list[tuple[ParamValue, ParamChangeBatch]]:
|
) -> list[tuple[ParamValue, ParamChangeBatch]]:
|
||||||
"""单参数的版本历史(含批次信息),最新在前。"""
|
"""单参数的版本历史(含批次信息),最新在前。
|
||||||
stmt = _history_stmt(session, param_id, dimension_value=dimension_value)
|
|
||||||
return [(value, batch) for value, batch in session.execute(stmt.offset(offset).limit(limit))]
|
``param_id`` 是「查哪个参数」的主锚点(由路径参数 param_code 解析而来),
|
||||||
|
不属于列表筛选项,故仍以位置参数传入;筛选与分页走 ``query``。
|
||||||
|
"""
|
||||||
|
stmt = _history_stmt(param_id, query)
|
||||||
|
return [
|
||||||
|
(value, batch)
|
||||||
|
for value, batch in session.execute(stmt.offset(query.offset).limit(query.page_size))
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def count_history(session: Session, param_id: int, *, dimension_value: str | None = None) -> int:
|
def count_history(session: Session, param_id: int, query: ParamHistoryQuery) -> int:
|
||||||
"""统计单参数的历史条目数。"""
|
"""统计单参数的历史条目数。"""
|
||||||
stmt = select(func.count()).select_from(
|
stmt = select(func.count()).select_from(_history_stmt(param_id, query).subquery())
|
||||||
_history_stmt(session, param_id, dimension_value=dimension_value).subquery()
|
|
||||||
)
|
|
||||||
return int(session.execute(stmt).scalar_one())
|
return int(session.execute(stmt).scalar_one())
|
||||||
|
|
||||||
|
|
||||||
def _history_stmt(session: Session, param_id: int, *, dimension_value: str | None) -> Select:
|
def _history_stmt(param_id: int, query: ParamHistoryQuery) -> Select:
|
||||||
"""历史查询基语句:条目 join 批次(版本号/操作人),最新在前。"""
|
"""历史查询基语句:条目 join 批次(版本号/操作人),最新在前。"""
|
||||||
stmt = (
|
stmt = (
|
||||||
select(ParamValue, ParamChangeBatch)
|
select(ParamValue, ParamChangeBatch)
|
||||||
@@ -125,8 +126,8 @@ def _history_stmt(session: Session, param_id: int, *, dimension_value: str | Non
|
|||||||
.where(ParamValue.param_id == param_id)
|
.where(ParamValue.param_id == param_id)
|
||||||
.order_by(ParamValue.effective_date.desc(), ParamValue.id.desc())
|
.order_by(ParamValue.effective_date.desc(), ParamValue.id.desc())
|
||||||
)
|
)
|
||||||
if dimension_value is not None:
|
if query.dimension_value is not None:
|
||||||
stmt = stmt.where(ParamValue.dimension_value == dimension_value)
|
stmt = stmt.where(ParamValue.dimension_value == query.dimension_value)
|
||||||
return stmt
|
return stmt
|
||||||
|
|
||||||
|
|
||||||
@@ -150,41 +151,30 @@ def get_batch_by_id(
|
|||||||
return session.get(ParamChangeBatch, batch_id)
|
return session.get(ParamChangeBatch, batch_id)
|
||||||
|
|
||||||
|
|
||||||
def list_batches(
|
def list_batches(session: Session, query: ParamBatchQuery) -> list[ParamChangeBatch]:
|
||||||
session: Session,
|
|
||||||
*,
|
|
||||||
offset: int,
|
|
||||||
limit: int,
|
|
||||||
start_date: date | None = None,
|
|
||||||
end_date: date | None = None,
|
|
||||||
) -> list[ParamChangeBatch]:
|
|
||||||
"""分页查询变更批次(按创建时间倒序)。"""
|
"""分页查询变更批次(按创建时间倒序)。"""
|
||||||
stmt = _batch_filters(select(ParamChangeBatch), start_date=start_date, end_date=end_date)
|
stmt = _batch_filters(select(ParamChangeBatch), query)
|
||||||
return list(
|
return list(
|
||||||
session.execute(
|
session.execute(
|
||||||
stmt.order_by(ParamChangeBatch.create_time.desc(), ParamChangeBatch.id.desc())
|
stmt.order_by(ParamChangeBatch.create_time.desc(), ParamChangeBatch.id.desc())
|
||||||
.offset(offset)
|
.offset(query.offset)
|
||||||
.limit(limit)
|
.limit(query.page_size)
|
||||||
).scalars()
|
).scalars()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def count_batches(
|
def count_batches(session: Session, query: ParamBatchQuery) -> int:
|
||||||
session: Session, *, start_date: date | None = None, end_date: date | None = None
|
|
||||||
) -> int:
|
|
||||||
"""统计符合条件的批次总数。"""
|
"""统计符合条件的批次总数。"""
|
||||||
stmt = _batch_filters(
|
stmt = _batch_filters(select(func.count(ParamChangeBatch.id)), query)
|
||||||
select(func.count(ParamChangeBatch.id)), start_date=start_date, end_date=end_date
|
|
||||||
)
|
|
||||||
return int(session.execute(stmt).scalar_one())
|
return int(session.execute(stmt).scalar_one())
|
||||||
|
|
||||||
|
|
||||||
def _batch_filters(stmt: Select, *, start_date: date | None, end_date: date | None) -> Select:
|
def _batch_filters(stmt: Select, query: ParamBatchQuery) -> Select:
|
||||||
"""批次筛选(生效日期区间,含边界)。列表与计数共用。"""
|
"""批次筛选(生效日期区间,含边界)。列表与计数共用。"""
|
||||||
if start_date is not None:
|
if query.start_date is not None:
|
||||||
stmt = stmt.where(ParamChangeBatch.effective_date >= start_date)
|
stmt = stmt.where(ParamChangeBatch.effective_date >= query.start_date)
|
||||||
if end_date is not None:
|
if query.end_date is not None:
|
||||||
stmt = stmt.where(ParamChangeBatch.effective_date <= end_date)
|
stmt = stmt.where(ParamChangeBatch.effective_date <= query.end_date)
|
||||||
return stmt
|
return stmt
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from sqlalchemy.orm import Session, aliased
|
|||||||
|
|
||||||
from app.models import BusinessRole, Customer, Personnel, PersonnelPlan, PersonnelRegion
|
from app.models import BusinessRole, Customer, Personnel, PersonnelPlan, PersonnelRegion
|
||||||
from app.repositories.query_utils import fuzzy
|
from app.repositories.query_utils import fuzzy
|
||||||
|
from app.schemas.personnel import PersonnelQuery
|
||||||
|
|
||||||
# 自引用别名:manager_id 指向 personnel.id,同一张表自联取「直属经理姓名」
|
# 自引用别名:manager_id 指向 personnel.id,同一张表自联取「直属经理姓名」
|
||||||
_manager = aliased(Personnel)
|
_manager = aliased(Personnel)
|
||||||
@@ -30,44 +31,23 @@ def get_with_names(session: Session, personnel_id: int) -> Row | None:
|
|||||||
return session.execute(stmt).first()
|
return session.execute(stmt).first()
|
||||||
|
|
||||||
|
|
||||||
def list_personnel(
|
def list_personnel(session: Session, query: PersonnelQuery) -> list[Row]:
|
||||||
session: Session,
|
|
||||||
*,
|
|
||||||
offset: int,
|
|
||||||
limit: int,
|
|
||||||
keyword: str | None = None,
|
|
||||||
status: int | None = None,
|
|
||||||
business_role_id: int | None = None,
|
|
||||||
) -> list[Row]:
|
|
||||||
"""分页查询人员,行内容为 (人员, 岗位名称, 直属经理姓名)。
|
"""分页查询人员,行内容为 (人员, 岗位名称, 直属经理姓名)。
|
||||||
|
|
||||||
排序按 id 倒序:人员无排序字段,新增的靠前符合列表页习惯。
|
排序按 id 倒序:人员无排序字段,新增的靠前符合列表页习惯。
|
||||||
"""
|
"""
|
||||||
stmt = _apply_filters(
|
stmt = _apply_filters(_base_select(), query)
|
||||||
_base_select(), keyword=keyword, status=status, business_role_id=business_role_id
|
stmt = stmt.order_by(Personnel.id.desc()).offset(query.offset).limit(query.page_size)
|
||||||
)
|
|
||||||
stmt = stmt.order_by(Personnel.id.desc()).offset(offset).limit(limit)
|
|
||||||
return list(session.execute(stmt).all())
|
return list(session.execute(stmt).all())
|
||||||
|
|
||||||
|
|
||||||
def count_personnel(
|
def count_personnel(session: Session, query: PersonnelQuery) -> int:
|
||||||
session: Session,
|
|
||||||
*,
|
|
||||||
keyword: str | None = None,
|
|
||||||
status: int | None = None,
|
|
||||||
business_role_id: int | None = None,
|
|
||||||
) -> int:
|
|
||||||
"""统计符合条件的人员总数,供分页使用。
|
"""统计符合条件的人员总数,供分页使用。
|
||||||
|
|
||||||
筛选条件全部落在主表上,计数无需 join(列表与计数的筛选口径由
|
筛选条件全部落在主表上,计数无需 join(列表与计数的筛选口径由
|
||||||
``_apply_filters`` 单点保证)。
|
``_apply_filters`` 单点保证)。
|
||||||
"""
|
"""
|
||||||
stmt = _apply_filters(
|
stmt = _apply_filters(select(func.count(Personnel.id)), query)
|
||||||
select(func.count(Personnel.id)),
|
|
||||||
keyword=keyword,
|
|
||||||
status=status,
|
|
||||||
business_role_id=business_role_id,
|
|
||||||
)
|
|
||||||
return int(session.execute(stmt).scalar_one())
|
return int(session.execute(stmt).scalar_one())
|
||||||
|
|
||||||
|
|
||||||
@@ -154,20 +134,14 @@ def _base_select() -> Select:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _apply_filters(
|
def _apply_filters(stmt: Select, query: PersonnelQuery) -> Select:
|
||||||
stmt: Select,
|
|
||||||
*,
|
|
||||||
keyword: str | None,
|
|
||||||
status: int | None,
|
|
||||||
business_role_id: int | None,
|
|
||||||
) -> Select:
|
|
||||||
"""应用通用筛选条件。列表与计数共用,避免两处口径不一致。"""
|
"""应用通用筛选条件。列表与计数共用,避免两处口径不一致。"""
|
||||||
if keyword:
|
if query.keyword:
|
||||||
stmt = fuzzy(stmt, Personnel.personnel_name, keyword)
|
stmt = fuzzy(stmt, Personnel.personnel_name, query.keyword)
|
||||||
if status is not None:
|
if query.status is not None:
|
||||||
stmt = stmt.where(Personnel.status == status)
|
stmt = stmt.where(Personnel.status == query.status)
|
||||||
if business_role_id is not None:
|
if query.business_role_id is not None:
|
||||||
stmt = stmt.where(Personnel.business_role_id == business_role_id)
|
stmt = stmt.where(Personnel.business_role_id == query.business_role_id)
|
||||||
return stmt
|
return stmt
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from sqlalchemy import Select, func, or_, select
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.models import BusinessItem, Product, ShipmentItem
|
from app.models import BusinessItem, Product, ShipmentItem
|
||||||
|
from app.schemas.product import ProductQuery
|
||||||
|
|
||||||
# ==================== 读取 ====================
|
# ==================== 读取 ====================
|
||||||
|
|
||||||
@@ -34,50 +35,20 @@ def get_by_name_spec(session: Session, product_name: str, spec_model: str) -> Pr
|
|||||||
return session.execute(stmt).scalar_one_or_none()
|
return session.execute(stmt).scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
def list_products(
|
def list_products(session: Session, query: ProductQuery) -> list[Product]:
|
||||||
session: Session,
|
|
||||||
*,
|
|
||||||
offset: int,
|
|
||||||
limit: int,
|
|
||||||
keyword: str | None = None,
|
|
||||||
product_category: str | None = None,
|
|
||||||
status: int | None = None,
|
|
||||||
) -> list[Product]:
|
|
||||||
"""分页查询产品(含停用)。按 id 升序,与 M1 岗位列表的次序口径一致。"""
|
"""分页查询产品(含停用)。按 id 升序,与 M1 岗位列表的次序口径一致。"""
|
||||||
stmt = _apply_filters(
|
stmt = _apply_filters(select(Product), query)
|
||||||
select(Product),
|
stmt = stmt.order_by(Product.id).offset(query.offset).limit(query.page_size)
|
||||||
keyword=keyword,
|
|
||||||
product_category=product_category,
|
|
||||||
status=status,
|
|
||||||
)
|
|
||||||
stmt = stmt.order_by(Product.id).offset(offset).limit(limit)
|
|
||||||
return list(session.execute(stmt).scalars())
|
return list(session.execute(stmt).scalars())
|
||||||
|
|
||||||
|
|
||||||
def count_products(
|
def count_products(session: Session, query: ProductQuery) -> int:
|
||||||
session: Session,
|
|
||||||
*,
|
|
||||||
keyword: str | None = None,
|
|
||||||
product_category: str | None = None,
|
|
||||||
status: int | None = None,
|
|
||||||
) -> int:
|
|
||||||
"""统计符合条件的产品总数,供分页使用。"""
|
"""统计符合条件的产品总数,供分页使用。"""
|
||||||
stmt = _apply_filters(
|
stmt = _apply_filters(select(func.count(Product.id)), query)
|
||||||
select(func.count(Product.id)),
|
|
||||||
keyword=keyword,
|
|
||||||
product_category=product_category,
|
|
||||||
status=status,
|
|
||||||
)
|
|
||||||
return int(session.execute(stmt).scalar_one())
|
return int(session.execute(stmt).scalar_one())
|
||||||
|
|
||||||
|
|
||||||
def _apply_filters(
|
def _apply_filters(stmt: Select, query: ProductQuery) -> Select:
|
||||||
stmt: Select,
|
|
||||||
*,
|
|
||||||
keyword: str | None,
|
|
||||||
product_category: str | None,
|
|
||||||
status: int | None,
|
|
||||||
) -> Select:
|
|
||||||
"""应用通用筛选条件。列表与计数共用,避免两处口径不一致。
|
"""应用通用筛选条件。列表与计数共用,避免两处口径不一致。
|
||||||
|
|
||||||
关键词对名称/规格两列取 OR 命中。转义与 query_utils.fuzzy 同一机制
|
关键词对名称/规格两列取 OR 命中。转义与 query_utils.fuzzy 同一机制
|
||||||
@@ -86,17 +57,17 @@ def _apply_filters(
|
|||||||
写 contains —— M1 business_role_repository 的双列关键词同款写法。
|
写 contains —— M1 business_role_repository 的双列关键词同款写法。
|
||||||
规格型号常含 ``_``(如 DN300_II),不转义会当单字符通配执行。
|
规格型号常含 ``_``(如 DN300_II),不转义会当单字符通配执行。
|
||||||
"""
|
"""
|
||||||
if keyword:
|
if query.keyword:
|
||||||
stmt = stmt.where(
|
stmt = stmt.where(
|
||||||
or_(
|
or_(
|
||||||
Product.product_name.contains(keyword, autoescape=True),
|
Product.product_name.contains(query.keyword, autoescape=True),
|
||||||
Product.spec_model.contains(keyword, autoescape=True),
|
Product.spec_model.contains(query.keyword, autoescape=True),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if product_category:
|
if query.product_category:
|
||||||
stmt = stmt.where(Product.product_category == product_category)
|
stmt = stmt.where(Product.product_category == query.product_category)
|
||||||
if status is not None:
|
if query.status is not None:
|
||||||
stmt = stmt.where(Product.status == status)
|
stmt = stmt.where(Product.status == query.status)
|
||||||
return stmt
|
return stmt
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -32,8 +32,9 @@ def list_all(session: Session, *, for_update: bool = False) -> list[CustomerRati
|
|||||||
def get_by_id(session: Session, grade_id: int) -> CustomerRatingGrade | None:
|
def get_by_id(session: Session, grade_id: int) -> CustomerRatingGrade | None:
|
||||||
"""按主键查询。
|
"""按主键查询。
|
||||||
|
|
||||||
等级表无下游引用(评级是纯计算器不落评分记录),无需 ``for_update``
|
等级表无外键引用(客户档案只存总分,等级由分数按当前映射实时推导,
|
||||||
锁行 —— product_repository 锁行防的是并发引用悬空,此处不存在该风险。
|
不存等级 id),无需 ``for_update`` 锁行 —— product_repository 锁行防的
|
||||||
|
是并发引用悬空,此处不存在该风险:删掉一个等级,只是以后不再判到它。
|
||||||
"""
|
"""
|
||||||
return session.get(CustomerRatingGrade, grade_id)
|
return session.get(CustomerRatingGrade, grade_id)
|
||||||
|
|
||||||
@@ -60,6 +61,10 @@ def create(session: Session, grade: CustomerRatingGrade) -> CustomerRatingGrade:
|
|||||||
|
|
||||||
|
|
||||||
def delete(session: Session, grade: CustomerRatingGrade) -> None:
|
def delete(session: Session, grade: CustomerRatingGrade) -> None:
|
||||||
"""物理删除等级。等级表无下游引用,调用方无需引用保护校验。"""
|
"""物理删除等级。
|
||||||
|
|
||||||
|
等级表无外键引用(客户档案只存总分,不存等级 id),调用方无需引用保护
|
||||||
|
校验:删掉等级只是「以后不再判到它」,已存的总分不受影响。
|
||||||
|
"""
|
||||||
session.delete(grade)
|
session.delete(grade)
|
||||||
session.flush()
|
session.flush()
|
||||||
@@ -14,6 +14,7 @@ from sqlalchemy import Select, func, select
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.models import Business, Receipt, Shipment
|
from app.models import Business, Receipt, Shipment
|
||||||
|
from app.schemas.receipt import ReceiptQuery
|
||||||
|
|
||||||
|
|
||||||
def get_by_id(session: Session, receipt_id: int) -> Receipt | None:
|
def get_by_id(session: Session, receipt_id: int) -> Receipt | None:
|
||||||
@@ -43,72 +44,42 @@ def get_shipment(session: Session, shipment_id: int) -> Shipment | None:
|
|||||||
return session.get(Shipment, shipment_id)
|
return session.get(Shipment, shipment_id)
|
||||||
|
|
||||||
|
|
||||||
def _apply_filters(
|
def _apply_filters(stmt: Select, query: ReceiptQuery) -> Select:
|
||||||
stmt: Select,
|
|
||||||
*,
|
|
||||||
business_id: int | None,
|
|
||||||
date_from: date | None,
|
|
||||||
date_to: date | None,
|
|
||||||
is_barter: int | None,
|
|
||||||
settlement_status: int | None,
|
|
||||||
) -> Select:
|
|
||||||
"""回款列表的筛选条件(全部落在 Receipt 自身列上)。"""
|
"""回款列表的筛选条件(全部落在 Receipt 自身列上)。"""
|
||||||
if business_id is not None:
|
if query.business_id is not None:
|
||||||
stmt = stmt.where(Receipt.business_id == business_id)
|
stmt = stmt.where(Receipt.business_id == query.business_id)
|
||||||
if date_from is not None:
|
if query.date_from is not None:
|
||||||
stmt = stmt.where(Receipt.receipt_date >= date_from)
|
stmt = stmt.where(Receipt.receipt_date >= query.date_from)
|
||||||
if date_to is not None:
|
if query.date_to is not None:
|
||||||
stmt = stmt.where(Receipt.receipt_date <= date_to)
|
stmt = stmt.where(Receipt.receipt_date <= query.date_to)
|
||||||
if is_barter is not None:
|
if query.is_barter is not None:
|
||||||
stmt = stmt.where(Receipt.is_barter == is_barter)
|
stmt = stmt.where(Receipt.is_barter == query.is_barter)
|
||||||
if settlement_status is not None:
|
# settlement_status 是 M3 核算防重锚点(需规 2.3.3),必须保持具名字段
|
||||||
stmt = stmt.where(Receipt.settlement_status == settlement_status)
|
if query.settlement_status is not None:
|
||||||
|
stmt = stmt.where(Receipt.settlement_status == query.settlement_status)
|
||||||
return stmt
|
return stmt
|
||||||
|
|
||||||
|
|
||||||
def list_receipts(
|
def list_receipts(
|
||||||
session: Session,
|
session: Session, query: ReceiptQuery
|
||||||
*,
|
|
||||||
offset: int,
|
|
||||||
limit: int,
|
|
||||||
business_id: int | None,
|
|
||||||
date_from: date | None,
|
|
||||||
date_to: date | None,
|
|
||||||
is_barter: int | None,
|
|
||||||
settlement_status: int | None,
|
|
||||||
) -> list[tuple[Receipt, str, str | None, date | None]]:
|
) -> list[tuple[Receipt, str, str | None, date | None]]:
|
||||||
"""分页查询回款(附业务号/发货单号/发货日),按 id 倒序。"""
|
"""分页查询回款(附业务号/发货单号/发货日),按 id 倒序。"""
|
||||||
stmt = _apply_filters(
|
stmt = _apply_filters(
|
||||||
select(Receipt, Business.business_no, Shipment.shipment_no, Shipment.ship_date)
|
select(Receipt, Business.business_no, Shipment.shipment_no, Shipment.ship_date)
|
||||||
.join(Business, Business.id == Receipt.business_id)
|
.join(Business, Business.id == Receipt.business_id)
|
||||||
.outerjoin(Shipment, Shipment.id == Receipt.shipment_id),
|
.outerjoin(Shipment, Shipment.id == Receipt.shipment_id),
|
||||||
business_id=business_id,
|
query,
|
||||||
date_from=date_from,
|
)
|
||||||
date_to=date_to,
|
return list(
|
||||||
is_barter=is_barter,
|
session.execute(
|
||||||
settlement_status=settlement_status,
|
stmt.order_by(Receipt.id.desc()).offset(query.offset).limit(query.page_size)
|
||||||
|
).all()
|
||||||
)
|
)
|
||||||
return list(session.execute(stmt.order_by(Receipt.id.desc()).offset(offset).limit(limit)).all())
|
|
||||||
|
|
||||||
|
|
||||||
def count_receipts(
|
def count_receipts(session: Session, query: ReceiptQuery) -> int:
|
||||||
session: Session,
|
|
||||||
*,
|
|
||||||
business_id: int | None,
|
|
||||||
date_from: date | None,
|
|
||||||
date_to: date | None,
|
|
||||||
is_barter: int | None,
|
|
||||||
settlement_status: int | None,
|
|
||||||
) -> int:
|
|
||||||
"""回款总数(筛选条件与列表共用同一 apply_filters,防两侧口径漂移)。"""
|
"""回款总数(筛选条件与列表共用同一 apply_filters,防两侧口径漂移)。"""
|
||||||
stmt = _apply_filters(
|
stmt = _apply_filters(select(func.count()).select_from(Receipt), query)
|
||||||
select(func.count()).select_from(Receipt),
|
|
||||||
business_id=business_id,
|
|
||||||
date_from=date_from,
|
|
||||||
date_to=date_to,
|
|
||||||
is_barter=is_barter,
|
|
||||||
settlement_status=settlement_status,
|
|
||||||
)
|
|
||||||
return int(session.execute(stmt).scalar_one())
|
return int(session.execute(stmt).scalar_one())
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -389,9 +389,7 @@ def _bucket_condition(bucket: DayBucket) -> tuple:
|
|||||||
return tuple(conds)
|
return tuple(conds)
|
||||||
|
|
||||||
|
|
||||||
def list_payment_detail(
|
def list_payment_detail(session: Session, query: PaymentDetailQuery, bucket: DayBucket) -> list:
|
||||||
session: Session, query: PaymentDetailQuery, bucket: DayBucket, *, offset: int, limit: int
|
|
||||||
) -> list:
|
|
||||||
"""区间下钻:单笔生效行联回款表(行级展示回款金额与批次号)。
|
"""区间下钻:单笔生效行联回款表(行级展示回款金额与批次号)。
|
||||||
|
|
||||||
bucket 由服务层解析(未注册编码 400),仓储只按区间拼条件。
|
bucket 由服务层解析(未注册编码 400),仓储只按区间拼条件。
|
||||||
@@ -408,7 +406,7 @@ def list_payment_detail(
|
|||||||
.where(*_payment_filters(query), *_bucket_condition(bucket))
|
.where(*_payment_filters(query), *_bucket_condition(bucket))
|
||||||
)
|
)
|
||||||
stmt = stmt.order_by(PerformanceLedger.receipt_date.desc(), PerformanceLedger.id.desc())
|
stmt = stmt.order_by(PerformanceLedger.receipt_date.desc(), PerformanceLedger.id.desc())
|
||||||
stmt = stmt.offset(offset).limit(limit)
|
stmt = stmt.offset(query.offset).limit(query.page_size)
|
||||||
return list(session.execute(stmt).all())
|
return list(session.execute(stmt).all())
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from sqlalchemy import Select, func, or_, select
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.models import Role, UserRole
|
from app.models import Role, UserRole
|
||||||
|
from app.schemas.role import RoleQuery
|
||||||
|
|
||||||
# ==================== 读取 ====================
|
# ==================== 读取 ====================
|
||||||
|
|
||||||
@@ -47,44 +48,37 @@ def get_all_roles(session: Session) -> list[Role]:
|
|||||||
return list(session.execute(stmt).scalars())
|
return list(session.execute(stmt).scalars())
|
||||||
|
|
||||||
|
|
||||||
def list_roles(
|
def list_roles(session: Session, query: RoleQuery) -> list[Role]:
|
||||||
session: Session,
|
|
||||||
*,
|
|
||||||
offset: int,
|
|
||||||
limit: int,
|
|
||||||
keyword: str | None = None,
|
|
||||||
status: int | None = None,
|
|
||||||
) -> list[Role]:
|
|
||||||
"""分页查询角色(管理列表:含停用角色)。
|
"""分页查询角色(管理列表:含停用角色)。
|
||||||
|
|
||||||
排序与下拉口径一致(sort_order, id),保持同一套相对次序。
|
排序与下拉口径一致(sort_order, id),保持同一套相对次序。
|
||||||
"""
|
"""
|
||||||
stmt = _apply_filters(select(Role), keyword=keyword, status=status)
|
stmt = _apply_filters(select(Role), query)
|
||||||
stmt = stmt.order_by(Role.sort_order, Role.id).offset(offset).limit(limit)
|
stmt = stmt.order_by(Role.sort_order, Role.id).offset(query.offset).limit(query.page_size)
|
||||||
return list(session.execute(stmt).scalars())
|
return list(session.execute(stmt).scalars())
|
||||||
|
|
||||||
|
|
||||||
def count_roles(session: Session, *, keyword: str | None = None, status: int | None = None) -> int:
|
def count_roles(session: Session, query: RoleQuery) -> int:
|
||||||
"""统计符合条件的角色总数,供分页使用。"""
|
"""统计符合条件的角色总数,供分页使用。"""
|
||||||
stmt = _apply_filters(select(func.count(Role.id)), keyword=keyword, status=status)
|
stmt = _apply_filters(select(func.count(Role.id)), query)
|
||||||
return int(session.execute(stmt).scalar_one())
|
return int(session.execute(stmt).scalar_one())
|
||||||
|
|
||||||
|
|
||||||
def _apply_filters(stmt: Select, *, keyword: str | None, status: int | None) -> Select:
|
def _apply_filters(stmt: Select, query: RoleQuery) -> Select:
|
||||||
"""应用通用筛选条件。列表与计数共用,避免两处口径不一致。
|
"""应用通用筛选条件。列表与计数共用,避免两处口径不一致。
|
||||||
|
|
||||||
关键词匹配走 `contains(autoescape=True)`(转义 LIKE 通配符)——
|
关键词匹配走 `contains(autoescape=True)`(转义 LIKE 通配符)——
|
||||||
角色编码普遍含下划线,直接拼接会让 `_` 当单字符通配执行。
|
角色编码普遍含下划线,直接拼接会让 `_` 当单字符通配执行。
|
||||||
"""
|
"""
|
||||||
if keyword:
|
if query.keyword:
|
||||||
stmt = stmt.where(
|
stmt = stmt.where(
|
||||||
or_(
|
or_(
|
||||||
Role.role_code.contains(keyword, autoescape=True),
|
Role.role_code.contains(query.keyword, autoescape=True),
|
||||||
Role.role_name.contains(keyword, autoescape=True),
|
Role.role_name.contains(query.keyword, autoescape=True),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if status is not None:
|
if query.status is not None:
|
||||||
stmt = stmt.where(Role.status == status)
|
stmt = stmt.where(Role.status == query.status)
|
||||||
return stmt
|
return stmt
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -8,13 +8,13 @@
|
|||||||
业务级汇总仍收口 business_service._recalc_totals
|
业务级汇总仍收口 business_service._recalc_totals
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import date
|
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
from sqlalchemy import Select, delete, func, select
|
from sqlalchemy import Select, delete, func, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.models import Business, Receipt, Shipment, ShipmentItem
|
from app.models import Business, Receipt, Shipment, ShipmentItem
|
||||||
|
from app.schemas.shipment import ShipmentQuery
|
||||||
|
|
||||||
|
|
||||||
def get_by_id(session: Session, shipment_id: int) -> Shipment | None:
|
def get_by_id(session: Session, shipment_id: int) -> Shipment | None:
|
||||||
@@ -31,59 +31,37 @@ def get_with_business_no(session: Session, shipment_id: int) -> tuple[Shipment,
|
|||||||
).first()
|
).first()
|
||||||
|
|
||||||
|
|
||||||
def _apply_filters(
|
def _apply_filters(stmt: Select, query: ShipmentQuery) -> Select:
|
||||||
stmt: Select,
|
|
||||||
*,
|
|
||||||
business_id: int | None,
|
|
||||||
ship_date_from: date | None,
|
|
||||||
ship_date_to: date | None,
|
|
||||||
) -> Select:
|
|
||||||
"""发货列表的筛选条件(business_id 空=全量,一期按业务维度查看为主)。"""
|
"""发货列表的筛选条件(business_id 空=全量,一期按业务维度查看为主)。"""
|
||||||
if business_id is not None:
|
if query.business_id is not None:
|
||||||
stmt = stmt.where(Shipment.business_id == business_id)
|
stmt = stmt.where(Shipment.business_id == query.business_id)
|
||||||
if ship_date_from is not None:
|
if query.ship_date_from is not None:
|
||||||
stmt = stmt.where(Shipment.ship_date >= ship_date_from)
|
stmt = stmt.where(Shipment.ship_date >= query.ship_date_from)
|
||||||
if ship_date_to is not None:
|
if query.ship_date_to is not None:
|
||||||
stmt = stmt.where(Shipment.ship_date <= ship_date_to)
|
stmt = stmt.where(Shipment.ship_date <= query.ship_date_to)
|
||||||
return stmt
|
return stmt
|
||||||
|
|
||||||
|
|
||||||
def list_shipments(
|
def list_shipments(session: Session, query: ShipmentQuery) -> list[tuple[Shipment, str]]:
|
||||||
session: Session,
|
|
||||||
*,
|
|
||||||
offset: int,
|
|
||||||
limit: int,
|
|
||||||
business_id: int | None,
|
|
||||||
ship_date_from: date | None,
|
|
||||||
ship_date_to: date | None,
|
|
||||||
) -> list[tuple[Shipment, str]]:
|
|
||||||
"""分页查询发货单(附业务号),按 id 倒序。"""
|
"""分页查询发货单(附业务号),按 id 倒序。"""
|
||||||
stmt = _apply_filters(
|
stmt = _apply_filters(
|
||||||
select(Shipment, Business.business_no).join(Business, Business.id == Shipment.business_id),
|
select(Shipment, Business.business_no).join(Business, Business.id == Shipment.business_id),
|
||||||
business_id=business_id,
|
query,
|
||||||
ship_date_from=ship_date_from,
|
|
||||||
ship_date_to=ship_date_to,
|
|
||||||
)
|
)
|
||||||
return list(
|
return list(
|
||||||
session.execute(stmt.order_by(Shipment.id.desc()).offset(offset).limit(limit)).all()
|
session.execute(
|
||||||
|
stmt.order_by(Shipment.id.desc()).offset(query.offset).limit(query.page_size)
|
||||||
|
).all()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def count_shipments(
|
def count_shipments(session: Session, query: ShipmentQuery) -> int:
|
||||||
session: Session,
|
|
||||||
*,
|
|
||||||
business_id: int | None,
|
|
||||||
ship_date_from: date | None,
|
|
||||||
ship_date_to: date | None,
|
|
||||||
) -> int:
|
|
||||||
"""发货单总数(筛选条件与列表共用同一 apply_filters,防两侧口径漂移)。"""
|
"""发货单总数(筛选条件与列表共用同一 apply_filters,防两侧口径漂移)。"""
|
||||||
stmt = _apply_filters(
|
stmt = _apply_filters(
|
||||||
select(func.count())
|
select(func.count())
|
||||||
.select_from(Shipment)
|
.select_from(Shipment)
|
||||||
.join(Business, Business.id == Shipment.business_id),
|
.join(Business, Business.id == Shipment.business_id),
|
||||||
business_id=business_id,
|
query,
|
||||||
ship_date_from=ship_date_from,
|
|
||||||
ship_date_to=ship_date_to,
|
|
||||||
)
|
)
|
||||||
return int(session.execute(stmt).scalar_one())
|
return int(session.execute(stmt).scalar_one())
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from sqlalchemy.orm import Session, selectinload
|
|||||||
|
|
||||||
from app.models import Role, User, UserRole
|
from app.models import Role, User, UserRole
|
||||||
from app.repositories.query_utils import fuzzy
|
from app.repositories.query_utils import fuzzy
|
||||||
|
from app.schemas.user import UserQuery
|
||||||
|
|
||||||
|
|
||||||
def _base_query() -> Select[tuple[User]]:
|
def _base_query() -> Select[tuple[User]]:
|
||||||
@@ -48,16 +49,7 @@ def touch_last_login(session: Session, user: User, moment: datetime) -> None:
|
|||||||
# ==================== 列表查询 ====================
|
# ==================== 列表查询 ====================
|
||||||
|
|
||||||
|
|
||||||
def list_users(
|
def list_users(session: Session, query: UserQuery) -> list[User]:
|
||||||
session: Session,
|
|
||||||
*,
|
|
||||||
offset: int,
|
|
||||||
limit: int,
|
|
||||||
username: str | None = None,
|
|
||||||
real_name: str | None = None,
|
|
||||||
status: int | None = None,
|
|
||||||
role_code: str | None = None,
|
|
||||||
) -> list[User]:
|
|
||||||
"""分页查询用户列表。
|
"""分页查询用户列表。
|
||||||
|
|
||||||
**按 id 倒序而非 create_time**:create_time 是秒级精度,批量导入的用户
|
**按 id 倒序而非 create_time**:create_time 是秒级精度,批量导入的用户
|
||||||
@@ -65,48 +57,35 @@ def list_users(
|
|||||||
id 是自增主键,天然唯一且与创建顺序一致。
|
id 是自增主键,天然唯一且与创建顺序一致。
|
||||||
"""
|
"""
|
||||||
stmt = _base_query()
|
stmt = _base_query()
|
||||||
stmt = _apply_filters(stmt, username=username, real_name=real_name, status=status)
|
stmt = _apply_filters(stmt, query)
|
||||||
if role_code:
|
if query.role_code:
|
||||||
# 按角色筛选需 join 关联表;角色是字典数据,数量极少,join 代价可忽略
|
# 按角色筛选需 join 关联表;角色是字典数据,数量极少,join 代价可忽略
|
||||||
stmt = stmt.join(UserRole).join(Role).where(Role.role_code == role_code)
|
stmt = stmt.join(UserRole).join(Role).where(Role.role_code == query.role_code)
|
||||||
stmt = stmt.order_by(User.id.desc()).offset(offset).limit(limit)
|
stmt = stmt.order_by(User.id.desc()).offset(query.offset).limit(query.page_size)
|
||||||
return list(session.execute(stmt).scalars().unique())
|
return list(session.execute(stmt).scalars().unique())
|
||||||
|
|
||||||
|
|
||||||
def count_users(
|
def count_users(session: Session, query: UserQuery) -> int:
|
||||||
session: Session,
|
|
||||||
*,
|
|
||||||
username: str | None = None,
|
|
||||||
real_name: str | None = None,
|
|
||||||
status: int | None = None,
|
|
||||||
role_code: str | None = None,
|
|
||||||
) -> int:
|
|
||||||
"""统计符合条件的用户总数,供分页使用。"""
|
"""统计符合条件的用户总数,供分页使用。"""
|
||||||
stmt = select(func.count(User.id))
|
stmt = select(func.count(User.id))
|
||||||
stmt = _apply_filters(stmt, username=username, real_name=real_name, status=status)
|
stmt = _apply_filters(stmt, query)
|
||||||
if role_code:
|
if query.role_code:
|
||||||
stmt = stmt.join(UserRole).join(Role).where(Role.role_code == role_code)
|
stmt = stmt.join(UserRole).join(Role).where(Role.role_code == query.role_code)
|
||||||
return int(session.execute(stmt).scalar_one())
|
return int(session.execute(stmt).scalar_one())
|
||||||
|
|
||||||
|
|
||||||
def _apply_filters(
|
def _apply_filters(stmt: Select, query: UserQuery) -> Select:
|
||||||
stmt: Select,
|
|
||||||
*,
|
|
||||||
username: str | None,
|
|
||||||
real_name: str | None,
|
|
||||||
status: int | None,
|
|
||||||
) -> Select:
|
|
||||||
"""应用通用筛选条件。列表与计数共用,避免两处口径不一致。
|
"""应用通用筛选条件。列表与计数共用,避免两处口径不一致。
|
||||||
|
|
||||||
模糊匹配统一走 `query_utils.fuzzy`(转义 LIKE 通配符)—— 用户名普遍
|
模糊匹配统一走 `query_utils.fuzzy`(转义 LIKE 通配符)—— 用户名普遍
|
||||||
含下划线,直接拼接会让 `_` 当单字符通配执行、误配无关账号。
|
含下划线,直接拼接会让 `_` 当单字符通配执行、误配无关账号。
|
||||||
"""
|
"""
|
||||||
if username:
|
if query.username:
|
||||||
stmt = fuzzy(stmt, User.username, username)
|
stmt = fuzzy(stmt, User.username, query.username)
|
||||||
if real_name:
|
if query.real_name:
|
||||||
stmt = fuzzy(stmt, User.real_name, real_name)
|
stmt = fuzzy(stmt, User.real_name, query.real_name)
|
||||||
if status is not None:
|
if query.status is not None:
|
||||||
stmt = stmt.where(User.status == status)
|
stmt = stmt.where(User.status == query.status)
|
||||||
return stmt
|
return stmt
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -49,7 +49,8 @@ class ApprovalOrderDetailOut(ApprovalOrderOut):
|
|||||||
|
|
||||||
steps: list[ApprovalStepOut] = Field(default_factory=list)
|
steps: list[ApprovalStepOut] = Field(default_factory=list)
|
||||||
snapshot: dict[str, Any] | None = Field(
|
snapshot: dict[str, Any] | None = Field(
|
||||||
default=None, description="提交时内容快照(信封:schema_version/order_type/captured_at/data)"
|
default=None,
|
||||||
|
description="提交时内容快照(信封:schema_version/order_type/captured_at/data)",
|
||||||
)
|
)
|
||||||
content_source: Literal["snapshot", "live", "missing"] = Field(
|
content_source: Literal["snapshot", "live", "missing"] = Field(
|
||||||
default="missing", description="审批内容来源:提交时快照/当前内容回退/缺失"
|
default="missing", description="审批内容来源:提交时快照/当前内容回退/缺失"
|
||||||
|
|||||||
@@ -27,6 +27,19 @@ class BusinessRoleOut(BaseModel):
|
|||||||
is_builtin: int = 0
|
is_builtin: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class BusinessRoleOptionOut(BaseModel):
|
||||||
|
"""岗位下拉选项(人员档案与报表筛选共用)。
|
||||||
|
|
||||||
|
与 ``BusinessRoleOut`` 分开:下拉只需渲染用的最小字段集,不必顺带
|
||||||
|
暴露计提模板等管理字段。字段名直出蛇形(前端按同名取值)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
id: int = Field(description="岗位ID")
|
||||||
|
role_code: str = Field(description="岗位编码")
|
||||||
|
role_name: str = Field(description="岗位名称")
|
||||||
|
status: int = Field(description="状态:1启用 0禁用")
|
||||||
|
|
||||||
|
|
||||||
class TemplateParamOut(BaseModel):
|
class TemplateParamOut(BaseModel):
|
||||||
"""模板声明的参数(供岗位表单展示,含当前口径说明)。"""
|
"""模板声明的参数(供岗位表单展示,含当前口径说明)。"""
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""客户管理的请求/响应模型(需求 2.1.3)。"""
|
"""客户管理的请求/响应模型(需求 2.1.3)。"""
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
@@ -28,6 +29,8 @@ class CustomerOut(BaseModel):
|
|||||||
owner_personnel_id: int | None = None
|
owner_personnel_id: int | None = None
|
||||||
owner_personnel_name: str | None = None
|
owner_personnel_name: str | None = None
|
||||||
remark: str | None = None
|
remark: str | None = None
|
||||||
|
rating_score: int | None = None
|
||||||
|
rating_time: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
class CustomerCreateIn(BaseModel):
|
class CustomerCreateIn(BaseModel):
|
||||||
@@ -90,13 +93,32 @@ class CustomerUpdateIn(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class CustomerQuery(PageQuery):
|
class CustomerQuery(PageQuery):
|
||||||
"""客户列表查询条件(字段名蛇形直出,与前端 api 封装同名对应)。"""
|
"""客户列表查询条件(字段名蛇形直出,与前端 api 封装同名对应)。
|
||||||
|
|
||||||
|
排序用 ``Literal`` 白名单而不是自由字符串:``order_by`` 直接拼进
|
||||||
|
``ORDER BY``,自由值等于把注入面开到排序键上;客户列表只有两个
|
||||||
|
有意义的排序键,白名单足够。
|
||||||
|
"""
|
||||||
|
|
||||||
keyword: str | None = Field(
|
keyword: str | None = Field(
|
||||||
default=None, max_length=64, description="公司名称或客户负责人,模糊匹配"
|
default=None, max_length=64, description="公司名称或客户负责人,模糊匹配"
|
||||||
)
|
)
|
||||||
customer_type: str | None = Field(default=None, max_length=32, description="客户分类筛选")
|
customer_type: str | None = Field(default=None, max_length=32, description="客户分类筛选")
|
||||||
owner_personnel_id: int | None = Field(default=None, ge=1, description="归属人员ID筛选")
|
owner_personnel_id: int | None = Field(default=None, ge=1, description="归属人员ID筛选")
|
||||||
|
order_by: Literal["id", "rating_score"] = Field(
|
||||||
|
default="id", description="排序键:id 建档序 / rating_score 评级总分"
|
||||||
|
)
|
||||||
|
order: Literal["asc", "desc"] = Field(default="desc", description="排序方向")
|
||||||
|
|
||||||
|
|
||||||
|
class CustomerRatingIn(BaseModel):
|
||||||
|
"""保存客户评级入参(评级计算器「保存评分」)。
|
||||||
|
|
||||||
|
score 是五项之和的整数(0-100);五项明细不落库(评级记录只存总分,
|
||||||
|
见对照表 #25 的定位:评分是档案属性而非审计事件)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
score: int = Field(ge=0, le=100, description="评级总分(0-100 整数)")
|
||||||
|
|
||||||
|
|
||||||
class CustomerOwnershipIn(BaseModel):
|
class CustomerOwnershipIn(BaseModel):
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
"""工作台的请求/响应模型(M4 ⑧)。
|
"""工作台的请求/响应模型(M4 ⑧)。
|
||||||
|
|
||||||
四区块一次返回(前端一次请求渲染整页,避免 6-10 次往返):
|
聚合端点一次返回三区块(前端一次请求渲染整页,避免多次往返):
|
||||||
经营绩效总览 / 待办事项 / 风险预警 / 最近动态。金额一律 Decimal
|
经营绩效总览 / 待办事项 / 风险预警。金额一律 Decimal
|
||||||
(序列化为字符串),前端不做数值运算。
|
(序列化为字符串),前端不做数值运算。
|
||||||
|
|
||||||
|
**最近动态独立成分页端点**:它可能有很长(操作日志全量),塞进聚合
|
||||||
|
响应既撑爆页面又只能截断前 20 条;拆开后翻页只重取这张列表,不必
|
||||||
|
重算总览/待办/预警。列表与模块筛选项同源返回(一次请求给全)。
|
||||||
|
|
||||||
待办与预警为**查询时实时派生**(不落库、不引调度器,见计划决策);
|
待办与预警为**查询时实时派生**(不落库、不引调度器,见计划决策);
|
||||||
⑥ T+ 同步未建前,同步相关条目用 `sync_available=false` 显式降级,
|
⑥ T+ 同步未建前,同步相关条目用 `sync_available=false` 显式降级,
|
||||||
不返回伪造的 0 条。
|
不返回伪造的 0 条。
|
||||||
@@ -12,7 +16,9 @@
|
|||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
from app.core.response import PageQuery
|
||||||
|
|
||||||
|
|
||||||
class TrendPoint(BaseModel):
|
class TrendPoint(BaseModel):
|
||||||
@@ -116,11 +122,42 @@ class ActivityItem(BaseModel):
|
|||||||
create_time: datetime
|
create_time: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class ActivityQuery(PageQuery):
|
||||||
|
"""最近动态查询条件:分页 + 模块筛选。
|
||||||
|
|
||||||
|
只支持模块筛选(工作台只需要这一个维度);其余筛选项见日志查询页。
|
||||||
|
"""
|
||||||
|
|
||||||
|
module: str | None = Field(default=None, max_length=64, description="模块,精确匹配")
|
||||||
|
|
||||||
|
|
||||||
|
class ActivityPageOut(BaseModel):
|
||||||
|
"""最近动态分页结果:当前页 + 总数 + 模块筛选项。
|
||||||
|
|
||||||
|
分页字段名沿用全站规范(list/total/pageNum/pageSize),与
|
||||||
|
`PageResult` 同形;额外带 `modules`(全量模块名),
|
||||||
|
供前端模块下拉使用 —— 不随当前页变化,避免翻页后选项增减。
|
||||||
|
"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(populate_by_name=True)
|
||||||
|
|
||||||
|
records: list[ActivityItem] = Field(
|
||||||
|
default_factory=list, alias="list", serialization_alias="list", description="当前页数据"
|
||||||
|
)
|
||||||
|
total: int = Field(default=0, description="总记录数")
|
||||||
|
page_num: int = Field(
|
||||||
|
default=1, alias="pageNum", serialization_alias="pageNum", description="当前页码"
|
||||||
|
)
|
||||||
|
page_size: int = Field(
|
||||||
|
default=10, alias="pageSize", serialization_alias="pageSize", description="每页条数"
|
||||||
|
)
|
||||||
|
modules: list[str] = Field(default_factory=list, description="全部模块名(筛选项)")
|
||||||
|
|
||||||
|
|
||||||
class DashboardOut(BaseModel):
|
class DashboardOut(BaseModel):
|
||||||
"""工作台四区块一次性响应。"""
|
"""工作台三区块一次性响应(最近动态走独立分页端点)。"""
|
||||||
|
|
||||||
month: str
|
month: str
|
||||||
overview: OverviewBlock
|
overview: OverviewBlock
|
||||||
todos: TodoBlock
|
todos: TodoBlock
|
||||||
warnings: WarningBlock
|
warnings: WarningBlock
|
||||||
activities: list[ActivityItem] = Field(default_factory=list)
|
|
||||||
@@ -264,7 +264,9 @@ class DepartmentReconciliation(BaseModel):
|
|||||||
description="聚合额 − 团队分成合计 − 全公司分成合计(分列舍入差)",
|
description="聚合额 − 团队分成合计 − 全公司分成合计(分列舍入差)",
|
||||||
)
|
)
|
||||||
adjust_net_total: Decimal = Field(description="明细层调整净额合计(成员 + 未分配)")
|
adjust_net_total: Decimal = Field(description="明细层调整净额合计(成员 + 未分配)")
|
||||||
payable_total: Decimal = Field(description="应发合计 = 成员应付 + 未分配应付 + 聚合行 + 调整净额")
|
payable_total: Decimal = Field(
|
||||||
|
description="应发合计 = 成员应付 + 未分配应付 + 聚合行 + 调整净额"
|
||||||
|
)
|
||||||
team_base_total: Decimal = Field(description="展示团队基数合计")
|
team_base_total: Decimal = Field(description="展示团队基数合计")
|
||||||
unassigned_performance_total: Decimal = Field(description="展示未分配人员绩效合计")
|
unassigned_performance_total: Decimal = Field(description="展示未分配人员绩效合计")
|
||||||
other_team_base: Decimal = Field(
|
other_team_base: Decimal = Field(
|
||||||
|
|||||||
@@ -37,12 +37,14 @@ def _record(
|
|||||||
"""写登录日志并立即提交。单独提交是刻意为之,见模块 docstring。"""
|
"""写登录日志并立即提交。单独提交是刻意为之,见模块 docstring。"""
|
||||||
login_log_repository.add(
|
login_log_repository.add(
|
||||||
session,
|
session,
|
||||||
|
login_log_repository.LoginLogEntry(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
username=username,
|
username=username,
|
||||||
ip=ip,
|
ip=ip,
|
||||||
user_agent=user_agent,
|
user_agent=user_agent,
|
||||||
success=success,
|
success=success,
|
||||||
fail_reason=fail_reason,
|
fail_reason=fail_reason,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ from app.models import BusinessRole, User
|
|||||||
from app.repositories import business_role_repository
|
from app.repositories import business_role_repository
|
||||||
from app.schemas.business_role import (
|
from app.schemas.business_role import (
|
||||||
BusinessRoleCreateIn,
|
BusinessRoleCreateIn,
|
||||||
|
BusinessRoleOptionOut,
|
||||||
BusinessRoleOut,
|
BusinessRoleOut,
|
||||||
BusinessRoleQuery,
|
BusinessRoleQuery,
|
||||||
BusinessRoleUpdateIn,
|
BusinessRoleUpdateIn,
|
||||||
@@ -67,16 +68,8 @@ def _ensure_template(calc_template: str) -> None:
|
|||||||
|
|
||||||
def list_roles(session: Session, query: BusinessRoleQuery) -> PageResult[BusinessRoleOut]:
|
def list_roles(session: Session, query: BusinessRoleQuery) -> PageResult[BusinessRoleOut]:
|
||||||
"""分页查询岗位(含停用)。"""
|
"""分页查询岗位(含停用)。"""
|
||||||
roles = business_role_repository.list_roles(
|
roles = business_role_repository.list_roles(session, query)
|
||||||
session,
|
total = business_role_repository.count_roles(session, query)
|
||||||
offset=query.offset,
|
|
||||||
limit=query.page_size,
|
|
||||||
keyword=query.keyword,
|
|
||||||
status=query.status,
|
|
||||||
)
|
|
||||||
total = business_role_repository.count_roles(
|
|
||||||
session, keyword=query.keyword, status=query.status
|
|
||||||
)
|
|
||||||
return PageResult[BusinessRoleOut](
|
return PageResult[BusinessRoleOut](
|
||||||
records=[_to_out(role) for role in roles],
|
records=[_to_out(role) for role in roles],
|
||||||
total=total,
|
total=total,
|
||||||
@@ -85,6 +78,23 @@ def list_roles(session: Session, query: BusinessRoleQuery) -> PageResult[Busines
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def list_options(session: Session) -> list[BusinessRoleOptionOut]:
|
||||||
|
"""岗位下拉选项(含停用,不分页)。
|
||||||
|
|
||||||
|
含停用是因为人员档案可能仍引用停用岗位(停用只是禁止新增引用,
|
||||||
|
不改历史归属);岗位规模有限(内置 + 自定义),一次取全量。
|
||||||
|
"""
|
||||||
|
return [
|
||||||
|
BusinessRoleOptionOut(
|
||||||
|
id=role.id,
|
||||||
|
role_code=role.role_code,
|
||||||
|
role_name=role.role_name,
|
||||||
|
status=role.status,
|
||||||
|
)
|
||||||
|
for role in business_role_repository.list_all(session)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def get_role(session: Session, role_id: int) -> BusinessRoleOut:
|
def get_role(session: Session, role_id: int) -> BusinessRoleOut:
|
||||||
"""岗位详情。
|
"""岗位详情。
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,8 @@
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from collections.abc import Callable
|
||||||
|
from dataclasses import dataclass
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from decimal import ROUND_HALF_UP, Decimal
|
from decimal import ROUND_HALF_UP, Decimal
|
||||||
from fractions import Fraction
|
from fractions import Fraction
|
||||||
@@ -234,26 +236,8 @@ def _is_duplicate_entry(exc: IntegrityError) -> bool:
|
|||||||
|
|
||||||
def list_businesses(session: Session, query: BusinessQuery) -> PageResult[BusinessOut]:
|
def list_businesses(session: Session, query: BusinessQuery) -> PageResult[BusinessOut]:
|
||||||
"""分页查询业务(附客户名与归属人名)。"""
|
"""分页查询业务(附客户名与归属人名)。"""
|
||||||
rows = business_repository.list_businesses(
|
rows = business_repository.list_businesses(session, query)
|
||||||
session,
|
total = business_repository.count_businesses(session, query)
|
||||||
offset=query.offset,
|
|
||||||
limit=query.page_size,
|
|
||||||
keyword=query.keyword,
|
|
||||||
status=query.status,
|
|
||||||
customer_id=query.customer_id,
|
|
||||||
owner_personnel_id=query.owner_personnel_id,
|
|
||||||
date_from=query.date_from,
|
|
||||||
date_to=query.date_to,
|
|
||||||
)
|
|
||||||
total = business_repository.count_businesses(
|
|
||||||
session,
|
|
||||||
keyword=query.keyword,
|
|
||||||
status=query.status,
|
|
||||||
customer_id=query.customer_id,
|
|
||||||
owner_personnel_id=query.owner_personnel_id,
|
|
||||||
date_from=query.date_from,
|
|
||||||
date_to=query.date_to,
|
|
||||||
)
|
|
||||||
return PageResult[BusinessOut](
|
return PageResult[BusinessOut](
|
||||||
records=[_to_out(row) for row in rows],
|
records=[_to_out(row) for row in rows],
|
||||||
total=total,
|
total=total,
|
||||||
@@ -748,6 +732,67 @@ def change_status(
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class _BusinessOwnershipResolution:
|
||||||
|
"""业务归属变更的解析结果(新归属人行 + 变更前固化 ID)。"""
|
||||||
|
|
||||||
|
owner: Personnel | None
|
||||||
|
assistant: Personnel | None
|
||||||
|
old_owner_id: int | None
|
||||||
|
old_assistant_id: int | None
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_business_ownership(
|
||||||
|
session: Session, business: Business, payload: BusinessOwnershipIn
|
||||||
|
) -> _BusinessOwnershipResolution:
|
||||||
|
"""校验新归属人(须在职)并判定归属是否真的变化。
|
||||||
|
|
||||||
|
校验在审计上下文内执行 —— 失败也留痕。两者均未变化 → 400「归属未变化」。
|
||||||
|
"""
|
||||||
|
# 归属变更的出参姓名来自校验函数的返回行(锁定读):首查姓名是变更前
|
||||||
|
# 固化标量,新归属人名必须按新 ID 重取
|
||||||
|
owner = (
|
||||||
|
_ensure_personnel_active(session, payload.new_owner_personnel_id, "负责")
|
||||||
|
if payload.new_owner_personnel_id is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
assistant = (
|
||||||
|
_ensure_personnel_active(session, payload.new_assistant_personnel_id, "协助")
|
||||||
|
if payload.new_assistant_personnel_id is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
owner_changed = business.owner_personnel_id != payload.new_owner_personnel_id
|
||||||
|
assistant_changed = business.assistant_personnel_id != payload.new_assistant_personnel_id
|
||||||
|
if not owner_changed and not assistant_changed:
|
||||||
|
raise BadRequestError("归属未变化")
|
||||||
|
return _BusinessOwnershipResolution(
|
||||||
|
owner=owner,
|
||||||
|
assistant=assistant,
|
||||||
|
old_owner_id=business.owner_personnel_id,
|
||||||
|
old_assistant_id=business.assistant_personnel_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _business_ownership_log(
|
||||||
|
business: Business,
|
||||||
|
payload: BusinessOwnershipIn,
|
||||||
|
resolution: _BusinessOwnershipResolution,
|
||||||
|
operator: User,
|
||||||
|
) -> OwnershipChangeLog:
|
||||||
|
"""归属变更留痕行(负责/协助两个字段的前后值都记)。"""
|
||||||
|
return OwnershipChangeLog(
|
||||||
|
owner_type=OWNER_TYPE_BUSINESS,
|
||||||
|
business_id=business.id,
|
||||||
|
old_owner_personnel_id=resolution.old_owner_id,
|
||||||
|
new_owner_personnel_id=payload.new_owner_personnel_id,
|
||||||
|
old_assistant_personnel_id=resolution.old_assistant_id,
|
||||||
|
new_assistant_personnel_id=payload.new_assistant_personnel_id,
|
||||||
|
change_reason=payload.reason,
|
||||||
|
operator_id=operator.id,
|
||||||
|
operator_name=operator.username,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def change_ownership(
|
def change_ownership(
|
||||||
session: Session,
|
session: Session,
|
||||||
business_id: int,
|
business_id: int,
|
||||||
@@ -779,41 +824,12 @@ def change_ownership(
|
|||||||
if row is None:
|
if row is None:
|
||||||
raise NotFoundError("业务不存在")
|
raise NotFoundError("业务不存在")
|
||||||
business, _, old_owner_name, old_assistant_name = row
|
business, _, old_owner_name, old_assistant_name = row
|
||||||
# 归属变更的出参姓名来自校验函数的返回行(锁定读):首查姓名是变更前
|
resolution = _resolve_business_ownership(session, business, payload)
|
||||||
# 固化标量,新归属人名必须按新 ID 重取
|
|
||||||
new_owner = (
|
|
||||||
_ensure_personnel_active(session, payload.new_owner_personnel_id, "负责")
|
|
||||||
if payload.new_owner_personnel_id is not None
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
new_assistant = (
|
|
||||||
_ensure_personnel_active(session, payload.new_assistant_personnel_id, "协助")
|
|
||||||
if payload.new_assistant_personnel_id is not None
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
|
|
||||||
owner_changed = business.owner_personnel_id != payload.new_owner_personnel_id
|
|
||||||
assistant_changed = business.assistant_personnel_id != payload.new_assistant_personnel_id
|
|
||||||
if not owner_changed and not assistant_changed:
|
|
||||||
raise BadRequestError("归属未变化")
|
|
||||||
|
|
||||||
old_owner_id = business.owner_personnel_id
|
|
||||||
old_assistant_id = business.assistant_personnel_id
|
|
||||||
business.owner_personnel_id = payload.new_owner_personnel_id
|
business.owner_personnel_id = payload.new_owner_personnel_id
|
||||||
business.assistant_personnel_id = payload.new_assistant_personnel_id
|
business.assistant_personnel_id = payload.new_assistant_personnel_id
|
||||||
business_repository.add_ownership_log(
|
business_repository.add_ownership_log(
|
||||||
session,
|
session, _business_ownership_log(business, payload, resolution, operator)
|
||||||
OwnershipChangeLog(
|
|
||||||
owner_type=OWNER_TYPE_BUSINESS,
|
|
||||||
business_id=business_id,
|
|
||||||
old_owner_personnel_id=old_owner_id,
|
|
||||||
new_owner_personnel_id=payload.new_owner_personnel_id,
|
|
||||||
old_assistant_personnel_id=old_assistant_id,
|
|
||||||
new_assistant_personnel_id=payload.new_assistant_personnel_id,
|
|
||||||
change_reason=payload.reason,
|
|
||||||
operator_id=operator.id,
|
|
||||||
operator_name=operator.username,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
# 出参组装:ID 在锁定行上已赋值(_to_out 读内存值即反映新值),姓名
|
# 出参组装:ID 在锁定行上已赋值(_to_out 读内存值即反映新值),姓名
|
||||||
# 一侧「变更项用新 ID 的人名、未变项沿用首查名」——首查名是变更前快照
|
# 一侧「变更项用新 ID 的人名、未变项沿用首查名」——首查名是变更前快照
|
||||||
@@ -821,17 +837,19 @@ def change_ownership(
|
|||||||
(
|
(
|
||||||
business,
|
business,
|
||||||
row[1],
|
row[1],
|
||||||
new_owner.personnel_name if new_owner is not None else old_owner_name,
|
resolution.owner.personnel_name if resolution.owner is not None else old_owner_name,
|
||||||
new_assistant.personnel_name if new_assistant is not None else old_assistant_name,
|
resolution.assistant.personnel_name
|
||||||
|
if resolution.assistant is not None
|
||||||
|
else old_assistant_name,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
session.commit()
|
session.commit()
|
||||||
logger.info(
|
logger.info(
|
||||||
"调整业务归属 | business_id=%s | owner %s->%s | assistant %s->%s",
|
"调整业务归属 | business_id=%s | owner %s->%s | assistant %s->%s",
|
||||||
business_id,
|
business_id,
|
||||||
old_owner_id,
|
resolution.old_owner_id,
|
||||||
payload.new_owner_personnel_id,
|
payload.new_owner_personnel_id,
|
||||||
old_assistant_id,
|
resolution.old_assistant_id,
|
||||||
payload.new_assistant_personnel_id,
|
payload.new_assistant_personnel_id,
|
||||||
)
|
)
|
||||||
return out
|
return out
|
||||||
@@ -897,6 +915,94 @@ def list_status_logs(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class _GrossProfitScan:
|
||||||
|
"""毛利润逐行扫描结果(行 DTO、类别聚合桶、成本缺失计数)。"""
|
||||||
|
|
||||||
|
rows: list[GrossProfitRowOut]
|
||||||
|
category_agg: dict[str, list[Decimal]]
|
||||||
|
missing_count: int
|
||||||
|
|
||||||
|
|
||||||
|
def _gross_profit_row(
|
||||||
|
item: BusinessItem, threshold: Fraction
|
||||||
|
) -> tuple[GrossProfitRowOut, tuple[Decimal, Decimal] | None]:
|
||||||
|
"""单行毛利润派生值(行 DTO + 类别聚合桶)。
|
||||||
|
|
||||||
|
聚合桶为 None 表示成本缺失行 —— 这类行不计入类别汇总,但仍要输出
|
||||||
|
派生列为 None 的行与缺失原因。阈值对两类行都要取(成本缺失也展示阈值)。
|
||||||
|
"""
|
||||||
|
row_kwargs: dict = {
|
||||||
|
"item_id": item.id,
|
||||||
|
"product_category": item.product_category,
|
||||||
|
"product_name": item.product_name,
|
||||||
|
"spec_model": item.spec_model,
|
||||||
|
"quantity": str(item.quantity),
|
||||||
|
"sale_amount": str(item.sale_amount),
|
||||||
|
"threshold": str(threshold),
|
||||||
|
}
|
||||||
|
if item.cost_amount is None:
|
||||||
|
row_kwargs.update(
|
||||||
|
cost_amount=None,
|
||||||
|
profit_amount=None,
|
||||||
|
profit_rate=None,
|
||||||
|
converted_profit=None,
|
||||||
|
missing_reason="成本缺失,无法计算毛利润",
|
||||||
|
)
|
||||||
|
return GrossProfitRowOut(**row_kwargs), None
|
||||||
|
|
||||||
|
profit = item.sale_amount - item.cost_amount
|
||||||
|
rate = profit_rate(profit, item.sale_amount)
|
||||||
|
conv = converted_profit(item.sale_amount, rate, threshold)
|
||||||
|
row_kwargs.update(
|
||||||
|
cost_amount=str(item.cost_amount),
|
||||||
|
profit_amount=str(profit),
|
||||||
|
profit_rate=str(rate),
|
||||||
|
converted_profit=str(conv),
|
||||||
|
missing_reason=None,
|
||||||
|
)
|
||||||
|
return GrossProfitRowOut(**row_kwargs), (item.sale_amount, item.cost_amount)
|
||||||
|
|
||||||
|
|
||||||
|
def _scan_gross_profit_rows(
|
||||||
|
session: Session, business_id: int, threshold_of: Callable[[str], Fraction]
|
||||||
|
) -> _GrossProfitScan:
|
||||||
|
"""逐行扫描业务明细:出行 DTO、类别聚合桶与成本缺失计数。
|
||||||
|
|
||||||
|
类别顺序沿用明细行的首次出现顺序(不排序)—— 前端汇总展示依赖该顺序。
|
||||||
|
"""
|
||||||
|
rows: list[GrossProfitRowOut] = []
|
||||||
|
agg: dict[str, list[Decimal]] = {}
|
||||||
|
missing_count = 0
|
||||||
|
for item in business_repository.list_items(session, business_id):
|
||||||
|
row, bucket = _gross_profit_row(item, threshold_of(item.product_category))
|
||||||
|
rows.append(row)
|
||||||
|
if bucket is None:
|
||||||
|
missing_count += 1
|
||||||
|
continue
|
||||||
|
current = agg.setdefault(item.product_category, [Decimal("0"), Decimal("0")])
|
||||||
|
current[0] += bucket[0]
|
||||||
|
current[1] += bucket[1]
|
||||||
|
return _GrossProfitScan(rows=rows, category_agg=agg, missing_count=missing_count)
|
||||||
|
|
||||||
|
|
||||||
|
def _gross_profit_category(
|
||||||
|
category: str, sale: Decimal, cost: Decimal, threshold: Fraction
|
||||||
|
) -> GrossProfitCategoryOut:
|
||||||
|
"""类别汇总行(派生口径与行级一致:利润率/折算复用 formula 纯函数)。"""
|
||||||
|
profit = sale - cost
|
||||||
|
rate = profit_rate(profit, sale)
|
||||||
|
return GrossProfitCategoryOut(
|
||||||
|
product_category=category,
|
||||||
|
sale_amount=str(sale),
|
||||||
|
cost_amount=str(cost),
|
||||||
|
profit_amount=str(profit),
|
||||||
|
profit_rate=str(rate),
|
||||||
|
threshold=str(threshold),
|
||||||
|
converted_profit=str(converted_profit(sale, rate, threshold)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def gross_profit(session: Session, business_id: int, as_of: date | None) -> GrossProfitOut:
|
def gross_profit(session: Session, business_id: int, as_of: date | None) -> GrossProfitOut:
|
||||||
"""毛利润数据视图(需求 2.2.4):行级派生值 + 类别汇总,实时计算不落库。
|
"""毛利润数据视图(需求 2.2.4):行级派生值 + 类别汇总,实时计算不落库。
|
||||||
|
|
||||||
@@ -915,64 +1021,15 @@ def gross_profit(session: Session, business_id: int, as_of: date | None) -> Gros
|
|||||||
def threshold_of(category: str) -> Fraction:
|
def threshold_of(category: str) -> Fraction:
|
||||||
return snapshot.fraction("product.threshold", product_category=category)
|
return snapshot.fraction("product.threshold", product_category=category)
|
||||||
|
|
||||||
rows_out: list[GrossProfitRowOut] = []
|
scan = _scan_gross_profit_rows(session, business_id, threshold_of)
|
||||||
missing_count = 0
|
|
||||||
# 类别聚合容器:{类别: [sale, cost]},成本缺失行不计入该类别聚合
|
|
||||||
agg: dict[str, list[Decimal]] = {}
|
|
||||||
for item in business_repository.list_items(session, business_id):
|
|
||||||
threshold = threshold_of(item.product_category)
|
|
||||||
row_kwargs: dict = {
|
|
||||||
"item_id": item.id,
|
|
||||||
"product_category": item.product_category,
|
|
||||||
"product_name": item.product_name,
|
|
||||||
"spec_model": item.spec_model,
|
|
||||||
"quantity": str(item.quantity),
|
|
||||||
"sale_amount": str(item.sale_amount),
|
|
||||||
"threshold": str(threshold),
|
|
||||||
}
|
|
||||||
if item.cost_amount is None:
|
|
||||||
missing_count += 1
|
|
||||||
row_kwargs.update(
|
|
||||||
cost_amount=None,
|
|
||||||
profit_amount=None,
|
|
||||||
profit_rate=None,
|
|
||||||
converted_profit=None,
|
|
||||||
missing_reason="成本缺失,无法计算毛利润",
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
profit = item.sale_amount - item.cost_amount
|
|
||||||
rate = profit_rate(profit, item.sale_amount)
|
|
||||||
conv = converted_profit(item.sale_amount, rate, threshold)
|
|
||||||
row_kwargs.update(
|
|
||||||
cost_amount=str(item.cost_amount),
|
|
||||||
profit_amount=str(profit),
|
|
||||||
profit_rate=str(rate),
|
|
||||||
converted_profit=str(conv),
|
|
||||||
missing_reason=None,
|
|
||||||
)
|
|
||||||
bucket = agg.setdefault(item.product_category, [Decimal("0"), Decimal("0")])
|
|
||||||
bucket[0] += item.sale_amount
|
|
||||||
bucket[1] += item.cost_amount
|
|
||||||
rows_out.append(GrossProfitRowOut(**row_kwargs))
|
|
||||||
|
|
||||||
categories = [
|
categories = [
|
||||||
GrossProfitCategoryOut(
|
_gross_profit_category(category, sale, cost, threshold_of(category))
|
||||||
product_category=category,
|
for category, [sale, cost] in scan.category_agg.items()
|
||||||
sale_amount=str(sale),
|
|
||||||
cost_amount=str(cost),
|
|
||||||
profit_amount=str(sale - cost),
|
|
||||||
profit_rate=str(profit_rate(sale - cost, sale)),
|
|
||||||
threshold=str(threshold_of(category)),
|
|
||||||
converted_profit=str(
|
|
||||||
converted_profit(sale, profit_rate(sale - cost, sale), threshold_of(category))
|
|
||||||
),
|
|
||||||
)
|
|
||||||
for category, [sale, cost] in agg.items()
|
|
||||||
]
|
]
|
||||||
return GrossProfitOut(
|
return GrossProfitOut(
|
||||||
business_id=business_id,
|
business_id=business_id,
|
||||||
as_of=as_of,
|
as_of=as_of,
|
||||||
rows=rows_out,
|
rows=scan.rows,
|
||||||
categories=categories,
|
categories=categories,
|
||||||
missing_count=missing_count,
|
missing_count=scan.missing_count,
|
||||||
)
|
)
|
||||||
@@ -60,7 +60,7 @@ from app.services.param_service import parse_fraction, resolve_snapshot
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# snapshot_json 里的策略点注记(口径检索锚点;#12 已参数化,开关 0 时留此注记)
|
# snapshot_json 里的策略点注记(口径检索锚点;#12 已参数化,开关 0 时留此注记)
|
||||||
_NOTE_MANAGER_PENDING = "经理个人户单笔不计提(基数开关=0):聚合口径在月结时由团队聚合计算"
|
_NOTE_MANAGER_PENDING = "经理个人户单笔不计提(未计入分成基数):绩效在月结时按团队与全公司分成计算"
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
@@ -544,7 +544,7 @@ def _personal_rate(
|
|||||||
if customer_type == "enterprise":
|
if customer_type == "enterprise":
|
||||||
# 存储口径 ‰ → 纯比例(漏换算放大 1000 倍,与预览同防线)
|
# 存储口径 ‰ → 纯比例(漏换算放大 1000 倍,与预览同防线)
|
||||||
rate = parse_fraction(snapshot.get("enterprise.rate")) / 1000
|
rate = parse_fraction(snapshot.get("enterprise.rate")) / 1000
|
||||||
return _RateDecision(rate, "企业户固定计提比例", None, False)
|
return _RateDecision(rate, "企业户:不按回款天数,统一按固定比例计提", None, False)
|
||||||
|
|
||||||
template = role.calc_template if role else "TPL_SALESPERSON_DECAY"
|
template = role.calc_template if role else "TPL_SALESPERSON_DECAY"
|
||||||
if template == "TPL_MANAGER_SHARE":
|
if template == "TPL_MANAGER_SHARE":
|
||||||
@@ -554,7 +554,7 @@ def _personal_rate(
|
|||||||
if snapshot.get("manager.personal.included") != "1":
|
if snapshot.get("manager.personal.included") != "1":
|
||||||
return _RateDecision(
|
return _RateDecision(
|
||||||
Decimal("0"),
|
Decimal("0"),
|
||||||
"经理个人户:12.5%+5% 聚合计提(月结时计算),单笔落 0",
|
"经理个人户:绩效按团队与全公司分成在月结时计算,单笔不计提",
|
||||||
None,
|
None,
|
||||||
True,
|
True,
|
||||||
)
|
)
|
||||||
@@ -563,12 +563,12 @@ def _personal_rate(
|
|||||||
floor = parse_fraction(snapshot.get("salesperson.rate.floor"))
|
floor = parse_fraction(snapshot.get("salesperson.rate.floor"))
|
||||||
boundary = int(parse_fraction(snapshot.get("salesperson.days.boundary")))
|
boundary = int(parse_fraction(snapshot.get("salesperson.days.boundary")))
|
||||||
rate = salesperson_rate(days, start=start, slope=slope, floor=floor, boundary_days=boundary)
|
rate = salesperson_rate(days, start=start, slope=slope, floor=floor, boundary_days=boundary)
|
||||||
note = f"经理个人户(基数开关=1):按业务员递减 {start}‰ 起,{days} 天"
|
note = f"经理个人户(已开启计入分成基数):按业务员递减,回款 {days} 天,起始 {start}‰"
|
||||||
return _RateDecision(rate, note, None, False)
|
return _RateDecision(rate, note, None, False)
|
||||||
if template == "TPL_INFO_SALES_FIXED":
|
if template == "TPL_INFO_SALES_FIXED":
|
||||||
# 信息员基数=本人销售额(覆盖折算与阈值),‰ 换算同防线
|
# 信息员基数=本人销售额(覆盖折算与阈值),‰ 换算同防线
|
||||||
rate = parse_fraction(snapshot.get("info.rate")) / 1000
|
rate = parse_fraction(snapshot.get("info.rate")) / 1000
|
||||||
return _RateDecision(rate, "信息员:本人销售额 × 计提比例", None, False)
|
return _RateDecision(rate, "信息员:按本人销售额乘计提比例,不随回款天数递减", None, False)
|
||||||
if template == "TPL_SALESPERSON_DECAY":
|
if template == "TPL_SALESPERSON_DECAY":
|
||||||
start = parse_fraction(snapshot.get("salesperson.rate.start"))
|
start = parse_fraction(snapshot.get("salesperson.rate.start"))
|
||||||
slope = parse_fraction(snapshot.get("salesperson.rate.slope"))
|
slope = parse_fraction(snapshot.get("salesperson.rate.slope"))
|
||||||
@@ -577,9 +577,16 @@ def _personal_rate(
|
|||||||
# 经 Fraction 转换再取整(INT 型参数入库时已校验分母为 1,取整无损)
|
# 经 Fraction 转换再取整(INT 型参数入库时已校验分母为 1,取整无损)
|
||||||
boundary = int(parse_fraction(snapshot.get("salesperson.days.boundary")))
|
boundary = int(parse_fraction(snapshot.get("salesperson.days.boundary")))
|
||||||
rate = salesperson_rate(days, start=start, slope=slope, floor=floor, boundary_days=boundary)
|
rate = salesperson_rate(days, start=start, slope=slope, floor=floor, boundary_days=boundary)
|
||||||
note = f"业务员递减:{start}‰ 起,{days} 天(分界 {boundary} 天,超界取 {floor}‰)"
|
note = (
|
||||||
|
f"回款 {days} 天,从起始 {start}‰ 按日递减"
|
||||||
|
f"(满 {boundary} 天后固定按 {floor}‰ 计算)"
|
||||||
|
)
|
||||||
return _RateDecision(rate, note, None, False)
|
return _RateDecision(rate, note, None, False)
|
||||||
raise BadRequestError(f"公式模板 {template} 暂不支持核算")
|
# 提示给模板中文名而非编码:业务人员在岗位角色页看到的是「营销经理·团队与全公司分成」
|
||||||
|
from app.services.calc_templates import TEMPLATES
|
||||||
|
|
||||||
|
tpl_name = TEMPLATES[template].name if template in TEMPLATES else template
|
||||||
|
raise BadRequestError(f"「{tpl_name}」不支持单笔核算,请检查该人员的岗位设置")
|
||||||
|
|
||||||
|
|
||||||
def _role_of(session: Session, person):
|
def _role_of(session: Session, person):
|
||||||
|
|||||||
@@ -14,6 +14,8 @@
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
from sqlalchemy import Row
|
from sqlalchemy import Row
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
@@ -37,6 +39,7 @@ from app.schemas.customer import (
|
|||||||
CustomerOut,
|
CustomerOut,
|
||||||
CustomerOwnershipIn,
|
CustomerOwnershipIn,
|
||||||
CustomerQuery,
|
CustomerQuery,
|
||||||
|
CustomerRatingIn,
|
||||||
CustomerUpdateIn,
|
CustomerUpdateIn,
|
||||||
OwnershipChangeLogOut,
|
OwnershipChangeLogOut,
|
||||||
)
|
)
|
||||||
@@ -60,6 +63,8 @@ def _to_out(customer: Customer, owner_name: str | None) -> CustomerOut:
|
|||||||
owner_personnel_id=customer.owner_personnel_id,
|
owner_personnel_id=customer.owner_personnel_id,
|
||||||
owner_personnel_name=owner_name,
|
owner_personnel_name=owner_name,
|
||||||
remark=customer.remark,
|
remark=customer.remark,
|
||||||
|
rating_score=customer.rating_score,
|
||||||
|
rating_time=customer.rating_time,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -107,20 +112,8 @@ def _is_duplicate_entry(exc: IntegrityError) -> bool:
|
|||||||
|
|
||||||
def list_customers(session: Session, query: CustomerQuery) -> PageResult[CustomerOut]:
|
def list_customers(session: Session, query: CustomerQuery) -> PageResult[CustomerOut]:
|
||||||
"""分页查询客户(附归属人员姓名)。"""
|
"""分页查询客户(附归属人员姓名)。"""
|
||||||
rows = customer_repository.list_customers(
|
rows = customer_repository.list_customers(session, query)
|
||||||
session,
|
total = customer_repository.count_customers(session, query)
|
||||||
offset=query.offset,
|
|
||||||
limit=query.page_size,
|
|
||||||
keyword=query.keyword,
|
|
||||||
customer_type=query.customer_type,
|
|
||||||
owner_personnel_id=query.owner_personnel_id,
|
|
||||||
)
|
|
||||||
total = customer_repository.count_customers(
|
|
||||||
session,
|
|
||||||
keyword=query.keyword,
|
|
||||||
customer_type=query.customer_type,
|
|
||||||
owner_personnel_id=query.owner_personnel_id,
|
|
||||||
)
|
|
||||||
return PageResult[CustomerOut](
|
return PageResult[CustomerOut](
|
||||||
records=[_to_out(customer, owner_name) for customer, owner_name in rows],
|
records=[_to_out(customer, owner_name) for customer, owner_name in rows],
|
||||||
total=total,
|
total=total,
|
||||||
@@ -274,6 +267,53 @@ def update_customer(
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def rate_customer(
|
||||||
|
session: Session,
|
||||||
|
customer_id: int,
|
||||||
|
payload: CustomerRatingIn,
|
||||||
|
*,
|
||||||
|
operator: User,
|
||||||
|
meta: RequestMeta | None = None,
|
||||||
|
) -> CustomerOut:
|
||||||
|
"""保存客户评级(评级计算器「保存评分」)。
|
||||||
|
|
||||||
|
评分是客户档案属性而非审计事件:只存总分与评级时间,五项明细与所用
|
||||||
|
等级配置不落库(对照表 #25 的定位口径)——等级映射是可变判定表,回溯
|
||||||
|
「当时命中的等级」需要快照设计,超出档案属性的范围;评级时间已足够
|
||||||
|
回答「这个分是什么时候打的」。等级由前端按当前映射实时算并展示。
|
||||||
|
|
||||||
|
:raises NotFoundError: 客户不存在
|
||||||
|
"""
|
||||||
|
# 客户名进审计摘要,预取放在审计上下文之前(同 change_ownership 口径)
|
||||||
|
row = customer_repository.get_with_owner_name(session, customer_id)
|
||||||
|
customer_name = row[0].company_name if row else f"#{customer_id}"
|
||||||
|
with audit_service.audit(
|
||||||
|
session,
|
||||||
|
operator=operator,
|
||||||
|
record=AuditRecord(
|
||||||
|
module=MODULE_CUSTOMER,
|
||||||
|
action=ACTION_UPDATE,
|
||||||
|
summary=f"保存客户评级 {customer_name}:{payload.score} 分",
|
||||||
|
request_data=payload.model_dump(),
|
||||||
|
),
|
||||||
|
meta=meta,
|
||||||
|
):
|
||||||
|
if row is None:
|
||||||
|
raise NotFoundError("客户不存在")
|
||||||
|
customer, owner_name = row
|
||||||
|
|
||||||
|
customer.rating_score = payload.score
|
||||||
|
# 评级时间取应用层本地时间(库列 TZ=Asia/Shanghai,与 TimestampMixin
|
||||||
|
# 的服务器默认值同一时区口径),不用数据库 NOW():行已在本会话
|
||||||
|
customer.rating_time = datetime.now()
|
||||||
|
|
||||||
|
# 响应在 commit 之前构造(同 user_service 顶部说明)
|
||||||
|
out = _to_out(customer, owner_name)
|
||||||
|
session.commit()
|
||||||
|
logger.info("保存客户评级 | customer_id=%s | score=%s", customer_id, payload.score)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def delete_customer(
|
def delete_customer(
|
||||||
session: Session,
|
session: Session,
|
||||||
customer_id: int,
|
customer_id: int,
|
||||||
@@ -317,6 +357,71 @@ def delete_customer(
|
|||||||
logger.info("删除客户 | customer_id=%s | company=%s", customer_id, company_name)
|
logger.info("删除客户 | customer_id=%s | company=%s", customer_id, company_name)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class _CustomerOwnershipPrefetch:
|
||||||
|
"""客户归属变更的审计前只读预取(摘要要客户名与新旧归属人名)。"""
|
||||||
|
|
||||||
|
row: Row | None
|
||||||
|
new_person: Personnel | None
|
||||||
|
old_label: str
|
||||||
|
new_label: str
|
||||||
|
customer_name: str
|
||||||
|
|
||||||
|
|
||||||
|
def _prefetch_customer_ownership(
|
||||||
|
session: Session, customer_id: int, payload: CustomerOwnershipIn
|
||||||
|
) -> _CustomerOwnershipPrefetch:
|
||||||
|
"""审计上下文之前的只读预取(预取不写库,失败路径也要有可读摘要)。"""
|
||||||
|
row = customer_repository.get_with_owner_name(session, customer_id)
|
||||||
|
old_person = (
|
||||||
|
personnel_repository.get_by_id(session, row[0].owner_personnel_id)
|
||||||
|
if row is not None and row[0].owner_personnel_id is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
new_person = (
|
||||||
|
personnel_repository.get_by_id(session, payload.new_owner_personnel_id)
|
||||||
|
if payload.new_owner_personnel_id is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
return _CustomerOwnershipPrefetch(
|
||||||
|
row=row,
|
||||||
|
new_person=new_person,
|
||||||
|
old_label=old_person.personnel_name if old_person else "无",
|
||||||
|
new_label=new_person.personnel_name if new_person else "无",
|
||||||
|
customer_name=row[0].company_name if row else f"#{customer_id}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_customer_ownership(
|
||||||
|
session: Session, customer: Customer, payload: CustomerOwnershipIn
|
||||||
|
) -> int | None:
|
||||||
|
"""校验新归属人(须在职)并判定归属是否变化,返回变更前归属人 ID。"""
|
||||||
|
if payload.new_owner_personnel_id is not None:
|
||||||
|
_ensure_owner_personnel(session, payload.new_owner_personnel_id)
|
||||||
|
old_owner_id = customer.owner_personnel_id
|
||||||
|
if old_owner_id == payload.new_owner_personnel_id:
|
||||||
|
raise BadRequestError("归属未变化")
|
||||||
|
return old_owner_id
|
||||||
|
|
||||||
|
|
||||||
|
def _customer_ownership_log(
|
||||||
|
customer: Customer,
|
||||||
|
payload: CustomerOwnershipIn,
|
||||||
|
old_owner_id: int | None,
|
||||||
|
operator: User,
|
||||||
|
) -> OwnershipChangeLog:
|
||||||
|
"""客户归属变更留痕行。"""
|
||||||
|
return OwnershipChangeLog(
|
||||||
|
owner_type=OWNER_TYPE_CUSTOMER,
|
||||||
|
customer_id=customer.id,
|
||||||
|
old_owner_personnel_id=old_owner_id,
|
||||||
|
new_owner_personnel_id=payload.new_owner_personnel_id,
|
||||||
|
change_reason=payload.reason,
|
||||||
|
operator_id=operator.id,
|
||||||
|
operator_name=operator.username,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def change_ownership(
|
def change_ownership(
|
||||||
session: Session,
|
session: Session,
|
||||||
customer_id: int,
|
customer_id: int,
|
||||||
@@ -335,57 +440,34 @@ def change_ownership(
|
|||||||
"""
|
"""
|
||||||
# 只读预取在审计上下文之前:摘要需要客户名与新旧归属人名;校验仍在
|
# 只读预取在审计上下文之前:摘要需要客户名与新旧归属人名;校验仍在
|
||||||
# 上下文内进行,失败同样要留下可追溯的记录
|
# 上下文内进行,失败同样要留下可追溯的记录
|
||||||
row = customer_repository.get_with_owner_name(session, customer_id)
|
prefetch = _prefetch_customer_ownership(session, customer_id, payload)
|
||||||
old_person = (
|
|
||||||
personnel_repository.get_by_id(session, row[0].owner_personnel_id)
|
|
||||||
if row is not None and row[0].owner_personnel_id is not None
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
new_person = (
|
|
||||||
personnel_repository.get_by_id(session, payload.new_owner_personnel_id)
|
|
||||||
if payload.new_owner_personnel_id is not None
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
old_label = old_person.personnel_name if old_person else "无"
|
|
||||||
new_label = new_person.personnel_name if new_person else "无"
|
|
||||||
customer_name = row[0].company_name if row else f"#{customer_id}"
|
|
||||||
with audit_service.audit(
|
with audit_service.audit(
|
||||||
session,
|
session,
|
||||||
operator=operator,
|
operator=operator,
|
||||||
record=AuditRecord(
|
record=AuditRecord(
|
||||||
module=MODULE_CUSTOMER,
|
module=MODULE_CUSTOMER,
|
||||||
action=ACTION_UPDATE,
|
action=ACTION_UPDATE,
|
||||||
summary=f"调整客户归属 {customer_name}:{old_label} → {new_label}",
|
summary=(
|
||||||
|
f"调整客户归属 {prefetch.customer_name}:"
|
||||||
|
f"{prefetch.old_label} → {prefetch.new_label}"
|
||||||
|
),
|
||||||
request_data=payload.model_dump(exclude_none=True),
|
request_data=payload.model_dump(exclude_none=True),
|
||||||
),
|
),
|
||||||
meta=meta,
|
meta=meta,
|
||||||
):
|
):
|
||||||
if row is None:
|
if prefetch.row is None:
|
||||||
raise NotFoundError("客户不存在")
|
raise NotFoundError("客户不存在")
|
||||||
customer, _ = row
|
customer, _ = prefetch.row
|
||||||
|
|
||||||
if payload.new_owner_personnel_id is not None:
|
old_owner_id = _validate_customer_ownership(session, customer, payload)
|
||||||
_ensure_owner_personnel(session, payload.new_owner_personnel_id)
|
|
||||||
old_owner_id = customer.owner_personnel_id
|
|
||||||
if old_owner_id == payload.new_owner_personnel_id:
|
|
||||||
raise BadRequestError("归属未变化")
|
|
||||||
|
|
||||||
# 主表更新与留痕插入同事务:只在本方法内一次 commit(见模块 docstring)
|
# 主表更新与留痕插入同事务:只在本方法内一次 commit(见模块 docstring)
|
||||||
customer.owner_personnel_id = payload.new_owner_personnel_id
|
customer.owner_personnel_id = payload.new_owner_personnel_id
|
||||||
customer_repository.add_ownership_log(
|
customer_repository.add_ownership_log(
|
||||||
session,
|
session, _customer_ownership_log(customer, payload, old_owner_id, operator)
|
||||||
OwnershipChangeLog(
|
|
||||||
owner_type=OWNER_TYPE_CUSTOMER,
|
|
||||||
customer_id=customer.id,
|
|
||||||
old_owner_personnel_id=old_owner_id,
|
|
||||||
new_owner_personnel_id=payload.new_owner_personnel_id,
|
|
||||||
change_reason=payload.reason,
|
|
||||||
operator_id=operator.id,
|
|
||||||
operator_name=operator.username,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
# 响应在 commit 之前构造(同 user_service 顶部说明);归属人名以新值为准
|
# 响应在 commit 之前构造(同 user_service 顶部说明);归属人名以新值为准
|
||||||
out = _to_out(customer, new_person.personnel_name if new_person else None)
|
out = _to_out(customer, prefetch.new_person.personnel_name if prefetch.new_person else None)
|
||||||
session.commit()
|
session.commit()
|
||||||
logger.info(
|
logger.info(
|
||||||
"调整客户归属 | customer_id=%s | old=%s | new=%s",
|
"调整客户归属 | customer_id=%s | old=%s | new=%s",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""工作台服务(M4 ⑧):四区块实时聚合编排。
|
"""工作台服务(M4 ⑧):区块实时聚合编排 + 最近动态分页。
|
||||||
|
|
||||||
设计决策(2026-10-19):
|
设计决策(2026-10-19):
|
||||||
- **只读**:不落库、不写审计(读操作不入操作日志);总览只做 SQL 聚合,
|
- **只读**:不落库、不写审计(读操作不入操作日志);总览只做 SQL 聚合,
|
||||||
@@ -9,6 +9,8 @@
|
|||||||
- **⑥ 同步模块未启用**:同步失败待办、同步连续失败预警以
|
- **⑥ 同步模块未启用**:同步失败待办、同步连续失败预警以
|
||||||
``sync_available=false`` 显式降级,不返回伪造的 0 条
|
``sync_available=false`` 显式降级,不返回伪造的 0 条
|
||||||
- 预警阈值走参数体系(``warning.*``),一次请求只解析一次快照
|
- 预警阈值走参数体系(``warning.*``),一次请求只解析一次快照
|
||||||
|
- **最近动态独立分页**(2026-10-23):操作日志可能很长,聚合响应里
|
||||||
|
只能塞前 20 条;拆成独立端点后翻页只重取这张列表
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import date, datetime, timedelta
|
from datetime import date, datetime, timedelta
|
||||||
@@ -20,22 +22,23 @@ from app.core.exceptions import BadRequestError
|
|||||||
from app.core.month import validate_month
|
from app.core.month import validate_month
|
||||||
from app.repositories import dashboard_repository, operation_log_repository, user_repository
|
from app.repositories import dashboard_repository, operation_log_repository, user_repository
|
||||||
from app.repositories.query_utils import LogFilters
|
from app.repositories.query_utils import LogFilters
|
||||||
|
from app.schemas.dashboard import ActivityItem, ActivityPageOut, ActivityQuery
|
||||||
from app.services import approval_service
|
from app.services import approval_service
|
||||||
|
|
||||||
# 工作台只展示摘要,完整列表跳转对应管理页
|
# 工作台只展示摘要,完整列表跳转对应管理页
|
||||||
_TODO_LIMIT = 5
|
_TODO_LIMIT = 5
|
||||||
_WARNING_LIMIT = 20
|
_WARNING_LIMIT = 20
|
||||||
_ACTIVITY_LIMIT = 20
|
|
||||||
_TREND_MONTHS = 6
|
_TREND_MONTHS = 6
|
||||||
|
|
||||||
_ORDER_TYPE_LABELS = {1: "二次分配方案", 2: "月度结算", 3: "调整单"}
|
_ORDER_TYPE_LABELS = {1: "二次分配方案", 2: "月度结算", 3: "调整单"}
|
||||||
|
|
||||||
|
|
||||||
def get_dashboard(session: Session, month: str | None = None, *, current_user) -> dict:
|
def get_dashboard(session: Session, month: str | None = None, *, current_user) -> dict:
|
||||||
"""四区块一次性出数(前端一次请求渲染整页)。
|
"""三区块一次性出数(前端一次请求渲染整页)。
|
||||||
|
|
||||||
待办按当前用户角色只展示其可操作节点(admin 可查看全部在途单);
|
待办按当前用户角色只展示其可操作节点(admin 可查看全部在途单);
|
||||||
风险预警仍是全局经营视角,不按人裁剪。
|
风险预警仍是全局经营视角,不按人裁剪。
|
||||||
|
最近动态不在此返回 —— 它是分页列表,走 ``list_activities``。
|
||||||
"""
|
"""
|
||||||
resolved = _normalize_month(month)
|
resolved = _normalize_month(month)
|
||||||
warning_params = _warning_params(session)
|
warning_params = _warning_params(session)
|
||||||
@@ -47,7 +50,6 @@ def get_dashboard(session: Session, month: str | None = None, *, current_user) -
|
|||||||
"overview": _overview(session, resolved),
|
"overview": _overview(session, resolved),
|
||||||
"todos": _todos(session, pending_statuses),
|
"todos": _todos(session, pending_statuses),
|
||||||
"warnings": _warnings(session, warning_params),
|
"warnings": _warnings(session, warning_params),
|
||||||
"activities": _activities(session),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -112,8 +114,8 @@ def _overview(session: Session, month: str) -> dict:
|
|||||||
for item in months
|
for item in months
|
||||||
],
|
],
|
||||||
"note": (
|
"note": (
|
||||||
"绩效按台账归属月(单笔生效行,二次分摊拆行求和不失真);回款按到账月"
|
"绩效按台账归属月统计(二次分配按参与人拆分后求和,不重复不计漏);"
|
||||||
"(含未核算回款);不含经理 12.5%+5% 聚合分成与调整单"
|
"回款按到账月统计(含尚未核算的回款);不含经理团队分成与调整单"
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,9 +170,7 @@ def _warning_params(session: Session) -> dict:
|
|||||||
return {
|
return {
|
||||||
"enabled": snapshot.get("warning.enabled") == "1",
|
"enabled": snapshot.get("warning.enabled") == "1",
|
||||||
"receipt_overdue_days": int(parse_fraction(snapshot.get("warning.receipt.overdue.days"))),
|
"receipt_overdue_days": int(parse_fraction(snapshot.get("warning.receipt.overdue.days"))),
|
||||||
"approval_timeout_days": int(
|
"approval_timeout_days": int(parse_fraction(snapshot.get("warning.approval.timeout.days"))),
|
||||||
parse_fraction(snapshot.get("warning.approval.timeout.days"))
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -249,26 +249,38 @@ def _warnings(session: Session, params: dict) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _activities(session: Session) -> list[dict]:
|
def list_activities(session: Session, query: ActivityQuery) -> ActivityPageOut:
|
||||||
"""最近动态(FR-036):以操作日志为唯一来源(写操作即留痕)。"""
|
"""最近动态分页(FR-036):以操作日志为唯一来源(写操作即留痕)。
|
||||||
logs = operation_log_repository.list_logs(
|
|
||||||
session,
|
模块筛选项与列表同源返回(全量模块名,不随当前页变化),
|
||||||
offset=0,
|
前端模块下拉不必另取一次全量。
|
||||||
limit=_ACTIVITY_LIMIT,
|
"""
|
||||||
|
repo_query = operation_log_repository.OperationLogListQuery(
|
||||||
filters=LogFilters(),
|
filters=LogFilters(),
|
||||||
|
offset=query.offset,
|
||||||
|
limit=query.page_size,
|
||||||
|
module=query.module,
|
||||||
|
)
|
||||||
|
logs = operation_log_repository.list_logs(session, repo_query)
|
||||||
|
total = operation_log_repository.count_logs(session, repo_query)
|
||||||
|
return ActivityPageOut(
|
||||||
|
records=[
|
||||||
|
ActivityItem(
|
||||||
|
id=log.id,
|
||||||
|
module=log.module,
|
||||||
|
action=log.action,
|
||||||
|
summary=log.summary,
|
||||||
|
username=log.username,
|
||||||
|
success=log.success,
|
||||||
|
create_time=log.create_time,
|
||||||
)
|
)
|
||||||
return [
|
|
||||||
{
|
|
||||||
"id": log.id,
|
|
||||||
"module": log.module,
|
|
||||||
"action": log.action,
|
|
||||||
"summary": log.summary,
|
|
||||||
"username": log.username,
|
|
||||||
"success": log.success,
|
|
||||||
"create_time": log.create_time,
|
|
||||||
}
|
|
||||||
for log in logs
|
for log in logs
|
||||||
]
|
],
|
||||||
|
total=total,
|
||||||
|
page_num=query.page_num,
|
||||||
|
page_size=query.page_size,
|
||||||
|
modules=operation_log_repository.distinct_modules(session),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _dec(value) -> Decimal | None:
|
def _dec(value) -> Decimal | None:
|
||||||
|
|||||||
@@ -22,18 +22,15 @@ from app.schemas.log import (
|
|||||||
|
|
||||||
def list_operation_logs(session: Session, query: OperationLogQuery) -> PageResult[OperationLogOut]:
|
def list_operation_logs(session: Session, query: OperationLogQuery) -> PageResult[OperationLogOut]:
|
||||||
"""分页查询操作日志(列表不含 request_data,详情走 `get_operation_log`)。"""
|
"""分页查询操作日志(列表不含 request_data,详情走 `get_operation_log`)。"""
|
||||||
filters = _filters_from(query)
|
repo_query = operation_log_repository.OperationLogListQuery(
|
||||||
logs = operation_log_repository.list_logs(
|
filters=_filters_from(query),
|
||||||
session,
|
|
||||||
offset=query.offset,
|
offset=query.offset,
|
||||||
limit=query.page_size,
|
limit=query.page_size,
|
||||||
filters=filters,
|
|
||||||
module=query.module,
|
module=query.module,
|
||||||
action=query.action,
|
action=query.action,
|
||||||
)
|
)
|
||||||
total = operation_log_repository.count_logs(
|
logs = operation_log_repository.list_logs(session, repo_query)
|
||||||
session, filters=filters, module=query.module, action=query.action
|
total = operation_log_repository.count_logs(session, repo_query)
|
||||||
)
|
|
||||||
return PageResult[OperationLogOut](
|
return PageResult[OperationLogOut](
|
||||||
records=[OperationLogOut.model_validate(log) for log in logs],
|
records=[OperationLogOut.model_validate(log) for log in logs],
|
||||||
total=total,
|
total=total,
|
||||||
@@ -55,15 +52,14 @@ def get_operation_log(session: Session, log_id: int) -> OperationLogDetailOut:
|
|||||||
|
|
||||||
def list_login_logs(session: Session, query: LoginLogQuery) -> PageResult[LoginLogOut]:
|
def list_login_logs(session: Session, query: LoginLogQuery) -> PageResult[LoginLogOut]:
|
||||||
"""分页查询登录日志。"""
|
"""分页查询登录日志。"""
|
||||||
filters = _filters_from(query)
|
repo_query = login_log_repository.LoginLogListQuery(
|
||||||
logs = login_log_repository.list_logs(
|
filters=_filters_from(query),
|
||||||
session,
|
|
||||||
offset=query.offset,
|
offset=query.offset,
|
||||||
limit=query.page_size,
|
limit=query.page_size,
|
||||||
filters=filters,
|
|
||||||
ip=query.ip,
|
ip=query.ip,
|
||||||
)
|
)
|
||||||
total = login_log_repository.count_logs(session, filters=filters, ip=query.ip)
|
logs = login_log_repository.list_logs(session, repo_query)
|
||||||
|
total = login_log_repository.count_logs(session, repo_query)
|
||||||
return PageResult[LoginLogOut](
|
return PageResult[LoginLogOut](
|
||||||
records=[LoginLogOut.model_validate(log) for log in logs],
|
records=[LoginLogOut.model_validate(log) for log in logs],
|
||||||
total=total,
|
total=total,
|
||||||
|
|||||||
@@ -3,6 +3,10 @@
|
|||||||
注册表是**迁移种子与运行期校验的唯一来源**:编码、默认值、取值域在此定义
|
注册表是**迁移种子与运行期校验的唯一来源**:编码、默认值、取值域在此定义
|
||||||
一次;新增参数 = 改这里 + 生成迁移种子(两者一致性由测试锁定)。
|
一次;新增参数 = 改这里 + 生成迁移种子(两者一致性由测试锁定)。
|
||||||
值一律以 Fraction 可解析字符串表示(如 "4/360",禁 0.011111 —— 见算法红线)。
|
值一律以 Fraction 可解析字符串表示(如 "4/360",禁 0.011111 —— 见算法红线)。
|
||||||
|
|
||||||
|
`description` 会直接显示在「计提参数」页的参数名下方,**面向业务人员撰写**:
|
||||||
|
说清这个参数管什么、钱怎么变、什么时候该调它。不写英文枚举名、需求编号、
|
||||||
|
内部阶段号,也不写实现层面的精度约束(那些留在代码注释与测试里)。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
@@ -43,7 +47,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
|||||||
default_value="10",
|
default_value="10",
|
||||||
value_min="0",
|
value_min="0",
|
||||||
value_max=None,
|
value_max=None,
|
||||||
description="线性递减段起始值:比例 = 起始 − 回款天数 × 斜率(≤分界天数)",
|
description="回款当天能拿到的最高计提比例。回款每晚一天,按日递减斜率往下走,直到分界天数为止。",
|
||||||
sort_order=1,
|
sort_order=1,
|
||||||
),
|
),
|
||||||
ParamSpec(
|
ParamSpec(
|
||||||
@@ -57,7 +61,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
|||||||
default_value="4/360",
|
default_value="4/360",
|
||||||
value_min="0",
|
value_min="0",
|
||||||
value_max=None,
|
value_max=None,
|
||||||
description="精确分数 4/360‰/天(=1/90),禁止按 0.011111 截断存储",
|
description="回款每延后一天,计提比例下降多少。按分数填写(如 4/360)可保持精确,填小数会引入四舍五入误差。",
|
||||||
sort_order=2,
|
sort_order=2,
|
||||||
),
|
),
|
||||||
ParamSpec(
|
ParamSpec(
|
||||||
@@ -71,7 +75,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
|||||||
default_value="4",
|
default_value="4",
|
||||||
value_min="0",
|
value_min="0",
|
||||||
value_max=None,
|
value_max=None,
|
||||||
description="回款天数超过分界天数后固定取此比例(≥361 天 = 4‰)",
|
description="回款天数超过分界天数后,计提比例不再下降,固定按这个值计算,即最低计提比例。",
|
||||||
sort_order=3,
|
sort_order=3,
|
||||||
),
|
),
|
||||||
ParamSpec(
|
ParamSpec(
|
||||||
@@ -85,7 +89,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
|||||||
default_value="360",
|
default_value="360",
|
||||||
value_min="0",
|
value_min="0",
|
||||||
value_max=None,
|
value_max=None,
|
||||||
description="≤分界天数按公式线性递减(360 天=6‰);超过则取下限",
|
description="回款天数在这个范围内按日递减;超过这个天数就固定按下限计提。默认 360 天。",
|
||||||
sort_order=4,
|
sort_order=4,
|
||||||
),
|
),
|
||||||
ParamSpec(
|
ParamSpec(
|
||||||
@@ -99,7 +103,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
|||||||
default_value="12.5",
|
default_value="12.5",
|
||||||
value_min="0",
|
value_min="0",
|
||||||
value_max="100",
|
value_max="100",
|
||||||
description="∑本人带领业务员绩效 × 此比例(限本团队)",
|
description="经理按本团队业务员的绩效总额提取的分成比例,只统计自己带的业务员。",
|
||||||
sort_order=5,
|
sort_order=5,
|
||||||
),
|
),
|
||||||
ParamSpec(
|
ParamSpec(
|
||||||
@@ -113,7 +117,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
|||||||
default_value="5",
|
default_value="5",
|
||||||
value_min="0",
|
value_min="0",
|
||||||
value_max="100",
|
value_max="100",
|
||||||
description="∑全部业务员绩效 × 此比例(全公司)",
|
description="经理按全公司业务员的绩效总额提取的分成比例,与本团队分成叠加计算。",
|
||||||
sort_order=6,
|
sort_order=6,
|
||||||
),
|
),
|
||||||
ParamSpec(
|
ParamSpec(
|
||||||
@@ -127,7 +131,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
|||||||
default_value="3",
|
default_value="3",
|
||||||
value_min="0",
|
value_min="0",
|
||||||
value_max=None,
|
value_max=None,
|
||||||
description="所有企业户适用;2026-08-13 起由 2‰ 提高至 3‰",
|
description="企业户客户的回款统一按这个比例计提,不看回款天数。2026-08-13 起由 2‰ 调整为 3‰。",
|
||||||
sort_order=7,
|
sort_order=7,
|
||||||
),
|
),
|
||||||
ParamSpec(
|
ParamSpec(
|
||||||
@@ -141,7 +145,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
|||||||
default_value="1",
|
default_value="1",
|
||||||
value_min="0",
|
value_min="0",
|
||||||
value_max=None,
|
value_max=None,
|
||||||
description="信息员绩效 = 本人销售额 × 此比例",
|
description="信息员的绩效按本人销售额乘这个比例计算,不随回款天数递减。",
|
||||||
sort_order=8,
|
sort_order=8,
|
||||||
),
|
),
|
||||||
ParamSpec(
|
ParamSpec(
|
||||||
@@ -155,7 +159,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
|||||||
default_value="10",
|
default_value="10",
|
||||||
value_min="0",
|
value_min="0",
|
||||||
value_max=None,
|
value_max=None,
|
||||||
description="预付款统一按此比例计提",
|
description="预付款到账即按这个比例计提绩效,不必等到全额回款。",
|
||||||
sort_order=9,
|
sort_order=9,
|
||||||
),
|
),
|
||||||
ParamSpec(
|
ParamSpec(
|
||||||
@@ -169,7 +173,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
|||||||
default_value="80",
|
default_value="80",
|
||||||
value_min="0",
|
value_min="0",
|
||||||
value_max="100",
|
value_max="100",
|
||||||
description="以物抵顶回款绩效按应发绩效的此比例折算",
|
description="以物抵顶的回款,按应发绩效的这个比例折算,即打折后的金额参与绩效。",
|
||||||
sort_order=10,
|
sort_order=10,
|
||||||
),
|
),
|
||||||
ParamSpec(
|
ParamSpec(
|
||||||
@@ -183,7 +187,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
|||||||
default_value="60",
|
default_value="60",
|
||||||
value_min="0",
|
value_min="0",
|
||||||
value_max="100",
|
value_max="100",
|
||||||
description="单项业务回款额达销售额(不含运费)此比例后起付;达标前累计",
|
description="单项业务的回款累计达到销售额(不含运费)的这个比例后才开始发放绩效;达标之前只累计、不发放。",
|
||||||
sort_order=11,
|
sort_order=11,
|
||||||
),
|
),
|
||||||
ParamSpec(
|
ParamSpec(
|
||||||
@@ -197,7 +201,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
|||||||
default_value="FULL",
|
default_value="FULL",
|
||||||
value_min=None,
|
value_min=None,
|
||||||
value_max=None,
|
value_max=None,
|
||||||
description="FULL=按 R×R 乘当月全额应发绩效;BALANCE=乘余额(语义待 M3 前确认)",
|
description="选择支付权重乘在哪个基数上:当月全额 = 乘本月全部应发绩效;余额 = 只乘尚未发放的部分。",
|
||||||
sort_order=12,
|
sort_order=12,
|
||||||
),
|
),
|
||||||
ParamSpec(
|
ParamSpec(
|
||||||
@@ -211,7 +215,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
|||||||
default_value="30",
|
default_value="30",
|
||||||
value_min="0",
|
value_min="0",
|
||||||
value_max="100",
|
value_max="100",
|
||||||
description="折算毛利润额 = 销售额 ×(毛利润率 − 阈值);市政水利 30%、轨交 20%",
|
description="折算毛利润额 = 销售额 ×(毛利润率 − 阈值),低于阈值的部分不参与绩效。市政水利默认 30%,轨道交通默认 20%。",
|
||||||
sort_order=13,
|
sort_order=13,
|
||||||
),
|
),
|
||||||
ParamSpec(
|
ParamSpec(
|
||||||
@@ -225,7 +229,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
|||||||
default_value="INCLUDE",
|
default_value="INCLUDE",
|
||||||
value_min=None,
|
value_min=None,
|
||||||
value_max=None,
|
value_max=None,
|
||||||
description="INCLUDE含基本工资 / DEDUCT扣除基本工资 / EXCLUDE不计",
|
description="算月工资时怎么处理基本工资:含基本工资 = 与绩效一起发;扣除基本工资 = 从绩效里扣掉已发的基本工资;不计 = 月工资只发绩效。",
|
||||||
sort_order=14,
|
sort_order=14,
|
||||||
),
|
),
|
||||||
ParamSpec(
|
ParamSpec(
|
||||||
@@ -239,7 +243,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
|||||||
default_value="18000",
|
default_value="18000",
|
||||||
value_min="0",
|
value_min="0",
|
||||||
value_max=None,
|
value_max=None,
|
||||||
description="试用期年累计绩效超过此值部分年底一次性结算",
|
description="试用期绩效先不发放;年内累计超过这个金额的部分,年底一次性结算。",
|
||||||
sort_order=15,
|
sort_order=15,
|
||||||
),
|
),
|
||||||
ParamSpec(
|
ParamSpec(
|
||||||
@@ -253,11 +257,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
|||||||
default_value="1",
|
default_value="1",
|
||||||
value_min=None,
|
value_min=None,
|
||||||
value_max=None,
|
value_max=None,
|
||||||
description=(
|
description="二次分配方案提交后是否要审批:开启 = 先经财务复核、再经领导审核,通过后才生效;关闭 = 提交即生效,系统自动记录各节点留痕。",
|
||||||
"0=二次分配方案提交即生效(系统自动通过全节点留痕,留痕不断链);"
|
|
||||||
"1=提交后进入财务复核→领导审核审批流。"
|
|
||||||
"默认开启(澄清 #5 业务答复 2026-10-19),业务可关闭"
|
|
||||||
),
|
|
||||||
sort_order=17,
|
sort_order=17,
|
||||||
),
|
),
|
||||||
ParamSpec(
|
ParamSpec(
|
||||||
@@ -271,11 +271,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
|||||||
default_value="1",
|
default_value="1",
|
||||||
value_min=None,
|
value_min=None,
|
||||||
value_max=None,
|
value_max=None,
|
||||||
description=(
|
description="月度结算提交后是否要审批:开启 = 先经财务复核、再经领导审核;关闭 = 生成即生效。月结涉及工资发放,默认开启。",
|
||||||
"月度结算审批链开关(④ 月结提交时消费):"
|
|
||||||
"0=结算生成即生效,1=走财务复核→领导审核审批流。"
|
|
||||||
"默认开启(澄清 #7 业务答复 2026-10-19,月结涉及工资发放从严),业务可关闭"
|
|
||||||
),
|
|
||||||
sort_order=18,
|
sort_order=18,
|
||||||
),
|
),
|
||||||
ParamSpec(
|
ParamSpec(
|
||||||
@@ -289,11 +285,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
|||||||
default_value="0",
|
default_value="0",
|
||||||
value_min=None,
|
value_min=None,
|
||||||
value_max=None,
|
value_max=None,
|
||||||
description=(
|
description="调整单提交后是否要审批:开启 = 先经财务复核、再经领导审核;关闭 = 提交即生效。调整单是已锁定月份补发或扣减的唯一通道。",
|
||||||
"0=调整单提交即生效(系统自动通过留痕);1=走财务复核→领导审核审批流。"
|
|
||||||
"调整单是已锁定月份补发/扣减的唯一通道(澄清 #9:走同一审批链,"
|
|
||||||
"默认值业务未明确,暂按直接生效,业务可开启)"
|
|
||||||
),
|
|
||||||
sort_order=19,
|
sort_order=19,
|
||||||
),
|
),
|
||||||
ParamSpec(
|
ParamSpec(
|
||||||
@@ -307,11 +299,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
|||||||
default_value="0",
|
default_value="0",
|
||||||
value_min=None,
|
value_min=None,
|
||||||
value_max=None,
|
value_max=None,
|
||||||
description=(
|
description="经理提取团队分成时,是否把经理本人名下的业绩(经理个人户)也算进分成基数。默认不计入。",
|
||||||
"经理 12.5%+5% 聚合时是否计入经理本人名下经理个人户业绩(澄清 #12 "
|
|
||||||
"业务答复 2026-10-19:作为参数配置、手动调整是否纳入):"
|
|
||||||
"0=不计入(默认,暂口径) 1=计入"
|
|
||||||
),
|
|
||||||
sort_order=20,
|
sort_order=20,
|
||||||
),
|
),
|
||||||
# ===== 预警规则(M4 ⑧ 工作台,澄清 #10「做成参数化规则便于调整」)=====
|
# ===== 预警规则(M4 ⑧ 工作台,澄清 #10「做成参数化规则便于调整」)=====
|
||||||
@@ -327,7 +315,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
|||||||
default_value="1",
|
default_value="1",
|
||||||
value_min=None,
|
value_min=None,
|
||||||
value_max=None,
|
value_max=None,
|
||||||
description="工作台风险预警区块总开关:0=关闭(不扫描不展示) 1=开启(默认)",
|
description="工作台风险预警区块的总开关:关闭后不再扫描、也不展示预警条目。默认开启。",
|
||||||
sort_order=21,
|
sort_order=21,
|
||||||
),
|
),
|
||||||
ParamSpec(
|
ParamSpec(
|
||||||
@@ -341,9 +329,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
|||||||
default_value="90",
|
default_value="90",
|
||||||
value_min="1",
|
value_min="1",
|
||||||
value_max=None,
|
value_max=None,
|
||||||
description=(
|
description="发货后超过这么多天仍未收到回款的发货批次,会出现在工作台的预警列表里。默认 90 天。",
|
||||||
"发货后超过该天数仍未回款的发货批次触发预警(澄清 #10 建议默认 90 天)"
|
|
||||||
),
|
|
||||||
sort_order=22,
|
sort_order=22,
|
||||||
),
|
),
|
||||||
ParamSpec(
|
ParamSpec(
|
||||||
@@ -357,9 +343,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
|||||||
default_value="3",
|
default_value="3",
|
||||||
value_min="1",
|
value_min="1",
|
||||||
value_max=None,
|
value_max=None,
|
||||||
description=(
|
description="审批单提交后超过这么多天还没处理,会出现在工作台的预警列表里。默认 3 天。",
|
||||||
"待审批单提交后超过该天数未处理触发预警(澄清 #10 建议默认 3 天)"
|
|
||||||
),
|
|
||||||
sort_order=23,
|
sort_order=23,
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ ParamSnapshot 是 M3 核算与计算预览共用的输入契约:按业务日
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from collections.abc import Iterable
|
from collections.abc import Callable, Iterable
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
@@ -44,7 +44,7 @@ from app.schemas.param import (
|
|||||||
ParamPreviewOut,
|
ParamPreviewOut,
|
||||||
ParamUsedOut,
|
ParamUsedOut,
|
||||||
)
|
)
|
||||||
from app.services import audit_service, formula
|
from app.services import audit_service, calc_templates, formula
|
||||||
from app.services.audit_service import AuditRecord, RequestMeta
|
from app.services.audit_service import AuditRecord, RequestMeta
|
||||||
from app.services.formula import to_decimal
|
from app.services.formula import to_decimal
|
||||||
from app.services.no_generator import next_no
|
from app.services.no_generator import next_no
|
||||||
@@ -379,16 +379,8 @@ def get_history(
|
|||||||
definition = param_repository.get_definition_by_code(session, param_code)
|
definition = param_repository.get_definition_by_code(session, param_code)
|
||||||
if definition is None:
|
if definition is None:
|
||||||
raise NotFoundError("参数不存在")
|
raise NotFoundError("参数不存在")
|
||||||
rows = param_repository.list_history(
|
rows = param_repository.list_history(session, definition.id, query)
|
||||||
session,
|
total = param_repository.count_history(session, definition.id, query)
|
||||||
definition.id,
|
|
||||||
offset=query.offset,
|
|
||||||
limit=query.page_size,
|
|
||||||
dimension_value=query.dimension_value,
|
|
||||||
)
|
|
||||||
total = param_repository.count_history(
|
|
||||||
session, definition.id, dimension_value=query.dimension_value
|
|
||||||
)
|
|
||||||
return PageResult[ParamHistoryOut](
|
return PageResult[ParamHistoryOut](
|
||||||
records=[
|
records=[
|
||||||
ParamHistoryOut(
|
ParamHistoryOut(
|
||||||
@@ -410,16 +402,8 @@ def get_history(
|
|||||||
|
|
||||||
def list_batches(session: Session, query: ParamBatchQuery) -> PageResult[ParamBatchOut]:
|
def list_batches(session: Session, query: ParamBatchQuery) -> PageResult[ParamBatchOut]:
|
||||||
"""变更批次分页(按创建时间倒序)。"""
|
"""变更批次分页(按创建时间倒序)。"""
|
||||||
batches = param_repository.list_batches(
|
batches = param_repository.list_batches(session, query)
|
||||||
session,
|
total = param_repository.count_batches(session, query)
|
||||||
offset=query.offset,
|
|
||||||
limit=query.page_size,
|
|
||||||
start_date=query.start_date,
|
|
||||||
end_date=query.end_date,
|
|
||||||
)
|
|
||||||
total = param_repository.count_batches(
|
|
||||||
session, start_date=query.start_date, end_date=query.end_date
|
|
||||||
)
|
|
||||||
return PageResult[ParamBatchOut](
|
return PageResult[ParamBatchOut](
|
||||||
records=[
|
records=[
|
||||||
ParamBatchOut(
|
ParamBatchOut(
|
||||||
@@ -509,42 +493,89 @@ def compare(session: Session, *, from_date: date, to_date: date) -> list[ParamDi
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _preview_rate(
|
@dataclass(frozen=True, slots=True)
|
||||||
session: Session,
|
class _PreviewContext:
|
||||||
payload: ParamPreviewIn,
|
"""预览上下文(会话、入参、参数取值闭包)。
|
||||||
snapshot: ParamSnapshot,
|
|
||||||
days: int,
|
``take`` 闭包同时负责取数与记录「用了哪些参数」——**调用顺序即
|
||||||
take,
|
``params_used`` 的展示顺序**,拆分时不得调换各段的先后。
|
||||||
) -> tuple[Fraction, str, Decimal | None]:
|
"""
|
||||||
|
|
||||||
|
session: Session
|
||||||
|
payload: ParamPreviewIn
|
||||||
|
take: Callable[[str], Fraction]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class _PreviewRateDecision:
|
||||||
|
"""计提比例判定(回款天数、纯比例、人读说明、绩效基数覆盖值)。"""
|
||||||
|
|
||||||
|
days: int
|
||||||
|
rate: Fraction
|
||||||
|
rate_text: str
|
||||||
|
base_override: Decimal | None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class _PreviewEarning:
|
||||||
|
"""折算与绩效(毛利润率、折算额、绩效额)。"""
|
||||||
|
|
||||||
|
profit_rate: Fraction
|
||||||
|
converted: Decimal
|
||||||
|
performance: Decimal
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class _PreviewBarter:
|
||||||
|
"""以物抵顶(是否适用、折算后绩效、计发基数)。"""
|
||||||
|
|
||||||
|
applied: bool
|
||||||
|
after_barter: Decimal | None
|
||||||
|
base_amount: Decimal
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class _PreviewPayment:
|
||||||
|
"""支付环节(起付判定、R 权重、权重、应付金额)。"""
|
||||||
|
|
||||||
|
start_ok: bool
|
||||||
|
weight_r: int
|
||||||
|
weight: Fraction
|
||||||
|
payable: Decimal
|
||||||
|
|
||||||
|
|
||||||
|
def _preview_rate(ctx: _PreviewContext, days: int) -> tuple[Fraction, str, Decimal | None]:
|
||||||
"""按岗位模板分派计提比例,返回(纯比例, 人读说明, 绩效基数覆盖值)。
|
"""按岗位模板分派计提比例,返回(纯比例, 人读说明, 绩效基数覆盖值)。
|
||||||
|
|
||||||
基数覆盖值非 None 表示该模板的绩效基数不是折算毛利润额(如信息员按
|
基数覆盖值非 None 表示该模板的绩效基数不是折算毛利润额(如信息员按
|
||||||
「本人销售额」计提),预览层直接采用并跳过折算与阈值。
|
「本人销售额」计提),预览层直接采用并跳过折算与阈值。
|
||||||
聚合模板(营销经理 12.5%+5%)依赖月度团队数据,不支持单笔预览。
|
聚合模板(营销经理 12.5%+5%)依赖月度团队数据,不支持单笔预览。
|
||||||
"""
|
"""
|
||||||
|
payload = ctx.payload
|
||||||
|
take = ctx.take
|
||||||
if payload.customer_type == "enterprise":
|
if payload.customer_type == "enterprise":
|
||||||
# 存储口径为 ‰ 数(如 "3"),除以 1000 转纯比例 —— performance_amount
|
# 存储口径为 ‰ 数(如 "3"),除以 1000 转纯比例 —— performance_amount
|
||||||
# 按纯比例相乘,漏换算会把绩效金额放大 1000 倍(与业务员分支同防线)
|
# 按纯比例相乘,漏换算会把绩效金额放大 1000 倍(与业务员分支同防线)
|
||||||
rate = take("enterprise.rate") / 1000
|
rate = take("enterprise.rate") / 1000
|
||||||
return rate, "企业户固定计提比例", None
|
return rate, "企业户:不按回款天数,统一按固定比例计提", None
|
||||||
|
|
||||||
role_code = payload.business_role
|
role_code = payload.business_role
|
||||||
template = "TPL_SALESPERSON_DECAY"
|
template = "TPL_SALESPERSON_DECAY"
|
||||||
if role_code:
|
if role_code:
|
||||||
role = business_role_repository.get_by_code(session, role_code)
|
role = business_role_repository.get_by_code(ctx.session, role_code)
|
||||||
if role is None:
|
if role is None:
|
||||||
raise BadRequestError(f"岗位 {role_code!r} 不存在")
|
raise BadRequestError(f"岗位「{role_code}」不存在,请到岗位角色里确认")
|
||||||
template = role.calc_template
|
template = role.calc_template
|
||||||
|
|
||||||
if template == "TPL_MANAGER_SHARE":
|
if template == "TPL_MANAGER_SHARE":
|
||||||
raise BadRequestError(
|
raise BadRequestError(
|
||||||
"营销经理绩效为团队聚合口径(本团队 12.5% + 全公司 5%),不支持单笔预览"
|
"营销经理的绩效按团队与全公司分成计算,不单笔试算;请到月度结算里查看"
|
||||||
)
|
)
|
||||||
if template == "TPL_INFO_SALES_FIXED":
|
if template == "TPL_INFO_SALES_FIXED":
|
||||||
# ‰ 存储口径 → 纯比例,同企业户分支(不换算则绩效放大 1000 倍)
|
# ‰ 存储口径 → 纯比例,同企业户分支(不换算则绩效放大 1000 倍)
|
||||||
rate = take("info.rate") / 1000
|
rate = take("info.rate") / 1000
|
||||||
# 信息员的基数是本人销售额(无折算与阈值),覆盖默认基数
|
# 信息员的基数是本人销售额(无折算与阈值),覆盖默认基数
|
||||||
return rate, "信息员:本人销售额 × 计提比例", Decimal(payload.revenue)
|
return rate, "信息员:按本人销售额乘计提比例,不随回款天数递减", Decimal(payload.revenue)
|
||||||
if template == "TPL_SALESPERSON_DECAY":
|
if template == "TPL_SALESPERSON_DECAY":
|
||||||
start = take("salesperson.rate.start")
|
start = take("salesperson.rate.start")
|
||||||
slope = take("salesperson.rate.slope")
|
slope = take("salesperson.rate.slope")
|
||||||
@@ -553,12 +584,84 @@ def _preview_rate(
|
|||||||
rate = formula.salesperson_rate(
|
rate = formula.salesperson_rate(
|
||||||
days, start=start, slope=slope, floor=floor, boundary_days=boundary
|
days, start=start, slope=slope, floor=floor, boundary_days=boundary
|
||||||
)
|
)
|
||||||
|
# 说明写成业务话:说清这笔为什么是这个比例、什么时候不再降
|
||||||
return (
|
return (
|
||||||
rate,
|
rate,
|
||||||
f"业务员递减:{to_decimal(start)}‰ 起,{days} 天(分界 {boundary} 天,超界取 {to_decimal(floor)}‰)",
|
f"回款 {days} 天,从起始 {to_decimal(start)}‰ 按日递减"
|
||||||
|
f"(满 {boundary} 天后固定按 {to_decimal(floor)}‰ 计算)",
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
raise BadRequestError(f"公式模板 {template} 暂不支持预览")
|
# 提示里给模板中文名而不是编码:编码是内部标识,业务人员在岗位角色页看不到它
|
||||||
|
tpl_name = calc_templates.TEMPLATES[template].name if template in calc_templates.TEMPLATES else template
|
||||||
|
raise BadRequestError(
|
||||||
|
f"「{tpl_name}」不支持单笔试算,请在岗位角色里改用业务员递减或信息员固定计提"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _preview_rate_decision(ctx: _PreviewContext) -> _PreviewRateDecision:
|
||||||
|
"""产品类别校验 + 回款天数 + 计提比例判定。"""
|
||||||
|
payload = ctx.payload
|
||||||
|
if payload.product_category and payload.product_category not in PRODUCT_CATEGORIES:
|
||||||
|
# 提示里用中文类别名:前端下拉显示的是「市政水利」,报错却回英文编码会让人对不上
|
||||||
|
raise BadRequestError(
|
||||||
|
f"产品类别「{payload.product_category}」不存在,可选:市政水利、轨道交通"
|
||||||
|
)
|
||||||
|
days = formula.payment_days(payload.ship_date, payload.receipt_date)
|
||||||
|
rate, rate_text, base_override = _preview_rate(ctx, days)
|
||||||
|
return _PreviewRateDecision(
|
||||||
|
days=days, rate=rate, rate_text=rate_text, base_override=base_override
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _preview_earning(ctx: _PreviewContext, decision: _PreviewRateDecision) -> _PreviewEarning:
|
||||||
|
"""毛利润率 → 折算额(阈值)→ 绩效额(比例)。"""
|
||||||
|
payload = ctx.payload
|
||||||
|
revenue = Decimal(payload.revenue)
|
||||||
|
profit = Decimal(payload.profit)
|
||||||
|
profit_rate = formula.profit_rate(profit, revenue)
|
||||||
|
if decision.base_override is not None:
|
||||||
|
# 基数覆盖(信息员按本人销售额计提):不走折算与阈值
|
||||||
|
converted = decision.base_override
|
||||||
|
else:
|
||||||
|
threshold = ctx.take("product.threshold")
|
||||||
|
converted = formula.converted_profit(revenue, profit_rate, threshold)
|
||||||
|
performance = formula.performance_amount(converted, decision.rate)
|
||||||
|
return _PreviewEarning(profit_rate=profit_rate, converted=converted, performance=performance)
|
||||||
|
|
||||||
|
|
||||||
|
def _preview_barter(ctx: _PreviewContext, performance: Decimal) -> _PreviewBarter:
|
||||||
|
"""以物抵顶折算(按应发绩效的固定比例折算,比例来自参数)。"""
|
||||||
|
applied = ctx.payload.barter
|
||||||
|
after_barter: Decimal | None = None
|
||||||
|
if applied:
|
||||||
|
discount = ctx.take("barter.discount")
|
||||||
|
after_barter = formula.apply_barter(performance, discount)
|
||||||
|
base_amount = after_barter if applied else performance
|
||||||
|
return _PreviewBarter(applied=applied, after_barter=after_barter, base_amount=base_amount)
|
||||||
|
|
||||||
|
|
||||||
|
def _preview_payment(
|
||||||
|
ctx: _PreviewContext, received: Decimal, revenue: Decimal, base_amount: Decimal
|
||||||
|
) -> _PreviewPayment:
|
||||||
|
"""起付比例判定 + R 权重 + 应付金额。"""
|
||||||
|
start_ratio = ctx.take("payment.start.ratio")
|
||||||
|
start_ok = formula.start_payment_ok(received, revenue, start_ratio)
|
||||||
|
# R 钳制已提取为纯函数 clamp_weight_r(④ 核算与预览共用同一实现)
|
||||||
|
weight_r = formula.clamp_weight_r(received, revenue)
|
||||||
|
weight = formula.payment_weight(weight_r)
|
||||||
|
payable = to_decimal(Fraction(base_amount) * weight)
|
||||||
|
return _PreviewPayment(start_ok=start_ok, weight_r=weight_r, weight=weight, payable=payable)
|
||||||
|
|
||||||
|
|
||||||
|
def _preview_probation_note(ctx: _PreviewContext) -> str | None:
|
||||||
|
"""试用期说明(绩效照算不发放,年累计超阈值的部分年底一次性结算)。"""
|
||||||
|
if not ctx.payload.probation:
|
||||||
|
return None
|
||||||
|
annual = ctx.take("probation.annual.threshold")
|
||||||
|
return (
|
||||||
|
f"试用期人员:绩效照常计算但不发放;年内累计超过 {to_decimal(annual)} 元的部分,"
|
||||||
|
"年底一次性结清。"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def preview(session: Session, payload: ParamPreviewIn) -> ParamPreviewOut:
|
def preview(session: Session, payload: ParamPreviewIn) -> ParamPreviewOut:
|
||||||
@@ -566,7 +669,6 @@ def preview(session: Session, payload: ParamPreviewIn) -> ParamPreviewOut:
|
|||||||
# 参数按回款到账日取值(对照表 #11)
|
# 参数按回款到账日取值(对照表 #11)
|
||||||
snapshot = resolve_snapshot(session, payload.receipt_date)
|
snapshot = resolve_snapshot(session, payload.receipt_date)
|
||||||
revenue = Decimal(payload.revenue)
|
revenue = Decimal(payload.revenue)
|
||||||
profit = Decimal(payload.profit)
|
|
||||||
received = Decimal(payload.received_amount)
|
received = Decimal(payload.received_amount)
|
||||||
used: list[ParamUsedOut] = []
|
used: list[ParamUsedOut] = []
|
||||||
|
|
||||||
@@ -589,55 +691,29 @@ def preview(session: Session, payload: ParamPreviewIn) -> ParamPreviewOut:
|
|||||||
)
|
)
|
||||||
return parse_fraction(raw)
|
return parse_fraction(raw)
|
||||||
|
|
||||||
if payload.product_category and payload.product_category not in PRODUCT_CATEGORIES:
|
ctx = _PreviewContext(session=session, payload=payload, take=take)
|
||||||
raise BadRequestError(
|
# 各段顺序固定 —— take 的调用顺序即 params_used 的展示顺序
|
||||||
f"产品类别 {payload.product_category!r} 不存在,可选:municipal_water / rail_transit"
|
decision = _preview_rate_decision(ctx)
|
||||||
)
|
earning = _preview_earning(ctx, decision)
|
||||||
|
barter = _preview_barter(ctx, earning.performance)
|
||||||
days = formula.payment_days(payload.ship_date, payload.receipt_date)
|
payment = _preview_payment(ctx, received, revenue, barter.base_amount)
|
||||||
rate, rate_text, base_override = _preview_rate(session, payload, snapshot, days, take)
|
probation_note = _preview_probation_note(ctx)
|
||||||
|
|
||||||
profit_rate = formula.profit_rate(profit, revenue)
|
|
||||||
if base_override is not None:
|
|
||||||
# 基数覆盖(信息员按本人销售额计提):不走折算与阈值
|
|
||||||
converted = base_override
|
|
||||||
else:
|
|
||||||
threshold = take("product.threshold")
|
|
||||||
converted = formula.converted_profit(revenue, profit_rate, threshold)
|
|
||||||
performance = formula.performance_amount(converted, rate)
|
|
||||||
|
|
||||||
barter_applied = payload.barter
|
|
||||||
after_barter: Decimal | None = None
|
|
||||||
if barter_applied:
|
|
||||||
discount = take("barter.discount")
|
|
||||||
after_barter = formula.apply_barter(performance, discount)
|
|
||||||
|
|
||||||
start_ratio = take("payment.start.ratio")
|
|
||||||
start_ok = formula.start_payment_ok(received, revenue, start_ratio)
|
|
||||||
# R 钳制已提取为纯函数 clamp_weight_r(④ 核算与预览共用同一实现)
|
|
||||||
weight_r = formula.clamp_weight_r(received, revenue)
|
|
||||||
weight = formula.payment_weight(weight_r)
|
|
||||||
base_amount = after_barter if barter_applied else performance
|
|
||||||
payable = to_decimal(Fraction(base_amount) * weight)
|
|
||||||
|
|
||||||
probation_note: str | None = None
|
|
||||||
if payload.probation:
|
|
||||||
annual = take("probation.annual.threshold")
|
|
||||||
probation_note = f"试用期绩效照算不发放;年累计超 {to_decimal(annual)} 元部分年底一次性结算"
|
|
||||||
|
|
||||||
return ParamPreviewOut(
|
return ParamPreviewOut(
|
||||||
payment_days=days,
|
payment_days=decision.days,
|
||||||
rate=str(rate),
|
rate=str(decision.rate),
|
||||||
rate_text=rate_text,
|
rate_text=decision.rate_text,
|
||||||
profit_rate=str(profit_rate),
|
profit_rate=str(earning.profit_rate),
|
||||||
converted_profit=str(converted),
|
converted_profit=str(earning.converted),
|
||||||
performance_amount=str(performance),
|
performance_amount=str(earning.performance),
|
||||||
barter_applied=barter_applied,
|
barter_applied=barter.applied,
|
||||||
performance_after_barter=str(after_barter) if after_barter is not None else None,
|
performance_after_barter=(
|
||||||
start_payment_ok=start_ok,
|
str(barter.after_barter) if barter.after_barter is not None else None
|
||||||
payment_weight_r=weight_r,
|
),
|
||||||
payment_weight=str(weight),
|
start_payment_ok=payment.start_ok,
|
||||||
payable_amount=str(payable),
|
payment_weight_r=payment.weight_r,
|
||||||
|
payment_weight=str(payment.weight),
|
||||||
|
payable_amount=str(payment.payable),
|
||||||
probation_note=probation_note,
|
probation_note=probation_note,
|
||||||
params_used=used,
|
params_used=used,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -70,20 +70,8 @@ def ensure_active(session: Session, personnel_id: int, *, label: str) -> Personn
|
|||||||
|
|
||||||
def list_personnel(session: Session, query: PersonnelQuery) -> PageResult[PersonnelOut]:
|
def list_personnel(session: Session, query: PersonnelQuery) -> PageResult[PersonnelOut]:
|
||||||
"""分页查询人员(含离职)。"""
|
"""分页查询人员(含离职)。"""
|
||||||
rows = personnel_repository.list_personnel(
|
rows = personnel_repository.list_personnel(session, query)
|
||||||
session,
|
total = personnel_repository.count_personnel(session, query)
|
||||||
offset=query.offset,
|
|
||||||
limit=query.page_size,
|
|
||||||
keyword=query.keyword,
|
|
||||||
status=query.status,
|
|
||||||
business_role_id=query.business_role_id,
|
|
||||||
)
|
|
||||||
total = personnel_repository.count_personnel(
|
|
||||||
session,
|
|
||||||
keyword=query.keyword,
|
|
||||||
status=query.status,
|
|
||||||
business_role_id=query.business_role_id,
|
|
||||||
)
|
|
||||||
return PageResult[PersonnelOut](
|
return PageResult[PersonnelOut](
|
||||||
records=[
|
records=[
|
||||||
_to_out(personnel, role_name, manager_name)
|
_to_out(personnel, role_name, manager_name)
|
||||||
|
|||||||
@@ -99,20 +99,8 @@ def _raise_if_duplicate(exc: IntegrityError) -> None:
|
|||||||
|
|
||||||
def list_products(session: Session, query: ProductQuery) -> PageResult[ProductOut]:
|
def list_products(session: Session, query: ProductQuery) -> PageResult[ProductOut]:
|
||||||
"""分页查询产品(含停用)。"""
|
"""分页查询产品(含停用)。"""
|
||||||
products = product_repository.list_products(
|
products = product_repository.list_products(session, query)
|
||||||
session,
|
total = product_repository.count_products(session, query)
|
||||||
offset=query.offset,
|
|
||||||
limit=query.page_size,
|
|
||||||
keyword=query.keyword,
|
|
||||||
product_category=query.product_category,
|
|
||||||
status=query.status,
|
|
||||||
)
|
|
||||||
total = product_repository.count_products(
|
|
||||||
session,
|
|
||||||
keyword=query.keyword,
|
|
||||||
product_category=query.product_category,
|
|
||||||
status=query.status,
|
|
||||||
)
|
|
||||||
return PageResult[ProductOut](
|
return PageResult[ProductOut](
|
||||||
records=[_to_out(product) for product in products],
|
records=[_to_out(product) for product in products],
|
||||||
total=total,
|
total=total,
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
@@ -39,7 +40,7 @@ from app.core.constants import (
|
|||||||
from app.core.db_errors import is_duplicate_entry
|
from app.core.db_errors import is_duplicate_entry
|
||||||
from app.core.exceptions import BadRequestError, NotFoundError
|
from app.core.exceptions import BadRequestError, NotFoundError
|
||||||
from app.core.response import PageResult
|
from app.core.response import PageResult
|
||||||
from app.models import Receipt
|
from app.models import Business, Receipt
|
||||||
from app.models.business import BUSINESS_STATUS_ACTIVE
|
from app.models.business import BUSINESS_STATUS_ACTIVE
|
||||||
from app.models.receipt import SETTLEMENT_DONE, SETTLEMENT_PENDING
|
from app.models.receipt import SETTLEMENT_DONE, SETTLEMENT_PENDING
|
||||||
from app.repositories import business_repository, receipt_repository
|
from app.repositories import business_repository, receipt_repository
|
||||||
@@ -129,24 +130,8 @@ def _ensure_business_writable(session: Session, business_id: int, action: str):
|
|||||||
|
|
||||||
def list_receipts(session: Session, query: ReceiptQuery) -> PageResult[ReceiptOut]:
|
def list_receipts(session: Session, query: ReceiptQuery) -> PageResult[ReceiptOut]:
|
||||||
"""分页查询回款(附业务号/发货单号与 payment_days 派生值)。"""
|
"""分页查询回款(附业务号/发货单号与 payment_days 派生值)。"""
|
||||||
rows = receipt_repository.list_receipts(
|
rows = receipt_repository.list_receipts(session, query)
|
||||||
session,
|
total = receipt_repository.count_receipts(session, query)
|
||||||
offset=query.offset,
|
|
||||||
limit=query.page_size,
|
|
||||||
business_id=query.business_id,
|
|
||||||
date_from=query.date_from,
|
|
||||||
date_to=query.date_to,
|
|
||||||
is_barter=query.is_barter,
|
|
||||||
settlement_status=query.settlement_status,
|
|
||||||
)
|
|
||||||
total = receipt_repository.count_receipts(
|
|
||||||
session,
|
|
||||||
business_id=query.business_id,
|
|
||||||
date_from=query.date_from,
|
|
||||||
date_to=query.date_to,
|
|
||||||
is_barter=query.is_barter,
|
|
||||||
settlement_status=query.settlement_status,
|
|
||||||
)
|
|
||||||
return PageResult[ReceiptOut](
|
return PageResult[ReceiptOut](
|
||||||
records=[_to_out(row) for row in rows],
|
records=[_to_out(row) for row in rows],
|
||||||
total=total,
|
total=total,
|
||||||
@@ -166,6 +151,57 @@ def get_detail(session: Session, receipt_id: int) -> ReceiptOut:
|
|||||||
return _to_out(row)
|
return _to_out(row)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class _CreatedReceipt:
|
||||||
|
"""建行结果(出参 + 固化的 ID/批次号,commit 后不再触碰 ORM 属性)。"""
|
||||||
|
|
||||||
|
out: ReceiptOut
|
||||||
|
receipt_id: int
|
||||||
|
batch_no: str
|
||||||
|
|
||||||
|
|
||||||
|
def _create_receipt_once(
|
||||||
|
session: Session,
|
||||||
|
payload: ReceiptCreateIn,
|
||||||
|
business: Business | None,
|
||||||
|
batch_no: str,
|
||||||
|
) -> _CreatedReceipt:
|
||||||
|
"""一次建行尝试:准入校验 → 建行 → 重算合计 → 出参。
|
||||||
|
|
||||||
|
**不 commit**:提交与回滚由调用方(重试循环)统一控制,保证「一次尝试
|
||||||
|
= 一个事务边界」。
|
||||||
|
"""
|
||||||
|
if business is None:
|
||||||
|
raise NotFoundError("业务不存在")
|
||||||
|
if business.status != BUSINESS_STATUS_ACTIVE:
|
||||||
|
raise BadRequestError("业务已完成或已终止,禁止新增回款")
|
||||||
|
# 月结锁定拦截(2026-10-15 补充需求):比需求原文「提示」取硬,
|
||||||
|
# 一期安全侧(策略点注释);未指定日期时以当天月近似
|
||||||
|
from app.services import settlement_service
|
||||||
|
|
||||||
|
_lock_month = (payload.receipt_date or date.today()).strftime("%Y-%m")
|
||||||
|
settlement_service.ensure_month_not_locked(session, _lock_month)
|
||||||
|
shipment_no, ship_date = _ensure_shipment_of_business(
|
||||||
|
session, payload.shipment_id, payload.business_id
|
||||||
|
)
|
||||||
|
receipt = receipt_repository.create(
|
||||||
|
session,
|
||||||
|
Receipt(
|
||||||
|
receipt_batch_no=batch_no,
|
||||||
|
business_id=payload.business_id,
|
||||||
|
shipment_id=payload.shipment_id,
|
||||||
|
receipt_date=payload.receipt_date,
|
||||||
|
amount=Decimal(payload.amount),
|
||||||
|
is_barter=payload.is_barter,
|
||||||
|
settlement_status=SETTLEMENT_PENDING,
|
||||||
|
remark=payload.remark,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
_recalc_totals(session, business)
|
||||||
|
out = _to_out((receipt, business.business_no, shipment_no, ship_date))
|
||||||
|
return _CreatedReceipt(out=out, receipt_id=receipt.id, batch_no=batch_no)
|
||||||
|
|
||||||
|
|
||||||
def create_receipt(
|
def create_receipt(
|
||||||
session: Session,
|
session: Session,
|
||||||
payload: ReceiptCreateIn,
|
payload: ReceiptCreateIn,
|
||||||
@@ -206,35 +242,7 @@ def create_receipt(
|
|||||||
# 锁业务行与生成批次号——成功/最终失败各只记一条审计,无递归
|
# 锁业务行与生成批次号——成功/最终失败各只记一条审计,无递归
|
||||||
for attempt in range(_NO_RETRY + 1):
|
for attempt in range(_NO_RETRY + 1):
|
||||||
try:
|
try:
|
||||||
if business is None:
|
created = _create_receipt_once(session, payload, business, batch_no)
|
||||||
raise NotFoundError("业务不存在")
|
|
||||||
if business.status != BUSINESS_STATUS_ACTIVE:
|
|
||||||
raise BadRequestError("业务已完成或已终止,禁止新增回款")
|
|
||||||
# 月结锁定拦截(2026-10-15 补充需求):比需求原文「提示」取硬,
|
|
||||||
# 一期安全侧(策略点注释);未指定日期时以当天月近似
|
|
||||||
from app.services import settlement_service
|
|
||||||
|
|
||||||
_lock_month = (payload.receipt_date or date.today()).strftime("%Y-%m")
|
|
||||||
settlement_service.ensure_month_not_locked(session, _lock_month)
|
|
||||||
shipment_no, ship_date = _ensure_shipment_of_business(
|
|
||||||
session, payload.shipment_id, payload.business_id
|
|
||||||
)
|
|
||||||
receipt = receipt_repository.create(
|
|
||||||
session,
|
|
||||||
Receipt(
|
|
||||||
receipt_batch_no=batch_no,
|
|
||||||
business_id=payload.business_id,
|
|
||||||
shipment_id=payload.shipment_id,
|
|
||||||
receipt_date=payload.receipt_date,
|
|
||||||
amount=Decimal(payload.amount),
|
|
||||||
is_barter=payload.is_barter,
|
|
||||||
settlement_status=SETTLEMENT_PENDING,
|
|
||||||
remark=payload.remark,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
_recalc_totals(session, business)
|
|
||||||
out = _to_out((receipt, business.business_no, shipment_no, ship_date))
|
|
||||||
new_id, new_no = receipt.id, batch_no # 固化:commit 后不碰 ORM
|
|
||||||
session.commit()
|
session.commit()
|
||||||
break
|
break
|
||||||
except IntegrityError as exc:
|
except IntegrityError as exc:
|
||||||
@@ -249,11 +257,11 @@ def create_receipt(
|
|||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"新增回款 | receipt_id=%s | no=%s | business_id=%s",
|
"新增回款 | receipt_id=%s | no=%s | business_id=%s",
|
||||||
new_id,
|
created.receipt_id,
|
||||||
new_no,
|
created.batch_no,
|
||||||
payload.business_id,
|
payload.business_id,
|
||||||
)
|
)
|
||||||
return out
|
return created.out
|
||||||
|
|
||||||
|
|
||||||
def update_receipt(
|
def update_receipt(
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ _DATE_FORMAT = "YYYY-MM-DD"
|
|||||||
_FORMULA_PREFIXES = ("=", "+", "-", "@", "\t")
|
_FORMULA_PREFIXES = ("=", "+", "-", "@", "\t")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class _Column:
|
class _Column:
|
||||||
"""列定义:key 取行 dict 字段,kind 决定单元格类型与格式。"""
|
"""列定义:key 取行 dict 字段,kind 决定单元格类型与格式。"""
|
||||||
@@ -403,7 +402,13 @@ def _write_department_document(
|
|||||||
company_share_count = int(company.get("company_share_count") or 0)
|
company_share_count = int(company.get("company_share_count") or 0)
|
||||||
|
|
||||||
_write_document_header(
|
_write_document_header(
|
||||||
sheet, len(_DEPARTMENT_WIDTHS), company_name, title, filters, template_version, operator_name
|
sheet,
|
||||||
|
len(_DEPARTMENT_WIDTHS),
|
||||||
|
company_name,
|
||||||
|
title,
|
||||||
|
filters,
|
||||||
|
template_version,
|
||||||
|
operator_name,
|
||||||
)
|
)
|
||||||
|
|
||||||
_write_block(
|
_write_block(
|
||||||
@@ -439,16 +444,12 @@ def _write_department_document(
|
|||||||
"manager_name": "合计",
|
"manager_name": "合计",
|
||||||
"member_count": sum(team["member_count"] for team in teams),
|
"member_count": sum(team["member_count"] for team in teams),
|
||||||
"team_base": _sum_amount(team["team_base"] for team in teams),
|
"team_base": _sum_amount(team["team_base"] for team in teams),
|
||||||
"team_share_amount": _sum_amount(
|
"team_share_amount": _sum_amount(team["team_share_amount"] for team in teams),
|
||||||
team["team_share_amount"] for team in teams
|
|
||||||
),
|
|
||||||
"team_subtotal": _sum_amount(team["team_subtotal"] for team in teams),
|
"team_subtotal": _sum_amount(team["team_subtotal"] for team in teams),
|
||||||
# 未生成月结时全部为 None,合计也留空(不能显示 0.00 误导)
|
# 未生成月结时全部为 None,合计也留空(不能显示 0.00 误导)
|
||||||
"frozen_amount": (
|
"frozen_amount": (
|
||||||
_sum_amount(
|
_sum_amount(
|
||||||
team["frozen_amount"]
|
team["frozen_amount"] for team in teams if team["frozen_amount"] is not None
|
||||||
for team in teams
|
|
||||||
if team["frozen_amount"] is not None
|
|
||||||
)
|
)
|
||||||
if any(team["frozen_amount"] is not None for team in teams)
|
if any(team["frozen_amount"] is not None for team in teams)
|
||||||
else None
|
else None
|
||||||
|
|||||||
@@ -103,10 +103,7 @@ def _person_row(row: PerformanceLedger, role_names: dict[str, str]) -> dict:
|
|||||||
|
|
||||||
def _role_names(session) -> dict[str, str]:
|
def _role_names(session) -> dict[str, str]:
|
||||||
"""岗位编码 → 名称一次查询(明细与部门行共用,避免 N+1)。"""
|
"""岗位编码 → 名称一次查询(明细与部门行共用,避免 N+1)。"""
|
||||||
return {
|
return {r.role_code: r.role_name for r in session.execute(select(BusinessRole)).scalars()}
|
||||||
r.role_code: r.role_name
|
|
||||||
for r in session.execute(select(BusinessRole)).scalars()
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _dec(value) -> Decimal | None:
|
def _dec(value) -> Decimal | None:
|
||||||
@@ -178,11 +175,7 @@ def department_summary(session, query) -> dict:
|
|||||||
extra_ids = {
|
extra_ids = {
|
||||||
target
|
target
|
||||||
for pid in adjustments
|
for pid in adjustments
|
||||||
if (
|
if (target := _adjustment_target(pid, member_map, manager_of, manager_role, include_mp))
|
||||||
target := _adjustment_target(
|
|
||||||
pid, member_map, manager_of, manager_role, include_mp
|
|
||||||
)
|
|
||||||
)
|
|
||||||
is not None
|
is not None
|
||||||
} - set(manager_ids)
|
} - set(manager_ids)
|
||||||
if extra_ids:
|
if extra_ids:
|
||||||
@@ -193,9 +186,7 @@ def department_summary(session, query) -> dict:
|
|||||||
for team in teams_all
|
for team in teams_all
|
||||||
if query.manager_id is None or team["manager_id"] == query.manager_id
|
if query.manager_id is None or team["manager_id"] == query.manager_id
|
||||||
]
|
]
|
||||||
unassigned_all = _unassigned_rows(
|
unassigned_all = _unassigned_rows(member_map, manager_of, manager_role, adjustments, include_mp)
|
||||||
member_map, manager_of, manager_role, adjustments, include_mp
|
|
||||||
)
|
|
||||||
# 「仅看某经理团队」时未分配桶不随展示:全公司无经理人员与该团队无关
|
# 「仅看某经理团队」时未分配桶不随展示:全公司无经理人员与该团队无关
|
||||||
unassigned = [] if query.manager_id is not None else unassigned_all
|
unassigned = [] if query.manager_id is not None else unassigned_all
|
||||||
company_share_amount = _company_share(company_base, share.company_pct)
|
company_share_amount = _company_share(company_base, share.company_pct)
|
||||||
@@ -335,9 +326,7 @@ def _reconciliation(
|
|||||||
(m["adjust_net"] for team in teams for m in team["members"]), Decimal("0")
|
(m["adjust_net"] for team in teams for m in team["members"]), Decimal("0")
|
||||||
) + sum((row["adjust_net"] for row in unassigned), Decimal("0"))
|
) + sum((row["adjust_net"] for row in unassigned), Decimal("0"))
|
||||||
team_base_total = sum((team["team_base"] for team in teams), Decimal("0"))
|
team_base_total = sum((team["team_base"] for team in teams), Decimal("0"))
|
||||||
unassigned_performance = sum(
|
unassigned_performance = sum((row["performance_sum"] for row in unassigned), Decimal("0"))
|
||||||
(row["performance_sum"] for row in unassigned), Decimal("0")
|
|
||||||
)
|
|
||||||
payable_total = (
|
payable_total = (
|
||||||
team_member_payable + unassigned_payable + aggregate_payable_total + adjust_net_total
|
team_member_payable + unassigned_payable + aggregate_payable_total + adjust_net_total
|
||||||
)
|
)
|
||||||
@@ -378,9 +367,7 @@ def _reconciliation(
|
|||||||
|
|
||||||
def _team_share(team_base: Decimal, team_pct: Fraction) -> Decimal:
|
def _team_share(team_base: Decimal, team_pct: Fraction) -> Decimal:
|
||||||
"""12.5% 团队分成:全公司部分传 0,仅取 team_pct 项。"""
|
"""12.5% 团队分成:全公司部分传 0,仅取 team_pct 项。"""
|
||||||
return manager_team_share(
|
return manager_team_share(team_base, Decimal("0"), team_pct=team_pct, company_pct=Fraction(0))
|
||||||
team_base, Decimal("0"), team_pct=team_pct, company_pct=Fraction(0)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _company_share(company_base: Decimal, company_pct: Fraction) -> Decimal:
|
def _company_share(company_base: Decimal, company_pct: Fraction) -> Decimal:
|
||||||
@@ -483,12 +470,8 @@ def _team_relations(
|
|||||||
referenced = {p.manager_id for p in people if p.manager_id is not None}
|
referenced = {p.manager_id for p in people if p.manager_id is not None}
|
||||||
missing = referenced - seed
|
missing = referenced - seed
|
||||||
if missing:
|
if missing:
|
||||||
people.extend(
|
people.extend(session.execute(select(Personnel).where(Personnel.id.in_(missing))).scalars())
|
||||||
session.execute(select(Personnel).where(Personnel.id.in_(missing))).scalars()
|
role_by_id = {r.id: r.role_code for r in session.execute(select(BusinessRole)).scalars()}
|
||||||
)
|
|
||||||
role_by_id = {
|
|
||||||
r.id: r.role_code for r in session.execute(select(BusinessRole)).scalars()
|
|
||||||
}
|
|
||||||
manager_of = {p.id: p.manager_id for p in people}
|
manager_of = {p.id: p.manager_id for p in people}
|
||||||
manager_role = {p.id: role_by_id.get(p.business_role_id, "") for p in people}
|
manager_role = {p.id: role_by_id.get(p.business_role_id, "") for p in people}
|
||||||
person_info = {
|
person_info = {
|
||||||
@@ -619,9 +602,7 @@ def _member_rows(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
personal_in_team = (
|
personal_in_team = (
|
||||||
pid == manager_id
|
pid == manager_id and include_manager_personal and member["has_manager_personal"]
|
||||||
and include_manager_personal
|
|
||||||
and member["has_manager_personal"]
|
|
||||||
)
|
)
|
||||||
if personal_in_team:
|
if personal_in_team:
|
||||||
rows.append(
|
rows.append(
|
||||||
@@ -893,9 +874,7 @@ def _group_rows(rows) -> list[dict]:
|
|||||||
def payment_detail(session, query) -> dict:
|
def payment_detail(session, query) -> dict:
|
||||||
"""区间下钻分页(bucket 未注册 → 400)。"""
|
"""区间下钻分页(bucket 未注册 → 400)。"""
|
||||||
bucket = _bucket_by_code(query.bucket)
|
bucket = _bucket_by_code(query.bucket)
|
||||||
rows = report_repository.list_payment_detail(
|
rows = report_repository.list_payment_detail(session, query, bucket)
|
||||||
session, query, bucket, offset=query.offset, limit=query.page_size
|
|
||||||
)
|
|
||||||
return {
|
return {
|
||||||
"list": [_payment_row(r) for r in rows],
|
"list": [_payment_row(r) for r in rows],
|
||||||
"total": report_repository.count_payment_detail(session, query, bucket),
|
"total": report_repository.count_payment_detail(session, query, bucket),
|
||||||
|
|||||||
@@ -68,14 +68,8 @@ def list_roles(session: Session, query: RoleQuery) -> PageResult[RoleManageOut]:
|
|||||||
与用户表单下拉(`/users/meta/roles`)口径不同:**管理列表含停用角色**,
|
与用户表单下拉(`/users/meta/roles`)口径不同:**管理列表含停用角色**,
|
||||||
下拉只给启用项。
|
下拉只给启用项。
|
||||||
"""
|
"""
|
||||||
roles = role_repository.list_roles(
|
roles = role_repository.list_roles(session, query)
|
||||||
session,
|
total = role_repository.count_roles(session, query)
|
||||||
offset=query.offset,
|
|
||||||
limit=query.page_size,
|
|
||||||
keyword=query.keyword,
|
|
||||||
status=query.status,
|
|
||||||
)
|
|
||||||
total = role_repository.count_roles(session, keyword=query.keyword, status=query.status)
|
|
||||||
counts = role_repository.count_users_by_role(session, [role.id for role in roles])
|
counts = role_repository.count_users_by_role(session, [role.id for role in roles])
|
||||||
return PageResult[RoleManageOut](
|
return PageResult[RoleManageOut](
|
||||||
records=[_to_out(role, counts.get(role.id, 0)) for role in roles],
|
records=[_to_out(role, counts.get(role.id, 0)) for role in roles],
|
||||||
|
|||||||
@@ -423,9 +423,10 @@ def _aggregate_row(
|
|||||||
"members": member_names,
|
"members": member_names,
|
||||||
"params_as_of": str(share.param_date),
|
"params_as_of": str(share.param_date),
|
||||||
"manager_personal_included": 1 if share.include_manager_personal else 0,
|
"manager_personal_included": 1 if share.include_manager_personal else 0,
|
||||||
|
# 说明写给业务人员看:说清这一笔的基数是怎么凑出来的
|
||||||
"note": (
|
"note": (
|
||||||
f"#12 参数化:基数{'含' if share.include_manager_personal else '不含'}"
|
f"团队分成:基数{'含' if share.include_manager_personal else '不含'}"
|
||||||
"经理个人户业绩;9.8 方案公式"
|
"经理本人名下业绩,按本团队与全公司两级比例合计计提"
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -157,20 +157,8 @@ def _is_duplicate_entry(exc: IntegrityError) -> bool:
|
|||||||
|
|
||||||
def list_shipments(session: Session, query: ShipmentQuery) -> PageResult[ShipmentOut]:
|
def list_shipments(session: Session, query: ShipmentQuery) -> PageResult[ShipmentOut]:
|
||||||
"""分页查询发货单(附业务号;business_id 可空=全量,一期按业务维度查看为主)。"""
|
"""分页查询发货单(附业务号;business_id 可空=全量,一期按业务维度查看为主)。"""
|
||||||
rows = shipment_repository.list_shipments(
|
rows = shipment_repository.list_shipments(session, query)
|
||||||
session,
|
total = shipment_repository.count_shipments(session, query)
|
||||||
offset=query.offset,
|
|
||||||
limit=query.page_size,
|
|
||||||
business_id=query.business_id,
|
|
||||||
ship_date_from=query.ship_date_from,
|
|
||||||
ship_date_to=query.ship_date_to,
|
|
||||||
)
|
|
||||||
total = shipment_repository.count_shipments(
|
|
||||||
session,
|
|
||||||
business_id=query.business_id,
|
|
||||||
ship_date_from=query.ship_date_from,
|
|
||||||
ship_date_to=query.ship_date_to,
|
|
||||||
)
|
|
||||||
return PageResult[ShipmentOut](
|
return PageResult[ShipmentOut](
|
||||||
records=[_to_out(row) for row in rows],
|
records=[_to_out(row) for row in rows],
|
||||||
total=total,
|
total=total,
|
||||||
|
|||||||
@@ -68,22 +68,8 @@ def _to_out(user: User) -> UserOut:
|
|||||||
|
|
||||||
def list_users(session: Session, query: UserQuery) -> PageResult[UserOut]:
|
def list_users(session: Session, query: UserQuery) -> PageResult[UserOut]:
|
||||||
"""分页查询用户列表。"""
|
"""分页查询用户列表。"""
|
||||||
users = user_repository.list_users(
|
users = user_repository.list_users(session, query)
|
||||||
session,
|
total = user_repository.count_users(session, query)
|
||||||
offset=query.offset,
|
|
||||||
limit=query.page_size,
|
|
||||||
username=query.username,
|
|
||||||
real_name=query.real_name,
|
|
||||||
status=query.status,
|
|
||||||
role_code=query.role_code,
|
|
||||||
)
|
|
||||||
total = user_repository.count_users(
|
|
||||||
session,
|
|
||||||
username=query.username,
|
|
||||||
real_name=query.real_name,
|
|
||||||
status=query.status,
|
|
||||||
role_code=query.role_code,
|
|
||||||
)
|
|
||||||
return PageResult[UserOut](
|
return PageResult[UserOut](
|
||||||
records=[_to_out(user) for user in users],
|
records=[_to_out(user) for user in users],
|
||||||
total=total,
|
total=total,
|
||||||
|
|||||||
@@ -98,9 +98,9 @@ uv run pytest tests/integration -q # 仅集成
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `tests/integration/test_personnel.py` | 未登录 401、新增默认在职、工资标准 str 直出、经理自引用/不存在或已离职/岗位停用 400、删除保护(团队成员与客户归属引用→引导改离职)、辐射区域与拓展计划全量替换(含清空与详情回显)、在职/离职切换、审计成功与失败双记录 | 20 |
|
| `tests/integration/test_personnel.py` | 未登录 401、新增默认在职、工资标准 str 直出、经理自引用/不存在或已离职/岗位停用 400、删除保护(团队成员与客户归属引用→引导改离职)、辐射区域与拓展计划全量替换(含清空与详情回显)、在职/离职切换、审计成功与失败双记录 | 20 |
|
||||||
| `tests/integration/test_products.py` | 未登录 401、类别值域 400、名称+规格组合唯一(同名称不同规格放行)、单价负数/非数字 400、启停切换、改类别、审计留痕 | 14 |
|
| `tests/integration/test_products.py` | 未登录 401、类别值域 400、名称+规格组合唯一(同名称不同规格放行)、单价负数/非数字 400、启停切换、改类别、审计留痕 | 14 |
|
||||||
| `tests/integration/test_customers.py` | 未登录 401、客户分类值域 400、「公司名称+负责人」组合唯一(同公司不同负责人放行)、归属变更(主表更新+留痕字段断言)、归属人员离职 400、新旧相同含双方为空 400、留痕分页 join 姓名、编辑端点不触归属、审计留痕 | 14 |
|
| `tests/integration/test_customers.py` | 未登录 401、客户分类值域 400、「公司名称+负责人」组合唯一(同公司不同负责人放行)、归属变更(主表更新+留痕字段断言)、归属人员离职 400、新旧相同含双方为空 400、留痕分页 join 姓名、编辑端点不触归属、审计留痕、评级保存(回显总分/时间、覆盖式保存)、评分 101→400、按评分排序未评分垫底(asc/desc) | 18 |
|
||||||
|
|
||||||
运行:`uv run pytest tests/integration/test_personnel.py tests/integration/test_products.py tests/integration/test_customers.py -q` → **48 passed**(2026-10-13,连跑两次幂等)。
|
运行:`uv run pytest tests/integration/test_personnel.py tests/integration/test_products.py tests/integration/test_customers.py -q` → **48 passed**(2026-10-13,连跑两次幂等);2026-10-23 评级功能后客户单文件 **18 passed**(18.5s)。
|
||||||
|
|
||||||
### 业务数据管理(2026-10-13 完成)
|
### 业务数据管理(2026-10-13 完成)
|
||||||
|
|
||||||
@@ -205,9 +205,13 @@ uv run pytest tests/integration -q # 仅集成
|
|||||||
| 2026-10-20 | 全量回归 | 862 | ✅ 862 passed(11:32) | **工作台/报表修复提交后的全量复核**;e2e 三脚本复跑:role_home 4/4、report 12/12、calc_settlement 8/8;calc 审批开关已还原为 1,无 `e2e_role_*` 残留 |
|
| 2026-10-20 | 全量回归 | 862 | ✅ 862 passed(11:32) | **工作台/报表修复提交后的全量复核**;e2e 三脚本复跑:role_home 4/4、report 12/12、calc_settlement 8/8;calc 审批开关已还原为 1,无 `e2e_role_*` 残留 |
|
||||||
| 2026-10-20 | code-review 遗留 2 项 | 全量 875 | ✅ 875 passed(12:13) | **方案版本化 + 审批内容快照**(V11 迁移):审批单 `version_no`/`snapshot_json`,方案行 `is_active` 版本化;编辑/驳回不再物理删方案;历史单无快照时 live 回退并显式标注。新增集成 7 + 单元 6;定点审批/月结/核算/业务/报表全绿;ruff 与 type-check/build 绿 |
|
| 2026-10-20 | code-review 遗留 2 项 | 全量 875 | ✅ 875 passed(12:13) | **方案版本化 + 审批内容快照**(V11 迁移):审批单 `version_no`/`snapshot_json`,方案行 `is_active` 版本化;编辑/驳回不再物理删方案;历史单无快照时 live 回退并显式标注。新增集成 7 + 单元 6;定点审批/月结/核算/业务/报表全绿;ruff 与 type-check/build 绿 |
|
||||||
| 2026-10-20 | 遗留项自评审收口 | 全量 891 | ✅ 891 passed(10:23) | **列表 defer 快照列**(审批列表/工作台待办/超时预警)+ **送审内容未变不重提、方案未变不新增版本**(两个 version_no 语义解耦)+ **月结提交加行锁**。新增单元 10 + 集成 5;单元 525、集成定点 96 全绿 |
|
| 2026-10-20 | 遗留项自评审收口 | 全量 891 | ✅ 891 passed(10:23) | **列表 defer 快照列**(审批列表/工作台待办/超时预警)+ **送审内容未变不重提、方案未变不新增版本**(两个 version_no 语义解耦)+ **月结提交加行锁**。新增单元 10 + 集成 5;单元 525、集成定点 96 全绿 |
|
||||||
|
| 2026-10-22 | 清理批次(散参数 DTO 化) | 全量 900 | ✅ 900 passed(11:08) | **仓储层散参数收敛为查询 DTO**(`list_*(session, query)` 取代 keyword/status/offset/limit 四参数),同步调整服务层与测试调用;单元 526 绿、ruff 绿、前端 type-check 与 build 绿 |
|
||||||
| 2026-10-19 | 单元全量 + 报表定点 | 484 | ✅ 484 passed(45.94s);报表单元 11 例 0.70s | **M4 报表模块收尾**(前端四页/打印视图/导出与单元测试补齐)。ruff 绿;前端 `pnpm type-check` 绿。报表集成 7 例记录见上节 |
|
| 2026-10-19 | 单元全量 + 报表定点 | 484 | ✅ 484 passed(45.94s);报表单元 11 例 0.70s | **M4 报表模块收尾**(前端四页/打印视图/导出与单元测试补齐)。ruff 绿;前端 `pnpm type-check` 绿。报表集成 7 例记录见上节 |
|
||||||
| 2026-10-19 | 全部 | 806 | ✅ 806 passed(7:59) | **M4 ⑤ 报表统计全量回归**(报表新增 18 例:集成 7 + 单元 11;另含并行会话并入的 M3 前端修复与字体合规)。ruff 绿。e2e:`calc_settlement_flow` 8 步由并行会话跑通;`report_flow.py` 待 `E2E_ADMIN_PASSWORD` 运行 |
|
| 2026-10-19 | 全部 | 806 | ✅ 806 passed(7:59) | **M4 ⑤ 报表统计全量回归**(报表新增 18 例:集成 7 + 单元 11;另含并行会话并入的 M3 前端修复与字体合规)。ruff 绿。e2e:`calc_settlement_flow` 8 步由并行会话跑通;`report_flow.py` 待 `E2E_ADMIN_PASSWORD` 运行 |
|
||||||
|
|
||||||
|
| 2026-10-23 | 参数文案中文化 | 单元 526 + 参数集成 24 | ✅ 526 passed(45.15s)/ 24 passed(10.60s) | **计提参数页全面中文化**:`param_registry` 22 条 `description` 改写为业务语言(去掉「澄清 #10」「M3」「禁止按 0.011111 截断」等开发者语汇),`db/init/init-dev.sql` 种子同源同步;`param_service` 模板报错改中文模板名、产品类别报错去掉英文枚举;`dashboard_service` 概览口径说明重写。**这些文案无测试断言**(`rate_text` 断言 `11/1125`、`聚合:20%+5%` 未动),故用例数不变、全绿即口径未被破坏。ruff 绿;前端 `pnpm type-check` 绿 |
|
||||||
|
| 2026-10-23 | 台账/预览的计提说明口语化 | 单元 526 + 集成 87(calc/settlements/param/reports) | ✅ 526 passed(47.29s)/ 87 passed(7:41) | **`rate_text`/`rate_note`/聚合行 `note` 改业务话**:`企业户固定计提比例`→「企业户:不按回款天数,统一按固定比例计提」、`业务员递减:10‰ 起,20 天(分界 360 天…)`→「回款 20 天,从起始 10‰ 按日递减(满 360 天后固定按 4‰ 计算)」、`信息员:本人销售额 × 计提比例`→「信息员:按本人销售额乘计提比例,不随回款天数递减」、聚合行 `#12 参数化:基数不含经理个人户业绩;9.8 方案公式`→「团队分成:基数不含经理本人名下业绩,按本团队与全公司两级比例合计计提」。**用例同步**:`test_经理聚合口径_预览拒绝` 原断言消息含「聚合」,改为断言含「营销经理」+「月度结算」(守「拒绝且给下一步」,不再绑定措辞);`rate_text` 的 `11/1125`、`聚合:20%+5%` 断言未动(那是分数原文与百分比拼装,非说明文案) |
|
||||||
|
|
||||||
## 四、测试发现并修复的缺陷
|
## 四、测试发现并修复的缺陷
|
||||||
|
|
||||||
| 日期 | 缺陷 | 影响 | 处理 |
|
| 日期 | 缺陷 | 影响 | 处理 |
|
||||||
|
|||||||
@@ -451,7 +451,9 @@ class TestContentSnapshot:
|
|||||||
row.snapshot_json = None
|
row.snapshot_json = None
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
detail = client.get(f"{_APPROVALS}/{order['id']}", headers=finance["headers"]).json()["data"]
|
detail = client.get(f"{_APPROVALS}/{order['id']}", headers=finance["headers"]).json()[
|
||||||
|
"data"
|
||||||
|
]
|
||||||
assert detail["content_source"] == "live"
|
assert detail["content_source"] == "live"
|
||||||
assert detail["snapshot"]["captured_at"] is None
|
assert detail["snapshot"]["captured_at"] is None
|
||||||
assert detail["snapshot"]["data"]["business_no"] == data["business_no"]
|
assert detail["snapshot"]["data"]["business_no"] == data["business_no"]
|
||||||
@@ -474,7 +476,9 @@ class TestContentSnapshot:
|
|||||||
row.ref_id = 999_999_999
|
row.ref_id = 999_999_999
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
detail = client.get(f"{_APPROVALS}/{order['id']}", headers=finance["headers"]).json()["data"]
|
detail = client.get(f"{_APPROVALS}/{order['id']}", headers=finance["headers"]).json()[
|
||||||
|
"data"
|
||||||
|
]
|
||||||
assert detail["content_source"] == "missing"
|
assert detail["content_source"] == "missing"
|
||||||
assert detail["snapshot"] is None
|
assert detail["snapshot"] is None
|
||||||
|
|
||||||
@@ -679,9 +683,7 @@ class TestAllocationVersions:
|
|||||||
data = _create_with_plan(client, admin["headers"], p["id"])
|
data = _create_with_plan(client, admin["headers"], p["id"])
|
||||||
|
|
||||||
detail = client.get(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"]).json()["data"]
|
detail = client.get(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"]).json()["data"]
|
||||||
assert [(h["version_no"], h["is_active"]) for h in detail["allocation_history"]] == [
|
assert [(h["version_no"], h["is_active"]) for h in detail["allocation_history"]] == [(1, 1)]
|
||||||
(1, 1)
|
|
||||||
]
|
|
||||||
|
|
||||||
payload = business_payload(
|
payload = business_payload(
|
||||||
detail["customer_id"],
|
detail["customer_id"],
|
||||||
@@ -691,7 +693,9 @@ class TestAllocationVersions:
|
|||||||
assert resp.status_code == 200, resp.text
|
assert resp.status_code == 200, resp.text
|
||||||
|
|
||||||
detail = client.get(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"]).json()["data"]
|
detail = client.get(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"]).json()["data"]
|
||||||
history = [(h["version_no"], h["is_active"], h["ratio"]) for h in detail["allocation_history"]]
|
history = [
|
||||||
|
(h["version_no"], h["is_active"], h["ratio"]) for h in detail["allocation_history"]
|
||||||
|
]
|
||||||
assert history == [(1, 0, "50.00"), (2, 1, "80.00")]
|
assert history == [(1, 0, "50.00"), (2, 1, "80.00")]
|
||||||
assert [a["ratio"] for a in detail["secondary_allocations"]] == ["80.00"]
|
assert [a["ratio"] for a in detail["secondary_allocations"]] == ["80.00"]
|
||||||
|
|
||||||
|
|||||||
@@ -13,21 +13,24 @@ audit_service.py「上下文内不得吞掉异常后正常退出」的约定相
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
|
|
||||||
from app.core.constants import ACTION_CREATE, MODULE_BUSINESS
|
from app.core.constants import ACTION_CREATE, MODULE_BUSINESS, MODULE_RECEIPT
|
||||||
from app.core.db import get_session_factory
|
from app.core.db import get_session_factory
|
||||||
from app.main import app
|
from app.main import app
|
||||||
from app.models import OperationLog
|
from app.models import OperationLog
|
||||||
from app.services import business_service
|
from app.services import business_service, receipt_service
|
||||||
|
from tests.integration.factories import create_business
|
||||||
|
|
||||||
pytestmark = pytest.mark.integration
|
pytestmark = pytest.mark.integration
|
||||||
|
|
||||||
_BUSINESSES = "/api/v1/business/businesses"
|
_BUSINESSES = "/api/v1/business/businesses"
|
||||||
_CUSTOMERS = "/api/v1/base/customers"
|
_CUSTOMERS = "/api/v1/base/customers"
|
||||||
|
_RECEIPTS = "/api/v1/business/receipts"
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -160,3 +163,94 @@ class TestAuditRetry:
|
|||||||
# 每层各写一条记录;修复后重试有上限,最终失败仅一条失败日志
|
# 每层各写一条记录;修复后重试有上限,最终失败仅一条失败日志
|
||||||
assert _log_count(normal["username"], success=False) == base_fail + 1
|
assert _log_count(normal["username"], success=False) == base_fail + 1
|
||||||
assert _log_count(normal["username"], success=True) == base_ok
|
assert _log_count(normal["username"], success=True) == base_ok
|
||||||
|
|
||||||
|
|
||||||
|
def _receipt_log_count(username: str, *, success: bool) -> int:
|
||||||
|
"""按操作人统计「新增回款」操作日志条数。"""
|
||||||
|
factory = get_session_factory()
|
||||||
|
with factory() as session:
|
||||||
|
stmt = (
|
||||||
|
select(func.count())
|
||||||
|
.select_from(OperationLog)
|
||||||
|
.where(
|
||||||
|
OperationLog.username == username,
|
||||||
|
OperationLog.module == MODULE_RECEIPT,
|
||||||
|
OperationLog.action == ACTION_CREATE,
|
||||||
|
OperationLog.summary.like("新增回款%"),
|
||||||
|
OperationLog.success == int(success),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return int(session.execute(stmt).scalar_one())
|
||||||
|
|
||||||
|
|
||||||
|
def _receipt_payload(business_id: int) -> dict:
|
||||||
|
return {
|
||||||
|
"business_id": business_id,
|
||||||
|
"receipt_date": str(date.today()),
|
||||||
|
"amount": "1200.00",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestReceiptAuditRetry:
|
||||||
|
"""回款新增的 1062 重试路径(回款与业务同构,但走独立实现)。
|
||||||
|
|
||||||
|
回款侧的重试循环本次从 create 函数体内移入 helper 调用,重试边界、
|
||||||
|
审计条数、批次号重取都必须保持原样 —— 用例会盯住这三条。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_首次撞号重试成功_审计恰一条真成功(
|
||||||
|
self, quiet_client: TestClient, normal: dict, monkeypatch
|
||||||
|
) -> None:
|
||||||
|
client = quiet_client
|
||||||
|
business = create_business(client, normal["headers"])
|
||||||
|
first = client.post(
|
||||||
|
_RECEIPTS, json=_receipt_payload(business["id"]), headers=normal["headers"]
|
||||||
|
)
|
||||||
|
assert first.status_code == 200, first.text
|
||||||
|
existing_no = first.json()["data"]["receipt_batch_no"]
|
||||||
|
|
||||||
|
real_generate = receipt_service._generate_no
|
||||||
|
calls = {"n": 0}
|
||||||
|
|
||||||
|
def flaky_generate(session):
|
||||||
|
calls["n"] += 1
|
||||||
|
if calls["n"] == 1:
|
||||||
|
return existing_no # 首次返回库内已存在批次号 → flush 撞 1062
|
||||||
|
return real_generate(session)
|
||||||
|
|
||||||
|
monkeypatch.setattr(receipt_service, "_generate_no", flaky_generate)
|
||||||
|
base_ok = _receipt_log_count(normal["username"], success=True)
|
||||||
|
base_fail = _receipt_log_count(normal["username"], success=False)
|
||||||
|
|
||||||
|
second = client.post(
|
||||||
|
_RECEIPTS, json=_receipt_payload(business["id"]), headers=normal["headers"]
|
||||||
|
)
|
||||||
|
assert second.status_code == 200, second.text
|
||||||
|
assert calls["n"] == 2, "重试应恰好再取一次号"
|
||||||
|
assert _receipt_log_count(normal["username"], success=True) == base_ok + 1
|
||||||
|
assert _receipt_log_count(normal["username"], success=False) == base_fail
|
||||||
|
|
||||||
|
def test_持续冲突_最终失败且审计恰一条失败(
|
||||||
|
self, quiet_client: TestClient, normal: dict, monkeypatch
|
||||||
|
) -> None:
|
||||||
|
client = quiet_client
|
||||||
|
business = create_business(client, normal["headers"])
|
||||||
|
first = client.post(
|
||||||
|
_RECEIPTS, json=_receipt_payload(business["id"]), headers=normal["headers"]
|
||||||
|
)
|
||||||
|
assert first.status_code == 200, first.text
|
||||||
|
existing_no = first.json()["data"]["receipt_batch_no"]
|
||||||
|
|
||||||
|
def always_conflict(session):
|
||||||
|
return existing_no
|
||||||
|
|
||||||
|
monkeypatch.setattr(receipt_service, "_generate_no", always_conflict)
|
||||||
|
base_ok = _receipt_log_count(normal["username"], success=True)
|
||||||
|
base_fail = _receipt_log_count(normal["username"], success=False)
|
||||||
|
|
||||||
|
second = client.post(
|
||||||
|
_RECEIPTS, json=_receipt_payload(business["id"]), headers=normal["headers"]
|
||||||
|
)
|
||||||
|
assert second.status_code == 400, second.text
|
||||||
|
assert _receipt_log_count(normal["username"], success=False) == base_fail + 1
|
||||||
|
assert _receipt_log_count(normal["username"], success=True) == base_ok
|
||||||
@@ -452,9 +452,7 @@ class TestEdit:
|
|||||||
assert resp.status_code == 400, resp.text
|
assert resp.status_code == 400, resp.text
|
||||||
assert "含合同负责营销人员" in resp.json()["message"]
|
assert "含合同负责营销人员" in resp.json()["message"]
|
||||||
|
|
||||||
def test_二次分配方案合法比例_保存成功且回显(
|
def test_二次分配方案合法比例_保存成功且回显(self, client: TestClient, normal: dict) -> None:
|
||||||
self, client: TestClient, normal: dict
|
|
||||||
) -> None:
|
|
||||||
headers = normal["headers"]
|
headers = normal["headers"]
|
||||||
customer = _customer(client, headers)
|
customer = _customer(client, headers)
|
||||||
owner = _personnel(client, headers)
|
owner = _personnel(client, headers)
|
||||||
|
|||||||
@@ -376,3 +376,73 @@ class TestAudit:
|
|||||||
latest = logs[0]
|
latest = logs[0]
|
||||||
assert customer["company_name"] in latest.summary, "摘要应含客户名"
|
assert customer["company_name"] in latest.summary, "摘要应含客户名"
|
||||||
assert "→" in latest.summary, "摘要应体现新旧归属走向"
|
assert "→" in latest.summary, "摘要应体现新旧归属走向"
|
||||||
|
|
||||||
|
|
||||||
|
class TestRating:
|
||||||
|
"""评级保存与列表排序 —— 评分是档案属性,只存总分与评级时间。
|
||||||
|
|
||||||
|
守的是:评分只走专属端点(编辑端点不含评分,旁路写评分会绕开口径);
|
||||||
|
分值红线 0-100 在入口拦截;按评分排序时未评分客户必须垫底,不能因
|
||||||
|
MySQL 把 NULL 视为最小值而在升序里压过打了低分的客户。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_保存评级_回显总分与时间_且编辑端点不含评分(
|
||||||
|
self, client: TestClient, normal: dict
|
||||||
|
) -> None:
|
||||||
|
headers = normal["headers"]
|
||||||
|
customer = _create_customer(client, headers)
|
||||||
|
assert customer["rating_score"] is None, "新客户应无评分"
|
||||||
|
|
||||||
|
resp = client.put(
|
||||||
|
f"{_CUSTOMERS}/{customer['id']}/rating", json={"score": 88}, headers=headers
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
body = resp.json()["data"]
|
||||||
|
assert body["rating_score"] == 88
|
||||||
|
assert body["rating_time"] is not None, "保存评分必须刷新评级时间"
|
||||||
|
|
||||||
|
# 覆盖式保存:新分数替换旧分数
|
||||||
|
resp = client.put(
|
||||||
|
f"{_CUSTOMERS}/{customer['id']}/rating", json={"score": 60}, headers=headers
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert resp.json()["data"]["rating_score"] == 60
|
||||||
|
|
||||||
|
def test_评分越界_400(self, client: TestClient, normal: dict) -> None:
|
||||||
|
headers = normal["headers"]
|
||||||
|
customer = _create_customer(client, headers)
|
||||||
|
resp = client.put(
|
||||||
|
f"{_CUSTOMERS}/{customer['id']}/rating", json={"score": 101}, headers=headers
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400, resp.text
|
||||||
|
|
||||||
|
def test_按评分排序_未评分垫底_升序降序都不压过低分(
|
||||||
|
self, client: TestClient, normal: dict
|
||||||
|
) -> None:
|
||||||
|
headers = normal["headers"]
|
||||||
|
high = _create_customer(client, headers)
|
||||||
|
low = _create_customer(client, headers)
|
||||||
|
unrated = _create_customer(client, headers)
|
||||||
|
for target, score in ((high, 80), (low, 60)):
|
||||||
|
resp = client.put(
|
||||||
|
f"{_CUSTOMERS}/{target['id']}/rating", json={"score": score}, headers=headers
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
|
||||||
|
def _order_names(order: str) -> list[str]:
|
||||||
|
resp = client.get(
|
||||||
|
_CUSTOMERS,
|
||||||
|
params={"pageNum": 1, "pageSize": 50, "order_by": "rating_score", "order": order},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
names = [row["company_name"] for row in resp.json()["data"]["list"]]
|
||||||
|
return names
|
||||||
|
|
||||||
|
# 降序:高分在前,未评分最后;升序:低分在前,未评分仍垫底
|
||||||
|
desc_names = _order_names("desc")
|
||||||
|
assert desc_names.index(high["company_name"]) < desc_names.index(low["company_name"])
|
||||||
|
assert desc_names[-1] == unrated["company_name"]
|
||||||
|
asc_names = _order_names("asc")
|
||||||
|
assert asc_names.index(low["company_name"]) < asc_names.index(high["company_name"])
|
||||||
|
assert asc_names[-1] == unrated["company_name"], "未评分必须垫底"
|
||||||
@@ -118,10 +118,20 @@ def _dashboard(client: TestClient, headers: dict, month: str | None = None) -> d
|
|||||||
return resp.json()["data"]
|
return resp.json()["data"]
|
||||||
|
|
||||||
|
|
||||||
|
def _activities(client: TestClient, headers: dict, **params) -> dict:
|
||||||
|
"""最近动态分页端点。"""
|
||||||
|
resp = client.get(f"{_DASHBOARD}/activities", params=params, headers=headers)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
return resp.json()["data"]
|
||||||
|
|
||||||
|
|
||||||
def test_权限_普通用户403文案(client: TestClient, normal: dict):
|
def test_权限_普通用户403文案(client: TestClient, normal: dict):
|
||||||
resp = client.get(_DASHBOARD, headers=normal["headers"])
|
resp = client.get(_DASHBOARD, headers=normal["headers"])
|
||||||
assert resp.status_code == 403
|
assert resp.status_code == 403
|
||||||
assert resp.json()["message"] == "无权访问工作台"
|
assert resp.json()["message"] == "无权访问工作台"
|
||||||
|
# 最近动态分页端点同属工作台,权限一致
|
||||||
|
resp = client.get(f"{_DASHBOARD}/activities", headers=normal["headers"])
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
def test_总览_绩效合计与台账一致且非法月份400(client: TestClient, admin: dict):
|
def test_总览_绩效合计与台账一致且非法月份400(client: TestClient, admin: dict):
|
||||||
@@ -138,9 +148,7 @@ def test_总览_绩效合计与台账一致且非法月份400(client: TestClient
|
|||||||
expected = Decimal(
|
expected = Decimal(
|
||||||
str(
|
str(
|
||||||
session.execute(
|
session.execute(
|
||||||
select(
|
select(func.coalesce(func.sum(PerformanceLedger.performance_amount), 0)).where(
|
||||||
func.coalesce(func.sum(PerformanceLedger.performance_amount), 0)
|
|
||||||
).where(
|
|
||||||
PerformanceLedger.settlement_month == _month(),
|
PerformanceLedger.settlement_month == _month(),
|
||||||
PerformanceLedger.calc_type == 1,
|
PerformanceLedger.calc_type == 1,
|
||||||
PerformanceLedger.is_active == 1,
|
PerformanceLedger.is_active == 1,
|
||||||
@@ -166,16 +174,12 @@ def test_待办_待审批与未指定批次与同步降级(client: TestClient, a
|
|||||||
)
|
)
|
||||||
assert resp.status_code == 200, resp.text
|
assert resp.status_code == 200, resp.text
|
||||||
settlement_id = resp.json()["data"]["id"]
|
settlement_id = resp.json()["data"]["id"]
|
||||||
resp = client.post(
|
resp = client.post(f"{_SETTLEMENTS}/{settlement_id}/submit", headers=admin["headers"])
|
||||||
f"{_SETTLEMENTS}/{settlement_id}/submit", headers=admin["headers"]
|
|
||||||
)
|
|
||||||
assert resp.status_code == 200, resp.text
|
assert resp.status_code == 200, resp.text
|
||||||
|
|
||||||
data = _dashboard(client, admin["headers"])
|
data = _dashboard(client, admin["headers"])
|
||||||
assert data["todos"]["pending_approval_total"] >= 1
|
assert data["todos"]["pending_approval_total"] >= 1
|
||||||
assert any(
|
assert any(item["order_type"] == 2 for item in data["todos"]["pending_approvals"])
|
||||||
item["order_type"] == 2 for item in data["todos"]["pending_approvals"]
|
|
||||||
)
|
|
||||||
assert data["todos"]["unassigned_receipt_total"] >= 1
|
assert data["todos"]["unassigned_receipt_total"] >= 1
|
||||||
assert data["todos"]["unassigned_receipts"][0]["receipt_id"] == ctx["receipt"]["id"]
|
assert data["todos"]["unassigned_receipts"][0]["receipt_id"] == ctx["receipt"]["id"]
|
||||||
# ⑥ 未建:显式降级
|
# ⑥ 未建:显式降级
|
||||||
@@ -227,9 +231,40 @@ def test_预警_审批超时命中且参数关闭后不产生(client: TestClient
|
|||||||
|
|
||||||
def test_动态_来源操作日志(client: TestClient, admin: dict):
|
def test_动态_来源操作日志(client: TestClient, admin: dict):
|
||||||
_chain(client, admin["headers"])
|
_chain(client, admin["headers"])
|
||||||
data = _dashboard(client, admin["headers"])
|
data = _activities(client, admin["headers"])
|
||||||
assert data["activities"], "工作台动态为空:写操作应产生操作日志"
|
assert data["list"], "工作台动态为空:写操作应产生操作日志"
|
||||||
assert {"module", "action", "summary", "create_time"} <= set(data["activities"][0])
|
assert {"module", "action", "summary", "create_time"} <= set(data["list"][0])
|
||||||
|
|
||||||
|
|
||||||
|
def test_动态_分页翻页不重不漏(client: TestClient, admin: dict):
|
||||||
|
_chain(client, admin["headers"])
|
||||||
|
first = _activities(client, admin["headers"], pageNum=1, pageSize=2)
|
||||||
|
assert first["pageNum"] == 1
|
||||||
|
assert first["pageSize"] == 2
|
||||||
|
assert len(first["list"]) == 2
|
||||||
|
assert first["total"] >= 2
|
||||||
|
|
||||||
|
second = _activities(client, admin["headers"], pageNum=2, pageSize=2)
|
||||||
|
assert len(second["list"]) == 2
|
||||||
|
# 两页不重不漏(倒序取,按 id 倒排)
|
||||||
|
first_ids = {item["id"] for item in first["list"]}
|
||||||
|
second_ids = {item["id"] for item in second["list"]}
|
||||||
|
assert first_ids.isdisjoint(second_ids)
|
||||||
|
assert first["list"][0]["id"] > second["list"][0]["id"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_动态_模块筛选与筛选项(client: TestClient, admin: dict):
|
||||||
|
_chain(client, admin["headers"])
|
||||||
|
page = _activities(client, admin["headers"], pageSize=50)
|
||||||
|
assert page["modules"], "模块筛选项不应为空"
|
||||||
|
module = page["modules"][0]
|
||||||
|
|
||||||
|
filtered = _activities(client, admin["headers"], module=module, pageSize=50)
|
||||||
|
assert filtered["total"] >= 1
|
||||||
|
assert filtered["list"], "按模块筛选后应有数据"
|
||||||
|
assert all(item["module"] == module for item in filtered["list"])
|
||||||
|
# 筛选项不随筛选条件变化(下拉里始终是全量模块)
|
||||||
|
assert filtered["modules"] == page["modules"]
|
||||||
|
|
||||||
|
|
||||||
# ==== 角色待办 / 预警阈值 / 趋势(2026-10-19 code-review 修复回归)====
|
# ==== 角色待办 / 预警阈值 / 趋势(2026-10-19 code-review 修复回归)====
|
||||||
@@ -335,17 +370,17 @@ def test_待办_按角色仅显示可操作节点(
|
|||||||
assert _dashboard(client, both["headers"])["todos"]["pending_approval_total"] == 2
|
assert _dashboard(client, both["headers"])["todos"]["pending_approval_total"] == 2
|
||||||
|
|
||||||
finance_orders = {
|
finance_orders = {
|
||||||
item["order_no"] for item in _dashboard(client, finance["headers"])["todos"]["pending_approvals"]
|
item["order_no"]
|
||||||
|
for item in _dashboard(client, finance["headers"])["todos"]["pending_approvals"]
|
||||||
}
|
}
|
||||||
leader_orders = {
|
leader_orders = {
|
||||||
item["order_no"] for item in _dashboard(client, leader["headers"])["todos"]["pending_approvals"]
|
item["order_no"]
|
||||||
|
for item in _dashboard(client, leader["headers"])["todos"]["pending_approvals"]
|
||||||
}
|
}
|
||||||
assert finance_orders.isdisjoint(leader_orders)
|
assert finance_orders.isdisjoint(leader_orders)
|
||||||
|
|
||||||
|
|
||||||
def test_待办_财务通过后领导出现待办(
|
def test_待办_财务通过后领导出现待办(client: TestClient, admin: dict, finance: dict, leader: dict):
|
||||||
client: TestClient, admin: dict, finance: dict, leader: dict
|
|
||||||
):
|
|
||||||
"""真实流转:财务节点通过后,条目从财务待办消失并进入领导待办。"""
|
"""真实流转:财务节点通过后,条目从财务待办消失并进入领导待办。"""
|
||||||
_chain(client, admin["headers"])
|
_chain(client, admin["headers"])
|
||||||
resp = client.post(
|
resp = client.post(
|
||||||
@@ -353,9 +388,7 @@ def test_待办_财务通过后领导出现待办(
|
|||||||
)
|
)
|
||||||
assert resp.status_code == 200, resp.text
|
assert resp.status_code == 200, resp.text
|
||||||
settlement_id = resp.json()["data"]["id"]
|
settlement_id = resp.json()["data"]["id"]
|
||||||
resp = client.post(
|
resp = client.post(f"{_SETTLEMENTS}/{settlement_id}/submit", headers=admin["headers"])
|
||||||
f"{_SETTLEMENTS}/{settlement_id}/submit", headers=admin["headers"]
|
|
||||||
)
|
|
||||||
assert resp.status_code == 200, resp.text
|
assert resp.status_code == 200, resp.text
|
||||||
|
|
||||||
order = _dashboard(client, admin["headers"])["todos"]["pending_approvals"][0]
|
order = _dashboard(client, admin["headers"])["todos"]["pending_approvals"][0]
|
||||||
|
|||||||
@@ -326,6 +326,38 @@ class TestPreview:
|
|||||||
# Fraction 自动约分:4/1000 存储为 1/250,按数值断言
|
# Fraction 自动约分:4/1000 存储为 1/250,按数值断言
|
||||||
assert Fraction(data["rate"]) == Fraction(4, 1000)
|
assert Fraction(data["rate"]) == Fraction(4, 1000)
|
||||||
|
|
||||||
|
def test_预览参数使用顺序稳定(self, client: TestClient, admin: dict) -> None:
|
||||||
|
"""params_used 的顺序即前端「用了哪些参数」的展示顺序。
|
||||||
|
|
||||||
|
预览实现拆成多段后,各段的调用先后决定参数读取先后;顺序漂移会让
|
||||||
|
复核者看到的参数清单与核算口径对不上(不报错,但会误导判读)。
|
||||||
|
"""
|
||||||
|
payload = {
|
||||||
|
"business_role": "salesperson",
|
||||||
|
"customer_type": "personal",
|
||||||
|
"product_category": "municipal_water",
|
||||||
|
"ship_date": "2025-12-15",
|
||||||
|
"receipt_date": "2026-01-10",
|
||||||
|
"revenue": "100000",
|
||||||
|
"profit": "45000",
|
||||||
|
"received_amount": "60000",
|
||||||
|
"barter": True,
|
||||||
|
"probation": True,
|
||||||
|
}
|
||||||
|
data = client.post(f"{_PARAMS}/preview", json=payload, headers=admin["headers"]).json()[
|
||||||
|
"data"
|
||||||
|
]
|
||||||
|
assert [p["param_code"] for p in data["params_used"]] == [
|
||||||
|
"salesperson.rate.start",
|
||||||
|
"salesperson.rate.slope",
|
||||||
|
"salesperson.rate.floor",
|
||||||
|
"salesperson.days.boundary",
|
||||||
|
"product.threshold",
|
||||||
|
"barter.discount",
|
||||||
|
"payment.start.ratio",
|
||||||
|
"probation.annual.threshold",
|
||||||
|
]
|
||||||
|
|
||||||
def test_经理聚合口径_预览拒绝(self, client: TestClient, admin: dict) -> None:
|
def test_经理聚合口径_预览拒绝(self, client: TestClient, admin: dict) -> None:
|
||||||
payload = {
|
payload = {
|
||||||
"business_role": "sales_manager",
|
"business_role": "sales_manager",
|
||||||
@@ -339,7 +371,11 @@ class TestPreview:
|
|||||||
}
|
}
|
||||||
resp = client.post(f"{_PARAMS}/preview", json=payload, headers=admin["headers"])
|
resp = client.post(f"{_PARAMS}/preview", json=payload, headers=admin["headers"])
|
||||||
assert resp.status_code == 400
|
assert resp.status_code == 400
|
||||||
assert "聚合" in resp.json()["message"]
|
# 守两件事:① 聚合口径不允许单笔试算(拒绝而不是算出个错数);
|
||||||
|
# ② 提示要能指导下一步(点名是谁的口径 + 去哪儿看),不是一句「不支持」。
|
||||||
|
message = resp.json()["message"]
|
||||||
|
assert "营销经理" in message
|
||||||
|
assert "月度结算" in message
|
||||||
|
|
||||||
def test_回款日早于发货日_400(self, client: TestClient, admin: dict) -> None:
|
def test_回款日早于发货日_400(self, client: TestClient, admin: dict) -> None:
|
||||||
payload = {
|
payload = {
|
||||||
|
|||||||
@@ -44,7 +44,9 @@ def _role_id(code: str) -> int:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _person(client: TestClient, headers: dict, *, role: str = "salesperson", manager_id=None) -> dict:
|
def _person(
|
||||||
|
client: TestClient, headers: dict, *, role: str = "salesperson", manager_id=None
|
||||||
|
) -> dict:
|
||||||
body = {
|
body = {
|
||||||
"personnel_name": f"t_报表_{uuid.uuid4().hex[:8]}",
|
"personnel_name": f"t_报表_{uuid.uuid4().hex[:8]}",
|
||||||
"business_role_id": _role_id(role),
|
"business_role_id": _role_id(role),
|
||||||
@@ -194,9 +196,7 @@ def test_部门汇总_无成员经理出零值行(admin, client: TestClient):
|
|||||||
headers=admin["headers"],
|
headers=admin["headers"],
|
||||||
)
|
)
|
||||||
assert resp.status_code == 200, resp.text
|
assert resp.status_code == 200, resp.text
|
||||||
team = next(
|
team = next((t for t in resp.json()["data"]["teams"] if t["manager_id"] == manager["id"]), None)
|
||||||
(t for t in resp.json()["data"]["teams"] if t["manager_id"] == manager["id"]), None
|
|
||||||
)
|
|
||||||
assert team is not None
|
assert team is not None
|
||||||
assert Decimal(str(team["team_base"])) == Decimal("0")
|
assert Decimal(str(team["team_base"])) == Decimal("0")
|
||||||
assert team["member_count"] == 0
|
assert team["member_count"] == 0
|
||||||
@@ -224,7 +224,14 @@ def test_回款统计_空桶恒在且分摊拆行金额只计一次(admin, clien
|
|||||||
|
|
||||||
assert len(data["buckets"]) == 8
|
assert len(data["buckets"]) == 8
|
||||||
assert [b["code"] for b in data["buckets"]] == [
|
assert [b["code"] for b in data["buckets"]] == [
|
||||||
"le0", "1-30", "31-60", "61-90", "91-180", "181-360", "361+", "none",
|
"le0",
|
||||||
|
"1-30",
|
||||||
|
"31-60",
|
||||||
|
"61-90",
|
||||||
|
"91-180",
|
||||||
|
"181-360",
|
||||||
|
"361+",
|
||||||
|
"none",
|
||||||
]
|
]
|
||||||
# 60 天前发货 → 落在 61-90 桶(发货日首日计第 1 天)
|
# 60 天前发货 → 落在 61-90 桶(发货日首日计第 1 天)
|
||||||
bucket = next(b for b in data["buckets"] if b["code"] == "61-90")
|
bucket = next(b for b in data["buckets"] if b["code"] == "61-90")
|
||||||
@@ -293,11 +300,15 @@ def test_导出_excel含签字栏且入操作日志(admin, client: TestClient):
|
|||||||
|
|
||||||
factory = get_session_factory()
|
factory = get_session_factory()
|
||||||
with factory() as session:
|
with factory() as session:
|
||||||
log = session.execute(
|
log = (
|
||||||
|
session.execute(
|
||||||
select(OperationLog)
|
select(OperationLog)
|
||||||
.where(OperationLog.module == "报表统计", OperationLog.action == "导出")
|
.where(OperationLog.module == "报表统计", OperationLog.action == "导出")
|
||||||
.order_by(OperationLog.id.desc())
|
.order_by(OperationLog.id.desc())
|
||||||
).scalars().first()
|
)
|
||||||
|
.scalars()
|
||||||
|
.first()
|
||||||
|
)
|
||||||
assert log is not None
|
assert log is not None
|
||||||
|
|
||||||
|
|
||||||
@@ -389,19 +400,21 @@ def _latest_export_log():
|
|||||||
|
|
||||||
factory = get_session_factory()
|
factory = get_session_factory()
|
||||||
with factory() as session:
|
with factory() as session:
|
||||||
return session.execute(
|
return (
|
||||||
|
session.execute(
|
||||||
select(OperationLog)
|
select(OperationLog)
|
||||||
.where(OperationLog.module == "报表统计", OperationLog.action == "导出")
|
.where(OperationLog.module == "报表统计", OperationLog.action == "导出")
|
||||||
.order_by(OperationLog.id.desc())
|
.order_by(OperationLog.id.desc())
|
||||||
).scalars().first()
|
)
|
||||||
|
.scalars()
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_部门汇总_无台账经理挂上级不报500(admin, client: TestClient):
|
def test_部门汇总_无台账经理挂上级不报500(admin, client: TestClient):
|
||||||
"""经理 A 无台账且上级是经理 B:不能因 member_map 直接索引而 KeyError。"""
|
"""经理 A 无台账且上级是经理 B:不能因 member_map 直接索引而 KeyError。"""
|
||||||
manager_b = _person(client, admin["headers"], role="sales_manager")
|
manager_b = _person(client, admin["headers"], role="sales_manager")
|
||||||
manager_a = _person(
|
manager_a = _person(client, admin["headers"], role="sales_manager", manager_id=manager_b["id"])
|
||||||
client, admin["headers"], role="sales_manager", manager_id=manager_b["id"]
|
|
||||||
)
|
|
||||||
|
|
||||||
resp = client.get(
|
resp = client.get(
|
||||||
f"{_REPORTS}/department-summary",
|
f"{_REPORTS}/department-summary",
|
||||||
@@ -467,9 +480,7 @@ def test_部门汇总_经理个人户计入时基数等于成员合计(admin, cl
|
|||||||
headers=admin["headers"],
|
headers=admin["headers"],
|
||||||
)
|
)
|
||||||
assert resp.status_code == 200, resp.text
|
assert resp.status_code == 200, resp.text
|
||||||
team = next(
|
team = next(t for t in resp.json()["data"]["teams"] if t["manager_id"] == manager["id"])
|
||||||
t for t in resp.json()["data"]["teams"] if t["manager_id"] == manager["id"]
|
|
||||||
)
|
|
||||||
assert Decimal(str(team["team_base"])) == Decimal("100.00")
|
assert Decimal(str(team["team_base"])) == Decimal("100.00")
|
||||||
assert sum(
|
assert sum(
|
||||||
(Decimal(str(m["performance_sum"])) for m in team["members"]), Decimal("0")
|
(Decimal(str(m["performance_sum"])) for m in team["members"]), Decimal("0")
|
||||||
@@ -516,9 +527,7 @@ def test_部门汇总_按经理筛选不误判其他团队下属(admin, client:
|
|||||||
assert [t["manager_id"] for t in data["teams"]] == [manager_1["id"]]
|
assert [t["manager_id"] for t in data["teams"]] == [manager_1["id"]]
|
||||||
assert data["unassigned"] == []
|
assert data["unassigned"] == []
|
||||||
assert all(
|
assert all(
|
||||||
m["personnel_id"] != salesperson_2["id"]
|
m["personnel_id"] != salesperson_2["id"] for team in data["teams"] for m in team["members"]
|
||||||
for team in data["teams"]
|
|
||||||
for m in team["members"]
|
|
||||||
)
|
)
|
||||||
assert Decimal(str(data["reconciliation"]["other_team_base"])) > 0
|
assert Decimal(str(data["reconciliation"]["other_team_base"])) > 0
|
||||||
|
|
||||||
@@ -557,9 +566,7 @@ def test_部门汇总_对账恒等式(admin, client: TestClient):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _two_manager_scene(
|
def _two_manager_scene(client: TestClient, headers: dict) -> tuple[dict, dict, dict, dict, dict]:
|
||||||
client: TestClient, headers: dict
|
|
||||||
) -> tuple[dict, dict, dict, dict, dict]:
|
|
||||||
"""两位经理各有下属与业务 + 一位零成员经理(无聚合行)。"""
|
"""两位经理各有下属与业务 + 一位零成员经理(无聚合行)。"""
|
||||||
manager_1 = _person(client, headers, role="sales_manager")
|
manager_1 = _person(client, headers, role="sales_manager")
|
||||||
manager_2 = _person(client, headers, role="sales_manager")
|
manager_2 = _person(client, headers, role="sales_manager")
|
||||||
@@ -777,9 +784,7 @@ def test_导出取数异常_失败审计留痕(admin, client: TestClient, monkey
|
|||||||
assert "导出个人绩效工资明细表" in (log.summary or "")
|
assert "导出个人绩效工资明细表" in (log.summary or "")
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize("path", ["/personal-detail", "/department-summary", "/payment-stats"])
|
||||||
"path", ["/personal-detail", "/department-summary", "/payment-stats"]
|
|
||||||
)
|
|
||||||
def test_非法月份_查询返回400(admin, client: TestClient, path: str):
|
def test_非法月份_查询返回400(admin, client: TestClient, path: str):
|
||||||
"""非法月份必须是参数错误 400,不能落 500。"""
|
"""非法月份必须是参数错误 400,不能落 500。"""
|
||||||
resp = client.get(
|
resp = client.get(
|
||||||
@@ -790,9 +795,7 @@ def test_非法月份_查询返回400(admin, client: TestClient, path: str):
|
|||||||
assert resp.status_code == 400, resp.text
|
assert resp.status_code == 400, resp.text
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize("code", ["personal-detail", "department-summary", "payment-stats"])
|
||||||
"code", ["personal-detail", "department-summary", "payment-stats"]
|
|
||||||
)
|
|
||||||
def test_非法月份_导出返回400(admin, client: TestClient, code: str):
|
def test_非法月份_导出返回400(admin, client: TestClient, code: str):
|
||||||
"""导出手工构造查询模型同样要转 400。"""
|
"""导出手工构造查询模型同样要转 400。"""
|
||||||
resp = client.get(
|
resp = client.get(
|
||||||
|
|||||||
@@ -127,8 +127,7 @@ class TestExcel模板:
|
|||||||
cell
|
cell
|
||||||
for row in sheet.iter_rows()
|
for row in sheet.iter_rows()
|
||||||
for cell in row
|
for cell in row
|
||||||
if isinstance(cell.value, (int, float))
|
if isinstance(cell.value, (int, float)) and Decimal(str(cell.value)) == Decimal("39.11")
|
||||||
and Decimal(str(cell.value)) == Decimal("39.11")
|
|
||||||
]
|
]
|
||||||
assert amount_cells, "未找到金额数值单元格(Decimal 被转成了文本?)"
|
assert amount_cells, "未找到金额数值单元格(Decimal 被转成了文本?)"
|
||||||
assert amount_cells[0].number_format == "#,##0.00"
|
assert amount_cells[0].number_format == "#,##0.00"
|
||||||
@@ -142,16 +141,14 @@ class TestExcel模板:
|
|||||||
workbook = load_workbook(io.BytesIO(content))
|
workbook = load_workbook(io.BytesIO(content))
|
||||||
cells = [cell for row in workbook.active.iter_rows() for cell in row]
|
cells = [cell for row in workbook.active.iter_rows() for cell in row]
|
||||||
values = [
|
values = [
|
||||||
Decimal(str(cell.value))
|
Decimal(str(cell.value)) for cell in cells if isinstance(cell.value, (int, float))
|
||||||
for cell in cells
|
|
||||||
if isinstance(cell.value, (int, float))
|
|
||||||
]
|
]
|
||||||
assert Decimal("15.50") in values
|
assert Decimal("15.50") in values
|
||||||
# 「合计」标签只写一次(写满每个文本列是回归缺陷)
|
# 「合计」标签只写一次(写满每个文本列是回归缺陷)
|
||||||
assert sum(1 for cell in cells if cell.value == "合计") == 1
|
assert sum(1 for cell in cells if cell.value == "合计") == 1
|
||||||
|
|
||||||
def test_文本公式注入被前置单引号(self):
|
def test_文本公式注入被前置单引号(self):
|
||||||
evil = "=HYPERLINK(\"http://evil\")"
|
evil = '=HYPERLINK("http://evil")'
|
||||||
content = _build("personal-detail", [_personal_row(business_no=evil)])
|
content = _build("personal-detail", [_personal_row(business_no=evil)])
|
||||||
workbook = load_workbook(io.BytesIO(content))
|
workbook = load_workbook(io.BytesIO(content))
|
||||||
values = [
|
values = [
|
||||||
|
|||||||
+26
-24
@@ -366,6 +366,8 @@ CREATE TABLE customers (
|
|||||||
customer_type VARCHAR(32) NOT NULL COMMENT '客户分类:personal个人户 manager_personal经理个人户 enterprise企业户(计提规则匹配锚点;T+同步不覆盖)' DEFAULT 'personal',
|
customer_type VARCHAR(32) NOT NULL COMMENT '客户分类:personal个人户 manager_personal经理个人户 enterprise企业户(计提规则匹配锚点;T+同步不覆盖)' DEFAULT 'personal',
|
||||||
owner_personnel_id BIGINT UNSIGNED COMMENT '归属负责营销人员ID(手动修改不回溯历史,变更留痕见 ownership_change_logs)',
|
owner_personnel_id BIGINT UNSIGNED COMMENT '归属负责营销人员ID(手动修改不回溯历史,变更留痕见 ownership_change_logs)',
|
||||||
remark VARCHAR(255) COMMENT '备注',
|
remark VARCHAR(255) COMMENT '备注',
|
||||||
|
rating_score TINYINT COMMENT '评级总分(0-100,评级计算器保存;null=未评分;人工维护字段,T+ 同步不覆盖)',
|
||||||
|
rating_time DATETIME COMMENT '最近评级时间(评级计算器保存评分时刷新;人工维护字段)',
|
||||||
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
|
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
|
||||||
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
source_no VARCHAR(64) COMMENT 'T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)',
|
source_no VARCHAR(64) COMMENT 'T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)',
|
||||||
@@ -437,29 +439,29 @@ INSERT INTO param_change_batches (id, version_no, effective_date, change_type, r
|
|||||||
(1, 'P20260101-00', '2026-01-01', 1, '初始版本(系统初始化种子,口径依据 2026-10-12 业务裁决与 2026-10-19 澄清答复)', 'system', 19);
|
(1, 'P20260101-00', '2026-01-01', 1, '初始版本(系统初始化种子,口径依据 2026-10-12 业务裁决与 2026-10-19 澄清答复)', 'system', 19);
|
||||||
|
|
||||||
INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES
|
INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES
|
||||||
(1, 'salesperson.rate.start', '业务员起始计提比例', '业务员个人户', 'DECIMAL', '‰', 'BUSINESS_ROLE', NULL, '10', '0', NULL, '线性递减段起始值:比例 = 起始 − 回款天数 × 斜率(≤分界天数)', 1),
|
(1, 'salesperson.rate.start', '业务员起始计提比例', '业务员个人户', 'DECIMAL', '‰', 'BUSINESS_ROLE', NULL, '10', '0', NULL, '回款当天能拿到的最高计提比例。回款每晚一天,按日递减斜率往下走,直到分界天数为止。', 1),
|
||||||
(2, 'salesperson.rate.slope', '业务员日递减斜率', '业务员个人户', 'DECIMAL', '‰/天', 'BUSINESS_ROLE', NULL, '4/360', '0', NULL, '精确分数 4/360‰/天(=1/90),禁止按 0.011111 截断存储', 2),
|
(2, 'salesperson.rate.slope', '业务员日递减斜率', '业务员个人户', 'DECIMAL', '‰/天', 'BUSINESS_ROLE', NULL, '4/360', '0', NULL, '回款每延后一天,计提比例下降多少。按分数填写(如 4/360)可保持精确,填小数会引入四舍五入误差。', 2),
|
||||||
(3, 'salesperson.rate.floor', '业务员计提下限', '业务员个人户', 'DECIMAL', '‰', 'BUSINESS_ROLE', NULL, '4', '0', NULL, '回款天数超过分界天数后固定取此比例(≥361 天 = 4‰)', 3),
|
(3, 'salesperson.rate.floor', '业务员计提下限', '业务员个人户', 'DECIMAL', '‰', 'BUSINESS_ROLE', NULL, '4', '0', NULL, '回款天数超过分界天数后,计提比例不再下降,固定按这个值计算,即最低计提比例。', 3),
|
||||||
(4, 'salesperson.days.boundary', '线性递减分界天数', '业务员个人户', 'INT', '天', 'BUSINESS_ROLE', NULL, '360', '0', NULL, '≤分界天数按公式线性递减(360 天=6‰);超过则取下限', 4),
|
(4, 'salesperson.days.boundary', '线性递减分界天数', '业务员个人户', 'INT', '天', 'BUSINESS_ROLE', NULL, '360', '0', NULL, '回款天数在这个范围内按日递减;超过这个天数就固定按下限计提。默认 360 天。', 4),
|
||||||
(5, 'manager.share.team', '经理本团队分成比例', '经理分成', 'DECIMAL', '%', 'BUSINESS_ROLE', NULL, '12.5', '0', '100', '∑本人带领业务员绩效 × 此比例(限本团队)', 5),
|
(5, 'manager.share.team', '经理本团队分成比例', '经理分成', 'DECIMAL', '%', 'BUSINESS_ROLE', NULL, '12.5', '0', '100', '经理按本团队业务员的绩效总额提取的分成比例,只统计自己带的业务员。', 5),
|
||||||
(6, 'manager.share.company', '经理全公司分成比例', '经理分成', 'DECIMAL', '%', 'BUSINESS_ROLE', NULL, '5', '0', '100', '∑全部业务员绩效 × 此比例(全公司)', 6),
|
(6, 'manager.share.company', '经理全公司分成比例', '经理分成', 'DECIMAL', '%', 'BUSINESS_ROLE', NULL, '5', '0', '100', '经理按全公司业务员的绩效总额提取的分成比例,与本团队分成叠加计算。', 6),
|
||||||
(7, 'enterprise.rate', '企业户计提比例', '企业户', 'DECIMAL', '‰', 'PRODUCT_CATEGORY', NULL, '3', '0', NULL, '所有企业户适用;2026-08-13 起由 2‰ 提高至 3‰', 7),
|
(7, 'enterprise.rate', '企业户计提比例', '企业户', 'DECIMAL', '‰', 'PRODUCT_CATEGORY', NULL, '3', '0', NULL, '企业户客户的回款统一按这个比例计提,不看回款天数。2026-08-13 起由 2‰ 调整为 3‰。', 7),
|
||||||
(8, 'info.rate', '信息员计提比例', '信息员', 'DECIMAL', '‰', 'BUSINESS_ROLE', NULL, '1', '0', NULL, '信息员绩效 = 本人销售额 × 此比例', 8),
|
(8, 'info.rate', '信息员计提比例', '信息员', 'DECIMAL', '‰', 'BUSINESS_ROLE', NULL, '1', '0', NULL, '信息员的绩效按本人销售额乘这个比例计算,不随回款天数递减。', 8),
|
||||||
(9, 'prepay.rate', '预付款计提比例', '支付环节', 'DECIMAL', '‰', 'NONE', NULL, '10', '0', NULL, '预付款统一按此比例计提', 9),
|
(9, 'prepay.rate', '预付款计提比例', '支付环节', 'DECIMAL', '‰', 'NONE', NULL, '10', '0', NULL, '预付款到账即按这个比例计提绩效,不必等到全额回款。', 9),
|
||||||
(10, 'barter.discount', '以物抵顶折算比例', '支付环节', 'DECIMAL', '%', 'NONE', NULL, '80', '0', '100', '以物抵顶回款绩效按应发绩效的此比例折算', 10),
|
(10, 'barter.discount', '以物抵顶折算比例', '支付环节', 'DECIMAL', '%', 'NONE', NULL, '80', '0', '100', '以物抵顶的回款,按应发绩效的这个比例折算,即打折后的金额参与绩效。', 10),
|
||||||
(11, 'payment.start.ratio', '支付起付比例', '支付环节', 'DECIMAL', '%', 'NONE', NULL, '60', '0', '100', '单项业务回款额达销售额(不含运费)此比例后起付;达标前累计', 11),
|
(11, 'payment.start.ratio', '支付起付比例', '支付环节', 'DECIMAL', '%', 'NONE', NULL, '60', '0', '100', '单项业务的回款累计达到销售额(不含运费)的这个比例后才开始发放绩效;达标之前只累计、不发放。', 11),
|
||||||
(12, 'payment.weight.mode', '支付权重应用方式', '支付环节', 'ENUM', NULL, 'NONE', 'FULL|BALANCE', 'FULL', NULL, NULL, 'FULL=按 R×R 乘当月全额应发绩效;BALANCE=乘余额(语义待 M3 前确认)', 12),
|
(12, 'payment.weight.mode', '支付权重应用方式', '支付环节', 'ENUM', NULL, 'NONE', 'FULL|BALANCE', 'FULL', NULL, NULL, '选择支付权重乘在哪个基数上:当月全额 = 乘本月全部应发绩效;余额 = 只乘尚未发放的部分。', 12),
|
||||||
(13, 'product.threshold', '折算毛利润额阈值', '产品阈值', 'DECIMAL', '%', 'PRODUCT_CATEGORY', NULL, '30', '0', '100', '折算毛利润额 = 销售额 ×(毛利润率 − 阈值);市政水利 30%、轨交 20%', 13),
|
(13, 'product.threshold', '折算毛利润额阈值', '产品阈值', 'DECIMAL', '%', 'PRODUCT_CATEGORY', NULL, '30', '0', '100', '折算毛利润额 = 销售额 ×(毛利润率 − 阈值),低于阈值的部分不参与绩效。市政水利默认 30%,轨道交通默认 20%。', 13),
|
||||||
(14, 'salary.basic.mode', '月工资基本工资口径', '月工资口径', 'ENUM', NULL, 'BUSINESS_ROLE', 'INCLUDE|DEDUCT|EXCLUDE', 'INCLUDE', NULL, NULL, 'INCLUDE含基本工资 / DEDUCT扣除基本工资 / EXCLUDE不计', 14),
|
(14, 'salary.basic.mode', '月工资基本工资口径', '月工资口径', 'ENUM', NULL, 'BUSINESS_ROLE', 'INCLUDE|DEDUCT|EXCLUDE', 'INCLUDE', NULL, NULL, '算月工资时怎么处理基本工资:含基本工资 = 与绩效一起发;扣除基本工资 = 从绩效里扣掉已发的基本工资;不计 = 月工资只发绩效。', 14),
|
||||||
(15, 'probation.annual.threshold', '试用期年累计结算阈值', '月工资口径', 'DECIMAL', '元', 'NONE', NULL, '18000', '0', NULL, '试用期年累计绩效超过此值部分年底一次性结算', 15),
|
(15, 'probation.annual.threshold', '试用期年累计结算阈值', '月工资口径', 'DECIMAL', '元', 'NONE', NULL, '18000', '0', NULL, '试用期绩效先不发放;年内累计超过这个金额的部分,年底一次性结算。', 15),
|
||||||
(17, 'approval.secondary_allocation.enabled', '二次分配审批开关', '审批开关', 'BOOL', NULL, 'NONE', NULL, '1', NULL, NULL, '0=二次分配方案提交即生效(系统自动通过全节点留痕,留痕不断链);1=提交后进入财务复核→领导审核审批流。默认开启(澄清 #5 业务答复 2026-10-19),业务可关闭', 17),
|
(17, 'approval.secondary_allocation.enabled', '二次分配审批开关', '审批开关', 'BOOL', NULL, 'NONE', NULL, '1', NULL, NULL, '二次分配方案提交后是否要审批:开启 = 先经财务复核、再经领导审核,通过后才生效;关闭 = 提交即生效,系统自动记录各节点留痕。', 17),
|
||||||
(18, 'approval.monthly_settlement.enabled', '月度结算审批开关', '审批开关', 'BOOL', NULL, 'NONE', NULL, '1', NULL, NULL, '月度结算审批链开关(④ 月结提交时消费):0=结算生成即生效,1=走财务复核→领导审核审批流。默认开启(澄清 #7 业务答复 2026-10-19,月结涉及工资发放从严),业务可关闭', 18),
|
(18, 'approval.monthly_settlement.enabled', '月度结算审批开关', '审批开关', 'BOOL', NULL, 'NONE', NULL, '1', NULL, NULL, '月度结算提交后是否要审批:开启 = 先经财务复核、再经领导审核;关闭 = 生成即生效。月结涉及工资发放,默认开启。', 18),
|
||||||
(19, 'approval.adjustment.enabled', '调整单审批开关', '审批开关', 'BOOL', NULL, 'NONE', NULL, '0', NULL, NULL, '0=调整单提交即生效(系统自动通过留痕);1=走财务复核→领导审核审批流。调整单是已锁定月份补发/扣减的唯一通道(澄清 #9:走同一审批链,默认值业务未明确,暂按直接生效,业务可开启)', 19),
|
(19, 'approval.adjustment.enabled', '调整单审批开关', '审批开关', 'BOOL', NULL, 'NONE', NULL, '0', NULL, NULL, '调整单提交后是否要审批:开启 = 先经财务复核、再经领导审核;关闭 = 提交即生效。调整单是已锁定月份补发或扣减的唯一通道。', 19),
|
||||||
(20, 'manager.personal.included', '经理个人户计入聚合基数', '经理分成', 'BOOL', NULL, 'NONE', NULL, '0', NULL, NULL, '经理 12.5%+5% 聚合时是否计入经理本人名下经理个人户业绩(澄清 #12 业务答复 2026-10-19:作为参数配置、手动调整是否纳入):0=不计入(默认,暂口径) 1=计入', 20),
|
(20, 'manager.personal.included', '经理个人户计入聚合基数', '经理分成', 'BOOL', NULL, 'NONE', NULL, '0', NULL, NULL, '经理提取团队分成时,是否把经理本人名下的业绩(经理个人户)也算进分成基数。默认不计入。', 20),
|
||||||
-- 预警规则参数(M4 ⑧ 工作台,澄清 #10「做成参数化规则便于调整」)
|
-- 预警规则参数:工作台预警阈值,可按业务需要调整
|
||||||
(21, 'warning.enabled', '风险预警总开关', '预警规则', 'BOOL', NULL, 'NONE', NULL, '1', NULL, NULL, '工作台风险预警区块总开关:0=关闭(不扫描不展示) 1=开启(默认)', 21),
|
(21, 'warning.enabled', '风险预警总开关', '预警规则', 'BOOL', NULL, 'NONE', NULL, '1', NULL, NULL, '工作台风险预警区块的总开关:关闭后不再扫描、也不展示预警条目。默认开启。', 21),
|
||||||
(22, 'warning.receipt.overdue.days', '回款逾期天数阈值', '预警规则', 'INT', '天', 'NONE', NULL, '90', '1', NULL, '发货后超过该天数仍未回款的发货批次触发预警(澄清 #10 建议默认 90 天)', 22),
|
(22, 'warning.receipt.overdue.days', '回款逾期天数阈值', '预警规则', 'INT', '天', 'NONE', NULL, '90', '1', NULL, '发货后超过这么多天仍未收到回款的发货批次,会出现在工作台的预警列表里。默认 90 天。', 22),
|
||||||
(23, 'warning.approval.timeout.days', '审批超时天数阈值', '预警规则', 'INT', '天', 'NONE', NULL, '3', '1', NULL, '待审批单提交后超过该天数未处理触发预警(澄清 #10 建议默认 3 天)', 23);
|
(23, 'warning.approval.timeout.days', '审批超时天数阈值', '预警规则', 'INT', '天', 'NONE', NULL, '3', '1', NULL, '审批单提交后超过这么多天还没处理,会出现在工作台的预警列表里。默认 3 天。', 23);
|
||||||
|
|
||||||
INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES
|
INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES
|
||||||
(1, 1, 'salesperson', '2026-02-17', '10', 1),
|
(1, 1, 'salesperson', '2026-02-17', '10', 1),
|
||||||
@@ -891,4 +893,4 @@ CREATE TABLE alembic_version (
|
|||||||
CONSTRAINT alembic_version_pkc PRIMARY KEY (version_num)
|
CONSTRAINT alembic_version_pkc PRIMARY KEY (version_num)
|
||||||
)CHARSET=utf8mb4 ENGINE=InnoDB COLLATE utf8mb4_general_ci;
|
)CHARSET=utf8mb4 ENGINE=InnoDB COLLATE utf8mb4_general_ci;
|
||||||
|
|
||||||
INSERT INTO alembic_version (version_num) VALUES ('7f4f7b918bc4');
|
INSERT INTO alembic_version (version_num) VALUES ('c3a9e1d7b2f4');
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
-- V10: 客户评级落库(2026-10-23)
|
||||||
|
-- 评级计算器从独立工具页并入客户管理(行内弹窗),评分保存为客户档案属性;
|
||||||
|
-- 两列可空(null=未评分,存量客户无需回填),人工维护字段,T+ 同步不覆盖。
|
||||||
|
-- 对应迁移:alembic/versions/c3a9e1d7b2f4_v13_客户评级落库.py
|
||||||
|
-- 每列一条 ALTER:test_schema_sync 按「ALTER TABLE … ADD COLUMN」逐条解析,
|
||||||
|
-- 一条语句挂多个 ADD 只会解析出第一列,两份 DDL 会假性漂移。
|
||||||
|
ALTER TABLE `customers`
|
||||||
|
ADD COLUMN `rating_score` TINYINT COMMENT '评级总分(0-100,评级计算器保存;null=未评分;人工维护字段,T+ 同步不覆盖)' AFTER `remark`;
|
||||||
|
ALTER TABLE `customers`
|
||||||
|
ADD COLUMN `rating_time` DATETIME COMMENT '最近评级时间(评级计算器保存评分时刷新;人工维护字段)' AFTER `rating_score`;
|
||||||
+919
-90
File diff suppressed because it is too large.
Load diff
Binary file not shown.
+59
-5
@@ -79,7 +79,7 @@ pnpm build # 类型检查 + 生产构建
|
|||||||
|
|
||||||
## 视觉设计
|
## 视觉设计
|
||||||
|
|
||||||
**设计立意**:公司名「衍水」是水,而中文「流水账」字面就是**流水之账**——系统的全部工作正是记录回款怎么流、绩效怎么算。视觉方向由此而生:中性灰白的水面纸感 + 靛蓝点缀,安静、克制,长时间核数不累眼。完整规范见 `docs/design/视觉方向预览.html`(2026-10-12 起为 v3「质感版」)。
|
**设计立意**:公司名「衍水」是水,而中文「流水账」字面就是**流水之账**——系统的全部工作正是记录回款怎么流、绩效怎么算。视觉方向由此而生:中性灰白的水面纸感 + 靛蓝点缀,安静、克制,长时间核数不累眼。完整规范见 `docs/design/视觉方向预览.html`(2026-10-12 起为 v3「质感版」;2026-10-22 升 v4「同步版」——设计稿与已交付的月结 / 审批 / 报表 / 工作台对齐,色板、字体与组件基调未变)。
|
||||||
|
|
||||||
**配色**(靛蓝只作重点点缀——主按钮 / 链接 / 焦点 / 当前菜单,大面积用中性灰白)
|
**配色**(靛蓝只作重点点缀——主按钮 / 链接 / 焦点 / 当前菜单,大面积用中性灰白)
|
||||||
|
|
||||||
@@ -100,17 +100,65 @@ pnpm build # 类型检查 + 生产构建
|
|||||||
|
|
||||||
**落地方式**:品牌色已提升为全局 CSS 变量(`src/assets/styles/index.css` 的 `:root`,`--ys-*` 前缀),登录页、布局与各业务页面共用;全局把 `--el-color-primary` 指向靛蓝,并按 Element Plus 公式预计算了 light-3/5/7/8/9 与 dark-2 色阶(不用 `color-mix()`,避免依赖较新浏览器特性)。旧 token 名(深水青 / 金色系列)在 `:root` 中保留映射,存量页面迁移完成后移除。
|
**落地方式**:品牌色已提升为全局 CSS 变量(`src/assets/styles/index.css` 的 `:root`,`--ys-*` 前缀),登录页、布局与各业务页面共用;全局把 `--el-color-primary` 指向靛蓝,并按 Element Plus 公式预计算了 light-3/5/7/8/9 与 dark-2 色阶(不用 `color-mix()`,避免依赖较新浏览器特性)。旧 token 名(深水青 / 金色系列)在 `:root` 中保留映射,存量页面迁移完成后移除。
|
||||||
|
|
||||||
**签名元素**:**渐隐靛蓝水线**——仪表盘等页面标题下的细线(56px,左实右虚)与登录页品牌名下的短线(40px),「衍水」之水的提纯,全站唯一的装饰性元素;登录页右上角另有涟漪同心圆与顶部靛蓝天光作背景(低透明度)。与早期深色面板 + 水纹层理同一意象,品牌连续而非推倒重来。
|
**语义三色**(2026-10-22 落地):成功 `#1e9e6a` / 警示 `#c98a1b` / 危险 `#c94f4f`,已覆盖 `--el-color-success/warning/danger/error` 及其 `-rgb`、`light-3/5/7/8/9`、`dark-2` 全阶。Element Plus 默认的绿黄红饱和度过高,放在灰底卡片上会跳色。**`-rgb` 变体必须一并覆盖**——组件库的半透明底色用 `rgba(var(--el-color-success-rgb), .1)` 取色,漏改会出现「文字是新的、底色还是旧的」。色阶按 Element Plus 公式预计算,不用 `color-mix()`。
|
||||||
|
|
||||||
|
**组件基调**(同样写在 `src/assets/styles/index.css`,页面不要再重复):按钮高 34px / 圆角 7px / 字号 13px,主按钮是 `#3a58bc → #2e4ba8` 微渐变 + 内高光 + 悬停上抬 1px 并加深阴影;输入框高 34px,焦点为靛蓝描边加 3px 柔光外环;表头 12px/500/字距 `0.04em` + 极浅渐变底,单元格内距 12px 16px、行高 1.5,行悬停 120ms;分页需 `background` 模式,当前页与主按钮同材质;`el-tag` 压到 11px 方角。动效统一 120–180ms,只做颜色、阴影与 1px 位移,并尊重 `prefers-reduced-motion`。
|
||||||
|
|
||||||
|
**表格行高必须是 1.5**(全局 `.el-table .cell`):Element Plus 默认给单元格 23px 行高(≈1.77 倍),加上 12px 上下内距,单行数据行会到 51px——一屏少看两行。收到 1.5 后单行数据行 44px、纯文本行 44px,与设计稿「12px 内距 + 13px 字」对齐;多行单元格仍按内容撑开。
|
||||||
|
|
||||||
|
**页面版式:只用全局类**(新页面不再各写一套)
|
||||||
|
|
||||||
|
| 类 | 用途 |
|
||||||
|
| --- | --- |
|
||||||
|
| `.page-head` / `.page-title` / `.page-desc` / `.waterline` | 页面头:18px 标题 + 一句 12px 描述 + 水线(**每页一条,不重复**) |
|
||||||
|
| `.card` / `.card-head` / `.card-title` / `.card-body` / `.card-foot` | 内容卡片;卡脚右对齐,承载分页或合计条 |
|
||||||
|
| `.filter-bar` / `.filter-bar__actions` | 筛选条(横向 flex);按钮组靠右 |
|
||||||
|
| `.toolbar` / `.toolbar-note` | 主操作行 / 右侧提示文字 |
|
||||||
|
| `.section-title` | 区块小标题(13px/600) |
|
||||||
|
| `.cell-code` / `.cell-num` / `.cell-muted` | 表格列语义:等宽编号 / 数字右对齐 + 等宽数字 / 弱化文本 |
|
||||||
|
| `.ys-pill` + `--ok` `--wait` `--lock` `--reject` | 状态胶囊(浅底 + 6px 圆点),四档语义与月结生命周期一致 |
|
||||||
|
| `.ys-rise` | 入场上浮动画(卡片默认已带) |
|
||||||
|
|
||||||
|
`.cell-num` 有两种用法:直接挂在 `td` 上,或包一层 `<span class="cell-num">` 处理「一行里既文本又数字」。给后者单独撑满一行是必要的——行内元素的 `text-align` 无效;但**不能**给 `.cell-num` 统一加 `display`,那会把 `td` 从 `table-cell` 变成 `inline-block`,表格列宽算法随即失效。
|
||||||
|
|
||||||
|
**表单页版式(公司信息页为样板)**
|
||||||
|
|
||||||
|
设置类表单页不要把表单缩在左上角一小块——内容区再宽也只占三分之一,页面看着像没做完。`views/system/settings/index.vue` 是基准:
|
||||||
|
|
||||||
|
| 做法 | 口径 |
|
||||||
|
| --- | --- |
|
||||||
|
| 网格 | 左列 `minmax(0, 1fr)` + 右列固定 480px(实景预览),`align-items: start`;≤1200px 折成单列 |
|
||||||
|
| 控件行 | 主控件与说明同一行(`.field-control`):控件 ≤420px,说明跟在后面 20px;≥1600px 说明改右对齐贴卡片右缘,整行铺满而不是缩在左上角 |
|
||||||
|
| 实景预览 | 右侧「登录页预览」按**真实登录页 1:1 复刻**(控件 38px 高、品牌名 17px / 0.34em 字距、logo ≤56×36、三项输入含验证码位、登录按钮、版权行),不是缩比示意——控件高度对不上就等于没有所见即所得 |
|
||||||
|
| 生效范围 | 独立一张卡列出这份设置会出现在哪(登录页品牌位与版权行 / Excel 导出页眉 / 打印稿页眉),用户不用存完再去别处找 |
|
||||||
|
| 预览态 | 预览卡 `aria-hidden` + 标注「示意,非交互」,避免被当成可点的登录框 |
|
||||||
|
|
||||||
|
**状态与流程表达(2026-10-23 实测补)**
|
||||||
|
|
||||||
|
- 月结生命周期条四段圆点按阶段着色:草稿中性 / 待审批琥珀 / 已审定靛蓝 / 已驳回砖红。颜色之外每段仍有文字,不靠颜色单独表意。
|
||||||
|
- 流程条这类分段控件旁要挂一行操作提示(如「点状态只看该阶段,再点一次取消」)。没有提示时,未选中态的四段读起来像四颗孤立的小按钮,不像一条流程。
|
||||||
|
- 卡片内空态(工作台 `.b-empty-mini`)在卡片剩余高度内居中,不要全挤在顶部留一整块空白。
|
||||||
|
|
||||||
|
**菜单图标**(2026-10-22 落地,M1 遗留项):路由 `meta.icon` 只写**语义 key**,图标实现集中在 `src/components/layout/menuIcons.ts` 的注册表(`@element-plus/icons-vue` 具名导入,可摇树)。换图标库或换风格只改注册表,路由表不动。视觉口径沿用设计稿:16px、透明度 0.7(激活项 1)、右间距 9px;分组标题**不加图标**,只有菜单项有。**新增页面必须补 `meta.icon`**,否则菜单项只有文字。注册表是 **UTF-8** 文件——批量转码脚本会把中文注释转成乱码(2026-10-23 已在该文件踩过一次,已修回),改这个文件不要过任何编码转换。
|
||||||
|
|
||||||
|
**侧栏层级**(2026-10-23 改版):组标题与菜单项**同字号 13px**——层级靠「折叠箭头 + 组间留白 + 子项缩进 12px」表达,不靠字号/字重压过父级。前两版都不采用:11px 弱墨「目录行」让父级弱于子项,14px/600 加靛蓝竖标又让父级刻意压过子项、像一排小标题在喊叫。激活态只保留**白底浮起 + 靛蓝字**,不再叠左侧 2px 竖条——竖条只能落在侧栏内边缘、与白底悬空,是重复的第二个信号。菜单横向 10px 留白由 `AppMenu.vue` 的 `.app-menu` 提供,`BasicLayout.vue` 的 `.app-aside__menu` 只写上下内距:两处都用 `padding` 简写会同权重靠注入顺序决胜,横向留白会被整条抹掉、条目贴到侧栏边缘。
|
||||||
|
|
||||||
|
**签名元素**:**渐隐靛蓝水线**——各页面标题下的细线(56px,左实右虚)与登录页品牌名下的短线(40px),「衍水」之水的提纯,全站唯一的装饰性元素;登录页右上角另有涟漪同心圆与顶部靛蓝天光作背景(低透明度)。与早期深色面板 + 水纹层理同一意象,品牌连续而非推倒重来。
|
||||||
|
|
||||||
|
**打印视图**(`views/reports/print/`):纸面产物与屏幕稿不同源——独立顶层路由、不挂侧栏顶栏,白灰画布上一张定宽白稿纸,表格 11px 细实线、表头浅底左对齐(数值列表头右对齐)、章节编号、三栏签字(制表 / 复核 / 审批),无圆角无阴影。屏幕上的操作条带 `.no-print`,打印时整块消失。超出打印行数上限时只打前 N 行并在稿面明确提示,不静默截断。
|
||||||
|
|
||||||
**构图**:登录页品牌内容(名称 / 标语 / 水线)**垂直居中聚成一块**,配一层柔和径向光晕作落点。
|
**构图**:登录页品牌内容(名称 / 标语 / 水线)**垂直居中聚成一块**,配一层柔和径向光晕作落点。
|
||||||
早期版本用 `space-between` 把品牌与标语顶到两端,中间空出大半屏,两处都成了孤岛——超宽屏会把这种疏离放大得更明显。**内容块要限宽**(`max-width`),否则宽屏下文字会被拉散。
|
早期版本用 `space-between` 把品牌与标语顶到两端,中间空出大半屏,两处都成了孤岛——超宽屏会把这种疏离放大得更明显。**内容块要限宽**(`max-width`),否则宽屏下文字会被拉散。
|
||||||
|
|
||||||
### 写代码时的四条约定
|
### 写代码时的六条约定
|
||||||
|
|
||||||
- 覆盖 Element Plus 主题请改 **CSS 变量**(`--el-button-bg-color` 等),不要直接写 `background-color` —— 后者与 `.el-button--primary` 同权重,会受样式注入顺序影响而不稳定
|
- 覆盖 Element Plus 主题请改 **CSS 变量**(`--el-button-bg-color` 等),不要直接写 `background-color` —— 后者与 `.el-button--primary` 同权重,会受样式注入顺序影响而不稳定
|
||||||
- 中文可在任意字间断行,**关键文案的断行位置要显式控制**(如主标语拆成两个 `<span>`),交给浏览器自动换行会出现单字独占一行的难看结果
|
- 中文可在任意字间断行,**关键文案的断行位置要显式控制**(如主标语拆成两个 `<span>`),交给浏览器自动换行会出现单字独占一行的难看结果
|
||||||
- 新页面需响应式到移动端、保留键盘焦点可见、并尊重 `prefers-reduced-motion`
|
- 新页面需响应式到移动端、保留键盘焦点可见、并尊重 `prefers-reduced-motion`
|
||||||
- **确认框统一加 `customClass: 'ys-confirm-box'`,不要再传 `type: 'warning'`** —— Element Plus 的默认 warning 图标会渲染在正文左侧独立成列(多行正文时图标悬在文字块中间),与 v3 靛蓝体系也冲突。全局样式(`src/assets/styles/index.css`)已把图标改为 18px 描边样式并放在标题行左侧,正文与标题文字对齐
|
- **确认框统一加 `customClass: 'ys-confirm-box'`,不要再传 `type: 'warning'`** —— Element Plus 的默认 warning 图标会渲染在正文左侧独立成列(多行正文时图标悬在文字块中间),与 v3 靛蓝体系也冲突。全局样式(`src/assets/styles/index.css`)已把图标改为 18px 描边样式并放在标题行左侧,正文与标题文字对齐
|
||||||
|
- **页面 scoped 里只写本页特有的东西**:表格 / 按钮 / 输入框 / 表头 / 分页的皮肤已经全局覆盖,页面再写一遍与组件库同权重,会受样式注入顺序影响而不稳定;结构用全局类(见上一节表格),样式只补本页独有的块(如展开区、抽屉内的键值表)
|
||||||
|
- **状态 / 结果列的固定宽度要按胶囊内容给足**:`.cell` 左右各 16px 内距(共 32px),2 字胶囊约 55px、3 字约 67px、4 字约 79px,宽度不够时 Element Plus 会在胶囊右侧画一个省略号(看起来像多了个小点)。口径:2 字列 ≥96px(如 启用/禁用、成功/失败)、3 字列 ≥108px(如 已核算/已替代/执行中)、4 字列 ≥120px(如 以物抵顶)
|
||||||
|
- **`v-loading` 只罩数据区,不要罩到分页条上**:遮罩会拦截指针事件,盖住分页器后用户点翻页其实点在遮罩上被吞掉,得再点一次,观感就是「数据反复变化」。详见下文「分页与「数据反复变化」的两个坑」
|
||||||
|
|
||||||
## 验证码图片
|
## 验证码图片
|
||||||
|
|
||||||
@@ -214,14 +262,20 @@ E2E_ADMIN_PASSWORD=<开发库admin密码> python e2e/system_management.py
|
|||||||
|
|
||||||
## 前端页面(2026-10-19 追加 · M4 ⑧ 工作台)
|
## 前端页面(2026-10-19 追加 · M4 ⑧ 工作台)
|
||||||
|
|
||||||
`/dashboard` 由 Bento 占位升级为工作台四区块(**一次聚合请求** `/api/v1/dashboard`):
|
`/dashboard` 由 Bento 占位升级为工作台(**总览/待办/预警一次聚合请求** `/api/v1/dashboard`;最近动态是分页列表,走独立端点):
|
||||||
|
|
||||||
| 区块 | 展示 |
|
| 区块 | 展示 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| 经营绩效总览 | KPI 四卡(本月绩效/应付/核算人数/团队数 + 月结状态);近 6 个月绩效折线 + 回款柱状(ECharts) |
|
| 经营绩效总览 | KPI 四卡(本月绩效/应付/核算人数/团队数 + 月结状态);近 6 个月绩效折线 + 回款柱状(ECharts) |
|
||||||
| 待办事项 | 待审批(跳审批管理)、回款未指定批次(跳回款管理)、T+ 同步失败(未启用显式占位) |
|
| 待办事项 | 待审批(跳审批管理)、回款未指定批次(跳回款管理)、T+ 同步失败(未启用显式占位) |
|
||||||
| 风险预警 | 回款逾期(danger)/ 审批超时(warning),点击跳处理页;总开关关闭时显示参数提示 |
|
| 风险预警 | 回款逾期(danger)/ 审批超时(warning),点击跳处理页;总开关关闭时显示参数提示 |
|
||||||
| 最近动态 | 操作日志时间线,按模块本地筛选 |
|
| 最近动态 | 操作日志列表,**服务端分页**(每页 10/20/50)+ 按模块筛选 |
|
||||||
|
|
||||||
|
- **最近动态独立分页端点**(2026-10-23):`GET /api/v1/dashboard/activities?pageNum&pageSize&module`,返回 `list/total/pageNum/pageSize/modules`(`modules` 是全量模块名,供下拉用,不随翻页变化)。与聚合端点拆开的原因:操作日志可能很长,塞进聚合响应只能截断前 20 条;拆开后**翻页只重取这张列表**,不必重算总览/待办/预警。分页字段沿用全站规范(`pageNum`/`pageSize`/`total`),组件用统一的 `AppPagination` + `usePagedList`(含请求序号防竞态)。
|
||||||
|
- **分页与「数据反复变化」的两个坑(2026-10-23 实测定位并修复,改分页相关代码前先读)**:
|
||||||
|
1. **`v-loading` 不能挂在包含分页条的容器上**。Element Plus 的遮罩会拦截指针事件:遮罩一出现,用户点的「上一页 / 下一页」其实点在遮罩上被吞掉,必须再点一次才生效,观感就是「页码和数据对不上、来回跳」。工作台最近动态原先 `v-loading` 挂在 `.activity-body`(同时包住列表与分页条),已改为只罩列表的 `.activity-list-wrap`;其余列表页的 `v-loading` 都挂在 `el-table` 上,分页条在表格之外,天然没有这个问题——**新增页面请照此办理**。
|
||||||
|
2. **翻页失败必须把分页器退回「屏幕上正在显示的那一页」**。`records` 只在请求成功时更新,而分页器高亮绑的是 `pageNum`;失败不回滚就会出现「高亮第 1 页、列表是第 2 页的数据」的永久错位(用户来回点才恢复)。`usePagedList` 里用 `shownPageNum` / `shownPageSize` 记录实际显示的页,失败时 `restoreShownPage(seq)` 退回;**回滚本身也要防竞态**——只有本次请求仍是最新请求(`seq === requestSeq`)才回滚,否则连点时旧请求失败会把用户刚点出的新页码覆盖掉,表现为「页码自己跳回去」。页码与条数在 `await` 之前取快照,避免等待期间被别的点击改动后记错「显示的页」。
|
||||||
|
- 两个缺陷都用 Playwright 探针实测过:`ai-aux/preview/probe_pager7.py`(8/8:正常连点、请求在飞时命中分页器而非遮罩、失败回滚、解除注入后恢复)、`probe_pager13.py`(旧请求失败不拉回页码)、`probe_pager14.py`(点第 3 页失败退回第 1 页、连续两次失败仍停在当前页、解除注入后可正常翻页)、`probe_pager8.py`(其它 `usePagedList` 列表页回归)。
|
||||||
|
|
||||||
- **ECharts 5.6 按需引入**:`echarts/core` + `BarChart/LineChart/Grid/Tooltip` + `SVGRenderer`(与设计预览一致),只注册用到的模块。
|
- **ECharts 5.6 按需引入**:`echarts/core` + `BarChart/LineChart/Grid/Tooltip` + `SVGRenderer`(与设计预览一致),只注册用到的模块。
|
||||||
- **字体红线**:图表 `textStyle.fontFamily` 必须显式设为思源黑体栈——ECharts 在 Windows 默认微软雅黑(字体商用合规要求)。
|
- **字体红线**:图表 `textStyle.fontFamily` 必须显式设为思源黑体栈——ECharts 在 Windows 默认微软雅黑(字体商用合规要求)。
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
"type-check": "vue-tsc --noEmit"
|
"type-check": "vue-tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@element-plus/icons-vue": "^2.3.2",
|
||||||
"@vue/devtools-api": "^8.1.5",
|
"@vue/devtools-api": "^8.1.5",
|
||||||
"axios": "^1.20.0",
|
"axios": "^1.20.0",
|
||||||
"echarts": "^5.6.0",
|
"echarts": "^5.6.0",
|
||||||
|
|||||||
Generated
+3
@@ -8,6 +8,9 @@ importers:
|
|||||||
|
|
||||||
.:
|
.:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@element-plus/icons-vue':
|
||||||
|
specifier: ^2.3.2
|
||||||
|
version: 2.3.2(vue@3.5.43(typescript@5.9.3))
|
||||||
'@vue/devtools-api':
|
'@vue/devtools-api':
|
||||||
specifier: ^8.1.5
|
specifier: ^8.1.5
|
||||||
version: 8.2.1
|
version: 8.2.1
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ export interface CustomerItem {
|
|||||||
owner_personnel_id: number | null
|
owner_personnel_id: number | null
|
||||||
owner_personnel_name: string | null
|
owner_personnel_name: string | null
|
||||||
remark: string | null
|
remark: string | null
|
||||||
|
/** 评级总分(0-100,评级计算器保存;null=未评分) */
|
||||||
|
rating_score: number | null
|
||||||
|
/** 最近评级时间(评级计算器保存评分时刷新;null=从未评级) */
|
||||||
|
rating_time: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CustomerQueryParams {
|
export interface CustomerQueryParams {
|
||||||
@@ -30,6 +34,10 @@ export interface CustomerQueryParams {
|
|||||||
keyword?: string
|
keyword?: string
|
||||||
customer_type?: string
|
customer_type?: string
|
||||||
owner_personnel_id?: number
|
owner_personnel_id?: number
|
||||||
|
/** 排序键(后端白名单:id 建档序 / rating_score 评分),默认 id 倒序 */
|
||||||
|
order_by?: 'id' | 'rating_score'
|
||||||
|
/** 排序方向,默认 desc */
|
||||||
|
order?: 'asc' | 'desc'
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 新增客户(owner_personnel_id 为初始归属,可空:先建档后派归属也合法) */
|
/** 新增客户(owner_personnel_id 为初始归属,可空:先建档后派归属也合法) */
|
||||||
@@ -57,6 +65,17 @@ export interface CustomerOwnershipParams {
|
|||||||
reason?: string | null
|
reason?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存评级入参:只传总分。
|
||||||
|
*
|
||||||
|
* 等级不落库,也不由客户端传入 —— 后端只存总分与评级时间,等级由分数按
|
||||||
|
* 当前等级映射实时推导(前端 utils/rating.ts)。不收客户端传来的等级,
|
||||||
|
* 否则改请求体就能把 30 分存成 A 级。
|
||||||
|
*/
|
||||||
|
export interface CustomerRatingParams {
|
||||||
|
score: number
|
||||||
|
}
|
||||||
|
|
||||||
/** 归属变更留痕条目(对应后端 `OwnershipChangeLogOut`;姓名可随人员消失,ID 固化) */
|
/** 归属变更留痕条目(对应后端 `OwnershipChangeLogOut`;姓名可随人员消失,ID 固化) */
|
||||||
export interface OwnershipLogItem {
|
export interface OwnershipLogItem {
|
||||||
id: number
|
id: number
|
||||||
@@ -114,6 +133,14 @@ export function changeOwnership(
|
|||||||
return request({ url: `/v1/base/customers/${customerId}/ownership`, method: 'put', data })
|
return request({ url: `/v1/base/customers/${customerId}/ownership`, method: 'put', data })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 保存评级(评级计算器「保存评分」;后端存总分+评级时间,评分只走本端点) */
|
||||||
|
export function rateCustomer(
|
||||||
|
customerId: number,
|
||||||
|
data: CustomerRatingParams,
|
||||||
|
): Promise<CustomerItem> {
|
||||||
|
return request({ url: `/v1/base/customers/${customerId}/rating`, method: 'put', data })
|
||||||
|
}
|
||||||
|
|
||||||
/** 查某客户的归属变更留痕(分页,按变更时间倒序) */
|
/** 查某客户的归属变更留痕(分页,按变更时间倒序) */
|
||||||
export function listOwnershipLogs(
|
export function listOwnershipLogs(
|
||||||
customerId: number,
|
customerId: number,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { request } from './request'
|
import { request } from './request'
|
||||||
|
import type { PageData } from './types'
|
||||||
|
|
||||||
/** 近 6 个月趋势点(绩效按台账归属月,回款按到账月) */
|
/** 近 6 个月趋势点(绩效按台账归属月,回款按到账月) */
|
||||||
export interface TrendPoint {
|
export interface TrendPoint {
|
||||||
@@ -91,9 +92,29 @@ export interface DashboardData {
|
|||||||
overview: OverviewBlock
|
overview: OverviewBlock
|
||||||
todos: TodoBlock
|
todos: TodoBlock
|
||||||
warnings: WarningBlock
|
warnings: WarningBlock
|
||||||
activities: ActivityItem[]
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 最近动态分页结果:分页字段与全站规范同形(list/total/pageNum/pageSize),
|
||||||
|
* 额外带 `modules` —— 全量模块名(不随当前页变化),供模块下拉使用。
|
||||||
|
*/
|
||||||
|
export interface ActivityPage extends PageData<ActivityItem> {
|
||||||
|
modules: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getDashboard(params: { month?: string }): Promise<DashboardData> {
|
export function getDashboard(params: { month?: string }): Promise<DashboardData> {
|
||||||
return request({ url: '/v1/dashboard', method: 'get', params })
|
return request({ url: '/v1/dashboard', method: 'get', params })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页查询最近动态(工作台动态列表)。
|
||||||
|
*
|
||||||
|
* 独立于聚合接口:翻页只重取这张列表,不必重算总览/待办/预警。
|
||||||
|
*/
|
||||||
|
export function listDashboardActivities(params: {
|
||||||
|
pageNum?: number
|
||||||
|
pageSize?: number
|
||||||
|
module?: string
|
||||||
|
}): Promise<ActivityPage> {
|
||||||
|
return request({ url: '/v1/dashboard/activities', method: 'get', params })
|
||||||
|
}
|
||||||
@@ -1,24 +1,60 @@
|
|||||||
/* 全局样式:重置 + 品牌 token + 基础版式 */
|
/*
|
||||||
|
* 全局样式:品牌 token + Element Plus 组件基调 + 全站版式工具类
|
||||||
|
*
|
||||||
|
* 设计来源:docs/design/视觉方向预览.html(v4,2026-10-22)。
|
||||||
|
* 落实口径:token 与设计稿 :root 同名同值;组件基调按设计稿 §03;
|
||||||
|
* 版式工具类(.page-head / .card / .filter-bar / .data-table 等)按 §04、§09,
|
||||||
|
* 各页面只写业务差异,不再重复定义这些类。
|
||||||
|
*
|
||||||
|
* 加载顺序:main.ts 里排在 element-plus/dist/index.css 之后,
|
||||||
|
* 同权重选择器靠后者生效 —— 覆盖组件库样式必须与此保持一致。
|
||||||
|
*/
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
/*
|
/* ── 品牌色 ─────────────────────────────────────────────────────────── */
|
||||||
* 品牌色(2026-10-12 视觉方向:企业级扁平 · 轻质感 · 靛蓝点缀)。
|
/* 靛蓝只作重点点缀(主按钮/链接/焦点/当前菜单),大面积用中性灰白。 */
|
||||||
* 靛蓝只作重点点缀(主按钮/链接/焦点/当前菜单),大面积用中性灰白。
|
|
||||||
* 设计预览与规范:docs/design/视觉方向预览.html
|
|
||||||
*/
|
|
||||||
--ys-indigo: #2e4ba8;
|
--ys-indigo: #2e4ba8;
|
||||||
|
--ys-indigo-hi: #3a58bc; /* 主按钮渐变亮端 */
|
||||||
--ys-indigo-deep: #223a85;
|
--ys-indigo-deep: #223a85;
|
||||||
--ys-indigo-wash: #edf1fa;
|
--ys-indigo-wash: #edf1fa;
|
||||||
|
|
||||||
--ys-paper: #ffffff;
|
--ys-paper: #ffffff;
|
||||||
--ys-mist: #f4f5f8;
|
--ys-mist: #f4f5f8;
|
||||||
--ys-line: rgba(28, 35, 51, 0.09);
|
--ys-aside-1: #f2f4f8;
|
||||||
|
--ys-aside-2: #ecedf3;
|
||||||
|
|
||||||
--ys-ink: #1c2333;
|
--ys-ink: #1c2333;
|
||||||
--ys-ink-2: #5b6577;
|
--ys-ink-2: #5b6577;
|
||||||
--ys-ink-3: #9aa3b2;
|
--ys-ink-3: #9aa3b2;
|
||||||
|
|
||||||
|
--ys-line: rgba(28, 35, 51, 0.09);
|
||||||
|
--ys-line-soft: rgba(28, 35, 51, 0.065);
|
||||||
|
|
||||||
|
/* 语义三色:饱和度压到与靛蓝同级,保证灰底卡片上不跳色 */
|
||||||
|
--ys-ok: #1e9e6a;
|
||||||
|
--ys-ok-wash: #ecf8f2;
|
||||||
|
--ys-ok-wash-2: #e4f3ec;
|
||||||
|
--ys-warn: #c98a1b;
|
||||||
|
--ys-warn-wash: #fbf4e4;
|
||||||
|
--ys-warn-wash-2: #f8efd9;
|
||||||
|
--ys-danger: #c94f4f;
|
||||||
|
--ys-danger-wash: #fbeded;
|
||||||
|
--ys-danger-wash-2: #f6e3e3;
|
||||||
|
|
||||||
|
/* 形体与材质 */
|
||||||
|
--ys-radius: 10px;
|
||||||
|
--ys-radius-sm: 7px;
|
||||||
|
--ys-ease: cubic-bezier(0.25, 0.1, 0.25, 1);
|
||||||
|
--ys-shadow-card:
|
||||||
|
0 1px 2px rgba(28, 35, 51, 0.04),
|
||||||
|
0 3px 10px rgba(28, 35, 51, 0.03);
|
||||||
|
--ys-shadow-raise:
|
||||||
|
0 1px 2px rgba(28, 35, 51, 0.05),
|
||||||
|
0 6px 18px rgba(28, 35, 51, 0.07);
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* 旧 token 名保留映射(BasicLayout / 登录页存量引用,值迁移到新体系:
|
* 旧 token 名保留映射(存量页面引用,值已迁移到新体系):
|
||||||
* 深水青 → 冷墨系、金色 → 靛蓝系),存量页面迁移完成后移除。
|
* 深水青 → 冷墨系、金色 → 靛蓝系。存量页面迁移完成后移除。
|
||||||
*/
|
*/
|
||||||
--ys-ink-900: #16203a;
|
--ys-ink-900: #16203a;
|
||||||
--ys-ink-800: #1c2333;
|
--ys-ink-800: #1c2333;
|
||||||
@@ -26,7 +62,6 @@
|
|||||||
--ys-ink-600: #3a4a6e;
|
--ys-ink-600: #3a4a6e;
|
||||||
--ys-gold-bright: #6d81c2;
|
--ys-gold-bright: #6d81c2;
|
||||||
--ys-gold-deep: #2e4ba8;
|
--ys-gold-deep: #2e4ba8;
|
||||||
|
|
||||||
--ys-text: #1c2333;
|
--ys-text: #1c2333;
|
||||||
--ys-muted: #5b6577;
|
--ys-muted: #5b6577;
|
||||||
|
|
||||||
@@ -36,11 +71,12 @@
|
|||||||
*/
|
*/
|
||||||
--ys-font-brand: 'Noto Sans SC', 'Source Han Sans SC', 'HarmonyOS Sans SC', 'PingFang SC',
|
--ys-font-brand: 'Noto Sans SC', 'Source Han Sans SC', 'HarmonyOS Sans SC', 'PingFang SC',
|
||||||
sans-serif;
|
sans-serif;
|
||||||
|
--ys-font-mono: 'SF Mono', 'Cascadia Mono', Consolas, 'Courier New', monospace;
|
||||||
|
|
||||||
|
/* ── Element Plus 主色 ──────────────────────────────────────────────── */
|
||||||
/*
|
/*
|
||||||
* Element Plus 主色 = 靛蓝(白字对比约 7.5:1)。
|
* 白字对比约 7.5:1。色阶为按 Element Plus 公式「主色与白色混合」预先算好的
|
||||||
* 色阶为按 Element Plus 公式「主色与白色混合」预先算好的静态值 ——
|
* 静态值 —— 不用 color-mix(),避免依赖较新的浏览器特性。
|
||||||
* 不用 color-mix(),避免依赖较新的浏览器特性。
|
|
||||||
*/
|
*/
|
||||||
--el-color-primary: #2e4ba8;
|
--el-color-primary: #2e4ba8;
|
||||||
--el-color-primary-light-3: #6d81c2;
|
--el-color-primary-light-3: #6d81c2;
|
||||||
@@ -49,6 +85,72 @@
|
|||||||
--el-color-primary-light-8: #d5dbee;
|
--el-color-primary-light-8: #d5dbee;
|
||||||
--el-color-primary-light-9: #eaedf6;
|
--el-color-primary-light-9: #eaedf6;
|
||||||
--el-color-primary-dark-2: #253c86;
|
--el-color-primary-dark-2: #253c86;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 语义三色(设计稿规范值):成功 #1E9E6A / 警示 #C98A1B / 危险 #C94F4F。
|
||||||
|
*
|
||||||
|
* Element Plus 默认是绿 #67C23A / 黄 #E6A23C / 红 #F56C6C,饱和度明显高于
|
||||||
|
* 本系统「中性灰白 + 靛蓝点缀」基调,状态标签放在灰底卡片上会跳色。
|
||||||
|
* 色阶同样按 Element Plus 公式预计算(light-N = N×10% 白 + 其余主色,
|
||||||
|
* dark-2 = 20% 黑 + 80% 主色),不用 color-mix(),与主色色阶口径一致。
|
||||||
|
* -rgb 变体一并覆盖:组件库的半透明底色(rgba(var(--el-color-success-rgb), .1))
|
||||||
|
* 依赖它,漏改会出现「文字是新的、底色还是旧的」。
|
||||||
|
* error 与 danger 同值:表单校验错误与危险操作在本系统语义一致。
|
||||||
|
*/
|
||||||
|
--el-color-success: #1e9e6a;
|
||||||
|
--el-color-success-rgb: 30, 158, 106;
|
||||||
|
--el-color-success-light-3: #62bb97;
|
||||||
|
--el-color-success-light-5: #8eceb4;
|
||||||
|
--el-color-success-light-7: #bce2d2;
|
||||||
|
--el-color-success-light-8: #d2ece1;
|
||||||
|
--el-color-success-light-9: #e8f5f0;
|
||||||
|
--el-color-success-dark-2: #187e55;
|
||||||
|
|
||||||
|
--el-color-warning: #c98a1b;
|
||||||
|
--el-color-warning-rgb: 201, 138, 27;
|
||||||
|
--el-color-warning-light-3: #d9ad5f;
|
||||||
|
--el-color-warning-light-5: #e4c48d;
|
||||||
|
--el-color-warning-light-7: #efdcbb;
|
||||||
|
--el-color-warning-light-8: #f4e8d1;
|
||||||
|
--el-color-warning-light-9: #faf3e8;
|
||||||
|
--el-color-warning-dark-2: #a16e16;
|
||||||
|
|
||||||
|
--el-color-danger: #c94f4f;
|
||||||
|
--el-color-danger-rgb: 201, 79, 79;
|
||||||
|
--el-color-danger-light-3: #d98484;
|
||||||
|
--el-color-danger-light-5: #e4a7a7;
|
||||||
|
--el-color-danger-light-7: #efcaca;
|
||||||
|
--el-color-danger-light-8: #f4dcdc;
|
||||||
|
--el-color-danger-light-9: #faeded;
|
||||||
|
--el-color-danger-dark-2: #a13f3f;
|
||||||
|
|
||||||
|
--el-color-error: #c94f4f;
|
||||||
|
--el-color-error-rgb: 201, 79, 79;
|
||||||
|
--el-color-error-light-3: #d98484;
|
||||||
|
--el-color-error-light-5: #e4a7a7;
|
||||||
|
--el-color-error-light-7: #efcaca;
|
||||||
|
--el-color-error-light-8: #f4dcdc;
|
||||||
|
--el-color-error-light-9: #faeded;
|
||||||
|
--el-color-error-dark-2: #a13f3f;
|
||||||
|
|
||||||
|
/* ── Element Plus 形体基准(设计稿 §03:控件高 34px、圆角 7px)─────── */
|
||||||
|
--el-component-size: 34px;
|
||||||
|
--el-component-size-small: 28px;
|
||||||
|
--el-component-size-large: 38px;
|
||||||
|
--el-border-radius-base: 7px;
|
||||||
|
--el-border-radius-small: 6px;
|
||||||
|
|
||||||
|
/* ── Element Plus 表格 ──────────────────────────────────────────────── */
|
||||||
|
--el-table-border-color: var(--ys-line-soft);
|
||||||
|
--el-table-text-color: var(--ys-ink);
|
||||||
|
--el-table-header-text-color: var(--ys-ink-3);
|
||||||
|
--el-table-row-hover-bg-color: #f7f9fd;
|
||||||
|
/* 斑马纹压到几乎不可见:设计稿列表只有行悬停,没有斑马带 */
|
||||||
|
--el-fill-color-lighter: #fafbfd;
|
||||||
|
|
||||||
|
/* ── 字体 ───────────────────────────────────────────────────────────── */
|
||||||
|
--el-font-family: 'Noto Sans SC', 'Source Han Sans SC', 'HarmonyOS Sans SC', 'PingFang SC',
|
||||||
|
'Helvetica Neue', Arial, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
@@ -73,15 +175,534 @@ body {
|
|||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 系统管理列表页共用的版式(原在三个页面 scoped 中各自重复) */
|
/* 滚动条:细、低对比,避免在密集表格页抢视线 */
|
||||||
.filter-form {
|
*::-webkit-scrollbar {
|
||||||
margin-bottom: 4px;
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
*::-webkit-scrollbar-thumb {
|
||||||
|
background-color: rgba(28, 35, 51, 0.16);
|
||||||
|
border: 2px solid transparent;
|
||||||
|
border-radius: 6px;
|
||||||
|
background-clip: padding-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
*::-webkit-scrollbar-thumb:hover {
|
||||||
|
background-color: rgba(28, 35, 51, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
*::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ══════════════════════════════════════════════════════════════════════
|
||||||
|
* 全站版式工具类(设计稿 §04 / §09):页面不再各自重复定义
|
||||||
|
* ══════════════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
/* 页面头:标题 + 描述 + 签名水线,每页出现一次 */
|
||||||
|
.page-head {
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
color: var(--ys-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-desc {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
font-size: 12px;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
color: var(--ys-ink-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 签名元素:渐隐靛蓝水线。
|
||||||
|
* 全站唯一的装饰性元素 —— 其余层次靠底色、描边与阴影区分,
|
||||||
|
* 所以每页只允许出现一次(页面标题下,或登录卡品牌区)。
|
||||||
|
*/
|
||||||
|
.waterline {
|
||||||
|
display: block;
|
||||||
|
width: 56px;
|
||||||
|
height: 2px;
|
||||||
|
margin-top: 10px;
|
||||||
|
border-radius: 1px;
|
||||||
|
background: linear-gradient(90deg, var(--ys-indigo), rgba(46, 75, 168, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 区块小标题(抽屉内、卡片内分组) */
|
||||||
|
.section-title {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
color: var(--ys-ink-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 卡片:纸白微渐变 + 半透明描边 + 双层柔影 */
|
||||||
|
.card {
|
||||||
|
background: linear-gradient(180deg, var(--ys-paper), #fcfcfe);
|
||||||
|
border: 1px solid var(--ys-line);
|
||||||
|
border-radius: var(--ys-radius);
|
||||||
|
box-shadow: var(--ys-shadow-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 13px 16px;
|
||||||
|
border-bottom: 1px solid var(--ys-line-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ys-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-body {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-foot {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding: 10px 16px;
|
||||||
|
background: linear-gradient(180deg, #fdfdfe, #fbfcfe);
|
||||||
|
border-top: 1px solid var(--ys-line-soft);
|
||||||
|
border-radius: 0 0 var(--ys-radius) var(--ys-radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 筛选条:与卡片同材质,比表格低一档体量 */
|
||||||
|
.filter-bar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
background: linear-gradient(180deg, var(--ys-paper), #fcfcfe);
|
||||||
|
border: 1px solid var(--ys-line);
|
||||||
|
border-radius: var(--ys-radius);
|
||||||
|
box-shadow: var(--ys-shadow-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-bar .el-form-item {
|
||||||
|
margin-right: 0;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-bar__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 工具栏:主操作行,与筛选条/表格区分(无底色,只占位) */
|
||||||
.toolbar {
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.toolbar-note {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ys-ink-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 编号/代码类单元格:等宽字体,弱一档颜色 */
|
||||||
|
.cell-code {
|
||||||
|
font-family: var(--ys-font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ys-ink-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 金额/数量类单元格:等宽数字右对齐(对齐是工资表的可读性前提)。
|
||||||
|
*
|
||||||
|
* 两种用法都要成立:
|
||||||
|
* 1) 直接挂在 td 上 —— 单元格本身就是右对齐的容器;
|
||||||
|
* 2) 包在单元格里当 <span class="cell-num"> —— 一行里既有文本又有数字时用。
|
||||||
|
* 行内元素的 text-align 无效,所以要给 span 单独撑满一行再右对齐;
|
||||||
|
* 反过来不能给 .cell-num 统一加 display,那会把 td 从 table-cell 变成
|
||||||
|
* inline-block,整个表格的列宽算法随即失效。
|
||||||
|
*/
|
||||||
|
.cell-num {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
td.cell-num,
|
||||||
|
th.cell-num {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
span.cell-num {
|
||||||
|
display: block;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-muted {
|
||||||
|
color: var(--ys-ink-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 状态 pill(设计稿 §03):浅底 + 细边 + 6px 圆点。
|
||||||
|
* 四档语义与月结生命周期一致:草稿/待处理(中性)、待办(琥珀)、
|
||||||
|
* 已锁定/完成(靛蓝)、驳回(砖红)。
|
||||||
|
*/
|
||||||
|
.ys-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 2px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 18px;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: var(--ys-ink-2);
|
||||||
|
background: linear-gradient(180deg, #f4f5f8, #eff1f5);
|
||||||
|
border: 1px solid var(--ys-line);
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ys-pill::before {
|
||||||
|
content: '';
|
||||||
|
flex: none;
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: currentColor;
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ys-pill--ok {
|
||||||
|
color: var(--ys-ok);
|
||||||
|
background: linear-gradient(180deg, var(--ys-ok-wash), var(--ys-ok-wash-2));
|
||||||
|
border-color: rgba(30, 158, 106, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ys-pill--wait {
|
||||||
|
color: var(--ys-warn);
|
||||||
|
background: linear-gradient(180deg, var(--ys-warn-wash), var(--ys-warn-wash-2));
|
||||||
|
border-color: rgba(201, 138, 27, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ys-pill--lock {
|
||||||
|
color: var(--ys-indigo);
|
||||||
|
background: linear-gradient(180deg, #eef2fc, #e5eaf8);
|
||||||
|
border-color: rgba(46, 75, 168, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ys-pill--reject {
|
||||||
|
color: var(--ys-danger);
|
||||||
|
background: linear-gradient(180deg, var(--ys-danger-wash), var(--ys-danger-wash-2));
|
||||||
|
border-color: rgba(201, 79, 79, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ══════════════════════════════════════════════════════════════════════
|
||||||
|
* Element Plus 组件基调(设计稿 §02 字体与数字 / §03 组件 / §04 表格)
|
||||||
|
* ══════════════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
/* 按钮:高 34px、圆角 7px、字号 13px、150ms 过渡 */
|
||||||
|
.el-button {
|
||||||
|
height: var(--el-component-size);
|
||||||
|
padding: 0 15px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 400;
|
||||||
|
border-radius: var(--ys-radius-sm);
|
||||||
|
transition:
|
||||||
|
background-color 150ms var(--ys-ease),
|
||||||
|
background-image 150ms var(--ys-ease),
|
||||||
|
border-color 150ms var(--ys-ease),
|
||||||
|
color 150ms var(--ys-ease),
|
||||||
|
box-shadow 150ms var(--ys-ease),
|
||||||
|
transform 150ms var(--ys-ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-button--large {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-button--small {
|
||||||
|
height: var(--el-component-size-small);
|
||||||
|
padding: 0 11px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-button + .el-button {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-button.is-text,
|
||||||
|
.el-button.is-link {
|
||||||
|
height: auto;
|
||||||
|
padding: 2px 0;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 主按钮:靛蓝微渐变 + 顶部内高光,悬停抬高 1px 并扩散柔影 */
|
||||||
|
.el-button--primary:not(.is-plain):not(.is-text):not(.is-link):not(.is-disabled) {
|
||||||
|
background-color: var(--ys-indigo);
|
||||||
|
background-image: linear-gradient(180deg, var(--ys-indigo-hi), var(--ys-indigo));
|
||||||
|
border-color: var(--ys-indigo);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.18),
|
||||||
|
0 1px 2px rgba(46, 75, 168, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-button--primary:not(.is-plain):not(.is-text):not(.is-link):not(.is-disabled):hover {
|
||||||
|
background-image: linear-gradient(180deg, #31509f, var(--ys-indigo-deep));
|
||||||
|
border-color: var(--ys-indigo-deep);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.2),
|
||||||
|
0 4px 12px rgba(46, 75, 168, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-button--primary:not(.is-plain):not(.is-text):not(.is-link):not(.is-disabled):active {
|
||||||
|
transform: translateY(0);
|
||||||
|
background-image: linear-gradient(180deg, #2b4699, #1f3478);
|
||||||
|
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-button--primary.is-disabled,
|
||||||
|
.el-button--primary.is-loading {
|
||||||
|
background-image: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 默认按钮:纸白微渐变 + 细描边,悬停只加靛蓝描边与文字色,不做位移
|
||||||
|
* —— 筛选条里可能并排四五个默认按钮,集体位移会让整行抖动。
|
||||||
|
*/
|
||||||
|
.el-button:not(
|
||||||
|
.el-button--primary,
|
||||||
|
.el-button--success,
|
||||||
|
.el-button--warning,
|
||||||
|
.el-button--danger,
|
||||||
|
.el-button--info,
|
||||||
|
.is-link,
|
||||||
|
.is-text,
|
||||||
|
.is-plain
|
||||||
|
) {
|
||||||
|
background-image: linear-gradient(180deg, var(--ys-paper), #fbfcfe);
|
||||||
|
border-color: var(--ys-line);
|
||||||
|
box-shadow: 0 1px 2px rgba(28, 35, 51, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-button:not(
|
||||||
|
.el-button--primary,
|
||||||
|
.el-button--success,
|
||||||
|
.el-button--warning,
|
||||||
|
.el-button--danger,
|
||||||
|
.el-button--info,
|
||||||
|
.is-link,
|
||||||
|
.is-text,
|
||||||
|
.is-plain
|
||||||
|
):hover {
|
||||||
|
color: var(--ys-indigo);
|
||||||
|
background-image: linear-gradient(180deg, var(--ys-paper), #f8fafd);
|
||||||
|
border-color: rgba(46, 75, 168, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 危险按钮:白底 + 砖红文字与描边(红色只提示边界,不做大面积填充) */
|
||||||
|
.el-button--danger:not(.is-link):not(.is-text):not(.is-plain):not(.is-disabled) {
|
||||||
|
color: var(--ys-danger);
|
||||||
|
background-color: var(--ys-paper);
|
||||||
|
background-image: linear-gradient(180deg, var(--ys-paper), #fdfbfb);
|
||||||
|
border-color: rgba(201, 79, 79, 0.28);
|
||||||
|
box-shadow: 0 1px 2px rgba(28, 35, 51, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-button--danger:not(.is-link):not(.is-text):not(.is-plain):not(.is-disabled):hover {
|
||||||
|
color: #b23f3f;
|
||||||
|
background-color: var(--ys-danger-wash);
|
||||||
|
border-color: rgba(201, 79, 79, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 输入框 / 选择器 / 文本域:微内阴影 + 靛蓝焦点环 */
|
||||||
|
.el-input__wrapper,
|
||||||
|
.el-select__wrapper,
|
||||||
|
.el-textarea__inner {
|
||||||
|
border-radius: var(--ys-radius-sm);
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 1px rgba(28, 35, 51, 0.13) inset,
|
||||||
|
inset 0 1px 2px rgba(28, 35, 51, 0.035);
|
||||||
|
transition: box-shadow 150ms var(--ys-ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-input__wrapper:hover,
|
||||||
|
.el-select__wrapper:hover,
|
||||||
|
.el-textarea__inner:hover {
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 1px rgba(28, 35, 51, 0.24) inset,
|
||||||
|
inset 0 1px 2px rgba(28, 35, 51, 0.035);
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-input__wrapper.is-focus,
|
||||||
|
.el-select__wrapper.is-focused,
|
||||||
|
.el-textarea__inner:focus {
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 1px var(--ys-indigo) inset,
|
||||||
|
inset 0 1px 2px rgba(28, 35, 51, 0.03),
|
||||||
|
0 0 0 3px rgba(46, 75, 168, 0.13);
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-input__inner,
|
||||||
|
.el-textarea__inner {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 表单标签:13px 次墨,不抢输入框的注意力 */
|
||||||
|
.el-form-item__label {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--ys-ink-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 表格容器:与卡片同材质;裸表格(未套 .card)也不会贴在雾灰底上 */
|
||||||
|
.el-table {
|
||||||
|
border-radius: var(--ys-radius);
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-table th.el-table__cell {
|
||||||
|
padding: 10px 0;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
background-color: transparent;
|
||||||
|
background-image: linear-gradient(180deg, #fafbfc, #f5f6f9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-table td.el-table__cell {
|
||||||
|
padding: 12px 0;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-table .cell {
|
||||||
|
padding: 0 16px;
|
||||||
|
/*
|
||||||
|
* Element Plus 默认给单元格 23px 行高(≈1.77 倍),加上 12px 上下内距,
|
||||||
|
* 单行数据行会到 51px —— 一屏少看两行,也与设计稿的「12px 内距 + 13px 字」
|
||||||
|
* 不符。收到 1.5(中文正文常用行高)后单行约 45px;多行单元格仍按内容撑开。
|
||||||
|
*/
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-table__body tr:hover > td.el-table__cell {
|
||||||
|
transition: background-color 120ms var(--ys-ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 分页:当前页与主按钮同材质(靛蓝渐变 + 内高光) */
|
||||||
|
.el-pagination button,
|
||||||
|
.el-pagination .el-pager li {
|
||||||
|
border-radius: 6px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-pagination.is-background .el-pager li.is-active {
|
||||||
|
color: #fff;
|
||||||
|
background-color: var(--ys-indigo);
|
||||||
|
background-image: linear-gradient(180deg, var(--ys-indigo-hi), var(--ys-indigo));
|
||||||
|
border: 1px solid var(--ys-indigo);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.18),
|
||||||
|
0 1px 2px rgba(46, 75, 168, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 标签:默认压成 11px 方角小签(状态列请改用 .ys-pill) */
|
||||||
|
.el-tag {
|
||||||
|
font-size: 11px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-tag--small {
|
||||||
|
height: 20px;
|
||||||
|
padding: 0 7px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 弹层:淡入 + 上移 4px(160ms),与设计稿 §08 一致 */
|
||||||
|
.el-popper.is-light {
|
||||||
|
border-radius: var(--ys-radius-sm);
|
||||||
|
box-shadow: var(--ys-shadow-raise);
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-drawer__header {
|
||||||
|
margin-bottom: 0;
|
||||||
|
padding: 16px 20px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ys-ink);
|
||||||
|
border-bottom: 1px solid var(--ys-line-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-drawer__body {
|
||||||
|
padding: 18px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 空状态:弱墨、留白,不喊叫 */
|
||||||
|
.el-table__empty-text {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--ys-ink-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 入场上浮:卡片/面板通用(设计稿 §08 320ms,可被页面用 animation-delay 交错) */
|
||||||
|
@keyframes ys-rise {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(6px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ys-rise {
|
||||||
|
animation: ys-rise 320ms var(--ys-ease) backwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 不可再补装饰的场合(打印)统一去动效 */
|
||||||
|
@media print {
|
||||||
|
.ys-rise {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.el-button,
|
||||||
|
.el-input__wrapper,
|
||||||
|
.el-select__wrapper,
|
||||||
|
.el-textarea__inner,
|
||||||
|
.el-table__body tr:hover > td.el-table__cell {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-button--primary:not(.is-disabled):hover,
|
||||||
|
.el-button--primary:not(.is-disabled):active {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ys-rise {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 分页放进卡片页脚时去掉自身的上外边距 —— 页脚已有 padding,
|
||||||
|
* 再加 14px 会让卡片底部比顶部厚一档(各页原本各写一遍 :deep 覆盖)。
|
||||||
|
* 连 .app-pager 一起写:AppPagination 组件自己的 scoped 样式里有
|
||||||
|
* margin-top,同权重下后注入的 scoped 会赢,这里多一级类压住它。
|
||||||
|
*/
|
||||||
|
.card-foot .el-pagination.app-pager {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* 确认框(ElMessageBox)统一视觉
|
* 确认框(ElMessageBox)统一视觉
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -10,12 +10,25 @@ const { topLevelMenus, groupedMenus } = useAppMenu()
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<el-menu class="app-menu" :default-active="route.path" router>
|
<el-menu class="app-menu" :default-active="route.path" router>
|
||||||
<el-menu-item v-for="item in topLevelMenus" :key="item.path" :index="item.path">
|
<el-menu-item
|
||||||
|
v-for="item in topLevelMenus"
|
||||||
|
:key="item.path"
|
||||||
|
class="app-menu__top"
|
||||||
|
:index="item.path"
|
||||||
|
>
|
||||||
|
<el-icon v-if="item.icon" class="app-menu__icon" aria-hidden="true">
|
||||||
|
<component :is="item.icon" />
|
||||||
|
</el-icon>
|
||||||
{{ item.title }}
|
{{ item.title }}
|
||||||
</el-menu-item>
|
</el-menu-item>
|
||||||
<el-sub-menu v-for="group in groupedMenus" :key="group.name" :index="group.name">
|
<el-sub-menu v-for="group in groupedMenus" :key="group.name" :index="group.name">
|
||||||
<template #title>{{ group.name }}</template>
|
<template #title>
|
||||||
|
<span class="app-menu__group">{{ group.name }}</span>
|
||||||
|
</template>
|
||||||
<el-menu-item v-for="item in group.items" :key="item.path" :index="item.path">
|
<el-menu-item v-for="item in group.items" :key="item.path" :index="item.path">
|
||||||
|
<el-icon v-if="item.icon" class="app-menu__icon" aria-hidden="true">
|
||||||
|
<component :is="item.icon" />
|
||||||
|
</el-icon>
|
||||||
{{ item.title }}
|
{{ item.title }}
|
||||||
</el-menu-item>
|
</el-menu-item>
|
||||||
</el-sub-menu>
|
</el-sub-menu>
|
||||||
@@ -24,42 +37,159 @@ const { topLevelMenus, groupedMenus } = useAppMenu()
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/*
|
/*
|
||||||
* 中性灰侧栏上的菜单:覆盖 Element Plus 的主题变量而非直接写颜色 ——
|
* 侧栏菜单:中性灰底 + 靛蓝点缀。
|
||||||
* 后者与组件库规则同权重,受样式注入顺序影响而不稳定。
|
* 覆盖 Element Plus 的主题变量而非直接写颜色 —— 后者与组件库规则同权重,
|
||||||
* 激活项 = 白底浮起(微渐变 + 内高光 + 分层阴影)+ 左侧 2px 靛蓝条。
|
* 受样式注入顺序影响而不稳定。
|
||||||
|
*
|
||||||
|
* 层级口径:父子**同字号(13px)**,层级不靠字号/字重刻意压过 ——
|
||||||
|
* 组标题与条目一样大,结构由「组标题带折叠箭头 + 组间留白 + 子项缩进一档」承担。
|
||||||
|
* 前两版各走偏一次:先把组标题压成 11px 弱墨「目录行」,父级弱于子项;
|
||||||
|
* 后改成 14px/600 + 靛蓝竖标,父级刻意压过子项、像一排小标题在喊叫。
|
||||||
|
* 层级是位置关系,不是字号关系 —— 两版都不要。
|
||||||
|
*
|
||||||
|
* 激活态:白底浮起(微渐变 + 内高光 + 分层阴影)+ 靛蓝字,不再叠左侧竖条 ——
|
||||||
|
* 竖条只能落在侧栏内边缘(离白底悬空),是与白底重复的第二个信号。
|
||||||
*/
|
*/
|
||||||
.app-menu {
|
.app-menu {
|
||||||
--el-menu-bg-color: transparent;
|
--el-menu-bg-color: transparent;
|
||||||
--el-menu-text-color: var(--ys-ink-2);
|
--el-menu-text-color: var(--ys-ink-2);
|
||||||
--el-menu-active-color: var(--ys-indigo);
|
--el-menu-active-color: var(--ys-indigo);
|
||||||
--el-menu-hover-bg-color: rgba(255, 255, 255, 0.6);
|
--el-menu-hover-bg-color: rgba(255, 255, 255, 0.55);
|
||||||
--el-menu-item-height: 40px;
|
--el-menu-item-height: 34px;
|
||||||
|
|
||||||
|
padding: 2px 10px 12px;
|
||||||
border-right: none;
|
border-right: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 组与组之间留白:分组交给间距,不加分割线 */
|
||||||
|
.app-menu :deep(.el-sub-menu) {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 顶层叶子项(首页):与组标题同级 —— 13px / 500 / 主墨。
|
||||||
|
* 用 scoped 模板 class 定位(权重高于组件库默认样式),
|
||||||
|
* 不与子项共用 :deep 规则,避免字号被子项规则带走。
|
||||||
|
*/
|
||||||
|
.app-menu__top {
|
||||||
|
height: 34px;
|
||||||
|
padding: 0 10px;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 34px;
|
||||||
|
color: var(--ys-ink);
|
||||||
|
border-radius: var(--ys-radius-sm);
|
||||||
|
transition:
|
||||||
|
background-color 150ms var(--ys-ease),
|
||||||
|
color 150ms var(--ys-ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-menu__top:hover {
|
||||||
|
color: var(--ys-ink);
|
||||||
|
background-color: rgba(255, 255, 255, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 组标题:与条目同字号,靠折叠箭头表明「可展开」,不靠压重 */
|
||||||
|
.app-menu :deep(.el-sub-menu__title) {
|
||||||
|
height: 34px;
|
||||||
|
padding: 0 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 34px;
|
||||||
|
color: var(--ys-ink);
|
||||||
|
border-radius: var(--ys-radius-sm);
|
||||||
|
transition:
|
||||||
|
background-color 150ms var(--ys-ease),
|
||||||
|
color 150ms var(--ys-ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-menu__group {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 子项:13px 次墨 + 缩进一档(12px)。
|
||||||
|
* Element Plus 的 `.el-menu--inline .el-menu-item` 带 !important 的 40px 缩进,
|
||||||
|
* 必须同样用 !important 才能压住,否则缩进档位不受控。
|
||||||
|
*/
|
||||||
|
.app-menu :deep(.el-sub-menu .el-menu-item) {
|
||||||
|
height: 32px;
|
||||||
|
padding-right: 10px !important;
|
||||||
|
padding-left: 22px !important;
|
||||||
|
margin-bottom: 1px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 32px;
|
||||||
|
color: var(--ys-ink-2);
|
||||||
|
border-radius: var(--ys-radius-sm);
|
||||||
|
transition:
|
||||||
|
background-color 150ms var(--ys-ease),
|
||||||
|
color 150ms var(--ys-ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-menu :deep(.el-sub-menu .el-menu-item:hover) {
|
||||||
|
color: var(--ys-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 激活项:白底浮起 + 靛蓝字(阴影分层与设计稿卡片同口径) */
|
||||||
.app-menu :deep(.el-menu-item.is-active) {
|
.app-menu :deep(.el-menu-item.is-active) {
|
||||||
position: relative;
|
font-weight: 500;
|
||||||
background: linear-gradient(180deg, #ffffff, #fbfcfe);
|
color: var(--ys-indigo);
|
||||||
|
background: linear-gradient(180deg, var(--ys-paper), #fbfcfe);
|
||||||
box-shadow:
|
box-shadow:
|
||||||
inset 0 1px 0 #fff,
|
inset 0 1px 0 #fff,
|
||||||
0 1px 2px rgba(28, 35, 51, 0.06),
|
0 1px 2px rgba(28, 35, 51, 0.06),
|
||||||
0 4px 10px rgba(28, 35, 51, 0.06);
|
0 4px 10px rgba(28, 35, 51, 0.06);
|
||||||
font-weight: 500;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-menu :deep(.el-menu-item.is-active)::before {
|
.app-menu :deep(.el-sub-menu__title:hover) {
|
||||||
content: '';
|
background-color: rgba(255, 255, 255, 0.5);
|
||||||
position: absolute;
|
|
||||||
left: -14px;
|
|
||||||
top: 9px;
|
|
||||||
bottom: 9px;
|
|
||||||
width: 2px;
|
|
||||||
border-radius: 1px;
|
|
||||||
background: var(--ys-indigo);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-menu :deep(.el-sub-menu.is-active > .el-sub-menu__title) {
|
/*
|
||||||
color: var(--ys-indigo);
|
* 展开/收起箭头:Element Plus 默认「闭合向下、展开转 180°」,
|
||||||
|
* 设计稿口径是「闭合向右、展开向下」——先统一掰到向右(-90°),
|
||||||
|
* 展开时回到 0°。选择器权重高于组件库默认值,且 scoped 属性使其稳定生效。
|
||||||
|
*/
|
||||||
|
.app-menu :deep(.el-sub-menu__icon-arrow) {
|
||||||
|
width: 11px;
|
||||||
|
height: 11px;
|
||||||
|
margin-top: -6px;
|
||||||
|
color: var(--ys-ink-3);
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
transition: transform 150ms var(--ys-ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-menu :deep(.el-sub-menu.is-opened > .el-sub-menu__title .el-sub-menu__icon-arrow) {
|
||||||
|
color: var(--ys-ink-2);
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 菜单图标:设计稿口径为 16px、透明度 0.7(激活项 1)——Element Plus 默认
|
||||||
|
* 给菜单图标 24px 宽 + 5px 间距,直接沿用会让图标比文字更抢眼、字距发散,
|
||||||
|
* 故在此显式覆盖。父子同级,图标尺寸统一,不再按层级分档。
|
||||||
|
*/
|
||||||
|
.app-menu__icon {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
margin-right: 9px;
|
||||||
|
font-size: 16px;
|
||||||
|
opacity: 0.72;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-menu :deep(.el-menu-item.is-active) .app-menu__icon {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.app-menu__top,
|
||||||
|
.app-menu :deep(.el-sub-menu .el-menu-item),
|
||||||
|
.app-menu :deep(.el-sub-menu__title),
|
||||||
|
.app-menu :deep(.el-sub-menu__icon-arrow) {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -17,6 +17,9 @@ const drawerVisible = ref(false)
|
|||||||
/** 「首页」按角色解析:普通用户没有工作台权限,固定链到业务数据页 */
|
/** 「首页」按角色解析:普通用户没有工作台权限,固定链到业务数据页 */
|
||||||
const homePath = computed(() => firstAccessiblePath(router, userStore.roles))
|
const homePath = computed(() => firstAccessiblePath(router, userStore.roles))
|
||||||
|
|
||||||
|
/** 头像取显示名首字:没有头像上传能力,首字比图标更有辨识度 */
|
||||||
|
const avatarInitial = computed(() => userStore.displayName.trim().charAt(0) || '衍')
|
||||||
|
|
||||||
/** 面包屑:首页 + 分组(如有)+ 当前页标题(首页自身不重复) */
|
/** 面包屑:首页 + 分组(如有)+ 当前页标题(首页自身不重复) */
|
||||||
const breadcrumbs = computed<string[]>(() => {
|
const breadcrumbs = computed<string[]>(() => {
|
||||||
const items: string[] = []
|
const items: string[] = []
|
||||||
@@ -46,13 +49,13 @@ async function handleLogout(): Promise<void> {
|
|||||||
<div class="app-brand">
|
<div class="app-brand">
|
||||||
<span class="app-brand__name">衍水集团</span>
|
<span class="app-brand__name">衍水集团</span>
|
||||||
<span class="app-brand__sub">营销人员工资管理系统</span>
|
<span class="app-brand__sub">营销人员工资管理系统</span>
|
||||||
<span class="app-brand__line"></span>
|
<span class="waterline app-brand__line"></span>
|
||||||
</div>
|
</div>
|
||||||
<AppMenu class="app-aside__menu" />
|
<AppMenu class="app-aside__menu" />
|
||||||
</el-aside>
|
</el-aside>
|
||||||
|
|
||||||
<el-container class="app-body">
|
<el-container class="app-body">
|
||||||
<el-header class="app-header">
|
<el-header class="app-header" height="52px">
|
||||||
<div class="app-header__left">
|
<div class="app-header__left">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -81,7 +84,9 @@ async function handleLogout(): Promise<void> {
|
|||||||
<el-tag v-for="role in userStore.roles" :key="role" size="small" type="info">
|
<el-tag v-for="role in userStore.roles" :key="role" size="small" type="info">
|
||||||
{{ role }}
|
{{ role }}
|
||||||
</el-tag>
|
</el-tag>
|
||||||
|
<span class="app-header__avatar" aria-hidden="true">{{ avatarInitial }}</span>
|
||||||
<span class="app-header__username">{{ userStore.displayName }}</span>
|
<span class="app-header__username">{{ userStore.displayName }}</span>
|
||||||
|
<span class="app-header__divider" aria-hidden="true"></span>
|
||||||
<el-button link type="primary" @click="handleLogout">退出登录</el-button>
|
<el-button link type="primary" @click="handleLogout">退出登录</el-button>
|
||||||
</div>
|
</div>
|
||||||
</el-header>
|
</el-header>
|
||||||
@@ -101,7 +106,7 @@ async function handleLogout(): Promise<void> {
|
|||||||
<div class="app-brand">
|
<div class="app-brand">
|
||||||
<span class="app-brand__name">衍水集团</span>
|
<span class="app-brand__name">衍水集团</span>
|
||||||
<span class="app-brand__sub">营销人员工资管理系统</span>
|
<span class="app-brand__sub">营销人员工资管理系统</span>
|
||||||
<span class="app-brand__line"></span>
|
<span class="waterline app-brand__line"></span>
|
||||||
</div>
|
</div>
|
||||||
<AppMenu />
|
<AppMenu />
|
||||||
</el-drawer>
|
</el-drawer>
|
||||||
@@ -117,7 +122,7 @@ async function handleLogout(): Promise<void> {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
/* 中性灰侧栏:比页面底沉一档,与纸白卡片拉开层次 */
|
/* 中性灰侧栏:比页面底沉一档,与纸白卡片拉开层次 */
|
||||||
background: linear-gradient(180deg, #f2f4f8 0%, #ecedf3 100%);
|
background: linear-gradient(180deg, var(--ys-aside-1) 0%, var(--ys-aside-2) 100%);
|
||||||
border-right: 1px solid var(--ys-line);
|
border-right: 1px solid var(--ys-line);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,9 +135,9 @@ async function handleLogout(): Promise<void> {
|
|||||||
|
|
||||||
.app-brand__name {
|
.app-brand__name {
|
||||||
font-family: var(--ys-font-brand);
|
font-family: var(--ys-font-brand);
|
||||||
font-size: 16px;
|
font-size: 15px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
letter-spacing: 4px;
|
letter-spacing: 0.32em;
|
||||||
color: var(--ys-ink);
|
color: var(--ys-ink);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,17 +147,21 @@ async function handleLogout(): Promise<void> {
|
|||||||
color: var(--ys-ink-3);
|
color: var(--ys-ink-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 品牌签名:logo 下的渐隐水线(全站唯一装饰元素,页面标题处同款) */
|
/* 品牌签名:logo 下的渐隐水线(全站唯一装饰元素,页面标题处同款,此处收窄) */
|
||||||
.app-brand__line {
|
.app-brand__line {
|
||||||
width: 24px;
|
width: 24px;
|
||||||
height: 2px;
|
|
||||||
border-radius: 1px;
|
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
background: linear-gradient(90deg, var(--ys-indigo), rgba(46, 75, 168, 0));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-aside__menu {
|
.app-aside__menu {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
/*
|
||||||
|
* 只写上下内距:横向 10px 留白由 AppMenu 自己的 .app-menu 提供。
|
||||||
|
* 这里若用 padding 简写,会与 .app-menu 的 padding 简写同权重、
|
||||||
|
* 靠样式注入顺序决胜 —— 横向留白可能被整条抹掉,条目贴到侧栏边缘。
|
||||||
|
*/
|
||||||
|
padding-top: 2px;
|
||||||
|
padding-bottom: 12px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,8 +169,8 @@ async function handleLogout(): Promise<void> {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
height: 56px;
|
padding: 0 18px;
|
||||||
background: linear-gradient(180deg, #ffffff, #fdfdfe);
|
background: linear-gradient(180deg, var(--ys-paper), #fdfdfe);
|
||||||
border-bottom: 1px solid var(--ys-line);
|
border-bottom: 1px solid var(--ys-line);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,15 +188,16 @@ async function handleLogout(): Promise<void> {
|
|||||||
width: 32px;
|
width: 32px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
color: var(--ys-text);
|
color: var(--ys-ink-2);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 6px;
|
border-radius: 7px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-header__menu-toggle:hover {
|
.app-header__menu-toggle:hover {
|
||||||
background-color: #f2f3f5;
|
color: var(--ys-indigo);
|
||||||
|
background-color: var(--ys-indigo-wash);
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-header__menu-toggle:focus-visible {
|
.app-header__menu-toggle:focus-visible {
|
||||||
@@ -198,20 +208,46 @@ async function handleLogout(): Promise<void> {
|
|||||||
.app-header__user {
|
.app-header__user {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 首字头像:靛蓝洗底,26px —— 与菜单图标同为「轻质感」而非实心色块 */
|
||||||
|
.app-header__avatar {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ys-indigo);
|
||||||
|
background: linear-gradient(180deg, #f1f4fb, var(--ys-indigo-wash));
|
||||||
|
border: 1px solid rgba(46, 75, 168, 0.14);
|
||||||
|
border-radius: 50%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-header__username {
|
.app-header__username {
|
||||||
font-size: 14px;
|
font-size: 13px;
|
||||||
color: var(--ys-text);
|
color: var(--ys-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header__divider {
|
||||||
|
width: 1px;
|
||||||
|
height: 14px;
|
||||||
|
background-color: var(--ys-line);
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-main {
|
.app-main {
|
||||||
padding: 20px;
|
/*
|
||||||
/* 雾灰底 + 点阵网格:内容区的秩序与密度 */
|
* 内容区:雾灰底 + 顶部受光(靛蓝微光叠一层高光)。
|
||||||
|
* 受光用固定像素半径而非百分比:百分比会随窗口高度变化,
|
||||||
|
* 窄高三列布局时天光会被拉成竖条。
|
||||||
|
*/
|
||||||
|
padding: 22px;
|
||||||
background-color: var(--ys-mist);
|
background-color: var(--ys-mist);
|
||||||
background-image: radial-gradient(rgba(28, 35, 51, 0.05) 1px, transparent 1px);
|
background-image:
|
||||||
background-size: 24px 24px;
|
radial-gradient(900px 320px at 50% -60px, rgba(46, 75, 168, 0.055), rgba(46, 75, 168, 0) 70%),
|
||||||
|
radial-gradient(1200px 420px at 50% -140px, rgba(255, 255, 255, 0.85), rgba(255, 255, 255, 0) 75%);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
@@ -222,6 +258,10 @@ async function handleLogout(): Promise<void> {
|
|||||||
.app-header__menu-toggle {
|
.app-header__menu-toggle {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-main {
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
@@ -235,6 +275,6 @@ async function handleLogout(): Promise<void> {
|
|||||||
/* 抽屉内容铺满侧栏同款灰(非 scoped:目标元素由 el-drawer 渲染在组件根之外) */
|
/* 抽屉内容铺满侧栏同款灰(非 scoped:目标元素由 el-drawer 渲染在组件根之外) */
|
||||||
.app-drawer .el-drawer__body {
|
.app-drawer .el-drawer__body {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
background: linear-gradient(180deg, #f2f4f8 0%, #ecedf3 100%);
|
background: linear-gradient(180deg, var(--ys-aside-1) 0%, var(--ys-aside-2) 100%);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import type { Component } from 'vue'
|
||||||
|
|
||||||
|
import {
|
||||||
|
Avatar,
|
||||||
|
Box,
|
||||||
|
Briefcase,
|
||||||
|
Calendar,
|
||||||
|
DataLine,
|
||||||
|
Histogram,
|
||||||
|
House,
|
||||||
|
Management,
|
||||||
|
Money,
|
||||||
|
Notebook,
|
||||||
|
OfficeBuilding,
|
||||||
|
PieChart,
|
||||||
|
Postcard,
|
||||||
|
SetUp,
|
||||||
|
Shop,
|
||||||
|
Stamp,
|
||||||
|
Tickets,
|
||||||
|
TrendCharts,
|
||||||
|
User,
|
||||||
|
Van,
|
||||||
|
} from '@element-plus/icons-vue'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 菜单图标注册表。
|
||||||
|
*
|
||||||
|
* **路由只写语义 key(meta.icon),不直接引用图标组件**:路由表保持轻量、
|
||||||
|
* 序列化,图标实现换库或换风格时只改本文件,路由与菜单组件不动。
|
||||||
|
*
|
||||||
|
* 具名导入(不用 `import * as`)以保证摇树 —— 整包引入会把 200+ 图标打进
|
||||||
|
* 产物。注册表里多余的图标同样会被摇掉,但会让「用了哪些」难以核对,
|
||||||
|
* 只保留实际用到的。
|
||||||
|
*
|
||||||
|
* **全部取线性(outline)图标,不用 *-filled 变体**:侧栏同一层级混用实心与
|
||||||
|
* 线性图标会让菜单看着不是一套语汇(设计稿 §04 的图标族:16px 线性)。
|
||||||
|
* 首页用 House(线性房屋)而非 HomeFilled,人员管理用 Postcard(人员档案卡)
|
||||||
|
* 而非 UserFilled —— 用户管理已占 User,人员改用档案卡以免两个「人」撞形。
|
||||||
|
*/
|
||||||
|
const ICONS = {
|
||||||
|
home: House,
|
||||||
|
users: User,
|
||||||
|
roles: Avatar,
|
||||||
|
'business-roles': Management,
|
||||||
|
params: SetUp,
|
||||||
|
settings: OfficeBuilding,
|
||||||
|
approvals: Stamp,
|
||||||
|
logs: Tickets,
|
||||||
|
personnel: Postcard,
|
||||||
|
products: Box,
|
||||||
|
customers: Shop,
|
||||||
|
businesses: Briefcase,
|
||||||
|
shipments: Van,
|
||||||
|
receipts: Money,
|
||||||
|
ledger: Notebook,
|
||||||
|
settlements: Calendar,
|
||||||
|
'report-personal': DataLine,
|
||||||
|
'report-department': Histogram,
|
||||||
|
'report-business': TrendCharts,
|
||||||
|
'report-payment': PieChart,
|
||||||
|
} satisfies Record<string, Component>
|
||||||
|
|
||||||
|
/** 菜单图标的语义 key(路由 meta.icon 的取值范围)。 */
|
||||||
|
export type MenuIconName = keyof typeof ICONS
|
||||||
|
|
||||||
|
/** 按语义 key 取图标;未配置时返回 undefined(菜单项只显示文字)。 */
|
||||||
|
export function menuIcon(name?: string): Component | undefined {
|
||||||
|
return name ? (ICONS[name as MenuIconName] ?? undefined) : undefined
|
||||||
|
}
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
/**
|
/**
|
||||||
* 分页条:统一四个列表页的布局与页码尺寸选项。
|
* 分页条:统一各列表页的布局与页码尺寸选项。
|
||||||
* 与 `usePagedList` 的分页字段(pageNum/pageSize/total)配套使用。
|
* 与 `usePagedList` 的分页字段(pageNum/pageSize/total)配套使用。
|
||||||
|
*
|
||||||
|
* 开 background:设计稿 §03 要求当前页与主按钮同材质(靛蓝渐变 + 内高光),
|
||||||
|
* 该效果只在 is-background 模式下由 .el-pager li 承载,样式见全局 index.css。
|
||||||
*/
|
*/
|
||||||
defineProps<{
|
defineProps<{
|
||||||
total: number
|
total: number
|
||||||
@@ -18,6 +21,7 @@ defineEmits<{
|
|||||||
<template>
|
<template>
|
||||||
<el-pagination
|
<el-pagination
|
||||||
class="app-pager"
|
class="app-pager"
|
||||||
|
background
|
||||||
layout="total, sizes, prev, pager, next, jumper"
|
layout="total, sizes, prev, pager, next, jumper"
|
||||||
:total="total"
|
:total="total"
|
||||||
:current-page="pageNum"
|
:current-page="pageNum"
|
||||||
@@ -31,6 +35,6 @@ defineEmits<{
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.app-pager {
|
.app-pager {
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
margin-top: 16px;
|
margin-top: 14px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -4,8 +4,15 @@
|
|||||||
*
|
*
|
||||||
* 用户状态、角色状态、操作结果、登录结果四处共用,故提取;
|
* 用户状态、角色状态、操作结果、登录结果四处共用,故提取;
|
||||||
* 语义文字由调用方通过 props 定制(如同一组件展示「启用/禁用」与「成功/失败」)。
|
* 语义文字由调用方通过 props 定制(如同一组件展示「启用/禁用」与「成功/失败」)。
|
||||||
|
*
|
||||||
|
* 视觉按设计稿 §03 的状态 pill 落地(浅底 + 6px 圆点 + 999px 圆角),
|
||||||
|
* 用 span 而非 el-tag:el-tag 的实心/描边形态与四档色阶对不上,
|
||||||
|
* 强行覆盖会牵连其它用 el-tag 的地方(角色标签、日志模块标签)。
|
||||||
|
* activeType/inactiveType 保留为语义到 pill 色调的映射,调用方无需改动。
|
||||||
*/
|
*/
|
||||||
withDefaults(
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
value: number
|
value: number
|
||||||
activeText?: string
|
activeText?: string
|
||||||
@@ -20,10 +27,23 @@ withDefaults(
|
|||||||
inactiveType: 'info',
|
inactiveType: 'info',
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** Element Plus 的语义类型 → 设计稿的四档 pill 色调 */
|
||||||
|
const TONE_BY_TYPE: Record<string, string> = {
|
||||||
|
success: 'ok',
|
||||||
|
warning: 'wait',
|
||||||
|
danger: 'reject',
|
||||||
|
primary: 'lock',
|
||||||
|
info: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
const isActive = computed(() => props.value === 1)
|
||||||
|
const tone = computed(() =>
|
||||||
|
TONE_BY_TYPE[isActive.value ? props.activeType : props.inactiveType] ?? '',
|
||||||
|
)
|
||||||
|
const text = computed(() => (isActive.value ? props.activeText : props.inactiveText))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<el-tag :type="value === 1 ? activeType : inactiveType" size="small" disable-transitions>
|
<span class="ys-pill" :class="tone ? `ys-pill--${tone}` : ''">{{ text }}</span>
|
||||||
{{ value === 1 ? activeText : inactiveText }}
|
|
||||||
</el-tag>
|
|
||||||
</template>
|
</template>
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
import type { Component } from 'vue'
|
||||||
|
|
||||||
|
import { menuIcon } from '@/components/layout/menuIcons'
|
||||||
import { canAccessRoute, isMenuRecord } from '@/router/access'
|
import { canAccessRoute, isMenuRecord } from '@/router/access'
|
||||||
import { useUserStore } from '@/store/user'
|
import { useUserStore } from '@/store/user'
|
||||||
|
|
||||||
@@ -10,6 +12,8 @@ export interface MenuNode {
|
|||||||
title: string
|
title: string
|
||||||
order: number
|
order: number
|
||||||
group?: string
|
group?: string
|
||||||
|
/** 图标组件(由 meta.icon 语义 key 解析;未配置则为 undefined) */
|
||||||
|
icon?: Component
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MenuGroup {
|
export interface MenuGroup {
|
||||||
@@ -42,6 +46,7 @@ export function useAppMenu() {
|
|||||||
title: record.meta.title ?? '',
|
title: record.meta.title ?? '',
|
||||||
order: record.meta.order ?? 99,
|
order: record.meta.order ?? 99,
|
||||||
group: record.meta.group,
|
group: record.meta.group,
|
||||||
|
icon: menuIcon(record.meta.icon),
|
||||||
}))
|
}))
|
||||||
.sort((a, b) => a.order - b.order),
|
.sort((a, b) => a.order - b.order),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -28,42 +28,101 @@ export function usePagedList<T, F extends object, D extends PageData<T> = PageDa
|
|||||||
/** 自增请求序号:仅最新请求的响应可写入状态 */
|
/** 自增请求序号:仅最新请求的响应可写入状态 */
|
||||||
let requestSeq = 0
|
let requestSeq = 0
|
||||||
|
|
||||||
/** 按当前页码与筛选条件加载 */
|
/**
|
||||||
async function load(): Promise<void> {
|
* 列表当前**实际显示**的是哪一页(成功写入 records 时同步)。
|
||||||
|
*
|
||||||
|
* 这是「分页器高亮」与「列表内容」保持一致的唯一依据:失败回滚要退回
|
||||||
|
* 这个页码,而不是退回点击前的页码 —— 连点两次时前一次请求的响应会被
|
||||||
|
* 序号守卫丢弃,退回「点击前」的页码可能与屏幕上的数据对不上。
|
||||||
|
*/
|
||||||
|
let shownPageNum = 1
|
||||||
|
let shownPageSize = 10
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按当前页码与筛选条件加载,返回是否成功以及本次请求的序号。
|
||||||
|
*
|
||||||
|
* 返回值是翻页失败回滚的依据:`records` 只在成功时更新,而分页器
|
||||||
|
* 高亮绑的是 `pageNum`,两者必须同进同退,否则会出现
|
||||||
|
* 「页码已跳到第 2 页、列表还停在第 1 页」的错位(用户来回点才恢复)。
|
||||||
|
*
|
||||||
|
* 带出 `seq` 是因为**回滚本身也要防竞态**:连点两次时旧请求失败,
|
||||||
|
* 若无条件把页码退回它那一轮的旧值,就会把用户刚点出的新页码覆盖掉,
|
||||||
|
* 表现为「页码自己跳回去」。
|
||||||
|
*
|
||||||
|
* 页码与条数在 await **之前**取快照:等待期间另一次点击可能已改动
|
||||||
|
* `pageNum`,用 await 之后的值记录「显示的页」会记错。
|
||||||
|
*
|
||||||
|
* 对外不暴露返回值:`load` 被大量页面当 `() => void | Promise<void>`
|
||||||
|
* 的回调使用,多一个返回值会让那些赋值类型不兼容。
|
||||||
|
*/
|
||||||
|
async function fetchPage(): Promise<{ ok: boolean; seq: number }> {
|
||||||
const seq = ++requestSeq
|
const seq = ++requestSeq
|
||||||
|
const reqPageNum = pageNum.value
|
||||||
|
const reqPageSize = pageSize.value
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const data = await fetcher({
|
const data = await fetcher({
|
||||||
...getFilters(),
|
...getFilters(),
|
||||||
pageNum: pageNum.value,
|
pageNum: reqPageNum,
|
||||||
pageSize: pageSize.value,
|
pageSize: reqPageSize,
|
||||||
})
|
})
|
||||||
if (seq !== requestSeq) return // 已有更新的请求发出,丢弃过期响应
|
if (seq !== requestSeq) return { ok: false, seq } // 已有更新的请求发出,丢弃过期响应
|
||||||
records.value = data.list
|
records.value = data.list
|
||||||
total.value = data.total
|
total.value = data.total
|
||||||
|
shownPageNum = reqPageNum
|
||||||
|
shownPageSize = reqPageSize
|
||||||
onData?.(data)
|
onData?.(data)
|
||||||
|
return { ok: true, seq }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handledError(error) // 失败提示由请求拦截器统一完成
|
handledError(error) // 失败提示由请求拦截器统一完成
|
||||||
|
return { ok: false, seq }
|
||||||
} finally {
|
} finally {
|
||||||
if (seq === requestSeq) loading.value = false
|
if (seq === requestSeq) loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 请求失败后把分页状态退回到「屏幕上的数据」。
|
||||||
|
*
|
||||||
|
* 只在本次请求仍是最新请求时执行:否则用户已经点了别的页,
|
||||||
|
* 这里的回滚会把他的点击覆盖掉。
|
||||||
|
*/
|
||||||
|
function restoreShownPage(seq: number): void {
|
||||||
|
if (seq !== requestSeq) return
|
||||||
|
pageNum.value = shownPageNum
|
||||||
|
pageSize.value = shownPageSize
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按当前页码与筛选条件加载(供页面直接调用) */
|
||||||
|
async function load(): Promise<void> {
|
||||||
|
const { ok, seq } = await fetchPage()
|
||||||
|
if (!ok) restoreShownPage(seq)
|
||||||
|
}
|
||||||
|
|
||||||
/** 条件查询:回到第一页再加载(停留旧页码会查到空页) */
|
/** 条件查询:回到第一页再加载(停留旧页码会查到空页) */
|
||||||
function search(): void {
|
async function search(): Promise<void> {
|
||||||
pageNum.value = 1
|
pageNum.value = 1
|
||||||
void load()
|
await load()
|
||||||
}
|
}
|
||||||
|
|
||||||
function onPageChange(page: number): void {
|
/**
|
||||||
|
* 翻页:失败则退回屏幕上正在显示的那一页。
|
||||||
|
*
|
||||||
|
* 不回滚的后果是分页器高亮与列表数据永久错位 —— 用户看到的是
|
||||||
|
* 「点了没反应,再点一下又跳回去」,且必须连点两次才能对齐。
|
||||||
|
*/
|
||||||
|
async function onPageChange(page: number): Promise<void> {
|
||||||
pageNum.value = page
|
pageNum.value = page
|
||||||
void load()
|
const { ok, seq } = await fetchPage()
|
||||||
|
if (!ok) restoreShownPage(seq)
|
||||||
}
|
}
|
||||||
|
|
||||||
function onSizeChange(size: number): void {
|
/** 改每页条数:回到第一页;失败同时退回页码与条数 */
|
||||||
|
async function onSizeChange(size: number): Promise<void> {
|
||||||
pageSize.value = size
|
pageSize.value = size
|
||||||
pageNum.value = 1
|
pageNum.value = 1
|
||||||
void load()
|
const { ok, seq } = await fetchPage()
|
||||||
|
if (!ok) restoreShownPage(seq)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 删除后刷新:当前页已被删空且不在第一页时,先回退页码再加载 */
|
/** 删除后刷新:当前页已被删空且不在第一页时,先回退页码再加载 */
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
|
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
|
|
||||||
|
import type { MenuIconName } from '@/components/layout/menuIcons'
|
||||||
import { firstAccessiblePath } from '@/router/access'
|
import { firstAccessiblePath } from '@/router/access'
|
||||||
import { useUserStore } from '@/store/user'
|
import { useUserStore } from '@/store/user'
|
||||||
import { ROLE_ADMIN } from '@/utils/constants'
|
import { ROLE_ADMIN } from '@/utils/constants'
|
||||||
@@ -20,6 +21,11 @@ declare module 'vue-router' {
|
|||||||
menu?: boolean
|
menu?: boolean
|
||||||
/** 菜单分组名(同名聚合为一个子菜单);不设则为顶层菜单项 */
|
/** 菜单分组名(同名聚合为一个子菜单);不设则为顶层菜单项 */
|
||||||
group?: string
|
group?: string
|
||||||
|
/**
|
||||||
|
* 菜单图标语义 key —— 取值见 components/layout/menuIcons.ts 的注册表。
|
||||||
|
* 路由只写 key 不写图标组件:换图标库/换风格只改注册表。
|
||||||
|
*/
|
||||||
|
icon?: MenuIconName
|
||||||
/** 菜单排序,越小越靠前 */
|
/** 菜单排序,越小越靠前 */
|
||||||
order?: number
|
order?: number
|
||||||
}
|
}
|
||||||
@@ -46,6 +52,7 @@ const routes: RouteRecordRaw[] = [
|
|||||||
component: () => import('@/views/dashboard/index.vue'),
|
component: () => import('@/views/dashboard/index.vue'),
|
||||||
meta: {
|
meta: {
|
||||||
title: '首页',
|
title: '首页',
|
||||||
|
icon: 'home',
|
||||||
order: 1,
|
order: 1,
|
||||||
allowedRoles: [ROLE_ADMIN, 'finance', 'leader'],
|
allowedRoles: [ROLE_ADMIN, 'finance', 'leader'],
|
||||||
},
|
},
|
||||||
@@ -54,19 +61,25 @@ const routes: RouteRecordRaw[] = [
|
|||||||
path: '/system/users',
|
path: '/system/users',
|
||||||
name: 'system-users',
|
name: 'system-users',
|
||||||
component: () => import('@/views/system/users/index.vue'),
|
component: () => import('@/views/system/users/index.vue'),
|
||||||
meta: { title: '用户管理', group: '系统管理', requiresAdmin: true, order: 2 },
|
meta: { title: '用户管理', icon: 'users', group: '系统管理', requiresAdmin: true, order: 2 },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/system/roles',
|
path: '/system/roles',
|
||||||
name: 'system-roles',
|
name: 'system-roles',
|
||||||
component: () => import('@/views/system/roles/index.vue'),
|
component: () => import('@/views/system/roles/index.vue'),
|
||||||
meta: { title: '角色管理', group: '系统管理', requiresAdmin: true, order: 3 },
|
meta: { title: '角色管理', icon: 'roles', group: '系统管理', requiresAdmin: true, order: 3 },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/system/business-roles',
|
path: '/system/business-roles',
|
||||||
name: 'system-business-roles',
|
name: 'system-business-roles',
|
||||||
component: () => import('@/views/system/business-roles/index.vue'),
|
component: () => import('@/views/system/business-roles/index.vue'),
|
||||||
meta: { title: '岗位角色', group: '系统管理', requiresAdmin: true, order: 5 },
|
meta: {
|
||||||
|
title: '岗位角色',
|
||||||
|
icon: 'business-roles',
|
||||||
|
group: '系统管理',
|
||||||
|
requiresAdmin: true,
|
||||||
|
order: 5,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// 计提参数:查看放开到管理员/财务/领导(#17 答复「查看可、修改仅
|
// 计提参数:查看放开到管理员/财务/领导(#17 答复「查看可、修改仅
|
||||||
@@ -76,6 +89,7 @@ const routes: RouteRecordRaw[] = [
|
|||||||
component: () => import('@/views/system/params/index.vue'),
|
component: () => import('@/views/system/params/index.vue'),
|
||||||
meta: {
|
meta: {
|
||||||
title: '计提参数',
|
title: '计提参数',
|
||||||
|
icon: 'params',
|
||||||
group: '系统管理',
|
group: '系统管理',
|
||||||
allowedRoles: [ROLE_ADMIN, 'finance', 'leader'],
|
allowedRoles: [ROLE_ADMIN, 'finance', 'leader'],
|
||||||
order: 6,
|
order: 6,
|
||||||
@@ -85,7 +99,13 @@ const routes: RouteRecordRaw[] = [
|
|||||||
path: '/system/settings',
|
path: '/system/settings',
|
||||||
name: 'system-settings',
|
name: 'system-settings',
|
||||||
component: () => import('@/views/system/settings/index.vue'),
|
component: () => import('@/views/system/settings/index.vue'),
|
||||||
meta: { title: '公司信息', group: '系统管理', requiresAdmin: true, order: 7 },
|
meta: {
|
||||||
|
title: '公司信息',
|
||||||
|
icon: 'settings',
|
||||||
|
group: '系统管理',
|
||||||
|
requiresAdmin: true,
|
||||||
|
order: 7,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// 审批管理:管理员/财务/领导可见(营销后勤待权限清单答复后放开)
|
// 审批管理:管理员/财务/领导可见(营销后勤待权限清单答复后放开)
|
||||||
@@ -94,6 +114,7 @@ const routes: RouteRecordRaw[] = [
|
|||||||
component: () => import('@/views/system/approvals/index.vue'),
|
component: () => import('@/views/system/approvals/index.vue'),
|
||||||
meta: {
|
meta: {
|
||||||
title: '审批管理',
|
title: '审批管理',
|
||||||
|
icon: 'approvals',
|
||||||
group: '系统管理',
|
group: '系统管理',
|
||||||
allowedRoles: [ROLE_ADMIN, 'finance', 'leader'],
|
allowedRoles: [ROLE_ADMIN, 'finance', 'leader'],
|
||||||
order: 8,
|
order: 8,
|
||||||
@@ -103,67 +124,66 @@ const routes: RouteRecordRaw[] = [
|
|||||||
path: '/system/logs',
|
path: '/system/logs',
|
||||||
name: 'system-logs',
|
name: 'system-logs',
|
||||||
component: () => import('@/views/system/logs/index.vue'),
|
component: () => import('@/views/system/logs/index.vue'),
|
||||||
meta: { title: '日志查询', group: '系统管理', requiresAdmin: true, order: 9 },
|
meta: {
|
||||||
|
title: '日志查询',
|
||||||
|
icon: 'logs',
|
||||||
|
group: '系统管理',
|
||||||
|
requiresAdmin: true,
|
||||||
|
order: 9,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// 基础数据属业务操作:登录即可访问,不设 requiresAdmin(对照后端 api/v1/base)
|
// 基础数据属业务操作:登录即可访问,不设 requiresAdmin(对照后端 api/v1/base)
|
||||||
path: '/base/personnel',
|
path: '/base/personnel',
|
||||||
name: 'base-personnel',
|
name: 'base-personnel',
|
||||||
component: () => import('@/views/base/personnel/index.vue'),
|
component: () => import('@/views/base/personnel/index.vue'),
|
||||||
meta: { title: '人员管理', group: '基础数据', order: 10 },
|
meta: { title: '人员管理', icon: 'personnel', group: '基础数据', order: 10 },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/base/products',
|
path: '/base/products',
|
||||||
name: 'base-products',
|
name: 'base-products',
|
||||||
component: () => import('@/views/base/products/index.vue'),
|
component: () => import('@/views/base/products/index.vue'),
|
||||||
meta: { title: '产品管理', group: '基础数据', order: 11 },
|
meta: { title: '产品管理', icon: 'products', group: '基础数据', order: 11 },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/base/customers',
|
path: '/base/customers',
|
||||||
name: 'base-customers',
|
name: 'base-customers',
|
||||||
component: () => import('@/views/base/customers/index.vue'),
|
component: () => import('@/views/base/customers/index.vue'),
|
||||||
meta: { title: '客户管理', group: '基础数据', order: 12 },
|
meta: { title: '客户管理', icon: 'customers', group: '基础数据', order: 12 },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// 业务数据组一律为业务操作:登录即可访问,不设 requiresAdmin
|
// 业务数据组一律为业务操作:登录即可访问,不设 requiresAdmin
|
||||||
path: '/business/businesses',
|
path: '/business/businesses',
|
||||||
name: 'business-businesses',
|
name: 'business-businesses',
|
||||||
component: () => import('@/views/business/businesses/index.vue'),
|
component: () => import('@/views/business/businesses/index.vue'),
|
||||||
meta: { title: '业务管理', group: '业务数据', order: 13 },
|
meta: { title: '业务管理', icon: 'businesses', group: '业务数据', order: 13 },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/business/shipments',
|
path: '/business/shipments',
|
||||||
name: 'business-shipments',
|
name: 'business-shipments',
|
||||||
component: () => import('@/views/business/shipments/index.vue'),
|
component: () => import('@/views/business/shipments/index.vue'),
|
||||||
meta: { title: '发货管理', group: '业务数据', order: 14 },
|
meta: { title: '发货管理', icon: 'shipments', group: '业务数据', order: 14 },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/business/receipts',
|
path: '/business/receipts',
|
||||||
name: 'business-receipts',
|
name: 'business-receipts',
|
||||||
component: () => import('@/views/business/receipts/index.vue'),
|
component: () => import('@/views/business/receipts/index.vue'),
|
||||||
meta: { title: '回款管理', group: '业务数据', order: 15 },
|
meta: { title: '回款管理', icon: 'receipts', group: '业务数据', order: 15 },
|
||||||
},
|
|
||||||
{
|
|
||||||
// 业务数据组首个页面:评级计算器是打分工具,不影响绩效金额,
|
|
||||||
// 登录用户即可用,不设 requiresAdmin(区别于系统管理组)
|
|
||||||
path: '/business/rating',
|
|
||||||
name: 'business-rating',
|
|
||||||
component: () => import('@/views/business/rating/index.vue'),
|
|
||||||
meta: { title: '客户评级计算器', group: '业务数据', order: 16 },
|
|
||||||
},
|
},
|
||||||
|
// 客户评级计算器已并入客户管理(行内弹窗),独立页移除
|
||||||
{
|
{
|
||||||
// 绩效工资组(M3 ④):核算台账读登录即可,月结写限管理员与营销后勤
|
// 绩效工资组(M3 ④):核算台账读登录即可,月结写限管理员与营销后勤
|
||||||
// (api 层 require_roles 兜底,页面层不再叠加 requiresAdmin)
|
// (api 层 require_roles 兜底,页面层不再叠加 requiresAdmin)
|
||||||
path: '/calc/ledger',
|
path: '/calc/ledger',
|
||||||
name: 'calc-ledger',
|
name: 'calc-ledger',
|
||||||
component: () => import('@/views/calc/ledger/index.vue'),
|
component: () => import('@/views/calc/ledger/index.vue'),
|
||||||
meta: { title: '绩效台账', group: '绩效工资', order: 17 },
|
meta: { title: '绩效台账', icon: 'ledger', group: '绩效工资', order: 17 },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/settlements',
|
path: '/settlements',
|
||||||
name: 'settlements',
|
name: 'settlements',
|
||||||
component: () => import('@/views/settlements/index.vue'),
|
component: () => import('@/views/settlements/index.vue'),
|
||||||
meta: { title: '月度结算', group: '绩效工资', order: 18 },
|
meta: { title: '月度结算', icon: 'settlements', group: '绩效工资', order: 18 },
|
||||||
},
|
},
|
||||||
// 报表统计组(M4 ⑤):FR-030/031 限领导/财务/管理员;admin 须显式
|
// 报表统计组(M4 ⑤):FR-030/031 限领导/财务/管理员;admin 须显式
|
||||||
// 列入 allowedRoles(项目既定规则,admin 不天然放行)
|
// 列入 allowedRoles(项目既定规则,admin 不天然放行)
|
||||||
@@ -173,6 +193,7 @@ const routes: RouteRecordRaw[] = [
|
|||||||
component: () => import('@/views/reports/personal-detail/index.vue'),
|
component: () => import('@/views/reports/personal-detail/index.vue'),
|
||||||
meta: {
|
meta: {
|
||||||
title: '个人绩效明细',
|
title: '个人绩效明细',
|
||||||
|
icon: 'report-personal',
|
||||||
group: '报表统计',
|
group: '报表统计',
|
||||||
order: 19,
|
order: 19,
|
||||||
allowedRoles: [ROLE_ADMIN, 'finance', 'leader'],
|
allowedRoles: [ROLE_ADMIN, 'finance', 'leader'],
|
||||||
@@ -184,6 +205,7 @@ const routes: RouteRecordRaw[] = [
|
|||||||
component: () => import('@/views/reports/department-summary/index.vue'),
|
component: () => import('@/views/reports/department-summary/index.vue'),
|
||||||
meta: {
|
meta: {
|
||||||
title: '部门绩效汇总',
|
title: '部门绩效汇总',
|
||||||
|
icon: 'report-department',
|
||||||
group: '报表统计',
|
group: '报表统计',
|
||||||
order: 20,
|
order: 20,
|
||||||
allowedRoles: [ROLE_ADMIN, 'finance', 'leader'],
|
allowedRoles: [ROLE_ADMIN, 'finance', 'leader'],
|
||||||
@@ -195,6 +217,7 @@ const routes: RouteRecordRaw[] = [
|
|||||||
component: () => import('@/views/reports/business-stats/index.vue'),
|
component: () => import('@/views/reports/business-stats/index.vue'),
|
||||||
meta: {
|
meta: {
|
||||||
title: '业务统计',
|
title: '业务统计',
|
||||||
|
icon: 'report-business',
|
||||||
group: '报表统计',
|
group: '报表统计',
|
||||||
order: 21,
|
order: 21,
|
||||||
allowedRoles: [ROLE_ADMIN, 'finance', 'leader'],
|
allowedRoles: [ROLE_ADMIN, 'finance', 'leader'],
|
||||||
@@ -206,6 +229,7 @@ const routes: RouteRecordRaw[] = [
|
|||||||
component: () => import('@/views/reports/payment-stats/index.vue'),
|
component: () => import('@/views/reports/payment-stats/index.vue'),
|
||||||
meta: {
|
meta: {
|
||||||
title: '回款统计',
|
title: '回款统计',
|
||||||
|
icon: 'report-payment',
|
||||||
group: '报表统计',
|
group: '报表统计',
|
||||||
order: 22,
|
order: 22,
|
||||||
allowedRoles: [ROLE_ADMIN, 'finance', 'leader'],
|
allowedRoles: [ROLE_ADMIN, 'finance', 'leader'],
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import type { RatingGradeItem } from '@/api/ratings'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 评级等级判定(纯函数,客户列表与评级弹窗共用同一口径)。
|
||||||
|
*
|
||||||
|
* 规则:按 sort_order 升序取首个「min_score ≤ 总分」的等级,max_score 仅作
|
||||||
|
* 展示 —— 单边判定避免区间边界(如 75 分)在「≥75 且 ≤89」与「>74 且 <75」
|
||||||
|
* 两套写法间的重叠/遗漏歧义。
|
||||||
|
*
|
||||||
|
* 后端 min_score 是字符串直出(分值红线:分值全程不进 float),比较前必须
|
||||||
|
* parseFloat 转数值,禁止字符串直接比大小("90.0" 的字典序比较会得出错误
|
||||||
|
* 结果)。无命中(总分低于全部下限)返回 null,由调用方展示未命中提示。
|
||||||
|
*/
|
||||||
|
export function resolveGrade(
|
||||||
|
grades: RatingGradeItem[],
|
||||||
|
totalScore: number,
|
||||||
|
): RatingGradeItem | null {
|
||||||
|
const ordered = [...grades].sort((a, b) => a.sort_order - b.sort_order)
|
||||||
|
for (const grade of ordered) {
|
||||||
|
if (totalScore >= parseFloat(grade.min_score)) {
|
||||||
|
return grade
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 等级名称 → 色调后缀(客户列表的等级徽标用)。
|
||||||
|
*
|
||||||
|
* 按名称首字母映射到体系语义色:A 优秀(绿)、B 良好(靛蓝)、C 合格
|
||||||
|
* (琥珀)、D 待改进(砖红);自定义等级名不在此列,落回中性灰 —— 等级
|
||||||
|
* 名称是业务可配置的,不能靠写死的枚举判定语义,未知即中性最安全。
|
||||||
|
*/
|
||||||
|
export function gradeTone(gradeName: string): '' | 'ok' | 'lock' | 'wait' | 'reject' {
|
||||||
|
const initial = gradeName.trim().charAt(0).toUpperCase()
|
||||||
|
switch (initial) {
|
||||||
|
case 'A':
|
||||||
|
return 'ok'
|
||||||
|
case 'B':
|
||||||
|
return 'lock'
|
||||||
|
case 'C':
|
||||||
|
return 'wait'
|
||||||
|
case 'D':
|
||||||
|
return 'reject'
|
||||||
|
default:
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,557 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, reactive, ref, watch } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
|
||||||
|
import {
|
||||||
|
createRatingGrade,
|
||||||
|
deleteRatingGrade,
|
||||||
|
listRatingGrades,
|
||||||
|
updateRatingGrade,
|
||||||
|
type RatingGradeItem,
|
||||||
|
type RatingGradeParams,
|
||||||
|
} from '@/api/ratings'
|
||||||
|
import { rateCustomer, type CustomerItem } from '@/api/customers'
|
||||||
|
import { useConfirmAction } from '@/composables/useConfirmAction'
|
||||||
|
import { formatDateTime } from '@/utils/format'
|
||||||
|
import { resolveGrade } from '@/utils/rating'
|
||||||
|
import { handledError } from '@/utils/error'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 客户评级计算器(需求 2.2.5 / 对照表 #25)· 客户管理行内弹窗版。
|
||||||
|
*
|
||||||
|
* 原独立页(/business/rating)并入客户管理:在客户行上点「评级计算」打开,
|
||||||
|
* 五项打分实时判定等级,「保存评分」把总分写入客户档案(rating_score +
|
||||||
|
* rating_time)。等级**不落库**,由分数 + 当前等级映射实时算(判定函数在
|
||||||
|
* utils/rating.ts,与列表页共用同一口径);等级映射日后调整,全部客户的
|
||||||
|
* 等级随之更新,档案只负责记住「这个分是什么时候打的」。
|
||||||
|
*
|
||||||
|
* 五项打分明细同样不落库 —— 那是打分过程的中间量,没有下游消费方。
|
||||||
|
*
|
||||||
|
* 等级映射的增删改走 /v1/business/rating-grades 维护接口,配置变更后
|
||||||
|
* 重载判定用列表 —— 判定与抽屉表格共用同一份数据。
|
||||||
|
*/
|
||||||
|
|
||||||
|
const visible = defineModel<boolean>('visible', { default: false })
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
/** 目标客户;弹窗打开期间必非空(由父页在 open 时赋值) */
|
||||||
|
customer: CustomerItem | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
/** 保存成功,带回最新 CustomerItem(父页原位更新行,不打乱当前浏览位置) */
|
||||||
|
saved: [item: CustomerItem]
|
||||||
|
/**
|
||||||
|
* 等级映射被改动(新增/编辑/删除)。
|
||||||
|
*
|
||||||
|
* 父页的等级徽标靠自己缓存的一份映射把分数翻成等级,抽屉里改完配置
|
||||||
|
* 那份缓存就旧了 —— 必须通知父页重拉,否则「同一分数在弹窗里是 B、
|
||||||
|
* 在列表里还是 C」,两处判定口径出现可见的分歧。
|
||||||
|
*/
|
||||||
|
gradesChanged: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// ==================== 评分计算(纯前端实时计算) ====================
|
||||||
|
|
||||||
|
/** 五项评分项:各 0-20 分、步进 1,总分 = 五项之和(0-100) */
|
||||||
|
const SCORE_ITEMS = [
|
||||||
|
{ key: 'ship_amount', label: '发货额' },
|
||||||
|
{ key: 'receipt_rate', label: '回款率' },
|
||||||
|
{ key: 'profit_rate', label: '毛利率' },
|
||||||
|
{ key: 'contract_cooperation', label: '合同履约配合能力' },
|
||||||
|
{ key: 'site_construction', label: '现场规范施工能力' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
type ScoreKey = (typeof SCORE_ITEMS)[number]['key']
|
||||||
|
|
||||||
|
/** el-input-number 清空后值为 undefined,字段用宽松类型,求和处 ?? 0 兜底 */
|
||||||
|
const scores = reactive<Record<ScoreKey, number | undefined>>({
|
||||||
|
ship_amount: 0,
|
||||||
|
receipt_rate: 0,
|
||||||
|
profit_rate: 0,
|
||||||
|
contract_cooperation: 0,
|
||||||
|
site_construction: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const totalScore = computed(() =>
|
||||||
|
SCORE_ITEMS.reduce((sum, item) => sum + (scores[item.key] ?? 0), 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 重置即把五项清零(不动档案里已保存的评分) */
|
||||||
|
function resetScores(): void {
|
||||||
|
for (const item of SCORE_ITEMS) {
|
||||||
|
scores[item.key] = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 等级映射(判定用列表) ====================
|
||||||
|
|
||||||
|
const { confirmAndRun } = useConfirmAction()
|
||||||
|
|
||||||
|
const grades = ref<RatingGradeItem[]>([])
|
||||||
|
const gradesLoading = ref(false)
|
||||||
|
|
||||||
|
/** 判定与抽屉表格共用同一份全量列表,配置变更后重载一次即同时生效 */
|
||||||
|
async function loadGrades(): Promise<void> {
|
||||||
|
gradesLoading.value = true
|
||||||
|
try {
|
||||||
|
grades.value = await listRatingGrades()
|
||||||
|
} catch (error) {
|
||||||
|
handledError(error)
|
||||||
|
} finally {
|
||||||
|
gradesLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 配置被改动后的统一收尾:重载本弹窗的判定列表 + 通知父页刷新徽标缓存 */
|
||||||
|
async function reloadAfterGradeChange(): Promise<void> {
|
||||||
|
await loadGrades()
|
||||||
|
emit('gradesChanged')
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchedGrade = computed(() => resolveGrade(grades.value, totalScore.value))
|
||||||
|
|
||||||
|
// ==================== 弹窗打开 / 关闭 ====================
|
||||||
|
|
||||||
|
/** 打开时重置五项并重载等级配置,避免上一次打分的残留 */
|
||||||
|
watch(visible, (isOpen) => {
|
||||||
|
if (!isOpen) return
|
||||||
|
resetScores()
|
||||||
|
void loadGrades()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ==================== 保存评分 ====================
|
||||||
|
|
||||||
|
const saving = ref(false)
|
||||||
|
|
||||||
|
/** 档案里已保存的评分(打开时的快照,仅展示用;保存成功后由父页更新行数据) */
|
||||||
|
const archivedScore = computed(() => {
|
||||||
|
const target = props.customer
|
||||||
|
if (!target || target.rating_score === null) return null
|
||||||
|
// 等级不落库,按当前映射由已存分数实时算(与列表页同一口径)
|
||||||
|
const grade = resolveGrade(grades.value, target.rating_score)
|
||||||
|
return {
|
||||||
|
score: target.rating_score,
|
||||||
|
grade: grade?.grade_name ?? null,
|
||||||
|
time: target.rating_time ? formatDateTime(target.rating_time) : '',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function handleSave(): Promise<void> {
|
||||||
|
const target = props.customer
|
||||||
|
if (!target) return
|
||||||
|
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
const item = await rateCustomer(target.id, { score: totalScore.value })
|
||||||
|
ElMessage.success('评级已保存')
|
||||||
|
emit('saved', item)
|
||||||
|
visible.value = false
|
||||||
|
} catch (error) {
|
||||||
|
handledError(error) // 失败提示由请求拦截器统一完成
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 等级映射设置抽屉 ====================
|
||||||
|
|
||||||
|
const drawerVisible = ref(false)
|
||||||
|
|
||||||
|
/** 打开时重新拉取,避免展示陈旧数据 */
|
||||||
|
function openDrawer(): void {
|
||||||
|
drawerVisible.value = true
|
||||||
|
void loadGrades()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 新增 / 编辑等级弹窗 ====================
|
||||||
|
|
||||||
|
const editVisible = ref(false)
|
||||||
|
/** null = 新增,否则为编辑目标 id */
|
||||||
|
const editingId = ref<number | null>(null)
|
||||||
|
const editSaving = ref(false)
|
||||||
|
const isCreate = computed(() => editingId.value === null)
|
||||||
|
|
||||||
|
const editForm = reactive({
|
||||||
|
grade_name: '',
|
||||||
|
min_score: undefined as number | undefined,
|
||||||
|
max_score: undefined as number | undefined,
|
||||||
|
sort_order: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
function openCreate(): void {
|
||||||
|
editingId.value = null
|
||||||
|
editForm.grade_name = ''
|
||||||
|
editForm.min_score = undefined
|
||||||
|
editForm.max_score = undefined
|
||||||
|
editForm.sort_order = 0
|
||||||
|
editVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(row: RatingGradeItem): void {
|
||||||
|
editingId.value = row.id
|
||||||
|
editForm.grade_name = row.grade_name
|
||||||
|
// 后端字符串直出,回填 parseFloat 转数值,保存时再 String() 转回字符串
|
||||||
|
editForm.min_score = parseFloat(row.min_score)
|
||||||
|
editForm.max_score = parseFloat(row.max_score)
|
||||||
|
editForm.sort_order = row.sort_order
|
||||||
|
editVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 前端值域校验(0≤min≤max≤100);后端在审计上下文内兜底同规则与区间重叠 */
|
||||||
|
function editFormError(minScore: number | undefined, maxScore: number | undefined): string | null {
|
||||||
|
if (!editForm.grade_name.trim()) return '请输入等级名称'
|
||||||
|
if (minScore === undefined || maxScore === undefined) return '请输入分值区间'
|
||||||
|
if (minScore < 0 || maxScore > 100 || minScore > maxScore) {
|
||||||
|
return '分值区间须满足 0 ≤ 下限 ≤ 上限 ≤ 100'
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveGrade(): Promise<void> {
|
||||||
|
// 局部变量收窄类型:reactive 属性在异步流程中不保证保持窄化
|
||||||
|
const minScore = editForm.min_score
|
||||||
|
const maxScore = editForm.max_score
|
||||||
|
const error = editFormError(minScore, maxScore)
|
||||||
|
if (error) {
|
||||||
|
ElMessage.warning(error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload: RatingGradeParams = {
|
||||||
|
grade_name: editForm.grade_name.trim(),
|
||||||
|
min_score: String(minScore),
|
||||||
|
max_score: String(maxScore),
|
||||||
|
sort_order: editForm.sort_order ?? 0,
|
||||||
|
}
|
||||||
|
// 后端为全量替换语义;remark 本组件不维护,编辑时原样带回避免误清
|
||||||
|
if (editingId.value !== null) {
|
||||||
|
payload.remark = grades.value.find((g) => g.id === editingId.value)?.remark ?? undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
editSaving.value = true
|
||||||
|
try {
|
||||||
|
if (editingId.value === null) {
|
||||||
|
await createRatingGrade(payload)
|
||||||
|
} else {
|
||||||
|
await updateRatingGrade(editingId.value, payload)
|
||||||
|
}
|
||||||
|
editVisible.value = false
|
||||||
|
ElMessage.success(isCreate.value ? '评级等级已创建' : '已保存')
|
||||||
|
await reloadAfterGradeChange()
|
||||||
|
} catch (requestError) {
|
||||||
|
handledError(requestError)
|
||||||
|
} finally {
|
||||||
|
editSaving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除走确认动作流;文案明确后果(该区间不再能映射等级) */
|
||||||
|
function removeGrade(row: RatingGradeItem): void {
|
||||||
|
void confirmAndRun({
|
||||||
|
confirmText: `确定删除等级「${row.grade_name}」(${row.min_score} ~ ${row.max_score})?删除后该分数区间将无法映射等级。`,
|
||||||
|
title: '删除确认',
|
||||||
|
confirmButtonText: '删除',
|
||||||
|
successText: '评级等级已删除',
|
||||||
|
run: () => deleteRatingGrade(row.id),
|
||||||
|
after: () => reloadAfterGradeChange(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<el-dialog
|
||||||
|
v-model="visible"
|
||||||
|
:title="`客户评级 · ${customer?.company_name ?? ''}`"
|
||||||
|
width="680px"
|
||||||
|
destroy-on-close
|
||||||
|
>
|
||||||
|
<!-- 对照表 #25:口径提示改写 —— 评分现在会落库,但仍不参与绩效金额 -->
|
||||||
|
<el-alert
|
||||||
|
class="dialog-alert"
|
||||||
|
title="评分保存为客户档案,只用于客户评级展示,不参与绩效金额计算"
|
||||||
|
type="warning"
|
||||||
|
:closable="false"
|
||||||
|
show-icon
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div v-if="archivedScore" class="archived-line">
|
||||||
|
档案评分:<b>{{ archivedScore.score }}</b> 分
|
||||||
|
<template v-if="archivedScore.grade">
|
||||||
|
· 等级 <b>{{ archivedScore.grade }}</b>
|
||||||
|
</template>
|
||||||
|
<span v-if="archivedScore.time" class="archived-time">({{ archivedScore.time }} 保存)</span>
|
||||||
|
<span class="archived-tip">重新保存将覆盖</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="calc-grid">
|
||||||
|
<div class="calc-form">
|
||||||
|
<div class="section-title">评分项(各 0-20 分,步进 1)</div>
|
||||||
|
<div v-for="item in SCORE_ITEMS" :key="item.key" class="score-row">
|
||||||
|
<span class="score-label">{{ item.label }}</span>
|
||||||
|
<el-input-number
|
||||||
|
v-model="scores[item.key]"
|
||||||
|
:min="0"
|
||||||
|
:max="20"
|
||||||
|
:step="1"
|
||||||
|
controls-position="right"
|
||||||
|
/>
|
||||||
|
<span class="score-unit">分</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="calc-result">
|
||||||
|
<div class="section-title">判定结果</div>
|
||||||
|
<div>
|
||||||
|
<span class="result-label">总分</span>
|
||||||
|
<b class="total-num">{{ totalScore }}</b>
|
||||||
|
<span class="total-max">/ 100</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="result-label">评级等级</span>
|
||||||
|
<template v-if="grades.length === 0">
|
||||||
|
<div class="grade-name">—</div>
|
||||||
|
<div class="grade-hint">未配置等级映射,仍可保存总分</div>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="matchedGrade">
|
||||||
|
<div class="grade-name">{{ matchedGrade.grade_name }}</div>
|
||||||
|
<div class="grade-hint">
|
||||||
|
分值区间 {{ matchedGrade.min_score }} ~ {{ matchedGrade.max_score }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<div class="grade-name">—</div>
|
||||||
|
<div class="grade-hint">总分低于全部等级下限,未命中等级</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="resetScores">重置</el-button>
|
||||||
|
<el-button @click="openDrawer">等级映射设置</el-button>
|
||||||
|
<el-button type="primary" :loading="saving" @click="handleSave">保存评分</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- 等级映射设置抽屉(评级弹窗的二级层,append-to-body 保证遮罩层级正确) -->
|
||||||
|
<el-drawer v-model="drawerVisible" title="等级映射设置" size="640px" append-to-body>
|
||||||
|
<div class="drawer-hint">
|
||||||
|
判定规则:按排序升序取首个「下限 ≤ 总分」的等级,上限仅作展示;修改后评级区立即按新配置判定。
|
||||||
|
</div>
|
||||||
|
<div class="drawer-toolbar">
|
||||||
|
<el-button type="primary" size="small" @click="openCreate">新增等级</el-button>
|
||||||
|
</div>
|
||||||
|
<el-table v-loading="gradesLoading" :data="grades" size="small">
|
||||||
|
<el-table-column prop="grade_name" label="等级名称" min-width="110" />
|
||||||
|
<el-table-column prop="min_score" label="下限(含)" width="100" align="center" />
|
||||||
|
<el-table-column prop="max_score" label="上限" width="90" align="center" />
|
||||||
|
<el-table-column prop="sort_order" label="排序" width="70" align="center" />
|
||||||
|
<el-table-column label="操作" width="110" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="table-actions">
|
||||||
|
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
|
||||||
|
<span class="table-actions__divider" aria-hidden="true"></span>
|
||||||
|
<el-button link type="danger" size="small" @click="removeGrade(row)">删除</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-drawer>
|
||||||
|
|
||||||
|
<!-- 新增 / 编辑等级 -->
|
||||||
|
<el-dialog
|
||||||
|
v-model="editVisible"
|
||||||
|
:title="isCreate ? '新增评级等级' : '编辑评级等级'"
|
||||||
|
width="440px"
|
||||||
|
destroy-on-close
|
||||||
|
append-to-body
|
||||||
|
@closed="editingId = null"
|
||||||
|
>
|
||||||
|
<el-form label-width="90px" @submit.prevent>
|
||||||
|
<el-form-item label="等级名称" required>
|
||||||
|
<el-input v-model="editForm.grade_name" maxlength="32" placeholder="如:A / 优秀" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="下限(含)" required>
|
||||||
|
<el-input-number
|
||||||
|
v-model="editForm.min_score"
|
||||||
|
:min="0"
|
||||||
|
:max="100"
|
||||||
|
:step="5"
|
||||||
|
controls-position="right"
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="上限" required>
|
||||||
|
<el-input-number
|
||||||
|
v-model="editForm.max_score"
|
||||||
|
:min="0"
|
||||||
|
:max="100"
|
||||||
|
:step="5"
|
||||||
|
controls-position="right"
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="排序">
|
||||||
|
<el-input-number
|
||||||
|
v-model="editForm.sort_order"
|
||||||
|
:min="0"
|
||||||
|
:step="10"
|
||||||
|
controls-position="right"
|
||||||
|
/>
|
||||||
|
<span class="form-tip">升序判定,越小越先判定</span>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<div class="form-tip-block">
|
||||||
|
分值区间须满足 0 ≤ 下限 ≤ 上限 ≤ 100;区间重叠、名称重复由后端校验兜底。
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="editVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="editSaving" @click="saveGrade">保存</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* 版式皮肤(.card/.section-title/按钮/表格)由全局 index.css 提供,这里只留弹窗特有排版 */
|
||||||
|
|
||||||
|
.dialog-alert {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 档案评分快照行:一眼看出这是覆盖式保存 */
|
||||||
|
.archived-line {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--ys-ink-2);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archived-time {
|
||||||
|
color: var(--ys-ink-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.archived-tip {
|
||||||
|
margin-left: 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ys-ink-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calc-grid {
|
||||||
|
display: flex;
|
||||||
|
gap: 28px;
|
||||||
|
align-items: stretch;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calc-form {
|
||||||
|
flex: 1 1 320px;
|
||||||
|
min-width: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
line-height: 2.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-label {
|
||||||
|
width: 140px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--ys-ink);
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-unit {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ys-ink-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calc-result {
|
||||||
|
flex: 0 0 220px;
|
||||||
|
min-width: 200px;
|
||||||
|
border: 1px solid var(--ys-line);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 18px 20px;
|
||||||
|
background: linear-gradient(180deg, #ffffff, #fcfcfe);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 12px;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--ys-ink-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.total-num {
|
||||||
|
font-size: 36px;
|
||||||
|
color: var(--ys-indigo);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.total-max {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--ys-ink-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grade-name {
|
||||||
|
font-family: var(--ys-font-brand);
|
||||||
|
font-size: 26px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ys-ink);
|
||||||
|
line-height: 1.3;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grade-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ys-ink-3);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ys-ink-3);
|
||||||
|
background: var(--ys-mist);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-toolbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-tip {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ys-ink-3);
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-tip-block {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ys-ink-3);
|
||||||
|
background: var(--ys-mist);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 移动端:标签左对齐、结果块自然换行到表单下方 */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.score-label {
|
||||||
|
width: auto;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calc-result {
|
||||||
|
flex: 1 1 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -5,11 +5,15 @@ import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
|||||||
import * as customerApi from '@/api/customers'
|
import * as customerApi from '@/api/customers'
|
||||||
import type { CustomerItem, OwnershipLogItem } from '@/api/customers'
|
import type { CustomerItem, OwnershipLogItem } from '@/api/customers'
|
||||||
import * as personnelApi from '@/api/personnel'
|
import * as personnelApi from '@/api/personnel'
|
||||||
|
import { listRatingGrades, type RatingGradeItem } from '@/api/ratings'
|
||||||
import AppPagination from '@/components/table/AppPagination.vue'
|
import AppPagination from '@/components/table/AppPagination.vue'
|
||||||
import { useConfirmAction } from '@/composables/useConfirmAction'
|
import { useConfirmAction } from '@/composables/useConfirmAction'
|
||||||
|
import { useDropdownOptions } from '@/composables/useDropdownOptions'
|
||||||
import { usePagedList } from '@/composables/usePagedList'
|
import { usePagedList } from '@/composables/usePagedList'
|
||||||
import { formatDateTime } from '@/utils/format'
|
import { formatDateTime } from '@/utils/format'
|
||||||
|
import { gradeTone, resolveGrade } from '@/utils/rating'
|
||||||
import { handledError } from '@/utils/error'
|
import { handledError } from '@/utils/error'
|
||||||
|
import RatingCalculatorDialog from './RatingCalculatorDialog.vue'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 客户管理(需求 2.1.3:客户档案 + 归属人员 + 归属变更留痕)。
|
* 客户管理(需求 2.1.3:客户档案 + 归属人员 + 归属变更留痕)。
|
||||||
@@ -20,6 +24,9 @@ import { handledError } from '@/utils/error'
|
|||||||
* - 归属变更提交前二次确认,文案必须包含「仅影响变更后核算,不回溯历史」
|
* - 归属变更提交前二次确认,文案必须包含「仅影响变更后核算,不回溯历史」
|
||||||
* (对照表 #19 的业务口径)
|
* (对照表 #19 的业务口径)
|
||||||
* - 归属变更记录为分页留痕表(抽屉内展示,按变更时间倒序)
|
* - 归属变更记录为分页留痕表(抽屉内展示,按变更时间倒序)
|
||||||
|
* - 评级计算器并入本页(原 /business/rating 独立页已移除):行内弹窗打分,
|
||||||
|
* 保存评分只写档案字段 rating_score / rating_time;等级不落库,由分数按
|
||||||
|
* 当前等级映射实时算(utils/rating.ts 与弹窗共用同一判定函数)
|
||||||
*/
|
*/
|
||||||
const { confirmAndRun } = useConfirmAction()
|
const { confirmAndRun } = useConfirmAction()
|
||||||
|
|
||||||
@@ -38,6 +45,8 @@ const { records, total, pageNum, pageSize, loading, load, search, onPageChange,
|
|||||||
keyword: filters.keyword,
|
keyword: filters.keyword,
|
||||||
customer_type: filters.customer_type,
|
customer_type: filters.customer_type,
|
||||||
owner_personnel_id: filters.owner_personnel_id,
|
owner_personnel_id: filters.owner_personnel_id,
|
||||||
|
order_by: sortState.order_by,
|
||||||
|
order: sortState.order,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -48,6 +57,96 @@ function resetFilters(): void {
|
|||||||
search()
|
search()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== 排序(评分列) ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 表头排序是查询参数而非本地排序:分页下本地排序只能排当前页。
|
||||||
|
* 目前唯一可排序列是评分;el-table 第三次点击取消排序,回到建档序(id 倒序)。
|
||||||
|
*/
|
||||||
|
const sortState = reactive<{ order_by: 'id' | 'rating_score'; order: 'asc' | 'desc' }>({
|
||||||
|
order_by: 'id',
|
||||||
|
order: 'desc',
|
||||||
|
})
|
||||||
|
|
||||||
|
function onSortChange({
|
||||||
|
prop,
|
||||||
|
order,
|
||||||
|
}: {
|
||||||
|
prop: string
|
||||||
|
order: 'ascending' | 'descending' | null
|
||||||
|
}): void {
|
||||||
|
if (prop === 'rating_score' && order) {
|
||||||
|
sortState.order_by = 'rating_score'
|
||||||
|
sortState.order = order === 'ascending' ? 'asc' : 'desc'
|
||||||
|
} else {
|
||||||
|
sortState.order_by = 'id'
|
||||||
|
sortState.order = 'desc'
|
||||||
|
}
|
||||||
|
search()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 评级计算 ====================
|
||||||
|
|
||||||
|
const ratingVisible = ref(false)
|
||||||
|
const ratingTarget = ref<CustomerItem | null>(null)
|
||||||
|
|
||||||
|
function openRating(row: CustomerItem): void {
|
||||||
|
ratingTarget.value = row
|
||||||
|
ratingVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 保存评分后回写列表:按评分排序时刷新(位置随分变化),否则原位更新不打乱浏览 */
|
||||||
|
function onRatingSaved(item: CustomerItem): void {
|
||||||
|
if (sortState.order_by === 'rating_score') {
|
||||||
|
void load()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const index = records.value.findIndex((record) => record.id === item.id)
|
||||||
|
if (index !== -1) records.value[index] = item
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 评级列(等级 + 分数) ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 等级映射只读列表:列表页的等级徽标要靠它把分数翻成等级。
|
||||||
|
*
|
||||||
|
* 等级不落库(用户口径:等级在前端算),所以每行渲染都要用当前映射;
|
||||||
|
* 拉一次缓存在内存里,避免逐行请求。配置在评级弹窗的抽屉里改完会回调
|
||||||
|
* reloadGrades,列表徽标随之更新。
|
||||||
|
*/
|
||||||
|
const grades = ref<RatingGradeItem[]>([])
|
||||||
|
|
||||||
|
async function loadGrades(): Promise<void> {
|
||||||
|
try {
|
||||||
|
grades.value = await listRatingGrades()
|
||||||
|
} catch (error) {
|
||||||
|
// 等级配置拉取失败不该挡住客户列表:徽标退化为只显示分数
|
||||||
|
handledError(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 分数 → 等级名(未命中等级时返回「未定级」,不显示空白) */
|
||||||
|
function gradeLabel(score: number): string {
|
||||||
|
return resolveGrade(grades.value, score)?.grade_name ?? '未定级'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 等级色调后缀(A 绿 / B 靛蓝 / C 琥珀 / D 砖红 / 自定义中性) */
|
||||||
|
function gradeNameClass(score: number): string {
|
||||||
|
const grade = resolveGrade(grades.value, score)
|
||||||
|
if (!grade) return ''
|
||||||
|
const tone = gradeTone(grade.grade_name)
|
||||||
|
return tone ? `grade__name--${tone}` : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 悬浮提示:等级区间 + 评级时间,回答「这个等级是怎么来的、什么时候打的」 */
|
||||||
|
function ratingTitle(row: CustomerItem): string {
|
||||||
|
if (row.rating_score === null) return '尚未评级'
|
||||||
|
const grade = resolveGrade(grades.value, row.rating_score)
|
||||||
|
const range = grade ? `${grade.grade_name} 档(${grade.min_score} ~ ${grade.max_score} 分)` : '未命中任何等级档'
|
||||||
|
const time = row.rating_time ? `,${formatDateTime(row.rating_time)} 评定` : ''
|
||||||
|
return `${range}${time};重新评级请点行内「评级计算」`
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== 归属人员下拉数据源 ====================
|
// ==================== 归属人员下拉数据源 ====================
|
||||||
|
|
||||||
interface PersonnelOption {
|
interface PersonnelOption {
|
||||||
@@ -56,18 +155,14 @@ interface PersonnelOption {
|
|||||||
status: number
|
status: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const ownerOptions = ref<PersonnelOption[]>([])
|
const { options: ownerOptions, load: loadOwnerOptions } = useDropdownOptions<PersonnelOption>(
|
||||||
|
async () => {
|
||||||
async function loadOwnerOptions(): Promise<void> {
|
|
||||||
try {
|
|
||||||
// 归属下拉的近似全量数据源:内部系统人员规模有限,200 条足够;
|
// 归属下拉的近似全量数据源:内部系统人员规模有限,200 条足够;
|
||||||
// 极端场景由后端对归属合法性的校验兜底
|
// 极端场景由后端对归属合法性的校验兜底
|
||||||
const data = await personnelApi.listPersonnel({ pageNum: 1, pageSize: 200 })
|
const data = await personnelApi.listPersonnel({ pageNum: 1, pageSize: 200 })
|
||||||
ownerOptions.value = data.list.map((p) => ({ id: p.id, name: p.personnel_name, status: p.status }))
|
return data.list.map((p) => ({ id: p.id, name: p.personnel_name, status: p.status }))
|
||||||
} catch (error) {
|
},
|
||||||
handledError(error) // 下拉为空不阻断列表使用
|
)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 下拉项展示名:离职人员加后缀标注(仍可被选作归属吗?由后端校验兜底) */
|
/** 下拉项展示名:离职人员加后缀标注(仍可被选作归属吗?由后端校验兜底) */
|
||||||
function ownerLabel(option: PersonnelOption): string {
|
function ownerLabel(option: PersonnelOption): string {
|
||||||
@@ -314,16 +409,29 @@ async function handleDelete(row: CustomerItem): Promise<void> {
|
|||||||
// ==================== 初始化 ====================
|
// ==================== 初始化 ====================
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
// 两个请求各自容错(load 内部已兜底),互不拖累
|
// 三个请求各自容错(load 内部已兜底),互不拖累
|
||||||
void load()
|
void load()
|
||||||
void loadOwnerOptions()
|
void loadOwnerOptions()
|
||||||
|
// 等级徽标要用等级映射把分数翻成等级,先拉一次;失败只影响徽标,不影响列表
|
||||||
|
void loadGrades()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<el-card shadow="never">
|
<div class="page-head">
|
||||||
<el-form class="filter-form" :inline="true" @submit.prevent>
|
<h1 class="page-title">客户管理</h1>
|
||||||
|
<p class="page-desc">
|
||||||
|
客户档案与归属人员;归属变更只影响变更后的核算、不回溯历史,评分与等级由「评级计算」维护并保存进档案
|
||||||
|
</p>
|
||||||
|
<span class="waterline"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
.filter-bar 直接挂在 el-form 上:el-form 不是 flex 透明容器,
|
||||||
|
外面再包一层 div 会让 el-form-item 丢掉 gap 排版,筛选条退化成竖排。
|
||||||
|
-->
|
||||||
|
<el-form class="filter-bar" @submit.prevent>
|
||||||
<el-form-item label="关键词">
|
<el-form-item label="关键词">
|
||||||
<el-input
|
<el-input
|
||||||
v-model="filters.keyword"
|
v-model="filters.keyword"
|
||||||
@@ -359,24 +467,69 @@ onMounted(() => {
|
|||||||
/>
|
/>
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item>
|
<div class="filter-bar__actions">
|
||||||
<el-button type="primary" @click="search">查询</el-button>
|
<el-button type="primary" @click="search">查询</el-button>
|
||||||
<el-button @click="resetFilters">重置</el-button>
|
<el-button @click="resetFilters">重置</el-button>
|
||||||
</el-form-item>
|
</div>
|
||||||
</el-form>
|
</el-form>
|
||||||
|
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<el-button type="primary" @click="openCreate">新增客户</el-button>
|
<el-button type="primary" @click="openCreate">新增客户</el-button>
|
||||||
|
<span class="toolbar-note">归属调整走行内「归属变更」,系统自动留痕</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-table v-loading="loading" :data="records" stripe>
|
<!-- ys-rise:整页只给列表卡一次入场上浮,筛选与页头立即呈现,避免整屏一起动 -->
|
||||||
|
<div class="card ys-rise">
|
||||||
|
<el-table
|
||||||
|
v-loading="loading"
|
||||||
|
:data="records"
|
||||||
|
stripe
|
||||||
|
empty-text="还没有客户 —— 点「新增客户」建第一个,或换个关键词再查"
|
||||||
|
@sort-change="onSortChange"
|
||||||
|
>
|
||||||
<el-table-column prop="company_name" label="公司名称" min-width="180" />
|
<el-table-column prop="company_name" label="公司名称" min-width="180" />
|
||||||
|
<!--
|
||||||
|
评级列 = 等级 + 分数,纯文字两级层次。
|
||||||
|
等级不落库,按当前等级映射由已存分数实时算(与评级弹窗同一判定函数
|
||||||
|
utils/rating.ts)。
|
||||||
|
|
||||||
|
形态刻意既不用 .ys-pill 也不用色块:全站胶囊是「状态」的语汇(草稿/
|
||||||
|
待审/已锁定/已驳回,都带圆点和胶囊边),评级是档案属性,套状态形态
|
||||||
|
会让两种信息混读;色块则会在每行右侧堆一片彩色矩形,表格看起来像
|
||||||
|
贴了标签墙。这里只用字重与语义色分层 —— 等级立起来(13px/600/语义色),
|
||||||
|
分数退到弱墨等宽(13px/tabular-nums),一行读完「B 级 82 分」,
|
||||||
|
整列数字竖向对齐可扫。
|
||||||
|
排序仍挂在 rating_score 上。
|
||||||
|
-->
|
||||||
|
<el-table-column
|
||||||
|
prop="rating_score"
|
||||||
|
label="评级"
|
||||||
|
width="96"
|
||||||
|
align="center"
|
||||||
|
sortable="custom"
|
||||||
|
>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span v-if="row.rating_score !== null" class="grade" :title="ratingTitle(row)">
|
||||||
|
<span class="grade__name" :class="gradeNameClass(row.rating_score)">
|
||||||
|
{{ gradeLabel(row.rating_score) }}
|
||||||
|
</span>
|
||||||
|
<span class="grade__score">{{ row.rating_score }}</span>
|
||||||
|
</span>
|
||||||
|
<span v-else class="cell-muted" title="尚未评级">—</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column prop="owner_name" label="负责人" min-width="90" />
|
<el-table-column prop="owner_name" label="负责人" min-width="90" />
|
||||||
<el-table-column prop="contact_person" label="联系人" min-width="90">
|
<el-table-column prop="contact_person" label="联系人" min-width="90">
|
||||||
<template #default="{ row }">{{ row.contact_person ?? '—' }}</template>
|
<template #default="{ row }">
|
||||||
|
<span v-if="row.contact_person">{{ row.contact_person }}</span>
|
||||||
|
<span v-else class="cell-muted">—</span>
|
||||||
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="contact_phone" label="电话" min-width="120">
|
<el-table-column prop="contact_phone" label="电话" min-width="120">
|
||||||
<template #default="{ row }">{{ row.contact_phone ?? '—' }}</template>
|
<template #default="{ row }">
|
||||||
|
<span v-if="row.contact_phone">{{ row.contact_phone }}</span>
|
||||||
|
<span v-else class="cell-muted">—</span>
|
||||||
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="分类" width="110" align="center">
|
<el-table-column label="分类" width="110" align="center">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
@@ -396,11 +549,15 @@ onMounted(() => {
|
|||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="归属人员" min-width="110">
|
<el-table-column label="归属人员" min-width="110">
|
||||||
<template #default="{ row }">{{ row.owner_personnel_name ?? '(无归属)' }}</template>
|
<template #default="{ row }">
|
||||||
|
<span v-if="row.owner_personnel_name">{{ row.owner_personnel_name }}</span>
|
||||||
|
<span v-else class="cell-muted">(无归属)</span>
|
||||||
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="260" fixed="right" align="center">
|
<el-table-column label="操作" width="340" fixed="right" align="center">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div class="table-actions">
|
<div class="table-actions">
|
||||||
|
<el-button link type="primary" size="small" @click="openRating(row)">评级计算</el-button>
|
||||||
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
|
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
|
||||||
<el-button link type="primary" size="small" @click="openOwnership(row)">
|
<el-button link type="primary" size="small" @click="openOwnership(row)">
|
||||||
归属变更
|
归属变更
|
||||||
@@ -413,6 +570,7 @@ onMounted(() => {
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
|
||||||
|
<div class="card-foot">
|
||||||
<AppPagination
|
<AppPagination
|
||||||
:total="total"
|
:total="total"
|
||||||
:page-num="pageNum"
|
:page-num="pageNum"
|
||||||
@@ -420,7 +578,8 @@ onMounted(() => {
|
|||||||
@page-change="onPageChange"
|
@page-change="onPageChange"
|
||||||
@size-change="onSizeChange"
|
@size-change="onSizeChange"
|
||||||
/>
|
/>
|
||||||
</el-card>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 新增 / 编辑(编辑不含归属,归属走独立按钮保证留痕) -->
|
<!-- 新增 / 编辑(编辑不含归属,归属走独立按钮保证留痕) -->
|
||||||
<el-dialog
|
<el-dialog
|
||||||
@@ -532,7 +691,13 @@ onMounted(() => {
|
|||||||
|
|
||||||
<!-- 归属变更记录(分页留痕,按变更时间倒序) -->
|
<!-- 归属变更记录(分页留痕,按变更时间倒序) -->
|
||||||
<el-drawer v-model="logsVisible" :title="logsTitle" size="760px" destroy-on-close>
|
<el-drawer v-model="logsVisible" :title="logsTitle" size="760px" destroy-on-close>
|
||||||
<el-table v-loading="logsLoading" :data="logRecords" stripe size="small">
|
<el-table
|
||||||
|
v-loading="logsLoading"
|
||||||
|
:data="logRecords"
|
||||||
|
stripe
|
||||||
|
size="small"
|
||||||
|
empty-text="这个客户还没有归属变更记录 —— 调整一次归属后这里就会出现留痕"
|
||||||
|
>
|
||||||
<el-table-column label="变更时间" width="160">
|
<el-table-column label="变更时间" width="160">
|
||||||
<template #default="{ row }">{{ formatDateTime(row.create_time) }}</template>
|
<template #default="{ row }">{{ formatDateTime(row.create_time) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
@@ -552,6 +717,14 @@ onMounted(() => {
|
|||||||
@size-change="onLogsSizeChange"
|
@size-change="onLogsSizeChange"
|
||||||
/>
|
/>
|
||||||
</el-drawer>
|
</el-drawer>
|
||||||
|
|
||||||
|
<!-- 评级计算器(原 /business/rating 独立页并入;保存评分回写档案) -->
|
||||||
|
<RatingCalculatorDialog
|
||||||
|
v-model:visible="ratingVisible"
|
||||||
|
:customer="ratingTarget"
|
||||||
|
@saved="onRatingSaved"
|
||||||
|
@grades-changed="loadGrades"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -564,4 +737,52 @@ onMounted(() => {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 1.7;
|
line-height: 1.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 评级列 = 等级 + 分数,纯文字两级层次(无底色、无边框、无胶囊)。
|
||||||
|
*
|
||||||
|
* 为什么不用胶囊也不用色块:全站胶囊(.ys-pill)是「状态」的语汇 —— 草稿/
|
||||||
|
* 待审/已锁定/已驳回,都带圆点和胶囊边;评级是档案属性不是流程状态,套状态
|
||||||
|
* 形态会让两种信息混读。色块方案在实测里会在每行右侧堆一片彩色矩形,表格
|
||||||
|
* 像贴了标签墙,且「A 档」这类两字自定义等级名一撑就变形。
|
||||||
|
*
|
||||||
|
* 改成纯文字分层:等级 13px/600 用语义色立起来(这是几档),分数 13px 弱墨
|
||||||
|
* 等宽跟在后面(依据多少)。零装饰元素,行高不受影响,等级名多长都不会挤。
|
||||||
|
*/
|
||||||
|
.grade {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 6px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grade__name {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ys-ink-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 四档语义色:只改字色,不加底色 —— 表格里色块过多会压过数据本身 */
|
||||||
|
.grade__name--ok {
|
||||||
|
color: var(--ys-ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grade__name--lock {
|
||||||
|
color: var(--ys-indigo);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grade__name--wait {
|
||||||
|
color: var(--ys-warn);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grade__name--reject {
|
||||||
|
color: var(--ys-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 分数:等宽数字保证整列竖向对齐,弱墨让视线先落在等级上 */
|
||||||
|
.grade__score {
|
||||||
|
font-size: 13px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: var(--ys-ink-3);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -8,6 +8,7 @@ import type { PersonnelItem } from '@/api/personnel'
|
|||||||
import AppPagination from '@/components/table/AppPagination.vue'
|
import AppPagination from '@/components/table/AppPagination.vue'
|
||||||
import StatusTag from '@/components/table/StatusTag.vue'
|
import StatusTag from '@/components/table/StatusTag.vue'
|
||||||
import { useConfirmAction } from '@/composables/useConfirmAction'
|
import { useConfirmAction } from '@/composables/useConfirmAction'
|
||||||
|
import { useDropdownOptions } from '@/composables/useDropdownOptions'
|
||||||
import { usePagedList } from '@/composables/usePagedList'
|
import { usePagedList } from '@/composables/usePagedList'
|
||||||
import { useEditingRow } from '@/composables/useEditingRow'
|
import { useEditingRow } from '@/composables/useEditingRow'
|
||||||
import { AMOUNT_PATTERN } from '@/utils/validate'
|
import { AMOUNT_PATTERN } from '@/utils/validate'
|
||||||
@@ -64,29 +65,22 @@ interface SelectOption {
|
|||||||
status: number
|
status: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const roleOptions = ref<SelectOption[]>([])
|
const { options: roleOptions, load: loadRoleOptions } = useDropdownOptions<SelectOption>(
|
||||||
const managerOptions = ref<SelectOption[]>([])
|
async () => {
|
||||||
|
|
||||||
async function loadRoleOptions(): Promise<void> {
|
|
||||||
try {
|
|
||||||
// 岗位规模有限(内置 3 个 + 自定义),选项接口一次取全
|
// 岗位规模有限(内置 3 个 + 自定义),选项接口一次取全
|
||||||
const data = await businessRoleApi.listBusinessRoleOptions()
|
const data = await businessRoleApi.listBusinessRoleOptions()
|
||||||
roleOptions.value = data.map((r) => ({ id: r.id, name: r.role_name, status: r.status }))
|
return data.map((r) => ({ id: r.id, name: r.role_name, status: r.status }))
|
||||||
} catch (error) {
|
},
|
||||||
handledError(error) // 下拉为空不阻断列表使用
|
)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadManagerOptions(): Promise<void> {
|
const { options: managerOptions, load: loadManagerOptions } = useDropdownOptions<SelectOption>(
|
||||||
try {
|
async () => {
|
||||||
// 经理下拉的近似全量数据源:内部系统人员规模有限,200 条足够;
|
// 经理下拉的近似全量数据源:内部系统人员规模有限,200 条足够;
|
||||||
// 超出规模的极端场景由后端对经理合法性的校验兜底
|
// 超出规模的极端场景由后端对经理合法性的校验兜底
|
||||||
const data = await personnelApi.listPersonnel({ pageNum: 1, pageSize: 200 })
|
const data = await personnelApi.listPersonnel({ pageNum: 1, pageSize: 200 })
|
||||||
managerOptions.value = data.list.map((p) => ({ id: p.id, name: p.personnel_name, status: p.status }))
|
return data.list.map((p) => ({ id: p.id, name: p.personnel_name, status: p.status }))
|
||||||
} catch (error) {
|
},
|
||||||
handledError(error)
|
)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== 新增 / 编辑(基本信息) ====================
|
// ==================== 新增 / 编辑(基本信息) ====================
|
||||||
|
|
||||||
@@ -372,8 +366,19 @@ onMounted(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<el-card shadow="never">
|
<div class="page-head">
|
||||||
<el-form class="filter-form" :inline="true" @submit.prevent>
|
<h1 class="page-title">人员管理</h1>
|
||||||
|
<p class="page-desc">
|
||||||
|
人员岗位与适用计提参数在这里定;改岗位会改变后续回款的绩效口径,设为离职后不可再被业务与客户归属引用
|
||||||
|
</p>
|
||||||
|
<span class="waterline"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
.filter-bar 直接挂在 el-form 上:el-form 不是 flex 透明容器,
|
||||||
|
外面再包一层 div 会让 el-form-item 丢掉 gap 排版,筛选条退化成竖排。
|
||||||
|
-->
|
||||||
|
<el-form class="filter-bar" @submit.prevent>
|
||||||
<el-form-item label="姓名">
|
<el-form-item label="姓名">
|
||||||
<el-input
|
<el-input
|
||||||
v-model="filters.keyword"
|
v-model="filters.keyword"
|
||||||
@@ -409,17 +414,25 @@ onMounted(() => {
|
|||||||
/>
|
/>
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item>
|
<div class="filter-bar__actions">
|
||||||
<el-button type="primary" @click="search">查询</el-button>
|
<el-button type="primary" @click="search">查询</el-button>
|
||||||
<el-button @click="resetFilters">重置</el-button>
|
<el-button @click="resetFilters">重置</el-button>
|
||||||
</el-form-item>
|
</div>
|
||||||
</el-form>
|
</el-form>
|
||||||
|
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<el-button type="primary" @click="openCreate">新增人员</el-button>
|
<el-button type="primary" @click="openCreate">新增人员</el-button>
|
||||||
|
<span class="toolbar-note">辐射区域与拓展计划在行内「区域与计划」维护,保存即全量替换</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-table v-loading="loading" :data="records" stripe>
|
<!-- ys-rise:整页只给列表卡一次入场上浮,筛选与页头立即呈现,避免整屏一起动 -->
|
||||||
|
<div class="card ys-rise">
|
||||||
|
<el-table
|
||||||
|
v-loading="loading"
|
||||||
|
:data="records"
|
||||||
|
stripe
|
||||||
|
empty-text="还没有人员 —— 点「新增人员」建第一个,或换个关键词再查"
|
||||||
|
>
|
||||||
<el-table-column prop="personnel_name" label="姓名" min-width="100" />
|
<el-table-column prop="personnel_name" label="姓名" min-width="100" />
|
||||||
<el-table-column prop="role_name" label="岗位" min-width="100">
|
<el-table-column prop="role_name" label="岗位" min-width="100">
|
||||||
<template #default="{ row }">{{ row.role_name ?? '—' }}</template>
|
<template #default="{ row }">{{ row.role_name ?? '—' }}</template>
|
||||||
@@ -444,10 +457,10 @@ onMounted(() => {
|
|||||||
<el-table-column label="工资标准(元)" min-width="120" align="right">
|
<el-table-column label="工资标准(元)" min-width="120" align="right">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<!-- 字符串直出:金额红线,禁 toFixed / float 格式化 -->
|
<!-- 字符串直出:金额红线,禁 toFixed / float 格式化 -->
|
||||||
<span class="cell-amount">{{ row.salary_standard ?? '—' }}</span>
|
<span class="cell-num">{{ row.salary_standard ?? '—' }}</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="状态" width="90" align="center">
|
<el-table-column label="状态" width="96" align="center">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<StatusTag :value="row.status" active-text="在职" inactive-text="离职" />
|
<StatusTag :value="row.status" active-text="在职" inactive-text="离职" />
|
||||||
</template>
|
</template>
|
||||||
@@ -469,6 +482,7 @@ onMounted(() => {
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
|
||||||
|
<div class="card-foot">
|
||||||
<AppPagination
|
<AppPagination
|
||||||
:total="total"
|
:total="total"
|
||||||
:page-num="pageNum"
|
:page-num="pageNum"
|
||||||
@@ -476,7 +490,8 @@ onMounted(() => {
|
|||||||
@page-change="onPageChange"
|
@page-change="onPageChange"
|
||||||
@size-change="onSizeChange"
|
@size-change="onSizeChange"
|
||||||
/>
|
/>
|
||||||
</el-card>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 新增 / 编辑(基本信息;不含状态与子表) -->
|
<!-- 新增 / 编辑(基本信息;不含状态与子表) -->
|
||||||
<el-dialog
|
<el-dialog
|
||||||
@@ -567,7 +582,12 @@ onMounted(() => {
|
|||||||
</el-button>
|
</el-button>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<el-table :data="regionRows" stripe size="small">
|
<el-table
|
||||||
|
:data="regionRows"
|
||||||
|
stripe
|
||||||
|
size="small"
|
||||||
|
empty-text="还没有辐射区域 —— 点「添加区域」加一条"
|
||||||
|
>
|
||||||
<el-table-column label="区域名称" min-width="200">
|
<el-table-column label="区域名称" min-width="200">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-input v-model="row.region_name" maxlength="128" placeholder="如 沈阳市铁西区" />
|
<el-input v-model="row.region_name" maxlength="128" placeholder="如 沈阳市铁西区" />
|
||||||
@@ -598,7 +618,12 @@ onMounted(() => {
|
|||||||
</el-button>
|
</el-button>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<el-table :data="planRows" stripe size="small">
|
<el-table
|
||||||
|
:data="planRows"
|
||||||
|
stripe
|
||||||
|
size="small"
|
||||||
|
empty-text="还没有拓展计划 —— 点「添加计划」加一条"
|
||||||
|
>
|
||||||
<el-table-column label="期间" min-width="110">
|
<el-table-column label="期间" min-width="110">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-input v-model="row.plan_period" maxlength="32" placeholder="如 2026Q1" />
|
<el-input v-model="row.plan_period" maxlength="32" placeholder="如 2026Q1" />
|
||||||
@@ -629,23 +654,19 @@ onMounted(() => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.cell-muted {
|
/* .cell-muted / .cell-num 由全局 index.css 提供,本页不再重复定义 */
|
||||||
color: var(--ys-ink-3);
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cell-amount {
|
|
||||||
font-family: 'SFMono-Regular', Consolas, 'Courier New', monospace;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--ys-ink-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 试用期的计算口径必须写在开关旁:它不是状态而是计提规则,
|
||||||
|
* 单靠「试用」二字看不出「照算不照发、超 18000 年底结」。
|
||||||
|
*/
|
||||||
.switch-hint {
|
.switch-hint {
|
||||||
margin-left: 10px;
|
margin-left: 10px;
|
||||||
color: var(--ys-ink-3);
|
color: var(--ys-ink-3);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 抽屉内的子表分组:区域与计划各自成块,块内标题与操作按钮同行 */
|
||||||
.sub-section {
|
.sub-section {
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -185,6 +185,14 @@ onMounted(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
|
<div class="page-head">
|
||||||
|
<h1 class="page-title">产品管理</h1>
|
||||||
|
<p class="page-desc">
|
||||||
|
产品主数据;类别是折算阈值的行级锚点,改类别会改变该产品成交回款的绩效口径
|
||||||
|
</p>
|
||||||
|
<span class="waterline"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<el-alert
|
<el-alert
|
||||||
class="page-alert"
|
class="page-alert"
|
||||||
type="info"
|
type="info"
|
||||||
@@ -193,8 +201,11 @@ onMounted(() => {
|
|||||||
title="产品类别决定折算阈值:市政水利 30% / 轨道交通 20%;阈值在「系统设置-计提参数」维护。"
|
title="产品类别决定折算阈值:市政水利 30% / 轨道交通 20%;阈值在「系统设置-计提参数」维护。"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<el-card shadow="never">
|
<!--
|
||||||
<el-form class="filter-form" :inline="true" @submit.prevent>
|
.filter-bar 直接挂在 el-form 上:el-form 不是 flex 透明容器,
|
||||||
|
外面再包一层 div 会让 el-form-item 丢掉 gap 排版,筛选条退化成竖排。
|
||||||
|
-->
|
||||||
|
<el-form class="filter-bar" @submit.prevent>
|
||||||
<el-form-item label="关键词">
|
<el-form-item label="关键词">
|
||||||
<el-input
|
<el-input
|
||||||
v-model="filters.keyword"
|
v-model="filters.keyword"
|
||||||
@@ -224,17 +235,25 @@ onMounted(() => {
|
|||||||
/>
|
/>
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item>
|
<div class="filter-bar__actions">
|
||||||
<el-button type="primary" @click="search">查询</el-button>
|
<el-button type="primary" @click="search">查询</el-button>
|
||||||
<el-button @click="resetFilters">重置</el-button>
|
<el-button @click="resetFilters">重置</el-button>
|
||||||
</el-form-item>
|
</div>
|
||||||
</el-form>
|
</el-form>
|
||||||
|
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<el-button type="primary" @click="openCreate">新增产品</el-button>
|
<el-button type="primary" @click="openCreate">新增产品</el-button>
|
||||||
|
<span class="toolbar-note">常规下架用「停用」,删除是物理删除、不可恢复</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-table v-loading="loading" :data="records" stripe>
|
<!-- ys-rise:整页只给列表卡一次入场上浮,筛选与页头立即呈现,避免整屏一起动 -->
|
||||||
|
<div class="card ys-rise">
|
||||||
|
<el-table
|
||||||
|
v-loading="loading"
|
||||||
|
:data="records"
|
||||||
|
stripe
|
||||||
|
empty-text="还没有产品 —— 点「新增产品」建第一个,或换个关键词再查"
|
||||||
|
>
|
||||||
<el-table-column prop="product_name" label="产品名称" min-width="150" />
|
<el-table-column prop="product_name" label="产品名称" min-width="150" />
|
||||||
<el-table-column prop="spec_model" label="规格型号" min-width="140">
|
<el-table-column prop="spec_model" label="规格型号" min-width="140">
|
||||||
<template #default="{ row }">{{ row.spec_model || '—' }}</template>
|
<template #default="{ row }">{{ row.spec_model || '—' }}</template>
|
||||||
@@ -245,12 +264,12 @@ onMounted(() => {
|
|||||||
<el-table-column label="数量" min-width="100" align="right">
|
<el-table-column label="数量" min-width="100" align="right">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<!-- 字符串直出:金额红线,禁 toFixed / float 格式化 -->
|
<!-- 字符串直出:金额红线,禁 toFixed / float 格式化 -->
|
||||||
<span class="cell-amount">{{ row.stock_quantity ?? '—' }}</span>
|
<span class="cell-num">{{ row.stock_quantity ?? '—' }}</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="单价(元)" min-width="110" align="right">
|
<el-table-column label="单价(元)" min-width="110" align="right">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<span class="cell-amount">{{ row.sale_price ?? '—' }}</span>
|
<span class="cell-num">{{ row.sale_price ?? '—' }}</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="类别" width="110" align="center">
|
<el-table-column label="类别" width="110" align="center">
|
||||||
@@ -264,7 +283,7 @@ onMounted(() => {
|
|||||||
</el-tag>
|
</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="状态" width="90" align="center">
|
<el-table-column label="状态" width="96" align="center">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<StatusTag :value="row.status" />
|
<StatusTag :value="row.status" />
|
||||||
</template>
|
</template>
|
||||||
@@ -283,6 +302,7 @@ onMounted(() => {
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
|
||||||
|
<div class="card-foot">
|
||||||
<AppPagination
|
<AppPagination
|
||||||
:total="total"
|
:total="total"
|
||||||
:page-num="pageNum"
|
:page-num="pageNum"
|
||||||
@@ -290,7 +310,8 @@ onMounted(() => {
|
|||||||
@page-change="onPageChange"
|
@page-change="onPageChange"
|
||||||
@size-change="onSizeChange"
|
@size-change="onSizeChange"
|
||||||
/>
|
/>
|
||||||
</el-card>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 新增 / 编辑 -->
|
<!-- 新增 / 编辑 -->
|
||||||
<el-dialog
|
<el-dialog
|
||||||
@@ -342,13 +363,10 @@ onMounted(() => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.page-alert {
|
/* .cell-num 由全局 index.css 提供(等宽数字 + 右对齐),本页不再重复定义 */
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cell-amount {
|
/* 阈值提示条:与卡片同宽,下距由这里控制,不占卡的内部留白 */
|
||||||
font-family: 'SFMono-Regular', Consolas, 'Courier New', monospace;
|
.page-alert {
|
||||||
font-size: 12px;
|
margin-bottom: 14px;
|
||||||
color: var(--ys-ink-2);
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -10,6 +10,7 @@ import * as personnelApi from '@/api/personnel'
|
|||||||
import { productCategoryLabel } from '@/api/products'
|
import { productCategoryLabel } from '@/api/products'
|
||||||
import AppPagination from '@/components/table/AppPagination.vue'
|
import AppPagination from '@/components/table/AppPagination.vue'
|
||||||
import { useConfirmAction } from '@/composables/useConfirmAction'
|
import { useConfirmAction } from '@/composables/useConfirmAction'
|
||||||
|
import { useDropdownOptions } from '@/composables/useDropdownOptions'
|
||||||
import { usePagedList } from '@/composables/usePagedList'
|
import { usePagedList } from '@/composables/usePagedList'
|
||||||
import { formatDateTime } from '@/utils/format'
|
import { formatDateTime } from '@/utils/format'
|
||||||
import { AMOUNT_PATTERN, PRICE_PATTERN, ZERO_PATTERN } from '@/utils/validate'
|
import { AMOUNT_PATTERN, PRICE_PATTERN, ZERO_PATTERN } from '@/utils/validate'
|
||||||
@@ -69,37 +70,51 @@ interface PersonnelOption {
|
|||||||
status: number
|
status: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const customerOptions = ref<CustomerOption[]>([])
|
const { options: customerOptions, load: loadCustomerOptions } = useDropdownOptions<CustomerOption>(
|
||||||
const personnelOptions = ref<PersonnelOption[]>([])
|
async () => {
|
||||||
|
|
||||||
async function loadCustomerOptions(): Promise<void> {
|
|
||||||
try {
|
|
||||||
// 客户下拉的近似全量数据源:内部系统规模有限,200 条足够;超出由后端校验兜底
|
// 客户下拉的近似全量数据源:内部系统规模有限,200 条足够;超出由后端校验兜底
|
||||||
const data = await customerApi.listCustomers({ pageNum: 1, pageSize: 200 })
|
const data = await customerApi.listCustomers({ pageNum: 1, pageSize: 200 })
|
||||||
customerOptions.value = data.list.map((c) => ({ id: c.id, name: c.company_name }))
|
return data.list.map((c) => ({ id: c.id, name: c.company_name }))
|
||||||
} catch (error) {
|
},
|
||||||
handledError(error) // 下拉为空不阻断列表使用
|
)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadPersonnelOptions(): Promise<void> {
|
const { options: personnelOptions, load: loadPersonnelOptions } = useDropdownOptions<PersonnelOption>(
|
||||||
try {
|
async () => {
|
||||||
const data = await personnelApi.listPersonnel({ pageNum: 1, pageSize: 200 })
|
const data = await personnelApi.listPersonnel({ pageNum: 1, pageSize: 200 })
|
||||||
personnelOptions.value = data.list.map((p) => ({
|
return data.list.map((p) => ({
|
||||||
id: p.id,
|
id: p.id,
|
||||||
name: p.personnel_name,
|
name: p.personnel_name,
|
||||||
status: p.status,
|
status: p.status,
|
||||||
}))
|
}))
|
||||||
} catch (error) {
|
},
|
||||||
handledError(error)
|
)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 下拉项展示名:离职人员加后缀标注(可否被选由后端归属校验兜底) */
|
/** 下拉项展示名:离职人员加后缀标注(可否被选由后端归属校验兜底) */
|
||||||
function personnelLabel(option: PersonnelOption): string {
|
function personnelLabel(option: PersonnelOption): string {
|
||||||
return option.status === 1 ? option.name : `${option.name}(离职)`
|
return option.status === 1 ? option.name : `${option.name}(离职)`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== 状态呈现(只做样式映射,不改判定) ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Element Plus 语义类型 → 设计稿四档 pill。
|
||||||
|
*
|
||||||
|
* 状态判定沿用 businessApi.businessStatusTagType(三色语义已在接口层定死),
|
||||||
|
* 这里只把语义翻成 pill 的浅底 + 圆点形态:列表的状态列统一成 pill 之后,
|
||||||
|
* el-tag 的实心块状形态会与相邻的编号/金额列打架。
|
||||||
|
*/
|
||||||
|
const STATUS_PILL_TONE: Record<string, string> = {
|
||||||
|
success: 'ys-pill--ok',
|
||||||
|
danger: 'ys-pill--reject',
|
||||||
|
primary: 'ys-pill--lock',
|
||||||
|
info: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusPillClass(status: number): string {
|
||||||
|
const tone = STATUS_PILL_TONE[businessApi.businessStatusTagType(status)] ?? ''
|
||||||
|
return tone ? `ys-pill ${tone}` : 'ys-pill'
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== 新增 / 编辑(含明细行编辑器) ====================
|
// ==================== 新增 / 编辑(含明细行编辑器) ====================
|
||||||
|
|
||||||
const dialogVisible = ref(false)
|
const dialogVisible = ref(false)
|
||||||
@@ -156,7 +171,7 @@ const allocationHint = computed(() => {
|
|||||||
const total = Number(allocationTotal.value.toFixed(2))
|
const total = Number(allocationTotal.value.toFixed(2))
|
||||||
if (total === 100) return `二次分配合计 ${total.toFixed(2)}%`
|
if (total === 100) return `二次分配合计 ${total.toFixed(2)}%`
|
||||||
const rest = Number((100 - total).toFixed(2))
|
const rest = Number((100 - total).toFixed(2))
|
||||||
return `二次分配合计 ${total.toFixed(2)}%,余额 ${rest.toFixed(2)}%(归属待业务确认)`
|
return `二次分配合计 ${total.toFixed(2)}%,余额 ${rest.toFixed(2)}%(归负责人)`
|
||||||
})
|
})
|
||||||
|
|
||||||
function addAllocationRow(): void {
|
function addAllocationRow(): void {
|
||||||
@@ -602,8 +617,21 @@ onMounted(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<el-card shadow="never">
|
<!-- 页面头:标题 + 一句口径说明 + 签名水线(每页只出现一次) -->
|
||||||
<el-form class="filter-form" :inline="true" @submit.prevent>
|
<div class="page-head">
|
||||||
|
<h1 class="page-title">业务管理</h1>
|
||||||
|
<p class="page-desc">
|
||||||
|
业务台账是发货、回款与绩效的源头:客户、归属人员与产品类别在这里定下来,后面的核算才有依据
|
||||||
|
</p>
|
||||||
|
<span class="waterline"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
.filter-bar 直接挂在 el-form 上(去掉 inline):el-form 不是 flex 透明容器,
|
||||||
|
外面再包一层 div 会让 el-form-item 丢掉 gap 排版,筛选条退化成竖排;
|
||||||
|
查询/重置收进 .filter-bar__actions,由 margin-left:auto 推到行尾。
|
||||||
|
-->
|
||||||
|
<el-form class="filter-bar" @submit.prevent>
|
||||||
<el-form-item label="关键词">
|
<el-form-item label="关键词">
|
||||||
<el-input
|
<el-input
|
||||||
v-model="filters.keyword"
|
v-model="filters.keyword"
|
||||||
@@ -634,33 +662,42 @@ onMounted(() => {
|
|||||||
style="width: 240px"
|
style="width: 240px"
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item>
|
<div class="filter-bar__actions">
|
||||||
<el-button type="primary" @click="search">查询</el-button>
|
<el-button type="primary" @click="search">查询</el-button>
|
||||||
<el-button @click="resetFilters">重置</el-button>
|
<el-button @click="resetFilters">重置</el-button>
|
||||||
</el-form-item>
|
</div>
|
||||||
</el-form>
|
</el-form>
|
||||||
|
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<el-button type="primary" @click="openCreate">新增业务</el-button>
|
<el-button type="primary" @click="openCreate">新增业务</el-button>
|
||||||
|
<span class="toolbar-note">状态流转与归属调整走行内操作,每次变更都写留痕</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-table v-loading="loading" :data="records" stripe>
|
<!-- 表格与分页共用一张卡:分页落进 card-foot,表体与表尾同一材质 -->
|
||||||
<el-table-column prop="business_no" label="业务编号" min-width="150" />
|
<div class="card">
|
||||||
|
<el-table
|
||||||
|
v-loading="loading"
|
||||||
|
:data="records"
|
||||||
|
stripe
|
||||||
|
empty-text="还没有业务记录 —— 点「新增业务」建第一笔,或换个关键词再查"
|
||||||
|
>
|
||||||
|
<el-table-column label="业务编号" min-width="150">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="cell-code">{{ row.business_no }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column prop="customer_name" label="客户名称" min-width="160">
|
<el-table-column prop="customer_name" label="客户名称" min-width="160">
|
||||||
<template #default="{ row }">{{ row.customer_name ?? '—' }}</template>
|
<template #default="{ row }">{{ row.customer_name ?? '—' }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="business_date" label="业务日期" min-width="110">
|
<el-table-column prop="business_date" label="业务日期" min-width="110">
|
||||||
<template #default="{ row }">{{ row.business_date ?? '—' }}</template>
|
<template #default="{ row }">{{ row.business_date ?? '—' }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="状态" width="90" align="center">
|
<el-table-column label="状态" width="108" align="center">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag
|
<!-- 状态列统一用 pill:颜色之外必有文字,色觉差异不影响判读 -->
|
||||||
:type="businessApi.businessStatusTagType(row.status)"
|
<span :class="statusPillClass(row.status)">
|
||||||
size="small"
|
|
||||||
disable-transitions
|
|
||||||
>
|
|
||||||
{{ businessApi.businessStatusLabel(row.status) }}
|
{{ businessApi.businessStatusLabel(row.status) }}
|
||||||
</el-tag>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="owner_personnel_name" label="负责人员" min-width="100">
|
<el-table-column prop="owner_personnel_name" label="负责人员" min-width="100">
|
||||||
@@ -672,22 +709,22 @@ onMounted(() => {
|
|||||||
<el-table-column label="销售额(元)" min-width="120" align="right">
|
<el-table-column label="销售额(元)" min-width="120" align="right">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<!-- 字符串直出:金额红线,禁 toFixed / float 格式化 -->
|
<!-- 字符串直出:金额红线,禁 toFixed / float 格式化 -->
|
||||||
<span class="cell-amount">{{ row.total_sale_amount ?? '—' }}</span>
|
<span class="cell-num">{{ row.total_sale_amount ?? '—' }}</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="成本(元)" min-width="120" align="right">
|
<el-table-column label="成本(元)" min-width="120" align="right">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<span class="cell-amount">{{ row.total_cost_amount ?? '—' }}</span>
|
<span class="cell-num">{{ row.total_cost_amount ?? '—' }}</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="发货额(元)" min-width="120" align="right">
|
<el-table-column label="发货额(元)" min-width="120" align="right">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<span class="cell-amount">{{ row.total_ship_amount ?? '—' }}</span>
|
<span class="cell-num">{{ row.total_ship_amount ?? '—' }}</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="回款额(元)" min-width="120" align="right">
|
<el-table-column label="回款额(元)" min-width="120" align="right">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<span class="cell-amount">{{ row.total_received_amount ?? '—' }}</span>
|
<span class="cell-num">{{ row.total_received_amount ?? '—' }}</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="300" fixed="right" align="center">
|
<el-table-column label="操作" width="300" fixed="right" align="center">
|
||||||
@@ -734,6 +771,8 @@ onMounted(() => {
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
|
||||||
|
<!-- 分页落进 card-foot:与表体同一张卡,卡底微渐变收边 -->
|
||||||
|
<div class="card-foot">
|
||||||
<AppPagination
|
<AppPagination
|
||||||
:total="total"
|
:total="total"
|
||||||
:page-num="pageNum"
|
:page-num="pageNum"
|
||||||
@@ -741,7 +780,8 @@ onMounted(() => {
|
|||||||
@page-change="onPageChange"
|
@page-change="onPageChange"
|
||||||
@size-change="onSizeChange"
|
@size-change="onSizeChange"
|
||||||
/>
|
/>
|
||||||
</el-card>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 新增 / 编辑(含明细行编辑器;编辑不含归属,归属走独立按钮保证留痕) -->
|
<!-- 新增 / 编辑(含明细行编辑器;编辑不含归属,归属走独立按钮保证留痕) -->
|
||||||
<el-dialog
|
<el-dialog
|
||||||
@@ -840,7 +880,7 @@ onMounted(() => {
|
|||||||
<div class="items-head">
|
<div class="items-head">
|
||||||
<span class="items-title">
|
<span class="items-title">
|
||||||
二次分配方案
|
二次分配方案
|
||||||
<span class="alloc-note">(可选;比例不要求合计 100%,分摊计算待业务确认后另批)</span>
|
<span class="alloc-note">(可选;各参与人比例不要求合计 100%,余额归负责人)</span>
|
||||||
</span>
|
</span>
|
||||||
<el-button size="small" @click="addAllocationRow">添加参与人</el-button>
|
<el-button size="small" @click="addAllocationRow">添加参与人</el-button>
|
||||||
</div>
|
</div>
|
||||||
@@ -1077,19 +1117,19 @@ onMounted(() => {
|
|||||||
<span v-else class="cell-muted">否</span>
|
<span v-else class="cell-muted">否</span>
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
<el-descriptions-item label="销售额(元)">
|
<el-descriptions-item label="销售额(元)">
|
||||||
<span class="cell-amount">{{ detail.total_sale_amount ?? '—' }}</span>
|
<span class="cell-num">{{ detail.total_sale_amount ?? '—' }}</span>
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
<el-descriptions-item label="成本(元)">
|
<el-descriptions-item label="成本(元)">
|
||||||
<span class="cell-amount">{{ detail.total_cost_amount ?? '—' }}</span>
|
<span class="cell-num">{{ detail.total_cost_amount ?? '—' }}</span>
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
<el-descriptions-item label="发货额(元)">
|
<el-descriptions-item label="发货额(元)">
|
||||||
<span class="cell-amount">{{ detail.total_ship_amount ?? '—' }}</span>
|
<span class="cell-num">{{ detail.total_ship_amount ?? '—' }}</span>
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
<el-descriptions-item label="回款额(元)">
|
<el-descriptions-item label="回款额(元)">
|
||||||
<span class="cell-amount">{{ detail.total_received_amount ?? '—' }}</span>
|
<span class="cell-num">{{ detail.total_received_amount ?? '—' }}</span>
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
<el-descriptions-item label="运费额(元)">
|
<el-descriptions-item label="运费额(元)">
|
||||||
<span class="cell-amount">{{ detail.total_freight_amount ?? '—' }}</span>
|
<span class="cell-num">{{ detail.total_freight_amount ?? '—' }}</span>
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
<el-descriptions-item label="备注">
|
<el-descriptions-item label="备注">
|
||||||
{{ detail.remark ?? '—' }}
|
{{ detail.remark ?? '—' }}
|
||||||
@@ -1115,27 +1155,27 @@ onMounted(() => {
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="数量" min-width="80" align="right">
|
<el-table-column label="数量" min-width="80" align="right">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<span class="cell-amount">{{ row.quantity }}</span>
|
<span class="cell-num">{{ row.quantity }}</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="销售单价" min-width="100" align="right">
|
<el-table-column label="销售单价" min-width="100" align="right">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<span class="cell-amount">{{ row.sale_price ?? '—' }}</span>
|
<span class="cell-num">{{ row.sale_price ?? '—' }}</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="行销售额" min-width="110" align="right">
|
<el-table-column label="行销售额" min-width="110" align="right">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<span class="cell-amount">{{ row.sale_amount }}</span>
|
<span class="cell-num">{{ row.sale_amount }}</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="成本额" min-width="100" align="right">
|
<el-table-column label="成本额" min-width="100" align="right">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<span class="cell-amount">{{ row.cost_amount ?? '—' }}</span>
|
<span class="cell-num">{{ row.cost_amount ?? '—' }}</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="指导价" min-width="100" align="right">
|
<el-table-column label="指导价" min-width="100" align="right">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<span class="cell-amount">{{ row.guided_price ?? '—' }}</span>
|
<span class="cell-num">{{ row.guided_price ?? '—' }}</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
@@ -1155,7 +1195,7 @@ onMounted(() => {
|
|||||||
<el-table-column prop="personnel_name" label="参与业务员" min-width="140" />
|
<el-table-column prop="personnel_name" label="参与业务员" min-width="140" />
|
||||||
<el-table-column label="分配比例" width="110" align="right">
|
<el-table-column label="分配比例" width="110" align="right">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<span class="cell-amount">{{ row.ratio }}%</span>
|
<span class="cell-num">{{ row.ratio }}%</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="状态" width="90">
|
<el-table-column label="状态" width="90">
|
||||||
@@ -1259,18 +1299,18 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="gp-grid">
|
<div class="gp-grid">
|
||||||
<span class="gp-label">销售额</span>
|
<span class="gp-label">销售额</span>
|
||||||
<span class="gp-value cell-amount">{{ cat.sale_amount }}</span>
|
<span class="gp-value cell-num">{{ cat.sale_amount }}</span>
|
||||||
<span class="gp-label">成本额</span>
|
<span class="gp-label">成本额</span>
|
||||||
<span class="gp-value cell-amount">{{ cat.cost_amount }}</span>
|
<span class="gp-value cell-num">{{ cat.cost_amount }}</span>
|
||||||
<span class="gp-label">毛利润额</span>
|
<span class="gp-label">毛利润额</span>
|
||||||
<span class="gp-value cell-amount">{{ cat.profit_amount }}</span>
|
<span class="gp-value cell-num">{{ cat.profit_amount }}</span>
|
||||||
<span class="gp-label">毛利润率</span>
|
<span class="gp-label">毛利润率</span>
|
||||||
<!-- 分数字符串原样展示(如 997/1000),禁止数值化 -->
|
<!-- 分数字符串原样展示(如 997/1000),禁止数值化 -->
|
||||||
<span class="gp-value cell-amount">{{ cat.profit_rate }}</span>
|
<span class="gp-value cell-num">{{ cat.profit_rate }}</span>
|
||||||
<span class="gp-label">折算阈值</span>
|
<span class="gp-label">折算阈值</span>
|
||||||
<span class="gp-value cell-amount">{{ cat.threshold }}</span>
|
<span class="gp-value cell-num">{{ cat.threshold }}</span>
|
||||||
<span class="gp-label">折算毛利润额</span>
|
<span class="gp-label">折算毛利润额</span>
|
||||||
<span class="gp-value cell-amount">{{ cat.converted_profit }}</span>
|
<span class="gp-value cell-num">{{ cat.converted_profit }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</el-col>
|
</el-col>
|
||||||
@@ -1287,38 +1327,38 @@ onMounted(() => {
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="数量" min-width="80" align="right">
|
<el-table-column label="数量" min-width="80" align="right">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<span class="cell-amount">{{ row.quantity }}</span>
|
<span class="cell-num">{{ row.quantity }}</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="销售额" min-width="100" align="right">
|
<el-table-column label="销售额" min-width="100" align="right">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<span class="cell-amount">{{ row.sale_amount }}</span>
|
<span class="cell-num">{{ row.sale_amount }}</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="成本额" min-width="120" align="right">
|
<el-table-column label="成本额" min-width="120" align="right">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<span v-if="row.cost_amount" class="cell-amount">{{ row.cost_amount }}</span>
|
<span v-if="row.cost_amount" class="cell-num">{{ row.cost_amount }}</span>
|
||||||
<span v-else class="cell-missing">{{ row.missing_reason ?? '成本缺失' }}</span>
|
<span v-else class="cell-missing">{{ row.missing_reason ?? '成本缺失' }}</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="毛利润额" min-width="100" align="right">
|
<el-table-column label="毛利润额" min-width="100" align="right">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<span class="cell-amount">{{ row.profit_amount ?? '—' }}</span>
|
<span class="cell-num">{{ row.profit_amount ?? '—' }}</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="毛利润率" min-width="100" align="right">
|
<el-table-column label="毛利润率" min-width="100" align="right">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<span class="cell-amount">{{ row.profit_rate ?? '—' }}</span>
|
<span class="cell-num">{{ row.profit_rate ?? '—' }}</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="折算阈值" min-width="90" align="right">
|
<el-table-column label="折算阈值" min-width="90" align="right">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<span class="cell-amount">{{ row.threshold }}</span>
|
<span class="cell-num">{{ row.threshold }}</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="折算毛利润额" min-width="110" align="right">
|
<el-table-column label="折算毛利润额" min-width="110" align="right">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<span class="cell-amount">{{ row.converted_profit ?? '—' }}</span>
|
<span class="cell-num">{{ row.converted_profit ?? '—' }}</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
@@ -1333,16 +1373,8 @@ onMounted(() => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.cell-muted {
|
/* 单元格口径(.cell-code / .cell-num / .cell-muted)与表头、按钮、输入框皮肤
|
||||||
color: var(--ys-ink-3);
|
已由全局 index.css 提供,此处不再重复定义,避免两处口径打架 */
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cell-amount {
|
|
||||||
font-family: 'SFMono-Regular', Consolas, 'Courier New', monospace;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--ys-ink-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cell-missing {
|
.cell-missing {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@@ -1512,4 +1544,5 @@ onMounted(() => {
|
|||||||
color: var(--ys-ink-3);
|
color: var(--ys-ink-3);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
@@ -1,485 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed, onMounted, reactive, ref } from 'vue'
|
|
||||||
import { ElMessage } from 'element-plus'
|
|
||||||
|
|
||||||
import {
|
|
||||||
createRatingGrade,
|
|
||||||
deleteRatingGrade,
|
|
||||||
listRatingGrades,
|
|
||||||
updateRatingGrade,
|
|
||||||
type RatingGradeItem,
|
|
||||||
type RatingGradeParams,
|
|
||||||
} from '@/api/ratings'
|
|
||||||
import { useConfirmAction } from '@/composables/useConfirmAction'
|
|
||||||
import { handledError } from '@/utils/error'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 客户评级计算器(需求 2.2.5 / 对照表 #25)。
|
|
||||||
*
|
|
||||||
* 评级计算器仅是**打分工具**:五项打分求总分、按等级映射实时判定等级,
|
|
||||||
* 评分记录不落库、不参与绩效金额计算,因此页面没有保存动作(仅重置)。
|
|
||||||
* 等级映射的增删改走 /v1/business/rating-grades 维护接口,配置变更后
|
|
||||||
* 重载判定用列表——判定与抽屉表格共用同一份数据。
|
|
||||||
*/
|
|
||||||
|
|
||||||
// ==================== 等级判定(纯函数) ====================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 等级判定:按 sort_order 升序取首个 min_score ≤ 总分的等级。
|
|
||||||
*
|
|
||||||
* 后端 min_score 是字符串直出(分值红线),比较前必须 parseFloat 转数值,
|
|
||||||
* 禁止字符串直接比大小("90.0" 这类字典序比较会得出错误结果)。
|
|
||||||
* 无命中(总分低于全部下限)返回 null,由调用方展示未命中提示。
|
|
||||||
*/
|
|
||||||
function resolveGrade(grades: RatingGradeItem[], totalScore: number): RatingGradeItem | null {
|
|
||||||
const ordered = [...grades].sort((a, b) => a.sort_order - b.sort_order)
|
|
||||||
for (const grade of ordered) {
|
|
||||||
if (totalScore >= parseFloat(grade.min_score)) {
|
|
||||||
return grade
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== 评分计算(纯前端实时计算) ====================
|
|
||||||
|
|
||||||
/** 五项评分项:各 0-20 分、步进 1,总分 = 五项之和(0-100) */
|
|
||||||
const SCORE_ITEMS = [
|
|
||||||
{ key: 'ship_amount', label: '发货额' },
|
|
||||||
{ key: 'receipt_rate', label: '回款率' },
|
|
||||||
{ key: 'profit_rate', label: '毛利率' },
|
|
||||||
{ key: 'contract_cooperation', label: '合同履约配合能力' },
|
|
||||||
{ key: 'site_construction', label: '现场规范施工能力' },
|
|
||||||
] as const
|
|
||||||
|
|
||||||
type ScoreKey = (typeof SCORE_ITEMS)[number]['key']
|
|
||||||
|
|
||||||
/** el-input-number 清空后值为 undefined,字段用宽松类型,求和处 ?? 0 兜底 */
|
|
||||||
const scores = reactive<Record<ScoreKey, number | undefined>>({
|
|
||||||
ship_amount: 0,
|
|
||||||
receipt_rate: 0,
|
|
||||||
profit_rate: 0,
|
|
||||||
contract_cooperation: 0,
|
|
||||||
site_construction: 0,
|
|
||||||
})
|
|
||||||
|
|
||||||
const totalScore = computed(() =>
|
|
||||||
SCORE_ITEMS.reduce((sum, item) => sum + (scores[item.key] ?? 0), 0),
|
|
||||||
)
|
|
||||||
|
|
||||||
/** 无保存动作(不落库),重置即把五项清零 */
|
|
||||||
function resetScores(): void {
|
|
||||||
for (const item of SCORE_ITEMS) {
|
|
||||||
scores[item.key] = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== 等级映射(判定用列表) ====================
|
|
||||||
|
|
||||||
const { confirmAndRun } = useConfirmAction()
|
|
||||||
|
|
||||||
const grades = ref<RatingGradeItem[]>([])
|
|
||||||
const gradesLoading = ref(false)
|
|
||||||
|
|
||||||
/** 判定与抽屉表格共用同一份全量列表,配置变更后重载一次即同时生效 */
|
|
||||||
async function loadGrades(): Promise<void> {
|
|
||||||
gradesLoading.value = true
|
|
||||||
try {
|
|
||||||
grades.value = await listRatingGrades()
|
|
||||||
} catch (error) {
|
|
||||||
handledError(error)
|
|
||||||
} finally {
|
|
||||||
gradesLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const matchedGrade = computed(() => resolveGrade(grades.value, totalScore.value))
|
|
||||||
|
|
||||||
// ==================== 等级映射设置抽屉 ====================
|
|
||||||
|
|
||||||
const drawerVisible = ref(false)
|
|
||||||
|
|
||||||
/** 打开时重新拉取,避免展示陈旧数据 */
|
|
||||||
function openDrawer(): void {
|
|
||||||
drawerVisible.value = true
|
|
||||||
void loadGrades()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== 新增 / 编辑等级弹窗 ====================
|
|
||||||
|
|
||||||
const editVisible = ref(false)
|
|
||||||
/** null = 新增,否则为编辑目标 id */
|
|
||||||
const editingId = ref<number | null>(null)
|
|
||||||
const editSaving = ref(false)
|
|
||||||
const isCreate = computed(() => editingId.value === null)
|
|
||||||
|
|
||||||
const editForm = reactive({
|
|
||||||
grade_name: '',
|
|
||||||
min_score: undefined as number | undefined,
|
|
||||||
max_score: undefined as number | undefined,
|
|
||||||
sort_order: 0,
|
|
||||||
})
|
|
||||||
|
|
||||||
function openCreate(): void {
|
|
||||||
editingId.value = null
|
|
||||||
editForm.grade_name = ''
|
|
||||||
editForm.min_score = undefined
|
|
||||||
editForm.max_score = undefined
|
|
||||||
editForm.sort_order = 0
|
|
||||||
editVisible.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function openEdit(row: RatingGradeItem): void {
|
|
||||||
editingId.value = row.id
|
|
||||||
editForm.grade_name = row.grade_name
|
|
||||||
// 后端字符串直出,回填 parseFloat 转数值,保存时再 String() 转回字符串
|
|
||||||
editForm.min_score = parseFloat(row.min_score)
|
|
||||||
editForm.max_score = parseFloat(row.max_score)
|
|
||||||
editForm.sort_order = row.sort_order
|
|
||||||
editVisible.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 前端值域校验(0≤min≤max≤100);后端在审计上下文内兜底同规则与区间重叠 */
|
|
||||||
function editFormError(minScore: number | undefined, maxScore: number | undefined): string | null {
|
|
||||||
if (!editForm.grade_name.trim()) return '请输入等级名称'
|
|
||||||
if (minScore === undefined || maxScore === undefined) return '请输入分值区间'
|
|
||||||
if (minScore < 0 || maxScore > 100 || minScore > maxScore) {
|
|
||||||
return '分值区间须满足 0 ≤ 下限 ≤ 上限 ≤ 100'
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveGrade(): Promise<void> {
|
|
||||||
// 局部变量收窄类型:reactive 属性在异步流程中不保证保持窄化
|
|
||||||
const minScore = editForm.min_score
|
|
||||||
const maxScore = editForm.max_score
|
|
||||||
const error = editFormError(minScore, maxScore)
|
|
||||||
if (error) {
|
|
||||||
ElMessage.warning(error)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload: RatingGradeParams = {
|
|
||||||
grade_name: editForm.grade_name.trim(),
|
|
||||||
min_score: String(minScore),
|
|
||||||
max_score: String(maxScore),
|
|
||||||
sort_order: editForm.sort_order ?? 0,
|
|
||||||
}
|
|
||||||
// 后端为全量替换语义;remark 本页不维护,编辑时原样带回避免误清
|
|
||||||
if (editingId.value !== null) {
|
|
||||||
payload.remark = grades.value.find((g) => g.id === editingId.value)?.remark ?? undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
editSaving.value = true
|
|
||||||
try {
|
|
||||||
if (editingId.value === null) {
|
|
||||||
await createRatingGrade(payload)
|
|
||||||
} else {
|
|
||||||
await updateRatingGrade(editingId.value, payload)
|
|
||||||
}
|
|
||||||
editVisible.value = false
|
|
||||||
ElMessage.success(isCreate.value ? '评级等级已创建' : '已保存')
|
|
||||||
await loadGrades()
|
|
||||||
} catch (requestError) {
|
|
||||||
handledError(requestError)
|
|
||||||
} finally {
|
|
||||||
editSaving.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 删除走确认动作流;文案明确后果(该区间不再能映射等级) */
|
|
||||||
function removeGrade(row: RatingGradeItem): void {
|
|
||||||
void confirmAndRun({
|
|
||||||
confirmText: `确定删除等级「${row.grade_name}」(${row.min_score} ~ ${row.max_score})?删除后该分数区间将无法映射等级。`,
|
|
||||||
title: '删除确认',
|
|
||||||
confirmButtonText: '删除',
|
|
||||||
successText: '评级等级已删除',
|
|
||||||
run: () => deleteRatingGrade(row.id),
|
|
||||||
after: () => loadGrades(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
void loadGrades()
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="rating-page">
|
|
||||||
<!-- 对照表 #25:常驻口径提示,防止误认为评分会影响绩效金额 -->
|
|
||||||
<el-alert
|
|
||||||
class="page-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>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
</el-table>
|
|
||||||
</el-drawer>
|
|
||||||
|
|
||||||
<!-- 新增 / 编辑等级 -->
|
|
||||||
<el-dialog
|
|
||||||
v-model="editVisible"
|
|
||||||
:title="isCreate ? '新增评级等级' : '编辑评级等级'"
|
|
||||||
width="440px"
|
|
||||||
destroy-on-close
|
|
||||||
@closed="editingId = null"
|
|
||||||
>
|
|
||||||
<el-form label-width="90px" @submit.prevent>
|
|
||||||
<el-form-item label="等级名称" required>
|
|
||||||
<el-input v-model="editForm.grade_name" maxlength="32" placeholder="如:A / 优秀" />
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="下限(含)" required>
|
|
||||||
<el-input-number
|
|
||||||
v-model="editForm.min_score"
|
|
||||||
:min="0"
|
|
||||||
:max="100"
|
|
||||||
:step="5"
|
|
||||||
controls-position="right"
|
|
||||||
style="width: 100%"
|
|
||||||
/>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="上限" required>
|
|
||||||
<el-input-number
|
|
||||||
v-model="editForm.max_score"
|
|
||||||
:min="0"
|
|
||||||
:max="100"
|
|
||||||
:step="5"
|
|
||||||
controls-position="right"
|
|
||||||
style="width: 100%"
|
|
||||||
/>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="排序">
|
|
||||||
<el-input-number
|
|
||||||
v-model="editForm.sort_order"
|
|
||||||
:min="0"
|
|
||||||
:step="10"
|
|
||||||
controls-position="right"
|
|
||||||
/>
|
|
||||||
<span class="form-tip">升序判定,越小越先判定</span>
|
|
||||||
</el-form-item>
|
|
||||||
</el-form>
|
|
||||||
<div class="form-tip-block">
|
|
||||||
分值区间须满足 0 ≤ 下限 ≤ 上限 ≤ 100;区间重叠、名称重复由后端校验兜底。
|
|
||||||
</div>
|
|
||||||
<template #footer>
|
|
||||||
<el-button @click="editVisible = false">取消</el-button>
|
|
||||||
<el-button type="primary" :loading="editSaving" @click="saveGrade">保存</el-button>
|
|
||||||
</template>
|
|
||||||
</el-dialog>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.page-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);
|
|
||||||
}
|
|
||||||
|
|
||||||
.calc-grid {
|
|
||||||
display: flex;
|
|
||||||
gap: 28px;
|
|
||||||
align-items: stretch;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.calc-form {
|
|
||||||
flex: 1 1 380px;
|
|
||||||
min-width: 280px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.score-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
line-height: 2.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.score-label {
|
|
||||||
width: 140px;
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--ys-ink);
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
.score-unit {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--ys-ink-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.calc-result {
|
|
||||||
flex: 0 0 250px;
|
|
||||||
min-width: 220px;
|
|
||||||
border: 1px solid var(--ys-line);
|
|
||||||
border-radius: 10px;
|
|
||||||
padding: 18px 20px;
|
|
||||||
background: linear-gradient(180deg, #ffffff, #fcfcfe);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.result-label {
|
|
||||||
display: block;
|
|
||||||
font-size: 12px;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
color: var(--ys-ink-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.total-num {
|
|
||||||
font-size: 40px;
|
|
||||||
color: var(--ys-indigo);
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
margin-right: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.total-max {
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--ys-ink-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.grade-name {
|
|
||||||
font-family: var(--ys-font-brand);
|
|
||||||
font-size: 28px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--ys-ink);
|
|
||||||
line-height: 1.3;
|
|
||||||
margin-top: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.grade-hint {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--ys-ink-3);
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.drawer-hint {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--ys-ink-3);
|
|
||||||
background: var(--ys-mist);
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 8px 12px;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.drawer-toolbar {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-tip {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--ys-ink-3);
|
|
||||||
margin-left: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-tip-block {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--ys-ink-3);
|
|
||||||
background: var(--ys-mist);
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 8px 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 移动端:标签左对齐、结果块自然换行到表单下方 */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.score-label {
|
|
||||||
width: auto;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.calc-result {
|
|
||||||
flex: 1 1 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -242,8 +242,18 @@ onMounted(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<el-card shadow="never">
|
<!-- 页面头:标题 + 一句口径说明(回款是绩效源头)+ 签名水线 -->
|
||||||
<el-form class="filter-form" :inline="true" @submit.prevent>
|
<div class="page-head">
|
||||||
|
<h1 class="page-title">回款管理</h1>
|
||||||
|
<p class="page-desc">回款是绩效的源头:批次、到账日与产品类别决定计提口径</p>
|
||||||
|
<span class="waterline"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
.filter-bar 直接挂在 el-form 上(去掉 inline):el-form 不是 flex 透明容器,
|
||||||
|
外面再包一层 div 会让 el-form-item 丢掉 gap 排版,筛选条退化成竖排。
|
||||||
|
-->
|
||||||
|
<el-form class="filter-bar" @submit.prevent>
|
||||||
<el-form-item label="业务">
|
<el-form-item label="业务">
|
||||||
<el-select
|
<el-select
|
||||||
v-model="filters.business_id"
|
v-model="filters.business_id"
|
||||||
@@ -296,54 +306,70 @@ onMounted(() => {
|
|||||||
/>
|
/>
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item>
|
<div class="filter-bar__actions">
|
||||||
<el-button type="primary" @click="search">查询</el-button>
|
<el-button type="primary" @click="search">查询</el-button>
|
||||||
<el-button @click="resetFilters">重置</el-button>
|
<el-button @click="resetFilters">重置</el-button>
|
||||||
</el-form-item>
|
</div>
|
||||||
</el-form>
|
</el-form>
|
||||||
|
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<el-button type="primary" @click="openCreate">新增回款</el-button>
|
<el-button type="primary" @click="openCreate">新增回款</el-button>
|
||||||
|
<span class="toolbar-note">已核算的回款不可变更;批次一旦指定,回款天数即按发货日派生</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-table v-loading="loading" :data="records" stripe>
|
<div class="card">
|
||||||
<el-table-column prop="receipt_batch_no" label="批次号" min-width="150" />
|
<el-table
|
||||||
<el-table-column prop="business_no" label="业务编号" min-width="140">
|
v-loading="loading"
|
||||||
<template #default="{ row }">{{ row.business_no }}</template>
|
:data="records"
|
||||||
|
stripe
|
||||||
|
empty-text="还没有回款记录 —— 点「新增回款」登记第一笔,或换个业务再查"
|
||||||
|
>
|
||||||
|
<el-table-column label="批次号" min-width="150">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="cell-code">{{ row.receipt_batch_no }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="业务编号" min-width="140">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span v-if="row.business_no" class="cell-code">{{ row.business_no }}</span>
|
||||||
|
<span v-else class="cell-muted">—</span>
|
||||||
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="发货单号" min-width="140">
|
<el-table-column label="发货单号" min-width="140">
|
||||||
<template #default="{ row }">{{ row.shipment_no ?? '—' }}</template>
|
<template #default="{ row }">
|
||||||
|
<span v-if="row.shipment_no" class="cell-code">{{ row.shipment_no }}</span>
|
||||||
|
<span v-else class="cell-muted">—</span>
|
||||||
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="receipt_date" label="回款日期" min-width="110" />
|
<el-table-column prop="receipt_date" label="回款日期" min-width="110" />
|
||||||
<el-table-column label="金额(元)" min-width="120" align="right">
|
<el-table-column label="金额(元)" min-width="120" align="right">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<!-- 字符串直出:金额红线,禁 toFixed / float 格式化 -->
|
<!-- 字符串直出:金额红线,禁 toFixed / float 格式化 -->
|
||||||
<span class="cell-amount">{{ row.amount }}</span>
|
<span class="cell-num">{{ row.amount }}</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="以物抵顶" width="100" align="center">
|
<el-table-column label="以物抵顶" width="120" align="center">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag v-if="row.is_barter === 1" type="warning" size="small" disable-transitions>
|
<!-- 以物抵顶影响折算比例,属需要一眼看见的分支,用 pill 而非普通文字 -->
|
||||||
以物抵顶
|
<span v-if="row.is_barter === 1" class="ys-pill ys-pill--wait">以物抵顶</span>
|
||||||
</el-tag>
|
|
||||||
<span v-else class="cell-muted">否</span>
|
<span v-else class="cell-muted">否</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="回款天数" width="120" align="center">
|
<el-table-column label="回款天数" width="120" align="right">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<span v-if="row.payment_days !== null" class="cell-amount">{{ row.payment_days }} 天</span>
|
<span v-if="row.payment_days !== null" class="cell-num">{{ row.payment_days }} 天</span>
|
||||||
<span v-else class="cell-muted">未指定批次</span>
|
<span v-else class="cell-muted">未指定批次</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="核算状态" width="90" align="center">
|
<el-table-column label="核算状态" width="108" align="center">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag
|
<!-- 已核算=已确认(靛绿)、未核算=待处理(琥珀);已核算行禁改禁删 -->
|
||||||
:type="row.settlement_status === 1 ? 'success' : 'info'"
|
<span
|
||||||
size="small"
|
class="ys-pill"
|
||||||
disable-transitions
|
:class="row.settlement_status === 1 ? 'ys-pill--ok' : 'ys-pill--wait'"
|
||||||
>
|
>
|
||||||
{{ row.settlement_status === 1 ? '已核算' : '未核算' }}
|
{{ row.settlement_status === 1 ? '已核算' : '未核算' }}
|
||||||
</el-tag>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="130" fixed="right" align="center">
|
<el-table-column label="操作" width="130" fixed="right" align="center">
|
||||||
@@ -389,6 +415,8 @@ onMounted(() => {
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
|
||||||
|
<!-- 分页落进 card-foot:与表体同一张卡,卡底微渐变收边 -->
|
||||||
|
<div class="card-foot">
|
||||||
<AppPagination
|
<AppPagination
|
||||||
:total="total"
|
:total="total"
|
||||||
:page-num="pageNum"
|
:page-num="pageNum"
|
||||||
@@ -396,7 +424,8 @@ onMounted(() => {
|
|||||||
@page-change="onPageChange"
|
@page-change="onPageChange"
|
||||||
@size-change="onSizeChange"
|
@size-change="onSizeChange"
|
||||||
/>
|
/>
|
||||||
</el-card>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 新增 / 编辑(业务必选 + 发货批次级联指定,可清空=暂不指定) -->
|
<!-- 新增 / 编辑(业务必选 + 发货批次级联指定,可清空=暂不指定) -->
|
||||||
<el-dialog
|
<el-dialog
|
||||||
@@ -450,7 +479,7 @@ onMounted(() => {
|
|||||||
v-model="form.receipt_date"
|
v-model="form.receipt_date"
|
||||||
type="date"
|
type="date"
|
||||||
value-format="YYYY-MM-DD"
|
value-format="YYYY-MM-DD"
|
||||||
placeholder="M3 核算按此日匹配参数版本"
|
placeholder="选到账当天;计提比例按这一天的参数计算"
|
||||||
style="width: 100%"
|
style="width: 100%"
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
@@ -474,16 +503,7 @@ onMounted(() => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.cell-muted {
|
/* 单元格口径(.cell-code / .cell-num / .cell-muted)由全局 index.css 提供,此处不再重复定义 */
|
||||||
color: var(--ys-ink-3);
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cell-amount {
|
|
||||||
font-family: 'SFMono-Regular', Consolas, 'Courier New', monospace;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--ys-ink-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.op-wrap {
|
.op-wrap {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
|||||||
@@ -273,8 +273,20 @@ onMounted(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<el-card shadow="never">
|
<!-- 页面头:标题 + 一句口径说明(发货日是回款天数的起算点)+ 签名水线 -->
|
||||||
<el-form class="filter-form" :inline="true" @submit.prevent>
|
<div class="page-head">
|
||||||
|
<h1 class="page-title">发货管理</h1>
|
||||||
|
<p class="page-desc">
|
||||||
|
发货单挂靠执行中的业务,发货日就是回款天数的起算点(发货日计第 1 天);批次与金额经回款指定后决定计提比例与绩效
|
||||||
|
</p>
|
||||||
|
<span class="waterline"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
.filter-bar 直接挂在 el-form 上(去掉 inline):el-form 不是 flex 透明容器,
|
||||||
|
外面再包一层 div 会让 el-form-item 丢掉 gap 排版,筛选条退化成竖排。
|
||||||
|
-->
|
||||||
|
<el-form class="filter-bar" @submit.prevent>
|
||||||
<el-form-item label="业务">
|
<el-form-item label="业务">
|
||||||
<el-select
|
<el-select
|
||||||
v-model="filters.business_id"
|
v-model="filters.business_id"
|
||||||
@@ -302,20 +314,34 @@ onMounted(() => {
|
|||||||
style="width: 240px"
|
style="width: 240px"
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item>
|
<div class="filter-bar__actions">
|
||||||
<el-button type="primary" @click="search">查询</el-button>
|
<el-button type="primary" @click="search">查询</el-button>
|
||||||
<el-button @click="resetFilters">重置</el-button>
|
<el-button @click="resetFilters">重置</el-button>
|
||||||
</el-form-item>
|
</div>
|
||||||
</el-form>
|
</el-form>
|
||||||
|
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<el-button type="primary" @click="openCreate">新增发货单</el-button>
|
<el-button type="primary" @click="openCreate">新增发货单</el-button>
|
||||||
|
<span class="toolbar-note">发货明细与运费在弹窗内维护,提交后自动回写业务发货额</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-table v-loading="loading" :data="records" stripe>
|
<div class="card">
|
||||||
<el-table-column prop="shipment_no" label="发货单号" min-width="150" />
|
<el-table
|
||||||
<el-table-column prop="business_no" label="业务编号" min-width="150">
|
v-loading="loading"
|
||||||
<template #default="{ row }">{{ row.business_no ?? '—' }}</template>
|
:data="records"
|
||||||
|
stripe
|
||||||
|
empty-text="还没有发货单 —— 点「新增发货单」登记第一批,或换个业务再查"
|
||||||
|
>
|
||||||
|
<el-table-column label="发货单号" min-width="150">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="cell-code">{{ row.shipment_no }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="业务编号" min-width="150">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span v-if="row.business_no" class="cell-code">{{ row.business_no }}</span>
|
||||||
|
<span v-else class="cell-muted">—</span>
|
||||||
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="发货日期" min-width="140">
|
<el-table-column label="发货日期" min-width="140">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
@@ -329,12 +355,12 @@ onMounted(() => {
|
|||||||
<el-table-column label="运费(元)" min-width="110" align="right">
|
<el-table-column label="运费(元)" min-width="110" align="right">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<!-- 字符串直出:金额红线,禁 toFixed / float 格式化 -->
|
<!-- 字符串直出:金额红线,禁 toFixed / float 格式化 -->
|
||||||
<span class="cell-amount">{{ row.freight_amount }}</span>
|
<span class="cell-num">{{ row.freight_amount }}</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="发货总额(元)" min-width="130" align="right">
|
<el-table-column label="发货总额(元)" min-width="130" align="right">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<span class="cell-amount">{{ row.total_amount }}</span>
|
<span class="cell-num">{{ row.total_amount }}</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="130" fixed="right" align="center">
|
<el-table-column label="操作" width="130" fixed="right" align="center">
|
||||||
@@ -348,6 +374,8 @@ onMounted(() => {
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
|
||||||
|
<!-- 分页落进 card-foot:与表体同一张卡,卡底微渐变收边 -->
|
||||||
|
<div class="card-foot">
|
||||||
<AppPagination
|
<AppPagination
|
||||||
:total="total"
|
:total="total"
|
||||||
:page-num="pageNum"
|
:page-num="pageNum"
|
||||||
@@ -355,7 +383,8 @@ onMounted(() => {
|
|||||||
@page-change="onPageChange"
|
@page-change="onPageChange"
|
||||||
@size-change="onSizeChange"
|
@size-change="onSizeChange"
|
||||||
/>
|
/>
|
||||||
</el-card>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 新增 / 编辑(明细行编辑器;编辑不含 business_id,挂靠业务不可改) -->
|
<!-- 新增 / 编辑(明细行编辑器;编辑不含 business_id,挂靠业务不可改) -->
|
||||||
<el-dialog
|
<el-dialog
|
||||||
@@ -450,16 +479,7 @@ onMounted(() => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.cell-amount {
|
/* 单元格口径(.cell-code / .cell-num / .cell-muted)由全局 index.css 提供,此处不再重复定义 */
|
||||||
font-family: 'SFMono-Regular', Consolas, 'Courier New', monospace;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--ys-ink-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cell-muted {
|
|
||||||
color: var(--ys-ink-3);
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.field-hint {
|
.field-hint {
|
||||||
display: block;
|
display: block;
|
||||||
@@ -489,4 +509,5 @@ onMounted(() => {
|
|||||||
.tail-form {
|
.tail-form {
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
@@ -70,7 +70,20 @@ async function submitRecalc(): Promise<void> {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<el-form inline class="filters" @submit.prevent>
|
<!-- 页面头:标题 + 一句口径说明(台账是核算事实的固化点)+ 签名水线 -->
|
||||||
|
<div class="page-head">
|
||||||
|
<h1 class="page-title">绩效台账</h1>
|
||||||
|
<p class="page-desc">
|
||||||
|
每笔回款只核算一次;重算作废旧版本、按当前参数落新版本并留痕,展开行可看当次核算的参数快照
|
||||||
|
</p>
|
||||||
|
<span class="waterline"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
.filter-bar 直接挂在 el-form 上(去掉 inline):el-form 不是 flex 透明容器,
|
||||||
|
外面再包一层 div 会让 el-form-item 丢掉 gap 排版,筛选条退化成竖排。
|
||||||
|
-->
|
||||||
|
<el-form class="filter-bar" @submit.prevent>
|
||||||
<el-form-item label="归属月">
|
<el-form-item label="归属月">
|
||||||
<el-input
|
<el-input
|
||||||
v-model="filters.settlement_month"
|
v-model="filters.settlement_month"
|
||||||
@@ -93,34 +106,63 @@ async function submitRecalc(): Promise<void> {
|
|||||||
<el-option label="仅历史版本" :value="0" />
|
<el-option label="仅历史版本" :value="0" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item>
|
<div class="filter-bar__actions">
|
||||||
<el-button type="primary" @click="search">查询</el-button>
|
<el-button type="primary" @click="search">查询</el-button>
|
||||||
<el-button @click="resetFilters">重置</el-button>
|
<el-button @click="resetFilters">重置</el-button>
|
||||||
</el-form-item>
|
</div>
|
||||||
</el-form>
|
</el-form>
|
||||||
|
|
||||||
<el-table v-loading="loading" :data="records" stripe>
|
<div class="card">
|
||||||
|
<el-table
|
||||||
|
v-loading="loading"
|
||||||
|
:data="records"
|
||||||
|
stripe
|
||||||
|
empty-text="台账还没有核算行 —— 换个归属月看看,或先在回款管理里登记回款后再核算"
|
||||||
|
>
|
||||||
<el-table-column type="expand">
|
<el-table-column type="expand">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<pre class="snapshot">{{ JSON.stringify(row.snapshot_json ?? {}, null, 2) }}</pre>
|
<pre class="snapshot">{{ JSON.stringify(row.snapshot_json ?? {}, null, 2) }}</pre>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="settlement_month" label="归属月" width="90" />
|
<el-table-column prop="settlement_month" label="归属月" width="90" />
|
||||||
<el-table-column prop="business_no" label="业务编号" min-width="150" />
|
<el-table-column label="业务编号" min-width="150">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="cell-code">{{ row.business_no }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column prop="personnel_name" label="归属人员" width="100" />
|
<el-table-column prop="personnel_name" label="归属人员" width="100" />
|
||||||
<el-table-column prop="role_code" label="岗位" width="110" />
|
<el-table-column prop="role_code" label="岗位" width="110" />
|
||||||
<el-table-column prop="receipt_date" label="回款日" width="100" />
|
<el-table-column prop="receipt_date" label="回款日" width="100" />
|
||||||
<el-table-column prop="payment_days" label="天数" width="70" />
|
<el-table-column label="天数" width="70" align="right">
|
||||||
<el-table-column prop="rate_text" label="计提比例" min-width="90" />
|
|
||||||
<el-table-column prop="converted_profit" label="折算毛利" min-width="100" />
|
|
||||||
<el-table-column prop="performance_amount" label="绩效金额" min-width="100" />
|
|
||||||
<el-table-column prop="payable_amount" label="应付绩效" min-width="100" />
|
|
||||||
<el-table-column label="版本" width="80">
|
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
v{{ row.calc_version }}
|
<span class="cell-num">{{ row.payment_days }}</span>
|
||||||
<el-tag v-if="row.is_active === 0" size="small" type="info" disable-transitions>
|
</template>
|
||||||
已替代
|
</el-table-column>
|
||||||
</el-tag>
|
<el-table-column label="计提比例" min-width="90" align="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="cell-num">{{ row.rate_text }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="折算毛利" min-width="100" align="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="cell-num">{{ row.converted_profit }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="绩效金额" min-width="100" align="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="cell-num">{{ row.performance_amount }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="应付绩效" min-width="100" align="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="cell-num">{{ row.payable_amount }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="版本" width="108">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="cell-code">v{{ row.calc_version }}</span>
|
||||||
|
<!-- 历史版本用中性 pill:被替代不是异常,只是不再是生效版本 -->
|
||||||
|
<span v-if="row.is_active === 0" class="ys-pill">已替代</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="90" fixed="right" align="center">
|
<el-table-column label="操作" width="90" fixed="right" align="center">
|
||||||
@@ -137,6 +179,8 @@ async function submitRecalc(): Promise<void> {
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
|
||||||
|
<!-- 分页落进 card-foot:与表体同一张卡,卡底微渐变收边 -->
|
||||||
|
<div class="card-foot">
|
||||||
<AppPagination
|
<AppPagination
|
||||||
:total="total"
|
:total="total"
|
||||||
:page-num="pageNum"
|
:page-num="pageNum"
|
||||||
@@ -144,6 +188,8 @@ async function submitRecalc(): Promise<void> {
|
|||||||
@page-change="onPageChange"
|
@page-change="onPageChange"
|
||||||
@size-change="onSizeChange"
|
@size-change="onSizeChange"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<el-dialog v-model="recalcVisible" title="重算确认" width="460px">
|
<el-dialog v-model="recalcVisible" title="重算确认" width="460px">
|
||||||
<p class="recalc-warn">
|
<p class="recalc-warn">
|
||||||
@@ -166,9 +212,8 @@ async function submitRecalc(): Promise<void> {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.filters {
|
/* 筛选条(.filter-bar)、卡片与表尾(.card/.card-foot)的皮肤来自全局 index.css,
|
||||||
margin-bottom: 4px;
|
此处只保留本页特有的快照块与重算提示 */
|
||||||
}
|
|
||||||
|
|
||||||
.snapshot {
|
.snapshot {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -186,4 +231,5 @@ async function submitRecalc(): Promise<void> {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--ys-ink-3);
|
color: var(--ys-ink-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -217,11 +217,11 @@ onMounted(() => {
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
top: -180px;
|
top: -180px;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
width: 900px;
|
width: 860px;
|
||||||
height: 480px;
|
height: 460px;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
background: radial-gradient(closest-side, rgba(46, 75, 168, 0.09), rgba(46, 75, 168, 0));
|
background: radial-gradient(closest-side, rgba(46, 75, 168, 0.08), rgba(46, 75, 168, 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 右上涟漪:同心圆水波,部分出画 */
|
/* 右上涟漪:同心圆水波,部分出画 */
|
||||||
@@ -229,8 +229,8 @@ onMounted(() => {
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
top: -190px;
|
top: -190px;
|
||||||
right: -150px;
|
right: -150px;
|
||||||
width: 560px;
|
width: 520px;
|
||||||
height: 560px;
|
height: 520px;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,17 +242,17 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.login-ripple i:nth-child(2) {
|
.login-ripple i:nth-child(2) {
|
||||||
inset: 70px;
|
inset: 65px;
|
||||||
border-color: rgba(46, 75, 168, 0.075);
|
border-color: rgba(46, 75, 168, 0.075);
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-ripple i:nth-child(3) {
|
.login-ripple i:nth-child(3) {
|
||||||
inset: 140px;
|
inset: 130px;
|
||||||
border-color: rgba(46, 75, 168, 0.055);
|
border-color: rgba(46, 75, 168, 0.055);
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-ripple i:nth-child(4) {
|
.login-ripple i:nth-child(4) {
|
||||||
inset: 210px;
|
inset: 195px;
|
||||||
border: none;
|
border: none;
|
||||||
background: radial-gradient(circle, rgba(46, 75, 168, 0.05), rgba(46, 75, 168, 0.015));
|
background: radial-gradient(circle, rgba(46, 75, 168, 0.05), rgba(46, 75, 168, 0.015));
|
||||||
}
|
}
|
||||||
@@ -266,6 +266,12 @@ onMounted(() => {
|
|||||||
|
|
||||||
/* 登录卡:纸白微渐变 + 半透明描边 + 分层阴影 + 入场上浮 */
|
/* 登录卡:纸白微渐变 + 半透明描边 + 分层阴影 + 入场上浮 */
|
||||||
.login-card {
|
.login-card {
|
||||||
|
/*
|
||||||
|
* 卡内控件比列表页高一档(38px):登录是单任务场景,输入框更宽敞可减少误触;
|
||||||
|
* 覆盖组件基准变量,让输入框/按钮/验证码框一起长高,避免逐个子选择器硬编码。
|
||||||
|
*/
|
||||||
|
--el-component-size: 38px;
|
||||||
|
|
||||||
width: 400px;
|
width: 400px;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
padding: 30px 36px 32px;
|
padding: 30px 36px 32px;
|
||||||
@@ -307,20 +313,16 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.card-brand__slogan {
|
.card-brand__slogan {
|
||||||
margin-top: 8px;
|
margin: 8px 0 0;
|
||||||
font-size: 12.5px;
|
font-size: 12.5px;
|
||||||
letter-spacing: 0.02em;
|
letter-spacing: 0.02em;
|
||||||
color: var(--ys-ink-3);
|
color: var(--ys-ink-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 签名元素:渐隐靛蓝水线 */
|
/* 签名元素:渐变来自全局 .waterline,此处只收窄到 40px 并水平居中 */
|
||||||
.waterline {
|
.waterline {
|
||||||
display: block;
|
|
||||||
width: 40px;
|
width: 40px;
|
||||||
height: 2px;
|
|
||||||
margin: 14px auto 0;
|
margin: 14px auto 0;
|
||||||
border-radius: 1px;
|
|
||||||
background: linear-gradient(90deg, var(--ys-indigo), rgba(46, 75, 168, 0));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-head {
|
.card-head {
|
||||||
@@ -361,14 +363,14 @@ onMounted(() => {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 120px;
|
width: 104px;
|
||||||
height: 40px;
|
height: 38px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
background: linear-gradient(180deg, #f7f8fb, #f1f3f8);
|
background: linear-gradient(180deg, #f7f8fb, #f1f3f8);
|
||||||
border: 1px solid var(--ys-line);
|
border: 1px solid var(--ys-line);
|
||||||
border-radius: 7px;
|
border-radius: var(--ys-radius-sm);
|
||||||
box-shadow: inset 0 1px 2px rgba(28, 35, 51, 0.04);
|
box-shadow: inset 0 1px 2px rgba(28, 35, 51, 0.04);
|
||||||
transition: border-color 150ms var(--ys-ease, ease);
|
transition: border-color 150ms var(--ys-ease, ease);
|
||||||
}
|
}
|
||||||
@@ -405,16 +407,7 @@ onMounted(() => {
|
|||||||
color: var(--ys-ink-3);
|
color: var(--ys-ink-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 输入聚焦落在体系主色上(Element Plus 主色已随全局 token 换为靛蓝) */
|
/* 输入框的描边/焦点环已由全局 index.css 统一(登录卡只把控件高度调到 38px) */
|
||||||
.login :deep(.el-input__wrapper) {
|
|
||||||
box-shadow: 0 0 0 1px rgba(28, 35, 51, 0.13) inset;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login :deep(.el-input__wrapper.is-focus) {
|
|
||||||
box-shadow:
|
|
||||||
0 0 0 1px var(--ys-indigo) inset,
|
|
||||||
0 0 0 3px rgba(46, 75, 168, 0.13);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.login {
|
.login {
|
||||||
|
|||||||
@@ -155,7 +155,20 @@ function today(): string {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<el-form inline class="filters" @submit.prevent>
|
<!-- 页面头:一句话说清这张报表回答什么问题、口径是什么;水线每页只出现一次 -->
|
||||||
|
<div class="page-head">
|
||||||
|
<h1 class="page-title">业务统计</h1>
|
||||||
|
<p class="page-desc">
|
||||||
|
按业务看发货、回款与绩效的累计进度;回款与绩效是累计值,不随业务日期筛选变化
|
||||||
|
</p>
|
||||||
|
<span class="waterline"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
.filter-bar 直接挂在 el-form 上(去掉 inline):el-form 本身就是 flex 容器,
|
||||||
|
外面再包一层 div 会让 el-form-item 的 gap 排版失效。
|
||||||
|
-->
|
||||||
|
<el-form class="filter-bar" @submit.prevent>
|
||||||
<el-form-item label="关键字">
|
<el-form-item label="关键字">
|
||||||
<el-input
|
<el-input
|
||||||
v-model="filters.keyword"
|
v-model="filters.keyword"
|
||||||
@@ -218,21 +231,28 @@ function today(): string {
|
|||||||
style="width: 240px"
|
style="width: 240px"
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item>
|
<div class="filter-bar__actions">
|
||||||
<el-button type="primary" @click="search">查询</el-button>
|
<el-button type="primary" @click="search">查询</el-button>
|
||||||
<el-button @click="resetFilters">重置</el-button>
|
<el-button @click="resetFilters">重置</el-button>
|
||||||
<el-button :loading="exporting" @click="doExport">导出 Excel</el-button>
|
<el-button :loading="exporting" @click="doExport">导出 Excel</el-button>
|
||||||
<el-button @click="doPrint">打印</el-button>
|
<el-button @click="doPrint">打印</el-button>
|
||||||
</el-form-item>
|
</div>
|
||||||
</el-form>
|
</el-form>
|
||||||
|
|
||||||
<div class="hint">回款额与绩效为业务累计值,不随业务日期筛选变化</div>
|
<!-- 表格包进卡片:报表页的信息密度靠卡片边界收拢,分页条共用卡脚材质 -->
|
||||||
|
<div class="card">
|
||||||
<el-table v-loading="loading" :data="records" stripe @expand-change="loadDrill">
|
<el-table
|
||||||
|
v-loading="loading"
|
||||||
|
class="dense"
|
||||||
|
:data="records"
|
||||||
|
stripe
|
||||||
|
empty-text="还没有符合条件的业务 —— 先清空筛选看全量,或点「重置」重来"
|
||||||
|
@expand-change="loadDrill"
|
||||||
|
>
|
||||||
<el-table-column type="expand">
|
<el-table-column type="expand">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div class="drill-wrap">
|
<div class="drill-wrap">
|
||||||
<h4>回款明细</h4>
|
<p class="section-title">回款明细</p>
|
||||||
<el-table :data="receiptsMap[row.business_id] ?? []" size="small" stripe>
|
<el-table :data="receiptsMap[row.business_id] ?? []" size="small" stripe>
|
||||||
<el-table-column prop="receipt_batch_no" label="回款批次号" width="160" />
|
<el-table-column prop="receipt_batch_no" label="回款批次号" width="160" />
|
||||||
<el-table-column prop="receipt_date" label="回款日" width="110" />
|
<el-table-column prop="receipt_date" label="回款日" width="110" />
|
||||||
@@ -240,36 +260,68 @@ function today(): string {
|
|||||||
<el-table-column prop="settlement_status" label="入结状态" width="100" />
|
<el-table-column prop="settlement_status" label="入结状态" width="100" />
|
||||||
<el-table-column prop="remark" label="备注" min-width="140" />
|
<el-table-column prop="remark" label="备注" min-width="140" />
|
||||||
</el-table>
|
</el-table>
|
||||||
<h4>台账明细</h4>
|
<p class="section-title">台账明细</p>
|
||||||
<el-table :data="ledgersMap[row.business_id] ?? []" size="small" stripe>
|
<el-table :data="ledgersMap[row.business_id] ?? []" size="small" stripe>
|
||||||
<el-table-column prop="personnel_name" label="归属人员" width="120" />
|
<el-table-column prop="personnel_name" label="归属人员" width="120" />
|
||||||
<el-table-column prop="receipt_date" label="回款日" width="110" />
|
<el-table-column prop="receipt_date" label="回款日" width="110" />
|
||||||
<el-table-column prop="payment_days" label="天数" width="80" align="right" />
|
<el-table-column prop="payment_days" label="天数" width="80" align="right" />
|
||||||
<el-table-column prop="rate_text" label="计提比例" width="110" />
|
<el-table-column prop="rate_text" label="计提比例" width="110" />
|
||||||
<el-table-column prop="performance_amount" label="绩效金额" width="120" align="right" />
|
<el-table-column
|
||||||
|
prop="performance_amount"
|
||||||
|
label="绩效金额"
|
||||||
|
width="120"
|
||||||
|
align="right"
|
||||||
|
/>
|
||||||
<el-table-column prop="payable_amount" label="应付绩效" width="120" align="right" />
|
<el-table-column prop="payable_amount" label="应付绩效" width="120" align="right" />
|
||||||
</el-table>
|
</el-table>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="business_no" label="业务编号" min-width="160" />
|
<el-table-column prop="business_no" label="业务编号" min-width="160">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="cell-code">{{ row.business_no }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column prop="business_date" label="业务日期" width="110" />
|
<el-table-column prop="business_date" label="业务日期" width="110" />
|
||||||
<el-table-column prop="customer_name" label="客户" min-width="170" />
|
<el-table-column prop="customer_name" label="客户" min-width="170" />
|
||||||
<el-table-column prop="owner_personnel_name" label="归属人" width="110" />
|
<el-table-column prop="owner_personnel_name" label="归属人" width="110" />
|
||||||
<el-table-column label="状态" width="100">
|
<el-table-column label="状态" width="100">
|
||||||
<template #default="{ row }">{{ businessStatusLabel(row.status) }}</template>
|
<template #default="{ row }">{{ businessStatusLabel(row.status) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="total_sale_amount" label="销售额" width="120" align="right" />
|
<el-table-column prop="total_sale_amount" label="销售额" width="120" align="right">
|
||||||
<el-table-column prop="total_ship_amount" label="发货额" width="120" align="right" />
|
<template #default="{ row }">
|
||||||
<el-table-column prop="total_received_amount" label="回款额" width="120" align="right" />
|
<span class="cell-num">{{ row.total_sale_amount }}</span>
|
||||||
<el-table-column prop="performance_amount" label="绩效金额" width="120" align="right" />
|
</template>
|
||||||
<el-table-column prop="payable_amount" label="应付绩效" width="120" align="right" />
|
</el-table-column>
|
||||||
|
<el-table-column prop="total_ship_amount" label="发货额" width="120" align="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="cell-num">{{ row.total_ship_amount }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="total_received_amount" label="回款额" width="120" align="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="cell-num">{{ row.total_received_amount }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="performance_amount" label="绩效金额" width="120" align="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<!-- 绩效列加粗:一屏几十行时,加粗是读者定位「钱」的唯一线索 -->
|
||||||
|
<span class="cell-num cell-strong">{{ row.performance_amount }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="payable_amount" label="应付绩效" width="120" align="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="cell-num">{{ row.payable_amount }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
|
||||||
|
<!-- 合计与分页共用卡脚:合计靠 margin-right:auto 占到左侧,分页恒靠右 -->
|
||||||
|
<div class="card-foot">
|
||||||
<div v-if="summary" class="summary-bar">
|
<div v-if="summary" class="summary-bar">
|
||||||
全部筛选结果:{{ summary.row_count }} 笔业务 | 发货合计 {{ summary.ship_total ?? '0.00' }}
|
全部筛选结果:{{ summary.row_count }} 笔业务 | 发货合计
|
||||||
| 回款合计 {{ summary.received_total ?? '0.00' }} | 绩效合计
|
{{ summary.ship_total ?? '0.00' }} | 回款合计 {{ summary.received_total ?? '0.00' }} |
|
||||||
{{ summary.performance_total ?? '0.00' }}
|
绩效合计 {{ summary.performance_total ?? '0.00' }}
|
||||||
<span class="summary-note">(合计为全部筛选结果,非当前页)</span>
|
<span class="summary-note">(合计为全部筛选结果,非当前页)</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -282,39 +334,48 @@ function today(): string {
|
|||||||
@size-change="onSizeChange"
|
@size-change="onSizeChange"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.filters {
|
/*
|
||||||
margin-bottom: 4px;
|
* 密集表(设计稿 §09-3):报表要的是信息密度,行高与字号各降半档。
|
||||||
|
* 只写在页面 scoped 里 —— 写进全局会波及其它列表页的行高。
|
||||||
|
*/
|
||||||
|
.dense :deep(.el-table__cell) {
|
||||||
|
padding: 7px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hint {
|
.dense :deep(.el-table__cell .cell) {
|
||||||
margin-bottom: 8px;
|
font-size: 12.5px;
|
||||||
font-size: 12px;
|
|
||||||
color: var(--ys-ink-3);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.drill-wrap {
|
/* 绩效列加粗:设计稿要求「密集不等于看不清」 */
|
||||||
padding: 8px 16px;
|
.cell-strong {
|
||||||
}
|
|
||||||
|
|
||||||
.drill-wrap h4 {
|
|
||||||
margin: 8px 0;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 展开区:两块下钻表跟着密集表收内距,字号由 size="small" 与 .dense 决定 */
|
||||||
|
.drill-wrap {
|
||||||
|
padding: 10px 14px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drill-wrap .section-title {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 合计条:比正文加粗、字号放大半档,读者一眼能把「合计」与数据行分开 */
|
||||||
.summary-bar {
|
.summary-bar {
|
||||||
margin-top: 10px;
|
margin-right: auto;
|
||||||
padding: 8px 12px;
|
font-size: 13.5px;
|
||||||
font-size: 13px;
|
font-weight: 600;
|
||||||
background: var(--ys-indigo-wash);
|
color: var(--ys-ink);
|
||||||
border-radius: 6px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.summary-note {
|
.summary-note {
|
||||||
color: var(--ys-ink-3);
|
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--ys-ink-3);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -135,7 +135,20 @@ function pct(value: string): string {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<el-form inline class="filters" @submit.prevent>
|
<!-- 页面头:一句话说清这张报表回答什么问题、口径是什么;水线每页只出现一次 -->
|
||||||
|
<div class="page-head">
|
||||||
|
<h1 class="page-title">部门绩效汇总</h1>
|
||||||
|
<p class="page-desc">
|
||||||
|
按经理团队汇总当月绩效与应付;团队/全公司分成为月结同源口径,可与冻结额对账
|
||||||
|
</p>
|
||||||
|
<span class="waterline"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
.filter-bar 直接挂在 el-form 上(去掉 inline):el-form 本身就是 flex 容器,
|
||||||
|
外面再包一层 div 会让 el-form-item 的 gap 排版失效。
|
||||||
|
-->
|
||||||
|
<el-form class="filter-bar" @submit.prevent>
|
||||||
<el-form-item label="统计月份" required>
|
<el-form-item label="统计月份" required>
|
||||||
<el-date-picker
|
<el-date-picker
|
||||||
v-model="filters.settlement_month"
|
v-model="filters.settlement_month"
|
||||||
@@ -164,17 +177,20 @@ function pct(value: string): string {
|
|||||||
/>
|
/>
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item>
|
<div class="filter-bar__actions">
|
||||||
<el-button type="primary" :loading="loading" @click="load">查询</el-button>
|
<el-button type="primary" :loading="loading" @click="load">查询</el-button>
|
||||||
<el-button :loading="exporting" @click="doExport">导出 Excel</el-button>
|
<el-button :loading="exporting" @click="doExport">导出 Excel</el-button>
|
||||||
<el-button @click="doPrint">打印</el-button>
|
<el-button @click="doPrint">打印</el-button>
|
||||||
</el-form-item>
|
</div>
|
||||||
</el-form>
|
</el-form>
|
||||||
|
|
||||||
<div v-if="data?.params" class="params-hint">
|
<!-- 参数口径行:动态值,不能并进页面描述(描述是稳定的,参数随在线配置变) -->
|
||||||
|
<div v-if="data?.params" class="toolbar">
|
||||||
|
<span class="toolbar-note">
|
||||||
分成参数:团队 {{ data.params.team_text }}% | 全公司 {{ data.params.company_text }}% |
|
分成参数:团队 {{ data.params.team_text }}% | 全公司 {{ data.params.company_text }}% |
|
||||||
经理个人户{{ data.params.manager_personal_included === 1 ? '计入' : '不计入' }}基数 |
|
经理个人户{{ data.params.manager_personal_included === 1 ? '计入' : '不计入' }}基数 |
|
||||||
取值时点 {{ data.params.params_as_of }}
|
取值时点 {{ data.params.params_as_of }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-loading="loading" class="company-card">
|
<div v-loading="loading" class="company-card">
|
||||||
@@ -191,22 +207,46 @@ function pct(value: string): string {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-table v-loading="loading" :data="data?.teams ?? []" row-key="manager_id" stripe>
|
<div class="card">
|
||||||
|
<el-table
|
||||||
|
v-loading="loading"
|
||||||
|
class="dense"
|
||||||
|
:data="data?.teams ?? []"
|
||||||
|
row-key="manager_id"
|
||||||
|
stripe
|
||||||
|
empty-text="还没有这个月的团队绩效 —— 先确认统计月份,再点查询"
|
||||||
|
>
|
||||||
<el-table-column type="expand">
|
<el-table-column type="expand">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div class="member-wrap">
|
<div class="member-wrap">
|
||||||
<el-table :data="row.members" size="small" stripe>
|
<el-table :data="row.members" size="small" stripe>
|
||||||
<el-table-column prop="personnel_name" label="成员" width="120" />
|
<el-table-column prop="personnel_name" label="成员" width="120" />
|
||||||
<el-table-column prop="role_code" label="岗位" width="120" />
|
<el-table-column prop="role_code" label="岗位" width="120" />
|
||||||
<el-table-column prop="performance_sum" label="绩效金额" width="120" align="right" />
|
<el-table-column prop="performance_sum" label="绩效金额" width="120" align="right">
|
||||||
<el-table-column prop="payable_sum" label="应付绩效" width="120" align="right" />
|
<template #default="{ row: member }">
|
||||||
|
<span class="cell-num cell-strong">{{ member.performance_sum }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="payable_sum" label="应付绩效" width="120" align="right">
|
||||||
|
<template #default="{ row: member }">
|
||||||
|
<span class="cell-num">{{ member.payable_sum }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column label="调整补发" width="110" align="right">
|
<el-table-column label="调整补发" width="110" align="right">
|
||||||
<template #default="{ row: member }">{{ member.adjust_extra }}</template>
|
<template #default="{ row: member }">
|
||||||
|
<span class="cell-num">{{ member.adjust_extra }}</span>
|
||||||
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="调整扣减" width="110" align="right">
|
<el-table-column label="调整扣减" width="110" align="right">
|
||||||
<template #default="{ row: member }">{{ member.adjust_deduct }}</template>
|
<template #default="{ row: member }">
|
||||||
|
<span class="cell-num">{{ member.adjust_deduct }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="net_total" label="净合计" width="120" align="right">
|
||||||
|
<template #default="{ row: member }">
|
||||||
|
<span class="cell-num">{{ member.net_total }}</span>
|
||||||
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="net_total" label="净合计" width="120" align="right" />
|
|
||||||
<el-table-column label="操作" width="100" fixed="right">
|
<el-table-column label="操作" width="100" fixed="right">
|
||||||
<template #default="{ row: member }">
|
<template #default="{ row: member }">
|
||||||
<el-button link type="primary" @click="openPersonalDetail(member)">
|
<el-button link type="primary" @click="openPersonalDetail(member)">
|
||||||
@@ -227,47 +267,89 @@ function pct(value: string): string {
|
|||||||
</el-tag>
|
</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="member_count" label="团队人数" width="90" align="right" />
|
<el-table-column prop="member_count" label="团队人数" width="90" align="right">
|
||||||
<el-table-column prop="team_base" label="团队基数" width="120" align="right" />
|
<template #default="{ row }">
|
||||||
<el-table-column prop="team_share_amount" :label="teamShareLabel" width="150" align="right" />
|
<span class="cell-num">{{ row.member_count }}</span>
|
||||||
<el-table-column prop="company_share_amount" :label="companyShareLabel" width="150" align="right" />
|
</template>
|
||||||
<el-table-column label="占全公司比" width="110" align="right">
|
</el-table-column>
|
||||||
<template #default="{ row }">{{ pct(row.company_base_ratio) }}</template>
|
<el-table-column prop="team_base" label="团队基数" width="120" align="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="cell-num">{{ row.team_base }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column :label="teamShareLabel" width="150" align="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="cell-num">{{ row.team_share_amount }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column :label="companyShareLabel" width="150" align="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="cell-num">{{ row.company_share_amount }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="占全公司比" width="110" align="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="cell-num">{{ pct(row.company_base_ratio) }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="adjust_net" label="调整净额" width="110" align="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="cell-num">{{ row.adjust_net }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<!-- 本表没有单列绩效列,「钱」的落点就是净合计:按设计稿对绩效列的加粗处理 -->
|
||||||
|
<el-table-column prop="net_total" label="净合计" width="120" align="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="cell-num cell-strong">{{ row.net_total }}</span>
|
||||||
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="adjust_net" label="调整净额" width="110" align="right" />
|
|
||||||
<el-table-column prop="net_total" label="净合计" width="120" align="right" />
|
|
||||||
<el-table-column label="月结冻结额" width="130" align="right">
|
<el-table-column label="月结冻结额" width="130" align="right">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<span v-if="row.frozen_amount !== null" class="frozen">{{ row.frozen_amount }}</span>
|
<span v-if="row.frozen_amount !== null" class="cell-num frozen">
|
||||||
<span v-else class="frozen-none">未生成月结</span>
|
{{ row.frozen_amount }}
|
||||||
|
</span>
|
||||||
|
<span v-else class="cell-num frozen-none">未生成月结</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="data && data.unassigned.length" class="unassigned">
|
<div v-if="data && data.unassigned.length" class="unassigned">
|
||||||
<h4>未纳入团队基数(无经理 / 非营销经理上级的下属)</h4>
|
<p class="section-title">未纳入团队基数(无经理 / 非营销经理上级的下属)</p>
|
||||||
<el-table :data="data.unassigned" size="small" stripe>
|
<el-table :data="data.unassigned" size="small" stripe>
|
||||||
<el-table-column prop="personnel_name" label="人员" width="130" />
|
<el-table-column prop="personnel_name" label="人员" width="130" />
|
||||||
<el-table-column prop="role_code" label="岗位" width="120" />
|
<el-table-column prop="role_code" label="岗位" width="120" />
|
||||||
<el-table-column prop="performance_sum" label="绩效金额" width="130" align="right" />
|
<el-table-column prop="performance_sum" label="绩效金额" width="130" align="right">
|
||||||
<el-table-column prop="net_total" label="净合计" width="130" align="right" />
|
<template #default="{ row }">
|
||||||
|
<span class="cell-num">{{ row.performance_sum }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="net_total" label="净合计" width="130" align="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="cell-num">{{ row.net_total }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.filters {
|
/*
|
||||||
margin-bottom: 4px;
|
* 密集表(设计稿 §09-3):报表要的是信息密度,行高与字号各降半档。
|
||||||
|
* 只写在页面 scoped 里 —— 写进全局会波及其它列表页的行高。
|
||||||
|
*/
|
||||||
|
.dense :deep(.el-table__cell) {
|
||||||
|
padding: 7px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.params-hint {
|
.dense :deep(.el-table__cell .cell) {
|
||||||
margin-bottom: 10px;
|
font-size: 12.5px;
|
||||||
padding: 8px 12px;
|
}
|
||||||
font-size: 12px;
|
|
||||||
color: var(--ys-ink-2);
|
/* 绩效列加粗:设计稿要求「密集不等于看不清」 */
|
||||||
background: var(--ys-mist);
|
.cell-strong {
|
||||||
border-radius: 6px;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.company-card {
|
.company-card {
|
||||||
@@ -277,7 +359,8 @@ function pct(value: string): string {
|
|||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
background: var(--ys-indigo-wash);
|
background: var(--ys-indigo-wash);
|
||||||
border-radius: 8px;
|
border: 1px solid var(--ys-line);
|
||||||
|
border-radius: var(--ys-radius);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -290,8 +373,9 @@ function pct(value: string): string {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 展开区:内容字号与内距跟着密集表再收半档,不与主表抢注意力 */
|
||||||
.member-wrap {
|
.member-wrap {
|
||||||
padding: 8px 16px;
|
padding: 8px 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.frozen {
|
.frozen {
|
||||||
@@ -306,10 +390,4 @@ function pct(value: string): string {
|
|||||||
.unassigned {
|
.unassigned {
|
||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.unassigned h4 {
|
|
||||||
margin: 0 0 8px;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
@@ -161,7 +161,20 @@ function today(): string {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<el-form inline class="filters" @submit.prevent>
|
<!-- 页面头:一句话说清这张报表回答什么问题、口径是什么;水线每页只出现一次 -->
|
||||||
|
<div class="page-head">
|
||||||
|
<h1 class="page-title">回款统计</h1>
|
||||||
|
<p class="page-desc">
|
||||||
|
按回款天数区间看回款结构与绩效分布;只统计已计提(有台账行)的回款,空区间记 0
|
||||||
|
</p>
|
||||||
|
<span class="waterline"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
.filter-bar 直接挂在 el-form 上(去掉 inline):el-form 本身就是 flex 容器,
|
||||||
|
外面再包一层 div 会让 el-form-item 的 gap 排版失效。
|
||||||
|
-->
|
||||||
|
<el-form class="filter-bar" @submit.prevent>
|
||||||
<el-form-item label="月份">
|
<el-form-item label="月份">
|
||||||
<el-date-picker
|
<el-date-picker
|
||||||
v-model="filters.settlement_month"
|
v-model="filters.settlement_month"
|
||||||
@@ -205,24 +218,23 @@ function today(): string {
|
|||||||
<el-option label="混合业务" value="mixed" />
|
<el-option label="混合业务" value="mixed" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item>
|
<div class="filter-bar__actions">
|
||||||
<el-button type="primary" :loading="loading" @click="load">查询</el-button>
|
<el-button type="primary" :loading="loading" @click="load">查询</el-button>
|
||||||
<el-button :loading="exporting" @click="doExport">导出 Excel</el-button>
|
<el-button :loading="exporting" @click="doExport">导出 Excel</el-button>
|
||||||
<el-button @click="doPrint">打印</el-button>
|
<el-button @click="doPrint">打印</el-button>
|
||||||
</el-form-item>
|
</div>
|
||||||
</el-form>
|
</el-form>
|
||||||
|
|
||||||
<div class="hint">
|
<!-- 表格包进卡片:报表页的信息密度靠卡片边界收拢,合计行共用卡脚材质 -->
|
||||||
统计口径:已计提回款(有台账行)的金额与绩效;进度登记回款不计入。空区间显示 0。
|
<div class="card">
|
||||||
</div>
|
|
||||||
|
|
||||||
<el-table
|
<el-table
|
||||||
v-loading="loading"
|
v-loading="loading"
|
||||||
class="bucket-table"
|
class="bucket-table dense"
|
||||||
data-testid="payment-bucket-table"
|
data-testid="payment-bucket-table"
|
||||||
:data="data?.buckets ?? []"
|
:data="data?.buckets ?? []"
|
||||||
row-key="code"
|
row-key="code"
|
||||||
stripe
|
stripe
|
||||||
|
empty-text="所选条件下还没有回款记录 —— 先选择月份再查一次"
|
||||||
@expand-change="loadDrill"
|
@expand-change="loadDrill"
|
||||||
>
|
>
|
||||||
<el-table-column type="expand">
|
<el-table-column type="expand">
|
||||||
@@ -241,16 +253,21 @@ function today(): string {
|
|||||||
<el-table-column prop="personnel_name" label="归属人员" width="110" />
|
<el-table-column prop="personnel_name" label="归属人员" width="110" />
|
||||||
<el-table-column prop="payment_days" label="天数" width="80" align="right" />
|
<el-table-column prop="payment_days" label="天数" width="80" align="right" />
|
||||||
<el-table-column label="客户类型" width="100">
|
<el-table-column label="客户类型" width="100">
|
||||||
<template #default="{ row }">
|
<template #default="{ row: detail }">
|
||||||
{{ customerTypeLabel(row.customer_type) }}
|
{{ customerTypeLabel(detail.customer_type) }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="产品类别" width="100">
|
<el-table-column label="产品类别" width="100">
|
||||||
<template #default="{ row }">
|
<template #default="{ row: detail }">
|
||||||
{{ reportApi.reportCategoryLabel(row.product_category) }}
|
{{ reportApi.reportCategoryLabel(detail.product_category) }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="performance_amount" label="绩效金额" width="120" align="right" />
|
<el-table-column
|
||||||
|
prop="performance_amount"
|
||||||
|
label="绩效金额"
|
||||||
|
width="120"
|
||||||
|
align="right"
|
||||||
|
/>
|
||||||
</el-table>
|
</el-table>
|
||||||
<AppPagination
|
<AppPagination
|
||||||
:total="drillOf(row.code).total"
|
:total="drillOf(row.code).total"
|
||||||
@@ -263,36 +280,86 @@ function today(): string {
|
|||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="label" label="回款天数区间" min-width="150" />
|
<el-table-column prop="label" label="回款天数区间" min-width="150" />
|
||||||
<el-table-column prop="receipt_amount" label="回款金额" width="140" align="right" />
|
<el-table-column prop="receipt_amount" label="回款金额" width="140" align="right">
|
||||||
<el-table-column prop="receipt_count" label="回款笔数" width="100" align="right" />
|
<template #default="{ row }">
|
||||||
<el-table-column prop="performance_amount" label="绩效金额" width="130" align="right" />
|
<span class="cell-num">{{ row.receipt_amount }}</span>
|
||||||
<el-table-column prop="payable_amount" label="应付绩效" width="130" align="right" />
|
</template>
|
||||||
<el-table-column prop="ledger_count" label="台账行数" width="100" align="right" />
|
</el-table-column>
|
||||||
|
<el-table-column prop="receipt_count" label="回款笔数" width="100" align="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="cell-num">{{ row.receipt_count }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="performance_amount" label="绩效金额" width="130" align="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<!-- 绩效列加粗:一屏几十行时,加粗是读者定位「钱」的唯一线索 -->
|
||||||
|
<span class="cell-num cell-strong">{{ row.performance_amount }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="payable_amount" label="应付绩效" width="130" align="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="cell-num">{{ row.payable_amount }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="ledger_count" label="台账行数" width="100" align="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="cell-num">{{ row.ledger_count }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
|
||||||
|
<!-- 合计行:本表无分页,卡脚只承载合计;加粗并放大半档,与数据行区分 -->
|
||||||
|
<div class="card-foot">
|
||||||
<div v-if="data" class="summary-bar">
|
<div v-if="data" class="summary-bar">
|
||||||
合计:回款 {{ data.totals.receipt_amount ?? '0.00' }} | 笔数
|
合计:回款 {{ data.totals.receipt_amount ?? '0.00' }} | 笔数
|
||||||
{{ data.totals.receipt_count ?? 0 }} | 绩效 {{ data.totals.performance_amount ?? '0.00' }}
|
{{ data.totals.receipt_count ?? 0 }} | 绩效
|
||||||
| 应付 {{ data.totals.payable_amount ?? '0.00' }}
|
{{ data.totals.performance_amount ?? '0.00' }} | 应付
|
||||||
|
{{ data.totals.payable_amount ?? '0.00' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="data" class="dimension-grid">
|
<div v-if="data" class="dimension-grid">
|
||||||
<div>
|
<div>
|
||||||
<h4>客户类型对比</h4>
|
<p class="section-title">客户类型对比</p>
|
||||||
<el-table :data="data.by_customer_type" size="small" stripe>
|
<el-table :data="data.by_customer_type" size="small" stripe>
|
||||||
<el-table-column prop="label" label="客户类型" width="120" />
|
<el-table-column prop="label" label="客户类型" width="120" />
|
||||||
<el-table-column prop="receipt_amount" label="回款金额" width="130" align="right" />
|
<el-table-column prop="receipt_amount" label="回款金额" width="130" align="right">
|
||||||
<el-table-column prop="performance_amount" label="绩效金额" width="130" align="right" />
|
<template #default="{ row }">
|
||||||
<el-table-column prop="ledger_count" label="台账行数" width="100" align="right" />
|
<span class="cell-num">{{ row.receipt_amount }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="performance_amount" label="绩效金额" width="130" align="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="cell-num">{{ row.performance_amount }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="ledger_count" label="台账行数" width="100" align="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="cell-num">{{ row.ledger_count }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h4>产品类别对比</h4>
|
<p class="section-title">产品类别对比</p>
|
||||||
<el-table :data="data.by_product_category" size="small" stripe>
|
<el-table :data="data.by_product_category" size="small" stripe>
|
||||||
<el-table-column prop="label" label="产品类别" width="120" />
|
<el-table-column prop="label" label="产品类别" width="120" />
|
||||||
<el-table-column prop="receipt_amount" label="回款金额" width="130" align="right" />
|
<el-table-column prop="receipt_amount" label="回款金额" width="130" align="right">
|
||||||
<el-table-column prop="performance_amount" label="绩效金额" width="130" align="right" />
|
<template #default="{ row }">
|
||||||
<el-table-column prop="ledger_count" label="台账行数" width="100" align="right" />
|
<span class="cell-num">{{ row.receipt_amount }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="performance_amount" label="绩效金额" width="130" align="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="cell-num">{{ row.performance_amount }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="ledger_count" label="台账行数" width="100" align="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="cell-num">{{ row.ledger_count }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -300,26 +367,34 @@ function today(): string {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.filters {
|
/*
|
||||||
margin-bottom: 4px;
|
* 密集表(设计稿 §09-3):报表要的是信息密度,行高与字号各降半档。
|
||||||
|
* 只写在页面 scoped 里 —— 写进全局会波及其它列表页的行高。
|
||||||
|
*/
|
||||||
|
.dense :deep(.el-table__cell) {
|
||||||
|
padding: 7px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hint {
|
.dense :deep(.el-table__cell .cell) {
|
||||||
margin-bottom: 8px;
|
font-size: 12.5px;
|
||||||
font-size: 12px;
|
|
||||||
color: var(--ys-ink-3);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 绩效列加粗:设计稿要求「密集不等于看不清」 */
|
||||||
|
.cell-strong {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 展开区:内距跟着密集表收半档,字号由 size="small" 与 .dense 决定 */
|
||||||
.drill-wrap {
|
.drill-wrap {
|
||||||
padding: 8px 16px;
|
padding: 10px 14px 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 合计条:比正文加粗、字号放大半档,读者一眼能把「合计」与数据行分开 */
|
||||||
.summary-bar {
|
.summary-bar {
|
||||||
margin-top: 10px;
|
margin-right: auto;
|
||||||
padding: 8px 12px;
|
font-size: 13.5px;
|
||||||
font-size: 13px;
|
font-weight: 600;
|
||||||
background: var(--ys-indigo-wash);
|
color: var(--ys-ink);
|
||||||
border-radius: 6px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dimension-grid {
|
.dimension-grid {
|
||||||
@@ -328,10 +403,4 @@ function today(): string {
|
|||||||
gap: 20px;
|
gap: 20px;
|
||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dimension-grid h4 {
|
|
||||||
margin: 0 0 8px;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
@@ -170,7 +170,18 @@ function today(): string {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<el-form inline class="filters" @submit.prevent>
|
<!-- 页面头:一句话说清这张报表回答什么问题、口径是什么;水线每页只出现一次 -->
|
||||||
|
<div class="page-head">
|
||||||
|
<h1 class="page-title">个人绩效明细</h1>
|
||||||
|
<p class="page-desc">按人拆到每一笔回款;绩效 = 回款 × 计提比例 × 权重</p>
|
||||||
|
<span class="waterline"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
.filter-bar 直接挂在 el-form 上(去掉 inline):el-form 本身就是 flex 容器,
|
||||||
|
外面再包一层 div 会让 el-form-item 的 gap 排版失效。
|
||||||
|
-->
|
||||||
|
<el-form class="filter-bar" @submit.prevent>
|
||||||
<el-form-item label="月份">
|
<el-form-item label="月份">
|
||||||
<el-date-picker
|
<el-date-picker
|
||||||
v-model="filters.settlement_month"
|
v-model="filters.settlement_month"
|
||||||
@@ -229,15 +240,24 @@ function today(): string {
|
|||||||
/>
|
/>
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item>
|
<div class="filter-bar__actions">
|
||||||
<el-button type="primary" @click="search">查询</el-button>
|
<el-button type="primary" @click="search">查询</el-button>
|
||||||
<el-button @click="resetFilters">重置</el-button>
|
<el-button @click="resetFilters">重置</el-button>
|
||||||
<el-button :loading="exporting" @click="doExport">导出 Excel</el-button>
|
<el-button :loading="exporting" @click="doExport">导出 Excel</el-button>
|
||||||
<el-button @click="doPrint">打印</el-button>
|
<el-button @click="doPrint">打印</el-button>
|
||||||
</el-form-item>
|
</div>
|
||||||
</el-form>
|
</el-form>
|
||||||
|
|
||||||
<el-table v-loading="loading" :data="records" stripe @expand-change="loadSnapshot">
|
<!-- 表格包进卡片:报表页的信息密度靠卡片边界收拢,分页条共用卡脚材质 -->
|
||||||
|
<div class="card">
|
||||||
|
<el-table
|
||||||
|
v-loading="loading"
|
||||||
|
class="dense"
|
||||||
|
:data="records"
|
||||||
|
stripe
|
||||||
|
empty-text="还没有符合条件的明细 —— 换个月份或放宽人员再查一次"
|
||||||
|
@expand-change="loadSnapshot"
|
||||||
|
>
|
||||||
<el-table-column type="expand">
|
<el-table-column type="expand">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<pre class="snapshot">{{
|
<pre class="snapshot">{{
|
||||||
@@ -245,27 +265,53 @@ function today(): string {
|
|||||||
}}</pre>
|
}}</pre>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="business_no" label="合同编号" min-width="160" />
|
<el-table-column prop="business_no" label="合同编号" min-width="160">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="cell-code">{{ row.business_no }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column prop="ship_date" label="发货日" width="105" />
|
<el-table-column prop="ship_date" label="发货日" width="105" />
|
||||||
<el-table-column prop="receipt_date" label="回款日" width="105" />
|
<el-table-column prop="receipt_date" label="回款日" width="105" />
|
||||||
<el-table-column prop="payment_days" label="回款天数" width="90" align="right" />
|
<el-table-column prop="payment_days" label="回款天数" width="90" align="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="cell-num">{{ row.payment_days }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column label="产品类别" width="100">
|
<el-table-column label="产品类别" width="100">
|
||||||
<template #default="{ row }">{{ reportApi.reportCategoryLabel(row.product_category) }}</template>
|
<template #default="{ row }">{{
|
||||||
|
reportApi.reportCategoryLabel(row.product_category)
|
||||||
|
}}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="客户类型" width="100">
|
<el-table-column label="客户类型" width="100">
|
||||||
<template #default="{ row }">{{ customerTypeLabel(row.customer_type) }}</template>
|
<template #default="{ row }">{{ customerTypeLabel(row.customer_type) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="role_name" label="岗位" width="100" />
|
<el-table-column prop="role_name" label="岗位" width="100" />
|
||||||
<el-table-column prop="rate_text" label="计提比例" width="110" />
|
<el-table-column prop="rate_text" label="计提比例" width="110">
|
||||||
<el-table-column prop="weight_r" label="支付权重R" width="95" align="right" />
|
<template #default="{ row }">
|
||||||
|
<span class="cell-num">{{ row.rate_text }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="weight_r" label="支付权重R" width="95" align="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="cell-num">{{ row.weight_r }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column prop="performance_amount" label="绩效金额" width="110" align="right">
|
<el-table-column prop="performance_amount" label="绩效金额" width="110" align="right">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<span :class="{ negative: Number(row.performance_amount) < 0 }">
|
<!-- 绩效列加粗:一屏几十行时,加粗是读者定位「钱」的唯一线索 -->
|
||||||
|
<span
|
||||||
|
class="cell-num cell-strong"
|
||||||
|
:class="{ negative: Number(row.performance_amount) < 0 }"
|
||||||
|
>
|
||||||
{{ row.performance_amount ?? '—' }}
|
{{ row.performance_amount ?? '—' }}
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="payable_amount" label="应付绩效" width="110" align="right" />
|
<el-table-column prop="payable_amount" label="应付绩效" width="110" align="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="cell-num">{{ row.payable_amount }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column label="状态" width="90">
|
<el-table-column label="状态" width="90">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag v-if="row.is_probation === 1" size="small" type="warning" disable-transitions>
|
<el-tag v-if="row.is_probation === 1" size="small" type="warning" disable-transitions>
|
||||||
@@ -278,9 +324,12 @@ function today(): string {
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
|
||||||
|
<!-- 合计与分页共用卡脚:合计靠 margin-right:auto 占到左侧,分页恒靠右 -->
|
||||||
|
<div class="card-foot">
|
||||||
<div v-if="summary" class="summary-bar">
|
<div v-if="summary" class="summary-bar">
|
||||||
全部筛选结果:{{ summary.row_count }} 行 | 绩效合计 {{ summary.performance_total ?? '0.00' }}
|
全部筛选结果:{{ summary.row_count }} 行 | 绩效合计
|
||||||
| 应付合计 {{ summary.payable_total ?? '0.00' }}
|
{{ summary.performance_total ?? '0.00' }} | 应付合计
|
||||||
|
{{ summary.payable_total ?? '0.00' }}
|
||||||
<span class="summary-note">(合计为全部筛选结果,非当前页)</span>
|
<span class="summary-note">(合计为全部筛选结果,非当前页)</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -294,38 +343,62 @@ function today(): string {
|
|||||||
@size-change="onSizeChange"
|
@size-change="onSizeChange"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.filters {
|
/*
|
||||||
margin-bottom: 4px;
|
* 密集表(设计稿 §09-3):报表要的是信息密度,行高与字号各降半档。
|
||||||
|
* 只写在页面 scoped 里 —— 写进全局会波及其它列表页的行高。
|
||||||
|
*/
|
||||||
|
.dense :deep(.el-table__cell) {
|
||||||
|
padding: 7px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dense :deep(.el-table__cell .cell) {
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 金额/比例 span 变块级:行内元素的 text-align 无效,只有块级才真正右对齐;
|
||||||
|
* 表头保持左对齐(若改用列 align 会连表头一起右移,与设计稿不符)。
|
||||||
|
*/
|
||||||
|
.cell-num {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 绩效列加粗:设计稿要求「密集不等于看不清」 */
|
||||||
|
.cell-strong {
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.snapshot {
|
.snapshot {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 10px;
|
padding: 12px 14px;
|
||||||
font-size: 12px;
|
font-size: 12.5px;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
background: #f4f5f8;
|
background: var(--ys-mist);
|
||||||
border-radius: 6px;
|
border-radius: var(--ys-radius-sm);
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
|
|
||||||
.negative {
|
.negative {
|
||||||
color: #c45656;
|
color: var(--ys-danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 合计条:比正文加粗、字号放大半档,读者一眼能把「合计」与数据行分开 */
|
||||||
.summary-bar {
|
.summary-bar {
|
||||||
margin-top: 10px;
|
margin-right: auto;
|
||||||
padding: 8px 12px;
|
font-size: 13.5px;
|
||||||
font-size: 13px;
|
font-weight: 600;
|
||||||
background: var(--ys-indigo-wash);
|
color: var(--ys-ink);
|
||||||
border-radius: 6px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.summary-note {
|
.summary-note {
|
||||||
color: var(--ys-ink-3);
|
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--ys-ink-3);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -171,23 +171,35 @@ onMounted(load)
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="print-page" v-loading="loading">
|
<div class="print-page" v-loading="loading">
|
||||||
<div class="no-print toolbar">
|
<!-- 屏幕操作条:打印时随 .no-print 一起消失,纸面上不出现任何按钮 -->
|
||||||
<el-button type="primary" @click="doPrint">打印 / 另存为 PDF</el-button>
|
<div class="no-print print-bar">
|
||||||
<span class="toolbar-hint">在打印对话框选择「另存为 PDF」即可导出 PDF</span>
|
<div class="print-bar__crumb">
|
||||||
|
<b>报表打印</b>
|
||||||
|
<span class="print-bar__sep">/</span>
|
||||||
|
<span>{{ title }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="print-bar__actions">
|
||||||
|
<span class="print-bar__hint">在打印对话框选择「另存为 PDF」即可导出</span>
|
||||||
|
<el-button type="primary" class="print-bar__btn" @click="doPrint">
|
||||||
|
打印 / 另存为 PDF
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 稿纸:与屏幕稿不同源 —— 白底、实线表格、章节编号、签字栏,无圆角与阴影 -->
|
||||||
|
<div class="sheet">
|
||||||
<header class="sheet-header">
|
<header class="sheet-header">
|
||||||
<img v-if="logoUrl" :src="logoUrl" class="logo" alt="公司 logo" />
|
<img v-if="logoUrl" :src="logoUrl" class="logo" alt="公司 logo" />
|
||||||
<div class="company">{{ companyName }}</div>
|
<div class="company">{{ companyName }}</div>
|
||||||
<h1 class="title">{{ title }}</h1>
|
<h1 class="title">{{ title }}</h1>
|
||||||
<div class="meta">
|
<div class="sheet-meta">筛选条件:{{ filterText }}</div>
|
||||||
<span>筛选条件:{{ filterText }}</span>
|
<div class="sheet-meta">
|
||||||
<span>打印时间:{{ printedAt.toLocaleString('zh-CN') }} | 模板版本 V1.2</span>
|
打印时间:{{ printedAt.toLocaleString('zh-CN') }} | 模板版本 V1.2
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div v-if="truncated" class="truncated">
|
<div v-if="truncated" class="truncated">
|
||||||
数据量超过打印上限({{ PRINT_MAX_ROWS }} 行),仅打印前 {{ PRINT_MAX_ROWS }}
|
数据量超过打印上限({{ PRINT_MAX_ROWS }} 行),本页只含前 {{ PRINT_MAX_ROWS }}
|
||||||
行;请缩小筛选范围后重新打印。
|
行;请缩小筛选范围后重新打印。
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -195,9 +207,9 @@ onMounted(load)
|
|||||||
<table class="sheet-table">
|
<table class="sheet-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>合同编号</th><th>发货日</th><th>回款日</th><th>回款天数</th>
|
<th>合同编号</th><th>发货日</th><th>回款日</th><th class="num">回款天数</th>
|
||||||
<th>产品类别</th><th>客户类型</th><th>岗位</th><th>计提比例</th>
|
<th>产品类别</th><th>客户类型</th><th>岗位</th><th class="num">计提比例</th>
|
||||||
<th>支付权重R</th><th>绩效金额</th><th>应付绩效</th>
|
<th class="num">支付权重R</th><th class="num">绩效金额</th><th class="num">应付绩效</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -223,9 +235,12 @@ onMounted(load)
|
|||||||
<table class="sheet-table" data-testid="print-department-teams">
|
<table class="sheet-table" data-testid="print-department-teams">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>序号</th><th>经理团队</th><th>状态</th><th>团队人数</th><th>团队基数</th>
|
<th>序号</th><th>经理团队</th><th>状态</th>
|
||||||
<th>团队分成({{ department.params?.team_text ?? '—' }}%)</th>
|
<th class="num">团队人数</th><th class="num">团队基数</th>
|
||||||
<th>调整净额(仅核对)</th><th>团队应发小计(含聚合分成)</th><th>月结冻结额</th>
|
<th class="num">团队分成({{ department.params?.team_text ?? '—' }}%)</th>
|
||||||
|
<th class="num">调整净额(仅核对)</th>
|
||||||
|
<th class="num">团队应发小计(含聚合分成)</th>
|
||||||
|
<th class="num">月结冻结额</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -250,8 +265,9 @@ onMounted(load)
|
|||||||
<table class="sheet-table" data-testid="print-company-block">
|
<table class="sheet-table" data-testid="print-company-block">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>全公司基数</th><th>分成比例</th><th>单经理分成</th>
|
<th class="num">全公司基数</th><th class="num">分成比例</th>
|
||||||
<th>计入次数</th><th>分成合计</th>
|
<th class="num">单经理分成</th>
|
||||||
|
<th class="num">计入次数</th><th class="num">分成合计</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -268,7 +284,7 @@ onMounted(load)
|
|||||||
<h2 class="block-title">三、对账汇总</h2>
|
<h2 class="block-title">三、对账汇总</h2>
|
||||||
<table class="sheet-table" data-testid="print-reconciliation">
|
<table class="sheet-table" data-testid="print-reconciliation">
|
||||||
<thead>
|
<thead>
|
||||||
<tr><th>项目</th><th>金额</th><th>说明</th></tr>
|
<tr><th>项目</th><th class="num">金额</th><th>说明</th></tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -335,8 +351,10 @@ onMounted(load)
|
|||||||
<table class="sheet-table" data-testid="print-department-members">
|
<table class="sheet-table" data-testid="print-department-members">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>经理团队</th><th>成员</th><th>岗位</th><th>来源</th><th>绩效金额</th>
|
<th>经理团队</th><th>成员</th><th>岗位</th><th>来源</th>
|
||||||
<th>应付绩效</th><th>调整补发</th><th>调整扣减</th><th>调整净额</th><th>净合计</th>
|
<th class="num">绩效金额</th><th class="num">应付绩效</th>
|
||||||
|
<th class="num">调整补发</th><th class="num">调整扣减</th>
|
||||||
|
<th class="num">调整净额</th><th class="num">净合计</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -362,8 +380,10 @@ onMounted(load)
|
|||||||
<table class="sheet-table" data-testid="print-unassigned">
|
<table class="sheet-table" data-testid="print-unassigned">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>人员</th><th>岗位</th><th>未纳入原因</th><th>绩效金额</th><th>应付绩效</th>
|
<th>人员</th><th>岗位</th><th>未纳入原因</th>
|
||||||
<th>调整补发</th><th>调整扣减</th><th>调整净额</th><th>净合计</th>
|
<th class="num">绩效金额</th><th class="num">应付绩效</th>
|
||||||
|
<th class="num">调整补发</th><th class="num">调整扣减</th>
|
||||||
|
<th class="num">调整净额</th><th class="num">净合计</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -393,7 +413,8 @@ onMounted(load)
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>业务编号</th><th>业务日期</th><th>客户</th><th>归属人</th><th>状态</th>
|
<th>业务编号</th><th>业务日期</th><th>客户</th><th>归属人</th><th>状态</th>
|
||||||
<th>销售额</th><th>发货额</th><th>回款额</th><th>绩效金额</th><th>应付绩效</th>
|
<th class="num">销售额</th><th class="num">发货额</th><th class="num">回款额</th>
|
||||||
|
<th class="num">绩效金额</th><th class="num">应付绩效</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -417,8 +438,8 @@ onMounted(load)
|
|||||||
<table class="sheet-table">
|
<table class="sheet-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>回款天数区间</th><th>回款金额</th><th>回款笔数</th>
|
<th>回款天数区间</th><th class="num">回款金额</th><th class="num">回款笔数</th>
|
||||||
<th>绩效金额</th><th>应付绩效</th><th>台账行数</th>
|
<th class="num">绩效金额</th><th class="num">应付绩效</th><th class="num">台账行数</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -436,7 +457,10 @@ onMounted(load)
|
|||||||
<h3>{{ group.title }}</h3>
|
<h3>{{ group.title }}</h3>
|
||||||
<table class="sheet-table">
|
<table class="sheet-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr><th>维度</th><th>回款金额</th><th>绩效金额</th><th>台账行数</th></tr>
|
<tr>
|
||||||
|
<th>维度</th><th class="num">回款金额</th>
|
||||||
|
<th class="num">绩效金额</th><th class="num">台账行数</th>
|
||||||
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="row in group.rows" :key="String(row.code)">
|
<tr v-for="row in group.rows" :key="String(row.code)">
|
||||||
@@ -450,108 +474,182 @@ onMounted(load)
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<footer class="signature">
|
<!-- 三栏签字:制表 / 复核 / 审批,栏位用上横线表示手写位置 -->
|
||||||
<div>财务复核:____________________</div>
|
<footer class="sign-row">
|
||||||
<div>领导签字:____________________</div>
|
<div>制表:____________</div>
|
||||||
<div>日期:______年__月__日</div>
|
<div>复核:____________</div>
|
||||||
|
<div>审批:____________</div>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
/*
|
||||||
|
* 打印视图:屏幕上是「灰画布 + 白稿纸」,打印时退化为纯纸面。
|
||||||
|
* 画布灰比内容页沉一档,让白稿纸一眼是「一张纸」而不是页面里的一个区块。
|
||||||
|
*/
|
||||||
.print-page {
|
.print-page {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
padding: 24px;
|
padding-bottom: 28px;
|
||||||
background: #fff;
|
background: #eceef2;
|
||||||
color: #1c2333;
|
color: var(--ys-ink);
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbar {
|
/* 屏幕操作条:只在屏幕上出现,纸面上不出现任何按钮 */
|
||||||
|
.print-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
height: 52px;
|
||||||
|
padding: 0 22px;
|
||||||
|
margin-bottom: 22px;
|
||||||
|
background: linear-gradient(180deg, #ffffff, #fdfdfe);
|
||||||
|
border-bottom: 1px solid var(--ys-line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-bar__crumb {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--ys-ink-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-bar__crumb b {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ys-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-bar__sep {
|
||||||
|
color: var(--ys-line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-bar__actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbar-hint {
|
.print-bar__hint {
|
||||||
|
font-size: 12px;
|
||||||
color: var(--ys-ink-3);
|
color: var(--ys-ink-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 顶栏内的按钮压到 30px/12px:与页面顶栏的节奏一致(全局按钮是 34px) */
|
||||||
|
.print-bar__btn {
|
||||||
|
height: 30px;
|
||||||
|
padding: 0 14px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 稿纸:按 A4 横向版心宽度定宽,屏幕预览与打印同宽 ——
|
||||||
|
* 两套宽度会让「预览时一页放得下、打出来却折行」。
|
||||||
|
*/
|
||||||
|
.sheet {
|
||||||
|
width: 1040px;
|
||||||
|
max-width: calc(100% - 44px);
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 26px 30px 30px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
.sheet-header {
|
.sheet-header {
|
||||||
margin-bottom: 16px;
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo {
|
.logo {
|
||||||
max-height: 48px;
|
max-height: 44px;
|
||||||
margin-bottom: 6px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.company {
|
.company {
|
||||||
font-size: 18px;
|
font-size: 13px;
|
||||||
font-weight: 700;
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
color: var(--ys-ink-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
margin: 6px 0;
|
margin: 4px 0 0;
|
||||||
font-size: 20px;
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.meta {
|
.sheet-meta {
|
||||||
display: flex;
|
margin-top: 4px;
|
||||||
justify-content: space-between;
|
font-size: 11px;
|
||||||
font-size: 12px;
|
color: var(--ys-ink-3);
|
||||||
color: #5b6577;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 截断提示:纸面上也要让人一眼看出「这不是全部数据」 */
|
||||||
.truncated {
|
.truncated {
|
||||||
margin-bottom: 10px;
|
margin-top: 12px;
|
||||||
padding: 8px 12px;
|
padding: 6px 10px;
|
||||||
font-size: 12px;
|
font-size: 11px;
|
||||||
color: #8a5a00;
|
color: #8a5a00;
|
||||||
background: #fdf6e3;
|
background: #fbf4e4;
|
||||||
border-radius: 4px;
|
border: 1px solid #f0e2c4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 稿面表格:纸面口径 —— 11px、细实线 #d9dde5、表头浅底左对齐。
|
||||||
|
* th 一律左对齐,只有数值列右对齐(与数据行同向),否则表头与数字错位。
|
||||||
|
*/
|
||||||
.sheet-table {
|
.sheet-table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
margin-top: 8px;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
font-size: 12px;
|
font-size: 11px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sheet-table th,
|
.sheet-table th,
|
||||||
.sheet-table td {
|
.sheet-table td {
|
||||||
padding: 6px 8px;
|
padding: 4px 7px;
|
||||||
border: 1px solid #b0b7c3;
|
text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
|
border: 1px solid #d9dde5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sheet-table th {
|
.sheet-table th {
|
||||||
background: #dde3f0;
|
background: #f6f7f9;
|
||||||
font-weight: 600;
|
font-weight: 500;
|
||||||
|
color: var(--ys-ink-2);
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sheet-table .num {
|
.sheet-table .num {
|
||||||
text-align: right;
|
text-align: right;
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.block-title {
|
.block-title {
|
||||||
margin: 18px 0 6px;
|
margin: 14px 0 6px;
|
||||||
font-size: 14px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.group-block {
|
.group-block h3 {
|
||||||
margin-top: 18px;
|
margin: 12px 0 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--ys-ink-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.signature {
|
/* 三栏签字:上横线代替手写下划线,栏宽等分 */
|
||||||
display: flex;
|
.sign-row {
|
||||||
justify-content: space-between;
|
display: grid;
|
||||||
margin-top: 40px;
|
grid-template-columns: repeat(3, 1fr);
|
||||||
padding-top: 16px;
|
gap: 18px;
|
||||||
font-size: 13px;
|
margin-top: 22px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--ys-ink-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sign-row div {
|
||||||
|
padding-top: 5px;
|
||||||
|
border-top: 1px solid var(--ys-line);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media print {
|
@media print {
|
||||||
@@ -559,8 +657,17 @@ onMounted(load)
|
|||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 打印时画布与稿纸合并成一张纸:外层灰底、定宽、内距都是屏幕专有 */
|
||||||
.print-page {
|
.print-page {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet {
|
||||||
|
width: auto;
|
||||||
|
max-width: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@page {
|
@page {
|
||||||
@@ -575,5 +682,10 @@ onMounted(load)
|
|||||||
.sheet-table tr {
|
.sheet-table tr {
|
||||||
page-break-inside: avoid;
|
page-break-inside: avoid;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 浏览器默认不打印背景色,表头浅底会丢失,这里强制保留 */
|
||||||
|
.sheet-table th {
|
||||||
|
print-color-adjust: exact;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
getSettlement,
|
getSettlement,
|
||||||
listSettlements,
|
listSettlements,
|
||||||
settlementStatusLabel,
|
settlementStatusLabel,
|
||||||
settlementStatusTagType,
|
|
||||||
submitSettlement,
|
submitSettlement,
|
||||||
type Settlement,
|
type Settlement,
|
||||||
type SettlementDetail,
|
type SettlementDetail,
|
||||||
@@ -39,6 +38,39 @@ function resetFilters(): void {
|
|||||||
search()
|
search()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 月结生命周期四段(设计稿 §09-1)。
|
||||||
|
* 状态条同时是快捷筛选:与下拉读同一个 filters.status,因此下拉变化时
|
||||||
|
* 状态条自动跟随、点击状态条时下拉也自动跟随,不需要额外同步逻辑;
|
||||||
|
* 复用 search() 意味着**不新增任何后端请求口径**。
|
||||||
|
*/
|
||||||
|
const FLOW_STEPS = [
|
||||||
|
{ value: 1, label: '草稿', tone: 'draft' },
|
||||||
|
{ value: 2, label: '待审批', tone: 'wait' },
|
||||||
|
{ value: 3, label: '已审定(锁定)', tone: 'lock' },
|
||||||
|
{ value: 4, label: '已驳回', tone: 'reject' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
/** 点当前段再点一次即取消筛选(idle 态),避免「点了没反应」的死角 */
|
||||||
|
function pickStatus(status: number): void {
|
||||||
|
filters.status = filters.status === status ? undefined : status
|
||||||
|
search()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 状态 → pill 修饰符。
|
||||||
|
* 草稿是中性态(无修饰符);待审批琥珀、已审定靛蓝、已驳回砖红,
|
||||||
|
* 与设计稿四档语义一致,颜色之外还有文字,不单靠颜色表意。
|
||||||
|
*/
|
||||||
|
function statusPillClass(status: number): string {
|
||||||
|
return (
|
||||||
|
({ 1: '', 2: 'ys-pill--wait', 3: 'ys-pill--lock', 4: 'ys-pill--reject' } as Record<
|
||||||
|
number,
|
||||||
|
string
|
||||||
|
>)[status] ?? ''
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const generateMonth = ref('')
|
const generateMonth = ref('')
|
||||||
const generating = ref(false)
|
const generating = ref(false)
|
||||||
|
|
||||||
@@ -96,8 +128,35 @@ async function handleSubmit(row: Settlement): Promise<void> {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="page">
|
<div class="page">
|
||||||
|
<div class="page-head">
|
||||||
|
<h1 class="page-title">月度结算</h1>
|
||||||
|
<p class="page-desc">
|
||||||
|
按月生成绩效结算批次;送审后锁定,人数与金额不再随源数据变化
|
||||||
|
</p>
|
||||||
|
<span class="waterline"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
月结生命周期条:用 button 而非 div —— 它可点击、可聚焦,
|
||||||
|
键盘 Tab + Enter 也能筛选(div 做不到),且不会有默认提交行为。
|
||||||
|
-->
|
||||||
|
<div class="flow-step">
|
||||||
|
<button
|
||||||
|
v-for="step in FLOW_STEPS"
|
||||||
|
:key="step.value"
|
||||||
|
type="button"
|
||||||
|
class="flow-step__item"
|
||||||
|
:class="{ 'flow-step__item--cur': filters.status === step.value }"
|
||||||
|
@click="pickStatus(step.value)"
|
||||||
|
>
|
||||||
|
<span class="flow-step__dot" :class="`flow-step__dot--${step.tone}`"></span>
|
||||||
|
{{ step.label }}
|
||||||
|
</button>
|
||||||
|
<span class="flow-step__hint">点状态只看该阶段,再点一次取消</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 生成行:文案保留「生成月结快照」,对应真实动作(生成的是快照批次) -->
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<div class="gen-row">
|
|
||||||
<el-input
|
<el-input
|
||||||
v-model="generateMonth"
|
v-model="generateMonth"
|
||||||
placeholder="结算月份 YYYY-MM"
|
placeholder="结算月份 YYYY-MM"
|
||||||
@@ -108,9 +167,12 @@ async function handleSubmit(row: Settlement): Promise<void> {
|
|||||||
生成月结快照
|
生成月结快照
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<el-form inline class="filters" @submit.prevent>
|
<!--
|
||||||
|
.filter-bar 直接挂到 el-form 上:el-form 不能当 flex 透明容器,
|
||||||
|
外面再包一层 div 会让 el-form-item 的 gap 排版失效。
|
||||||
|
-->
|
||||||
|
<el-form class="filter-bar" @submit.prevent>
|
||||||
<el-form-item label="状态">
|
<el-form-item label="状态">
|
||||||
<el-select v-model="filters.status" placeholder="全部" clearable style="width: 150px">
|
<el-select v-model="filters.status" placeholder="全部" clearable style="width: 150px">
|
||||||
<el-option label="草稿" :value="1" />
|
<el-option label="草稿" :value="1" />
|
||||||
@@ -119,27 +181,47 @@ async function handleSubmit(row: Settlement): Promise<void> {
|
|||||||
<el-option label="已驳回" :value="4" />
|
<el-option label="已驳回" :value="4" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item>
|
<div class="filter-bar__actions">
|
||||||
<el-button type="primary" @click="search">查询</el-button>
|
<el-button type="primary" @click="search">查询</el-button>
|
||||||
<el-button @click="resetFilters">重置</el-button>
|
<el-button @click="resetFilters">重置</el-button>
|
||||||
</el-form-item>
|
</div>
|
||||||
</el-form>
|
</el-form>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
<el-table v-loading="loading" :data="records" stripe>
|
<el-table v-loading="loading" :data="records" stripe>
|
||||||
<el-table-column prop="settlement_month" label="结算月份" width="100" />
|
<el-table-column prop="settlement_month" label="结算月份" width="110" />
|
||||||
<el-table-column label="状态" width="130">
|
<el-table-column label="状态" width="150">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag :type="settlementStatusTagType(row.status)" size="small" disable-transitions>
|
<span class="ys-pill" :class="statusPillClass(row.status)">
|
||||||
{{ settlementStatusLabel(row.status) }}
|
{{ settlementStatusLabel(row.status) }}
|
||||||
</el-tag>
|
</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="绩效合计" min-width="115">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="cell-num">{{ row.total_performance ?? '—' }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="应付合计" min-width="115">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="cell-num">{{ row.total_payable ?? '—' }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="人数" width="80">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="cell-num">{{ row.person_count }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="审批单号" min-width="140">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span v-if="row.approval_order_no" class="cell-code">{{ row.approval_order_no }}</span>
|
||||||
|
<span v-else class="cell-muted">—</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="total_performance" label="绩效合计" min-width="110" />
|
|
||||||
<el-table-column prop="total_payable" label="应付合计" min-width="110" />
|
|
||||||
<el-table-column prop="person_count" label="人数" width="70" />
|
|
||||||
<el-table-column prop="approval_order_no" label="审批单号" min-width="150" />
|
|
||||||
<el-table-column label="提交时间" min-width="150">
|
<el-table-column label="提交时间" min-width="150">
|
||||||
<template #default="{ row }">{{ formatDateTime(row.create_time) }}</template>
|
<template #default="{ row }">
|
||||||
|
<span class="cell-muted">{{ formatDateTime(row.create_time) }}</span>
|
||||||
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="140" fixed="right" align="center">
|
<el-table-column label="操作" width="140" fixed="right" align="center">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
@@ -159,6 +241,7 @@ async function handleSubmit(row: Settlement): Promise<void> {
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
|
||||||
|
<div class="card-foot">
|
||||||
<AppPagination
|
<AppPagination
|
||||||
:total="total"
|
:total="total"
|
||||||
:page-num="pageNum"
|
:page-num="pageNum"
|
||||||
@@ -166,21 +249,34 @@ async function handleSubmit(row: Settlement): Promise<void> {
|
|||||||
@page-change="onPageChange"
|
@page-change="onPageChange"
|
||||||
@size-change="onSizeChange"
|
@size-change="onSizeChange"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<el-drawer v-model="detailVisible" title="月结详情" size="560px">
|
<el-drawer v-model="detailVisible" title="月结详情" size="560px">
|
||||||
<div v-loading="detailLoading">
|
<div v-loading="detailLoading">
|
||||||
<template v-if="detail">
|
<template v-if="detail">
|
||||||
<div class="detail-head">
|
<div class="detail-head">
|
||||||
<span class="detail-month">{{ detail.settlement_month }}</span>
|
<span class="detail-month">{{ detail.settlement_month }}</span>
|
||||||
<el-tag :type="settlementStatusTagType(detail.status)" size="small" disable-transitions>
|
<span class="ys-pill" :class="statusPillClass(detail.status)">
|
||||||
{{ settlementStatusLabel(detail.status) }}
|
{{ settlementStatusLabel(detail.status) }}
|
||||||
</el-tag>
|
</span>
|
||||||
</div>
|
|
||||||
<div class="detail-meta">
|
|
||||||
<span>审批单号:{{ detail.approval_order_no || '—' }}</span>
|
|
||||||
<span>提交人:{{ detail.submitter_name }}</span>
|
|
||||||
<span v-if="detail.locked_by">锁定人:{{ detail.locked_by }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<p class="section-title section-title--gap">基本信息</p>
|
||||||
|
<!--
|
||||||
|
dl.kv 两列网格:值缺省时统一显示 — 而不是整行隐藏,
|
||||||
|
审批人才能区分「没有这个字段」与「有字段但为空」。
|
||||||
|
-->
|
||||||
|
<dl class="kv">
|
||||||
|
<dt>审批单号</dt>
|
||||||
|
<dd>{{ detail.approval_order_no || '—' }}</dd>
|
||||||
|
<dt>提交人</dt>
|
||||||
|
<dd>{{ detail.submitter_name || '—' }}</dd>
|
||||||
|
<dt>锁定人</dt>
|
||||||
|
<dd>{{ detail.locked_by || '—' }}</dd>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<p class="section-title section-title--gap">按人明细</p>
|
||||||
<el-table :data="detail.items" size="small" stripe>
|
<el-table :data="detail.items" size="small" stripe>
|
||||||
<el-table-column prop="personnel_name" label="人员" min-width="90" />
|
<el-table-column prop="personnel_name" label="人员" min-width="90" />
|
||||||
<el-table-column label="类型" width="90">
|
<el-table-column label="类型" width="90">
|
||||||
@@ -188,17 +284,32 @@ async function handleSubmit(row: Settlement): Promise<void> {
|
|||||||
{{ row.calc_type === 1 ? '单笔回款' : '经理聚合' }}
|
{{ row.calc_type === 1 ? '单笔回款' : '经理聚合' }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="performance_sum" label="绩效" min-width="90" />
|
<el-table-column label="绩效" min-width="90">
|
||||||
<el-table-column prop="payable_sum" label="应付" min-width="90" />
|
<template #default="{ row }">
|
||||||
<el-table-column prop="net_amount" label="净工资" min-width="90" />
|
<span class="cell-num">{{ row.performance_sum }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="应付" min-width="90">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="cell-num">{{ row.payable_sum }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="净工资" min-width="90">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="cell-num">{{ row.net_amount ?? '—' }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column label="试用期" width="70">
|
<el-table-column label="试用期" width="70">
|
||||||
<template #default="{ row }">{{ row.is_probation === 1 ? '是' : '否' }}</template>
|
<template #default="{ row }">{{ row.is_probation === 1 ? '是' : '否' }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
<p v-if="detail.status === 1 || detail.status === 4" class="hint">
|
|
||||||
|
<p v-if="detail.status === 1 || detail.status === 4" class="drawer-hint">
|
||||||
草稿/已驳回状态可重新生成快照后再次提交。
|
草稿/已驳回状态可重新生成快照后再次提交。
|
||||||
</p>
|
</p>
|
||||||
<p v-else-if="detail.status === 3" class="hint">已锁定:源数据改动只产生提示与调整单。</p>
|
<p v-else-if="detail.status === 3" class="drawer-hint">
|
||||||
|
已锁定:源数据改动只产生提示与调整单。
|
||||||
|
</p>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</el-drawer>
|
</el-drawer>
|
||||||
@@ -206,19 +317,100 @@ async function handleSubmit(row: Settlement): Promise<void> {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.toolbar {
|
/*
|
||||||
display: flex;
|
* 金额列右对齐:全局 .cell-num 是给 td 设计的,这里只能包 span,
|
||||||
justify-content: flex-end;
|
* 行内元素的 text-align 无效,所以把 span 变块级让对齐真正生效;
|
||||||
margin-bottom: 12px;
|
* 表头保持左对齐,与设计稿一致(列 align="right" 会连表头一起右移)。
|
||||||
|
*/
|
||||||
|
.cell-num {
|
||||||
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gen-row {
|
/* 生命周期条:横向分段,相邻段共用描边(border-left:0)避免双线 */
|
||||||
|
.flow-step {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filters {
|
.flow-step__item {
|
||||||
margin-bottom: 4px;
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 7px 14px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--ys-ink-2);
|
||||||
|
white-space: nowrap;
|
||||||
|
/* 白底而非设计稿的极淡渐变:色阶靠全局 token 表达,页面不引入新色值 */
|
||||||
|
background: var(--ys-paper);
|
||||||
|
border: 1px solid var(--ys-line);
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
color 150ms var(--ys-ease),
|
||||||
|
background-color 150ms var(--ys-ease),
|
||||||
|
border-color 150ms var(--ys-ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 未选中时四段必须读起来仍是「一条流程」:给右侧挂一行说明,
|
||||||
|
高度与分段齐平,避免出现四颗孤立小按钮的观感 */
|
||||||
|
.flow-step__hint {
|
||||||
|
margin-left: 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ys-ink-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.flow-step__item:first-child {
|
||||||
|
border-radius: var(--ys-radius-sm) 0 0 var(--ys-radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.flow-step__item:last-child {
|
||||||
|
border-radius: 0 var(--ys-radius-sm) var(--ys-radius-sm) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flow-step__item + .flow-step__item {
|
||||||
|
border-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flow-step__item:hover {
|
||||||
|
color: var(--ys-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.flow-step__item:focus-visible {
|
||||||
|
outline: 2px solid rgba(46, 75, 168, 0.3);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flow-step__dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--ys-ink-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 圆点按阶段着色:未选中时也能看出四段各自代表什么(颜色之外仍有文字) */
|
||||||
|
.flow-step__dot--wait {
|
||||||
|
background: var(--ys-warn);
|
||||||
|
}
|
||||||
|
|
||||||
|
.flow-step__dot--lock {
|
||||||
|
background: var(--ys-indigo);
|
||||||
|
}
|
||||||
|
|
||||||
|
.flow-step__dot--reject {
|
||||||
|
background: var(--ys-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.flow-step__item--cur {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--ys-indigo);
|
||||||
|
background: var(--ys-indigo-wash);
|
||||||
|
border-color: rgba(46, 75, 168, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.flow-step__item--cur .flow-step__dot {
|
||||||
|
background: var(--ys-indigo);
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-head {
|
.detail-head {
|
||||||
@@ -228,22 +420,38 @@ async function handleSubmit(row: Settlement): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.detail-month {
|
.detail-month {
|
||||||
font-size: 15px;
|
font-size: 16px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
color: var(--ys-ink);
|
color: var(--ys-ink);
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-meta {
|
/* 分块标题之间需要上间距;不改全局 .section-title,用局部修饰符叠加 */
|
||||||
display: flex;
|
.section-title--gap {
|
||||||
flex-direction: column;
|
margin-top: 18px;
|
||||||
gap: 2px;
|
}
|
||||||
padding: 10px 0;
|
|
||||||
|
/* 键值两列:72px 的键列足够放四个汉字,值列自适应余宽(抽屉 560px 内可读) */
|
||||||
|
.kv {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 72px 1fr;
|
||||||
|
gap: 8px 10px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kv dt {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--ys-ink-3);
|
color: var(--ys-ink-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.hint {
|
.kv dd {
|
||||||
margin-top: 12px;
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--ys-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-hint {
|
||||||
|
margin: 14px 0 0;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--ys-ink-3);
|
color: var(--ys-ink-3);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { ElMessage } from 'element-plus'
|
|||||||
import {
|
import {
|
||||||
actApproval,
|
actApproval,
|
||||||
approvalStatusLabel,
|
approvalStatusLabel,
|
||||||
approvalStatusTagType,
|
|
||||||
getApproval,
|
getApproval,
|
||||||
listApprovals,
|
listApprovals,
|
||||||
orderTypeLabel,
|
orderTypeLabel,
|
||||||
@@ -62,6 +61,18 @@ function resetFilters(): void {
|
|||||||
search()
|
search()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 状态 → pill 修饰符(设计稿 §03 四档语义)。
|
||||||
|
* 在途单(待财务复核/待领导审核)统一琥珀「待办」;已通过绿、已驳回砖红;
|
||||||
|
* 已撤回是中性终态,不带修饰符,避免与「待办」抢注意力。
|
||||||
|
*/
|
||||||
|
function approvalPillClass(status: number): string {
|
||||||
|
return (
|
||||||
|
({ 3: 'ys-pill--ok', 4: 'ys-pill--reject' } as Record<number, string>)[status] ??
|
||||||
|
(status === 1 || status === 2 ? 'ys-pill--wait' : '')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== 详情抽屉(节点时间线) ====================
|
// ==================== 详情抽屉(节点时间线) ====================
|
||||||
|
|
||||||
const detailVisible = ref(false)
|
const detailVisible = ref(false)
|
||||||
@@ -156,7 +167,19 @@ async function submitAction(): Promise<void> {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<el-form inline class="filters" @submit.prevent>
|
<div class="page-head">
|
||||||
|
<h1 class="page-title">审批管理</h1>
|
||||||
|
<p class="page-desc">
|
||||||
|
二次分配方案与月度结算的送审复核;送审内容为快照,与源单据当前值无关
|
||||||
|
</p>
|
||||||
|
<span class="waterline"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
.filter-bar 直接挂到 el-form 上:再包一层 div 会让 el-form-item
|
||||||
|
失去 gap 排版(el-form 不是 flex 透明容器)。
|
||||||
|
-->
|
||||||
|
<el-form class="filter-bar" @submit.prevent>
|
||||||
<el-form-item label="关键词">
|
<el-form-item label="关键词">
|
||||||
<el-input
|
<el-input
|
||||||
v-model="filters.keyword"
|
v-model="filters.keyword"
|
||||||
@@ -182,31 +205,46 @@ async function submitAction(): Promise<void> {
|
|||||||
<el-option label="调整单" :value="3" />
|
<el-option label="调整单" :value="3" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item>
|
<div class="filter-bar__actions">
|
||||||
<el-button type="primary" @click="search">查询</el-button>
|
<el-button type="primary" @click="search">查询</el-button>
|
||||||
<el-button @click="resetFilters">重置</el-button>
|
<el-button @click="resetFilters">重置</el-button>
|
||||||
</el-form-item>
|
</div>
|
||||||
</el-form>
|
</el-form>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
<el-table v-loading="loading" :data="records" stripe>
|
<el-table v-loading="loading" :data="records" stripe>
|
||||||
<el-table-column prop="order_no" label="审批单号" min-width="150" />
|
<el-table-column label="审批单号" min-width="140">
|
||||||
<el-table-column label="类型" width="120">
|
<template #default="{ row }">
|
||||||
|
<span class="cell-code">{{ row.order_no }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="类型" width="130">
|
||||||
<template #default="{ row }">{{ orderTypeLabel(row.order_type) }}</template>
|
<template #default="{ row }">{{ orderTypeLabel(row.order_type) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="版本" width="70" align="center">
|
<!-- 版本号与状态同屏:审批人必须能判断自己批的是哪一版,不能藏进 tooltip -->
|
||||||
<template #default="{ row }">V{{ row.version_no }}</template>
|
<el-table-column label="版本" width="80">
|
||||||
</el-table-column>
|
|
||||||
<el-table-column prop="refer_no" label="引用单号" min-width="140" />
|
|
||||||
<el-table-column label="状态" width="110">
|
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag :type="approvalStatusTagType(row.status)" size="small" disable-transitions>
|
<span class="cell-num">v{{ row.version_no }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="引用单号" min-width="140">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span v-if="row.refer_no" class="cell-code">{{ row.refer_no }}</span>
|
||||||
|
<span v-else class="cell-muted">—</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="状态" width="130">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="ys-pill" :class="approvalPillClass(row.status)">
|
||||||
{{ approvalStatusLabel(row.status) }}
|
{{ approvalStatusLabel(row.status) }}
|
||||||
</el-tag>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="submitter_name" label="提交人" width="110" />
|
<el-table-column prop="submitter_name" label="提交人" width="110" />
|
||||||
<el-table-column label="提交时间" min-width="150">
|
<el-table-column label="提交时间" min-width="150">
|
||||||
<template #default="{ row }">{{ formatDateTime(row.create_time) }}</template>
|
<template #default="{ row }">
|
||||||
|
<span class="cell-muted">{{ formatDateTime(row.create_time) }}</span>
|
||||||
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="90" fixed="right" align="center">
|
<el-table-column label="操作" width="90" fixed="right" align="center">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
@@ -215,6 +253,7 @@ async function submitAction(): Promise<void> {
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
|
||||||
|
<div class="card-foot">
|
||||||
<AppPagination
|
<AppPagination
|
||||||
:total="total"
|
:total="total"
|
||||||
:page-num="pageNum"
|
:page-num="pageNum"
|
||||||
@@ -222,30 +261,36 @@ async function submitAction(): Promise<void> {
|
|||||||
@page-change="onPageChange"
|
@page-change="onPageChange"
|
||||||
@size-change="onSizeChange"
|
@size-change="onSizeChange"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<el-drawer v-model="detailVisible" title="审批详情" size="720px">
|
<el-drawer v-model="detailVisible" title="审批详情" size="720px">
|
||||||
<div v-loading="detailLoading">
|
<div v-loading="detailLoading">
|
||||||
<template v-if="detail">
|
<template v-if="detail">
|
||||||
<div class="detail-head">
|
<div class="panel-title">
|
||||||
<span class="detail-no">{{ detail.order_no }}</span>
|
<span class="panel-title__no">{{ detail.order_no }}</span>
|
||||||
<el-tag
|
<span class="ys-pill" :class="approvalPillClass(detail.status)">
|
||||||
:type="approvalStatusTagType(detail.status)"
|
|
||||||
size="small"
|
|
||||||
disable-transitions
|
|
||||||
>
|
|
||||||
{{ approvalStatusLabel(detail.status) }}
|
{{ approvalStatusLabel(detail.status) }}
|
||||||
</el-tag>
|
</span>
|
||||||
</div>
|
|
||||||
<div class="detail-meta">
|
|
||||||
<span>类型:{{ orderTypeLabel(detail.order_type) }} · 版本 V{{ detail.version_no }}</span>
|
|
||||||
<span>引用单号:{{ detail.refer_no }}</span>
|
|
||||||
<span>提交人:{{ detail.submitter_name }}</span>
|
|
||||||
<span>提交时间:{{ formatDateTime(detail.create_time) }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p class="panel-sub">送审内容为快照,与源单据当前值无关</p>
|
||||||
|
|
||||||
|
<dl class="kv">
|
||||||
|
<dt>类型</dt>
|
||||||
|
<dd>{{ orderTypeLabel(detail.order_type) }}</dd>
|
||||||
|
<dt>版本</dt>
|
||||||
|
<dd>v{{ detail.version_no }}</dd>
|
||||||
|
<dt>引用单号</dt>
|
||||||
|
<dd>{{ detail.refer_no || '—' }}</dd>
|
||||||
|
<dt>提交人</dt>
|
||||||
|
<dd>{{ detail.submitter_name }}</dd>
|
||||||
|
<dt>提交时间</dt>
|
||||||
|
<dd>{{ formatDateTime(detail.create_time) }}</dd>
|
||||||
|
</dl>
|
||||||
|
|
||||||
<!-- 审批内容:优先提交时快照;历史单缺快照时读当前并显式标注 -->
|
<!-- 审批内容:优先提交时快照;历史单缺快照时读当前并显式标注 -->
|
||||||
<div class="content-section">
|
<div class="content-section">
|
||||||
<div class="content-title">审批内容</div>
|
<p class="section-title">审批内容</p>
|
||||||
<!-- 采集时间让复核者明确「看到的是哪个时点的内容」:快照=送审时刻;
|
<!-- 采集时间让复核者明确「看到的是哪个时点的内容」:快照=送审时刻;
|
||||||
live=打开详情时的当前内容(不做时刻展示——渲染函数里的
|
live=打开详情时的当前内容(不做时刻展示——渲染函数里的
|
||||||
new Date() 非响应式且 toISOString 是 UTC,会差 8 小时) -->
|
new Date() 非响应式且 toISOString 是 UTC,会差 8 小时) -->
|
||||||
@@ -271,30 +316,26 @@ async function submitAction(): Promise<void> {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<template v-if="allocationData">
|
<template v-if="allocationData">
|
||||||
<el-descriptions :column="2" border size="small" class="content-desc">
|
<dl class="kv">
|
||||||
<el-descriptions-item label="业务编号">
|
<dt>业务编号</dt>
|
||||||
{{ allocationData.business_no }}
|
<dd>{{ allocationData.business_no }}</dd>
|
||||||
</el-descriptions-item>
|
<dt>业务日期</dt>
|
||||||
<el-descriptions-item label="业务日期">
|
<dd>{{ allocationData.business_date ?? '—' }}</dd>
|
||||||
{{ allocationData.business_date ?? '—' }}
|
<dt>客户名称</dt>
|
||||||
</el-descriptions-item>
|
<dd>{{ allocationData.customer_name ?? '—' }}</dd>
|
||||||
<el-descriptions-item label="客户名称">
|
<dt>负责人员</dt>
|
||||||
{{ allocationData.customer_name ?? '—' }}
|
<dd>{{ allocationData.owner_personnel_name ?? '(待指定)' }}</dd>
|
||||||
</el-descriptions-item>
|
<dt>销售额</dt>
|
||||||
<el-descriptions-item label="负责人员">
|
<dd>{{ allocationData.sale_amount ? `${allocationData.sale_amount} 元` : '—' }}</dd>
|
||||||
{{ allocationData.owner_personnel_name ?? '(待指定)' }}
|
<dt>成本</dt>
|
||||||
</el-descriptions-item>
|
<dd>{{ allocationData.cost_amount ? `${allocationData.cost_amount} 元` : '—' }}</dd>
|
||||||
<el-descriptions-item label="销售额(元)">
|
</dl>
|
||||||
{{ allocationData.sale_amount ?? '—' }}
|
|
||||||
</el-descriptions-item>
|
|
||||||
<el-descriptions-item label="成本(元)">
|
|
||||||
{{ allocationData.cost_amount ?? '—' }}
|
|
||||||
</el-descriptions-item>
|
|
||||||
</el-descriptions>
|
|
||||||
<el-table :data="allocationData.allocations" stripe size="small" class="content-table">
|
<el-table :data="allocationData.allocations" stripe size="small" class="content-table">
|
||||||
<el-table-column prop="personnel_name" label="参与业务员" min-width="160" />
|
<el-table-column prop="personnel_name" label="参与业务员" min-width="160" />
|
||||||
<el-table-column label="分配比例" width="120" align="right">
|
<el-table-column label="分配比例" width="120">
|
||||||
<template #default="{ row }">{{ row.ratio }}%</template>
|
<template #default="{ row }">
|
||||||
|
<span class="cell-num">{{ row.ratio }}%</span>
|
||||||
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<template #empty>
|
<template #empty>
|
||||||
<span class="content-empty">未设置二次分配 —— 全部绩效归负责营销人员</span>
|
<span class="content-empty">未设置二次分配 —— 全部绩效归负责营销人员</span>
|
||||||
@@ -307,60 +348,63 @@ async function submitAction(): Promise<void> {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else-if="settlementData">
|
<template v-else-if="settlementData">
|
||||||
<el-descriptions :column="2" border size="small" class="content-desc">
|
<dl class="kv">
|
||||||
<el-descriptions-item label="结算月份">
|
<dt>结算月份</dt>
|
||||||
{{ settlementData.settlement_month }}
|
<dd>{{ settlementData.settlement_month }}</dd>
|
||||||
</el-descriptions-item>
|
<dt>核算人数</dt>
|
||||||
<el-descriptions-item label="结算人数">
|
<dd>{{ settlementData.person_count }} 人</dd>
|
||||||
{{ settlementData.person_count }}
|
<dt>绩效合计</dt>
|
||||||
</el-descriptions-item>
|
<dd>
|
||||||
<el-descriptions-item label="绩效合计(元)">
|
{{
|
||||||
{{ settlementData.total_performance ?? '—' }}
|
settlementData.total_performance ? `${settlementData.total_performance} 元` : '—'
|
||||||
</el-descriptions-item>
|
}}
|
||||||
<el-descriptions-item label="应付合计(元)">
|
</dd>
|
||||||
{{ settlementData.total_payable ?? '—' }}
|
<dt>应付合计</dt>
|
||||||
</el-descriptions-item>
|
<dd>
|
||||||
</el-descriptions>
|
{{ settlementData.total_payable ? `${settlementData.total_payable} 元` : '—' }}
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
<el-table :data="settlementData.items" stripe size="small" class="content-table">
|
<el-table :data="settlementData.items" stripe size="small" class="content-table">
|
||||||
<el-table-column prop="personnel_name" label="人员" min-width="110" />
|
<el-table-column prop="personnel_name" label="人员" min-width="110" />
|
||||||
<el-table-column prop="role_code" label="岗位" width="110" />
|
<el-table-column prop="role_code" label="岗位" width="110" />
|
||||||
<el-table-column label="绩效(元)" width="110" align="right">
|
<el-table-column label="绩效(元)" width="120">
|
||||||
<template #default="{ row }">{{ row.performance_sum }}</template>
|
<template #default="{ row }">
|
||||||
|
<span class="cell-num">{{ row.performance_sum }}</span>
|
||||||
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="应付(元)" width="110" align="right">
|
<el-table-column label="应付(元)" width="120">
|
||||||
<template #default="{ row }">{{ row.payable_sum }}</template>
|
<template #default="{ row }">
|
||||||
|
<span class="cell-num">{{ row.payable_sum }}</span>
|
||||||
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="净工资(元)" width="120" align="right">
|
<el-table-column label="净工资(元)" width="120">
|
||||||
<template #default="{ row }">{{ row.net_amount ?? '—' }}</template>
|
<template #default="{ row }">
|
||||||
|
<span class="cell-num">{{ row.net_amount ?? '—' }}</span>
|
||||||
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else-if="adjustmentData">
|
<template v-else-if="adjustmentData">
|
||||||
<el-descriptions :column="2" border size="small" class="content-desc">
|
<dl class="kv">
|
||||||
<el-descriptions-item label="调整单号">
|
<dt>调整单号</dt>
|
||||||
{{ adjustmentData.order_no }}
|
<dd>{{ adjustmentData.order_no }}</dd>
|
||||||
</el-descriptions-item>
|
<dt>调整月份</dt>
|
||||||
<el-descriptions-item label="调整月份">
|
<dd>{{ adjustmentData.settlement_month }}</dd>
|
||||||
{{ adjustmentData.settlement_month }}
|
<dt>调整人员</dt>
|
||||||
</el-descriptions-item>
|
<dd>{{ adjustmentData.personnel_name }}</dd>
|
||||||
<el-descriptions-item label="调整人员">
|
<dt>方向</dt>
|
||||||
{{ adjustmentData.personnel_name }}
|
<dd>{{ adjustmentData.direction === 1 ? '补发' : '扣减' }}</dd>
|
||||||
</el-descriptions-item>
|
<dt>金额</dt>
|
||||||
<el-descriptions-item label="方向">
|
<dd>{{ adjustmentData.amount }} 元</dd>
|
||||||
{{ adjustmentData.direction === 1 ? '补发' : '扣减' }}
|
<dt>扣补说明</dt>
|
||||||
</el-descriptions-item>
|
<dd>{{ adjustmentData.reason }}</dd>
|
||||||
<el-descriptions-item label="金额(元)">
|
</dl>
|
||||||
{{ adjustmentData.amount }}
|
|
||||||
</el-descriptions-item>
|
|
||||||
<el-descriptions-item label="扣补说明">
|
|
||||||
{{ adjustmentData.reason }}
|
|
||||||
</el-descriptions-item>
|
|
||||||
</el-descriptions>
|
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 签名元素:竖排节点时间线(财务复核 → 领导审核的处理链) -->
|
<!-- 签名元素:竖排节点时间线(财务复核 → 领导审核的处理链) -->
|
||||||
|
<p class="section-title section-title--gap">处理记录</p>
|
||||||
<div class="timeline">
|
<div class="timeline">
|
||||||
<div
|
<div
|
||||||
v-for="step in detail.steps"
|
v-for="step in detail.steps"
|
||||||
@@ -373,7 +417,10 @@ async function submitAction(): Promise<void> {
|
|||||||
class="timeline-dot"
|
class="timeline-dot"
|
||||||
:class="{ 'is-auto': step.action === 3, 'is-reject': step.action === 2 }"
|
:class="{ 'is-auto': step.action === 3, 'is-reject': step.action === 2 }"
|
||||||
></span>
|
></span>
|
||||||
<span v-if="step.id !== detail.steps[detail.steps.length - 1].id" class="timeline-line"></span>
|
<span
|
||||||
|
v-if="step.id !== detail.steps[detail.steps.length - 1].id"
|
||||||
|
class="timeline-line"
|
||||||
|
></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="timeline-body">
|
<div class="timeline-body">
|
||||||
<div class="timeline-head">
|
<div class="timeline-head">
|
||||||
@@ -396,7 +443,6 @@ async function submitAction(): Promise<void> {
|
|||||||
<div class="action-bar">
|
<div class="action-bar">
|
||||||
<el-button
|
<el-button
|
||||||
type="primary"
|
type="primary"
|
||||||
size="small"
|
|
||||||
:loading="actionSubmitting && actionForm.action === 1"
|
:loading="actionSubmitting && actionForm.action === 1"
|
||||||
@click="openAction(detail, 1)"
|
@click="openAction(detail, 1)"
|
||||||
>
|
>
|
||||||
@@ -404,16 +450,17 @@ async function submitAction(): Promise<void> {
|
|||||||
</el-button>
|
</el-button>
|
||||||
<el-button
|
<el-button
|
||||||
type="danger"
|
type="danger"
|
||||||
plain
|
|
||||||
size="small"
|
|
||||||
:loading="actionSubmitting && actionForm.action === 2"
|
:loading="actionSubmitting && actionForm.action === 2"
|
||||||
@click="openAction(detail, 2)"
|
@click="openAction(detail, 2)"
|
||||||
>
|
>
|
||||||
驳回
|
驳回
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
<p class="action-hint">驳回需写明原因,将与审批记录一并留痕</p>
|
||||||
<el-form v-if="actingId === detail.id" label-position="top" class="action-form">
|
<el-form v-if="actingId === detail.id" label-position="top" class="action-form">
|
||||||
<el-form-item :label="actionForm.action === 2 ? '驳回意见(必填)' : '审批意见(选填)'">
|
<el-form-item
|
||||||
|
:label="actionForm.action === 2 ? '驳回意见(必填)' : '审批意见(选填)'"
|
||||||
|
>
|
||||||
<el-input
|
<el-input
|
||||||
v-model="actionForm.opinion"
|
v-model="actionForm.opinion"
|
||||||
type="textarea"
|
type="textarea"
|
||||||
@@ -435,58 +482,77 @@ async function submitAction(): Promise<void> {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.filters {
|
/* 版本/比例等数值列:全局 .cell-num 是给 td 设计的,行内 span 的
|
||||||
margin-bottom: 4px;
|
text-align 不生效,变块级才能右对齐(表头仍左对齐,与设计稿一致)。 */
|
||||||
|
.cell-num {
|
||||||
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-head {
|
/* 抽屉头部:单号与状态同屏,标题 15px/600 与卡片标题同级 */
|
||||||
|
.panel-title {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-no {
|
.panel-title__no {
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
color: var(--ys-ink);
|
color: var(--ys-ink);
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-meta {
|
.panel-sub {
|
||||||
display: flex;
|
margin: 4px 0 0;
|
||||||
flex-direction: column;
|
|
||||||
gap: 2px;
|
|
||||||
padding: 10px 0 4px;
|
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--ys-ink-3);
|
color: var(--ys-ink-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.content-section {
|
/* 键值两列:72px 键列放得下四个汉字,值列自适应余宽 */
|
||||||
margin-top: 12px;
|
.kv {
|
||||||
padding: 12px;
|
display: grid;
|
||||||
background: #f7f8fa;
|
grid-template-columns: 72px 1fr;
|
||||||
border: 1px solid var(--ys-line);
|
gap: 8px 10px;
|
||||||
border-radius: 8px;
|
margin: 14px 0 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content-title {
|
.kv dt {
|
||||||
margin-bottom: 10px;
|
font-size: 12px;
|
||||||
|
color: var(--ys-ink-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kv dd {
|
||||||
|
margin: 0;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
|
||||||
color: var(--ys-ink);
|
color: var(--ys-ink);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 审批内容块内的 kv 紧跟 .section-title,由标题的下边距负责间距,不再叠加 */
|
||||||
|
.content-section .kv {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 分块标题之间补上间距;不改全局 .section-title,用局部修饰符叠加 */
|
||||||
|
.section-title--gap {
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-section {
|
||||||
|
margin-top: 16px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
background: var(--ys-mist);
|
||||||
|
border: 1px solid var(--ys-line);
|
||||||
|
border-radius: var(--ys-radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
.content-captured {
|
.content-captured {
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--ys-ink-2);
|
color: var(--ys-ink-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.content-desc {
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content-table {
|
.content-table {
|
||||||
margin-top: 8px;
|
margin-top: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content-total {
|
.content-total {
|
||||||
@@ -502,7 +568,7 @@ async function submitAction(): Promise<void> {
|
|||||||
|
|
||||||
/* 竖排时间线:左侧轨道 + 节点圆点,动作色区分人工/自动/驳回 */
|
/* 竖排时间线:左侧轨道 + 节点圆点,动作色区分人工/自动/驳回 */
|
||||||
.timeline {
|
.timeline {
|
||||||
margin-top: 14px;
|
margin-top: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeline-row {
|
.timeline-row {
|
||||||
@@ -531,14 +597,14 @@ async function submitAction(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.timeline-dot.is-reject {
|
.timeline-dot.is-reject {
|
||||||
background: #c0392b;
|
background: var(--ys-danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeline-line {
|
.timeline-line {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
width: 1px;
|
width: 1px;
|
||||||
min-height: 26px;
|
min-height: 26px;
|
||||||
background: rgba(28, 35, 51, 0.14);
|
background: var(--ys-line);
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeline-body {
|
.timeline-body {
|
||||||
@@ -563,7 +629,7 @@ async function submitAction(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.timeline-action.is-reject-text {
|
.timeline-action.is-reject-text {
|
||||||
color: #c0392b;
|
color: var(--ys-danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeline-meta {
|
.timeline-meta {
|
||||||
@@ -577,8 +643,8 @@ async function submitAction(): Promise<void> {
|
|||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--ys-ink);
|
color: var(--ys-ink);
|
||||||
background: #f4f5f8;
|
background: var(--ys-mist);
|
||||||
border-radius: 6px;
|
border-radius: var(--ys-radius-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeline-empty {
|
.timeline-empty {
|
||||||
@@ -590,13 +656,24 @@ async function submitAction(): Promise<void> {
|
|||||||
color: var(--ys-ink-3);
|
color: var(--ys-ink-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 通过/驳回并排等宽:两个动作权重相同,审批人不必在大小按钮间做偏向性选择 */
|
||||||
.action-bar {
|
.action-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding-top: 6px;
|
padding-top: 14px;
|
||||||
border-top: 1px solid var(--ys-line);
|
border-top: 1px solid var(--ys-line);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.action-bar :deep(.el-button) {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-hint {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ys-ink-3);
|
||||||
|
}
|
||||||
|
|
||||||
.action-form {
|
.action-form {
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -207,9 +207,21 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div class="page">
|
||||||
<el-card shadow="never">
|
<!-- 页面头:点明岗位是「公式模板 + 参数」的绑定关系,改它会牵动后续核算口径 -->
|
||||||
<el-form class="filter-form" :inline="true" @submit.prevent>
|
<div class="page-head">
|
||||||
|
<h1 class="page-title">岗位角色</h1>
|
||||||
|
<p class="page-desc">
|
||||||
|
决定人员适用哪套计提公式与参数;新增岗位只需选模板,不用改代码,改动只影响之后的核算。
|
||||||
|
</p>
|
||||||
|
<div class="waterline"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
筛选条:flex 与间距由 .filter-bar 提供,去掉 el-form 的 inline(inline 的行内块
|
||||||
|
排布会与 gap 叠成不等距);查询/重置由 .filter-bar__actions 推到行尾。
|
||||||
|
-->
|
||||||
|
<el-form class="filter-bar" @submit.prevent>
|
||||||
<el-form-item label="关键词">
|
<el-form-item label="关键词">
|
||||||
<el-input
|
<el-input
|
||||||
v-model="filters.keyword"
|
v-model="filters.keyword"
|
||||||
@@ -229,20 +241,28 @@ onMounted(() => {
|
|||||||
/>
|
/>
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item>
|
<div class="filter-bar__actions">
|
||||||
<el-button type="primary" @click="search">查询</el-button>
|
<el-button type="primary" @click="search">查询</el-button>
|
||||||
<el-button @click="resetFilters">重置</el-button>
|
<el-button @click="resetFilters">重置</el-button>
|
||||||
</el-form-item>
|
</div>
|
||||||
</el-form>
|
</el-form>
|
||||||
|
|
||||||
|
<!-- 主操作行:新增岗位单独成行,与筛选动作分开 -->
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<el-button type="primary" @click="openCreate">新增岗位</el-button>
|
<el-button type="primary" @click="openCreate">新增岗位</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-table v-loading="loading" :data="records" stripe>
|
<div class="card">
|
||||||
|
<el-table
|
||||||
|
v-loading="loading"
|
||||||
|
:data="records"
|
||||||
|
stripe
|
||||||
|
empty-text="还没有岗位角色,点「新增岗位」创建第一个"
|
||||||
|
>
|
||||||
<el-table-column prop="role_code" label="岗位编码" min-width="130">
|
<el-table-column prop="role_code" label="岗位编码" min-width="130">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<span class="role-code">{{ row.role_code }}</span>
|
<!-- 岗位编码是参数维度值与模板绑定的锚点,等宽字体便于逐字符核对 -->
|
||||||
|
<span class="cell-code">{{ row.role_code }}</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="role_name" label="岗位名称" min-width="110" />
|
<el-table-column prop="role_name" label="岗位名称" min-width="110" />
|
||||||
@@ -252,8 +272,11 @@ onMounted(() => {
|
|||||||
<el-table-column prop="description" label="说明" min-width="200">
|
<el-table-column prop="description" label="说明" min-width="200">
|
||||||
<template #default="{ row }">{{ row.description || '—' }}</template>
|
<template #default="{ row }">{{ row.description || '—' }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="sort_order" label="排序" width="70" align="center" />
|
<!-- 排序是用于比较的数字:右对齐 + tabular-nums,纵向扫读时数位对齐 -->
|
||||||
<el-table-column label="状态" width="90" align="center">
|
<el-table-column label="排序" width="70" align="right">
|
||||||
|
<template #default="{ row }"><span class="cell-num">{{ row.sort_order }}</span></template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="状态" width="96" align="center">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<StatusTag :value="row.status" />
|
<StatusTag :value="row.status" />
|
||||||
</template>
|
</template>
|
||||||
@@ -290,6 +313,7 @@ onMounted(() => {
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
|
||||||
|
<div class="card-foot">
|
||||||
<AppPagination
|
<AppPagination
|
||||||
:total="total"
|
:total="total"
|
||||||
:page-num="pageNum"
|
:page-num="pageNum"
|
||||||
@@ -297,7 +321,8 @@ onMounted(() => {
|
|||||||
@page-change="onPageChange"
|
@page-change="onPageChange"
|
||||||
@size-change="onSizeChange"
|
@size-change="onSizeChange"
|
||||||
/>
|
/>
|
||||||
</el-card>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 新增 / 编辑 -->
|
<!-- 新增 / 编辑 -->
|
||||||
<el-dialog
|
<el-dialog
|
||||||
@@ -355,12 +380,7 @@ onMounted(() => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.role-code {
|
/* 弹窗里「所选模板的说明与参数清单」:靛蓝浅底把这段提示与表单控件区分开 */
|
||||||
font-family: 'SFMono-Regular', Consolas, 'Courier New', monospace;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--ys-ink-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tpl-info {
|
.tpl-info {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
@@ -371,7 +391,9 @@ onMounted(() => {
|
|||||||
line-height: 1.7;
|
line-height: 1.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 参数清单比说明再弱一档,读者先看模板用途再看它要配哪些参数 */
|
||||||
.tpl-params {
|
.tpl-params {
|
||||||
color: var(--ys-ink-3);
|
color: var(--ys-ink-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
@@ -170,11 +170,24 @@ watch(activeTab, (tab) => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<el-card shadow="never">
|
<div class="page">
|
||||||
|
<!-- 页面头:说明日志是只读取证材料,并点明保留期由后端控制 -->
|
||||||
|
<div class="page-head">
|
||||||
|
<h1 class="page-title">日志查询</h1>
|
||||||
|
<p class="page-desc">
|
||||||
|
检索操作与登录痕迹,用于追责与排障;日志只读,超出保留期的记录由后端自动清理。
|
||||||
|
</p>
|
||||||
|
<div class="waterline"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<el-tabs v-model="activeTab">
|
<el-tabs v-model="activeTab">
|
||||||
<!-- 操作日志 -->
|
<!-- 操作日志 -->
|
||||||
<el-tab-pane label="操作日志" name="operation" lazy>
|
<el-tab-pane label="操作日志" name="operation" lazy>
|
||||||
<el-form class="filter-form" :inline="true" @submit.prevent>
|
<!--
|
||||||
|
筛选条:flex 排布与间距由 .filter-bar 提供,故 el-form 去掉 inline;
|
||||||
|
查询/重置收进 .filter-bar__actions,由 margin-left:auto 推到行尾。
|
||||||
|
-->
|
||||||
|
<el-form class="filter-bar" @submit.prevent>
|
||||||
<el-form-item label="操作人">
|
<el-form-item label="操作人">
|
||||||
<el-input
|
<el-input
|
||||||
v-model="opFilters.username"
|
v-model="opFilters.username"
|
||||||
@@ -215,16 +228,18 @@ watch(activeTab, (tab) => {
|
|||||||
:default-time="RANGE_DEFAULT_TIME"
|
:default-time="RANGE_DEFAULT_TIME"
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item>
|
<div class="filter-bar__actions">
|
||||||
<el-button type="primary" @click="searchOperationLogs">查询</el-button>
|
<el-button type="primary" @click="searchOperationLogs">查询</el-button>
|
||||||
<el-button @click="resetOpFilters">重置</el-button>
|
<el-button @click="resetOpFilters">重置</el-button>
|
||||||
</el-form-item>
|
</div>
|
||||||
</el-form>
|
</el-form>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
<el-table
|
<el-table
|
||||||
v-loading="opLoading"
|
v-loading="opLoading"
|
||||||
:data="opRecords"
|
:data="opRecords"
|
||||||
stripe
|
stripe
|
||||||
|
empty-text="这段时间还没有操作记录,试试放宽筛选条件"
|
||||||
@expand-change="onExpandChange"
|
@expand-change="onExpandChange"
|
||||||
>
|
>
|
||||||
<el-table-column type="expand">
|
<el-table-column type="expand">
|
||||||
@@ -250,7 +265,7 @@ watch(activeTab, (tab) => {
|
|||||||
<el-table-column prop="module" label="模块" width="100" />
|
<el-table-column prop="module" label="模块" width="100" />
|
||||||
<el-table-column prop="action" label="操作" width="90" />
|
<el-table-column prop="action" label="操作" width="90" />
|
||||||
<el-table-column prop="summary" label="摘要" min-width="200" show-overflow-tooltip />
|
<el-table-column prop="summary" label="摘要" min-width="200" show-overflow-tooltip />
|
||||||
<el-table-column label="结果" width="80" align="center">
|
<el-table-column label="结果" width="96" align="center">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tooltip
|
<el-tooltip
|
||||||
:content="row.error_message ?? ''"
|
:content="row.error_message ?? ''"
|
||||||
@@ -266,14 +281,18 @@ watch(activeTab, (tab) => {
|
|||||||
</el-tooltip>
|
</el-tooltip>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
<!-- 耗时是用于比较的数字:右对齐 + tabular-nums,慢请求一眼能扫出来 -->
|
||||||
<el-table-column label="耗时(ms)" width="90" align="right">
|
<el-table-column label="耗时(ms)" width="90" align="right">
|
||||||
<template #default="{ row }">{{ row.duration_ms ?? '—' }}</template>
|
<template #default="{ row }">
|
||||||
|
<span class="cell-num">{{ row.duration_ms ?? '—' }}</span>
|
||||||
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="ip" label="IP" min-width="120">
|
<el-table-column prop="ip" label="IP" min-width="120">
|
||||||
<template #default="{ row }">{{ row.ip || '—' }}</template>
|
<template #default="{ row }">{{ row.ip || '—' }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
|
||||||
|
<div class="card-foot">
|
||||||
<AppPagination
|
<AppPagination
|
||||||
:total="opTotal"
|
:total="opTotal"
|
||||||
:page-num="opPageNum"
|
:page-num="opPageNum"
|
||||||
@@ -281,11 +300,13 @@ watch(activeTab, (tab) => {
|
|||||||
@page-change="onOpPageChange"
|
@page-change="onOpPageChange"
|
||||||
@size-change="onOpSizeChange"
|
@size-change="onOpSizeChange"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
|
|
||||||
<!-- 登录日志 -->
|
<!-- 登录日志 -->
|
||||||
<el-tab-pane label="登录日志" name="login" lazy>
|
<el-tab-pane label="登录日志" name="login" lazy>
|
||||||
<el-form class="filter-form" :inline="true" @submit.prevent>
|
<el-form class="filter-bar" @submit.prevent>
|
||||||
<el-form-item label="用户名">
|
<el-form-item label="用户名">
|
||||||
<el-input
|
<el-input
|
||||||
v-model="loginFilters.username"
|
v-model="loginFilters.username"
|
||||||
@@ -305,7 +326,12 @@ watch(activeTab, (tab) => {
|
|||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="结果">
|
<el-form-item label="结果">
|
||||||
<el-select v-model="loginFilters.success" placeholder="全部" clearable style="width: 110px">
|
<el-select
|
||||||
|
v-model="loginFilters.success"
|
||||||
|
placeholder="全部"
|
||||||
|
clearable
|
||||||
|
style="width: 110px"
|
||||||
|
>
|
||||||
<el-option
|
<el-option
|
||||||
v-for="item in RESULT_OPTIONS"
|
v-for="item in RESULT_OPTIONS"
|
||||||
:key="item.value"
|
:key="item.value"
|
||||||
@@ -325,18 +351,24 @@ watch(activeTab, (tab) => {
|
|||||||
:default-time="RANGE_DEFAULT_TIME"
|
:default-time="RANGE_DEFAULT_TIME"
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item>
|
<div class="filter-bar__actions">
|
||||||
<el-button type="primary" @click="searchLoginLogs">查询</el-button>
|
<el-button type="primary" @click="searchLoginLogs">查询</el-button>
|
||||||
<el-button @click="resetLoginFilters">重置</el-button>
|
<el-button @click="resetLoginFilters">重置</el-button>
|
||||||
</el-form-item>
|
</div>
|
||||||
</el-form>
|
</el-form>
|
||||||
|
|
||||||
<el-table v-loading="loginLoading" :data="loginRecords" stripe>
|
<div class="card">
|
||||||
|
<el-table
|
||||||
|
v-loading="loginLoading"
|
||||||
|
:data="loginRecords"
|
||||||
|
stripe
|
||||||
|
empty-text="这段时间还没有登录记录,试试放宽筛选条件"
|
||||||
|
>
|
||||||
<el-table-column label="时间" min-width="165">
|
<el-table-column label="时间" min-width="165">
|
||||||
<template #default="{ row }">{{ formatDateTime(row.create_time) }}</template>
|
<template #default="{ row }">{{ formatDateTime(row.create_time) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="username" label="用户名" min-width="130" />
|
<el-table-column prop="username" label="用户名" min-width="130" />
|
||||||
<el-table-column label="结果" width="80" align="center">
|
<el-table-column label="结果" width="96" align="center">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<StatusTag
|
<StatusTag
|
||||||
:value="row.success"
|
:value="row.success"
|
||||||
@@ -359,6 +391,7 @@ watch(activeTab, (tab) => {
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
|
||||||
|
<div class="card-foot">
|
||||||
<AppPagination
|
<AppPagination
|
||||||
:total="loginTotal"
|
:total="loginTotal"
|
||||||
:page-num="loginPageNum"
|
:page-num="loginPageNum"
|
||||||
@@ -366,21 +399,29 @@ watch(activeTab, (tab) => {
|
|||||||
@page-change="onLoginPageChange"
|
@page-change="onLoginPageChange"
|
||||||
@size-change="onLoginSizeChange"
|
@size-change="onLoginSizeChange"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
</el-tabs>
|
</el-tabs>
|
||||||
</el-card>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
/*
|
||||||
|
* 展开行面板:表格单元格 padding 由全局统一为 12px 16px,这里再套一层容器,
|
||||||
|
* 所以上下压到 4px 即可,否则展开区会明显比行内容松散;文字用次级墨色,
|
||||||
|
* 与主行(--ys-ink)区分开,读起来是「附注」而非新的一条记录。
|
||||||
|
*/
|
||||||
.log-detail {
|
.log-detail {
|
||||||
padding: 4px 16px;
|
padding: 4px 16px;
|
||||||
color: var(--ys-text);
|
color: var(--ys-ink-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.log-detail__label {
|
.log-detail__label {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 脱敏后的 JSON 参数按等宽排版才能看出字段边界;边框用全局线色,避免与主题脱钩 */
|
||||||
.log-detail__data {
|
.log-detail__data {
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
margin: 6px 0 12px;
|
margin: 6px 0 12px;
|
||||||
@@ -388,10 +429,11 @@ watch(activeTab, (tab) => {
|
|||||||
font-family: Consolas, 'Courier New', monospace;
|
font-family: Consolas, 'Courier New', monospace;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
background-color: var(--ys-paper);
|
background-color: var(--ys-paper);
|
||||||
border: 1px solid #e5e7eb;
|
border: 1px solid var(--ys-line);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* UA 串很长,单行省略 + title 悬浮看全文,避免撑高行或被裁成半截 */
|
||||||
.ua-cell {
|
.ua-cell {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
@@ -400,4 +442,5 @@ watch(activeTab, (tab) => {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
vertical-align: bottom;
|
vertical-align: bottom;
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
@@ -99,9 +99,24 @@ function valueLabel(value: string | null | undefined): string {
|
|||||||
return ENUM_VALUE_LABELS[value] ?? value
|
return ENUM_VALUE_LABELS[value] ?? value
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 带单位的值展示 */
|
/** 带单位的值展示:枚举与开关先翻成中文,其余原样加单位 */
|
||||||
function formatValue(item: ParamCatalogItem, value: string): string {
|
function formatValue(item: ParamCatalogItem, value: string): string {
|
||||||
return item.unit ? `${value} ${item.unit}` : value
|
// 枚举/开关的原始值是 INCLUDE、FULL、1、0 —— 直接摆给业务人员看不懂,
|
||||||
|
// 数字型(比例、天数、金额)保持原样,只补单位
|
||||||
|
const shown =
|
||||||
|
item.value_type === 'ENUM' || item.value_type === 'BOOL' ? valueLabel(value) : value
|
||||||
|
return item.unit ? `${shown} ${item.unit}` : shown
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按参数编码格式化取值:变更记录 / 版本历史 / 快照对比拿到的都是裸 value_text,
|
||||||
|
* 没有 value_type 与 unit,只能回目录里按编码查定义再格式化。
|
||||||
|
* 目录还没加载完或编码已下线时原样返回,宁可显示原文也不显示空白。
|
||||||
|
*/
|
||||||
|
function formatByCode(code: string, value: string | null | undefined): string {
|
||||||
|
if (value === null || value === undefined || value === '') return '—'
|
||||||
|
const item = catalog.value.find((entry) => entry.param_code === code)
|
||||||
|
return item ? formatValue(item, value) : value
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 修改(待提交) ====================
|
// ==================== 修改(待提交) ====================
|
||||||
@@ -431,19 +446,28 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div class="page">
|
||||||
<el-card shadow="never">
|
<!-- 页面头:这句描述原本挤在工具栏左侧当说明,现在归位成页面描述,语义更清楚 -->
|
||||||
<div class="page-toolbar">
|
<div class="page-head">
|
||||||
<span class="page-desc">
|
<h1 class="page-title">计提参数</h1>
|
||||||
计提比例、系数、阈值、分成与起付比例在此统一维护;变更逐项留痕并支持按日期生效。
|
<p class="page-desc">
|
||||||
</span>
|
计提比例、系数、阈值、分成与起付比例都在这里统一维护;每次改动留下旧值,可以指定从哪天起生效。
|
||||||
<span>
|
</p>
|
||||||
<el-button @click="openPreviewDrawer">计算预览</el-button>
|
<div class="waterline"></div>
|
||||||
<el-button @click="openVersionDrawer">版本管理</el-button>
|
|
||||||
<el-button @click="doExport" :loading="exporting">导出记录</el-button>
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
主操作行:这页没有筛选条件(参数按分组浏览而非检索),所以只有工具按钮。
|
||||||
|
「计算预览」是管理员最常走的一步,用主按钮把它与另两个次要动作区分开。
|
||||||
|
-->
|
||||||
|
<div class="toolbar">
|
||||||
|
<el-button type="primary" @click="openPreviewDrawer">计算预览</el-button>
|
||||||
|
<el-button @click="openVersionDrawer">版本管理</el-button>
|
||||||
|
<el-button @click="doExport" :loading="exporting">导出记录</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
<el-collapse v-loading="catalogLoading">
|
<el-collapse v-loading="catalogLoading">
|
||||||
<el-collapse-item v-for="group in groupedCatalog" :key="group.name" :name="group.name">
|
<el-collapse-item v-for="group in groupedCatalog" :key="group.name" :name="group.name">
|
||||||
<template #title>
|
<template #title>
|
||||||
@@ -475,7 +499,7 @@ onMounted(() => {
|
|||||||
type="warning"
|
type="warning"
|
||||||
disable-transitions
|
disable-transitions
|
||||||
>
|
>
|
||||||
待改为 {{ pendingOf(item, v.dimension_value)!.new_value }}
|
待改为 {{ formatValue(item, pendingOf(item, v.dimension_value)!.new_value) }}
|
||||||
</el-tag>
|
</el-tag>
|
||||||
<el-button
|
<el-button
|
||||||
v-if="isAdmin"
|
v-if="isAdmin"
|
||||||
@@ -489,7 +513,7 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<span v-else class="no-value">
|
<span v-else class="no-value">
|
||||||
未设置(默认 {{ item.default_value ?? '—' }})
|
未设置(默认 {{ formatByCode(item.param_code, item.default_value) }})
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
@@ -509,16 +533,17 @@ onMounted(() => {
|
|||||||
</table>
|
</table>
|
||||||
</el-collapse-item>
|
</el-collapse-item>
|
||||||
</el-collapse>
|
</el-collapse>
|
||||||
</el-card>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 待提交批次(仅管理员可见可操作) -->
|
<!-- 待提交批次(仅管理员可见可操作):提交前只是暂存,故用独立的次级卡片承载 -->
|
||||||
<el-card v-if="isAdmin && pendingList.length" shadow="never" class="pending-card">
|
<div v-if="isAdmin && pendingList.length" class="card pending-card">
|
||||||
<template #header>
|
<div class="card-head">
|
||||||
<div class="pending-head">
|
<div class="pending-head">
|
||||||
<b>待提交变更({{ pendingList.length }} 项)</b>
|
<b>待提交变更({{ pendingList.length }} 项)</b>
|
||||||
<span class="pending-hint">提交前不会生效;提交后未生效前可整批撤回</span>
|
<span class="pending-hint">提交前不会生效;提交后未生效前可整批撤回</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</div>
|
||||||
<el-table :data="pendingList" size="small">
|
<el-table :data="pendingList" size="small">
|
||||||
<el-table-column label="参数" min-width="160">
|
<el-table-column label="参数" min-width="160">
|
||||||
<template #default="{ row }">{{ row.item.param_name }}</template>
|
<template #default="{ row }">{{ row.item.param_name }}</template>
|
||||||
@@ -527,10 +552,14 @@ onMounted(() => {
|
|||||||
<template #default="{ row }">{{ dimensionLabel(row.dimension_value) }}</template>
|
<template #default="{ row }">{{ dimensionLabel(row.dimension_value) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="旧值" min-width="110">
|
<el-table-column label="旧值" min-width="110">
|
||||||
<template #default="{ row }">{{ row.old_value || '—' }}</template>
|
<template #default="{ row }">
|
||||||
|
{{ row.old_value ? formatByCode(row.item.param_code, row.old_value) : '—' }}
|
||||||
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="新值" min-width="110">
|
<el-table-column label="新值" min-width="110">
|
||||||
<template #default="{ row }"><b class="num">{{ row.new_value }}</b></template>
|
<template #default="{ row }">
|
||||||
|
<b class="num">{{ formatByCode(row.item.param_code, row.new_value) }}</b>
|
||||||
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="80">
|
<el-table-column label="操作" width="80">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
@@ -538,6 +567,7 @@ onMounted(() => {
|
|||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
<div class="card-foot">
|
||||||
<div class="submit-row">
|
<div class="submit-row">
|
||||||
<el-radio-group v-model="changeType">
|
<el-radio-group v-model="changeType">
|
||||||
<el-radio-button :value="1">立即生效</el-radio-button>
|
<el-radio-button :value="1">立即生效</el-radio-button>
|
||||||
@@ -559,7 +589,8 @@ onMounted(() => {
|
|||||||
/>
|
/>
|
||||||
<el-button type="primary" :loading="submitting" @click="submitAll">提交变更</el-button>
|
<el-button type="primary" :loading="submitting" @click="submitAll">提交变更</el-button>
|
||||||
</div>
|
</div>
|
||||||
</el-card>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 修改参数 -->
|
<!-- 修改参数 -->
|
||||||
<el-dialog v-model="editVisible" title="修改参数" width="440px" destroy-on-close>
|
<el-dialog v-model="editVisible" title="修改参数" width="440px" destroy-on-close>
|
||||||
@@ -578,24 +609,50 @@ onMounted(() => {
|
|||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="当前值">
|
<el-form-item label="当前值">
|
||||||
<span class="num">{{ currentValue(editTarget.item, editTarget.dimension) ?? editTarget.item.default_value ?? '—' }}</span>
|
<!-- 当前值同目录卡口径:枚举翻中文、数字补单位;编辑框里存的仍是原始值 -->
|
||||||
<span v-if="editTarget.item.unit" style="color: var(--ys-ink-3)"> {{ editTarget.item.unit }}</span>
|
<span class="num">
|
||||||
|
{{ formatByCode(editTarget.item.param_code, currentValue(editTarget.item, editTarget.dimension) ?? editTarget.item.default_value) }}
|
||||||
|
</span>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="新值">
|
<el-form-item label="新值">
|
||||||
|
<!--
|
||||||
|
枚举与开关都给下拉:让业务人员在「含基本工资」「开」里选,
|
||||||
|
而不是自己敲 INCLUDE / 1 —— 提交给后端的仍是原始值(v-model 绑的是 value)
|
||||||
|
-->
|
||||||
<el-select
|
<el-select
|
||||||
v-if="editTarget.item.value_type === 'ENUM'"
|
v-if="editTarget.item.value_type === 'ENUM'"
|
||||||
v-model="editValue"
|
v-model="editValue"
|
||||||
style="width: 100%"
|
style="width: 100%"
|
||||||
>
|
>
|
||||||
<el-option v-for="opt in editTarget.item.options" :key="opt" :label="opt" :value="opt" />
|
<!-- label 走 valueLabel:下拉里给的是「含基本工资」这类中文,提交值仍是 INCLUDE 原值 -->
|
||||||
|
<el-option
|
||||||
|
v-for="opt in editTarget.item.options"
|
||||||
|
:key="opt"
|
||||||
|
:label="valueLabel(opt)"
|
||||||
|
:value="opt"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
<el-select
|
||||||
|
v-else-if="editTarget.item.value_type === 'BOOL'"
|
||||||
|
v-model="editValue"
|
||||||
|
style="width: 100%"
|
||||||
|
>
|
||||||
|
<el-option label="开启" value="1" />
|
||||||
|
<el-option label="关闭" value="0" />
|
||||||
</el-select>
|
</el-select>
|
||||||
<el-input v-else v-model="editValue" placeholder="输入新值(分数式如 4/360 原样保留)" />
|
<el-input v-else v-model="editValue" placeholder="输入新值(分数式如 4/360 原样保留)" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<div class="edit-tip">
|
<!--
|
||||||
取值范围:{{ editTarget.item.value_min ?? '−' }} ~ {{ editTarget.item.value_max ?? '不限' }};
|
取值提示只对数字型参数有意义:枚举与开关是选出来的,摆「取值范围」只会添乱。
|
||||||
精确分数(如 4/360)原样存储、不做小数截断。
|
分数式(4/360)保留原文是因为截成小数会算出不一样的绩效,这条得讲给业务听。
|
||||||
|
-->
|
||||||
|
<div v-if="editTarget.item.value_type !== 'ENUM' && editTarget.item.value_type !== 'BOOL'" class="edit-tip">
|
||||||
|
取值范围:{{ editTarget.item.value_min ?? '不限' }} ~ {{ editTarget.item.value_max ?? '不限' }}
|
||||||
|
<template v-if="editTarget.item.unit">(单位:{{ editTarget.item.unit }})</template>
|
||||||
|
;比例可写成 4/360 这样的分数,系统按精确值计算,不四舍五入。
|
||||||
</div>
|
</div>
|
||||||
|
<div v-else class="edit-tip">改动提交后按你选择的生效日期起作用,旧值会保留在变更记录里。</div>
|
||||||
</template>
|
</template>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button @click="editVisible = false">取消</el-button>
|
<el-button @click="editVisible = false">取消</el-button>
|
||||||
@@ -603,7 +660,7 @@ onMounted(() => {
|
|||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
<!-- 版本管理抽屉 -->
|
<!-- 版本管理抽屉:对比两个日期各自的生效值 + 变更记录 -->
|
||||||
<el-drawer v-model="versionVisible" title="版本管理" size="760px">
|
<el-drawer v-model="versionVisible" title="版本管理" size="760px">
|
||||||
<div class="drawer-block">
|
<div class="drawer-block">
|
||||||
<div class="drawer-row">
|
<div class="drawer-row">
|
||||||
@@ -625,18 +682,18 @@ onMounted(() => {
|
|||||||
<el-button :loading="exporting" @click="doExport">导出记录</el-button>
|
<el-button :loading="exporting" @click="doExport">导出记录</el-button>
|
||||||
</div>
|
</div>
|
||||||
<template v-if="diffSearched">
|
<template v-if="diffSearched">
|
||||||
<div class="drawer-title">快照对比({{ compareFrom }} → {{ compareTo }})</div>
|
<div class="drawer-title">两个日期的参数对比({{ compareFrom }} → {{ compareTo }})</div>
|
||||||
<el-table :data="diffResult" size="small" max-height="260">
|
<el-table :data="diffResult" size="small" max-height="260">
|
||||||
<el-table-column prop="param_name" label="参数" min-width="130" />
|
<el-table-column prop="param_name" label="参数" min-width="130" />
|
||||||
<el-table-column label="维度" width="90">
|
<el-table-column label="维度" width="90">
|
||||||
<template #default="{ row }">{{ dimensionLabel(row.dimension_value) }}</template>
|
<template #default="{ row }">{{ dimensionLabel(row.dimension_value) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="旧值" min-width="90">
|
<el-table-column label="旧值" min-width="90">
|
||||||
<template #default="{ row }">{{ row.old_value ?? '—' }}</template>
|
<template #default="{ row }">{{ formatByCode(row.param_code, row.old_value) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="新值" min-width="90">
|
<el-table-column label="新值" min-width="90">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<span :class="{ changed: row.changed }">{{ row.new_value ?? '—' }}</span>
|
<span :class="{ changed: row.changed }">{{ formatByCode(row.param_code, row.new_value) }}</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="变化" width="70" align="center">
|
<el-table-column label="变化" width="70" align="center">
|
||||||
@@ -658,7 +715,7 @@ onMounted(() => {
|
|||||||
<div v-for="item in detailCache[row.id].items" :key="item.param_code + item.dimension_value" class="batch-item">
|
<div v-for="item in detailCache[row.id].items" :key="item.param_code + item.dimension_value" class="batch-item">
|
||||||
<span class="param-name">{{ item.param_name }}</span>
|
<span class="param-name">{{ item.param_name }}</span>
|
||||||
<span class="dim-label">{{ dimensionLabel(item.dimension_value) }}</span>
|
<span class="dim-label">{{ dimensionLabel(item.dimension_value) }}</span>
|
||||||
<span class="num">{{ item.old_value_text ?? '—' }} → <b>{{ item.value_text }}</b></span>
|
<span class="num">{{ formatByCode(item.param_code, item.old_value_text) }} → <b>{{ formatByCode(item.param_code, item.value_text) }}</b></span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<el-button v-else link size="small" @click="detailOf(row).catch(() => {})">
|
<el-button v-else link size="small" @click="detailOf(row).catch(() => {})">
|
||||||
@@ -707,7 +764,11 @@ onMounted(() => {
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="旧值 → 新值" min-width="130">
|
<el-table-column label="旧值 → 新值" min-width="130">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<span class="num">{{ row.old_value_text ?? '—' }} → {{ row.value_text }}</span>
|
<!-- 历史行没有 value_type,按当前参数定义格式化(枚举翻中文、数字补单位) -->
|
||||||
|
<span class="num">
|
||||||
|
{{ formatByCode(historyItem?.param_code ?? '', row.old_value_text) }} →
|
||||||
|
{{ formatByCode(historyItem?.param_code ?? '', row.value_text) }}
|
||||||
|
</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="version_no" label="版本" width="115" />
|
<el-table-column prop="version_no" label="版本" width="115" />
|
||||||
@@ -797,9 +858,10 @@ onMounted(() => {
|
|||||||
<div v-if="previewResult.probation_note" class="pr-note">{{ previewResult.probation_note }}</div>
|
<div v-if="previewResult.probation_note" class="pr-note">{{ previewResult.probation_note }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="pr-used">
|
<div class="pr-used">
|
||||||
<div class="drawer-title">所用参数(按回款到账日取值)</div>
|
<div class="drawer-title">本次用到的参数(按回款到账日取当天生效值)</div>
|
||||||
|
<!-- 只显示中文参数名与格式化后的值:参数编码是内部标识,业务人员对不上 -->
|
||||||
<div v-for="u in previewResult.params_used" :key="u.param_code" class="pr-used-row">
|
<div v-for="u in previewResult.params_used" :key="u.param_code" class="pr-used-row">
|
||||||
{{ paramNameByCode(u.param_code) }}({{ u.param_code }})= {{ valueLabel(u.value_text) || u.value_text }}
|
{{ paramNameByCode(u.param_code) }} = {{ formatByCode(u.param_code, u.value_text) }}
|
||||||
<span v-if="u.effective_date" class="eff">({{ u.effective_date }} 起)</span>
|
<span v-if="u.effective_date" class="eff">({{ u.effective_date }} 起)</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -809,17 +871,7 @@ onMounted(() => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.page-toolbar {
|
/* 页面里已经没有 .page-toolbar/.page-desc —— 描述与工具栏改用全局 .page-head/.toolbar */
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-desc {
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--ys-ink-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.group-title {
|
.group-title {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -885,6 +937,7 @@ onMounted(() => {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 待提交卡片紧跟目录卡,需要与上方卡片拉开距离(.card 自身不带外边距) */
|
||||||
.pending-card {
|
.pending-card {
|
||||||
margin-top: 14px;
|
margin-top: 14px;
|
||||||
}
|
}
|
||||||
@@ -900,11 +953,14 @@ onMounted(() => {
|
|||||||
color: var(--ys-ink-3);
|
color: var(--ys-ink-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 提交区已放进 .card-foot:底栏自带 10px 16px 内边距与右对齐,
|
||||||
|
* 所以这里去掉原先的 margin-top,否则会与底栏留白叠成双份。
|
||||||
|
*/
|
||||||
.submit-row {
|
.submit-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-top: 14px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.edit-name {
|
.edit-name {
|
||||||
|
|||||||
@@ -170,9 +170,21 @@ onMounted(load)
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div class="page">
|
||||||
<el-card shadow="never">
|
<!-- 页面头:说明这页管授权基线,并点出两条会挡住操作的保护规则 -->
|
||||||
<el-form class="filter-form" :inline="true" @submit.prevent>
|
<div class="page-head">
|
||||||
|
<h1 class="page-title">角色管理</h1>
|
||||||
|
<p class="page-desc">
|
||||||
|
决定账号能进哪些功能;内置 admin 不可删停,仍有用户绑定的角色需先解绑才能删除。
|
||||||
|
</p>
|
||||||
|
<div class="waterline"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
筛选条:.filter-bar 自带 flex + gap + 内边距,故 el-form 不再写 inline;
|
||||||
|
查询/重置收进 .filter-bar__actions,靠 margin-left:auto 贴到行尾。
|
||||||
|
-->
|
||||||
|
<el-form class="filter-bar" @submit.prevent>
|
||||||
<el-form-item label="关键词">
|
<el-form-item label="关键词">
|
||||||
<el-input
|
<el-input
|
||||||
v-model="filters.keyword"
|
v-model="filters.keyword"
|
||||||
@@ -192,21 +204,35 @@ onMounted(load)
|
|||||||
/>
|
/>
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item>
|
<div class="filter-bar__actions">
|
||||||
<el-button type="primary" @click="search">查询</el-button>
|
<el-button type="primary" @click="search">查询</el-button>
|
||||||
<el-button @click="resetFilters">重置</el-button>
|
<el-button @click="resetFilters">重置</el-button>
|
||||||
</el-form-item>
|
</div>
|
||||||
</el-form>
|
</el-form>
|
||||||
|
|
||||||
|
<!-- 主操作行:新增是这页唯一的高频动作,单独成行且用主按钮 -->
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<el-button type="primary" @click="openCreate">新增角色</el-button>
|
<el-button type="primary" @click="openCreate">新增角色</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-table v-loading="loading" :data="records" stripe>
|
<div class="card">
|
||||||
|
<el-table
|
||||||
|
v-loading="loading"
|
||||||
|
:data="records"
|
||||||
|
stripe
|
||||||
|
empty-text="还没有角色,点「新增角色」创建第一个"
|
||||||
|
>
|
||||||
<el-table-column label="编码" min-width="150">
|
<el-table-column label="编码" min-width="150">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<span class="role-code">{{ row.role_code }}</span>
|
<!-- 角色编码是授权判定的锚点,等宽字体便于逐字符核对 -->
|
||||||
<el-tag v-if="isBuiltin(row)" class="builtin-tag" size="small" type="warning" disable-transitions>
|
<span class="cell-code">{{ row.role_code }}</span>
|
||||||
|
<el-tag
|
||||||
|
v-if="isBuiltin(row)"
|
||||||
|
class="builtin-tag"
|
||||||
|
size="small"
|
||||||
|
type="warning"
|
||||||
|
disable-transitions
|
||||||
|
>
|
||||||
内置
|
内置
|
||||||
</el-tag>
|
</el-tag>
|
||||||
</template>
|
</template>
|
||||||
@@ -215,14 +241,17 @@ onMounted(load)
|
|||||||
<el-table-column prop="description" label="说明" min-width="180">
|
<el-table-column prop="description" label="说明" min-width="180">
|
||||||
<template #default="{ row }">{{ row.description || '—' }}</template>
|
<template #default="{ row }">{{ row.description || '—' }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="sort_order" label="排序" width="80" align="center" />
|
<!-- 排序值与用户数都是用于比较的数字:右对齐 + tabular-nums,纵向扫读时数位对齐 -->
|
||||||
<el-table-column label="状态" width="90" align="center">
|
<el-table-column label="排序" width="80" align="right">
|
||||||
|
<template #default="{ row }"><span class="cell-num">{{ row.sort_order }}</span></template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="状态" width="96" align="center">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<StatusTag :value="row.status" />
|
<StatusTag :value="row.status" />
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="用户数" width="90" align="center">
|
<el-table-column label="用户数" width="90" align="right">
|
||||||
<template #default="{ row }">{{ row.user_count }}</template>
|
<template #default="{ row }"><span class="cell-num">{{ row.user_count }}</span></template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="170" fixed="right" align="center">
|
<el-table-column label="操作" width="170" fixed="right" align="center">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
@@ -256,6 +285,7 @@ onMounted(load)
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
|
||||||
|
<div class="card-foot">
|
||||||
<AppPagination
|
<AppPagination
|
||||||
:total="total"
|
:total="total"
|
||||||
:page-num="pageNum"
|
:page-num="pageNum"
|
||||||
@@ -263,7 +293,8 @@ onMounted(load)
|
|||||||
@page-change="onPageChange"
|
@page-change="onPageChange"
|
||||||
@size-change="onSizeChange"
|
@size-change="onSizeChange"
|
||||||
/>
|
/>
|
||||||
</el-card>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<el-dialog
|
<el-dialog
|
||||||
v-model="dialogVisible"
|
v-model="dialogVisible"
|
||||||
@@ -298,11 +329,9 @@ onMounted(load)
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.role-code {
|
/* 「内置」标记跟在编码后面,需要一个横向间距;.el-tag 自身不带外边距 */
|
||||||
font-family: Consolas, 'Courier New', monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
.builtin-tag {
|
.builtin-tag {
|
||||||
margin-left: 6px;
|
margin-left: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
@@ -10,7 +10,10 @@ import { handledError } from '@/utils/error'
|
|||||||
* 公司信息设置(2026-10-15 补充需求:公司名称、logo)。
|
* 公司信息设置(2026-10-15 补充需求:公司名称、logo)。
|
||||||
*
|
*
|
||||||
* 签名元素是右侧「登录页实景预览」:品牌名与 logo 改动即时反映在一张
|
* 签名元素是右侧「登录页实景预览」:品牌名与 logo 改动即时反映在一张
|
||||||
* 缩比登录卡上 —— 所见即所得,避免「保存后去登录页核对」的往返。
|
* **1:1 等比**的登录卡上 —— 所见即所得,避免「保存后去登录页核对」的往返。
|
||||||
|
* 预览按登录页的真实版式复刻(品牌区 / 欢迎回来 / 三项输入 / 登录按钮 /
|
||||||
|
* 版权行),控件尺寸与登录页一致,logo 展示位也按登录页的 ≤56×36 约束。
|
||||||
|
*
|
||||||
* logo 校验(png/jpg ≤2MB)与后端三重校验同口径,前端先拦一道。
|
* logo 校验(png/jpg ≤2MB)与后端三重校验同口径,前端先拦一道。
|
||||||
*
|
*
|
||||||
* 进入页面即加载当前设置(设置页即所见即所得,无需手动刷新)。
|
* 进入页面即加载当前设置(设置页即所见即所得,无需手动刷新)。
|
||||||
@@ -91,30 +94,48 @@ const previewName = computed(() => form.company_name.trim() || '衍水集团')
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<div class="settings-grid">
|
<!-- 页面头:这是表单页不是列表页,所以没有筛选条,只有标题 + 影响面说明 -->
|
||||||
<el-card shadow="never" class="settings-card">
|
<div class="page-head">
|
||||||
<template #header>
|
<h1 class="page-title">公司信息</h1>
|
||||||
<span class="card-title">公司信息</span>
|
<p class="page-desc">维护公司名称与 logo;保存后立即作用于登录页品牌位与导出页眉。</p>
|
||||||
</template>
|
<div class="waterline"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-grid">
|
||||||
|
<!-- 左列:基础信息(表单)+ 生效范围(这份设置会出现在哪里) -->
|
||||||
|
<div class="settings-col">
|
||||||
|
<div class="card">
|
||||||
|
<!-- 卡片标题刻意不写「公司信息」:页面标题已经说明了,再重复一遍只是噪音 -->
|
||||||
|
<div class="card-head">
|
||||||
|
<span class="card-title">基础信息</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-body">
|
||||||
<el-form
|
<el-form
|
||||||
ref="formRef"
|
ref="formRef"
|
||||||
:model="form"
|
:model="form"
|
||||||
:rules="rules"
|
:rules="rules"
|
||||||
label-width="88px"
|
label-width="92px"
|
||||||
@submit.prevent
|
@submit.prevent
|
||||||
>
|
>
|
||||||
|
<!--
|
||||||
|
字段自适应分列:卡片够宽时两个字段并排(各占一列),窄时自动折回单列。
|
||||||
|
说明文字紧贴控件下方 —— 早前把它顶到卡片最右缘,宽屏下与输入框隔了
|
||||||
|
上千像素,视线关联直接断掉。
|
||||||
|
-->
|
||||||
|
<div class="field-grid">
|
||||||
<el-form-item label="公司名称" prop="company_name">
|
<el-form-item label="公司名称" prop="company_name">
|
||||||
<el-input
|
<el-input
|
||||||
v-model="form.company_name"
|
v-model="form.company_name"
|
||||||
|
class="field-control__main"
|
||||||
maxlength="64"
|
maxlength="64"
|
||||||
placeholder="展示在登录页与导出页眉"
|
placeholder="展示在登录页与导出页眉"
|
||||||
clearable
|
clearable
|
||||||
/>
|
/>
|
||||||
|
<p class="field-control__note">最多 64 个字符;保存后对所有在线用户立即生效。</p>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
<el-form-item label="logo">
|
<el-form-item label="logo">
|
||||||
<div class="logo-field">
|
|
||||||
<el-upload
|
<el-upload
|
||||||
class="logo-uploader"
|
class="logo-uploader"
|
||||||
:show-file-list="false"
|
:show-file-list="false"
|
||||||
@@ -127,35 +148,89 @@ const previewName = computed(() => form.company_name.trim() || '衍水集团')
|
|||||||
<span v-else class="logo-plus">+</span>
|
<span v-else class="logo-plus">+</span>
|
||||||
</div>
|
</div>
|
||||||
</el-upload>
|
</el-upload>
|
||||||
<p class="logo-hint">png / jpg,不超过 2MB;<br />展示于登录页品牌位与导出页眉。</p>
|
<p class="field-control__note">
|
||||||
|
png / jpg,不超过 2MB;仅展示于登录页品牌位,按 ≤56×36 显示。
|
||||||
|
</p>
|
||||||
|
</el-form-item>
|
||||||
</div>
|
</div>
|
||||||
</el-form-item>
|
|
||||||
|
|
||||||
<el-form-item>
|
|
||||||
<el-button type="primary" :loading="submitting" @click="handleSubmit">保存</el-button>
|
|
||||||
</el-form-item>
|
|
||||||
</el-form>
|
</el-form>
|
||||||
</el-card>
|
</div>
|
||||||
|
|
||||||
<!-- 签名元素:登录页实景预览(缩比登录卡,品牌名/logo 即时反映) -->
|
<!--
|
||||||
<el-card shadow="never" class="preview-card">
|
保存按钮移进 card-foot:表单项里的按钮会跟着 label 列缩进、并对齐到输入框左缘,
|
||||||
<template #header>
|
作为整张表单的收尾动作用卡片底栏右对齐更符合阅读顺序。
|
||||||
<span class="card-title">登录页预览</span>
|
按钮在 form 之外仍安全:handleSubmit 走 formRef 校验,且 el-button 默认 type="button",
|
||||||
|
不会触发浏览器原生提交。
|
||||||
|
-->
|
||||||
|
<div class="card-foot">
|
||||||
|
<el-button type="primary" :loading="submitting" @click="handleSubmit">保存</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-head">
|
||||||
|
<span class="card-title">生效范围</span>
|
||||||
<span class="preview-note">保存后对所有人生效</span>
|
<span class="preview-note">保存后对所有人生效</span>
|
||||||
</template>
|
</div>
|
||||||
<div class="login-mock">
|
<div class="card-body">
|
||||||
|
<dl class="scope-list">
|
||||||
|
<div class="scope-item">
|
||||||
|
<dt>公司名称</dt>
|
||||||
|
<dd>登录页品牌位与版权行 · Excel 导出页眉 · 打印稿页眉</dd>
|
||||||
|
</div>
|
||||||
|
<div class="scope-item">
|
||||||
|
<dt>logo</dt>
|
||||||
|
<dd>登录页品牌位(≤56×36)</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 签名元素:登录页实景预览(1:1 等比登录卡,非 iframe 截图) -->
|
||||||
|
<div class="card preview-card">
|
||||||
|
<div class="card-head">
|
||||||
|
<span class="card-title">登录页预览</span>
|
||||||
|
<span class="preview-note">1:1 等比</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="login-stage" aria-hidden="true">
|
||||||
|
<div class="mock-card">
|
||||||
<div class="mock-brand">
|
<div class="mock-brand">
|
||||||
<img v-if="logoUrl" :src="logoUrl" alt="" class="mock-logo" />
|
<img v-if="logoUrl" :src="logoUrl" alt="" class="mock-logo" />
|
||||||
<div class="mock-name">{{ previewName }}</div>
|
<div class="mock-name">{{ previewName }}</div>
|
||||||
<p class="mock-slogan">把每一笔回款,算成一份踏实。</p>
|
<p class="mock-slogan">把每一笔回款,算成一份踏实。</p>
|
||||||
<span class="mock-waterline"></span>
|
<span class="mock-waterline"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="mock-field"></div>
|
|
||||||
<div class="mock-field"></div>
|
<div class="mock-head">
|
||||||
|
<div class="mock-title">欢迎回来</div>
|
||||||
|
<div class="mock-hint">请输入账号密码进入系统</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mock-field">
|
||||||
|
<span class="mock-label">用户名</span>
|
||||||
|
<span class="mock-box"></span>
|
||||||
|
</div>
|
||||||
|
<div class="mock-field">
|
||||||
|
<span class="mock-label">密码</span>
|
||||||
|
<span class="mock-box"></span>
|
||||||
|
</div>
|
||||||
|
<div class="mock-field">
|
||||||
|
<span class="mock-label">验证码</span>
|
||||||
|
<span class="mock-captcha-row">
|
||||||
|
<span class="mock-box"></span>
|
||||||
|
<span class="mock-captcha"></span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="mock-submit">登 录</div>
|
<div class="mock-submit">登 录</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="preview-caption">登录页品牌位实景(示意,非交互)</p>
|
<div class="mock-foot">© {{ previewName }} · 内部系统</div>
|
||||||
</el-card>
|
</div>
|
||||||
|
<p class="preview-caption">登录页品牌位实景(示意,非交互)——显示的是当前编辑值</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -163,25 +238,58 @@ const previewName = computed(() => form.company_name.trim() || '衍水集团')
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.settings-grid {
|
.settings-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(360px, 460px) minmax(300px, 380px);
|
grid-template-columns: minmax(0, 1fr) 480px;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-title {
|
.settings-col {
|
||||||
font-weight: 600;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 说明文字不再用 float:right 顶到卡片头右侧 —— .card-head 已是 flex +
|
||||||
|
* space-between,第二个子元素自然落在右端,float 反而会破坏行内布局。
|
||||||
|
*/
|
||||||
.preview-note {
|
.preview-note {
|
||||||
float: right;
|
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--ys-ink-3);
|
color: var(--ys-ink-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo-field {
|
/*
|
||||||
|
* 控件行:主控件在左、说明在右,两者共享一行高度。
|
||||||
|
* 说明用 flex-grow 撑满剩余宽度并右对齐 —— 宽屏下整行铺到卡片右缘,
|
||||||
|
* 不会出现「输入框缩在左上角、右侧留一整块空白」的观感;
|
||||||
|
* 窄屏放不下时自动换行,说明退回左对齐。
|
||||||
|
*/
|
||||||
|
.field-control {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
flex-wrap: wrap;
|
||||||
gap: 14px;
|
align-items: center;
|
||||||
|
gap: 8px 20px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-control__main {
|
||||||
|
flex: 0 1 420px;
|
||||||
|
min-width: 240px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-control__note {
|
||||||
|
flex: 1 1 240px;
|
||||||
|
min-width: 240px;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: var(--ys-ink-3);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-uploader {
|
||||||
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo-box {
|
.logo-box {
|
||||||
@@ -215,22 +323,76 @@ const previewName = computed(() => form.company_name.trim() || '衍水集团')
|
|||||||
color: var(--ys-ink-3);
|
color: var(--ys-ink-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo-hint {
|
/* ── 生效范围:定义列表(项名 / 取值) ── */
|
||||||
margin: 4px 0 0;
|
.scope-list {
|
||||||
font-size: 12px;
|
margin: 0;
|
||||||
line-height: 1.7;
|
}
|
||||||
|
|
||||||
|
.scope-item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 92px minmax(0, 1fr);
|
||||||
|
column-gap: 20px;
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scope-item + .scope-item {
|
||||||
|
padding-top: 10px;
|
||||||
|
margin-top: 10px;
|
||||||
|
border-top: 1px solid var(--ys-line-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scope-item dt {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--ys-ink-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scope-item dd {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12.5px;
|
||||||
color: var(--ys-ink-3);
|
color: var(--ys-ink-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── 登录页实景预览:按登录卡视觉 0.8 缩比重建(非 iframe 截图) ── */
|
/* ── 登录页实景预览:按登录页真实版式 1:1 复刻 ── */
|
||||||
.login-mock {
|
.login-stage {
|
||||||
padding: 22px 26px 24px;
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 24px 0 20px;
|
||||||
|
overflow: hidden;
|
||||||
|
background-color: #f2f4f8;
|
||||||
|
/* 点阵网格:与登录页同一套秩序感 */
|
||||||
|
background-image: radial-gradient(rgba(28, 35, 51, 0.07) 1px, transparent 1px);
|
||||||
|
background-size: 24px 24px;
|
||||||
|
border-radius: var(--ys-radius-sm);
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 顶部天光:登录页的落点光源,等比搬过来 */
|
||||||
|
.login-stage::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -180px;
|
||||||
|
left: 50%;
|
||||||
|
width: 860px;
|
||||||
|
height: 460px;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
pointer-events: none;
|
||||||
|
background: radial-gradient(closest-side, rgba(46, 75, 168, 0.08), rgba(46, 75, 168, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
.mock-card {
|
||||||
|
position: relative;
|
||||||
|
width: 400px;
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 30px 36px 32px;
|
||||||
background: linear-gradient(180deg, #ffffff, #fcfcfe);
|
background: linear-gradient(180deg, #ffffff, #fcfcfe);
|
||||||
border: 1px solid var(--ys-line);
|
border: 1px solid rgba(28, 35, 51, 0.09);
|
||||||
border-radius: 12px;
|
border-radius: 14px;
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 2px 4px rgba(28, 35, 51, 0.04),
|
0 2px 4px rgba(28, 35, 51, 0.04),
|
||||||
0 10px 26px rgba(28, 35, 51, 0.08);
|
0 16px 40px rgba(28, 35, 51, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.mock-brand {
|
.mock-brand {
|
||||||
@@ -239,62 +401,136 @@ const previewName = computed(() => form.company_name.trim() || '衍水集团')
|
|||||||
|
|
||||||
.mock-logo {
|
.mock-logo {
|
||||||
display: block;
|
display: block;
|
||||||
max-width: 64px;
|
max-width: 56px;
|
||||||
max-height: 40px;
|
max-height: 36px;
|
||||||
margin: 0 auto 8px;
|
margin: 0 auto 10px;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mock-name {
|
.mock-name {
|
||||||
font-family: var(--ys-font-brand);
|
font-family: var(--ys-font-brand);
|
||||||
font-size: 14px;
|
font-size: 17px;
|
||||||
|
font-weight: 500;
|
||||||
letter-spacing: 0.34em;
|
letter-spacing: 0.34em;
|
||||||
color: var(--ys-ink);
|
color: var(--ys-ink);
|
||||||
}
|
}
|
||||||
|
|
||||||
.mock-slogan {
|
.mock-slogan {
|
||||||
margin: 6px 0 0;
|
margin: 8px 0 0;
|
||||||
font-size: 11px;
|
font-size: 12.5px;
|
||||||
color: var(--ys-ink-3);
|
color: var(--ys-ink-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.mock-waterline {
|
.mock-waterline {
|
||||||
display: block;
|
display: block;
|
||||||
width: 34px;
|
width: 40px;
|
||||||
height: 2px;
|
height: 2px;
|
||||||
margin: 10px auto 0;
|
margin: 14px auto 0;
|
||||||
border-radius: 1px;
|
border-radius: 1px;
|
||||||
background: linear-gradient(90deg, var(--ys-indigo), rgba(46, 75, 168, 0));
|
background: linear-gradient(90deg, var(--ys-indigo), rgba(46, 75, 168, 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mock-head {
|
||||||
|
margin-top: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mock-title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ys-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mock-hint {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ys-ink-3);
|
||||||
|
}
|
||||||
|
|
||||||
.mock-field {
|
.mock-field {
|
||||||
height: 32px;
|
margin-top: 16px;
|
||||||
margin-top: 12px;
|
}
|
||||||
|
|
||||||
|
.mock-field + .mock-field {
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mock-label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 22px;
|
||||||
|
color: var(--ys-ink-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mock-box {
|
||||||
|
display: block;
|
||||||
|
height: 38px;
|
||||||
background: #f4f5f8;
|
background: #f4f5f8;
|
||||||
border: 1px solid rgba(28, 35, 51, 0.07);
|
border: 1px solid rgba(28, 35, 51, 0.07);
|
||||||
border-radius: 6px;
|
border-radius: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mock-captcha-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mock-captcha-row .mock-box {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 验证码位:留空壳 + 斜纹质感,不伪造字符,避免与真实验证码混淆 */
|
||||||
|
.mock-captcha {
|
||||||
|
flex: 0 0 104px;
|
||||||
|
height: 38px;
|
||||||
|
background-color: #f1f3f8;
|
||||||
|
background-image: repeating-linear-gradient(
|
||||||
|
45deg,
|
||||||
|
rgba(28, 35, 51, 0.07) 0 1px,
|
||||||
|
transparent 1px 7px
|
||||||
|
);
|
||||||
|
border: 1px solid var(--ys-line);
|
||||||
|
border-radius: 7px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mock-submit {
|
.mock-submit {
|
||||||
height: 32px;
|
height: 38px;
|
||||||
margin-top: 14px;
|
margin-top: 20px;
|
||||||
font-size: 12px;
|
font-size: 13px;
|
||||||
line-height: 32px;
|
line-height: 38px;
|
||||||
letter-spacing: 0.4em;
|
letter-spacing: 0.4em;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
background: var(--ys-indigo);
|
background: linear-gradient(180deg, var(--ys-indigo-hi), var(--ys-indigo));
|
||||||
border-radius: 6px;
|
border-radius: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mock-foot {
|
||||||
|
margin-top: 16px;
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--ys-ink-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-caption {
|
.preview-caption {
|
||||||
margin: 10px 0 0;
|
margin: 12px 0 0;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: var(--ys-ink-3);
|
color: var(--ys-ink-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
/*
|
||||||
|
* 宽屏(≥1600px)说明文字靠右缘对齐,与左侧输入框形成「两端锚点」,
|
||||||
|
* 整行铺满卡片而不是缩在左上角;1600px 以下说明跟着输入框走,
|
||||||
|
* 放不下时整体换行到控件下方。
|
||||||
|
*/
|
||||||
|
@media (min-width: 1600px) {
|
||||||
|
.field-control__note {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1200px) {
|
||||||
.settings-grid {
|
.settings-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|||||||
Loaded 100 of 101 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user