feat: 统一后端分页查询与前端服务层适配

后端新增通用分页模块,为报销单、员工、预算、agent 资产等
端点统一接入分页参数和游标查询,优化 repository 层分页实
现,前端服务层适配分页响应结构,完善预算图表和全局样式,
优化侧边栏和企业选择器组件,引入 Element Plus 插件注册。
This commit is contained in:
caoxiaozhu
2026-05-29 14:11:06 +08:00
parent e080105f9f
commit 678f64d772
43 changed files with 1863 additions and 378 deletions

View File

@@ -37,6 +37,7 @@ from app.services.agent_asset_spreadsheet_helpers import AgentAssetSpreadsheetHe
from app.services.agent_asset_timeline import AgentAssetTimelineMixin
from app.services.agent_foundation import AgentFoundationService
from app.services.audit import AuditLogService
from app.services.pagination import PageResult
from app.services.risk_rule_score_backfill import backfill_missing_risk_rule_score
logger = get_logger("app.services.agent_assets")
@@ -75,6 +76,32 @@ class AgentAssetService(
version_stats = self._collect_version_stats(assets)
return [self._serialize_list_item(asset, version_stats.get(asset.id)) for asset in assets]
def list_assets_page(
self,
*,
asset_type: str | None = None,
status: str | None = None,
domain: str | None = None,
keyword: str | None = None,
page: int | None,
page_size: int | None,
) -> PageResult[AgentAssetListItem]:
self._ensure_ready()
if asset_type in {None, "", AgentAssetType.RULE.value}:
self.sync_platform_risk_rules_from_library()
result = self.repository.list_page(
asset_type=asset_type,
status=status,
domain=domain,
keyword=keyword,
page=page,
page_size=page_size,
)
version_stats = self._collect_version_stats(result.items)
return result.map(
lambda asset: self._serialize_list_item(asset, version_stats.get(asset.id))
)
def get_asset(self, asset_id: str) -> AgentAssetRead | None:
self._ensure_ready()
asset = self.repository.get(asset_id)

View File

@@ -21,11 +21,12 @@ from app.schemas.budget import (
BudgetTransactionRead,
)
from app.services.budget_expense_control import BudgetExpenseControlModel
from app.services.budget_pagination import BudgetPaginationMixin
from app.services.budget_support import BudgetSupportMixin
from app.services.budget_types import BudgetControlError, SUPPORTED_BUDGET_SUBJECT_CODES
from app.services.budget_types import BudgetControlError
class BudgetService(BudgetSupportMixin):
class BudgetService(BudgetPaginationMixin, BudgetSupportMixin):
def __init__(self, db: Session) -> None:
self.db = db
@@ -46,22 +47,13 @@ class BudgetService(BudgetSupportMixin):
cost_center: str | None = None,
) -> list[BudgetAllocationRead]:
self.ensure_budget_ready()
stmt = select(BudgetAllocation).order_by(
BudgetAllocation.fiscal_year.desc(),
BudgetAllocation.period_key.asc(),
BudgetAllocation.department_name.asc(),
BudgetAllocation.subject_code.asc(),
).where(BudgetAllocation.subject_code.in_(SUPPORTED_BUDGET_SUBJECT_CODES))
if fiscal_year is not None:
stmt = stmt.where(BudgetAllocation.fiscal_year == fiscal_year)
if period_key:
stmt = stmt.where(BudgetAllocation.period_key == period_key)
if department_id:
stmt = stmt.where(BudgetAllocation.department_id == department_id)
if department_name:
stmt = stmt.where(BudgetAllocation.department_name == department_name)
if cost_center:
stmt = stmt.where(BudgetAllocation.cost_center == cost_center)
stmt = self.build_allocation_stmt(
fiscal_year=fiscal_year,
period_key=period_key,
department_id=department_id,
department_name=department_name,
cost_center=cost_center,
)
return [self.serialize_allocation(row) for row in self.db.scalars(stmt).all()]
def get_summary(

View File

@@ -0,0 +1,83 @@
from __future__ import annotations
from sqlalchemy import select
from app.models.budget import BudgetAllocation, BudgetTransaction
from app.schemas.budget import BudgetAllocationRead, BudgetTransactionRead
from app.services.budget_types import SUPPORTED_BUDGET_SUBJECT_CODES
from app.services.pagination import PageResult, paginate_select
class BudgetPaginationMixin:
def build_allocation_stmt(
self,
*,
fiscal_year: int | None = None,
period_key: str | None = None,
department_id: str | None = None,
department_name: str | None = None,
cost_center: str | None = None,
):
stmt = select(BudgetAllocation).where(
BudgetAllocation.subject_code.in_(SUPPORTED_BUDGET_SUBJECT_CODES)
)
if fiscal_year is not None:
stmt = stmt.where(BudgetAllocation.fiscal_year == fiscal_year)
if period_key:
stmt = stmt.where(BudgetAllocation.period_key == period_key)
if department_id:
stmt = stmt.where(BudgetAllocation.department_id == department_id)
if department_name:
stmt = stmt.where(BudgetAllocation.department_name == department_name)
if cost_center:
stmt = stmt.where(BudgetAllocation.cost_center == cost_center)
return stmt.order_by(
BudgetAllocation.fiscal_year.desc(),
BudgetAllocation.period_key.asc(),
BudgetAllocation.department_name.asc(),
BudgetAllocation.subject_code.asc(),
)
def list_allocations_page(
self,
*,
fiscal_year: int | None = None,
period_key: str | None = None,
department_id: str | None = None,
department_name: str | None = None,
cost_center: str | None = None,
page: int | None,
page_size: int | None,
) -> PageResult[BudgetAllocationRead]:
self.ensure_budget_ready()
result = paginate_select(
self.db,
self.build_allocation_stmt(
fiscal_year=fiscal_year,
period_key=period_key,
department_id=department_id,
department_name=department_name,
cost_center=cost_center,
),
page=page,
page_size=page_size,
)
return result.map(self.serialize_allocation)
def list_transactions_page(
self,
allocation_id: str,
*,
page: int | None,
page_size: int | None,
) -> PageResult[BudgetTransactionRead]:
self.ensure_budget_ready()
result = paginate_select(
self.db,
select(BudgetTransaction)
.where(BudgetTransaction.allocation_id == allocation_id)
.order_by(BudgetTransaction.created_at.desc()),
page=page,
page_size=page_size,
)
return result.map(BudgetTransactionRead.model_validate)

View File

@@ -0,0 +1,39 @@
from __future__ import annotations
from sqlalchemy.orm import Session
from app.core.logging import get_logger
from app.schemas.employee import EmployeeRead
from app.services.employee import EmployeeService
from app.services.pagination import PageResult
logger = get_logger("app.services.employee")
class EmployeePaginationService:
def __init__(self, db: Session) -> None:
self.service = EmployeeService(db)
def list_employees_page(
self,
*,
status: str | None = None,
keyword: str | None = None,
page: int | None,
page_size: int | None,
) -> PageResult[EmployeeRead]:
self.service.ensure_directory_ready()
result = self.service.repository.list_page(
status=status,
keyword=keyword,
page=page,
page_size=page_size,
)
logger.info(
"Listed employees page (count=%d, total=%d, page=%d, page_size=%d)",
len(result.items),
result.total,
result.page,
result.page_size,
)
return result.map(self.service._serialize_employee)

View File

@@ -0,0 +1,61 @@
from __future__ import annotations
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from app.api.deps import CurrentUserContext
from app.models.employee import Employee
from app.models.financial_record import ExpenseClaim
from app.services.pagination import PageResult, paginate_select
class ExpenseClaimPaginationMixin:
def _claim_list_stmt(self):
return select(ExpenseClaim).options(
selectinload(ExpenseClaim.items),
selectinload(ExpenseClaim.employee).selectinload(Employee.manager),
selectinload(ExpenseClaim.employee).selectinload(Employee.roles),
)
def list_claims_page(
self,
current_user: CurrentUserContext,
*,
page: int | None,
page_size: int | None,
) -> PageResult[ExpenseClaim]:
stmt = self._claim_list_stmt().order_by(
ExpenseClaim.created_at.desc(),
ExpenseClaim.occurred_at.desc(),
)
stmt = self._access_policy.apply_claim_scope(stmt, current_user)
return paginate_select(self.db, stmt, page=page, page_size=page_size)
def list_approval_claims_page(
self,
current_user: CurrentUserContext,
*,
page: int | None,
page_size: int | None,
) -> PageResult[ExpenseClaim]:
stmt = self._claim_list_stmt().order_by(
ExpenseClaim.submitted_at.desc(),
ExpenseClaim.created_at.desc(),
)
stmt = self._access_policy.apply_approval_claim_scope(stmt, current_user)
return paginate_select(self.db, stmt, page=page, page_size=page_size)
def list_archived_claims_page(
self,
current_user: CurrentUserContext,
*,
page: int | None,
page_size: int | None,
) -> PageResult[ExpenseClaim]:
stmt = self._claim_list_stmt().order_by(
ExpenseClaim.updated_at.desc(),
ExpenseClaim.submitted_at.desc(),
ExpenseClaim.created_at.desc(),
)
stmt = self._access_policy.apply_archived_claim_scope(stmt, current_user)
return paginate_select(self.db, stmt, page=page, page_size=page_size)

View File

@@ -48,6 +48,7 @@ from app.services.expense_claim_document_parsing import ExpenseClaimDocumentPars
from app.services.expense_claim_draft_flow import ExpenseClaimDraftFlowMixin
from app.services.expense_claim_draft_persistence import ExpenseClaimDraftPersistenceMixin
from app.services.expense_claim_errors import ExpenseClaimSubmissionBlockedError
from app.services.expense_claim_pagination import ExpenseClaimPaginationMixin
from app.services.expense_claim_ontology_resolvers import ExpenseClaimOntologyResolverMixin
from app.services.expense_claim_read_model import ExpenseClaimReadModelMixin
from app.services.expense_claim_review_preview import ExpenseClaimReviewPreviewMixin
@@ -128,6 +129,7 @@ from app.services.ocr import OcrService
class ExpenseClaimService(
ExpenseClaimPaginationMixin,
ExpenseClaimApprovalFlowMixin,
ExpenseClaimApplicationHandoffMixin,
ExpenseClaimBudgetFlowMixin,

View File

@@ -0,0 +1,95 @@
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from math import ceil
from typing import Any, Generic, TypeVar
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from sqlalchemy.sql import Select
T = TypeVar("T")
U = TypeVar("U")
DEFAULT_PAGE = 1
DEFAULT_PAGE_SIZE = 20
MAX_PAGE_SIZE = 100
@dataclass(frozen=True)
class PageParams:
page: int
page_size: int
@property
def offset(self) -> int:
return (self.page - 1) * self.page_size
@dataclass(frozen=True)
class PageResult(Generic[T]):
items: list[T]
total: int
page: int
page_size: int
@property
def total_pages(self) -> int:
if self.total <= 0:
return 0
return ceil(self.total / self.page_size)
@property
def has_next(self) -> bool:
return self.page < self.total_pages
@property
def has_previous(self) -> bool:
return self.page > 1 and self.total_pages > 0
def map(self, mapper: Callable[[T], U]) -> PageResult[U]:
return PageResult(
items=[mapper(item) for item in self.items],
total=self.total,
page=self.page,
page_size=self.page_size,
)
def normalize_page_params(
page: int | None,
page_size: int | None,
*,
max_page_size: int = MAX_PAGE_SIZE,
) -> PageParams:
normalized_page = max(DEFAULT_PAGE, int(page or DEFAULT_PAGE))
normalized_page_size = max(1, int(page_size or DEFAULT_PAGE_SIZE))
normalized_page_size = min(normalized_page_size, max_page_size)
return PageParams(page=normalized_page, page_size=normalized_page_size)
def paginate_select(
db: Session,
stmt: Select[Any],
*,
page: int | None,
page_size: int | None,
max_page_size: int = MAX_PAGE_SIZE,
unique: bool = False,
) -> PageResult[Any]:
params = normalize_page_params(page, page_size, max_page_size=max_page_size)
count_stmt = select(func.count()).select_from(stmt.order_by(None).subquery())
total = int(db.scalar(count_stmt) or 0)
page_stmt = stmt.limit(params.page_size).offset(params.offset)
scalars = db.execute(page_stmt).scalars()
if unique:
scalars = scalars.unique()
return PageResult(
items=list(scalars.all()),
total=total,
page=params.page,
page_size=params.page_size,
)

View File

@@ -4,6 +4,7 @@ from app.core.logging import get_logger
from app.models.reimbursement import ReimbursementRequest
from app.repositories.reimbursement import ReimbursementRepository
from app.schemas.reimbursement import ReimbursementCreate
from app.services.pagination import PageResult
logger = get_logger("app.services.reimbursement")
@@ -17,6 +18,22 @@ class ReimbursementService:
logger.info("Listed reimbursements (count=%d)", len(items))
return items
def list_reimbursements_page(
self,
*,
page: int | None,
page_size: int | None,
) -> PageResult[ReimbursementRequest]:
result = self.repository.list_page(page=page, page_size=page_size)
logger.info(
"Listed reimbursements page (count=%d, total=%d, page=%d, page_size=%d)",
len(result.items),
result.total,
result.page,
result.page_size,
)
return result
def get_reimbursement(self, request_id: str) -> ReimbursementRequest | None:
request = self.repository.get(request_id)
if request: