[feat]:[FL-154][系统消息页面一致性优化]

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
chengkai3
2026-06-20 06:56:17 +08:00
parent 012b62fab9
commit a9a2d32fd5
8 changed files with 227 additions and 62 deletions
+1
View File
@@ -213,6 +213,7 @@
- `单词统计` 菜单迁移采用最小改动策略:保留菜单编码 `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/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*`
- 系统消息个人列表 `GET /api/v1/system-messages/me` 支持可选 `message_type=info|warning|error|success` 筛选;响应 `total` 跟随当前筛选条件,`unread_count` 保持当前用户全局未读数,用于顶部未读徽标。
- `收件箱``代码评审``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` 页面承载脚本任务清单能力。
+3
View File
@@ -9,6 +9,7 @@ from ...schemas.system_message import (
SystemMessageListResponse,
SystemMessageMarkReadRequest,
SystemMessagePublic,
SystemMessageType,
)
from ...services.system_message_service import (
create_system_message,
@@ -37,6 +38,7 @@ def get_my_messages(
limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0),
unread_only: bool = Query(default=False),
message_type: SystemMessageType | None = Query(default=None),
current_user: CurrentUser = Depends(get_current_user),
db: Session = Depends(get_db),
) -> SystemMessageListResponse:
@@ -47,6 +49,7 @@ def get_my_messages(
limit=limit,
offset=offset,
unread_only=unread_only,
message_type=message_type,
)
return SystemMessageListResponse(
items=[SystemMessagePublic.model_validate(m) for m in messages],
+4 -2
View File
@@ -3,6 +3,8 @@ from typing import Literal
from pydantic import BaseModel, ConfigDict, Field
SystemMessageType = Literal["info", "warning", "error", "success"]
class SystemMessagePublic(BaseModel):
model_config = ConfigDict(from_attributes=True)
@@ -10,7 +12,7 @@ class SystemMessagePublic(BaseModel):
id: str
title: str
content: str
message_type: str
message_type: SystemMessageType
target_user_id: str | None
is_read: bool
created_at: datetime
@@ -26,7 +28,7 @@ class SystemMessageListResponse(BaseModel):
class SystemMessageCreateRequest(BaseModel):
title: str = Field(min_length=1, max_length=255)
content: str = Field(min_length=1)
message_type: Literal["info", "warning", "error", "success"] = Field(default="info")
message_type: SystemMessageType = Field(default="info")
target_user_id: str | None = Field(default=None, description="发送给特定用户,为空则全员广播")
+12 -9
View File
@@ -5,7 +5,7 @@ from sqlalchemy import delete, func, select, update
from sqlalchemy.orm import Session
from ..models.system_message import SystemMessage
from ..schemas.system_message import SystemMessageCreateRequest
from ..schemas.system_message import SystemMessageCreateRequest, SystemMessageType
if TYPE_CHECKING:
from ..schemas.system_message import SystemMessagePublic
@@ -34,20 +34,23 @@ def list_user_messages(
limit: int = 50,
offset: int = 0,
unread_only: bool = False,
message_type: SystemMessageType | None = None,
) -> tuple[list[SystemMessage], int, int]:
"""获取用户的系统消息列表"""
# 构建查询条件:全员广播或者特定用户
query = select(SystemMessage).where(
(SystemMessage.target_user_id == user_id) | (SystemMessage.target_user_id.is_(None))
)
base_conditions = [
(SystemMessage.target_user_id == user_id) | (SystemMessage.target_user_id.is_(None)),
]
if unread_only:
query = query.where(SystemMessage.is_read == False)
base_conditions.append(SystemMessage.is_read == False)
if message_type:
base_conditions.append(SystemMessage.message_type == message_type)
query = select(SystemMessage).where(*base_conditions)
# 获取总数
total_query = select(func.count()).select_from(SystemMessage).where(
(SystemMessage.target_user_id == user_id) | (SystemMessage.target_user_id.is_(None))
)
total_query = select(func.count()).select_from(SystemMessage).where(*base_conditions)
total = db.scalar(total_query)
# 获取未读数
+38 -1
View File
@@ -6,7 +6,11 @@ from sqlalchemy.orm import Session
from app.core.database import Base
from app.models.system_message import SystemMessage
from app.schemas.system_message import SystemMessageCreateRequest
from app.services.system_message_service import create_system_message, delete_system_message
from app.services.system_message_service import (
create_system_message,
delete_system_message,
list_user_messages,
)
def test_delete_system_message_removes_existing_message() -> None:
@@ -37,3 +41,36 @@ def test_delete_system_message_returns_false_when_missing() -> None:
deleted = delete_system_message(db, "missing-message-id")
assert deleted is False
def test_list_user_messages_filters_items_and_total_by_type() -> None:
engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(bind=engine, tables=[SystemMessage.__table__])
with Session(engine) as db:
info_message = create_system_message(
db,
SystemMessageCreateRequest(
title="通知",
content="测试内容",
message_type="info",
),
)
create_system_message(
db,
SystemMessageCreateRequest(
title="警告",
content="测试内容",
message_type="warning",
),
)
messages, total, unread_count = list_user_messages(
db,
user_id="user-1",
message_type="info",
)
assert [message.id for message in messages] == [info_message.id]
assert total == 1
assert unread_count == 2
+25
View File
@@ -103,3 +103,28 @@
- 风险与关注点:
- 改动涉及系统参数列表接口分页契约,但不改变请求/响应字段结构、权限码或 CRUD 语义。
- 当前本机 `python3` 为 3.7.9,不满足 `api/pyproject.toml``requires-python >=3.10`;后端单测使用 `uv` 管理的 Python 3.11 环境验证。
# Work Log - 系统消息页面一致性优化(FL-154)
- 背景:
- 系统消息页面需对齐用户管理页的后台列表页布局、动态表格高度、移动卡片视图和无限滚动交互。
- 本次处理:
- 系统消息页补齐表格 body 最小高度 CSS 钩子,动态 `scroll.y` 对应的 CSS 变量现已生效。
- 移动端卡片视图改为与用户管理页一致的容器、空态、卡片视觉和字段网格样式,移除卡片字段内散落的内联间距。
- 移动卡片累积列表改为 `requestAnimationFrame` 内更新,避免 React hooks lint 的同步 effect 更新错误;筛选切换直接重置卡片分页和累积数据。
- 系统消息列表 API 增加可选 `message_type` 查询参数,服务端按类型/未读状态返回匹配 `items``total`,保证移动端无限滚动的 total 与当前筛选一致;`unread_count` 仍保持用户全局未读数。
- 补充系统消息服务层测试覆盖类型筛选后的 total 行为。
- 验证:
- 基线:`npm --workspace web exec eslint src/app/admin/users/page.tsx src/app/admin/system-messages/page.tsx` 失败,系统消息页存在 2 个 `react-hooks/set-state-in-effect` 既有错误;用户页存在 1 条既有 unused eslint-disable warning。
- 基线:`npm --workspace web exec tsc --noEmit` 通过。
- 基线:`python -m pytest api/tests/test_system_message_service.py api/tests/test_system_message_schema.py` 因系统 `python` 缺少 pytest 无法执行。
- 修改后:`npm --workspace web exec eslint src/app/admin/system-messages/page.tsx --max-warnings=0` 通过。
- 修改后:`npm --workspace web exec eslint src/app/admin/users/page.tsx src/app/admin/system-messages/page.tsx` 通过,仍仅用户页 1 条既有 warning。
- 修改后:`npm --workspace web exec tsc --noEmit` 通过。
- 修改后:`UV_CACHE_DIR=/tmp/fquiz-uv-cache UV_PYTHON_INSTALL_DIR=/tmp/fquiz-uv-python /home/jenkins/.local/bin/uv run --python 3.11 --with pytest --with fastapi --with pydantic-settings --with sqlalchemy --with PyJWT --with argon2-cffi --with email-validator --with python-multipart --with psycopg[binary] pytest api/tests/test_system_message_service.py api/tests/test_system_message_schema.py` 通过,4 passed,存在 1 条既有 SQLAlchemy relationship warning。
- 风险与关注点:
- `/api/v1/system-messages/me` 新增可选 `message_type` 参数,未传参时保持原列表语义;前端不再做本地类型过滤,分页 total 与服务端过滤结果一致。
- 改动影响系统消息列表展示与筛选,不改变创建、删除、标记已读接口字段。
+70 -50
View File
@@ -329,9 +329,12 @@ export default function AdminSystemMessagesPage() {
if (unreadOnly) {
params.set("unread_only", "true");
}
if (messageTypeFilter !== "all") {
params.set("message_type", messageTypeFilter);
}
const qs = params.toString();
return `/api/v1/system-messages/me?${qs}`;
}, [unreadOnly, viewMode, cardViewPage]);
}, [unreadOnly, viewMode, cardViewPage, messageTypeFilter]);
const listQuery = useQuery({
queryKey: ["admin.system-messages", listPath],
@@ -446,13 +449,7 @@ export default function AdminSystemMessagesPage() {
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 messages = useMemo(() => listQuery.data?.items ?? [], [listQuery.data?.items]);
const unreadIds = useMemo(
() => messages.filter((item) => !item.is_read).map((item) => item.id),
@@ -527,23 +524,37 @@ export default function AdminSystemMessagesPage() {
};
}, [updateTableScrollY]);
const resetCardViewState = () => {
setCardViewPage(1);
setAllLoadedMessages([]);
setIsLoadingMore(false);
};
// Update allLoadedMessages when messages data changes in card view
useEffect(() => {
if (viewMode === "card" && !listQuery.isLoading) {
if (viewMode !== "card" || listQuery.isLoading) {
return;
}
const frameId = window.requestAnimationFrame(() => {
if (cardViewPage === 1) {
setAllLoadedMessages(messages);
setAllLoadedMessages(() => messages);
} else {
setAllLoadedMessages((prev) => {
if (messages.length === 0) {
return prev;
}
const existingIds = new Set(prev.map(m => m.id));
const newMessages = messages.filter(m => !existingIds.has(m.id));
const existingIds = new Set(prev.map((message) => message.id));
const newMessages = messages.filter((message) => !existingIds.has(message.id));
return [...prev, ...newMessages];
});
}
setIsLoadingMore(false);
}
});
return () => {
window.cancelAnimationFrame(frameId);
};
}, [messages, listQuery.isLoading, viewMode, cardViewPage]);
// Handle infinite scroll for card view
@@ -578,12 +589,6 @@ export default function AdminSystemMessagesPage() {
return () => cardBody.removeEventListener("scroll", handleScroll);
}, [viewMode, isLoadingMore, listQuery.isLoading, listQuery.data?.total, allLoadedMessages.length]);
// Reset card view state when filters change
useEffect(() => {
setCardViewPage(1);
setAllLoadedMessages([]);
}, [messageTypeFilter, unreadOnly]);
const openCreateMessageModal = () => {
setError("");
setSuccess("");
@@ -639,6 +644,7 @@ export default function AdminSystemMessagesPage() {
return (
<AntCard
key={item.id}
className="admin-system-messages-message-card"
size="small"
title={
<Space direction="vertical" size={2} style={{ width: "100%" }}>
@@ -662,33 +668,25 @@ export default function AdminSystemMessagesPage() {
}
>
<Space direction="vertical" size={10} style={{ width: "100%" }}>
<div>
<div className="admin-system-messages-message-card-field">
<Typography.Text type="secondary"></Typography.Text>
<div style={{ marginTop: 4 }}>
<Tag color={MESSAGE_TYPE_COLORS[item.message_type]}>
{MESSAGE_TYPE_LABELS[item.message_type]}
</Tag>
</div>
<Tag color={MESSAGE_TYPE_COLORS[item.message_type]}>
{MESSAGE_TYPE_LABELS[item.message_type]}
</Tag>
</div>
<div>
<div className="admin-system-messages-message-card-field">
<Typography.Text type="secondary"></Typography.Text>
<div style={{ marginTop: 4 }}>
<Tag color={item.is_read ? "default" : "processing"}>
{item.is_read ? "已读" : "未读"}
</Tag>
</div>
<Tag color={item.is_read ? "default" : "processing"}>
{item.is_read ? "已读" : "未读"}
</Tag>
</div>
<div>
<div className="admin-system-messages-message-card-field">
<Typography.Text type="secondary"></Typography.Text>
<div style={{ marginTop: 4 }}>
<MarkdownPreview compact content={item.content} />
</div>
<MarkdownPreview compact content={item.content} />
</div>
<div>
<div className="admin-system-messages-message-card-field">
<Typography.Text type="secondary"></Typography.Text>
<div style={{ marginTop: 4 }}>
<Typography.Text>{formatDateTime(item.created_at)}</Typography.Text>
</div>
<Typography.Text>{formatDateTime(item.created_at)}</Typography.Text>
</div>
</Space>
</AntCard>
@@ -851,25 +849,31 @@ export default function AdminSystemMessagesPage() {
)}
<Form layout="inline" style={{ rowGap: 12 }}>
<Form.Item label="类型" className="min-w-[170px]">
<Form.Item label="类型" style={{ width: 170 }}>
<Select<SystemMessageType | "all">
value={messageTypeFilter}
options={[
{ label: "全部类型", value: "all" },
...MESSAGE_TYPE_OPTIONS,
]}
onChange={setMessageTypeFilter}
onChange={(value) => {
setMessageTypeFilter(value);
resetCardViewState();
}}
/>
</Form.Item>
<Form.Item label="状态" className="min-w-[170px]">
<Form.Item label="状态" style={{ width: 170 }}>
<Select<"all" | "unread">
value={unreadOnly ? "unread" : "all"}
options={[
{ label: "全部状态", value: "all" },
{ label: "仅未读", value: "unread" },
]}
onChange={(value) => setUnreadOnly(value === "unread")}
onChange={(value) => {
setUnreadOnly(value === "unread");
resetCardViewState();
}}
/>
</Form.Item>
@@ -889,23 +893,39 @@ export default function AdminSystemMessagesPage() {
columns={columns}
dataSource={messages}
loading={listQuery.isFetching}
locale={{ emptyText: <Empty description="暂无系统消息" /> }}
pagination={{ pageSize: 20, showSizeChanger: true, hideOnSinglePage: false, showTotal: (total) => `${total}` }}
locale={{
emptyText: (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="暂无系统消息"
/>
),
}}
pagination={{
pageSize: 20,
showSizeChanger: true,
hideOnSinglePage: false,
showTotal: (total) => `${total}`,
style: { marginBottom: 0 },
}}
scroll={{ x: 1100, y: tableScrollY }}
/>
</div>
) : (
<div className="mt-4">
<div className="admin-system-messages-card-view">
{listQuery.isLoading && allLoadedMessages.length === 0 ? (
<div style={{ textAlign: "center", padding: "40px 0" }}>
<div className="admin-system-messages-card-view-state">
<Spin tip="加载中..." />
</div>
) : allLoadedMessages.length === 0 ? (
<div style={{ textAlign: "center", padding: "40px 0" }}>
<Empty description="暂无系统消息" />
<div className="admin-system-messages-card-view-state">
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="暂无系统消息"
/>
</div>
) : (
<div>
<div className="admin-system-messages-card-view-content">
<Row gutter={[12, 12]}>
{allLoadedMessages.map((item) => (
<Col key={item.id} xs={24} sm={24} md={12} lg={8} xl={6}>
+74
View File
@@ -116,6 +116,26 @@
background: var(--ant-color-bg-container) !important;
}
:root[data-fquiz-theme="dark"] .admin-system-messages-message-card {
border-color: color-mix(in srgb, var(--fquiz-theme-primary) 35%, var(--ant-color-border-secondary)) !important;
background:
linear-gradient(
180deg,
color-mix(in srgb, var(--ant-color-bg-container) 92%, var(--fquiz-theme-primary) 8%) 0%,
var(--ant-color-bg-container) 100%
) !important;
box-shadow: 0 8px 18px color-mix(in srgb, black 40%, transparent) !important;
}
:root[data-fquiz-theme="dark"] .admin-system-messages-message-card > .ant-card-head {
border-bottom-color: color-mix(in srgb, var(--fquiz-theme-primary) 25%, var(--ant-color-border-secondary)) !important;
background: color-mix(in srgb, var(--fquiz-theme-primary) 10%, transparent) !important;
}
:root[data-fquiz-theme="dark"] .admin-system-messages-message-card > .ant-card-body {
background: var(--ant-color-bg-container) !important;
}
:root[data-fquiz-theme="dark"] .admin-system-params-param-card {
border-color: color-mix(in srgb, var(--fquiz-theme-primary) 35%, var(--ant-color-border-secondary)) !important;
background:
@@ -379,6 +399,60 @@ body {
overflow-y: auto;
}
.admin-system-messages-table-anchor .ant-table-body {
min-height: var(--admin-system-messages-table-body-min-height, 180px);
}
.admin-system-messages-card-view {
display: flex;
min-height: 0;
flex: 1;
flex-direction: column;
}
.admin-system-messages-card-view-content {
min-height: 0;
flex: 1;
padding: 2px 2px 4px;
}
.admin-system-messages-card-view-state {
display: flex;
min-height: 240px;
flex: 1;
align-items: center;
justify-content: center;
}
.admin-system-messages-message-card {
height: 100%;
border-color: color-mix(in srgb, var(--fquiz-theme-primary) 26%, var(--ant-color-border-secondary));
background:
linear-gradient(
180deg,
color-mix(in srgb, var(--fquiz-theme-bg-container) 96%, var(--fquiz-theme-primary) 4%) 0%,
var(--fquiz-theme-bg-container) 100%
);
box-shadow: 0 8px 18px color-mix(in srgb, var(--fquiz-theme-text-primary) 8%, transparent);
}
.admin-system-messages-message-card > .ant-card-head {
min-height: 44px;
border-bottom-color: color-mix(in srgb, var(--fquiz-theme-primary) 18%, var(--ant-color-border-secondary));
background: color-mix(in srgb, var(--fquiz-theme-primary) 6%, transparent);
}
.admin-system-messages-message-card > .ant-card-body {
padding-block: 14px;
}
.admin-system-messages-message-card-field {
display: grid;
grid-template-columns: 64px minmax(0, 1fr);
gap: 8px;
align-items: baseline;
}
.admin-users-card-view {
display: flex;
min-height: 0;