From 61fe5cca98b8ef6a905f7acffced7053987cf989 Mon Sep 17 00:00:00 2001 From: liuwenxin <1009266660@qq.com> Date: Fri, 23 Oct 2026 16:15:09 +0800 Subject: [PATCH] =?UTF-8?q?feat(dashboard):=20=E6=9C=80=E8=BF=91=E5=8A=A8?= =?UTF-8?q?=E6=80=81=E6=94=B9=E6=9C=8D=E5=8A=A1=E7=AB=AF=E5=88=86=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 工作台「最近动态」原为聚合端点内嵌、固定截断前 20 条,操作日志变长后 既撑大响应又只能看到头部。改为独立分页端点: - GET /api/v1/dashboard/activities,支持 pageNum/pageSize/module 筛选 - 模块筛选项与列表同源返回(全量模块名,不随翻页变化),前端不必再取一次全量 - 聚合端点去掉内嵌 activities,避免同一份数据两处查询 前端改用 usePagedList + 统一 AppPagination(每页 10/20/50),模块筛选由 本地过滤改为服务端筛选。集成测试 13 例(含翻页不重不漏 / 模块筛选 / 越权 403)。 Co-Authored-By: Claude Code --- backend/app/api/v1/dashboard.py | 22 +- .../repositories/operation_log_repository.py | 9 + backend/app/schemas/dashboard.py | 47 +- backend/app/services/dashboard_service.py | 66 +- backend/tests/integration/test_dashboard.py | 47 +- frontend/src/api/dashboard.ts | 23 +- frontend/src/views/dashboard/index.vue | 950 ++++++++++++------ 7 files changed, 835 insertions(+), 329 deletions(-) diff --git a/backend/app/api/v1/dashboard.py b/backend/app/api/v1/dashboard.py index 70afe93..e49a769 100644 --- a/backend/app/api/v1/dashboard.py +++ b/backend/app/api/v1/dashboard.py @@ -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)) diff --git a/backend/app/repositories/operation_log_repository.py b/backend/app/repositories/operation_log_repository.py index 0aba518..20b55b0 100644 --- a/backend/app/repositories/operation_log_repository.py +++ b/backend/app/repositories/operation_log_repository.py @@ -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) diff --git a/backend/app/schemas/dashboard.py b/backend/app/schemas/dashboard.py index d52bc7b..118d88b 100644 --- a/backend/app/schemas/dashboard.py +++ b/backend/app/schemas/dashboard.py @@ -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) diff --git a/backend/app/services/dashboard_service.py b/backend/app/services/dashboard_service.py index 546416f..eff98b2 100644 --- a/backend/app/services/dashboard_service.py +++ b/backend/app/services/dashboard_service.py @@ -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: diff --git a/backend/tests/integration/test_dashboard.py b/backend/tests/integration/test_dashboard.py index e8c27cf..a4d9978 100644 --- a/backend/tests/integration/test_dashboard.py +++ b/backend/tests/integration/test_dashboard.py @@ -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 修复回归)==== diff --git a/frontend/src/api/dashboard.ts b/frontend/src/api/dashboard.ts index 1cf1153..5ff42cb 100644 --- a/frontend/src/api/dashboard.ts +++ b/frontend/src/api/dashboard.ts @@ -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 { + modules: string[] } export function getDashboard(params: { month?: string }): Promise { return request({ url: '/v1/dashboard', method: 'get', params }) } + +/** + * 分页查询最近动态(工作台动态列表)。 + * + * 独立于聚合接口:翻页只重取这张列表,不必重算总览/待办/预警。 + */ +export function listDashboardActivities(params: { + pageNum?: number + pageSize?: number + module?: string +}): Promise { + return request({ url: '/v1/dashboard/activities', method: 'get', params }) +} diff --git a/frontend/src/views/dashboard/index.vue b/frontend/src/views/dashboard/index.vue index c621421..8ff1dce 100644 --- a/frontend/src/views/dashboard/index.vue +++ b/frontend/src/views/dashboard/index.vue @@ -1,5 +1,5 @@