Compare commits

..

No commits in common. "8e8bcaa5fb78f3b85f3828aa3f6e0cbdf59192a4" and "c48d24ab6d853bcdee039ed3ac20ef28c5ca5ca8" have entirely different histories.

84 changed files with 3212 additions and 5993 deletions

View File

@ -11,7 +11,6 @@ Create Date: 2026-10-19 17:55:48.892998
「同步连续失败」规则依赖 ⑥ T+ 同步,模块未建前不设参数、不产生条目。
"""
from collections.abc import Sequence
from datetime import date
@ -20,8 +19,8 @@ import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "90653814a421"
down_revision: str | Sequence[str] | None = "ba0217f5091b"
revision: str = '90653814a421'
down_revision: str | Sequence[str] | None = 'ba0217f5091b'
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None

View File

@ -34,7 +34,10 @@ depends_on = None
def upgrade() -> None:
"""种子行级变更 + 台账唯一键扩展(无表结构新增/删除)。"""
# 1. 审批开关默认值翻转(#5/#7:默认开启,业务可关闭)
op.execute("UPDATE param_definitions SET default_value = '1' WHERE id IN (17, 18)")
op.execute(
"UPDATE param_definitions SET default_value = '1' "
"WHERE id IN (17, 18)"
)
# 时点化生效行:插新行不改历史(10-19 前仍按 0 解析)
op.bulk_insert(
sa.table(
@ -185,4 +188,6 @@ def downgrade() -> None:
)
op.execute("DELETE FROM param_values WHERE id IN (23, 24)")
op.execute("UPDATE param_definitions SET default_value = '0' WHERE id IN (17, 18)")
op.execute(
"UPDATE param_definitions SET default_value = '0' WHERE id IN (17, 18)"
)

View File

@ -43,7 +43,9 @@ def upgrade() -> None:
"COMMENT '提交时内容快照(信封:schema_version/order_type/captured_at/data;历史单可空)' "
"AFTER version_no"
)
op.execute("CREATE INDEX idx_approval_orders_type_ref ON approval_orders (order_type, ref_id)")
op.execute(
"CREATE INDEX idx_approval_orders_type_ref ON approval_orders (order_type, ref_id)"
)
op.execute(
"ALTER TABLE business_secondary_allocations "

View File

@ -12,8 +12,7 @@ from fastapi import APIRouter, Depends
from app.api.deps import DbSession, get_current_user
from app.core.response import ApiResponse, ok
from app.models import User
from app.schemas.business_role import BusinessRoleOptionOut
from app.services import business_role_service
from app.repositories import business_role_repository
router = APIRouter(prefix="/business-roles", tags=["人员管理"])
@ -21,14 +20,21 @@ router = APIRouter(prefix="/business-roles", tags=["人员管理"])
CurrentUser = Annotated[User, Depends(get_current_user)]
@router.get(
"/options",
response_model=ApiResponse[list[BusinessRoleOptionOut]],
summary="岗位下拉选项",
)
def list_options(session: DbSession, _: CurrentUser) -> ApiResponse[list[BusinessRoleOptionOut]]:
@router.get("/options", response_model=ApiResponse[list[dict]], summary="岗位下拉选项")
def list_options(session: DbSession, _: CurrentUser) -> ApiResponse[list[dict]]:
"""岗位选项(登录即可;含停用——人员档案可能仍引用停用岗位)。
岗位规模有限(内置 + 自定义),一次取全量,不做分页。
岗位规模有限(内置 + 自定义),一次取足够上限,不做分页。
"""
return ok(business_role_service.list_options(session))
roles = business_role_repository.list_roles(session, offset=0, limit=1000)
return ok(
[
{
"id": role.id,
"role_code": role.role_code,
"role_name": role.role_name,
"status": role.status,
}
for role in roles
]
)

View File

@ -19,7 +19,11 @@ router = APIRouter(prefix="/dashboard", tags=["工作台"])
_DashboardUser = Annotated[
User,
Depends(require_roles(ROLE_ADMIN, ROLE_FINANCE, ROLE_LEADER, message="无权访问工作台")),
Depends(
require_roles(
ROLE_ADMIN, ROLE_FINANCE, ROLE_LEADER, message="无权访问工作台"
)
),
]
@ -35,4 +39,6 @@ def get_dashboard(
待办按当前用户角色只返回其可操作节点(admin 可查看全部在途单)。
"""
return ok(dashboard_service.get_dashboard(session, month, current_user=current_user))
return ok(
dashboard_service.get_dashboard(session, month, current_user=current_user)
)

View File

@ -79,7 +79,9 @@ class SecondaryAllocation(Base, TimestampMixin):
Index("idx_business_secondary_allocations_business_id", "business_id"),
Index("idx_business_secondary_allocations_personnel_id", "personnel_id"),
# 取当前生效行的唯一查询形态:WHERE business_id=? AND is_active=1
Index("idx_business_secondary_allocations_business_active", "business_id", "is_active"),
Index(
"idx_business_secondary_allocations_business_active", "business_id", "is_active"
),
)
id: Mapped[int] = mapped_column(

View File

@ -5,6 +5,8 @@
models/customer.py 定义)。
"""
from datetime import date
from sqlalchemy import Row, Select, delete, exists, func, or_, select, update
from sqlalchemy.orm import Session, aliased
@ -21,8 +23,6 @@ from app.models import (
)
from app.models.customer import OWNER_TYPE_BUSINESS
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:
@ -77,32 +77,50 @@ def get_with_names(
return session.execute(stmt).first()
def _apply_filters(stmt: Select, query: BusinessQuery) -> Select:
def _apply_filters(
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)。"""
if query.keyword:
if keyword:
# 业务编号/公司名称双列 OR 模糊;contains(autoescape) 同 query_utils.fuzzy
# 的转义口径(fuzzy 为单列 where 语义,双列 OR 在此直接组合)
stmt = stmt.where(
or_(
Business.business_no.contains(query.keyword, autoescape=True),
Customer.company_name.contains(query.keyword, autoescape=True),
Business.business_no.contains(keyword, autoescape=True),
Customer.company_name.contains(keyword, autoescape=True),
)
)
if query.status is not None:
stmt = stmt.where(Business.status == query.status)
if query.customer_id is not None:
stmt = stmt.where(Business.customer_id == query.customer_id)
if query.owner_personnel_id is not None:
stmt = stmt.where(Business.owner_personnel_id == query.owner_personnel_id)
if query.date_from is not None:
stmt = stmt.where(Business.business_date >= query.date_from)
if query.date_to is not None:
stmt = stmt.where(Business.business_date <= query.date_to)
if status is not None:
stmt = stmt.where(Business.status == status)
if customer_id is not None:
stmt = stmt.where(Business.customer_id == customer_id)
if owner_personnel_id is not None:
stmt = stmt.where(Business.owner_personnel_id == owner_personnel_id)
if date_from is not None:
stmt = stmt.where(Business.business_date >= date_from)
if date_to is not None:
stmt = stmt.where(Business.business_date <= date_to)
return stmt
def list_businesses(
session: Session, query: BusinessQuery
session: Session,
*,
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]]:
"""分页查询业务(附客户名/负责人名/协助人名),按 id 倒序。"""
owner = aliased(Personnel)
@ -110,24 +128,49 @@ def list_businesses(
stmt = select(
Business, Customer.company_name, owner.personnel_name, assistant.personnel_name
).join(Customer, Customer.id == Business.customer_id)
stmt = _apply_filters(stmt, query)
stmt = _apply_filters(
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.outerjoin(owner, owner.id == Business.owner_personnel_id)
.outerjoin(assistant, assistant.id == Business.assistant_personnel_id)
.order_by(Business.id.desc())
.offset(query.offset)
.limit(query.page_size)
.offset(offset)
.limit(limit)
)
return list(session.execute(stmt).all())
def count_businesses(session: Session, query: BusinessQuery) -> int:
def count_businesses(
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 = (
select(func.count())
.select_from(Business)
.join(Customer, Customer.id == Business.customer_id)
)
stmt = _apply_filters(stmt, query)
stmt = _apply_filters(
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())
@ -270,17 +313,18 @@ def list_ownership_logs(
) -> list[Row]:
"""某业务的归属变更留痕(共享实现,owner_type/business_id 参数化)。"""
return ownership_log_repository.list_by_ref(
session, _business_log_ref(business_id), offset=offset, limit=limit
session,
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:
return ownership_log_repository.count_by_ref(session, _business_log_ref(business_id))
def _business_log_ref(business_id: int) -> OwnershipLogRef:
"""业务维度的归属留痕锚点。"""
return OwnershipLogRef(
return ownership_log_repository.count_by_ref(
session,
owner_type=OWNER_TYPE_BUSINESS,
ref_column=OwnershipChangeLog.business_id,
ref_id=business_id,

View File

@ -7,7 +7,6 @@ from sqlalchemy import Select, func, or_, select
from sqlalchemy.orm import Session
from app.models import BusinessRole
from app.schemas.business_role import BusinessRoleQuery
# ==================== 读取 ====================
@ -40,48 +39,41 @@ def list_by_codes(session: Session, role_codes: list[str]) -> dict[str, Business
return {role.role_code: role for role in rows}
def list_roles(session: Session, query: BusinessRoleQuery) -> list[BusinessRole]:
def list_roles(
session: Session,
*,
offset: int,
limit: int,
keyword: str | None = None,
status: int | None = None,
) -> list[BusinessRole]:
"""分页查询岗位(含停用)。排序 (sort_order, id),与系统角色口径一致。"""
stmt = _apply_filters(select(BusinessRole), query)
stmt = (
stmt.order_by(BusinessRole.sort_order, BusinessRole.id)
.offset(query.offset)
.limit(query.page_size)
)
stmt = _apply_filters(select(BusinessRole), keyword=keyword, status=status)
stmt = stmt.order_by(BusinessRole.sort_order, BusinessRole.id).offset(offset).limit(limit)
return list(session.execute(stmt).scalars())
def count_roles(session: Session, query: BusinessRoleQuery) -> int:
def count_roles(session: Session, *, keyword: str | None = None, status: int | None = None) -> int:
"""统计符合条件的岗位总数,供分页使用。"""
stmt = _apply_filters(select(func.count(BusinessRole.id)), query)
stmt = _apply_filters(select(func.count(BusinessRole.id)), keyword=keyword, status=status)
return int(session.execute(stmt).scalar_one())
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:
def _apply_filters(stmt: Select, *, keyword: str | None, status: int | None) -> Select:
"""应用通用筛选条件。列表与计数共用,避免两处口径不一致。
关键词走 ``contains(autoescape=True)``(转义 LIKE 通配符)——岗位编码含下划线,
不转义时 ``_`` 会当单字符通配执行。
"""
if query.keyword:
if keyword:
stmt = stmt.where(
or_(
BusinessRole.role_code.contains(query.keyword, autoescape=True),
BusinessRole.role_name.contains(query.keyword, autoescape=True),
BusinessRole.role_code.contains(keyword, autoescape=True),
BusinessRole.role_name.contains(keyword, autoescape=True),
)
)
if query.status is not None:
stmt = stmt.where(BusinessRole.status == query.status)
if status is not None:
stmt = stmt.where(BusinessRole.status == status)
return stmt

View File

@ -9,9 +9,7 @@ from sqlalchemy.orm import Session
from app.models import Customer, OwnershipChangeLog, Personnel
from app.models.customer import OWNER_TYPE_CUSTOMER
from app.repositories import ownership_log_repository
from app.repositories.ownership_log_repository import OwnershipLogRef
from app.repositories.query_utils import fuzzy
from app.schemas.customer import CustomerQuery
# ==================== 读取 ====================
@ -47,25 +45,49 @@ def get_by_company_owner(session: Session, company_name: str, owner_name: str) -
return session.execute(stmt).scalar_one_or_none()
def list_customers(session: Session, query: CustomerQuery) -> list[Row]:
def list_customers(
session: Session,
*,
offset: int,
limit: int,
keyword: str | None = None,
customer_type: str | None = None,
owner_personnel_id: int | None = None,
) -> list[Row]:
"""分页查询客户,行内容为 (客户, 归属人员姓名)。
归属人员外键非空但统一用 outerjoin:人员行不可考(被物理删除的脏数据)
不应让客户行整个查不出来(同 personnel_repository 的韧性口径)。
排序按 id 倒序:客户无排序字段,新增的靠前符合列表页习惯。
"""
stmt = _apply_filters(_base_select(), query)
stmt = stmt.order_by(Customer.id.desc()).offset(query.offset).limit(query.page_size)
stmt = _apply_filters(
_base_select(),
keyword=keyword,
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())
def count_customers(session: Session, query: CustomerQuery) -> int:
def count_customers(
session: Session,
*,
keyword: str | None = None,
customer_type: str | None = None,
owner_personnel_id: int | None = None,
) -> int:
"""统计符合条件的客户总数,供分页使用。
筛选条件全部落在主表上,计数无需 join(列表与计数的筛选口径由
``_apply_filters`` 单点保证)。
"""
stmt = _apply_filters(select(func.count(Customer.id)), query)
stmt = _apply_filters(
select(func.count(Customer.id)),
keyword=keyword,
customer_type=customer_type,
owner_personnel_id=owner_personnel_id,
)
return int(session.execute(stmt).scalar_one())
@ -79,7 +101,9 @@ def list_ownership_logs(
"""查某客户的归属变更留痕(共享实现,owner_type/customer_id 参数化)。"""
return ownership_log_repository.list_by_ref(
session,
_customer_log_ref(customer_id),
owner_type=OWNER_TYPE_CUSTOMER,
ref_column=OwnershipChangeLog.customer_id,
ref_id=customer_id,
offset=offset,
limit=limit,
)
@ -87,12 +111,8 @@ def list_ownership_logs(
def count_ownership_logs(session: Session, *, customer_id: int) -> int:
"""统计某客户的归属留痕总数,供分页使用。"""
return ownership_log_repository.count_by_ref(session, _customer_log_ref(customer_id))
def _customer_log_ref(customer_id: int) -> OwnershipLogRef:
"""客户维度的归属留痕锚点。"""
return OwnershipLogRef(
return ownership_log_repository.count_by_ref(
session,
owner_type=OWNER_TYPE_CUSTOMER,
ref_column=OwnershipChangeLog.customer_id,
ref_id=customer_id,
@ -106,14 +126,20 @@ def _base_select() -> Select:
)
def _apply_filters(stmt: Select, query: CustomerQuery) -> Select:
def _apply_filters(
stmt: Select,
*,
keyword: str | None,
customer_type: str | None,
owner_personnel_id: int | None,
) -> Select:
"""应用通用筛选条件。列表与计数共用,避免两处口径不一致。"""
if query.keyword:
stmt = stmt.where(_keyword_clause(query.keyword))
if query.customer_type:
stmt = stmt.where(Customer.customer_type == query.customer_type)
if query.owner_personnel_id is not None:
stmt = stmt.where(Customer.owner_personnel_id == query.owner_personnel_id)
if keyword:
stmt = stmt.where(_keyword_clause(keyword))
if customer_type:
stmt = stmt.where(Customer.customer_type == customer_type)
if owner_personnel_id is not None:
stmt = stmt.where(Customer.owner_personnel_id == owner_personnel_id)
return stmt

View File

@ -34,25 +34,20 @@ _PENDING_STATUSES = (
# ==== 经营绩效总览 ====
def sum_month_performance(session: Session, month: str) -> dict:
"""当月单笔生效行合计与人员数(拆行求和不失真,人数需 DISTINCT)。"""
return (
session.execute(
select(
func.coalesce(func.sum(PerformanceLedger.performance_amount), 0).label(
"performance_total"
),
func.coalesce(func.sum(PerformanceLedger.payable_amount), 0).label("payable_total"),
func.count(func.distinct(PerformanceLedger.personnel_id)).label("person_count"),
).where(
PerformanceLedger.settlement_month == month,
*_ACTIVE_SINGLE,
)
return session.execute(
select(
func.coalesce(func.sum(PerformanceLedger.performance_amount), 0).label(
"performance_total"
),
func.coalesce(func.sum(PerformanceLedger.payable_amount), 0).label("payable_total"),
func.count(func.distinct(PerformanceLedger.personnel_id)).label("person_count"),
).where(
PerformanceLedger.settlement_month == month,
*_ACTIVE_SINGLE,
)
.mappings()
.one()
)
).mappings().one()
def performance_trend(session: Session, months: list[str]) -> dict[str, object]:
@ -71,7 +66,9 @@ def performance_trend(session: Session, months: list[str]) -> dict[str, object]:
return dict(rows)
def receipt_trend(session: Session, *, start_date: date, end_date: date) -> dict[str, object]:
def receipt_trend(
session: Session, *, start_date: date, end_date: date
) -> dict[str, object]:
"""近 N 个月回款合计(按到账月,含未核算回款——与台账口径不同,页面注明)。
过滤走 ``receipt_date`` 原始列范围(可用索引);``DATE_FORMAT`` 只用于
@ -110,7 +107,6 @@ def get_month_settlement(session: Session, month: str) -> MonthlySettlement | No
# ==== 待办事项 ====
def list_pending_approvals(
session: Session, *, statuses: tuple[int, ...], limit: int
) -> list[ApprovalOrder]:
@ -164,14 +160,15 @@ def list_unassigned_receipts(session: Session, *, limit: int) -> list:
def count_unassigned_receipts(session: Session) -> int:
return int(
session.execute(
select(func.count()).select_from(Receipt).where(Receipt.shipment_id.is_(None))
select(func.count())
.select_from(Receipt)
.where(Receipt.shipment_id.is_(None))
).scalar_one()
)
# ==== 风险预警 ====
def list_overdue_shipments(session: Session, *, cutoff: date, limit: int) -> list:
"""发货后超过阈值且该批次无任何回款的发货单(回款逾期规则)。

View File

@ -1,7 +1,5 @@
"""登录日志数据访问。"""
from dataclasses import dataclass
from sqlalchemy import Select, func, select
from sqlalchemy.orm import Session
@ -12,73 +10,68 @@ from app.repositories.query_utils import LogFilters, apply_log_filters
_USER_AGENT_MAX_LEN = 255
@dataclass(frozen=True, slots=True)
class LoginLogEntry:
"""一条待写入的登录日志(全字段 DTO)。
与 ``OperationLogEntry`` 同款做法:写入字段平铺传递时,任何一处漏传
都只表现为日志字段缺失、不报错(success 默认 0 的语义尤其隐蔽)。
"""
user_id: int | 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:
def add(
session: Session,
*,
user_id: int | None,
username: str,
ip: str | None,
user_agent: str | None,
success: bool,
fail_reason: str | None = None,
) -> None:
"""写入一条登录日志。只 flush 不 commit。
**绝不记录密码** —— 明文与哈希都不写。日志是最容易泄漏凭据的地方。
:param entry.user_id: 账号不存在时为 None(列本身可空)
:param entry.username: 用户名原样记录,便于账号不存在时也能追溯
:param user_id: 账号不存在时传 None(列本身可空)
:param username: 用户名原样记录,便于账号不存在时也能追溯
"""
session.add(
LoginLog(
user_id=entry.user_id,
username=entry.username,
ip=entry.ip,
user_agent=entry.user_agent[:_USER_AGENT_MAX_LEN] if entry.user_agent else None,
success=1 if entry.success else 0,
fail_reason=entry.fail_reason,
user_id=user_id,
username=username,
ip=ip,
user_agent=user_agent[:_USER_AGENT_MAX_LEN] if user_agent else None,
success=1 if success else 0,
fail_reason=fail_reason,
)
)
session.flush()
def list_logs(session: Session, query: LoginLogListQuery) -> list[LoginLog]:
def list_logs(
session: Session,
*,
offset: int,
limit: int,
filters: LogFilters,
ip: str | None = None,
) -> list[LoginLog]:
"""分页查询登录日志。
**按 id 倒序而非 create_time**:后者是秒级精度,同一秒内的多条记录排序
不确定,翻页会重复或遗漏(与用户列表同一理由)。
"""
stmt = _apply_filters(select(LoginLog), query)
stmt = stmt.order_by(LoginLog.id.desc()).offset(query.offset).limit(query.limit)
stmt = _apply_filters(select(LoginLog), filters=filters, ip=ip)
stmt = stmt.order_by(LoginLog.id.desc()).offset(offset).limit(limit)
return list(session.execute(stmt).scalars())
def count_logs(session: Session, query: LoginLogListQuery) -> int:
def count_logs(
session: Session,
*,
filters: LogFilters,
ip: str | None = None,
) -> int:
"""统计符合条件的登录日志总数,供分页使用。"""
stmt = _apply_filters(select(func.count(LoginLog.id)), query)
stmt = _apply_filters(select(func.count(LoginLog.id)), filters=filters, ip=ip)
return int(session.execute(stmt).scalar_one())
def _apply_filters(stmt: Select, query: LoginLogListQuery) -> Select:
def _apply_filters(stmt: Select, *, filters: LogFilters, ip: str | None) -> Select:
"""应用筛选条件(公共部分复用 query_utils,与操作日志同口径)。"""
stmt = apply_log_filters(stmt, LoginLog, query.filters)
if query.ip:
stmt = stmt.where(LoginLog.ip == query.ip)
stmt = apply_log_filters(stmt, LoginLog, filters)
if ip:
stmt = stmt.where(LoginLog.ip == ip)
return stmt

View File

@ -107,18 +107,15 @@ def get_by_id(session: Session, log_id: int) -> OperationLog | None:
return session.get(OperationLog, log_id)
@dataclass(frozen=True, slots=True)
class OperationLogListQuery:
"""操作日志列表的查询条件(筛选 + 分页)。"""
filters: LogFilters
offset: int = 0
limit: int = 20
module: str | None = None
action: str | None = None
def list_logs(session: Session, query: OperationLogListQuery) -> list[OperationLog]:
def list_logs(
session: Session,
*,
offset: int,
limit: int,
filters: LogFilters,
module: str | None = None,
action: str | None = None,
) -> list[OperationLog]:
"""分页查询操作日志。
**按 id 倒序而非 create_time**:后者是秒级精度,同一秒内的多条记录排序
@ -130,25 +127,41 @@ def list_logs(session: Session, query: OperationLogListQuery) -> list[OperationL
"""
stmt = _apply_filters(
select(OperationLog).options(defer(OperationLog.request_data)),
query,
filters=filters,
module=module,
action=action,
)
stmt = stmt.order_by(OperationLog.id.desc()).offset(query.offset).limit(query.limit)
stmt = stmt.order_by(OperationLog.id.desc()).offset(offset).limit(limit)
return list(session.execute(stmt).scalars())
def count_logs(session: Session, query: OperationLogListQuery) -> int:
def count_logs(
session: Session,
*,
filters: LogFilters,
module: str | None = None,
action: str | None = None,
) -> int:
"""统计符合条件的日志总数,供分页使用。"""
stmt = _apply_filters(select(func.count(OperationLog.id)), query)
stmt = _apply_filters(
select(func.count(OperationLog.id)), filters=filters, module=module, action=action
)
return int(session.execute(stmt).scalar_one())
def _apply_filters(stmt: Select, query: OperationLogListQuery) -> Select:
def _apply_filters(
stmt: Select,
*,
filters: LogFilters,
module: str | None,
action: str | None,
) -> Select:
"""应用筛选条件(公共部分复用 query_utils,与登录日志同口径)。"""
stmt = apply_log_filters(stmt, OperationLog, query.filters)
if query.module:
stmt = stmt.where(OperationLog.module == query.module)
if query.action:
stmt = stmt.where(OperationLog.action == query.action)
stmt = apply_log_filters(stmt, OperationLog, filters)
if module:
stmt = stmt.where(OperationLog.module == module)
if action:
stmt = stmt.where(OperationLog.action == action)
return stmt

View File

@ -5,32 +5,18 @@ owner_type 与引用列由调用方参数化 —— 此前客户、业务两侧
「被删人员留痕仍可读」「翻页稳定双键倒序」等口径单点维护。
"""
from dataclasses import dataclass
from typing import Any
from sqlalchemy import Row, func, select
from sqlalchemy.orm import Session, aliased
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(
session: Session,
ref: OwnershipLogRef,
*,
owner_type: int,
ref_column,
ref_id: int,
offset: int,
limit: int,
) -> list[Row]:
@ -46,7 +32,7 @@ def list_by_ref(
select(OwnershipChangeLog, old_person.personnel_name, new_person.personnel_name)
.outerjoin(old_person, OwnershipChangeLog.old_owner_personnel_id == old_person.id)
.outerjoin(new_person, OwnershipChangeLog.new_owner_personnel_id == new_person.id)
.where(OwnershipChangeLog.owner_type == ref.owner_type, ref.ref_column == ref.ref_id)
.where(OwnershipChangeLog.owner_type == owner_type, ref_column == ref_id)
.order_by(OwnershipChangeLog.create_time.desc(), OwnershipChangeLog.id.desc())
.offset(offset)
.limit(limit)
@ -54,11 +40,11 @@ def list_by_ref(
return list(session.execute(stmt).all())
def count_by_ref(session: Session, ref: OwnershipLogRef) -> int:
def count_by_ref(session: Session, *, owner_type: int, ref_column, ref_id: int) -> int:
"""统计某引用对象的归属留痕总数,供分页使用。"""
stmt = select(func.count(OwnershipChangeLog.id)).where(
OwnershipChangeLog.owner_type == ref.owner_type,
ref.ref_column == ref.ref_id,
OwnershipChangeLog.owner_type == owner_type,
ref_column == ref_id,
)
return int(session.execute(stmt).scalar_one())

View File

@ -10,7 +10,6 @@ from sqlalchemy import Select, delete, func, select
from sqlalchemy.orm import Session
from app.models import ParamChangeBatch, ParamDefinition, ParamValue
from app.schemas.param import ParamBatchQuery, ParamHistoryQuery
# ==================== 读取 ====================
@ -98,27 +97,27 @@ def list_definitions_by_codes(
def list_history(
session: Session, param_id: int, query: ParamHistoryQuery
session: Session,
param_id: int,
*,
offset: int,
limit: int,
dimension_value: str | None = None,
) -> list[tuple[ParamValue, ParamChangeBatch]]:
"""单参数的版本历史(含批次信息),最新在前。
``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))
]
"""单参数的版本历史(含批次信息),最新在前。"""
stmt = _history_stmt(session, param_id, dimension_value=dimension_value)
return [(value, batch) for value, batch in session.execute(stmt.offset(offset).limit(limit))]
def count_history(session: Session, param_id: int, query: ParamHistoryQuery) -> int:
def count_history(session: Session, param_id: int, *, dimension_value: str | None = None) -> int:
"""统计单参数的历史条目数。"""
stmt = select(func.count()).select_from(_history_stmt(param_id, query).subquery())
stmt = select(func.count()).select_from(
_history_stmt(session, param_id, dimension_value=dimension_value).subquery()
)
return int(session.execute(stmt).scalar_one())
def _history_stmt(param_id: int, query: ParamHistoryQuery) -> Select:
def _history_stmt(session: Session, param_id: int, *, dimension_value: str | None) -> Select:
"""历史查询基语句:条目 join 批次(版本号/操作人),最新在前。"""
stmt = (
select(ParamValue, ParamChangeBatch)
@ -126,8 +125,8 @@ def _history_stmt(param_id: int, query: ParamHistoryQuery) -> Select:
.where(ParamValue.param_id == param_id)
.order_by(ParamValue.effective_date.desc(), ParamValue.id.desc())
)
if query.dimension_value is not None:
stmt = stmt.where(ParamValue.dimension_value == query.dimension_value)
if dimension_value is not None:
stmt = stmt.where(ParamValue.dimension_value == dimension_value)
return stmt
@ -151,30 +150,41 @@ def get_batch_by_id(
return session.get(ParamChangeBatch, batch_id)
def list_batches(session: Session, query: ParamBatchQuery) -> list[ParamChangeBatch]:
def list_batches(
session: Session,
*,
offset: int,
limit: int,
start_date: date | None = None,
end_date: date | None = None,
) -> list[ParamChangeBatch]:
"""分页查询变更批次(按创建时间倒序)。"""
stmt = _batch_filters(select(ParamChangeBatch), query)
stmt = _batch_filters(select(ParamChangeBatch), start_date=start_date, end_date=end_date)
return list(
session.execute(
stmt.order_by(ParamChangeBatch.create_time.desc(), ParamChangeBatch.id.desc())
.offset(query.offset)
.limit(query.page_size)
.offset(offset)
.limit(limit)
).scalars()
)
def count_batches(session: Session, query: ParamBatchQuery) -> int:
def count_batches(
session: Session, *, start_date: date | None = None, end_date: date | None = None
) -> int:
"""统计符合条件的批次总数。"""
stmt = _batch_filters(select(func.count(ParamChangeBatch.id)), query)
stmt = _batch_filters(
select(func.count(ParamChangeBatch.id)), start_date=start_date, end_date=end_date
)
return int(session.execute(stmt).scalar_one())
def _batch_filters(stmt: Select, query: ParamBatchQuery) -> Select:
def _batch_filters(stmt: Select, *, start_date: date | None, end_date: date | None) -> Select:
"""批次筛选(生效日期区间,含边界)。列表与计数共用。"""
if query.start_date is not None:
stmt = stmt.where(ParamChangeBatch.effective_date >= query.start_date)
if query.end_date is not None:
stmt = stmt.where(ParamChangeBatch.effective_date <= query.end_date)
if start_date is not None:
stmt = stmt.where(ParamChangeBatch.effective_date >= start_date)
if end_date is not None:
stmt = stmt.where(ParamChangeBatch.effective_date <= end_date)
return stmt

View File

@ -9,7 +9,6 @@ from sqlalchemy.orm import Session, aliased
from app.models import BusinessRole, Customer, Personnel, PersonnelPlan, PersonnelRegion
from app.repositories.query_utils import fuzzy
from app.schemas.personnel import PersonnelQuery
# 自引用别名:manager_id 指向 personnel.id,同一张表自联取「直属经理姓名」
_manager = aliased(Personnel)
@ -31,23 +30,44 @@ def get_with_names(session: Session, personnel_id: int) -> Row | None:
return session.execute(stmt).first()
def list_personnel(session: Session, query: PersonnelQuery) -> list[Row]:
def list_personnel(
session: Session,
*,
offset: int,
limit: int,
keyword: str | None = None,
status: int | None = None,
business_role_id: int | None = None,
) -> list[Row]:
"""分页查询人员,行内容为 (人员, 岗位名称, 直属经理姓名)。
排序按 id 倒序:人员无排序字段,新增的靠前符合列表页习惯。
"""
stmt = _apply_filters(_base_select(), query)
stmt = stmt.order_by(Personnel.id.desc()).offset(query.offset).limit(query.page_size)
stmt = _apply_filters(
_base_select(), keyword=keyword, status=status, business_role_id=business_role_id
)
stmt = stmt.order_by(Personnel.id.desc()).offset(offset).limit(limit)
return list(session.execute(stmt).all())
def count_personnel(session: Session, query: PersonnelQuery) -> int:
def count_personnel(
session: Session,
*,
keyword: str | None = None,
status: int | None = None,
business_role_id: int | None = None,
) -> int:
"""统计符合条件的人员总数,供分页使用。
筛选条件全部落在主表上,计数无需 join(列表与计数的筛选口径由
``_apply_filters`` 单点保证)。
"""
stmt = _apply_filters(select(func.count(Personnel.id)), query)
stmt = _apply_filters(
select(func.count(Personnel.id)),
keyword=keyword,
status=status,
business_role_id=business_role_id,
)
return int(session.execute(stmt).scalar_one())
@ -134,14 +154,20 @@ def _base_select() -> Select:
)
def _apply_filters(stmt: Select, query: PersonnelQuery) -> Select:
def _apply_filters(
stmt: Select,
*,
keyword: str | None,
status: int | None,
business_role_id: int | None,
) -> Select:
"""应用通用筛选条件。列表与计数共用,避免两处口径不一致。"""
if query.keyword:
stmt = fuzzy(stmt, Personnel.personnel_name, query.keyword)
if query.status is not None:
stmt = stmt.where(Personnel.status == query.status)
if query.business_role_id is not None:
stmt = stmt.where(Personnel.business_role_id == query.business_role_id)
if keyword:
stmt = fuzzy(stmt, Personnel.personnel_name, keyword)
if status is not None:
stmt = stmt.where(Personnel.status == status)
if business_role_id is not None:
stmt = stmt.where(Personnel.business_role_id == business_role_id)
return stmt

View File

@ -7,7 +7,6 @@ from sqlalchemy import Select, func, or_, select
from sqlalchemy.orm import Session
from app.models import BusinessItem, Product, ShipmentItem
from app.schemas.product import ProductQuery
# ==================== 读取 ====================
@ -35,20 +34,50 @@ def get_by_name_spec(session: Session, product_name: str, spec_model: str) -> Pr
return session.execute(stmt).scalar_one_or_none()
def list_products(session: Session, query: ProductQuery) -> list[Product]:
def list_products(
session: Session,
*,
offset: int,
limit: int,
keyword: str | None = None,
product_category: str | None = None,
status: int | None = None,
) -> list[Product]:
"""分页查询产品(含停用)。按 id 升序,与 M1 岗位列表的次序口径一致。"""
stmt = _apply_filters(select(Product), query)
stmt = stmt.order_by(Product.id).offset(query.offset).limit(query.page_size)
stmt = _apply_filters(
select(Product),
keyword=keyword,
product_category=product_category,
status=status,
)
stmt = stmt.order_by(Product.id).offset(offset).limit(limit)
return list(session.execute(stmt).scalars())
def count_products(session: Session, query: ProductQuery) -> int:
def count_products(
session: Session,
*,
keyword: str | None = None,
product_category: str | None = None,
status: int | None = None,
) -> int:
"""统计符合条件的产品总数,供分页使用。"""
stmt = _apply_filters(select(func.count(Product.id)), query)
stmt = _apply_filters(
select(func.count(Product.id)),
keyword=keyword,
product_category=product_category,
status=status,
)
return int(session.execute(stmt).scalar_one())
def _apply_filters(stmt: Select, query: ProductQuery) -> Select:
def _apply_filters(
stmt: Select,
*,
keyword: str | None,
product_category: str | None,
status: int | None,
) -> Select:
"""应用通用筛选条件。列表与计数共用,避免两处口径不一致。
关键词对名称/规格两列取 OR 命中。转义与 query_utils.fuzzy 同一机制
@ -57,17 +86,17 @@ def _apply_filters(stmt: Select, query: ProductQuery) -> Select:
写 contains —— M1 business_role_repository 的双列关键词同款写法。
规格型号常含 ``_``(如 DN300_II),不转义会当单字符通配执行。
"""
if query.keyword:
if keyword:
stmt = stmt.where(
or_(
Product.product_name.contains(query.keyword, autoescape=True),
Product.spec_model.contains(query.keyword, autoescape=True),
Product.product_name.contains(keyword, autoescape=True),
Product.spec_model.contains(keyword, autoescape=True),
)
)
if query.product_category:
stmt = stmt.where(Product.product_category == query.product_category)
if query.status is not None:
stmt = stmt.where(Product.status == query.status)
if product_category:
stmt = stmt.where(Product.product_category == product_category)
if status is not None:
stmt = stmt.where(Product.status == status)
return stmt

View File

@ -14,7 +14,6 @@ from sqlalchemy import Select, func, select
from sqlalchemy.orm import Session
from app.models import Business, Receipt, Shipment
from app.schemas.receipt import ReceiptQuery
def get_by_id(session: Session, receipt_id: int) -> Receipt | None:
@ -44,42 +43,72 @@ def get_shipment(session: Session, shipment_id: int) -> Shipment | None:
return session.get(Shipment, shipment_id)
def _apply_filters(stmt: Select, query: ReceiptQuery) -> Select:
def _apply_filters(
stmt: Select,
*,
business_id: int | None,
date_from: date | None,
date_to: date | None,
is_barter: int | None,
settlement_status: int | None,
) -> Select:
"""回款列表的筛选条件(全部落在 Receipt 自身列上)。"""
if query.business_id is not None:
stmt = stmt.where(Receipt.business_id == query.business_id)
if query.date_from is not None:
stmt = stmt.where(Receipt.receipt_date >= query.date_from)
if query.date_to is not None:
stmt = stmt.where(Receipt.receipt_date <= query.date_to)
if query.is_barter is not None:
stmt = stmt.where(Receipt.is_barter == query.is_barter)
# settlement_status 是 M3 核算防重锚点(需规 2.3.3),必须保持具名字段
if query.settlement_status is not None:
stmt = stmt.where(Receipt.settlement_status == query.settlement_status)
if business_id is not None:
stmt = stmt.where(Receipt.business_id == business_id)
if date_from is not None:
stmt = stmt.where(Receipt.receipt_date >= date_from)
if date_to is not None:
stmt = stmt.where(Receipt.receipt_date <= date_to)
if is_barter is not None:
stmt = stmt.where(Receipt.is_barter == is_barter)
if settlement_status is not None:
stmt = stmt.where(Receipt.settlement_status == settlement_status)
return stmt
def list_receipts(
session: Session, query: ReceiptQuery
session: Session,
*,
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]]:
"""分页查询回款(附业务号/发货单号/发货日),按 id 倒序。"""
stmt = _apply_filters(
select(Receipt, Business.business_no, Shipment.shipment_no, Shipment.ship_date)
.join(Business, Business.id == Receipt.business_id)
.outerjoin(Shipment, Shipment.id == Receipt.shipment_id),
query,
)
return list(
session.execute(
stmt.order_by(Receipt.id.desc()).offset(query.offset).limit(query.page_size)
).all()
business_id=business_id,
date_from=date_from,
date_to=date_to,
is_barter=is_barter,
settlement_status=settlement_status,
)
return list(session.execute(stmt.order_by(Receipt.id.desc()).offset(offset).limit(limit)).all())
def count_receipts(session: Session, query: ReceiptQuery) -> int:
def count_receipts(
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,防两侧口径漂移)。"""
stmt = _apply_filters(select(func.count()).select_from(Receipt), query)
stmt = _apply_filters(
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())

View File

@ -389,7 +389,9 @@ def _bucket_condition(bucket: DayBucket) -> tuple:
return tuple(conds)
def list_payment_detail(session: Session, query: PaymentDetailQuery, bucket: DayBucket) -> list:
def list_payment_detail(
session: Session, query: PaymentDetailQuery, bucket: DayBucket, *, offset: int, limit: int
) -> list:
"""区间下钻:单笔生效行联回款表(行级展示回款金额与批次号)。
bucket 由服务层解析(未注册编码 400),仓储只按区间拼条件。
@ -406,7 +408,7 @@ def list_payment_detail(session: Session, query: PaymentDetailQuery, bucket: Day
.where(*_payment_filters(query), *_bucket_condition(bucket))
)
stmt = stmt.order_by(PerformanceLedger.receipt_date.desc(), PerformanceLedger.id.desc())
stmt = stmt.offset(query.offset).limit(query.page_size)
stmt = stmt.offset(offset).limit(limit)
return list(session.execute(stmt).all())

View File

@ -10,7 +10,6 @@ from sqlalchemy import Select, func, or_, select
from sqlalchemy.orm import Session
from app.models import Role, UserRole
from app.schemas.role import RoleQuery
# ==================== 读取 ====================
@ -48,37 +47,44 @@ def get_all_roles(session: Session) -> list[Role]:
return list(session.execute(stmt).scalars())
def list_roles(session: Session, query: RoleQuery) -> list[Role]:
def list_roles(
session: Session,
*,
offset: int,
limit: int,
keyword: str | None = None,
status: int | None = None,
) -> list[Role]:
"""分页查询角色(管理列表:含停用角色)。
排序与下拉口径一致(sort_order, id),保持同一套相对次序。
"""
stmt = _apply_filters(select(Role), query)
stmt = stmt.order_by(Role.sort_order, Role.id).offset(query.offset).limit(query.page_size)
stmt = _apply_filters(select(Role), keyword=keyword, status=status)
stmt = stmt.order_by(Role.sort_order, Role.id).offset(offset).limit(limit)
return list(session.execute(stmt).scalars())
def count_roles(session: Session, query: RoleQuery) -> int:
def count_roles(session: Session, *, keyword: str | None = None, status: int | None = None) -> int:
"""统计符合条件的角色总数,供分页使用。"""
stmt = _apply_filters(select(func.count(Role.id)), query)
stmt = _apply_filters(select(func.count(Role.id)), keyword=keyword, status=status)
return int(session.execute(stmt).scalar_one())
def _apply_filters(stmt: Select, query: RoleQuery) -> Select:
def _apply_filters(stmt: Select, *, keyword: str | None, status: int | None) -> Select:
"""应用通用筛选条件。列表与计数共用,避免两处口径不一致。
关键词匹配走 `contains(autoescape=True)`(转义 LIKE 通配符)——
角色编码普遍含下划线,直接拼接会让 `_` 当单字符通配执行。
"""
if query.keyword:
if keyword:
stmt = stmt.where(
or_(
Role.role_code.contains(query.keyword, autoescape=True),
Role.role_name.contains(query.keyword, autoescape=True),
Role.role_code.contains(keyword, autoescape=True),
Role.role_name.contains(keyword, autoescape=True),
)
)
if query.status is not None:
stmt = stmt.where(Role.status == query.status)
if status is not None:
stmt = stmt.where(Role.status == status)
return stmt

View File

@ -8,13 +8,13 @@
业务级汇总仍收口 business_service._recalc_totals
"""
from datetime import date
from decimal import Decimal
from sqlalchemy import Select, delete, func, select
from sqlalchemy.orm import Session
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:
@ -31,37 +31,59 @@ def get_with_business_no(session: Session, shipment_id: int) -> tuple[Shipment,
).first()
def _apply_filters(stmt: Select, query: ShipmentQuery) -> Select:
def _apply_filters(
stmt: Select,
*,
business_id: int | None,
ship_date_from: date | None,
ship_date_to: date | None,
) -> Select:
"""发货列表的筛选条件(business_id 空=全量,一期按业务维度查看为主)。"""
if query.business_id is not None:
stmt = stmt.where(Shipment.business_id == query.business_id)
if query.ship_date_from is not None:
stmt = stmt.where(Shipment.ship_date >= query.ship_date_from)
if query.ship_date_to is not None:
stmt = stmt.where(Shipment.ship_date <= query.ship_date_to)
if business_id is not None:
stmt = stmt.where(Shipment.business_id == business_id)
if ship_date_from is not None:
stmt = stmt.where(Shipment.ship_date >= ship_date_from)
if ship_date_to is not None:
stmt = stmt.where(Shipment.ship_date <= ship_date_to)
return stmt
def list_shipments(session: Session, query: ShipmentQuery) -> list[tuple[Shipment, str]]:
def list_shipments(
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 倒序。"""
stmt = _apply_filters(
select(Shipment, Business.business_no).join(Business, Business.id == Shipment.business_id),
query,
business_id=business_id,
ship_date_from=ship_date_from,
ship_date_to=ship_date_to,
)
return list(
session.execute(
stmt.order_by(Shipment.id.desc()).offset(query.offset).limit(query.page_size)
).all()
session.execute(stmt.order_by(Shipment.id.desc()).offset(offset).limit(limit)).all()
)
def count_shipments(session: Session, query: ShipmentQuery) -> int:
def count_shipments(
session: Session,
*,
business_id: int | None,
ship_date_from: date | None,
ship_date_to: date | None,
) -> int:
"""发货单总数(筛选条件与列表共用同一 apply_filters,防两侧口径漂移)。"""
stmt = _apply_filters(
select(func.count())
.select_from(Shipment)
.join(Business, Business.id == Shipment.business_id),
query,
business_id=business_id,
ship_date_from=ship_date_from,
ship_date_to=ship_date_to,
)
return int(session.execute(stmt).scalar_one())

View File

@ -10,7 +10,6 @@ from sqlalchemy.orm import Session, selectinload
from app.models import Role, User, UserRole
from app.repositories.query_utils import fuzzy
from app.schemas.user import UserQuery
def _base_query() -> Select[tuple[User]]:
@ -49,7 +48,16 @@ def touch_last_login(session: Session, user: User, moment: datetime) -> None:
# ==================== 列表查询 ====================
def list_users(session: Session, query: UserQuery) -> list[User]:
def list_users(
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 是秒级精度,批量导入的用户
@ -57,35 +65,48 @@ def list_users(session: Session, query: UserQuery) -> list[User]:
id 是自增主键,天然唯一且与创建顺序一致。
"""
stmt = _base_query()
stmt = _apply_filters(stmt, query)
if query.role_code:
stmt = _apply_filters(stmt, username=username, real_name=real_name, status=status)
if role_code:
# 按角色筛选需 join 关联表;角色是字典数据,数量极少,join 代价可忽略
stmt = stmt.join(UserRole).join(Role).where(Role.role_code == query.role_code)
stmt = stmt.order_by(User.id.desc()).offset(query.offset).limit(query.page_size)
stmt = stmt.join(UserRole).join(Role).where(Role.role_code == role_code)
stmt = stmt.order_by(User.id.desc()).offset(offset).limit(limit)
return list(session.execute(stmt).scalars().unique())
def count_users(session: Session, query: UserQuery) -> int:
def count_users(
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 = _apply_filters(stmt, query)
if query.role_code:
stmt = stmt.join(UserRole).join(Role).where(Role.role_code == query.role_code)
stmt = _apply_filters(stmt, username=username, real_name=real_name, status=status)
if role_code:
stmt = stmt.join(UserRole).join(Role).where(Role.role_code == role_code)
return int(session.execute(stmt).scalar_one())
def _apply_filters(stmt: Select, query: UserQuery) -> Select:
def _apply_filters(
stmt: Select,
*,
username: str | None,
real_name: str | None,
status: int | None,
) -> Select:
"""应用通用筛选条件。列表与计数共用,避免两处口径不一致。
模糊匹配统一走 `query_utils.fuzzy`(转义 LIKE 通配符)—— 用户名普遍
含下划线,直接拼接会让 `_` 当单字符通配执行、误配无关账号。
"""
if query.username:
stmt = fuzzy(stmt, User.username, query.username)
if query.real_name:
stmt = fuzzy(stmt, User.real_name, query.real_name)
if query.status is not None:
stmt = stmt.where(User.status == query.status)
if username:
stmt = fuzzy(stmt, User.username, username)
if real_name:
stmt = fuzzy(stmt, User.real_name, real_name)
if status is not None:
stmt = stmt.where(User.status == status)
return stmt

View File

@ -49,8 +49,7 @@ class ApprovalOrderDetailOut(ApprovalOrderOut):
steps: list[ApprovalStepOut] = Field(default_factory=list)
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(
default="missing", description="审批内容来源:提交时快照/当前内容回退/缺失"

View File

@ -27,19 +27,6 @@ class BusinessRoleOut(BaseModel):
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):
"""模板声明的参数(供岗位表单展示,含当前口径说明)。"""

View File

@ -264,9 +264,7 @@ class DepartmentReconciliation(BaseModel):
description="聚合额 − 团队分成合计 − 全公司分成合计(分列舍入差)",
)
adjust_net_total: Decimal = Field(description="明细层调整净额合计(成员 + 未分配)")
payable_total: Decimal = Field(
description="应发合计 = 成员应付 + 未分配应付 + 聚合行 + 调整净额"
)
payable_total: Decimal = Field(description="应发合计 = 成员应付 + 未分配应付 + 聚合行 + 调整净额")
team_base_total: Decimal = Field(description="展示团队基数合计")
unassigned_performance_total: Decimal = Field(description="展示未分配人员绩效合计")
other_team_base: Decimal = Field(

View File

@ -37,14 +37,12 @@ def _record(
"""写登录日志并立即提交。单独提交是刻意为之,见模块 docstring。"""
login_log_repository.add(
session,
login_log_repository.LoginLogEntry(
user_id=user_id,
username=username,
ip=ip,
user_agent=user_agent,
success=success,
fail_reason=fail_reason,
),
user_id=user_id,
username=username,
ip=ip,
user_agent=user_agent,
success=success,
fail_reason=fail_reason,
)
session.commit()

View File

@ -31,7 +31,6 @@ from app.models import BusinessRole, User
from app.repositories import business_role_repository
from app.schemas.business_role import (
BusinessRoleCreateIn,
BusinessRoleOptionOut,
BusinessRoleOut,
BusinessRoleQuery,
BusinessRoleUpdateIn,
@ -68,8 +67,16 @@ def _ensure_template(calc_template: str) -> None:
def list_roles(session: Session, query: BusinessRoleQuery) -> PageResult[BusinessRoleOut]:
"""分页查询岗位(含停用)。"""
roles = business_role_repository.list_roles(session, query)
total = business_role_repository.count_roles(session, query)
roles = business_role_repository.list_roles(
session,
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](
records=[_to_out(role) for role in roles],
total=total,
@ -78,23 +85,6 @@ 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:
"""岗位详情。

View File

@ -15,8 +15,6 @@
"""
import logging
from collections.abc import Callable
from dataclasses import dataclass
from datetime import date
from decimal import ROUND_HALF_UP, Decimal
from fractions import Fraction
@ -236,8 +234,26 @@ def _is_duplicate_entry(exc: IntegrityError) -> bool:
def list_businesses(session: Session, query: BusinessQuery) -> PageResult[BusinessOut]:
"""分页查询业务(附客户名与归属人名)。"""
rows = business_repository.list_businesses(session, query)
total = business_repository.count_businesses(session, query)
rows = business_repository.list_businesses(
session,
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](
records=[_to_out(row) for row in rows],
total=total,
@ -732,67 +748,6 @@ def change_status(
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(
session: Session,
business_id: int,
@ -824,12 +779,41 @@ def change_ownership(
if row is None:
raise NotFoundError("业务不存在")
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.assistant_personnel_id = payload.new_assistant_personnel_id
business_repository.add_ownership_log(
session, _business_ownership_log(business, payload, resolution, operator)
session,
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 的人名、未变项沿用首查名」——首查名是变更前快照
@ -837,19 +821,17 @@ def change_ownership(
(
business,
row[1],
resolution.owner.personnel_name if resolution.owner is not None else old_owner_name,
resolution.assistant.personnel_name
if resolution.assistant is not None
else old_assistant_name,
new_owner.personnel_name if new_owner is not None else old_owner_name,
new_assistant.personnel_name if new_assistant is not None else old_assistant_name,
)
)
session.commit()
logger.info(
"调整业务归属 | business_id=%s | owner %s->%s | assistant %s->%s",
business_id,
resolution.old_owner_id,
old_owner_id,
payload.new_owner_personnel_id,
resolution.old_assistant_id,
old_assistant_id,
payload.new_assistant_personnel_id,
)
return out
@ -915,94 +897,6 @@ 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:
"""毛利润数据视图(需求 2.2.4):行级派生值 + 类别汇总,实时计算不落库。
@ -1021,15 +915,64 @@ def gross_profit(session: Session, business_id: int, as_of: date | None) -> Gros
def threshold_of(category: str) -> Fraction:
return snapshot.fraction("product.threshold", product_category=category)
scan = _scan_gross_profit_rows(session, business_id, threshold_of)
rows_out: list[GrossProfitRowOut] = []
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 = [
_gross_profit_category(category, sale, cost, threshold_of(category))
for category, [sale, cost] in scan.category_agg.items()
GrossProfitCategoryOut(
product_category=category,
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(
business_id=business_id,
as_of=as_of,
rows=scan.rows,
rows=rows_out,
categories=categories,
missing_count=scan.missing_count,
missing_count=missing_count,
)

View File

@ -14,7 +14,6 @@
"""
import logging
from dataclasses import dataclass
from sqlalchemy import Row
from sqlalchemy.exc import IntegrityError
@ -108,8 +107,20 @@ def _is_duplicate_entry(exc: IntegrityError) -> bool:
def list_customers(session: Session, query: CustomerQuery) -> PageResult[CustomerOut]:
"""分页查询客户(附归属人员姓名)。"""
rows = customer_repository.list_customers(session, query)
total = customer_repository.count_customers(session, query)
rows = customer_repository.list_customers(
session,
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](
records=[_to_out(customer, owner_name) for customer, owner_name in rows],
total=total,
@ -306,71 +317,6 @@ def delete_customer(
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(
session: Session,
customer_id: int,
@ -389,34 +335,57 @@ def change_ownership(
"""
# 只读预取在审计上下文之前:摘要需要客户名与新旧归属人名;校验仍在
# 上下文内进行,失败同样要留下可追溯的记录
prefetch = _prefetch_customer_ownership(session, customer_id, payload)
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
)
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(
session,
operator=operator,
record=AuditRecord(
module=MODULE_CUSTOMER,
action=ACTION_UPDATE,
summary=(
f"调整客户归属 {prefetch.customer_name}:"
f"{prefetch.old_label} → {prefetch.new_label}"
),
summary=f"调整客户归属 {customer_name}:{old_label} → {new_label}",
request_data=payload.model_dump(exclude_none=True),
),
meta=meta,
):
if prefetch.row is None:
if row is None:
raise NotFoundError("客户不存在")
customer, _ = prefetch.row
customer, _ = row
old_owner_id = _validate_customer_ownership(session, customer, payload)
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("归属未变化")
# 主表更新与留痕插入同事务:只在本方法内一次 commit(见模块 docstring)
customer.owner_personnel_id = payload.new_owner_personnel_id
customer_repository.add_ownership_log(
session, _customer_ownership_log(customer, payload, old_owner_id, operator)
session,
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 顶部说明);归属人名以新值为准
out = _to_out(customer, prefetch.new_person.personnel_name if prefetch.new_person else None)
out = _to_out(customer, new_person.personnel_name if new_person else None)
session.commit()
logger.info(
"调整客户归属 | customer_id=%s | old=%s | new=%s",

View File

@ -168,7 +168,9 @@ def _warning_params(session: Session) -> dict:
return {
"enabled": snapshot.get("warning.enabled") == "1",
"receipt_overdue_days": int(parse_fraction(snapshot.get("warning.receipt.overdue.days"))),
"approval_timeout_days": int(parse_fraction(snapshot.get("warning.approval.timeout.days"))),
"approval_timeout_days": int(
parse_fraction(snapshot.get("warning.approval.timeout.days"))
),
}
@ -251,11 +253,9 @@ def _activities(session: Session) -> list[dict]:
"""最近动态(FR-036):以操作日志为唯一来源(写操作即留痕)。"""
logs = operation_log_repository.list_logs(
session,
operation_log_repository.OperationLogListQuery(
filters=LogFilters(),
offset=0,
limit=_ACTIVITY_LIMIT,
),
offset=0,
limit=_ACTIVITY_LIMIT,
filters=LogFilters(),
)
return [
{

View File

@ -22,15 +22,18 @@ from app.schemas.log import (
def list_operation_logs(session: Session, query: OperationLogQuery) -> PageResult[OperationLogOut]:
"""分页查询操作日志(列表不含 request_data,详情走 `get_operation_log`)。"""
repo_query = operation_log_repository.OperationLogListQuery(
filters=_filters_from(query),
filters = _filters_from(query)
logs = operation_log_repository.list_logs(
session,
offset=query.offset,
limit=query.page_size,
filters=filters,
module=query.module,
action=query.action,
)
logs = operation_log_repository.list_logs(session, repo_query)
total = operation_log_repository.count_logs(session, repo_query)
total = operation_log_repository.count_logs(
session, filters=filters, module=query.module, action=query.action
)
return PageResult[OperationLogOut](
records=[OperationLogOut.model_validate(log) for log in logs],
total=total,
@ -52,14 +55,15 @@ def get_operation_log(session: Session, log_id: int) -> OperationLogDetailOut:
def list_login_logs(session: Session, query: LoginLogQuery) -> PageResult[LoginLogOut]:
"""分页查询登录日志。"""
repo_query = login_log_repository.LoginLogListQuery(
filters=_filters_from(query),
filters = _filters_from(query)
logs = login_log_repository.list_logs(
session,
offset=query.offset,
limit=query.page_size,
filters=filters,
ip=query.ip,
)
logs = login_log_repository.list_logs(session, repo_query)
total = login_log_repository.count_logs(session, repo_query)
total = login_log_repository.count_logs(session, filters=filters, ip=query.ip)
return PageResult[LoginLogOut](
records=[LoginLogOut.model_validate(log) for log in logs],
total=total,

View File

@ -341,7 +341,9 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="90",
value_min="1",
value_max=None,
description=("发货后超过该天数仍未回款的发货批次触发预警(澄清 #10 建议默认 90 天)"),
description=(
"发货后超过该天数仍未回款的发货批次触发预警(澄清 #10 建议默认 90 天)"
),
sort_order=22,
),
ParamSpec(
@ -355,7 +357,9 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
default_value="3",
value_min="1",
value_max=None,
description=("待审批单提交后超过该天数未处理触发预警(澄清 #10 建议默认 3 天)"),
description=(
"待审批单提交后超过该天数未处理触发预警(澄清 #10 建议默认 3 天)"
),
sort_order=23,
),
]

View File

@ -6,7 +6,7 @@ ParamSnapshot 是 M3 核算与计算预览共用的输入契约:按业务日
"""
import logging
from collections.abc import Callable, Iterable
from collections.abc import Iterable
from dataclasses import dataclass
from datetime import date
from decimal import Decimal
@ -379,8 +379,16 @@ def get_history(
definition = param_repository.get_definition_by_code(session, param_code)
if definition is None:
raise NotFoundError("参数不存在")
rows = param_repository.list_history(session, definition.id, query)
total = param_repository.count_history(session, definition.id, query)
rows = param_repository.list_history(
session,
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](
records=[
ParamHistoryOut(
@ -402,8 +410,16 @@ def get_history(
def list_batches(session: Session, query: ParamBatchQuery) -> PageResult[ParamBatchOut]:
"""变更批次分页(按创建时间倒序)。"""
batches = param_repository.list_batches(session, query)
total = param_repository.count_batches(session, query)
batches = param_repository.list_batches(
session,
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](
records=[
ParamBatchOut(
@ -493,66 +509,19 @@ def compare(session: Session, *, from_date: date, to_date: date) -> list[ParamDi
return out
@dataclass(frozen=True, slots=True)
class _PreviewContext:
"""预览上下文(会话、入参、参数取值闭包)。
``take`` 闭包同时负责取数与记录「用了哪些参数」——**调用顺序即
``params_used`` 的展示顺序**,拆分时不得调换各段的先后。
"""
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]:
def _preview_rate(
session: Session,
payload: ParamPreviewIn,
snapshot: ParamSnapshot,
days: int,
take,
) -> tuple[Fraction, str, Decimal | None]:
"""按岗位模板分派计提比例,返回(纯比例, 人读说明, 绩效基数覆盖值)。
基数覆盖值非 None 表示该模板的绩效基数不是折算毛利润额(如信息员按
「本人销售额」计提),预览层直接采用并跳过折算与阈值。
聚合模板(营销经理 12.5%+5%)依赖月度团队数据,不支持单笔预览。
"""
payload = ctx.payload
take = ctx.take
if payload.customer_type == "enterprise":
# 存储口径为 ‰ 数(如 "3"),除以 1000 转纯比例 —— performance_amount
# 按纯比例相乘,漏换算会把绩效金额放大 1000 倍(与业务员分支同防线)
@ -562,7 +531,7 @@ def _preview_rate(ctx: _PreviewContext, days: int) -> tuple[Fraction, str, Decim
role_code = payload.business_role
template = "TPL_SALESPERSON_DECAY"
if role_code:
role = business_role_repository.get_by_code(ctx.session, role_code)
role = business_role_repository.get_by_code(session, role_code)
if role is None:
raise BadRequestError(f"岗位 {role_code!r} 不存在")
template = role.calc_template
@ -592,73 +561,12 @@ def _preview_rate(ctx: _PreviewContext, days: int) -> tuple[Fraction, str, Decim
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:
"""计算预览:入参(发货/回款/金额/岗位)→ 公式纯函数 → 出参(含所用参数)。"""
# 参数按回款到账日取值(对照表 #11)
snapshot = resolve_snapshot(session, payload.receipt_date)
revenue = Decimal(payload.revenue)
profit = Decimal(payload.profit)
received = Decimal(payload.received_amount)
used: list[ParamUsedOut] = []
@ -681,29 +589,55 @@ def preview(session: Session, payload: ParamPreviewIn) -> ParamPreviewOut:
)
return parse_fraction(raw)
ctx = _PreviewContext(session=session, payload=payload, take=take)
# 各段顺序固定 —— take 的调用顺序即 params_used 的展示顺序
decision = _preview_rate_decision(ctx)
earning = _preview_earning(ctx, decision)
barter = _preview_barter(ctx, earning.performance)
payment = _preview_payment(ctx, received, revenue, barter.base_amount)
probation_note = _preview_probation_note(ctx)
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(session, payload, snapshot, days, take)
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(
payment_days=decision.days,
rate=str(decision.rate),
rate_text=decision.rate_text,
profit_rate=str(earning.profit_rate),
converted_profit=str(earning.converted),
performance_amount=str(earning.performance),
barter_applied=barter.applied,
performance_after_barter=(
str(barter.after_barter) if barter.after_barter is not None else None
),
start_payment_ok=payment.start_ok,
payment_weight_r=payment.weight_r,
payment_weight=str(payment.weight),
payable_amount=str(payment.payable),
payment_days=days,
rate=str(rate),
rate_text=rate_text,
profit_rate=str(profit_rate),
converted_profit=str(converted),
performance_amount=str(performance),
barter_applied=barter_applied,
performance_after_barter=str(after_barter) if after_barter is not None else None,
start_payment_ok=start_ok,
payment_weight_r=weight_r,
payment_weight=str(weight),
payable_amount=str(payable),
probation_note=probation_note,
params_used=used,
)

View File

@ -70,8 +70,20 @@ def ensure_active(session: Session, personnel_id: int, *, label: str) -> Personn
def list_personnel(session: Session, query: PersonnelQuery) -> PageResult[PersonnelOut]:
"""分页查询人员(含离职)。"""
rows = personnel_repository.list_personnel(session, query)
total = personnel_repository.count_personnel(session, query)
rows = personnel_repository.list_personnel(
session,
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](
records=[
_to_out(personnel, role_name, manager_name)

View File

@ -99,8 +99,20 @@ def _raise_if_duplicate(exc: IntegrityError) -> None:
def list_products(session: Session, query: ProductQuery) -> PageResult[ProductOut]:
"""分页查询产品(含停用)。"""
products = product_repository.list_products(session, query)
total = product_repository.count_products(session, query)
products = product_repository.list_products(
session,
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](
records=[_to_out(product) for product in products],
total=total,

View File

@ -23,7 +23,6 @@
"""
import logging
from dataclasses import dataclass
from datetime import date
from decimal import Decimal
@ -40,7 +39,7 @@ from app.core.constants import (
from app.core.db_errors import is_duplicate_entry
from app.core.exceptions import BadRequestError, NotFoundError
from app.core.response import PageResult
from app.models import Business, Receipt
from app.models import Receipt
from app.models.business import BUSINESS_STATUS_ACTIVE
from app.models.receipt import SETTLEMENT_DONE, SETTLEMENT_PENDING
from app.repositories import business_repository, receipt_repository
@ -130,8 +129,24 @@ def _ensure_business_writable(session: Session, business_id: int, action: str):
def list_receipts(session: Session, query: ReceiptQuery) -> PageResult[ReceiptOut]:
"""分页查询回款(附业务号/发货单号与 payment_days 派生值)。"""
rows = receipt_repository.list_receipts(session, query)
total = receipt_repository.count_receipts(session, query)
rows = receipt_repository.list_receipts(
session,
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](
records=[_to_out(row) for row in rows],
total=total,
@ -151,57 +166,6 @@ def get_detail(session: Session, receipt_id: int) -> ReceiptOut:
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(
session: Session,
payload: ReceiptCreateIn,
@ -242,7 +206,35 @@ def create_receipt(
# 锁业务行与生成批次号——成功/最终失败各只记一条审计,无递归
for attempt in range(_NO_RETRY + 1):
try:
created = _create_receipt_once(session, payload, business, batch_no)
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))
new_id, new_no = receipt.id, batch_no # 固化:commit 后不碰 ORM
session.commit()
break
except IntegrityError as exc:
@ -257,11 +249,11 @@ def create_receipt(
logger.info(
"新增回款 | receipt_id=%s | no=%s | business_id=%s",
created.receipt_id,
created.batch_no,
new_id,
new_no,
payload.business_id,
)
return created.out
return out
def update_receipt(

View File

@ -36,6 +36,7 @@ _DATE_FORMAT = "YYYY-MM-DD"
_FORMULA_PREFIXES = ("=", "+", "-", "@", "\t")
@dataclass(frozen=True, slots=True)
class _Column:
"""列定义:key 取行 dict 字段,kind 决定单元格类型与格式。"""
@ -402,13 +403,7 @@ def _write_department_document(
company_share_count = int(company.get("company_share_count") or 0)
_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(
@ -444,12 +439,16 @@ def _write_department_document(
"manager_name": "合计",
"member_count": sum(team["member_count"] for team in teams),
"team_base": _sum_amount(team["team_base"] for team in teams),
"team_share_amount": _sum_amount(team["team_share_amount"] for team in teams),
"team_share_amount": _sum_amount(
team["team_share_amount"] for team in teams
),
"team_subtotal": _sum_amount(team["team_subtotal"] for team in teams),
# 未生成月结时全部为 None,合计也留空(不能显示 0.00 误导)
"frozen_amount": (
_sum_amount(
team["frozen_amount"] for team in teams if team["frozen_amount"] is not None
team["frozen_amount"]
for team in teams
if team["frozen_amount"] is not None
)
if any(team["frozen_amount"] is not None for team in teams)
else None

View File

@ -103,7 +103,10 @@ def _person_row(row: PerformanceLedger, role_names: dict[str, str]) -> dict:
def _role_names(session) -> dict[str, str]:
"""岗位编码 → 名称一次查询(明细与部门行共用,避免 N+1)。"""
return {r.role_code: r.role_name for r in session.execute(select(BusinessRole)).scalars()}
return {
r.role_code: r.role_name
for r in session.execute(select(BusinessRole)).scalars()
}
def _dec(value) -> Decimal | None:
@ -175,7 +178,11 @@ def department_summary(session, query) -> dict:
extra_ids = {
target
for pid in adjustments
if (target := _adjustment_target(pid, member_map, manager_of, manager_role, include_mp))
if (
target := _adjustment_target(
pid, member_map, manager_of, manager_role, include_mp
)
)
is not None
} - set(manager_ids)
if extra_ids:
@ -186,7 +193,9 @@ def department_summary(session, query) -> dict:
for team in teams_all
if query.manager_id is None or team["manager_id"] == query.manager_id
]
unassigned_all = _unassigned_rows(member_map, manager_of, manager_role, adjustments, include_mp)
unassigned_all = _unassigned_rows(
member_map, manager_of, manager_role, adjustments, include_mp
)
# 「仅看某经理团队」时未分配桶不随展示:全公司无经理人员与该团队无关
unassigned = [] if query.manager_id is not None else unassigned_all
company_share_amount = _company_share(company_base, share.company_pct)
@ -326,7 +335,9 @@ def _reconciliation(
(m["adjust_net"] for team in teams for m in team["members"]), 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"))
unassigned_performance = sum((row["performance_sum"] for row in unassigned), Decimal("0"))
unassigned_performance = sum(
(row["performance_sum"] for row in unassigned), Decimal("0")
)
payable_total = (
team_member_payable + unassigned_payable + aggregate_payable_total + adjust_net_total
)
@ -367,7 +378,9 @@ def _reconciliation(
def _team_share(team_base: Decimal, team_pct: Fraction) -> Decimal:
"""12.5% 团队分成:全公司部分传 0,仅取 team_pct 项。"""
return manager_team_share(team_base, Decimal("0"), team_pct=team_pct, company_pct=Fraction(0))
return manager_team_share(
team_base, Decimal("0"), team_pct=team_pct, company_pct=Fraction(0)
)
def _company_share(company_base: Decimal, company_pct: Fraction) -> Decimal:
@ -470,8 +483,12 @@ def _team_relations(
referenced = {p.manager_id for p in people if p.manager_id is not None}
missing = referenced - seed
if missing:
people.extend(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()}
people.extend(
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()
}
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}
person_info = {
@ -602,7 +619,9 @@ def _member_rows(
)
)
personal_in_team = (
pid == manager_id and include_manager_personal and member["has_manager_personal"]
pid == manager_id
and include_manager_personal
and member["has_manager_personal"]
)
if personal_in_team:
rows.append(
@ -874,7 +893,9 @@ def _group_rows(rows) -> list[dict]:
def payment_detail(session, query) -> dict:
"""区间下钻分页(bucket 未注册 → 400)。"""
bucket = _bucket_by_code(query.bucket)
rows = report_repository.list_payment_detail(session, query, bucket)
rows = report_repository.list_payment_detail(
session, query, bucket, offset=query.offset, limit=query.page_size
)
return {
"list": [_payment_row(r) for r in rows],
"total": report_repository.count_payment_detail(session, query, bucket),

View File

@ -68,8 +68,14 @@ def list_roles(session: Session, query: RoleQuery) -> PageResult[RoleManageOut]:
与用户表单下拉(`/users/meta/roles`)口径不同:**管理列表含停用角色**,
下拉只给启用项。
"""
roles = role_repository.list_roles(session, query)
total = role_repository.count_roles(session, query)
roles = role_repository.list_roles(
session,
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])
return PageResult[RoleManageOut](
records=[_to_out(role, counts.get(role.id, 0)) for role in roles],

View File

@ -157,8 +157,20 @@ def _is_duplicate_entry(exc: IntegrityError) -> bool:
def list_shipments(session: Session, query: ShipmentQuery) -> PageResult[ShipmentOut]:
"""分页查询发货单(附业务号;business_id 可空=全量,一期按业务维度查看为主)。"""
rows = shipment_repository.list_shipments(session, query)
total = shipment_repository.count_shipments(session, query)
rows = shipment_repository.list_shipments(
session,
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](
records=[_to_out(row) for row in rows],
total=total,

View File

@ -68,8 +68,22 @@ def _to_out(user: User) -> UserOut:
def list_users(session: Session, query: UserQuery) -> PageResult[UserOut]:
"""分页查询用户列表。"""
users = user_repository.list_users(session, query)
total = user_repository.count_users(session, query)
users = user_repository.list_users(
session,
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](
records=[_to_out(user) for user in users],
total=total,

View File

@ -205,7 +205,6 @@ 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 | 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-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 | 全部 | 806 | ✅ 806 passed(7:59) | **M4 ⑤ 报表统计全量回归**(报表新增 18 例:集成 7 + 单元 11;另含并行会话并入的 M3 前端修复与字体合规)。ruff 绿。e2e:`calc_settlement_flow` 8 步由并行会话跑通;`report_flow.py` 待 `E2E_ADMIN_PASSWORD` 运行 |

View File

@ -451,9 +451,7 @@ class TestContentSnapshot:
row.snapshot_json = None
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["snapshot"]["captured_at"] is None
assert detail["snapshot"]["data"]["business_no"] == data["business_no"]
@ -476,9 +474,7 @@ class TestContentSnapshot:
row.ref_id = 999_999_999
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["snapshot"] is None
@ -683,7 +679,9 @@ class TestAllocationVersions:
data = _create_with_plan(client, admin["headers"], p["id"])
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"]] == [(1, 1)]
assert [(h["version_no"], h["is_active"]) for h in detail["allocation_history"]] == [
(1, 1)
]
payload = business_payload(
detail["customer_id"],
@ -693,9 +691,7 @@ class TestAllocationVersions:
assert resp.status_code == 200, resp.text
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 [a["ratio"] for a in detail["secondary_allocations"]] == ["80.00"]

View File

@ -13,24 +13,21 @@ audit_service.py「上下文内不得吞掉异常后正常退出」的约定相
"""
import uuid
from datetime import date
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import func, select
from app.core.constants import ACTION_CREATE, MODULE_BUSINESS, MODULE_RECEIPT
from app.core.constants import ACTION_CREATE, MODULE_BUSINESS
from app.core.db import get_session_factory
from app.main import app
from app.models import OperationLog
from app.services import business_service, receipt_service
from tests.integration.factories import create_business
from app.services import business_service
pytestmark = pytest.mark.integration
_BUSINESSES = "/api/v1/business/businesses"
_CUSTOMERS = "/api/v1/base/customers"
_RECEIPTS = "/api/v1/business/receipts"
@pytest.fixture
@ -163,94 +160,3 @@ class TestAuditRetry:
# 每层各写一条记录;修复后重试有上限,最终失败仅一条失败日志
assert _log_count(normal["username"], success=False) == base_fail + 1
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

View File

@ -452,7 +452,9 @@ class TestEdit:
assert resp.status_code == 400, resp.text
assert "含合同负责营销人员" in resp.json()["message"]
def test_二次分配方案合法比例_保存成功且回显(self, client: TestClient, normal: dict) -> None:
def test_二次分配方案合法比例_保存成功且回显(
self, client: TestClient, normal: dict
) -> None:
headers = normal["headers"]
customer = _customer(client, headers)
owner = _personnel(client, headers)

View File

@ -138,7 +138,9 @@ def test_总览_绩效合计与台账一致且非法月份400(client: TestClient
expected = Decimal(
str(
session.execute(
select(func.coalesce(func.sum(PerformanceLedger.performance_amount), 0)).where(
select(
func.coalesce(func.sum(PerformanceLedger.performance_amount), 0)
).where(
PerformanceLedger.settlement_month == _month(),
PerformanceLedger.calc_type == 1,
PerformanceLedger.is_active == 1,
@ -164,12 +166,16 @@ def test_待办_待审批与未指定批次与同步降级(client: TestClient, a
)
assert resp.status_code == 200, resp.text
settlement_id = resp.json()["data"]["id"]
resp = client.post(f"{_SETTLEMENTS}/{settlement_id}/submit", headers=admin["headers"])
resp = client.post(
f"{_SETTLEMENTS}/{settlement_id}/submit", headers=admin["headers"]
)
assert resp.status_code == 200, resp.text
data = _dashboard(client, admin["headers"])
assert data["todos"]["pending_approval_total"] >= 1
assert any(item["order_type"] == 2 for item in data["todos"]["pending_approvals"])
assert any(
item["order_type"] == 2 for item in data["todos"]["pending_approvals"]
)
assert data["todos"]["unassigned_receipt_total"] >= 1
assert data["todos"]["unassigned_receipts"][0]["receipt_id"] == ctx["receipt"]["id"]
# ⑥ 未建:显式降级
@ -329,17 +335,17 @@ def test_待办_按角色仅显示可操作节点(
assert _dashboard(client, both["headers"])["todos"]["pending_approval_total"] == 2
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 = {
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)
def test_待办_财务通过后领导出现待办(client: TestClient, admin: dict, finance: dict, leader: dict):
def test_待办_财务通过后领导出现待办(
client: TestClient, admin: dict, finance: dict, leader: dict
):
"""真实流转:财务节点通过后,条目从财务待办消失并进入领导待办。"""
_chain(client, admin["headers"])
resp = client.post(
@ -347,7 +353,9 @@ def test_待办_财务通过后领导出现待办(client: TestClient, admin: dic
)
assert resp.status_code == 200, resp.text
settlement_id = resp.json()["data"]["id"]
resp = client.post(f"{_SETTLEMENTS}/{settlement_id}/submit", headers=admin["headers"])
resp = client.post(
f"{_SETTLEMENTS}/{settlement_id}/submit", headers=admin["headers"]
)
assert resp.status_code == 200, resp.text
order = _dashboard(client, admin["headers"])["todos"]["pending_approvals"][0]

View File

@ -326,38 +326,6 @@ class TestPreview:
# Fraction 自动约分:4/1000 存储为 1/250,按数值断言
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:
payload = {
"business_role": "sales_manager",

View File

@ -44,9 +44,7 @@ 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 = {
"personnel_name": f"t_报表_{uuid.uuid4().hex[:8]}",
"business_role_id": _role_id(role),
@ -196,7 +194,9 @@ def test_部门汇总_无成员经理出零值行(admin, client: TestClient):
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
team = next((t for t in resp.json()["data"]["teams"] if t["manager_id"] == manager["id"]), None)
team = next(
(t for t in resp.json()["data"]["teams"] if t["manager_id"] == manager["id"]), None
)
assert team is not None
assert Decimal(str(team["team_base"])) == Decimal("0")
assert team["member_count"] == 0
@ -224,14 +224,7 @@ def test_回款统计_空桶恒在且分摊拆行金额只计一次(admin, clien
assert len(data["buckets"]) == 8
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 天)
bucket = next(b for b in data["buckets"] if b["code"] == "61-90")
@ -300,15 +293,11 @@ def test_导出_excel含签字栏且入操作日志(admin, client: TestClient):
factory = get_session_factory()
with factory() as session:
log = (
session.execute(
select(OperationLog)
.where(OperationLog.module == "报表统计", OperationLog.action == "导出")
.order_by(OperationLog.id.desc())
)
.scalars()
.first()
)
log = session.execute(
select(OperationLog)
.where(OperationLog.module == "报表统计", OperationLog.action == "导出")
.order_by(OperationLog.id.desc())
).scalars().first()
assert log is not None
@ -400,21 +389,19 @@ def _latest_export_log():
factory = get_session_factory()
with factory() as session:
return (
session.execute(
select(OperationLog)
.where(OperationLog.module == "报表统计", OperationLog.action == "导出")
.order_by(OperationLog.id.desc())
)
.scalars()
.first()
)
return session.execute(
select(OperationLog)
.where(OperationLog.module == "报表统计", OperationLog.action == "导出")
.order_by(OperationLog.id.desc())
).scalars().first()
def test_部门汇总_无台账经理挂上级不报500(admin, client: TestClient):
"""经理 A 无台账且上级是经理 B:不能因 member_map 直接索引而 KeyError。"""
manager_b = _person(client, admin["headers"], role="sales_manager")
manager_a = _person(client, admin["headers"], role="sales_manager", manager_id=manager_b["id"])
manager_a = _person(
client, admin["headers"], role="sales_manager", manager_id=manager_b["id"]
)
resp = client.get(
f"{_REPORTS}/department-summary",
@ -480,7 +467,9 @@ def test_部门汇总_经理个人户计入时基数等于成员合计(admin, cl
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
team = next(t for t in resp.json()["data"]["teams"] if t["manager_id"] == manager["id"])
team = next(
t for t in resp.json()["data"]["teams"] if t["manager_id"] == manager["id"]
)
assert Decimal(str(team["team_base"])) == Decimal("100.00")
assert sum(
(Decimal(str(m["performance_sum"])) for m in team["members"]), Decimal("0")
@ -527,7 +516,9 @@ def test_部门汇总_按经理筛选不误判其他团队下属(admin, client:
assert [t["manager_id"] for t in data["teams"]] == [manager_1["id"]]
assert data["unassigned"] == []
assert all(
m["personnel_id"] != salesperson_2["id"] for team in data["teams"] for m in team["members"]
m["personnel_id"] != salesperson_2["id"]
for team in data["teams"]
for m in team["members"]
)
assert Decimal(str(data["reconciliation"]["other_team_base"])) > 0
@ -566,7 +557,9 @@ def test_部门汇总_对账恒等式(admin, client: TestClient):
)
def _two_manager_scene(client: TestClient, headers: dict) -> tuple[dict, dict, dict, dict, dict]:
def _two_manager_scene(
client: TestClient, headers: dict
) -> tuple[dict, dict, dict, dict, dict]:
"""两位经理各有下属与业务 + 一位零成员经理(无聚合行)。"""
manager_1 = _person(client, headers, role="sales_manager")
manager_2 = _person(client, headers, role="sales_manager")
@ -784,7 +777,9 @@ def test_导出取数异常_失败审计留痕(admin, client: TestClient, monkey
assert "导出个人绩效工资明细表" in (log.summary or "")
@pytest.mark.parametrize("path", ["/personal-detail", "/department-summary", "/payment-stats"])
@pytest.mark.parametrize(
"path", ["/personal-detail", "/department-summary", "/payment-stats"]
)
def test_非法月份_查询返回400(admin, client: TestClient, path: str):
"""非法月份必须是参数错误 400,不能落 500。"""
resp = client.get(
@ -795,7 +790,9 @@ def test_非法月份_查询返回400(admin, client: TestClient, path: str):
assert resp.status_code == 400, resp.text
@pytest.mark.parametrize("code", ["personal-detail", "department-summary", "payment-stats"])
@pytest.mark.parametrize(
"code", ["personal-detail", "department-summary", "payment-stats"]
)
def test_非法月份_导出返回400(admin, client: TestClient, code: str):
"""导出手工构造查询模型同样要转 400。"""
resp = client.get(

View File

@ -127,7 +127,8 @@ class TestExcel模板:
cell
for row in sheet.iter_rows()
for cell in row
if isinstance(cell.value, (int, float)) and Decimal(str(cell.value)) == Decimal("39.11")
if isinstance(cell.value, (int, float))
and Decimal(str(cell.value)) == Decimal("39.11")
]
assert amount_cells, "未找到金额数值单元格(Decimal 被转成了文本?)"
assert amount_cells[0].number_format == "#,##0.00"
@ -141,14 +142,16 @@ class TestExcel模板:
workbook = load_workbook(io.BytesIO(content))
cells = [cell for row in workbook.active.iter_rows() for cell in row]
values = [
Decimal(str(cell.value)) for cell in cells if isinstance(cell.value, (int, float))
Decimal(str(cell.value))
for cell in cells
if isinstance(cell.value, (int, float))
]
assert Decimal("15.50") in values
# 「合计」标签只写一次(写满每个文本列是回归缺陷)
assert sum(1 for cell in cells if cell.value == "合计") == 1
def test_文本公式注入被前置单引号(self):
evil = '=HYPERLINK("http://evil")'
evil = "=HYPERLINK(\"http://evil\")"
content = _build("personal-detail", [_personal_row(business_no=evil)])
workbook = load_workbook(io.BytesIO(content))
values = [

View File

@ -5,12 +5,7 @@
<title>衍水工资系统 · 视觉方向预览</title>
<style>
/* ============================================================
设计体系 v4(同步已交付模块):中性灰白 · 靛蓝点缀 · 微质感
v3 → v4 变更(2026-10-22):设计稿与已交付功能对齐
1) 侧栏菜单改为真实路由分组(5 组 + 首页 = 21 项),组可折叠
2) 首页工作台按真实四区块重画(总览 / 待办 / 预警 / 动态)
3) 新增 §09:月结、审批、报表、打印稿(v3 缺 M3/M4 页面)
4) token 与 frontend/src/assets/styles/index.css 对齐
设计体系 v3(质感版):中性灰白 · 靛蓝点缀 · 微质感
质感来源(而非装饰):
1) 表面微渐变 —— 1–2% 明度差,暗示受光方向
2) 半透明描边 —— 边界在不同底色上自然融合
@ -22,7 +17,7 @@
:root {
--ys-indigo: #2e4ba8;
--ys-indigo-hi: #3a58bc; /* 渐变亮端 */
--ys-indigo-deep: #223a85; /* 深靛蓝:与 index.css 的 --ys-indigo-deep 一致 */
--ys-indigo-deep: #243c8b;
--ys-indigo-wash: #edf1fa;
--ys-paper: #ffffff;
--ys-mist: #f4f5f8; /* 页面底 */
@ -245,7 +240,6 @@
.aside .logo { font-family: var(--ys-font-brand); font-size: 15px; font-weight: 500; letter-spacing: 0.3em; color: var(--ys-ink); padding: 0 10px; }
.aside .logo-sub { font-size: 11px; color: var(--ys-ink-3); letter-spacing: 0.06em; padding: 0 10px; margin-top: 4px; }
.menu-group { margin-top: 26px; }
.aside .menu-group + .menu-group { margin-top: 12px; }
.menu-label { font-size: 11px; letter-spacing: 0.1em; color: var(--ys-ink-3); padding: 0 10px; margin-bottom: 8px; }
.menu-item {
display: flex; align-items: center; gap: 10px; padding: 9px 10px; border-radius: var(--ys-radius-sm);
@ -338,7 +332,7 @@
.spec-table td.good { color: var(--ys-ink); }
/* ---------- 首页仪表盘(Bento Grid) ---------- */
.bento { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); grid-auto-rows: 102px; gap: 14px; }
.bento { display: grid; grid-template-columns: repeat(4, 1fr); grid-auto-rows: 102px; gap: 14px; }
.bento-card {
background: linear-gradient(180deg, #ffffff, #fcfcfe);
border: 1px solid var(--ys-line); border-radius: var(--ys-radius);
@ -359,22 +353,21 @@
.bento-card:nth-child(6) { animation-delay: 225ms; }
.bento-card:nth-child(7) { animation-delay: 270ms; }
.bento-card:nth-child(8) { animation-delay: 315ms; }
.bento-card:nth-child(9) { animation-delay: 360ms; }
.bento-card:nth-child(10) { animation-delay: 405ms; }
@keyframes rise { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } }
@media (prefers-reduced-motion: reduce) {
.bento-card, .login-card { animation: none; transition: none; }
}
.b-2x2 { grid-column: span 2; grid-row: span 2; }
.b-full { grid-column: span 4; }
.b-eyebrow { display: flex; align-items: center; gap: 8px; font-size: 12px; letter-spacing: 0.04em; color: var(--ys-ink-3); }
.b-eyebrow-gap { margin-top: 12px; }
.b-num { font-size: 26px; font-weight: 600; letter-spacing: -0.01em; font-variant-numeric: tabular-nums; line-height: 1.2; margin-top: 10px; }
.b-num small { font-size: 12px; font-weight: 400; color: var(--ys-ink-3); margin-left: 4px; }
.b-sub { font-size: 12px; color: var(--ys-ink-3); margin-top: 4px; }
.b-2x1 { grid-column: span 2; }
.b-eyebrow { font-size: 12px; font-weight: 500; letter-spacing: 0.04em; color: var(--ys-ink-2); }
.b-big { font-size: 30px; font-weight: 600; letter-spacing: -0.02em; font-variant-numeric: tabular-nums; line-height: 1.15; margin-top: auto; }
.b-big small { font-size: 13px; font-weight: 500; color: var(--ys-ink-3); margin-left: 4px; letter-spacing: 0; }
.b-num { font-size: 24px; font-weight: 600; letter-spacing: -0.01em; font-variant-numeric: tabular-nums; line-height: 1.2; margin-top: auto; }
.b-num small { font-size: 12px; font-weight: 400; color: var(--ys-ink-3); margin-left: 3px; }
.b-sub { font-size: 11px; color: var(--ys-ink-3); margin-top: 3px; }
.b-up { color: var(--ys-ok); font-weight: 500; }
/* 待办 / 预警列表:超出部分在卡内滚动,卡片高度不被列表撑开 */
.todo-list, .warning-list { max-height: 178px; margin-top: 8px; overflow-y: auto; }
.b-todo { display: flex; align-items: baseline; gap: 8px; font-size: 13px; color: var(--ys-ink-2); }
.b-todo .cnt { color: var(--ys-indigo); font-weight: 600; font-variant-numeric: tabular-nums; }
/* ---------- 登录页 ---------- */
.login-shot { border: 1px solid var(--ys-line); border-radius: 14px; overflow: hidden; box-shadow: 0 12px 40px rgba(28, 35, 51, 0.1); }
@ -417,122 +410,6 @@
}
.login-btn { width: 100%; height: 38px; margin-top: 6px; font-size: 14px; }
/* ---------- 菜单分组(路由 meta.group 派生的可折叠组) ---------- */
.menu-group-head {
display: flex; align-items: center; justify-content: space-between;
padding: 9px 10px; margin-bottom: 4px; border-radius: var(--ys-radius-sm);
font-size: 11px; letter-spacing: 0.1em; color: var(--ys-ink-3);
}
.menu-group.open .menu-group-head { color: var(--ys-indigo); background: rgba(255, 255, 255, 0.45); }
.menu-group-head .chev { width: 11px; height: 11px; flex: none; opacity: 0.55; }
.menu-group.open .menu-group-head .chev { opacity: 0.9; transform: rotate(90deg); }
/* ---------- 工作台卡片内的列表行 ---------- */
.b-count {
min-width: 20px; padding: 0 6px; border-radius: 9px; text-align: center;
font-size: 11px; line-height: 18px; color: var(--ys-indigo);
background: var(--ys-indigo-wash); font-variant-numeric: tabular-nums;
}
.b-row { display: flex; align-items: baseline; gap: 8px; font-size: 12.5px; color: var(--ys-ink-2); }
.b-row .t { color: var(--ys-ink); font-weight: 500; }
.b-row .m { margin-left: auto; color: var(--ys-ink-3); font-size: 11.5px; font-variant-numeric: tabular-nums; white-space: nowrap; }
.b-tag {
display: inline-block; flex: none; padding: 1px 7px; border-radius: 4px;
font-size: 11px; line-height: 1.6; white-space: nowrap;
}
.b-tag-warn { color: var(--ys-warn); background: var(--ys-warn-wash-1); }
.b-tag-danger { color: var(--ys-danger); background: var(--ys-danger-wash); }
.b-tag-info { color: var(--ys-indigo); background: var(--ys-indigo-wash); }
.b-tag-ok { color: var(--ys-ok); background: var(--ys-ok-wash-1); }
.b-empty { font-size: 12px; color: var(--ys-ink-3); margin-top: 6px; }
/* 最近动态:时间 / 模块·动作 / 摘要 / 操作人 —— 四段各自成块,不并排挤在一行 */
.b-act {
display: flex; align-items: center; gap: 10px;
font-size: 12px; color: var(--ys-ink-2);
padding: 5px 0; border-bottom: 1px dashed var(--ys-line-soft);
}
.b-act:last-child { border-bottom: 0; }
.b-act .tm { width: 108px; flex: none; color: var(--ys-ink-3); font-size: 11.5px; font-variant-numeric: tabular-nums; }
.b-act .sum { flex: 1; color: var(--ys-ink); }
.b-act .us { flex: none; width: 56px; text-align: right; color: var(--ys-ink-3); font-size: 11.5px; }
/* 常用操作:靛蓝淡底按钮,悬停才显边框 —— 一排多个入口不靠实心按钮抢注意力 */
.b-links { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 10px; }
.b-op {
padding: 6px 14px; border-radius: 6px; font-size: 13px; font-family: inherit;
color: var(--ys-indigo); background: var(--ys-indigo-wash);
border: 1px solid transparent; cursor: pointer;
transition: all 150ms var(--ys-ease);
}
.b-op:hover { border-color: var(--ys-indigo); }
/* 图表容器:与实现同高(150px) */
.chart { height: 150px; margin-top: 6px; }
/* ---------- 状态标签(月结 / 审批) ---------- */
.pill-lock { color: var(--ys-indigo); background: linear-gradient(180deg, #eef2fc, #e5eaf8); }
.pill-reject { color: var(--ys-danger); background: linear-gradient(180deg, #fbeded, #f6e3e3); }
.pill-draft { color: var(--ys-ink-2); background: linear-gradient(180deg, #f4f5f8, #eff1f5); }
.pill-wait { color: var(--ys-warn); background: linear-gradient(180deg, var(--ys-warn-wash-1), var(--ys-warn-wash-2)); }
/* ---------- §09 内容区画布(沿用 §04 的顶栏与内容底,省略侧栏) ---------- */
.canvas {
border: 1px solid var(--ys-line); border-radius: 14px; overflow: hidden;
background: var(--ys-mist); box-shadow: 0 12px 40px rgba(28, 35, 51, 0.1);
}
.canvas .topbar { height: 46px; }
.split { display: grid; grid-template-columns: 1fr 296px; gap: 14px; align-items: start; }
/* 详情面板:实际为右侧抽屉(遮罩 + 面板),此处并列展示便于看清内容 */
.panel {
background: linear-gradient(180deg, #ffffff, #fcfcfe);
border: 1px solid var(--ys-line); border-radius: var(--ys-radius);
box-shadow: var(--ys-shadow-card); padding: 16px 18px;
}
.panel-title { font-size: 13px; font-weight: 600; display: flex; align-items: center; justify-content: space-between; }
.panel-sub { font-size: 11px; color: var(--ys-ink-3); margin-top: 2px; }
.kv { display: grid; grid-template-columns: 72px 1fr; gap: 6px 10px; margin-top: 12px; font-size: 12.5px; }
.kv dt { color: var(--ys-ink-3); }
.kv dd { color: var(--ys-ink); }
.kv dd .num { font-variant-numeric: tabular-nums; }
table.grid.dense th { padding: 7px 12px; }
table.grid.dense td { padding: 7px 12px; font-size: 12.5px; }
.cell-num { text-align: right; font-variant-numeric: tabular-nums; }
.cell-strong { font-weight: 600; }
/* 状态机条:月结的生命周期 */
.flow { display: flex; align-items: center; gap: 0; margin: 4px 0 2px; flex-wrap: wrap; }
.flow-step {
display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--ys-ink-2);
padding: 4px 10px 4px 12px; background: linear-gradient(180deg, #ffffff, #fbfcfe);
border: 1px solid var(--ys-line); position: relative; white-space: nowrap;
}
.flow-step:first-child { border-radius: var(--ys-radius-sm) 0 0 var(--ys-radius-sm); }
.flow-step:last-child { border-radius: 0 var(--ys-radius-sm) var(--ys-radius-sm) 0; }
.flow-step + .flow-step { border-left: 0; }
.flow-step .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--ys-ink-3); }
/* 圆点按「阶段」着色,与选中与否无关 —— 未选中时也能看出四段各自代表什么;
颜色之外每段仍有文字,不靠颜色单独表意 */
.flow-step.tone-wait .dot { background: var(--ys-warn); }
.flow-step.tone-lock .dot { background: var(--ys-indigo); }
.flow-step.tone-reject .dot { background: var(--ys-danger); }
.flow-step.cur { color: var(--ys-indigo); font-weight: 500; background: var(--ys-indigo-wash); border-color: rgba(46, 75, 168, 0.3); }
.flow-step.cur .dot { background: var(--ys-indigo); }
/* 流程条右侧挂一行操作提示:没有提示时,四段读起来像四颗孤立的小按钮 */
.flow-hint { margin-left: 10px; font-size: 12px; color: var(--ys-ink-3); }
/* 打印稿(A4):独立顶层路由,浏览器打印只出报表本身 */
.sheet {
width: 460px; background: #fff; border: 1px solid var(--ys-line);
box-shadow: var(--ys-shadow-card); padding: 26px 28px 24px;
}
.sheet h4 { font-size: 15px; font-weight: 600; text-align: center; }
.sheet .sheet-meta { font-size: 11px; color: var(--ys-ink-3); text-align: center; margin-top: 4px; }
.sheet .block-title { font-size: 12px; font-weight: 600; margin: 14px 0 6px; }
.sheet table { width: 100%; border-collapse: collapse; font-size: 11px; font-variant-numeric: tabular-nums; }
.sheet th, .sheet td { border: 1px solid #d9dde5; padding: 4px 7px; }
.sheet th { background: #f6f7f9; font-weight: 500; color: var(--ys-ink-2); text-align: left; }
.sheet td.num { text-align: right; }
.sheet .sign-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; margin-top: 22px; font-size: 11px; color: var(--ys-ink-3); }
.sheet .sign-row div { border-top: 1px solid var(--ys-line); padding-top: 5px; }
/* 方案 B:居中卡片 */
.login-center {
position: relative; overflow: hidden;
@ -589,11 +466,6 @@
<div class="brand">衍水集团</div>
<h1>营销人员工资管理系统 · 视觉方向</h1>
<div class="sub">企业级扁平 · 轻质感 · Bento 布局 · 品牌色点缀 · 微动效 · 精致文案</div>
<div class="sub" style="margin-top:6px">
<b style="color:var(--ys-ink);font-weight:500">v4(2026-10-22 同步版)</b>
—— 本稿与已交付功能对齐:菜单按真实路由分组、工作台按真实四区块重画,并补齐月结 / 审批 / 报表 / 打印稿。
视觉语言沿用 v3 质感版,未改色板、未改字体、未改组件基调。
</div>
<div class="waterline"></div>
<div class="tags">
<span class="tag">表面微渐变(受光)</span>
@ -603,8 +475,6 @@
<span class="tag">2% 噪点</span>
<span class="tag">等宽数字</span>
<span class="tag">120–180ms 轻动效</span>
<span class="tag">21 项菜单 · 5 组</span>
<span class="tag">工作台四区块</span>
</div>
</header>
@ -653,31 +523,6 @@
<div class="use">成功/警示/危险,浅底微渐变 + 深字</div></div>
</div>
</div>
<div class="comp-card" style="margin-top:16px">
<h3>落地对照 · 设计值 ↔ 代码实现(2026-10-22 核对)</h3>
<table class="spec-table">
<tr><td>靛蓝</td><td>#2E4BA8</td><td class="num">index.css:<code>--ys-indigo</code> / <code>--el-color-primary</code></td><td class="num">已落地</td></tr>
<tr><td>靛蓝亮端</td><td>#3A58BC</td><td class="num">仅设计稿(按钮微渐变上端)</td><td class="num">设计值</td></tr>
<tr><td>深靛蓝</td><td>#223A85</td><td class="num">index.css:<code>--ys-indigo-deep</code>(EP dark-2 为 #253C86)</td><td class="num">已落地</td></tr>
<tr><td>EP 色阶</td><td>light-3…9 / dark-2</td><td class="num">index.css:<code>--el-color-primary-light-3/5/7/8/9</code>、<code>--el-color-primary-dark-2</code></td><td class="num">已落地</td></tr>
<tr><td>墨 / 次墨 / 弱墨</td><td>#1C2333 · #5B6577 · #9AA3B2</td><td class="num">index.css:<code>--ys-ink</code> / <code>--ys-ink-2</code> / <code>--ys-ink-3</code></td><td class="num">已落地</td></tr>
<tr><td>界线</td><td>rgba(28,35,51,.09)</td><td class="num">index.css:<code>--ys-line</code></td><td class="num">已落地</td></tr>
<tr><td>纸白 / 雾灰</td><td>#FFFFFF→#FCFCFE · #F4F5F8</td><td class="num">index.css:<code>--ys-paper</code> / <code>--ys-mist</code></td><td class="num">已落地</td></tr>
<tr><td>语义色</td><td>#1E9E6A · #C98A1B · #C94F4F</td><td class="num">index.css:<code>--el-color-success/warning/danger/error</code> 全阶覆盖</td><td class="num">已落地</td></tr>
<tr><td>确认框琥珀</td><td>#B7791F(底 #FBF3E4)</td><td class="num">index.css:<code>.ys-confirm-box</code> 图标与底色</td><td class="num">已落地</td></tr>
<tr><td>菜单图标</td><td>16px 线性 · 静止 0.7</td><td class="num"><code>menuIcons.ts</code> 注册表(Element Plus 图标库,具名导入可摇树)</td><td class="num">已落地</td></tr>
</table>
<div class="note">
<b>语义三色已按本稿规范值落地</b>(2026-10-22):在 <code>index.css</code> 的 <code>:root</code> 覆盖
<code>--el-color-success/warning/danger/error</code> 及其 light-3/5/7/8/9、dark-2 与 <code>-rgb</code> 变体,
色阶同样按 Element Plus「主色与白色混合」公式预计算——<code>-rgb</code> 一并覆盖,否则半透明底色仍是旧的黄绿色。
<br>
<b>菜单图标</b>取自 Element Plus 图标库而非本稿的手绘线性稿:同一套语义 key 注册在
<code>components/layout/menuIcons.ts</code>,路由只写 <code>meta.icon</code>;视觉口径不变
(16px、静止 0.7 / 激活 1、右间距 9px),并统一取<b>线性</b>变体、禁用 <code>*-filled</code>——
同一层级混用实心与线性图标会让菜单不像一套语言。
</div>
</div>
</section>
<!-- ============ 字体与数字 ============ -->
@ -777,11 +622,8 @@
首页
</div>
</div>
<div class="menu-group open">
<div class="menu-group-head">
<span>系统管理</span>
<svg class="chev" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M6 3.5 10.5 8 6 12.5"/></svg>
</div>
<div class="menu-group">
<div class="menu-label">系统管理</div>
<div class="menu-item">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><circle cx="8" cy="5.5" r="2.5"/><path d="M3 13.5c0-2.5 2.2-4 5-4s5 1.5 5 4"/></svg>
用户管理
@ -798,41 +640,9 @@
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M3 5h10M3 11h10"/><circle cx="6" cy="5" r="1.6"/><circle cx="10" cy="11" r="1.6"/></svg>
计提参数
</div>
<div class="menu-item">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M4 13.5V3.5h8v10M3 13.5h10M6.5 6h3M6.5 9h3"/></svg>
公司信息
</div>
<div class="menu-item">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3.5" width="10" height="9" rx="1.5"/><path d="M5.6 8.2 7.1 9.7l3.3-3.4"/></svg>
审批管理
</div>
<div class="menu-item">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M4 4.5h8M4 8h8M4 11.5h5"/></svg>
日志查询
</div>
</div>
<div class="menu-group">
<div class="menu-group-head">
<span>基础数据</span>
<svg class="chev" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M6 3.5 10.5 8 6 12.5"/></svg>
</div>
</div>
<div class="menu-group">
<div class="menu-group-head">
<span>业务数据</span>
<svg class="chev" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M6 3.5 10.5 8 6 12.5"/></svg>
</div>
</div>
<div class="menu-group">
<div class="menu-group-head">
<span>绩效工资</span>
<svg class="chev" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M6 3.5 10.5 8 6 12.5"/></svg>
</div>
</div>
<div class="menu-group">
<div class="menu-group-head">
<span>报表统计</span>
<svg class="chev" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M6 3.5 10.5 8 6 12.5"/></svg>
操作日志
</div>
</div>
<div class="aside-foot">
@ -912,25 +722,6 @@
质感的三层材料:<b>侧栏</b>灰渐变(沉)→ <b>内容底</b>雾灰 + 顶部受光 → <b>卡片</b>纸白微渐变 + 半透明描边 + 分层阴影。
当前项白底浮起,白色被灰托住,不再扎眼。
</div>
<div class="comp-card" style="margin-top:16px">
<h3>菜单全览(按真实路由分组 · 2026-10-22 核对)</h3>
<table class="spec-table">
<tr><td>首页</td><td>首页(工作台:总览 / 待办 / 预警 / 动态)</td><td class="num">顶层项 · 管理员 / 财务 / 领导</td></tr>
<tr><td>系统管理</td><td>用户管理、角色管理、岗位角色、计提参数、公司信息、审批管理、日志查询</td><td class="num">可折叠组 · 当前组自动展开</td></tr>
<tr><td>基础数据</td><td>人员管理、产品管理、客户管理</td><td class="num">可折叠组 · 登录即可见</td></tr>
<tr><td>业务数据</td><td>业务管理、发货管理、回款管理、客户评级计算器</td><td class="num">可折叠组 · 登录即可见</td></tr>
<tr><td>绩效工资</td><td>绩效台账、月度结算</td><td class="num">可折叠组 · 月结写权限限管理员与营销后勤</td></tr>
<tr><td>报表统计</td><td>个人绩效明细、部门绩效汇总、业务统计、回款统计</td><td class="num">可折叠组 · 限管理员 / 财务 / 领导</td></tr>
<tr><td>不进菜单</td><td>报表打印视图(独立顶层路由)</td><td class="num">浏览器打印只出报表本身</td></tr>
</table>
<div class="note">
侧栏是<b>单列树</b>:组标题可折叠,当前页所在组默认展开,当前项白底浮起 + 左侧 2px 靛蓝竖条。
菜单图标(16px、静止 0.7 / 激活 1、组标题不加图标)已于 2026-10-22 落地:
路由 <code>meta.icon</code> 只写语义 key,图标注册在 <code>menuIcons.ts</code>。
本稿画的是手绘线性稿,实际取自 Element Plus 图标库的线性变体——<b>形状以图标库为准</b>,
尺寸、透明度与间距口径保持一致。
</div>
</div>
</section>
<!-- ============ 首页仪表盘 ============ -->
@ -949,33 +740,26 @@
</div>
</div>
<div class="menu-group">
<div class="menu-group-head">
<span>系统管理</span>
<svg class="chev" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M6 3.5 10.5 8 6 12.5"/></svg>
<div class="menu-label">系统管理</div>
<div class="menu-item">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><circle cx="8" cy="5.5" r="2.5"/><path d="M3 13.5c0-2.5 2.2-4 5-4s5 1.5 5 4"/></svg>
用户管理
</div>
</div>
<div class="menu-group">
<div class="menu-group-head">
<span>基础数据</span>
<svg class="chev" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M6 3.5 10.5 8 6 12.5"/></svg>
<div class="menu-item">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"><path d="M8 2 13 4v4c0 3-2 5-5 6-3-1-5-3-5-6V4Z"/></svg>
角色管理
</div>
</div>
<div class="menu-group">
<div class="menu-group-head">
<span>业务数据</span>
<svg class="chev" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M6 3.5 10.5 8 6 12.5"/></svg>
<div class="menu-item">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><rect x="2.5" y="3.5" width="11" height="9" rx="1.5"/><circle cx="6" cy="7.5" r="1.2"/><path d="M4.5 11c.3-1 1-1.5 1.5-1.5s1.2.5 1.5 1.5M9.5 6.5h2M9.5 9h2"/></svg>
岗位角色
</div>
</div>
<div class="menu-group">
<div class="menu-group-head">
<span>绩效工资</span>
<svg class="chev" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M6 3.5 10.5 8 6 12.5"/></svg>
<div class="menu-item">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M3 5h10M3 11h10"/><circle cx="6" cy="5" r="1.6"/><circle cx="10" cy="11" r="1.6"/></svg>
计提参数
</div>
</div>
<div class="menu-group">
<div class="menu-group-head">
<span>报表统计</span>
<svg class="chev" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M6 3.5 10.5 8 6 12.5"/></svg>
<div class="menu-item">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M4 4.5h8M4 8h8M4 11.5h5"/></svg>
操作日志
</div>
</div>
<div class="aside-foot">
@ -985,110 +769,78 @@
</aside>
<div class="main">
<div class="topbar">
<div class="crumb"><b>首页</b></div>
<div class="crumb"><b>仪表盘</b></div>
<div class="who"><span class="avatar">管</span><span>管理员</span><span class="logout">退出登录</span></div>
</div>
<div class="content">
<div class="page-head">
<div>
<h1>工作台</h1>
<div class="desc">经营绩效总览 · 待办与风险集中处置 · 最近动态</div>
<h1>仪表盘</h1>
<div class="desc">数据截至今日 06:00 · 12 笔回款待核算</div>
<div class="waterline"></div>
</div>
</div>
<div class="filter" style="margin-bottom:14px">
<div class="input" style="width:140px;display:flex;align-items:center;justify-content:space-between">2026-10 <span style="color:var(--ys-ink-3)">▾</span></div>
<button class="btn btn-primary">刷新</button>
<span class="spacer"></span>
<span style="font-size:12px;color:var(--ys-ink-3)">数据截至 2026-10-22 06:00 · 口径:本月已核算批次</span>
</div>
<div class="bento">
<!-- 经营绩效总览:KPI -->
<div class="bento-card">
<div class="b-eyebrow">本月绩效总额</div>
<div class="b-num">268,430.16<small>元</small></div>
</div>
<div class="bento-card">
<div class="b-eyebrow">本月应付绩效</div>
<div class="b-num">241,876.40<small>元</small></div>
</div>
<div class="bento-card">
<div class="b-eyebrow">核算人数</div>
<div class="b-num">36<small>人</small></div>
</div>
<div class="bento-card">
<div class="b-eyebrow">营销团队</div>
<div class="b-num">4<small>个</small></div>
<div class="b-sub">月结:草稿</div>
</div>
<!-- 经营绩效总览:图表 -->
<div class="bento-card b-2x2">
<div class="b-eyebrow">近 6 个月绩效</div>
<div id="chartPerf" class="chart"></div>
<div class="b-eyebrow">本月绩效总额</div>
<div class="b-big" style="color:var(--ys-indigo)">268,430.16<small>元</small></div>
<div class="b-sub">较上月 <span class="b-up">+12.4%</span> · 已核销 86 笔</div>
<div id="chartPerf" style="width:100%;height:40px;margin-top:12px"></div>
</div>
<div class="bento-card b-2x2">
<div class="b-eyebrow">近 6 个月回款</div>
<div id="chartRev" class="chart"></div>
<div class="b-sub" style="margin-top:2px">单位:万元</div>
<div id="chartRev" style="width:100%;height:78px;margin-top:auto"></div>
</div>
<!-- 待办事项 -->
<div class="bento-card b-2x2">
<div class="b-eyebrow">待办事项 <span class="b-count">2</span></div>
<div class="todo-list">
<div class="b-row"><span class="t">月度结算 · YS202610</span><span class="m">张岚 · 10-21 14:32</span></div>
<div class="b-row"><span class="t">二次分配方案 · SP20261003</span><span class="m">王浩 · 10-21 11:08</span></div>
</div>
<div class="b-eyebrow b-eyebrow-gap">回款未指定批次 <span class="b-count">1</span></div>
<div class="todo-list" style="max-height:26px">
<div class="b-row"><span class="t">BS20261018 · 86,400.00</span><span class="m">10-21</span></div>
</div>
<div class="b-eyebrow b-eyebrow-gap">T+ 同步失败</div>
<div class="b-empty">同步模块未启用(待 T+ 对接)</div>
</div>
<!-- 风险预警 -->
<div class="bento-card b-2x2">
<div class="b-eyebrow">风险预警 <span class="b-count">2</span></div>
<div class="warning-list">
<div class="b-row"><span class="b-tag b-tag-danger">资金</span><span class="t">回款逾期 128 天</span><span class="m">BS20260703</span></div>
<div class="b-row"><span class="b-tag b-tag-warn">时效</span><span class="t">审批超时 5 天</span><span class="m">YS202609</span></div>
</div>
<div class="b-eyebrow b-eyebrow-gap">同步异常预警</div>
<div class="b-empty">同步模块未启用(待 T+ 对接)</div>
</div>
<!-- 最近动态 -->
<div class="bento-card b-full">
<div class="b-eyebrow">最近动态
<span style="margin-left:auto;font-size:11px;color:var(--ys-ink-3);border:1px solid var(--ys-line);border-radius:var(--ys-radius-sm);padding:1px 8px;background:#fff">全部模块 ▾</span>
</div>
<div class="todo-list" style="max-height:40px;margin-top:4px">
<div class="b-act"><span class="tm">10-22 09:12</span><span class="b-tag b-tag-info">核算 · 月结</span><span class="sum">生成 2026-10 月结草稿,绩效合计 268,430.16 元</span><span class="us">张岚</span></div>
<div class="b-act"><span class="tm">10-21 16:40</span><span class="b-tag b-tag-info">回款 · 新增</span><span class="sum">录入回款 86,400.00 元(业务 BS20261018)</span><span class="us">王浩</span></div>
<div class="bento-card b-2x1">
<div class="b-eyebrow">待办</div>
<div style="margin-top:auto;display:flex;flex-direction:column;gap:4px">
<div class="b-todo"><span class="cnt">12 笔</span>回款待核算</div>
<div class="b-todo"><span class="cnt">3 笔</span>业务归属待确认</div>
</div>
</div>
<!-- 常用操作 -->
<div class="bento-card b-full">
<div class="bento-card">
<div class="b-head"><div class="b-eyebrow">营销人员</div>
<svg class="b-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><circle cx="8" cy="5.5" r="2.5"/><path d="M3 13.5c0-2.5 2.2-4 5-4s5 1.5 5 4"/></svg>
</div>
<div class="b-num">36<small>人</small></div>
</div>
<div class="bento-card">
<div class="b-head"><div class="b-eyebrow">岗位角色</div>
<svg class="b-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><rect x="2.5" y="3.5" width="11" height="9" rx="1.5"/><circle cx="6" cy="7.5" r="1.2"/><path d="M4.5 11c.3-1 1-1.5 1.5-1.5s1.2.5 1.5 1.5M9.5 6.5h2M9.5 9h2"/></svg>
</div>
<div class="b-num">3<small>个</small></div>
</div>
<div class="bento-card b-2x1">
<div class="b-eyebrow">常用操作</div>
<div class="b-links">
<button class="b-op">录入回款</button>
<button class="b-op">绩效台账</button>
<button class="b-op">月度结算</button>
<button class="b-op">报表统计</button>
<div class="row" style="margin-top:auto">
<button class="btn btn-primary" style="height:30px;font-size:12px;padding:0 13px">录入回款</button>
<button class="btn btn-default" style="height:30px;font-size:12px;padding:0 13px">计提参数</button>
<button class="btn btn-default" style="height:30px;font-size:12px;padding:0 13px">岗位角色</button>
</div>
</div>
<div class="bento-card">
<div class="b-head"><div class="b-eyebrow">计提参数</div>
<svg class="b-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M3 5h10M3 11h10"/><circle cx="6" cy="5" r="1.6"/><circle cx="10" cy="11" r="1.6"/></svg>
</div>
<div class="b-num">16<small>项</small></div>
<div class="b-sub">版本 P20260101-00</div>
</div>
<div class="bento-card">
<div class="b-head"><div class="b-eyebrow">T+ 同步</div>
<svg class="b-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M13 8a5 5 0 1 1-1.5-3.5"/><path d="M13 2.5V5h-2.5"/></svg>
</div>
<div class="b-num" style="color:var(--ys-ok)">正常</div>
<div class="b-sub">每周一 06:00</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="note">
<b>Bento Grid(4 列 · 102px 行高)</b>:KPI 四格 1×1 起手 → 绩效 / 回款趋势各占 2×2 →
待办与预警并排 2×2 → 最近动态与常用操作整行铺满(<code>b-full</code>)。
卡片入场 <b>45ms 交错上浮(320ms)</b>,悬停抬升 2px、影加深。
待办与预警的列表<b>在卡内滚动</b>(上限 178px)——条目再多也不撑开网格,行高恒定。
<b>Bento Grid</b>:大小不一的卡片按重要性排布——绩效总额与回款趋势占 2×2 主角位,数字型指标 1×1 紧凑排列。
卡片入场为 <b>45ms 交错上浮(320ms)</b>,悬停抬升 2px、阴影加深——打开预览即可看到微动效。
</div>
</section>
@ -1180,340 +932,20 @@
</div>
</section>
<!-- ============ 已交付模块页面(M3/M4) ============ -->
<section class="section" id="sec-m34">
<h2>09 · 已交付模块页面(M3 / M4)</h2>
<div class="note" style="margin-top:-6px;margin-bottom:18px">
v3 稿只画到「系统管理 + 首页 + 登录页」;此后<b>绩效核算、月结、审批、报表、工作台</b>均已交付,
本节按实现补齐。以下页面沿用 §04 的同一侧栏与顶栏,<b>只画内容区</b>。
</div>
<!-- 09-1 月度结算 -->
<div class="app-shot">
<div class="canvas">
<div class="topbar">
<div class="crumb">绩效工资 <span>/</span> <b>月度结算</b></div>
<div class="who"><span class="avatar">管</span><span>管理员</span><span class="logout">退出登录</span></div>
</div>
<div class="content">
<div class="page-head">
<div>
<h1>月度结算</h1>
<div class="desc">按月生成绩效结算批次;送审后锁定,人数与金额不再随源数据变化</div>
<div class="waterline"></div>
</div>
<button class="btn btn-primary">生成月结</button>
</div>
<div class="comp-card" style="margin-bottom:14px;padding:16px 20px">
<div class="b-eyebrow" style="margin-bottom:12px">月结生命周期</div>
<div class="flow">
<div class="flow-step"><span class="dot"></span>草稿</div>
<div class="flow-step cur tone-wait"><span class="dot"></span>待审批</div>
<div class="flow-step tone-lock"><span class="dot"></span>已审定(锁定)</div>
<div class="flow-step tone-reject"><span class="dot"></span>已驳回 → 回到草稿</div>
<span class="flow-hint">点状态只看该阶段,再点一次取消</span>
</div>
</div>
<div class="filter">
<input class="input" placeholder="状态:全部" style="width:130px" />
<button class="btn btn-primary">查询</button>
<button class="btn btn-default">重置</button>
<span class="spacer"></span>
</div>
<div class="card">
<table class="grid">
<thead>
<tr><th style="width:110px">结算月份</th><th style="width:150px">状态</th><th style="width:115px">绩效合计</th><th style="width:115px">应付合计</th><th style="width:70px">人数</th><th style="width:140px">审批单号</th><th style="width:140px">提交时间</th><th style="width:130px">操作</th></tr>
</thead>
<tbody>
<tr>
<td>2026-10</td>
<td><span class="pill pill-wait">待审批</span></td>
<td class="cell-num">268,430.16</td>
<td class="cell-num">241,876.40</td>
<td class="cell-num">36</td>
<td class="cell-code">YS202610</td>
<td class="cell-muted">2026-10-21 14:32</td>
<td class="cell-actions"><a>详情</a><a class="muted">撤回</a></td>
</tr>
<tr>
<td>2026-09</td>
<td><span class="pill pill-lock">已审定(锁定)</span></td>
<td class="cell-num">251,904.72</td>
<td class="cell-num">226,338.10</td>
<td class="cell-num">35</td>
<td class="cell-code">YS202609</td>
<td class="cell-muted">2026-09-28 10:12</td>
<td class="cell-actions"><a>详情</a><a class="muted" title="已锁定的月结不可修改">调整单</a></td>
</tr>
<tr>
<td>2026-08</td>
<td><span class="pill pill-reject">已驳回</span></td>
<td class="cell-num">232,117.55</td>
<td class="cell-num">208,640.02</td>
<td class="cell-num">34</td>
<td class="cell-code">YS202608</td>
<td class="cell-muted">2026-08-29 16:40</td>
<td class="cell-actions"><a>详情</a><a>重新送审</a></td>
</tr>
</tbody>
</table>
<div class="card-foot">
<div class="pager">
<span class="pg">‹</span><span class="pg cur">1</span><span class="pg">›</span>
<span style="margin-left:10px">共 10 条</span>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="note">
月结是<b>锁点</b>:审定后绩效与应付合计不可改,只能靠调整单纠偏,状态条与列表同步体现这件事。
状态标签分四档——草稿(中性灰)、待审批(琥珀)、已审定(靛蓝)、已驳回(砖红),
颜色之外还有文字,不靠颜色单独表意。
</div>
<!-- 09-2 审批管理 -->
<div class="app-shot" style="margin-top:26px">
<div class="canvas">
<div class="topbar">
<div class="crumb">系统管理 <span>/</span> <b>审批管理</b></div>
<div class="who"><span class="avatar">财</span><span>财务</span><span class="logout">退出登录</span></div>
</div>
<div class="content">
<div class="page-head">
<div>
<h1>审批管理</h1>
<div class="desc">月度结算、二次分配方案、调整单的复核与审定;送审内容以快照留存</div>
<div class="waterline"></div>
</div>
</div>
<div class="filter">
<input class="input" placeholder="单号或业务编号" />
<input class="input" placeholder="状态:全部" style="width:130px" />
<input class="input" placeholder="类型:全部" style="width:130px" />
<button class="btn btn-primary">查询</button>
<button class="btn btn-default">重置</button>
<span class="spacer"></span>
</div>
<div class="split">
<div class="card">
<table class="grid">
<thead>
<tr><th style="width:140px">审批单号</th><th style="width:120px">类型</th><th style="width:60px">版本</th><th>引用单号</th><th style="width:110px">状态</th><th style="width:90px">提交人</th><th style="width:140px">提交时间</th></tr>
</thead>
<tbody>
<tr>
<td class="cell-code">YS202610</td>
<td>月度结算</td>
<td class="cell-num">v2</td>
<td class="cell-code">2026-10</td>
<td><span class="pill pill-wait">待财务复核</span></td>
<td>张岚</td>
<td class="cell-muted">10-21 14:32</td>
</tr>
<tr>
<td class="cell-code">SP20261003</td>
<td>二次分配方案</td>
<td class="cell-num">v1</td>
<td class="cell-code">BS20261018</td>
<td><span class="pill pill-wait">待领导审核</span></td>
<td>王浩</td>
<td class="cell-muted">10-21 11:08</td>
</tr>
<tr>
<td class="cell-code">AD20261007</td>
<td>调整单</td>
<td class="cell-num">v3</td>
<td class="cell-code">BS20261009</td>
<td><span class="pill pill-ok">已通过</span></td>
<td>李敏</td>
<td class="cell-muted">10-20 17:45</td>
</tr>
<tr>
<td class="cell-code">YS202609</td>
<td>月度结算</td>
<td class="cell-num">v1</td>
<td class="cell-code">2026-09</td>
<td><span class="pill pill-reject">已驳回</span></td>
<td>张岚</td>
<td class="cell-muted">09-28 10:12</td>
</tr>
</tbody>
</table>
</div>
<div class="panel">
<div class="panel-title">审批详情 <span class="b-tag b-tag-warn">待财务复核</span></div>
<div class="panel-sub">送审内容为快照,与源单据当前值无关</div>
<dl class="kv">
<dt>审批单号</dt><dd class="num">YS202610</dd>
<dt>类型</dt><dd>月度结算</dd>
<dt>版本</dt><dd class="num">v2</dd>
<dt>提交人</dt><dd>张岚 · 2026-10-21 14:32</dd>
<dt>结算月份</dt><dd class="num">2026-10</dd>
<dt>绩效合计</dt><dd class="num">268,430.16 元</dd>
<dt>应付合计</dt><dd class="num">241,876.40 元</dd>
<dt>核算人数</dt><dd class="num">36 人</dd>
</dl>
<div class="row" style="margin-top:16px">
<button class="btn btn-primary" style="flex:1">通过</button>
<button class="btn btn-danger" style="flex:1">驳回</button>
</div>
<div class="note" style="margin-top:12px;font-size:12px">
驳回必须写明原因——审批意见与送审快照一起留痕。
</div>
</div>
</div>
</div>
</div>
</div>
<div class="note">
审批详情是<b>抽屉</b>(此处与列表并列展示以便看清内容):右侧滑入,遮罩 + 面板。
列表与详情的关键字段——<b>版本号</b>与<b>状态</b>——必须同时可见,审批人才能判断自己看的是哪一版。
送审内容不变则不重复提单、不新增版本号。
</div>
<!-- 09-3 报表统计 -->
<div class="app-shot" style="margin-top:26px">
<div class="canvas">
<div class="topbar">
<div class="crumb">报表统计 <span>/</span> <b>个人绩效明细</b></div>
<div class="who"><span class="avatar">领</span><span>领导</span><span class="logout">退出登录</span></div>
</div>
<div class="content">
<div class="page-head">
<div>
<h1>个人绩效明细</h1>
<div class="desc">按月核对每一笔业务的绩效与应付金额;口径与月结一致</div>
<div class="waterline"></div>
</div>
</div>
<div class="filter">
<input class="input" placeholder="月份:2026-10" style="width:120px" />
<input class="input" placeholder="人员:全部" style="width:120px" />
<input class="input" placeholder="产品类别:全部" style="width:130px" />
<input class="input" placeholder="岗位:全部" style="width:110px" />
<button class="btn btn-primary">查询</button>
<button class="btn btn-default">重置</button>
<span class="spacer"></span>
<button class="btn btn-default">导出 Excel</button>
<button class="btn btn-default">打印</button>
</div>
<div class="card">
<table class="grid dense">
<thead>
<tr><th style="width:140px">合同编号</th><th style="width:92px">发货日</th><th style="width:92px">回款日</th><th style="width:72px">回款天数</th><th style="width:92px">产品类别</th><th style="width:92px">客户类型</th><th style="width:82px">岗位</th><th style="width:92px">计提比例</th><th style="width:82px">权重R</th><th style="width:104px">绩效</th><th style="width:104px">应付</th></tr>
</thead>
<tbody>
<tr>
<td class="cell-code">BS20261018</td><td>2026-10-08</td><td>2026-10-21</td>
<td class="cell-num">13</td><td>市政水利</td><td>市政单位</td><td>营销员</td>
<td class="cell-num">5‰</td><td class="cell-num">0.85</td>
<td class="cell-num cell-strong">4,320.00</td><td class="cell-num">3,928.00</td>
</tr>
<tr>
<td class="cell-code">BS20261021</td><td>2026-10-11</td><td>2026-10-22</td>
<td class="cell-num">11</td><td>轨道交通</td><td>建设单位</td><td>营销员</td>
<td class="cell-num">5‰</td><td class="cell-num">0.85</td>
<td class="cell-num cell-strong">2,100.00</td><td class="cell-num">1,910.00</td>
</tr>
<tr>
<td class="cell-code">BS20261009</td><td>2026-09-26</td><td>2026-10-09</td>
<td class="cell-num">13</td><td>市政水利</td><td>市政单位</td><td>营销经理</td>
<td class="cell-num">5‰</td><td class="cell-num">0.12</td>
<td class="cell-num cell-strong">1,860.00</td><td class="cell-num">1,690.00</td>
</tr>
<tr>
<td class="cell-code">BS20261012</td><td>2026-10-02</td><td>2026-10-15</td>
<td class="cell-num">13</td><td>混合业务</td><td>经销单位</td><td>信息员</td>
<td class="cell-num">1‰</td><td class="cell-num">1.00</td>
<td class="cell-num cell-strong">860.00</td><td class="cell-num">782.00</td>
</tr>
</tbody>
</table>
<div class="card-foot">
<div class="pager">
<span class="pg">‹</span><span class="pg cur">1</span><span class="pg">2</span><span class="pg">›</span>
<span style="margin-left:10px">共 128 条 · 每页 10 条</span>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="note">
报表是<b>密集表</b>:行高与字号各降半档(12.5px / 7px 内距),但金额仍用等宽数字右对齐、绩效列加粗——
密集不等于看不清。首列可展开查看该合同的分摊明细。
</div>
<!-- 09-4 打印视图 -->
<div class="app-shot" style="margin-top:26px">
<div class="canvas" style="background:#eceef2">
<div class="topbar">
<div class="crumb"><b>报表打印</b> <span style="color:var(--ys-ink-3)">/ 个人绩效工资明细表</span></div>
<div class="who"><button class="btn btn-primary" style="height:30px;font-size:12px">打印 / 另存为 PDF</button></div>
</div>
<div class="content" style="display:flex;justify-content:center;padding:28px 22px">
<div class="sheet">
<h4>个人绩效工资明细表</h4>
<div class="sheet-meta">统计月份:2026-10 | 打印时间:2026-10-22 09:41 | 模板版本 V1.2</div>
<div class="block-title">一、团队分成汇总</div>
<table>
<thead><tr><th>团队</th><th style="text-align:right">人数</th><th style="text-align:right">绩效合计</th><th style="text-align:right">应付合计</th></tr></thead>
<tbody>
<tr><td>市政一部</td><td class="num">12</td><td class="num">96,412.30</td><td class="num">87,735.99</td></tr>
<tr><td>市政二部</td><td class="num">9</td><td class="num">71,845.16</td><td class="num">65,379.60</td></tr>
<tr><td>轨道交通组</td><td class="num">7</td><td class="num">54,102.44</td><td class="num">49,233.22</td></tr>
</tbody>
</table>
<div class="block-title">二、按客户类型对比</div>
<table>
<thead><tr><th>客户类型</th><th style="text-align:right">笔数</th><th style="text-align:right">绩效合计</th><th style="text-align:right">占比</th></tr></thead>
<tbody>
<tr><td>市政单位</td><td class="num">46</td><td class="num">132,806.90</td><td class="num">49.5%</td></tr>
<tr><td>建设单位</td><td class="num">31</td><td class="num">82,904.55</td><td class="num">30.9%</td></tr>
<tr><td>经销单位</td><td class="num">18</td><td class="num">52,718.71</td><td class="num">19.6%</td></tr>
</tbody>
</table>
<div class="block-title">三、对账汇总</div>
<table>
<tbody>
<tr><td>台账绩效合计</td><td class="num">268,430.16 元</td></tr>
<tr><td>调整单影响</td><td class="num">−3,120.00 元</td></tr>
<tr><td>月结应付合计</td><td class="num">241,876.40 元</td></tr>
</tbody>
</table>
<div class="sign-row">
<div>制表:____________</div>
<div>复核:____________</div>
<div>审批:____________</div>
</div>
</div>
</div>
</div>
</div>
<div class="note">
打印视图是<b>独立顶层路由</b>,不挂侧栏与顶栏——浏览器打印只出报表本身,PDF 由打印对话框「另存为 PDF」导出。
打印稿是<b>纸面产物</b>,与屏幕稿不同源:白底、实线表格、章节编号、签字栏,不带任何阴影与圆角。
超出打印行数上限时只打前 N 行,并在稿面上明确提示,不静默截断。
</div>
</section>
</div>
<script src="echarts.min.js"></script>
<script>
/* 工作台图表:ECharts 定制主题(圆角柱 / 无网格线 / 浅灰轴标签)——实施时前端引入 echarts 依赖 */
/* 仪表盘图表:ECharts 定制主题(圆角柱 / 无网格线 / 浅灰轴标签)——实施时前端引入 echarts 依赖 */
const chartFont = "'Noto Sans SC','PingFang SC',sans-serif";
const rev = echarts.init(document.getElementById('chartRev'), null, { renderer: 'svg' });
rev.setOption({
grid: { left: 8, right: 12, top: 18, bottom: 4, containLabel: true },
grid: { left: 0, right: 0, top: 6, bottom: 20, containLabel: true },
xAxis: {
type: 'category', data: ['5月', '6月', '7月', '8月', '9月', '10月'],
axisLine: { lineStyle: { color: 'rgba(28,35,51,.15)' } }, axisTick: { show: false },
axisLine: { show: false }, axisTick: { show: false },
axisLabel: { color: '#9aa3b2', fontSize: 9, margin: 10, fontFamily: chartFont }
},
yAxis: { type: 'value', show: false, splitLine: { show: false } },
yAxis: { type: 'value', show: false },
series: [{
type: 'bar', barWidth: 22, data: [34, 42, 30, 48, 38, 56],
itemStyle: {
@ -1526,19 +958,15 @@
});
const perf = echarts.init(document.getElementById('chartPerf'), null, { renderer: 'svg' });
perf.setOption({
grid: { left: 8, right: 12, top: 18, bottom: 4, containLabel: true },
xAxis: {
type: 'category', data: ['5月', '6月', '7月', '8月', '9月', '10月'],
axisLine: { lineStyle: { color: 'rgba(28,35,51,.15)' } }, axisTick: { show: false },
axisLabel: { color: '#9aa3b2', fontSize: 9, margin: 10, fontFamily: chartFont }
},
yAxis: { type: 'value', show: false, splitLine: { show: false } },
grid: { left: 0, right: 0, top: 6, bottom: 2 },
xAxis: { type: 'category', show: false, boundaryGap: false, data: [1, 2, 3, 4, 5, 6, 7] },
yAxis: { type: 'value', show: false, min: 0 },
series: [{
type: 'line', data: [186, 204, 178, 232, 251, 268], smooth: 0.4, symbol: 'none',
lineStyle: { color: '#2e4ba8', width: 2 },
type: 'line', data: [8, 13, 10, 19, 23, 29, 33], smooth: 0.4, symbol: 'none',
lineStyle: { color: '#2e4ba8', width: 1.6 },
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: 'rgba(46,75,168,.22)' }, { offset: 1, color: 'rgba(46,75,168,0)' }
{ offset: 0, color: 'rgba(46,75,168,.10)' }, { offset: 1, color: 'rgba(46,75,168,0)' }
])
}
}]

Binary file not shown.

View File

@ -79,7 +79,7 @@ pnpm build # 类型检查 + 生产构建
## 视觉设计
**设计立意**:公司名「衍水」是水,而中文「流水账」字面就是**流水之账**——系统的全部工作正是记录回款怎么流、绩效怎么算。视觉方向由此而生:中性灰白的水面纸感 + 靛蓝点缀,安静、克制,长时间核数不累眼。完整规范见 `docs/design/视觉方向预览.html`(2026-10-12 起为 v3「质感版」;2026-10-22 升 v4「同步版」——设计稿与已交付的月结 / 审批 / 报表 / 工作台对齐,色板、字体与组件基调未变)。
**设计立意**:公司名「衍水」是水,而中文「流水账」字面就是**流水之账**——系统的全部工作正是记录回款怎么流、绩效怎么算。视觉方向由此而生:中性灰白的水面纸感 + 靛蓝点缀,安静、克制,长时间核数不累眼。完整规范见 `docs/design/视觉方向预览.html`(2026-10-12 起为 v3「质感版」)。
**配色**(靛蓝只作重点点缀——主按钮 / 链接 / 焦点 / 当前菜单,大面积用中性灰白)
@ -100,62 +100,17 @@ 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` 中保留映射,存量页面迁移完成后移除。
**语义三色**(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 行并在稿面明确提示,不静默截断。
**签名元素**:**渐隐靛蓝水线**——仪表盘等页面标题下的细线(56px,左实右虚)与登录页品牌名下的短线(40px),「衍水」之水的提纯,全站唯一的装饰性元素;登录页右上角另有涟漪同心圆与顶部靛蓝天光作背景(低透明度)。与早期深色面板 + 水纹层理同一意象,品牌连续而非推倒重来。
**构图**:登录页品牌内容(名称 / 标语 / 水线)**垂直居中聚成一块**,配一层柔和径向光晕作落点。
早期版本用 `space-between` 把品牌与标语顶到两端,中间空出大半屏,两处都成了孤岛——超宽屏会把这种疏离放大得更明显。**内容块要限宽**(`max-width`),否则宽屏下文字会被拉散。
### 写代码时的五条约定
### 写代码时的四条约定
- 覆盖 Element Plus 主题请改 **CSS 变量**(`--el-button-bg-color` 等),不要直接写 `background-color` —— 后者与 `.el-button--primary` 同权重,会受样式注入顺序影响而不稳定
- 中文可在任意字间断行,**关键文案的断行位置要显式控制**(如主标语拆成两个 `<span>`),交给浏览器自动换行会出现单字独占一行的难看结果
- 新页面需响应式到移动端、保留键盘焦点可见、并尊重 `prefers-reduced-motion`
- **确认框统一加 `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(如 以物抵顶)
## 验证码图片

View File

@ -11,7 +11,6 @@
"type-check": "vue-tsc --noEmit"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"@vue/devtools-api": "^8.1.5",
"axios": "^1.20.0",
"echarts": "^5.6.0",

View File

@ -8,9 +8,6 @@ importers:
.:
dependencies:
'@element-plus/icons-vue':
specifier: ^2.3.2
version: 2.3.2(vue@3.5.43(typescript@5.9.3))
'@vue/devtools-api':
specifier: ^8.1.5
version: 8.2.1

View File

@ -1,60 +1,24 @@
/*
* 全局样式:品牌 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 之后,
* 同权重选择器靠后者生效 —— 覆盖组件库样式必须与此保持一致。
*/
/* 全局样式:重置 + 品牌 token + 基础版式 */
:root {
/* ── 品牌色 ─────────────────────────────────────────────────────────── */
/* 靛蓝只作重点点缀(主按钮/链接/焦点/当前菜单),大面积用中性灰白。 */
/*
* 品牌色(2026-10-12 视觉方向:企业级扁平 · 轻质感 · 靛蓝点缀)。
* 靛蓝只作重点点缀(主按钮/链接/焦点/当前菜单),大面积用中性灰白。
* 设计预览与规范:docs/design/视觉方向预览.html
*/
--ys-indigo: #2e4ba8;
--ys-indigo-hi: #3a58bc; /* 主按钮渐变亮端 */
--ys-indigo-deep: #223a85;
--ys-indigo-wash: #edf1fa;
--ys-paper: #ffffff;
--ys-mist: #f4f5f8;
--ys-aside-1: #f2f4f8;
--ys-aside-2: #ecedf3;
--ys-line: rgba(28, 35, 51, 0.09);
--ys-ink: #1c2333;
--ys-ink-2: #5b6577;
--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 名保留映射(存量页面引用,值已迁移到新体系):
* 深水青 → 冷墨系、金色 → 靛蓝系。存量页面迁移完成后移除。
* 旧 token 名保留映射(BasicLayout / 登录页存量引用,值迁移到新体系:
* 深水青 → 冷墨系、金色 → 靛蓝系),存量页面迁移完成后移除。
*/
--ys-ink-900: #16203a;
--ys-ink-800: #1c2333;
@ -62,6 +26,7 @@
--ys-ink-600: #3a4a6e;
--ys-gold-bright: #6d81c2;
--ys-gold-deep: #2e4ba8;
--ys-text: #1c2333;
--ys-muted: #5b6577;
@ -71,12 +36,11 @@
*/
--ys-font-brand: 'Noto Sans SC', 'Source Han Sans SC', 'HarmonyOS Sans SC', 'PingFang SC',
sans-serif;
--ys-font-mono: 'SF Mono', 'Cascadia Mono', Consolas, 'Courier New', monospace;
/* ── Element Plus 主色 ──────────────────────────────────────────────── */
/*
* 白字对比约 7.5:1。色阶为按 Element Plus 公式「主色与白色混合」预先算好的
* 静态值 —— 不用 color-mix(),避免依赖较新的浏览器特性。
* Element Plus 主色 = 靛蓝(白字对比约 7.5:1)。
* 色阶为按 Element Plus 公式「主色与白色混合」预先算好的静态值 ——
* 不用 color-mix(),避免依赖较新的浏览器特性。
*/
--el-color-primary: #2e4ba8;
--el-color-primary-light-3: #6d81c2;
@ -85,72 +49,6 @@
--el-color-primary-light-8: #d5dbee;
--el-color-primary-light-9: #eaedf6;
--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;
}
* {
@ -175,534 +73,15 @@ body {
-webkit-font-smoothing: antialiased;
}
/* 滚动条:细、低对比,避免在密集表格页抢视线 */
*::-webkit-scrollbar {
width: 8px;
height: 8px;
/* 系统管理列表页共用的版式(原在三个页面 scoped 中各自重复) */
.filter-form {
margin-bottom: 4px;
}
*::-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 {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
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)统一视觉
*

View File

@ -11,19 +11,11 @@ const { topLevelMenus, groupedMenus } = useAppMenu()
<template>
<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-icon v-if="item.icon" class="app-menu__icon" aria-hidden="true">
<component :is="item.icon" />
</el-icon>
{{ item.title }}
</el-menu-item>
<el-sub-menu v-for="group in groupedMenus" :key="group.name" :index="group.name">
<template #title>
<span class="app-menu__group">{{ group.name }}</span>
</template>
<template #title>{{ group.name }}</template>
<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 }}
</el-menu-item>
</el-sub-menu>
@ -40,129 +32,34 @@ const { topLevelMenus, groupedMenus } = useAppMenu()
--el-menu-bg-color: transparent;
--el-menu-text-color: var(--ys-ink-2);
--el-menu-active-color: var(--ys-indigo);
--el-menu-hover-bg-color: rgba(255, 255, 255, 0.55);
--el-menu-item-height: 36px;
--el-menu-hover-bg-color: rgba(255, 255, 255, 0.6);
--el-menu-item-height: 40px;
padding: 0 10px;
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) {
position: relative;
font-weight: 500;
background: linear-gradient(180deg, var(--ys-paper), #fbfcfe);
background: linear-gradient(180deg, #ffffff, #fbfcfe);
box-shadow:
inset 0 1px 0 #fff,
0 1px 2px 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 {
content: '';
position: absolute;
left: -14px;
top: 9px;
bottom: 9px;
left: -10px;
width: 2px;
border-radius: 1px;
background: var(--ys-indigo);
}
/* 组标题: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 {
.app-menu :deep(.el-sub-menu.is-active > .el-sub-menu__title) {
color: var(--ys-indigo);
}
/*
* 展开/收起箭头:Element Plus 默认「闭合向下、展开转 180°」,
* 设计稿口径是「闭合向右、展开向下」——先统一掰到向右(-90°),
* 展开时回到 0°。选择器权重高于组件库默认值,且 scoped 属性使其稳定生效。
*/
.app-menu :deep(.el-sub-menu__icon-arrow) {
width: 11px;
height: 11px;
margin-top: -6px;
color: var(--ys-ink-3);
transform: rotate(-90deg);
transition: transform 150ms var(--ys-ease);
}
.app-menu :deep(.el-sub-menu.is-opened > .el-sub-menu__title .el-sub-menu__icon-arrow) {
color: var(--ys-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>

View File

@ -17,9 +17,6 @@ const drawerVisible = ref(false)
/** 「首页」按角色解析:普通用户没有工作台权限,固定链到业务数据页 */
const homePath = computed(() => firstAccessiblePath(router, userStore.roles))
/** 头像取显示名首字:没有头像上传能力,首字比图标更有辨识度 */
const avatarInitial = computed(() => userStore.displayName.trim().charAt(0) || '衍')
/** 面包屑:首页 + 分组(如有)+ 当前页标题(首页自身不重复) */
const breadcrumbs = computed<string[]>(() => {
const items: string[] = []
@ -49,13 +46,13 @@ async function handleLogout(): Promise<void> {
<div class="app-brand">
<span class="app-brand__name">衍水集团</span>
<span class="app-brand__sub">营销人员工资管理系统</span>
<span class="waterline app-brand__line"></span>
<span class="app-brand__line"></span>
</div>
<AppMenu class="app-aside__menu" />
</el-aside>
<el-container class="app-body">
<el-header class="app-header" height="52px">
<el-header class="app-header">
<div class="app-header__left">
<button
type="button"
@ -84,9 +81,7 @@ async function handleLogout(): Promise<void> {
<el-tag v-for="role in userStore.roles" :key="role" size="small" type="info">
{{ role }}
</el-tag>
<span class="app-header__avatar" aria-hidden="true">{{ avatarInitial }}</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>
</div>
</el-header>
@ -106,7 +101,7 @@ async function handleLogout(): Promise<void> {
<div class="app-brand">
<span class="app-brand__name">衍水集团</span>
<span class="app-brand__sub">营销人员工资管理系统</span>
<span class="waterline app-brand__line"></span>
<span class="app-brand__line"></span>
</div>
<AppMenu />
</el-drawer>
@ -122,7 +117,7 @@ async function handleLogout(): Promise<void> {
display: flex;
flex-direction: column;
/* 中性灰侧栏:比页面底沉一档,与纸白卡片拉开层次 */
background: linear-gradient(180deg, var(--ys-aside-1) 0%, var(--ys-aside-2) 100%);
background: linear-gradient(180deg, #f2f4f8 0%, #ecedf3 100%);
border-right: 1px solid var(--ys-line);
}
@ -135,9 +130,9 @@ async function handleLogout(): Promise<void> {
.app-brand__name {
font-family: var(--ys-font-brand);
font-size: 15px;
font-size: 16px;
font-weight: 500;
letter-spacing: 0.32em;
letter-spacing: 4px;
color: var(--ys-ink);
}
@ -147,15 +142,17 @@ async function handleLogout(): Promise<void> {
color: var(--ys-ink-3);
}
/* 品牌签名:logo 下的渐隐水线(全站唯一装饰元素,页面标题处同款,此处收窄) */
/* 品牌签名:logo 下的渐隐水线(全站唯一装饰元素,页面标题处同款) */
.app-brand__line {
width: 24px;
height: 2px;
border-radius: 1px;
margin-top: 8px;
background: linear-gradient(90deg, var(--ys-indigo), rgba(46, 75, 168, 0));
}
.app-aside__menu {
flex: 1;
padding: 2px 0 12px;
overflow-y: auto;
}
@ -163,8 +160,8 @@ async function handleLogout(): Promise<void> {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 18px;
background: linear-gradient(180deg, var(--ys-paper), #fdfdfe);
height: 56px;
background: linear-gradient(180deg, #ffffff, #fdfdfe);
border-bottom: 1px solid var(--ys-line);
}
@ -182,16 +179,15 @@ async function handleLogout(): Promise<void> {
width: 32px;
height: 32px;
padding: 0;
color: var(--ys-ink-2);
color: var(--ys-text);
cursor: pointer;
background: none;
border: none;
border-radius: 7px;
border-radius: 6px;
}
.app-header__menu-toggle:hover {
color: var(--ys-indigo);
background-color: var(--ys-indigo-wash);
background-color: #f2f3f5;
}
.app-header__menu-toggle:focus-visible {
@ -202,46 +198,20 @@ async function handleLogout(): Promise<void> {
.app-header__user {
display: flex;
align-items: center;
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%;
gap: 10px;
}
.app-header__username {
font-size: 13px;
color: var(--ys-ink);
}
.app-header__divider {
width: 1px;
height: 14px;
background-color: var(--ys-line);
font-size: 14px;
color: var(--ys-text);
}
.app-main {
/*
* 内容区:雾灰底 + 顶部受光(靛蓝微光叠一层高光)。
* 受光用固定像素半径而非百分比:百分比会随窗口高度变化,
* 窄高三列布局时天光会被拉成竖条。
*/
padding: 22px;
padding: 20px;
/* 雾灰底 + 点阵网格:内容区的秩序与密度 */
background-color: var(--ys-mist);
background-image:
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%);
background-image: radial-gradient(rgba(28, 35, 51, 0.05) 1px, transparent 1px);
background-size: 24px 24px;
}
@media (max-width: 768px) {
@ -252,10 +222,6 @@ async function handleLogout(): Promise<void> {
.app-header__menu-toggle {
display: inline-flex;
}
.app-main {
padding: 14px;
}
}
@media (prefers-reduced-motion: reduce) {
@ -269,6 +235,6 @@ async function handleLogout(): Promise<void> {
/* 抽屉内容铺满侧栏同款灰(非 scoped:目标元素由 el-drawer 渲染在组件根之外) */
.app-drawer .el-drawer__body {
padding: 0;
background: linear-gradient(180deg, var(--ys-aside-1) 0%, var(--ys-aside-2) 100%);
background: linear-gradient(180deg, #f2f4f8 0%, #ecedf3 100%);
}
</style>

View File

@ -1,72 +0,0 @@
import type { Component } from 'vue'
import {
Avatar,
Box,
Briefcase,
Calendar,
DataLine,
Histogram,
House,
Management,
Money,
Notebook,
OfficeBuilding,
PieChart,
Postcard,
SetUp,
Shop,
Stamp,
Star,
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,
rating: Star,
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
}

View File

@ -1,10 +1,7 @@
<script setup lang="ts">
/**
* 分页条:统一各列表页的布局与页码尺寸选项。
* 分页条:统一四个列表页的布局与页码尺寸选项。
* 与 `usePagedList` 的分页字段(pageNum/pageSize/total)配套使用。
*
* 开 background:设计稿 §03 要求当前页与主按钮同材质(靛蓝渐变 + 内高光),
* 该效果只在 is-background 模式下由 .el-pager li 承载,样式见全局 index.css。
*/
defineProps<{
total: number
@ -21,7 +18,6 @@ defineEmits<{
<template>
<el-pagination
class="app-pager"
background
layout="total, sizes, prev, pager, next, jumper"
:total="total"
:current-page="pageNum"
@ -35,6 +31,6 @@ defineEmits<{
<style scoped>
.app-pager {
justify-content: flex-end;
margin-top: 14px;
margin-top: 16px;
}
</style>

View File

@ -4,15 +4,8 @@
*
* 用户状态、角色状态、操作结果、登录结果四处共用,故提取;
* 语义文字由调用方通过 props 定制(如同一组件展示「启用/禁用」与「成功/失败」)。
*
* 视觉按设计稿 §03 的状态 pill 落地(浅底 + 6px 圆点 + 999px 圆角),
* 用 span 而非 el-tag:el-tag 的实心/描边形态与四档色阶对不上,
* 强行覆盖会牵连其它用 el-tag 的地方(角色标签、日志模块标签)。
* activeType/inactiveType 保留为语义到 pill 色调的映射,调用方无需改动。
*/
import { computed } from 'vue'
const props = withDefaults(
withDefaults(
defineProps<{
value: number
activeText?: string
@ -27,23 +20,10 @@ const props = withDefaults(
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>
<template>
<span class="ys-pill" :class="tone ? `ys-pill--${tone}` : ''">{{ text }}</span>
<el-tag :type="value === 1 ? activeType : inactiveType" size="small" disable-transitions>
{{ value === 1 ? activeText : inactiveText }}
</el-tag>
</template>

View File

@ -1,8 +1,6 @@
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import type { Component } from 'vue'
import { menuIcon } from '@/components/layout/menuIcons'
import { canAccessRoute, isMenuRecord } from '@/router/access'
import { useUserStore } from '@/store/user'
@ -12,8 +10,6 @@ export interface MenuNode {
title: string
order: number
group?: string
/** 图标组件(由 meta.icon 语义 key 解析;未配置则为 undefined) */
icon?: Component
}
export interface MenuGroup {
@ -46,7 +42,6 @@ export function useAppMenu() {
title: record.meta.title ?? '',
order: record.meta.order ?? 99,
group: record.meta.group,
icon: menuIcon(record.meta.icon),
}))
.sort((a, b) => a.order - b.order),
)

View File

@ -1,7 +1,6 @@
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
import { ElMessage } from 'element-plus'
import type { MenuIconName } from '@/components/layout/menuIcons'
import { firstAccessiblePath } from '@/router/access'
import { useUserStore } from '@/store/user'
import { ROLE_ADMIN } from '@/utils/constants'
@ -21,11 +20,6 @@ declare module 'vue-router' {
menu?: boolean
/** 菜单分组名(同名聚合为一个子菜单);不设则为顶层菜单项 */
group?: string
/**
* 菜单图标语义 key —— 取值见 components/layout/menuIcons.ts 的注册表。
* 路由只写 key 不写图标组件:换图标库/换风格只改注册表。
*/
icon?: MenuIconName
/** 菜单排序,越小越靠前 */
order?: number
}
@ -52,7 +46,6 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/dashboard/index.vue'),
meta: {
title: '首页',
icon: 'home',
order: 1,
allowedRoles: [ROLE_ADMIN, 'finance', 'leader'],
},
@ -61,25 +54,19 @@ const routes: RouteRecordRaw[] = [
path: '/system/users',
name: 'system-users',
component: () => import('@/views/system/users/index.vue'),
meta: { title: '用户管理', icon: 'users', group: '系统管理', requiresAdmin: true, order: 2 },
meta: { title: '用户管理', group: '系统管理', requiresAdmin: true, order: 2 },
},
{
path: '/system/roles',
name: 'system-roles',
component: () => import('@/views/system/roles/index.vue'),
meta: { title: '角色管理', icon: 'roles', group: '系统管理', requiresAdmin: true, order: 3 },
meta: { title: '角色管理', group: '系统管理', requiresAdmin: true, order: 3 },
},
{
path: '/system/business-roles',
name: 'system-business-roles',
component: () => import('@/views/system/business-roles/index.vue'),
meta: {
title: '岗位角色',
icon: 'business-roles',
group: '系统管理',
requiresAdmin: true,
order: 5,
},
meta: { title: '岗位角色', group: '系统管理', requiresAdmin: true, order: 5 },
},
{
// 计提参数:查看放开到管理员/财务/领导(#17 答复「查看可、修改仅
@ -89,7 +76,6 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/system/params/index.vue'),
meta: {
title: '计提参数',
icon: 'params',
group: '系统管理',
allowedRoles: [ROLE_ADMIN, 'finance', 'leader'],
order: 6,
@ -99,13 +85,7 @@ const routes: RouteRecordRaw[] = [
path: '/system/settings',
name: 'system-settings',
component: () => import('@/views/system/settings/index.vue'),
meta: {
title: '公司信息',
icon: 'settings',
group: '系统管理',
requiresAdmin: true,
order: 7,
},
meta: { title: '公司信息', group: '系统管理', requiresAdmin: true, order: 7 },
},
{
// 审批管理:管理员/财务/领导可见(营销后勤待权限清单答复后放开)
@ -114,7 +94,6 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/system/approvals/index.vue'),
meta: {
title: '审批管理',
icon: 'approvals',
group: '系统管理',
allowedRoles: [ROLE_ADMIN, 'finance', 'leader'],
order: 8,
@ -124,51 +103,45 @@ const routes: RouteRecordRaw[] = [
path: '/system/logs',
name: 'system-logs',
component: () => import('@/views/system/logs/index.vue'),
meta: {
title: '日志查询',
icon: 'logs',
group: '系统管理',
requiresAdmin: true,
order: 9,
},
meta: { title: '日志查询', group: '系统管理', requiresAdmin: true, order: 9 },
},
{
// 基础数据属业务操作:登录即可访问,不设 requiresAdmin(对照后端 api/v1/base)
path: '/base/personnel',
name: 'base-personnel',
component: () => import('@/views/base/personnel/index.vue'),
meta: { title: '人员管理', icon: 'personnel', group: '基础数据', order: 10 },
meta: { title: '人员管理', group: '基础数据', order: 10 },
},
{
path: '/base/products',
name: 'base-products',
component: () => import('@/views/base/products/index.vue'),
meta: { title: '产品管理', icon: 'products', group: '基础数据', order: 11 },
meta: { title: '产品管理', group: '基础数据', order: 11 },
},
{
path: '/base/customers',
name: 'base-customers',
component: () => import('@/views/base/customers/index.vue'),
meta: { title: '客户管理', icon: 'customers', group: '基础数据', order: 12 },
meta: { title: '客户管理', group: '基础数据', order: 12 },
},
{
// 业务数据组一律为业务操作:登录即可访问,不设 requiresAdmin
path: '/business/businesses',
name: 'business-businesses',
component: () => import('@/views/business/businesses/index.vue'),
meta: { title: '业务管理', icon: 'businesses', group: '业务数据', order: 13 },
meta: { title: '业务管理', group: '业务数据', order: 13 },
},
{
path: '/business/shipments',
name: 'business-shipments',
component: () => import('@/views/business/shipments/index.vue'),
meta: { title: '发货管理', icon: 'shipments', group: '业务数据', order: 14 },
meta: { title: '发货管理', group: '业务数据', order: 14 },
},
{
path: '/business/receipts',
name: 'business-receipts',
component: () => import('@/views/business/receipts/index.vue'),
meta: { title: '回款管理', icon: 'receipts', group: '业务数据', order: 15 },
meta: { title: '回款管理', group: '业务数据', order: 15 },
},
{
// 业务数据组首个页面:评级计算器是打分工具,不影响绩效金额,
@ -176,7 +149,7 @@ const routes: RouteRecordRaw[] = [
path: '/business/rating',
name: 'business-rating',
component: () => import('@/views/business/rating/index.vue'),
meta: { title: '客户评级计算器', icon: 'rating', group: '业务数据', order: 16 },
meta: { title: '客户评级计算器', group: '业务数据', order: 16 },
},
{
// 绩效工资组(M3 ④):核算台账读登录即可,月结写限管理员与营销后勤
@ -184,13 +157,13 @@ const routes: RouteRecordRaw[] = [
path: '/calc/ledger',
name: 'calc-ledger',
component: () => import('@/views/calc/ledger/index.vue'),
meta: { title: '绩效台账', icon: 'ledger', group: '绩效工资', order: 17 },
meta: { title: '绩效台账', group: '绩效工资', order: 17 },
},
{
path: '/settlements',
name: 'settlements',
component: () => import('@/views/settlements/index.vue'),
meta: { title: '月度结算', icon: 'settlements', group: '绩效工资', order: 18 },
meta: { title: '月度结算', group: '绩效工资', order: 18 },
},
// 报表统计组(M4 ⑤):FR-030/031 限领导/财务/管理员;admin 须显式
// 列入 allowedRoles(项目既定规则,admin 不天然放行)
@ -200,7 +173,6 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/reports/personal-detail/index.vue'),
meta: {
title: '个人绩效明细',
icon: 'report-personal',
group: '报表统计',
order: 19,
allowedRoles: [ROLE_ADMIN, 'finance', 'leader'],
@ -212,7 +184,6 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/reports/department-summary/index.vue'),
meta: {
title: '部门绩效汇总',
icon: 'report-department',
group: '报表统计',
order: 20,
allowedRoles: [ROLE_ADMIN, 'finance', 'leader'],
@ -224,7 +195,6 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/reports/business-stats/index.vue'),
meta: {
title: '业务统计',
icon: 'report-business',
group: '报表统计',
order: 21,
allowedRoles: [ROLE_ADMIN, 'finance', 'leader'],
@ -236,7 +206,6 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/reports/payment-stats/index.vue'),
meta: {
title: '回款统计',
icon: 'report-payment',
group: '报表统计',
order: 22,
allowedRoles: [ROLE_ADMIN, 'finance', 'leader'],

View File

@ -7,7 +7,6 @@ import type { CustomerItem, OwnershipLogItem } from '@/api/customers'
import * as personnelApi from '@/api/personnel'
import AppPagination from '@/components/table/AppPagination.vue'
import { useConfirmAction } from '@/composables/useConfirmAction'
import { useDropdownOptions } from '@/composables/useDropdownOptions'
import { usePagedList } from '@/composables/usePagedList'
import { formatDateTime } from '@/utils/format'
import { handledError } from '@/utils/error'
@ -57,14 +56,18 @@ interface PersonnelOption {
status: number
}
const { options: ownerOptions, load: loadOwnerOptions } = useDropdownOptions<PersonnelOption>(
async () => {
const ownerOptions = ref<PersonnelOption[]>([])
async function loadOwnerOptions(): Promise<void> {
try {
// 归属下拉的近似全量数据源:内部系统人员规模有限,200 条足够;
// 极端场景由后端对归属合法性的校验兜底
const data = await personnelApi.listPersonnel({ pageNum: 1, pageSize: 200 })
return data.list.map((p) => ({ id: p.id, name: p.personnel_name, status: p.status }))
},
)
ownerOptions.value = data.list.map((p) => ({ id: p.id, name: p.personnel_name, status: p.status }))
} catch (error) {
handledError(error) // 下拉为空不阻断列表使用
}
}
/** 下拉项展示名:离职人员加后缀标注(仍可被选作归属吗?由后端校验兜底) */
function ownerLabel(option: PersonnelOption): string {
@ -319,86 +322,61 @@ onMounted(() => {
<template>
<div>
<div class="page-head">
<h1 class="page-title">客户管理</h1>
<p class="page-desc">
客户档案与归属人员;归属变更只影响变更后的核算、不回溯历史,每次调整都在「变更记录」留痕
</p>
<span class="waterline"></span>
</div>
<el-card shadow="never">
<el-form class="filter-form" :inline="true" @submit.prevent>
<el-form-item label="关键词">
<el-input
v-model="filters.keyword"
placeholder="公司名称或客户负责人"
clearable
style="width: 190px"
@keyup.enter="search"
/>
</el-form-item>
<el-form-item label="分类">
<el-select v-model="filters.customer_type" placeholder="全部" clearable style="width: 130px">
<el-option
v-for="item in customerApi.CUSTOMER_TYPE_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="归属人员">
<el-select
v-model="filters.owner_personnel_id"
placeholder="全部"
clearable
filterable
style="width: 160px"
>
<el-option
v-for="item in ownerOptions"
:key="item.id"
:label="ownerLabel(item)"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="search">查询</el-button>
<el-button @click="resetFilters">重置</el-button>
</el-form-item>
</el-form>
<!--
.filter-bar 直接挂在 el-form 上:el-form 不是 flex 透明容器,
外面再包一层 div 会让 el-form-item 丢掉 gap 排版,筛选条退化成竖排。
-->
<el-form class="filter-bar" @submit.prevent>
<el-form-item label="关键词">
<el-input
v-model="filters.keyword"
placeholder="公司名称或客户负责人"
clearable
style="width: 190px"
@keyup.enter="search"
/>
</el-form-item>
<el-form-item label="分类">
<el-select v-model="filters.customer_type" placeholder="全部" clearable style="width: 130px">
<el-option
v-for="item in customerApi.CUSTOMER_TYPE_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="归属人员">
<el-select
v-model="filters.owner_personnel_id"
placeholder="全部"
clearable
filterable
style="width: 160px"
>
<el-option
v-for="item in ownerOptions"
:key="item.id"
:label="ownerLabel(item)"
:value="item.id"
/>
</el-select>
</el-form-item>
<div class="filter-bar__actions">
<el-button type="primary" @click="search">查询</el-button>
<el-button @click="resetFilters">重置</el-button>
<div class="toolbar">
<el-button type="primary" @click="openCreate">新增客户</el-button>
</div>
</el-form>
<div class="toolbar">
<el-button type="primary" @click="openCreate">新增客户</el-button>
<span class="toolbar-note">归属调整走行内「归属变更」,系统自动留痕</span>
</div>
<!-- ys-rise:整页只给列表卡一次入场上浮,筛选与页头立即呈现,避免整屏一起动 -->
<div class="card ys-rise">
<el-table
v-loading="loading"
:data="records"
stripe
empty-text="还没有客户 —— 点「新增客户」建第一个,或换个关键词再查"
>
<el-table v-loading="loading" :data="records" stripe>
<el-table-column prop="company_name" label="公司名称" min-width="180" />
<el-table-column prop="owner_name" label="负责人" min-width="90" />
<el-table-column prop="contact_person" label="联系人" min-width="90">
<template #default="{ row }">
<span v-if="row.contact_person">{{ row.contact_person }}</span>
<span v-else class="cell-muted">—</span>
</template>
<template #default="{ row }">{{ row.contact_person ?? '—' }}</template>
</el-table-column>
<el-table-column prop="contact_phone" label="电话" min-width="120">
<template #default="{ row }">
<span v-if="row.contact_phone">{{ row.contact_phone }}</span>
<span v-else class="cell-muted">—</span>
</template>
<template #default="{ row }">{{ row.contact_phone ?? '—' }}</template>
</el-table-column>
<el-table-column label="分类" width="110" align="center">
<template #default="{ row }">
@ -418,10 +396,7 @@ onMounted(() => {
</template>
</el-table-column>
<el-table-column label="归属人员" min-width="110">
<template #default="{ row }">
<span v-if="row.owner_personnel_name">{{ row.owner_personnel_name }}</span>
<span v-else class="cell-muted">(无归属)</span>
</template>
<template #default="{ row }">{{ row.owner_personnel_name ?? '(无归属)' }}</template>
</el-table-column>
<el-table-column label="操作" width="260" fixed="right" align="center">
<template #default="{ row }">
@ -438,16 +413,14 @@ onMounted(() => {
</el-table-column>
</el-table>
<div class="card-foot">
<AppPagination
:total="total"
:page-num="pageNum"
:page-size="pageSize"
@page-change="onPageChange"
@size-change="onSizeChange"
/>
</div>
</div>
<AppPagination
:total="total"
:page-num="pageNum"
:page-size="pageSize"
@page-change="onPageChange"
@size-change="onSizeChange"
/>
</el-card>
<!-- 新增 / 编辑(编辑不含归属,归属走独立按钮保证留痕) -->
<el-dialog
@ -559,13 +532,7 @@ onMounted(() => {
<!-- 归属变更记录(分页留痕,按变更时间倒序) -->
<el-drawer v-model="logsVisible" :title="logsTitle" size="760px" destroy-on-close>
<el-table
v-loading="logsLoading"
:data="logRecords"
stripe
size="small"
empty-text="这个客户还没有归属变更记录 —— 调整一次归属后这里就会出现留痕"
>
<el-table v-loading="logsLoading" :data="logRecords" stripe size="small">
<el-table-column label="变更时间" width="160">
<template #default="{ row }">{{ formatDateTime(row.create_time) }}</template>
</el-table-column>

View File

@ -8,7 +8,6 @@ import type { PersonnelItem } from '@/api/personnel'
import AppPagination from '@/components/table/AppPagination.vue'
import StatusTag from '@/components/table/StatusTag.vue'
import { useConfirmAction } from '@/composables/useConfirmAction'
import { useDropdownOptions } from '@/composables/useDropdownOptions'
import { usePagedList } from '@/composables/usePagedList'
import { useEditingRow } from '@/composables/useEditingRow'
import { AMOUNT_PATTERN } from '@/utils/validate'
@ -65,22 +64,29 @@ interface SelectOption {
status: number
}
const { options: roleOptions, load: loadRoleOptions } = useDropdownOptions<SelectOption>(
async () => {
const roleOptions = ref<SelectOption[]>([])
const managerOptions = ref<SelectOption[]>([])
async function loadRoleOptions(): Promise<void> {
try {
// 岗位规模有限(内置 3 个 + 自定义),选项接口一次取全
const data = await businessRoleApi.listBusinessRoleOptions()
return data.map((r) => ({ id: r.id, name: r.role_name, status: r.status }))
},
)
roleOptions.value = data.map((r) => ({ id: r.id, name: r.role_name, status: r.status }))
} catch (error) {
handledError(error) // 下拉为空不阻断列表使用
}
}
const { options: managerOptions, load: loadManagerOptions } = useDropdownOptions<SelectOption>(
async () => {
async function loadManagerOptions(): Promise<void> {
try {
// 经理下拉的近似全量数据源:内部系统人员规模有限,200 条足够;
// 超出规模的极端场景由后端对经理合法性的校验兜底
const data = await personnelApi.listPersonnel({ pageNum: 1, pageSize: 200 })
return data.list.map((p) => ({ id: p.id, name: p.personnel_name, status: p.status }))
},
)
managerOptions.value = data.list.map((p) => ({ id: p.id, name: p.personnel_name, status: p.status }))
} catch (error) {
handledError(error)
}
}
// ==================== 新增 / 编辑(基本信息) ====================
@ -366,73 +372,54 @@ onMounted(() => {
<template>
<div>
<div class="page-head">
<h1 class="page-title">人员管理</h1>
<p class="page-desc">
人员岗位与适用计提参数在这里定;改岗位会改变后续回款的绩效口径,设为离职后不可再被业务与客户归属引用
</p>
<span class="waterline"></span>
</div>
<el-card shadow="never">
<el-form class="filter-form" :inline="true" @submit.prevent>
<el-form-item label="姓名">
<el-input
v-model="filters.keyword"
placeholder="姓名,模糊匹配"
clearable
style="width: 180px"
@keyup.enter="search"
/>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="filters.status" placeholder="全部" clearable style="width: 110px">
<el-option
v-for="item in EMPLOYMENT_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="岗位">
<el-select
v-model="filters.business_role_id"
placeholder="全部"
clearable
filterable
style="width: 160px"
>
<el-option
v-for="item in searchRoleOptions"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="search">查询</el-button>
<el-button @click="resetFilters">重置</el-button>
</el-form-item>
</el-form>
<!--
.filter-bar 直接挂在 el-form 上:el-form 不是 flex 透明容器,
外面再包一层 div 会让 el-form-item 丢掉 gap 排版,筛选条退化成竖排。
-->
<el-form class="filter-bar" @submit.prevent>
<el-form-item label="姓名">
<el-input
v-model="filters.keyword"
placeholder="姓名,模糊匹配"
clearable
style="width: 180px"
@keyup.enter="search"
/>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="filters.status" placeholder="全部" clearable style="width: 110px">
<el-option
v-for="item in EMPLOYMENT_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="岗位">
<el-select
v-model="filters.business_role_id"
placeholder="全部"
clearable
filterable
style="width: 160px"
>
<el-option
v-for="item in searchRoleOptions"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
<div class="filter-bar__actions">
<el-button type="primary" @click="search">查询</el-button>
<el-button @click="resetFilters">重置</el-button>
<div class="toolbar">
<el-button type="primary" @click="openCreate">新增人员</el-button>
</div>
</el-form>
<div class="toolbar">
<el-button type="primary" @click="openCreate">新增人员</el-button>
<span class="toolbar-note">辐射区域与拓展计划在行内「区域与计划」维护,保存即全量替换</span>
</div>
<!-- ys-rise:整页只给列表卡一次入场上浮,筛选与页头立即呈现,避免整屏一起动 -->
<div class="card ys-rise">
<el-table
v-loading="loading"
:data="records"
stripe
empty-text="还没有人员 —— 点「新增人员」建第一个,或换个关键词再查"
>
<el-table v-loading="loading" :data="records" stripe>
<el-table-column prop="personnel_name" label="姓名" min-width="100" />
<el-table-column prop="role_name" label="岗位" min-width="100">
<template #default="{ row }">{{ row.role_name ?? '—' }}</template>
@ -457,10 +444,10 @@ onMounted(() => {
<el-table-column label="工资标准(元)" min-width="120" align="right">
<template #default="{ row }">
<!-- 字符串直出:金额红线,禁 toFixed / float 格式化 -->
<span class="cell-num">{{ row.salary_standard ?? '—' }}</span>
<span class="cell-amount">{{ row.salary_standard ?? '—' }}</span>
</template>
</el-table-column>
<el-table-column label="状态" width="96" align="center">
<el-table-column label="状态" width="90" align="center">
<template #default="{ row }">
<StatusTag :value="row.status" active-text="在职" inactive-text="离职" />
</template>
@ -482,16 +469,14 @@ onMounted(() => {
</el-table-column>
</el-table>
<div class="card-foot">
<AppPagination
:total="total"
:page-num="pageNum"
:page-size="pageSize"
@page-change="onPageChange"
@size-change="onSizeChange"
/>
</div>
</div>
<AppPagination
:total="total"
:page-num="pageNum"
:page-size="pageSize"
@page-change="onPageChange"
@size-change="onSizeChange"
/>
</el-card>
<!-- 新增 / 编辑(基本信息;不含状态与子表) -->
<el-dialog
@ -582,12 +567,7 @@ onMounted(() => {
</el-button>
</span>
</div>
<el-table
:data="regionRows"
stripe
size="small"
empty-text="还没有辐射区域 —— 点「添加区域」加一条"
>
<el-table :data="regionRows" stripe size="small">
<el-table-column label="区域名称" min-width="200">
<template #default="{ row }">
<el-input v-model="row.region_name" maxlength="128" placeholder="如 沈阳市铁西区" />
@ -618,12 +598,7 @@ onMounted(() => {
</el-button>
</span>
</div>
<el-table
:data="planRows"
stripe
size="small"
empty-text="还没有拓展计划 —— 点「添加计划」加一条"
>
<el-table :data="planRows" stripe size="small">
<el-table-column label="期间" min-width="110">
<template #default="{ row }">
<el-input v-model="row.plan_period" maxlength="32" placeholder="如 2026Q1" />
@ -654,19 +629,23 @@ onMounted(() => {
</template>
<style scoped>
/* .cell-muted / .cell-num 由全局 index.css 提供,本页不再重复定义 */
.cell-muted {
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 {
margin-left: 10px;
color: var(--ys-ink-3);
font-size: 12px;
}
/* 抽屉内的子表分组:区域与计划各自成块,块内标题与操作按钮同行 */
.sub-section {
margin-bottom: 20px;
}

View File

@ -185,14 +185,6 @@ onMounted(() => {
<template>
<div>
<div class="page-head">
<h1 class="page-title">产品管理</h1>
<p class="page-desc">
产品主数据;类别是折算阈值的行级锚点,改类别会改变该产品成交回款的绩效口径
</p>
<span class="waterline"></span>
</div>
<el-alert
class="page-alert"
type="info"
@ -201,59 +193,48 @@ onMounted(() => {
title="产品类别决定折算阈值:市政水利 30% / 轨道交通 20%;阈值在「系统设置-计提参数」维护。"
/>
<!--
.filter-bar 直接挂在 el-form 上:el-form 不是 flex 透明容器,
外面再包一层 div 会让 el-form-item 丢掉 gap 排版,筛选条退化成竖排。
-->
<el-form class="filter-bar" @submit.prevent>
<el-form-item label="关键词">
<el-input
v-model="filters.keyword"
placeholder="名称或规格"
clearable
style="width: 180px"
@keyup.enter="search"
/>
</el-form-item>
<el-form-item label="类别">
<el-select v-model="filters.product_category" placeholder="全部" clearable style="width: 130px">
<el-option
v-for="item in productApi.PRODUCT_CATEGORY_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
<el-card shadow="never">
<el-form class="filter-form" :inline="true" @submit.prevent>
<el-form-item label="关键词">
<el-input
v-model="filters.keyword"
placeholder="名称或规格"
clearable
style="width: 180px"
@keyup.enter="search"
/>
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="filters.status" placeholder="全部" clearable style="width: 110px">
<el-option
v-for="item in STATUS_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<div class="filter-bar__actions">
<el-button type="primary" @click="search">查询</el-button>
<el-button @click="resetFilters">重置</el-button>
</el-form-item>
<el-form-item label="类别">
<el-select v-model="filters.product_category" placeholder="全部" clearable style="width: 130px">
<el-option
v-for="item in productApi.PRODUCT_CATEGORY_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="filters.status" placeholder="全部" clearable style="width: 110px">
<el-option
v-for="item in STATUS_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="search">查询</el-button>
<el-button @click="resetFilters">重置</el-button>
</el-form-item>
</el-form>
<div class="toolbar">
<el-button type="primary" @click="openCreate">新增产品</el-button>
</div>
</el-form>
<div class="toolbar">
<el-button type="primary" @click="openCreate">新增产品</el-button>
<span class="toolbar-note">常规下架用「停用」,删除是物理删除、不可恢复</span>
</div>
<!-- ys-rise:整页只给列表卡一次入场上浮,筛选与页头立即呈现,避免整屏一起动 -->
<div class="card ys-rise">
<el-table
v-loading="loading"
:data="records"
stripe
empty-text="还没有产品 —— 点「新增产品」建第一个,或换个关键词再查"
>
<el-table v-loading="loading" :data="records" stripe>
<el-table-column prop="product_name" label="产品名称" min-width="150" />
<el-table-column prop="spec_model" label="规格型号" min-width="140">
<template #default="{ row }">{{ row.spec_model || '—' }}</template>
@ -264,12 +245,12 @@ onMounted(() => {
<el-table-column label="数量" min-width="100" align="right">
<template #default="{ row }">
<!-- 字符串直出:金额红线,禁 toFixed / float 格式化 -->
<span class="cell-num">{{ row.stock_quantity ?? '—' }}</span>
<span class="cell-amount">{{ row.stock_quantity ?? '—' }}</span>
</template>
</el-table-column>
<el-table-column label="单价(元)" min-width="110" align="right">
<template #default="{ row }">
<span class="cell-num">{{ row.sale_price ?? '—' }}</span>
<span class="cell-amount">{{ row.sale_price ?? '—' }}</span>
</template>
</el-table-column>
<el-table-column label="类别" width="110" align="center">
@ -283,7 +264,7 @@ onMounted(() => {
</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="96" align="center">
<el-table-column label="状态" width="90" align="center">
<template #default="{ row }">
<StatusTag :value="row.status" />
</template>
@ -302,16 +283,14 @@ onMounted(() => {
</el-table-column>
</el-table>
<div class="card-foot">
<AppPagination
:total="total"
:page-num="pageNum"
:page-size="pageSize"
@page-change="onPageChange"
@size-change="onSizeChange"
/>
</div>
</div>
<AppPagination
:total="total"
:page-num="pageNum"
:page-size="pageSize"
@page-change="onPageChange"
@size-change="onSizeChange"
/>
</el-card>
<!-- 新增 / 编辑 -->
<el-dialog
@ -363,10 +342,13 @@ onMounted(() => {
</template>
<style scoped>
/* .cell-num 由全局 index.css 提供(等宽数字 + 右对齐),本页不再重复定义 */
/* 阈值提示条:与卡片同宽,下距由这里控制,不占卡的内部留白 */
.page-alert {
margin-bottom: 14px;
margin-bottom: 12px;
}
.cell-amount {
font-family: 'SFMono-Regular', Consolas, 'Courier New', monospace;
font-size: 12px;
color: var(--ys-ink-2);
}
</style>

View File

@ -10,7 +10,6 @@ import * as personnelApi from '@/api/personnel'
import { productCategoryLabel } from '@/api/products'
import AppPagination from '@/components/table/AppPagination.vue'
import { useConfirmAction } from '@/composables/useConfirmAction'
import { useDropdownOptions } from '@/composables/useDropdownOptions'
import { usePagedList } from '@/composables/usePagedList'
import { formatDateTime } from '@/utils/format'
import { AMOUNT_PATTERN, PRICE_PATTERN, ZERO_PATTERN } from '@/utils/validate'
@ -70,51 +69,37 @@ interface PersonnelOption {
status: number
}
const { options: customerOptions, load: loadCustomerOptions } = useDropdownOptions<CustomerOption>(
async () => {
const customerOptions = ref<CustomerOption[]>([])
const personnelOptions = ref<PersonnelOption[]>([])
async function loadCustomerOptions(): Promise<void> {
try {
// 客户下拉的近似全量数据源:内部系统规模有限,200 条足够;超出由后端校验兜底
const data = await customerApi.listCustomers({ pageNum: 1, pageSize: 200 })
return data.list.map((c) => ({ id: c.id, name: c.company_name }))
},
)
customerOptions.value = data.list.map((c) => ({ id: c.id, name: c.company_name }))
} catch (error) {
handledError(error) // 下拉为空不阻断列表使用
}
}
const { options: personnelOptions, load: loadPersonnelOptions } = useDropdownOptions<PersonnelOption>(
async () => {
async function loadPersonnelOptions(): Promise<void> {
try {
const data = await personnelApi.listPersonnel({ pageNum: 1, pageSize: 200 })
return data.list.map((p) => ({
personnelOptions.value = data.list.map((p) => ({
id: p.id,
name: p.personnel_name,
status: p.status,
}))
},
)
} catch (error) {
handledError(error)
}
}
/** 下拉项展示名:离职人员加后缀标注(可否被选由后端归属校验兜底) */
function personnelLabel(option: PersonnelOption): string {
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)
@ -617,87 +602,65 @@ onMounted(() => {
<template>
<div>
<!-- 页面头:标题 + 一句口径说明 + 签名水线(每页只出现一次) -->
<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-input
v-model="filters.keyword"
placeholder="业务编号或公司名称"
clearable
style="width: 190px"
@keyup.enter="search"
/>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="filters.status" placeholder="全部" clearable style="width: 110px">
<el-option
v-for="item in businessApi.BUSINESS_STATUS_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
<el-card shadow="never">
<el-form class="filter-form" :inline="true" @submit.prevent>
<el-form-item label="关键词">
<el-input
v-model="filters.keyword"
placeholder="业务编号或公司名称"
clearable
style="width: 190px"
@keyup.enter="search"
/>
</el-select>
</el-form-item>
<el-form-item label="业务日期">
<el-date-picker
v-model="dateRange"
type="daterange"
value-format="YYYY-MM-DD"
range-separator="至"
start-placeholder="起"
end-placeholder="止"
style="width: 240px"
/>
</el-form-item>
<div class="filter-bar__actions">
<el-button type="primary" @click="search">查询</el-button>
<el-button @click="resetFilters">重置</el-button>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="filters.status" placeholder="全部" clearable style="width: 110px">
<el-option
v-for="item in businessApi.BUSINESS_STATUS_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="业务日期">
<el-date-picker
v-model="dateRange"
type="daterange"
value-format="YYYY-MM-DD"
range-separator="至"
start-placeholder="起"
end-placeholder="止"
style="width: 240px"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="search">查询</el-button>
<el-button @click="resetFilters">重置</el-button>
</el-form-item>
</el-form>
<div class="toolbar">
<el-button type="primary" @click="openCreate">新增业务</el-button>
</div>
</el-form>
<div class="toolbar">
<el-button type="primary" @click="openCreate">新增业务</el-button>
<span class="toolbar-note">状态流转与归属调整走行内操作,每次变更都写留痕</span>
</div>
<!-- 表格与分页共用一张卡:分页落进 card-foot,表体与表尾同一材质 -->
<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 v-loading="loading" :data="records" stripe>
<el-table-column prop="business_no" label="业务编号" min-width="150" />
<el-table-column prop="customer_name" label="客户名称" min-width="160">
<template #default="{ row }">{{ row.customer_name ?? '—' }}</template>
</el-table-column>
<el-table-column prop="business_date" label="业务日期" min-width="110">
<template #default="{ row }">{{ row.business_date ?? '—' }}</template>
</el-table-column>
<el-table-column label="状态" width="108" align="center">
<el-table-column label="状态" width="90" align="center">
<template #default="{ row }">
<!-- 状态列统一用 pill:颜色之外必有文字,色觉差异不影响判读 -->
<span :class="statusPillClass(row.status)">
<el-tag
:type="businessApi.businessStatusTagType(row.status)"
size="small"
disable-transitions
>
{{ businessApi.businessStatusLabel(row.status) }}
</span>
</el-tag>
</template>
</el-table-column>
<el-table-column prop="owner_personnel_name" label="负责人员" min-width="100">
@ -709,22 +672,22 @@ onMounted(() => {
<el-table-column label="销售额(元)" min-width="120" align="right">
<template #default="{ row }">
<!-- 字符串直出:金额红线,禁 toFixed / float 格式化 -->
<span class="cell-num">{{ row.total_sale_amount ?? '—' }}</span>
<span class="cell-amount">{{ row.total_sale_amount ?? '—' }}</span>
</template>
</el-table-column>
<el-table-column label="成本(元)" min-width="120" align="right">
<template #default="{ row }">
<span class="cell-num">{{ row.total_cost_amount ?? '—' }}</span>
<span class="cell-amount">{{ row.total_cost_amount ?? '—' }}</span>
</template>
</el-table-column>
<el-table-column label="发货额(元)" min-width="120" align="right">
<template #default="{ row }">
<span class="cell-num">{{ row.total_ship_amount ?? '—' }}</span>
<span class="cell-amount">{{ row.total_ship_amount ?? '—' }}</span>
</template>
</el-table-column>
<el-table-column label="回款额(元)" min-width="120" align="right">
<template #default="{ row }">
<span class="cell-num">{{ row.total_received_amount ?? '—' }}</span>
<span class="cell-amount">{{ row.total_received_amount ?? '—' }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="300" fixed="right" align="center">
@ -771,17 +734,14 @@ onMounted(() => {
</el-table-column>
</el-table>
<!-- 分页落进 card-foot:与表体同一张卡,卡底微渐变收边 -->
<div class="card-foot">
<AppPagination
:total="total"
:page-num="pageNum"
:page-size="pageSize"
@page-change="onPageChange"
@size-change="onSizeChange"
/>
</div>
</div>
<AppPagination
:total="total"
:page-num="pageNum"
:page-size="pageSize"
@page-change="onPageChange"
@size-change="onSizeChange"
/>
</el-card>
<!-- 新增 / 编辑(含明细行编辑器;编辑不含归属,归属走独立按钮保证留痕) -->
<el-dialog
@ -1117,19 +1077,19 @@ onMounted(() => {
<span v-else class="cell-muted">否</span>
</el-descriptions-item>
<el-descriptions-item label="销售额(元)">
<span class="cell-num">{{ detail.total_sale_amount ?? '—' }}</span>
<span class="cell-amount">{{ detail.total_sale_amount ?? '—' }}</span>
</el-descriptions-item>
<el-descriptions-item label="成本(元)">
<span class="cell-num">{{ detail.total_cost_amount ?? '—' }}</span>
<span class="cell-amount">{{ detail.total_cost_amount ?? '—' }}</span>
</el-descriptions-item>
<el-descriptions-item label="发货额(元)">
<span class="cell-num">{{ detail.total_ship_amount ?? '—' }}</span>
<span class="cell-amount">{{ detail.total_ship_amount ?? '—' }}</span>
</el-descriptions-item>
<el-descriptions-item label="回款额(元)">
<span class="cell-num">{{ detail.total_received_amount ?? '—' }}</span>
<span class="cell-amount">{{ detail.total_received_amount ?? '—' }}</span>
</el-descriptions-item>
<el-descriptions-item label="运费额(元)">
<span class="cell-num">{{ detail.total_freight_amount ?? '—' }}</span>
<span class="cell-amount">{{ detail.total_freight_amount ?? '—' }}</span>
</el-descriptions-item>
<el-descriptions-item label="备注">
{{ detail.remark ?? '—' }}
@ -1155,27 +1115,27 @@ onMounted(() => {
</el-table-column>
<el-table-column label="数量" min-width="80" align="right">
<template #default="{ row }">
<span class="cell-num">{{ row.quantity }}</span>
<span class="cell-amount">{{ row.quantity }}</span>
</template>
</el-table-column>
<el-table-column label="销售单价" min-width="100" align="right">
<template #default="{ row }">
<span class="cell-num">{{ row.sale_price ?? '—' }}</span>
<span class="cell-amount">{{ row.sale_price ?? '—' }}</span>
</template>
</el-table-column>
<el-table-column label="行销售额" min-width="110" align="right">
<template #default="{ row }">
<span class="cell-num">{{ row.sale_amount }}</span>
<span class="cell-amount">{{ row.sale_amount }}</span>
</template>
</el-table-column>
<el-table-column label="成本额" min-width="100" align="right">
<template #default="{ row }">
<span class="cell-num">{{ row.cost_amount ?? '—' }}</span>
<span class="cell-amount">{{ row.cost_amount ?? '—' }}</span>
</template>
</el-table-column>
<el-table-column label="指导价" min-width="100" align="right">
<template #default="{ row }">
<span class="cell-num">{{ row.guided_price ?? '—' }}</span>
<span class="cell-amount">{{ row.guided_price ?? '—' }}</span>
</template>
</el-table-column>
</el-table>
@ -1195,7 +1155,7 @@ onMounted(() => {
<el-table-column prop="personnel_name" label="参与业务员" min-width="140" />
<el-table-column label="分配比例" width="110" align="right">
<template #default="{ row }">
<span class="cell-num">{{ row.ratio }}%</span>
<span class="cell-amount">{{ row.ratio }}%</span>
</template>
</el-table-column>
<el-table-column label="状态" width="90">
@ -1299,18 +1259,18 @@ onMounted(() => {
</div>
<div class="gp-grid">
<span class="gp-label">销售额</span>
<span class="gp-value cell-num">{{ cat.sale_amount }}</span>
<span class="gp-value cell-amount">{{ cat.sale_amount }}</span>
<span class="gp-label">成本额</span>
<span class="gp-value cell-num">{{ cat.cost_amount }}</span>
<span class="gp-value cell-amount">{{ cat.cost_amount }}</span>
<span class="gp-label">毛利润额</span>
<span class="gp-value cell-num">{{ cat.profit_amount }}</span>
<span class="gp-value cell-amount">{{ cat.profit_amount }}</span>
<span class="gp-label">毛利润率</span>
<!-- 分数字符串原样展示(如 997/1000),禁止数值化 -->
<span class="gp-value cell-num">{{ cat.profit_rate }}</span>
<span class="gp-value cell-amount">{{ cat.profit_rate }}</span>
<span class="gp-label">折算阈值</span>
<span class="gp-value cell-num">{{ cat.threshold }}</span>
<span class="gp-value cell-amount">{{ cat.threshold }}</span>
<span class="gp-label">折算毛利润额</span>
<span class="gp-value cell-num">{{ cat.converted_profit }}</span>
<span class="gp-value cell-amount">{{ cat.converted_profit }}</span>
</div>
</div>
</el-col>
@ -1327,38 +1287,38 @@ onMounted(() => {
</el-table-column>
<el-table-column label="数量" min-width="80" align="right">
<template #default="{ row }">
<span class="cell-num">{{ row.quantity }}</span>
<span class="cell-amount">{{ row.quantity }}</span>
</template>
</el-table-column>
<el-table-column label="销售额" min-width="100" align="right">
<template #default="{ row }">
<span class="cell-num">{{ row.sale_amount }}</span>
<span class="cell-amount">{{ row.sale_amount }}</span>
</template>
</el-table-column>
<el-table-column label="成本额" min-width="120" align="right">
<template #default="{ row }">
<span v-if="row.cost_amount" class="cell-num">{{ row.cost_amount }}</span>
<span v-if="row.cost_amount" class="cell-amount">{{ row.cost_amount }}</span>
<span v-else class="cell-missing">{{ row.missing_reason ?? '成本缺失' }}</span>
</template>
</el-table-column>
<el-table-column label="毛利润额" min-width="100" align="right">
<template #default="{ row }">
<span class="cell-num">{{ row.profit_amount ?? '—' }}</span>
<span class="cell-amount">{{ row.profit_amount ?? '—' }}</span>
</template>
</el-table-column>
<el-table-column label="毛利润率" min-width="100" align="right">
<template #default="{ row }">
<span class="cell-num">{{ row.profit_rate ?? '—' }}</span>
<span class="cell-amount">{{ row.profit_rate ?? '—' }}</span>
</template>
</el-table-column>
<el-table-column label="折算阈值" min-width="90" align="right">
<template #default="{ row }">
<span class="cell-num">{{ row.threshold }}</span>
<span class="cell-amount">{{ row.threshold }}</span>
</template>
</el-table-column>
<el-table-column label="折算毛利润额" min-width="110" align="right">
<template #default="{ row }">
<span class="cell-num">{{ row.converted_profit ?? '—' }}</span>
<span class="cell-amount">{{ row.converted_profit ?? '—' }}</span>
</template>
</el-table-column>
</el-table>
@ -1373,8 +1333,16 @@ onMounted(() => {
</template>
<style scoped>
/* 单元格口径(.cell-code / .cell-num / .cell-muted)与表头、按钮、输入框皮肤
已由全局 index.css 提供,此处不再重复定义,避免两处口径打架 */
.cell-muted {
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);
}
.cell-missing {
font-size: 12px;
@ -1544,5 +1512,4 @@ onMounted(() => {
color: var(--ys-ink-3);
font-size: 12px;
}
</style>

View File

@ -206,15 +206,6 @@ onMounted(() => {
<template>
<div class="rating-page">
<!-- 页面头:标题 + 一句说明 + 签名水线(每页只出现一次) -->
<div class="page-head">
<h1 class="page-title">客户评级计算器</h1>
<p class="page-desc">
五项各 0-20 分、总分 0-100,按配置的分值下限实时判定等级;评分只决定评级,不参与绩效金额计算
</p>
<span class="waterline"></span>
</div>
<!-- 对照表 #25:常驻口径提示,防止误认为评分会影响绩效金额 -->
<el-alert
class="page-alert"
@ -224,61 +215,57 @@ onMounted(() => {
show-icon
/>
<!-- 计算器是一张卡:卡头放标题与操作,卡体内分「评分项 / 判定结果」两块 -->
<div class="card">
<div class="card-head">
<span class="card-title">评分计算</span>
<span class="card-head__actions">
<!-- 评分计算区:无保存按钮,纯前端实时计算 -->
<el-card shadow="never">
<div class="page-toolbar">
<span class="page-desc">五项各 0-20 分,总分 0-100;评级等级按配置的下限实时判定。</span>
<span>
<el-button @click="resetScores">重置</el-button>
<el-button type="primary" @click="openDrawer">等级映射设置</el-button>
</span>
</div>
<div class="card-body">
<div class="calc-grid">
<div class="calc-form">
<div class="section-title">评分项(各 0-20 分,步进 1)</div>
<div v-for="item in SCORE_ITEMS" :key="item.key" class="score-row">
<span class="score-label">{{ item.label }}</span>
<el-input-number
v-model="scores[item.key]"
:min="0"
:max="20"
:step="1"
controls-position="right"
/>
<span class="score-unit">分</span>
</div>
<div class="calc-grid">
<div class="calc-form">
<div v-for="item in SCORE_ITEMS" :key="item.key" class="score-row">
<span class="score-label">{{ item.label }}</span>
<el-input-number
v-model="scores[item.key]"
:min="0"
:max="20"
:step="1"
controls-position="right"
/>
<span class="score-unit">分</span>
</div>
</div>
<div class="calc-result">
<div class="section-title">判定结果</div>
<div>
<span class="result-label">总分</span>
<b class="total-num">{{ totalScore }}</b>
<span class="total-max">/ 100</span>
</div>
<div>
<span class="result-label">评级等级</span>
<template v-if="grades.length === 0">
<div class="grade-name">—</div>
<div class="grade-hint">未配置等级映射</div>
</template>
<template v-else-if="matchedGrade">
<div class="grade-name">{{ matchedGrade.grade_name }}</div>
<div class="grade-hint">
分值区间 {{ matchedGrade.min_score }} ~ {{ matchedGrade.max_score }}
</div>
</template>
<template v-else>
<div class="grade-name">—</div>
<div class="grade-hint">总分低于全部等级下限,未命中等级</div>
</template>
</div>
<div class="calc-result">
<div>
<span class="result-label">总分</span>
<b class="total-num">{{ totalScore }}</b>
<span class="total-max">/ 100</span>
</div>
<div>
<span class="result-label">评级等级</span>
<template v-if="grades.length === 0">
<div class="grade-name">—</div>
<div class="grade-hint">未配置等级映射</div>
</template>
<template v-else-if="matchedGrade">
<div class="grade-name">{{ matchedGrade.grade_name }}</div>
<div class="grade-hint">
分值区间 {{ matchedGrade.min_score }} ~ {{ matchedGrade.max_score }}
</div>
</template>
<template v-else>
<div class="grade-name">—</div>
<div class="grade-hint">总分低于全部等级下限,未命中等级</div>
</template>
</div>
</div>
</div>
</div>
</el-card>
<!-- 等级映射设置抽屉 -->
<el-drawer v-model="drawerVisible" title="等级映射设置" size="640px">
@ -359,17 +346,22 @@ onMounted(() => {
</template>
<style scoped>
/* 页面头(.page-head/.page-title/.page-desc/.waterline)、.card 与 .section-title
都由全局 index.css 提供,此处只保留本页特有的排版 */
.page-alert {
margin-bottom: 14px;
}
/* 卡头右侧操作组:.card-head 已是 flex + justify-between,这里只补按钮之间的间距 */
.card-head__actions {
.page-toolbar {
display: flex;
gap: 10px;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
gap: 12px;
flex-wrap: wrap;
}
.page-desc {
font-size: 13px;
color: var(--ys-ink-2);
}
.calc-grid {

View File

@ -242,134 +242,108 @@ onMounted(() => {
<template>
<div>
<!-- 页面头:标题 + 一句口径说明(回款是绩效源头)+ 签名水线 -->
<div class="page-head">
<h1 class="page-title">回款管理</h1>
<p class="page-desc">回款是绩效的源头:批次、到账日与产品类别决定计提口径</p>
<span class="waterline"></span>
</div>
<el-card shadow="never">
<el-form class="filter-form" :inline="true" @submit.prevent>
<el-form-item label="业务">
<el-select
v-model="filters.business_id"
placeholder="全部"
clearable
filterable
style="width: 230px"
>
<el-option
v-for="item in businessOptions"
:key="item.id"
:label="businessLabel(item)"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="回款日期">
<el-date-picker
v-model="dateRange"
type="daterange"
value-format="YYYY-MM-DD"
range-separator="至"
start-placeholder="起"
end-placeholder="止"
style="width: 240px"
/>
</el-form-item>
<el-form-item label="以物抵顶">
<el-select v-model="filters.is_barter" placeholder="全部" clearable style="width: 120px">
<el-option
v-for="item in receiptApi.BARTER_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="核算状态">
<el-select
v-model="filters.settlement_status"
placeholder="全部"
clearable
style="width: 110px"
>
<el-option
v-for="item in receiptApi.SETTLEMENT_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="search">查询</el-button>
<el-button @click="resetFilters">重置</el-button>
</el-form-item>
</el-form>
<!--
.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-select
v-model="filters.business_id"
placeholder="全部"
clearable
filterable
style="width: 230px"
>
<el-option
v-for="item in businessOptions"
:key="item.id"
:label="businessLabel(item)"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="回款日期">
<el-date-picker
v-model="dateRange"
type="daterange"
value-format="YYYY-MM-DD"
range-separator="至"
start-placeholder="起"
end-placeholder="止"
style="width: 240px"
/>
</el-form-item>
<el-form-item label="以物抵顶">
<el-select v-model="filters.is_barter" placeholder="全部" clearable style="width: 120px">
<el-option
v-for="item in receiptApi.BARTER_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="核算状态">
<el-select
v-model="filters.settlement_status"
placeholder="全部"
clearable
style="width: 110px"
>
<el-option
v-for="item in receiptApi.SETTLEMENT_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<div class="filter-bar__actions">
<el-button type="primary" @click="search">查询</el-button>
<el-button @click="resetFilters">重置</el-button>
<div class="toolbar">
<el-button type="primary" @click="openCreate">新增回款</el-button>
</div>
</el-form>
<div class="toolbar">
<el-button type="primary" @click="openCreate">新增回款</el-button>
<span class="toolbar-note">已核算的回款不可变更;批次一旦指定,回款天数即按发货日派生</span>
</div>
<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.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 v-loading="loading" :data="records" stripe>
<el-table-column prop="receipt_batch_no" label="批次号" min-width="150" />
<el-table-column prop="business_no" label="业务编号" min-width="140">
<template #default="{ row }">{{ row.business_no }}</template>
</el-table-column>
<el-table-column label="发货单号" min-width="140">
<template #default="{ row }">
<span v-if="row.shipment_no" class="cell-code">{{ row.shipment_no }}</span>
<span v-else class="cell-muted">—</span>
</template>
<template #default="{ row }">{{ row.shipment_no ?? '—' }}</template>
</el-table-column>
<el-table-column prop="receipt_date" label="回款日期" min-width="110" />
<el-table-column label="金额(元)" min-width="120" align="right">
<template #default="{ row }">
<!-- 字符串直出:金额红线,禁 toFixed / float 格式化 -->
<span class="cell-num">{{ row.amount }}</span>
<span class="cell-amount">{{ row.amount }}</span>
</template>
</el-table-column>
<el-table-column label="以物抵顶" width="120" align="center">
<el-table-column label="以物抵顶" width="100" align="center">
<template #default="{ row }">
<!-- 以物抵顶影响折算比例,属需要一眼看见的分支,用 pill 而非普通文字 -->
<span v-if="row.is_barter === 1" class="ys-pill ys-pill--wait">以物抵顶</span>
<el-tag v-if="row.is_barter === 1" type="warning" size="small" disable-transitions>
以物抵顶
</el-tag>
<span v-else class="cell-muted">否</span>
</template>
</el-table-column>
<el-table-column label="回款天数" width="120" align="right">
<el-table-column label="回款天数" width="120" align="center">
<template #default="{ row }">
<span v-if="row.payment_days !== null" class="cell-num">{{ row.payment_days }} 天</span>
<span v-if="row.payment_days !== null" class="cell-amount">{{ row.payment_days }} 天</span>
<span v-else class="cell-muted">未指定批次</span>
</template>
</el-table-column>
<el-table-column label="核算状态" width="108" align="center">
<el-table-column label="核算状态" width="90" align="center">
<template #default="{ row }">
<!-- 已核算=已确认(靛绿)、未核算=待处理(琥珀);已核算行禁改禁删 -->
<span
class="ys-pill"
:class="row.settlement_status === 1 ? 'ys-pill--ok' : 'ys-pill--wait'"
<el-tag
:type="row.settlement_status === 1 ? 'success' : 'info'"
size="small"
disable-transitions
>
{{ row.settlement_status === 1 ? '已核算' : '未核算' }}
</span>
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="130" fixed="right" align="center">
@ -415,17 +389,14 @@ onMounted(() => {
</el-table-column>
</el-table>
<!-- 分页落进 card-foot:与表体同一张卡,卡底微渐变收边 -->
<div class="card-foot">
<AppPagination
:total="total"
:page-num="pageNum"
:page-size="pageSize"
@page-change="onPageChange"
@size-change="onSizeChange"
/>
</div>
</div>
<AppPagination
:total="total"
:page-num="pageNum"
:page-size="pageSize"
@page-change="onPageChange"
@size-change="onSizeChange"
/>
</el-card>
<!-- 新增 / 编辑(业务必选 + 发货批次级联指定,可清空=暂不指定) -->
<el-dialog
@ -503,7 +474,16 @@ onMounted(() => {
</template>
<style scoped>
/* 单元格口径(.cell-code / .cell-num / .cell-muted)由全局 index.css 提供,此处不再重复定义 */
.cell-muted {
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 {
display: inline-block;

View File

@ -273,75 +273,49 @@ onMounted(() => {
<template>
<div>
<!-- 页面头:标题 + 一句口径说明(发货日是回款天数的起算点)+ 签名水线 -->
<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-select
v-model="filters.business_id"
placeholder="全部"
clearable
filterable
style="width: 240px"
>
<el-option
v-for="item in businessOptions"
:key="item.id"
:label="businessLabel(item)"
:value="item.id"
<el-card shadow="never">
<el-form class="filter-form" :inline="true" @submit.prevent>
<el-form-item label="业务">
<el-select
v-model="filters.business_id"
placeholder="全部"
clearable
filterable
style="width: 240px"
>
<el-option
v-for="item in businessOptions"
:key="item.id"
:label="businessLabel(item)"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="发货日期">
<el-date-picker
v-model="dateRange"
type="daterange"
value-format="YYYY-MM-DD"
range-separator="至"
start-placeholder="起"
end-placeholder="止"
style="width: 240px"
/>
</el-select>
</el-form-item>
<el-form-item label="发货日期">
<el-date-picker
v-model="dateRange"
type="daterange"
value-format="YYYY-MM-DD"
range-separator="至"
start-placeholder="起"
end-placeholder="止"
style="width: 240px"
/>
</el-form-item>
<div class="filter-bar__actions">
<el-button type="primary" @click="search">查询</el-button>
<el-button @click="resetFilters">重置</el-button>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="search">查询</el-button>
<el-button @click="resetFilters">重置</el-button>
</el-form-item>
</el-form>
<div class="toolbar">
<el-button type="primary" @click="openCreate">新增发货单</el-button>
</div>
</el-form>
<div class="toolbar">
<el-button type="primary" @click="openCreate">新增发货单</el-button>
<span class="toolbar-note">发货明细与运费在弹窗内维护,提交后自动回写业务发货额</span>
</div>
<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.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 v-loading="loading" :data="records" stripe>
<el-table-column prop="shipment_no" label="发货单号" min-width="150" />
<el-table-column prop="business_no" label="业务编号" min-width="150">
<template #default="{ row }">{{ row.business_no ?? '—' }}</template>
</el-table-column>
<el-table-column label="发货日期" min-width="140">
<template #default="{ row }">
@ -355,12 +329,12 @@ onMounted(() => {
<el-table-column label="运费(元)" min-width="110" align="right">
<template #default="{ row }">
<!-- 字符串直出:金额红线,禁 toFixed / float 格式化 -->
<span class="cell-num">{{ row.freight_amount }}</span>
<span class="cell-amount">{{ row.freight_amount }}</span>
</template>
</el-table-column>
<el-table-column label="发货总额(元)" min-width="130" align="right">
<template #default="{ row }">
<span class="cell-num">{{ row.total_amount }}</span>
<span class="cell-amount">{{ row.total_amount }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="130" fixed="right" align="center">
@ -374,17 +348,14 @@ onMounted(() => {
</el-table-column>
</el-table>
<!-- 分页落进 card-foot:与表体同一张卡,卡底微渐变收边 -->
<div class="card-foot">
<AppPagination
:total="total"
:page-num="pageNum"
:page-size="pageSize"
@page-change="onPageChange"
@size-change="onSizeChange"
/>
</div>
</div>
<AppPagination
:total="total"
:page-num="pageNum"
:page-size="pageSize"
@page-change="onPageChange"
@size-change="onSizeChange"
/>
</el-card>
<!-- 新增 / 编辑(明细行编辑器;编辑不含 business_id,挂靠业务不可改) -->
<el-dialog
@ -479,7 +450,16 @@ onMounted(() => {
</template>
<style scoped>
/* 单元格口径(.cell-code / .cell-num / .cell-muted)由全局 index.css 提供,此处不再重复定义 */
.cell-amount {
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 {
display: block;
@ -509,5 +489,4 @@ onMounted(() => {
.tail-form {
margin-top: 4px;
}
</style>

View File

@ -70,20 +70,7 @@ async function submitRecalc(): Promise<void> {
<template>
<div class="page">
<!-- 页面头:标题 + 一句口径说明(台账是核算事实的固化点)+ 签名水线 -->
<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 inline class="filters" @submit.prevent>
<el-form-item label="归属月">
<el-input
v-model="filters.settlement_month"
@ -106,90 +93,57 @@ async function submitRecalc(): Promise<void> {
<el-option label="仅历史版本" :value="0" />
</el-select>
</el-form-item>
<div class="filter-bar__actions">
<el-form-item>
<el-button type="primary" @click="search">查询</el-button>
<el-button @click="resetFilters">重置</el-button>
</div>
</el-form-item>
</el-form>
<div class="card">
<el-table
v-loading="loading"
:data="records"
stripe
empty-text="台账还没有核算行 —— 换个归属月看看,或先在回款管理里登记回款后再核算"
>
<el-table-column type="expand">
<template #default="{ row }">
<pre class="snapshot">{{ JSON.stringify(row.snapshot_json ?? {}, null, 2) }}</pre>
</template>
</el-table-column>
<el-table-column prop="settlement_month" label="归属月" width="90" />
<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="role_code" label="岗位" width="110" />
<el-table-column prop="receipt_date" label="回款日" width="100" />
<el-table-column label="天数" width="70" align="right">
<template #default="{ row }">
<span class="cell-num">{{ row.payment_days }}</span>
</template>
</el-table-column>
<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>
</el-table-column>
<el-table-column label="操作" width="90" fixed="right" align="center">
<template #default="{ row }">
<el-button
v-if="row.is_active === 1 && row.calc_type === 1"
link
type="primary"
@click="openRecalc(row)"
>
重算
</el-button>
</template>
</el-table-column>
</el-table>
<el-table v-loading="loading" :data="records" stripe>
<el-table-column type="expand">
<template #default="{ row }">
<pre class="snapshot">{{ JSON.stringify(row.snapshot_json ?? {}, null, 2) }}</pre>
</template>
</el-table-column>
<el-table-column prop="settlement_month" label="归属月" width="90" />
<el-table-column prop="business_no" label="业务编号" min-width="150" />
<el-table-column prop="personnel_name" label="归属人员" width="100" />
<el-table-column prop="role_code" label="岗位" width="110" />
<el-table-column prop="receipt_date" label="回款日" width="100" />
<el-table-column prop="payment_days" label="天数" width="70" />
<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 }">
v{{ row.calc_version }}
<el-tag v-if="row.is_active === 0" size="small" type="info" disable-transitions>
已替代
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="90" fixed="right" align="center">
<template #default="{ row }">
<el-button
v-if="row.is_active === 1 && row.calc_type === 1"
link
type="primary"
@click="openRecalc(row)"
>
重算
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页落进 card-foot:与表体同一张卡,卡底微渐变收边 -->
<div class="card-foot">
<AppPagination
:total="total"
:page-num="pageNum"
:page-size="pageSize"
@page-change="onPageChange"
@size-change="onSizeChange"
/>
</div>
</div>
<AppPagination
:total="total"
:page-num="pageNum"
:page-size="pageSize"
@page-change="onPageChange"
@size-change="onSizeChange"
/>
<el-dialog v-model="recalcVisible" title="重算确认" width="460px">
<p class="recalc-warn">
@ -212,8 +166,9 @@ async function submitRecalc(): Promise<void> {
</template>
<style scoped>
/* 筛选条(.filter-bar)、卡片与表尾(.card/.card-foot)的皮肤来自全局 index.css,
此处只保留本页特有的快照块与重算提示 */
.filters {
margin-bottom: 4px;
}
.snapshot {
margin: 0;
@ -231,5 +186,4 @@ async function submitRecalc(): Promise<void> {
font-size: 12px;
color: var(--ys-ink-3);
}
</style>

View File

@ -83,53 +83,23 @@ 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 {
return {
textStyle: { fontFamily: CHART_FONT },
grid: { left: 8, right: 12, top: 18, bottom: 4, containLabel: true },
tooltip: { trigger: 'axis', valueFormatter: (value: unknown) => Number(value).toFixed(2) },
...axisCommon(trend),
xAxis: {
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: [
{
name: '绩效',
@ -138,35 +108,48 @@ function performanceOption(trend: TrendPoint[]): EChartsCoreOption {
symbol: 'none',
data: trend.map((item) => Number(item.performance_total ?? 0)),
lineStyle: { color: '#2e4ba8', width: 2 },
areaStyle: { color: verticalGradient('rgba(46,75,168,.22)', 'rgba(46,75,168,0)') },
areaStyle: {
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 {
// 最近一个月用饱和靛蓝强调,其余压成浅靛蓝:一眼就能读出「本月」在哪
const lastIndex = trend.length - 1
return {
textStyle: { fontFamily: CHART_FONT },
grid: { left: 8, right: 12, top: 18, bottom: 4, containLabel: true },
tooltip: { trigger: 'axis', valueFormatter: (value: unknown) => Number(value).toFixed(2) },
...axisCommon(trend),
xAxis: {
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: [
{
name: '回款',
type: 'bar',
data: trend.map((item) => Number(item.receipt_total ?? 0)),
barMaxWidth: 22,
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],
},
})),
itemStyle: { color: '#2e4ba8', borderRadius: [4, 4, 0, 0] },
},
],
}
@ -400,7 +383,42 @@ function formatTime(value: string): string {
</template>
<style scoped>
/* 页面头(.page-head/.page-title/.page-desc/.waterline)、.toolbar 均来自全局 index.css */
.page-head {
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 {
display: grid;
@ -411,52 +429,22 @@ function formatTime(value: string): string {
.bento-card {
position: relative;
display: flex;
flex-direction: column;
padding: 16px 18px;
overflow: hidden;
background: linear-gradient(180deg, var(--ys-paper), #fcfcfe);
background: linear-gradient(180deg, #ffffff, #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;
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;
}
.bento-card:hover {
transform: translateY(-2px);
box-shadow: var(--ys-shadow-raise);
box-shadow:
0 2px 4px rgba(28, 35, 51, 0.05),
0 16px 30px -18px rgba(28, 35, 51, 0.22);
}
.b-2x2 {
@ -513,19 +501,10 @@ function formatTime(value: string): string {
color: var(--ys-ink-3);
}
/*
* 空态在卡片剩余高度里居中:待办/预警是 2×2 的卡,只有一行提示时
* 内容全挤在顶部、下方留一大片空白,看起来像没加载完。
*/
.b-empty-mini {
display: flex;
flex: 1;
align-items: center;
justify-content: center;
margin-top: 8px;
font-size: 12px;
color: var(--ys-ink-3);
text-align: center;
}
.chart {
@ -604,7 +583,7 @@ function formatTime(value: string): string {
}
.activity-time {
width: 108px;
width: 118px;
color: var(--ys-ink-3);
font-variant-numeric: tabular-nums;
}
@ -629,23 +608,14 @@ function formatTime(value: string): string {
padding: 6px 14px;
font-size: 13px;
color: var(--ys-indigo);
cursor: pointer;
background: var(--ys-indigo-wash);
border: 1px solid transparent;
border-radius: var(--ys-radius-sm);
transition:
border-color 150ms var(--ys-ease),
background-color 150ms var(--ys-ease);
border-radius: 6px;
cursor: pointer;
}
.b-op:hover {
background: #e6ecf9;
border-color: rgba(46, 75, 168, 0.35);
}
.b-op:focus-visible {
outline: 2px solid var(--ys-indigo);
outline-offset: 2px;
border-color: var(--ys-indigo);
}
@media (max-width: 1024px) {

View File

@ -217,11 +217,11 @@ onMounted(() => {
position: absolute;
top: -180px;
left: 50%;
width: 860px;
height: 460px;
width: 900px;
height: 480px;
transform: translateX(-50%);
pointer-events: none;
background: radial-gradient(closest-side, rgba(46, 75, 168, 0.08), rgba(46, 75, 168, 0));
background: radial-gradient(closest-side, rgba(46, 75, 168, 0.09), rgba(46, 75, 168, 0));
}
/* 右上涟漪:同心圆水波,部分出画 */
@ -229,8 +229,8 @@ onMounted(() => {
position: absolute;
top: -190px;
right: -150px;
width: 520px;
height: 520px;
width: 560px;
height: 560px;
pointer-events: none;
}
@ -242,17 +242,17 @@ onMounted(() => {
}
.login-ripple i:nth-child(2) {
inset: 65px;
inset: 70px;
border-color: rgba(46, 75, 168, 0.075);
}
.login-ripple i:nth-child(3) {
inset: 130px;
inset: 140px;
border-color: rgba(46, 75, 168, 0.055);
}
.login-ripple i:nth-child(4) {
inset: 195px;
inset: 210px;
border: none;
background: radial-gradient(circle, rgba(46, 75, 168, 0.05), rgba(46, 75, 168, 0.015));
}
@ -266,12 +266,6 @@ onMounted(() => {
/* 登录卡:纸白微渐变 + 半透明描边 + 分层阴影 + 入场上浮 */
.login-card {
/*
* 卡内控件比列表页高一档(38px):登录是单任务场景,输入框更宽敞可减少误触;
* 覆盖组件基准变量,让输入框/按钮/验证码框一起长高,避免逐个子选择器硬编码。
*/
--el-component-size: 38px;
width: 400px;
max-width: 100%;
padding: 30px 36px 32px;
@ -313,16 +307,20 @@ onMounted(() => {
}
.card-brand__slogan {
margin: 8px 0 0;
margin-top: 8px;
font-size: 12.5px;
letter-spacing: 0.02em;
color: var(--ys-ink-3);
}
/* 签名元素:渐变来自全局 .waterline,此处只收窄到 40px 并水平居中 */
/* 签名元素:渐隐靛蓝水线 */
.waterline {
display: block;
width: 40px;
height: 2px;
margin: 14px auto 0;
border-radius: 1px;
background: linear-gradient(90deg, var(--ys-indigo), rgba(46, 75, 168, 0));
}
.card-head {
@ -363,14 +361,14 @@ onMounted(() => {
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 104px;
height: 38px;
width: 120px;
height: 40px;
padding: 0;
overflow: hidden;
cursor: pointer;
background: linear-gradient(180deg, #f7f8fb, #f1f3f8);
border: 1px solid var(--ys-line);
border-radius: var(--ys-radius-sm);
border-radius: 7px;
box-shadow: inset 0 1px 2px rgba(28, 35, 51, 0.04);
transition: border-color 150ms var(--ys-ease, ease);
}
@ -407,7 +405,16 @@ onMounted(() => {
color: var(--ys-ink-3);
}
/* 输入框的描边/焦点环已由全局 index.css 统一(登录卡只把控件高度调到 38px) */
/* 输入聚焦落在体系主色上(Element Plus 主色已随全局 token 换为靛蓝) */
.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) {
.login {

View File

@ -155,20 +155,7 @@ function today(): string {
<template>
<div class="page">
<!-- 页面头:一句话说清这张报表回答什么问题、口径是什么;水线每页只出现一次 -->
<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 inline class="filters" @submit.prevent>
<el-form-item label="关键字">
<el-input
v-model="filters.keyword"
@ -231,151 +218,103 @@ function today(): string {
style="width: 240px"
/>
</el-form-item>
<div class="filter-bar__actions">
<el-form-item>
<el-button type="primary" @click="search">查询</el-button>
<el-button @click="resetFilters">重置</el-button>
<el-button :loading="exporting" @click="doExport">导出 Excel</el-button>
<el-button @click="doPrint">打印</el-button>
</div>
</el-form-item>
</el-form>
<!-- 表格包进卡片:报表页的信息密度靠卡片边界收拢,分页条共用卡脚材质 -->
<div class="card">
<el-table
v-loading="loading"
class="dense"
:data="records"
stripe
empty-text="还没有符合条件的业务 —— 先清空筛选看全量,或点「重置」重来"
@expand-change="loadDrill"
>
<el-table-column type="expand">
<template #default="{ row }">
<div class="drill-wrap">
<p class="section-title">回款明细</p>
<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_date" label="回款日" width="110" />
<el-table-column prop="amount" label="回款金额" width="120" align="right" />
<el-table-column prop="settlement_status" label="入结状态" width="100" />
<el-table-column prop="remark" label="备注" min-width="140" />
</el-table>
<p class="section-title">台账明细</p>
<el-table :data="ledgersMap[row.business_id] ?? []" size="small" stripe>
<el-table-column prop="personnel_name" label="归属人员" width="120" />
<el-table-column prop="receipt_date" label="回款日" width="110" />
<el-table-column prop="payment_days" label="天数" width="80" align="right" />
<el-table-column prop="rate_text" label="计提比例" width="110" />
<el-table-column
prop="performance_amount"
label="绩效金额"
width="120"
align="right"
/>
<el-table-column prop="payable_amount" label="应付绩效" width="120" align="right" />
</el-table>
</div>
</template>
</el-table-column>
<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="customer_name" label="客户" min-width="170" />
<el-table-column prop="owner_personnel_name" label="归属人" width="110" />
<el-table-column label="状态" width="100">
<template #default="{ row }">{{ businessStatusLabel(row.status) }}</template>
</el-table-column>
<el-table-column prop="total_sale_amount" label="销售额" width="120" align="right">
<template #default="{ row }">
<span class="cell-num">{{ row.total_sale_amount }}</span>
</template>
</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>
<div class="hint">回款额与绩效为业务累计值,不随业务日期筛选变化</div>
<!-- 合计与分页共用卡脚:合计靠 margin-right:auto 占到左侧,分页恒靠右 -->
<div class="card-foot">
<div v-if="summary" class="summary-bar">
全部筛选结果:{{ summary.row_count }} 笔业务 | 发货合计
{{ summary.ship_total ?? '0.00' }} | 回款合计 {{ summary.received_total ?? '0.00' }} |
绩效合计 {{ summary.performance_total ?? '0.00' }}
<span class="summary-note">(合计为全部筛选结果,非当前页)</span>
</div>
<el-table v-loading="loading" :data="records" stripe @expand-change="loadDrill">
<el-table-column type="expand">
<template #default="{ row }">
<div class="drill-wrap">
<h4>回款明细</h4>
<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_date" label="回款日" width="110" />
<el-table-column prop="amount" label="回款金额" width="120" align="right" />
<el-table-column prop="settlement_status" label="入结状态" width="100" />
<el-table-column prop="remark" label="备注" min-width="140" />
</el-table>
<h4>台账明细</h4>
<el-table :data="ledgersMap[row.business_id] ?? []" size="small" stripe>
<el-table-column prop="personnel_name" label="归属人员" width="120" />
<el-table-column prop="receipt_date" label="回款日" width="110" />
<el-table-column prop="payment_days" label="天数" width="80" align="right" />
<el-table-column prop="rate_text" label="计提比例" width="110" />
<el-table-column prop="performance_amount" label="绩效金额" width="120" align="right" />
<el-table-column prop="payable_amount" label="应付绩效" width="120" align="right" />
</el-table>
</div>
</template>
</el-table-column>
<el-table-column prop="business_no" label="业务编号" min-width="160" />
<el-table-column prop="business_date" label="业务日期" width="110" />
<el-table-column prop="customer_name" label="客户" min-width="170" />
<el-table-column prop="owner_personnel_name" label="归属人" width="110" />
<el-table-column label="状态" width="100">
<template #default="{ row }">{{ businessStatusLabel(row.status) }}</template>
</el-table-column>
<el-table-column prop="total_sale_amount" label="销售额" width="120" align="right" />
<el-table-column prop="total_ship_amount" label="发货额" width="120" align="right" />
<el-table-column prop="total_received_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>
<!-- AppPagination 只派发 page-change/size-change(无 update:pageNum) -->
<AppPagination
:total="total"
:page-num="pageNum"
:page-size="pageSize"
@page-change="onPageChange"
@size-change="onSizeChange"
/>
</div>
<div v-if="summary" class="summary-bar">
全部筛选结果:{{ summary.row_count }} 笔业务 | 发货合计 {{ summary.ship_total ?? '0.00' }}
| 回款合计 {{ summary.received_total ?? '0.00' }} | 绩效合计
{{ summary.performance_total ?? '0.00' }}
<span class="summary-note">(合计为全部筛选结果,非当前页)</span>
</div>
<!-- AppPagination 只派发 page-change/size-change(无 update:pageNum) -->
<AppPagination
:total="total"
:page-num="pageNum"
:page-size="pageSize"
@page-change="onPageChange"
@size-change="onSizeChange"
/>
</div>
</template>
<style scoped>
/*
* 密集表(设计稿 §09-3):报表要的是信息密度,行高与字号各降半档。
* 只写在页面 scoped 里 —— 写进全局会波及其它列表页的行高。
*/
.dense :deep(.el-table__cell) {
padding: 7px 0;
.filters {
margin-bottom: 4px;
}
.dense :deep(.el-table__cell .cell) {
font-size: 12.5px;
.hint {
margin-bottom: 8px;
font-size: 12px;
color: var(--ys-ink-3);
}
/* 绩效列加粗:设计稿要求「密集不等于看不清」 */
.cell-strong {
font-weight: 600;
}
/* 展开区:两块下钻表跟着密集表收内距,字号由 size="small" 与 .dense 决定 */
.drill-wrap {
padding: 10px 14px 6px;
padding: 8px 16px;
}
.drill-wrap .section-title {
margin-top: 8px;
}
/* 合计条:比正文加粗、字号放大半档,读者一眼能把「合计」与数据行分开 */
.summary-bar {
margin-right: auto;
font-size: 13.5px;
.drill-wrap h4 {
margin: 8px 0;
font-size: 13px;
font-weight: 600;
color: var(--ys-ink);
}
.summary-bar {
margin-top: 10px;
padding: 8px 12px;
font-size: 13px;
background: var(--ys-indigo-wash);
border-radius: 6px;
}
.summary-note {
font-size: 12px;
font-weight: 400;
color: var(--ys-ink-3);
font-size: 12px;
}
</style>

View File

@ -135,20 +135,7 @@ function pct(value: string): string {
<template>
<div class="page">
<!-- 页面头:一句话说清这张报表回答什么问题、口径是什么;水线每页只出现一次 -->
<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 inline class="filters" @submit.prevent>
<el-form-item label="统计月份" required>
<el-date-picker
v-model="filters.settlement_month"
@ -177,20 +164,17 @@ function pct(value: string): string {
/>
</el-select>
</el-form-item>
<div class="filter-bar__actions">
<el-form-item>
<el-button type="primary" :loading="loading" @click="load">查询</el-button>
<el-button :loading="exporting" @click="doExport">导出 Excel</el-button>
<el-button @click="doPrint">打印</el-button>
</div>
</el-form-item>
</el-form>
<!-- 参数口径行:动态值,不能并进页面描述(描述是稳定的,参数随在线配置变) -->
<div v-if="data?.params" class="toolbar">
<span class="toolbar-note">
分成参数:团队 {{ data.params.team_text }}% | 全公司 {{ data.params.company_text }}% |
经理个人户{{ data.params.manager_personal_included === 1 ? '计入' : '不计入' }}基数 |
取值时点 {{ data.params.params_as_of }}
</span>
<div v-if="data?.params" class="params-hint">
分成参数:团队 {{ data.params.team_text }}% | 全公司 {{ data.params.company_text }}% |
经理个人户{{ data.params.manager_personal_included === 1 ? '计入' : '不计入' }}基数 |
取值时点 {{ data.params.params_as_of }}
</div>
<div v-loading="loading" class="company-card">
@ -207,149 +191,83 @@ function pct(value: string): string {
</span>
</div>
<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">
<template #default="{ row }">
<div class="member-wrap">
<el-table :data="row.members" size="small" stripe>
<el-table-column prop="personnel_name" label="成员" width="120" />
<el-table-column prop="role_code" label="岗位" width="120" />
<el-table-column prop="performance_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">
<template #default="{ row: member }">
<span class="cell-num">{{ member.adjust_extra }}</span>
</template>
</el-table-column>
<el-table-column label="调整扣减" width="110" align="right">
<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 label="操作" width="100" fixed="right">
<template #default="{ row: member }">
<el-button link type="primary" @click="openPersonalDetail(member)">
查看明细
</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-if="row.members.length === 0" description="该经理本月无团队成员业绩" />
</div>
</template>
</el-table-column>
<el-table-column prop="manager_name" label="经理团队" width="130">
<template #default="{ row }">
{{ row.manager_name }}
<el-tag v-if="row.manager_status === 0" size="small" type="info" disable-transitions>
离职
</el-tag>
</template>
</el-table-column>
<el-table-column prop="member_count" label="团队人数" width="90" align="right">
<template #default="{ row }">
<span class="cell-num">{{ row.member_count }}</span>
</template>
</el-table-column>
<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 label="月结冻结额" width="130" align="right">
<template #default="{ row }">
<span v-if="row.frozen_amount !== null" class="cell-num frozen">
{{ row.frozen_amount }}
</span>
<span v-else class="cell-num frozen-none">未生成月结</span>
</template>
</el-table-column>
</el-table>
</div>
<el-table v-loading="loading" :data="data?.teams ?? []" row-key="manager_id" stripe>
<el-table-column type="expand">
<template #default="{ row }">
<div class="member-wrap">
<el-table :data="row.members" size="small" stripe>
<el-table-column prop="personnel_name" 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="payable_sum" label="应付绩效" width="120" align="right" />
<el-table-column label="调整补发" width="110" align="right">
<template #default="{ row: member }">{{ member.adjust_extra }}</template>
</el-table-column>
<el-table-column label="调整扣减" width="110" align="right">
<template #default="{ row: member }">{{ member.adjust_deduct }}</template>
</el-table-column>
<el-table-column prop="net_total" label="净合计" width="120" align="right" />
<el-table-column label="操作" width="100" fixed="right">
<template #default="{ row: member }">
<el-button link type="primary" @click="openPersonalDetail(member)">
查看明细
</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-if="row.members.length === 0" description="该经理本月无团队成员业绩" />
</div>
</template>
</el-table-column>
<el-table-column prop="manager_name" label="经理团队" width="130">
<template #default="{ row }">
{{ row.manager_name }}
<el-tag v-if="row.manager_status === 0" size="small" type="info" disable-transitions>
离职
</el-tag>
</template>
</el-table-column>
<el-table-column prop="member_count" label="团队人数" width="90" align="right" />
<el-table-column prop="team_base" label="团队基数" width="120" align="right" />
<el-table-column prop="team_share_amount" :label="teamShareLabel" width="150" align="right" />
<el-table-column prop="company_share_amount" :label="companyShareLabel" width="150" align="right" />
<el-table-column label="占全公司比" width="110" align="right">
<template #default="{ row }">{{ pct(row.company_base_ratio) }}</template>
</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">
<template #default="{ row }">
<span v-if="row.frozen_amount !== null" class="frozen">{{ row.frozen_amount }}</span>
<span v-else class="frozen-none">未生成月结</span>
</template>
</el-table-column>
</el-table>
<div v-if="data && data.unassigned.length" class="unassigned">
<p class="section-title">未纳入团队基数(无经理 / 非营销经理上级的下属)</p>
<h4>未纳入团队基数(无经理 / 非营销经理上级的下属)</h4>
<el-table :data="data.unassigned" size="small" stripe>
<el-table-column prop="personnel_name" label="人员" width="130" />
<el-table-column prop="role_code" label="岗位" width="120" />
<el-table-column prop="performance_sum" 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-column prop="performance_sum" label="绩效金额" width="130" align="right" />
<el-table-column prop="net_total" label="净合计" width="130" align="right" />
</el-table>
</div>
</div>
</template>
<style scoped>
/*
* 密集表(设计稿 §09-3):报表要的是信息密度,行高与字号各降半档。
* 只写在页面 scoped 里 —— 写进全局会波及其它列表页的行高。
*/
.dense :deep(.el-table__cell) {
padding: 7px 0;
.filters {
margin-bottom: 4px;
}
.dense :deep(.el-table__cell .cell) {
font-size: 12.5px;
}
/* 绩效列加粗:设计稿要求「密集不等于看不清」 */
.cell-strong {
font-weight: 600;
.params-hint {
margin-bottom: 10px;
padding: 8px 12px;
font-size: 12px;
color: var(--ys-ink-2);
background: var(--ys-mist);
border-radius: 6px;
}
.company-card {
@ -359,8 +277,7 @@ function pct(value: string): string {
margin-bottom: 12px;
padding: 12px 16px;
background: var(--ys-indigo-wash);
border: 1px solid var(--ys-line);
border-radius: var(--ys-radius);
border-radius: 8px;
font-size: 13px;
}
@ -373,9 +290,8 @@ function pct(value: string): string {
font-size: 12px;
}
/* 展开区:内容字号与内距跟着密集表再收半档,不与主表抢注意力 */
.member-wrap {
padding: 8px 14px;
padding: 8px 16px;
}
.frozen {
@ -390,4 +306,10 @@ function pct(value: string): string {
.unassigned {
margin-top: 20px;
}
.unassigned h4 {
margin: 0 0 8px;
font-size: 14px;
font-weight: 600;
}
</style>

View File

@ -161,20 +161,7 @@ function today(): string {
<template>
<div class="page">
<!-- 页面头:一句话说清这张报表回答什么问题、口径是什么;水线每页只出现一次 -->
<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 inline class="filters" @submit.prevent>
<el-form-item label="月份">
<el-date-picker
v-model="filters.settlement_month"
@ -218,148 +205,94 @@ function today(): string {
<el-option label="混合业务" value="mixed" />
</el-select>
</el-form-item>
<div class="filter-bar__actions">
<el-form-item>
<el-button type="primary" :loading="loading" @click="load">查询</el-button>
<el-button :loading="exporting" @click="doExport">导出 Excel</el-button>
<el-button @click="doPrint">打印</el-button>
</div>
</el-form-item>
</el-form>
<!-- 表格包进卡片:报表页的信息密度靠卡片边界收拢,合计行共用卡脚材质 -->
<div class="card">
<el-table
v-loading="loading"
class="bucket-table dense"
data-testid="payment-bucket-table"
:data="data?.buckets ?? []"
row-key="code"
stripe
empty-text="所选条件下还没有回款记录 —— 先选择月份再查一次"
@expand-change="loadDrill"
>
<el-table-column type="expand">
<template #default="{ row }">
<div class="drill-wrap">
<el-table
v-loading="drillOf(row.code).loading"
:data="drillOf(row.code).rows"
size="small"
stripe
>
<el-table-column prop="business_no" label="合同编号" min-width="150" />
<el-table-column prop="receipt_batch_no" label="回款批次号" width="160" />
<el-table-column prop="receipt_date" label="回款日" width="110" />
<el-table-column prop="amount" label="回款金额" width="120" align="right" />
<el-table-column prop="personnel_name" label="归属人员" width="110" />
<el-table-column prop="payment_days" label="天数" width="80" align="right" />
<el-table-column label="客户类型" width="100">
<template #default="{ row: detail }">
{{ customerTypeLabel(detail.customer_type) }}
</template>
</el-table-column>
<el-table-column label="产品类别" width="100">
<template #default="{ row: detail }">
{{ reportApi.reportCategoryLabel(detail.product_category) }}
</template>
</el-table-column>
<el-table-column
prop="performance_amount"
label="绩效金额"
width="120"
align="right"
/>
</el-table>
<AppPagination
:total="drillOf(row.code).total"
:page-num="drillOf(row.code).pageNum"
:page-size="drillOf(row.code).pageSize"
@page-change="onDrillPage(row.code, $event)"
@size-change="onDrillSize(row.code, $event)"
/>
</div>
</template>
</el-table-column>
<el-table-column prop="label" label="回款天数区间" min-width="150" />
<el-table-column prop="receipt_amount" label="回款金额" width="140" align="right">
<template #default="{ row }">
<span class="cell-num">{{ row.receipt_amount }}</span>
</template>
</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>
<div class="hint">
统计口径:已计提回款(有台账行)的金额与绩效;进度登记回款不计入。空区间显示 0。
</div>
<!-- 合计行:本表无分页,卡脚只承载合计;加粗并放大半档,与数据行区分 -->
<div class="card-foot">
<div v-if="data" class="summary-bar">
合计:回款 {{ data.totals.receipt_amount ?? '0.00' }} | 笔数
{{ data.totals.receipt_count ?? 0 }} | 绩效
{{ data.totals.performance_amount ?? '0.00' }} | 应付
{{ data.totals.payable_amount ?? '0.00' }}
</div>
</div>
<el-table
v-loading="loading"
class="bucket-table"
data-testid="payment-bucket-table"
:data="data?.buckets ?? []"
row-key="code"
stripe
@expand-change="loadDrill"
>
<el-table-column type="expand">
<template #default="{ row }">
<div class="drill-wrap">
<el-table
v-loading="drillOf(row.code).loading"
:data="drillOf(row.code).rows"
size="small"
stripe
>
<el-table-column prop="business_no" label="合同编号" min-width="150" />
<el-table-column prop="receipt_batch_no" label="回款批次号" width="160" />
<el-table-column prop="receipt_date" label="回款日" width="110" />
<el-table-column prop="amount" label="回款金额" width="120" align="right" />
<el-table-column prop="personnel_name" label="归属人员" width="110" />
<el-table-column prop="payment_days" label="天数" width="80" align="right" />
<el-table-column label="客户类型" width="100">
<template #default="{ row }">
{{ customerTypeLabel(row.customer_type) }}
</template>
</el-table-column>
<el-table-column label="产品类别" width="100">
<template #default="{ row }">
{{ reportApi.reportCategoryLabel(row.product_category) }}
</template>
</el-table-column>
<el-table-column prop="performance_amount" label="绩效金额" width="120" align="right" />
</el-table>
<AppPagination
:total="drillOf(row.code).total"
:page-num="drillOf(row.code).pageNum"
:page-size="drillOf(row.code).pageSize"
@page-change="onDrillPage(row.code, $event)"
@size-change="onDrillSize(row.code, $event)"
/>
</div>
</template>
</el-table-column>
<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_count" label="回款笔数" width="100" align="right" />
<el-table-column prop="performance_amount" label="绩效金额" width="130" align="right" />
<el-table-column prop="payable_amount" label="应付绩效" width="130" align="right" />
<el-table-column prop="ledger_count" label="台账行数" width="100" align="right" />
</el-table>
<div v-if="data" class="summary-bar">
合计:回款 {{ data.totals.receipt_amount ?? '0.00' }} | 笔数
{{ data.totals.receipt_count ?? 0 }} | 绩效 {{ data.totals.performance_amount ?? '0.00' }}
| 应付 {{ data.totals.payable_amount ?? '0.00' }}
</div>
<div v-if="data" class="dimension-grid">
<div>
<p class="section-title">客户类型对比</p>
<h4>客户类型对比</h4>
<el-table :data="data.by_customer_type" size="small" stripe>
<el-table-column prop="label" label="客户类型" width="120" />
<el-table-column prop="receipt_amount" label="回款金额" width="130" align="right">
<template #default="{ row }">
<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-column prop="receipt_amount" label="回款金额" width="130" align="right" />
<el-table-column prop="performance_amount" label="绩效金额" width="130" align="right" />
<el-table-column prop="ledger_count" label="台账行数" width="100" align="right" />
</el-table>
</div>
<div>
<p class="section-title">产品类别对比</p>
<h4>产品类别对比</h4>
<el-table :data="data.by_product_category" size="small" stripe>
<el-table-column prop="label" label="产品类别" width="120" />
<el-table-column prop="receipt_amount" label="回款金额" width="130" align="right">
<template #default="{ row }">
<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-column prop="receipt_amount" label="回款金额" width="130" align="right" />
<el-table-column prop="performance_amount" label="绩效金额" width="130" align="right" />
<el-table-column prop="ledger_count" label="台账行数" width="100" align="right" />
</el-table>
</div>
</div>
@ -367,34 +300,26 @@ function today(): string {
</template>
<style scoped>
/*
* 密集表(设计稿 §09-3):报表要的是信息密度,行高与字号各降半档。
* 只写在页面 scoped 里 —— 写进全局会波及其它列表页的行高。
*/
.dense :deep(.el-table__cell) {
padding: 7px 0;
.filters {
margin-bottom: 4px;
}
.dense :deep(.el-table__cell .cell) {
font-size: 12.5px;
.hint {
margin-bottom: 8px;
font-size: 12px;
color: var(--ys-ink-3);
}
/* 绩效列加粗:设计稿要求「密集不等于看不清」 */
.cell-strong {
font-weight: 600;
}
/* 展开区:内距跟着密集表收半档,字号由 size="small" 与 .dense 决定 */
.drill-wrap {
padding: 10px 14px 6px;
padding: 8px 16px;
}
/* 合计条:比正文加粗、字号放大半档,读者一眼能把「合计」与数据行分开 */
.summary-bar {
margin-right: auto;
font-size: 13.5px;
font-weight: 600;
color: var(--ys-ink);
margin-top: 10px;
padding: 8px 12px;
font-size: 13px;
background: var(--ys-indigo-wash);
border-radius: 6px;
}
.dimension-grid {
@ -403,4 +328,10 @@ function today(): string {
gap: 20px;
margin-top: 20px;
}
.dimension-grid h4 {
margin: 0 0 8px;
font-size: 14px;
font-weight: 600;
}
</style>

View File

@ -170,18 +170,7 @@ function today(): string {
<template>
<div class="page">
<!-- 页面头:一句话说清这张报表回答什么问题、口径是什么;水线每页只出现一次 -->
<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 inline class="filters" @submit.prevent>
<el-form-item label="月份">
<el-date-picker
v-model="filters.settlement_month"
@ -240,165 +229,103 @@ function today(): string {
/>
</el-select>
</el-form-item>
<div class="filter-bar__actions">
<el-form-item>
<el-button type="primary" @click="search">查询</el-button>
<el-button @click="resetFilters">重置</el-button>
<el-button :loading="exporting" @click="doExport">导出 Excel</el-button>
<el-button @click="doPrint">打印</el-button>
</div>
</el-form-item>
</el-form>
<!-- 表格包进卡片:报表页的信息密度靠卡片边界收拢,分页条共用卡脚材质 -->
<div class="card">
<el-table
v-loading="loading"
class="dense"
:data="records"
stripe
empty-text="还没有符合条件的明细 —— 换个月份或放宽人员再查一次"
@expand-change="loadSnapshot"
>
<el-table-column type="expand">
<template #default="{ row }">
<pre class="snapshot">{{
JSON.stringify(snapshots[row.ledger_id]?.snapshot_json ?? '加载中…', null, 2)
}}</pre>
</template>
</el-table-column>
<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="receipt_date" label="回款日" width="105" />
<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">
<template #default="{ row }">{{
reportApi.reportCategoryLabel(row.product_category)
}}</template>
</el-table-column>
<el-table-column label="客户类型" width="100">
<template #default="{ row }">{{ customerTypeLabel(row.customer_type) }}</template>
</el-table-column>
<el-table-column prop="role_name" label="岗位" width="100" />
<el-table-column prop="rate_text" label="计提比例" width="110">
<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">
<template #default="{ row }">
<!-- 绩效列加粗:一屏几十行时,加粗是读者定位「钱」的唯一线索 -->
<span
class="cell-num cell-strong"
:class="{ negative: Number(row.performance_amount) < 0 }"
>
{{ row.performance_amount ?? '—' }}
</span>
</template>
</el-table-column>
<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">
<template #default="{ row }">
<el-tag v-if="row.is_probation === 1" size="small" type="warning" disable-transitions>
试用期
</el-tag>
<el-tag v-if="row.missing_cost === 1" size="small" type="info" disable-transitions>
缺成本
</el-tag>
</template>
</el-table-column>
</el-table>
<el-table v-loading="loading" :data="records" stripe @expand-change="loadSnapshot">
<el-table-column type="expand">
<template #default="{ row }">
<pre class="snapshot">{{
JSON.stringify(snapshots[row.ledger_id]?.snapshot_json ?? '加载中…', null, 2)
}}</pre>
</template>
</el-table-column>
<el-table-column prop="business_no" label="合同编号" min-width="160" />
<el-table-column prop="ship_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 label="产品类别" width="100">
<template #default="{ row }">{{ reportApi.reportCategoryLabel(row.product_category) }}</template>
</el-table-column>
<el-table-column label="客户类型" width="100">
<template #default="{ row }">{{ customerTypeLabel(row.customer_type) }}</template>
</el-table-column>
<el-table-column prop="role_name" label="岗位" width="100" />
<el-table-column prop="rate_text" label="计提比例" width="110" />
<el-table-column prop="weight_r" label="支付权重R" width="95" align="right" />
<el-table-column prop="performance_amount" label="绩效金额" width="110" align="right">
<template #default="{ row }">
<span :class="{ negative: Number(row.performance_amount) < 0 }">
{{ row.performance_amount ?? '—' }}
</span>
</template>
</el-table-column>
<el-table-column prop="payable_amount" label="应付绩效" width="110" align="right" />
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag v-if="row.is_probation === 1" size="small" type="warning" disable-transitions>
试用期
</el-tag>
<el-tag v-if="row.missing_cost === 1" size="small" type="info" disable-transitions>
缺成本
</el-tag>
</template>
</el-table-column>
</el-table>
<!-- 合计与分页共用卡脚:合计靠 margin-right:auto 占到左侧,分页恒靠右 -->
<div class="card-foot">
<div v-if="summary" class="summary-bar">
全部筛选结果:{{ summary.row_count }} 行 | 绩效合计
{{ summary.performance_total ?? '0.00' }} | 应付合计
{{ summary.payable_total ?? '0.00' }}
<span class="summary-note">(合计为全部筛选结果,非当前页)</span>
</div>
<!-- AppPagination 只派发 page-change/size-change(无 update:pageNum),
不能用 v-model 或 @change——翻了页不会加载 -->
<AppPagination
:total="total"
:page-num="pageNum"
:page-size="pageSize"
@page-change="onPageChange"
@size-change="onSizeChange"
/>
</div>
<div v-if="summary" class="summary-bar">
全部筛选结果:{{ summary.row_count }} 行 | 绩效合计 {{ summary.performance_total ?? '0.00' }}
| 应付合计 {{ summary.payable_total ?? '0.00' }}
<span class="summary-note">(合计为全部筛选结果,非当前页)</span>
</div>
<!-- AppPagination 只派发 page-change/size-change(无 update:pageNum),
不能用 v-model 或 @change——翻了页不会加载 -->
<AppPagination
:total="total"
:page-num="pageNum"
:page-size="pageSize"
@page-change="onPageChange"
@size-change="onSizeChange"
/>
</div>
</template>
<style scoped>
/*
* 密集表(设计稿 §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;
.filters {
margin-bottom: 4px;
}
.snapshot {
margin: 0;
padding: 12px 14px;
font-size: 12.5px;
padding: 10px;
font-size: 12px;
line-height: 1.6;
background: var(--ys-mist);
border-radius: var(--ys-radius-sm);
background: #f4f5f8;
border-radius: 6px;
white-space: pre-wrap;
word-break: break-all;
}
.negative {
color: var(--ys-danger);
color: #c45656;
}
/* 合计条:比正文加粗、字号放大半档,读者一眼能把「合计」与数据行分开 */
.summary-bar {
margin-right: auto;
font-size: 13.5px;
font-weight: 600;
color: var(--ys-ink);
margin-top: 10px;
padding: 8px 12px;
font-size: 13px;
background: var(--ys-indigo-wash);
border-radius: 6px;
}
.summary-note {
font-size: 12px;
font-weight: 400;
color: var(--ys-ink-3);
font-size: 12px;
}
</style>

View File

@ -171,485 +171,387 @@ onMounted(load)
<template>
<div class="print-page" v-loading="loading">
<!-- 屏幕操作条:打印时随 .no-print 一起消失,纸面上不出现任何按钮 -->
<div class="no-print print-bar">
<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 class="no-print toolbar">
<el-button type="primary" @click="doPrint">打印 / 另存为 PDF</el-button>
<span class="toolbar-hint">在打印对话框选择「另存为 PDF」即可导出 PDF</span>
</div>
<!-- 稿纸:与屏幕稿不同源 —— 白底、实线表格、章节编号、签字栏,无圆角与阴影 -->
<div class="sheet">
<header class="sheet-header">
<img v-if="logoUrl" :src="logoUrl" class="logo" alt="公司 logo" />
<div class="company">{{ companyName }}</div>
<h1 class="title">{{ title }}</h1>
<div class="sheet-meta">筛选条件:{{ filterText }}</div>
<div class="sheet-meta">
打印时间:{{ printedAt.toLocaleString('zh-CN') }} | 模板版本 V1.2
</div>
</header>
<div v-if="truncated" class="truncated">
数据量超过打印上限({{ PRINT_MAX_ROWS }} 行),本页只含前 {{ PRINT_MAX_ROWS }}
行;请缩小筛选范围后重新打印。
<header class="sheet-header">
<img v-if="logoUrl" :src="logoUrl" class="logo" alt="公司 logo" />
<div class="company">{{ companyName }}</div>
<h1 class="title">{{ title }}</h1>
<div class="meta">
<span>筛选条件:{{ filterText }}</span>
<span>打印时间:{{ printedAt.toLocaleString('zh-CN') }} | 模板版本 V1.2</span>
</div>
</header>
<template v-if="code === 'personal-detail'">
<div v-if="truncated" class="truncated">
数据量超过打印上限({{ PRINT_MAX_ROWS }} 行),仅打印前 {{ PRINT_MAX_ROWS }}
行;请缩小筛选范围后重新打印。
</div>
<template v-if="code === 'personal-detail'">
<table class="sheet-table">
<thead>
<tr>
<th>合同编号</th><th>发货日</th><th>回款日</th><th>回款天数</th>
<th>产品类别</th><th>客户类型</th><th>岗位</th><th>计提比例</th>
<th>支付权重R</th><th>绩效金额</th><th>应付绩效</th>
</tr>
</thead>
<tbody>
<tr v-for="row in rows" :key="String(row.ledger_id)">
<td>{{ row.business_no }}</td>
<td>{{ row.ship_date }}</td>
<td>{{ row.receipt_date }}</td>
<td class="num">{{ row.payment_days ?? '—' }}</td>
<td>{{ row.product_category }}</td>
<td>{{ row.customer_type }}</td>
<td>{{ row.role_name }}</td>
<td>{{ row.rate_text }}</td>
<td class="num">{{ row.weight_r ?? '—' }}</td>
<td class="num">{{ row.performance_amount ?? '—' }}</td>
<td class="num">{{ row.payable_amount ?? '—' }}</td>
</tr>
</tbody>
</table>
</template>
<template v-else-if="code === 'department-summary' && department">
<h2 class="block-title">一、团队分成汇总</h2>
<table class="sheet-table" data-testid="print-department-teams">
<thead>
<tr>
<th>序号</th><th>经理团队</th><th>状态</th><th>团队人数</th><th>团队基数</th>
<th>团队分成({{ department.params?.team_text ?? '—' }}%)</th>
<th>调整净额(仅核对)</th><th>团队应发小计(含聚合分成)</th><th>月结冻结额</th>
</tr>
</thead>
<tbody>
<tr v-for="(team, index) in department.teams" :key="team.manager_id">
<td>{{ index + 1 }}</td>
<td>{{ team.manager_name }}</td>
<td>{{ team.manager_status === 1 ? '在职' : '离职' }}</td>
<td class="num">{{ team.member_count }}</td>
<td class="num">{{ team.team_base }}</td>
<td class="num">{{ team.team_share_amount }}</td>
<td class="num">{{ team.adjust_net }}</td>
<td class="num">{{ team.team_subtotal }}</td>
<td class="num">{{ team.frozen_amount ?? '—' }}</td>
</tr>
</tbody>
</table>
<h2 class="block-title">
二、全公司分成(每位有聚合行的经理各计一次,共
{{ department.company?.company_share_count ?? 0 }} 次)
</h2>
<table class="sheet-table" data-testid="print-company-block">
<thead>
<tr>
<th>全公司基数</th><th>分成比例</th><th>单经理分成</th>
<th>计入次数</th><th>分成合计</th>
</tr>
</thead>
<tbody>
<tr>
<td class="num">{{ department.company?.company_base ?? '—' }}</td>
<td class="num">{{ department.params?.company_text ?? '—' }}%</td>
<td class="num">{{ department.company?.company_share_amount ?? '—' }}</td>
<td class="num">{{ department.company?.company_share_count ?? 0 }}</td>
<td class="num">{{ department.company?.company_share_total ?? '—' }}</td>
</tr>
</tbody>
</table>
<h2 class="block-title">三、对账汇总</h2>
<table class="sheet-table" data-testid="print-reconciliation">
<thead>
<tr><th>项目</th><th>金额</th><th>说明</th></tr>
</thead>
<tbody>
<tr>
<td>团队成员应付合计</td>
<td class="num">{{ department.reconciliation?.team_member_payable_total ?? '—' }}</td>
<td />
</tr>
<tr>
<td>未分配人员应付合计</td>
<td class="num">{{ department.reconciliation?.unassigned_payable_total ?? '—' }}</td>
<td />
</tr>
<tr>
<td>团队分成合计(分列)</td>
<td class="num">{{ department.reconciliation?.team_share_total ?? '—' }}</td>
<td />
</tr>
<tr>
<td>
全公司分成合计({{ department.company?.company_share_count ?? 0 }} 次,分列)
</td>
<td class="num">{{ department.reconciliation?.company_share_total ?? '—' }}</td>
<td>每位有聚合行的经理各计一次</td>
</tr>
<tr>
<td>聚合行舍入差</td>
<td class="num">{{ department.reconciliation?.aggregate_rounding_diff ?? '—' }}</td>
<td>分列舍入与聚合行一次舍入的差</td>
</tr>
<tr>
<td>调整净额合计(明细层)</td>
<td class="num">{{ department.reconciliation?.adjust_net_total ?? '—' }}</td>
<td />
</tr>
<tr>
<td>应发合计</td>
<td class="num">{{ department.reconciliation?.payable_total ?? '—' }}</td>
<td>上列各项之和</td>
</tr>
<tr>
<td>团队基数合计</td>
<td class="num">{{ department.reconciliation?.team_base_total ?? '—' }}</td>
<td />
</tr>
<tr>
<td>未分配绩效合计</td>
<td class="num">{{ department.reconciliation?.unassigned_performance_total ?? '—' }}</td>
<td />
</tr>
<tr>
<td>其他未展示基数</td>
<td class="num">{{ department.reconciliation?.other_team_base ?? '—' }}</td>
<td>筛选时含其他团队与全公司未分配</td>
</tr>
<tr>
<td>全公司基数</td>
<td class="num">{{ department.reconciliation?.company_base ?? '—' }}</td>
<td />
</tr>
</tbody>
</table>
<h2 class="block-title">四、团队成员明细</h2>
<table class="sheet-table" data-testid="print-department-members">
<thead>
<tr>
<th>经理团队</th><th>成员</th><th>岗位</th><th>来源</th><th>绩效金额</th>
<th>应付绩效</th><th>调整补发</th><th>调整扣减</th><th>调整净额</th><th>净合计</th>
</tr>
</thead>
<tbody>
<tr
v-for="member in departmentMembers"
:key="`${member.personnel_id}-${member.member_kind}`"
>
<td>{{ member.manager_name }}</td>
<td>{{ member.personnel_name }}</td>
<td>{{ member.role_code }}</td>
<td>{{ memberKindLabel(member.member_kind) }}</td>
<td class="num">{{ member.performance_sum }}</td>
<td class="num">{{ member.payable_sum }}</td>
<td class="num">{{ member.adjust_extra }}</td>
<td class="num">{{ member.adjust_deduct }}</td>
<td class="num">{{ member.adjust_net }}</td>
<td class="num">{{ member.net_total }}</td>
</tr>
</tbody>
</table>
<h2 class="block-title">五、未分配人员明细</h2>
<table class="sheet-table" data-testid="print-unassigned">
<thead>
<tr>
<th>人员</th><th>岗位</th><th>未纳入原因</th><th>绩效金额</th><th>应付绩效</th>
<th>调整补发</th><th>调整扣减</th><th>调整净额</th><th>净合计</th>
</tr>
</thead>
<tbody>
<tr
v-for="member in department.unassigned"
:key="`${member.personnel_id}-${member.member_kind}`"
>
<td>{{ member.personnel_name }}</td>
<td>{{ member.role_code }}</td>
<td>{{ member.unassigned_reason ?? '—' }}</td>
<td class="num">{{ member.performance_sum }}</td>
<td class="num">{{ member.payable_sum }}</td>
<td class="num">{{ member.adjust_extra }}</td>
<td class="num">{{ member.adjust_deduct }}</td>
<td class="num">{{ member.adjust_net }}</td>
<td class="num">{{ member.net_total }}</td>
</tr>
<tr v-if="department.unassigned.length === 0">
<td colspan="9">(无)</td>
</tr>
</tbody>
</table>
</template>
<template v-else-if="code === 'business-stats'">
<table class="sheet-table">
<thead>
<tr>
<th>业务编号</th><th>业务日期</th><th>客户</th><th>归属人</th><th>状态</th>
<th>销售额</th><th>发货额</th><th>回款额</th><th>绩效金额</th><th>应付绩效</th>
</tr>
</thead>
<tbody>
<tr v-for="row in rows" :key="String(row.business_id)">
<td>{{ row.business_no }}</td>
<td>{{ row.business_date }}</td>
<td>{{ row.customer_name }}</td>
<td>{{ row.owner_personnel_name }}</td>
<td>{{ row.status }}</td>
<td class="num">{{ row.total_sale_amount }}</td>
<td class="num">{{ row.total_ship_amount }}</td>
<td class="num">{{ row.total_received_amount }}</td>
<td class="num">{{ row.performance_amount }}</td>
<td class="num">{{ row.payable_amount }}</td>
</tr>
</tbody>
</table>
</template>
<template v-else>
<table class="sheet-table">
<thead>
<tr>
<th>回款天数区间</th><th>回款金额</th><th>回款笔数</th>
<th>绩效金额</th><th>应付绩效</th><th>台账行数</th>
</tr>
</thead>
<tbody>
<tr v-for="row in buckets" :key="String(row.code)">
<td>{{ row.label }}</td>
<td class="num">{{ row.receipt_amount }}</td>
<td class="num">{{ row.receipt_count }}</td>
<td class="num">{{ row.performance_amount }}</td>
<td class="num">{{ row.payable_amount }}</td>
<td class="num">{{ row.ledger_count }}</td>
</tr>
</tbody>
</table>
<div v-for="group in groups" :key="group.title" class="group-block">
<h3>{{ group.title }}</h3>
<table class="sheet-table">
<thead>
<tr>
<th>合同编号</th><th>发货日</th><th>回款日</th><th class="num">回款天数</th>
<th>产品类别</th><th>客户类型</th><th>岗位</th><th class="num">计提比例</th>
<th class="num">支付权重R</th><th class="num">绩效金额</th><th class="num">应付绩效</th>
</tr>
<tr><th>维度</th><th>回款金额</th><th>绩效金额</th><th>台账行数</th></tr>
</thead>
<tbody>
<tr v-for="row in rows" :key="String(row.ledger_id)">
<td>{{ row.business_no }}</td>
<td>{{ row.ship_date }}</td>
<td>{{ row.receipt_date }}</td>
<td class="num">{{ row.payment_days ?? '—' }}</td>
<td>{{ row.product_category }}</td>
<td>{{ row.customer_type }}</td>
<td>{{ row.role_name }}</td>
<td>{{ row.rate_text }}</td>
<td class="num">{{ row.weight_r ?? '—' }}</td>
<td class="num">{{ row.performance_amount ?? '—' }}</td>
<td class="num">{{ row.payable_amount ?? '—' }}</td>
</tr>
</tbody>
</table>
</template>
<template v-else-if="code === 'department-summary' && department">
<h2 class="block-title">一、团队分成汇总</h2>
<table class="sheet-table" data-testid="print-department-teams">
<thead>
<tr>
<th>序号</th><th>经理团队</th><th>状态</th>
<th class="num">团队人数</th><th class="num">团队基数</th>
<th class="num">团队分成({{ department.params?.team_text ?? '—' }}%)</th>
<th class="num">调整净额(仅核对)</th>
<th class="num">团队应发小计(含聚合分成)</th>
<th class="num">月结冻结额</th>
</tr>
</thead>
<tbody>
<tr v-for="(team, index) in department.teams" :key="team.manager_id">
<td>{{ index + 1 }}</td>
<td>{{ team.manager_name }}</td>
<td>{{ team.manager_status === 1 ? '在职' : '离职' }}</td>
<td class="num">{{ team.member_count }}</td>
<td class="num">{{ team.team_base }}</td>
<td class="num">{{ team.team_share_amount }}</td>
<td class="num">{{ team.adjust_net }}</td>
<td class="num">{{ team.team_subtotal }}</td>
<td class="num">{{ team.frozen_amount ?? '—' }}</td>
</tr>
</tbody>
</table>
<h2 class="block-title">
二、全公司分成(每位有聚合行的经理各计一次,共
{{ department.company?.company_share_count ?? 0 }} 次)
</h2>
<table class="sheet-table" data-testid="print-company-block">
<thead>
<tr>
<th class="num">全公司基数</th><th class="num">分成比例</th>
<th class="num">单经理分成</th>
<th class="num">计入次数</th><th class="num">分成合计</th>
</tr>
</thead>
<tbody>
<tr>
<td class="num">{{ department.company?.company_base ?? '—' }}</td>
<td class="num">{{ department.params?.company_text ?? '—' }}%</td>
<td class="num">{{ department.company?.company_share_amount ?? '—' }}</td>
<td class="num">{{ department.company?.company_share_count ?? 0 }}</td>
<td class="num">{{ department.company?.company_share_total ?? '—' }}</td>
</tr>
</tbody>
</table>
<h2 class="block-title">三、对账汇总</h2>
<table class="sheet-table" data-testid="print-reconciliation">
<thead>
<tr><th>项目</th><th class="num">金额</th><th>说明</th></tr>
</thead>
<tbody>
<tr>
<td>团队成员应付合计</td>
<td class="num">{{ department.reconciliation?.team_member_payable_total ?? '—' }}</td>
<td />
</tr>
<tr>
<td>未分配人员应付合计</td>
<td class="num">{{ department.reconciliation?.unassigned_payable_total ?? '—' }}</td>
<td />
</tr>
<tr>
<td>团队分成合计(分列)</td>
<td class="num">{{ department.reconciliation?.team_share_total ?? '—' }}</td>
<td />
</tr>
<tr>
<td>
全公司分成合计({{ department.company?.company_share_count ?? 0 }} 次,分列)
</td>
<td class="num">{{ department.reconciliation?.company_share_total ?? '—' }}</td>
<td>每位有聚合行的经理各计一次</td>
</tr>
<tr>
<td>聚合行舍入差</td>
<td class="num">{{ department.reconciliation?.aggregate_rounding_diff ?? '—' }}</td>
<td>分列舍入与聚合行一次舍入的差</td>
</tr>
<tr>
<td>调整净额合计(明细层)</td>
<td class="num">{{ department.reconciliation?.adjust_net_total ?? '—' }}</td>
<td />
</tr>
<tr>
<td>应发合计</td>
<td class="num">{{ department.reconciliation?.payable_total ?? '—' }}</td>
<td>上列各项之和</td>
</tr>
<tr>
<td>团队基数合计</td>
<td class="num">{{ department.reconciliation?.team_base_total ?? '—' }}</td>
<td />
</tr>
<tr>
<td>未分配绩效合计</td>
<td class="num">{{ department.reconciliation?.unassigned_performance_total ?? '—' }}</td>
<td />
</tr>
<tr>
<td>其他未展示基数</td>
<td class="num">{{ department.reconciliation?.other_team_base ?? '—' }}</td>
<td>筛选时含其他团队与全公司未分配</td>
</tr>
<tr>
<td>全公司基数</td>
<td class="num">{{ department.reconciliation?.company_base ?? '—' }}</td>
<td />
</tr>
</tbody>
</table>
<h2 class="block-title">四、团队成员明细</h2>
<table class="sheet-table" data-testid="print-department-members">
<thead>
<tr>
<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>
</thead>
<tbody>
<tr
v-for="member in departmentMembers"
:key="`${member.personnel_id}-${member.member_kind}`"
>
<td>{{ member.manager_name }}</td>
<td>{{ member.personnel_name }}</td>
<td>{{ member.role_code }}</td>
<td>{{ memberKindLabel(member.member_kind) }}</td>
<td class="num">{{ member.performance_sum }}</td>
<td class="num">{{ member.payable_sum }}</td>
<td class="num">{{ member.adjust_extra }}</td>
<td class="num">{{ member.adjust_deduct }}</td>
<td class="num">{{ member.adjust_net }}</td>
<td class="num">{{ member.net_total }}</td>
</tr>
</tbody>
</table>
<h2 class="block-title">五、未分配人员明细</h2>
<table class="sheet-table" data-testid="print-unassigned">
<thead>
<tr>
<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>
</thead>
<tbody>
<tr
v-for="member in department.unassigned"
:key="`${member.personnel_id}-${member.member_kind}`"
>
<td>{{ member.personnel_name }}</td>
<td>{{ member.role_code }}</td>
<td>{{ member.unassigned_reason ?? '—' }}</td>
<td class="num">{{ member.performance_sum }}</td>
<td class="num">{{ member.payable_sum }}</td>
<td class="num">{{ member.adjust_extra }}</td>
<td class="num">{{ member.adjust_deduct }}</td>
<td class="num">{{ member.adjust_net }}</td>
<td class="num">{{ member.net_total }}</td>
</tr>
<tr v-if="department.unassigned.length === 0">
<td colspan="9">(无)</td>
</tr>
</tbody>
</table>
</template>
<template v-else-if="code === 'business-stats'">
<table class="sheet-table">
<thead>
<tr>
<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>
</thead>
<tbody>
<tr v-for="row in rows" :key="String(row.business_id)">
<td>{{ row.business_no }}</td>
<td>{{ row.business_date }}</td>
<td>{{ row.customer_name }}</td>
<td>{{ row.owner_personnel_name }}</td>
<td>{{ row.status }}</td>
<td class="num">{{ row.total_sale_amount }}</td>
<td class="num">{{ row.total_ship_amount }}</td>
<td class="num">{{ row.total_received_amount }}</td>
<td class="num">{{ row.performance_amount }}</td>
<td class="num">{{ row.payable_amount }}</td>
</tr>
</tbody>
</table>
</template>
<template v-else>
<table class="sheet-table">
<thead>
<tr>
<th>回款天数区间</th><th class="num">回款金额</th><th class="num">回款笔数</th>
<th class="num">绩效金额</th><th class="num">应付绩效</th><th class="num">台账行数</th>
</tr>
</thead>
<tbody>
<tr v-for="row in buckets" :key="String(row.code)">
<tr v-for="row in group.rows" :key="String(row.code)">
<td>{{ row.label }}</td>
<td class="num">{{ row.receipt_amount }}</td>
<td class="num">{{ row.receipt_count }}</td>
<td class="num">{{ row.performance_amount }}</td>
<td class="num">{{ row.payable_amount }}</td>
<td class="num">{{ row.ledger_count }}</td>
</tr>
</tbody>
</table>
<div v-for="group in groups" :key="group.title" class="group-block">
<h3>{{ group.title }}</h3>
<table class="sheet-table">
<thead>
<tr>
<th>维度</th><th class="num">回款金额</th>
<th class="num">绩效金额</th><th class="num">台账行数</th>
</tr>
</thead>
<tbody>
<tr v-for="row in group.rows" :key="String(row.code)">
<td>{{ row.label }}</td>
<td class="num">{{ row.receipt_amount }}</td>
<td class="num">{{ row.performance_amount }}</td>
<td class="num">{{ row.ledger_count }}</td>
</tr>
</tbody>
</table>
</div>
</template>
</div>
</template>
<!-- 三栏签字:制表 / 复核 / 审批,栏位用上横线表示手写位置 -->
<footer class="sign-row">
<div>制表:____________</div>
<div>复核:____________</div>
<div>审批:____________</div>
</footer>
</div>
<footer class="signature">
<div>财务复核:____________________</div>
<div>领导签字:____________________</div>
<div>日期:______年__月__日</div>
</footer>
</div>
</template>
<style scoped>
/*
* 打印视图:屏幕上是「灰画布 + 白稿纸」,打印时退化为纯纸面。
* 画布灰比内容页沉一档,让白稿纸一眼是「一张纸」而不是页面里的一个区块。
*/
.print-page {
min-height: 100vh;
padding-bottom: 28px;
background: #eceef2;
color: var(--ys-ink);
padding: 24px;
background: #fff;
color: #1c2333;
}
/* 屏幕操作条:只在屏幕上出现,纸面上不出现任何按钮 */
.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 {
.toolbar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 20px;
}
.print-bar__hint {
font-size: 12px;
.toolbar-hint {
color: var(--ys-ink-3);
}
/* 顶栏内的按钮压到 30px/12px:与页面顶栏的节奏一致(全局按钮是 34px) */
.print-bar__btn {
height: 30px;
padding: 0 14px;
font-size: 12px;
}
/*
* 稿纸:按 A4 横向版心宽度定宽,屏幕预览与打印同宽 ——
* 两套宽度会让「预览时一页放得下、打出来却折行」。
*/
.sheet {
width: 1040px;
max-width: calc(100% - 44px);
margin: 0 auto;
padding: 26px 30px 30px;
background: #fff;
}
.sheet-header {
margin-bottom: 16px;
text-align: center;
}
.logo {
max-height: 44px;
margin-bottom: 8px;
max-height: 48px;
margin-bottom: 6px;
}
.company {
font-size: 13px;
font-weight: 500;
letter-spacing: 0.18em;
color: var(--ys-ink-2);
font-size: 18px;
font-weight: 700;
}
.title {
margin: 4px 0 0;
font-size: 15px;
font-weight: 600;
margin: 6px 0;
font-size: 20px;
}
.sheet-meta {
margin-top: 4px;
font-size: 11px;
color: var(--ys-ink-3);
.meta {
display: flex;
justify-content: space-between;
font-size: 12px;
color: #5b6577;
}
/* 截断提示:纸面上也要让人一眼看出「这不是全部数据」 */
.truncated {
margin-top: 12px;
padding: 6px 10px;
font-size: 11px;
margin-bottom: 10px;
padding: 8px 12px;
font-size: 12px;
color: #8a5a00;
background: #fbf4e4;
border: 1px solid #f0e2c4;
background: #fdf6e3;
border-radius: 4px;
}
/*
* 稿面表格:纸面口径 —— 11px、细实线 #d9dde5、表头浅底左对齐。
* th 一律左对齐,只有数值列右对齐(与数据行同向),否则表头与数字错位。
*/
.sheet-table {
width: 100%;
margin-top: 8px;
border-collapse: collapse;
font-size: 11px;
font-variant-numeric: tabular-nums;
font-size: 12px;
}
.sheet-table th,
.sheet-table td {
padding: 4px 7px;
text-align: left;
vertical-align: top;
border: 1px solid #d9dde5;
padding: 6px 8px;
border: 1px solid #b0b7c3;
}
.sheet-table th {
background: #f6f7f9;
font-weight: 500;
color: var(--ys-ink-2);
white-space: nowrap;
background: #dde3f0;
font-weight: 600;
}
.sheet-table .num {
text-align: right;
font-variant-numeric: tabular-nums;
}
.block-title {
margin: 14px 0 6px;
font-size: 12px;
margin: 18px 0 6px;
font-size: 14px;
font-weight: 600;
}
.group-block h3 {
margin: 12px 0 6px;
font-size: 12px;
font-weight: 500;
color: var(--ys-ink-2);
.group-block {
margin-top: 18px;
}
/* 三栏签字:上横线代替手写下划线,栏宽等分 */
.sign-row {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 18px;
margin-top: 22px;
font-size: 11px;
color: var(--ys-ink-3);
}
.sign-row div {
padding-top: 5px;
border-top: 1px solid var(--ys-line);
.signature {
display: flex;
justify-content: space-between;
margin-top: 40px;
padding-top: 16px;
font-size: 13px;
}
@media print {
@ -657,17 +559,8 @@ onMounted(load)
display: none !important;
}
/* 打印时画布与稿纸合并成一张纸:外层灰底、定宽、内距都是屏幕专有 */
.print-page {
padding: 0;
background: #fff;
}
.sheet {
width: auto;
max-width: none;
margin: 0;
padding: 0;
}
@page {
@ -682,10 +575,5 @@ onMounted(load)
.sheet-table tr {
page-break-inside: avoid;
}
/* 浏览器默认不打印背景色,表头浅底会丢失,这里强制保留 */
.sheet-table th {
print-color-adjust: exact;
}
}
</style>

View File

@ -7,6 +7,7 @@ import {
getSettlement,
listSettlements,
settlementStatusLabel,
settlementStatusTagType,
submitSettlement,
type Settlement,
type SettlementDetail,
@ -38,39 +39,6 @@ function resetFilters(): void {
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 generating = ref(false)
@ -128,51 +96,21 @@ async function handleSubmit(row: Settlement): Promise<void> {
<template>
<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">
<el-input
v-model="generateMonth"
placeholder="结算月份 YYYY-MM"
style="width: 160px"
maxlength="7"
/>
<el-button type="primary" :loading="generating" @click="handleGenerate">
生成月结快照
</el-button>
<div class="gen-row">
<el-input
v-model="generateMonth"
placeholder="结算月份 YYYY-MM"
style="width: 160px"
maxlength="7"
/>
<el-button type="primary" :loading="generating" @click="handleGenerate">
生成月结快照
</el-button>
</div>
</div>
<!--
.filter-bar 直接挂到 el-form 上:el-form 不能当 flex 透明容器,
外面再包一层 div 会让 el-form-item 的 gap 排版失效。
-->
<el-form class="filter-bar" @submit.prevent>
<el-form inline class="filters" @submit.prevent>
<el-form-item label="状态">
<el-select v-model="filters.status" placeholder="全部" clearable style="width: 150px">
<el-option label="草稿" :value="1" />
@ -181,102 +119,68 @@ async function handleSubmit(row: Settlement): Promise<void> {
<el-option label="已驳回" :value="4" />
</el-select>
</el-form-item>
<div class="filter-bar__actions">
<el-form-item>
<el-button type="primary" @click="search">查询</el-button>
<el-button @click="resetFilters">重置</el-button>
</div>
</el-form-item>
</el-form>
<div class="card">
<el-table v-loading="loading" :data="records" stripe>
<el-table-column prop="settlement_month" label="结算月份" width="110" />
<el-table-column label="状态" width="150">
<template #default="{ row }">
<span class="ys-pill" :class="statusPillClass(row.status)">
{{ settlementStatusLabel(row.status) }}
</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>
</el-table-column>
<el-table-column label="提交时间" min-width="150">
<template #default="{ row }">
<span class="cell-muted">{{ formatDateTime(row.create_time) }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="140" fixed="right" align="center">
<template #default="{ row }">
<div class="table-actions">
<el-button link type="primary" @click="openDetail(row)">详情</el-button>
<el-button
v-if="row.status === 1 || row.status === 4"
link
type="success"
:loading="submitting"
@click="handleSubmit(row)"
>
提交审批
</el-button>
</div>
</template>
</el-table-column>
</el-table>
<el-table v-loading="loading" :data="records" stripe>
<el-table-column prop="settlement_month" label="结算月份" width="100" />
<el-table-column label="状态" width="130">
<template #default="{ row }">
<el-tag :type="settlementStatusTagType(row.status)" size="small" disable-transitions>
{{ settlementStatusLabel(row.status) }}
</el-tag>
</template>
</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">
<template #default="{ row }">{{ formatDateTime(row.create_time) }}</template>
</el-table-column>
<el-table-column label="操作" width="140" fixed="right" align="center">
<template #default="{ row }">
<div class="table-actions">
<el-button link type="primary" @click="openDetail(row)">详情</el-button>
<el-button
v-if="row.status === 1 || row.status === 4"
link
type="success"
:loading="submitting"
@click="handleSubmit(row)"
>
提交审批
</el-button>
</div>
</template>
</el-table-column>
</el-table>
<div class="card-foot">
<AppPagination
:total="total"
:page-num="pageNum"
:page-size="pageSize"
@page-change="onPageChange"
@size-change="onSizeChange"
/>
</div>
</div>
<AppPagination
:total="total"
:page-num="pageNum"
:page-size="pageSize"
@page-change="onPageChange"
@size-change="onSizeChange"
/>
<el-drawer v-model="detailVisible" title="月结详情" size="560px">
<div v-loading="detailLoading">
<template v-if="detail">
<div class="detail-head">
<span class="detail-month">{{ detail.settlement_month }}</span>
<span class="ys-pill" :class="statusPillClass(detail.status)">
<el-tag :type="settlementStatusTagType(detail.status)" size="small" disable-transitions>
{{ settlementStatusLabel(detail.status) }}
</span>
</el-tag>
</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>
<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-column prop="personnel_name" label="人员" min-width="90" />
<el-table-column label="类型" width="90">
@ -284,32 +188,17 @@ async function handleSubmit(row: Settlement): Promise<void> {
{{ row.calc_type === 1 ? '单笔回款' : '经理聚合' }}
</template>
</el-table-column>
<el-table-column label="绩效" min-width="90">
<template #default="{ row }">
<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 prop="performance_sum" label="绩效" min-width="90" />
<el-table-column prop="payable_sum" label="应付" min-width="90" />
<el-table-column prop="net_amount" label="净工资" min-width="90" />
<el-table-column label="试用期" width="70">
<template #default="{ row }">{{ row.is_probation === 1 ? '是' : '否' }}</template>
</el-table-column>
</el-table>
<p v-if="detail.status === 1 || detail.status === 4" class="drawer-hint">
<p v-if="detail.status === 1 || detail.status === 4" class="hint">
草稿/已驳回状态可重新生成快照后再次提交。
</p>
<p v-else-if="detail.status === 3" class="drawer-hint">
已锁定:源数据改动只产生提示与调整单。
</p>
<p v-else-if="detail.status === 3" class="hint">已锁定:源数据改动只产生提示与调整单。</p>
</template>
</div>
</el-drawer>
@ -317,100 +206,19 @@ async function handleSubmit(row: Settlement): Promise<void> {
</template>
<style scoped>
/*
* 金额列右对齐:全局 .cell-num 是给 td 设计的,这里只能包 span,
* 行内元素的 text-align 无效,所以把 span 变块级让对齐真正生效;
* 表头保持左对齐,与设计稿一致(列 align="right" 会连表头一起右移)。
*/
.cell-num {
display: block;
}
/* 生命周期条:横向分段,相邻段共用描边(border-left:0)避免双线 */
.flow-step {
.toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
margin-bottom: 14px;
justify-content: flex-end;
margin-bottom: 12px;
}
.flow-step__item {
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);
.gen-row {
display: flex;
gap: 10px;
}
/* 未选中时四段必须读起来仍是「一条流程」:给右侧挂一行说明,
高度与分段齐平,避免出现四颗孤立小按钮的观感 */
.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);
.filters {
margin-bottom: 4px;
}
.detail-head {
@ -420,38 +228,22 @@ async function handleSubmit(row: Settlement): Promise<void> {
}
.detail-month {
font-size: 16px;
font-size: 15px;
font-weight: 600;
letter-spacing: -0.01em;
color: var(--ys-ink);
}
/* 分块标题之间需要上间距;不改全局 .section-title,用局部修饰符叠加 */
.section-title--gap {
margin-top: 18px;
}
/* 键值两列:72px 的键列足够放四个汉字,值列自适应余宽(抽屉 560px 内可读) */
.kv {
display: grid;
grid-template-columns: 72px 1fr;
gap: 8px 10px;
margin: 0;
}
.kv dt {
.detail-meta {
display: flex;
flex-direction: column;
gap: 2px;
padding: 10px 0;
font-size: 12px;
color: var(--ys-ink-3);
}
.kv dd {
margin: 0;
font-size: 13px;
color: var(--ys-ink);
}
.drawer-hint {
margin: 14px 0 0;
.hint {
margin-top: 12px;
font-size: 12px;
color: var(--ys-ink-3);
}

View File

@ -5,6 +5,7 @@ import { ElMessage } from 'element-plus'
import {
actApproval,
approvalStatusLabel,
approvalStatusTagType,
getApproval,
listApprovals,
orderTypeLabel,
@ -61,18 +62,6 @@ function resetFilters(): void {
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)
@ -167,19 +156,7 @@ async function submitAction(): Promise<void> {
<template>
<div class="page">
<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 inline class="filters" @submit.prevent>
<el-form-item label="关键词">
<el-input
v-model="filters.keyword"
@ -205,92 +182,70 @@ async function submitAction(): Promise<void> {
<el-option label="调整单" :value="3" />
</el-select>
</el-form-item>
<div class="filter-bar__actions">
<el-form-item>
<el-button type="primary" @click="search">查询</el-button>
<el-button @click="resetFilters">重置</el-button>
</div>
</el-form-item>
</el-form>
<div class="card">
<el-table v-loading="loading" :data="records" stripe>
<el-table-column label="审批单号" min-width="140">
<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>
</el-table-column>
<!-- 版本号与状态同屏:审批人必须能判断自己批的是哪一版,不能藏进 tooltip -->
<el-table-column label="版本" width="80">
<template #default="{ row }">
<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) }}
</span>
</template>
</el-table-column>
<el-table-column prop="submitter_name" label="提交人" width="110" />
<el-table-column label="提交时间" min-width="150">
<template #default="{ row }">
<span class="cell-muted">{{ formatDateTime(row.create_time) }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="90" fixed="right" align="center">
<template #default="{ row }">
<el-button link type="primary" @click="openDetail(row)">详情</el-button>
</template>
</el-table-column>
</el-table>
<el-table v-loading="loading" :data="records" stripe>
<el-table-column prop="order_no" label="审批单号" min-width="150" />
<el-table-column label="类型" width="120">
<template #default="{ row }">{{ orderTypeLabel(row.order_type) }}</template>
</el-table-column>
<el-table-column label="版本" width="70" align="center">
<template #default="{ row }">V{{ row.version_no }}</template>
</el-table-column>
<el-table-column prop="refer_no" label="引用单号" min-width="140" />
<el-table-column label="状态" width="110">
<template #default="{ row }">
<el-tag :type="approvalStatusTagType(row.status)" size="small" disable-transitions>
{{ approvalStatusLabel(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="submitter_name" label="提交人" width="110" />
<el-table-column label="提交时间" min-width="150">
<template #default="{ row }">{{ formatDateTime(row.create_time) }}</template>
</el-table-column>
<el-table-column label="操作" width="90" fixed="right" align="center">
<template #default="{ row }">
<el-button link type="primary" @click="openDetail(row)">详情</el-button>
</template>
</el-table-column>
</el-table>
<div class="card-foot">
<AppPagination
:total="total"
:page-num="pageNum"
:page-size="pageSize"
@page-change="onPageChange"
@size-change="onSizeChange"
/>
</div>
</div>
<AppPagination
:total="total"
:page-num="pageNum"
:page-size="pageSize"
@page-change="onPageChange"
@size-change="onSizeChange"
/>
<el-drawer v-model="detailVisible" title="审批详情" size="720px">
<div v-loading="detailLoading">
<template v-if="detail">
<div class="panel-title">
<span class="panel-title__no">{{ detail.order_no }}</span>
<span class="ys-pill" :class="approvalPillClass(detail.status)">
<div class="detail-head">
<span class="detail-no">{{ detail.order_no }}</span>
<el-tag
:type="approvalStatusTagType(detail.status)"
size="small"
disable-transitions
>
{{ approvalStatusLabel(detail.status) }}
</span>
</el-tag>
</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>
<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">
<p class="section-title">审批内容</p>
<div class="content-title">审批内容</div>
<!-- 采集时间让复核者明确「看到的是哪个时点的内容」:快照=送审时刻;
live=打开详情时的当前内容(不做时刻展示——渲染函数里的
new Date() 非响应式且 toISOString 是 UTC,会差 8 小时) -->
@ -316,26 +271,30 @@ async function submitAction(): Promise<void> {
/>
<template v-if="allocationData">
<dl class="kv">
<dt>业务编号</dt>
<dd>{{ allocationData.business_no }}</dd>
<dt>业务日期</dt>
<dd>{{ allocationData.business_date ?? '—' }}</dd>
<dt>客户名称</dt>
<dd>{{ allocationData.customer_name ?? '—' }}</dd>
<dt>负责人员</dt>
<dd>{{ allocationData.owner_personnel_name ?? '(待指定)' }}</dd>
<dt>销售额</dt>
<dd>{{ allocationData.sale_amount ? `${allocationData.sale_amount} 元` : '—' }}</dd>
<dt>成本</dt>
<dd>{{ allocationData.cost_amount ? `${allocationData.cost_amount} 元` : '—' }}</dd>
</dl>
<el-descriptions :column="2" border size="small" class="content-desc">
<el-descriptions-item label="业务编号">
{{ allocationData.business_no }}
</el-descriptions-item>
<el-descriptions-item label="业务日期">
{{ allocationData.business_date ?? '—' }}
</el-descriptions-item>
<el-descriptions-item label="客户名称">
{{ allocationData.customer_name ?? '—' }}
</el-descriptions-item>
<el-descriptions-item label="负责人员">
{{ allocationData.owner_personnel_name ?? '(待指定)' }}
</el-descriptions-item>
<el-descriptions-item label="销售额(元)">
{{ 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-column prop="personnel_name" label="参与业务员" min-width="160" />
<el-table-column label="分配比例" width="120">
<template #default="{ row }">
<span class="cell-num">{{ row.ratio }}%</span>
</template>
<el-table-column label="分配比例" width="120" align="right">
<template #default="{ row }">{{ row.ratio }}%</template>
</el-table-column>
<template #empty>
<span class="content-empty">未设置二次分配 —— 全部绩效归负责营销人员</span>
@ -348,63 +307,60 @@ async function submitAction(): Promise<void> {
</template>
<template v-else-if="settlementData">
<dl class="kv">
<dt>结算月份</dt>
<dd>{{ settlementData.settlement_month }}</dd>
<dt>核算人数</dt>
<dd>{{ settlementData.person_count }} 人</dd>
<dt>绩效合计</dt>
<dd>
{{
settlementData.total_performance ? `${settlementData.total_performance} 元` : '—'
}}
</dd>
<dt>应付合计</dt>
<dd>
{{ settlementData.total_payable ? `${settlementData.total_payable} 元` : '—' }}
</dd>
</dl>
<el-descriptions :column="2" border size="small" class="content-desc">
<el-descriptions-item label="结算月份">
{{ settlementData.settlement_month }}
</el-descriptions-item>
<el-descriptions-item label="结算人数">
{{ settlementData.person_count }}
</el-descriptions-item>
<el-descriptions-item label="绩效合计(元)">
{{ settlementData.total_performance ?? '—' }}
</el-descriptions-item>
<el-descriptions-item label="应付合计(元)">
{{ settlementData.total_payable ?? '—' }}
</el-descriptions-item>
</el-descriptions>
<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="role_code" label="岗位" width="110" />
<el-table-column label="绩效(元)" width="120">
<template #default="{ row }">
<span class="cell-num">{{ row.performance_sum }}</span>
</template>
<el-table-column label="绩效(元)" width="110" align="right">
<template #default="{ row }">{{ row.performance_sum }}</template>
</el-table-column>
<el-table-column label="应付(元)" width="120">
<template #default="{ row }">
<span class="cell-num">{{ row.payable_sum }}</span>
</template>
<el-table-column label="应付(元)" width="110" align="right">
<template #default="{ row }">{{ row.payable_sum }}</template>
</el-table-column>
<el-table-column label="净工资(元)" width="120">
<template #default="{ row }">
<span class="cell-num">{{ row.net_amount ?? '—' }}</span>
</template>
<el-table-column label="净工资(元)" width="120" align="right">
<template #default="{ row }">{{ row.net_amount ?? '—' }}</template>
</el-table-column>
</el-table>
</template>
<template v-else-if="adjustmentData">
<dl class="kv">
<dt>调整单号</dt>
<dd>{{ adjustmentData.order_no }}</dd>
<dt>调整月份</dt>
<dd>{{ adjustmentData.settlement_month }}</dd>
<dt>调整人员</dt>
<dd>{{ adjustmentData.personnel_name }}</dd>
<dt>方向</dt>
<dd>{{ adjustmentData.direction === 1 ? '补发' : '扣减' }}</dd>
<dt>金额</dt>
<dd>{{ adjustmentData.amount }} 元</dd>
<dt>扣补说明</dt>
<dd>{{ adjustmentData.reason }}</dd>
</dl>
<el-descriptions :column="2" border size="small" class="content-desc">
<el-descriptions-item label="调整单号">
{{ adjustmentData.order_no }}
</el-descriptions-item>
<el-descriptions-item label="调整月份">
{{ adjustmentData.settlement_month }}
</el-descriptions-item>
<el-descriptions-item label="调整人员">
{{ adjustmentData.personnel_name }}
</el-descriptions-item>
<el-descriptions-item label="方向">
{{ adjustmentData.direction === 1 ? '补发' : '扣减' }}
</el-descriptions-item>
<el-descriptions-item label="金额(元)">
{{ adjustmentData.amount }}
</el-descriptions-item>
<el-descriptions-item label="扣补说明">
{{ adjustmentData.reason }}
</el-descriptions-item>
</el-descriptions>
</template>
</div>
<!-- 签名元素:竖排节点时间线(财务复核 → 领导审核的处理链) -->
<p class="section-title section-title--gap">处理记录</p>
<div class="timeline">
<div
v-for="step in detail.steps"
@ -417,10 +373,7 @@ async function submitAction(): Promise<void> {
class="timeline-dot"
:class="{ 'is-auto': step.action === 3, 'is-reject': step.action === 2 }"
></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 class="timeline-body">
<div class="timeline-head">
@ -443,6 +396,7 @@ async function submitAction(): Promise<void> {
<div class="action-bar">
<el-button
type="primary"
size="small"
:loading="actionSubmitting && actionForm.action === 1"
@click="openAction(detail, 1)"
>
@ -450,17 +404,16 @@ async function submitAction(): Promise<void> {
</el-button>
<el-button
type="danger"
plain
size="small"
:loading="actionSubmitting && actionForm.action === 2"
@click="openAction(detail, 2)"
>
驳回
</el-button>
</div>
<p class="action-hint">驳回需写明原因,将与审批记录一并留痕</p>
<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
v-model="actionForm.opinion"
type="textarea"
@ -482,67 +435,44 @@ async function submitAction(): Promise<void> {
</template>
<style scoped>
/* 版本/比例等数值列:全局 .cell-num 是给 td 设计的,行内 span 的
text-align 不生效,变块级才能右对齐(表头仍左对齐,与设计稿一致)。 */
.cell-num {
display: block;
.filters {
margin-bottom: 4px;
}
/* 抽屉头部:单号与状态同屏,标题 15px/600 与卡片标题同级 */
.panel-title {
.detail-head {
display: flex;
align-items: center;
gap: 10px;
}
.panel-title__no {
.detail-no {
font-size: 15px;
font-weight: 600;
letter-spacing: -0.01em;
color: var(--ys-ink);
}
.panel-sub {
margin: 4px 0 0;
.detail-meta {
display: flex;
flex-direction: column;
gap: 2px;
padding: 10px 0 4px;
font-size: 12px;
color: var(--ys-ink-3);
}
/* 键值两列:72px 键列放得下四个汉字,值列自适应余宽 */
.kv {
display: grid;
grid-template-columns: 72px 1fr;
gap: 8px 10px;
margin: 14px 0 0;
}
.kv dt {
font-size: 12px;
color: var(--ys-ink-3);
}
.kv dd {
margin: 0;
font-size: 13px;
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);
margin-top: 12px;
padding: 12px;
background: #f7f8fa;
border: 1px solid var(--ys-line);
border-radius: var(--ys-radius-sm);
border-radius: 8px;
}
.content-title {
margin-bottom: 10px;
font-size: 13px;
font-weight: 600;
color: var(--ys-ink);
}
.content-captured {
@ -551,8 +481,12 @@ async function submitAction(): Promise<void> {
color: var(--ys-ink-2);
}
.content-desc {
margin-bottom: 10px;
}
.content-table {
margin-top: 10px;
margin-top: 8px;
}
.content-total {
@ -568,7 +502,7 @@ async function submitAction(): Promise<void> {
/* 竖排时间线:左侧轨道 + 节点圆点,动作色区分人工/自动/驳回 */
.timeline {
margin-top: 12px;
margin-top: 14px;
}
.timeline-row {
@ -597,14 +531,14 @@ async function submitAction(): Promise<void> {
}
.timeline-dot.is-reject {
background: var(--ys-danger);
background: #c0392b;
}
.timeline-line {
flex: 1;
width: 1px;
min-height: 26px;
background: var(--ys-line);
background: rgba(28, 35, 51, 0.14);
}
.timeline-body {
@ -629,7 +563,7 @@ async function submitAction(): Promise<void> {
}
.timeline-action.is-reject-text {
color: var(--ys-danger);
color: #c0392b;
}
.timeline-meta {
@ -643,8 +577,8 @@ async function submitAction(): Promise<void> {
padding: 6px 10px;
font-size: 12px;
color: var(--ys-ink);
background: var(--ys-mist);
border-radius: var(--ys-radius-sm);
background: #f4f5f8;
border-radius: 6px;
}
.timeline-empty {
@ -656,24 +590,13 @@ async function submitAction(): Promise<void> {
color: var(--ys-ink-3);
}
/* 通过/驳回并排等宽:两个动作权重相同,审批人不必在大小按钮间做偏向性选择 */
.action-bar {
display: flex;
gap: 10px;
padding-top: 14px;
padding-top: 6px;
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 {
margin-top: 12px;
}

View File

@ -207,62 +207,42 @@ onMounted(() => {
</script>
<template>
<div class="page">
<!-- 页面头:点明岗位是「公式模板 + 参数」的绑定关系,改它会牵动后续核算口径 -->
<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-input
v-model="filters.keyword"
placeholder="岗位编码或名称"
clearable
style="width: 180px"
@keyup.enter="search"
/>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="filters.status" placeholder="全部" clearable style="width: 110px">
<el-option
v-for="item in STATUS_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
<div>
<el-card shadow="never">
<el-form class="filter-form" :inline="true" @submit.prevent>
<el-form-item label="关键词">
<el-input
v-model="filters.keyword"
placeholder="岗位编码或名称"
clearable
style="width: 180px"
@keyup.enter="search"
/>
</el-select>
</el-form-item>
<div class="filter-bar__actions">
<el-button type="primary" @click="search">查询</el-button>
<el-button @click="resetFilters">重置</el-button>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="filters.status" placeholder="全部" clearable style="width: 110px">
<el-option
v-for="item in STATUS_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="search">查询</el-button>
<el-button @click="resetFilters">重置</el-button>
</el-form-item>
</el-form>
<div class="toolbar">
<el-button type="primary" @click="openCreate">新增岗位</el-button>
</div>
</el-form>
<!-- 主操作行:新增岗位单独成行,与筛选动作分开 -->
<div class="toolbar">
<el-button type="primary" @click="openCreate">新增岗位</el-button>
</div>
<div class="card">
<el-table
v-loading="loading"
:data="records"
stripe
empty-text="还没有岗位角色,点「新增岗位」创建第一个"
>
<el-table v-loading="loading" :data="records" stripe>
<el-table-column prop="role_code" label="岗位编码" min-width="130">
<template #default="{ row }">
<!-- 岗位编码是参数维度值与模板绑定的锚点,等宽字体便于逐字符核对 -->
<span class="cell-code">{{ row.role_code }}</span>
<span class="role-code">{{ row.role_code }}</span>
</template>
</el-table-column>
<el-table-column prop="role_name" label="岗位名称" min-width="110" />
@ -272,11 +252,8 @@ onMounted(() => {
<el-table-column prop="description" label="说明" min-width="200">
<template #default="{ row }">{{ row.description || '—' }}</template>
</el-table-column>
<!-- 排序是用于比较的数字:右对齐 + tabular-nums,纵向扫读时数位对齐 -->
<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">
<el-table-column prop="sort_order" label="排序" width="70" align="center" />
<el-table-column label="状态" width="90" align="center">
<template #default="{ row }">
<StatusTag :value="row.status" />
</template>
@ -313,16 +290,14 @@ onMounted(() => {
</el-table-column>
</el-table>
<div class="card-foot">
<AppPagination
:total="total"
:page-num="pageNum"
:page-size="pageSize"
@page-change="onPageChange"
@size-change="onSizeChange"
/>
</div>
</div>
<AppPagination
:total="total"
:page-num="pageNum"
:page-size="pageSize"
@page-change="onPageChange"
@size-change="onSizeChange"
/>
</el-card>
<!-- 新增 / 编辑 -->
<el-dialog
@ -380,7 +355,12 @@ onMounted(() => {
</template>
<style scoped>
/* 弹窗里「所选模板的说明与参数清单」:靛蓝浅底把这段提示与表单控件区分开 */
.role-code {
font-family: 'SFMono-Regular', Consolas, 'Courier New', monospace;
font-size: 12px;
color: var(--ys-ink-2);
}
.tpl-info {
width: 100%;
padding: 8px 12px;
@ -391,9 +371,7 @@ onMounted(() => {
line-height: 1.7;
}
/* 参数清单比说明再弱一档,读者先看模板用途再看它要配哪些参数 */
.tpl-params {
color: var(--ys-ink-3);
}
</style>

View File

@ -170,24 +170,11 @@ watch(activeTab, (tab) => {
</script>
<template>
<div class="page">
<!-- 页面头:说明日志是只读取证材料,并点明保留期由后端控制 -->
<div class="page-head">
<h1 class="page-title">日志查询</h1>
<p class="page-desc">
检索操作与登录痕迹,用于追责与排障;日志只读,超出保留期的记录由后端自动清理。
</p>
<div class="waterline"></div>
</div>
<el-card shadow="never">
<el-tabs v-model="activeTab">
<!-- 操作日志 -->
<el-tab-pane label="操作日志" name="operation" lazy>
<!--
筛选条:flex 排布与间距由 .filter-bar 提供,故 el-form 去掉 inline;
查询/重置收进 .filter-bar__actions,由 margin-left:auto 推到行尾。
-->
<el-form class="filter-bar" @submit.prevent>
<el-form class="filter-form" :inline="true" @submit.prevent>
<el-form-item label="操作人">
<el-input
v-model="opFilters.username"
@ -228,85 +215,77 @@ watch(activeTab, (tab) => {
:default-time="RANGE_DEFAULT_TIME"
/>
</el-form-item>
<div class="filter-bar__actions">
<el-form-item>
<el-button type="primary" @click="searchOperationLogs">查询</el-button>
<el-button @click="resetOpFilters">重置</el-button>
</div>
</el-form-item>
</el-form>
<div class="card">
<el-table
v-loading="opLoading"
:data="opRecords"
stripe
empty-text="这段时间还没有操作记录,试试放宽筛选条件"
@expand-change="onExpandChange"
>
<el-table-column type="expand">
<template #default="{ row }">
<div class="log-detail">
<p>
<span class="log-detail__label">请求:</span>
{{ row.method || '—' }} {{ row.path || '—' }}
</p>
<p>
<span class="log-detail__label">User-Agent:</span>
{{ row.user_agent || '—' }}
</p>
<p class="log-detail__label">请求参数(已脱敏):</p>
<pre class="log-detail__data">{{ detailText(row.id) }}</pre>
</div>
</template>
</el-table-column>
<el-table-column label="时间" min-width="165">
<template #default="{ row }">{{ formatDateTime(row.create_time) }}</template>
</el-table-column>
<el-table-column prop="username" label="操作人" min-width="110" />
<el-table-column prop="module" label="模块" width="100" />
<el-table-column prop="action" label="操作" width="90" />
<el-table-column prop="summary" label="摘要" min-width="200" show-overflow-tooltip />
<el-table-column label="结果" width="96" align="center">
<template #default="{ row }">
<el-tooltip
:content="row.error_message ?? ''"
:disabled="row.success !== 0 || !row.error_message"
placement="top"
>
<StatusTag
:value="row.success"
active-text="成功"
inactive-text="失败"
inactive-type="danger"
/>
</el-tooltip>
</template>
</el-table-column>
<!-- 耗时是用于比较的数字:右对齐 + tabular-nums,慢请求一眼能扫出来 -->
<el-table-column label="耗时(ms)" width="90" align="right">
<template #default="{ row }">
<span class="cell-num">{{ row.duration_ms ?? '—' }}</span>
</template>
</el-table-column>
<el-table-column prop="ip" label="IP" min-width="120">
<template #default="{ row }">{{ row.ip || '—' }}</template>
</el-table-column>
</el-table>
<el-table
v-loading="opLoading"
:data="opRecords"
stripe
@expand-change="onExpandChange"
>
<el-table-column type="expand">
<template #default="{ row }">
<div class="log-detail">
<p>
<span class="log-detail__label">请求:</span>
{{ row.method || '—' }} {{ row.path || '—' }}
</p>
<p>
<span class="log-detail__label">User-Agent:</span>
{{ row.user_agent || '—' }}
</p>
<p class="log-detail__label">请求参数(已脱敏):</p>
<pre class="log-detail__data">{{ detailText(row.id) }}</pre>
</div>
</template>
</el-table-column>
<el-table-column label="时间" min-width="165">
<template #default="{ row }">{{ formatDateTime(row.create_time) }}</template>
</el-table-column>
<el-table-column prop="username" label="操作人" min-width="110" />
<el-table-column prop="module" label="模块" width="100" />
<el-table-column prop="action" label="操作" width="90" />
<el-table-column prop="summary" label="摘要" min-width="200" show-overflow-tooltip />
<el-table-column label="结果" width="80" align="center">
<template #default="{ row }">
<el-tooltip
:content="row.error_message ?? ''"
:disabled="row.success !== 0 || !row.error_message"
placement="top"
>
<StatusTag
:value="row.success"
active-text="成功"
inactive-text="失败"
inactive-type="danger"
/>
</el-tooltip>
</template>
</el-table-column>
<el-table-column label="耗时(ms)" width="90" align="right">
<template #default="{ row }">{{ row.duration_ms ?? '—' }}</template>
</el-table-column>
<el-table-column prop="ip" label="IP" min-width="120">
<template #default="{ row }">{{ row.ip || '—' }}</template>
</el-table-column>
</el-table>
<div class="card-foot">
<AppPagination
:total="opTotal"
:page-num="opPageNum"
:page-size="opPageSize"
@page-change="onOpPageChange"
@size-change="onOpSizeChange"
/>
</div>
</div>
<AppPagination
:total="opTotal"
:page-num="opPageNum"
:page-size="opPageSize"
@page-change="onOpPageChange"
@size-change="onOpSizeChange"
/>
</el-tab-pane>
<!-- 登录日志 -->
<el-tab-pane label="登录日志" name="login" lazy>
<el-form class="filter-bar" @submit.prevent>
<el-form class="filter-form" :inline="true" @submit.prevent>
<el-form-item label="用户名">
<el-input
v-model="loginFilters.username"
@ -326,12 +305,7 @@ watch(activeTab, (tab) => {
/>
</el-form-item>
<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
v-for="item in RESULT_OPTIONS"
:key="item.value"
@ -351,77 +325,62 @@ watch(activeTab, (tab) => {
:default-time="RANGE_DEFAULT_TIME"
/>
</el-form-item>
<div class="filter-bar__actions">
<el-form-item>
<el-button type="primary" @click="searchLoginLogs">查询</el-button>
<el-button @click="resetLoginFilters">重置</el-button>
</div>
</el-form-item>
</el-form>
<div class="card">
<el-table
v-loading="loginLoading"
:data="loginRecords"
stripe
empty-text="这段时间还没有登录记录,试试放宽筛选条件"
>
<el-table-column label="时间" min-width="165">
<template #default="{ row }">{{ formatDateTime(row.create_time) }}</template>
</el-table-column>
<el-table-column prop="username" label="用户名" min-width="130" />
<el-table-column label="结果" width="96" align="center">
<template #default="{ row }">
<StatusTag
:value="row.success"
active-text="成功"
inactive-text="失败"
inactive-type="danger"
/>
</template>
</el-table-column>
<el-table-column prop="fail_reason" label="失败原因" min-width="150">
<template #default="{ row }">{{ row.fail_reason || '—' }}</template>
</el-table-column>
<el-table-column prop="ip" label="IP" min-width="120">
<template #default="{ row }">{{ row.ip || '—' }}</template>
</el-table-column>
<el-table-column label="User-Agent" min-width="200">
<template #default="{ row }">
<span class="ua-cell" :title="row.user_agent || ''">{{ row.user_agent || '—' }}</span>
</template>
</el-table-column>
</el-table>
<el-table v-loading="loginLoading" :data="loginRecords" stripe>
<el-table-column label="时间" min-width="165">
<template #default="{ row }">{{ formatDateTime(row.create_time) }}</template>
</el-table-column>
<el-table-column prop="username" label="用户名" min-width="130" />
<el-table-column label="结果" width="80" align="center">
<template #default="{ row }">
<StatusTag
:value="row.success"
active-text="成功"
inactive-text="失败"
inactive-type="danger"
/>
</template>
</el-table-column>
<el-table-column prop="fail_reason" label="失败原因" min-width="150">
<template #default="{ row }">{{ row.fail_reason || '—' }}</template>
</el-table-column>
<el-table-column prop="ip" label="IP" min-width="120">
<template #default="{ row }">{{ row.ip || '—' }}</template>
</el-table-column>
<el-table-column label="User-Agent" min-width="200">
<template #default="{ row }">
<span class="ua-cell" :title="row.user_agent || ''">{{ row.user_agent || '—' }}</span>
</template>
</el-table-column>
</el-table>
<div class="card-foot">
<AppPagination
:total="loginTotal"
:page-num="loginPageNum"
:page-size="loginPageSize"
@page-change="onLoginPageChange"
@size-change="onLoginSizeChange"
/>
</div>
</div>
<AppPagination
:total="loginTotal"
:page-num="loginPageNum"
:page-size="loginPageSize"
@page-change="onLoginPageChange"
@size-change="onLoginSizeChange"
/>
</el-tab-pane>
</el-tabs>
</div>
</el-card>
</template>
<style scoped>
/*
* 展开行面板:表格单元格 padding 由全局统一为 12px 16px,这里再套一层容器,
* 所以上下压到 4px 即可,否则展开区会明显比行内容松散;文字用次级墨色,
* 与主行(--ys-ink)区分开,读起来是「附注」而非新的一条记录。
*/
.log-detail {
padding: 4px 16px;
color: var(--ys-ink-2);
color: var(--ys-text);
}
.log-detail__label {
font-weight: 600;
}
/* 脱敏后的 JSON 参数按等宽排版才能看出字段边界;边框用全局线色,避免与主题脱钩 */
.log-detail__data {
padding: 8px 12px;
margin: 6px 0 12px;
@ -429,11 +388,10 @@ watch(activeTab, (tab) => {
font-family: Consolas, 'Courier New', monospace;
font-size: 12px;
background-color: var(--ys-paper);
border: 1px solid var(--ys-line);
border: 1px solid #e5e7eb;
border-radius: 4px;
}
/* UA 串很长,单行省略 + title 悬浮看全文,避免撑高行或被裁成半截 */
.ua-cell {
display: inline-block;
max-width: 100%;
@ -442,5 +400,4 @@ watch(activeTab, (tab) => {
white-space: nowrap;
vertical-align: bottom;
}
</style>

View File

@ -431,104 +431,94 @@ onMounted(() => {
</script>
<template>
<div class="page">
<!-- 页面头:这句描述原本挤在工具栏左侧当说明,现在归位成页面描述,语义更清楚 -->
<div class="page-head">
<h1 class="page-title">计提参数</h1>
<p class="page-desc">
计提比例、系数、阈值、分成与起付比例在此统一维护;变更逐项留痕并支持按日期生效。
</p>
<div class="waterline"></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-item v-for="group in groupedCatalog" :key="group.name" :name="group.name">
<template #title>
<span class="group-title">{{ group.name }}</span>
</template>
<table class="param-table">
<thead>
<tr>
<th style="width: 190px">参数</th>
<th>当前生效值</th>
<th style="width: 110px">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="item in group.items" :key="item.param_code">
<td>
<div class="param-name">{{ item.param_name }}</div>
<div class="param-desc">{{ item.description || item.param_code }}</div>
</td>
<td>
<template v-if="item.values.length">
<div v-for="v in item.values" :key="v.dimension_value" class="value-row">
<span class="dim-label">{{ dimensionLabel(v.dimension_value) }}</span>
<b class="num">{{ formatValue(item, v.value_text) }}</b>
<span class="eff">{{ v.effective_date }} 起</span>
<el-tag
v-if="pendingOf(item, v.dimension_value)"
size="small"
type="warning"
disable-transitions
>
待改为 {{ pendingOf(item, v.dimension_value)!.new_value }}
</el-tag>
<el-button
v-if="isAdmin"
link
type="primary"
size="small"
@click="openEdit(item, v.dimension_value)"
>
改
</el-button>
</div>
</template>
<span v-else class="no-value">
未设置(默认 {{ item.default_value ?? '—' }})
</span>
</td>
<td>
<el-button
v-if="isAdmin && !item.values.length"
link
type="primary"
size="small"
@click="openEdit(item, '')"
>
设置
</el-button>
<el-button link type="primary" size="small" @click="openHistory(item)">历史</el-button>
</td>
</tr>
</tbody>
</table>
</el-collapse-item>
</el-collapse>
<div>
<el-card shadow="never">
<div class="page-toolbar">
<span class="page-desc">
计提比例、系数、阈值、分成与起付比例在此统一维护;变更逐项留痕并支持按日期生效。
</span>
<span>
<el-button @click="openPreviewDrawer">计算预览</el-button>
<el-button @click="openVersionDrawer">版本管理</el-button>
<el-button @click="doExport" :loading="exporting">导出记录</el-button>
</span>
</div>
</div>
<!-- 待提交批次(仅管理员可见可操作):提交前只是暂存,故用独立的次级卡片承载 -->
<div v-if="isAdmin && pendingList.length" class="card pending-card">
<div class="card-head">
<el-collapse v-loading="catalogLoading">
<el-collapse-item v-for="group in groupedCatalog" :key="group.name" :name="group.name">
<template #title>
<span class="group-title">{{ group.name }}</span>
</template>
<table class="param-table">
<thead>
<tr>
<th style="width: 190px">参数</th>
<th>当前生效值</th>
<th style="width: 110px">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="item in group.items" :key="item.param_code">
<td>
<div class="param-name">{{ item.param_name }}</div>
<div class="param-desc">{{ item.description || item.param_code }}</div>
</td>
<td>
<template v-if="item.values.length">
<div v-for="v in item.values" :key="v.dimension_value" class="value-row">
<span class="dim-label">{{ dimensionLabel(v.dimension_value) }}</span>
<b class="num">{{ formatValue(item, v.value_text) }}</b>
<span class="eff">{{ v.effective_date }} 起</span>
<el-tag
v-if="pendingOf(item, v.dimension_value)"
size="small"
type="warning"
disable-transitions
>
待改为 {{ pendingOf(item, v.dimension_value)!.new_value }}
</el-tag>
<el-button
v-if="isAdmin"
link
type="primary"
size="small"
@click="openEdit(item, v.dimension_value)"
>
改
</el-button>
</div>
</template>
<span v-else class="no-value">
未设置(默认 {{ item.default_value ?? '—' }})
</span>
</td>
<td>
<el-button
v-if="isAdmin && !item.values.length"
link
type="primary"
size="small"
@click="openEdit(item, '')"
>
设置
</el-button>
<el-button link type="primary" size="small" @click="openHistory(item)">历史</el-button>
</td>
</tr>
</tbody>
</table>
</el-collapse-item>
</el-collapse>
</el-card>
<!-- 待提交批次(仅管理员可见可操作) -->
<el-card v-if="isAdmin && pendingList.length" shadow="never" class="pending-card">
<template #header>
<div class="pending-head">
<b>待提交变更({{ pendingList.length }} 项)</b>
<span class="pending-hint">提交前不会生效;提交后未生效前可整批撤回</span>
</div>
</div>
</template>
<el-table :data="pendingList" size="small">
<el-table-column label="参数" min-width="160">
<template #default="{ row }">{{ row.item.param_name }}</template>
@ -548,30 +538,28 @@ onMounted(() => {
</template>
</el-table-column>
</el-table>
<div class="card-foot">
<div class="submit-row">
<el-radio-group v-model="changeType">
<el-radio-button :value="1">立即生效</el-radio-button>
<el-radio-button :value="2">指定日期生效</el-radio-button>
</el-radio-group>
<el-date-picker
v-if="changeType === 2"
v-model="effectiveDate"
type="date"
value-format="YYYY-MM-DD"
placeholder="生效日期"
style="width: 150px"
/>
<el-input
v-model="remark"
placeholder="变更说明(如:四季度系数调整)"
style="width: 240px"
maxlength="255"
/>
<el-button type="primary" :loading="submitting" @click="submitAll">提交变更</el-button>
</div>
<div class="submit-row">
<el-radio-group v-model="changeType">
<el-radio-button :value="1">立即生效</el-radio-button>
<el-radio-button :value="2">指定日期生效</el-radio-button>
</el-radio-group>
<el-date-picker
v-if="changeType === 2"
v-model="effectiveDate"
type="date"
value-format="YYYY-MM-DD"
placeholder="生效日期"
style="width: 150px"
/>
<el-input
v-model="remark"
placeholder="变更说明(如:四季度系数调整)"
style="width: 240px"
maxlength="255"
/>
<el-button type="primary" :loading="submitting" @click="submitAll">提交变更</el-button>
</div>
</div>
</el-card>
<!-- 修改参数 -->
<el-dialog v-model="editVisible" title="修改参数" width="440px" destroy-on-close>
@ -821,7 +809,17 @@ onMounted(() => {
</template>
<style scoped>
/* 页面里已经没有 .page-toolbar/.page-desc —— 描述与工具栏改用全局 .page-head/.toolbar */
.page-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 {
font-weight: 600;
@ -887,7 +885,6 @@ onMounted(() => {
font-size: 12px;
}
/* 待提交卡片紧跟目录卡,需要与上方卡片拉开距离(.card 自身不带外边距) */
.pending-card {
margin-top: 14px;
}
@ -903,14 +900,11 @@ onMounted(() => {
color: var(--ys-ink-3);
}
/*
* 提交区已放进 .card-foot:底栏自带 10px 16px 内边距与右对齐,
* 所以这里去掉原先的 margin-top,否则会与底栏留白叠成双份。
*/
.submit-row {
display: flex;
gap: 10px;
align-items: center;
margin-top: 14px;
}
.edit-name {

View File

@ -170,69 +170,43 @@ onMounted(load)
</script>
<template>
<div class="page">
<!-- 页面头:说明这页管授权基线,并点出两条会挡住操作的保护规则 -->
<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-input
v-model="filters.keyword"
placeholder="编码或名称,模糊匹配"
clearable
style="width: 200px"
@keyup.enter="search"
/>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="filters.status" placeholder="全部" clearable style="width: 110px">
<el-option
v-for="item in STATUS_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
<div>
<el-card shadow="never">
<el-form class="filter-form" :inline="true" @submit.prevent>
<el-form-item label="关键词">
<el-input
v-model="filters.keyword"
placeholder="编码或名称,模糊匹配"
clearable
style="width: 200px"
@keyup.enter="search"
/>
</el-select>
</el-form-item>
<div class="filter-bar__actions">
<el-button type="primary" @click="search">查询</el-button>
<el-button @click="resetFilters">重置</el-button>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="filters.status" placeholder="全部" clearable style="width: 110px">
<el-option
v-for="item in STATUS_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="search">查询</el-button>
<el-button @click="resetFilters">重置</el-button>
</el-form-item>
</el-form>
<div class="toolbar">
<el-button type="primary" @click="openCreate">新增角色</el-button>
</div>
</el-form>
<!-- 主操作行:新增是这页唯一的高频动作,单独成行且用主按钮 -->
<div class="toolbar">
<el-button type="primary" @click="openCreate">新增角色</el-button>
</div>
<div class="card">
<el-table
v-loading="loading"
:data="records"
stripe
empty-text="还没有角色,点「新增角色」创建第一个"
>
<el-table v-loading="loading" :data="records" stripe>
<el-table-column label="编码" min-width="150">
<template #default="{ row }">
<!-- 角色编码是授权判定的锚点,等宽字体便于逐字符核对 -->
<span class="cell-code">{{ row.role_code }}</span>
<el-tag
v-if="isBuiltin(row)"
class="builtin-tag"
size="small"
type="warning"
disable-transitions
>
<span class="role-code">{{ row.role_code }}</span>
<el-tag v-if="isBuiltin(row)" class="builtin-tag" size="small" type="warning" disable-transitions>
内置
</el-tag>
</template>
@ -241,17 +215,14 @@ onMounted(load)
<el-table-column prop="description" label="说明" min-width="180">
<template #default="{ row }">{{ row.description || '—' }}</template>
</el-table-column>
<!-- 排序值与用户数都是用于比较的数字:右对齐 + tabular-nums,纵向扫读时数位对齐 -->
<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">
<el-table-column prop="sort_order" label="排序" width="80" align="center" />
<el-table-column label="状态" width="90" align="center">
<template #default="{ row }">
<StatusTag :value="row.status" />
</template>
</el-table-column>
<el-table-column label="用户数" width="90" align="right">
<template #default="{ row }"><span class="cell-num">{{ row.user_count }}</span></template>
<el-table-column label="用户数" width="90" align="center">
<template #default="{ row }">{{ row.user_count }}</template>
</el-table-column>
<el-table-column label="操作" width="170" fixed="right" align="center">
<template #default="{ row }">
@ -285,16 +256,14 @@ onMounted(load)
</el-table-column>
</el-table>
<div class="card-foot">
<AppPagination
:total="total"
:page-num="pageNum"
:page-size="pageSize"
@page-change="onPageChange"
@size-change="onSizeChange"
/>
</div>
</div>
<AppPagination
:total="total"
:page-num="pageNum"
:page-size="pageSize"
@page-change="onPageChange"
@size-change="onSizeChange"
/>
</el-card>
<el-dialog
v-model="dialogVisible"
@ -329,9 +298,11 @@ onMounted(load)
</template>
<style scoped>
/* 「内置」标记跟在编码后面,需要一个横向间距;.el-tag 自身不带外边距 */
.role-code {
font-family: Consolas, 'Courier New', monospace;
}
.builtin-tag {
margin-left: 6px;
}
</style>

View File

@ -10,10 +10,7 @@ import { handledError } from '@/utils/error'
* 公司信息设置(2026-10-15 补充需求:公司名称、logo)。
*
* 签名元素是右侧「登录页实景预览」:品牌名与 logo 改动即时反映在一张
* **1:1 等比**的登录卡上 —— 所见即所得,避免「保存后去登录页核对」的往返。
* 预览按登录页的真实版式复刻(品牌区 / 欢迎回来 / 三项输入 / 登录按钮 /
* 版权行),控件尺寸与登录页一致,logo 展示位也按登录页的 ≤56×36 约束。
*
* 缩比登录卡上 —— 所见即所得,避免「保存后去登录页核对」的往返。
* logo 校验(png/jpg ≤2MB)与后端三重校验同口径,前端先拦一道。
*
* 进入页面即加载当前设置(设置页即所见即所得,无需手动刷新)。
@ -94,141 +91,71 @@ const previewName = computed(() => form.company_name.trim() || '衍水集团')
<template>
<div class="page">
<!-- 页面头:这是表单页不是列表页,所以没有筛选条,只有标题 + 影响面说明 -->
<div class="page-head">
<h1 class="page-title">公司信息</h1>
<p class="page-desc">维护公司名称与 logo;保存后立即作用于登录页品牌位与导出页眉。</p>
<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>
<el-card shadow="never" class="settings-card">
<template #header>
<span class="card-title">公司信息</span>
</template>
<div class="card-body">
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-width="92px"
@submit.prevent
>
<el-form-item label="公司名称" prop="company_name">
<!-- 控件与说明并排:说明靠行尾对齐,宽屏下整行铺满卡片而非缩在左上角 -->
<div class="field-control">
<el-input
v-model="form.company_name"
class="field-control__main"
maxlength="64"
placeholder="展示在登录页与导出页眉"
clearable
/>
<p class="field-control__note">最多 64 个字符;保存后对所有在线用户立即生效。</p>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-width="88px"
@submit.prevent
>
<el-form-item label="公司名称" prop="company_name">
<el-input
v-model="form.company_name"
maxlength="64"
placeholder="展示在登录页与导出页眉"
clearable
/>
</el-form-item>
<el-form-item label="logo">
<div class="logo-field">
<el-upload
class="logo-uploader"
:show-file-list="false"
:http-request="handleUpload"
:before-upload="beforeUpload"
accept="image/png,image/jpeg"
>
<div v-loading="uploading" class="logo-box" title="点击上传 png / jpg(≤2MB)">
<img v-if="logoUrl" :src="logoUrl" alt="公司 logo" />
<span v-else class="logo-plus">+</span>
</div>
</el-form-item>
<el-form-item label="logo">
<div class="field-control">
<el-upload
class="logo-uploader"
:show-file-list="false"
:http-request="handleUpload"
:before-upload="beforeUpload"
accept="image/png,image/jpeg"
>
<div v-loading="uploading" class="logo-box" title="点击上传 png / jpg(≤2MB)">
<img v-if="logoUrl" :src="logoUrl" alt="公司 logo" />
<span v-else class="logo-plus">+</span>
</div>
</el-upload>
<p class="field-control__note">
png / jpg,不超过 2MB;仅展示于登录页品牌位,按 ≤56×36 显示。
</p>
</div>
</el-form-item>
</el-form>
</div>
<!--
保存按钮移进 card-foot:表单项里的按钮会跟着 label 列缩进、并对齐到输入框左缘,
作为整张表单的收尾动作用卡片底栏右对齐更符合阅读顺序。
按钮在 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>
</div>
<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">
<img v-if="logoUrl" :src="logoUrl" alt="" class="mock-logo" />
<div class="mock-name">{{ previewName }}</div>
<p class="mock-slogan">把每一笔回款,算成一份踏实。</p>
<span class="mock-waterline"></span>
</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>
</el-upload>
<p class="logo-hint">png / jpg,不超过 2MB;<br />展示于登录页品牌位与导出页眉。</p>
</div>
<div class="mock-foot">© {{ previewName }} · 内部系统</div>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="submitting" @click="handleSubmit">保存</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 签名元素:登录页实景预览(缩比登录卡,品牌名/logo 即时反映) -->
<el-card shadow="never" class="preview-card">
<template #header>
<span class="card-title">登录页预览</span>
<span class="preview-note">保存后对所有人生效</span>
</template>
<div class="login-mock">
<div class="mock-brand">
<img v-if="logoUrl" :src="logoUrl" alt="" class="mock-logo" />
<div class="mock-name">{{ previewName }}</div>
<p class="mock-slogan">把每一笔回款,算成一份踏实。</p>
<span class="mock-waterline"></span>
</div>
<p class="preview-caption">登录页品牌位实景(示意,非交互)——显示的是当前编辑值</p>
<div class="mock-field"></div>
<div class="mock-field"></div>
<div class="mock-submit">登 录</div>
</div>
</div>
<p class="preview-caption">登录页品牌位实景(示意,非交互)</p>
</el-card>
</div>
</div>
</template>
@ -236,58 +163,25 @@ const previewName = computed(() => form.company_name.trim() || '衍水集团')
<style scoped>
.settings-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) 480px;
grid-template-columns: minmax(360px, 460px) minmax(300px, 380px);
gap: 16px;
align-items: start;
}
.settings-col {
display: flex;
flex-direction: column;
gap: 16px;
min-width: 0;
.card-title {
font-weight: 600;
}
/*
* 说明文字不再用 float:right 顶到卡片头右侧 —— .card-head 已是 flex +
* space-between,第二个子元素自然落在右端,float 反而会破坏行内布局。
*/
.preview-note {
float: right;
font-size: 12px;
color: var(--ys-ink-3);
}
/*
* 控件行:主控件在左、说明在右,两者共享一行高度。
* 说明用 flex-grow 撑满剩余宽度并右对齐 —— 宽屏下整行铺到卡片右缘,
* 不会出现「输入框缩在左上角、右侧留一整块空白」的观感;
* 窄屏放不下时自动换行,说明退回左对齐。
*/
.field-control {
.logo-field {
display: flex;
flex-wrap: wrap;
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;
align-items: flex-start;
gap: 14px;
}
.logo-box {
@ -321,76 +215,22 @@ const previewName = computed(() => form.company_name.trim() || '衍水集团')
color: var(--ys-ink-3);
}
/* ── 生效范围:定义列表(项名 / 取值) ── */
.scope-list {
margin: 0;
}
.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;
.logo-hint {
margin: 4px 0 0;
font-size: 12px;
line-height: 1.7;
color: var(--ys-ink-3);
}
/* ── 登录页实景预览:按登录页真实版式 1:1 复刻 ── */
.login-stage {
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;
/* ── 登录页实景预览:按登录卡视觉 0.8 缩比重建(非 iframe 截图) ── */
.login-mock {
padding: 22px 26px 24px;
background: linear-gradient(180deg, #ffffff, #fcfcfe);
border: 1px solid rgba(28, 35, 51, 0.09);
border-radius: 14px;
border: 1px solid var(--ys-line);
border-radius: 12px;
box-shadow:
0 2px 4px rgba(28, 35, 51, 0.04),
0 16px 40px rgba(28, 35, 51, 0.1);
0 10px 26px rgba(28, 35, 51, 0.08);
}
.mock-brand {
@ -399,136 +239,62 @@ const previewName = computed(() => form.company_name.trim() || '衍水集团')
.mock-logo {
display: block;
max-width: 56px;
max-height: 36px;
margin: 0 auto 10px;
max-width: 64px;
max-height: 40px;
margin: 0 auto 8px;
object-fit: contain;
}
.mock-name {
font-family: var(--ys-font-brand);
font-size: 17px;
font-weight: 500;
font-size: 14px;
letter-spacing: 0.34em;
color: var(--ys-ink);
}
.mock-slogan {
margin: 8px 0 0;
font-size: 12.5px;
margin: 6px 0 0;
font-size: 11px;
color: var(--ys-ink-3);
}
.mock-waterline {
display: block;
width: 40px;
width: 34px;
height: 2px;
margin: 14px auto 0;
margin: 10px auto 0;
border-radius: 1px;
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 {
margin-top: 16px;
}
.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;
height: 32px;
margin-top: 12px;
background: #f4f5f8;
border: 1px solid rgba(28, 35, 51, 0.07);
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;
border-radius: 6px;
}
.mock-submit {
height: 38px;
margin-top: 20px;
font-size: 13px;
line-height: 38px;
height: 32px;
margin-top: 14px;
font-size: 12px;
line-height: 32px;
letter-spacing: 0.4em;
text-align: center;
color: #fff;
background: linear-gradient(180deg, var(--ys-indigo-hi), var(--ys-indigo));
border-radius: 7px;
}
.mock-foot {
margin-top: 16px;
font-size: 11px;
letter-spacing: 0.08em;
color: var(--ys-ink-3);
background: var(--ys-indigo);
border-radius: 6px;
}
.preview-caption {
margin: 12px 0 0;
margin: 10px 0 0;
font-size: 11px;
text-align: center;
color: var(--ys-ink-3);
}
/*
* 宽屏(≥1600px)说明文字靠右缘对齐,与左侧输入框形成「两端锚点」,
* 整行铺满卡片而不是缩在左上角;1600px 以下说明跟着输入框走,
* 放不下时整体换行到控件下方。
*/
@media (min-width: 1600px) {
.field-control__note {
text-align: right;
}
}
@media (max-width: 1200px) {
@media (max-width: 900px) {
.settings-grid {
grid-template-columns: 1fr;
}

View File

@ -250,77 +250,58 @@ onMounted(() => {
</script>
<template>
<div class="page">
<!-- 页面头:标题 + 一句话说清「管什么、动它会波及什么」+ 签名水线(每页仅此一处) -->
<div class="page-head">
<h1 class="page-title">用户管理</h1>
<p class="page-desc">管登录账号与角色分配;禁用、改密或重置密码都会让该用户已登录的会话立即失效。</p>
<div class="waterline"></div>
</div>
<div>
<el-card shadow="never">
<el-form class="filter-form" :inline="true" @submit.prevent>
<el-form-item label="用户名">
<el-input
v-model="filters.username"
placeholder="模糊匹配"
clearable
style="width: 160px"
@keyup.enter="search"
/>
</el-form-item>
<el-form-item label="姓名">
<el-input
v-model="filters.real_name"
placeholder="模糊匹配"
clearable
style="width: 140px"
@keyup.enter="search"
/>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="filters.status" placeholder="全部" clearable style="width: 110px">
<el-option
v-for="item in STATUS_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="角色">
<el-select v-model="filters.role_code" placeholder="全部" clearable style="width: 150px">
<el-option
v-for="role in roleOptions"
:key="role.role_code"
:label="role.role_name"
:value="role.role_code"
/>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="search">查询</el-button>
<el-button @click="resetFilters">重置</el-button>
</el-form-item>
</el-form>
<!--
筛选条:.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-input
v-model="filters.username"
placeholder="模糊匹配"
clearable
style="width: 160px"
@keyup.enter="search"
/>
</el-form-item>
<el-form-item label="姓名">
<el-input
v-model="filters.real_name"
placeholder="模糊匹配"
clearable
style="width: 140px"
@keyup.enter="search"
/>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="filters.status" placeholder="全部" clearable style="width: 110px">
<el-option
v-for="item in STATUS_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="角色">
<el-select v-model="filters.role_code" placeholder="全部" clearable style="width: 150px">
<el-option
v-for="role in roleOptions"
:key="role.role_code"
:label="role.role_name"
:value="role.role_code"
/>
</el-select>
</el-form-item>
<div class="filter-bar__actions">
<el-button type="primary" @click="search">查询</el-button>
<el-button @click="resetFilters">重置</el-button>
<div class="toolbar">
<el-button type="primary" @click="openCreate">新增用户</el-button>
</div>
</el-form>
<!-- 主操作行:与筛选条分离,避免「查」和「建」在视觉上同级 -->
<div class="toolbar">
<el-button type="primary" @click="openCreate">新增用户</el-button>
</div>
<!-- 表格卡片:表皮与筛选条同材质但更高一档;分页落进 card-foot,与表体共用同一张卡 -->
<div class="card">
<el-table
v-loading="loading"
:data="records"
stripe
empty-text="还没有用户,点「新增用户」创建第一个账号"
>
<el-table v-loading="loading" :data="records" stripe>
<el-table-column prop="username" label="用户名" min-width="120" />
<el-table-column prop="real_name" label="姓名" min-width="100">
<template #default="{ row }">{{ row.real_name || '—' }}</template>
@ -342,9 +323,8 @@ onMounted(() => {
</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="96" align="center">
<el-table-column label="状态" width="90" align="center">
<template #default="{ row }">
<!-- 状态统一交给 StatusTag(内部已是 ys-pill),页面不再自造标签形态 -->
<StatusTag :value="row.status" />
</template>
</el-table-column>
@ -386,16 +366,14 @@ onMounted(() => {
</el-table-column>
</el-table>
<div class="card-foot">
<AppPagination
:total="total"
:page-num="pageNum"
:page-size="pageSize"
@page-change="onPageChange"
@size-change="onSizeChange"
/>
</div>
</div>
<AppPagination
:total="total"
:page-num="pageNum"
:page-size="pageSize"
@page-change="onPageChange"
@size-change="onSizeChange"
/>
</el-card>
<!-- 新增 / 编辑 -->
<el-dialog
@ -481,11 +459,9 @@ onMounted(() => {
<style scoped>
.role-tag {
/* 一个用户可能有多个角色,标签并排时需要横向间距(.el-tag 自身不带) */
margin-right: 4px;
}
.reset-tip {
margin: 0 0 16px;
color: var(--ys-text);