[fix]:[FL-152][补齐角色管理页面一致性细节]
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
@@ -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 或权限语义。
|
||||
|
||||
@@ -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}>
|
||||
|
||||
Reference in New Issue
Block a user