fix(dashboard): 修复普通用户落地页与待办角色过滤
- 新增 router/access.ts 统一准入与落地页(工作台→业务数据→回款), 守卫/登录回跳/菜单/面包屑复用;普通用户不再被无限重定向 - 待办按角色映射在途审批节点(财务/领导/admin),admin 不代办但可查全部 - 预警排除已终止业务;WarningBlock.total 为全量命中数;回款趋势改日期范围 - 工作台加载请求序号防竞态;KPI 口径说明补经理分成/调整单 - 新增 role_home_flow e2e,更新 system_management 普通用户断言 Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
parent
ebb3a92f25
commit
39a3fa7796
@ -30,10 +30,15 @@ _DashboardUser = Annotated[
|
|||||||
@router.get("", response_model=ApiResponse[DashboardOut], summary="工作台四区块")
|
@router.get("", response_model=ApiResponse[DashboardOut], summary="工作台四区块")
|
||||||
def get_dashboard(
|
def get_dashboard(
|
||||||
session: DbSession,
|
session: DbSession,
|
||||||
_: _DashboardUser,
|
current_user: _DashboardUser,
|
||||||
month: Annotated[
|
month: Annotated[
|
||||||
str | None, Query(max_length=7, description="统计月份 YYYY-MM,默认当月")
|
str | None, Query(max_length=7, description="统计月份 YYYY-MM,默认当月")
|
||||||
] = None,
|
] = None,
|
||||||
) -> ApiResponse[DashboardOut]:
|
) -> ApiResponse[DashboardOut]:
|
||||||
"""一次返回经营总览/待办/风险预警/最近动态(待办与预警实时派生)。"""
|
"""一次返回经营总览/待办/风险预警/最近动态(待办与预警实时派生)。
|
||||||
return ok(dashboard_service.get_dashboard(session, month))
|
|
||||||
|
待办按当前用户角色只返回其可操作节点(admin 可查看全部在途单)。
|
||||||
|
"""
|
||||||
|
return ok(
|
||||||
|
dashboard_service.get_dashboard(session, month, current_user=current_user)
|
||||||
|
)
|
||||||
|
|||||||
@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
|
|
||||||
from sqlalchemy import exists, func, select
|
from sqlalchemy import exists, func, or_, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.models import (
|
from app.models import (
|
||||||
@ -66,12 +66,18 @@ def performance_trend(session: Session, months: list[str]) -> dict[str, object]:
|
|||||||
return dict(rows)
|
return dict(rows)
|
||||||
|
|
||||||
|
|
||||||
def receipt_trend(session: Session, months: list[str]) -> dict[str, object]:
|
def receipt_trend(
|
||||||
"""近 N 个月回款合计(按到账月,含未核算回款——与台账口径不同,页面注明)。"""
|
session: Session, *, start_date: date, end_date: date
|
||||||
|
) -> dict[str, object]:
|
||||||
|
"""近 N 个月回款合计(按到账月,含未核算回款——与台账口径不同,页面注明)。
|
||||||
|
|
||||||
|
过滤走 ``receipt_date`` 原始列范围(可用索引);``DATE_FORMAT`` 只用于
|
||||||
|
分组,不参与 WHERE,避免函数列导致全表扫描。
|
||||||
|
"""
|
||||||
month_expr = func.date_format(Receipt.receipt_date, "%Y-%m")
|
month_expr = func.date_format(Receipt.receipt_date, "%Y-%m")
|
||||||
rows = session.execute(
|
rows = session.execute(
|
||||||
select(month_expr, func.coalesce(func.sum(Receipt.amount), 0))
|
select(month_expr, func.coalesce(func.sum(Receipt.amount), 0))
|
||||||
.where(month_expr.in_(months))
|
.where(Receipt.receipt_date >= start_date, Receipt.receipt_date < end_date)
|
||||||
.group_by(month_expr)
|
.group_by(month_expr)
|
||||||
).all()
|
).all()
|
||||||
return dict(rows)
|
return dict(rows)
|
||||||
@ -101,24 +107,33 @@ def get_month_settlement(session: Session, month: str) -> MonthlySettlement | No
|
|||||||
|
|
||||||
# ==== 待办事项 ====
|
# ==== 待办事项 ====
|
||||||
|
|
||||||
def list_pending_approvals(session: Session, *, limit: int) -> list[ApprovalOrder]:
|
def list_pending_approvals(
|
||||||
"""待处理审批单(按提交时间升序——最久的排前面)。"""
|
session: Session, *, statuses: tuple[int, ...], limit: int
|
||||||
|
) -> list[ApprovalOrder]:
|
||||||
|
"""待处理审批单(按提交时间升序——最久的排前面)。
|
||||||
|
|
||||||
|
``statuses`` 由调用方按当前用户角色解析(待我审批),空元组返回空列表。
|
||||||
|
"""
|
||||||
|
if not statuses:
|
||||||
|
return []
|
||||||
return list(
|
return list(
|
||||||
session.execute(
|
session.execute(
|
||||||
select(ApprovalOrder)
|
select(ApprovalOrder)
|
||||||
.where(ApprovalOrder.status.in_(_PENDING_STATUSES))
|
.where(ApprovalOrder.status.in_(statuses))
|
||||||
.order_by(ApprovalOrder.create_time.asc(), ApprovalOrder.id.asc())
|
.order_by(ApprovalOrder.create_time.asc(), ApprovalOrder.id.asc())
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
).scalars()
|
).scalars()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def count_pending_approvals(session: Session) -> int:
|
def count_pending_approvals(session: Session, *, statuses: tuple[int, ...]) -> int:
|
||||||
|
if not statuses:
|
||||||
|
return 0
|
||||||
return int(
|
return int(
|
||||||
session.execute(
|
session.execute(
|
||||||
select(func.count())
|
select(func.count())
|
||||||
.select_from(ApprovalOrder)
|
.select_from(ApprovalOrder)
|
||||||
.where(ApprovalOrder.status.in_(_PENDING_STATUSES))
|
.where(ApprovalOrder.status.in_(statuses))
|
||||||
).scalar_one()
|
).scalar_one()
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -152,7 +167,11 @@ def count_unassigned_receipts(session: Session) -> int:
|
|||||||
# ==== 风险预警 ====
|
# ==== 风险预警 ====
|
||||||
|
|
||||||
def list_overdue_shipments(session: Session, *, cutoff: date, limit: int) -> list:
|
def list_overdue_shipments(session: Session, *, cutoff: date, limit: int) -> list:
|
||||||
"""发货后超过阈值且该批次无任何回款的发货单(回款逾期规则)。"""
|
"""发货后超过阈值且该批次无任何回款的发货单(回款逾期规则)。
|
||||||
|
|
||||||
|
已终止业务(status=3)不再催收,排除在预警之外;业务行缺失时仍保留,
|
||||||
|
避免历史脏数据让发货单从预警中静默消失。
|
||||||
|
"""
|
||||||
has_receipt = exists().where(Receipt.shipment_id == Shipment.id)
|
has_receipt = exists().where(Receipt.shipment_id == Shipment.id)
|
||||||
return list(
|
return list(
|
||||||
session.execute(
|
session.execute(
|
||||||
@ -161,7 +180,11 @@ def list_overdue_shipments(session: Session, *, cutoff: date, limit: int) -> lis
|
|||||||
Business.business_no.label("business_no"),
|
Business.business_no.label("business_no"),
|
||||||
)
|
)
|
||||||
.outerjoin(Business, Business.id == Shipment.business_id)
|
.outerjoin(Business, Business.id == Shipment.business_id)
|
||||||
.where(Shipment.ship_date <= cutoff, ~has_receipt)
|
.where(
|
||||||
|
Shipment.ship_date <= cutoff,
|
||||||
|
~has_receipt,
|
||||||
|
or_(Business.status.is_(None), Business.status != 3),
|
||||||
|
)
|
||||||
.order_by(Shipment.ship_date.asc())
|
.order_by(Shipment.ship_date.asc())
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
).all()
|
).all()
|
||||||
@ -174,7 +197,12 @@ def count_overdue_shipments(session: Session, *, cutoff: date) -> int:
|
|||||||
session.execute(
|
session.execute(
|
||||||
select(func.count())
|
select(func.count())
|
||||||
.select_from(Shipment)
|
.select_from(Shipment)
|
||||||
.where(Shipment.ship_date <= cutoff, ~has_receipt)
|
.outerjoin(Business, Business.id == Shipment.business_id)
|
||||||
|
.where(
|
||||||
|
Shipment.ship_date <= cutoff,
|
||||||
|
~has_receipt,
|
||||||
|
or_(Business.status.is_(None), Business.status != 3),
|
||||||
|
)
|
||||||
).scalar_one()
|
).scalar_one()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -98,6 +98,7 @@ class WarningBlock(BaseModel):
|
|||||||
receipt_overdue_days: int = 90
|
receipt_overdue_days: int = 90
|
||||||
approval_timeout_days: int = 3
|
approval_timeout_days: int = 3
|
||||||
items: list[WarningItem] = Field(default_factory=list)
|
items: list[WarningItem] = Field(default_factory=list)
|
||||||
|
total: int = Field(default=0, description="全部规则命中总数(items 仅为展示上限内的子集)")
|
||||||
receipt_overdue_total: int = 0
|
receipt_overdue_total: int = 0
|
||||||
approval_timeout_total: int = 0
|
approval_timeout_total: int = 0
|
||||||
sync_available: bool = False
|
sync_available: bool = False
|
||||||
|
|||||||
@ -19,6 +19,7 @@
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from collections.abc import Iterable
|
||||||
from datetime import date
|
from datetime import date
|
||||||
|
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
@ -29,6 +30,7 @@ from app.core.constants import (
|
|||||||
ACTION_REJECT,
|
ACTION_REJECT,
|
||||||
MODULE_APPROVAL,
|
MODULE_APPROVAL,
|
||||||
NO_PREFIX_APPROVAL,
|
NO_PREFIX_APPROVAL,
|
||||||
|
ROLE_ADMIN,
|
||||||
ROLE_FINANCE,
|
ROLE_FINANCE,
|
||||||
ROLE_LEADER,
|
ROLE_LEADER,
|
||||||
)
|
)
|
||||||
@ -84,6 +86,18 @@ _NODE_ROLE_BY_STATUS = {
|
|||||||
_ROLE_LABEL = {ROLE_FINANCE: "财务", ROLE_LEADER: "领导"}
|
_ROLE_LABEL = {ROLE_FINANCE: "财务", ROLE_LEADER: "领导"}
|
||||||
|
|
||||||
|
|
||||||
|
def pending_statuses_for_roles(role_codes: Iterable[str]) -> tuple[int, ...]:
|
||||||
|
"""工作台「待我审批」:按当前用户角色映射在途审批状态。
|
||||||
|
|
||||||
|
admin 不代办审批,但作为管理视角可查看全部在途单(与审批管理列表
|
||||||
|
的可见性一致);财务/领导只看到本节点可操作的条目。
|
||||||
|
"""
|
||||||
|
codes = set(role_codes)
|
||||||
|
if ROLE_ADMIN in codes:
|
||||||
|
return _PENDING_STATUSES
|
||||||
|
return tuple(status for status, role in _NODE_ROLE_BY_STATUS.items() if role in codes)
|
||||||
|
|
||||||
|
|
||||||
def is_enabled(session: Session, order_type: int) -> bool:
|
def is_enabled(session: Session, order_type: int) -> bool:
|
||||||
"""读当日参数快照判定审批开关(一次提交路径内只读一次,调用方勿重读)。"""
|
"""读当日参数快照判定审批开关(一次提交路径内只读一次,调用方勿重读)。"""
|
||||||
code = ENABLE_CODE_BY_TYPE.get(order_type)
|
code = ENABLE_CODE_BY_TYPE.get(order_type)
|
||||||
|
|||||||
@ -18,8 +18,9 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from app.core.exceptions import BadRequestError
|
from app.core.exceptions import BadRequestError
|
||||||
from app.core.month import validate_month
|
from app.core.month import validate_month
|
||||||
from app.repositories import dashboard_repository, operation_log_repository
|
from app.repositories import dashboard_repository, operation_log_repository, user_repository
|
||||||
from app.repositories.query_utils import LogFilters
|
from app.repositories.query_utils import LogFilters
|
||||||
|
from app.services import approval_service
|
||||||
|
|
||||||
# 工作台只展示摘要,完整列表跳转对应管理页
|
# 工作台只展示摘要,完整列表跳转对应管理页
|
||||||
_TODO_LIMIT = 5
|
_TODO_LIMIT = 5
|
||||||
@ -30,14 +31,21 @@ _TREND_MONTHS = 6
|
|||||||
_ORDER_TYPE_LABELS = {1: "二次分配方案", 2: "月度结算", 3: "调整单"}
|
_ORDER_TYPE_LABELS = {1: "二次分配方案", 2: "月度结算", 3: "调整单"}
|
||||||
|
|
||||||
|
|
||||||
def get_dashboard(session: Session, month: str | None = None) -> dict:
|
def get_dashboard(session: Session, month: str | None = None, *, current_user) -> dict:
|
||||||
"""四区块一次性出数(前端一次请求渲染整页)。"""
|
"""四区块一次性出数(前端一次请求渲染整页)。
|
||||||
|
|
||||||
|
待办按当前用户角色只展示其可操作节点(admin 可查看全部在途单);
|
||||||
|
风险预警仍是全局经营视角,不按人裁剪。
|
||||||
|
"""
|
||||||
resolved = _normalize_month(month)
|
resolved = _normalize_month(month)
|
||||||
warning_params = _warning_params(session)
|
warning_params = _warning_params(session)
|
||||||
|
pending_statuses = approval_service.pending_statuses_for_roles(
|
||||||
|
user_repository.get_role_codes(current_user)
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"month": resolved,
|
"month": resolved,
|
||||||
"overview": _overview(session, resolved),
|
"overview": _overview(session, resolved),
|
||||||
"todos": _todos(session),
|
"todos": _todos(session, pending_statuses),
|
||||||
"warnings": _warnings(session, warning_params),
|
"warnings": _warnings(session, warning_params),
|
||||||
"activities": _activities(session),
|
"activities": _activities(session),
|
||||||
}
|
}
|
||||||
@ -61,6 +69,11 @@ def _shift_month(month: str, delta: int) -> str:
|
|||||||
return f"{index // 12:04d}-{index % 12 + 1:02d}"
|
return f"{index // 12:04d}-{index % 12 + 1:02d}"
|
||||||
|
|
||||||
|
|
||||||
|
def _month_start(month: str) -> date:
|
||||||
|
"""YYYY-MM → 该月首日(范围查询边界,避免函数列过滤)。"""
|
||||||
|
return date(int(month[:4]), int(month[5:7]), 1)
|
||||||
|
|
||||||
|
|
||||||
def _trend_months(month: str) -> list[str]:
|
def _trend_months(month: str) -> list[str]:
|
||||||
"""窗口为「当月及之前共 6 个月」,升序返回便于前端画图。"""
|
"""窗口为「当月及之前共 6 个月」,升序返回便于前端画图。"""
|
||||||
return [_shift_month(month, offset) for offset in range(-(_TREND_MONTHS - 1), 1)]
|
return [_shift_month(month, offset) for offset in range(-(_TREND_MONTHS - 1), 1)]
|
||||||
@ -72,7 +85,11 @@ def _overview(session: Session, month: str) -> dict:
|
|||||||
settlement = dashboard_repository.get_month_settlement(session, month)
|
settlement = dashboard_repository.get_month_settlement(session, month)
|
||||||
months = _trend_months(month)
|
months = _trend_months(month)
|
||||||
performance = dashboard_repository.performance_trend(session, months)
|
performance = dashboard_repository.performance_trend(session, months)
|
||||||
receipts = dashboard_repository.receipt_trend(session, months)
|
receipts = dashboard_repository.receipt_trend(
|
||||||
|
session,
|
||||||
|
start_date=_month_start(months[0]),
|
||||||
|
end_date=_month_start(_shift_month(months[-1], 1)),
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"month": month,
|
"month": month,
|
||||||
"performance_total": _dec(sums["performance_total"]),
|
"performance_total": _dec(sums["performance_total"]),
|
||||||
@ -94,16 +111,27 @@ def _overview(session: Session, month: str) -> dict:
|
|||||||
}
|
}
|
||||||
for item in months
|
for item in months
|
||||||
],
|
],
|
||||||
"note": "绩效按台账归属月(单笔生效行,二次分摊拆行求和不失真);回款按到账月(含未核算回款);不含调整单",
|
"note": (
|
||||||
|
"绩效按台账归属月(单笔生效行,二次分摊拆行求和不失真);回款按到账月"
|
||||||
|
"(含未核算回款);不含经理 12.5%+5% 聚合分成与调整单"
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _todos(session: Session) -> dict:
|
def _todos(session: Session, pending_statuses: tuple[int, ...]) -> dict:
|
||||||
"""待办事项(FR-034,澄清 #11 起步范围)。"""
|
"""待办事项(FR-034,澄清 #11 起步范围)。
|
||||||
orders = dashboard_repository.list_pending_approvals(session, limit=_TODO_LIMIT)
|
|
||||||
|
``pending_statuses`` 已按当前用户角色解析:财务只看待财务复核、
|
||||||
|
领导只看待领导审核;admin 查看全部在途单。
|
||||||
|
"""
|
||||||
|
orders = dashboard_repository.list_pending_approvals(
|
||||||
|
session, statuses=pending_statuses, limit=_TODO_LIMIT
|
||||||
|
)
|
||||||
receipts = dashboard_repository.list_unassigned_receipts(session, limit=_TODO_LIMIT)
|
receipts = dashboard_repository.list_unassigned_receipts(session, limit=_TODO_LIMIT)
|
||||||
return {
|
return {
|
||||||
"pending_approval_total": dashboard_repository.count_pending_approvals(session),
|
"pending_approval_total": dashboard_repository.count_pending_approvals(
|
||||||
|
session, statuses=pending_statuses
|
||||||
|
),
|
||||||
"pending_approvals": [
|
"pending_approvals": [
|
||||||
{
|
{
|
||||||
"order_id": order.id,
|
"order_id": order.id,
|
||||||
@ -154,6 +182,7 @@ def _warnings(session: Session, params: dict) -> dict:
|
|||||||
"receipt_overdue_days": params["receipt_overdue_days"],
|
"receipt_overdue_days": params["receipt_overdue_days"],
|
||||||
"approval_timeout_days": params["approval_timeout_days"],
|
"approval_timeout_days": params["approval_timeout_days"],
|
||||||
"items": [],
|
"items": [],
|
||||||
|
"total": 0,
|
||||||
"receipt_overdue_total": 0,
|
"receipt_overdue_total": 0,
|
||||||
"approval_timeout_total": 0,
|
"approval_timeout_total": 0,
|
||||||
"sync_available": False,
|
"sync_available": False,
|
||||||
@ -201,17 +230,21 @@ def _warnings(session: Session, params: dict) -> dict:
|
|||||||
}
|
}
|
||||||
for order in orders
|
for order in orders
|
||||||
]
|
]
|
||||||
|
receipt_overdue_total = dashboard_repository.count_overdue_shipments(
|
||||||
|
session, cutoff=receipt_cutoff
|
||||||
|
)
|
||||||
|
approval_timeout_total = dashboard_repository.count_overdue_approvals(
|
||||||
|
session, cutoff=approval_cutoff
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
"receipt_overdue_days": params["receipt_overdue_days"],
|
"receipt_overdue_days": params["receipt_overdue_days"],
|
||||||
"approval_timeout_days": params["approval_timeout_days"],
|
"approval_timeout_days": params["approval_timeout_days"],
|
||||||
"items": items,
|
"items": items,
|
||||||
"receipt_overdue_total": dashboard_repository.count_overdue_shipments(
|
# items 有展示上限(_WARNING_LIMIT),徽标计数必须用全量命中数
|
||||||
session, cutoff=receipt_cutoff
|
"total": receipt_overdue_total + approval_timeout_total,
|
||||||
),
|
"receipt_overdue_total": receipt_overdue_total,
|
||||||
"approval_timeout_total": dashboard_repository.count_overdue_approvals(
|
"approval_timeout_total": approval_timeout_total,
|
||||||
session, cutoff=approval_cutoff
|
|
||||||
),
|
|
||||||
"sync_available": False,
|
"sync_available": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -191,7 +191,7 @@ def test_预警_审批超时命中且参数关闭后不产生(client: TestClient
|
|||||||
client.post(f"{_SETTLEMENTS}/{settlement_id}/submit", headers=admin["headers"])
|
client.post(f"{_SETTLEMENTS}/{settlement_id}/submit", headers=admin["headers"])
|
||||||
|
|
||||||
# 把待审批单提交时间改到 4 天前(阈值默认 3 天)
|
# 把待审批单提交时间改到 4 天前(阈值默认 3 天)
|
||||||
from sqlalchemy import select, update
|
from sqlalchemy import update
|
||||||
|
|
||||||
from app.core.db import get_session_factory
|
from app.core.db import get_session_factory
|
||||||
from app.models import ApprovalOrder
|
from app.models import ApprovalOrder
|
||||||
@ -230,3 +230,224 @@ def test_动态_来源操作日志(client: TestClient, admin: dict):
|
|||||||
data = _dashboard(client, admin["headers"])
|
data = _dashboard(client, admin["headers"])
|
||||||
assert data["activities"], "工作台动态为空:写操作应产生操作日志"
|
assert data["activities"], "工作台动态为空:写操作应产生操作日志"
|
||||||
assert {"module", "action", "summary", "create_time"} <= set(data["activities"][0])
|
assert {"module", "action", "summary", "create_time"} <= set(data["activities"][0])
|
||||||
|
|
||||||
|
|
||||||
|
# ==== 角色待办 / 预警阈值 / 趋势(2026-10-19 code-review 修复回归)====
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def finance(make_user, auth_headers) -> dict:
|
||||||
|
user = make_user(["finance"])
|
||||||
|
user["headers"] = auth_headers(user)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def leader(make_user, auth_headers) -> dict:
|
||||||
|
user = make_user(["leader"])
|
||||||
|
user["headers"] = auth_headers(user)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_approval(status: int) -> None:
|
||||||
|
"""直插在途审批单(避免依赖两次流转,专注工作台过滤口径)。"""
|
||||||
|
from app.core.db import get_session_factory
|
||||||
|
from app.models import ApprovalOrder
|
||||||
|
|
||||||
|
factory = get_session_factory()
|
||||||
|
with factory() as session:
|
||||||
|
session.add(
|
||||||
|
ApprovalOrder(
|
||||||
|
order_no=f"AP{uuid.uuid4().hex[:12]}",
|
||||||
|
order_type=2,
|
||||||
|
ref_id=0,
|
||||||
|
refer_no="t_工作台",
|
||||||
|
status=status,
|
||||||
|
submitter_name="t_admin",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _set_param(client: TestClient, headers: dict, code: str, value: str) -> None:
|
||||||
|
resp = client.post(
|
||||||
|
f"{_PARAMS}/changes",
|
||||||
|
json={
|
||||||
|
"change_type": 1,
|
||||||
|
"remark": f"t_工作台:{code}",
|
||||||
|
"items": [{"param_code": code, "dimension_value": "", "value": value}],
|
||||||
|
},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
|
||||||
|
|
||||||
|
def _shipment_scene(client: TestClient, headers: dict, *, days_ago: int) -> tuple[dict, dict]:
|
||||||
|
"""造一笔无回款的发货(用于回款逾期预警)。"""
|
||||||
|
from tests.integration.factories import business_item, business_payload, customer
|
||||||
|
|
||||||
|
person = _person(client, headers)
|
||||||
|
cust = customer(client, headers)
|
||||||
|
resp = client.post(
|
||||||
|
_BUSINESSES,
|
||||||
|
json=business_payload(
|
||||||
|
cust["id"],
|
||||||
|
owner_personnel_id=person["id"],
|
||||||
|
items=[business_item(cost_amount="3000.00")],
|
||||||
|
),
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
business = resp.json()["data"]
|
||||||
|
ship_date = (date.today() - timedelta(days=days_ago)).isoformat()
|
||||||
|
resp = client.post(
|
||||||
|
_SHIPMENTS,
|
||||||
|
json={
|
||||||
|
"business_id": business["id"],
|
||||||
|
"ship_date": ship_date,
|
||||||
|
"items": [{"product_name": "t_工作台逾期", "quantity": "1", "sale_price": "1.00"}],
|
||||||
|
},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
return business, resp.json()["data"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_待办_按角色仅显示可操作节点(
|
||||||
|
client: TestClient, admin: dict, finance: dict, leader: dict, make_user, auth_headers
|
||||||
|
):
|
||||||
|
"""财务只看到待财务复核、领导只看到待领导审核;admin 与多角色看并集。"""
|
||||||
|
_insert_approval(1)
|
||||||
|
_insert_approval(2)
|
||||||
|
|
||||||
|
assert _dashboard(client, finance["headers"])["todos"]["pending_approval_total"] == 1
|
||||||
|
assert _dashboard(client, leader["headers"])["todos"]["pending_approval_total"] == 1
|
||||||
|
assert _dashboard(client, admin["headers"])["todos"]["pending_approval_total"] == 2
|
||||||
|
|
||||||
|
both = make_user(["finance", "leader"])
|
||||||
|
both["headers"] = auth_headers(both)
|
||||||
|
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"]
|
||||||
|
}
|
||||||
|
leader_orders = {
|
||||||
|
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
|
||||||
|
):
|
||||||
|
"""真实流转:财务节点通过后,条目从财务待办消失并进入领导待办。"""
|
||||||
|
_chain(client, admin["headers"])
|
||||||
|
resp = client.post(
|
||||||
|
f"{_SETTLEMENTS}/generate", json={"month": _month()}, headers=admin["headers"]
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
settlement_id = resp.json()["data"]["id"]
|
||||||
|
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]
|
||||||
|
assert _dashboard(client, finance["headers"])["todos"]["pending_approval_total"] == 1
|
||||||
|
assert _dashboard(client, leader["headers"])["todos"]["pending_approval_total"] == 0
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
f"/api/v1/system/approvals/{order['order_id']}/action",
|
||||||
|
json={"action": 1},
|
||||||
|
headers=finance["headers"],
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert _dashboard(client, finance["headers"])["todos"]["pending_approval_total"] == 0
|
||||||
|
assert _dashboard(client, leader["headers"])["todos"]["pending_approval_total"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_预警_回款逾期命中且阈值参数生效(client: TestClient, admin: dict):
|
||||||
|
"""发货超阈值且无任何回款 → 命中;调大阈值后不再命中。"""
|
||||||
|
_, shipment = _shipment_scene(client, admin["headers"], days_ago=120)
|
||||||
|
|
||||||
|
data = _dashboard(client, admin["headers"])
|
||||||
|
assert any(
|
||||||
|
item["code"] == "receipt_overdue" and item["object_id"] == shipment["id"]
|
||||||
|
for item in data["warnings"]["items"]
|
||||||
|
), "120 天未回款的发货批次未命中预警"
|
||||||
|
|
||||||
|
_set_param(client, admin["headers"], "warning.receipt.overdue.days", "200")
|
||||||
|
data = _dashboard(client, admin["headers"])
|
||||||
|
assert not any(
|
||||||
|
item["code"] == "receipt_overdue" and item["object_id"] == shipment["id"]
|
||||||
|
for item in data["warnings"]["items"]
|
||||||
|
), "阈值调大后仍命中,说明参数未生效"
|
||||||
|
|
||||||
|
|
||||||
|
def test_预警_终止业务不再产生回款逾期(client: TestClient, admin: dict):
|
||||||
|
"""已终止业务不再催收,其未回款发货单不得留在预警列表。"""
|
||||||
|
business, shipment = _shipment_scene(client, admin["headers"], days_ago=120)
|
||||||
|
resp = client.put(
|
||||||
|
f"{_BUSINESSES}/{business['id']}/status",
|
||||||
|
json={"status": 3, "reason": "t_工作台终止"},
|
||||||
|
headers=admin["headers"],
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
|
||||||
|
data = _dashboard(client, admin["headers"])
|
||||||
|
assert not any(item["object_id"] == shipment["id"] for item in data["warnings"]["items"])
|
||||||
|
assert data["warnings"]["receipt_overdue_total"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_预警_总数不受展示上限截断(client: TestClient, admin: dict, monkeypatch):
|
||||||
|
"""items 只展示前 N 条,徽标 total 必须仍是全量命中数。"""
|
||||||
|
from app.services import dashboard_service
|
||||||
|
|
||||||
|
_shipment_scene(client, admin["headers"], days_ago=120)
|
||||||
|
_shipment_scene(client, admin["headers"], days_ago=120)
|
||||||
|
monkeypatch.setattr(dashboard_service, "_WARNING_LIMIT", 1)
|
||||||
|
|
||||||
|
data = _dashboard(client, admin["headers"])
|
||||||
|
assert data["warnings"]["total"] == 2
|
||||||
|
assert len(data["warnings"]["items"]) == 1
|
||||||
|
assert data["warnings"]["total"] == data["warnings"]["receipt_overdue_total"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_总览_回款趋势按到账月跨月归属(client: TestClient, admin: dict):
|
||||||
|
"""趋势窗口按 receipt_date 范围过滤,月初/月末不得串月。"""
|
||||||
|
from tests.integration.factories import business_item, business_payload, customer
|
||||||
|
|
||||||
|
person = _person(client, admin["headers"])
|
||||||
|
cust = customer(client, admin["headers"])
|
||||||
|
resp = client.post(
|
||||||
|
_BUSINESSES,
|
||||||
|
json=business_payload(
|
||||||
|
cust["id"],
|
||||||
|
owner_personnel_id=person["id"],
|
||||||
|
items=[business_item(cost_amount="3000.00")],
|
||||||
|
),
|
||||||
|
headers=admin["headers"],
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
business = resp.json()["data"]
|
||||||
|
|
||||||
|
first_of_month = date.today().replace(day=1)
|
||||||
|
previous_last = first_of_month - timedelta(days=1)
|
||||||
|
for receipt_date in (previous_last, first_of_month):
|
||||||
|
resp = client.post(
|
||||||
|
_RECEIPTS,
|
||||||
|
json={
|
||||||
|
"business_id": business["id"],
|
||||||
|
"receipt_date": receipt_date.isoformat(),
|
||||||
|
"amount": "1000.00",
|
||||||
|
},
|
||||||
|
headers=admin["headers"],
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
|
||||||
|
trend = {
|
||||||
|
item["month"]: Decimal(str(item["receipt_total"] or 0))
|
||||||
|
for item in _dashboard(client, admin["headers"])["overview"]["trend"]
|
||||||
|
}
|
||||||
|
assert trend[previous_last.strftime("%Y-%m")] == Decimal("1000.00")
|
||||||
|
assert trend[first_of_month.strftime("%Y-%m")] == Decimal("1000.00")
|
||||||
|
|||||||
@ -112,10 +112,12 @@ def expect_toast(page: Page, expected: str, step: str, timeout_ms: int = 5000) -
|
|||||||
fail(step, f"未出现提示「{expected}」,当前提示:{text or '(无)'}")
|
fail(step, f"未出现提示「{expected}」,当前提示:{text or '(无)'}")
|
||||||
|
|
||||||
|
|
||||||
def login(page: Page, username: str, password: str) -> None:
|
def login(page: Page, username: str, password: str, expected_path: str = "/dashboard") -> None:
|
||||||
"""完整登录:打开登录页 → 从 Redis 取验证码答案 → 提交 → 等待进入首页。
|
"""完整登录:打开登录页 → 从 Redis 取验证码答案 → 提交 → 等待进入落地页。
|
||||||
|
|
||||||
验证码 id 经响应监听捕获(前端把它内联在响应体里,不是 URL 参数)。
|
验证码 id 经响应监听捕获(前端把它内联在响应体里,不是 URL 参数)。
|
||||||
|
``expected_path`` 按角色传入:管理员/财务/领导默认工作台,普通用户
|
||||||
|
落业务数据页(工作台权限收紧后守卫按角色解析首页)。
|
||||||
"""
|
"""
|
||||||
captcha_ids: list[str] = []
|
captcha_ids: list[str] = []
|
||||||
|
|
||||||
@ -148,8 +150,8 @@ def login(page: Page, username: str, password: str) -> None:
|
|||||||
page.fill('input[autocomplete="off"]', answer)
|
page.fill('input[autocomplete="off"]', answer)
|
||||||
page.click("button:has-text('登 录')")
|
page.click("button:has-text('登 录')")
|
||||||
page.wait_for_timeout(2000)
|
page.wait_for_timeout(2000)
|
||||||
if current_path(page) != "/dashboard":
|
if current_path(page) != expected_path:
|
||||||
detail = f"未跳转到首页,当前 {page.url}"
|
detail = f"未跳转到 {expected_path},当前 {page.url}"
|
||||||
if message := toast_text(page):
|
if message := toast_text(page):
|
||||||
detail += f",提示:{message}"
|
detail += f",提示:{message}"
|
||||||
fail("登录", detail)
|
fail("登录", detail)
|
||||||
|
|||||||
141
frontend/e2e/role_home_flow.py
Normal file
141
frontend/e2e/role_home_flow.py
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
"""浏览器端到端验证:普通用户落地页与路由守卫回退。
|
||||||
|
|
||||||
|
工作台收紧为 admin/finance/leader 后,登录默认页不能硬编码 `/dashboard`
|
||||||
|
(普通用户会被无限重定向)。本脚本验证:
|
||||||
|
|
||||||
|
- 普通用户登录后落业务数据页,不出现「无权访问」提示
|
||||||
|
- 侧边菜单不出现首页/系统管理/报表统计
|
||||||
|
- 刷新后仍保持登录态与落地页
|
||||||
|
- 直连 `/dashboard` 或报表页时回退业务数据页并给出权限提示
|
||||||
|
|
||||||
|
运行前置:容器、后端 8000、前端 5173 均在运行;设置 ``E2E_ADMIN_PASSWORD``。
|
||||||
|
|
||||||
|
运行::
|
||||||
|
|
||||||
|
cd frontend && E2E_ADMIN_PASSWORD=<密码> python e2e/role_home_flow.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from playwright.sync_api import Page, sync_playwright
|
||||||
|
|
||||||
|
from common import BASE_URL, E2EFailure, current_path, fail, login, toast_text
|
||||||
|
|
||||||
|
ADMIN = "admin"
|
||||||
|
USERNAME = f"e2e_role_{int(time.time())}_{uuid.uuid4().hex[:4]}"
|
||||||
|
PASSWORD = "E2eRole@2026"
|
||||||
|
HOME_PATH = "/business/businesses"
|
||||||
|
|
||||||
|
|
||||||
|
def _api(method: str, path: str, token: str, body: dict | None = None) -> dict:
|
||||||
|
"""带令牌的轻量 API 调用(urllib,避免额外依赖)。"""
|
||||||
|
request = urllib.request.Request(
|
||||||
|
f"{BASE_URL}{path}",
|
||||||
|
method=method,
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {token}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
data=json.dumps(body).encode() if body is not None else None,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(request, timeout=20) as response:
|
||||||
|
payload = json.loads(response.read().decode())
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
detail = exc.read().decode()[:200]
|
||||||
|
raise E2EFailure(f"API {method} {path} HTTP {exc.code}:{detail}") from exc
|
||||||
|
if payload.get("code") != 200:
|
||||||
|
raise E2EFailure(f"API {method} {path} 业务失败:{payload.get('message')}")
|
||||||
|
return payload["data"]
|
||||||
|
|
||||||
|
|
||||||
|
def _token(page: Page) -> str:
|
||||||
|
token = page.evaluate("localStorage.getItem('ys_token')")
|
||||||
|
if not token:
|
||||||
|
fail("API", "未取到登录令牌")
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
def _create_user(page: Page) -> dict:
|
||||||
|
return _api(
|
||||||
|
"POST",
|
||||||
|
"/api/v1/system/users",
|
||||||
|
_token(page),
|
||||||
|
{
|
||||||
|
"username": USERNAME,
|
||||||
|
"password": PASSWORD,
|
||||||
|
"real_name": "E2E普通用户",
|
||||||
|
"roles": ["user"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def run(page: Page) -> None:
|
||||||
|
admin_password = os.environ.get("E2E_ADMIN_PASSWORD", "")
|
||||||
|
if not admin_password:
|
||||||
|
fail("环境变量", "未设置 E2E_ADMIN_PASSWORD(开发库 admin 密码)")
|
||||||
|
|
||||||
|
login(page, ADMIN, admin_password)
|
||||||
|
user = _create_user(page)
|
||||||
|
try:
|
||||||
|
page.evaluate("localStorage.clear()")
|
||||||
|
login(page, USERNAME, PASSWORD, expected_path=HOME_PATH)
|
||||||
|
page.wait_for_timeout(500)
|
||||||
|
print("✓ 1. 普通用户登录 落地业务数据页")
|
||||||
|
|
||||||
|
menu_text = page.locator(".app-menu").inner_text()
|
||||||
|
for forbidden in ("首页", "系统管理", "报表统计"):
|
||||||
|
if forbidden in menu_text:
|
||||||
|
fail("菜单权限", f"普通用户看到了「{forbidden}」菜单")
|
||||||
|
print("✓ 2. 菜单权限 首页/系统管理/报表统计均不可见")
|
||||||
|
|
||||||
|
page.reload(wait_until="networkidle")
|
||||||
|
page.wait_for_timeout(600)
|
||||||
|
if current_path(page) != HOME_PATH:
|
||||||
|
fail("刷新恢复", f"刷新后未保持落地页,当前 {page.url}")
|
||||||
|
print("✓ 3. 刷新恢复 登录态与落地页保持")
|
||||||
|
|
||||||
|
for path in ("/dashboard", "/reports/personal-detail"):
|
||||||
|
page.goto(f"{BASE_URL}{path}", wait_until="networkidle")
|
||||||
|
page.wait_for_timeout(800)
|
||||||
|
if current_path(page) != HOME_PATH:
|
||||||
|
fail("路由守卫", f"直连 {path} 未回退业务数据页,当前 {page.url}")
|
||||||
|
if "无权访问该页面" not in toast_text(page):
|
||||||
|
fail("路由守卫", f"直连 {path} 未给出权限提示")
|
||||||
|
print("✓ 4. 路由守卫 受限页均回退业务数据页并提示")
|
||||||
|
finally:
|
||||||
|
# 无论用例是否失败都清理测试账号(重新以 admin 登录取令牌)
|
||||||
|
page.evaluate("localStorage.clear()")
|
||||||
|
login(page, ADMIN, admin_password)
|
||||||
|
_api("DELETE", f"/api/v1/system/users/{user['id']}", _token(page))
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
with sync_playwright() as playwright:
|
||||||
|
browser = playwright.chromium.launch(headless=True)
|
||||||
|
page = browser.new_page(viewport={"width": 1440, "height": 900})
|
||||||
|
page.set_default_timeout(15000)
|
||||||
|
try:
|
||||||
|
run(page)
|
||||||
|
except E2EFailure as exc:
|
||||||
|
page.screenshot(path="e2e/failure.png", full_page=True)
|
||||||
|
print(f"\n ✗ {exc}")
|
||||||
|
print(" 已保存截图 e2e/failure.png")
|
||||||
|
return 1
|
||||||
|
except Exception as exc: # noqa: BLE001 — 测试脚本需打印完整失败原因
|
||||||
|
page.screenshot(path="e2e/failure.png", full_page=True)
|
||||||
|
print(f"\n ✗ 执行中断:{type(exc).__name__}: {exc}")
|
||||||
|
return 1
|
||||||
|
finally:
|
||||||
|
browser.close()
|
||||||
|
print("\n全部通过。")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@ -249,18 +249,18 @@ def step_permission_check(page: Page, admin_password: str) -> None:
|
|||||||
page.click("text=退出登录")
|
page.click("text=退出登录")
|
||||||
page.wait_for_url("**/login**", timeout=10_000)
|
page.wait_for_url("**/login**", timeout=10_000)
|
||||||
|
|
||||||
login(page, VIEWER_USERNAME, E2E_PASSWORD)
|
login(page, VIEWER_USERNAME, E2E_PASSWORD, expected_path="/business/businesses")
|
||||||
if page.locator(".app-menu").get_by_text("系统管理", exact=True).count() != 0:
|
if page.locator(".app-menu").get_by_text("系统管理", exact=True).count() != 0:
|
||||||
fail("菜单权限", "普通用户看到了「系统管理」菜单")
|
fail("菜单权限", "普通用户看到了「系统管理」菜单")
|
||||||
print("✓ 11. 菜单权限 普通用户看不到「系统管理」分组")
|
print("✓ 11. 菜单权限 普通用户看不到「系统管理」分组")
|
||||||
|
|
||||||
page.goto(f"{BASE_URL}/system/users", wait_until="networkidle")
|
page.goto(f"{BASE_URL}/system/users", wait_until="networkidle")
|
||||||
page.wait_for_timeout(800)
|
page.wait_for_timeout(800)
|
||||||
if current_path(page) != "/dashboard":
|
if current_path(page) != "/business/businesses":
|
||||||
fail("路由守卫", f"普通用户直连管理页未被弹回,当前 {page.url}")
|
fail("路由守卫", f"普通用户直连管理页未被弹回,当前 {page.url}")
|
||||||
if "需要管理员权限" not in toast_text(page):
|
if "需要管理员权限" not in toast_text(page):
|
||||||
fail("路由守卫", "直连管理页未给出权限提示")
|
fail("路由守卫", "直连管理页未给出权限提示")
|
||||||
print("✓ 12. 路由守卫 直连 /system/users 被弹回首页")
|
print("✓ 12. 路由守卫 直连 /system/users 被弹回业务数据页")
|
||||||
|
|
||||||
page.click("text=退出登录")
|
page.click("text=退出登录")
|
||||||
page.wait_for_url("**/login**", timeout=10_000)
|
page.wait_for_url("**/login**", timeout=10_000)
|
||||||
|
|||||||
@ -67,7 +67,10 @@ export interface WarningBlock {
|
|||||||
enabled: boolean
|
enabled: boolean
|
||||||
receipt_overdue_days: number
|
receipt_overdue_days: number
|
||||||
approval_timeout_days: number
|
approval_timeout_days: number
|
||||||
|
/** 当前返回的条目(受展示上限约束) */
|
||||||
items: WarningItem[]
|
items: WarningItem[]
|
||||||
|
/** 全部规则命中总数;徽标计数用它,不能用 items.length */
|
||||||
|
total: number
|
||||||
receipt_overdue_total: number
|
receipt_overdue_total: number
|
||||||
approval_timeout_total: number
|
approval_timeout_total: number
|
||||||
sync_available: boolean
|
sync_available: boolean
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { useRoute, useRouter } from 'vue-router'
|
|||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
|
|
||||||
import AppMenu from '@/components/layout/AppMenu.vue'
|
import AppMenu from '@/components/layout/AppMenu.vue'
|
||||||
|
import { firstAccessiblePath } from '@/router/access'
|
||||||
import { useUserStore } from '@/store/user'
|
import { useUserStore } from '@/store/user'
|
||||||
|
|
||||||
/** 基础布局:左侧品牌导航(窄屏收为抽屉)+ 顶栏(面包屑/用户/退出)+ 内容区。 */
|
/** 基础布局:左侧品牌导航(窄屏收为抽屉)+ 顶栏(面包屑/用户/退出)+ 内容区。 */
|
||||||
@ -13,11 +14,14 @@ const userStore = useUserStore()
|
|||||||
|
|
||||||
const drawerVisible = ref(false)
|
const drawerVisible = ref(false)
|
||||||
|
|
||||||
|
/** 「首页」按角色解析:普通用户没有工作台权限,固定链到业务数据页 */
|
||||||
|
const homePath = computed(() => firstAccessiblePath(router, userStore.roles))
|
||||||
|
|
||||||
/** 面包屑:首页 + 分组(如有)+ 当前页标题(首页自身不重复) */
|
/** 面包屑:首页 + 分组(如有)+ 当前页标题(首页自身不重复) */
|
||||||
const breadcrumbs = computed<string[]>(() => {
|
const breadcrumbs = computed<string[]>(() => {
|
||||||
const items: string[] = []
|
const items: string[] = []
|
||||||
if (route.meta.group) items.push(route.meta.group)
|
if (route.meta.group) items.push(route.meta.group)
|
||||||
if (route.meta.title && route.path !== '/dashboard') items.push(route.meta.title)
|
if (route.meta.title && route.path !== homePath.value) items.push(route.meta.title)
|
||||||
return items
|
return items
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -66,7 +70,7 @@ async function handleLogout(): Promise<void> {
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<el-breadcrumb separator="/">
|
<el-breadcrumb separator="/">
|
||||||
<el-breadcrumb-item :to="{ path: '/dashboard' }">首页</el-breadcrumb-item>
|
<el-breadcrumb-item :to="{ path: homePath }">首页</el-breadcrumb-item>
|
||||||
<el-breadcrumb-item v-for="item in breadcrumbs" :key="item">
|
<el-breadcrumb-item v-for="item in breadcrumbs" :key="item">
|
||||||
{{ item }}
|
{{ item }}
|
||||||
</el-breadcrumb-item>
|
</el-breadcrumb-item>
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
import { canAccessRoute, isMenuRecord } from '@/router/access'
|
||||||
import { useUserStore } from '@/store/user'
|
import { useUserStore } from '@/store/user'
|
||||||
import { ROLE_ADMIN } from '@/utils/constants'
|
|
||||||
|
|
||||||
/** 菜单节点:从路由表派生,避免菜单标题与页面标题两处维护漂移。 */
|
/** 菜单节点:从路由表派生,避免菜单标题与页面标题两处维护漂移。 */
|
||||||
export interface MenuNode {
|
export interface MenuNode {
|
||||||
@ -30,19 +30,13 @@ export function useAppMenu() {
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
|
||||||
const isAdmin = computed(() => userStore.roles.includes(ROLE_ADMIN))
|
|
||||||
const userRoles = computed(() => userStore.roles)
|
const userRoles = computed(() => userStore.roles)
|
||||||
|
|
||||||
const menuNodes = computed<MenuNode[]>(() =>
|
const menuNodes = computed<MenuNode[]>(() =>
|
||||||
router
|
router
|
||||||
.getRoutes()
|
.getRoutes()
|
||||||
.filter((record) => Boolean(record.meta.title) && record.meta.menu !== false)
|
// 准入规则与路由守卫、落地页解析共用,避免三处漂移
|
||||||
.filter((record) => !record.meta.requiresAdmin || isAdmin.value)
|
.filter((record) => isMenuRecord(record) && canAccessRoute(record, userRoles.value))
|
||||||
// 职能页面(审批等):任一列入角色命中即可见(admin 须显式列入集合)
|
|
||||||
.filter(
|
|
||||||
(record) =>
|
|
||||||
!record.meta.allowedRoles || record.meta.allowedRoles.some((role) => userRoles.value.includes(role)),
|
|
||||||
)
|
|
||||||
.map((record) => ({
|
.map((record) => ({
|
||||||
path: record.path,
|
path: record.path,
|
||||||
title: record.meta.title ?? '',
|
title: record.meta.title ?? '',
|
||||||
|
|||||||
62
frontend/src/router/access.ts
Normal file
62
frontend/src/router/access.ts
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
import type { RouteRecordNormalized, Router } from 'vue-router'
|
||||||
|
|
||||||
|
import { ROLE_ADMIN } from '@/utils/constants'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 路由准入与落地页解析(菜单、守卫、登录回跳共用一份规则)。
|
||||||
|
*
|
||||||
|
* 之前守卫两处兜底硬编码 `/dashboard`,工作台收紧权限后普通用户会被
|
||||||
|
* 反复重定向到同一受限页(无限循环)。集中在这里后,任何角色的落地页
|
||||||
|
* 都必须先通过同一准入判定。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 可作为菜单项/落地页:有标题、未被 menu:false 排除、非免登录页 */
|
||||||
|
export function isMenuRecord(record: RouteRecordNormalized): boolean {
|
||||||
|
return Boolean(record.meta.title) && record.meta.menu !== false && !record.meta.public
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 当前角色是否可访问该路由(与守卫、菜单过滤同源) */
|
||||||
|
export function canAccessRoute(record: RouteRecordNormalized, roles: string[]): boolean {
|
||||||
|
if (record.meta.public) return false
|
||||||
|
if (record.meta.requiresAdmin && !roles.includes(ROLE_ADMIN)) return false
|
||||||
|
if (
|
||||||
|
record.meta.allowedRoles &&
|
||||||
|
!record.meta.allowedRoles.some((role) => roles.includes(role))
|
||||||
|
) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 落地页候选(按优先级):工作台 → 业务数据 → 回款。
|
||||||
|
* 普通用户没有工作台权限时落到业务数据页,而不是被菜单 order 带到
|
||||||
|
* 基础数据管理页;候选都不可访问时再按菜单顺序兜底。
|
||||||
|
*/
|
||||||
|
const HOME_CANDIDATES = ['/dashboard', '/business/businesses', '/business/receipts']
|
||||||
|
|
||||||
|
function matchedRecord(router: Router, path: string): RouteRecordNormalized | undefined {
|
||||||
|
const matched = router.resolve(path).matched
|
||||||
|
return matched[matched.length - 1]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 当前角色可访问的默认首页;无任何可访问页时回登录页(防循环) */
|
||||||
|
export function firstAccessiblePath(router: Router, roles: string[]): string {
|
||||||
|
for (const path of HOME_CANDIDATES) {
|
||||||
|
const record = matchedRecord(router, path)
|
||||||
|
if (record && isMenuRecord(record) && canAccessRoute(record, roles)) return path
|
||||||
|
}
|
||||||
|
const fallback = router
|
||||||
|
.getRoutes()
|
||||||
|
.filter(isMenuRecord)
|
||||||
|
.filter((record) => canAccessRoute(record, roles))
|
||||||
|
.sort((a, b) => (a.meta.order ?? 99) - (b.meta.order ?? 99))
|
||||||
|
return fallback[0]?.path ?? '/login'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 登录回跳目标是否可信且可访问(防开放重定向与回跳受限页) */
|
||||||
|
export function canAccessPath(router: Router, path: string, roles: string[]): boolean {
|
||||||
|
if (!path.startsWith('/') || path.startsWith('//')) return false
|
||||||
|
const record = matchedRecord(router, path)
|
||||||
|
return Boolean(record && isMenuRecord(record) && canAccessRoute(record, roles))
|
||||||
|
}
|
||||||
@ -1,6 +1,7 @@
|
|||||||
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
|
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
|
|
||||||
|
import { firstAccessiblePath } from '@/router/access'
|
||||||
import { useUserStore } from '@/store/user'
|
import { useUserStore } from '@/store/user'
|
||||||
import { ROLE_ADMIN } from '@/utils/constants'
|
import { ROLE_ADMIN } from '@/utils/constants'
|
||||||
|
|
||||||
@ -265,14 +266,17 @@ router.beforeEach(async (to) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (to.meta.requiresAdmin && !userStore.roles.includes(ROLE_ADMIN)) {
|
if (to.meta.requiresAdmin && !userStore.roles.includes(ROLE_ADMIN)) {
|
||||||
ElMessage.error('需要管理员权限')
|
const home = firstAccessiblePath(router, userStore.roles)
|
||||||
return { path: '/dashboard' }
|
// 根路径/catch-all 的重定向不提示;用户手输受限 URL 才提示
|
||||||
|
if (!to.redirectedFrom) ElMessage.error('需要管理员权限')
|
||||||
|
return home === to.path ? { path: '/login' } : { path: home }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 职能页面(审批等):任一角色命中即可进;菜单隐藏之外的第二道防线
|
// 职能页面(审批等):任一角色命中即可进;菜单隐藏之外的第二道防线
|
||||||
if (to.meta.allowedRoles && !to.meta.allowedRoles.some((role) => userStore.roles.includes(role))) {
|
if (to.meta.allowedRoles && !to.meta.allowedRoles.some((role) => userStore.roles.includes(role))) {
|
||||||
ElMessage.error('无权访问该页面')
|
const home = firstAccessiblePath(router, userStore.roles)
|
||||||
return { path: '/dashboard' }
|
if (!to.redirectedFrom) ElMessage.error('无权访问该页面')
|
||||||
|
return home === to.path ? { path: '/login' } : { path: home }
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
|
|||||||
@ -42,6 +42,8 @@ const ORDER_TYPE_LABELS: Record<number, string> = {
|
|||||||
const month = ref(currentMonth())
|
const month = ref(currentMonth())
|
||||||
const data = ref<DashboardData | null>(null)
|
const data = ref<DashboardData | null>(null)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
/** 快速切换月份时,只允许最新请求写入数据与 loading */
|
||||||
|
let loadSeq = 0
|
||||||
|
|
||||||
const perfChartRef = ref<HTMLDivElement | null>(null)
|
const perfChartRef = ref<HTMLDivElement | null>(null)
|
||||||
const receiptChartRef = ref<HTMLDivElement | null>(null)
|
const receiptChartRef = ref<HTMLDivElement | null>(null)
|
||||||
@ -54,15 +56,18 @@ function currentMonth(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function load(): Promise<void> {
|
async function load(): Promise<void> {
|
||||||
|
const seq = ++loadSeq
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
data.value = await dashboardApi.getDashboard({ month: month.value })
|
const result = await dashboardApi.getDashboard({ month: month.value })
|
||||||
|
if (seq !== loadSeq) return // 已有更新的请求,丢弃过期响应
|
||||||
|
data.value = result
|
||||||
await nextTick()
|
await nextTick()
|
||||||
renderCharts()
|
renderCharts()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handledError(error)
|
if (seq === loadSeq) handledError(error)
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
if (seq === loadSeq) loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -294,7 +299,7 @@ function formatTime(value: string): string {
|
|||||||
<div class="bento-card b-2x2">
|
<div class="bento-card b-2x2">
|
||||||
<div class="b-eyebrow">
|
<div class="b-eyebrow">
|
||||||
风险预警
|
风险预警
|
||||||
<span class="b-count">{{ data?.warnings.items.length ?? 0 }}</span>
|
<span class="b-count">{{ data?.warnings.total ?? 0 }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="data && !data.warnings.enabled" class="b-empty-mini">
|
<div v-if="data && !data.warnings.enabled" class="b-empty-mini">
|
||||||
预警已关闭(参数 warning.enabled=0)
|
预警已关闭(参数 warning.enabled=0)
|
||||||
@ -322,6 +327,12 @@ function formatTime(value: string): string {
|
|||||||
暂无预警(回款逾期 {{ data?.warnings.receipt_overdue_days ?? 90 }} 天 /
|
暂无预警(回款逾期 {{ data?.warnings.receipt_overdue_days ?? 90 }} 天 /
|
||||||
审批超时 {{ data?.warnings.approval_timeout_days ?? 3 }} 天)
|
审批超时 {{ data?.warnings.approval_timeout_days ?? 3 }} 天)
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="data && data.warnings.total > data.warnings.items.length"
|
||||||
|
class="b-empty-mini"
|
||||||
|
>
|
||||||
|
仅显示前 {{ data.warnings.items.length }} 条,共 {{ data.warnings.total }} 条
|
||||||
|
</div>
|
||||||
<div class="b-eyebrow b-eyebrow-gap">同步异常预警</div>
|
<div class="b-eyebrow b-eyebrow-gap">同步异常预警</div>
|
||||||
<div class="b-empty-mini">
|
<div class="b-empty-mini">
|
||||||
{{ data?.warnings.sync_available ? '已接入' : '同步模块未启用(待 T+ 对接)' }}
|
{{ data?.warnings.sync_available ? '已接入' : '同步模块未启用(待 T+ 对接)' }}
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { onMounted, reactive, ref } from 'vue'
|
|||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
|
||||||
import { getCaptcha } from '@/api/auth'
|
import { getCaptcha } from '@/api/auth'
|
||||||
|
import { canAccessPath, firstAccessiblePath } from '@/router/access'
|
||||||
import { useUserStore } from '@/store/user'
|
import { useUserStore } from '@/store/user'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@ -87,8 +88,15 @@ async function handleSubmit(): Promise<void> {
|
|||||||
captcha_id: captchaId.value,
|
captcha_id: captchaId.value,
|
||||||
captcha_code: form.captcha_code.trim(),
|
captcha_code: form.captcha_code.trim(),
|
||||||
})
|
})
|
||||||
|
// 先拉角色再解析落地页:普通用户不能回跳到受限的工作台
|
||||||
|
await userStore.fetchUserInfo()
|
||||||
ElMessage.success('登录成功')
|
ElMessage.success('登录成功')
|
||||||
await router.replace((route.query.redirect as string) || '/dashboard')
|
const redirect = route.query.redirect
|
||||||
|
const target =
|
||||||
|
typeof redirect === 'string' && canAccessPath(router, redirect, userStore.roles)
|
||||||
|
? redirect
|
||||||
|
: firstAccessiblePath(router, userStore.roles)
|
||||||
|
await router.replace(target)
|
||||||
} catch {
|
} catch {
|
||||||
// **必须刷新验证码**:后端用 GETDEL 消费验证码,无论何种失败它都已失效。
|
// **必须刷新验证码**:后端用 GETDEL 消费验证码,无论何种失败它都已失效。
|
||||||
// 不刷新的话,用户第二次提交必然报「验证码错误」,且极难自查原因。
|
// 不刷新的话,用户第二次提交必然报「验证码错误」,且极难自查原因。
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user