Compare commits
5 Commits
c48d24ab6d
...
8e8bcaa5fb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e8bcaa5fb | ||
|
|
1f65913002 | ||
|
|
e61800dcdc | ||
|
|
f88857e026 | ||
|
|
5ece9f6dab |
@ -11,6 +11,7 @@ Create Date: 2026-10-19 17:55:48.892998
|
||||
|
||||
「同步连续失败」规则依赖 ⑥ T+ 同步,模块未建前不设参数、不产生条目。
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
from datetime import date
|
||||
|
||||
@ -19,8 +20,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
|
||||
|
||||
|
||||
@ -34,10 +34,7 @@ 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(
|
||||
@ -188,6 +185,4 @@ 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)")
|
||||
|
||||
@ -43,9 +43,7 @@ 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 "
|
||||
|
||||
@ -12,7 +12,8 @@ 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.repositories import business_role_repository
|
||||
from app.schemas.business_role import BusinessRoleOptionOut
|
||||
from app.services import business_role_service
|
||||
|
||||
router = APIRouter(prefix="/business-roles", tags=["人员管理"])
|
||||
|
||||
@ -20,21 +21,14 @@ router = APIRouter(prefix="/business-roles", tags=["人员管理"])
|
||||
CurrentUser = Annotated[User, Depends(get_current_user)]
|
||||
|
||||
|
||||
@router.get("/options", response_model=ApiResponse[list[dict]], summary="岗位下拉选项")
|
||||
def list_options(session: DbSession, _: CurrentUser) -> ApiResponse[list[dict]]:
|
||||
@router.get(
|
||||
"/options",
|
||||
response_model=ApiResponse[list[BusinessRoleOptionOut]],
|
||||
summary="岗位下拉选项",
|
||||
)
|
||||
def list_options(session: DbSession, _: CurrentUser) -> ApiResponse[list[BusinessRoleOptionOut]]:
|
||||
"""岗位选项(登录即可;含停用——人员档案可能仍引用停用岗位)。
|
||||
|
||||
岗位规模有限(内置 + 自定义),一次取足够上限,不做分页。
|
||||
岗位规模有限(内置 + 自定义),一次取全量,不做分页。
|
||||
"""
|
||||
roles = business_role_repository.list_roles(session, offset=0, limit=1000)
|
||||
return ok(
|
||||
[
|
||||
{
|
||||
"id": role.id,
|
||||
"role_code": role.role_code,
|
||||
"role_name": role.role_name,
|
||||
"status": role.status,
|
||||
}
|
||||
for role in roles
|
||||
]
|
||||
)
|
||||
return ok(business_role_service.list_options(session))
|
||||
|
||||
@ -19,11 +19,7 @@ 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="无权访问工作台")),
|
||||
]
|
||||
|
||||
|
||||
@ -39,6 +35,4 @@ 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))
|
||||
|
||||
@ -79,9 +79,7 @@ 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(
|
||||
|
||||
@ -5,8 +5,6 @@
|
||||
models/customer.py 定义)。
|
||||
"""
|
||||
|
||||
from datetime import date
|
||||
|
||||
from sqlalchemy import Row, Select, delete, exists, func, or_, select, update
|
||||
from sqlalchemy.orm import Session, aliased
|
||||
|
||||
@ -23,6 +21,8 @@ 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,50 +77,32 @@ def get_with_names(
|
||||
return session.execute(stmt).first()
|
||||
|
||||
|
||||
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:
|
||||
def _apply_filters(stmt: Select, query: BusinessQuery) -> Select:
|
||||
"""业务列表的筛选条件(调用方须已 join Customer)。"""
|
||||
if keyword:
|
||||
if query.keyword:
|
||||
# 业务编号/公司名称双列 OR 模糊;contains(autoescape) 同 query_utils.fuzzy
|
||||
# 的转义口径(fuzzy 为单列 where 语义,双列 OR 在此直接组合)
|
||||
stmt = stmt.where(
|
||||
or_(
|
||||
Business.business_no.contains(keyword, autoescape=True),
|
||||
Customer.company_name.contains(keyword, autoescape=True),
|
||||
Business.business_no.contains(query.keyword, autoescape=True),
|
||||
Customer.company_name.contains(query.keyword, autoescape=True),
|
||||
)
|
||||
)
|
||||
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)
|
||||
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)
|
||||
return stmt
|
||||
|
||||
|
||||
def list_businesses(
|
||||
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,
|
||||
session: Session, query: BusinessQuery
|
||||
) -> list[tuple[Business, str, str | None, str | None]]:
|
||||
"""分页查询业务(附客户名/负责人名/协助人名),按 id 倒序。"""
|
||||
owner = aliased(Personnel)
|
||||
@ -128,49 +110,24 @@ 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,
|
||||
keyword=keyword,
|
||||
status=status,
|
||||
customer_id=customer_id,
|
||||
owner_personnel_id=owner_personnel_id,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
)
|
||||
stmt = _apply_filters(stmt, query)
|
||||
stmt = (
|
||||
stmt.outerjoin(owner, owner.id == Business.owner_personnel_id)
|
||||
.outerjoin(assistant, assistant.id == Business.assistant_personnel_id)
|
||||
.order_by(Business.id.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
.offset(query.offset)
|
||||
.limit(query.page_size)
|
||||
)
|
||||
return list(session.execute(stmt).all())
|
||||
|
||||
|
||||
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:
|
||||
def count_businesses(session: Session, query: BusinessQuery) -> int:
|
||||
stmt = (
|
||||
select(func.count())
|
||||
.select_from(Business)
|
||||
.join(Customer, Customer.id == Business.customer_id)
|
||||
)
|
||||
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 = _apply_filters(stmt, query)
|
||||
return int(session.execute(stmt).scalar_one())
|
||||
|
||||
|
||||
@ -313,18 +270,17 @@ def list_ownership_logs(
|
||||
) -> list[Row]:
|
||||
"""某业务的归属变更留痕(共享实现,owner_type/business_id 参数化)。"""
|
||||
return ownership_log_repository.list_by_ref(
|
||||
session,
|
||||
owner_type=OWNER_TYPE_BUSINESS,
|
||||
ref_column=OwnershipChangeLog.business_id,
|
||||
ref_id=business_id,
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
session, _business_log_ref(business_id), offset=offset, limit=limit
|
||||
)
|
||||
|
||||
|
||||
def count_ownership_logs(session: Session, business_id: int) -> int:
|
||||
return ownership_log_repository.count_by_ref(
|
||||
session,
|
||||
return ownership_log_repository.count_by_ref(session, _business_log_ref(business_id))
|
||||
|
||||
|
||||
def _business_log_ref(business_id: int) -> OwnershipLogRef:
|
||||
"""业务维度的归属留痕锚点。"""
|
||||
return OwnershipLogRef(
|
||||
owner_type=OWNER_TYPE_BUSINESS,
|
||||
ref_column=OwnershipChangeLog.business_id,
|
||||
ref_id=business_id,
|
||||
|
||||
@ -7,6 +7,7 @@ from sqlalchemy import Select, func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import BusinessRole
|
||||
from app.schemas.business_role import BusinessRoleQuery
|
||||
|
||||
# ==================== 读取 ====================
|
||||
|
||||
@ -39,41 +40,48 @@ def list_by_codes(session: Session, role_codes: list[str]) -> dict[str, Business
|
||||
return {role.role_code: role for role in rows}
|
||||
|
||||
|
||||
def list_roles(
|
||||
session: Session,
|
||||
*,
|
||||
offset: int,
|
||||
limit: int,
|
||||
keyword: str | None = None,
|
||||
status: int | None = None,
|
||||
) -> list[BusinessRole]:
|
||||
def list_roles(session: Session, query: BusinessRoleQuery) -> list[BusinessRole]:
|
||||
"""分页查询岗位(含停用)。排序 (sort_order, id),与系统角色口径一致。"""
|
||||
stmt = _apply_filters(select(BusinessRole), keyword=keyword, status=status)
|
||||
stmt = stmt.order_by(BusinessRole.sort_order, BusinessRole.id).offset(offset).limit(limit)
|
||||
stmt = _apply_filters(select(BusinessRole), query)
|
||||
stmt = (
|
||||
stmt.order_by(BusinessRole.sort_order, BusinessRole.id)
|
||||
.offset(query.offset)
|
||||
.limit(query.page_size)
|
||||
)
|
||||
return list(session.execute(stmt).scalars())
|
||||
|
||||
|
||||
def count_roles(session: Session, *, keyword: str | None = None, status: int | None = None) -> int:
|
||||
def count_roles(session: Session, query: BusinessRoleQuery) -> int:
|
||||
"""统计符合条件的岗位总数,供分页使用。"""
|
||||
stmt = _apply_filters(select(func.count(BusinessRole.id)), keyword=keyword, status=status)
|
||||
stmt = _apply_filters(select(func.count(BusinessRole.id)), query)
|
||||
return int(session.execute(stmt).scalar_one())
|
||||
|
||||
|
||||
def _apply_filters(stmt: Select, *, keyword: str | None, status: int | None) -> Select:
|
||||
def list_all(session: Session) -> list[BusinessRole]:
|
||||
"""全部岗位(含停用,不分页)——岗位下拉选项的取数口径。
|
||||
|
||||
岗位规模有限(内置 + 自定义),一次取全量,不受分页上限约束;
|
||||
排序与 list_roles 一致(sort_order, id),下拉与列表相对次序相同。
|
||||
"""
|
||||
stmt = select(BusinessRole).order_by(BusinessRole.sort_order, BusinessRole.id)
|
||||
return list(session.execute(stmt).scalars())
|
||||
|
||||
|
||||
def _apply_filters(stmt: Select, query: BusinessRoleQuery) -> Select:
|
||||
"""应用通用筛选条件。列表与计数共用,避免两处口径不一致。
|
||||
|
||||
关键词走 ``contains(autoescape=True)``(转义 LIKE 通配符)——岗位编码含下划线,
|
||||
不转义时 ``_`` 会当单字符通配执行。
|
||||
"""
|
||||
if keyword:
|
||||
if query.keyword:
|
||||
stmt = stmt.where(
|
||||
or_(
|
||||
BusinessRole.role_code.contains(keyword, autoescape=True),
|
||||
BusinessRole.role_name.contains(keyword, autoescape=True),
|
||||
BusinessRole.role_code.contains(query.keyword, autoescape=True),
|
||||
BusinessRole.role_name.contains(query.keyword, autoescape=True),
|
||||
)
|
||||
)
|
||||
if status is not None:
|
||||
stmt = stmt.where(BusinessRole.status == status)
|
||||
if query.status is not None:
|
||||
stmt = stmt.where(BusinessRole.status == query.status)
|
||||
return stmt
|
||||
|
||||
|
||||
|
||||
@ -9,7 +9,9 @@ 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
|
||||
|
||||
# ==================== 读取 ====================
|
||||
|
||||
@ -45,49 +47,25 @@ 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,
|
||||
*,
|
||||
offset: int,
|
||||
limit: int,
|
||||
keyword: str | None = None,
|
||||
customer_type: str | None = None,
|
||||
owner_personnel_id: int | None = None,
|
||||
) -> list[Row]:
|
||||
def list_customers(session: Session, query: CustomerQuery) -> list[Row]:
|
||||
"""分页查询客户,行内容为 (客户, 归属人员姓名)。
|
||||
|
||||
归属人员外键非空但统一用 outerjoin:人员行不可考(被物理删除的脏数据)
|
||||
不应让客户行整个查不出来(同 personnel_repository 的韧性口径)。
|
||||
排序按 id 倒序:客户无排序字段,新增的靠前符合列表页习惯。
|
||||
"""
|
||||
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)
|
||||
stmt = _apply_filters(_base_select(), query)
|
||||
stmt = stmt.order_by(Customer.id.desc()).offset(query.offset).limit(query.page_size)
|
||||
return list(session.execute(stmt).all())
|
||||
|
||||
|
||||
def count_customers(
|
||||
session: Session,
|
||||
*,
|
||||
keyword: str | None = None,
|
||||
customer_type: str | None = None,
|
||||
owner_personnel_id: int | None = None,
|
||||
) -> int:
|
||||
def count_customers(session: Session, query: CustomerQuery) -> int:
|
||||
"""统计符合条件的客户总数,供分页使用。
|
||||
|
||||
筛选条件全部落在主表上,计数无需 join(列表与计数的筛选口径由
|
||||
``_apply_filters`` 单点保证)。
|
||||
"""
|
||||
stmt = _apply_filters(
|
||||
select(func.count(Customer.id)),
|
||||
keyword=keyword,
|
||||
customer_type=customer_type,
|
||||
owner_personnel_id=owner_personnel_id,
|
||||
)
|
||||
stmt = _apply_filters(select(func.count(Customer.id)), query)
|
||||
return int(session.execute(stmt).scalar_one())
|
||||
|
||||
|
||||
@ -101,9 +79,7 @@ def list_ownership_logs(
|
||||
"""查某客户的归属变更留痕(共享实现,owner_type/customer_id 参数化)。"""
|
||||
return ownership_log_repository.list_by_ref(
|
||||
session,
|
||||
owner_type=OWNER_TYPE_CUSTOMER,
|
||||
ref_column=OwnershipChangeLog.customer_id,
|
||||
ref_id=customer_id,
|
||||
_customer_log_ref(customer_id),
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
)
|
||||
@ -111,8 +87,12 @@ def list_ownership_logs(
|
||||
|
||||
def count_ownership_logs(session: Session, *, customer_id: int) -> int:
|
||||
"""统计某客户的归属留痕总数,供分页使用。"""
|
||||
return ownership_log_repository.count_by_ref(
|
||||
session,
|
||||
return ownership_log_repository.count_by_ref(session, _customer_log_ref(customer_id))
|
||||
|
||||
|
||||
def _customer_log_ref(customer_id: int) -> OwnershipLogRef:
|
||||
"""客户维度的归属留痕锚点。"""
|
||||
return OwnershipLogRef(
|
||||
owner_type=OWNER_TYPE_CUSTOMER,
|
||||
ref_column=OwnershipChangeLog.customer_id,
|
||||
ref_id=customer_id,
|
||||
@ -126,20 +106,14 @@ def _base_select() -> Select:
|
||||
)
|
||||
|
||||
|
||||
def _apply_filters(
|
||||
stmt: Select,
|
||||
*,
|
||||
keyword: str | None,
|
||||
customer_type: str | None,
|
||||
owner_personnel_id: int | None,
|
||||
) -> Select:
|
||||
def _apply_filters(stmt: Select, query: CustomerQuery) -> Select:
|
||||
"""应用通用筛选条件。列表与计数共用,避免两处口径不一致。"""
|
||||
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)
|
||||
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)
|
||||
return stmt
|
||||
|
||||
|
||||
|
||||
@ -34,20 +34,25 @@ _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]:
|
||||
@ -66,9 +71,7 @@ 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`` 只用于
|
||||
@ -107,6 +110,7 @@ def get_month_settlement(session: Session, month: str) -> MonthlySettlement | No
|
||||
|
||||
# ==== 待办事项 ====
|
||||
|
||||
|
||||
def list_pending_approvals(
|
||||
session: Session, *, statuses: tuple[int, ...], limit: int
|
||||
) -> list[ApprovalOrder]:
|
||||
@ -160,15 +164,14 @@ 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:
|
||||
"""发货后超过阈值且该批次无任何回款的发货单(回款逾期规则)。
|
||||
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
"""登录日志数据访问。"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from sqlalchemy import Select, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@ -10,68 +12,73 @@ from app.repositories.query_utils import LogFilters, apply_log_filters
|
||||
_USER_AGENT_MAX_LEN = 255
|
||||
|
||||
|
||||
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:
|
||||
@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:
|
||||
"""写入一条登录日志。只 flush 不 commit。
|
||||
|
||||
**绝不记录密码** —— 明文与哈希都不写。日志是最容易泄漏凭据的地方。
|
||||
|
||||
:param user_id: 账号不存在时传 None(列本身可空)
|
||||
:param username: 用户名原样记录,便于账号不存在时也能追溯
|
||||
:param entry.user_id: 账号不存在时为 None(列本身可空)
|
||||
:param entry.username: 用户名原样记录,便于账号不存在时也能追溯
|
||||
"""
|
||||
session.add(
|
||||
LoginLog(
|
||||
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,
|
||||
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,
|
||||
)
|
||||
)
|
||||
session.flush()
|
||||
|
||||
|
||||
def list_logs(
|
||||
session: Session,
|
||||
*,
|
||||
offset: int,
|
||||
limit: int,
|
||||
filters: LogFilters,
|
||||
ip: str | None = None,
|
||||
) -> list[LoginLog]:
|
||||
def list_logs(session: Session, query: LoginLogListQuery) -> list[LoginLog]:
|
||||
"""分页查询登录日志。
|
||||
|
||||
**按 id 倒序而非 create_time**:后者是秒级精度,同一秒内的多条记录排序
|
||||
不确定,翻页会重复或遗漏(与用户列表同一理由)。
|
||||
"""
|
||||
stmt = _apply_filters(select(LoginLog), filters=filters, ip=ip)
|
||||
stmt = stmt.order_by(LoginLog.id.desc()).offset(offset).limit(limit)
|
||||
stmt = _apply_filters(select(LoginLog), query)
|
||||
stmt = stmt.order_by(LoginLog.id.desc()).offset(query.offset).limit(query.limit)
|
||||
return list(session.execute(stmt).scalars())
|
||||
|
||||
|
||||
def count_logs(
|
||||
session: Session,
|
||||
*,
|
||||
filters: LogFilters,
|
||||
ip: str | None = None,
|
||||
) -> int:
|
||||
def count_logs(session: Session, query: LoginLogListQuery) -> int:
|
||||
"""统计符合条件的登录日志总数,供分页使用。"""
|
||||
stmt = _apply_filters(select(func.count(LoginLog.id)), filters=filters, ip=ip)
|
||||
stmt = _apply_filters(select(func.count(LoginLog.id)), query)
|
||||
return int(session.execute(stmt).scalar_one())
|
||||
|
||||
|
||||
def _apply_filters(stmt: Select, *, filters: LogFilters, ip: str | None) -> Select:
|
||||
def _apply_filters(stmt: Select, query: LoginLogListQuery) -> Select:
|
||||
"""应用筛选条件(公共部分复用 query_utils,与操作日志同口径)。"""
|
||||
stmt = apply_log_filters(stmt, LoginLog, filters)
|
||||
if ip:
|
||||
stmt = stmt.where(LoginLog.ip == ip)
|
||||
stmt = apply_log_filters(stmt, LoginLog, query.filters)
|
||||
if query.ip:
|
||||
stmt = stmt.where(LoginLog.ip == query.ip)
|
||||
return stmt
|
||||
|
||||
@ -107,15 +107,18 @@ def get_by_id(session: Session, log_id: int) -> OperationLog | None:
|
||||
return session.get(OperationLog, log_id)
|
||||
|
||||
|
||||
def list_logs(
|
||||
session: Session,
|
||||
*,
|
||||
offset: int,
|
||||
limit: int,
|
||||
filters: LogFilters,
|
||||
module: str | None = None,
|
||||
action: str | None = None,
|
||||
) -> list[OperationLog]:
|
||||
@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]:
|
||||
"""分页查询操作日志。
|
||||
|
||||
**按 id 倒序而非 create_time**:后者是秒级精度,同一秒内的多条记录排序
|
||||
@ -127,41 +130,25 @@ def list_logs(
|
||||
"""
|
||||
stmt = _apply_filters(
|
||||
select(OperationLog).options(defer(OperationLog.request_data)),
|
||||
filters=filters,
|
||||
module=module,
|
||||
action=action,
|
||||
query,
|
||||
)
|
||||
stmt = stmt.order_by(OperationLog.id.desc()).offset(offset).limit(limit)
|
||||
stmt = stmt.order_by(OperationLog.id.desc()).offset(query.offset).limit(query.limit)
|
||||
return list(session.execute(stmt).scalars())
|
||||
|
||||
|
||||
def count_logs(
|
||||
session: Session,
|
||||
*,
|
||||
filters: LogFilters,
|
||||
module: str | None = None,
|
||||
action: str | None = None,
|
||||
) -> int:
|
||||
def count_logs(session: Session, query: OperationLogListQuery) -> int:
|
||||
"""统计符合条件的日志总数,供分页使用。"""
|
||||
stmt = _apply_filters(
|
||||
select(func.count(OperationLog.id)), filters=filters, module=module, action=action
|
||||
)
|
||||
stmt = _apply_filters(select(func.count(OperationLog.id)), query)
|
||||
return int(session.execute(stmt).scalar_one())
|
||||
|
||||
|
||||
def _apply_filters(
|
||||
stmt: Select,
|
||||
*,
|
||||
filters: LogFilters,
|
||||
module: str | None,
|
||||
action: str | None,
|
||||
) -> Select:
|
||||
def _apply_filters(stmt: Select, query: OperationLogListQuery) -> Select:
|
||||
"""应用筛选条件(公共部分复用 query_utils,与登录日志同口径)。"""
|
||||
stmt = apply_log_filters(stmt, OperationLog, filters)
|
||||
if module:
|
||||
stmt = stmt.where(OperationLog.module == module)
|
||||
if action:
|
||||
stmt = stmt.where(OperationLog.action == action)
|
||||
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)
|
||||
return stmt
|
||||
|
||||
|
||||
|
||||
@ -5,18 +5,32 @@ 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]:
|
||||
@ -32,7 +46,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 == owner_type, ref_column == ref_id)
|
||||
.where(OwnershipChangeLog.owner_type == ref.owner_type, ref.ref_column == ref.ref_id)
|
||||
.order_by(OwnershipChangeLog.create_time.desc(), OwnershipChangeLog.id.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
@ -40,11 +54,11 @@ def list_by_ref(
|
||||
return list(session.execute(stmt).all())
|
||||
|
||||
|
||||
def count_by_ref(session: Session, *, owner_type: int, ref_column, ref_id: int) -> int:
|
||||
def count_by_ref(session: Session, ref: OwnershipLogRef) -> int:
|
||||
"""统计某引用对象的归属留痕总数,供分页使用。"""
|
||||
stmt = select(func.count(OwnershipChangeLog.id)).where(
|
||||
OwnershipChangeLog.owner_type == owner_type,
|
||||
ref_column == ref_id,
|
||||
OwnershipChangeLog.owner_type == ref.owner_type,
|
||||
ref.ref_column == ref.ref_id,
|
||||
)
|
||||
return int(session.execute(stmt).scalar_one())
|
||||
|
||||
|
||||
@ -10,6 +10,7 @@ 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
|
||||
|
||||
# ==================== 读取 ====================
|
||||
|
||||
@ -97,27 +98,27 @@ def list_definitions_by_codes(
|
||||
|
||||
|
||||
def list_history(
|
||||
session: Session,
|
||||
param_id: int,
|
||||
*,
|
||||
offset: int,
|
||||
limit: int,
|
||||
dimension_value: str | None = None,
|
||||
session: Session, param_id: int, query: ParamHistoryQuery
|
||||
) -> list[tuple[ParamValue, ParamChangeBatch]]:
|
||||
"""单参数的版本历史(含批次信息),最新在前。"""
|
||||
stmt = _history_stmt(session, param_id, dimension_value=dimension_value)
|
||||
return [(value, batch) for value, batch in session.execute(stmt.offset(offset).limit(limit))]
|
||||
"""单参数的版本历史(含批次信息),最新在前。
|
||||
|
||||
``param_id`` 是「查哪个参数」的主锚点(由路径参数 param_code 解析而来),
|
||||
不属于列表筛选项,故仍以位置参数传入;筛选与分页走 ``query``。
|
||||
"""
|
||||
stmt = _history_stmt(param_id, query)
|
||||
return [
|
||||
(value, batch)
|
||||
for value, batch in session.execute(stmt.offset(query.offset).limit(query.page_size))
|
||||
]
|
||||
|
||||
|
||||
def count_history(session: Session, param_id: int, *, dimension_value: str | None = None) -> int:
|
||||
def count_history(session: Session, param_id: int, query: ParamHistoryQuery) -> int:
|
||||
"""统计单参数的历史条目数。"""
|
||||
stmt = select(func.count()).select_from(
|
||||
_history_stmt(session, param_id, dimension_value=dimension_value).subquery()
|
||||
)
|
||||
stmt = select(func.count()).select_from(_history_stmt(param_id, query).subquery())
|
||||
return int(session.execute(stmt).scalar_one())
|
||||
|
||||
|
||||
def _history_stmt(session: Session, param_id: int, *, dimension_value: str | None) -> Select:
|
||||
def _history_stmt(param_id: int, query: ParamHistoryQuery) -> Select:
|
||||
"""历史查询基语句:条目 join 批次(版本号/操作人),最新在前。"""
|
||||
stmt = (
|
||||
select(ParamValue, ParamChangeBatch)
|
||||
@ -125,8 +126,8 @@ def _history_stmt(session: Session, param_id: int, *, dimension_value: str | Non
|
||||
.where(ParamValue.param_id == param_id)
|
||||
.order_by(ParamValue.effective_date.desc(), ParamValue.id.desc())
|
||||
)
|
||||
if dimension_value is not None:
|
||||
stmt = stmt.where(ParamValue.dimension_value == dimension_value)
|
||||
if query.dimension_value is not None:
|
||||
stmt = stmt.where(ParamValue.dimension_value == query.dimension_value)
|
||||
return stmt
|
||||
|
||||
|
||||
@ -150,41 +151,30 @@ def get_batch_by_id(
|
||||
return session.get(ParamChangeBatch, batch_id)
|
||||
|
||||
|
||||
def list_batches(
|
||||
session: Session,
|
||||
*,
|
||||
offset: int,
|
||||
limit: int,
|
||||
start_date: date | None = None,
|
||||
end_date: date | None = None,
|
||||
) -> list[ParamChangeBatch]:
|
||||
def list_batches(session: Session, query: ParamBatchQuery) -> list[ParamChangeBatch]:
|
||||
"""分页查询变更批次(按创建时间倒序)。"""
|
||||
stmt = _batch_filters(select(ParamChangeBatch), start_date=start_date, end_date=end_date)
|
||||
stmt = _batch_filters(select(ParamChangeBatch), query)
|
||||
return list(
|
||||
session.execute(
|
||||
stmt.order_by(ParamChangeBatch.create_time.desc(), ParamChangeBatch.id.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
.offset(query.offset)
|
||||
.limit(query.page_size)
|
||||
).scalars()
|
||||
)
|
||||
|
||||
|
||||
def count_batches(
|
||||
session: Session, *, start_date: date | None = None, end_date: date | None = None
|
||||
) -> int:
|
||||
def count_batches(session: Session, query: ParamBatchQuery) -> int:
|
||||
"""统计符合条件的批次总数。"""
|
||||
stmt = _batch_filters(
|
||||
select(func.count(ParamChangeBatch.id)), start_date=start_date, end_date=end_date
|
||||
)
|
||||
stmt = _batch_filters(select(func.count(ParamChangeBatch.id)), query)
|
||||
return int(session.execute(stmt).scalar_one())
|
||||
|
||||
|
||||
def _batch_filters(stmt: Select, *, start_date: date | None, end_date: date | None) -> Select:
|
||||
def _batch_filters(stmt: Select, query: ParamBatchQuery) -> Select:
|
||||
"""批次筛选(生效日期区间,含边界)。列表与计数共用。"""
|
||||
if start_date is not None:
|
||||
stmt = stmt.where(ParamChangeBatch.effective_date >= start_date)
|
||||
if end_date is not None:
|
||||
stmt = stmt.where(ParamChangeBatch.effective_date <= end_date)
|
||||
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)
|
||||
return stmt
|
||||
|
||||
|
||||
|
||||
@ -9,6 +9,7 @@ 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)
|
||||
@ -30,44 +31,23 @@ def get_with_names(session: Session, personnel_id: int) -> Row | None:
|
||||
return session.execute(stmt).first()
|
||||
|
||||
|
||||
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]:
|
||||
def list_personnel(session: Session, query: PersonnelQuery) -> list[Row]:
|
||||
"""分页查询人员,行内容为 (人员, 岗位名称, 直属经理姓名)。
|
||||
|
||||
排序按 id 倒序:人员无排序字段,新增的靠前符合列表页习惯。
|
||||
"""
|
||||
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)
|
||||
stmt = _apply_filters(_base_select(), query)
|
||||
stmt = stmt.order_by(Personnel.id.desc()).offset(query.offset).limit(query.page_size)
|
||||
return list(session.execute(stmt).all())
|
||||
|
||||
|
||||
def count_personnel(
|
||||
session: Session,
|
||||
*,
|
||||
keyword: str | None = None,
|
||||
status: int | None = None,
|
||||
business_role_id: int | None = None,
|
||||
) -> int:
|
||||
def count_personnel(session: Session, query: PersonnelQuery) -> int:
|
||||
"""统计符合条件的人员总数,供分页使用。
|
||||
|
||||
筛选条件全部落在主表上,计数无需 join(列表与计数的筛选口径由
|
||||
``_apply_filters`` 单点保证)。
|
||||
"""
|
||||
stmt = _apply_filters(
|
||||
select(func.count(Personnel.id)),
|
||||
keyword=keyword,
|
||||
status=status,
|
||||
business_role_id=business_role_id,
|
||||
)
|
||||
stmt = _apply_filters(select(func.count(Personnel.id)), query)
|
||||
return int(session.execute(stmt).scalar_one())
|
||||
|
||||
|
||||
@ -154,20 +134,14 @@ def _base_select() -> Select:
|
||||
)
|
||||
|
||||
|
||||
def _apply_filters(
|
||||
stmt: Select,
|
||||
*,
|
||||
keyword: str | None,
|
||||
status: int | None,
|
||||
business_role_id: int | None,
|
||||
) -> Select:
|
||||
def _apply_filters(stmt: Select, query: PersonnelQuery) -> Select:
|
||||
"""应用通用筛选条件。列表与计数共用,避免两处口径不一致。"""
|
||||
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)
|
||||
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)
|
||||
return stmt
|
||||
|
||||
|
||||
|
||||
@ -7,6 +7,7 @@ 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
|
||||
|
||||
# ==================== 读取 ====================
|
||||
|
||||
@ -34,50 +35,20 @@ def get_by_name_spec(session: Session, product_name: str, spec_model: str) -> Pr
|
||||
return session.execute(stmt).scalar_one_or_none()
|
||||
|
||||
|
||||
def list_products(
|
||||
session: Session,
|
||||
*,
|
||||
offset: int,
|
||||
limit: int,
|
||||
keyword: str | None = None,
|
||||
product_category: str | None = None,
|
||||
status: int | None = None,
|
||||
) -> list[Product]:
|
||||
def list_products(session: Session, query: ProductQuery) -> list[Product]:
|
||||
"""分页查询产品(含停用)。按 id 升序,与 M1 岗位列表的次序口径一致。"""
|
||||
stmt = _apply_filters(
|
||||
select(Product),
|
||||
keyword=keyword,
|
||||
product_category=product_category,
|
||||
status=status,
|
||||
)
|
||||
stmt = stmt.order_by(Product.id).offset(offset).limit(limit)
|
||||
stmt = _apply_filters(select(Product), query)
|
||||
stmt = stmt.order_by(Product.id).offset(query.offset).limit(query.page_size)
|
||||
return list(session.execute(stmt).scalars())
|
||||
|
||||
|
||||
def count_products(
|
||||
session: Session,
|
||||
*,
|
||||
keyword: str | None = None,
|
||||
product_category: str | None = None,
|
||||
status: int | None = None,
|
||||
) -> int:
|
||||
def count_products(session: Session, query: ProductQuery) -> int:
|
||||
"""统计符合条件的产品总数,供分页使用。"""
|
||||
stmt = _apply_filters(
|
||||
select(func.count(Product.id)),
|
||||
keyword=keyword,
|
||||
product_category=product_category,
|
||||
status=status,
|
||||
)
|
||||
stmt = _apply_filters(select(func.count(Product.id)), query)
|
||||
return int(session.execute(stmt).scalar_one())
|
||||
|
||||
|
||||
def _apply_filters(
|
||||
stmt: Select,
|
||||
*,
|
||||
keyword: str | None,
|
||||
product_category: str | None,
|
||||
status: int | None,
|
||||
) -> Select:
|
||||
def _apply_filters(stmt: Select, query: ProductQuery) -> Select:
|
||||
"""应用通用筛选条件。列表与计数共用,避免两处口径不一致。
|
||||
|
||||
关键词对名称/规格两列取 OR 命中。转义与 query_utils.fuzzy 同一机制
|
||||
@ -86,17 +57,17 @@ def _apply_filters(
|
||||
写 contains —— M1 business_role_repository 的双列关键词同款写法。
|
||||
规格型号常含 ``_``(如 DN300_II),不转义会当单字符通配执行。
|
||||
"""
|
||||
if keyword:
|
||||
if query.keyword:
|
||||
stmt = stmt.where(
|
||||
or_(
|
||||
Product.product_name.contains(keyword, autoescape=True),
|
||||
Product.spec_model.contains(keyword, autoescape=True),
|
||||
Product.product_name.contains(query.keyword, autoescape=True),
|
||||
Product.spec_model.contains(query.keyword, autoescape=True),
|
||||
)
|
||||
)
|
||||
if product_category:
|
||||
stmt = stmt.where(Product.product_category == product_category)
|
||||
if status is not None:
|
||||
stmt = stmt.where(Product.status == status)
|
||||
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)
|
||||
return stmt
|
||||
|
||||
|
||||
|
||||
@ -14,6 +14,7 @@ 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:
|
||||
@ -43,72 +44,42 @@ def get_shipment(session: Session, shipment_id: int) -> Shipment | None:
|
||||
return session.get(Shipment, shipment_id)
|
||||
|
||||
|
||||
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:
|
||||
def _apply_filters(stmt: Select, query: ReceiptQuery) -> Select:
|
||||
"""回款列表的筛选条件(全部落在 Receipt 自身列上)。"""
|
||||
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)
|
||||
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)
|
||||
return stmt
|
||||
|
||||
|
||||
def list_receipts(
|
||||
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,
|
||||
session: Session, query: ReceiptQuery
|
||||
) -> 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),
|
||||
business_id=business_id,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
is_barter=is_barter,
|
||||
settlement_status=settlement_status,
|
||||
query,
|
||||
)
|
||||
return list(
|
||||
session.execute(
|
||||
stmt.order_by(Receipt.id.desc()).offset(query.offset).limit(query.page_size)
|
||||
).all()
|
||||
)
|
||||
return list(session.execute(stmt.order_by(Receipt.id.desc()).offset(offset).limit(limit)).all())
|
||||
|
||||
|
||||
def count_receipts(
|
||||
session: Session,
|
||||
*,
|
||||
business_id: int | None,
|
||||
date_from: date | None,
|
||||
date_to: date | None,
|
||||
is_barter: int | None,
|
||||
settlement_status: int | None,
|
||||
) -> int:
|
||||
def count_receipts(session: Session, query: ReceiptQuery) -> int:
|
||||
"""回款总数(筛选条件与列表共用同一 apply_filters,防两侧口径漂移)。"""
|
||||
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,
|
||||
)
|
||||
stmt = _apply_filters(select(func.count()).select_from(Receipt), query)
|
||||
return int(session.execute(stmt).scalar_one())
|
||||
|
||||
|
||||
|
||||
@ -389,9 +389,7 @@ def _bucket_condition(bucket: DayBucket) -> tuple:
|
||||
return tuple(conds)
|
||||
|
||||
|
||||
def list_payment_detail(
|
||||
session: Session, query: PaymentDetailQuery, bucket: DayBucket, *, offset: int, limit: int
|
||||
) -> list:
|
||||
def list_payment_detail(session: Session, query: PaymentDetailQuery, bucket: DayBucket) -> list:
|
||||
"""区间下钻:单笔生效行联回款表(行级展示回款金额与批次号)。
|
||||
|
||||
bucket 由服务层解析(未注册编码 400),仓储只按区间拼条件。
|
||||
@ -408,7 +406,7 @@ def list_payment_detail(
|
||||
.where(*_payment_filters(query), *_bucket_condition(bucket))
|
||||
)
|
||||
stmt = stmt.order_by(PerformanceLedger.receipt_date.desc(), PerformanceLedger.id.desc())
|
||||
stmt = stmt.offset(offset).limit(limit)
|
||||
stmt = stmt.offset(query.offset).limit(query.page_size)
|
||||
return list(session.execute(stmt).all())
|
||||
|
||||
|
||||
|
||||
@ -10,6 +10,7 @@ from sqlalchemy import Select, func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import Role, UserRole
|
||||
from app.schemas.role import RoleQuery
|
||||
|
||||
# ==================== 读取 ====================
|
||||
|
||||
@ -47,44 +48,37 @@ def get_all_roles(session: Session) -> list[Role]:
|
||||
return list(session.execute(stmt).scalars())
|
||||
|
||||
|
||||
def list_roles(
|
||||
session: Session,
|
||||
*,
|
||||
offset: int,
|
||||
limit: int,
|
||||
keyword: str | None = None,
|
||||
status: int | None = None,
|
||||
) -> list[Role]:
|
||||
def list_roles(session: Session, query: RoleQuery) -> list[Role]:
|
||||
"""分页查询角色(管理列表:含停用角色)。
|
||||
|
||||
排序与下拉口径一致(sort_order, id),保持同一套相对次序。
|
||||
"""
|
||||
stmt = _apply_filters(select(Role), keyword=keyword, status=status)
|
||||
stmt = stmt.order_by(Role.sort_order, Role.id).offset(offset).limit(limit)
|
||||
stmt = _apply_filters(select(Role), query)
|
||||
stmt = stmt.order_by(Role.sort_order, Role.id).offset(query.offset).limit(query.page_size)
|
||||
return list(session.execute(stmt).scalars())
|
||||
|
||||
|
||||
def count_roles(session: Session, *, keyword: str | None = None, status: int | None = None) -> int:
|
||||
def count_roles(session: Session, query: RoleQuery) -> int:
|
||||
"""统计符合条件的角色总数,供分页使用。"""
|
||||
stmt = _apply_filters(select(func.count(Role.id)), keyword=keyword, status=status)
|
||||
stmt = _apply_filters(select(func.count(Role.id)), query)
|
||||
return int(session.execute(stmt).scalar_one())
|
||||
|
||||
|
||||
def _apply_filters(stmt: Select, *, keyword: str | None, status: int | None) -> Select:
|
||||
def _apply_filters(stmt: Select, query: RoleQuery) -> Select:
|
||||
"""应用通用筛选条件。列表与计数共用,避免两处口径不一致。
|
||||
|
||||
关键词匹配走 `contains(autoescape=True)`(转义 LIKE 通配符)——
|
||||
角色编码普遍含下划线,直接拼接会让 `_` 当单字符通配执行。
|
||||
"""
|
||||
if keyword:
|
||||
if query.keyword:
|
||||
stmt = stmt.where(
|
||||
or_(
|
||||
Role.role_code.contains(keyword, autoescape=True),
|
||||
Role.role_name.contains(keyword, autoescape=True),
|
||||
Role.role_code.contains(query.keyword, autoescape=True),
|
||||
Role.role_name.contains(query.keyword, autoescape=True),
|
||||
)
|
||||
)
|
||||
if status is not None:
|
||||
stmt = stmt.where(Role.status == status)
|
||||
if query.status is not None:
|
||||
stmt = stmt.where(Role.status == query.status)
|
||||
return stmt
|
||||
|
||||
|
||||
|
||||
@ -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,59 +31,37 @@ def get_with_business_no(session: Session, shipment_id: int) -> tuple[Shipment,
|
||||
).first()
|
||||
|
||||
|
||||
def _apply_filters(
|
||||
stmt: Select,
|
||||
*,
|
||||
business_id: int | None,
|
||||
ship_date_from: date | None,
|
||||
ship_date_to: date | None,
|
||||
) -> Select:
|
||||
def _apply_filters(stmt: Select, query: ShipmentQuery) -> Select:
|
||||
"""发货列表的筛选条件(business_id 空=全量,一期按业务维度查看为主)。"""
|
||||
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)
|
||||
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)
|
||||
return stmt
|
||||
|
||||
|
||||
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]]:
|
||||
def list_shipments(session: Session, query: ShipmentQuery) -> list[tuple[Shipment, str]]:
|
||||
"""分页查询发货单(附业务号),按 id 倒序。"""
|
||||
stmt = _apply_filters(
|
||||
select(Shipment, Business.business_no).join(Business, Business.id == Shipment.business_id),
|
||||
business_id=business_id,
|
||||
ship_date_from=ship_date_from,
|
||||
ship_date_to=ship_date_to,
|
||||
query,
|
||||
)
|
||||
return list(
|
||||
session.execute(stmt.order_by(Shipment.id.desc()).offset(offset).limit(limit)).all()
|
||||
session.execute(
|
||||
stmt.order_by(Shipment.id.desc()).offset(query.offset).limit(query.page_size)
|
||||
).all()
|
||||
)
|
||||
|
||||
|
||||
def count_shipments(
|
||||
session: Session,
|
||||
*,
|
||||
business_id: int | None,
|
||||
ship_date_from: date | None,
|
||||
ship_date_to: date | None,
|
||||
) -> int:
|
||||
def count_shipments(session: Session, query: ShipmentQuery) -> int:
|
||||
"""发货单总数(筛选条件与列表共用同一 apply_filters,防两侧口径漂移)。"""
|
||||
stmt = _apply_filters(
|
||||
select(func.count())
|
||||
.select_from(Shipment)
|
||||
.join(Business, Business.id == Shipment.business_id),
|
||||
business_id=business_id,
|
||||
ship_date_from=ship_date_from,
|
||||
ship_date_to=ship_date_to,
|
||||
query,
|
||||
)
|
||||
return int(session.execute(stmt).scalar_one())
|
||||
|
||||
|
||||
@ -10,6 +10,7 @@ from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from app.models import Role, User, UserRole
|
||||
from app.repositories.query_utils import fuzzy
|
||||
from app.schemas.user import UserQuery
|
||||
|
||||
|
||||
def _base_query() -> Select[tuple[User]]:
|
||||
@ -48,16 +49,7 @@ def touch_last_login(session: Session, user: User, moment: datetime) -> None:
|
||||
# ==================== 列表查询 ====================
|
||||
|
||||
|
||||
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]:
|
||||
def list_users(session: Session, query: UserQuery) -> list[User]:
|
||||
"""分页查询用户列表。
|
||||
|
||||
**按 id 倒序而非 create_time**:create_time 是秒级精度,批量导入的用户
|
||||
@ -65,48 +57,35 @@ def list_users(
|
||||
id 是自增主键,天然唯一且与创建顺序一致。
|
||||
"""
|
||||
stmt = _base_query()
|
||||
stmt = _apply_filters(stmt, username=username, real_name=real_name, status=status)
|
||||
if role_code:
|
||||
stmt = _apply_filters(stmt, query)
|
||||
if query.role_code:
|
||||
# 按角色筛选需 join 关联表;角色是字典数据,数量极少,join 代价可忽略
|
||||
stmt = stmt.join(UserRole).join(Role).where(Role.role_code == role_code)
|
||||
stmt = stmt.order_by(User.id.desc()).offset(offset).limit(limit)
|
||||
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)
|
||||
return list(session.execute(stmt).scalars().unique())
|
||||
|
||||
|
||||
def count_users(
|
||||
session: Session,
|
||||
*,
|
||||
username: str | None = None,
|
||||
real_name: str | None = None,
|
||||
status: int | None = None,
|
||||
role_code: str | None = None,
|
||||
) -> int:
|
||||
def count_users(session: Session, query: UserQuery) -> int:
|
||||
"""统计符合条件的用户总数,供分页使用。"""
|
||||
stmt = select(func.count(User.id))
|
||||
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)
|
||||
stmt = _apply_filters(stmt, query)
|
||||
if query.role_code:
|
||||
stmt = stmt.join(UserRole).join(Role).where(Role.role_code == query.role_code)
|
||||
return int(session.execute(stmt).scalar_one())
|
||||
|
||||
|
||||
def _apply_filters(
|
||||
stmt: Select,
|
||||
*,
|
||||
username: str | None,
|
||||
real_name: str | None,
|
||||
status: int | None,
|
||||
) -> Select:
|
||||
def _apply_filters(stmt: Select, query: UserQuery) -> Select:
|
||||
"""应用通用筛选条件。列表与计数共用,避免两处口径不一致。
|
||||
|
||||
模糊匹配统一走 `query_utils.fuzzy`(转义 LIKE 通配符)—— 用户名普遍
|
||||
含下划线,直接拼接会让 `_` 当单字符通配执行、误配无关账号。
|
||||
"""
|
||||
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)
|
||||
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)
|
||||
return stmt
|
||||
|
||||
|
||||
|
||||
@ -49,7 +49,8 @@ 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="审批内容来源:提交时快照/当前内容回退/缺失"
|
||||
|
||||
@ -27,6 +27,19 @@ 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):
|
||||
"""模板声明的参数(供岗位表单展示,含当前口径说明)。"""
|
||||
|
||||
|
||||
@ -264,7 +264,9 @@ 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(
|
||||
|
||||
@ -37,12 +37,14 @@ def _record(
|
||||
"""写登录日志并立即提交。单独提交是刻意为之,见模块 docstring。"""
|
||||
login_log_repository.add(
|
||||
session,
|
||||
user_id=user_id,
|
||||
username=username,
|
||||
ip=ip,
|
||||
user_agent=user_agent,
|
||||
success=success,
|
||||
fail_reason=fail_reason,
|
||||
login_log_repository.LoginLogEntry(
|
||||
user_id=user_id,
|
||||
username=username,
|
||||
ip=ip,
|
||||
user_agent=user_agent,
|
||||
success=success,
|
||||
fail_reason=fail_reason,
|
||||
),
|
||||
)
|
||||
session.commit()
|
||||
|
||||
|
||||
@ -31,6 +31,7 @@ from app.models import BusinessRole, User
|
||||
from app.repositories import business_role_repository
|
||||
from app.schemas.business_role import (
|
||||
BusinessRoleCreateIn,
|
||||
BusinessRoleOptionOut,
|
||||
BusinessRoleOut,
|
||||
BusinessRoleQuery,
|
||||
BusinessRoleUpdateIn,
|
||||
@ -67,16 +68,8 @@ def _ensure_template(calc_template: str) -> None:
|
||||
|
||||
def list_roles(session: Session, query: BusinessRoleQuery) -> PageResult[BusinessRoleOut]:
|
||||
"""分页查询岗位(含停用)。"""
|
||||
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
|
||||
)
|
||||
roles = business_role_repository.list_roles(session, query)
|
||||
total = business_role_repository.count_roles(session, query)
|
||||
return PageResult[BusinessRoleOut](
|
||||
records=[_to_out(role) for role in roles],
|
||||
total=total,
|
||||
@ -85,6 +78,23 @@ def list_roles(session: Session, query: BusinessRoleQuery) -> PageResult[Busines
|
||||
)
|
||||
|
||||
|
||||
def list_options(session: Session) -> list[BusinessRoleOptionOut]:
|
||||
"""岗位下拉选项(含停用,不分页)。
|
||||
|
||||
含停用是因为人员档案可能仍引用停用岗位(停用只是禁止新增引用,
|
||||
不改历史归属);岗位规模有限(内置 + 自定义),一次取全量。
|
||||
"""
|
||||
return [
|
||||
BusinessRoleOptionOut(
|
||||
id=role.id,
|
||||
role_code=role.role_code,
|
||||
role_name=role.role_name,
|
||||
status=role.status,
|
||||
)
|
||||
for role in business_role_repository.list_all(session)
|
||||
]
|
||||
|
||||
|
||||
def get_role(session: Session, role_id: int) -> BusinessRoleOut:
|
||||
"""岗位详情。
|
||||
|
||||
|
||||
@ -15,6 +15,8 @@
|
||||
"""
|
||||
|
||||
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
|
||||
@ -234,26 +236,8 @@ def _is_duplicate_entry(exc: IntegrityError) -> bool:
|
||||
|
||||
def list_businesses(session: Session, query: BusinessQuery) -> PageResult[BusinessOut]:
|
||||
"""分页查询业务(附客户名与归属人名)。"""
|
||||
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,
|
||||
)
|
||||
rows = business_repository.list_businesses(session, query)
|
||||
total = business_repository.count_businesses(session, query)
|
||||
return PageResult[BusinessOut](
|
||||
records=[_to_out(row) for row in rows],
|
||||
total=total,
|
||||
@ -748,6 +732,67 @@ 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,
|
||||
@ -779,41 +824,12 @@ def change_ownership(
|
||||
if row is None:
|
||||
raise NotFoundError("业务不存在")
|
||||
business, _, old_owner_name, old_assistant_name = row
|
||||
# 归属变更的出参姓名来自校验函数的返回行(锁定读):首查姓名是变更前
|
||||
# 固化标量,新归属人名必须按新 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
|
||||
)
|
||||
resolution = _resolve_business_ownership(session, business, payload)
|
||||
|
||||
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,
|
||||
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,
|
||||
),
|
||||
session, _business_ownership_log(business, payload, resolution, operator)
|
||||
)
|
||||
# 出参组装:ID 在锁定行上已赋值(_to_out 读内存值即反映新值),姓名
|
||||
# 一侧「变更项用新 ID 的人名、未变项沿用首查名」——首查名是变更前快照
|
||||
@ -821,17 +837,19 @@ def change_ownership(
|
||||
(
|
||||
business,
|
||||
row[1],
|
||||
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,
|
||||
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,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
logger.info(
|
||||
"调整业务归属 | business_id=%s | owner %s->%s | assistant %s->%s",
|
||||
business_id,
|
||||
old_owner_id,
|
||||
resolution.old_owner_id,
|
||||
payload.new_owner_personnel_id,
|
||||
old_assistant_id,
|
||||
resolution.old_assistant_id,
|
||||
payload.new_assistant_personnel_id,
|
||||
)
|
||||
return out
|
||||
@ -897,6 +915,94 @@ def list_status_logs(
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _GrossProfitScan:
|
||||
"""毛利润逐行扫描结果(行 DTO、类别聚合桶、成本缺失计数)。"""
|
||||
|
||||
rows: list[GrossProfitRowOut]
|
||||
category_agg: dict[str, list[Decimal]]
|
||||
missing_count: int
|
||||
|
||||
|
||||
def _gross_profit_row(
|
||||
item: BusinessItem, threshold: Fraction
|
||||
) -> tuple[GrossProfitRowOut, tuple[Decimal, Decimal] | None]:
|
||||
"""单行毛利润派生值(行 DTO + 类别聚合桶)。
|
||||
|
||||
聚合桶为 None 表示成本缺失行 —— 这类行不计入类别汇总,但仍要输出
|
||||
派生列为 None 的行与缺失原因。阈值对两类行都要取(成本缺失也展示阈值)。
|
||||
"""
|
||||
row_kwargs: dict = {
|
||||
"item_id": item.id,
|
||||
"product_category": item.product_category,
|
||||
"product_name": item.product_name,
|
||||
"spec_model": item.spec_model,
|
||||
"quantity": str(item.quantity),
|
||||
"sale_amount": str(item.sale_amount),
|
||||
"threshold": str(threshold),
|
||||
}
|
||||
if item.cost_amount is None:
|
||||
row_kwargs.update(
|
||||
cost_amount=None,
|
||||
profit_amount=None,
|
||||
profit_rate=None,
|
||||
converted_profit=None,
|
||||
missing_reason="成本缺失,无法计算毛利润",
|
||||
)
|
||||
return GrossProfitRowOut(**row_kwargs), None
|
||||
|
||||
profit = item.sale_amount - item.cost_amount
|
||||
rate = profit_rate(profit, item.sale_amount)
|
||||
conv = converted_profit(item.sale_amount, rate, threshold)
|
||||
row_kwargs.update(
|
||||
cost_amount=str(item.cost_amount),
|
||||
profit_amount=str(profit),
|
||||
profit_rate=str(rate),
|
||||
converted_profit=str(conv),
|
||||
missing_reason=None,
|
||||
)
|
||||
return GrossProfitRowOut(**row_kwargs), (item.sale_amount, item.cost_amount)
|
||||
|
||||
|
||||
def _scan_gross_profit_rows(
|
||||
session: Session, business_id: int, threshold_of: Callable[[str], Fraction]
|
||||
) -> _GrossProfitScan:
|
||||
"""逐行扫描业务明细:出行 DTO、类别聚合桶与成本缺失计数。
|
||||
|
||||
类别顺序沿用明细行的首次出现顺序(不排序)—— 前端汇总展示依赖该顺序。
|
||||
"""
|
||||
rows: list[GrossProfitRowOut] = []
|
||||
agg: dict[str, list[Decimal]] = {}
|
||||
missing_count = 0
|
||||
for item in business_repository.list_items(session, business_id):
|
||||
row, bucket = _gross_profit_row(item, threshold_of(item.product_category))
|
||||
rows.append(row)
|
||||
if bucket is None:
|
||||
missing_count += 1
|
||||
continue
|
||||
current = agg.setdefault(item.product_category, [Decimal("0"), Decimal("0")])
|
||||
current[0] += bucket[0]
|
||||
current[1] += bucket[1]
|
||||
return _GrossProfitScan(rows=rows, category_agg=agg, missing_count=missing_count)
|
||||
|
||||
|
||||
def _gross_profit_category(
|
||||
category: str, sale: Decimal, cost: Decimal, threshold: Fraction
|
||||
) -> GrossProfitCategoryOut:
|
||||
"""类别汇总行(派生口径与行级一致:利润率/折算复用 formula 纯函数)。"""
|
||||
profit = sale - cost
|
||||
rate = profit_rate(profit, sale)
|
||||
return GrossProfitCategoryOut(
|
||||
product_category=category,
|
||||
sale_amount=str(sale),
|
||||
cost_amount=str(cost),
|
||||
profit_amount=str(profit),
|
||||
profit_rate=str(rate),
|
||||
threshold=str(threshold),
|
||||
converted_profit=str(converted_profit(sale, rate, threshold)),
|
||||
)
|
||||
|
||||
|
||||
def gross_profit(session: Session, business_id: int, as_of: date | None) -> GrossProfitOut:
|
||||
"""毛利润数据视图(需求 2.2.4):行级派生值 + 类别汇总,实时计算不落库。
|
||||
|
||||
@ -915,64 +1021,15 @@ def gross_profit(session: Session, business_id: int, as_of: date | None) -> Gros
|
||||
def threshold_of(category: str) -> Fraction:
|
||||
return snapshot.fraction("product.threshold", product_category=category)
|
||||
|
||||
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))
|
||||
|
||||
scan = _scan_gross_profit_rows(session, business_id, threshold_of)
|
||||
categories = [
|
||||
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()
|
||||
_gross_profit_category(category, sale, cost, threshold_of(category))
|
||||
for category, [sale, cost] in scan.category_agg.items()
|
||||
]
|
||||
return GrossProfitOut(
|
||||
business_id=business_id,
|
||||
as_of=as_of,
|
||||
rows=rows_out,
|
||||
rows=scan.rows,
|
||||
categories=categories,
|
||||
missing_count=missing_count,
|
||||
missing_count=scan.missing_count,
|
||||
)
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
"""
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
|
||||
from sqlalchemy import Row
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
@ -107,20 +108,8 @@ def _is_duplicate_entry(exc: IntegrityError) -> bool:
|
||||
|
||||
def list_customers(session: Session, query: CustomerQuery) -> PageResult[CustomerOut]:
|
||||
"""分页查询客户(附归属人员姓名)。"""
|
||||
rows = customer_repository.list_customers(
|
||||
session,
|
||||
offset=query.offset,
|
||||
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,
|
||||
)
|
||||
rows = customer_repository.list_customers(session, query)
|
||||
total = customer_repository.count_customers(session, query)
|
||||
return PageResult[CustomerOut](
|
||||
records=[_to_out(customer, owner_name) for customer, owner_name in rows],
|
||||
total=total,
|
||||
@ -317,6 +306,71 @@ 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,
|
||||
@ -335,57 +389,34 @@ def change_ownership(
|
||||
"""
|
||||
# 只读预取在审计上下文之前:摘要需要客户名与新旧归属人名;校验仍在
|
||||
# 上下文内进行,失败同样要留下可追溯的记录
|
||||
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}"
|
||||
prefetch = _prefetch_customer_ownership(session, customer_id, payload)
|
||||
with audit_service.audit(
|
||||
session,
|
||||
operator=operator,
|
||||
record=AuditRecord(
|
||||
module=MODULE_CUSTOMER,
|
||||
action=ACTION_UPDATE,
|
||||
summary=f"调整客户归属 {customer_name}:{old_label} → {new_label}",
|
||||
summary=(
|
||||
f"调整客户归属 {prefetch.customer_name}:"
|
||||
f"{prefetch.old_label} → {prefetch.new_label}"
|
||||
),
|
||||
request_data=payload.model_dump(exclude_none=True),
|
||||
),
|
||||
meta=meta,
|
||||
):
|
||||
if row is None:
|
||||
if prefetch.row is None:
|
||||
raise NotFoundError("客户不存在")
|
||||
customer, _ = row
|
||||
customer, _ = prefetch.row
|
||||
|
||||
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("归属未变化")
|
||||
old_owner_id = _validate_customer_ownership(session, customer, payload)
|
||||
|
||||
# 主表更新与留痕插入同事务:只在本方法内一次 commit(见模块 docstring)
|
||||
customer.owner_personnel_id = payload.new_owner_personnel_id
|
||||
customer_repository.add_ownership_log(
|
||||
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,
|
||||
),
|
||||
session, _customer_ownership_log(customer, payload, old_owner_id, operator)
|
||||
)
|
||||
# 响应在 commit 之前构造(同 user_service 顶部说明);归属人名以新值为准
|
||||
out = _to_out(customer, new_person.personnel_name if new_person else None)
|
||||
out = _to_out(customer, prefetch.new_person.personnel_name if prefetch.new_person else None)
|
||||
session.commit()
|
||||
logger.info(
|
||||
"调整客户归属 | customer_id=%s | old=%s | new=%s",
|
||||
|
||||
@ -168,9 +168,7 @@ 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"))),
|
||||
}
|
||||
|
||||
|
||||
@ -253,9 +251,11 @@ def _activities(session: Session) -> list[dict]:
|
||||
"""最近动态(FR-036):以操作日志为唯一来源(写操作即留痕)。"""
|
||||
logs = operation_log_repository.list_logs(
|
||||
session,
|
||||
offset=0,
|
||||
limit=_ACTIVITY_LIMIT,
|
||||
filters=LogFilters(),
|
||||
operation_log_repository.OperationLogListQuery(
|
||||
filters=LogFilters(),
|
||||
offset=0,
|
||||
limit=_ACTIVITY_LIMIT,
|
||||
),
|
||||
)
|
||||
return [
|
||||
{
|
||||
|
||||
@ -22,18 +22,15 @@ from app.schemas.log import (
|
||||
|
||||
def list_operation_logs(session: Session, query: OperationLogQuery) -> PageResult[OperationLogOut]:
|
||||
"""分页查询操作日志(列表不含 request_data,详情走 `get_operation_log`)。"""
|
||||
filters = _filters_from(query)
|
||||
logs = operation_log_repository.list_logs(
|
||||
session,
|
||||
repo_query = operation_log_repository.OperationLogListQuery(
|
||||
filters=_filters_from(query),
|
||||
offset=query.offset,
|
||||
limit=query.page_size,
|
||||
filters=filters,
|
||||
module=query.module,
|
||||
action=query.action,
|
||||
)
|
||||
total = operation_log_repository.count_logs(
|
||||
session, 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)
|
||||
return PageResult[OperationLogOut](
|
||||
records=[OperationLogOut.model_validate(log) for log in logs],
|
||||
total=total,
|
||||
@ -55,15 +52,14 @@ def get_operation_log(session: Session, log_id: int) -> OperationLogDetailOut:
|
||||
|
||||
def list_login_logs(session: Session, query: LoginLogQuery) -> PageResult[LoginLogOut]:
|
||||
"""分页查询登录日志。"""
|
||||
filters = _filters_from(query)
|
||||
logs = login_log_repository.list_logs(
|
||||
session,
|
||||
repo_query = login_log_repository.LoginLogListQuery(
|
||||
filters=_filters_from(query),
|
||||
offset=query.offset,
|
||||
limit=query.page_size,
|
||||
filters=filters,
|
||||
ip=query.ip,
|
||||
)
|
||||
total = login_log_repository.count_logs(session, filters=filters, ip=query.ip)
|
||||
logs = login_log_repository.list_logs(session, repo_query)
|
||||
total = login_log_repository.count_logs(session, repo_query)
|
||||
return PageResult[LoginLogOut](
|
||||
records=[LoginLogOut.model_validate(log) for log in logs],
|
||||
total=total,
|
||||
|
||||
@ -341,9 +341,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
||||
default_value="90",
|
||||
value_min="1",
|
||||
value_max=None,
|
||||
description=(
|
||||
"发货后超过该天数仍未回款的发货批次触发预警(澄清 #10 建议默认 90 天)"
|
||||
),
|
||||
description=("发货后超过该天数仍未回款的发货批次触发预警(澄清 #10 建议默认 90 天)"),
|
||||
sort_order=22,
|
||||
),
|
||||
ParamSpec(
|
||||
@ -357,9 +355,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
||||
default_value="3",
|
||||
value_min="1",
|
||||
value_max=None,
|
||||
description=(
|
||||
"待审批单提交后超过该天数未处理触发预警(澄清 #10 建议默认 3 天)"
|
||||
),
|
||||
description=("待审批单提交后超过该天数未处理触发预警(澄清 #10 建议默认 3 天)"),
|
||||
sort_order=23,
|
||||
),
|
||||
]
|
||||
|
||||
@ -6,7 +6,7 @@ ParamSnapshot 是 M3 核算与计算预览共用的输入契约:按业务日
|
||||
"""
|
||||
|
||||
import logging
|
||||
from collections.abc import Iterable
|
||||
from collections.abc import Callable, Iterable
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
@ -379,16 +379,8 @@ 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,
|
||||
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
|
||||
)
|
||||
rows = param_repository.list_history(session, definition.id, query)
|
||||
total = param_repository.count_history(session, definition.id, query)
|
||||
return PageResult[ParamHistoryOut](
|
||||
records=[
|
||||
ParamHistoryOut(
|
||||
@ -410,16 +402,8 @@ def get_history(
|
||||
|
||||
def list_batches(session: Session, query: ParamBatchQuery) -> PageResult[ParamBatchOut]:
|
||||
"""变更批次分页(按创建时间倒序)。"""
|
||||
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
|
||||
)
|
||||
batches = param_repository.list_batches(session, query)
|
||||
total = param_repository.count_batches(session, query)
|
||||
return PageResult[ParamBatchOut](
|
||||
records=[
|
||||
ParamBatchOut(
|
||||
@ -509,19 +493,66 @@ def compare(session: Session, *, from_date: date, to_date: date) -> list[ParamDi
|
||||
return out
|
||||
|
||||
|
||||
def _preview_rate(
|
||||
session: Session,
|
||||
payload: ParamPreviewIn,
|
||||
snapshot: ParamSnapshot,
|
||||
days: int,
|
||||
take,
|
||||
) -> tuple[Fraction, str, Decimal | None]:
|
||||
@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]:
|
||||
"""按岗位模板分派计提比例,返回(纯比例, 人读说明, 绩效基数覆盖值)。
|
||||
|
||||
基数覆盖值非 None 表示该模板的绩效基数不是折算毛利润额(如信息员按
|
||||
「本人销售额」计提),预览层直接采用并跳过折算与阈值。
|
||||
聚合模板(营销经理 12.5%+5%)依赖月度团队数据,不支持单笔预览。
|
||||
"""
|
||||
payload = ctx.payload
|
||||
take = ctx.take
|
||||
if payload.customer_type == "enterprise":
|
||||
# 存储口径为 ‰ 数(如 "3"),除以 1000 转纯比例 —— performance_amount
|
||||
# 按纯比例相乘,漏换算会把绩效金额放大 1000 倍(与业务员分支同防线)
|
||||
@ -531,7 +562,7 @@ def _preview_rate(
|
||||
role_code = payload.business_role
|
||||
template = "TPL_SALESPERSON_DECAY"
|
||||
if role_code:
|
||||
role = business_role_repository.get_by_code(session, role_code)
|
||||
role = business_role_repository.get_by_code(ctx.session, role_code)
|
||||
if role is None:
|
||||
raise BadRequestError(f"岗位 {role_code!r} 不存在")
|
||||
template = role.calc_template
|
||||
@ -561,12 +592,73 @@ def _preview_rate(
|
||||
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] = []
|
||||
|
||||
@ -589,55 +681,29 @@ def preview(session: Session, payload: ParamPreviewIn) -> ParamPreviewOut:
|
||||
)
|
||||
return parse_fraction(raw)
|
||||
|
||||
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)} 元部分年底一次性结算"
|
||||
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)
|
||||
|
||||
return ParamPreviewOut(
|
||||
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),
|
||||
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),
|
||||
probation_note=probation_note,
|
||||
params_used=used,
|
||||
)
|
||||
|
||||
@ -70,20 +70,8 @@ def ensure_active(session: Session, personnel_id: int, *, label: str) -> Personn
|
||||
|
||||
def list_personnel(session: Session, query: PersonnelQuery) -> PageResult[PersonnelOut]:
|
||||
"""分页查询人员(含离职)。"""
|
||||
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,
|
||||
)
|
||||
rows = personnel_repository.list_personnel(session, query)
|
||||
total = personnel_repository.count_personnel(session, query)
|
||||
return PageResult[PersonnelOut](
|
||||
records=[
|
||||
_to_out(personnel, role_name, manager_name)
|
||||
|
||||
@ -99,20 +99,8 @@ def _raise_if_duplicate(exc: IntegrityError) -> None:
|
||||
|
||||
def list_products(session: Session, query: ProductQuery) -> PageResult[ProductOut]:
|
||||
"""分页查询产品(含停用)。"""
|
||||
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,
|
||||
)
|
||||
products = product_repository.list_products(session, query)
|
||||
total = product_repository.count_products(session, query)
|
||||
return PageResult[ProductOut](
|
||||
records=[_to_out(product) for product in products],
|
||||
total=total,
|
||||
|
||||
@ -23,6 +23,7 @@
|
||||
"""
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
@ -39,7 +40,7 @@ from app.core.constants import (
|
||||
from app.core.db_errors import is_duplicate_entry
|
||||
from app.core.exceptions import BadRequestError, NotFoundError
|
||||
from app.core.response import PageResult
|
||||
from app.models import Receipt
|
||||
from app.models import Business, Receipt
|
||||
from app.models.business import BUSINESS_STATUS_ACTIVE
|
||||
from app.models.receipt import SETTLEMENT_DONE, SETTLEMENT_PENDING
|
||||
from app.repositories import business_repository, receipt_repository
|
||||
@ -129,24 +130,8 @@ def _ensure_business_writable(session: Session, business_id: int, action: str):
|
||||
|
||||
def list_receipts(session: Session, query: ReceiptQuery) -> PageResult[ReceiptOut]:
|
||||
"""分页查询回款(附业务号/发货单号与 payment_days 派生值)。"""
|
||||
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,
|
||||
)
|
||||
rows = receipt_repository.list_receipts(session, query)
|
||||
total = receipt_repository.count_receipts(session, query)
|
||||
return PageResult[ReceiptOut](
|
||||
records=[_to_out(row) for row in rows],
|
||||
total=total,
|
||||
@ -166,6 +151,57 @@ 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,
|
||||
@ -206,35 +242,7 @@ def create_receipt(
|
||||
# 锁业务行与生成批次号——成功/最终失败各只记一条审计,无递归
|
||||
for attempt in range(_NO_RETRY + 1):
|
||||
try:
|
||||
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
|
||||
created = _create_receipt_once(session, payload, business, batch_no)
|
||||
session.commit()
|
||||
break
|
||||
except IntegrityError as exc:
|
||||
@ -249,11 +257,11 @@ def create_receipt(
|
||||
|
||||
logger.info(
|
||||
"新增回款 | receipt_id=%s | no=%s | business_id=%s",
|
||||
new_id,
|
||||
new_no,
|
||||
created.receipt_id,
|
||||
created.batch_no,
|
||||
payload.business_id,
|
||||
)
|
||||
return out
|
||||
return created.out
|
||||
|
||||
|
||||
def update_receipt(
|
||||
|
||||
@ -36,7 +36,6 @@ _DATE_FORMAT = "YYYY-MM-DD"
|
||||
_FORMULA_PREFIXES = ("=", "+", "-", "@", "\t")
|
||||
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _Column:
|
||||
"""列定义:key 取行 dict 字段,kind 决定单元格类型与格式。"""
|
||||
@ -403,7 +402,13 @@ 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(
|
||||
@ -439,16 +444,12 @@ 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
|
||||
|
||||
@ -103,10 +103,7 @@ 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:
|
||||
@ -178,11 +175,7 @@ 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:
|
||||
@ -193,9 +186,7 @@ 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)
|
||||
@ -335,9 +326,7 @@ 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
|
||||
)
|
||||
@ -378,9 +367,7 @@ 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:
|
||||
@ -483,12 +470,8 @@ 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 = {
|
||||
@ -619,9 +602,7 @@ 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(
|
||||
@ -893,9 +874,7 @@ 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, offset=query.offset, limit=query.page_size
|
||||
)
|
||||
rows = report_repository.list_payment_detail(session, query, bucket)
|
||||
return {
|
||||
"list": [_payment_row(r) for r in rows],
|
||||
"total": report_repository.count_payment_detail(session, query, bucket),
|
||||
|
||||
@ -68,14 +68,8 @@ def list_roles(session: Session, query: RoleQuery) -> PageResult[RoleManageOut]:
|
||||
与用户表单下拉(`/users/meta/roles`)口径不同:**管理列表含停用角色**,
|
||||
下拉只给启用项。
|
||||
"""
|
||||
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)
|
||||
roles = role_repository.list_roles(session, query)
|
||||
total = role_repository.count_roles(session, query)
|
||||
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],
|
||||
|
||||
@ -157,20 +157,8 @@ def _is_duplicate_entry(exc: IntegrityError) -> bool:
|
||||
|
||||
def list_shipments(session: Session, query: ShipmentQuery) -> PageResult[ShipmentOut]:
|
||||
"""分页查询发货单(附业务号;business_id 可空=全量,一期按业务维度查看为主)。"""
|
||||
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,
|
||||
)
|
||||
rows = shipment_repository.list_shipments(session, query)
|
||||
total = shipment_repository.count_shipments(session, query)
|
||||
return PageResult[ShipmentOut](
|
||||
records=[_to_out(row) for row in rows],
|
||||
total=total,
|
||||
|
||||
@ -68,22 +68,8 @@ def _to_out(user: User) -> UserOut:
|
||||
|
||||
def list_users(session: Session, query: UserQuery) -> PageResult[UserOut]:
|
||||
"""分页查询用户列表。"""
|
||||
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,
|
||||
)
|
||||
users = user_repository.list_users(session, query)
|
||||
total = user_repository.count_users(session, query)
|
||||
return PageResult[UserOut](
|
||||
records=[_to_out(user) for user in users],
|
||||
total=total,
|
||||
|
||||
@ -205,6 +205,7 @@ uv run pytest tests/integration -q # 仅集成
|
||||
| 2026-10-20 | 全量回归 | 862 | ✅ 862 passed(11:32) | **工作台/报表修复提交后的全量复核**;e2e 三脚本复跑:role_home 4/4、report 12/12、calc_settlement 8/8;calc 审批开关已还原为 1,无 `e2e_role_*` 残留 |
|
||||
| 2026-10-20 | 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` 运行 |
|
||||
|
||||
|
||||
@ -451,7 +451,9 @@ 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"]
|
||||
@ -474,7 +476,9 @@ 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
|
||||
|
||||
@ -679,9 +683,7 @@ 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"],
|
||||
@ -691,7 +693,9 @@ 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"]
|
||||
|
||||
|
||||
@ -13,21 +13,24 @@ 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
|
||||
from app.core.constants import ACTION_CREATE, MODULE_BUSINESS, MODULE_RECEIPT
|
||||
from app.core.db import get_session_factory
|
||||
from app.main import app
|
||||
from app.models import OperationLog
|
||||
from app.services import business_service
|
||||
from app.services import business_service, receipt_service
|
||||
from tests.integration.factories import create_business
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
_BUSINESSES = "/api/v1/business/businesses"
|
||||
_CUSTOMERS = "/api/v1/base/customers"
|
||||
_RECEIPTS = "/api/v1/business/receipts"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@ -160,3 +163,94 @@ 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
|
||||
|
||||
@ -452,9 +452,7 @@ 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)
|
||||
|
||||
@ -138,9 +138,7 @@ 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,
|
||||
@ -166,16 +164,12 @@ 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"]
|
||||
# ⑥ 未建:显式降级
|
||||
@ -335,17 +329,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(
|
||||
@ -353,9 +347,7 @@ def test_待办_财务通过后领导出现待办(
|
||||
)
|
||||
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]
|
||||
|
||||
@ -326,6 +326,38 @@ 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",
|
||||
|
||||
@ -44,7 +44,9 @@ def _role_id(code: str) -> int:
|
||||
)
|
||||
|
||||
|
||||
def _person(client: TestClient, headers: dict, *, role: str = "salesperson", manager_id=None) -> dict:
|
||||
def _person(
|
||||
client: TestClient, headers: dict, *, role: str = "salesperson", manager_id=None
|
||||
) -> dict:
|
||||
body = {
|
||||
"personnel_name": f"t_报表_{uuid.uuid4().hex[:8]}",
|
||||
"business_role_id": _role_id(role),
|
||||
@ -194,9 +196,7 @@ 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,7 +224,14 @@ 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")
|
||||
@ -293,11 +300,15 @@ 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
|
||||
|
||||
|
||||
@ -389,19 +400,21 @@ 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",
|
||||
@ -467,9 +480,7 @@ 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")
|
||||
@ -516,9 +527,7 @@ 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
|
||||
|
||||
@ -557,9 +566,7 @@ 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")
|
||||
@ -777,9 +784,7 @@ 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(
|
||||
@ -790,9 +795,7 @@ 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(
|
||||
|
||||
@ -127,8 +127,7 @@ 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"
|
||||
@ -142,16 +141,14 @@ 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 = [
|
||||
|
||||
@ -5,7 +5,12 @@
|
||||
<title>衍水工资系统 · 视觉方向预览</title>
|
||||
<style>
|
||||
/* ============================================================
|
||||
设计体系 v3(质感版):中性灰白 · 靛蓝点缀 · 微质感
|
||||
设计体系 v4(同步已交付模块):中性灰白 · 靛蓝点缀 · 微质感
|
||||
v3 → v4 变更(2026-10-22):设计稿与已交付功能对齐
|
||||
1) 侧栏菜单改为真实路由分组(5 组 + 首页 = 21 项),组可折叠
|
||||
2) 首页工作台按真实四区块重画(总览 / 待办 / 预警 / 动态)
|
||||
3) 新增 §09:月结、审批、报表、打印稿(v3 缺 M3/M4 页面)
|
||||
4) token 与 frontend/src/assets/styles/index.css 对齐
|
||||
质感来源(而非装饰):
|
||||
1) 表面微渐变 —— 1–2% 明度差,暗示受光方向
|
||||
2) 半透明描边 —— 边界在不同底色上自然融合
|
||||
@ -17,7 +22,7 @@
|
||||
:root {
|
||||
--ys-indigo: #2e4ba8;
|
||||
--ys-indigo-hi: #3a58bc; /* 渐变亮端 */
|
||||
--ys-indigo-deep: #243c8b;
|
||||
--ys-indigo-deep: #223a85; /* 深靛蓝:与 index.css 的 --ys-indigo-deep 一致 */
|
||||
--ys-indigo-wash: #edf1fa;
|
||||
--ys-paper: #ffffff;
|
||||
--ys-mist: #f4f5f8; /* 页面底 */
|
||||
@ -240,6 +245,7 @@
|
||||
.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);
|
||||
@ -332,7 +338,7 @@
|
||||
.spec-table td.good { color: var(--ys-ink); }
|
||||
|
||||
/* ---------- 首页仪表盘(Bento Grid) ---------- */
|
||||
.bento { display: grid; grid-template-columns: repeat(4, 1fr); grid-auto-rows: 102px; gap: 14px; }
|
||||
.bento { display: grid; grid-template-columns: repeat(4, minmax(0, 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);
|
||||
@ -353,21 +359,22 @@
|
||||
.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-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-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-up { color: var(--ys-ok); font-weight: 500; }
|
||||
.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; }
|
||||
/* 待办 / 预警列表:超出部分在卡内滚动,卡片高度不被列表撑开 */
|
||||
.todo-list, .warning-list { max-height: 178px; margin-top: 8px; overflow-y: auto; }
|
||||
|
||||
/* ---------- 登录页 ---------- */
|
||||
.login-shot { border: 1px solid var(--ys-line); border-radius: 14px; overflow: hidden; box-shadow: 0 12px 40px rgba(28, 35, 51, 0.1); }
|
||||
@ -410,6 +417,122 @@
|
||||
}
|
||||
.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;
|
||||
@ -466,6 +589,11 @@
|
||||
<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>
|
||||
@ -475,6 +603,8 @@
|
||||
<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>
|
||||
|
||||
@ -523,6 +653,31 @@
|
||||
<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>
|
||||
|
||||
<!-- ============ 字体与数字 ============ -->
|
||||
@ -622,8 +777,11 @@
|
||||
首页
|
||||
</div>
|
||||
</div>
|
||||
<div class="menu-group">
|
||||
<div class="menu-label">系统管理</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-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>
|
||||
用户管理
|
||||
@ -640,9 +798,41 @@
|
||||
<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">
|
||||
@ -722,6 +912,25 @@
|
||||
质感的三层材料:<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>
|
||||
|
||||
<!-- ============ 首页仪表盘 ============ -->
|
||||
@ -740,26 +949,33 @@
|
||||
</div>
|
||||
</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>
|
||||
用户管理
|
||||
<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-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 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 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 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 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 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 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 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">
|
||||
@ -769,78 +985,110 @@
|
||||
</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">数据截至今日 06:00 · 12 笔回款待核算</div>
|
||||
<h1>工作台</h1>
|
||||
<div class="desc">经营绩效总览 · 待办与风险集中处置 · 最近动态</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">
|
||||
<div class="bento-card b-2x2">
|
||||
<!-- 经营绩效总览:KPI -->
|
||||
<div class="bento-card">
|
||||
<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 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-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 class="b-num">268,430.16<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"><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-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-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 class="b-eyebrow">营销团队</div>
|
||||
<div class="b-num">4<small>个</small></div>
|
||||
<div class="b-sub">月结:草稿</div>
|
||||
</div>
|
||||
<div class="bento-card b-2x1">
|
||||
|
||||
<!-- 经营绩效总览:图表 -->
|
||||
<div class="bento-card b-2x2">
|
||||
<div class="b-eyebrow">近 6 个月绩效</div>
|
||||
<div id="chartPerf" class="chart"></div>
|
||||
</div>
|
||||
<div class="bento-card b-2x2">
|
||||
<div class="b-eyebrow">近 6 个月回款</div>
|
||||
<div id="chartRev" class="chart"></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>
|
||||
</div>
|
||||
|
||||
<!-- 常用操作 -->
|
||||
<div class="bento-card b-full">
|
||||
<div class="b-eyebrow">常用操作</div>
|
||||
<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 class="b-links">
|
||||
<button class="b-op">录入回款</button>
|
||||
<button class="b-op">绩效台账</button>
|
||||
<button class="b-op">月度结算</button>
|
||||
<button class="b-op">报表统计</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</b>:大小不一的卡片按重要性排布——绩效总额与回款趋势占 2×2 主角位,数字型指标 1×1 紧凑排列。
|
||||
卡片入场为 <b>45ms 交错上浮(320ms)</b>,悬停抬升 2px、阴影加深——打开预览即可看到微动效。
|
||||
<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)——条目再多也不撑开网格,行高恒定。
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@ -932,20 +1180,340 @@
|
||||
</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: 0, right: 0, top: 6, bottom: 20, containLabel: true },
|
||||
grid: { left: 8, right: 12, top: 18, bottom: 4, containLabel: true },
|
||||
xAxis: {
|
||||
type: 'category', data: ['5月', '6月', '7月', '8月', '9月', '10月'],
|
||||
axisLine: { show: false }, axisTick: { show: false },
|
||||
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 },
|
||||
yAxis: { type: 'value', show: false, splitLine: { show: false } },
|
||||
series: [{
|
||||
type: 'bar', barWidth: 22, data: [34, 42, 30, 48, 38, 56],
|
||||
itemStyle: {
|
||||
@ -958,15 +1526,19 @@
|
||||
});
|
||||
const perf = echarts.init(document.getElementById('chartPerf'), null, { renderer: 'svg' });
|
||||
perf.setOption({
|
||||
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 },
|
||||
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 } },
|
||||
series: [{
|
||||
type: 'line', data: [8, 13, 10, 19, 23, 29, 33], smooth: 0.4, symbol: 'none',
|
||||
lineStyle: { color: '#2e4ba8', width: 1.6 },
|
||||
type: 'line', data: [186, 204, 178, 232, 251, 268], smooth: 0.4, symbol: 'none',
|
||||
lineStyle: { color: '#2e4ba8', width: 2 },
|
||||
areaStyle: {
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: 'rgba(46,75,168,.10)' }, { offset: 1, color: 'rgba(46,75,168,0)' }
|
||||
{ offset: 0, color: 'rgba(46,75,168,.22)' }, { offset: 1, color: 'rgba(46,75,168,0)' }
|
||||
])
|
||||
}
|
||||
}]
|
||||
|
||||
Binary file not shown.
@ -79,7 +79,7 @@ pnpm build # 类型检查 + 生产构建
|
||||
|
||||
## 视觉设计
|
||||
|
||||
**设计立意**:公司名「衍水」是水,而中文「流水账」字面就是**流水之账**——系统的全部工作正是记录回款怎么流、绩效怎么算。视觉方向由此而生:中性灰白的水面纸感 + 靛蓝点缀,安静、克制,长时间核数不累眼。完整规范见 `docs/design/视觉方向预览.html`(2026-10-12 起为 v3「质感版」)。
|
||||
**设计立意**:公司名「衍水」是水,而中文「流水账」字面就是**流水之账**——系统的全部工作正是记录回款怎么流、绩效怎么算。视觉方向由此而生:中性灰白的水面纸感 + 靛蓝点缀,安静、克制,长时间核数不累眼。完整规范见 `docs/design/视觉方向预览.html`(2026-10-12 起为 v3「质感版」;2026-10-22 升 v4「同步版」——设计稿与已交付的月结 / 审批 / 报表 / 工作台对齐,色板、字体与组件基调未变)。
|
||||
|
||||
**配色**(靛蓝只作重点点缀——主按钮 / 链接 / 焦点 / 当前菜单,大面积用中性灰白)
|
||||
|
||||
@ -100,17 +100,62 @@ pnpm build # 类型检查 + 生产构建
|
||||
|
||||
**落地方式**:品牌色已提升为全局 CSS 变量(`src/assets/styles/index.css` 的 `:root`,`--ys-*` 前缀),登录页、布局与各业务页面共用;全局把 `--el-color-primary` 指向靛蓝,并按 Element Plus 公式预计算了 light-3/5/7/8/9 与 dark-2 色阶(不用 `color-mix()`,避免依赖较新浏览器特性)。旧 token 名(深水青 / 金色系列)在 `:root` 中保留映射,存量页面迁移完成后移除。
|
||||
|
||||
**签名元素**:**渐隐靛蓝水线**——仪表盘等页面标题下的细线(56px,左实右虚)与登录页品牌名下的短线(40px),「衍水」之水的提纯,全站唯一的装饰性元素;登录页右上角另有涟漪同心圆与顶部靛蓝天光作背景(低透明度)。与早期深色面板 + 水纹层理同一意象,品牌连续而非推倒重来。
|
||||
**语义三色**(2026-10-22 落地):成功 `#1e9e6a` / 警示 `#c98a1b` / 危险 `#c94f4f`,已覆盖 `--el-color-success/warning/danger/error` 及其 `-rgb`、`light-3/5/7/8/9`、`dark-2` 全阶。Element Plus 默认的绿黄红饱和度过高,放在灰底卡片上会跳色。**`-rgb` 变体必须一并覆盖**——组件库的半透明底色用 `rgba(var(--el-color-success-rgb), .1)` 取色,漏改会出现「文字是新的、底色还是旧的」。色阶按 Element Plus 公式预计算,不用 `color-mix()`。
|
||||
|
||||
**组件基调**(同样写在 `src/assets/styles/index.css`,页面不要再重复):按钮高 34px / 圆角 7px / 字号 13px,主按钮是 `#3a58bc → #2e4ba8` 微渐变 + 内高光 + 悬停上抬 1px 并加深阴影;输入框高 34px,焦点为靛蓝描边加 3px 柔光外环;表头 12px/500/字距 `0.04em` + 极浅渐变底,单元格内距 12px 16px、行高 1.5,行悬停 120ms;分页需 `background` 模式,当前页与主按钮同材质;`el-tag` 压到 11px 方角。动效统一 120–180ms,只做颜色、阴影与 1px 位移,并尊重 `prefers-reduced-motion`。
|
||||
|
||||
**表格行高必须是 1.5**(全局 `.el-table .cell`):Element Plus 默认给单元格 23px 行高(≈1.77 倍),加上 12px 上下内距,单行数据行会到 51px——一屏少看两行。收到 1.5 后单行数据行 44px、纯文本行 44px,与设计稿「12px 内距 + 13px 字」对齐;多行单元格仍按内容撑开。
|
||||
|
||||
**页面版式:只用全局类**(新页面不再各写一套)
|
||||
|
||||
| 类 | 用途 |
|
||||
| --- | --- |
|
||||
| `.page-head` / `.page-title` / `.page-desc` / `.waterline` | 页面头:18px 标题 + 一句 12px 描述 + 水线(**每页一条,不重复**) |
|
||||
| `.card` / `.card-head` / `.card-title` / `.card-body` / `.card-foot` | 内容卡片;卡脚右对齐,承载分页或合计条 |
|
||||
| `.filter-bar` / `.filter-bar__actions` | 筛选条(横向 flex);按钮组靠右 |
|
||||
| `.toolbar` / `.toolbar-note` | 主操作行 / 右侧提示文字 |
|
||||
| `.section-title` | 区块小标题(13px/600) |
|
||||
| `.cell-code` / `.cell-num` / `.cell-muted` | 表格列语义:等宽编号 / 数字右对齐 + 等宽数字 / 弱化文本 |
|
||||
| `.ys-pill` + `--ok` `--wait` `--lock` `--reject` | 状态胶囊(浅底 + 6px 圆点),四档语义与月结生命周期一致 |
|
||||
| `.ys-rise` | 入场上浮动画(卡片默认已带) |
|
||||
|
||||
`.cell-num` 有两种用法:直接挂在 `td` 上,或包一层 `<span class="cell-num">` 处理「一行里既文本又数字」。给后者单独撑满一行是必要的——行内元素的 `text-align` 无效;但**不能**给 `.cell-num` 统一加 `display`,那会把 `td` 从 `table-cell` 变成 `inline-block`,表格列宽算法随即失效。
|
||||
|
||||
**表单页版式(公司信息页为样板)**
|
||||
|
||||
设置类表单页不要把表单缩在左上角一小块——内容区再宽也只占三分之一,页面看着像没做完。`views/system/settings/index.vue` 是基准:
|
||||
|
||||
| 做法 | 口径 |
|
||||
| --- | --- |
|
||||
| 网格 | 左列 `minmax(0, 1fr)` + 右列固定 480px(实景预览),`align-items: start`;≤1200px 折成单列 |
|
||||
| 控件行 | 主控件与说明同一行(`.field-control`):控件 ≤420px,说明跟在后面 20px;≥1600px 说明改右对齐贴卡片右缘,整行铺满而不是缩在左上角 |
|
||||
| 实景预览 | 右侧「登录页预览」按**真实登录页 1:1 复刻**(控件 38px 高、品牌名 17px / 0.34em 字距、logo ≤56×36、三项输入含验证码位、登录按钮、版权行),不是缩比示意——控件高度对不上就等于没有所见即所得 |
|
||||
| 生效范围 | 独立一张卡列出这份设置会出现在哪(登录页品牌位与版权行 / Excel 导出页眉 / 打印稿页眉),用户不用存完再去别处找 |
|
||||
| 预览态 | 预览卡 `aria-hidden` + 标注「示意,非交互」,避免被当成可点的登录框 |
|
||||
|
||||
**状态与流程表达(2026-10-23 实测补)**
|
||||
|
||||
- 月结生命周期条四段圆点按阶段着色:草稿中性 / 待审批琥珀 / 已审定靛蓝 / 已驳回砖红。颜色之外每段仍有文字,不靠颜色单独表意。
|
||||
- 流程条这类分段控件旁要挂一行操作提示(如「点状态只看该阶段,再点一次取消」)。没有提示时,未选中态的四段读起来像四颗孤立的小按钮,不像一条流程。
|
||||
- 卡片内空态(工作台 `.b-empty-mini`)在卡片剩余高度内居中,不要全挤在顶部留一整块空白。
|
||||
|
||||
**菜单图标**(2026-10-22 落地,M1 遗留项):路由 `meta.icon` 只写**语义 key**,图标实现集中在 `src/components/layout/menuIcons.ts` 的注册表(`@element-plus/icons-vue` 具名导入,可摇树)。换图标库或换风格只改注册表,路由表不动。视觉口径沿用设计稿:16px、透明度 0.7(激活项 1)、右间距 9px;分组标题**不加图标**,只有菜单项有。**新增页面必须补 `meta.icon`**,否则菜单项只有文字。注册表是 **UTF-8** 文件——批量转码脚本会把中文注释转成乱码(2026-10-23 已在该文件踩过一次,已修回),改这个文件不要过任何编码转换。
|
||||
|
||||
**签名元素**:**渐隐靛蓝水线**——各页面标题下的细线(56px,左实右虚)与登录页品牌名下的短线(40px),「衍水」之水的提纯,全站唯一的装饰性元素;登录页右上角另有涟漪同心圆与顶部靛蓝天光作背景(低透明度)。与早期深色面板 + 水纹层理同一意象,品牌连续而非推倒重来。
|
||||
|
||||
**打印视图**(`views/reports/print/`):纸面产物与屏幕稿不同源——独立顶层路由、不挂侧栏顶栏,白灰画布上一张定宽白稿纸,表格 11px 细实线、表头浅底左对齐(数值列表头右对齐)、章节编号、三栏签字(制表 / 复核 / 审批),无圆角无阴影。屏幕上的操作条带 `.no-print`,打印时整块消失。超出打印行数上限时只打前 N 行并在稿面明确提示,不静默截断。
|
||||
|
||||
**构图**:登录页品牌内容(名称 / 标语 / 水线)**垂直居中聚成一块**,配一层柔和径向光晕作落点。
|
||||
早期版本用 `space-between` 把品牌与标语顶到两端,中间空出大半屏,两处都成了孤岛——超宽屏会把这种疏离放大得更明显。**内容块要限宽**(`max-width`),否则宽屏下文字会被拉散。
|
||||
|
||||
### 写代码时的四条约定
|
||||
### 写代码时的五条约定
|
||||
|
||||
- 覆盖 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(如 以物抵顶)
|
||||
|
||||
## 验证码图片
|
||||
|
||||
|
||||
@ -11,6 +11,7 @@
|
||||
"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",
|
||||
|
||||
3
frontend/pnpm-lock.yaml
generated
3
frontend/pnpm-lock.yaml
generated
@ -8,6 +8,9 @@ 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
|
||||
|
||||
@ -1,24 +1,60 @@
|
||||
/* 全局样式:重置 + 品牌 token + 基础版式 */
|
||||
/*
|
||||
* 全局样式:品牌 token + Element Plus 组件基调 + 全站版式工具类
|
||||
*
|
||||
* 设计来源:docs/design/视觉方向预览.html(v4,2026-10-22)。
|
||||
* 落实口径:token 与设计稿 :root 同名同值;组件基调按设计稿 §03;
|
||||
* 版式工具类(.page-head / .card / .filter-bar / .data-table 等)按 §04、§09,
|
||||
* 各页面只写业务差异,不再重复定义这些类。
|
||||
*
|
||||
* 加载顺序:main.ts 里排在 element-plus/dist/index.css 之后,
|
||||
* 同权重选择器靠后者生效 —— 覆盖组件库样式必须与此保持一致。
|
||||
*/
|
||||
|
||||
:root {
|
||||
/*
|
||||
* 品牌色(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-line: rgba(28, 35, 51, 0.09);
|
||||
--ys-aside-1: #f2f4f8;
|
||||
--ys-aside-2: #ecedf3;
|
||||
|
||||
--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 名保留映射(BasicLayout / 登录页存量引用,值迁移到新体系:
|
||||
* 深水青 → 冷墨系、金色 → 靛蓝系),存量页面迁移完成后移除。
|
||||
* 旧 token 名保留映射(存量页面引用,值已迁移到新体系):
|
||||
* 深水青 → 冷墨系、金色 → 靛蓝系。存量页面迁移完成后移除。
|
||||
*/
|
||||
--ys-ink-900: #16203a;
|
||||
--ys-ink-800: #1c2333;
|
||||
@ -26,7 +62,6 @@
|
||||
--ys-ink-600: #3a4a6e;
|
||||
--ys-gold-bright: #6d81c2;
|
||||
--ys-gold-deep: #2e4ba8;
|
||||
|
||||
--ys-text: #1c2333;
|
||||
--ys-muted: #5b6577;
|
||||
|
||||
@ -36,11 +71,12 @@
|
||||
*/
|
||||
--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 主色 ──────────────────────────────────────────────── */
|
||||
/*
|
||||
* Element Plus 主色 = 靛蓝(白字对比约 7.5:1)。
|
||||
* 色阶为按 Element Plus 公式「主色与白色混合」预先算好的静态值 ——
|
||||
* 不用 color-mix(),避免依赖较新的浏览器特性。
|
||||
* 白字对比约 7.5:1。色阶为按 Element Plus 公式「主色与白色混合」预先算好的
|
||||
* 静态值 —— 不用 color-mix(),避免依赖较新的浏览器特性。
|
||||
*/
|
||||
--el-color-primary: #2e4ba8;
|
||||
--el-color-primary-light-3: #6d81c2;
|
||||
@ -49,6 +85,72 @@
|
||||
--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;
|
||||
}
|
||||
|
||||
* {
|
||||
@ -73,15 +175,534 @@ body {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* 系统管理列表页共用的版式(原在三个页面 scoped 中各自重复) */
|
||||
.filter-form {
|
||||
margin-bottom: 4px;
|
||||
/* 滚动条:细、低对比,避免在密集表格页抢视线 */
|
||||
*::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(28, 35, 51, 0.16);
|
||||
border: 2px solid transparent;
|
||||
border-radius: 6px;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgba(28, 35, 51, 0.28);
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════════════
|
||||
* 全站版式工具类(设计稿 §04 / §09):页面不再各自重复定义
|
||||
* ══════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
/* 页面头:标题 + 描述 + 签名水线,每页出现一次 */
|
||||
.page-head {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--ys-ink);
|
||||
}
|
||||
|
||||
.page-desc {
|
||||
margin: 6px 0 0;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.01em;
|
||||
color: var(--ys-ink-3);
|
||||
}
|
||||
|
||||
/*
|
||||
* 签名元素:渐隐靛蓝水线。
|
||||
* 全站唯一的装饰性元素 —— 其余层次靠底色、描边与阴影区分,
|
||||
* 所以每页只允许出现一次(页面标题下,或登录卡品牌区)。
|
||||
*/
|
||||
.waterline {
|
||||
display: block;
|
||||
width: 56px;
|
||||
height: 2px;
|
||||
margin-top: 10px;
|
||||
border-radius: 1px;
|
||||
background: linear-gradient(90deg, var(--ys-indigo), rgba(46, 75, 168, 0));
|
||||
}
|
||||
|
||||
/* 区块小标题(抽屉内、卡片内分组) */
|
||||
.section-title {
|
||||
margin: 0 0 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--ys-ink-2);
|
||||
}
|
||||
|
||||
/* 卡片:纸白微渐变 + 半透明描边 + 双层柔影 */
|
||||
.card {
|
||||
background: linear-gradient(180deg, var(--ys-paper), #fcfcfe);
|
||||
border: 1px solid var(--ys-line);
|
||||
border-radius: var(--ys-radius);
|
||||
box-shadow: var(--ys-shadow-card);
|
||||
}
|
||||
|
||||
.card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 13px 16px;
|
||||
border-bottom: 1px solid var(--ys-line-soft);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--ys-ink);
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.card-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding: 10px 16px;
|
||||
background: linear-gradient(180deg, #fdfdfe, #fbfcfe);
|
||||
border-top: 1px solid var(--ys-line-soft);
|
||||
border-radius: 0 0 var(--ys-radius) var(--ys-radius);
|
||||
}
|
||||
|
||||
/* 筛选条:与卡片同材质,比表格低一档体量 */
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 14px;
|
||||
background: linear-gradient(180deg, var(--ys-paper), #fcfcfe);
|
||||
border: 1px solid var(--ys-line);
|
||||
border-radius: var(--ys-radius);
|
||||
box-shadow: var(--ys-shadow-card);
|
||||
}
|
||||
|
||||
.filter-bar .el-form-item {
|
||||
margin-right: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.filter-bar__actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* 工具栏:主操作行,与筛选条/表格区分(无底色,只占位) */
|
||||
.toolbar {
|
||||
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)统一视觉
|
||||
*
|
||||
|
||||
@ -11,11 +11,19 @@ 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>{{ group.name }}</template>
|
||||
<template #title>
|
||||
<span class="app-menu__group">{{ group.name }}</span>
|
||||
</template>
|
||||
<el-menu-item v-for="item in group.items" :key="item.path" :index="item.path">
|
||||
<el-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>
|
||||
@ -32,34 +40,129 @@ 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.6);
|
||||
--el-menu-item-height: 40px;
|
||||
--el-menu-hover-bg-color: rgba(255, 255, 255, 0.55);
|
||||
--el-menu-item-height: 36px;
|
||||
|
||||
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;
|
||||
background: linear-gradient(180deg, #ffffff, #fbfcfe);
|
||||
font-weight: 500;
|
||||
background: linear-gradient(180deg, var(--ys-paper), #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);
|
||||
}
|
||||
|
||||
.app-menu :deep(.el-sub-menu.is-active > .el-sub-menu__title) {
|
||||
/* 组标题:11px 大字距的「目录行」,不抢子项;展开时转靛蓝并压一层白底 */
|
||||
.app-menu :deep(.el-sub-menu__title) {
|
||||
height: 32px;
|
||||
padding: 0 10px;
|
||||
line-height: 32px;
|
||||
border-radius: var(--ys-radius-sm);
|
||||
transition:
|
||||
background-color 150ms var(--ys-ease),
|
||||
color 150ms var(--ys-ease);
|
||||
}
|
||||
|
||||
.app-menu__group {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--ys-ink-3);
|
||||
transition: color 150ms var(--ys-ease);
|
||||
}
|
||||
|
||||
.app-menu :deep(.el-sub-menu__title:hover) {
|
||||
background-color: rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
|
||||
.app-menu :deep(.el-sub-menu.is-opened > .el-sub-menu__title) {
|
||||
background-color: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
|
||||
.app-menu :deep(.el-sub-menu.is-opened > .el-sub-menu__title) .app-menu__group {
|
||||
color: var(--ys-indigo);
|
||||
}
|
||||
|
||||
/*
|
||||
* 展开/收起箭头: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>
|
||||
|
||||
@ -17,6 +17,9 @@ 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[] = []
|
||||
@ -46,13 +49,13 @@ async function handleLogout(): Promise<void> {
|
||||
<div class="app-brand">
|
||||
<span class="app-brand__name">衍水集团</span>
|
||||
<span class="app-brand__sub">营销人员工资管理系统</span>
|
||||
<span class="app-brand__line"></span>
|
||||
<span class="waterline app-brand__line"></span>
|
||||
</div>
|
||||
<AppMenu class="app-aside__menu" />
|
||||
</el-aside>
|
||||
|
||||
<el-container class="app-body">
|
||||
<el-header class="app-header">
|
||||
<el-header class="app-header" height="52px">
|
||||
<div class="app-header__left">
|
||||
<button
|
||||
type="button"
|
||||
@ -81,7 +84,9 @@ async function handleLogout(): Promise<void> {
|
||||
<el-tag v-for="role in userStore.roles" :key="role" size="small" type="info">
|
||||
{{ 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>
|
||||
@ -101,7 +106,7 @@ async function handleLogout(): Promise<void> {
|
||||
<div class="app-brand">
|
||||
<span class="app-brand__name">衍水集团</span>
|
||||
<span class="app-brand__sub">营销人员工资管理系统</span>
|
||||
<span class="app-brand__line"></span>
|
||||
<span class="waterline app-brand__line"></span>
|
||||
</div>
|
||||
<AppMenu />
|
||||
</el-drawer>
|
||||
@ -117,7 +122,7 @@ async function handleLogout(): Promise<void> {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* 中性灰侧栏:比页面底沉一档,与纸白卡片拉开层次 */
|
||||
background: linear-gradient(180deg, #f2f4f8 0%, #ecedf3 100%);
|
||||
background: linear-gradient(180deg, var(--ys-aside-1) 0%, var(--ys-aside-2) 100%);
|
||||
border-right: 1px solid var(--ys-line);
|
||||
}
|
||||
|
||||
@ -130,9 +135,9 @@ async function handleLogout(): Promise<void> {
|
||||
|
||||
.app-brand__name {
|
||||
font-family: var(--ys-font-brand);
|
||||
font-size: 16px;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 4px;
|
||||
letter-spacing: 0.32em;
|
||||
color: var(--ys-ink);
|
||||
}
|
||||
|
||||
@ -142,17 +147,15 @@ 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;
|
||||
}
|
||||
|
||||
@ -160,8 +163,8 @@ async function handleLogout(): Promise<void> {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 56px;
|
||||
background: linear-gradient(180deg, #ffffff, #fdfdfe);
|
||||
padding: 0 18px;
|
||||
background: linear-gradient(180deg, var(--ys-paper), #fdfdfe);
|
||||
border-bottom: 1px solid var(--ys-line);
|
||||
}
|
||||
|
||||
@ -179,15 +182,16 @@ async function handleLogout(): Promise<void> {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
color: var(--ys-text);
|
||||
color: var(--ys-ink-2);
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
.app-header__menu-toggle:hover {
|
||||
background-color: #f2f3f5;
|
||||
color: var(--ys-indigo);
|
||||
background-color: var(--ys-indigo-wash);
|
||||
}
|
||||
|
||||
.app-header__menu-toggle:focus-visible {
|
||||
@ -198,20 +202,46 @@ async function handleLogout(): Promise<void> {
|
||||
.app-header__user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
/* 首字头像:靛蓝洗底,26px —— 与菜单图标同为「轻质感」而非实心色块 */
|
||||
.app-header__avatar {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--ys-indigo);
|
||||
background: linear-gradient(180deg, #f1f4fb, var(--ys-indigo-wash));
|
||||
border: 1px solid rgba(46, 75, 168, 0.14);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.app-header__username {
|
||||
font-size: 14px;
|
||||
color: var(--ys-text);
|
||||
font-size: 13px;
|
||||
color: var(--ys-ink);
|
||||
}
|
||||
|
||||
.app-header__divider {
|
||||
width: 1px;
|
||||
height: 14px;
|
||||
background-color: var(--ys-line);
|
||||
}
|
||||
|
||||
.app-main {
|
||||
padding: 20px;
|
||||
/* 雾灰底 + 点阵网格:内容区的秩序与密度 */
|
||||
/*
|
||||
* 内容区:雾灰底 + 顶部受光(靛蓝微光叠一层高光)。
|
||||
* 受光用固定像素半径而非百分比:百分比会随窗口高度变化,
|
||||
* 窄高三列布局时天光会被拉成竖条。
|
||||
*/
|
||||
padding: 22px;
|
||||
background-color: var(--ys-mist);
|
||||
background-image: radial-gradient(rgba(28, 35, 51, 0.05) 1px, transparent 1px);
|
||||
background-size: 24px 24px;
|
||||
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%);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
@ -222,6 +252,10 @@ async function handleLogout(): Promise<void> {
|
||||
.app-header__menu-toggle {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.app-main {
|
||||
padding: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
@ -235,6 +269,6 @@ async function handleLogout(): Promise<void> {
|
||||
/* 抽屉内容铺满侧栏同款灰(非 scoped:目标元素由 el-drawer 渲染在组件根之外) */
|
||||
.app-drawer .el-drawer__body {
|
||||
padding: 0;
|
||||
background: linear-gradient(180deg, #f2f4f8 0%, #ecedf3 100%);
|
||||
background: linear-gradient(180deg, var(--ys-aside-1) 0%, var(--ys-aside-2) 100%);
|
||||
}
|
||||
</style>
|
||||
|
||||
72
frontend/src/components/layout/menuIcons.ts
Normal file
72
frontend/src/components/layout/menuIcons.ts
Normal file
@ -0,0 +1,72 @@
|
||||
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
|
||||
}
|
||||
@ -1,7 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 分页条:统一四个列表页的布局与页码尺寸选项。
|
||||
* 分页条:统一各列表页的布局与页码尺寸选项。
|
||||
* 与 `usePagedList` 的分页字段(pageNum/pageSize/total)配套使用。
|
||||
*
|
||||
* 开 background:设计稿 §03 要求当前页与主按钮同材质(靛蓝渐变 + 内高光),
|
||||
* 该效果只在 is-background 模式下由 .el-pager li 承载,样式见全局 index.css。
|
||||
*/
|
||||
defineProps<{
|
||||
total: number
|
||||
@ -18,6 +21,7 @@ defineEmits<{
|
||||
<template>
|
||||
<el-pagination
|
||||
class="app-pager"
|
||||
background
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
:total="total"
|
||||
:current-page="pageNum"
|
||||
@ -31,6 +35,6 @@ defineEmits<{
|
||||
<style scoped>
|
||||
.app-pager {
|
||||
justify-content: flex-end;
|
||||
margin-top: 16px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -4,8 +4,15 @@
|
||||
*
|
||||
* 用户状态、角色状态、操作结果、登录结果四处共用,故提取;
|
||||
* 语义文字由调用方通过 props 定制(如同一组件展示「启用/禁用」与「成功/失败」)。
|
||||
*
|
||||
* 视觉按设计稿 §03 的状态 pill 落地(浅底 + 6px 圆点 + 999px 圆角),
|
||||
* 用 span 而非 el-tag:el-tag 的实心/描边形态与四档色阶对不上,
|
||||
* 强行覆盖会牵连其它用 el-tag 的地方(角色标签、日志模块标签)。
|
||||
* activeType/inactiveType 保留为语义到 pill 色调的映射,调用方无需改动。
|
||||
*/
|
||||
withDefaults(
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
value: number
|
||||
activeText?: string
|
||||
@ -20,10 +27,23 @@ 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>
|
||||
<el-tag :type="value === 1 ? activeType : inactiveType" size="small" disable-transitions>
|
||||
{{ value === 1 ? activeText : inactiveText }}
|
||||
</el-tag>
|
||||
<span class="ys-pill" :class="tone ? `ys-pill--${tone}` : ''">{{ text }}</span>
|
||||
</template>
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
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'
|
||||
|
||||
@ -10,6 +12,8 @@ export interface MenuNode {
|
||||
title: string
|
||||
order: number
|
||||
group?: string
|
||||
/** 图标组件(由 meta.icon 语义 key 解析;未配置则为 undefined) */
|
||||
icon?: Component
|
||||
}
|
||||
|
||||
export interface MenuGroup {
|
||||
@ -42,6 +46,7 @@ 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),
|
||||
)
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
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'
|
||||
@ -20,6 +21,11 @@ declare module 'vue-router' {
|
||||
menu?: boolean
|
||||
/** 菜单分组名(同名聚合为一个子菜单);不设则为顶层菜单项 */
|
||||
group?: string
|
||||
/**
|
||||
* 菜单图标语义 key —— 取值见 components/layout/menuIcons.ts 的注册表。
|
||||
* 路由只写 key 不写图标组件:换图标库/换风格只改注册表。
|
||||
*/
|
||||
icon?: MenuIconName
|
||||
/** 菜单排序,越小越靠前 */
|
||||
order?: number
|
||||
}
|
||||
@ -46,6 +52,7 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import('@/views/dashboard/index.vue'),
|
||||
meta: {
|
||||
title: '首页',
|
||||
icon: 'home',
|
||||
order: 1,
|
||||
allowedRoles: [ROLE_ADMIN, 'finance', 'leader'],
|
||||
},
|
||||
@ -54,19 +61,25 @@ const routes: RouteRecordRaw[] = [
|
||||
path: '/system/users',
|
||||
name: 'system-users',
|
||||
component: () => import('@/views/system/users/index.vue'),
|
||||
meta: { title: '用户管理', group: '系统管理', requiresAdmin: true, order: 2 },
|
||||
meta: { title: '用户管理', icon: 'users', group: '系统管理', requiresAdmin: true, order: 2 },
|
||||
},
|
||||
{
|
||||
path: '/system/roles',
|
||||
name: 'system-roles',
|
||||
component: () => import('@/views/system/roles/index.vue'),
|
||||
meta: { title: '角色管理', group: '系统管理', requiresAdmin: true, order: 3 },
|
||||
meta: { title: '角色管理', icon: 'roles', group: '系统管理', requiresAdmin: true, order: 3 },
|
||||
},
|
||||
{
|
||||
path: '/system/business-roles',
|
||||
name: 'system-business-roles',
|
||||
component: () => import('@/views/system/business-roles/index.vue'),
|
||||
meta: { title: '岗位角色', group: '系统管理', requiresAdmin: true, order: 5 },
|
||||
meta: {
|
||||
title: '岗位角色',
|
||||
icon: 'business-roles',
|
||||
group: '系统管理',
|
||||
requiresAdmin: true,
|
||||
order: 5,
|
||||
},
|
||||
},
|
||||
{
|
||||
// 计提参数:查看放开到管理员/财务/领导(#17 答复「查看可、修改仅
|
||||
@ -76,6 +89,7 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import('@/views/system/params/index.vue'),
|
||||
meta: {
|
||||
title: '计提参数',
|
||||
icon: 'params',
|
||||
group: '系统管理',
|
||||
allowedRoles: [ROLE_ADMIN, 'finance', 'leader'],
|
||||
order: 6,
|
||||
@ -85,7 +99,13 @@ const routes: RouteRecordRaw[] = [
|
||||
path: '/system/settings',
|
||||
name: 'system-settings',
|
||||
component: () => import('@/views/system/settings/index.vue'),
|
||||
meta: { title: '公司信息', group: '系统管理', requiresAdmin: true, order: 7 },
|
||||
meta: {
|
||||
title: '公司信息',
|
||||
icon: 'settings',
|
||||
group: '系统管理',
|
||||
requiresAdmin: true,
|
||||
order: 7,
|
||||
},
|
||||
},
|
||||
{
|
||||
// 审批管理:管理员/财务/领导可见(营销后勤待权限清单答复后放开)
|
||||
@ -94,6 +114,7 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import('@/views/system/approvals/index.vue'),
|
||||
meta: {
|
||||
title: '审批管理',
|
||||
icon: 'approvals',
|
||||
group: '系统管理',
|
||||
allowedRoles: [ROLE_ADMIN, 'finance', 'leader'],
|
||||
order: 8,
|
||||
@ -103,45 +124,51 @@ const routes: RouteRecordRaw[] = [
|
||||
path: '/system/logs',
|
||||
name: 'system-logs',
|
||||
component: () => import('@/views/system/logs/index.vue'),
|
||||
meta: { title: '日志查询', group: '系统管理', requiresAdmin: true, order: 9 },
|
||||
meta: {
|
||||
title: '日志查询',
|
||||
icon: 'logs',
|
||||
group: '系统管理',
|
||||
requiresAdmin: true,
|
||||
order: 9,
|
||||
},
|
||||
},
|
||||
{
|
||||
// 基础数据属业务操作:登录即可访问,不设 requiresAdmin(对照后端 api/v1/base)
|
||||
path: '/base/personnel',
|
||||
name: 'base-personnel',
|
||||
component: () => import('@/views/base/personnel/index.vue'),
|
||||
meta: { title: '人员管理', group: '基础数据', order: 10 },
|
||||
meta: { title: '人员管理', icon: 'personnel', group: '基础数据', order: 10 },
|
||||
},
|
||||
{
|
||||
path: '/base/products',
|
||||
name: 'base-products',
|
||||
component: () => import('@/views/base/products/index.vue'),
|
||||
meta: { title: '产品管理', group: '基础数据', order: 11 },
|
||||
meta: { title: '产品管理', icon: 'products', group: '基础数据', order: 11 },
|
||||
},
|
||||
{
|
||||
path: '/base/customers',
|
||||
name: 'base-customers',
|
||||
component: () => import('@/views/base/customers/index.vue'),
|
||||
meta: { title: '客户管理', group: '基础数据', order: 12 },
|
||||
meta: { title: '客户管理', icon: 'customers', group: '基础数据', order: 12 },
|
||||
},
|
||||
{
|
||||
// 业务数据组一律为业务操作:登录即可访问,不设 requiresAdmin
|
||||
path: '/business/businesses',
|
||||
name: 'business-businesses',
|
||||
component: () => import('@/views/business/businesses/index.vue'),
|
||||
meta: { title: '业务管理', group: '业务数据', order: 13 },
|
||||
meta: { title: '业务管理', icon: 'businesses', group: '业务数据', order: 13 },
|
||||
},
|
||||
{
|
||||
path: '/business/shipments',
|
||||
name: 'business-shipments',
|
||||
component: () => import('@/views/business/shipments/index.vue'),
|
||||
meta: { title: '发货管理', group: '业务数据', order: 14 },
|
||||
meta: { title: '发货管理', icon: 'shipments', group: '业务数据', order: 14 },
|
||||
},
|
||||
{
|
||||
path: '/business/receipts',
|
||||
name: 'business-receipts',
|
||||
component: () => import('@/views/business/receipts/index.vue'),
|
||||
meta: { title: '回款管理', group: '业务数据', order: 15 },
|
||||
meta: { title: '回款管理', icon: 'receipts', group: '业务数据', order: 15 },
|
||||
},
|
||||
{
|
||||
// 业务数据组首个页面:评级计算器是打分工具,不影响绩效金额,
|
||||
@ -149,7 +176,7 @@ const routes: RouteRecordRaw[] = [
|
||||
path: '/business/rating',
|
||||
name: 'business-rating',
|
||||
component: () => import('@/views/business/rating/index.vue'),
|
||||
meta: { title: '客户评级计算器', group: '业务数据', order: 16 },
|
||||
meta: { title: '客户评级计算器', icon: 'rating', group: '业务数据', order: 16 },
|
||||
},
|
||||
{
|
||||
// 绩效工资组(M3 ④):核算台账读登录即可,月结写限管理员与营销后勤
|
||||
@ -157,13 +184,13 @@ const routes: RouteRecordRaw[] = [
|
||||
path: '/calc/ledger',
|
||||
name: 'calc-ledger',
|
||||
component: () => import('@/views/calc/ledger/index.vue'),
|
||||
meta: { title: '绩效台账', group: '绩效工资', order: 17 },
|
||||
meta: { title: '绩效台账', icon: 'ledger', group: '绩效工资', order: 17 },
|
||||
},
|
||||
{
|
||||
path: '/settlements',
|
||||
name: 'settlements',
|
||||
component: () => import('@/views/settlements/index.vue'),
|
||||
meta: { title: '月度结算', group: '绩效工资', order: 18 },
|
||||
meta: { title: '月度结算', icon: 'settlements', group: '绩效工资', order: 18 },
|
||||
},
|
||||
// 报表统计组(M4 ⑤):FR-030/031 限领导/财务/管理员;admin 须显式
|
||||
// 列入 allowedRoles(项目既定规则,admin 不天然放行)
|
||||
@ -173,6 +200,7 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import('@/views/reports/personal-detail/index.vue'),
|
||||
meta: {
|
||||
title: '个人绩效明细',
|
||||
icon: 'report-personal',
|
||||
group: '报表统计',
|
||||
order: 19,
|
||||
allowedRoles: [ROLE_ADMIN, 'finance', 'leader'],
|
||||
@ -184,6 +212,7 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import('@/views/reports/department-summary/index.vue'),
|
||||
meta: {
|
||||
title: '部门绩效汇总',
|
||||
icon: 'report-department',
|
||||
group: '报表统计',
|
||||
order: 20,
|
||||
allowedRoles: [ROLE_ADMIN, 'finance', 'leader'],
|
||||
@ -195,6 +224,7 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import('@/views/reports/business-stats/index.vue'),
|
||||
meta: {
|
||||
title: '业务统计',
|
||||
icon: 'report-business',
|
||||
group: '报表统计',
|
||||
order: 21,
|
||||
allowedRoles: [ROLE_ADMIN, 'finance', 'leader'],
|
||||
@ -206,6 +236,7 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import('@/views/reports/payment-stats/index.vue'),
|
||||
meta: {
|
||||
title: '回款统计',
|
||||
icon: 'report-payment',
|
||||
group: '报表统计',
|
||||
order: 22,
|
||||
allowedRoles: [ROLE_ADMIN, 'finance', 'leader'],
|
||||
|
||||
@ -7,6 +7,7 @@ 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'
|
||||
@ -56,18 +57,14 @@ interface PersonnelOption {
|
||||
status: number
|
||||
}
|
||||
|
||||
const ownerOptions = ref<PersonnelOption[]>([])
|
||||
|
||||
async function loadOwnerOptions(): Promise<void> {
|
||||
try {
|
||||
const { options: ownerOptions, load: loadOwnerOptions } = useDropdownOptions<PersonnelOption>(
|
||||
async () => {
|
||||
// 归属下拉的近似全量数据源:内部系统人员规模有限,200 条足够;
|
||||
// 极端场景由后端对归属合法性的校验兜底
|
||||
const data = await personnelApi.listPersonnel({ pageNum: 1, pageSize: 200 })
|
||||
ownerOptions.value = data.list.map((p) => ({ id: p.id, name: p.personnel_name, status: p.status }))
|
||||
} catch (error) {
|
||||
handledError(error) // 下拉为空不阻断列表使用
|
||||
}
|
||||
}
|
||||
return data.list.map((p) => ({ id: p.id, name: p.personnel_name, status: p.status }))
|
||||
},
|
||||
)
|
||||
|
||||
/** 下拉项展示名:离职人员加后缀标注(仍可被选作归属吗?由后端校验兜底) */
|
||||
function ownerLabel(option: PersonnelOption): string {
|
||||
@ -322,61 +319,86 @@ onMounted(() => {
|
||||
|
||||
<template>
|
||||
<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"
|
||||
<div class="page-head">
|
||||
<h1 class="page-title">客户管理</h1>
|
||||
<p class="page-desc">
|
||||
客户档案与归属人员;归属变更只影响变更后的核算、不回溯历史,每次调整都在「变更记录」留痕
|
||||
</p>
|
||||
<span class="waterline"></span>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
.filter-bar 直接挂在 el-form 上:el-form 不是 flex 透明容器,
|
||||
外面再包一层 div 会让 el-form-item 丢掉 gap 排版,筛选条退化成竖排。
|
||||
-->
|
||||
<el-form class="filter-bar" @submit.prevent>
|
||||
<el-form-item label="关键词">
|
||||
<el-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-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>
|
||||
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" @click="openCreate">新增客户</el-button>
|
||||
</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>
|
||||
</el-form>
|
||||
|
||||
<el-table v-loading="loading" :data="records" stripe>
|
||||
<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-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 }">{{ row.contact_person ?? '—' }}</template>
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.contact_person">{{ row.contact_person }}</span>
|
||||
<span v-else class="cell-muted">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="contact_phone" label="电话" min-width="120">
|
||||
<template #default="{ row }">{{ row.contact_phone ?? '—' }}</template>
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.contact_phone">{{ row.contact_phone }}</span>
|
||||
<span v-else class="cell-muted">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="分类" width="110" align="center">
|
||||
<template #default="{ row }">
|
||||
@ -396,7 +418,10 @@ onMounted(() => {
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="归属人员" min-width="110">
|
||||
<template #default="{ row }">{{ row.owner_personnel_name ?? '(无归属)' }}</template>
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.owner_personnel_name">{{ row.owner_personnel_name }}</span>
|
||||
<span v-else class="cell-muted">(无归属)</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="260" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
@ -413,14 +438,16 @@ onMounted(() => {
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<AppPagination
|
||||
:total="total"
|
||||
:page-num="pageNum"
|
||||
:page-size="pageSize"
|
||||
@page-change="onPageChange"
|
||||
@size-change="onSizeChange"
|
||||
/>
|
||||
</el-card>
|
||||
<div class="card-foot">
|
||||
<AppPagination
|
||||
:total="total"
|
||||
:page-num="pageNum"
|
||||
:page-size="pageSize"
|
||||
@page-change="onPageChange"
|
||||
@size-change="onSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 新增 / 编辑(编辑不含归属,归属走独立按钮保证留痕) -->
|
||||
<el-dialog
|
||||
@ -532,7 +559,13 @@ onMounted(() => {
|
||||
|
||||
<!-- 归属变更记录(分页留痕,按变更时间倒序) -->
|
||||
<el-drawer v-model="logsVisible" :title="logsTitle" size="760px" destroy-on-close>
|
||||
<el-table v-loading="logsLoading" :data="logRecords" stripe size="small">
|
||||
<el-table
|
||||
v-loading="logsLoading"
|
||||
:data="logRecords"
|
||||
stripe
|
||||
size="small"
|
||||
empty-text="这个客户还没有归属变更记录 —— 调整一次归属后这里就会出现留痕"
|
||||
>
|
||||
<el-table-column label="变更时间" width="160">
|
||||
<template #default="{ row }">{{ formatDateTime(row.create_time) }}</template>
|
||||
</el-table-column>
|
||||
|
||||
@ -8,6 +8,7 @@ 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'
|
||||
@ -64,29 +65,22 @@ interface SelectOption {
|
||||
status: number
|
||||
}
|
||||
|
||||
const roleOptions = ref<SelectOption[]>([])
|
||||
const managerOptions = ref<SelectOption[]>([])
|
||||
|
||||
async function loadRoleOptions(): Promise<void> {
|
||||
try {
|
||||
const { options: roleOptions, load: loadRoleOptions } = useDropdownOptions<SelectOption>(
|
||||
async () => {
|
||||
// 岗位规模有限(内置 3 个 + 自定义),选项接口一次取全
|
||||
const data = await businessRoleApi.listBusinessRoleOptions()
|
||||
roleOptions.value = data.map((r) => ({ id: r.id, name: r.role_name, status: r.status }))
|
||||
} catch (error) {
|
||||
handledError(error) // 下拉为空不阻断列表使用
|
||||
}
|
||||
}
|
||||
return data.map((r) => ({ id: r.id, name: r.role_name, status: r.status }))
|
||||
},
|
||||
)
|
||||
|
||||
async function loadManagerOptions(): Promise<void> {
|
||||
try {
|
||||
const { options: managerOptions, load: loadManagerOptions } = useDropdownOptions<SelectOption>(
|
||||
async () => {
|
||||
// 经理下拉的近似全量数据源:内部系统人员规模有限,200 条足够;
|
||||
// 超出规模的极端场景由后端对经理合法性的校验兜底
|
||||
const data = await personnelApi.listPersonnel({ pageNum: 1, pageSize: 200 })
|
||||
managerOptions.value = data.list.map((p) => ({ id: p.id, name: p.personnel_name, status: p.status }))
|
||||
} catch (error) {
|
||||
handledError(error)
|
||||
}
|
||||
}
|
||||
return data.list.map((p) => ({ id: p.id, name: p.personnel_name, status: p.status }))
|
||||
},
|
||||
)
|
||||
|
||||
// ==================== 新增 / 编辑(基本信息) ====================
|
||||
|
||||
@ -372,54 +366,73 @@ onMounted(() => {
|
||||
|
||||
<template>
|
||||
<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"
|
||||
<div class="page-head">
|
||||
<h1 class="page-title">人员管理</h1>
|
||||
<p class="page-desc">
|
||||
人员岗位与适用计提参数在这里定;改岗位会改变后续回款的绩效口径,设为离职后不可再被业务与客户归属引用
|
||||
</p>
|
||||
<span class="waterline"></span>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
.filter-bar 直接挂在 el-form 上:el-form 不是 flex 透明容器,
|
||||
外面再包一层 div 会让 el-form-item 丢掉 gap 排版,筛选条退化成竖排。
|
||||
-->
|
||||
<el-form class="filter-bar" @submit.prevent>
|
||||
<el-form-item label="姓名">
|
||||
<el-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-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>
|
||||
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" @click="openCreate">新增人员</el-button>
|
||||
</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>
|
||||
</el-form>
|
||||
|
||||
<el-table v-loading="loading" :data="records" stripe>
|
||||
<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-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>
|
||||
@ -444,10 +457,10 @@ onMounted(() => {
|
||||
<el-table-column label="工资标准(元)" min-width="120" align="right">
|
||||
<template #default="{ row }">
|
||||
<!-- 字符串直出:金额红线,禁 toFixed / float 格式化 -->
|
||||
<span class="cell-amount">{{ row.salary_standard ?? '—' }}</span>
|
||||
<span class="cell-num">{{ row.salary_standard ?? '—' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="90" align="center">
|
||||
<el-table-column label="状态" width="96" align="center">
|
||||
<template #default="{ row }">
|
||||
<StatusTag :value="row.status" active-text="在职" inactive-text="离职" />
|
||||
</template>
|
||||
@ -469,14 +482,16 @@ onMounted(() => {
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<AppPagination
|
||||
:total="total"
|
||||
:page-num="pageNum"
|
||||
:page-size="pageSize"
|
||||
@page-change="onPageChange"
|
||||
@size-change="onSizeChange"
|
||||
/>
|
||||
</el-card>
|
||||
<div class="card-foot">
|
||||
<AppPagination
|
||||
:total="total"
|
||||
:page-num="pageNum"
|
||||
:page-size="pageSize"
|
||||
@page-change="onPageChange"
|
||||
@size-change="onSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 新增 / 编辑(基本信息;不含状态与子表) -->
|
||||
<el-dialog
|
||||
@ -567,7 +582,12 @@ onMounted(() => {
|
||||
</el-button>
|
||||
</span>
|
||||
</div>
|
||||
<el-table :data="regionRows" stripe size="small">
|
||||
<el-table
|
||||
:data="regionRows"
|
||||
stripe
|
||||
size="small"
|
||||
empty-text="还没有辐射区域 —— 点「添加区域」加一条"
|
||||
>
|
||||
<el-table-column label="区域名称" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<el-input v-model="row.region_name" maxlength="128" placeholder="如 沈阳市铁西区" />
|
||||
@ -598,7 +618,12 @@ onMounted(() => {
|
||||
</el-button>
|
||||
</span>
|
||||
</div>
|
||||
<el-table :data="planRows" stripe size="small">
|
||||
<el-table
|
||||
:data="planRows"
|
||||
stripe
|
||||
size="small"
|
||||
empty-text="还没有拓展计划 —— 点「添加计划」加一条"
|
||||
>
|
||||
<el-table-column label="期间" min-width="110">
|
||||
<template #default="{ row }">
|
||||
<el-input v-model="row.plan_period" maxlength="32" placeholder="如 2026Q1" />
|
||||
@ -629,23 +654,19 @@ onMounted(() => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.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-muted / .cell-num 由全局 index.css 提供,本页不再重复定义 */
|
||||
|
||||
/*
|
||||
* 试用期的计算口径必须写在开关旁:它不是状态而是计提规则,
|
||||
* 单靠「试用」二字看不出「照算不照发、超 18000 年底结」。
|
||||
*/
|
||||
.switch-hint {
|
||||
margin-left: 10px;
|
||||
color: var(--ys-ink-3);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 抽屉内的子表分组:区域与计划各自成块,块内标题与操作按钮同行 */
|
||||
.sub-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
@ -185,6 +185,14 @@ 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"
|
||||
@ -193,48 +201,59 @@ onMounted(() => {
|
||||
title="产品类别决定折算阈值:市政水利 30% / 轨道交通 20%;阈值在「系统设置-计提参数」维护。"
|
||||
/>
|
||||
|
||||
<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"
|
||||
<!--
|
||||
.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-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>
|
||||
</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>
|
||||
</div>
|
||||
</el-form>
|
||||
|
||||
<el-table v-loading="loading" :data="records" stripe>
|
||||
<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-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>
|
||||
@ -245,12 +264,12 @@ onMounted(() => {
|
||||
<el-table-column label="数量" min-width="100" align="right">
|
||||
<template #default="{ row }">
|
||||
<!-- 字符串直出:金额红线,禁 toFixed / float 格式化 -->
|
||||
<span class="cell-amount">{{ row.stock_quantity ?? '—' }}</span>
|
||||
<span class="cell-num">{{ row.stock_quantity ?? '—' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="单价(元)" min-width="110" align="right">
|
||||
<template #default="{ row }">
|
||||
<span class="cell-amount">{{ row.sale_price ?? '—' }}</span>
|
||||
<span class="cell-num">{{ row.sale_price ?? '—' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="类别" width="110" align="center">
|
||||
@ -264,7 +283,7 @@ onMounted(() => {
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="90" align="center">
|
||||
<el-table-column label="状态" width="96" align="center">
|
||||
<template #default="{ row }">
|
||||
<StatusTag :value="row.status" />
|
||||
</template>
|
||||
@ -283,14 +302,16 @@ onMounted(() => {
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<AppPagination
|
||||
:total="total"
|
||||
:page-num="pageNum"
|
||||
:page-size="pageSize"
|
||||
@page-change="onPageChange"
|
||||
@size-change="onSizeChange"
|
||||
/>
|
||||
</el-card>
|
||||
<div class="card-foot">
|
||||
<AppPagination
|
||||
:total="total"
|
||||
:page-num="pageNum"
|
||||
:page-size="pageSize"
|
||||
@page-change="onPageChange"
|
||||
@size-change="onSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 新增 / 编辑 -->
|
||||
<el-dialog
|
||||
@ -342,13 +363,10 @@ onMounted(() => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-alert {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
/* .cell-num 由全局 index.css 提供(等宽数字 + 右对齐),本页不再重复定义 */
|
||||
|
||||
.cell-amount {
|
||||
font-family: 'SFMono-Regular', Consolas, 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
color: var(--ys-ink-2);
|
||||
/* 阈值提示条:与卡片同宽,下距由这里控制,不占卡的内部留白 */
|
||||
.page-alert {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -10,6 +10,7 @@ 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'
|
||||
@ -69,37 +70,51 @@ interface PersonnelOption {
|
||||
status: number
|
||||
}
|
||||
|
||||
const customerOptions = ref<CustomerOption[]>([])
|
||||
const personnelOptions = ref<PersonnelOption[]>([])
|
||||
|
||||
async function loadCustomerOptions(): Promise<void> {
|
||||
try {
|
||||
const { options: customerOptions, load: loadCustomerOptions } = useDropdownOptions<CustomerOption>(
|
||||
async () => {
|
||||
// 客户下拉的近似全量数据源:内部系统规模有限,200 条足够;超出由后端校验兜底
|
||||
const data = await customerApi.listCustomers({ pageNum: 1, pageSize: 200 })
|
||||
customerOptions.value = data.list.map((c) => ({ id: c.id, name: c.company_name }))
|
||||
} catch (error) {
|
||||
handledError(error) // 下拉为空不阻断列表使用
|
||||
}
|
||||
}
|
||||
return data.list.map((c) => ({ id: c.id, name: c.company_name }))
|
||||
},
|
||||
)
|
||||
|
||||
async function loadPersonnelOptions(): Promise<void> {
|
||||
try {
|
||||
const { options: personnelOptions, load: loadPersonnelOptions } = useDropdownOptions<PersonnelOption>(
|
||||
async () => {
|
||||
const data = await personnelApi.listPersonnel({ pageNum: 1, pageSize: 200 })
|
||||
personnelOptions.value = data.list.map((p) => ({
|
||||
return 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)
|
||||
@ -602,65 +617,87 @@ onMounted(() => {
|
||||
|
||||
<template>
|
||||
<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.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="page-head">
|
||||
<h1 class="page-title">业务管理</h1>
|
||||
<p class="page-desc">
|
||||
业务台账是发货、回款与绩效的源头:客户、归属人员与产品类别在这里定下来,后面的核算才有依据
|
||||
</p>
|
||||
<span class="waterline"></span>
|
||||
</div>
|
||||
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" @click="openCreate">新增业务</el-button>
|
||||
<!--
|
||||
.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-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>
|
||||
</div>
|
||||
</el-form>
|
||||
|
||||
<el-table v-loading="loading" :data="records" stripe>
|
||||
<el-table-column prop="business_no" label="业务编号" min-width="150" />
|
||||
<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-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="90" align="center">
|
||||
<el-table-column label="状态" width="108" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag
|
||||
:type="businessApi.businessStatusTagType(row.status)"
|
||||
size="small"
|
||||
disable-transitions
|
||||
>
|
||||
<!-- 状态列统一用 pill:颜色之外必有文字,色觉差异不影响判读 -->
|
||||
<span :class="statusPillClass(row.status)">
|
||||
{{ businessApi.businessStatusLabel(row.status) }}
|
||||
</el-tag>
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="owner_personnel_name" label="负责人员" min-width="100">
|
||||
@ -672,22 +709,22 @@ onMounted(() => {
|
||||
<el-table-column label="销售额(元)" min-width="120" align="right">
|
||||
<template #default="{ row }">
|
||||
<!-- 字符串直出:金额红线,禁 toFixed / float 格式化 -->
|
||||
<span class="cell-amount">{{ row.total_sale_amount ?? '—' }}</span>
|
||||
<span class="cell-num">{{ row.total_sale_amount ?? '—' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="成本(元)" min-width="120" align="right">
|
||||
<template #default="{ row }">
|
||||
<span class="cell-amount">{{ row.total_cost_amount ?? '—' }}</span>
|
||||
<span class="cell-num">{{ row.total_cost_amount ?? '—' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="发货额(元)" min-width="120" align="right">
|
||||
<template #default="{ row }">
|
||||
<span class="cell-amount">{{ row.total_ship_amount ?? '—' }}</span>
|
||||
<span class="cell-num">{{ row.total_ship_amount ?? '—' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="回款额(元)" min-width="120" align="right">
|
||||
<template #default="{ row }">
|
||||
<span class="cell-amount">{{ row.total_received_amount ?? '—' }}</span>
|
||||
<span class="cell-num">{{ row.total_received_amount ?? '—' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="300" fixed="right" align="center">
|
||||
@ -734,14 +771,17 @@ onMounted(() => {
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<AppPagination
|
||||
:total="total"
|
||||
:page-num="pageNum"
|
||||
:page-size="pageSize"
|
||||
@page-change="onPageChange"
|
||||
@size-change="onSizeChange"
|
||||
/>
|
||||
</el-card>
|
||||
<!-- 分页落进 card-foot:与表体同一张卡,卡底微渐变收边 -->
|
||||
<div class="card-foot">
|
||||
<AppPagination
|
||||
:total="total"
|
||||
:page-num="pageNum"
|
||||
:page-size="pageSize"
|
||||
@page-change="onPageChange"
|
||||
@size-change="onSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 新增 / 编辑(含明细行编辑器;编辑不含归属,归属走独立按钮保证留痕) -->
|
||||
<el-dialog
|
||||
@ -1077,19 +1117,19 @@ onMounted(() => {
|
||||
<span v-else class="cell-muted">否</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="销售额(元)">
|
||||
<span class="cell-amount">{{ detail.total_sale_amount ?? '—' }}</span>
|
||||
<span class="cell-num">{{ detail.total_sale_amount ?? '—' }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="成本(元)">
|
||||
<span class="cell-amount">{{ detail.total_cost_amount ?? '—' }}</span>
|
||||
<span class="cell-num">{{ detail.total_cost_amount ?? '—' }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="发货额(元)">
|
||||
<span class="cell-amount">{{ detail.total_ship_amount ?? '—' }}</span>
|
||||
<span class="cell-num">{{ detail.total_ship_amount ?? '—' }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="回款额(元)">
|
||||
<span class="cell-amount">{{ detail.total_received_amount ?? '—' }}</span>
|
||||
<span class="cell-num">{{ detail.total_received_amount ?? '—' }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="运费额(元)">
|
||||
<span class="cell-amount">{{ detail.total_freight_amount ?? '—' }}</span>
|
||||
<span class="cell-num">{{ detail.total_freight_amount ?? '—' }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="备注">
|
||||
{{ detail.remark ?? '—' }}
|
||||
@ -1115,27 +1155,27 @@ onMounted(() => {
|
||||
</el-table-column>
|
||||
<el-table-column label="数量" min-width="80" align="right">
|
||||
<template #default="{ row }">
|
||||
<span class="cell-amount">{{ row.quantity }}</span>
|
||||
<span class="cell-num">{{ row.quantity }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="销售单价" min-width="100" align="right">
|
||||
<template #default="{ row }">
|
||||
<span class="cell-amount">{{ row.sale_price ?? '—' }}</span>
|
||||
<span class="cell-num">{{ row.sale_price ?? '—' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="行销售额" min-width="110" align="right">
|
||||
<template #default="{ row }">
|
||||
<span class="cell-amount">{{ row.sale_amount }}</span>
|
||||
<span class="cell-num">{{ row.sale_amount }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="成本额" min-width="100" align="right">
|
||||
<template #default="{ row }">
|
||||
<span class="cell-amount">{{ row.cost_amount ?? '—' }}</span>
|
||||
<span class="cell-num">{{ row.cost_amount ?? '—' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="指导价" min-width="100" align="right">
|
||||
<template #default="{ row }">
|
||||
<span class="cell-amount">{{ row.guided_price ?? '—' }}</span>
|
||||
<span class="cell-num">{{ row.guided_price ?? '—' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@ -1155,7 +1195,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-amount">{{ row.ratio }}%</span>
|
||||
<span class="cell-num">{{ row.ratio }}%</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="90">
|
||||
@ -1259,18 +1299,18 @@ onMounted(() => {
|
||||
</div>
|
||||
<div class="gp-grid">
|
||||
<span class="gp-label">销售额</span>
|
||||
<span class="gp-value cell-amount">{{ cat.sale_amount }}</span>
|
||||
<span class="gp-value cell-num">{{ cat.sale_amount }}</span>
|
||||
<span class="gp-label">成本额</span>
|
||||
<span class="gp-value cell-amount">{{ cat.cost_amount }}</span>
|
||||
<span class="gp-value cell-num">{{ cat.cost_amount }}</span>
|
||||
<span class="gp-label">毛利润额</span>
|
||||
<span class="gp-value cell-amount">{{ cat.profit_amount }}</span>
|
||||
<span class="gp-value cell-num">{{ cat.profit_amount }}</span>
|
||||
<span class="gp-label">毛利润率</span>
|
||||
<!-- 分数字符串原样展示(如 997/1000),禁止数值化 -->
|
||||
<span class="gp-value cell-amount">{{ cat.profit_rate }}</span>
|
||||
<span class="gp-value cell-num">{{ cat.profit_rate }}</span>
|
||||
<span class="gp-label">折算阈值</span>
|
||||
<span class="gp-value cell-amount">{{ cat.threshold }}</span>
|
||||
<span class="gp-value cell-num">{{ cat.threshold }}</span>
|
||||
<span class="gp-label">折算毛利润额</span>
|
||||
<span class="gp-value cell-amount">{{ cat.converted_profit }}</span>
|
||||
<span class="gp-value cell-num">{{ cat.converted_profit }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
@ -1287,38 +1327,38 @@ onMounted(() => {
|
||||
</el-table-column>
|
||||
<el-table-column label="数量" min-width="80" align="right">
|
||||
<template #default="{ row }">
|
||||
<span class="cell-amount">{{ row.quantity }}</span>
|
||||
<span class="cell-num">{{ row.quantity }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="销售额" min-width="100" align="right">
|
||||
<template #default="{ row }">
|
||||
<span class="cell-amount">{{ row.sale_amount }}</span>
|
||||
<span class="cell-num">{{ 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-amount">{{ row.cost_amount }}</span>
|
||||
<span v-if="row.cost_amount" class="cell-num">{{ row.cost_amount }}</span>
|
||||
<span v-else class="cell-missing">{{ row.missing_reason ?? '成本缺失' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="毛利润额" min-width="100" align="right">
|
||||
<template #default="{ row }">
|
||||
<span class="cell-amount">{{ row.profit_amount ?? '—' }}</span>
|
||||
<span class="cell-num">{{ row.profit_amount ?? '—' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="毛利润率" min-width="100" align="right">
|
||||
<template #default="{ row }">
|
||||
<span class="cell-amount">{{ row.profit_rate ?? '—' }}</span>
|
||||
<span class="cell-num">{{ row.profit_rate ?? '—' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="折算阈值" min-width="90" align="right">
|
||||
<template #default="{ row }">
|
||||
<span class="cell-amount">{{ row.threshold }}</span>
|
||||
<span class="cell-num">{{ row.threshold }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="折算毛利润额" min-width="110" align="right">
|
||||
<template #default="{ row }">
|
||||
<span class="cell-amount">{{ row.converted_profit ?? '—' }}</span>
|
||||
<span class="cell-num">{{ row.converted_profit ?? '—' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@ -1333,16 +1373,8 @@ onMounted(() => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.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-code / .cell-num / .cell-muted)与表头、按钮、输入框皮肤
|
||||
已由全局 index.css 提供,此处不再重复定义,避免两处口径打架 */
|
||||
|
||||
.cell-missing {
|
||||
font-size: 12px;
|
||||
@ -1512,4 +1544,5 @@ onMounted(() => {
|
||||
color: var(--ys-ink-3);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@ -206,6 +206,15 @@ 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"
|
||||
@ -215,57 +224,61 @@ onMounted(() => {
|
||||
show-icon
|
||||
/>
|
||||
|
||||
<!-- 评分计算区:无保存按钮,纯前端实时计算 -->
|
||||
<el-card shadow="never">
|
||||
<div class="page-toolbar">
|
||||
<span class="page-desc">五项各 0-20 分,总分 0-100;评级等级按配置的下限实时判定。</span>
|
||||
<span>
|
||||
<!-- 计算器是一张卡:卡头放标题与操作,卡体内分「评分项 / 判定结果」两块 -->
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<span class="card-title">评分计算</span>
|
||||
<span class="card-head__actions">
|
||||
<el-button @click="resetScores">重置</el-button>
|
||||
<el-button type="primary" @click="openDrawer">等级映射设置</el-button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="calc-grid">
|
||||
<div class="calc-form">
|
||||
<div v-for="item in SCORE_ITEMS" :key="item.key" class="score-row">
|
||||
<span class="score-label">{{ item.label }}</span>
|
||||
<el-input-number
|
||||
v-model="scores[item.key]"
|
||||
:min="0"
|
||||
:max="20"
|
||||
:step="1"
|
||||
controls-position="right"
|
||||
/>
|
||||
<span class="score-unit">分</span>
|
||||
<div 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>
|
||||
</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 class="calc-result">
|
||||
<div class="section-title">判定结果</div>
|
||||
<div>
|
||||
<span class="result-label">总分</span>
|
||||
<b class="total-num">{{ totalScore }}</b>
|
||||
<span class="total-max">/ 100</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="result-label">评级等级</span>
|
||||
<template v-if="grades.length === 0">
|
||||
<div class="grade-name">—</div>
|
||||
<div class="grade-hint">未配置等级映射</div>
|
||||
</template>
|
||||
<template v-else-if="matchedGrade">
|
||||
<div class="grade-name">{{ matchedGrade.grade_name }}</div>
|
||||
<div class="grade-hint">
|
||||
分值区间 {{ matchedGrade.min_score }} ~ {{ matchedGrade.max_score }}
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="grade-name">—</div>
|
||||
<div class="grade-hint">总分低于全部等级下限,未命中等级</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<!-- 等级映射设置抽屉 -->
|
||||
<el-drawer v-model="drawerVisible" title="等级映射设置" size="640px">
|
||||
@ -346,22 +359,17 @@ onMounted(() => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 页面头(.page-head/.page-title/.page-desc/.waterline)、.card 与 .section-title
|
||||
都由全局 index.css 提供,此处只保留本页特有的排版 */
|
||||
|
||||
.page-alert {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.page-toolbar {
|
||||
/* 卡头右侧操作组:.card-head 已是 flex + justify-between,这里只补按钮之间的间距 */
|
||||
.card-head__actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.page-desc {
|
||||
font-size: 13px;
|
||||
color: var(--ys-ink-2);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.calc-grid {
|
||||
|
||||
@ -242,108 +242,134 @@ onMounted(() => {
|
||||
|
||||
<template>
|
||||
<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"
|
||||
<!-- 页面头:标题 + 一句口径说明(回款是绩效源头)+ 签名水线 -->
|
||||
<div class="page-head">
|
||||
<h1 class="page-title">回款管理</h1>
|
||||
<p class="page-desc">回款是绩效的源头:批次、到账日与产品类别决定计提口径</p>
|
||||
<span class="waterline"></span>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
.filter-bar 直接挂在 el-form 上(去掉 inline):el-form 不是 flex 透明容器,
|
||||
外面再包一层 div 会让 el-form-item 丢掉 gap 排版,筛选条退化成竖排。
|
||||
-->
|
||||
<el-form class="filter-bar" @submit.prevent>
|
||||
<el-form-item label="业务">
|
||||
<el-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-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>
|
||||
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" @click="openCreate">新增回款</el-button>
|
||||
</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>
|
||||
</el-form>
|
||||
|
||||
<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>
|
||||
<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-column>
|
||||
<el-table-column label="发货单号" min-width="140">
|
||||
<template #default="{ row }">{{ row.shipment_no ?? '—' }}</template>
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.shipment_no" class="cell-code">{{ row.shipment_no }}</span>
|
||||
<span v-else class="cell-muted">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="receipt_date" label="回款日期" min-width="110" />
|
||||
<el-table-column label="金额(元)" min-width="120" align="right">
|
||||
<template #default="{ row }">
|
||||
<!-- 字符串直出:金额红线,禁 toFixed / float 格式化 -->
|
||||
<span class="cell-amount">{{ row.amount }}</span>
|
||||
<span class="cell-num">{{ row.amount }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="以物抵顶" width="100" align="center">
|
||||
<el-table-column label="以物抵顶" width="120" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.is_barter === 1" type="warning" size="small" disable-transitions>
|
||||
以物抵顶
|
||||
</el-tag>
|
||||
<!-- 以物抵顶影响折算比例,属需要一眼看见的分支,用 pill 而非普通文字 -->
|
||||
<span v-if="row.is_barter === 1" class="ys-pill ys-pill--wait">以物抵顶</span>
|
||||
<span v-else class="cell-muted">否</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="回款天数" width="120" align="center">
|
||||
<el-table-column label="回款天数" width="120" align="right">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.payment_days !== null" class="cell-amount">{{ row.payment_days }} 天</span>
|
||||
<span v-if="row.payment_days !== null" class="cell-num">{{ row.payment_days }} 天</span>
|
||||
<span v-else class="cell-muted">未指定批次</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="核算状态" width="90" align="center">
|
||||
<el-table-column label="核算状态" width="108" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag
|
||||
:type="row.settlement_status === 1 ? 'success' : 'info'"
|
||||
size="small"
|
||||
disable-transitions
|
||||
<!-- 已核算=已确认(靛绿)、未核算=待处理(琥珀);已核算行禁改禁删 -->
|
||||
<span
|
||||
class="ys-pill"
|
||||
:class="row.settlement_status === 1 ? 'ys-pill--ok' : 'ys-pill--wait'"
|
||||
>
|
||||
{{ row.settlement_status === 1 ? '已核算' : '未核算' }}
|
||||
</el-tag>
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="130" fixed="right" align="center">
|
||||
@ -389,14 +415,17 @@ onMounted(() => {
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<AppPagination
|
||||
:total="total"
|
||||
:page-num="pageNum"
|
||||
:page-size="pageSize"
|
||||
@page-change="onPageChange"
|
||||
@size-change="onSizeChange"
|
||||
/>
|
||||
</el-card>
|
||||
<!-- 分页落进 card-foot:与表体同一张卡,卡底微渐变收边 -->
|
||||
<div class="card-foot">
|
||||
<AppPagination
|
||||
:total="total"
|
||||
:page-num="pageNum"
|
||||
:page-size="pageSize"
|
||||
@page-change="onPageChange"
|
||||
@size-change="onSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 新增 / 编辑(业务必选 + 发货批次级联指定,可清空=暂不指定) -->
|
||||
<el-dialog
|
||||
@ -474,16 +503,7 @@ onMounted(() => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.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-code / .cell-num / .cell-muted)由全局 index.css 提供,此处不再重复定义 */
|
||||
|
||||
.op-wrap {
|
||||
display: inline-block;
|
||||
|
||||
@ -273,49 +273,75 @@ onMounted(() => {
|
||||
|
||||
<template>
|
||||
<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: 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"
|
||||
<!-- 页面头:标题 + 一句口径说明(发货日是回款天数的起算点)+ 签名水线 -->
|
||||
<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-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>
|
||||
</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>
|
||||
</div>
|
||||
</el-form>
|
||||
|
||||
<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>
|
||||
<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-column>
|
||||
<el-table-column label="发货日期" min-width="140">
|
||||
<template #default="{ row }">
|
||||
@ -329,12 +355,12 @@ onMounted(() => {
|
||||
<el-table-column label="运费(元)" min-width="110" align="right">
|
||||
<template #default="{ row }">
|
||||
<!-- 字符串直出:金额红线,禁 toFixed / float 格式化 -->
|
||||
<span class="cell-amount">{{ row.freight_amount }}</span>
|
||||
<span class="cell-num">{{ row.freight_amount }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="发货总额(元)" min-width="130" align="right">
|
||||
<template #default="{ row }">
|
||||
<span class="cell-amount">{{ row.total_amount }}</span>
|
||||
<span class="cell-num">{{ row.total_amount }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="130" fixed="right" align="center">
|
||||
@ -348,14 +374,17 @@ onMounted(() => {
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<AppPagination
|
||||
:total="total"
|
||||
:page-num="pageNum"
|
||||
:page-size="pageSize"
|
||||
@page-change="onPageChange"
|
||||
@size-change="onSizeChange"
|
||||
/>
|
||||
</el-card>
|
||||
<!-- 分页落进 card-foot:与表体同一张卡,卡底微渐变收边 -->
|
||||
<div class="card-foot">
|
||||
<AppPagination
|
||||
:total="total"
|
||||
:page-num="pageNum"
|
||||
:page-size="pageSize"
|
||||
@page-change="onPageChange"
|
||||
@size-change="onSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 新增 / 编辑(明细行编辑器;编辑不含 business_id,挂靠业务不可改) -->
|
||||
<el-dialog
|
||||
@ -450,16 +479,7 @@ onMounted(() => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.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;
|
||||
}
|
||||
/* 单元格口径(.cell-code / .cell-num / .cell-muted)由全局 index.css 提供,此处不再重复定义 */
|
||||
|
||||
.field-hint {
|
||||
display: block;
|
||||
@ -489,4 +509,5 @@ onMounted(() => {
|
||||
.tail-form {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@ -70,7 +70,20 @@ async function submitRecalc(): Promise<void> {
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<el-form inline class="filters" @submit.prevent>
|
||||
<!-- 页面头:标题 + 一句口径说明(台账是核算事实的固化点)+ 签名水线 -->
|
||||
<div class="page-head">
|
||||
<h1 class="page-title">绩效台账</h1>
|
||||
<p class="page-desc">
|
||||
每笔回款只核算一次;重算作废旧版本、按当前参数落新版本并留痕,展开行可看当次核算的参数快照
|
||||
</p>
|
||||
<span class="waterline"></span>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
.filter-bar 直接挂在 el-form 上(去掉 inline):el-form 不是 flex 透明容器,
|
||||
外面再包一层 div 会让 el-form-item 丢掉 gap 排版,筛选条退化成竖排。
|
||||
-->
|
||||
<el-form class="filter-bar" @submit.prevent>
|
||||
<el-form-item label="归属月">
|
||||
<el-input
|
||||
v-model="filters.settlement_month"
|
||||
@ -93,57 +106,90 @@ async function submitRecalc(): Promise<void> {
|
||||
<el-option label="仅历史版本" :value="0" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<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>
|
||||
</div>
|
||||
</el-form>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<AppPagination
|
||||
:total="total"
|
||||
:page-num="pageNum"
|
||||
:page-size="pageSize"
|
||||
@page-change="onPageChange"
|
||||
@size-change="onSizeChange"
|
||||
/>
|
||||
<!-- 分页落进 card-foot:与表体同一张卡,卡底微渐变收边 -->
|
||||
<div class="card-foot">
|
||||
<AppPagination
|
||||
:total="total"
|
||||
:page-num="pageNum"
|
||||
:page-size="pageSize"
|
||||
@page-change="onPageChange"
|
||||
@size-change="onSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-dialog v-model="recalcVisible" title="重算确认" width="460px">
|
||||
<p class="recalc-warn">
|
||||
@ -166,9 +212,8 @@ async function submitRecalc(): Promise<void> {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.filters {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
/* 筛选条(.filter-bar)、卡片与表尾(.card/.card-foot)的皮肤来自全局 index.css,
|
||||
此处只保留本页特有的快照块与重算提示 */
|
||||
|
||||
.snapshot {
|
||||
margin: 0;
|
||||
@ -186,4 +231,5 @@ async function submitRecalc(): Promise<void> {
|
||||
font-size: 12px;
|
||||
color: var(--ys-ink-3);
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@ -83,23 +83,53 @@ function renderCharts(): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 图表轴:设计稿 §05 的口径 —— 轴标签 9px 弱墨、无 y 网格线、无刻度,
|
||||
* 只留一条极淡的 x 轴线。字号必须显式给,ECharts 默认 12px 会把
|
||||
* 150px 高的图压满标签。
|
||||
*/
|
||||
function axisCommon(trend: TrendPoint[]) {
|
||||
return {
|
||||
xAxis: {
|
||||
type: 'category' as const,
|
||||
data: trend.map((item) => item.month.slice(5)),
|
||||
axisLine: { lineStyle: { color: 'rgba(28,35,51,.15)' } },
|
||||
axisTick: { show: false },
|
||||
axisLabel: { fontFamily: CHART_FONT, color: '#9aa3b2', fontSize: 9, margin: 6 },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value' as const,
|
||||
splitLine: { show: false },
|
||||
axisLabel: { fontFamily: CHART_FONT, color: '#9aa3b2', fontSize: 9, margin: 6 },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 竖向渐变(从上到下),设计稿要求柱体与折线面积都用「上亮下暗」的品牌渐变。
|
||||
* color-mix 与 canvas 渐变对象在 SVG renderer 下表现一致,故直接用 echarts 的
|
||||
* linear 渐变描述对象,不依赖 canvas API。
|
||||
*/
|
||||
function verticalGradient(top: string, bottom: string) {
|
||||
return {
|
||||
type: 'linear' as const,
|
||||
x: 0,
|
||||
y: 0,
|
||||
x2: 0,
|
||||
y2: 1,
|
||||
colorStops: [
|
||||
{ offset: 0, color: top },
|
||||
{ offset: 1, color: bottom },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
function performanceOption(trend: TrendPoint[]): EChartsCoreOption {
|
||||
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) },
|
||||
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' },
|
||||
},
|
||||
...axisCommon(trend),
|
||||
series: [
|
||||
{
|
||||
name: '绩效',
|
||||
@ -108,48 +138,35 @@ function performanceOption(trend: TrendPoint[]): EChartsCoreOption {
|
||||
symbol: 'none',
|
||||
data: trend.map((item) => Number(item.performance_total ?? 0)),
|
||||
lineStyle: { color: '#2e4ba8', width: 2 },
|
||||
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)' },
|
||||
],
|
||||
},
|
||||
},
|
||||
areaStyle: { color: verticalGradient('rgba(46,75,168,.22)', '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) },
|
||||
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' },
|
||||
},
|
||||
...axisCommon(trend),
|
||||
series: [
|
||||
{
|
||||
name: '回款',
|
||||
type: 'bar',
|
||||
data: trend.map((item) => Number(item.receipt_total ?? 0)),
|
||||
barMaxWidth: 22,
|
||||
itemStyle: { color: '#2e4ba8', borderRadius: [4, 4, 0, 0] },
|
||||
data: trend.map((item, index) => ({
|
||||
value: Number(item.receipt_total ?? 0),
|
||||
itemStyle: {
|
||||
color:
|
||||
index === lastIndex
|
||||
? verticalGradient('#3a58bc', '#2e4ba8')
|
||||
: verticalGradient('#d5def3', '#e8edf8'),
|
||||
borderRadius: [3, 3, 0, 0],
|
||||
},
|
||||
})),
|
||||
},
|
||||
],
|
||||
}
|
||||
@ -383,42 +400,7 @@ function formatTime(value: string): string {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.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);
|
||||
}
|
||||
/* 页面头(.page-head/.page-title/.page-desc/.waterline)、.toolbar 均来自全局 index.css */
|
||||
|
||||
.bento {
|
||||
display: grid;
|
||||
@ -429,22 +411,52 @@ function formatTime(value: string): string {
|
||||
|
||||
.bento-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 16px 18px;
|
||||
background: linear-gradient(180deg, #ffffff, #fcfcfe);
|
||||
border: 1px solid var(--ys-line);
|
||||
border-radius: 10px;
|
||||
box-shadow:
|
||||
0 1px 2px rgba(28, 35, 51, 0.04),
|
||||
0 10px 24px -18px rgba(28, 35, 51, 0.18);
|
||||
transition: transform 0.18s ease, box-shadow 0.18s ease;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(180deg, var(--ys-paper), #fcfcfe);
|
||||
border: 1px solid var(--ys-line);
|
||||
border-radius: var(--ys-radius);
|
||||
box-shadow: var(--ys-shadow-card);
|
||||
transition:
|
||||
transform 180ms var(--ys-ease),
|
||||
box-shadow 180ms var(--ys-ease);
|
||||
/* 交错入场:45ms 一檔,卡片依次上浮,避免整屏同时闪一下 */
|
||||
animation: ys-rise 320ms var(--ys-ease) backwards;
|
||||
}
|
||||
|
||||
.bento > :nth-child(2) {
|
||||
animation-delay: 45ms;
|
||||
}
|
||||
|
||||
.bento > :nth-child(3) {
|
||||
animation-delay: 90ms;
|
||||
}
|
||||
|
||||
.bento > :nth-child(4) {
|
||||
animation-delay: 135ms;
|
||||
}
|
||||
|
||||
.bento > :nth-child(5) {
|
||||
animation-delay: 180ms;
|
||||
}
|
||||
|
||||
.bento > :nth-child(6) {
|
||||
animation-delay: 225ms;
|
||||
}
|
||||
|
||||
.bento > :nth-child(7) {
|
||||
animation-delay: 270ms;
|
||||
}
|
||||
|
||||
.bento > :nth-child(8) {
|
||||
animation-delay: 315ms;
|
||||
}
|
||||
|
||||
.bento-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow:
|
||||
0 2px 4px rgba(28, 35, 51, 0.05),
|
||||
0 16px 30px -18px rgba(28, 35, 51, 0.22);
|
||||
box-shadow: var(--ys-shadow-raise);
|
||||
}
|
||||
|
||||
.b-2x2 {
|
||||
@ -501,10 +513,19 @@ 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 {
|
||||
@ -583,7 +604,7 @@ function formatTime(value: string): string {
|
||||
}
|
||||
|
||||
.activity-time {
|
||||
width: 118px;
|
||||
width: 108px;
|
||||
color: var(--ys-ink-3);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
@ -608,14 +629,23 @@ 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: 6px;
|
||||
cursor: pointer;
|
||||
border-radius: var(--ys-radius-sm);
|
||||
transition:
|
||||
border-color 150ms var(--ys-ease),
|
||||
background-color 150ms var(--ys-ease);
|
||||
}
|
||||
|
||||
.b-op:hover {
|
||||
border-color: var(--ys-indigo);
|
||||
background: #e6ecf9;
|
||||
border-color: rgba(46, 75, 168, 0.35);
|
||||
}
|
||||
|
||||
.b-op:focus-visible {
|
||||
outline: 2px solid var(--ys-indigo);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
|
||||
@ -217,11 +217,11 @@ onMounted(() => {
|
||||
position: absolute;
|
||||
top: -180px;
|
||||
left: 50%;
|
||||
width: 900px;
|
||||
height: 480px;
|
||||
width: 860px;
|
||||
height: 460px;
|
||||
transform: translateX(-50%);
|
||||
pointer-events: none;
|
||||
background: radial-gradient(closest-side, rgba(46, 75, 168, 0.09), rgba(46, 75, 168, 0));
|
||||
background: radial-gradient(closest-side, rgba(46, 75, 168, 0.08), rgba(46, 75, 168, 0));
|
||||
}
|
||||
|
||||
/* 右上涟漪:同心圆水波,部分出画 */
|
||||
@ -229,8 +229,8 @@ onMounted(() => {
|
||||
position: absolute;
|
||||
top: -190px;
|
||||
right: -150px;
|
||||
width: 560px;
|
||||
height: 560px;
|
||||
width: 520px;
|
||||
height: 520px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@ -242,17 +242,17 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.login-ripple i:nth-child(2) {
|
||||
inset: 70px;
|
||||
inset: 65px;
|
||||
border-color: rgba(46, 75, 168, 0.075);
|
||||
}
|
||||
|
||||
.login-ripple i:nth-child(3) {
|
||||
inset: 140px;
|
||||
inset: 130px;
|
||||
border-color: rgba(46, 75, 168, 0.055);
|
||||
}
|
||||
|
||||
.login-ripple i:nth-child(4) {
|
||||
inset: 210px;
|
||||
inset: 195px;
|
||||
border: none;
|
||||
background: radial-gradient(circle, rgba(46, 75, 168, 0.05), rgba(46, 75, 168, 0.015));
|
||||
}
|
||||
@ -266,6 +266,12 @@ onMounted(() => {
|
||||
|
||||
/* 登录卡:纸白微渐变 + 半透明描边 + 分层阴影 + 入场上浮 */
|
||||
.login-card {
|
||||
/*
|
||||
* 卡内控件比列表页高一档(38px):登录是单任务场景,输入框更宽敞可减少误触;
|
||||
* 覆盖组件基准变量,让输入框/按钮/验证码框一起长高,避免逐个子选择器硬编码。
|
||||
*/
|
||||
--el-component-size: 38px;
|
||||
|
||||
width: 400px;
|
||||
max-width: 100%;
|
||||
padding: 30px 36px 32px;
|
||||
@ -307,20 +313,16 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.card-brand__slogan {
|
||||
margin-top: 8px;
|
||||
margin: 8px 0 0;
|
||||
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 {
|
||||
@ -361,14 +363,14 @@ onMounted(() => {
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 120px;
|
||||
height: 40px;
|
||||
width: 104px;
|
||||
height: 38px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
background: linear-gradient(180deg, #f7f8fb, #f1f3f8);
|
||||
border: 1px solid var(--ys-line);
|
||||
border-radius: 7px;
|
||||
border-radius: var(--ys-radius-sm);
|
||||
box-shadow: inset 0 1px 2px rgba(28, 35, 51, 0.04);
|
||||
transition: border-color 150ms var(--ys-ease, ease);
|
||||
}
|
||||
@ -405,16 +407,7 @@ onMounted(() => {
|
||||
color: var(--ys-ink-3);
|
||||
}
|
||||
|
||||
/* 输入聚焦落在体系主色上(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);
|
||||
}
|
||||
/* 输入框的描边/焦点环已由全局 index.css 统一(登录卡只把控件高度调到 38px) */
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.login {
|
||||
|
||||
@ -155,7 +155,20 @@ function today(): string {
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<el-form inline class="filters" @submit.prevent>
|
||||
<!-- 页面头:一句话说清这张报表回答什么问题、口径是什么;水线每页只出现一次 -->
|
||||
<div class="page-head">
|
||||
<h1 class="page-title">业务统计</h1>
|
||||
<p class="page-desc">
|
||||
按业务看发货、回款与绩效的累计进度;回款与绩效是累计值,不随业务日期筛选变化
|
||||
</p>
|
||||
<span class="waterline"></span>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
.filter-bar 直接挂在 el-form 上(去掉 inline):el-form 本身就是 flex 容器,
|
||||
外面再包一层 div 会让 el-form-item 的 gap 排版失效。
|
||||
-->
|
||||
<el-form class="filter-bar" @submit.prevent>
|
||||
<el-form-item label="关键字">
|
||||
<el-input
|
||||
v-model="filters.keyword"
|
||||
@ -218,103 +231,151 @@ function today(): string {
|
||||
style="width: 240px"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<div class="filter-bar__actions">
|
||||
<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>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
|
||||
<div class="hint">回款额与绩效为业务累计值,不随业务日期筛选变化</div>
|
||||
<!-- 表格包进卡片:报表页的信息密度靠卡片边界收拢,分页条共用卡脚材质 -->
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<!-- 合计与分页共用卡脚:合计靠 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>
|
||||
|
||||
<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>
|
||||
<!-- AppPagination 只派发 page-change/size-change(无 update:pageNum) -->
|
||||
<AppPagination
|
||||
:total="total"
|
||||
:page-num="pageNum"
|
||||
:page-size="pageSize"
|
||||
@page-change="onPageChange"
|
||||
@size-change="onSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</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>
|
||||
.filters {
|
||||
margin-bottom: 4px;
|
||||
/*
|
||||
* 密集表(设计稿 §09-3):报表要的是信息密度,行高与字号各降半档。
|
||||
* 只写在页面 scoped 里 —— 写进全局会波及其它列表页的行高。
|
||||
*/
|
||||
.dense :deep(.el-table__cell) {
|
||||
padding: 7px 0;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin-bottom: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--ys-ink-3);
|
||||
.dense :deep(.el-table__cell .cell) {
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.drill-wrap {
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
.drill-wrap h4 {
|
||||
margin: 8px 0;
|
||||
font-size: 13px;
|
||||
/* 绩效列加粗:设计稿要求「密集不等于看不清」 */
|
||||
.cell-strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 展开区:两块下钻表跟着密集表收内距,字号由 size="small" 与 .dense 决定 */
|
||||
.drill-wrap {
|
||||
padding: 10px 14px 6px;
|
||||
}
|
||||
|
||||
.drill-wrap .section-title {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* 合计条:比正文加粗、字号放大半档,读者一眼能把「合计」与数据行分开 */
|
||||
.summary-bar {
|
||||
margin-top: 10px;
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
background: var(--ys-indigo-wash);
|
||||
border-radius: 6px;
|
||||
margin-right: auto;
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
color: var(--ys-ink);
|
||||
}
|
||||
|
||||
.summary-note {
|
||||
color: var(--ys-ink-3);
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: var(--ys-ink-3);
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -135,7 +135,20 @@ function pct(value: string): string {
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<el-form inline class="filters" @submit.prevent>
|
||||
<!-- 页面头:一句话说清这张报表回答什么问题、口径是什么;水线每页只出现一次 -->
|
||||
<div class="page-head">
|
||||
<h1 class="page-title">部门绩效汇总</h1>
|
||||
<p class="page-desc">
|
||||
按经理团队汇总当月绩效与应付;团队/全公司分成为月结同源口径,可与冻结额对账
|
||||
</p>
|
||||
<span class="waterline"></span>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
.filter-bar 直接挂在 el-form 上(去掉 inline):el-form 本身就是 flex 容器,
|
||||
外面再包一层 div 会让 el-form-item 的 gap 排版失效。
|
||||
-->
|
||||
<el-form class="filter-bar" @submit.prevent>
|
||||
<el-form-item label="统计月份" required>
|
||||
<el-date-picker
|
||||
v-model="filters.settlement_month"
|
||||
@ -164,17 +177,20 @@ function pct(value: string): string {
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<div class="filter-bar__actions">
|
||||
<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>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
|
||||
<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 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>
|
||||
|
||||
<div v-loading="loading" class="company-card">
|
||||
@ -191,83 +207,149 @@ function pct(value: string): string {
|
||||
</span>
|
||||
</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 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>
|
||||
|
||||
<div v-if="data && data.unassigned.length" class="unassigned">
|
||||
<h4>未纳入团队基数(无经理 / 非营销经理上级的下属)</h4>
|
||||
<p class="section-title">未纳入团队基数(无经理 / 非营销经理上级的下属)</p>
|
||||
<el-table :data="data.unassigned" size="small" stripe>
|
||||
<el-table-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" />
|
||||
<el-table-column prop="net_total" label="净合计" width="130" align="right" />
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.filters {
|
||||
margin-bottom: 4px;
|
||||
/*
|
||||
* 密集表(设计稿 §09-3):报表要的是信息密度,行高与字号各降半档。
|
||||
* 只写在页面 scoped 里 —— 写进全局会波及其它列表页的行高。
|
||||
*/
|
||||
.dense :deep(.el-table__cell) {
|
||||
padding: 7px 0;
|
||||
}
|
||||
|
||||
.params-hint {
|
||||
margin-bottom: 10px;
|
||||
padding: 8px 12px;
|
||||
font-size: 12px;
|
||||
color: var(--ys-ink-2);
|
||||
background: var(--ys-mist);
|
||||
border-radius: 6px;
|
||||
.dense :deep(.el-table__cell .cell) {
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
/* 绩效列加粗:设计稿要求「密集不等于看不清」 */
|
||||
.cell-strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.company-card {
|
||||
@ -277,7 +359,8 @@ function pct(value: string): string {
|
||||
margin-bottom: 12px;
|
||||
padding: 12px 16px;
|
||||
background: var(--ys-indigo-wash);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--ys-line);
|
||||
border-radius: var(--ys-radius);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@ -290,8 +373,9 @@ function pct(value: string): string {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 展开区:内容字号与内距跟着密集表再收半档,不与主表抢注意力 */
|
||||
.member-wrap {
|
||||
padding: 8px 16px;
|
||||
padding: 8px 14px;
|
||||
}
|
||||
|
||||
.frozen {
|
||||
@ -306,10 +390,4 @@ function pct(value: string): string {
|
||||
.unassigned {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.unassigned h4 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -161,7 +161,20 @@ function today(): string {
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<el-form inline class="filters" @submit.prevent>
|
||||
<!-- 页面头:一句话说清这张报表回答什么问题、口径是什么;水线每页只出现一次 -->
|
||||
<div class="page-head">
|
||||
<h1 class="page-title">回款统计</h1>
|
||||
<p class="page-desc">
|
||||
按回款天数区间看回款结构与绩效分布;只统计已计提(有台账行)的回款,空区间记 0
|
||||
</p>
|
||||
<span class="waterline"></span>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
.filter-bar 直接挂在 el-form 上(去掉 inline):el-form 本身就是 flex 容器,
|
||||
外面再包一层 div 会让 el-form-item 的 gap 排版失效。
|
||||
-->
|
||||
<el-form class="filter-bar" @submit.prevent>
|
||||
<el-form-item label="月份">
|
||||
<el-date-picker
|
||||
v-model="filters.settlement_month"
|
||||
@ -205,94 +218,148 @@ function today(): string {
|
||||
<el-option label="混合业务" value="mixed" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<div class="filter-bar__actions">
|
||||
<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>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
|
||||
<div class="hint">
|
||||
统计口径:已计提回款(有台账行)的金额与绩效;进度登记回款不计入。空区间显示 0。
|
||||
</div>
|
||||
<!-- 表格包进卡片:报表页的信息密度靠卡片边界收拢,合计行共用卡脚材质 -->
|
||||
<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>
|
||||
|
||||
<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 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>
|
||||
</div>
|
||||
|
||||
<div v-if="data" class="dimension-grid">
|
||||
<div>
|
||||
<h4>客户类型对比</h4>
|
||||
<p class="section-title">客户类型对比</p>
|
||||
<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" />
|
||||
<el-table-column prop="performance_amount" label="绩效金额" width="130" align="right" />
|
||||
<el-table-column prop="ledger_count" label="台账行数" width="100" align="right" />
|
||||
<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>
|
||||
</div>
|
||||
<div>
|
||||
<h4>产品类别对比</h4>
|
||||
<p class="section-title">产品类别对比</p>
|
||||
<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" />
|
||||
<el-table-column prop="performance_amount" label="绩效金额" width="130" align="right" />
|
||||
<el-table-column prop="ledger_count" label="台账行数" width="100" align="right" />
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@ -300,26 +367,34 @@ function today(): string {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.filters {
|
||||
margin-bottom: 4px;
|
||||
/*
|
||||
* 密集表(设计稿 §09-3):报表要的是信息密度,行高与字号各降半档。
|
||||
* 只写在页面 scoped 里 —— 写进全局会波及其它列表页的行高。
|
||||
*/
|
||||
.dense :deep(.el-table__cell) {
|
||||
padding: 7px 0;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin-bottom: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--ys-ink-3);
|
||||
.dense :deep(.el-table__cell .cell) {
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
/* 绩效列加粗:设计稿要求「密集不等于看不清」 */
|
||||
.cell-strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 展开区:内距跟着密集表收半档,字号由 size="small" 与 .dense 决定 */
|
||||
.drill-wrap {
|
||||
padding: 8px 16px;
|
||||
padding: 10px 14px 6px;
|
||||
}
|
||||
|
||||
/* 合计条:比正文加粗、字号放大半档,读者一眼能把「合计」与数据行分开 */
|
||||
.summary-bar {
|
||||
margin-top: 10px;
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
background: var(--ys-indigo-wash);
|
||||
border-radius: 6px;
|
||||
margin-right: auto;
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
color: var(--ys-ink);
|
||||
}
|
||||
|
||||
.dimension-grid {
|
||||
@ -328,10 +403,4 @@ function today(): string {
|
||||
gap: 20px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.dimension-grid h4 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -170,7 +170,18 @@ function today(): string {
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<el-form inline class="filters" @submit.prevent>
|
||||
<!-- 页面头:一句话说清这张报表回答什么问题、口径是什么;水线每页只出现一次 -->
|
||||
<div class="page-head">
|
||||
<h1 class="page-title">个人绩效明细</h1>
|
||||
<p class="page-desc">按人拆到每一笔回款;绩效 = 回款 × 计提比例 × 权重</p>
|
||||
<span class="waterline"></span>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
.filter-bar 直接挂在 el-form 上(去掉 inline):el-form 本身就是 flex 容器,
|
||||
外面再包一层 div 会让 el-form-item 的 gap 排版失效。
|
||||
-->
|
||||
<el-form class="filter-bar" @submit.prevent>
|
||||
<el-form-item label="月份">
|
||||
<el-date-picker
|
||||
v-model="filters.settlement_month"
|
||||
@ -229,103 +240,165 @@ function today(): string {
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<div class="filter-bar__actions">
|
||||
<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>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
|
||||
<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>
|
||||
<!-- 表格包进卡片:报表页的信息密度靠卡片边界收拢,分页条共用卡脚材质 -->
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<!-- 合计与分页共用卡脚:合计靠 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>
|
||||
|
||||
<!-- 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>
|
||||
.filters {
|
||||
margin-bottom: 4px;
|
||||
/*
|
||||
* 密集表(设计稿 §09-3):报表要的是信息密度,行高与字号各降半档。
|
||||
* 只写在页面 scoped 里 —— 写进全局会波及其它列表页的行高。
|
||||
*/
|
||||
.dense :deep(.el-table__cell) {
|
||||
padding: 7px 0;
|
||||
}
|
||||
|
||||
.dense :deep(.el-table__cell .cell) {
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
/*
|
||||
* 金额/比例 span 变块级:行内元素的 text-align 无效,只有块级才真正右对齐;
|
||||
* 表头保持左对齐(若改用列 align 会连表头一起右移,与设计稿不符)。
|
||||
*/
|
||||
.cell-num {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* 绩效列加粗:设计稿要求「密集不等于看不清」 */
|
||||
.cell-strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.snapshot {
|
||||
margin: 0;
|
||||
padding: 10px;
|
||||
font-size: 12px;
|
||||
padding: 12px 14px;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.6;
|
||||
background: #f4f5f8;
|
||||
border-radius: 6px;
|
||||
background: var(--ys-mist);
|
||||
border-radius: var(--ys-radius-sm);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.negative {
|
||||
color: #c45656;
|
||||
color: var(--ys-danger);
|
||||
}
|
||||
|
||||
/* 合计条:比正文加粗、字号放大半档,读者一眼能把「合计」与数据行分开 */
|
||||
.summary-bar {
|
||||
margin-top: 10px;
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
background: var(--ys-indigo-wash);
|
||||
border-radius: 6px;
|
||||
margin-right: auto;
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
color: var(--ys-ink);
|
||||
}
|
||||
|
||||
.summary-note {
|
||||
color: var(--ys-ink-3);
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: var(--ys-ink-3);
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -171,387 +171,485 @@ onMounted(load)
|
||||
|
||||
<template>
|
||||
<div class="print-page" v-loading="loading">
|
||||
<div class="no-print toolbar">
|
||||
<el-button type="primary" @click="doPrint">打印 / 另存为 PDF</el-button>
|
||||
<span class="toolbar-hint">在打印对话框选择「另存为 PDF」即可导出 PDF</span>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<!-- 屏幕操作条:打印时随 .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>
|
||||
</header>
|
||||
|
||||
<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>
|
||||
<!-- 稿纸:与屏幕稿不同源 —— 白底、实线表格、章节编号、签字栏,无圆角与阴影 -->
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<div v-if="truncated" class="truncated">
|
||||
数据量超过打印上限({{ PRINT_MAX_ROWS }} 行),本页只含前 {{ PRINT_MAX_ROWS }}
|
||||
行;请缩小筛选范围后重新打印。
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<template v-if="code === 'personal-detail'">
|
||||
<table class="sheet-table">
|
||||
<thead>
|
||||
<tr><th>维度</th><th>回款金额</th><th>绩效金额</th><th>台账行数</th></tr>
|
||||
<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>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in group.rows" :key="String(row.code)">
|
||||
<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)">
|
||||
<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>
|
||||
</template>
|
||||
<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>
|
||||
|
||||
<footer class="signature">
|
||||
<div>财务复核:____________________</div>
|
||||
<div>领导签字:____________________</div>
|
||||
<div>日期:______年__月__日</div>
|
||||
</footer>
|
||||
<!-- 三栏签字:制表 / 复核 / 审批,栏位用上横线表示手写位置 -->
|
||||
<footer class="sign-row">
|
||||
<div>制表:____________</div>
|
||||
<div>复核:____________</div>
|
||||
<div>审批:____________</div>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/*
|
||||
* 打印视图:屏幕上是「灰画布 + 白稿纸」,打印时退化为纯纸面。
|
||||
* 画布灰比内容页沉一档,让白稿纸一眼是「一张纸」而不是页面里的一个区块。
|
||||
*/
|
||||
.print-page {
|
||||
min-height: 100vh;
|
||||
padding: 24px;
|
||||
background: #fff;
|
||||
color: #1c2333;
|
||||
padding-bottom: 28px;
|
||||
background: #eceef2;
|
||||
color: var(--ys-ink);
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
/* 屏幕操作条:只在屏幕上出现,纸面上不出现任何按钮 */
|
||||
.print-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 52px;
|
||||
padding: 0 22px;
|
||||
margin-bottom: 22px;
|
||||
background: linear-gradient(180deg, #ffffff, #fdfdfe);
|
||||
border-bottom: 1px solid var(--ys-line);
|
||||
}
|
||||
|
||||
.print-bar__crumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--ys-ink-3);
|
||||
}
|
||||
|
||||
.print-bar__crumb b {
|
||||
font-weight: 600;
|
||||
color: var(--ys-ink);
|
||||
}
|
||||
|
||||
.print-bar__sep {
|
||||
color: var(--ys-line);
|
||||
}
|
||||
|
||||
.print-bar__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.toolbar-hint {
|
||||
.print-bar__hint {
|
||||
font-size: 12px;
|
||||
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: 48px;
|
||||
margin-bottom: 6px;
|
||||
max-height: 44px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.company {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.18em;
|
||||
color: var(--ys-ink-2);
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 6px 0;
|
||||
font-size: 20px;
|
||||
margin: 4px 0 0;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
color: #5b6577;
|
||||
.sheet-meta {
|
||||
margin-top: 4px;
|
||||
font-size: 11px;
|
||||
color: var(--ys-ink-3);
|
||||
}
|
||||
|
||||
/* 截断提示:纸面上也要让人一眼看出「这不是全部数据」 */
|
||||
.truncated {
|
||||
margin-bottom: 10px;
|
||||
padding: 8px 12px;
|
||||
font-size: 12px;
|
||||
margin-top: 12px;
|
||||
padding: 6px 10px;
|
||||
font-size: 11px;
|
||||
color: #8a5a00;
|
||||
background: #fdf6e3;
|
||||
border-radius: 4px;
|
||||
background: #fbf4e4;
|
||||
border: 1px solid #f0e2c4;
|
||||
}
|
||||
|
||||
/*
|
||||
* 稿面表格:纸面口径 —— 11px、细实线 #d9dde5、表头浅底左对齐。
|
||||
* th 一律左对齐,只有数值列右对齐(与数据行同向),否则表头与数字错位。
|
||||
*/
|
||||
.sheet-table {
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
font-size: 11px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.sheet-table th,
|
||||
.sheet-table td {
|
||||
padding: 6px 8px;
|
||||
border: 1px solid #b0b7c3;
|
||||
padding: 4px 7px;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
border: 1px solid #d9dde5;
|
||||
}
|
||||
|
||||
.sheet-table th {
|
||||
background: #dde3f0;
|
||||
font-weight: 600;
|
||||
background: #f6f7f9;
|
||||
font-weight: 500;
|
||||
color: var(--ys-ink-2);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sheet-table .num {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.block-title {
|
||||
margin: 18px 0 6px;
|
||||
font-size: 14px;
|
||||
margin: 14px 0 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.group-block {
|
||||
margin-top: 18px;
|
||||
.group-block h3 {
|
||||
margin: 12px 0 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--ys-ink-2);
|
||||
}
|
||||
|
||||
.signature {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 40px;
|
||||
padding-top: 16px;
|
||||
font-size: 13px;
|
||||
/* 三栏签字:上横线代替手写下划线,栏宽等分 */
|
||||
.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);
|
||||
}
|
||||
|
||||
@media print {
|
||||
@ -559,8 +657,17 @@ onMounted(load)
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* 打印时画布与稿纸合并成一张纸:外层灰底、定宽、内距都是屏幕专有 */
|
||||
.print-page {
|
||||
padding: 0;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.sheet {
|
||||
width: auto;
|
||||
max-width: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@page {
|
||||
@ -575,5 +682,10 @@ onMounted(load)
|
||||
.sheet-table tr {
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
/* 浏览器默认不打印背景色,表头浅底会丢失,这里强制保留 */
|
||||
.sheet-table th {
|
||||
print-color-adjust: exact;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -7,7 +7,6 @@ import {
|
||||
getSettlement,
|
||||
listSettlements,
|
||||
settlementStatusLabel,
|
||||
settlementStatusTagType,
|
||||
submitSettlement,
|
||||
type Settlement,
|
||||
type SettlementDetail,
|
||||
@ -39,6 +38,39 @@ 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)
|
||||
|
||||
@ -96,21 +128,51 @@ async function handleSubmit(row: Settlement): Promise<void> {
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="toolbar">
|
||||
<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 class="page-head">
|
||||
<h1 class="page-title">月度结算</h1>
|
||||
<p class="page-desc">
|
||||
按月生成绩效结算批次;送审后锁定,人数与金额不再随源数据变化
|
||||
</p>
|
||||
<span class="waterline"></span>
|
||||
</div>
|
||||
|
||||
<el-form inline class="filters" @submit.prevent>
|
||||
<!--
|
||||
月结生命周期条:用 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>
|
||||
|
||||
<!--
|
||||
.filter-bar 直接挂到 el-form 上:el-form 不能当 flex 透明容器,
|
||||
外面再包一层 div 会让 el-form-item 的 gap 排版失效。
|
||||
-->
|
||||
<el-form class="filter-bar" @submit.prevent>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="filters.status" placeholder="全部" clearable style="width: 150px">
|
||||
<el-option label="草稿" :value="1" />
|
||||
@ -119,68 +181,102 @@ async function handleSubmit(row: Settlement): Promise<void> {
|
||||
<el-option label="已驳回" :value="4" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<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>
|
||||
</div>
|
||||
</el-form>
|
||||
|
||||
<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">
|
||||
<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>
|
||||
|
||||
<AppPagination
|
||||
:total="total"
|
||||
:page-num="pageNum"
|
||||
:page-size="pageSize"
|
||||
@page-change="onPageChange"
|
||||
@size-change="onSizeChange"
|
||||
/>
|
||||
<div class="card-foot">
|
||||
<AppPagination
|
||||
:total="total"
|
||||
:page-num="pageNum"
|
||||
:page-size="pageSize"
|
||||
@page-change="onPageChange"
|
||||
@size-change="onSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<el-tag :type="settlementStatusTagType(detail.status)" size="small" disable-transitions>
|
||||
<span class="ys-pill" :class="statusPillClass(detail.status)">
|
||||
{{ settlementStatusLabel(detail.status) }}
|
||||
</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>
|
||||
</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">
|
||||
@ -188,17 +284,32 @@ async function handleSubmit(row: Settlement): Promise<void> {
|
||||
{{ row.calc_type === 1 ? '单笔回款' : '经理聚合' }}
|
||||
</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="绩效" 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 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="hint">
|
||||
|
||||
<p v-if="detail.status === 1 || detail.status === 4" class="drawer-hint">
|
||||
草稿/已驳回状态可重新生成快照后再次提交。
|
||||
</p>
|
||||
<p v-else-if="detail.status === 3" class="hint">已锁定:源数据改动只产生提示与调整单。</p>
|
||||
<p v-else-if="detail.status === 3" class="drawer-hint">
|
||||
已锁定:源数据改动只产生提示与调整单。
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
</el-drawer>
|
||||
@ -206,19 +317,100 @@ async function handleSubmit(row: Settlement): Promise<void> {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.toolbar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: 12px;
|
||||
/*
|
||||
* 金额列右对齐:全局 .cell-num 是给 td 设计的,这里只能包 span,
|
||||
* 行内元素的 text-align 无效,所以把 span 变块级让对齐真正生效;
|
||||
* 表头保持左对齐,与设计稿一致(列 align="right" 会连表头一起右移)。
|
||||
*/
|
||||
.cell-num {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.gen-row {
|
||||
/* 生命周期条:横向分段,相邻段共用描边(border-left:0)避免双线 */
|
||||
.flow-step {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.filters {
|
||||
margin-bottom: 4px;
|
||||
.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);
|
||||
}
|
||||
|
||||
/* 未选中时四段必须读起来仍是「一条流程」:给右侧挂一行说明,
|
||||
高度与分段齐平,避免出现四颗孤立小按钮的观感 */
|
||||
.flow-step__hint {
|
||||
margin-left: 10px;
|
||||
font-size: 12px;
|
||||
color: var(--ys-ink-3);
|
||||
}
|
||||
|
||||
.flow-step__item:first-child {
|
||||
border-radius: var(--ys-radius-sm) 0 0 var(--ys-radius-sm);
|
||||
}
|
||||
|
||||
.flow-step__item:last-child {
|
||||
border-radius: 0 var(--ys-radius-sm) var(--ys-radius-sm) 0;
|
||||
}
|
||||
|
||||
.flow-step__item + .flow-step__item {
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
.flow-step__item:hover {
|
||||
color: var(--ys-ink);
|
||||
}
|
||||
|
||||
.flow-step__item:focus-visible {
|
||||
outline: 2px solid rgba(46, 75, 168, 0.3);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.flow-step__dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--ys-ink-3);
|
||||
}
|
||||
|
||||
/* 圆点按阶段着色:未选中时也能看出四段各自代表什么(颜色之外仍有文字) */
|
||||
.flow-step__dot--wait {
|
||||
background: var(--ys-warn);
|
||||
}
|
||||
|
||||
.flow-step__dot--lock {
|
||||
background: var(--ys-indigo);
|
||||
}
|
||||
|
||||
.flow-step__dot--reject {
|
||||
background: var(--ys-danger);
|
||||
}
|
||||
|
||||
.flow-step__item--cur {
|
||||
font-weight: 500;
|
||||
color: var(--ys-indigo);
|
||||
background: var(--ys-indigo-wash);
|
||||
border-color: rgba(46, 75, 168, 0.3);
|
||||
}
|
||||
|
||||
.flow-step__item--cur .flow-step__dot {
|
||||
background: var(--ys-indigo);
|
||||
}
|
||||
|
||||
.detail-head {
|
||||
@ -228,22 +420,38 @@ async function handleSubmit(row: Settlement): Promise<void> {
|
||||
}
|
||||
|
||||
.detail-month {
|
||||
font-size: 15px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--ys-ink);
|
||||
}
|
||||
|
||||
.detail-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 10px 0;
|
||||
/* 分块标题之间需要上间距;不改全局 .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 {
|
||||
font-size: 12px;
|
||||
color: var(--ys-ink-3);
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin-top: 12px;
|
||||
.kv dd {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--ys-ink);
|
||||
}
|
||||
|
||||
.drawer-hint {
|
||||
margin: 14px 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--ys-ink-3);
|
||||
}
|
||||
|
||||
@ -5,7 +5,6 @@ import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
actApproval,
|
||||
approvalStatusLabel,
|
||||
approvalStatusTagType,
|
||||
getApproval,
|
||||
listApprovals,
|
||||
orderTypeLabel,
|
||||
@ -62,6 +61,18 @@ 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)
|
||||
@ -156,7 +167,19 @@ async function submitAction(): Promise<void> {
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<el-form inline class="filters" @submit.prevent>
|
||||
<div class="page-head">
|
||||
<h1 class="page-title">审批管理</h1>
|
||||
<p class="page-desc">
|
||||
二次分配方案与月度结算的送审复核;送审内容为快照,与源单据当前值无关
|
||||
</p>
|
||||
<span class="waterline"></span>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
.filter-bar 直接挂到 el-form 上:再包一层 div 会让 el-form-item
|
||||
失去 gap 排版(el-form 不是 flex 透明容器)。
|
||||
-->
|
||||
<el-form class="filter-bar" @submit.prevent>
|
||||
<el-form-item label="关键词">
|
||||
<el-input
|
||||
v-model="filters.keyword"
|
||||
@ -182,70 +205,92 @@ async function submitAction(): Promise<void> {
|
||||
<el-option label="调整单" :value="3" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<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>
|
||||
</div>
|
||||
</el-form>
|
||||
|
||||
<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">
|
||||
<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>
|
||||
|
||||
<AppPagination
|
||||
:total="total"
|
||||
:page-num="pageNum"
|
||||
:page-size="pageSize"
|
||||
@page-change="onPageChange"
|
||||
@size-change="onSizeChange"
|
||||
/>
|
||||
<div class="card-foot">
|
||||
<AppPagination
|
||||
:total="total"
|
||||
:page-num="pageNum"
|
||||
:page-size="pageSize"
|
||||
@page-change="onPageChange"
|
||||
@size-change="onSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-drawer v-model="detailVisible" title="审批详情" size="720px">
|
||||
<div v-loading="detailLoading">
|
||||
<template v-if="detail">
|
||||
<div class="detail-head">
|
||||
<span class="detail-no">{{ detail.order_no }}</span>
|
||||
<el-tag
|
||||
:type="approvalStatusTagType(detail.status)"
|
||||
size="small"
|
||||
disable-transitions
|
||||
>
|
||||
<div class="panel-title">
|
||||
<span class="panel-title__no">{{ detail.order_no }}</span>
|
||||
<span class="ys-pill" :class="approvalPillClass(detail.status)">
|
||||
{{ approvalStatusLabel(detail.status) }}
|
||||
</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>
|
||||
</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">
|
||||
<div class="content-title">审批内容</div>
|
||||
<p class="section-title">审批内容</p>
|
||||
<!-- 采集时间让复核者明确「看到的是哪个时点的内容」:快照=送审时刻;
|
||||
live=打开详情时的当前内容(不做时刻展示——渲染函数里的
|
||||
new Date() 非响应式且 toISOString 是 UTC,会差 8 小时) -->
|
||||
@ -271,30 +316,26 @@ async function submitAction(): Promise<void> {
|
||||
/>
|
||||
|
||||
<template v-if="allocationData">
|
||||
<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>
|
||||
<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-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" align="right">
|
||||
<template #default="{ row }">{{ row.ratio }}%</template>
|
||||
<el-table-column label="分配比例" width="120">
|
||||
<template #default="{ row }">
|
||||
<span class="cell-num">{{ row.ratio }}%</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
<span class="content-empty">未设置二次分配 —— 全部绩效归负责营销人员</span>
|
||||
@ -307,60 +348,63 @@ async function submitAction(): Promise<void> {
|
||||
</template>
|
||||
|
||||
<template v-else-if="settlementData">
|
||||
<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>
|
||||
<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-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="110" align="right">
|
||||
<template #default="{ row }">{{ row.performance_sum }}</template>
|
||||
<el-table-column label="绩效(元)" width="120">
|
||||
<template #default="{ row }">
|
||||
<span class="cell-num">{{ row.performance_sum }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="应付(元)" width="110" align="right">
|
||||
<template #default="{ row }">{{ row.payable_sum }}</template>
|
||||
<el-table-column label="应付(元)" width="120">
|
||||
<template #default="{ row }">
|
||||
<span class="cell-num">{{ row.payable_sum }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="净工资(元)" width="120" align="right">
|
||||
<template #default="{ row }">{{ row.net_amount ?? '—' }}</template>
|
||||
<el-table-column label="净工资(元)" width="120">
|
||||
<template #default="{ row }">
|
||||
<span class="cell-num">{{ row.net_amount ?? '—' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</template>
|
||||
|
||||
<template v-else-if="adjustmentData">
|
||||
<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>
|
||||
<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>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 签名元素:竖排节点时间线(财务复核 → 领导审核的处理链) -->
|
||||
<p class="section-title section-title--gap">处理记录</p>
|
||||
<div class="timeline">
|
||||
<div
|
||||
v-for="step in detail.steps"
|
||||
@ -373,7 +417,10 @@ 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">
|
||||
@ -396,7 +443,6 @@ async function submitAction(): Promise<void> {
|
||||
<div class="action-bar">
|
||||
<el-button
|
||||
type="primary"
|
||||
size="small"
|
||||
:loading="actionSubmitting && actionForm.action === 1"
|
||||
@click="openAction(detail, 1)"
|
||||
>
|
||||
@ -404,16 +450,17 @@ 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"
|
||||
@ -435,58 +482,77 @@ async function submitAction(): Promise<void> {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.filters {
|
||||
margin-bottom: 4px;
|
||||
/* 版本/比例等数值列:全局 .cell-num 是给 td 设计的,行内 span 的
|
||||
text-align 不生效,变块级才能右对齐(表头仍左对齐,与设计稿一致)。 */
|
||||
.cell-num {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.detail-head {
|
||||
/* 抽屉头部:单号与状态同屏,标题 15px/600 与卡片标题同级 */
|
||||
.panel-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.detail-no {
|
||||
.panel-title__no {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--ys-ink);
|
||||
}
|
||||
|
||||
.detail-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 10px 0 4px;
|
||||
.panel-sub {
|
||||
margin: 4px 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--ys-ink-3);
|
||||
}
|
||||
|
||||
.content-section {
|
||||
margin-top: 12px;
|
||||
padding: 12px;
|
||||
background: #f7f8fa;
|
||||
border: 1px solid var(--ys-line);
|
||||
border-radius: 8px;
|
||||
/* 键值两列:72px 键列放得下四个汉字,值列自适应余宽 */
|
||||
.kv {
|
||||
display: grid;
|
||||
grid-template-columns: 72px 1fr;
|
||||
gap: 8px 10px;
|
||||
margin: 14px 0 0;
|
||||
}
|
||||
|
||||
.content-title {
|
||||
margin-bottom: 10px;
|
||||
.kv dt {
|
||||
font-size: 12px;
|
||||
color: var(--ys-ink-3);
|
||||
}
|
||||
|
||||
.kv dd {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--ys-ink);
|
||||
}
|
||||
|
||||
/* 审批内容块内的 kv 紧跟 .section-title,由标题的下边距负责间距,不再叠加 */
|
||||
.content-section .kv {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* 分块标题之间补上间距;不改全局 .section-title,用局部修饰符叠加 */
|
||||
.section-title--gap {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.content-section {
|
||||
margin-top: 16px;
|
||||
padding: 14px 16px;
|
||||
background: var(--ys-mist);
|
||||
border: 1px solid var(--ys-line);
|
||||
border-radius: var(--ys-radius-sm);
|
||||
}
|
||||
|
||||
.content-captured {
|
||||
margin-bottom: 10px;
|
||||
font-size: 12px;
|
||||
color: var(--ys-ink-2);
|
||||
}
|
||||
|
||||
.content-desc {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.content-table {
|
||||
margin-top: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.content-total {
|
||||
@ -502,7 +568,7 @@ async function submitAction(): Promise<void> {
|
||||
|
||||
/* 竖排时间线:左侧轨道 + 节点圆点,动作色区分人工/自动/驳回 */
|
||||
.timeline {
|
||||
margin-top: 14px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.timeline-row {
|
||||
@ -531,14 +597,14 @@ async function submitAction(): Promise<void> {
|
||||
}
|
||||
|
||||
.timeline-dot.is-reject {
|
||||
background: #c0392b;
|
||||
background: var(--ys-danger);
|
||||
}
|
||||
|
||||
.timeline-line {
|
||||
flex: 1;
|
||||
width: 1px;
|
||||
min-height: 26px;
|
||||
background: rgba(28, 35, 51, 0.14);
|
||||
background: var(--ys-line);
|
||||
}
|
||||
|
||||
.timeline-body {
|
||||
@ -563,7 +629,7 @@ async function submitAction(): Promise<void> {
|
||||
}
|
||||
|
||||
.timeline-action.is-reject-text {
|
||||
color: #c0392b;
|
||||
color: var(--ys-danger);
|
||||
}
|
||||
|
||||
.timeline-meta {
|
||||
@ -577,8 +643,8 @@ async function submitAction(): Promise<void> {
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
color: var(--ys-ink);
|
||||
background: #f4f5f8;
|
||||
border-radius: 6px;
|
||||
background: var(--ys-mist);
|
||||
border-radius: var(--ys-radius-sm);
|
||||
}
|
||||
|
||||
.timeline-empty {
|
||||
@ -590,13 +656,24 @@ async function submitAction(): Promise<void> {
|
||||
color: var(--ys-ink-3);
|
||||
}
|
||||
|
||||
/* 通过/驳回并排等宽:两个动作权重相同,审批人不必在大小按钮间做偏向性选择 */
|
||||
.action-bar {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding-top: 6px;
|
||||
padding-top: 14px;
|
||||
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;
|
||||
}
|
||||
|
||||
@ -207,42 +207,62 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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"
|
||||
<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"
|
||||
/>
|
||||
</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>
|
||||
</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>
|
||||
</el-form>
|
||||
|
||||
<el-table v-loading="loading" :data="records" stripe>
|
||||
<!-- 主操作行:新增岗位单独成行,与筛选动作分开 -->
|
||||
<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-column prop="role_code" label="岗位编码" min-width="130">
|
||||
<template #default="{ row }">
|
||||
<span class="role-code">{{ row.role_code }}</span>
|
||||
<!-- 岗位编码是参数维度值与模板绑定的锚点,等宽字体便于逐字符核对 -->
|
||||
<span class="cell-code">{{ row.role_code }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="role_name" label="岗位名称" min-width="110" />
|
||||
@ -252,8 +272,11 @@ onMounted(() => {
|
||||
<el-table-column prop="description" label="说明" min-width="200">
|
||||
<template #default="{ row }">{{ row.description || '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="sort_order" label="排序" width="70" align="center" />
|
||||
<el-table-column label="状态" width="90" align="center">
|
||||
<!-- 排序是用于比较的数字:右对齐 + 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">
|
||||
<template #default="{ row }">
|
||||
<StatusTag :value="row.status" />
|
||||
</template>
|
||||
@ -290,14 +313,16 @@ onMounted(() => {
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<AppPagination
|
||||
:total="total"
|
||||
:page-num="pageNum"
|
||||
:page-size="pageSize"
|
||||
@page-change="onPageChange"
|
||||
@size-change="onSizeChange"
|
||||
/>
|
||||
</el-card>
|
||||
<div class="card-foot">
|
||||
<AppPagination
|
||||
:total="total"
|
||||
:page-num="pageNum"
|
||||
:page-size="pageSize"
|
||||
@page-change="onPageChange"
|
||||
@size-change="onSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 新增 / 编辑 -->
|
||||
<el-dialog
|
||||
@ -355,12 +380,7 @@ 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;
|
||||
@ -371,7 +391,9 @@ onMounted(() => {
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
/* 参数清单比说明再弱一档,读者先看模板用途再看它要配哪些参数 */
|
||||
.tpl-params {
|
||||
color: var(--ys-ink-3);
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@ -170,11 +170,24 @@ watch(activeTab, (tab) => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-card shadow="never">
|
||||
<div class="page">
|
||||
<!-- 页面头:说明日志是只读取证材料,并点明保留期由后端控制 -->
|
||||
<div class="page-head">
|
||||
<h1 class="page-title">日志查询</h1>
|
||||
<p class="page-desc">
|
||||
检索操作与登录痕迹,用于追责与排障;日志只读,超出保留期的记录由后端自动清理。
|
||||
</p>
|
||||
<div class="waterline"></div>
|
||||
</div>
|
||||
|
||||
<el-tabs v-model="activeTab">
|
||||
<!-- 操作日志 -->
|
||||
<el-tab-pane label="操作日志" name="operation" lazy>
|
||||
<el-form class="filter-form" :inline="true" @submit.prevent>
|
||||
<!--
|
||||
筛选条:flex 排布与间距由 .filter-bar 提供,故 el-form 去掉 inline;
|
||||
查询/重置收进 .filter-bar__actions,由 margin-left:auto 推到行尾。
|
||||
-->
|
||||
<el-form class="filter-bar" @submit.prevent>
|
||||
<el-form-item label="操作人">
|
||||
<el-input
|
||||
v-model="opFilters.username"
|
||||
@ -215,77 +228,85 @@ watch(activeTab, (tab) => {
|
||||
:default-time="RANGE_DEFAULT_TIME"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<div class="filter-bar__actions">
|
||||
<el-button type="primary" @click="searchOperationLogs">查询</el-button>
|
||||
<el-button @click="resetOpFilters">重置</el-button>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
|
||||
<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">
|
||||
<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>
|
||||
|
||||
<AppPagination
|
||||
:total="opTotal"
|
||||
:page-num="opPageNum"
|
||||
:page-size="opPageSize"
|
||||
@page-change="onOpPageChange"
|
||||
@size-change="onOpSizeChange"
|
||||
/>
|
||||
<div class="card-foot">
|
||||
<AppPagination
|
||||
:total="opTotal"
|
||||
:page-num="opPageNum"
|
||||
:page-size="opPageSize"
|
||||
@page-change="onOpPageChange"
|
||||
@size-change="onOpSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 登录日志 -->
|
||||
<el-tab-pane label="登录日志" name="login" lazy>
|
||||
<el-form class="filter-form" :inline="true" @submit.prevent>
|
||||
<el-form class="filter-bar" @submit.prevent>
|
||||
<el-form-item label="用户名">
|
||||
<el-input
|
||||
v-model="loginFilters.username"
|
||||
@ -305,7 +326,12 @@ 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"
|
||||
@ -325,62 +351,77 @@ watch(activeTab, (tab) => {
|
||||
:default-time="RANGE_DEFAULT_TIME"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<div class="filter-bar__actions">
|
||||
<el-button type="primary" @click="searchLoginLogs">查询</el-button>
|
||||
<el-button @click="resetLoginFilters">重置</el-button>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
|
||||
<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">
|
||||
<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>
|
||||
|
||||
<AppPagination
|
||||
:total="loginTotal"
|
||||
:page-num="loginPageNum"
|
||||
:page-size="loginPageSize"
|
||||
@page-change="onLoginPageChange"
|
||||
@size-change="onLoginSizeChange"
|
||||
/>
|
||||
<div class="card-foot">
|
||||
<AppPagination
|
||||
:total="loginTotal"
|
||||
:page-num="loginPageNum"
|
||||
:page-size="loginPageSize"
|
||||
@page-change="onLoginPageChange"
|
||||
@size-change="onLoginSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/*
|
||||
* 展开行面板:表格单元格 padding 由全局统一为 12px 16px,这里再套一层容器,
|
||||
* 所以上下压到 4px 即可,否则展开区会明显比行内容松散;文字用次级墨色,
|
||||
* 与主行(--ys-ink)区分开,读起来是「附注」而非新的一条记录。
|
||||
*/
|
||||
.log-detail {
|
||||
padding: 4px 16px;
|
||||
color: var(--ys-text);
|
||||
color: var(--ys-ink-2);
|
||||
}
|
||||
|
||||
.log-detail__label {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 脱敏后的 JSON 参数按等宽排版才能看出字段边界;边框用全局线色,避免与主题脱钩 */
|
||||
.log-detail__data {
|
||||
padding: 8px 12px;
|
||||
margin: 6px 0 12px;
|
||||
@ -388,10 +429,11 @@ watch(activeTab, (tab) => {
|
||||
font-family: Consolas, 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
background-color: var(--ys-paper);
|
||||
border: 1px solid #e5e7eb;
|
||||
border: 1px solid var(--ys-line);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* UA 串很长,单行省略 + title 悬浮看全文,避免撑高行或被裁成半截 */
|
||||
.ua-cell {
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
@ -400,4 +442,5 @@ watch(activeTab, (tab) => {
|
||||
white-space: nowrap;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@ -431,94 +431,104 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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 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>
|
||||
</div>
|
||||
|
||||
<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 v-if="isAdmin && pendingList.length" class="card pending-card">
|
||||
<div class="card-head">
|
||||
<div class="pending-head">
|
||||
<b>待提交变更({{ pendingList.length }} 项)</b>
|
||||
<span class="pending-hint">提交前不会生效;提交后未生效前可整批撤回</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<el-table :data="pendingList" size="small">
|
||||
<el-table-column label="参数" min-width="160">
|
||||
<template #default="{ row }">{{ row.item.param_name }}</template>
|
||||
@ -538,28 +548,30 @@ onMounted(() => {
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<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 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>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<!-- 修改参数 -->
|
||||
<el-dialog v-model="editVisible" title="修改参数" width="440px" destroy-on-close>
|
||||
@ -809,17 +821,7 @@ onMounted(() => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.page-desc {
|
||||
font-size: 13px;
|
||||
color: var(--ys-ink-2);
|
||||
}
|
||||
/* 页面里已经没有 .page-toolbar/.page-desc —— 描述与工具栏改用全局 .page-head/.toolbar */
|
||||
|
||||
.group-title {
|
||||
font-weight: 600;
|
||||
@ -885,6 +887,7 @@ onMounted(() => {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 待提交卡片紧跟目录卡,需要与上方卡片拉开距离(.card 自身不带外边距) */
|
||||
.pending-card {
|
||||
margin-top: 14px;
|
||||
}
|
||||
@ -900,11 +903,14 @@ 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 {
|
||||
|
||||
@ -170,43 +170,69 @@ onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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"
|
||||
<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"
|
||||
/>
|
||||
</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>
|
||||
</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>
|
||||
</el-form>
|
||||
|
||||
<el-table v-loading="loading" :data="records" stripe>
|
||||
<!-- 主操作行:新增是这页唯一的高频动作,单独成行且用主按钮 -->
|
||||
<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-column label="编码" min-width="150">
|
||||
<template #default="{ row }">
|
||||
<span class="role-code">{{ row.role_code }}</span>
|
||||
<el-tag v-if="isBuiltin(row)" class="builtin-tag" size="small" type="warning" disable-transitions>
|
||||
<!-- 角色编码是授权判定的锚点,等宽字体便于逐字符核对 -->
|
||||
<span class="cell-code">{{ row.role_code }}</span>
|
||||
<el-tag
|
||||
v-if="isBuiltin(row)"
|
||||
class="builtin-tag"
|
||||
size="small"
|
||||
type="warning"
|
||||
disable-transitions
|
||||
>
|
||||
内置
|
||||
</el-tag>
|
||||
</template>
|
||||
@ -215,14 +241,17 @@ onMounted(load)
|
||||
<el-table-column prop="description" label="说明" min-width="180">
|
||||
<template #default="{ row }">{{ row.description || '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="sort_order" label="排序" width="80" align="center" />
|
||||
<el-table-column label="状态" width="90" align="center">
|
||||
<!-- 排序值与用户数都是用于比较的数字:右对齐 + 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">
|
||||
<template #default="{ row }">
|
||||
<StatusTag :value="row.status" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="用户数" width="90" align="center">
|
||||
<template #default="{ row }">{{ row.user_count }}</template>
|
||||
<el-table-column label="用户数" width="90" align="right">
|
||||
<template #default="{ row }"><span class="cell-num">{{ row.user_count }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="170" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
@ -256,14 +285,16 @@ onMounted(load)
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<AppPagination
|
||||
:total="total"
|
||||
:page-num="pageNum"
|
||||
:page-size="pageSize"
|
||||
@page-change="onPageChange"
|
||||
@size-change="onSizeChange"
|
||||
/>
|
||||
</el-card>
|
||||
<div class="card-foot">
|
||||
<AppPagination
|
||||
:total="total"
|
||||
:page-num="pageNum"
|
||||
:page-size="pageSize"
|
||||
@page-change="onPageChange"
|
||||
@size-change="onSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
@ -298,11 +329,9 @@ onMounted(load)
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.role-code {
|
||||
font-family: Consolas, 'Courier New', monospace;
|
||||
}
|
||||
|
||||
/* 「内置」标记跟在编码后面,需要一个横向间距;.el-tag 自身不带外边距 */
|
||||
.builtin-tag {
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@ -10,7 +10,10 @@ import { handledError } from '@/utils/error'
|
||||
* 公司信息设置(2026-10-15 补充需求:公司名称、logo)。
|
||||
*
|
||||
* 签名元素是右侧「登录页实景预览」:品牌名与 logo 改动即时反映在一张
|
||||
* 缩比登录卡上 —— 所见即所得,避免「保存后去登录页核对」的往返。
|
||||
* **1:1 等比**的登录卡上 —— 所见即所得,避免「保存后去登录页核对」的往返。
|
||||
* 预览按登录页的真实版式复刻(品牌区 / 欢迎回来 / 三项输入 / 登录按钮 /
|
||||
* 版权行),控件尺寸与登录页一致,logo 展示位也按登录页的 ≤56×36 约束。
|
||||
*
|
||||
* logo 校验(png/jpg ≤2MB)与后端三重校验同口径,前端先拦一道。
|
||||
*
|
||||
* 进入页面即加载当前设置(设置页即所见即所得,无需手动刷新)。
|
||||
@ -91,71 +94,141 @@ 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">
|
||||
<el-card shadow="never" class="settings-card">
|
||||
<template #header>
|
||||
<span class="card-title">公司信息</span>
|
||||
</template>
|
||||
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
label-width="88px"
|
||||
@submit.prevent
|
||||
>
|
||||
<el-form-item label="公司名称" prop="company_name">
|
||||
<el-input
|
||||
v-model="form.company_name"
|
||||
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-upload>
|
||||
<p class="logo-hint">png / jpg,不超过 2MB;<br />展示于登录页品牌位与导出页眉。</p>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSubmit">保存</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<!-- 签名元素:登录页实景预览(缩比登录卡,品牌名/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 class="settings-col">
|
||||
<div class="card">
|
||||
<!-- 卡片标题刻意不写「公司信息」:页面标题已经说明了,再重复一遍只是噪音 -->
|
||||
<div class="card-head">
|
||||
<span class="card-title">基础信息</span>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</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 class="mock-field"></div>
|
||||
<div class="mock-field"></div>
|
||||
<div class="mock-submit">登 录</div>
|
||||
</div>
|
||||
<p class="preview-caption">登录页品牌位实景(示意,非交互)</p>
|
||||
</el-card>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
<div class="mock-foot">© {{ previewName }} · 内部系统</div>
|
||||
</div>
|
||||
<p class="preview-caption">登录页品牌位实景(示意,非交互)——显示的是当前编辑值</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -163,25 +236,58 @@ const previewName = computed(() => form.company_name.trim() || '衍水集团')
|
||||
<style scoped>
|
||||
.settings-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(360px, 460px) minmax(300px, 380px);
|
||||
grid-template-columns: minmax(0, 1fr) 480px;
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-weight: 600;
|
||||
.settings-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/*
|
||||
* 说明文字不再用 float:right 顶到卡片头右侧 —— .card-head 已是 flex +
|
||||
* space-between,第二个子元素自然落在右端,float 反而会破坏行内布局。
|
||||
*/
|
||||
.preview-note {
|
||||
float: right;
|
||||
font-size: 12px;
|
||||
color: var(--ys-ink-3);
|
||||
}
|
||||
|
||||
.logo-field {
|
||||
/*
|
||||
* 控件行:主控件在左、说明在右,两者共享一行高度。
|
||||
* 说明用 flex-grow 撑满剩余宽度并右对齐 —— 宽屏下整行铺到卡片右缘,
|
||||
* 不会出现「输入框缩在左上角、右侧留一整块空白」的观感;
|
||||
* 窄屏放不下时自动换行,说明退回左对齐。
|
||||
*/
|
||||
.field-control {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 14px;
|
||||
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;
|
||||
}
|
||||
|
||||
.logo-box {
|
||||
@ -215,22 +321,76 @@ const previewName = computed(() => form.company_name.trim() || '衍水集团')
|
||||
color: var(--ys-ink-3);
|
||||
}
|
||||
|
||||
.logo-hint {
|
||||
margin: 4px 0 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
/* ── 生效范围:定义列表(项名 / 取值) ── */
|
||||
.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;
|
||||
color: var(--ys-ink-3);
|
||||
}
|
||||
|
||||
/* ── 登录页实景预览:按登录卡视觉 0.8 缩比重建(非 iframe 截图) ── */
|
||||
.login-mock {
|
||||
padding: 22px 26px 24px;
|
||||
/* ── 登录页实景预览:按登录页真实版式 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;
|
||||
background: linear-gradient(180deg, #ffffff, #fcfcfe);
|
||||
border: 1px solid var(--ys-line);
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(28, 35, 51, 0.09);
|
||||
border-radius: 14px;
|
||||
box-shadow:
|
||||
0 2px 4px rgba(28, 35, 51, 0.04),
|
||||
0 10px 26px rgba(28, 35, 51, 0.08);
|
||||
0 16px 40px rgba(28, 35, 51, 0.1);
|
||||
}
|
||||
|
||||
.mock-brand {
|
||||
@ -239,62 +399,136 @@ const previewName = computed(() => form.company_name.trim() || '衍水集团')
|
||||
|
||||
.mock-logo {
|
||||
display: block;
|
||||
max-width: 64px;
|
||||
max-height: 40px;
|
||||
margin: 0 auto 8px;
|
||||
max-width: 56px;
|
||||
max-height: 36px;
|
||||
margin: 0 auto 10px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.mock-name {
|
||||
font-family: var(--ys-font-brand);
|
||||
font-size: 14px;
|
||||
font-size: 17px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.34em;
|
||||
color: var(--ys-ink);
|
||||
}
|
||||
|
||||
.mock-slogan {
|
||||
margin: 6px 0 0;
|
||||
font-size: 11px;
|
||||
margin: 8px 0 0;
|
||||
font-size: 12.5px;
|
||||
color: var(--ys-ink-3);
|
||||
}
|
||||
|
||||
.mock-waterline {
|
||||
display: block;
|
||||
width: 34px;
|
||||
width: 40px;
|
||||
height: 2px;
|
||||
margin: 10px auto 0;
|
||||
margin: 14px 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 {
|
||||
height: 32px;
|
||||
margin-top: 12px;
|
||||
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;
|
||||
background: #f4f5f8;
|
||||
border: 1px solid rgba(28, 35, 51, 0.07);
|
||||
border-radius: 6px;
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
.mock-captcha-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.mock-captcha-row .mock-box {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* 验证码位:留空壳 + 斜纹质感,不伪造字符,避免与真实验证码混淆 */
|
||||
.mock-captcha {
|
||||
flex: 0 0 104px;
|
||||
height: 38px;
|
||||
background-color: #f1f3f8;
|
||||
background-image: repeating-linear-gradient(
|
||||
45deg,
|
||||
rgba(28, 35, 51, 0.07) 0 1px,
|
||||
transparent 1px 7px
|
||||
);
|
||||
border: 1px solid var(--ys-line);
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
.mock-submit {
|
||||
height: 32px;
|
||||
margin-top: 14px;
|
||||
font-size: 12px;
|
||||
line-height: 32px;
|
||||
height: 38px;
|
||||
margin-top: 20px;
|
||||
font-size: 13px;
|
||||
line-height: 38px;
|
||||
letter-spacing: 0.4em;
|
||||
text-align: center;
|
||||
color: #fff;
|
||||
background: var(--ys-indigo);
|
||||
border-radius: 6px;
|
||||
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);
|
||||
}
|
||||
|
||||
.preview-caption {
|
||||
margin: 10px 0 0;
|
||||
margin: 12px 0 0;
|
||||
font-size: 11px;
|
||||
text-align: center;
|
||||
color: var(--ys-ink-3);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
/*
|
||||
* 宽屏(≥1600px)说明文字靠右缘对齐,与左侧输入框形成「两端锚点」,
|
||||
* 整行铺满卡片而不是缩在左上角;1600px 以下说明跟着输入框走,
|
||||
* 放不下时整体换行到控件下方。
|
||||
*/
|
||||
@media (min-width: 1600px) {
|
||||
.field-control__note {
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.settings-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@ -250,58 +250,77 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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>
|
||||
<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="openCreate">新增用户</el-button>
|
||||
<!--
|
||||
筛选条:.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>
|
||||
</el-form>
|
||||
|
||||
<el-table v-loading="loading" :data="records" stripe>
|
||||
<!-- 主操作行:与筛选条分离,避免「查」和「建」在视觉上同级 -->
|
||||
<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-column prop="username" label="用户名" min-width="120" />
|
||||
<el-table-column prop="real_name" label="姓名" min-width="100">
|
||||
<template #default="{ row }">{{ row.real_name || '—' }}</template>
|
||||
@ -323,8 +342,9 @@ onMounted(() => {
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="90" align="center">
|
||||
<el-table-column label="状态" width="96" align="center">
|
||||
<template #default="{ row }">
|
||||
<!-- 状态统一交给 StatusTag(内部已是 ys-pill),页面不再自造标签形态 -->
|
||||
<StatusTag :value="row.status" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
@ -366,14 +386,16 @@ onMounted(() => {
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<AppPagination
|
||||
:total="total"
|
||||
:page-num="pageNum"
|
||||
:page-size="pageSize"
|
||||
@page-change="onPageChange"
|
||||
@size-change="onSizeChange"
|
||||
/>
|
||||
</el-card>
|
||||
<div class="card-foot">
|
||||
<AppPagination
|
||||
:total="total"
|
||||
:page-num="pageNum"
|
||||
:page-size="pageSize"
|
||||
@page-change="onPageChange"
|
||||
@size-change="onSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 新增 / 编辑 -->
|
||||
<el-dialog
|
||||
@ -459,9 +481,11 @@ onMounted(() => {
|
||||
|
||||
<style scoped>
|
||||
.role-tag {
|
||||
/* 一个用户可能有多个角色,标签并排时需要横向间距(.el-tag 自身不带) */
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
|
||||
.reset-tip {
|
||||
margin: 0 0 16px;
|
||||
color: var(--ys-text);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user