feat: restore system messages admin page

This commit is contained in:
chengkml
2026-06-14 00:38:33 +08:00
parent 98a65821e7
commit 2098e6797d
13 changed files with 472 additions and 17 deletions
+1 -1
View File
@@ -212,7 +212,7 @@
- `admin.wxapp` 已加入后端与前端受保护菜单集合、admin 默认菜单绑定与后台首页入口,确保可见、可达且不被误删。 - `admin.wxapp` 已加入后端与前端受保护菜单集合、admin 默认菜单绑定与后台首页入口,确保可见、可达且不被误删。
- `单词统计` 菜单迁移采用最小改动策略:保留菜单编码 `admin.knowledge_mastery``/admin/vocabulary-proficiency`,权限 `vocabulary.read`),并由 `web/src/app/admin/vocabulary-proficiency/page.tsx` 承载词条总量、状态分布、缺失字段与最近更新趋势统计能力;已加入后端与前端受保护菜单集合、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 默认菜单绑定与后台首页入口。 - `队列管理` 菜单迁移采用最小改动策略:新增菜单编码 `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` 不再提供页面。 - `收件箱``代码评审``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.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` 页面承载脚本任务清单能力。 - `脚本管理` 菜单迁移采用最小改动策略:保留菜单编码 `admin.cron_task_mgr``/admin/cron`,权限 `todo.read`),菜单文案统一为“脚本管理”,并继续由 `web/src/app/admin/cron/page.tsx` 复用 `todos` 页面承载脚本任务清单能力。
+1 -1
View File
@@ -34,7 +34,6 @@ AUDIT_LOG_LOAD_OPTIONS = (
REMOVED_MENU_CODES = { REMOVED_MENU_CODES = {
"dashboard", "dashboard",
"admin.wxapp", "admin.wxapp",
"admin.system_message",
"admin.inbox", "admin.inbox",
"admin.code_review", "admin.code_review",
"admin.git_desktop", "admin.git_desktop",
@@ -408,6 +407,7 @@ def delete_menu(db: Session, menu_id: int) -> bool:
"admin.roles", "admin.roles",
"admin.menus", "admin.menus",
"admin.system_params", "admin.system_params",
"admin.system_message",
"admin.system", "admin.system",
"admin.system_monitor", "admin.system_monitor",
"admin.basic_data", "admin.basic_data",
@@ -33,7 +33,6 @@ PROTECTED_ROLE_IDS = {"admin", "user", "sys_mgr"}
REMOVED_MENU_CODES = { REMOVED_MENU_CODES = {
"dashboard", "dashboard",
"admin.wxapp", "admin.wxapp",
"admin.system_message",
"admin.inbox", "admin.inbox",
"admin.code_review", "admin.code_review",
"admin.git_desktop", "admin.git_desktop",
@@ -66,6 +65,7 @@ PROTECTED_MENU_CODES = {
"admin.roles", "admin.roles",
"admin.menus", "admin.menus",
"admin.system_params", "admin.system_params",
"admin.system_message",
"admin.system", "admin.system",
"admin.system_monitor", "admin.system_monitor",
"admin.wxapp", "admin.wxapp",
+2 -1
View File
@@ -18,6 +18,7 @@ DEFAULT_ADMIN_PERMISSION_CODES: set[str] = {
"menu.manage", "menu.manage",
"system_param.read", "system_param.read",
"system_param.manage", "system_param.manage",
"admin.system_message",
"file.read", "file.read",
"file.manage", "file.manage",
"line.read", "line.read",
@@ -49,7 +50,6 @@ ADMIN_ROLE_IDS = {
DISABLED_MENU_CODES: set[str] = { DISABLED_MENU_CODES: set[str] = {
"dashboard", "dashboard",
"admin.wxapp", "admin.wxapp",
"admin.system_message",
"admin.inbox", "admin.inbox",
"admin.code_review", "admin.code_review",
"admin.git_desktop", "admin.git_desktop",
@@ -88,6 +88,7 @@ MENU_CODE_PERMISSION_MAP: dict[str, set[str]] = {
"admin.roles": {"role.read", "role.manage"}, "admin.roles": {"role.read", "role.manage"},
"admin.menus": {"menu.read", "menu.manage"}, "admin.menus": {"menu.read", "menu.manage"},
"admin.system_params": {"system_param.read", "system_param.manage"}, "admin.system_params": {"system_param.read", "system_param.manage"},
"admin.system_message": {"admin.system_message"},
"admin.fl_analysis": {"line.read", "line.manage"}, "admin.fl_analysis": {"line.read", "line.manage"},
"admin.files": {"file.read", "file.manage"}, "admin.files": {"file.read", "file.manage"},
"admin.elevation": {"elevation.read", "elevation.manage"}, "admin.elevation": {"elevation.read", "elevation.manage"},
+16
View File
@@ -92,6 +92,7 @@ DEFAULT_PERMISSIONS: dict[str, str] = {
"menu.manage": "Manage menus", "menu.manage": "Manage menus",
"system_param.read": "Read system parameters", "system_param.read": "Read system parameters",
"system_param.manage": "Manage system parameters", "system_param.manage": "Manage system parameters",
"admin.system_message": "Manage system messages",
"file.read": "Read file mounts and indexed entries", "file.read": "Read file mounts and indexed entries",
"file.manage": "Manage file operations and storage sync", "file.manage": "Manage file operations and storage sync",
"line.read": "Read power lines", "line.read": "Read power lines",
@@ -126,6 +127,7 @@ DEFAULT_ROLES: dict[str, dict[str, object]] = {
"menu.manage", "menu.manage",
"system_param.read", "system_param.read",
"system_param.manage", "system_param.manage",
"admin.system_message",
"file.read", "file.read",
"file.manage", "file.manage",
"line.read", "line.read",
@@ -206,6 +208,19 @@ DEFAULT_MENUS: list[dict[str, object]] = [
"cacheable": False, "cacheable": False,
"permission_code": "system_param.read", "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", "code": "admin.power_lines",
"name": "线路管理", "name": "线路管理",
@@ -435,6 +450,7 @@ ROLE_MENU_BINDINGS: dict[str, list[str]] = {
"admin.roles", "admin.roles",
"admin.menus", "admin.menus",
"admin.system_params", "admin.system_params",
"admin.system_message",
"admin.power_lines", "admin.power_lines",
"admin.fl_analysis", "admin.fl_analysis",
"admin.fault_recurrence", "admin.fault_recurrence",
+7 -7
View File
@@ -1,7 +1,7 @@
from datetime import datetime from datetime import datetime
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from sqlalchemy import select, update from sqlalchemy import func, select, update
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from ..models.system_message import SystemMessage from ..models.system_message import SystemMessage
@@ -45,17 +45,17 @@ def list_user_messages(
query = query.where(SystemMessage.is_read == False) 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)) (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.target_user_id == user_id) | (SystemMessage.target_user_id.is_(None)),
SystemMessage.is_read == False, 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()) 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: 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.target_user_id == user_id) | (SystemMessage.target_user_id.is_(None)),
SystemMessage.is_read == False, SystemMessage.is_read == False,
) )
count = db.scalar(select(len(query.subquery().c.message_id))) count = db.scalar(query)
return count or 0 return count or 0
+1
View File
@@ -21,6 +21,7 @@ TOPIC_RULES: dict[str, TopicRule] = {
"admin.roles": TopicRule(any_permission_codes={"role.read", "role.manage"}), "admin.roles": TopicRule(any_permission_codes={"role.read", "role.manage"}),
"admin.menus": TopicRule(any_permission_codes={"menu.read", "menu.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-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.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.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"}), "admin.elevation": TopicRule(any_permission_codes={"elevation.read", "elevation.manage"}),
@@ -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
+21
View File
@@ -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/迁移链路或手动补齐菜单记录。
+8 -6
View File
@@ -58,7 +58,7 @@ import { useAuth } from "@/components/auth-provider";
import { useTopicSubscription } from "@/hooks/use-topic-subscription"; import { useTopicSubscription } from "@/hooks/use-topic-subscription";
import { readApiError } from "@/lib/api"; import { readApiError } from "@/lib/api";
import { normalizeAppRoutePath } from "@/lib/app-route-path"; 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 { useThemeAppearance } from "@/components/ui-antd";
import { withBasePath } from "@/lib/base-path"; import { withBasePath } from "@/lib/base-path";
@@ -119,6 +119,7 @@ const MENU_ICON_COMPONENTS = {
Database: DatabaseOutlined, Database: DatabaseOutlined,
FileText: FileTextOutlined, FileText: FileTextOutlined,
Terminal: ConsoleSqlOutlined, Terminal: ConsoleSqlOutlined,
Bell: BellOutlined,
TeamOutlined, TeamOutlined,
SafetyCertificateOutlined, SafetyCertificateOutlined,
AppstoreOutlined, AppstoreOutlined,
@@ -134,6 +135,7 @@ const MENU_ICON_COMPONENTS = {
DatabaseOutlined, DatabaseOutlined,
FileTextOutlined, FileTextOutlined,
ConsoleSqlOutlined, ConsoleSqlOutlined,
BellOutlined,
} as const; } as const;
function resolveMenuIcon(icon: string | null): ReactNode { function resolveMenuIcon(icon: string | null): ReactNode {
@@ -367,7 +369,7 @@ export default function AdminLayout({ children }: { children: ReactNode }) {
); );
const [messagePopoverOpen, setMessagePopoverOpen] = useState(false); const [messagePopoverOpen, setMessagePopoverOpen] = useState(false);
const [messages, setMessages] = useState<any[]>([]); const [messages, setMessages] = useState<SystemMessageSummary[]>([]);
const [loadingMessages, setLoadingMessages] = useState(false); const [loadingMessages, setLoadingMessages] = useState(false);
const loadMessages = useCallback(async () => { const loadMessages = useCallback(async () => {
@@ -377,7 +379,7 @@ export default function AdminLayout({ children }: { children: ReactNode }) {
if (!response.ok) { if (!response.ok) {
return; return;
} }
const data = await response.json(); const data = (await response.json()) as SystemMessageListResponse;
setMessages(data.items || []); setMessages(data.items || []);
setUnreadMessageCount(data.unread_count || 0); setUnreadMessageCount(data.unread_count || 0);
} catch (error) { } catch (error) {
@@ -393,7 +395,7 @@ export default function AdminLayout({ children }: { children: ReactNode }) {
if (!response.ok) { if (!response.ok) {
return; return;
} }
const data = await response.json(); const data = (await response.json()) as { unread_count: number };
setUnreadMessageCount(data.unread_count || 0); setUnreadMessageCount(data.unread_count || 0);
} catch (error) { } catch (error) {
console.error("Failed to load unread count:", error); console.error("Failed to load unread count:", error);
@@ -526,9 +528,9 @@ export default function AdminLayout({ children }: { children: ReactNode }) {
) : messages.length === 0 ? ( ) : messages.length === 0 ? (
<Empty description="暂无消息" /> <Empty description="暂无消息" />
) : ( ) : (
<List <List<SystemMessageSummary>
dataSource={messages} dataSource={messages}
renderItem={(item: any) => ( renderItem={(item) => (
<List.Item <List.Item
key={item.id} key={item.id}
style={{ style={{
+385
View File
@@ -0,0 +1,385 @@
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Alert,
Button,
Card,
Empty,
Form,
Input,
Select,
Space,
Spin,
Table,
Tag,
Typography,
type CardProps,
type TableColumnsType,
} from "antd";
import type { ComponentType } from "react";
import { useCallback, useMemo, useState } from "react";
import { useAuth } from "@/components/auth-provider";
import { useToastFeedback } from "@/hooks/use-toast-feedback";
import { readApiError } from "@/lib/api";
import type { SystemMessageListResponse, SystemMessageSummary, SystemMessageType } from "@/types/auth";
type CreateMessageValues = {
title: string;
content: string;
message_type: SystemMessageType;
target_user_id: string;
};
const AntCard = Card as unknown as ComponentType<CardProps>;
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<SystemMessageType, string> = {
info: "通知",
success: "成功",
warning: "警告",
error: "错误",
};
const MESSAGE_TYPE_COLORS: Record<SystemMessageType, string> = {
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<CreateMessageValues>();
const [messageTypeFilter, setMessageTypeFilter] = useState<SystemMessageType | "all">("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<TableColumnsType<SystemMessageSummary>>(
() => [
{
title: "标题",
dataIndex: "title",
key: "title",
width: 220,
render: (_, item) => (
<Space direction="vertical" size={2}>
<Typography.Text strong={!item.is_read}>{item.title}</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{item.target_user_id ? `用户:${item.target_user_id}` : "全员广播"}
</Typography.Text>
</Space>
),
},
{
title: "类型",
dataIndex: "message_type",
key: "message_type",
width: 90,
render: (value: SystemMessageType) => (
<Tag color={MESSAGE_TYPE_COLORS[value]}>{MESSAGE_TYPE_LABELS[value]}</Tag>
),
},
{
title: "内容",
dataIndex: "content",
key: "content",
ellipsis: true,
render: (value: string) => <Typography.Text>{value}</Typography.Text>,
},
{
title: "状态",
dataIndex: "is_read",
key: "is_read",
width: 90,
render: (value: boolean) => (
<Tag color={value ? "default" : "processing"}>{value ? "已读" : "未读"}</Tag>
),
},
{
title: "创建时间",
dataIndex: "created_at",
key: "created_at",
width: 180,
render: (value: string) => formatDateTime(value),
},
{
title: "操作",
key: "actions",
width: 120,
fixed: "right",
render: (_, item) => (
<Button
disabled={item.is_read}
loading={markReadMutation.isPending && markReadMutation.variables?.includes(item.id)}
size="small"
type="link"
onClick={() => markReadMutation.mutate([item.id])}
>
</Button>
),
},
],
[markReadMutation],
);
if (initializing) {
return (
<div className="flex min-h-[360px] items-center justify-center">
<Spin />
</div>
);
}
return (
<Space direction="vertical" size={16} style={{ width: "100%" }}>
<Space align="start" direction="vertical" size={4}>
<Typography.Title level={3} style={{ margin: 0 }}>
</Typography.Title>
<Typography.Text type="secondary">
</Typography.Text>
</Space>
{listQuery.isError && (
<Alert
showIcon
type="error"
message="系统消息加载失败"
description={listQuery.error instanceof Error ? listQuery.error.message : "请检查后端服务。"}
/>
)}
{canManage && (
<AntCard title="发送消息">
<Form
form={formApi}
layout="vertical"
initialValues={{ message_type: "info", target_user_id: "" }}
onFinish={() => createMutation.mutate()}
>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[minmax(0,1fr)_220px_260px]">
<Form.Item
label="标题"
name="title"
rules={[{ required: true, message: "请输入标题" }]}
>
<Input maxLength={255} placeholder="请输入消息标题" />
</Form.Item>
<Form.Item label="类型" name="message_type">
<Select options={MESSAGE_TYPE_OPTIONS} />
</Form.Item>
<Form.Item label="目标用户 ID" name="target_user_id">
<Input allowClear placeholder="留空表示全员广播" />
</Form.Item>
</div>
<Form.Item
label="内容"
name="content"
rules={[{ required: true, message: "请输入内容" }]}
>
<Input.TextArea autoSize={{ minRows: 3, maxRows: 8 }} placeholder="请输入消息内容" />
</Form.Item>
<Space>
<Button htmlType="submit" loading={createMutation.isPending} type="primary">
</Button>
<Button htmlType="button" onClick={() => formApi.resetFields()}>
</Button>
</Space>
</Form>
</AntCard>
)}
{!canManage && (
<Alert
showIcon
type="info"
message="当前账号仅可查看和标记自己的系统消息。"
/>
)}
<AntCard
title={(
<Space wrap>
<span></span>
<Tag color="blue"> {listQuery.data?.total ?? 0}</Tag>
<Tag color="processing"> {listQuery.data?.unread_count ?? 0}</Tag>
</Space>
)}
extra={(
<Space wrap>
<Select
style={{ width: 132 }}
value={messageTypeFilter}
options={[
{ label: "全部类型", value: "all" },
...MESSAGE_TYPE_OPTIONS,
]}
onChange={setMessageTypeFilter}
/>
<Select
style={{ width: 112 }}
value={unreadOnly ? "unread" : "all"}
options={[
{ label: "全部状态", value: "all" },
{ label: "仅未读", value: "unread" },
]}
onChange={(value) => setUnreadOnly(value === "unread")}
/>
<Button onClick={() => void listQuery.refetch()}></Button>
<Button
disabled={unreadIds.length === 0}
loading={markReadMutation.isPending && unreadIds.some((id) => markReadMutation.variables?.includes(id))}
onClick={() => markReadMutation.mutate(unreadIds)}
>
</Button>
</Space>
)}
>
<Table<SystemMessageSummary>
rowKey="id"
columns={columns}
dataSource={messages}
loading={listQuery.isFetching}
locale={{ emptyText: <Empty description="暂无系统消息" /> }}
pagination={{ pageSize: 20, showSizeChanger: true }}
scroll={{ x: 980 }}
/>
</AntCard>
</Space>
);
}
+1
View File
@@ -5,6 +5,7 @@ const APP_ROUTE_ALIASES: Record<string, string> = {
"/role": "/roles", "/role": "/roles",
"/menu": "/menus", "/menu": "/menus",
"/system-param": "/system-params", "/system-param": "/system-params",
"/system-message": "/system-messages",
"/power-line": "/power-lines", "/power-line": "/power-lines",
"/power-lines/atp-viewer": "/atp-models", "/power-lines/atp-viewer": "/atp-models",
"/lightning-current": "/lightning-currents", "/lightning-current": "/lightning-currents",
+19
View File
@@ -103,6 +103,25 @@ export type SystemParamListResponse = {
total: number; 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 ScheduledTaskStatus = "idle" | "queued" | "running" | "success" | "failed" | "disabled";
export type ScheduledTaskType = "syslog_cleanup"; export type ScheduledTaskType = "syslog_cleanup";