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:
liuwenxin 2026-10-23 16:15:09 +08:00
parent 32b7940067
commit 61fe5cca98
7 changed files with 835 additions and 329 deletions

View File

@ -1,4 +1,4 @@
"""工作台接口(M4 ⑧):四区块一次性聚合。 """工作台接口(M4 ⑧):三区块聚合 + 最近动态分页。
准入:管理员/财务/领导(与报表、审批接口一致;FR-033/036 的「全部用户」 准入:管理员/财务/领导(与报表、审批接口一致;FR-033/036 的「全部用户」
待业务权限清单 #6 答复后再放宽)。只读接口,不写操作日志。 待业务权限清单 #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.constants import ROLE_ADMIN, ROLE_FINANCE, ROLE_LEADER
from app.core.response import ApiResponse, ok from app.core.response import ApiResponse, ok
from app.models import User 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 from app.services import dashboard_service
router = APIRouter(prefix="/dashboard", tags=["工作台"]) 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( def get_dashboard(
session: DbSession, session: DbSession,
current_user: _DashboardUser, current_user: _DashboardUser,
@ -31,8 +31,22 @@ def get_dashboard(
str | None, Query(max_length=7, description="统计月份 YYYY-MM,默认当月") str | None, Query(max_length=7, description="统计月份 YYYY-MM,默认当月")
] = None, ] = None,
) -> ApiResponse[DashboardOut]: ) -> ApiResponse[DashboardOut]:
"""一次返回经营总览/待办/风险预警/最近动态(待办与预警实时派生)。 """一次返回经营总览/待办/风险预警(待办与预警实时派生)。
待办按当前用户角色只返回其可操作节点(admin 可查看全部在途单)。 待办按当前用户角色只返回其可操作节点(admin 可查看全部在途单)。
最近动态是分页列表,走 `GET /dashboard/activities`。
""" """
return ok(dashboard_service.get_dashboard(session, month, current_user=current_user)) 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))

View File

@ -142,6 +142,15 @@ def count_logs(session: Session, query: OperationLogListQuery) -> int:
return int(session.execute(stmt).scalar_one()) 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: def _apply_filters(stmt: Select, query: OperationLogListQuery) -> Select:
"""应用筛选条件(公共部分复用 query_utils,与登录日志同口径)。""" """应用筛选条件(公共部分复用 query_utils,与登录日志同口径)。"""
stmt = apply_log_filters(stmt, OperationLog, query.filters) stmt = apply_log_filters(stmt, OperationLog, query.filters)

View File

@ -1,9 +1,13 @@
"""工作台的请求/响应模型(M4 ⑧)。 """工作台的请求/响应模型(M4 ⑧)。
四区块一次返回(前端一次请求渲染整页,避免 6-10 次往返): 聚合端点一次返回三区块(前端一次请求渲染整页,避免多次往返):
经营绩效总览 / 待办事项 / 风险预警 / 最近动态。金额一律 Decimal 经营绩效总览 / 待办事项 / 风险预警。金额一律 Decimal
(序列化为字符串),前端不做数值运算。 (序列化为字符串),前端不做数值运算。
**最近动态独立成分页端点**:它可能有很长(操作日志全量),塞进聚合
响应既撑爆页面又只能截断前 20 条;拆开后翻页只重取这张列表,不必
重算总览/待办/预警。列表与模块筛选项同源返回(一次请求给全)。
待办与预警为**查询时实时派生**(不落库、不引调度器,见计划决策); 待办与预警为**查询时实时派生**(不落库、不引调度器,见计划决策);
⑥ T+ 同步未建前,同步相关条目用 `sync_available=false` 显式降级, ⑥ T+ 同步未建前,同步相关条目用 `sync_available=false` 显式降级,
不返回伪造的 0 条。 不返回伪造的 0 条。
@ -12,7 +16,9 @@
from datetime import date, datetime from datetime import date, datetime
from decimal import Decimal from decimal import Decimal
from pydantic import BaseModel, Field from pydantic import BaseModel, ConfigDict, Field
from app.core.response import PageQuery
class TrendPoint(BaseModel): class TrendPoint(BaseModel):
@ -116,11 +122,42 @@ class ActivityItem(BaseModel):
create_time: datetime 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): class DashboardOut(BaseModel):
"""工作台四区块一次性响应。""" """工作台三区块一次性响应(最近动态走独立分页端点)。"""
month: str month: str
overview: OverviewBlock overview: OverviewBlock
todos: TodoBlock todos: TodoBlock
warnings: WarningBlock warnings: WarningBlock
activities: list[ActivityItem] = Field(default_factory=list)

View File

