[feat]:[FL-153][系统参数管理页面一致性优化]

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
chengkai3
2026-06-20 06:50:53 +08:00
parent 6989775abe
commit 012b62fab9
5 changed files with 293 additions and 39 deletions
+3 -1
View File
@@ -23,12 +23,14 @@ router = APIRouter(prefix="/admin/system-params", tags=["admin-system-params"])
@router.get("", response_model=SystemParamListResponse)
def get_system_params(
limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0),
keyword: str | None = Query(default=None),
status_filter: str | None = Query(default=None, alias="status"),
_: CurrentUser = Depends(require_any_permission("system_param.read", "system_param.manage")),
db: Session = Depends(get_db),
) -> SystemParamListResponse:
return list_system_params(db, keyword=keyword, status_filter=status_filter)
return list_system_params(db, limit=limit, offset=offset, keyword=keyword, status_filter=status_filter)
@router.post("", response_model=SystemParamSummary)
+11 -1
View File
@@ -46,6 +46,8 @@ def serialize_system_param(item: SystemParam) -> SystemParamSummary:
def list_system_params(
db: Session,
*,
limit: int,
offset: int,
keyword: str | None,
status_filter: str | None,
) -> SystemParamListResponse:
@@ -82,7 +84,15 @@ def list_system_params(
total_stmt = total_stmt.where(SystemParam.status == status_filter)
total = db.scalar(total_stmt) or 0
items = db.execute(stmt.order_by(SystemParam.updated_at.desc(), SystemParam.id.desc())).scalars().all()
items = (
db.execute(
stmt.order_by(SystemParam.updated_at.desc(), SystemParam.id.desc())
.offset(offset)
.limit(limit)
)
.scalars()
.all()
)
return SystemParamListResponse(items=[serialize_system_param(item) for item in items], total=total)
+87
View File
@@ -0,0 +1,87 @@
from __future__ import annotations
import os
import unittest
os.environ.setdefault("DATABASE_URL", "sqlite+pysqlite:///:memory:")
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from api.app import models # noqa: F401
from api.app.core.database import Base
from api.app.models.system_param import SystemParam
from api.app.services.system_param_service import list_system_params
class SystemParamServiceTest(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine(
"sqlite+pysqlite://",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
self.SessionLocal = sessionmaker(
bind=self.engine,
autocommit=False,
autoflush=False,
expire_on_commit=False,
)
Base.metadata.create_all(bind=self.engine)
self.session = self.SessionLocal()
def tearDown(self) -> None:
self.session.close()
Base.metadata.drop_all(bind=self.engine)
self.engine.dispose()
def test_list_system_params_applies_limit_and_offset(self) -> None:
for index in range(5):
self.session.add(
SystemParam(
param_key=f"param_{index}",
param_name=f"Param {index}",
param_value=f"value-{index}",
description=f"description-{index}",
status="enabled",
)
)
self.session.commit()
page = list_system_params(
self.session,
limit=2,
offset=1,
keyword=None,
status_filter=None,
)
self.assertEqual(page.total, 5)
self.assertEqual(len(page.items), 2)
def test_list_system_params_filters_before_paginating(self) -> None:
self.session.add_all(
[
SystemParam(param_key="enabled_first", param_name="Enabled First", status="enabled"),
SystemParam(param_key="disabled_first", param_name="Disabled First", status="disabled"),
SystemParam(param_key="enabled_second", param_name="Enabled Second", status="enabled"),
]
)
self.session.commit()
page = list_system_params(
self.session,
limit=1,
offset=0,
keyword="enabled",
status_filter="enabled",
)
self.assertEqual(page.total, 2)
self.assertEqual(len(page.items), 1)
self.assertEqual(page.items[0].status, "enabled")
if __name__ == "__main__":
unittest.main()
+25
View File
@@ -78,3 +78,28 @@
- 风险与关注点:
- 角色编码重复检查依赖现有角色列表 keyword 查询做前端预检查;服务端创建接口和数据库唯一约束仍是最终一致性保护。
- 改动仅影响角色管理页前端,不改变后端接口、schema 或权限语义。
# Work Log - 系统参数管理页一致性优化(FL-153)
- 背景:
- 系统参数管理页需要对齐用户管理页的列表布局、权限呈现、移动卡片、反馈校验与分页交互规范。
- 本次处理:
- 系统参数页新建入口改为仅 `system_param.manage` 权限可见,保持只读权限下的页面呈现与实际操作权限一致。
- 表格配置对齐用户管理页:`tableLayout="fixed"`、仅纵向滚动、空态属性顺序、加载态和分页最小 total 处理保持一致。
- 移动卡片状态 Tag 文案与颜色对齐用户管理页,并补齐 card view 数据累积的 `requestAnimationFrame` 时序处理。
- 新建/编辑弹窗补齐 `autoComplete="off"`、字段长度规则、参数键防抖重复检查和提交前重复检查。
- 后端系统参数列表接口补齐 `limit/offset` 查询参数并在 service 层实际分页,修复前端分页参数此前未生效的问题。
- 新增 `api/tests/test_system_param_service.py` 覆盖系统参数列表分页与筛选后分页行为。
- 验证:
- 基线:`npm --workspace web exec eslint src/app/admin/users/page.tsx src/app/admin/system-params/page.tsx` 通过,仅用户页存在 1 条既有 unused eslint-disable warning。
- 基线:`npm --workspace web exec tsc --noEmit` 通过。
- 修改后:`npm --workspace web exec eslint src/app/admin/system-params/page.tsx --max-warnings=0` 通过。
- 修改后:`npm --workspace web exec tsc --noEmit` 通过。
- 修改后:`python3 -m py_compile api/app/api/v1/system_params.py api/app/services/system_param_service.py api/tests/test_system_param_service.py` 通过。
- 修改后:`UV_PYTHON_INSTALL_DIR=/tmp/fquiz-uv-python uv run --cache-dir /tmp/fquiz-uv-cache --python 3.11 --with fastapi --with pydantic-settings --with sqlalchemy --with PyJWT --with argon2-cffi --with email-validator --with bcrypt -m unittest api.tests.test_system_param_service` 通过。
- 风险与关注点:
- 改动涉及系统参数列表接口分页契约,但不改变请求/响应字段结构、权限码或 CRUD 语义。
- 当前本机 `python3` 为 3.7.9,不满足 `api/pyproject.toml``requires-python >=3.10`;后端单测使用 `uv` 管理的 Python 3.11 环境验证。
+167 -37
View File
@@ -64,8 +64,8 @@ const PARAM_TABLE_VIEWPORT_GAP = 40;
const PARAM_TABLE_FALLBACK_RESERVE = 220;
function paramStatusLabel(status: SystemParamSummary["status"]): string {
if (status === "enabled") return "启用";
if (status === "disabled") return "禁用";
if (status === "enabled") return "启用";
if (status === "disabled") return "禁用";
return status || "-";
}
@@ -92,6 +92,8 @@ export default function AdminSystemParamsPage() {
const [isLoadingMore, setIsLoadingMore] = useState(false);
const pageCardRef = useRef<HTMLDivElement | null>(null);
const keywordDebounceTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const paramKeyCheckTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const [paramKeyValidationError, setParamKeyValidationError] = useState("");
const canRead = hasPermission("system_param.read") || hasPermission("system_param.manage");
const canManage = hasPermission("system_param.manage");
@@ -133,18 +135,24 @@ export default function AdminSystemParamsPage() {
});
}, [queryClient]);
useTopicSubscription("admin.system-params", useCallback(() => {
void refreshList();
}, [refreshList]));
useTopicSubscription(
"admin.system-params",
useCallback(() => {
if (!user || !canRead) return;
void refreshList();
}, [canRead, refreshList, user]),
);
const resetForm = useCallback(() => {
setEditingId(null);
setParamKeyValidationError("");
formApi.setFieldsValue(EMPTY_FORM);
}, [formApi]);
const startCreate = useCallback(() => {
setError("");
setSuccess("");
setParamKeyValidationError("");
resetForm();
setEditorOpen(true);
}, [resetForm]);
@@ -152,6 +160,7 @@ export default function AdminSystemParamsPage() {
const startEdit = useCallback((item: SystemParamSummary) => {
setError("");
setSuccess("");
setParamKeyValidationError("");
setEditingId(item.id);
formApi.setFieldsValue({
param_key: item.param_key,
@@ -163,6 +172,60 @@ export default function AdminSystemParamsPage() {
setEditorOpen(true);
}, [formApi]);
const validateParamKeyFormat = (paramKey: string): string | null => {
const trimmedKey = paramKey.trim();
if (!trimmedKey) return null;
if (trimmedKey.length < 2) return "参数键至少 2 位";
if (trimmedKey.length > 128) return "参数键不能超过 128 位";
return null;
};
const checkParamKeyAvailability = async (paramKey: string) => {
try {
const params = new URLSearchParams({
keyword: paramKey,
limit: "200",
offset: "0",
});
const response = await fetchWithAuth(`/api/v1/admin/system-params?${params.toString()}`);
if (!response.ok) {
return { available: false, message: "检查失败" };
}
const payload = (await response.json()) as SystemParamListResponse;
const normalizedKey = paramKey.trim().toLowerCase();
const exists = payload.items.some((item) => item.param_key.trim().toLowerCase() === normalizedKey);
return {
available: !exists,
message: exists ? "参数键已存在,请更换后重试" : "参数键可用",
};
} catch {
return { available: false, message: "检查失败" };
}
};
const handleParamKeyChange = (value: string) => {
if (paramKeyCheckTimeoutRef.current) {
clearTimeout(paramKeyCheckTimeoutRef.current);
}
const formatError = validateParamKeyFormat(value);
if (formatError) {
setParamKeyValidationError(formatError);
return;
}
const trimmedValue = value.trim();
if (!trimmedValue || editingId !== null) {
setParamKeyValidationError("");
return;
}
paramKeyCheckTimeoutRef.current = setTimeout(async () => {
const result = await checkParamKeyAvailability(trimmedValue);
setParamKeyValidationError(result.available ? "" : result.message);
}, 500);
};
const closeEditor = useCallback(() => {
setEditorOpen(false);
resetForm();
@@ -179,12 +242,25 @@ export default function AdminSystemParamsPage() {
throw new Error("参数键和参数名称不能为空");
}
const paramKey = values.param_key.trim();
const paramKeyFormatError = validateParamKeyFormat(paramKey);
if (paramKeyFormatError) {
setParamKeyValidationError(paramKeyFormatError);
throw new Error(paramKeyFormatError);
}
if (editingId === null) {
const availabilityCheck = await checkParamKeyAvailability(paramKey);
if (!availabilityCheck.available) {
setParamKeyValidationError(availabilityCheck.message);
throw new Error(availabilityCheck.message);
}
const response = await fetchWithAuth("/api/v1/admin/system-params", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
param_key: values.param_key.trim(),
param_key: paramKey,
param_name: values.param_name.trim(),
param_value: values.param_value,
description: values.description,
@@ -212,6 +288,10 @@ export default function AdminSystemParamsPage() {
}
return "updated" as const;
},
onMutate: () => {
setError("");
setSuccess("");
},
onSuccess: async (mode) => {
setError("");
setSuccess(mode === "created" ? "系统参数已创建" : "系统参数已更新");
@@ -274,7 +354,15 @@ export default function AdminSystemParamsPage() {
}, 500);
};
const items = useMemo(() => listQuery.data?.items ?? [], [listQuery.data?.items]);
const rawItems = useMemo(() => listQuery.data?.items ?? [], [listQuery.data?.items]);
const items = useMemo(() => {
const total = listQuery.data?.total ?? 0;
if (rawItems.length > paginationPageSize && rawItems.length === total) {
const start = (paginationCurrent - 1) * paginationPageSize;
return rawItems.slice(start, start + paginationPageSize);
}
return rawItems;
}, [listQuery.data?.total, paginationCurrent, paginationPageSize, rawItems]);
const listError = listQuery.error instanceof Error ? listQuery.error.message : "";
useToastFeedback({
@@ -284,9 +372,12 @@ export default function AdminSystemParamsPage() {
clearSuccess: () => setSuccess(""),
});
// Accumulate loaded params for card view
useEffect(() => {
if (viewMode === "card" && !listQuery.isLoading) {
if (viewMode !== "card" || listQuery.isLoading) {
return;
}
const frameId = window.requestAnimationFrame(() => {
if (cardViewPage === 1) {
setAllLoadedParams(() => items);
} else {
@@ -300,7 +391,11 @@ export default function AdminSystemParamsPage() {
});
}
setIsLoadingMore(() => false);
}
});
return () => {
window.cancelAnimationFrame(frameId);
};
}, [items, listQuery.isLoading, viewMode, cardViewPage]);
// Handle infinite scroll for card view
@@ -336,17 +431,14 @@ export default function AdminSystemParamsPage() {
return () => cardBody.removeEventListener("scroll", handleScroll);
}, [viewMode, isLoadingMore, listQuery.isLoading, listQuery.data?.total, allLoadedParams.length]);
// Reset card view state when filters change
useEffect(() => {
setCardViewPage(() => 1);
setAllLoadedParams(() => []);
}, [statusFilter, trimmedKeyword]);
useEffect(() => {
return () => {
if (keywordDebounceTimeoutRef.current) {
clearTimeout(keywordDebounceTimeoutRef.current);
}
if (paramKeyCheckTimeoutRef.current) {
clearTimeout(paramKeyCheckTimeoutRef.current);
}
};
}, []);
@@ -389,7 +481,7 @@ export default function AdminSystemParamsPage() {
title={
<Space className="min-w-0" size={8}>
<Typography.Text strong>{param.param_name}</Typography.Text>
<Tag color={param.status === "enabled" ? "success" : "default"} bordered={false}>
<Tag color={param.status === "enabled" ? "green" : "default"}>
{paramStatusLabel(param.status)}
</Tag>
</Space>
@@ -449,20 +541,20 @@ export default function AdminSystemParamsPage() {
title: "ID",
dataIndex: "id",
key: "id",
width: 110,
width: 80,
},
{
title: "参数键",
dataIndex: "param_key",
key: "param_key",
width: 240,
width: 180,
render: (value: string) => <span className="font-mono text-xs">{value}</span>,
},
{
title: "参数名称",
dataIndex: "param_name",
key: "param_name",
width: 200,
width: 160,
},
{
title: "参数值",
@@ -478,7 +570,7 @@ export default function AdminSystemParamsPage() {
key: "status",
width: 120,
render: (value: SystemParamSummary["status"]) => (
<Tag color={value === "enabled" ? "success" : "default"}>
<Tag color={value === "enabled" ? "green" : "default"}>
{paramStatusLabel(value)}
</Tag>
),
@@ -496,7 +588,6 @@ export default function AdminSystemParamsPage() {
baseColumns.push({
title: "操作",
key: "actions",
fixed: "right",
width: 180,
render: (_, record) => {
const deleteLoading = deletingId === record.id;
@@ -648,14 +739,14 @@ export default function AdminSystemParamsPage() {
return (
<div className="flex min-h-0 flex-1 flex-col">
<AntCard
ref={viewMode === "card" ? pageCardRef : undefined}
ref={pageCardRef}
className="admin-system-params-page-card"
title="系统参数管理"
extra={(
extra={canManage ? (
<Button type="primary" onClick={startCreate}>
</Button>
)}
) : null}
>
{viewMode === "card" ? (
<Form layout="vertical" style={{ marginBottom: 16 }}>
@@ -706,14 +797,14 @@ export default function AdminSystemParamsPage() {
>
<Table<SystemParamSummary>
rowKey="id"
loading={listQuery.isFetching}
dataSource={items}
columns={columns}
scroll={{ x: 1120, y: tableScrollY }}
loading={listQuery.isLoading}
tableLayout="fixed"
pagination={{
current: pagination.current,
pageSize: pagination.pageSize,
total: listQuery.data?.total ?? 0,
total: Math.max(listQuery.data?.total ?? 0, 1),
showSizeChanger: true,
pageSizeOptions: [10, 20, 50, 100],
showTotal: () => `${listQuery.data?.total ?? 0}`,
@@ -723,8 +814,14 @@ export default function AdminSystemParamsPage() {
setPagination({ current: page, pageSize });
},
}}
scroll={{ y: tableScrollY }}
locale={{
emptyText: <Empty description="未找到符合筛选条件的系统参数。" image={Empty.PRESENTED_IMAGE_SIMPLE} />,
emptyText: (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="未找到符合筛选条件的系统参数。"
/>
),
}}
/>
</div>
@@ -736,7 +833,10 @@ export default function AdminSystemParamsPage() {
</div>
) : allLoadedParams.length === 0 ? (
<div className="admin-system-params-card-view-state">
<Empty description="未找到符合筛选条件的系统参数。" image={Empty.PRESENTED_IMAGE_SIMPLE} />
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="未找到符合筛选条件的系统参数。"
/>
</div>
) : (
<div className="admin-system-params-card-view-content">
@@ -767,7 +867,7 @@ export default function AdminSystemParamsPage() {
{canManage && (
<Modal
title={editingId === null ? "新建系统参数" : `编辑系统参数:${formApi.getFieldValue('param_name')}ID: ${editingId}`}
title={editingId === null ? "新建系统参数" : `编辑系统参数:${formApi.getFieldValue("param_name")}ID: ${editingId}`}
open={editorOpen}
onCancel={closeEditor}
onOk={() => formApi.submit()}
@@ -776,29 +876,59 @@ export default function AdminSystemParamsPage() {
confirmLoading={saveMutation.isPending}
destroyOnClose
>
<Form<FormState> form={formApi} layout="vertical" initialValues={EMPTY_FORM} onFinish={() => saveMutation.mutate()}>
<Form<FormState>
form={formApi}
layout="vertical"
initialValues={EMPTY_FORM}
onFinish={() => saveMutation.mutate()}
autoComplete="off"
>
<div className="grid gap-4 md:grid-cols-2">
<Form.Item<FormState>
label="参数键"
name="param_key"
rules={[{ required: true, message: "请输入参数键" }]}
validateStatus={paramKeyValidationError ? "error" : ""}
help={paramKeyValidationError}
rules={[
{ required: true, message: "请输入参数键" },
{ min: 2, message: "参数键至少 2 位" },
{ max: 128, message: "参数键不能超过 128 位" },
]}
>
<Input disabled={editingId !== null} placeholder="请输入参数键" />
<Input
disabled={editingId !== null}
placeholder="请输入参数键"
onChange={(event) => handleParamKeyChange(event.target.value)}
/>
</Form.Item>
<Form.Item<FormState>
label="参数名称"
name="param_name"
rules={[{ required: true, message: "请输入参数名称" }]}
rules={[
{ required: true, message: "请输入参数名称" },
{ min: 2, message: "参数名称至少 2 位" },
{ max: 128, message: "参数名称不能超过 128 位" },
]}
>
<Input placeholder="请输入参数名称" />
</Form.Item>
<Form.Item<FormState> className="md:col-span-2" label="参数值" name="param_value">
<Form.Item<FormState>
className="md:col-span-2"
label="参数值"
name="param_value"
rules={[{ max: 20000, message: "参数值不能超过 20000 位" }]}
>
<Input.TextArea rows={4} />
</Form.Item>
<Form.Item<FormState> className="md:col-span-2" label="说明" name="description">
<Form.Item<FormState>
className="md:col-span-2"
label="说明"
name="description"
rules={[{ max: 20000, message: "说明不能超过 20000 位" }]}
>
<Input.TextArea rows={3} />
</Form.Item>