diff --git a/memory/2026-06-20.md b/memory/2026-06-20.md index 982c2d1..05441ea 100644 --- a/memory/2026-06-20.md +++ b/memory/2026-06-20.md @@ -36,3 +36,24 @@ - 风险与关注点: - 改动仅影响角色管理页前端展示与交互排布,不改变接口路径、请求/响应字段、权限判断或角色 CRUD 语义。 + +## Follow-up - 角色管理页细节一致性补齐(FL-152) + +- 背景: + - 评审继续指出角色管理页在搜索文案、角色编码校验、移动端操作丰富度等方面仍与用户管理页存在细节差异。 + +- 本次处理: + - 搜索占位文案统一为“按角色编码/名称/菜单搜索”,对齐用户管理页“按...搜索”的表达方式。 + - 新建角色表单新增角色编码格式校验、500ms 防抖重复检查和提交前重复检查,复用现有 `/api/v1/admin/roles` 列表接口做精确 code 命中判断。 + - 角色移动卡片更多菜单补齐“编辑”和“查看菜单”,删除仍使用 `Modal.confirm`,保持与用户卡片的操作入口组织方式一致。 + - 确认角色 schema 仅包含 `code/name/permission_codes/menu_ids`,无 `status` 字段,因此未新增状态筛选器,避免引入未支持的数据模型语义。 + +- 验证: + - 基线:`npm --workspace web exec eslint src/app/admin/users/page.tsx src/app/admin/roles/page.tsx` 通过,仅用户页存在 1 条既有 unused eslint-disable warning。 + - 修改后:`npm --workspace web exec eslint src/app/admin/roles/page.tsx --max-warnings=0` 通过。 + - 修改后:`npm --workspace web exec tsc --noEmit` 通过。 + - 修改后:`npm --workspace web exec eslint src/app/admin/users/page.tsx src/app/admin/roles/page.tsx` 通过,仍仅用户页 1 条既有 warning。 + +- 风险与关注点: + - 角色编码重复检查依赖现有角色列表 keyword 查询做前端预检查;服务端创建接口和数据库唯一约束仍是最终一致性保护。 + - 改动仅影响角色管理页前端,不改变后端接口、schema 或权限语义。 diff --git a/web/src/app/admin/roles/page.tsx b/web/src/app/admin/roles/page.tsx index 33ae5b3..b80f30d 100644 --- a/web/src/app/admin/roles/page.tsx +++ b/web/src/app/admin/roles/page.tsx @@ -71,10 +71,12 @@ export default function AdminRolesPage() { const [error, setError] = useState(""); const [success, setSuccess] = useState(""); + const [roleCodeValidationError, setRoleCodeValidationError] = useState(""); const [editingRole, setEditingRole] = useState(null); const [deletingRoleId, setDeletingRoleId] = useState(null); const [savingRoleId, setSavingRoleId] = useState(null); const [dialogOpen, setDialogOpen] = useState(false); + const roleCodeCheckTimeoutRef = useRef(null); const [tableScrollY, setTableScrollY] = useState(ROLE_TABLE_MIN_SCROLL_Y); const tableScrollAnchorRef = useRef(null); const viewMode: "table" | "card" = isMobile ? "card" : "table"; @@ -226,15 +228,77 @@ export default function AdminRolesPage() { return new Map(menus.map((menu) => [menu.id, `${menu.name} (${menu.code})`])); }, [menus]); + const validateRoleCodeFormat = useCallback((roleCode: string): string | null => { + const trimmedCode = roleCode.trim(); + if (!trimmedCode) return null; + + const validPattern = /^[a-zA-Z0-9_.-]+$/; + if (!validPattern.test(trimmedCode)) { + return "角色编码只能包含英文字母、数字、下划线、点和短横线"; + } + + return null; + }, []); + + const checkRoleCodeAvailability = useCallback(async (roleCode: string) => { + try { + const params = new URLSearchParams({ + limit: "20", + offset: "0", + keyword: roleCode, + }); + const response = await fetchWithAuth(`/api/v1/admin/roles?${params.toString()}`); + if (!response.ok) { + return { available: true, message: "" }; + } + const data = (await response.json()) as { items?: RoleItem[] }; + const existingRole = data.items?.some( + (role) => role.code.trim().toLowerCase() === roleCode.trim().toLowerCase(), + ); + return { + available: !existingRole, + message: existingRole ? "角色编码已存在,请更换后重试" : "", + }; + } catch { + return { available: true, message: "" }; + } + }, [fetchWithAuth]); + + const handleRoleCodeChange = useCallback((value: string) => { + if (roleCodeCheckTimeoutRef.current) { + clearTimeout(roleCodeCheckTimeoutRef.current); + } + + const formatError = validateRoleCodeFormat(value); + if (formatError) { + setRoleCodeValidationError(formatError); + return; + } + + const trimmedValue = value.trim(); + if (!trimmedValue) { + setRoleCodeValidationError(""); + return; + } + + roleCodeCheckTimeoutRef.current = setTimeout(async () => { + const result = await checkRoleCodeAvailability(trimmedValue); + setRoleCodeValidationError(result.available ? "" : result.message); + }, 500); + }, [checkRoleCodeAvailability, validateRoleCodeFormat]); + const closeDialog = useCallback(() => { + if (createRoleMutation.isPending || updateRoleMutation.isPending) return; setEditingRole(null); setDialogOpen(false); + setRoleCodeValidationError(""); form.resetFields(); - }, [form]); + }, [createRoleMutation.isPending, form, updateRoleMutation.isPending]); const startCreate = useCallback(() => { setError(""); setSuccess(""); + setRoleCodeValidationError(""); setEditingRole(null); form.setFieldsValue(EMPTY_FORM); setDialogOpen(true); @@ -243,6 +307,7 @@ export default function AdminRolesPage() { const startEdit = useCallback((role: RoleItem) => { setError(""); setSuccess(""); + setRoleCodeValidationError(""); setEditingRole(role); form.setFieldsValue({ code: role.code, @@ -263,6 +328,11 @@ export default function AdminRolesPage() { name: values.name.trim(), menu_ids: values.menu_ids ?? [], }; + const formatError = validateRoleCodeFormat(payload.code); + if (formatError) { + setRoleCodeValidationError(formatError); + return; + } if (editingRole) { updateRoleMutation.mutate({ @@ -273,6 +343,11 @@ export default function AdminRolesPage() { }, }); } else { + const availabilityCheck = await checkRoleCodeAvailability(payload.code); + if (!availabilityCheck.available) { + setRoleCodeValidationError(availabilityCheck.message); + return; + } createRoleMutation.mutate(payload); } } catch (candidate) { @@ -288,7 +363,7 @@ export default function AdminRolesPage() { const nextError = candidate instanceof Error ? candidate.message : "提交失败,请稍后重试"; setError(nextError); } - }, [createRoleMutation, editingRole, form, updateRoleMutation]); + }, [checkRoleCodeAvailability, createRoleMutation, editingRole, form, updateRoleMutation, validateRoleCodeFormat]); const handleKeywordChange = (value: string) => { setKeywordInput(value); @@ -469,7 +544,33 @@ export default function AdminRolesPage() { const isDeleting = deletingRoleId === role.id; const isSaving = savingRoleId === role.id; const rowBusy = isDeleting || isSaving || createRoleMutation.isPending || updateRoleMutation.isPending; + const menuLabels = role.menu_ids.length > 0 + ? role.menu_ids.map((menuId) => menuNameById.get(menuId) ?? String(menuId)) + : []; + const fullText = menuLabels.length > 0 ? menuLabels.join("、") : "未绑定菜单"; const moreMenuItems: MenuProps["items"] = [ + { + key: "view-menus", + label: "查看菜单", + disabled: menuLabels.length === 0, + onClick: () => { + Modal.info({ + title: `角色菜单:${role.name}(${role.code})`, + content: ( + + {menuLabels.length > 0 ? ( + menuLabels.map((label) => ( + {label} + )) + ) : ( + 未绑定菜单 + )} + + ), + okText: "知道了", + }); + }, + }, { key: "delete", label: "删除", @@ -488,11 +589,6 @@ export default function AdminRolesPage() { }, ]; - const menuLabels = role.menu_ids.length > 0 - ? role.menu_ids.map((menuId) => menuNameById.get(menuId) ?? String(menuId)) - : []; - const fullText = menuLabels.length > 0 ? menuLabels.join("、") : "未绑定菜单"; - return ( handleKeywordChange(event.target.value)} /> @@ -703,7 +802,7 @@ export default function AdminRolesPage() { handleKeywordChange(event.target.value)} /> @@ -812,12 +911,19 @@ export default function AdminRolesPage() { - + handleRoleCodeChange(event.target.value)} + />