[fix]:[FL-152][补齐角色管理页面一致性细节]

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
chengkai3
2026-06-20 02:17:21 +08:00
parent 2c1a4cd361
commit 06c23e5ff0
2 changed files with 138 additions and 11 deletions
+21
View File
@@ -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 或权限语义。
+117 -11
View File
@@ -71,10 +71,12 @@ export default function AdminRolesPage() {
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
const [roleCodeValidationError, setRoleCodeValidationError] = useState("");
const [editingRole, setEditingRole] = useState<RoleItem | null>(null);
const [deletingRoleId, setDeletingRoleId] = useState<string | null>(null);
const [savingRoleId, setSavingRoleId] = useState<string | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const roleCodeCheckTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const [tableScrollY, setTableScrollY] = useState(ROLE_TABLE_MIN_SCROLL_Y);
const tableScrollAnchorRef = useRef<HTMLDivElement | null>(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: (
<Space direction="vertical" size={6} style={{ width: "100%" }}>
{menuLabels.length > 0 ? (
menuLabels.map((label) => (
<Typography.Text key={label}>{label}</Typography.Text>
))
) : (
<Typography.Text type="secondary"></Typography.Text>
)}
</Space>
),
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 (
<AntCard
key={role.id}
@@ -634,6 +730,9 @@ export default function AdminRolesPage() {
if (keywordDebounceTimeoutRef.current) {
clearTimeout(keywordDebounceTimeoutRef.current);
}
if (roleCodeCheckTimeoutRef.current) {
clearTimeout(roleCodeCheckTimeoutRef.current);
}
};
}, []);
@@ -692,7 +791,7 @@ export default function AdminRolesPage() {
<Form.Item style={{ marginBottom: 0 }}>
<Input
allowClear
placeholder="搜索角色编码名称菜单"
placeholder="角色编码/名称/菜单搜索"
value={keywordInput}
onChange={(event) => handleKeywordChange(event.target.value)}
/>
@@ -703,7 +802,7 @@ export default function AdminRolesPage() {
<Form.Item label="关键词" style={{ width: 260 }}>
<Input
allowClear
placeholder="搜索角色编码名称菜单"
placeholder="角色编码/名称/菜单搜索"
value={keywordInput}
onChange={(event) => handleKeywordChange(event.target.value)}
/>
@@ -812,12 +911,19 @@ export default function AdminRolesPage() {
<Form.Item
label="角色编码"
name="code"
validateStatus={roleCodeValidationError ? "error" : ""}
help={roleCodeValidationError}
rules={[
{ required: true, message: "请输入角色编码" },
{ max: 80, message: "角色编码不能超过 80 位" },
{ min: 2, message: "角色编码至少 2 位" },
{ max: 64, message: "角色编码不能超过 64 位" },
]}
>
<Input disabled={editingRole !== null} placeholder="admin.operator" />
<Input
disabled={editingRole !== null}
placeholder="admin.operator"
onChange={(event) => handleRoleCodeChange(event.target.value)}
/>
</Form.Item>
</Col>
<Col xs={24}>