feat: restore system messages admin page
This commit is contained in:
@@ -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` 页面承载脚本任务清单能力。
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"}),
|
||||
|
||||
@@ -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
|
||||
@@ -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/迁移链路或手动补齐菜单记录。
|
||||
@@ -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<any[]>([]);
|
||||
const [messages, setMessages] = useState<SystemMessageSummary[]>([]);
|
||||
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 ? (
|
||||
<Empty description="暂无消息" />
|
||||
) : (
|
||||
<List
|
||||
<List<SystemMessageSummary>
|
||||
dataSource={messages}
|
||||
renderItem={(item: any) => (
|
||||
renderItem={(item) => (
|
||||
<List.Item
|
||||
key={item.id}
|
||||
style={{
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ const APP_ROUTE_ALIASES: Record<string, string> = {
|
||||
"/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",
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user