feat(dashboard): 最近动态改服务端分页
工作台「最近动态」原为聚合端点内嵌、固定截断前 20 条,操作日志变长后 既撑大响应又只能看到头部。改为独立分页端点: - GET /api/v1/dashboard/activities,支持 pageNum/pageSize/module 筛选 - 模块筛选项与列表同源返回(全量模块名,不随翻页变化),前端不必再取一次全量 - 聚合端点去掉内嵌 activities,避免同一份数据两处查询 前端改用 usePagedList + 统一 AppPagination(每页 10/20/50),模块筛选由 本地过滤改为服务端筛选。集成测试 13 例(含翻页不重不漏 / 模块筛选 / 越权 403)。 Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
parent
32b7940067
commit
61fe5cca98
@ -1,4 +1,4 @@
|
||||
"""工作台接口(M4 ⑧):四区块一次性聚合。
|
||||
"""工作台接口(M4 ⑧):三区块聚合 + 最近动态分页。
|
||||
|
||||
准入:管理员/财务/领导(与报表、审批接口一致;FR-033/036 的「全部用户」
|
||||
待业务权限清单 #6 答复后再放宽)。只读接口,不写操作日志。
|
||||
@ -12,7 +12,7 @@ from app.api.deps import DbSession, require_roles
|
||||
from app.core.constants import ROLE_ADMIN, ROLE_FINANCE, ROLE_LEADER
|
||||
from app.core.response import ApiResponse, ok
|
||||
from app.models import User
|
||||
from app.schemas.dashboard import DashboardOut
|
||||
from app.schemas.dashboard import ActivityPageOut, ActivityQuery, DashboardOut
|
||||
from app.services import dashboard_service
|
||||
|
||||
router = APIRouter(prefix="/dashboard", tags=["工作台"])
|
||||
@ -23,7 +23,7 @@ _DashboardUser = Annotated[
|
||||
]
|
||||
|
||||
|
||||
@router.get("", response_model=ApiResponse[DashboardOut], summary="工作台四区块")
|
||||
@router.get("", response_model=ApiResponse[DashboardOut], summary="工作台三区块")
|
||||
def get_dashboard(
|
||||
session: DbSession,
|
||||
current_user: _DashboardUser,
|
||||
@ -31,8 +31,22 @@ def get_dashboard(
|
||||
str | None, Query(max_length=7, description="统计月份 YYYY-MM,默认当月")
|
||||
] = None,
|
||||
) -> ApiResponse[DashboardOut]:
|
||||
"""一次返回经营总览/待办/风险预警/最近动态(待办与预警实时派生)。
|
||||
"""一次返回经营总览/待办/风险预警(待办与预警实时派生)。
|
||||
|
||||
待办按当前用户角色只返回其可操作节点(admin 可查看全部在途单)。
|
||||
最近动态是分页列表,走 `GET /dashboard/activities`。
|
||||
"""
|
||||
return ok(dashboard_service.get_dashboard(session, month, current_user=current_user))
|
||||
|
||||
|
||||
@router.get("/activities", response_model=ApiResponse[ActivityPageOut], summary="最近动态(分页)")
|
||||
def list_dashboard_activities(
|
||||
session: DbSession,
|
||||
current_user: _DashboardUser,
|
||||
query: Annotated[ActivityQuery, Query()],
|
||||
) -> ApiResponse[ActivityPageOut]:
|
||||
"""分页查询最近动态,可按模块筛选。
|
||||
|
||||
与聚合端点拆开:翻页只重取这张列表,不必重算总览/待办/预警。
|
||||
"""
|
||||
return ok(dashboard_service.list_activities(session, query))
|
||||
|
||||
@ -142,6 +142,15 @@ def count_logs(session: Session, query: OperationLogListQuery) -> int:
|
||||
return int(session.execute(stmt).scalar_one())
|
||||
|
||||
|
||||
def distinct_modules(session: Session) -> list[str]:
|
||||
"""全部模块名(去重、按名称升序):工作台「最近动态」的模块筛选项。
|
||||
|
||||
交给 SQL 的 DISTINCT,不在应用层聚合 —— 应用层去重要把整列拉进内存。
|
||||
"""
|
||||
stmt = select(OperationLog.module).distinct().order_by(OperationLog.module)
|
||||
return [module for module in session.execute(stmt).scalars() if module]
|
||||
|
||||
|
||||
def _apply_filters(stmt: Select, query: OperationLogListQuery) -> Select:
|
||||
"""应用筛选条件(公共部分复用 query_utils,与登录日志同口径)。"""
|
||||
stmt = apply_log_filters(stmt, OperationLog, query.filters)
|
||||
|
||||
@ -1,9 +1,13 @@
|
||||
"""工作台的请求/响应模型(M4 ⑧)。
|
||||
|
||||
四区块一次返回(前端一次请求渲染整页,避免 6-10 次往返):
|
||||
经营绩效总览 / 待办事项 / 风险预警 / 最近动态。金额一律 Decimal
|
||||
聚合端点一次返回三区块(前端一次请求渲染整页,避免多次往返):
|
||||
经营绩效总览 / 待办事项 / 风险预警。金额一律 Decimal
|
||||
(序列化为字符串),前端不做数值运算。
|
||||
|
||||
**最近动态独立成分页端点**:它可能有很长(操作日志全量),塞进聚合
|
||||
响应既撑爆页面又只能截断前 20 条;拆开后翻页只重取这张列表,不必
|
||||
重算总览/待办/预警。列表与模块筛选项同源返回(一次请求给全)。
|
||||
|
||||
待办与预警为**查询时实时派生**(不落库、不引调度器,见计划决策);
|
||||
⑥ T+ 同步未建前,同步相关条目用 `sync_available=false` 显式降级,
|
||||
不返回伪造的 0 条。
|
||||
@ -12,7 +16,9 @@
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.core.response import PageQuery
|
||||
|
||||
|
||||
class TrendPoint(BaseModel):
|
||||
@ -116,11 +122,42 @@ class ActivityItem(BaseModel):
|
||||
create_time: datetime
|
||||
|
||||
|
||||
class ActivityQuery(PageQuery):
|
||||
"""最近动态查询条件:分页 + 模块筛选。
|
||||
|
||||
只支持模块筛选(工作台只需要这一个维度);其余筛选项见日志查询页。
|
||||
"""
|
||||
|
||||
module: str | None = Field(default=None, max_length=64, description="模块,精确匹配")
|
||||
|
||||
|
||||
class ActivityPageOut(BaseModel):
|
||||
"""最近动态分页结果:当前页 + 总数 + 模块筛选项。
|
||||
|
||||
分页字段名沿用全站规范(list/total/pageNum/pageSize),与
|
||||
`PageResult` 同形;额外带 `modules`(全量模块名),
|
||||
供前端模块下拉使用 —— 不随当前页变化,避免翻页后选项增减。
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
records: list[ActivityItem] = Field(
|
||||
default_factory=list, alias="list", serialization_alias="list", description="当前页数据"
|
||||
)
|
||||
total: int = Field(default=0, description="总记录数")
|
||||
page_num: int = Field(
|
||||
default=1, alias="pageNum", serialization_alias="pageNum", description="当前页码"
|
||||
)
|
||||
page_size: int = Field(
|
||||
default=10, alias="pageSize", serialization_alias="pageSize", description="每页条数"
|
||||
)
|
||||
modules: list[str] = Field(default_factory=list, description="全部模块名(筛选项)")
|
||||
|
||||
|
||||
class DashboardOut(BaseModel):
|
||||
"""工作台四区块一次性响应。"""
|
||||
"""工作台三区块一次性响应(最近动态走独立分页端点)。"""
|
||||
|
||||
month: str
|
||||
overview: OverviewBlock
|
||||
todos: TodoBlock
|
||||
warnings: WarningBlock
|
||||
activities: list[ActivityItem] = Field(default_factory=list)
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
"""工作台服务(M4 ⑧):四区块实时聚合编排。
|
||||
"""工作台服务(M4 ⑧):区块实时聚合编排 + 最近动态分页。
|
||||
|
||||
设计决策(2026-10-19):
|
||||
- **只读**:不落库、不写审计(读操作不入操作日志);总览只做 SQL 聚合,
|
||||
@ -9,6 +9,8 @@
|
||||
- **⑥ 同步模块未启用**:同步失败待办、同步连续失败预警以
|
||||
``sync_available=false`` 显式降级,不返回伪造的 0 条
|
||||
- 预警阈值走参数体系(``warning.*``),一次请求只解析一次快照
|
||||
- **最近动态独立分页**(2026-10-23):操作日志可能很长,聚合响应里
|
||||
只能塞前 20 条;拆成独立端点后翻页只重取这张列表
|
||||
"""
|
||||
|
||||
from datetime import date, datetime, timedelta
|
||||
@ -20,22 +22,23 @@ from app.core.exceptions import BadRequestError
|
||||
from app.core.month import validate_month
|
||||
from app.repositories import dashboard_repository, operation_log_repository, user_repository
|
||||
from app.repositories.query_utils import LogFilters
|
||||
from app.schemas.dashboard import ActivityItem, ActivityPageOut, ActivityQuery
|
||||
from app.services import approval_service
|
||||
|
||||
# 工作台只展示摘要,完整列表跳转对应管理页
|
||||
_TODO_LIMIT = 5
|
||||
_WARNING_LIMIT = 20
|
||||
_ACTIVITY_LIMIT = 20
|
||||
_TREND_MONTHS = 6
|
||||
|
||||
_ORDER_TYPE_LABELS = {1: "二次分配方案", 2: "月度结算", 3: "调整单"}
|
||||
|
||||
|
||||
def get_dashboard(session: Session, month: str | None = None, *, current_user) -> dict:
|
||||
"""四区块一次性出数(前端一次请求渲染整页)。
|
||||
"""三区块一次性出数(前端一次请求渲染整页)。
|
||||
|
||||
待办按当前用户角色只展示其可操作节点(admin 可查看全部在途单);
|
||||
风险预警仍是全局经营视角,不按人裁剪。
|
||||
最近动态不在此返回 —— 它是分页列表,走 ``list_activities``。
|
||||
"""
|
||||
resolved = _normalize_month(month)
|
||||
warning_params = _warning_params(session)
|
||||
@ -47,7 +50,6 @@ def get_dashboard(session: Session, month: str | None = None, *, current_user) -
|
||||
"overview": _overview(session, resolved),
|
||||
"todos": _todos(session, pending_statuses),
|
||||
"warnings": _warnings(session, warning_params),
|
||||
"activities": _activities(session),
|
||||
}
|
||||
|
||||
|
||||
@ -112,8 +114,8 @@ def _overview(session: Session, month: str) -> dict:
|
||||
for item in months
|
||||
],
|
||||
"note": (
|
||||
"绩效按台账归属月(单笔生效行,二次分摊拆行求和不失真);回款按到账月"
|
||||
"(含未核算回款);不含经理 12.5%+5% 聚合分成与调整单"
|
||||
"绩效按台账归属月统计(二次分配按参与人拆分后求和,不重复不计漏);"
|
||||
"回款按到账月统计(含尚未核算的回款);不含经理团队分成与调整单"
|
||||
),
|
||||
}
|
||||
|
||||
@ -247,28 +249,38 @@ def _warnings(session: Session, params: dict) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _activities(session: Session) -> list[dict]:
|
||||
"""最近动态(FR-036):以操作日志为唯一来源(写操作即留痕)。"""
|
||||
logs = operation_log_repository.list_logs(
|
||||
session,
|
||||
operation_log_repository.OperationLogListQuery(
|
||||
filters=LogFilters(),
|
||||
offset=0,
|
||||
limit=_ACTIVITY_LIMIT,
|
||||
),
|
||||
def list_activities(session: Session, query: ActivityQuery) -> ActivityPageOut:
|
||||
"""最近动态分页(FR-036):以操作日志为唯一来源(写操作即留痕)。
|
||||
|
||||
模块筛选项与列表同源返回(全量模块名,不随当前页变化),
|
||||
前端模块下拉不必另取一次全量。
|
||||
"""
|
||||
repo_query = operation_log_repository.OperationLogListQuery(
|
||||
filters=LogFilters(),
|
||||
offset=query.offset,
|
||||
limit=query.page_size,
|
||||
module=query.module,
|
||||
)
|
||||
logs = operation_log_repository.list_logs(session, repo_query)
|
||||
total = operation_log_repository.count_logs(session, repo_query)
|
||||
return ActivityPageOut(
|
||||
records=[
|
||||
ActivityItem(
|
||||
id=log.id,
|
||||
module=log.module,
|
||||
action=log.action,
|
||||
summary=log.summary,
|
||||
username=log.username,
|
||||
success=log.success,
|
||||
create_time=log.create_time,
|
||||
)
|
||||
for log in logs
|
||||
],
|
||||
total=total,
|
||||
page_num=query.page_num,
|
||||
page_size=query.page_size,
|
||||
modules=operation_log_repository.distinct_modules(session),
|
||||
)
|
||||
return [
|
||||
{
|
||||
"id": log.id,
|
||||
"module": log.module,
|
||||
"action": log.action,
|
||||
"summary": log.summary,
|
||||
"username": log.username,
|
||||
"success": log.success,
|
||||
"create_time": log.create_time,
|
||||
}
|
||||
for log in logs
|
||||
]
|
||||
|
||||
|
||||
def _dec(value) -> Decimal | None:
|
||||
|
||||
@ -118,10 +118,20 @@ def _dashboard(client: TestClient, headers: dict, month: str | None = None) -> d
|
||||
return resp.json()["data"]
|
||||
|
||||
|
||||
def _activities(client: TestClient, headers: dict, **params) -> dict:
|
||||
"""最近动态分页端点。"""
|
||||
resp = client.get(f"{_DASHBOARD}/activities", params=params, headers=headers)
|
||||
assert resp.status_code == 200, resp.text
|
||||
return resp.json()["data"]
|
||||
|
||||
|
||||
def test_权限_普通用户403文案(client: TestClient, normal: dict):
|
||||
resp = client.get(_DASHBOARD, headers=normal["headers"])
|
||||
assert resp.status_code == 403
|
||||
assert resp.json()["message"] == "无权访问工作台"
|
||||
# 最近动态分页端点同属工作台,权限一致
|
||||
resp = client.get(f"{_DASHBOARD}/activities", headers=normal["headers"])
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_总览_绩效合计与台账一致且非法月份400(client: TestClient, admin: dict):
|
||||
@ -221,9 +231,40 @@ def test_预警_审批超时命中且参数关闭后不产生(client: TestClient
|
||||
|
||||
def test_动态_来源操作日志(client: TestClient, admin: dict):
|
||||
_chain(client, admin["headers"])
|
||||
data = _dashboard(client, admin["headers"])
|
||||
assert data["activities"], "工作台动态为空:写操作应产生操作日志"
|
||||
assert {"module", "action", "summary", "create_time"} <= set(data["activities"][0])
|
||||
data = _activities(client, admin["headers"])
|
||||
assert data["list"], "工作台动态为空:写操作应产生操作日志"
|
||||
assert {"module", "action", "summary", "create_time"} <= set(data["list"][0])
|
||||
|
||||
|
||||
def test_动态_分页翻页不重不漏(client: TestClient, admin: dict):
|
||||
_chain(client, admin["headers"])
|
||||
first = _activities(client, admin["headers"], pageNum=1, pageSize=2)
|
||||
assert first["pageNum"] == 1
|
||||
assert first["pageSize"] == 2
|
||||
assert len(first["list"]) == 2
|
||||
assert first["total"] >= 2
|
||||
|
||||
second = _activities(client, admin["headers"], pageNum=2, pageSize=2)
|
||||
assert len(second["list"]) == 2
|
||||
# 两页不重不漏(倒序取,按 id 倒排)
|
||||
first_ids = {item["id"] for item in first["list"]}
|
||||
second_ids = {item["id"] for item in second["list"]}
|
||||
assert first_ids.isdisjoint(second_ids)
|
||||
assert first["list"][0]["id"] > second["list"][0]["id"]
|
||||
|
||||
|
||||
def test_动态_模块筛选与筛选项(client: TestClient, admin: dict):
|
||||
_chain(client, admin["headers"])
|
||||
page = _activities(client, admin["headers"], pageSize=50)
|
||||
assert page["modules"], "模块筛选项不应为空"
|
||||
module = page["modules"][0]
|
||||
|
||||
filtered = _activities(client, admin["headers"], module=module, pageSize=50)
|
||||
assert filtered["total"] >= 1
|
||||
assert filtered["list"], "按模块筛选后应有数据"
|
||||
assert all(item["module"] == module for item in filtered["list"])
|
||||
# 筛选项不随筛选条件变化(下拉里始终是全量模块)
|
||||
assert filtered["modules"] == page["modules"]
|
||||
|
||||
|
||||
# ==== 角色待办 / 预警阈值 / 趋势(2026-10-19 code-review 修复回归)====
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { request } from './request'
|
||||
import type { PageData } from './types'
|
||||
|
||||
/** 近 6 个月趋势点(绩效按台账归属月,回款按到账月) */
|
||||
export interface TrendPoint {
|
||||
@ -91,9 +92,29 @@ export interface DashboardData {
|
||||
overview: OverviewBlock
|
||||
todos: TodoBlock
|
||||
warnings: WarningBlock
|
||||
activities: ActivityItem[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 最近动态分页结果:分页字段与全站规范同形(list/total/pageNum/pageSize),
|
||||
* 额外带 `modules` —— 全量模块名(不随当前页变化),供模块下拉使用。
|
||||
*/
|
||||
export interface ActivityPage extends PageData<ActivityItem> {
|
||||
modules: string[]
|
||||
}
|
||||
|
||||
export function getDashboard(params: { month?: string }): Promise<DashboardData> {
|
||||
return request({ url: '/v1/dashboard', method: 'get', params })
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页查询最近动态(工作台动态列表)。
|
||||
*
|
||||
* 独立于聚合接口:翻页只重取这张列表,不必重算总览/待办/预警。
|
||||
*/
|
||||
export function listDashboardActivities(params: {
|
||||
pageNum?: number
|
||||
pageSize?: number
|
||||
module?: string
|
||||
}): Promise<ActivityPage> {
|
||||
return request({ url: '/v1/dashboard/activities', method: 'get', params })
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user