Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32b7940067 | ||
|
|
b9781f7d3f | ||
|
|
8e8bcaa5fb | ||
|
|
1f65913002 | ||
|
|
e61800dcdc | ||
|
|
f88857e026 | ||
|
|
5ece9f6dab | ||
|
|
c48d24ab6d |
No files matched your search
+2
-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 人员出姓名) |
|
||||||
|
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -19,11 +19,7 @@ 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="无权访问工作台"
|
|
||||||
)
|
|
||||||
),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -39,6 +35,4 @@ def get_dashboard(
|
|||||||
|
|
||||||
待办按当前用户角色只返回其可操作节点(admin 可查看全部在途单)。
|
待办按当前用户角色只返回其可操作节点(admin 可查看全部在途单)。
|
||||||
"""
|
"""
|
||||||
return ok(
|
return ok(dashboard_service.get_dashboard(session, month, current_user=current_user))
|
||||||
dashboard_service.get_dashboard(session, month, current_user=current_user)
|
|
||||||
)
|
|
||||||
@@ -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,25 @@ 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 _apply_filters(stmt: Select, query: OperationLogListQuery) -> Select:
|
||||||
stmt: Select,
|
|
||||||
*,
|
|
||||||
filters: LogFilters,
|
|
||||||
module: str | None,
|
|
||||||
action: str | None,
|
|
||||||
) -> 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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -168,9 +168,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"))
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -253,9 +251,11 @@ def _activities(session: Session) -> list[dict]:
|
|||||||
"""最近动态(FR-036):以操作日志为唯一来源(写操作即留痕)。"""
|
"""最近动态(FR-036):以操作日志为唯一来源(写操作即留痕)。"""
|
||||||
logs = operation_log_repository.list_logs(
|
logs = operation_log_repository.list_logs(
|
||||||
session,
|
session,
|
||||||
|
operation_log_repository.OperationLogListQuery(
|
||||||
|
filters=LogFilters(),
|
||||||
offset=0,
|
offset=0,
|
||||||
limit=_ACTIVITY_LIMIT,
|
limit=_ACTIVITY_LIMIT,
|
||||||
filters=LogFilters(),
|
),
|
||||||
)
|
)
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -341,9 +341,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=("发货后超过该天数仍未回款的发货批次触发预警(澄清 #10 建议默认 90 天)"),
|
||||||
"发货后超过该天数仍未回款的发货批次触发预警(澄清 #10 建议默认 90 天)"
|
|
||||||
),
|
|
||||||
sort_order=22,
|
sort_order=22,
|
||||||
),
|
),
|
||||||
ParamSpec(
|
ParamSpec(
|
||||||
@@ -357,9 +355,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=("待审批单提交后超过该天数未处理触发预警(澄清 #10 建议默认 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
|
||||||
@@ -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,19 +493,66 @@ 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 倍(与业务员分支同防线)
|
||||||
@@ -531,7 +562,7 @@ def _preview_rate(
|
|||||||
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!r} 不存在")
|
||||||
template = role.calc_template
|
template = role.calc_template
|
||||||
@@ -561,12 +592,73 @@ def _preview_rate(
|
|||||||
raise BadRequestError(f"公式模板 {template} 暂不支持预览")
|
raise BadRequestError(f"公式模板 {template} 暂不支持预览")
|
||||||
|
|
||||||
|
|
||||||
|
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!r} 不存在,可选:municipal_water / rail_transit"
|
||||||
|
)
|
||||||
|
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:
|
||||||
"""计算预览:入参(发货/回款/金额/岗位)→ 公式纯函数 → 出参(含所用参数)。"""
|
"""计算预览:入参(发货/回款/金额/岗位)→ 公式纯函数 → 出参(含所用参数)。"""
|
||||||
# 参数按回款到账日取值(对照表 #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 +681,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],
|
||||||
|
|||||||
@@ -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,6 +205,7 @@ 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` 运行 |
|
||||||
|
|
||||||
|
|||||||
@@ -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"], "未评分必须垫底"
|
||||||
@@ -138,9 +138,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 +164,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"]
|
||||||
# ⑥ 未建:显式降级
|
# ⑥ 未建:显式降级
|
||||||
@@ -335,17 +329,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 +347,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",
|
||||||
|
|||||||
@@ -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 = [
|
||||||
|
|||||||
@@ -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 定键后升级)',
|
||||||
@@ -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`;
|
||||||
+900
-86
File diff suppressed because it is too large.
Load diff
Binary file not shown.
+48
-3
@@ -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,62 @@ 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 已在该文件踩过一次,已修回),改这个文件不要过任何编码转换。
|
||||||
|
|
||||||
|
**签名元素**:**渐隐靛蓝水线**——各页面标题下的细线(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(如 以物抵顶)
|
||||||
|
|
||||||
## 验证码图片
|
## 验证码图片
|
||||||
|
|
||||||
|
|||||||
@@ -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,11 @@ export interface CustomerOwnershipParams {
|
|||||||
reason?: string | null
|
reason?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 保存评级入参:只存总分(五项明细不落库,评分是档案属性而非审计事件) */
|
||||||
|
export interface CustomerRatingParams {
|
||||||
|
score: number
|
||||||
|
}
|
||||||
|
|
||||||
/** 归属变更留痕条目(对应后端 `OwnershipChangeLogOut`;姓名可随人员消失,ID 固化) */
|
/** 归属变更留痕条目(对应后端 `OwnershipChangeLogOut`;姓名可随人员消失,ID 固化) */
|
||||||
export interface OwnershipLogItem {
|
export interface OwnershipLogItem {
|
||||||
id: number
|
id: number
|
||||||
@@ -114,6 +127,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,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)统一视觉
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -11,11 +11,19 @@ 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" :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>
|
||||||
@@ -32,34 +40,129 @@ const { topLevelMenus, groupedMenus } = useAppMenu()
|
|||||||
--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: 36px;
|
||||||
|
|
||||||
|
padding: 0 10px;
|
||||||
border-right: none;
|
border-right: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 菜单项:内距 9px 10px、圆角 7px、13px 字 —— 组内层级靠缩进区分,不加分隔线 */
|
||||||
|
.app-menu :deep(.el-menu-item) {
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 10px;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 36px;
|
||||||
|
border-radius: var(--ys-radius-sm);
|
||||||
|
transition:
|
||||||
|
background-color 150ms var(--ys-ease),
|
||||||
|
color 150ms var(--ys-ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-menu :deep(.el-menu-item:hover) {
|
||||||
|
color: var(--ys-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 子项向右缩进一档,与顶层项形成「树」的暗示而非并列 */
|
||||||
|
.app-menu :deep(.el-sub-menu .el-menu-item) {
|
||||||
|
padding-left: 22px !important;
|
||||||
|
}
|
||||||
|
|
||||||
.app-menu :deep(.el-menu-item.is-active) {
|
.app-menu :deep(.el-menu-item.is-active) {
|
||||||
position: relative;
|
position: relative;
|
||||||
background: linear-gradient(180deg, #ffffff, #fbfcfe);
|
font-weight: 500;
|
||||||
|
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-menu-item.is-active)::before {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: -14px;
|
|
||||||
top: 9px;
|
top: 9px;
|
||||||
bottom: 9px;
|
bottom: 9px;
|
||||||
|
left: -10px;
|
||||||
width: 2px;
|
width: 2px;
|
||||||
border-radius: 1px;
|
border-radius: 1px;
|
||||||
background: var(--ys-indigo);
|
background: var(--ys-indigo);
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-menu :deep(.el-sub-menu.is-active > .el-sub-menu__title) {
|
/* 组标题:11px 大字距的「目录行」,不抢子项;展开时转靛蓝并压一层白底 */
|
||||||
|
.app-menu :deep(.el-sub-menu__title) {
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 10px;
|
||||||
|
line-height: 32px;
|
||||||
|
border-radius: var(--ys-radius-sm);
|
||||||
|
transition:
|
||||||
|
background-color 150ms var(--ys-ease),
|
||||||
|
color 150ms var(--ys-ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-menu__group {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
color: var(--ys-ink-3);
|
||||||
|
transition: color 150ms var(--ys-ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-menu :deep(.el-sub-menu__title:hover) {
|
||||||
|
background-color: rgba(255, 255, 255, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-menu :deep(.el-sub-menu.is-opened > .el-sub-menu__title) {
|
||||||
|
background-color: rgba(255, 255, 255, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-menu :deep(.el-sub-menu.is-opened > .el-sub-menu__title) .app-menu__group {
|
||||||
color: var(--ys-indigo);
|
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-indigo);
|
||||||
|
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.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-menu :deep(.el-menu-item.is-active) .app-menu__icon {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.app-menu :deep(.el-menu-item),
|
||||||
|
.app-menu :deep(.el-sub-menu__title),
|
||||||
|
.app-menu :deep(.el-sub-menu__icon-arrow),
|
||||||
|
.app-menu__group {
|
||||||
|
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,15 @@ 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;
|
||||||
|
padding: 2px 0 12px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,8 +163,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 +182,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 +202,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 +252,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 +269,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),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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'],
|
||||||
|
|||||||
+112
-44
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, reactive, ref } from 'vue'
|
import { computed, reactive, ref, watch } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -10,18 +10,36 @@ import {
|
|||||||
type RatingGradeItem,
|
type RatingGradeItem,
|
||||||
type RatingGradeParams,
|
type RatingGradeParams,
|
||||||
} from '@/api/ratings'
|
} from '@/api/ratings'
|
||||||
|
import { rateCustomer, type CustomerItem } from '@/api/customers'
|
||||||
import { useConfirmAction } from '@/composables/useConfirmAction'
|
import { useConfirmAction } from '@/composables/useConfirmAction'
|
||||||
|
import { formatDateTime } from '@/utils/format'
|
||||||
import { handledError } from '@/utils/error'
|
import { handledError } from '@/utils/error'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 客户评级计算器(需求 2.2.5 / 对照表 #25)。
|
* 客户评级计算器(需求 2.2.5 / 对照表 #25)· 客户管理行内弹窗版。
|
||||||
|
*
|
||||||
|
* 原独立页(/business/rating)并入客户管理:在客户行上点「评级计算」打开,
|
||||||
|
* 五项打分实时判定等级,「保存评分」把总分写入客户档案(rating_score +
|
||||||
|
* rating_time)。与独立页时代的差异:评分不再是过眼云烟 —— 保存为档案
|
||||||
|
* 属性;五项明细与命中等级仍不落库(等级映射是可变判定表,回溯「当时
|
||||||
|
* 命中的等级」需要快照设计,超出档案属性的范围)。
|
||||||
*
|
*
|
||||||
* 评级计算器仅是**打分工具**:五项打分求总分、按等级映射实时判定等级,
|
|
||||||
* 评分记录不落库、不参与绩效金额计算,因此页面没有保存动作(仅重置)。
|
|
||||||
* 等级映射的增删改走 /v1/business/rating-grades 维护接口,配置变更后
|
* 等级映射的增删改走 /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]
|
||||||
|
}>()
|
||||||
|
|
||||||
// ==================== 等级判定(纯函数) ====================
|
// ==================== 等级判定(纯函数) ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -67,7 +85,7 @@ const totalScore = computed(() =>
|
|||||||
SCORE_ITEMS.reduce((sum, item) => sum + (scores[item.key] ?? 0), 0),
|
SCORE_ITEMS.reduce((sum, item) => sum + (scores[item.key] ?? 0), 0),
|
||||||
)
|
)
|
||||||
|
|
||||||
/** 无保存动作(不落库),重置即把五项清零 */
|
/** 重置即把五项清零(不动档案里已保存的评分) */
|
||||||
function resetScores(): void {
|
function resetScores(): void {
|
||||||
for (const item of SCORE_ITEMS) {
|
for (const item of SCORE_ITEMS) {
|
||||||
scores[item.key] = 0
|
scores[item.key] = 0
|
||||||
@@ -95,6 +113,46 @@ async function loadGrades(): Promise<void> {
|
|||||||
|
|
||||||
const matchedGrade = computed(() => resolveGrade(grades.value, totalScore.value))
|
const matchedGrade = computed(() => resolveGrade(grades.value, totalScore.value))
|
||||||
|
|
||||||
|
// ==================== 弹窗打开 / 关闭 ====================
|
||||||
|
|
||||||
|
/** 打开时重置五项并重载等级配置,避免上一次打分的残留 */
|
||||||
|
watch(visible, (isOpen) => {
|
||||||
|
if (!isOpen) return
|
||||||
|
resetScores()
|
||||||
|
void loadGrades()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ==================== 保存评分 ====================
|
||||||
|
|
||||||
|
const saving = ref(false)
|
||||||
|
|
||||||
|
/** 档案里已保存的评分(打开时的快照,仅展示用;保存成功后由父页更新行数据) */
|
||||||
|
const archivedScore = computed(() => {
|
||||||
|
const target = props.customer
|
||||||
|
if (!target || target.rating_score === null) return null
|
||||||
|
return {
|
||||||
|
score: target.rating_score,
|
||||||
|
time: target.rating_time ? formatDateTime(target.rating_time) : '',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function handleSave(): Promise<void> {
|
||||||
|
const target = props.customer
|
||||||
|
if (!target) return
|
||||||
|
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
const item = await rateCustomer(target.id, { score: totalScore.value })
|
||||||
|
ElMessage.success('评级已保存')
|
||||||
|
emit('saved', item)
|
||||||
|
visible.value = false
|
||||||
|
} catch (error) {
|
||||||
|
handledError(error) // 失败提示由请求拦截器统一完成
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== 等级映射设置抽屉 ====================
|
// ==================== 等级映射设置抽屉 ====================
|
||||||
|
|
||||||
const drawerVisible = ref(false)
|
const drawerVisible = ref(false)
|
||||||
@@ -165,7 +223,7 @@ async function saveGrade(): Promise<void> {
|
|||||||
max_score: String(maxScore),
|
max_score: String(maxScore),
|
||||||
sort_order: editForm.sort_order ?? 0,
|
sort_order: editForm.sort_order ?? 0,
|
||||||
}
|
}
|
||||||
// 后端为全量替换语义;remark 本页不维护,编辑时原样带回避免误清
|
// 后端为全量替换语义;remark 本组件不维护,编辑时原样带回避免误清
|
||||||
if (editingId.value !== null) {
|
if (editingId.value !== null) {
|
||||||
payload.remark = grades.value.find((g) => g.id === editingId.value)?.remark ?? undefined
|
payload.remark = grades.value.find((g) => g.id === editingId.value)?.remark ?? undefined
|
||||||
}
|
}
|
||||||
@@ -198,35 +256,33 @@ function removeGrade(row: RatingGradeItem): void {
|
|||||||
after: () => loadGrades(),
|
after: () => loadGrades(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
void loadGrades()
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="rating-page">
|
<el-dialog
|
||||||
<!-- 对照表 #25:常驻口径提示,防止误认为评分会影响绩效金额 -->
|
v-model="visible"
|
||||||
|
:title="`客户评级 · ${customer?.company_name ?? ''}`"
|
||||||
|
width="680px"
|
||||||
|
destroy-on-close
|
||||||
|
>
|
||||||
|
<!-- 对照表 #25:口径提示改写 —— 评分现在会落库,但仍不参与绩效金额 -->
|
||||||
<el-alert
|
<el-alert
|
||||||
class="page-alert"
|
class="dialog-alert"
|
||||||
title="本页为评分计算工具,评分结果不参与绩效金额计算"
|
title="评分保存为客户档案,只用于客户评级展示,不参与绩效金额计算"
|
||||||
type="warning"
|
type="warning"
|
||||||
:closable="false"
|
:closable="false"
|
||||||
show-icon
|
show-icon
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 评分计算区:无保存按钮,纯前端实时计算 -->
|
<div v-if="archivedScore" class="archived-line">
|
||||||
<el-card shadow="never">
|
档案评分:<b>{{ archivedScore.score }}</b> 分
|
||||||
<div class="page-toolbar">
|
<span v-if="archivedScore.time" class="archived-time">({{ archivedScore.time }} 保存)</span>
|
||||||
<span class="page-desc">五项各 0-20 分,总分 0-100;评级等级按配置的下限实时判定。</span>
|
<span class="archived-tip">重新保存将覆盖</span>
|
||||||
<span>
|
|
||||||
<el-button @click="resetScores">重置</el-button>
|
|
||||||
<el-button type="primary" @click="openDrawer">等级映射设置</el-button>
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="calc-grid">
|
<div class="calc-grid">
|
||||||
<div class="calc-form">
|
<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">
|
<div v-for="item in SCORE_ITEMS" :key="item.key" class="score-row">
|
||||||
<span class="score-label">{{ item.label }}</span>
|
<span class="score-label">{{ item.label }}</span>
|
||||||
<el-input-number
|
<el-input-number
|
||||||
@@ -241,6 +297,7 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="calc-result">
|
<div class="calc-result">
|
||||||
|
<div class="section-title">判定结果</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="result-label">总分</span>
|
<span class="result-label">总分</span>
|
||||||
<b class="total-num">{{ totalScore }}</b>
|
<b class="total-num">{{ totalScore }}</b>
|
||||||
@@ -250,7 +307,7 @@ onMounted(() => {
|
|||||||
<span class="result-label">评级等级</span>
|
<span class="result-label">评级等级</span>
|
||||||
<template v-if="grades.length === 0">
|
<template v-if="grades.length === 0">
|
||||||
<div class="grade-name">—</div>
|
<div class="grade-name">—</div>
|
||||||
<div class="grade-hint">未配置等级映射</div>
|
<div class="grade-hint">未配置等级映射,仍可保存总分</div>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="matchedGrade">
|
<template v-else-if="matchedGrade">
|
||||||
<div class="grade-name">{{ matchedGrade.grade_name }}</div>
|
<div class="grade-name">{{ matchedGrade.grade_name }}</div>
|
||||||
@@ -265,10 +322,16 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</el-card>
|
|
||||||
|
|
||||||
<!-- 等级映射设置抽屉 -->
|
<template #footer>
|
||||||
<el-drawer v-model="drawerVisible" title="等级映射设置" size="640px">
|
<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 class="drawer-hint">
|
||||||
判定规则:按排序升序取首个「下限 ≤ 总分」的等级,上限仅作展示;修改后评级区立即按新配置判定。
|
判定规则:按排序升序取首个「下限 ≤ 总分」的等级,上限仅作展示;修改后评级区立即按新配置判定。
|
||||||
</div>
|
</div>
|
||||||
@@ -298,6 +361,7 @@ onMounted(() => {
|
|||||||
:title="isCreate ? '新增评级等级' : '编辑评级等级'"
|
:title="isCreate ? '新增评级等级' : '编辑评级等级'"
|
||||||
width="440px"
|
width="440px"
|
||||||
destroy-on-close
|
destroy-on-close
|
||||||
|
append-to-body
|
||||||
@closed="editingId = null"
|
@closed="editingId = null"
|
||||||
>
|
>
|
||||||
<el-form label-width="90px" @submit.prevent>
|
<el-form label-width="90px" @submit.prevent>
|
||||||
@@ -342,26 +406,30 @@ onMounted(() => {
|
|||||||
<el-button type="primary" :loading="editSaving" @click="saveGrade">保存</el-button>
|
<el-button type="primary" :loading="editSaving" @click="saveGrade">保存</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.page-alert {
|
/* 版式皮肤(.card/.section-title/按钮/表格)由全局 index.css 提供,这里只留弹窗特有排版 */
|
||||||
margin-bottom: 14px;
|
|
||||||
|
.dialog-alert {
|
||||||
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-toolbar {
|
/* 档案评分快照行:一眼看出这是覆盖式保存 */
|
||||||
display: flex;
|
.archived-line {
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
gap: 12px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-desc {
|
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--ys-ink-2);
|
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 {
|
.calc-grid {
|
||||||
@@ -372,7 +440,7 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.calc-form {
|
.calc-form {
|
||||||
flex: 1 1 380px;
|
flex: 1 1 320px;
|
||||||
min-width: 280px;
|
min-width: 280px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -396,8 +464,8 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.calc-result {
|
.calc-result {
|
||||||
flex: 0 0 250px;
|
flex: 0 0 220px;
|
||||||
min-width: 220px;
|
min-width: 200px;
|
||||||
border: 1px solid var(--ys-line);
|
border: 1px solid var(--ys-line);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
padding: 18px 20px;
|
padding: 18px 20px;
|
||||||
@@ -416,7 +484,7 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.total-num {
|
.total-num {
|
||||||
font-size: 40px;
|
font-size: 36px;
|
||||||
color: var(--ys-indigo);
|
color: var(--ys-indigo);
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
margin-right: 4px;
|
margin-right: 4px;
|
||||||
@@ -429,7 +497,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
.grade-name {
|
.grade-name {
|
||||||
font-family: var(--ys-font-brand);
|
font-family: var(--ys-font-brand);
|
||||||
font-size: 28px;
|
font-size: 26px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--ys-ink);
|
color: var(--ys-ink);
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
@@ -7,9 +7,11 @@ import type { CustomerItem, OwnershipLogItem } from '@/api/customers'
|
|||||||
import * as personnelApi from '@/api/personnel'
|
import * as personnelApi from '@/api/personnel'
|
||||||
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 { handledError } from '@/utils/error'
|
import { handledError } from '@/utils/error'
|
||||||
|
import RatingCalculatorDialog from './RatingCalculatorDialog.vue'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 客户管理(需求 2.1.3:客户档案 + 归属人员 + 归属变更留痕)。
|
* 客户管理(需求 2.1.3:客户档案 + 归属人员 + 归属变更留痕)。
|
||||||
@@ -20,6 +22,8 @@ import { handledError } from '@/utils/error'
|
|||||||
* - 归属变更提交前二次确认,文案必须包含「仅影响变更后核算,不回溯历史」
|
* - 归属变更提交前二次确认,文案必须包含「仅影响变更后核算,不回溯历史」
|
||||||
* (对照表 #19 的业务口径)
|
* (对照表 #19 的业务口径)
|
||||||
* - 归属变更记录为分页留痕表(抽屉内展示,按变更时间倒序)
|
* - 归属变更记录为分页留痕表(抽屉内展示,按变更时间倒序)
|
||||||
|
* - 评级计算器并入本页(原 /business/rating 独立页已移除):行内弹窗打分,
|
||||||
|
* 保存评分写入档案字段 rating_score/rating_time
|
||||||
*/
|
*/
|
||||||
const { confirmAndRun } = useConfirmAction()
|
const { confirmAndRun } = useConfirmAction()
|
||||||
|
|
||||||
@@ -38,6 +42,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 +54,54 @@ 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
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== 归属人员下拉数据源 ====================
|
// ==================== 归属人员下拉数据源 ====================
|
||||||
|
|
||||||
interface PersonnelOption {
|
interface PersonnelOption {
|
||||||
@@ -56,18 +110,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 {
|
||||||
@@ -322,8 +372,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"
|
||||||
@@ -359,24 +420,50 @@ 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" />
|
||||||
|
<el-table-column prop="rating_score" label="评分" width="88" align="center" sortable="custom">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span
|
||||||
|
v-if="row.rating_score !== null"
|
||||||
|
:title="row.rating_time ? `评级时间:${formatDateTime(row.rating_time)}` : undefined"
|
||||||
|
>
|
||||||
|
{{ row.rating_score }}
|
||||||
|
</span>
|
||||||
|
<span v-else class="cell-muted" title="尚未评级">—</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column prop="owner_name" label="负责人" min-width="90" />
|
<el-table-column prop="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 +483,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 +504,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 +512,8 @@ onMounted(() => {
|
|||||||
@page-change="onPageChange"
|
@page-change="onPageChange"
|
||||||
@size-change="onSizeChange"
|
@size-change="onSizeChange"
|
||||||
/>
|
/>
|
||||||
</el-card>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 新增 / 编辑(编辑不含归属,归属走独立按钮保证留痕) -->
|
<!-- 新增 / 编辑(编辑不含归属,归属走独立按钮保证留痕) -->
|
||||||
<el-dialog
|
<el-dialog
|
||||||
@@ -532,7 +625,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 +651,13 @@ onMounted(() => {
|
|||||||
@size-change="onLogsSizeChange"
|
@size-change="onLogsSizeChange"
|
||||||
/>
|
/>
|
||||||
</el-drawer>
|
</el-drawer>
|
||||||
|
|
||||||
|
<!-- 评级计算器(原 /business/rating 独立页并入;保存评分回写档案) -->
|
||||||
|
<RatingCalculatorDialog
|
||||||
|
v-model:visible="ratingVisible"
|
||||||
|
:customer="ratingTarget"
|
||||||
|
@saved="onRatingSaved"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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>
|
||||||
@@ -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
|
||||||
@@ -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>
|
||||||
@@ -83,23 +83,53 @@ function renderCharts(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 图表轴:设计稿 §05 的口径 —— 轴标签 9px 弱墨、无 y 网格线、无刻度,
|
||||||
|
* 只留一条极淡的 x 轴线。字号必须显式给,ECharts 默认 12px 会把
|
||||||
|
* 150px 高的图压满标签。
|
||||||
|
*/
|
||||||
|
function axisCommon(trend: TrendPoint[]) {
|
||||||
|
return {
|
||||||
|
xAxis: {
|
||||||
|
type: 'category' as const,
|
||||||
|
data: trend.map((item) => item.month.slice(5)),
|
||||||
|
axisLine: { lineStyle: { color: 'rgba(28,35,51,.15)' } },
|
||||||
|
axisTick: { show: false },
|
||||||
|
axisLabel: { fontFamily: CHART_FONT, color: '#9aa3b2', fontSize: 9, margin: 6 },
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: 'value' as const,
|
||||||
|
splitLine: { show: false },
|
||||||
|
axisLabel: { fontFamily: CHART_FONT, color: '#9aa3b2', fontSize: 9, margin: 6 },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 竖向渐变(从上到下),设计稿要求柱体与折线面积都用「上亮下暗」的品牌渐变。
|
||||||
|
* color-mix 与 canvas 渐变对象在 SVG renderer 下表现一致,故直接用 echarts 的
|
||||||
|
* linear 渐变描述对象,不依赖 canvas API。
|
||||||
|
*/
|
||||||
|
function verticalGradient(top: string, bottom: string) {
|
||||||
|
return {
|
||||||
|
type: 'linear' as const,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
x2: 0,
|
||||||
|
y2: 1,
|
||||||
|
colorStops: [
|
||||||
|
{ offset: 0, color: top },
|
||||||
|
{ offset: 1, color: bottom },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function performanceOption(trend: TrendPoint[]): EChartsCoreOption {
|
function performanceOption(trend: TrendPoint[]): EChartsCoreOption {
|
||||||
return {
|
return {
|
||||||
textStyle: { fontFamily: CHART_FONT },
|
textStyle: { fontFamily: CHART_FONT },
|
||||||
grid: { left: 8, right: 12, top: 18, bottom: 4, containLabel: true },
|
grid: { left: 8, right: 12, top: 18, bottom: 4, containLabel: true },
|
||||||
tooltip: { trigger: 'axis', valueFormatter: (value: unknown) => Number(value).toFixed(2) },
|
tooltip: { trigger: 'axis', valueFormatter: (value: unknown) => Number(value).toFixed(2) },
|
||||||
xAxis: {
|
...axisCommon(trend),
|
||||||
type: 'category',
|
|
||||||
data: trend.map((item) => item.month.slice(5)),
|
|
||||||
axisLine: { lineStyle: { color: 'rgba(28,35,51,.15)' } },
|
|
||||||
axisTick: { show: false },
|
|
||||||
axisLabel: { fontFamily: CHART_FONT, color: '#9aa3b2' },
|
|
||||||
},
|
|
||||||
yAxis: {
|
|
||||||
type: 'value',
|
|
||||||
splitLine: { show: false },
|
|
||||||
axisLabel: { fontFamily: CHART_FONT, color: '#9aa3b2' },
|
|
||||||
},
|
|
||||||
series: [
|
series: [
|
||||||
{
|
{
|
||||||
name: '绩效',
|
name: '绩效',
|
||||||
@@ -108,48 +138,35 @@ function performanceOption(trend: TrendPoint[]): EChartsCoreOption {
|
|||||||
symbol: 'none',
|
symbol: 'none',
|
||||||
data: trend.map((item) => Number(item.performance_total ?? 0)),
|
data: trend.map((item) => Number(item.performance_total ?? 0)),
|
||||||
lineStyle: { color: '#2e4ba8', width: 2 },
|
lineStyle: { color: '#2e4ba8', width: 2 },
|
||||||
areaStyle: {
|
areaStyle: { color: verticalGradient('rgba(46,75,168,.22)', 'rgba(46,75,168,0)') },
|
||||||
color: {
|
|
||||||
type: 'linear',
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
x2: 0,
|
|
||||||
y2: 1,
|
|
||||||
colorStops: [
|
|
||||||
{ offset: 0, color: 'rgba(46,75,168,.22)' },
|
|
||||||
{ offset: 1, color: 'rgba(46,75,168,0)' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function receiptOption(trend: TrendPoint[]): EChartsCoreOption {
|
function receiptOption(trend: TrendPoint[]): EChartsCoreOption {
|
||||||
|
// 最近一个月用饱和靛蓝强调,其余压成浅靛蓝:一眼就能读出「本月」在哪
|
||||||
|
const lastIndex = trend.length - 1
|
||||||
return {
|
return {
|
||||||
textStyle: { fontFamily: CHART_FONT },
|
textStyle: { fontFamily: CHART_FONT },
|
||||||
grid: { left: 8, right: 12, top: 18, bottom: 4, containLabel: true },
|
grid: { left: 8, right: 12, top: 18, bottom: 4, containLabel: true },
|
||||||
tooltip: { trigger: 'axis', valueFormatter: (value: unknown) => Number(value).toFixed(2) },
|
tooltip: { trigger: 'axis', valueFormatter: (value: unknown) => Number(value).toFixed(2) },
|
||||||
xAxis: {
|
...axisCommon(trend),
|
||||||
type: 'category',
|
|
||||||
data: trend.map((item) => item.month.slice(5)),
|
|
||||||
axisLine: { lineStyle: { color: 'rgba(28,35,51,.15)' } },
|
|
||||||
axisTick: { show: false },
|
|
||||||
axisLabel: { fontFamily: CHART_FONT, color: '#9aa3b2' },
|
|
||||||
},
|
|
||||||
yAxis: {
|
|
||||||
type: 'value',
|
|
||||||
splitLine: { show: false },
|
|
||||||
axisLabel: { fontFamily: CHART_FONT, color: '#9aa3b2' },
|
|
||||||
},
|
|
||||||
series: [
|
series: [
|
||||||
{
|
{
|
||||||
name: '回款',
|
name: '回款',
|
||||||
type: 'bar',
|
type: 'bar',
|
||||||
data: trend.map((item) => Number(item.receipt_total ?? 0)),
|
|
||||||
barMaxWidth: 22,
|
barMaxWidth: 22,
|
||||||
itemStyle: { color: '#2e4ba8', borderRadius: [4, 4, 0, 0] },
|
data: trend.map((item, index) => ({
|
||||||
|
value: Number(item.receipt_total ?? 0),
|
||||||
|
itemStyle: {
|
||||||
|
color:
|
||||||
|
index === lastIndex
|
||||||
|
? verticalGradient('#3a58bc', '#2e4ba8')
|
||||||
|
: verticalGradient('#d5def3', '#e8edf8'),
|
||||||
|
borderRadius: [3, 3, 0, 0],
|
||||||
|
},
|
||||||
|
})),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
@@ -383,42 +400,7 @@ function formatTime(value: string): string {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.page-head {
|
/* 页面头(.page-head/.page-title/.page-desc/.waterline)、.toolbar 均来自全局 index.css */
|
||||||
margin-bottom: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-title {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 22px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--ys-ink);
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-desc {
|
|
||||||
margin: 6px 0 0;
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--ys-ink-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.waterline {
|
|
||||||
display: block;
|
|
||||||
width: 46px;
|
|
||||||
height: 2px;
|
|
||||||
margin-top: 12px;
|
|
||||||
background: linear-gradient(90deg, var(--ys-indigo), rgba(46, 75, 168, 0));
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
margin-bottom: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar-note {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--ys-ink-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bento {
|
.bento {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -429,22 +411,52 @@ function formatTime(value: string): string {
|
|||||||
|
|
||||||
.bento-card {
|
.bento-card {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
padding: 16px 18px;
|
padding: 16px 18px;
|
||||||
background: linear-gradient(180deg, #ffffff, #fcfcfe);
|
|
||||||
border: 1px solid var(--ys-line);
|
|
||||||
border-radius: 10px;
|
|
||||||
box-shadow:
|
|
||||||
0 1px 2px rgba(28, 35, 51, 0.04),
|
|
||||||
0 10px 24px -18px rgba(28, 35, 51, 0.18);
|
|
||||||
transition: transform 0.18s ease, box-shadow 0.18s ease;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
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);
|
||||||
|
transition:
|
||||||
|
transform 180ms var(--ys-ease),
|
||||||
|
box-shadow 180ms var(--ys-ease);
|
||||||
|
/* 交错入场:45ms 一檔,卡片依次上浮,避免整屏同时闪一下 */
|
||||||
|
animation: ys-rise 320ms var(--ys-ease) backwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bento > :nth-child(2) {
|
||||||
|
animation-delay: 45ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bento > :nth-child(3) {
|
||||||
|
animation-delay: 90ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bento > :nth-child(4) {
|
||||||
|
animation-delay: 135ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bento > :nth-child(5) {
|
||||||
|
animation-delay: 180ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bento > :nth-child(6) {
|
||||||
|
animation-delay: 225ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bento > :nth-child(7) {
|
||||||
|
animation-delay: 270ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bento > :nth-child(8) {
|
||||||
|
animation-delay: 315ms;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bento-card:hover {
|
.bento-card:hover {
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
box-shadow:
|
box-shadow: var(--ys-shadow-raise);
|
||||||
0 2px 4px rgba(28, 35, 51, 0.05),
|
|
||||||
0 16px 30px -18px rgba(28, 35, 51, 0.22);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.b-2x2 {
|
.b-2x2 {
|
||||||
@@ -501,10 +513,19 @@ function formatTime(value: string): string {
|
|||||||
color: var(--ys-ink-3);
|
color: var(--ys-ink-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 空态在卡片剩余高度里居中:待办/预警是 2×2 的卡,只有一行提示时
|
||||||
|
* 内容全挤在顶部、下方留一大片空白,看起来像没加载完。
|
||||||
|
*/
|
||||||
.b-empty-mini {
|
.b-empty-mini {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--ys-ink-3);
|
color: var(--ys-ink-3);
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chart {
|
.chart {
|
||||||
@@ -583,7 +604,7 @@ function formatTime(value: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.activity-time {
|
.activity-time {
|
||||||
width: 118px;
|
width: 108px;
|
||||||
color: var(--ys-ink-3);
|
color: var(--ys-ink-3);
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
@@ -608,14 +629,23 @@ function formatTime(value: string): string {
|
|||||||
padding: 6px 14px;
|
padding: 6px 14px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--ys-indigo);
|
color: var(--ys-indigo);
|
||||||
|
cursor: pointer;
|
||||||
background: var(--ys-indigo-wash);
|
background: var(--ys-indigo-wash);
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
border-radius: 6px;
|
border-radius: var(--ys-radius-sm);
|
||||||
cursor: pointer;
|
transition:
|
||||||
|
border-color 150ms var(--ys-ease),
|
||||||
|
background-color 150ms var(--ys-ease);
|
||||||
}
|
}
|
||||||
|
|
||||||
.b-op:hover {
|
.b-op:hover {
|
||||||
border-color: var(--ys-indigo);
|
background: #e6ecf9;
|
||||||
|
border-color: rgba(46, 75, 168, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.b-op:focus-visible {
|
||||||
|
outline: 2px solid var(--ys-indigo);
|
||||||
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
@media (max-width: 1024px) {
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -431,19 +431,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>
|
</p>
|
||||||
<span>
|
<div class="waterline"></div>
|
||||||
<el-button @click="openPreviewDrawer">计算预览</el-button>
|
|
||||||
<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>
|
||||||
@@ -509,16 +518,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>
|
||||||
@@ -538,6 +548,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 +570,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>
|
||||||
@@ -809,17 +821,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 +887,7 @@ onMounted(() => {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 待提交卡片紧跟目录卡,需要与上方卡片拉开距离(.card 自身不带外边距) */
|
||||||
.pending-card {
|
.pending-card {
|
||||||
margin-top: 14px;
|
margin-top: 14px;
|
||||||
}
|
}
|
||||||
@@ -900,11 +903,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,46 @@ 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
|
||||||
>
|
>
|
||||||
<el-form-item label="公司名称" prop="company_name">
|
<el-form-item label="公司名称" prop="company_name">
|
||||||
|
<!-- 控件与说明并排:说明靠行尾对齐,宽屏下整行铺满卡片而非缩在左上角 -->
|
||||||
|
<div class="field-control">
|
||||||
<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>
|
||||||
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
<el-form-item label="logo">
|
<el-form-item label="logo">
|
||||||
<div class="logo-field">
|
<div class="field-control">
|
||||||
<el-upload
|
<el-upload
|
||||||
class="logo-uploader"
|
class="logo-uploader"
|
||||||
:show-file-list="false"
|
:show-file-list="false"
|
||||||
@@ -127,35 +146,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>
|
||||||
</div>
|
</div>
|
||||||
</el-form-item>
|
</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 +236,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 +321,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 +399,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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -250,9 +250,20 @@ 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>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
筛选条:.filter-bar 自身就是 flex 行(含 gap 与内边距),所以去掉 el-form 的 inline。
|
||||||
|
inline 模式靠行内块排布,会带基线下沉与固定 right-margin,与 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.username"
|
v-model="filters.username"
|
||||||
@@ -291,17 +302,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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-table v-loading="loading" :data="records" stripe>
|
<!-- 表格卡片:表皮与筛选条同材质但更高一档;分页落进 card-foot,与表体共用同一张卡 -->
|
||||||
|
<div class="card">
|
||||||
|
<el-table
|
||||||
|
v-loading="loading"
|
||||||
|
:data="records"
|
||||||
|
stripe
|
||||||
|
empty-text="还没有用户,点「新增用户」创建第一个账号"
|
||||||
|
>
|
||||||
<el-table-column prop="username" label="用户名" min-width="120" />
|
<el-table-column prop="username" label="用户名" min-width="120" />
|
||||||
<el-table-column prop="real_name" label="姓名" min-width="100">
|
<el-table-column prop="real_name" label="姓名" min-width="100">
|
||||||
<template #default="{ row }">{{ row.real_name || '—' }}</template>
|
<template #default="{ row }">{{ row.real_name || '—' }}</template>
|
||||||
@@ -323,8 +342,9 @@ 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(内部已是 ys-pill),页面不再自造标签形态 -->
|
||||||
<StatusTag :value="row.status" />
|
<StatusTag :value="row.status" />
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
@@ -366,6 +386,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"
|
||||||
@@ -373,7 +394,8 @@ onMounted(() => {
|
|||||||
@page-change="onPageChange"
|
@page-change="onPageChange"
|
||||||
@size-change="onSizeChange"
|
@size-change="onSizeChange"
|
||||||
/>
|
/>
|
||||||
</el-card>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 新增 / 编辑 -->
|
<!-- 新增 / 编辑 -->
|
||||||
<el-dialog
|
<el-dialog
|
||||||
@@ -459,9 +481,11 @@ onMounted(() => {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.role-tag {
|
.role-tag {
|
||||||
|
/* 一个用户可能有多个角色,标签并排时需要横向间距(.el-tag 自身不带) */
|
||||||
margin-right: 4px;
|
margin-right: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.reset-tip {
|
.reset-tip {
|
||||||
margin: 0 0 16px;
|
margin: 0 0 16px;
|
||||||
color: var(--ys-text);
|
color: var(--ys-text);
|
||||||
|
|||||||
Reference in new issue
Block a user