From b58f114918bd54a8e6e8729be4d190daf4b4edcd Mon Sep 17 00:00:00 2001 From: Lan Date: Wed, 3 Jun 2026 07:36:48 +0800 Subject: [PATCH] feat: add admin activity timeline filters --- apps/admin/services.py | 103 +++++++++++++++++++++++++++++++++++++++-- apps/admin/views.py | 20 +++++++- 2 files changed, 118 insertions(+), 5 deletions(-) diff --git a/apps/admin/services.py b/apps/admin/services.py index 6ba6790..a6549ba 100644 --- a/apps/admin/services.py +++ b/apps/admin/services.py @@ -732,14 +732,48 @@ class FileService: "updated_at": updated_at, } - async def list_admin_activities(self, limit: int = 8) -> dict[str, Any]: - limit = min(max(int(limit or 8), 1), self.MAX_ADMIN_ACTIVITIES) + async def list_admin_activities( + self, + limit: int = 8, + action: Optional[str] = None, + target_type: Optional[str] = None, + keyword: Optional[str] = None, + ) -> dict[str, Any]: + try: + normalized_limit = int(limit or 8) + except (TypeError, ValueError): + normalized_limit = 8 + limit = min(max(normalized_limit, 1), self.MAX_ADMIN_ACTIVITIES) activities = await self._get_admin_activities() - visible_activities = activities[:limit] + normalized_action = self._normalize_admin_activity_text(action).lower() + normalized_target_type = self._normalize_admin_activity_text(target_type).lower() + normalized_keyword = self._normalize_admin_activity_text(keyword).lower() + filtered_activities = self._filter_admin_activities( + activities, + action=normalized_action, + target_type=normalized_target_type, + keyword=normalized_keyword, + ) + visible_activities = filtered_activities[:limit] + action_options = self._build_admin_activity_options(activities, "action") + target_type_options = self._build_admin_activity_options(activities, "targetType") return { "activities": visible_activities, "items": visible_activities, - "total": len(activities), + "total": len(filtered_activities), + "storedTotal": len(activities), + "stored_total": len(activities), + "limit": limit, + "filters": { + "action": normalized_action, + "targetType": normalized_target_type, + "target_type": normalized_target_type, + "keyword": normalized_keyword, + }, + "actionOptions": action_options, + "action_options": action_options, + "targetTypeOptions": target_type_options, + "target_type_options": target_type_options, } async def record_admin_activity( @@ -883,6 +917,67 @@ class FileService: def _normalize_admin_activity_text(self, value: Any) -> str: return str(value or "").strip()[: self.MAX_ADMIN_ACTIVITY_TEXT_LENGTH] + def _filter_admin_activities( + self, + activities: list[dict[str, Any]], + action: str, + target_type: str, + keyword: str, + ) -> list[dict[str, Any]]: + filtered_activities = [] + for activity in activities: + if action and str(activity.get("action") or "").lower() != action: + continue + if target_type and str(activity.get("targetType") or "").lower() != target_type: + continue + if keyword and not self._activity_matches_keyword(activity, keyword): + continue + filtered_activities.append(activity) + return filtered_activities + + def _activity_matches_keyword(self, activity: dict[str, Any], keyword: str) -> bool: + searchable_values = [ + activity.get("action"), + activity.get("targetType"), + activity.get("target_type"), + activity.get("targetId"), + activity.get("target_id"), + activity.get("targetName"), + activity.get("target_name"), + ] + meta = activity.get("meta") + if isinstance(meta, dict): + searchable_values.extend(meta.values()) + + return any(keyword in str(value or "").lower() for value in searchable_values) + + def _build_admin_activity_options( + self, + activities: list[dict[str, Any]], + field: str, + ) -> list[dict[str, Any]]: + counters: dict[str, dict[str, Any]] = {} + for activity in activities: + raw_value = self._normalize_admin_activity_text(activity.get(field)) + if not raw_value: + continue + value = raw_value.lower() + if value not in counters: + counters[value] = {"label": raw_value, "count": 0} + counters[value]["count"] += 1 + + return [ + { + "value": value, + "label": option["label"], + "count": option["count"], + } + for value, option in sorted( + counters.items(), + key=lambda item: (-item[1]["count"], item[0]), + ) + ] + def _build_admin_activity_id( self, action: str, diff --git a/apps/admin/views.py b/apps/admin/views.py index ca95389..33e428e 100644 --- a/apps/admin/views.py +++ b/apps/admin/views.py @@ -4,6 +4,7 @@ # @Software: PyCharm import datetime from collections import Counter +from typing import Optional from fastapi import APIRouter, Depends, HTTPException from apps.admin.services import FileService, ConfigService, LocalFileService @@ -39,6 +40,14 @@ admin_api = APIRouter( ) +def _pick_query_text(*values: Optional[str]) -> Optional[str]: + for value in values: + normalized_value = str(value or "").strip() + if normalized_value: + return normalized_value + return None + + @admin_api.post("/login") async def login(data: LoginData): if not verify_password(data.password, settings.admin_token): @@ -150,9 +159,18 @@ async def dashboard(file_service: FileService = Depends(get_file_service)): @admin_api.get("/activities") async def admin_activities( limit: int = 20, + action: Optional[str] = None, + targetType: Optional[str] = None, + target_type: Optional[str] = None, + keyword: Optional[str] = None, file_service: FileService = Depends(get_file_service), ): - result = await file_service.list_admin_activities(limit=limit) + result = await file_service.list_admin_activities( + limit=limit, + action=action, + target_type=_pick_query_text(targetType, target_type), + keyword=keyword, + ) return APIResponse(detail=result)