diff --git a/backend/app/api/v1/dashboard.py b/backend/app/api/v1/dashboard.py index c9d7077..b0f492a 100644 --- a/backend/app/api/v1/dashboard.py +++ b/backend/app/api/v1/dashboard.py @@ -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) + ) diff --git a/backend/app/repositories/dashboard_repository.py b/backend/app/repositories/dashboard_repository.py index 2b4d0cf..179a03c 100644 --- a/backend/app/repositories/dashboard_repository.py +++ b/backend/app/repositories/dashboard_repository.py @@ -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() ) diff --git a/backend/app/schemas/dashboard.py b/backend/app/schemas/dashboard.py index bfad0d1..d52bc7b 100644 --- a/backend/app/schemas/dashboard.py +++ b/backend/app/schemas/dashboard.py @@ -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 diff --git a/backend/app/services/approval_service.py b/backend/app/services/approval_service.py index e0de787..e0cc6dd 100644 --- a/backend/app/services/approval_service.py +++ b/backend/app/services/approval_service.py @@ -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) diff --git a/backend/app/services/dashboard_service.py b/backend/app/services/dashboard_service.py index 4f6ddef..e96fe89 100644 --- a/backend/app/services/dashboard_service.py +++ b/backend/app/services/dashboard_service.py @@ -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, } diff --git a/backend/tests/integration/test_dashboard.py b/backend/tests/integration/test_dashboard.py index 6a5c5a3..67e6d47 100644 --- a/backend/tests/integration/test_dashboard.py +++ b/backend/tests/integration/test_dashboard.py @@ -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") diff --git a/frontend/e2e/common.py b/frontend/e2e/common.py index 7b56dee..e0ab5c0 100644 --- a/frontend/e2e/common.py +++ b/frontend/e2e/common.py @@ -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) diff --git a/frontend/e2e/role_home_flow.py b/frontend/e2e/role_home_flow.py new file mode 100644 index 0000000..7bc4765 --- /dev/null +++ b/frontend/e2e/role_home_flow.py @@ -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()) diff --git a/frontend/e2e/system_management.py b/frontend/e2e/system_management.py index cb4ace2..8d69713 100644 --- a/frontend/e2e/system_management.py +++ b/frontend/e2e/system_management.py @@ -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) diff --git a/frontend/src/api/dashboard.ts b/frontend/src/api/dashboard.ts index 3dba29e..1cf1153 100644 --- a/frontend/src/api/dashboard.ts +++ b/frontend/src/api/dashboard.ts @@ -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 diff --git a/frontend/src/components/layout/BasicLayout.vue b/frontend/src/components/layout/BasicLayout.vue index be56057..052f0d2 100644 --- a/frontend/src/components/layout/BasicLayout.vue +++ b/frontend/src/components/layout/BasicLayout.vue @@ -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(() => { 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 { - 首页 + 首页 {{ item }} diff --git a/frontend/src/composables/useAppMenu.ts b/frontend/src/composables/useAppMenu.ts index 2bc464b..966f36b 100644 --- a/frontend/src/composables/useAppMenu.ts +++ b/frontend/src/composables/useAppMenu.ts @@ -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(() => 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 ?? '', diff --git a/frontend/src/router/access.ts b/frontend/src/router/access.ts new file mode 100644 index 0000000..4d0cda9 --- /dev/null +++ b/frontend/src/router/access.ts @@ -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)) +} diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 8abf96e..0dbc0b0 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -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 diff --git a/frontend/src/views/dashboard/index.vue b/frontend/src/views/dashboard/index.vue index 05a081a..21d1419 100644 --- a/frontend/src/views/dashboard/index.vue +++ b/frontend/src/views/dashboard/index.vue @@ -42,6 +42,8 @@ const ORDER_TYPE_LABELS: Record = { const month = ref(currentMonth()) const data = ref(null) const loading = ref(false) +/** 快速切换月份时,只允许最新请求写入数据与 loading */ +let loadSeq = 0 const perfChartRef = ref(null) const receiptChartRef = ref(null) @@ -54,15 +56,18 @@ function currentMonth(): string { } async function load(): Promise { + 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 {
风险预警 - {{ data?.warnings.items.length ?? 0 }} + {{ data?.warnings.total ?? 0 }}
预警已关闭(参数 warning.enabled=0) @@ -322,6 +327,12 @@ function formatTime(value: string): string { 暂无预警(回款逾期 {{ data?.warnings.receipt_overdue_days ?? 90 }} 天 / 审批超时 {{ data?.warnings.approval_timeout_days ?? 3 }} 天)
+
+ 仅显示前 {{ data.warnings.items.length }} 条,共 {{ data.warnings.total }} 条 +
同步异常预警
{{ data?.warnings.sync_available ? '已接入' : '同步模块未启用(待 T+ 对接)' }} diff --git a/frontend/src/views/login/index.vue b/frontend/src/views/login/index.vue index 3b279ab..bee99f8 100644 --- a/frontend/src/views/login/index.vue +++ b/frontend/src/views/login/index.vue @@ -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 { 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 消费验证码,无论何种失败它都已失效。 // 不刷新的话,用户第二次提交必然报「验证码错误」,且极难自查原因。