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="工作台四区块")
|
||||
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)
|
||||
)
|
||||
|
||||
@ -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()
|
||||
)
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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)
|
||||
|
||||
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.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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 ?? '',
|
||||
|
||||
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 { 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
|
||||
|
||||
@ -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+ 对接)' }}
|
||||
|
||||
@ -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 消费验证码,无论何种失败它都已失效。
|
||||
// 不刷新的话,用户第二次提交必然报「验证码错误」,且极难自查原因。
|
||||
|
||||
Loading…
Reference in New Issue
Block a user