Merge branch 'Wren' into shiy-login

This commit is contained in:
2025-11-25 18:24:28 +08:00
30 changed files with 10523 additions and 747 deletions
+86
View File
@@ -0,0 +1,86 @@
/**
* RBAC API 代理 - 单个角色操作
* GET /api/v3/rbac/roles/:roleId - 获取角色详情
* PUT /api/v3/rbac/roles/:roleId - 更新角色
* DELETE /api/v3/rbac/roles/:roleId - 删除角色
*/
import { json, type LoaderFunctionArgs } from "@remix-run/node";
import { mockRoles, updateRole as updateMockRole, deleteRole as deleteMockRole } from "~/services/rbac-mock-data.server";
// GET - 获取角色详情
export async function loader({ params }: LoaderFunctionArgs) {
const roleId = parseInt(params.roleId || '0');
console.log('📡 [API Route] GET /api/v3/rbac/roles/' + roleId);
const role = mockRoles.find(r => r.id === roleId);
if (!role) {
return json({
detail: '角色不存在'
}, { status: 404 });
}
return json({
code: 200,
message: 'success',
data: role
});
}
// PUT/DELETE
export async function action({ request, params }: LoaderFunctionArgs) {
const roleId = parseInt(params.roleId || '0');
const method = request.method;
console.log('📡 [API Route]', method, '/api/v3/rbac/roles/' + roleId);
const role = mockRoles.find(r => r.id === roleId);
if (!role) {
return json({
detail: '角色不存在'
}, { status: 404 });
}
if (method === 'PUT') {
// 更新角色
const body = await request.json();
console.log('📋 [API Route] 更新数据:', body);
// 系统角色保护
if (role.is_system && body.role_key) {
return json({
detail: '系统角色的role_key不可修改'
}, { status: 400 });
}
// 使用共享Mock数据更新
updateMockRole(roleId, body);
return json({
code: 200,
message: '角色更新成功',
data: role
});
}
if (method === 'DELETE') {
// 删除角色
if (role.is_system) {
return json({
detail: '系统角色不能删除'
}, { status: 400 });
}
// 使用共享Mock数据删除
deleteMockRole(roleId);
return json({
code: 200,
message: '角色删除成功'
});
}
return json({ code: 405, message: 'Method Not Allowed' }, { status: 405 });
}
@@ -0,0 +1,65 @@
/**
* RBAC API 代理 - 角色用户管理
* GET /api/v3/rbac/roles/:roleId/users - 获取角色的用户列表
*/
import { json, type LoaderFunctionArgs } from "@remix-run/node";
import { mockUsers, mockUserRoles, getRoleUsers } from "~/services/rbac-mock-data.server";
// GET - 获取角色的用户列表
export async function loader({ params, request }: LoaderFunctionArgs) {
const roleId = parseInt(params.roleId || '0');
console.log('📡 [API Route] GET /api/v3/rbac/roles/' + roleId + '/users');
// 解析查询参数
const url = new URL(request.url);
const page = parseInt(url.searchParams.get('page') || '1');
const pageSize = parseInt(url.searchParams.get('page_size') || '20');
const area = url.searchParams.get('area');
const username = url.searchParams.get('username');
// 使用共享Mock数据获取角色用户
let users = getRoleUsers(roleId);
// 过滤
if (area) {
users = users.filter(u => u.area.includes(area));
}
if (username) {
users = users.filter(u =>
u.username.includes(username) || u.nick_name.includes(username)
);
}
// 添加assigned_at时间戳
const usersWithTime = users.map(user => {
const userRole = mockUserRoles.find(
ur => ur.user_id === (user.user_id || user.id) && ur.role_id === roleId
);
return {
...user,
user_id: user.user_id || user.id,
assigned_at: userRole?.assigned_at || new Date().toISOString()
};
});
// 分页
const total = usersWithTime.length;
const start = (page - 1) * pageSize;
const end = start + pageSize;
const items = usersWithTime.slice(start, end);
console.log('✅ [API Route] 返回用户数据:', { roleId, total, itemsCount: items.length });
return json({
code: 200,
message: 'success',
data: {
total,
page,
page_size: pageSize,
items
}
});
}
+99
View File
@@ -0,0 +1,99 @@
/**
* RBAC API 代理 - 获取角色列表
* GET /api/v3/rbac/roles
*/
import { json, type LoaderFunctionArgs } from "@remix-run/node";
import { mockRoles, mockUserRoles, addRole } from "~/services/rbac-mock-data.server";
// GET - 获取角色列表
export async function loader({ request }: LoaderFunctionArgs) {
console.log('📡 [API Route] GET /api/v3/rbac/roles 被调用');
// 解析查询参数
const url = new URL(request.url);
const page = parseInt(url.searchParams.get('page') || '1');
const pageSize = parseInt(url.searchParams.get('page_size') || '20');
const roleKey = url.searchParams.get('role_key');
const roleName = url.searchParams.get('role_name');
console.log('📋 [API Route] 查询参数:', { page, pageSize, roleKey, roleName });
// 过滤数据
let filteredRoles = [...mockRoles];
if (roleKey) {
filteredRoles = filteredRoles.filter(r => r.role_key.includes(roleKey));
}
if (roleName) {
filteredRoles = filteredRoles.filter(r => r.role_name.includes(roleName));
}
// 添加用户数统计
const rolesWithCount = filteredRoles.map(role => ({
...role,
user_count: mockUserRoles.filter(ur => ur.role_id === role.id).length,
permission_count: 0 // 暂时写死
}));
// 分页
const total = rolesWithCount.length;
const start = (page - 1) * pageSize;
const end = start + pageSize;
const items = rolesWithCount.slice(start, end);
// 返回标准格式
const response = {
code: 200,
message: 'success',
data: {
total,
page,
page_size: pageSize,
items
}
};
console.log('✅ [API Route] 返回数据:', { total, itemsCount: items.length });
return json(response, {
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization'
}
});
}
// POST - 创建角色
export async function action({ request }: LoaderFunctionArgs) {
const method = request.method;
if (method === 'POST') {
console.log('📡 [API Route] POST /api/v3/rbac/roles 被调用');
const body = await request.json();
console.log('📋 [API Route] 请求体:', body);
// 使用共享Mock数据
const newRole = addRole({
role_key: body.role_key,
role_name: body.role_name,
description: body.description || '',
data_scope: body.data_scope || 'SELF',
priority: body.priority || 10,
is_system: false
});
console.log('✅ [API Route] 角色创建成功:', newRole);
return json({
code: 200,
message: '角色创建成功',
data: newRole
});
}
return json({ code: 405, message: 'Method Not Allowed' }, { status: 405 });
}
@@ -0,0 +1,30 @@
/**
* RBAC API 代理 - 移除用户角色
* DELETE /api/v3/rbac/users/:userId/roles/:roleId
*/
import { json, type LoaderFunctionArgs } from "@remix-run/node";
import { removeUserRole } from "~/services/rbac-mock-data.server";
// DELETE - 移除用户角色
export async function action({ params }: LoaderFunctionArgs) {
const userId = parseInt(params.userId || '0');
const roleId = parseInt(params.roleId || '0');
console.log('📡 [API Route] DELETE /api/v3/rbac/users/' + userId + '/roles/' + roleId);
// 使用共享Mock数据移除角色
const success = removeUserRole(userId, roleId);
if (success) {
return json({
code: 200,
message: '用户角色移除成功'
});
}
return json({
detail: '用户角色关联不存在'
}, { status: 404 });
}
@@ -0,0 +1,38 @@
/**
* RBAC API 代理 - 用户角色管理
* POST /api/v3/rbac/users/:userId/roles - 为用户分配角色
*/
import { json, type LoaderFunctionArgs } from "@remix-run/node";
import { assignUserRole } from "~/services/rbac-mock-data.server";
// POST - 为用户分配角色
export async function action({ request, params }: LoaderFunctionArgs) {
const userId = parseInt(params.userId || '0');
const method = request.method;
console.log('📡 [API Route]', method, '/api/v3/rbac/users/' + userId + '/roles');
if (method === 'POST') {
const body = await request.json();
const roleIds = body.role_ids || [];
console.log('📋 [API Route] 分配角色:', { userId, roleIds });
// 使用共享Mock数据分配角色
roleIds.forEach((roleId: number) => {
assignUserRole(userId, roleId);
});
return json({
code: 200,
message: '角色分配成功',
data: {
user_id: userId,
roles: roleIds.map((id: number) => ({ role_id: id }))
}
});
}
return json({ code: 405, message: 'Method Not Allowed' }, { status: 405 });
}
+35 -26
View File
@@ -7,6 +7,7 @@ import { Button } from "~/components/ui/Button";
import { Pagination } from "~/components/ui/Pagination";
import { FilterPanel, FilterSelect, SearchFilter } from "~/components/ui/FilterPanel";
import { toastService } from "~/components/ui/Toast";
import { messageService } from "~/components/ui/MessageModal";
import {
getDocumentTypes,
deleteDocumentType,
@@ -201,34 +202,42 @@ export default function DocumentTypesList() {
// 处理删除文档类型
const handleDelete = async (id: number) => {
if (confirm('确定要删除该文档类型吗?此操作不会影响关联的评查点分组,但可能会影响使用该类型的文档评查。')) {
setIsDeleting(true);
try {
const formData = new FormData();
formData.append('id', id.toString());
formData.append('intent', 'delete');
const response = await fetch('/document-types?index', {
method: 'POST',
body: formData
});
const result = await response.json();
if (result.success) {
alert('删除成功!');
// 刷新页面
window.location.reload();
} else {
alert(`删除失败: ${result.error || '未知错误'}`);
messageService.show({
title: "确认删除",
message: "确定要删除该文档类型吗?此操作不会影响关联的评查点分组,但可能会影响使用该类型的文档评查。",
type: "warning",
confirmText: "删除",
cancelText: "取消",
confirmDelay: 4,
onConfirm: async () => {
setIsDeleting(true);
try {
const formData = new FormData();
formData.append('id', id.toString());
formData.append('intent', 'delete');
const response = await fetch('/document-types?index', {
method: 'POST',
body: formData
});
const result = await response.json();
if (result.success) {
toastService.success('删除成功!');
// 刷新页面
window.location.reload();
} else {
toastService.error(`删除失败: ${result.error || '未知错误'}`);
}
} catch (error) {
toastService.error(`删除失败: ${error instanceof Error ? error.message : '未知错误'}`);
} finally {
setIsDeleting(false);
}
} catch (error) {
alert(`删除失败: ${error instanceof Error ? error.message : '未知错误'}`);
} finally {
setIsDeleting(false);
}
}
});
};
// 处理编辑文档类型
+13 -11
View File
@@ -576,21 +576,22 @@ export default function DocumentsIndex() {
toastService.warning("文件正在处理中,无法删除");
return;
}
messageService.show({
title: "确认删除",
message: `确定要删除文档"${name}"吗?`,
type: "warning",
confirmText: "删除",
cancelText: "取消",
confirmDelay: 4,
onConfirm: () => {
// 设置删除状态为true
setIsDeleting(true);
const form = new FormData();
form.append("_action", "delete");
form.append("id", id);
fetcher.submit(form, { method: "post" });
}
});
@@ -602,38 +603,39 @@ export default function DocumentsIndex() {
toastService.error('请至少选择一个文档');
return;
}
// 检查是否有正在处理中的文件
const hasProcessingFiles = documents.some((doc: DocumentUI) =>
const hasProcessingFiles = documents.some((doc: DocumentUI) =>
selectedRowKeys.includes(doc.id.toString()) && doc.fileStatus !== 'Processed'
);
if (hasProcessingFiles) {
toastService.error('存在服务器未处理完成的文件,请重新选择需要删除的文件');
return;
}
messageService.show({
title: "确认批量删除",
message: `确认删除选中的 ${selectedRowKeys.length} 个文档?`,
type: "warning",
confirmText: "删除",
cancelText: "取消",
confirmDelay: 4,
onConfirm: () => {
// 设置删除状态为true
setIsDeleting(true);
// 使用fetcher提交表单
const formData = new FormData();
formData.append('_action', 'batchDelete');
// 添加所有选中的ID
selectedRowKeys.forEach(id => {
formData.append('ids', id);
});
fetcher.submit(formData, { method: 'post' });
// 清空选中行
setSelectedRowKeys([]);
}
+31 -22
View File
@@ -7,6 +7,7 @@ import { Button } from "~/components/ui/Button";
import { Pagination } from "~/components/ui/Pagination";
import { FilterPanel, FilterSelect, SearchFilter } from "~/components/ui/FilterPanel";
import { toastService } from "~/components/ui/Toast";
import { messageService } from "~/components/ui/MessageModal";
import {
getEntryModules,
deleteEntryModule,
@@ -211,34 +212,42 @@ export default function EntryModulesList() {
// 处理删除入口模块
const handleDelete = async (id: number) => {
if (confirm('确定要删除该入口模块吗?此操作不可撤销。')) {
setIsDeleting(true);
messageService.show({
title: "确认删除",
message: "确定要删除该入口模块吗?此操作不可撤销。",
type: "warning",
confirmText: "删除",
cancelText: "取消",
confirmDelay: 4,
onConfirm: async () => {
setIsDeleting(true);
try {
const formData = new FormData();
formData.append('id', id.toString());
formData.append('intent', 'delete');
try {
const formData = new FormData();
formData.append('id', id.toString());
formData.append('intent', 'delete');
const response = await fetch('/entry-modules', {
method: 'POST',
body: formData
});
const response = await fetch('/entry-modules', {
method: 'POST',
body: formData
});
const result = await response.json();
const result = await response.json();
if (result.success) {
toastService.success('删除成功!');
// 刷新页面
window.location.reload();
} else {
toastService.error(`删除失败: ${result.error || '未知错误'}`);
if (result.success) {
toastService.success('删除成功!');
// 刷新页面
window.location.reload();
} else {
toastService.error(`删除失败: ${result.error || '未知错误'}`);
}
} catch (error) {
toastService.error(`删除失败: ${error instanceof Error ? error.message : '未知错误'}`);
} finally {
setIsDeleting(false);
}
} catch (error) {
toastService.error(`删除失败: ${error instanceof Error ? error.message : '未知错误'}`);
} finally {
setIsDeleting(false);
}
}
});
};
// 处理编辑入口模块
+15 -7
View File
@@ -8,7 +8,7 @@ import { FilterPanel, FilterSelect, SearchFilter } from "~/components/ui/FilterP
import { Table } from "~/components/ui/Table";
import { Pagination } from "~/components/ui/Pagination";
import { getPromptTemplates, deletePromptTemplate, type PromptTemplateUI } from "~/api/prompts/prompts";
import { toastService } from "~/components/ui";
import { toastService, messageService } from "~/components/ui";
// 样式链接
export function links() {
@@ -217,13 +217,21 @@ export default function PromptsIndex() {
// 删除模板
const handleDeleteTemplate = (id: string) => {
if (confirm('确定要删除该模板吗?删除后无法恢复。')) {
const formData = new FormData();
formData.append('id', id);
formData.append('intent', 'delete');
messageService.show({
title: "确认删除",
message: "确定要删除该模板吗?删除后无法恢复。",
type: "warning",
confirmText: "删除",
cancelText: "取消",
confirmDelay: 4,
onConfirm: () => {
const formData = new FormData();
formData.append('id', id);
formData.append('intent', 'delete');
fetcher.submit(formData, { method: 'post' });
}
fetcher.submit(formData, { method: 'post' });
}
});
};
// 监听 fetcher 状态变化
+204 -39
View File
@@ -8,8 +8,15 @@ import { StatusDot } from "~/components/ui/StatusDot";
import { Table } from "~/components/ui/Table";
import { FilterPanel, FilterSelect, SearchFilter } from "~/components/ui/FilterPanel";
// import { Pagination } from "~/components/ui/Pagination";
import { getRuleGroups, getChildGroups, type RuleGroup, deleteRuleGroup } from "~/api/evaluation_points/rule-groups";
import { toastService } from "~/components/ui";
import {
getRuleGroups,
getChildGroups,
type RuleGroup,
deleteRuleGroup,
batchUpdateRuleGroupStatus,
batchDeleteRuleGroups
} from "~/api/evaluation_points/rule-groups";
import { toastService, messageService } from "~/components/ui";
export function links() {
return [{ rel: "stylesheet", href: indexStyles }];
@@ -27,20 +34,51 @@ export async function loader({ request }: { request: Request }) {
// 获取用户会话信息
const { getUserSession } = await import("~/api/login/auth.server");
const { frontendJWT } = await getUserSession(request);
const response = await getRuleGroups(frontendJWT);
// 🆕 解析URL查询参数(服务端筛选和分页)
const url = new URL(request.url);
const name = url.searchParams.get('name') || undefined;
const code = url.searchParams.get('code') || undefined;
const is_enabled = url.searchParams.get('is_enabled');
const page = parseInt(url.searchParams.get('page') || '1');
const pageSize = parseInt(url.searchParams.get('pageSize') || '50');
// 🆕 调用增强的 getRuleGroups API
const response = await getRuleGroups({
name,
code,
is_enabled: is_enabled ? is_enabled === 'true' : undefined,
pid: null, // 仅获取一级分组
page,
pageSize,
token: frontendJWT
});
if (response.error) {
throw new Error(response.error);
}
return Response.json({ groups: response.data, frontendJWT });
return Response.json({
groups: response.data || [],
totalCount: ('totalCount' in response) ? (response.totalCount || 0) : 0,
page,
pageSize,
frontendJWT
});
} catch (error) {
console.error('加载评查点分组失败:', error);
return Response.json({ groups: [] });
return Response.json({
groups: [],
totalCount: 0,
page: 1,
pageSize: 50
});
}
}
export default function RuleGroupsIndex() {
const { groups: initialGroups, frontendJWT } = useLoaderData<typeof loader>();
const loaderData = useLoaderData<typeof loader>();
const { groups: initialGroups, totalCount = 0, page = 1, pageSize = 50, frontendJWT } = loaderData;
const rootData = useRouteLoaderData("root") as { userRole: string };
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
@@ -49,6 +87,7 @@ export default function RuleGroupsIndex() {
const [loading, setLoading] = useState<Record<string, boolean>>({});
const [filteredChildrenMap, setFilteredChildrenMap] = useState<Record<string, RuleGroup[]>>({});
const [initialLoading, setInitialLoading] = useState<boolean>(true);
const [selectedIds, setSelectedIds] = useState<string[]>([]); // 🆕 批量选择状态
const userRole = rootData?.userRole || 'common';
const hasEditPermission = userRole.toLowerCase().includes('provin');
@@ -191,42 +230,118 @@ export default function RuleGroupsIndex() {
// 处理删除分组
const handleDeleteGroup = async (groupId: string) => {
if (confirm("确定要删除该分组吗?此操作将同时删除该分组下的所有评查点,且不可恢复。")) {
try {
const result = await deleteRuleGroup(groupId, frontendJWT);
if (result.success) {
// 从本地状态中移除被删除的分组
setGroups(prev => {
// 如果是一级分组,直接过滤掉
const filteredGroups = prev.filter(g => g.id !== groupId);
// 如果是二级分组,需要从父分组的 children 中移除
return filteredGroups.map(group => {
if (group.children) {
return {
...group,
children: group.children.filter(child => child.id !== groupId)
};
}
return group;
messageService.show({
title: "确认删除",
message: "确定要删除该分组吗?此操作将同时删除该分组下的所有评查点,且不可恢复。",
type: "warning",
confirmText: "删除",
cancelText: "取消",
confirmDelay: 4,
onConfirm: async () => {
try {
const result = await deleteRuleGroup(groupId, frontendJWT);
if (result.success) {
// 从本地状态中移除被删除的分组
setGroups(prev => {
// 如果是一级分组,直接过滤掉
const filteredGroups = prev.filter(g => g.id !== groupId);
// 如果是二级分组,需要从父分组的 children 中移除
return filteredGroups.map(group => {
if (group.children) {
return {
...group,
children: group.children.filter(child => child.id !== groupId)
};
}
return group;
});
});
});
// 如果被删除的分组当前是展开状态,从展开列表中移除
setExpandedGroups(prev => prev.filter(id => id !== groupId));
// 显示成功消息
// alert('删除成功');
toastService.success('删除成功')
} else {
toastService.error(`删除失败: ${result.error}`);
console.error(`删除失败: ${result.error}`);
// 如果被删除的分组当前是展开状态,从展开列表中移除
setExpandedGroups(prev => prev.filter(id => id !== groupId));
// 显示成功消息
toastService.success('删除成功')
} else {
toastService.error(`删除失败: ${result.error}`);
console.error(`删除失败: ${result.error}`);
}
} catch (error) {
console.error('删除分组失败:', error);
toastService.error('删除分组失败,请稍后重试');
}
} catch (error) {
console.error('删除分组失败:', error);
toastService.error('删除分组失败,请稍后重试');
}
});
};
// 🆕 批量启用/禁用
const handleBatchEnable = async (enable: boolean) => {
if (selectedIds.length === 0) {
toastService.warning('请先选择要操作的分组');
return;
}
try {
const result = await batchUpdateRuleGroupStatus(selectedIds, enable, frontendJWT);
if (result.success) {
toastService.success(`成功${enable ? '启用' : '禁用'} ${result.updated_count} 个分组`);
// 刷新页面以重新加载数据
window.location.reload();
} else {
toastService.error(`批量操作失败:${result.failed_ids.length} 个分组操作失败`);
}
} catch (error) {
console.error('批量操作失败:', error);
toastService.error('批量操作失败,请稍后重试');
}
};
// 🆕 批量删除
const handleBatchDelete = async () => {
if (selectedIds.length === 0) {
toastService.warning('请先选择要删除的分组');
return;
}
messageService.show({
title: "确认批量删除",
message: `确定要删除选中的 ${selectedIds.length} 个分组吗?此操作不可恢复。`,
type: "warning",
confirmText: "删除",
cancelText: "取消",
confirmDelay: 4,
onConfirm: async () => {
try {
const result = await batchDeleteRuleGroups(selectedIds, frontendJWT);
toastService.success(`成功删除 ${result.deleted_count} 个分组`);
if (result.failed_ids.length > 0) {
toastService.warning(`${result.failed_ids.length} 个分组删除失败`);
}
// 刷新页面以重新加载数据
window.location.reload();
} catch (error) {
console.error('批量删除失败:', error);
toastService.error('批量删除失败,请稍后重试');
}
}
});
};
// 🆕 处理全选/取消全选
const handleSelectAll = () => {
if (selectedIds.length === groups.length) {
setSelectedIds([]);
} else {
setSelectedIds(groups.map(g => g.id));
}
};
// 🆕 处理单选
const handleSelectRow = (id: string) => {
setSelectedIds(prev =>
prev.includes(id) ? prev.filter(selectedId => selectedId !== id) : [...prev, id]
);
};
// 处理搜索名称
@@ -450,6 +565,28 @@ export default function RuleGroupsIndex() {
// 定义表格列配置
const columns = [
// 🆕 复选框列
...(hasEditPermission ? [{
title: (
<input
type="checkbox"
checked={selectedIds.length > 0 && selectedIds.length === groups.length}
onChange={handleSelectAll}
style={{ cursor: 'pointer' }}
/>
),
key: "selection",
width: "50px",
render: (_: unknown, record: RuleGroup) => (
<input
type="checkbox"
checked={selectedIds.includes(record.id)}
onChange={() => handleSelectRow(record.id)}
onClick={(e) => e.stopPropagation()}
style={{ cursor: 'pointer' }}
/>
)
}] : []),
{
title: "分组名称",
key: "name",
@@ -579,6 +716,34 @@ export default function RuleGroupsIndex() {
>
</Button>
{hasEditPermission && selectedIds.length > 0 && (
<>
<Button
type="default"
icon="ri-checkbox-circle-line"
onClick={() => handleBatchEnable(true)}
className="mr-2"
>
({selectedIds.length})
</Button>
<Button
type="default"
icon="ri-close-circle-line"
onClick={() => handleBatchEnable(false)}
className="mr-2"
>
({selectedIds.length})
</Button>
<Button
type="danger"
icon="ri-delete-bin-line"
onClick={handleBatchDelete}
className="mr-2"
>
({selectedIds.length})
</Button>
</>
)}
{hasEditPermission && (
<Button
type="primary"
+80 -10
View File
@@ -100,12 +100,18 @@ export async function loader({ request }: LoaderFunctionArgs) {
// console.log("获取到的ID参数:", id);
// 获取一级分组列表 (用于选择父级分组)
const parentGroupsResponse = await getRuleGroups(frontendJWT);
// 🆕 使用增强的 getRuleGroups API,仅获取一级分组且已启用的分组
const parentGroupsResponse = await getRuleGroups({
pid: null, // 仅获取一级分组(pid为null表示顶级分组)
is_enabled: true, // 仅获取已启用的分组
pageSize: 100, // 获取足够多的分组
token: frontendJWT
});
if (parentGroupsResponse.error) {
console.error("获取父分组列表失败:", parentGroupsResponse.error);
throw new Error(parentGroupsResponse.error);
}
const parentGroups: ParentGroup[] = parentGroupsResponse.data ? parentGroupsResponse.data.map(group => ({
id: group.id,
name: group.name
@@ -194,7 +200,7 @@ export async function action({ request }: ActionFunctionArgs) {
code: code.trim(),
description: description?.trim() || "",
is_enabled: status === "active",
pid: parentId || undefined // 🆕 NULL/undefined 表示顶级分组
pid: parentId || null // 🆕 NULL 表示顶级分组
};
try {
@@ -274,10 +280,14 @@ export default function RuleGroupNew() {
parentId?: string;
general?: string;
}>({});
// 🆕 编码唯一性验证状态
const [codeValidating, setCodeValidating] = useState(false);
const [codeValidationTimer, setCodeValidationTimer] = useState<NodeJS.Timeout | null>(null);
// 表单引用
const formRef = useRef<HTMLFormElement>(null);
// 字段是否被触摸过(用于确定何时显示错误)
const [touchedFields, setTouchedFields] = useState<{
name: boolean;
@@ -310,6 +320,44 @@ export default function RuleGroupNew() {
}
}, [group]);
// 🆕 异步验证编码唯一性
const validateCodeUnique = async (code: string): Promise<string> => {
if (!code || code.trim() === "") {
return "";
}
try {
setCodeValidating(true);
const response = await getRuleGroups({
code: code.trim(),
pageSize: 10,
token: data.frontendJWT
});
if (response.error) {
console.error("验证编码唯一性失败:", response.error);
return ""; // 验证失败时不显示错误,避免干扰用户
}
if (response.data && response.data.length > 0) {
// 在编辑模式下,排除当前分组自身
const isDuplicate = response.data.some(g =>
g.id !== group?.id && g.code === code.trim()
);
if (isDuplicate) {
return "该编码已被使用,请使用其他编码";
}
}
return "";
} catch (error) {
console.error("验证编码唯一性出错:", error);
return "";
} finally {
setCodeValidating(false);
}
};
// 验证表单字段
const validateField = (field: string, value: string) => {
switch (field) {
@@ -323,8 +371,8 @@ export default function RuleGroupNew() {
}
return "";
case 'parentId':
return formValues.groupType === "secondary" && value.trim() === ""
? "请选择上级分组"
return formValues.groupType === "secondary" && value.trim() === ""
? "请选择上级分组"
: "";
default:
return "";
@@ -334,12 +382,12 @@ export default function RuleGroupNew() {
// 处理字段改变
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
const { name, value } = e.target;
setFormValues(prev => ({
...prev,
[name]: value
}));
// 标记字段为已触摸
if (['name', 'code', 'parentId'].includes(name)) {
setTouchedFields(prev => ({
@@ -347,13 +395,34 @@ export default function RuleGroupNew() {
[name]: true
}));
}
// 实时验证
const error = validateField(name, value);
setFormErrors(prev => ({
...prev,
[name]: error
}));
// 🆕 编码字段特殊处理:异步验证唯一性(防抖处理)
if (name === 'code' && !error) {
// 清除之前的定时器
if (codeValidationTimer) {
clearTimeout(codeValidationTimer);
}
// 设置新的定时器,500ms后执行验证
const timer = setTimeout(async () => {
const uniqueError = await validateCodeUnique(value);
if (uniqueError) {
setFormErrors(prev => ({
...prev,
code: uniqueError
}));
}
}, 500);
setCodeValidationTimer(timer);
}
};
// 处理分组类型更改
@@ -551,6 +620,7 @@ export default function RuleGroupNew() {
<div className="form-group">
<label htmlFor="code" className="form-label">
<span className="required-mark">*</span>
{codeValidating && <span className="ml-2 text-sm text-gray-500">...</span>}
</label>
<input
type="text"
+155 -10
View File
@@ -15,13 +15,15 @@ import { FilterPanel, FilterSelect, SearchFilter } from '~/components/ui/FilterP
import { Pagination } from '~/components/ui/Pagination';
import { messageService } from '~/components/ui/MessageModal';
import { toastService } from '~/components/ui/Toast';
import {
getRulesList,
deleteRule,
getRuleTypes,
import {
getRulesList,
deleteRule,
getRuleTypes,
getRuleGroupsByType,
batchUpdateRuleStatus,
batchDeleteRules,
type RuleType as ApiRuleType,
type RuleGroup
type RuleGroup
} from '~/api/evaluation_points/rules';
import type { UserRole } from '~/root';
@@ -209,6 +211,9 @@ export default function RulesIndex() {
// 添加一个状态来跟踪是否执行了删除操作
const [isDeleting, setIsDeleting] = useState(false);
// 批量选择状态
const [selectedIds, setSelectedIds] = useState<string[]>([]);
// 使用 ref 跟踪是否正在加载数据,避免重复加载
const isLoadingRef = useRef(false);
@@ -541,6 +546,92 @@ export default function RulesIndex() {
navigate(`/rules/new?id=${rule.id}&mode=copy`);
};
// 批量选择处理
const handleSelectAll = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.checked) {
setSelectedIds(filteredRules.map(rule => rule.id));
} else {
setSelectedIds([]);
}
};
const handleSelectRow = (id: string) => {
setSelectedIds(prev =>
prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id]
);
};
// 批量启用/禁用
const handleBatchEnable = async (isEnabled: boolean) => {
if (selectedIds.length === 0) {
toastService.warning('请先选择要操作的评查点');
return;
}
try {
setLoading(true);
const result = await batchUpdateRuleStatus(selectedIds, isEnabled, loaderData.frontendJWT);
if (result.success) {
toastService.success(`成功${isEnabled ? '启用' : '禁用'} ${result.updated_count} 个评查点`);
if (result.failed_ids.length > 0) {
toastService.warning(`${result.failed_ids.length} 个评查点操作失败`);
}
// 清空选择
setSelectedIds([]);
// 重新加载数据
fetchData();
} else {
toastService.error('批量操作失败');
}
} catch (error) {
console.error('批量操作失败:', error);
toastService.error('批量操作失败');
} finally {
setLoading(false);
}
};
// 批量删除
const handleBatchDelete = async () => {
if (selectedIds.length === 0) {
toastService.warning('请先选择要删除的评查点');
return;
}
messageService.show({
title: "确认批量删除",
message: `确定要删除选中的 ${selectedIds.length} 个评查点吗?此操作不可恢复。`,
type: "warning",
confirmText: "删除",
cancelText: "取消",
onConfirm: async () => {
try {
setLoading(true);
const result = await batchDeleteRules(selectedIds, loaderData.frontendJWT);
if (result.success) {
toastService.success(`成功删除 ${result.deleted_count} 个评查点`);
if (result.failed_ids.length > 0) {
toastService.warning(`${result.failed_ids.length} 个评查点删除失败`);
}
// 清空选择
setSelectedIds([]);
// 重新加载数据
fetchData();
} else {
toastService.error('批量删除失败');
}
} catch (error) {
console.error('批量删除失败:', error);
toastService.error('批量删除失败');
} finally {
setLoading(false);
}
}
});
};
const handlePageChange = (page: number) => {
const newParams = new URLSearchParams(searchParams);
newParams.set('page', page.toString());
@@ -573,6 +664,28 @@ export default function RulesIndex() {
// 定义表格列配置
const columns = [
// 添加复选框列(仅开发者可见)
...(isDeveloper ? [{
title: (
<input
type="checkbox"
checked={selectedIds.length > 0 && selectedIds.length === filteredRules.length && filteredRules.length > 0}
onChange={handleSelectAll}
disabled={filteredRules.length === 0}
/>
),
key: "selection",
align: "center" as const,
width: "50px",
render: (_: unknown, record: Rule) => (
<input
type="checkbox"
checked={selectedIds.includes(record.id)}
onChange={() => handleSelectRow(record.id)}
onClick={(e) => e.stopPropagation()}
/>
)
}] : []),
{
title: "评查点编码",
dataIndex: "code" as keyof Rule,
@@ -691,11 +804,43 @@ export default function RulesIndex() {
</div>
)}
</div>
{isDeveloper && (
<Button type="primary" icon="ri-add-line" to="/rules/new" className="btn-add-rule">
</Button>
)}
<div className="flex items-center gap-2">
{/* 批量操作按钮(仅在有选择时显示) */}
{isDeveloper && selectedIds.length > 0 && (
<>
<Button
type="default"
icon="ri-check-line"
onClick={() => handleBatchEnable(true)}
className="btn-batch-enable"
>
({selectedIds.length})
</Button>
<Button
type="default"
icon="ri-close-line"
onClick={() => handleBatchEnable(false)}
className="btn-batch-disable"
>
({selectedIds.length})
</Button>
<Button
type="danger"
icon="ri-delete-bin-line"
onClick={handleBatchDelete}
className="btn-batch-delete"
>
({selectedIds.length})
</Button>
</>
)}
{/* 新增按钮 */}
{isDeveloper && (
<Button type="primary" icon="ri-add-line" to="/rules/new" className="btn-add-rule">
</Button>
)}
</div>
</div>
{/* 筛选区域 */}
+66 -62
View File
@@ -48,10 +48,17 @@ import { EVALUATION_OPTIONS } from "~/models/evaluation_points";
import type { EvaluationPointGroup } from "~/models/evaluation_point_groups";
// 导入RuleContext上下文
import { RuleContext } from "~/contexts/RuleContext";
import { postgrestGet, postgrestPost, postgrestPut } from "~/api/postgrest-client";
import { toastService } from '~/components/ui/Toast';
import type { UserRole } from '~/root';
import { getPromptTemplateOptions } from '~/api/prompts/prompts';
import {
createEvaluationPoint,
updateEvaluationPoint,
getEvaluationPoint,
type EvaluationPointData
} from '~/api/evaluation_points/rules';
import { getRulesList } from '~/api/evaluation_points/rules';
import { postgrestGet } from '~/api/postgrest-client';
export const meta: MetaFunction = () => {
return [
@@ -276,69 +283,62 @@ export default function RuleNew() {
try {
setIsLoading(true);
// console.log(`获取评查点数据,ID: ${id}, 复制模式: ${isCopy}`);
// 使用 postgrestGet 替代直接调用 fetch
const postgrestParams = {
filter: {
'id': `eq.${id}`
},
token: frontendJWT
};
const response = await postgrestGet('evaluation_points', postgrestParams);
// 使用新的 getEvaluationPoint API 获取数据
const response = await getEvaluationPoint(String(id), frontendJWT);
if (response.error) {
// API返回错误
toastService.error(`获取评查点数据失败: ${response.error}`);
resetFormData();
navigate('/rules');
return;
}
if (response.data) {
// 使用extractApiData从响应中提取数据
const evaluationPoints = extractApiData<EvaluationPoint[]>(response.data);
try {
// 使用JSON序列化和反序列化来进行深拷贝,避免浏览器差异
const originalData = response.data;
const jsonString = JSON.stringify(originalData);
const data = JSON.parse(jsonString) as EvaluationPointData;
if (evaluationPoints && Array.isArray(evaluationPoints) && evaluationPoints.length > 0) {
try {
// 使用JSON序列化和反序列化来进行深拷贝,避免浏览器差异
const originalData = evaluationPoints[0];
const jsonString = JSON.stringify(originalData);
const data = JSON.parse(jsonString);
// 🔄 复制模式:删除不应该复制的字段
if (isCopy) {
delete data.id;
delete data.created_at;
delete data.updated_at;
delete data.usage_count;
// console.log('📋 复制模式:已清除不应复制的字段(id, created_at, updated_at, usage_count');
// 🔄 复制模式:删除不应该复制的字段
if (isCopy) {
delete data.id;
delete data.created_at;
delete data.updated_at;
// usage_count 不在 EvaluationPointData 接口中,但可能存在于响应数据中
if ('usage_count' in data) {
delete (data as Record<string, unknown>).usage_count;
}
// 🔑 清洗评查点编码:移除最后一个 '--' 及其后面的字符
// 例如:'code-mis--mz' --> 'code-mis', 'code-mbs--alsi--gz' --> 'code-mbs--alsi'
if (data.code) {
const lastDoubleHyphenIndex = data.code.lastIndexOf('--');
if (lastDoubleHyphenIndex !== -1) {
data.code = data.code.substring(0, lastDoubleHyphenIndex);
// console.log('🔑 已清洗评查点编码:', data.code);
}
}
// 设置表单数据
setFormData(data);
// 初始化extractionFields
const extractedFields = extractFieldsFromFormData(data);
setExtractionFields(extractedFields);
// 设置实例键
setInstanceKey(isCopy ? `copy_${id}_${Date.now()}` : `edit_${id}_${Date.now()}`);
} catch (jsonError) {
console.error('JSON处理错误:', jsonError);
toastService.error(`数据处理错误: ${jsonError instanceof Error ? jsonError.message : '未知错误'}`);
resetFormData();
navigate('/rules');
// console.log('📋 复制模式:已清除不应复制的字段(id, created_at, updated_at, usage_count');
}
} else {
console.error('获取数据失败: 返回数据为空');
toastService.error('获取数据失败: 返回数据为空');
// 🔑 清洗评查点编码:移除最后一个 '--' 及其后面的字符
// 例如:'code-mis--mz' --> 'code-mis', 'code-mbs--alsi--gz' --> 'code-mbs--alsi'
if (data.code) {
const lastDoubleHyphenIndex = data.code.lastIndexOf('--');
if (lastDoubleHyphenIndex !== -1) {
data.code = data.code.substring(0, lastDoubleHyphenIndex);
// console.log('🔑 已清洗评查点编码:', data.code);
}
}
// 设置表单数据(EvaluationPointData 兼容 EvaluationPoint
setFormData(data as EvaluationPoint);
// 初始化extractionFields
const extractedFields = extractFieldsFromFormData(data as EvaluationPoint);
setExtractionFields(extractedFields);
// 设置实例键
setInstanceKey(isCopy ? `copy_${id}_${Date.now()}` : `edit_${id}_${Date.now()}`);
} catch (jsonError) {
console.error('JSON处理错误:', jsonError);
toastService.error(`数据处理错误: ${jsonError instanceof Error ? jsonError.message : '未知错误'}`);
resetFormData();
navigate('/rules');
}
} else {
throw new Error(`响应状态: ${response.error}`);
}
} catch (error) {
console.error('获取评查点数据失败:', error);
@@ -801,27 +801,29 @@ export default function RuleNew() {
let response;
if (isEditMode) {
response = await postgrestPut('evaluation_points', finalData, {id: formData.id!}, frontendJWT);
// 使用新的 updateEvaluationPoint API
response = await updateEvaluationPoint(String(formData.id!), finalData, frontendJWT);
// console.log("最终提交的数据", finalData)
} else {
response = await postgrestPost('evaluation_points', finalData, frontendJWT);
// 使用新的 createEvaluationPoint API
response = await createEvaluationPoint(finalData as Omit<EvaluationPointData, 'id' | 'created_at' | 'updated_at'>, frontendJWT);
}
if (response.error) {
if (response.error.includes('evaluation_points_code_key')) {
toastService.error('在基本信息中:评查点编码已存在,请修改后保存。');
} else {
} else {
toastService.error(`系统繁忙: ${response.error}`);
}
setIsLoading(false);
} else if (response.data && Array.isArray(response.data) && response.data.length > 0) {
} else if (response.data) {
// 获取新创建或更新的评查点ID
const savedPointId = response.data[0]?.id;
const savedPointId = response.data.id;
if (savedPointId) {
// 显示成功消息
toastService.success(`评查点${isEditMode ? '更新' : '创建'}成功!`);
// 保存成功后跳转到编辑页面并重新加载数据
navigate(`/rules/new?id=${savedPointId}`, { replace: true });
// 重新获取评查点数据
@@ -1051,6 +1053,8 @@ export default function RuleNew() {
initialData={formData}
evaluationPointGroups={evaluationPointGroups}
riskOptions={EVALUATION_OPTIONS.riskLevelOptions}
frontendJWT={frontendJWT}
evaluationPointId={formData.id}
/>
</div>