@ -1,4 +1,4 @@
"""工作台服务(M4 ⑧):四区块实时聚合编排。 """工作台服务(M4 ⑧):区块实时聚合编排 + 最近动态分页。
设计决策(2026-10-19): 设计决策(2026-10-19):
- **只读**:不落库、不写审计(读操作不入操作日志);总览只做 SQL 聚合, - **只读**:不落库、不写审计(读操作不入操作日志);总览只做 SQL 聚合,
@ -9,6 +9,8 @@
- **⑥ 同步模块未启用**:同步失败待办、同步连续失败预警以 - **⑥ 同步模块未启用**:同步失败待办、同步连续失败预警以
``sync_available=false`` 显式降级,不返回伪造的 0 条 ``sync_available=false`` 显式降级,不返回伪造的 0 条
- 预警阈值走参数体系(``warning.*``),一次请求只解析一次快照 - 预警阈值走参数体系(``warning.*``),一次请求只解析一次快照
- **最近动态独立分页**(2026-10-23):操作日志可能很长,聚合响应里
只能塞前 20 条;拆成独立端点后翻页只重取这张列表
""" """
from datetime import date, datetime, timedelta 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.core.month import validate_month
from app.repositories import dashboard_repository, operation_log_repository, user_repository from app.repositories import dashboard_repository, operation_log_repository, user_repository
from app.repositories.query_utils import LogFilters from app.repositories.query_utils import LogFilters
from app.schemas.dashboard import ActivityItem, ActivityPageOut, ActivityQuery
from app.services import approval_service from app.services import approval_service
# 工作台只展示摘要,完整列表跳转对应管理页 # 工作台只展示摘要,完整列表跳转对应管理页
_TODO_LIMIT = 5 _TODO_LIMIT = 5
_WARNING_LIMIT = 20 _WARNING_LIMIT = 20
_ACTIVITY_LIMIT = 20
_TREND_MONTHS = 6 _TREND_MONTHS = 6
_ORDER_TYPE_LABELS = {1: "二次分配方案", 2: "月度结算", 3: "调整单"} _ORDER_TYPE_LABELS = {1: "二次分配方案", 2: "月度结算", 3: "调整单"}
def get_dashboard(session: Session, month: str | None = None, *, current_user) -> dict: def get_dashboard(session: Session, month: str | None = None, *, current_user) -> dict:
"""四区块一次性出数(前端一次请求渲染整页)。 """三区块一次性出数(前端一次请求渲染整页)。
待办按当前用户角色只展示其可操作节点(admin 可查看全部在途单); 待办按当前用户角色只展示其可操作节点(admin 可查看全部在途单);
风险预警仍是全局经营视角,不按人裁剪。 风险预警仍是全局经营视角,不按人裁剪。
最近动态不在此返回 —— 它是分页列表,走 ``list_activities``。
""" """
resolved = _normalize_month(month) resolved = _normalize_month(month)
warning_params = _warning_params(session) warning_params = _warning_params(session)
@ -47,7 +50,6 @@ def get_dashboard(session: Session, month: str | None = None, *, current_user) -
"overview": _overview(session, resolved), "overview": _overview(session, resolved),
"todos": _todos(session, pending_statuses), "todos": _todos(session, pending_statuses),
"warnings": _warnings(session, warning_params), "warnings": _warnings(session, warning_params),
"activities": _activities(session),
} }
@ -112,8 +114,8 @@ def _overview(session: Session, month: str) -> dict:
for item in months for item in months
], ],
"note": ( "note": (
"绩效按台账归属月(单笔生效行,二次分摊拆行求和不失真);回款按到账月" "绩效按台账归属月统计(二次分配按参与人拆分后求和,不重复不计漏);"
"(含未核算回款);不含经理 12.5%+5% 聚合分成与调整单" "回款按到账月统计(含尚未核算的回款);不含经理团队分成与调整单"
), ),
} }
@ -247,28 +249,38 @@ def _warnings(session: Session, params: dict) -> dict:
} }
def _activities(session: Session) -> list[dict]: def list_activities(session: Session, query: ActivityQuery) -> ActivityPageOut:
"""最近动态(FR-036):以操作日志为唯一来源(写操作即留痕)。""" """最近动态分页(FR-036):以操作日志为唯一来源(写操作即留痕)。
logs = operation_log_repository.list_logs(
session, 模块筛选项与列表同源返回(全量模块名,不随当前页变化),
operation_log_repository.OperationLogListQuery( 前端模块下拉不必另取一次全量。
filters=LogFilters(), """
offset=0, repo_query = operation_log_repository.OperationLogListQuery(
limit=_ACTIVITY_LIMIT, 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: def _dec(value) -> Decimal | None:

View File

@ -118,10 +118,20 @@ def _dashboard(client: TestClient, headers: dict, month: str | None = None) -> d
return resp.json()["data"] 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): def test_权限_普通用户403文案(client: TestClient, normal: dict):
resp = client.get(_DASHBOARD, headers=normal["headers"]) resp = client.get(_DASHBOARD, headers=normal["headers"])
assert resp.status_code == 403 assert resp.status_code == 403
assert resp.json()["message"] == "无权访问工作台" 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): def test_总览_绩效合计与台账一致且非法月份400(client: TestClient, admin: dict):
@ -221,9 +231,40 @@ def test_预警_审批超时命中且参数关闭后不产生(client: TestClient
def test_动态_来源操作日志(client: TestClient, admin: dict): def test_动态_来源操作日志(client: TestClient, admin: dict):
_chain(client, admin["headers"]) _chain(client, admin["headers"])
data = _dashboard(client, admin["headers"]) data = _activities(client, admin["headers"])
assert data["activities"], "工作台动态为空:写操作应产生操作日志" assert data["list"], "工作台动态为空:写操作应产生操作日志"
assert {"module", "action", "summary", "create_time"} <= set(data["activities"][0]) 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 修复回归)==== # ==== 角色待办 / 预警阈值 / 趋势(2026-10-19 code-review 修复回归)====

View File

@ -1,4 +1,5 @@
import { request } from './request' import { request } from './request'
import type { PageData } from './types'
/** 近 6 个月趋势点(绩效按台账归属月,回款按到账月) */ /** 近 6 个月趋势点(绩效按台账归属月,回款按到账月) */
export interface TrendPoint { export interface TrendPoint {
@ -91,9 +92,29 @@ export interface DashboardData {
overview: OverviewBlock overview: OverviewBlock
todos: TodoBlock todos: TodoBlock
warnings: WarningBlock 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> { export function getDashboard(params: { month?: string }): Promise<DashboardData> {
return request({ url: '/v1/dashboard', method: 'get', params }) 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