From 012b62fab94f4b4cc97f066b67dfe5e121bbea0a Mon Sep 17 00:00:00 2001 From: chengkai3 Date: Sat, 20 Jun 2026 06:50:53 +0800 Subject: [PATCH] =?UTF-8?q?[feat]:[FL-153][=E7=B3=BB=E7=BB=9F=E5=8F=82?= =?UTF-8?q?=E6=95=B0=E7=AE=A1=E7=90=86=E9=A1=B5=E9=9D=A2=E4=B8=80=E8=87=B4?= =?UTF-8?q?=E6=80=A7=E4=BC=98=E5=8C=96]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: multica-agent --- api/app/api/v1/system_params.py | 4 +- api/app/services/system_param_service.py | 12 +- api/tests/test_system_param_service.py | 87 ++++++++++ memory/2026-06-20.md | 25 +++ web/src/app/admin/system-params/page.tsx | 204 +++++++++++++++++++---- 5 files changed, 293 insertions(+), 39 deletions(-) create mode 100644 api/tests/test_system_param_service.py diff --git a/api/app/api/v1/system_params.py b/api/app/api/v1/system_params.py index b93ea02..72f9890 100644 --- a/api/app/api/v1/system_params.py +++ b/api/app/api/v1/system_params.py @@ -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) diff --git a/api/app/services/system_param_service.py b/api/app/services/system_param_service.py index 1e921c0..4ff6ca8 100644 --- a/api/app/services/system_param_service.py +++ b/api/app/services/system_param_service.py @@ -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) diff --git a/api/tests/test_system_param_service.py b/api/tests/test_system_param_service.py new file mode 100644 index 0000000..7654bd4 --- /dev/null +++ b/api/tests/test_system_param_service.py @@ -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() diff --git a/memory/2026-06-20.md b/memory/2026-06-20.md index 2cb60f8..1c7b464 100644 --- a/memory/2026-06-20.md +++ b/memory/2026-06-20.md @@ -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 环境验证。 diff --git a/web/src/app/admin/system-params/page.tsx b/web/src/app/admin/system-params/page.tsx index c315ea3..5900d8e 100644 --- a/web/src/app/admin/system-params/page.tsx +++ b/web/src/app/admin/system-params/page.tsx @@ -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(null); const keywordDebounceTimeoutRef = useRef(null); + const paramKeyCheckTimeoutRef = useRef(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={ {param.param_name} - + {paramStatusLabel(param.status)} @@ -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) => {value}, }, { 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"]) => ( - + {paramStatusLabel(value)} ), @@ -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 (
新建参数 - )} + ) : null} > {viewMode === "card" ? (
@@ -706,14 +797,14 @@ export default function AdminSystemParamsPage() { > 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: , + emptyText: ( + + ), }} />
@@ -736,7 +833,10 @@ export default function AdminSystemParamsPage() { ) : allLoadedParams.length === 0 ? (
- +
) : (
@@ -767,7 +867,7 @@ export default function AdminSystemParamsPage() { {canManage && ( formApi.submit()} @@ -776,29 +876,59 @@ export default function AdminSystemParamsPage() { confirmLoading={saveMutation.isPending} destroyOnClose > - form={formApi} layout="vertical" initialValues={EMPTY_FORM} onFinish={() => saveMutation.mutate()}> + + form={formApi} + layout="vertical" + initialValues={EMPTY_FORM} + onFinish={() => saveMutation.mutate()} + autoComplete="off" + >
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 位" }, + ]} > - + handleParamKeyChange(event.target.value)} + /> label="参数名称" name="param_name" - rules={[{ required: true, message: "请输入参数名称" }]} + rules={[ + { required: true, message: "请输入参数名称" }, + { min: 2, message: "参数名称至少 2 位" }, + { max: 128, message: "参数名称不能超过 128 位" }, + ]} > - className="md:col-span-2" label="参数值" name="param_value"> + + className="md:col-span-2" + label="参数值" + name="param_value" + rules={[{ max: 20000, message: "参数值不能超过 20000 位" }]} + > - className="md:col-span-2" label="说明" name="description"> + + className="md:col-span-2" + label="说明" + name="description" + rules={[{ max: 20000, message: "说明不能超过 20000 位" }]} + >