From 2098e6797da6905c75cece05415d99c7aad7fdf5 Mon Sep 17 00:00:00 2001 From: chengkml <45121067+chengkml@users.noreply.github.com> Date: Sun, 14 Jun 2026 00:38:33 +0800 Subject: [PATCH] feat: restore system messages admin page --- MEMORY.md | 2 +- api/app/services/admin_service.py | 2 +- api/app/services/legacy_admin_rbac_service.py | 2 +- api/app/services/legacy_authz_service.py | 3 +- api/app/services/seed_service.py | 16 + api/app/services/system_message_service.py | 14 +- api/app/services/topic_registry.py | 1 + .../pro-deploy/compose.restore-db-volume.yml | 9 + memory/2026-06-14.md | 21 + web/src/app/admin/layout.tsx | 14 +- web/src/app/admin/system-messages/page.tsx | 385 ++++++++++++++++++ web/src/lib/app-route-path.ts | 1 + web/src/types/auth.ts | 19 + 13 files changed, 472 insertions(+), 17 deletions(-) create mode 100644 deploy/pro-deploy/compose.restore-db-volume.yml create mode 100644 memory/2026-06-14.md create mode 100644 web/src/app/admin/system-messages/page.tsx diff --git a/MEMORY.md b/MEMORY.md index f93fce8..d019f8a 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -212,7 +212,7 @@ - `admin.wxapp` 已加入后端与前端受保护菜单集合、admin 默认菜单绑定与后台首页入口,确保可见、可达且不被误删。 - `单词统计` 菜单迁移采用最小改动策略:保留菜单编码 `admin.knowledge_mastery`(`/admin/vocabulary-proficiency`,权限 `vocabulary.read`),并由 `web/src/app/admin/vocabulary-proficiency/page.tsx` 承载词条总量、状态分布、缺失字段与最近更新趋势统计能力;已加入后端与前端受保护菜单集合、admin 默认菜单绑定与后台首页入口。 - `队列管理` 菜单迁移采用最小改动策略:新增菜单编码 `admin.queue_mgr`(`/admin/jobqueue`,权限 `todo.read`),并由 `web/src/app/admin/jobqueue/page.tsx` 复用 `todos` 页面承载队列任务清单能力;已加入后端与前端受保护菜单集合、admin 默认菜单绑定与后台首页入口。 -- `提示词管理` 菜单能力已于 2026-04-26 下线:`admin.system_message`、`system_message.read/system_message.manage`、`/admin/prompt`、`/admin/system-message` 与 `/api/v1/admin/system-messages*` 均不再作为有效功能入口;历史数据库表不主动删除。 +- `提示词管理` 菜单能力已于 2026-04-26 下线:`/admin/prompt`、`/admin/system-message`、`system_message.read/system_message.manage` 与 `/api/v1/admin/system-messages*` 均不再作为有效功能入口;历史数据库表不主动删除。`admin.system_message` 已恢复为“系统消息”菜单权限码,当前有效路由为 `/admin/system-messages`,接口入口为 `/api/v1/system-messages*`。 - `收件箱`、`代码评审`、`Git管理` 功能已于 2026-04-26 下线:`admin.inbox`、`admin.code_review`、`admin.git_desktop` 仅保留在 removed/disabled 过滤集合中,用于屏蔽存量菜单;前端路由 `/admin/inbox`、`/admin/code-review`、`/admin/git-desktop` 不再提供页面。 - `历史答卷` 菜单迁移采用最小改动策略:保留菜单编码 `admin.history`(`/admin/history`,权限 `question_bank.read`),并由 `web/src/app/admin/history/page.tsx` 复用 `question-bank` 页面承载历史答卷查询与管理能力;已加入后端与前端受保护菜单集合与后台首页入口。 - `脚本管理` 菜单迁移采用最小改动策略:保留菜单编码 `admin.cron_task_mgr`(`/admin/cron`,权限 `todo.read`),菜单文案统一为“脚本管理”,并继续由 `web/src/app/admin/cron/page.tsx` 复用 `todos` 页面承载脚本任务清单能力。 diff --git a/api/app/services/admin_service.py b/api/app/services/admin_service.py index 9038fc8..92b930e 100644 --- a/api/app/services/admin_service.py +++ b/api/app/services/admin_service.py @@ -34,7 +34,6 @@ AUDIT_LOG_LOAD_OPTIONS = ( REMOVED_MENU_CODES = { "dashboard", "admin.wxapp", - "admin.system_message", "admin.inbox", "admin.code_review", "admin.git_desktop", @@ -408,6 +407,7 @@ def delete_menu(db: Session, menu_id: int) -> bool: "admin.roles", "admin.menus", "admin.system_params", + "admin.system_message", "admin.system", "admin.system_monitor", "admin.basic_data", diff --git a/api/app/services/legacy_admin_rbac_service.py b/api/app/services/legacy_admin_rbac_service.py index 1a2c42b..d8a9459 100644 --- a/api/app/services/legacy_admin_rbac_service.py +++ b/api/app/services/legacy_admin_rbac_service.py @@ -33,7 +33,6 @@ PROTECTED_ROLE_IDS = {"admin", "user", "sys_mgr"} REMOVED_MENU_CODES = { "dashboard", "admin.wxapp", - "admin.system_message", "admin.inbox", "admin.code_review", "admin.git_desktop", @@ -66,6 +65,7 @@ PROTECTED_MENU_CODES = { "admin.roles", "admin.menus", "admin.system_params", + "admin.system_message", "admin.system", "admin.system_monitor", "admin.wxapp", diff --git a/api/app/services/legacy_authz_service.py b/api/app/services/legacy_authz_service.py index 9b4732a..2466339 100644 --- a/api/app/services/legacy_authz_service.py +++ b/api/app/services/legacy_authz_service.py @@ -18,6 +18,7 @@ DEFAULT_ADMIN_PERMISSION_CODES: set[str] = { "menu.manage", "system_param.read", "system_param.manage", + "admin.system_message", "file.read", "file.manage", "line.read", @@ -49,7 +50,6 @@ ADMIN_ROLE_IDS = { DISABLED_MENU_CODES: set[str] = { "dashboard", "admin.wxapp", - "admin.system_message", "admin.inbox", "admin.code_review", "admin.git_desktop", @@ -88,6 +88,7 @@ MENU_CODE_PERMISSION_MAP: dict[str, set[str]] = { "admin.roles": {"role.read", "role.manage"}, "admin.menus": {"menu.read", "menu.manage"}, "admin.system_params": {"system_param.read", "system_param.manage"}, + "admin.system_message": {"admin.system_message"}, "admin.fl_analysis": {"line.read", "line.manage"}, "admin.files": {"file.read", "file.manage"}, "admin.elevation": {"elevation.read", "elevation.manage"}, diff --git a/api/app/services/seed_service.py b/api/app/services/seed_service.py index 300e4e9..c2d57a4 100644 --- a/api/app/services/seed_service.py +++ b/api/app/services/seed_service.py @@ -92,6 +92,7 @@ DEFAULT_PERMISSIONS: dict[str, str] = { "menu.manage": "Manage menus", "system_param.read": "Read system parameters", "system_param.manage": "Manage system parameters", + "admin.system_message": "Manage system messages", "file.read": "Read file mounts and indexed entries", "file.manage": "Manage file operations and storage sync", "line.read": "Read power lines", @@ -126,6 +127,7 @@ DEFAULT_ROLES: dict[str, dict[str, object]] = { "menu.manage", "system_param.read", "system_param.manage", + "admin.system_message", "file.read", "file.manage", "line.read", @@ -206,6 +208,19 @@ DEFAULT_MENUS: list[dict[str, object]] = [ "cacheable": False, "permission_code": "system_param.read", }, + { + "code": "admin.system_message", + "name": "系统消息", + "path": "/admin/system-messages", + "icon": "Bell", + "parent_code": None, + "type": "menu", + "sort_order": 46, + "status": "enabled", + "visible": True, + "cacheable": False, + "permission_code": "admin.system_message", + }, { "code": "admin.power_lines", "name": "线路管理", @@ -435,6 +450,7 @@ ROLE_MENU_BINDINGS: dict[str, list[str]] = { "admin.roles", "admin.menus", "admin.system_params", + "admin.system_message", "admin.power_lines", "admin.fl_analysis", "admin.fault_recurrence", diff --git a/api/app/services/system_message_service.py b/api/app/services/system_message_service.py index 76119b5..0ee73fb 100644 --- a/api/app/services/system_message_service.py +++ b/api/app/services/system_message_service.py @@ -1,7 +1,7 @@ from datetime import datetime from typing import TYPE_CHECKING -from sqlalchemy import select, update +from sqlalchemy import func, select, update from sqlalchemy.orm import Session from ..models.system_message import SystemMessage @@ -45,17 +45,17 @@ def list_user_messages( query = query.where(SystemMessage.is_read == False) # 获取总数 - total_query = select(SystemMessage).where( + total_query = select(func.count()).select_from(SystemMessage).where( (SystemMessage.target_user_id == user_id) | (SystemMessage.target_user_id.is_(None)) ) - total = db.scalar(select(len(total_query.subquery().c.message_id))) + total = db.scalar(total_query) # 获取未读数 - unread_query = select(SystemMessage).where( + unread_query = select(func.count()).select_from(SystemMessage).where( (SystemMessage.target_user_id == user_id) | (SystemMessage.target_user_id.is_(None)), SystemMessage.is_read == False, ) - unread_count = db.scalar(select(len(unread_query.subquery().c.message_id))) + unread_count = db.scalar(unread_query) # 按创建时间倒序 query = query.order_by(SystemMessage.created_at.desc()) @@ -88,9 +88,9 @@ def mark_messages_as_read( def get_unread_count(db: Session, user_id: str) -> int: """获取用户未读消息数量""" - query = select(SystemMessage).where( + query = select(func.count()).select_from(SystemMessage).where( (SystemMessage.target_user_id == user_id) | (SystemMessage.target_user_id.is_(None)), SystemMessage.is_read == False, ) - count = db.scalar(select(len(query.subquery().c.message_id))) + count = db.scalar(query) return count or 0 diff --git a/api/app/services/topic_registry.py b/api/app/services/topic_registry.py index 43c859a..052108e 100644 --- a/api/app/services/topic_registry.py +++ b/api/app/services/topic_registry.py @@ -21,6 +21,7 @@ TOPIC_RULES: dict[str, TopicRule] = { "admin.roles": TopicRule(any_permission_codes={"role.read", "role.manage"}), "admin.menus": TopicRule(any_permission_codes={"menu.read", "menu.manage"}), "admin.system-params": TopicRule(any_permission_codes={"system_param.read", "system_param.manage"}), + "admin.system-messages": TopicRule(any_permission_codes={"admin.system_message"}), "admin.files": TopicRule(any_permission_codes={"file.read", "file.manage"}), "admin.tower-models": TopicRule(any_permission_codes={"tower_model.read", "tower_model.manage", "tower.read", "tower.manage"}), "admin.elevation": TopicRule(any_permission_codes={"elevation.read", "elevation.manage"}), diff --git a/deploy/pro-deploy/compose.restore-db-volume.yml b/deploy/pro-deploy/compose.restore-db-volume.yml new file mode 100644 index 0000000..ce59e23 --- /dev/null +++ b/deploy/pro-deploy/compose.restore-db-volume.yml @@ -0,0 +1,9 @@ +volumes: + fquiz_db_data: + external: true + name: fquiz_fquiz_db_data + +services: + db: + volumes: + - fquiz_db_data:/var/lib/postgresql/data diff --git a/memory/2026-06-14.md b/memory/2026-06-14.md new file mode 100644 index 0000000..9df63f8 --- /dev/null +++ b/memory/2026-06-14.md @@ -0,0 +1,21 @@ +# Work Log - 提交系统消息入口恢复与工作区变更(2026-06-14) + +- 背景: + - 当前工作区存在系统消息能力恢复相关改动,以及生产数据库卷恢复 compose 覆盖文件。 + - 用户要求提交并推送当前工作区改动。 + +- 本次处理: + - 恢复 `admin.system_message` 菜单与权限口径,不再把该菜单列入 removed/disabled 集合。 + - 新增后台 `/admin/system-messages` 页面,用于查看、筛选、发送和标记系统消息。 + - 修正系统消息计数查询,使用 SQLAlchemy `func.count()` 统计总数和未读数。 + - 补齐前端系统消息类型、菜单图标映射和旧路由别名。 + - 新增 `deploy/pro-deploy/compose.restore-db-volume.yml`,用于生产恢复时挂载既有外部数据库卷。 + - 更新 `MEMORY.md`,把 `admin.system_message` 从历史下线口径中拆出并记录当前有效入口。 + +- 验证: + - `git diff --check` 通过。 + - 提交前将执行 Python 编译与前端系统消息页面 ESLint。 + +- 风险与关注点: + - `deploy/pro-deploy/compose.restore-db-volume.yml` 指向外部卷 `fquiz_fquiz_db_data`,仅应在生产恢复场景按需叠加使用。 + - 系统消息恢复涉及菜单权限与前端入口;若存量数据库未 seed 新菜单,需要执行既有 seed/迁移链路或手动补齐菜单记录。 diff --git a/web/src/app/admin/layout.tsx b/web/src/app/admin/layout.tsx index f4378e3..5eb2bdd 100644 --- a/web/src/app/admin/layout.tsx +++ b/web/src/app/admin/layout.tsx @@ -58,7 +58,7 @@ import { useAuth } from "@/components/auth-provider"; import { useTopicSubscription } from "@/hooks/use-topic-subscription"; import { readApiError } from "@/lib/api"; import { normalizeAppRoutePath } from "@/lib/app-route-path"; -import type { MenuTreeItem } from "@/types/auth"; +import type { MenuTreeItem, SystemMessageListResponse, SystemMessageSummary } from "@/types/auth"; import { useThemeAppearance } from "@/components/ui-antd"; import { withBasePath } from "@/lib/base-path"; @@ -119,6 +119,7 @@ const MENU_ICON_COMPONENTS = { Database: DatabaseOutlined, FileText: FileTextOutlined, Terminal: ConsoleSqlOutlined, + Bell: BellOutlined, TeamOutlined, SafetyCertificateOutlined, AppstoreOutlined, @@ -134,6 +135,7 @@ const MENU_ICON_COMPONENTS = { DatabaseOutlined, FileTextOutlined, ConsoleSqlOutlined, + BellOutlined, } as const; function resolveMenuIcon(icon: string | null): ReactNode { @@ -367,7 +369,7 @@ export default function AdminLayout({ children }: { children: ReactNode }) { ); const [messagePopoverOpen, setMessagePopoverOpen] = useState(false); - const [messages, setMessages] = useState([]); + const [messages, setMessages] = useState([]); const [loadingMessages, setLoadingMessages] = useState(false); const loadMessages = useCallback(async () => { @@ -377,7 +379,7 @@ export default function AdminLayout({ children }: { children: ReactNode }) { if (!response.ok) { return; } - const data = await response.json(); + const data = (await response.json()) as SystemMessageListResponse; setMessages(data.items || []); setUnreadMessageCount(data.unread_count || 0); } catch (error) { @@ -393,7 +395,7 @@ export default function AdminLayout({ children }: { children: ReactNode }) { if (!response.ok) { return; } - const data = await response.json(); + const data = (await response.json()) as { unread_count: number }; setUnreadMessageCount(data.unread_count || 0); } catch (error) { console.error("Failed to load unread count:", error); @@ -526,9 +528,9 @@ export default function AdminLayout({ children }: { children: ReactNode }) { ) : messages.length === 0 ? ( ) : ( - dataSource={messages} - renderItem={(item: any) => ( + renderItem={(item) => ( ; + +const MESSAGE_TYPE_OPTIONS = [ + { label: "通知", value: "info" }, + { label: "成功", value: "success" }, + { label: "警告", value: "warning" }, + { label: "错误", value: "error" }, +] as const satisfies ReadonlyArray<{ label: string; value: SystemMessageType }>; + +const MESSAGE_TYPE_LABELS: Record = { + info: "通知", + success: "成功", + warning: "警告", + error: "错误", +}; + +const MESSAGE_TYPE_COLORS: Record = { + info: "blue", + success: "green", + warning: "orange", + error: "red", +}; + +function formatDateTime(value: string | null): string { + if (!value) { + return "-"; + } + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return value; + } + return date.toLocaleString("zh-CN"); +} + +export default function AdminSystemMessagesPage() { + const { user, initializing, fetchWithAuth, hasPermission } = useAuth(); + const queryClient = useQueryClient(); + const [formApi] = Form.useForm(); + const [messageTypeFilter, setMessageTypeFilter] = useState("all"); + const [unreadOnly, setUnreadOnly] = useState(false); + const [error, setError] = useState(""); + const [success, setSuccess] = useState(""); + + const canManage = hasPermission("admin.system_message"); + + const listPath = useMemo(() => { + const params = new URLSearchParams(); + params.set("limit", "200"); + if (unreadOnly) { + params.set("unread_only", "true"); + } + const qs = params.toString(); + return `/api/v1/system-messages/me?${qs}`; + }, [unreadOnly]); + + const listQuery = useQuery({ + queryKey: ["admin.system-messages", listPath], + enabled: !!user, + queryFn: async () => { + const response = await fetchWithAuth(listPath); + if (!response.ok) { + throw new Error(await readApiError(response)); + } + return (await response.json()) as SystemMessageListResponse; + }, + }); + + const refreshMessages = useCallback(async () => { + await queryClient.invalidateQueries({ queryKey: ["admin.system-messages"] }); + }, [queryClient]); + + const createMutation = useMutation({ + mutationFn: async () => { + if (!canManage) { + throw new Error("缺少 admin.system_message 权限"); + } + + const values = await formApi.validateFields(); + const response = await fetchWithAuth("/api/v1/system-messages", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + title: values.title.trim(), + content: values.content.trim(), + message_type: values.message_type, + target_user_id: values.target_user_id.trim() || null, + }), + }); + if (!response.ok) { + throw new Error(await readApiError(response)); + } + return (await response.json()) as SystemMessageSummary; + }, + onSuccess: async () => { + setError(""); + setSuccess("系统消息已发送"); + formApi.resetFields(); + await refreshMessages(); + }, + onError: (candidate) => { + setSuccess(""); + setError(candidate instanceof Error ? candidate.message : "发送失败"); + }, + }); + + const markReadMutation = useMutation({ + mutationFn: async (messageIds: string[]) => { + const response = await fetchWithAuth("/api/v1/system-messages/me/mark-read", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message_ids: messageIds }), + }); + if (!response.ok) { + throw new Error(await readApiError(response)); + } + return response.json() as Promise<{ affected: number }>; + }, + onSuccess: async () => { + setError(""); + setSuccess("消息已标记为已读"); + await refreshMessages(); + }, + onError: (candidate) => { + setSuccess(""); + setError(candidate instanceof Error ? candidate.message : "标记已读失败"); + }, + }); + + useToastFeedback({ + errorMessage: error, + successMessage: success, + clearError: () => setError(""), + clearSuccess: () => setSuccess(""), + }); + + const messages = useMemo(() => { + const items = listQuery.data?.items ?? []; + if (messageTypeFilter === "all") { + return items; + } + return items.filter((item) => item.message_type === messageTypeFilter); + }, [listQuery.data?.items, messageTypeFilter]); + + const unreadIds = useMemo( + () => messages.filter((item) => !item.is_read).map((item) => item.id), + [messages], + ); + + const columns = useMemo>( + () => [ + { + title: "标题", + dataIndex: "title", + key: "title", + width: 220, + render: (_, item) => ( + + {item.title} + + {item.target_user_id ? `用户:${item.target_user_id}` : "全员广播"} + + + ), + }, + { + title: "类型", + dataIndex: "message_type", + key: "message_type", + width: 90, + render: (value: SystemMessageType) => ( + {MESSAGE_TYPE_LABELS[value]} + ), + }, + { + title: "内容", + dataIndex: "content", + key: "content", + ellipsis: true, + render: (value: string) => {value}, + }, + { + title: "状态", + dataIndex: "is_read", + key: "is_read", + width: 90, + render: (value: boolean) => ( + {value ? "已读" : "未读"} + ), + }, + { + title: "创建时间", + dataIndex: "created_at", + key: "created_at", + width: 180, + render: (value: string) => formatDateTime(value), + }, + { + title: "操作", + key: "actions", + width: 120, + fixed: "right", + render: (_, item) => ( + + ), + }, + ], + [markReadMutation], + ); + + if (initializing) { + return ( +
+ +
+ ); + } + + return ( + + + + 系统消息 + + + 管理当前账号可见的系统消息,并发送全员或指定用户通知。 + + + + {listQuery.isError && ( + + )} + + {canManage && ( + +
createMutation.mutate()} + > +
+ + + + + + + +
+ + + + + + + + + +
+
+ )} + + {!canManage && ( + + )} + + + 消息列表 + 总数 {listQuery.data?.total ?? 0} + 未读 {listQuery.data?.unread_count ?? 0} +
+ )} + extra={( + + setUnreadOnly(value === "unread")} + /> + + + + )} + > + + rowKey="id" + columns={columns} + dataSource={messages} + loading={listQuery.isFetching} + locale={{ emptyText: }} + pagination={{ pageSize: 20, showSizeChanger: true }} + scroll={{ x: 980 }} + /> + + + ); +} diff --git a/web/src/lib/app-route-path.ts b/web/src/lib/app-route-path.ts index 8a28c29..b51ac8a 100644 --- a/web/src/lib/app-route-path.ts +++ b/web/src/lib/app-route-path.ts @@ -5,6 +5,7 @@ const APP_ROUTE_ALIASES: Record = { "/role": "/roles", "/menu": "/menus", "/system-param": "/system-params", + "/system-message": "/system-messages", "/power-line": "/power-lines", "/power-lines/atp-viewer": "/atp-models", "/lightning-current": "/lightning-currents", diff --git a/web/src/types/auth.ts b/web/src/types/auth.ts index b8b3084..81336d4 100644 --- a/web/src/types/auth.ts +++ b/web/src/types/auth.ts @@ -103,6 +103,25 @@ export type SystemParamListResponse = { total: number; }; +export type SystemMessageType = "info" | "warning" | "error" | "success"; + +export type SystemMessageSummary = { + id: string; + title: string; + content: string; + message_type: SystemMessageType; + target_user_id: string | null; + is_read: boolean; + created_at: string; + read_at: string | null; +}; + +export type SystemMessageListResponse = { + items: SystemMessageSummary[]; + total: number; + unread_count: number; +}; + export type ScheduledTaskStatus = "idle" | "queued" | "running" | "success" | "failed" | "disabled"; export type ScheduledTaskType = "syslog_cleanup";