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:
liuwenxin 2026-10-20 09:45:03 +08:00
parent ebb3a92f25
commit 39a3fa7796
16 changed files with 590 additions and 59 deletions

View File

@ -30,10 +30,15 @@ _DashboardUser = Annotated[
@router.get("", response_model=ApiResponse[DashboardOut], summary="工作台四区块")
def get_dashboard(
session: DbSession,
_: _DashboardUser,
current_user: _DashboardUser,
month: Annotated[
str | None, Query(max_length=7, description="统计月份 YYYY-MM,默认当月")
] = None,
) -> ApiResponse[DashboardOut]:
"""一次返回经营总览/待办/风险预警/最近动态(待办与预警实时派生)。"""
return ok(dashboard_service.get_dashboard(session, month))
"""一次返回经营总览/待办/风险预警/最近动态(待办与预警实时派生)。
待办按当前用户角色只返回其可操作节点(admin 可查看全部在途单)。
"""
return ok(
dashboard_service.get_dashboard(session, month, current_user=current_user)
)

View File

@ -7,7 +7,7 @@
from datetime import date, datetime
from sqlalchemy import exists, func, select
from sqlalchemy import exists, func, or_, select
from sqlalchemy.orm import Session
from app.models import (
@ -66,12 +66,18 @@ def performance_trend(session: Session, months: list[str]) -> dict[str, object]:
return dict(rows)
def receipt_trend(session: Session, months: list[str]) -> dict[str, object]:
"""近 N 个月回款合计(按到账月,含未核算回款——与台账口径不同,页面注明)。"""
def receipt_trend(
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")
rows = session.execute(
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)
).all()
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(
session.execute(
select(ApprovalOrder)
.where(ApprovalOrder.status.in_(_PENDING_STATUSES))
.where(ApprovalOrder.status.in_(statuses))
.order_by(ApprovalOrder.create_time.asc(), ApprovalOrder.id.asc())
.limit(limit)
).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(
session.execute(
select(func.count())
.select_from(ApprovalOrder)
.where(ApprovalOrder.status.in_(_PENDING_STATUSES))
.where(ApprovalOrder.status.in_(statuses))
).scalar_one()
)
@ -152,7 +167,11 @@ def count_unassigned_receipts(session: Session) -> int:
# ==== 风险预警 ====
def list_overdue_shipments(session: Session, *, cutoff: date, limit: int) -> list:
"""发货后超过阈值且该批次无任何回款的发货单(回款逾期规则)。"""
"""发货后超过阈值且该批次无任何回款的发货单(回款逾期规则)。
已终止业务(status=3)不再催收,排除在预警之外;业务行缺失时仍保留,
避免历史脏数据让发货单从预警中静默消失。
"""
has_receipt = exists().where(Receipt.shipment_id == Shipment.id)
return list(
session.execute(
@ -161,7 +180,11 @@ def list_overdue_shipments(session: Session, *, cutoff: date, limit: int) -> lis
Business.business_no.label("business_no"),
)
.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())
.limit(limit)
).all()
@ -174,7 +197,12 @@ def count_overdue_shipments(session: Session, *, cutoff: date) -> int:
session.execute(
select(func.count())
.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()
)

View File

@ -98,6 +98,7 @@ class WarningBlock(BaseModel):
receipt_overdue_days: int = 90
approval_timeout_days: int = 3
items: list[WarningItem] = Field(default_factory=list)
total: int = Field(default=0, description="全部规则命中总数(items 仅为展示上限内的子集)")
receipt_overdue_total: int = 0
approval_timeout_total: int = 0
sync_available: bool = False

View File

@ -19,6 +19,7 @@
"""
import logging
from collections.abc import Iterable
from datetime import date
from sqlalchemy import func, select
@ -29,6 +30,7 @@ from app.core.constants import (
ACTION_REJECT,
MODULE_APPROVAL,
NO_PREFIX_APPROVAL,
ROLE_ADMIN,
ROLE_FINANCE,
ROLE_LEADER,
)
@ -84,6 +86,18 @@ _NODE_ROLE_BY_STATUS = {
_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:
"""读当日参数快照判定审批开关(一次提交路径内只读一次,调用方勿重读)。"""
code = ENABLE_CODE_BY_TYPE.get(order_type)

View File

@ -18,8 +18,9 @@ from sqlalchemy.orm import Session
from app.core.exceptions import BadRequestError
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.services import approval_service
# 工作台只展示摘要,完整列表跳转对应管理页
_TODO_LIMIT = 5
@ -30,14 +31,21 @@ _TREND_MONTHS = 6
_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)
warning_params = _warning_params(session)
pending_statuses = approval_service.pending_statuses_for_roles(
user_repository.get_role_codes(current_user)
)
return {
"month": resolved,
"overview": _overview(session, resolved),
"todos": _todos(session),
"todos": _todos(session, pending_statuses),
"warnings": _warnings(session, warning_params),
"activities": _activities(session),
}
@ -61,6 +69,11 @@ def _shift_month(month: str, delta: int) -> str:
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]:
"""窗口为「当月及之前共 6 个月」,升序返回便于前端画图。"""
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)
months = _trend_months(month)
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 {
"month": month,
"performance_total": _dec(sums["performance_total"]),
@ -94,16 +111,27 @@ def _overview(session: Session, month: str) -> dict:
}
for item in months
],
"note": "绩效按台账归属月(单笔生效行,二次分摊拆行求和不失真);回款按到账月(含未核算回款);不含调整单",
"note": (
"绩效按台账归属月(单笔生效行,二次分摊拆行求和不失真);回款按到账月"
"(含未核算回款);不含经理 12.5%+5% 聚合分成与调整单"
),
}
def _todos(session: Session) -> dict:
"""待办事项(FR-034,澄清 #11 起步范围)。"""
orders = dashboard_repository.list_pending_approvals(session, limit=_TODO_LIMIT)
def _todos(session: Session, pending_statuses: tuple[int, ...]) -> dict:
"""待办事项(FR-034,澄清 #11 起步范围)。
``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)
return {
"pending_approval_total": dashboard_repository.count_pending_approvals(session),
"pending_approval_total": dashboard_repository.count_pending_approvals(
session, statuses=pending_statuses
),
"pending_approvals": [
{
"order_id": order.id,
@ -154,6 +182,7 @@ def _warnings(session: Session, params: dict) -> dict:
"receipt_overdue_days": params["receipt_overdue_days"],
"approval_timeout_days": params["approval_timeout_days"],
"items": [],
"total": 0,
"receipt_overdue_total": 0,
"approval_timeout_total": 0,
"sync_available": False,
@ -201,17 +230,21 @@ def _warnings(session: Session, params: dict) -> dict:
}
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 {
"enabled": True,
"receipt_overdue_days": params["receipt_overdue_days"],
"approval_timeout_days": params["approval_timeout_days"],
"items": items,
"receipt_overdue_total": dashboard_repository.count_overdue_shipments(
session, cutoff=receipt_cutoff
),
"approval_timeout_total": dashboard_repository.count_overdue_approvals(
session, cutoff=approval_cutoff
),
# items 有展示上限(_WARNING_LIMIT),徽标计数必须用全量命中数
"total": receipt_overdue_total + approval_timeout_total,
"receipt_overdue_total": receipt_overdue_total,
"approval_timeout_total": approval_timeout_total,
"sync_available": False,
}

View File

@ -191,7 +191,7 @@ def test_预警_审批超时命中且参数关闭后不产生(client: TestClient
client.post(f"{_SETTLEMENTS}/{settlement_id}/submit", headers=admin["headers"])
# 把待审批单提交时间改到 4 天前(阈值默认 3 天)
from sqlalchemy import select, update
from sqlalchemy import update
from app.core.db import get_session_factory
from app.models import ApprovalOrder
@ -230,3 +230,224 @@ def test_动态_来源操作日志(client: TestClient, admin: dict):
data = _dashboard(client, admin["headers"])
assert data["activities"], "工作台动态为空:写操作应产生操作日志"
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")

View File

@ -112,10 +112,12 @@ def expect_toast(page: Page, expected: str, step: str, timeout_ms: int = 5000) -
fail(step, f"未出现提示「{expected}」,当前提示:{text or '(无)'}")
def login(page: Page, username: str, password: str) -> None:
"""完整登录:打开登录页 → 从 Redis 取验证码答案 → 提交 → 等待进入首页。
def login(page: Page, username: str, password: str, expected_path: str = "/dashboard") -> None:
"""完整登录:打开登录页 → 从 Redis 取验证码答案 → 提交 → 等待进入落地页。
验证码 id 经响应监听捕获(前端把它内联在响应体里,不是 URL 参数)。
``expected_path`` 按角色传入:管理员/财务/领导默认工作台,普通用户
落业务数据页(工作台权限收紧后守卫按角色解析首页)。
"""
captcha_ids: list[str] = []
@ -148,8 +150,8 @@ def login(page: Page, username: str, password: str) -> None:
page.fill('input[autocomplete="off"]', answer)
page.click("button:has-text('登 录')")
page.wait_for_timeout(2000)
if current_path(page) != "/dashboard":
detail = f"未跳转到首页,当前 {page.url}"
if current_path(page) != expected_path:
detail = f"未跳转到 {expected_path},当前 {page.url}"
if message := toast_text(page):
detail += f",提示:{message}"
fail("登录", detail)

View 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())

View File

@ -249,18 +249,18 @@ def step_permission_check(page: Page, admin_password: str) -> None:
page.click("text=退出登录")
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:
fail("菜单权限", "普通用户看到了「系统管理」菜单")
print("✓ 11. 菜单权限 普通用户看不到「系统管理」分组")
page.goto(f"{BASE_URL}/system/users", wait_until="networkidle")
page.wait_for_timeout(800)
if current_path(page) != "/dashboard":
if current_path(page) != "/business/businesses":
fail("路由守卫", f"普通用户直连管理页未被弹回,当前 {page.url}")
if "需要管理员权限" not in toast_text(page):
fail("路由守卫", "直连管理页未给出权限提示")
print("✓ 12. 路由守卫 直连 /system/users 被弹回首页")
print("✓ 12. 路由守卫 直连 /system/users 被弹回业务数据页")
page.click("text=退出登录")
page.wait_for_url("**/login**", timeout=10_000)

View File

@ -67,7 +67,10 @@ export interface WarningBlock {
enabled: boolean
receipt_overdue_days: number
approval_timeout_days: number
/** 当前返回的条目(受展示上限约束) */
items: WarningItem[]
/** 全部规则命中总数;徽标计数用它,不能用 items.length */
total: number
receipt_overdue_total: number
approval_timeout_total: number
sync_available: boolean

View File

@ -4,6 +4,7 @@ import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import AppMenu from '@/components/layout/AppMenu.vue'
import { firstAccessiblePath } from '@/router/access'
import { useUserStore } from '@/store/user'
/** 基础布局:左侧品牌导航(窄屏收为抽屉)+ 顶栏(面包屑/用户/退出)+ 内容区。 */
@ -13,11 +14,14 @@ const userStore = useUserStore()
const drawerVisible = ref(false)
/** 「首页」按角色解析:普通用户没有工作台权限,固定链到业务数据页 */
const homePath = computed(() => firstAccessiblePath(router, userStore.roles))
/** 面包屑:首页 + 分组(如有)+ 当前页标题(首页自身不重复) */
const breadcrumbs = computed<string[]>(() => {
const items: string[] = []
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
})
@ -66,7 +70,7 @@ async function handleLogout(): Promise<void> {
</svg>
</button>
<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">
{{ item }}
</el-breadcrumb-item>

View File

@ -1,8 +1,8 @@
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { canAccessRoute, isMenuRecord } from '@/router/access'
import { useUserStore } from '@/store/user'
import { ROLE_ADMIN } from '@/utils/constants'
/** 菜单节点:从路由表派生,避免菜单标题与页面标题两处维护漂移。 */
export interface MenuNode {
@ -30,19 +30,13 @@ export function useAppMenu() {
const router = useRouter()
const userStore = useUserStore()
const isAdmin = computed(() => userStore.roles.includes(ROLE_ADMIN))
const userRoles = computed(() => userStore.roles)
const menuNodes = computed<MenuNode[]>(() =>
router
.getRoutes()
.filter((record) => Boolean(record.meta.title) && record.meta.menu !== false)
.filter((record) => !record.meta.requiresAdmin || isAdmin.value)
// 职能页面(审批等):任一列入角色命中即可见(admin 须显式列入集合)
.filter(
(record) =>
!record.meta.allowedRoles || record.meta.allowedRoles.some((role) => userRoles.value.includes(role)),
)
// 准入规则与路由守卫、落地页解析共用,避免三处漂移
.filter((record) => isMenuRecord(record) && canAccessRoute(record, userRoles.value))
.map((record) => ({
path: record.path,
title: record.meta.title ?? '',

View 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))
}

View File

@ -1,6 +1,7 @@
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
import { ElMessage } from 'element-plus'
import { firstAccessiblePath } from '@/router/access'
import { useUserStore } from '@/store/user'
import { ROLE_ADMIN } from '@/utils/constants'
@ -265,14 +266,17 @@ router.beforeEach(async (to) => {
}
if (to.meta.requiresAdmin && !userStore.roles.includes(ROLE_ADMIN)) {
ElMessage.error('需要管理员权限')
return { path: '/dashboard' }
const home = firstAccessiblePath(router, userStore.roles)
// 根路径/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))) {
ElMessage.error('无权访问该页面')
return { path: '/dashboard' }
const home = firstAccessiblePath(router, userStore.roles)
if (!to.redirectedFrom) ElMessage.error('无权访问该页面')
return home === to.path ? { path: '/login' } : { path: home }
}
return true

View File

@ -42,6 +42,8 @@ const ORDER_TYPE_LABELS: Record<number, string> = {
const month = ref(currentMonth())
const data = ref<DashboardData | null>(null)
const loading = ref(false)
/** 快速切换月份时,只允许最新请求写入数据与 loading */
let loadSeq = 0
const perfChartRef = ref<HTMLDivElement | null>(null)
const receiptChartRef = ref<HTMLDivElement | null>(null)
@ -54,15 +56,18 @@ function currentMonth(): string {
}
async function load(): Promise<void> {
const seq = ++loadSeq
loading.value = true
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()
renderCharts()
} catch (error) {
handledError(error)
if (seq === loadSeq) handledError(error)
} 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="b-eyebrow">
风险预警
<span class="b-count">{{ data?.warnings.items.length ?? 0 }}</span>
<span class="b-count">{{ data?.warnings.total ?? 0 }}</span>
</div>
<div v-if="data && !data.warnings.enabled" class="b-empty-mini">
预警已关闭(参数 warning.enabled=0)
@ -322,6 +327,12 @@ function formatTime(value: string): string {
暂无预警(回款逾期 {{ data?.warnings.receipt_overdue_days ?? 90 }} 天 /
审批超时 {{ data?.warnings.approval_timeout_days ?? 3 }} 天)
</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-empty-mini">
{{ data?.warnings.sync_available ? '已接入' : '同步模块未启用(待 T+ 对接)' }}

View File

@ -4,6 +4,7 @@ import { onMounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { getCaptcha } from '@/api/auth'
import { canAccessPath, firstAccessiblePath } from '@/router/access'
import { useUserStore } from '@/store/user'
const router = useRouter()
@ -87,8 +88,15 @@ async function handleSubmit(): Promise<void> {
captcha_id: captchaId.value,
captcha_code: form.captcha_code.trim(),
})
// 先拉角色再解析落地页:普通用户不能回跳到受限的工作台
await userStore.fetchUserInfo()
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 {
// **必须刷新验证码**:后端用 GETDEL 消费验证码,无论何种失败它都已失效。
// 不刷新的话,用户第二次提交必然报「验证码错误」,且极难自查原因。