feat: 1. 本地化思源黑体的字体包并优先使用。

2. 添加权限映射表和全局查看权限的hook,便于路由控制不同权限按钮显示/隐藏。
3. 删除评查点分组的部分旧api方法。
4. 对接评查点分组接口,文档类型接口, 提示词管理接口, 入口模块管理的接口。
5. 优化角色权限管理的接口,完善不用地区的访问权限认证。
6. 优化主页交叉评查和设置的入口样式和布局。
7. 优化评查点分组,评查规则的功能权限校验。
This commit is contained in:
2025-11-29 10:37:35 +08:00
parent 61facf5d71
commit 30e100ef3e
29 changed files with 2527 additions and 2126 deletions
+105 -3
View File
@@ -14,6 +14,7 @@ export interface BackendRouteInfo {
is_hidden: boolean;
is_cache: boolean;
meta: string;
permissions?: string[]; // ✅ 新增:该路由下用户拥有的权限列表
children?: BackendRouteInfo[];
}
@@ -60,6 +61,7 @@ export interface MenuItem {
order: number;
hideBreadcrumb?: boolean;
requiredRole?: string;
permissions?: string[]; // ✅ 新增:该菜单项的权限列表
children?: MenuItem[];
}
@@ -484,6 +486,88 @@ const FALLBACK_MENU_DATA: Record<string, MenuItem[]> = {
]
};
/**
* 权限映射表类型
* key: 路由路径 (如 '/prompts', '/documents')
* value: 该路由下的权限列表
*/
export type PermissionMap = Map<string, string[]>;
/**
* 从路由树中提取权限映射表
* @param routes 路由树
* @param aggregateChildren 是否聚合子路由权限到父路由(默认true)
* @returns 权限映射表 (路径 -> 权限列表)
*/
export function buildPermissionMap(routes: BackendRouteInfo[], aggregateChildren: boolean = true): PermissionMap {
const permissionMap = new Map<string, string[]>();
/**
* 递归收集路由及其所有子路由的权限
*/
function collectAllPermissions(route: BackendRouteInfo): string[] {
const allPermissions = new Set<string>();
// 添加当前路由的权限
if (route.permissions && route.permissions.length > 0) {
route.permissions.forEach(p => allPermissions.add(p));
}
// 递归收集子路由的权限
if (aggregateChildren && route.children && route.children.length > 0) {
route.children.forEach(child => {
const childPermissions = collectAllPermissions(child);
childPermissions.forEach(p => allPermissions.add(p));
});
}
return Array.from(allPermissions);
}
function traverse(routeList: BackendRouteInfo[]) {
for (const route of routeList) {
// 存储当前路由的权限(聚合或不聚合)
const permissions = aggregateChildren
? collectAllPermissions(route)
: (route.permissions || []);
if (permissions.length > 0) {
permissionMap.set(route.route_path, permissions);
}
// 递归处理子路由
if (route.children && route.children.length > 0) {
traverse(route.children);
}
}
}
traverse(routes);
return permissionMap;
}
/**
* 将权限映射表转换为普通对象(用于JSON序列化)
*/
export function permissionMapToObject(map: PermissionMap): Record<string, string[]> {
const obj: Record<string, string[]> = {};
map.forEach((value, key) => {
obj[key] = value;
});
return obj;
}
/**
* 从对象恢复权限映射表
*/
export function objectToPermissionMap(obj: Record<string, string[]>): PermissionMap {
const map = new Map<string, string[]>();
Object.entries(obj).forEach(([key, value]) => {
map.set(key, value);
});
return map;
}
/**
* 根据角色获取用户可访问的路由(调用后端统一接口)
* @param roleKey 角色标识 (如: 'admin', 'common', 'deptLeader', 'groupLeader') - 暂时不使用,后端通过JWT自动识别
@@ -491,7 +575,17 @@ const FALLBACK_MENU_DATA: Record<string, MenuItem[]> = {
* @param includeHidden 是否包含隐藏路由(默认 false)。true: 用于权限校验,false: 用于菜单渲染
* @returns 用户可访问的路由列表
*/
export async function getUserRoutesByRole(roleKey: string, jwt?: string, includeHidden: boolean = false): Promise<{ success: boolean; data?: MenuItem[]; error?: string; shouldRedirectToHome?: boolean }> {
export async function getUserRoutesByRole(
roleKey: string,
jwt?: string,
includeHidden: boolean = false
): Promise<{
success: boolean;
data?: MenuItem[];
permissionMap?: Record<string, string[]>; // ✅ 新增:返回权限映射表
error?: string;
shouldRedirectToHome?: boolean
}> {
try {
// console.log(`🔍 [User Routes] 获取用户路由,角色: ${roleKey}, JWT前20字符: ${jwt?.substring(0, 20)}`);
@@ -598,6 +692,9 @@ export async function getUserRoutesByRole(roleKey: string, jwt?: string, include
// console.log('🔍 [User Routes] 后端返回的原始路由数据:', JSON.stringify(routes, null, 2));
// console.log('🔍 [User Routes] 检查第一个路由是否有children:', routes[0]?.children);
// ✅ 构建权限映射表
const permissionMapObj = permissionMapToObject(buildPermissionMap(routes));
// 将后端路由格式转换为前端 MenuItem 格式
const menuItems = convertBackendRoutesToMenuItems(routes, includeHidden);
@@ -605,7 +702,11 @@ export async function getUserRoutesByRole(roleKey: string, jwt?: string, include
// console.log('🔍 [User Routes] 转换后的菜单数据:', JSON.stringify(menuItems, null, 2));
// console.log('🔍 [User Routes] 检查第一个菜单项是否有children:', menuItems[0]?.children);
return { success: true, data: menuItems };
return {
success: true,
data: menuItems,
permissionMap: permissionMapObj // ✅ 返回权限映射表
};
} catch (error) {
console.error("❌ [User Routes] 获取用户路由时发生错误:", error);
@@ -801,7 +902,8 @@ function convertBackendRoutesToMenuItems(backendRoutes: BackendRouteInfo[], incl
path: route.route_path,
icon: convertIcon(route.icon),
order: route.sort_order,
hideBreadcrumb: route.is_hidden
hideBreadcrumb: route.is_hidden,
permissions: route.permissions // ✅ 传递权限列表
};
// 递归处理子路由,传递 includeHidden 参数
File diff suppressed because it is too large Load Diff
+19 -678
View File
@@ -1,4 +1,4 @@
import { postgrestGet, postgrestPost, postgrestPut, postgrestDelete, type PostgrestParams } from '../postgrest-client';
import { postgrestGet, postgrestPut, type PostgrestParams } from '../postgrest-client';
import { apiRequest } from '../axios-client';
import { formatDate } from '../../utils';
@@ -238,680 +238,9 @@ export async function getChildGroups(parentId: string, token?: string): Promise<
}
}
/**
* 获取所有评查点分组(包括一级和二级)
* @deprecated 使用 getAllEvaluationPointGroups 代替(FastAPI v3
* @param token JWT token (可选)
* @returns 完整的评查点分组列表
*/
export async function getAllRuleGroups_legacy(token?: string): Promise<{data: RuleGroup[]; error?: never} | {data?: never; error: string; status?: number}> {
try {
// 1. 获取所有分组
const allGroupsParams: PostgrestParams = {
select: `
id,
pid,
name,
is_enabled
`,
token
};
const allGroupsResponse = await postgrestGet<{code: number; msg: string; data: Array<{
id: number;
pid: number;
name: string;
is_enabled: boolean;
}>}>('evaluation_point_groups', allGroupsParams);
if (allGroupsResponse.error) {
return { error: allGroupsResponse.error, status: allGroupsResponse.status };
}
// 2. 处理响应数据
let allGroups: RuleGroup[] = [];
if (allGroupsResponse.data && 'code' in allGroupsResponse.data && allGroupsResponse.data.data) {
allGroups = allGroupsResponse.data.data.map(group => ({
id: group.id.toString(),
pid: group.pid.toString(),
name: group.name,
is_enabled: group.is_enabled,
children: []
}));
} else if (Array.isArray(allGroupsResponse.data)) {
allGroups = allGroupsResponse.data.map(group => ({
id: group.id.toString(),
pid: group.pid.toString(),
name: group.name,
is_enabled: group.is_enabled,
children: []
}));
}
// 3. 构建树形结构(pid为NULL表示顶级分组)
const parentGroups = allGroups.filter(group => !group.pid || group.pid === '0' || group.pid === null);
// 4. 为每个父分组添加子分组
for (const parent of parentGroups) {
parent.children = allGroups.filter(group => group.pid === parent.id);
// 5. 获取每个子分组的评查点数量
for (const child of parent.children) {
const ruleCountParams: PostgrestParams = {
select: 'id',
filter: {
'evaluation_point_groups_id': `eq.${child.id}`
},
token
};
const ruleCountResponse = await postgrestGet<ApiResponse<Array<{id: number}>>>('evaluation_points', ruleCountParams);
child.ruleCount = ruleCountResponse.data && 'code' in ruleCountResponse.data
? (ruleCountResponse.data.data && Array.isArray(ruleCountResponse.data.data) ? ruleCountResponse.data.data.length : 0)
: (Array.isArray(ruleCountResponse.data) ? (ruleCountResponse.data as unknown[]).length : 0)
}
}
return { data: parentGroups };
} catch (error) {
console.error('获取所有评查点分组出错:', error);
return {
error: error instanceof Error ? error.message : '获取所有评查点分组失败',
status: 500
};
}
}
/**
* 获取单个评查点分组详情(包含评查点数量统计)
* @deprecated 使用 getEvaluationPointGroup 代替(FastAPI v3
* @param id 分组ID
* @param token JWT token (可选)
* @returns 分组详情
*/
export async function getRuleGroup_legacy(id: string, token?: string): Promise<{data: RuleGroup; error?: never} | {data?: never; error: string; status?: number}> {
try {
if (!id) {
return { error: '分组ID不能为空', status: 400 };
}
const params: PostgrestParams = {
select: `
id,
pid,
name,
code,
description,
is_enabled,
created_at
`,
filter: {
'id': `eq.${id}`
},
token
};
const response = await postgrestGet<{code: number; msg: string; data: Array<{
id: number;
pid: number;
name: string;
code?: string;
description?: string;
is_enabled: boolean;
created_at?: string;
}>}>('evaluation_point_groups', params);
if (response.error) {
return { error: response.error, status: response.status };
}
let group: RuleGroup | null = null;
if (response.data && 'code' in response.data && response.data.data && response.data.data.length > 0) {
const apiGroup = response.data.data[0];
group = {
id: apiGroup.id.toString(),
pid: apiGroup.pid.toString(),
name: apiGroup.name,
code: apiGroup.code,
description: apiGroup.description,
is_enabled: apiGroup.is_enabled,
createdAt: apiGroup.created_at ? formatDate(apiGroup.created_at) : undefined
};
} else if (Array.isArray(response.data) && response.data.length > 0) {
const apiGroup = response.data[0];
group = {
id: apiGroup.id.toString(),
pid: apiGroup.pid.toString(),
name: apiGroup.name,
code: apiGroup.code,
description: apiGroup.description,
is_enabled: apiGroup.is_enabled,
createdAt: apiGroup.created_at ? formatDate(apiGroup.created_at) : undefined
};
}
if (!group) {
return { error: '未找到指定分组', status: 404 };
}
// 获取该分组下的评查点数量(一级分组和二级分组都统计)
const ruleCountParams: PostgrestParams = {
select: 'id',
filter: {
'evaluation_point_groups_id': `eq.${group.id}`
},
token
};
const ruleCountResponse = await postgrestGet<ApiResponse<Array<{id: number}>>>(
'evaluation_points',
ruleCountParams
);
// 计算评查点数量
let ruleCount = 0;
if (ruleCountResponse.error) {
// 查询失败,使用默认值 0
ruleCount = 0;
} else if (ruleCountResponse.data) {
// 处理包装格式的响应
if ('code' in ruleCountResponse.data && 'data' in ruleCountResponse.data) {
const wrappedData = ruleCountResponse.data as {code: number; data: Array<{id: number}>};
ruleCount = Array.isArray(wrappedData.data) ? wrappedData.data.length : 0;
}
// 处理直接数组格式的响应
else if (Array.isArray(ruleCountResponse.data)) {
ruleCount = (ruleCountResponse.data as Array<{id: number}>).length;
}
}
group.ruleCount = ruleCount;
return { data: group };
} catch (error) {
console.error('获取评查点分组详情失败:', error);
return {
error: error instanceof Error ? error.message : '获取评查点分组详情失败',
status: 500
};
}
}
/**
* 创建评查点分组(增强版 - 包含完整验证)
* @deprecated 使用 createEvaluationPointGroup 代替(FastAPI v3
* @param groupData 分组数据
* @param token JWT token (可选)
* @returns 创建的分组
*/
export async function createRuleGroup_legacy(groupData: RuleGroupCreateUpdateDto, token?: string): Promise<{data: RuleGroup; error?: never} | {data?: never; error: string; status?: number}> {
try {
// ========== 1. 基本字段验证 ==========
// 验证必填字段
if (!groupData.name || !groupData.code) {
return { error: '分组名称和编码不能为空', status: 400 };
}
// 验证名称长度
const trimmedName = groupData.name.trim();
if (trimmedName.length === 0) {
return { error: '分组名称不能为空', status: 400 };
}
if (trimmedName.length > 100) {
return { error: '分组名称不能超过100个字符', status: 400 };
}
// 验证编码格式(只允许字母、数字、连字符、下划线)
const trimmedCode = groupData.code.trim();
if (trimmedCode.length === 0) {
return { error: '分组编码不能为空', status: 400 };
}
if (!/^[a-zA-Z0-9-_]+$/.test(trimmedCode)) {
return { error: '分组编码只能包含字母、数字、连字符和下划线', status: 400 };
}
if (trimmedCode.length > 50) {
return { error: '分组编码不能超过50个字符', status: 400 };
}
// ========== 2. 编码唯一性验证 ==========
const existingGroupsResponse = await getRuleGroups({
code: trimmedCode,
pageSize: 1,
token
});
if (existingGroupsResponse.error) {
return {
error: `编码唯一性检查失败: ${existingGroupsResponse.error}`,
status: existingGroupsResponse.status || 500
};
}
if (existingGroupsResponse.data && existingGroupsResponse.data.length > 0) {
return { error: '分组编码已存在,请使用其他编码', status: 409 };
}
// ========== 3. 父级ID验证 ==========
let pidValue: number | null;
if (!groupData.pid || groupData.pid === '0') {
// 一级分组(顶级分组)
pidValue = null;
} else {
// 二级分组 - 验证父级ID
pidValue = Number(groupData.pid);
if (isNaN(pidValue)) {
return { error: '父分组ID必须是有效的数字', status: 400 };
}
// 验证父级分组是否存在
const parentGroupResponse = await getRuleGroup(groupData.pid, token);
if (parentGroupResponse.error || !parentGroupResponse.data) {
return { error: '父分组不存在或无法访问', status: 404 };
}
// 验证父级分组本身不是二级分组(不允许三级分组)
const parentGroup = parentGroupResponse.data;
if (parentGroup.pid && parentGroup.pid !== '0') {
return { error: '不允许创建三级分组,父级分组必须是一级分组', status: 400 };
}
}
// ========== 4. 构建并发送请求 ==========
const apiGroup: ApiRuleGroup = {
pid: pidValue,
name: trimmedName,
code: trimmedCode,
description: groupData.description?.trim() || '',
is_enabled: groupData.is_enabled !== undefined ? groupData.is_enabled : true
};
const response = await postgrestPost<ApiResponse<ApiRuleGroup> | ApiRuleGroup, ApiRuleGroup>(
'evaluation_point_groups',
apiGroup,
token
);
if (response.error) {
// 处理数据库约束错误
if (response.error.includes('evaluation_point_groups_code_key')) {
return { error: '分组编码已存在(数据库约束)', status: 409 };
}
return { error: response.error, status: response.status };
}
// ========== 5. 处理响应数据 ==========
const apiResponse = extractApiData<ApiRuleGroup>(response.data);
if (!apiResponse || !apiResponse.id) {
return { error: '创建成功但未返回分组ID', status: 500 };
}
// 构建返回对象
const createdGroup: RuleGroup = {
id: apiResponse.id.toString(),
pid: apiResponse.pid !== null ? apiResponse.pid.toString() : '0',
name: apiResponse.name,
code: apiResponse.code || trimmedCode,
description: apiResponse.description,
is_enabled: apiResponse.is_enabled,
createdAt: apiResponse.created_at ? formatDate(apiResponse.created_at) : undefined
};
return { data: createdGroup };
} catch (error) {
console.error('创建评查点分组失败:', error);
return {
error: error instanceof Error ? error.message : '创建评查点分组失败',
status: 500
};
}
}
/**
* 更新评查点分组(增强版 - 包含完整验证,不允许修改 pid)
* @deprecated 使用 updateEvaluationPointGroup 代替(FastAPI v3
* @param id 分组ID
* @param data 更新的分组数据
* @param token JWT token (可选)
* @returns 更新后的分组
*/
export async function updateRuleGroup_legacy(id: string, data: RuleGroupCreateUpdateDto, token?: string): Promise<{data: RuleGroup; error?: never} | {data?: never; error: string; status?: number}> {
try {
// ========== 1. ID有效性验证 ==========
if (!id) {
return { error: '分组ID不能为空', status: 400 };
}
// 验证分组是否存在
const existingGroupResponse = await getRuleGroup(id, token);
if (existingGroupResponse.error || !existingGroupResponse.data) {
return { error: '分组不存在或无法访问', status: 404 };
}
const existingGroup = existingGroupResponse.data;
// ========== 2. 基本字段验证 ==========
// 验证必填字段
if (!data.name || !data.code) {
return { error: '分组名称和编码不能为空', status: 400 };
}
// 验证名称长度
const trimmedName = data.name.trim();
if (trimmedName.length === 0) {
return { error: '分组名称不能为空', status: 400 };
}
if (trimmedName.length > 100) {
return { error: '分组名称不能超过100个字符', status: 400 };
}
// 验证编码格式(只允许字母、数字、连字符、下划线)
const trimmedCode = data.code.trim();
if (trimmedCode.length === 0) {
return { error: '分组编码不能为空', status: 400 };
}
if (!/^[a-zA-Z0-9-_]+$/.test(trimmedCode)) {
return { error: '分组编码只能包含字母、数字、连字符和下划线', status: 400 };
}
if (trimmedCode.length > 50) {
return { error: '分组编码不能超过50个字符', status: 400 };
}
// ========== 3. 编码唯一性验证(排除自身) ==========
const duplicateCheckResponse = await getRuleGroups({
code: trimmedCode,
pageSize: 10,
token
});
if (duplicateCheckResponse.error) {
return {
error: `编码唯一性检查失败: ${duplicateCheckResponse.error}`,
status: duplicateCheckResponse.status || 500
};
}
// 检查是否有其他分组使用了相同的编码
if (duplicateCheckResponse.data && duplicateCheckResponse.data.length > 0) {
const isDuplicate = duplicateCheckResponse.data.some(group => group.id !== id);
if (isDuplicate) {
return { error: '分组编码已被其他分组使用,请使用其他编码', status: 409 };
}
}
// ========== 4. 不允许修改 pid(防止分组层级混乱) ==========
if (data.pid !== undefined) {
const existingPid = existingGroup.pid === '0' || !existingGroup.pid ? null : existingGroup.pid;
const newPid = !data.pid || data.pid === '0' ? null : data.pid;
if (existingPid !== newPid) {
return {
error: '不允许修改分组的父级ID,这会导致分组层级混乱。如需调整层级,请删除后重新创建。',
status: 400
};
}
}
// ========== 5. 构建并发送请求 ==========
const apiGroup: Partial<ApiRuleGroup> = {
name: trimmedName,
code: trimmedCode,
description: data.description?.trim() || '',
is_enabled: data.is_enabled !== undefined ? data.is_enabled : true
};
// 注意:不包含 pid 字段,防止误修改
const response = await postgrestPut<ApiResponse<RuleGroup> | RuleGroup, Partial<ApiRuleGroup>>(
'evaluation_point_groups',
apiGroup,
{ id },
token
);
if (response.error) {
// 处理数据库约束错误
if (response.error.includes('evaluation_point_groups_code_key')) {
return { error: '分组编码已存在(数据库约束)', status: 409 };
}
return { error: response.error, status: response.status };
}
// ========== 6. 处理响应数据 ==========
const extractedData = extractApiData<RuleGroup>(response.data);
if (!extractedData) {
return { error: '更新成功但未返回数据', status: 500 };
}
return { data: extractedData };
} catch (error) {
console.error('更新评查点分组失败:', error);
return {
error: error instanceof Error ? error.message : '更新评查点分组失败',
status: 500
};
}
}
/**
* 删除评查点分组(增强版 - 安全的阻止删除策略)
* @deprecated 使用 deleteEvaluationPointGroup 代替(FastAPI v3
*
* 删除策略:
* - 如果分组下有子分组,拒绝删除,提示用户先删除子分组
* - 如果分组下有评查点,拒绝删除,提示用户先删除或移动评查点
* - 只有空分组才能被删除
*
* @param id 分组ID
* @param token JWT token (可选)
* @returns 删除结果
*/
export async function deleteRuleGroup_legacy(id: string, token?: string): Promise<{success: boolean; error?: string; details?: { hasChildren: boolean; hasPoints: boolean; childCount?: number; pointCount?: number }}> {
try {
// ========== 1. ID验证 ==========
if (!id) {
return { success: false, error: '分组ID不能为空' };
}
// 验证分组是否存在
const groupResponse = await getRuleGroup(id, token);
if (groupResponse.error || !groupResponse.data) {
return { success: false, error: '分组不存在或无法访问' };
}
const group = groupResponse.data;
// ========== 2. 检查是否有子分组(一级分组专用) ==========
let hasChildren = false;
let childCount = 0;
// 如果是一级分组,检查是否有子分组
if (!group.pid || group.pid === '0') {
const childGroupsResponse = await getChildGroups(id, token);
if (childGroupsResponse.error) {
return {
success: false,
error: `检查子分组时出错: ${childGroupsResponse.error}`
};
}
const childGroups = childGroupsResponse.data || [];
childCount = childGroups.length;
hasChildren = childCount > 0;
if (hasChildren) {
return {
success: false,
error: `该分组下存在 ${childCount} 个子分组,请先删除所有子分组后再删除此分组。`,
details: {
hasChildren: true,
hasPoints: false,
childCount
}
};
}
}
// ========== 3. 检查是否有关联的评查点 ==========
const pointsParams: PostgrestParams = {
select: 'id',
filter: {
'evaluation_point_groups_id': `eq.${id}`
},
limit: 1, // 只需要知道是否存在,不需要获取所有数据
token
};
const pointsResponse = await postgrestGet<ApiResponse<Array<{id: number}>>>(
'evaluation_points',
pointsParams
);
let hasPoints = false;
let pointCount = group.ruleCount || 0;
if (pointsResponse.error) {
return {
success: false,
error: `检查关联评查点时出错: ${pointsResponse.error}`
};
}
if (pointsResponse.data) {
if ('code' in pointsResponse.data && pointsResponse.data.data) {
hasPoints = Array.isArray(pointsResponse.data.data) && pointsResponse.data.data.length > 0;
} else if (Array.isArray(pointsResponse.data)) {
hasPoints = pointsResponse.data.length > 0;
}
}
if (hasPoints) {
return {
success: false,
error: `该分组下存在 ${pointCount} 个评查点,请先删除或移动所有评查点后再删除此分组。`,
details: {
hasChildren: false,
hasPoints: true,
pointCount
}
};
}
// ========== 4. 执行删除操作 ==========
const response = await postgrestDelete<ApiResponse<{id: number}>>('evaluation_point_groups', {
filter: {
'id': `eq.${id}`
},
token
});
if (response.error) {
return { success: false, error: `删除失败: ${response.error}` };
}
return {
success: true,
details: {
hasChildren: false,
hasPoints: false
}
};
} catch (error) {
console.error('删除评查点分组失败:', error);
return {
success: false,
error: error instanceof Error ? error.message : '删除评查点分组失败'
};
}
}
/**
* 删除子分组及其相关数据(级联删除)
*
* @deprecated 当前采用阻止删除策略,此函数暂不使用
* @param id 子分组ID
* @param token JWT token (可选)
* @returns 删除结果
*/
async function deleteChildGroup(id: string, token?: string): Promise<{success: boolean; error?: string}> {
try {
// 1. 删除子分组下的所有评查点
const deletePointsResult = await deleteEvaluationPointsByGroupId(id, token);
if (!deletePointsResult.success) {
return deletePointsResult;
}
// 2. 删除子分组本身
const response = await postgrestDelete<ApiResponse<{id: number}>>('evaluation_point_groups', {
filter: {
'id': `eq.${id}`
},
token
});
if (response.error) {
return { success: false, error: response.error };
}
return { success: true };
} catch (error) {
console.error('删除子分组失败:', error);
return {
success: false,
error: error instanceof Error ? error.message : '删除子分组失败'
};
}
}
/**
* 删除指定分组下的所有评查点(级联删除)
*
* @deprecated 当前采用阻止删除策略,此函数暂不使用
* @param groupId 分组ID
* @param token JWT token (可选)
* @returns 删除结果
*/
async function deleteEvaluationPointsByGroupId(groupId: string, token?: string): Promise<{success: boolean; error?: string}> {
try {
const response = await postgrestDelete<ApiResponse<{id: number}>>('evaluation_points', {
filter: {
'evaluation_point_groups_id': `eq.${groupId}`
},
token
});
if (response.error) {
return { success: false, error: response.error };
}
return { success: true };
} catch (error) {
console.error('删除评查点失败:', error);
return {
success: false,
error: error instanceof Error ? error.message : '删除评查点失败'
};
}
}
// ==================== 批量操作接口 ====================
@@ -1184,14 +513,12 @@ export async function getEvaluationPointGroups(
if (code) queryParams.append('code', code);
if (is_enabled !== undefined) queryParams.append('is_enabled', String(is_enabled));
// 🔑 添加 pid 参数过滤
// pid=null 或 pid='0' 表示只查询一级分组,后端需要识别字符串 "null"
// pid=null 或 pid='0' 表示只查询一级分组pid=0
// 如果 pid 未定义,则不传该参数(默认查询所有分组)
if (pid !== undefined) {
if (pid === null || pid === '0') {
// 方案1:传递字符串 "null",后端需要识别并转换为 None/NULL
queryParams.append('pid', 'null');
// 方案2:不传参数,后端默认查询一级分组(需要后端支持)
// 不添加 pid 参数
// FastAPI v3 后端期望 pid=0(数字),不接受字符串 "null"
queryParams.append('pid', '0');
} else {
queryParams.append('pid', String(pid));
}
@@ -1262,11 +589,25 @@ export async function getAllEvaluationPointGroups(
return { error: response.error, status: response.status };
}
if (response.data) {
// ✅ 后端直接返回数组(不包裹在 { data: [...] } 中)
if (response.data && Array.isArray(response.data)) {
const ruleGroups = response.data.map(convertApiGroupToRuleGroup);
return { data: ruleGroups };
}
// ✅ 后端返回 { data: [...] } 格式(向后兼容)
if (response.data && response.data.data && Array.isArray(response.data.data)) {
const ruleGroups = response.data.data.map(convertApiGroupToRuleGroup);
return { data: ruleGroups };
}
// 返回错误(数据格式不正确)
// console.error('❌ 获取分组数据格式错误:', {
// responseData: response.data,
// isArray: Array.isArray(response.data),
// hasDataField: !!(response.data && 'data' in response.data)
// });
return { error: '获取分组树形结构失败:返回数据格式不正确', status: 500 };
} catch (error) {
console.error('❌ 获取分组树形结构出错:', error);
+317 -197
View File
@@ -1,21 +1,22 @@
import { postgrestGet, postgrestPut, postgrestPost, postgrestDelete, type PostgrestParams } from '../postgrest-client';
import { apiRequest } from '../axios-client';
import { formatDate } from '../../utils';
// 提示词模板接口
// 提示词模板接口(数据库格式)
export interface PromptTemplate {
id: number;
template_name: string;
template_code: string | null;
template_type: string;
description: string | null;
template_content: string;
variables: Record<string, string>; // 变量定义
template_abbreviation: string | null;
variables: Record<string, string> | null;
status: number;
version: string;
created_by: number;
created_by: number | null;
created_at: string;
updated_at: string;
template_code?: string; // 模板代码(VLM_Extraction 类型时使用)
template_abbreviation?: string; // 模板简称(VLM_Extraction 类型时使用)
is_active?: boolean;
}
// 提示词模板前端接口
@@ -32,8 +33,8 @@ export interface PromptTemplateUI {
created_by_username?: string; // 创建者用户名
created_at: string;
updated_at: string;
template_code?: string; // 模板代码VLM_Extraction 类型时使用)
template_abbreviation?: string; // 模板简称VLM_Extraction 类型时使用)
template_code?: string; // 模板代码
template_abbreviation?: string; // 模板简称
}
// 搜索参数接口
@@ -45,25 +46,65 @@ export interface PromptSearchParams {
pageSize?: number;
}
// API 响应格式
interface ApiResponse<T> {
code: number;
message: string;
data: T;
}
// 列表响应格式
interface ListResponse {
total: number;
page: number;
page_size: number;
items: PromptTemplate[];
}
// 类型选项
interface TypeOption {
value: string;
label: string;
count: number;
}
// 默认的模板类型列表(当没有指定类型时使用)
const DEFAULT_TEMPLATE_TYPES = [
'LLM_Extraction',
'VLM_Extraction',
'Evaluation',
'Summary',
'Common'
];
/**
* 从不同格式的 API 响应中提取数据
* @param responseData API 响应数据
* @returns 提取后的数据或 null
* 构建请求选项(包括 JWT token)
* @param method HTTP 方法
* @param data 请求数据(可选)
* @param jwt JWT token(可选)
* @returns 请求选项
*/
function extractApiData<T>(responseData: unknown): T | null {
if (!responseData) return null;
function buildRequestOptions(method: 'GET' | 'POST' | 'PUT' | 'DELETE', data?: unknown, jwt?: string) {
const options: {
method: string;
data?: unknown;
headers?: Record<string, string>;
} = {
method,
};
// 格式1: { code: number, msg: string, data: T }
if (typeof responseData === 'object' && responseData !== null &&
'code' in responseData &&
'data' in responseData &&
(responseData as { data: unknown }).data) {
return (responseData as { data: T }).data;
if (data !== undefined) {
options.data = data;
}
// 格式2: 直接是数据对象
return responseData as T;
// 如果提供了 JWT token(服务端调用时),添加到 headers
if (jwt) {
options.headers = {
'Authorization': `Bearer ${jwt}`,
};
}
return options;
}
/**
@@ -96,32 +137,31 @@ function mapStatusToAPI(status: string): number {
/**
* 将数据库模板转换为UI模板
* @param template 数据库模板(可能包含关联的用户信息)
* @param template 数据库模板
* @returns UI模板
*/
export function convertToUITemplate(template: PromptTemplate & { sso_users?: { username: string } }): PromptTemplateUI {
export function convertToUITemplate(template: PromptTemplate): PromptTemplateUI {
return {
id: template.id ? template.id.toString() : '',
template_name: template.template_name,
template_type: template.template_type as "LLM_Extraction" | "VLM_Extraction" | "Evaluation" | "Summary" | "Common",
description: template.description || '',
template_content: template.template_content,
variables: template.variables || {}, // 如果variables为null,则使用空对象
variables: template.variables || {},
status: mapStatusToUI(template.status),
version: template.version,
created_by: template.created_by,
created_by_username: template.sso_users?.username, // 从关联的用户信息中提取用户名
created_by: template.created_by || 0,
created_at: formatDate(template.created_at),
updated_at: formatDate(template.updated_at),
template_code: template.template_code,
template_abbreviation: template.template_abbreviation
template_code: template.template_code || undefined,
template_abbreviation: template.template_abbreviation || undefined
};
}
/**
* 获取提示词模板列表
* @param searchParams 搜索参数
* @param frontendJWT JWT token (可选)
* @param frontendJWT JWT token (可选,已由 axios-client 自动处理)
* @returns 提示词模板列表和总数
*/
export async function getPromptTemplates(searchParams: PromptSearchParams = {}, frontendJWT?: string): Promise<{
@@ -130,89 +170,71 @@ export async function getPromptTemplates(searchParams: PromptSearchParams = {},
status?: number;
}> {
try {
// console.log('获取提示词模板列表,参数:', searchParams);
const TYPE = 'Common,LLM_Extraction,VLM_Extraction,Evaluation,Summary';
const page = searchParams.page || 1;
const pageSize = searchParams.pageSize || 10;
// 构建查询参数,包含对 sso_users 表的左连接
const params: PostgrestParams = {
select: `id,template_name,template_type,description,template_content,variables,status,version,created_by,created_at,updated_at,template_code,template_abbreviation,sso_users!created_by(username)`,
order: 'updated_at.desc',
headers: {
'Prefer': 'count=exact'
},
limit: pageSize,
offset: (page - 1) * pageSize,
filter: {} as Record<string, string>,
token: frontendJWT
// 构建查询参数
const params: Record<string, string | number | boolean | undefined> = {
page,
page_size: pageSize,
};
// 添加筛选条件
const filter: Record<string, string> = {};
filter['status'] = `gte.0`;
if (searchParams.name) {
filter['template_name'] = `ilike.%${searchParams.name}%`;
params.search = searchParams.name;
}
// 模板类型:如果指定了类型则使用指定的,否则使用默认的 5 个类型
if (searchParams.type) {
filter['template_type'] = `eq.${searchParams.type}`;
}else{
filter['template_type'] = `in.(${TYPE})`;
params.template_type = searchParams.type;
} else {
// 没有指定类型时,默认只查询这 5 个类型
params.template_type = DEFAULT_TEMPLATE_TYPES.join(',');
}
if (searchParams.status) {
let statusValue: number;
switch (searchParams.status) {
case 'active': statusValue = 1; break;
case 'inactive': statusValue = 0; break;
case 'system': statusValue = 2; break;
default: statusValue = -1;
}
if (statusValue >= 0) {
filter['status'] = `eq.${statusValue}`;
}
params.status = mapStatusToAPI(searchParams.status);
}
params.filter = filter;
// 发送API请求
// console.log('API请求参数:', params);
const response = await postgrestGet<PromptTemplate[]>('prompt_templates', params);
const response = await apiRequest<ApiResponse<ListResponse>>(
'/api/v3/prompt-templates',
buildRequestOptions('GET', undefined, frontendJWT),
params
);
if (response.error) {
console.error('API返回错误:', response.error);
return { error: response.error, status: response.status };
}
// 提取API返回的数据
const extractedData = extractApiData<PromptTemplate[]>(response.data);
if (!extractedData) {
console.error('提取数据失败,原始响应:', response.data);
// 检查响应数据
if (!response.data) {
console.error('响应数据为空');
return { error: '获取提示词模板数据失败', status: 500 };
}
// console.log(`成功获取${extractedData.length}条提示词模板数据`);
// 解析响应数据
const apiResponse = response.data;
// 从响应头中获取总数
let totalCount = 0;
const responseWithHeaders = response as { data: PromptTemplate[]; headers: Record<string, string> };
if(responseWithHeaders.headers){
const rangeHeader = responseWithHeaders.headers['content-range'];
if(rangeHeader){
const total = rangeHeader.split('/')[1];
if(total !== '*'){
totalCount = parseInt(total, 10);
}
}
// 支持 code=0 (成功) 和 code=200 (成功) 两种格式
if (apiResponse.code !== 0 && apiResponse.code !== 200) {
console.error('API返回非成功状态码:', apiResponse.code, apiResponse.message);
return { error: apiResponse.message || '获取提示词模板失败', status: response.status };
}
const listData = apiResponse.data;
if (!listData || !listData.items) {
console.error('列表数据格式错误:', listData);
return { error: '获取提示词模板数据失败', status: 500 };
}
// 返回转换后的数据
return {
data: {
templates: extractedData.map(convertToUITemplate),
total: totalCount
templates: listData.items.map(convertToUITemplate),
total: listData.total || 0
}
};
} catch (error) {
@@ -227,7 +249,7 @@ export async function getPromptTemplates(searchParams: PromptSearchParams = {},
/**
* 获取提示词模板详情
* @param id 模板ID
* @param frontendJWT JWT token (可选)
* @param frontendJWT JWT token (可选,已由 axios-client 自动处理)
* @returns 提示词模板详情
*/
export async function getPromptTemplate(id: string, frontendJWT?: string): Promise<{
@@ -240,27 +262,31 @@ export async function getPromptTemplate(id: string, frontendJWT?: string): Promi
return { error: '模板ID不能为空', status: 400 };
}
const params: PostgrestParams = {
select: `id,template_name,template_type,description,template_content,variables,status,version,created_by,created_at,updated_at,template_code,template_abbreviation,sso_users!created_by(username)`,
filter: {
'id': `eq.${id}`
},
token: frontendJWT
};
const response = await postgrestGet<PromptTemplate[]>('prompt_templates', params);
const response = await apiRequest<ApiResponse<PromptTemplate>>(
`/api/v3/prompt-templates/${id}`,
buildRequestOptions('GET', undefined, frontendJWT)
);
if (response.error) {
return { error: response.error, status: response.status };
}
const extractedData = extractApiData<PromptTemplate[]>(response.data);
if (!response.data) {
return { error: '获取提示词模板详情失败', status: 500 };
}
if (!extractedData || extractedData.length === 0) {
const apiResponse = response.data;
// 支持 code=0 (成功) 和 code=200 (成功) 两种格式
if (apiResponse.code !== 0 && apiResponse.code !== 200) {
return { error: apiResponse.message || '获取提示词模板详情失败', status: response.status };
}
if (!apiResponse.data) {
return { error: '未找到指定模板', status: 404 };
}
return { data: convertToUITemplate(extractedData[0]) };
return { data: convertToUITemplate(apiResponse.data) };
} catch (error) {
console.error('获取提示词模板详情失败:', error);
return {
@@ -274,7 +300,7 @@ export async function getPromptTemplate(id: string, frontendJWT?: string): Promi
* 创建提示词模板
* @param template 提示词模板数据
* @param userId 当前用户ID
* @param frontendJWT JWT token (可选)
* @param frontendJWT JWT token (可选,已由 axios-client 自动处理)
* @returns 创建的提示词模板
*/
export async function createPromptTemplate(template: Partial<PromptTemplateUI>, userId: number, frontendJWT?: string): Promise<{
@@ -288,59 +314,47 @@ export async function createPromptTemplate(template: Partial<PromptTemplateUI>,
return { error: '模板名称、类型和内容不能为空', status: 400 };
}
// 验证用户ID
if (!userId) {
return { error: '用户ID不能为空', status: 400 };
}
// 准备变量数据
let variablesData: Record<string, string> = {};
if (typeof template.variables === 'string') {
try {
variablesData = JSON.parse(template.variables);
} catch (e) {
console.error('解析变量JSON失败:', e);
}
} else if (template.variables) {
variablesData = template.variables;
}
// 准备API数据
const apiTemplate: Partial<PromptTemplate> = {
const apiTemplate = {
template_name: template.template_name,
template_code: template.template_code || `custom_${Date.now()}`, // 如果没有提供 code,自动生成
template_type: template.template_type,
description: template.description || null,
template_content: template.template_content,
variables: variablesData,
template_abbreviation: template.template_abbreviation || null,
variables: template.variables || {},
status: mapStatusToAPI(template.status || 'active'),
version: template.version || 'v1.0',
created_by: userId, // 使用当前登录用户ID
template_code: template.template_code,
template_abbreviation: template.template_abbreviation
};
if(apiTemplate){
// console.log('apiTemplate', apiTemplate);
// throw new Error('测试错误');
}
const response = await postgrestPost<PromptTemplate, Partial<PromptTemplate>>(
'prompt_templates',
apiTemplate,
frontendJWT
const response = await apiRequest<ApiResponse<PromptTemplate>>(
'/api/v3/prompt-templates',
buildRequestOptions('POST', apiTemplate, frontendJWT)
);
if (response.error) {
return { error: response.error, status: response.status };
}
const extractedData = extractApiData<PromptTemplate>(response.data);
if (!extractedData) {
if (!response.data) {
return { error: '创建提示词模板失败', status: 500 };
}
return { data: convertToUITemplate(extractedData) };
const apiResponse = response.data;
// 支持 code=0 (成功) 和 code=200 (成功) 两种格式
if (apiResponse.code !== 0 && apiResponse.code !== 200) {
return { error: apiResponse.message || '创建提示词模板失败', status: response.status };
}
if (!apiResponse.data) {
return { error: '创建提示词模板失败', status: 500 };
}
return { data: convertToUITemplate(apiResponse.data) };
} catch (error) {
console.error('创建提示词模板失败:', error);
return {
@@ -354,7 +368,7 @@ export async function createPromptTemplate(template: Partial<PromptTemplateUI>,
* 更新提示词模板
* @param id 模板ID
* @param template 提示词模板数据
* @param frontendJWT JWT token (可选)
* @param frontendJWT JWT token (可选,已由 axios-client 自动处理)
* @returns 更新后的提示词模板
*/
export async function updatePromptTemplate(id: string, template: Partial<PromptTemplateUI>, frontendJWT?: string): Promise<{
@@ -367,25 +381,17 @@ export async function updatePromptTemplate(id: string, template: Partial<PromptT
return { error: '模板ID不能为空', status: 400 };
}
// 准备变量数据
let variablesData: Record<string, string> = {};
if (typeof template.variables === 'string') {
try {
variablesData = JSON.parse(template.variables);
} catch (e) {
console.error('解析变量JSON失败:', e);
}
} else if (template.variables) {
variablesData = template.variables;
}
// 准备API数据
const apiTemplate: Partial<PromptTemplate> = {};
// 准备API数据(只传需要更新的字段)
const apiTemplate: Record<string, unknown> = {};
if (template.template_name !== undefined) {
apiTemplate.template_name = template.template_name;
}
if (template.template_code !== undefined) {
apiTemplate.template_code = template.template_code;
}
if (template.template_type !== undefined) {
apiTemplate.template_type = template.template_type;
}
@@ -398,49 +404,43 @@ export async function updatePromptTemplate(id: string, template: Partial<PromptT
apiTemplate.template_content = template.template_content;
}
if (template.template_abbreviation !== undefined) {
apiTemplate.template_abbreviation = template.template_abbreviation;
}
if (template.variables !== undefined) {
apiTemplate.variables = variablesData;
apiTemplate.variables = template.variables;
}
if (template.status !== undefined) {
apiTemplate.status = mapStatusToAPI(template.status);
}
if (template.version !== undefined) {
apiTemplate.version = template.version;
}
if (template.template_code !== undefined) {
apiTemplate.template_code = template.template_code;
}
if (template.template_abbreviation !== undefined) {
apiTemplate.template_abbreviation = template.template_abbreviation;
}
// if(apiTemplate){
// console.log('apiTemplate', apiTemplate);
// throw new Error('测试错误');
// }
const response = await postgrestPut<PromptTemplate, Partial<PromptTemplate>>(
'prompt_templates',
apiTemplate,
{ id },
frontendJWT
const response = await apiRequest<ApiResponse<PromptTemplate>>(
`/api/v3/prompt-templates/${id}`,
buildRequestOptions('PUT', apiTemplate, frontendJWT)
);
if (response.error) {
return { error: response.error, status: response.status };
}
const extractedData = extractApiData<PromptTemplate>(response.data);
if (!extractedData) {
if (!response.data) {
return { error: '更新提示词模板失败', status: 500 };
}
return { data: convertToUITemplate(extractedData) };
const apiResponse = response.data;
// 支持 code=0 (成功) 和 code=200 (成功) 两种格式
if (apiResponse.code !== 0 && apiResponse.code !== 200) {
return { error: apiResponse.message || '更新提示词模板失败', status: response.status };
}
if (!apiResponse.data) {
return { error: '更新提示词模板失败', status: 500 };
}
return { data: convertToUITemplate(apiResponse.data) };
} catch (error) {
console.error('更新提示词模板失败:', error);
return {
@@ -453,7 +453,7 @@ export async function updatePromptTemplate(id: string, template: Partial<PromptT
/**
* 删除提示词模板
* @param id 模板ID
* @param frontendJWT JWT token (可选)
* @param frontendJWT JWT token (可选,已由 axios-client 自动处理)
* @returns 成功或失败信息
*/
export async function deletePromptTemplate(id: string, frontendJWT?: string): Promise<{
@@ -466,21 +466,26 @@ export async function deletePromptTemplate(id: string, frontendJWT?: string): Pr
return { error: '模板ID不能为空', status: 400 };
}
// 使用真实删除替代状态更新
const response = await postgrestDelete<PromptTemplate>(
'prompt_templates',
{
filter: {
'id': `eq.${id}`
},
token: frontendJWT
}
const response = await apiRequest<ApiResponse<{ message: string }>>(
`/api/v3/prompt-templates/${id}`,
buildRequestOptions('DELETE', undefined, frontendJWT)
);
if (response.error) {
return { error: response.error, status: response.status };
}
if (!response.data) {
return { error: '删除提示词模板失败', status: 500 };
}
const apiResponse = response.data;
// 支持 code=0 (成功) 和 code=200 (成功) 两种格式
if (apiResponse.code !== 0 && apiResponse.code !== 200) {
return { error: apiResponse.message || '删除提示词模板失败', status: response.status };
}
return { success: true };
} catch (error) {
console.error('删除提示词模板失败:', error);
@@ -494,7 +499,7 @@ export async function deletePromptTemplate(id: string, frontendJWT?: string): Pr
/**
* 获取指定类型的提示词模板选项
* @param templateType 模板类型(如 'VLM_Extraction', 'LLM_Extraction' 等)
* @param frontendJWT JWT token (可选)
* @param frontendJWT JWT token (可选,已由 axios-client 自动处理)
* @returns 模板选项列表 { value: template_code, label: template_abbreviation }
*/
export async function getPromptTemplateOptions(templateType: string, frontendJWT?: string): Promise<{
@@ -507,35 +512,49 @@ export async function getPromptTemplateOptions(templateType: string, frontendJWT
return { error: '模板类型不能为空', status: 400 };
}
const params: PostgrestParams = {
select: 'template_code,template_abbreviation',
filter: {
'template_type': `eq.${templateType}`,
'status': 'gte.0' // 只查询有效状态的模板
},
order: 'template_abbreviation.asc', // 按标签排序
token: frontendJWT
// 使用列表接口,筛选指定类型并只获取需要的字段
const params = {
template_type: templateType,
page: 1,
page_size: 500, // 获取足够多的选项
};
const response = await postgrestGet<Array<{ template_code: string; template_abbreviation: string }>>('prompt_templates', params);
const response = await apiRequest<ApiResponse<ListResponse>>(
'/api/v3/prompt-templates',
buildRequestOptions('GET', undefined, frontendJWT),
params
);
if (response.error) {
console.error('获取提示词模板选项失败:', response.error);
return { error: response.error, status: response.status };
}
const extractedData = extractApiData<Array<{ template_code: string; template_abbreviation: string }>>(response.data);
if (!extractedData) {
if (!response.data) {
console.error('提取提示词模板选项数据失败');
return { error: '获取提示词模板选项失败', status: 500 };
}
const apiResponse = response.data;
// 支持 code=0 (成功) 和 code=200 (成功) 两种格式
if (apiResponse.code !== 0 && apiResponse.code !== 200) {
return { error: apiResponse.message || '获取提示词模板选项失败', status: response.status };
}
const listData = apiResponse.data;
if (!listData || !listData.items) {
return { error: '获取提示词模板选项失败', status: 500 };
}
// 转换为选项格式
const options = extractedData.map(item => ({
value: item.template_code,
label: item.template_abbreviation
}));
const options = listData.items
.filter(item => item.template_code && item.template_abbreviation) // 只保留有 code 和 abbreviation 的
.map(item => ({
value: item.template_code!,
label: item.template_abbreviation!
}));
return { data: options };
} catch (error) {
@@ -546,3 +565,104 @@ export async function getPromptTemplateOptions(templateType: string, frontendJWT
};
}
}
/**
* 获取提示词模板类型列表
* @param frontendJWT JWT token (可选,已由 axios-client 自动处理)
* @returns 类型选项列表
*/
export async function getPromptTemplateTypes(frontendJWT?: string): Promise<{
data?: TypeOption[];
error?: string;
status?: number;
}> {
try {
const response = await apiRequest<ApiResponse<{ items: TypeOption[] }>>(
'/api/v3/prompt-templates/types',
buildRequestOptions('GET', undefined, frontendJWT)
);
if (response.error) {
console.error('获取提示词模板类型失败:', response.error);
return { error: response.error, status: response.status };
}
if (!response.data) {
console.error('提取提示词模板类型数据失败');
return { error: '获取提示词模板类型失败', status: 500 };
}
const apiResponse = response.data;
// 支持 code=0 (成功) 和 code=200 (成功) 两种格式
if (apiResponse.code !== 0 && apiResponse.code !== 200) {
return { error: apiResponse.message || '获取提示词模板类型失败', status: response.status };
}
if (!apiResponse.data || !apiResponse.data.items) {
return { error: '获取提示词模板类型失败', status: 500 };
}
return { data: apiResponse.data.items };
} catch (error) {
console.error('获取提示词模板类型出错:', error);
return {
error: error instanceof Error ? error.message : '获取提示词模板类型失败',
status: 500
};
}
}
/**
* 复制提示词模板
* @param id 原模板ID
* @param newCode 新模板代码(可选)
* @param frontendJWT JWT token (可选,已由 axios-client 自动处理)
* @returns 复制后的新模板
*/
export async function duplicatePromptTemplate(id: string, newCode?: string, frontendJWT?: string): Promise<{
data?: PromptTemplateUI;
error?: string;
status?: number;
}> {
try {
if (!id) {
return { error: '模板ID不能为空', status: 400 };
}
const params = newCode ? { new_code: newCode } : undefined;
const response = await apiRequest<ApiResponse<PromptTemplate>>(
`/api/v3/prompt-templates/${id}/duplicate`,
buildRequestOptions('POST', undefined, frontendJWT),
params
);
if (response.error) {
return { error: response.error, status: response.status };
}
if (!response.data) {
return { error: '复制提示词模板失败', status: 500 };
}
const apiResponse = response.data;
// 支持 code=0 (成功) 和 code=200 (成功) 两种格式
if (apiResponse.code !== 0 && apiResponse.code !== 200) {
return { error: apiResponse.message || '复制提示词模板失败', status: response.status };
}
if (!apiResponse.data) {
return { error: '复制提示词模板失败', status: 500 };
}
return { data: convertToUITemplate(apiResponse.data) };
} catch (error) {
console.error('复制提示词模板出错:', error);
return {
error: error instanceof Error ? error.message : '复制提示词模板失败',
status: 500
};
}
}
+42 -10
View File
@@ -100,7 +100,8 @@ export interface UserInfo {
nick_name: string;
phone_number?: string;
email?: string;
ou_name: string;
area: string; // v3.3: 地区字段,用于权限隔离(省/市级别)
ou_name: string; // 部门名称,用于组织显示(部门级别)
status: number;
is_leader: boolean;
}
@@ -277,6 +278,8 @@ export async function getRoutes(): Promise<RouteInfo[]> {
children: route.children ? route.children.map(mapRouteData) : undefined
});
console.log('获取当前用户的路由', routes.map(mapRouteData) )
return routes.map(mapRouteData);
} catch (error) {
console.error('❌ [getRoutes] 获取路由数据失败:', error);
@@ -316,6 +319,8 @@ export async function getRoleRoutePermissions(roleId: number): Promise<RoleRoute
created_at: new Date().toISOString()
}));
console.log("路由权限数据", permissions)
return permissions;
} catch (error) {
console.error('❌ [getRoleRoutePermissions] 获取角色路由权限失败:', error);
@@ -352,7 +357,7 @@ export async function getRoleRoutesWithPermissions(roleId: number): Promise<{
}
// 递归转换路由数据格式
const mapRouteData = (route: any): RouteInfo => ({
const mapRouteData = (route: any): any => ({
id: route.id,
route_path: route.route_path,
route_name: route.route_name,
@@ -364,6 +369,8 @@ export async function getRoleRoutesWithPermissions(roleId: number): Promise<{
status: route.status || 1,
parent_id: route.parent_id || null,
component: route.component,
// v3.2: 添加 enabled 字段
enabled: route.enabled !== undefined ? route.enabled : true,
// v3.0: 转换permissions数组
permissions: Array.isArray(route.permissions) ? route.permissions.map((p: any) => ({
id: p.id,
@@ -377,11 +384,14 @@ export async function getRoleRoutesWithPermissions(roleId: number): Promise<{
const mappedRoutes = routes.map(mapRouteData);
// 收集所有已选中的路由ID
const collectRouteIds = (routes: RouteInfo[]): number[] => {
// v3.2: 收集已启用的路由IDenabled=true
const collectRouteIds = (routes: any[]): number[] => {
let ids: number[] = [];
routes.forEach(route => {
ids.push(route.id);
// v3.2: 只收集 enabled=true 的路由
if (route.enabled) {
ids.push(route.id);
}
if (route.children) {
ids = ids.concat(collectRouteIds(route.children));
}
@@ -468,14 +478,14 @@ export async function saveRoleApiPermissions(
}
/**
* 更新角色的路由权限
* 更新角色的路由权限 - v3.2更新
* @param roleId 角色ID
* @param routeIds 路由ID数组
*/
export async function updateRoleRoutePermissions(
roleId: number,
routeIds: number[]
): Promise<{ success: boolean; message: string }> {
): Promise<{ success: boolean; message: string; code?: number }> {
try {
// 导入 axios-client 的 put 函数
const { put } = await import('~/api/axios-client');
@@ -491,13 +501,33 @@ export async function updateRoleRoutePermissions(
throw new Error(response.error);
}
// 后端响应格式: { code: 200, msg: "success", data: { role_id, assigned_count, removed_count, route_ids } }
// v3.3: 处理权限不足错误
if (response.data && response.data.code === 4003) {
return {
success: false,
message: response.data.msg || '权限不足:仅省级管理员可以修改角色路由权限',
code: 4003
};
}
// v3.2: 新响应格式: { code: 200, msg: "success", data: { role_id, enabled_count, disabled_count, inserted_count, route_ids } }
let message = '角色权限更新成功';
if (response.data && response.data.msg) {
message = response.data.msg;
} else if (response.data && response.data.data) {
const { assigned_count, removed_count } = response.data.data;
message = `成功分配 ${assigned_count} 个路由,移除了 ${removed_count} 个旧路由`;
const { enabled_count, disabled_count, inserted_count } = response.data.data;
if (enabled_count !== undefined && disabled_count !== undefined) {
message = `成功启用 ${enabled_count} 个路由,禁用 ${disabled_count} 个路由`;
if (inserted_count && inserted_count > 0) {
message += `,新增 ${inserted_count} 个路由关联`;
}
} else {
// 兼容旧版本响应格式
const { assigned_count, removed_count } = response.data.data;
if (assigned_count !== undefined && removed_count !== undefined) {
message = `成功分配 ${assigned_count} 个路由,移除了 ${removed_count} 个旧路由`;
}
}
}
return { success: true, message };
@@ -558,6 +588,7 @@ export async function getRoleUsers(
nick_name: user.nick_name,
phone_number: user.phone_number || '',
email: user.email || '',
area: user.area || '', // v3.3: 地区字段,用于权限隔离
ou_name: user.ou_name,
status: user.status || 1,
is_leader: user.is_leader || false
@@ -619,6 +650,7 @@ export async function getAllUsers(params?: {
nick_name: user.nick_name,
phone_number: user.phone_number || '',
email: user.email || '',
area: user.area || '', // v3.3: 地区字段,用于权限隔离
ou_name: user.ou_name,
status: user.status || 1,
is_leader: user.is_leader || false
+2 -2
View File
@@ -33,8 +33,8 @@ interface ToastProps {
className?: string;
}
// 默认自动关闭延迟
const DEFAULT_AUTO_CLOSE_DELAY = 3000;
// 默认自动关闭延迟(缩短为2秒)
const DEFAULT_AUTO_CLOSE_DELAY = 2000;
// 导出样式
export function links() {
+272
View File
@@ -0,0 +1,272 @@
/**
* 权限检查Hook
*
* 基于RBAC(基于角色的访问控制)模型,提供细粒度的权限检查功能。
*
* 权限键格式:module:resource:action
* 例如:prompt_template:create:write
*
* 使用示例:
* ```typescript
* const { hasPermission, canCreate, canEdit } = usePermission();
*
* // 检查单个权限
* if (hasPermission('prompt_template:create:write')) {
* // 显示创建按钮
* }
*
* // 使用便捷方法
* if (canCreate('prompt_template')) {
* // 显示创建按钮
* }
* ```
*/
import { useRouteLoaderData, useLocation } from "@remix-run/react";
interface RootLoaderData {
permissions?: string[];
permissionMap?: Record<string, string[]>; // ✅ 新增:权限映射表
userRole: string;
userInfo?: {
role_id?: number;
role_key?: string;
role_name?: string;
};
}
export function usePermission() {
const rootData = useRouteLoaderData("root") as RootLoaderData;
const location = useLocation();
// 从root loader获取权限映射表
const permissionMap = rootData?.permissionMap || {};
const userRole = rootData?.userRole || 'common';
// 🔑 根据当前路由获取权限列表
const currentPath = location.pathname;
// console.log('currentPath', currentPath)
const currentPermissions = permissionMap[currentPath] || [];
// 向后兼容:如果存在旧的permissions数组,也要支持
const legacyPermissions = rootData?.permissions || [];
/**
* 检查是否有指定权限
* @param permissionKey 权限键,如 "prompt_template:create:write"
* @returns boolean
*/
const hasPermission = (permissionKey: string): boolean => {
// 优先使用当前路由的权限列表
if (currentPermissions.length > 0) {
return currentPermissions.includes(permissionKey);
}
// 向后兼容:支持旧的permissions数组
if (legacyPermissions.length > 0) {
return legacyPermissions.includes(permissionKey);
}
// 降级方案:如果没有权限数据,使用userRole判断(兼容现有系统)
// 包含'provin'的角色拥有所有权限
if (userRole.toLowerCase().includes('provin')) {
return true;
}
// 默认只有查看权限
if (permissionKey.includes(':read')) {
return true;
}
return false;
};
/**
* 检查是否有指定路由的权限
* @param path 路由路径,如 "/prompts"
* @param permissionKey 权限键
* @returns boolean
*/
const hasRoutePermission = (path: string, permissionKey: string): boolean => {
const routePermissions = permissionMap[path] || [];
return routePermissions.includes(permissionKey);
};
/**
* 获取当前路由的所有权限
* @returns 权限列表
*/
const getCurrentPermissions = (): string[] => {
return currentPermissions;
};
/**
* 获取指定路由的所有权限
* @param path 路由路径
* @returns 权限列表
*/
const getRoutePermissions = (path: string): string[] => {
return permissionMap[path] || [];
};
/**
* 检查是否有指定模块的任意权限
* @param module 模块名,如 "prompt_template"
* @returns boolean
*/
const hasModulePermission = (module: string): boolean => {
if (currentPermissions.length > 0) {
return currentPermissions.some(p => p.startsWith(`${module}:`));
}
if (legacyPermissions.length > 0) {
return legacyPermissions.some(p => p.startsWith(`${module}:`));
}
// 降级方案
return userRole.toLowerCase().includes('provin');
};
/**
* 检查是否有指定资源和动作的权限
* @param module 模块名,如 "prompt_template"
* @param resource 资源名,如 "create", "list", "detail"
* @param action 动作,如 "read", "write", "delete"
* @returns boolean
*/
const hasResourcePermission = (module: string, resource: string, action: string): boolean => {
const permissionKey = `${module}:${resource}:${action}`;
return hasPermission(permissionKey);
};
/**
* 批量检查权限(需要全部满足)
* @param permissionKeys 权限键数组
* @returns boolean
*/
const hasAllPermissions = (permissionKeys: string[]): boolean => {
return permissionKeys.every(key => hasPermission(key));
};
/**
* 批量检查权限(满足任意一个即可)
* @param permissionKeys 权限键数组
* @returns boolean
*/
const hasAnyPermission = (permissionKeys: string[]): boolean => {
return permissionKeys.some(key => hasPermission(key));
};
// 便捷方法:检查常见操作权限
const canCreate = (module: string): boolean => {
return hasResourcePermission(module, 'create', 'write');
};
const canRead = (module: string, resource: string = 'list'): boolean => {
return hasResourcePermission(module, resource, 'read');
};
const canUpdate = (module: string): boolean => {
return hasResourcePermission(module, 'update', 'write');
};
const canDelete = (module: string): boolean => {
return hasResourcePermission(module, 'delete', 'delete');
};
const canList = (module: string): boolean => {
return hasResourcePermission(module, 'list', 'read');
};
const canView = (module: string): boolean => {
return hasResourcePermission(module, 'detail', 'read');
};
/**
* 检查是否有批量操作权限
* @param module 模块名,如 "evaluation_group"
* @returns boolean - 检查是否有 module:batch:write 权限
*/
const canBatch = (module: string): boolean => {
return hasResourcePermission(module, 'batch', 'write');
};
return {
// 原始权限数据
permissions: currentPermissions, // ✅ 返回当前路由的权限
permissionMap, // ✅ 返回完整的权限映射表
userRole,
// 基础检查方法
hasPermission,
hasModulePermission,
hasResourcePermission,
hasAllPermissions,
hasAnyPermission,
// ✅ 新增:路由权限查询方法
hasRoutePermission,
getCurrentPermissions,
getRoutePermissions,
// 便捷方法
canCreate,
canRead,
canUpdate,
canDelete,
canList,
canView,
canBatch // ✅ 新增:批量操作权限检查
};
}
/**
* 权限组件包装器
*
* 根据权限控制子组件的显示/隐藏
*
* 使用示例:
* ```typescript
* <PermissionGuard permission="prompt_template:create:write">
* <Button>新增模板</Button>
* </PermissionGuard>
* ```
*/
interface PermissionGuardProps {
permission?: string;
permissions?: string[];
requireAll?: boolean; // true=需要全部权限,false=任意一个即可
fallback?: React.ReactNode; // 无权限时显示的内容
children: React.ReactNode;
}
export function PermissionGuard({
permission,
permissions: permissionList,
requireAll = false,
fallback = null,
children
}: PermissionGuardProps) {
const { hasPermission, hasAllPermissions, hasAnyPermission } = usePermission();
let hasAccess = false;
if (permission) {
// 单个权限检查
hasAccess = hasPermission(permission);
} else if (permissionList && permissionList.length > 0) {
// 多个权限检查
hasAccess = requireAll
? hasAllPermissions(permissionList)
: hasAnyPermission(permissionList);
} else {
// 没有指定权限,默认允许访问
hasAccess = true;
}
if (!hasAccess) {
return <>{fallback}</>;
}
return <>{children}</>;
}
+11
View File
@@ -27,6 +27,7 @@ import "remixicon/fonts/remixicon.css";
import styles from "~/styles/main.css?url";
import messageModalStyles from "~/styles/components/message-modal.css?url";
import toastStyles from "~/styles/components/toast.css?url";
import sourceHanSansStyles from "~/styles/fonts/source-han-sans.css?url";
import LoadingBarContainer from "~/components/ui/LoadingBar";
import RouteChangeLoader from "~/components/ui/RouteChangeLoader";
// import { useState, useEffect } from "react";
@@ -124,6 +125,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
let userRole: UserRole = 'common'; // 默认为普通用户
let frontendJWT: string | null = null;
let allowedPaths: string[] = []; // 用户允许访问的路由列表
let permissionMap: Record<string, string[]> = {}; // ✅ 权限映射表
if (!isPublicPath) {
try {
@@ -166,6 +168,12 @@ export async function loader({ request }: LoaderFunctionArgs) {
allowedPaths = extractAllPaths(routesResult.data);
// console.log("🔑 [Root Loader] 用户允许的路由:", allowedPaths);
// ✅ 保存权限映射表
if (routesResult.permissionMap) {
permissionMap = routesResult.permissionMap;
console.log("🔑 [Root Loader] 权限映射表:", permissionMap);
}
// 检查当前路径是否在允许列表中
const isAllowedPath = isPathAllowed(pathname, allowedPaths);
@@ -240,6 +248,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
pathname,
frontendJWT,
isPublicPath, // 传递给客户端,用于判断是否需要认证
permissionMap, // ✅ 传递权限映射表
ENV: {
// 客户端不再需要直接调用 Dify API
},
@@ -263,9 +272,11 @@ export function links() {
{ rel: "stylesheet", href: styles },
{ rel: "stylesheet", href: messageModalStyles },
{ rel: "stylesheet", href: toastStyles },
{ rel: "stylesheet", href: sourceHanSansStyles }, // 思源黑体字体
// 添加 Antd 样式
// { rel: "stylesheet", href: "https://cdn.jsdelivr.net/npm/antd@5/dist/reset.css" },
{ rel: "icon", type: "image/svg+xml", href: "/logo.svg" },
// Google Fonts(已弃用,改用本地字体)
// { rel: "preconnect", href: "https://fonts.googleapis.com" },
// { rel: "preconnect", href: "https://fonts.gstatic.com", crossOrigin: "anonymous" },
// { rel: "stylesheet", href: "https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;500;700&display=swap" }
+82 -56
View File
@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import React, { useState, useEffect } from 'react';
import { useNavigate, Form, useLoaderData } from '@remix-run/react';
import { type MetaFunction, type ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node";
import styles from "~/styles/pages/home.css?url";
@@ -52,6 +52,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
// 🔑 检查用户是否有系统设置权限
let hasSettingsAccess = false;
let hasCrossCheckingAccess = false;
let hasChatLLMAccess = false;
let settingsChildren: { path: string; title: string }[] = [];
if (userRole && frontendJWT) {
@@ -74,14 +75,19 @@ export async function loader({ request }: LoaderFunctionArgs) {
// 检查是否存在顶级路由 '/cross-checking'
hasCrossCheckingAccess = routesResult.data.some(route => route.path === '/cross-checking');
// 检查是否存在顶级路由 '/chat-with-llm'
hasChatLLMAccess = routesResult.data.some(route => route.path === '/chat-with-llm');
// console.log(`🔑 [Index Loader] 用户${hasSettingsAccess ? '有' : '没有'}系统设置权限`);
// console.log(`🔑 [Index Loader] 系统设置子路由数量: ${settingsChildren.length}`);
// console.log(`🔑 [Index Loader] 用户${hasCrossCheckingAccess ? '有' : '没有'}交叉评查权限`);
// console.log(`🔑 [Index Loader] 用户${hasChatLLMAccess ? '有' : '没有'}智慧法务大模型权限`);
}
}
// 返回用户信息、入口模块和权限给客户端
return Response.json({ userRole, userInfo, entryModules, hasSettingsAccess, hasCrossCheckingAccess, settingsChildren });
return Response.json({ userRole, userInfo, entryModules, hasSettingsAccess, hasCrossCheckingAccess, hasChatLLMAccess, settingsChildren });
}
export default function Index() {
@@ -310,6 +316,17 @@ export default function Index() {
</div>
</div>
<div className="user-info">
{/* 系统设置按钮 - 只在有权限时显示 */}
{loaderData.hasSettingsAccess && (
<button
onClick={handleEnterSettings}
className="settings-button"
aria-label="系统设置"
title="系统设置"
>
<i className="ri-settings-4-line"></i>
</button>
)}
<span className="datetime">{currentDateTime.date} {currentDateTime.time}</span>
<div className="user">
{(() => {
@@ -340,67 +357,76 @@ export default function Index() {
<div className="index-main-content-container">
<h1 className="welcome-text">- -</h1>
{/* 模块网格区域 */}
<div className="modules-container">
{/* 动态渲染入口模块 */}
{loaderData.entryModules && loaderData.entryModules.length > 0 ? (
<>
{loaderData.entryModules.map((module) => (
<div
key={module.id}
className="module-card"
onClick={() => handleModuleClick(module)}
onKeyDown={(e) => handleKeyDown(module, e)}
role="button"
tabIndex={0}
aria-label={module.name}
>
<img
src={getModuleIcon(module)}
alt={module.name}
className="w-12 h-12 mx-1"
/>
<span className="module-name">{module.name}</span>
</div>
))}
{loaderData.entryModules.map((module) => {
// 判断是否为智慧法务大模型,如果是且有交叉评查权限,则在其之前插入交叉评查卡片
const isLLMModule = module.name === '智慧法务大模型';
{/* 🔑 交叉评查入口 - 只有有权限的用户才能看到 */}
{loaderData.hasCrossCheckingAccess && (
<div
className="module-card"
onClick={handleEnterCrossChecking}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
handleEnterCrossChecking();
}
}}
role="button"
tabIndex={0}
aria-label="交叉评查"
>
<i className="ri-shuffle-line text-5xl text-primary"></i>
<span className="module-name"></span>
</div>
)}
// 🔑 如果是智慧法务大模型且用户没有访问权限,则不渲染该模块
if (isLLMModule && !loaderData.hasChatLLMAccess) {
return null;
}
{/* 🔑 系统设置入口 - 只有有权限的用户才能看到 */}
{loaderData.hasSettingsAccess && (
<div
className="module-card"
onClick={handleEnterSettings}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
handleEnterSettings();
}
}}
role="button"
tabIndex={0}
aria-label="系统设置"
>
<i className="ri-settings-4-line text-5xl text-primary"></i>
<span className="module-name"></span>
</div>
)}
return (
<React.Fragment key={module.id}>
{/* 在智慧法务大模型之前插入交叉评查入口 */}
{isLLMModule && loaderData.hasCrossCheckingAccess && (
<div
className="module-card"
onClick={handleEnterCrossChecking}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
handleEnterCrossChecking();
}
}}
role="button"
tabIndex={0}
aria-label="交叉评查"
>
<img
src="/images/icon_cross_checking.png"
alt="交叉评查"
className="w-12 h-12 mx-1"
onError={(e) => {
// 如果图片加载失败,使用 icon
(e.target as HTMLImageElement).style.display = 'none';
const parent = (e.target as HTMLImageElement).parentElement;
if (parent) {
const icon = document.createElement('i');
icon.className = 'ri-shuffle-line';
icon.style.fontSize = '48px';
icon.style.color = 'var(--color-primary)';
parent.insertBefore(icon, parent.firstChild);
}
}}
/>
<span className="module-name"></span>
</div>
)}
{/* 渲染原有模块 */}
<div
className="module-card"
onClick={() => handleModuleClick(module)}
onKeyDown={(e) => handleKeyDown(module, e)}
role="button"
tabIndex={0}
aria-label={module.name}
>
<img
src={getModuleIcon(module)}
alt={module.name}
className="w-12 h-12 mx-1"
/>
<span className="module-name">{module.name}</span>
</div>
</React.Fragment>
);
})}
</>
) : (
<div className="text-center text-gray-500 py-8">
+78 -36
View File
@@ -1,5 +1,5 @@
import { useState, useEffect } from "react";
import { useSearchParams, useNavigate, useLoaderData, useRouteLoaderData } from "@remix-run/react";
import { useSearchParams, useNavigate, useLoaderData } from "@remix-run/react";
import { ActionFunctionArgs, LoaderFunctionArgs, MetaFunction } from "@remix-run/node";
import { Table } from "~/components/ui/Table";
import { Card } from "~/components/ui/Card";
@@ -8,6 +8,7 @@ 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 { usePermission } from "~/hooks/usePermission";
import {
getDocumentTypes,
deleteDocumentType,
@@ -54,36 +55,47 @@ export async function loader({ request }: LoaderFunctionArgs) {
const url = new URL(request.url);
const name = url.searchParams.get('name') || undefined;
const ruleType = url.searchParams.get('ruleType') || undefined;
const groupId = url.searchParams.get('groupId') || undefined;
const groupId = url.searchParams.get('group_id') || undefined;
const entryModuleId = url.searchParams.get('entry_module_id') || undefined;
const page = parseInt(url.searchParams.get('page') || '1', 10);
const pageSize = parseInt(url.searchParams.get('pageSize') || '10', 10);
// 构建搜索参数
const searchParams: DocumentTypeSearchParams = {
name,
ruleType,
groupId,
group_id: groupId ? parseInt(groupId, 10) : undefined,
entry_module_id: entryModuleId ? parseInt(entryModuleId, 10) : undefined,
page,
pageSize
};
// 并行获取文档类型数据和父级评查点分组
const parentGroupsResponse = await getParentEvaluationPointGroups(frontendJWT);
const [parentGroupsResponse, typesResponse] = await Promise.all([
getParentEvaluationPointGroups(frontendJWT),
getDocumentTypes(searchParams, frontendJWT)
]);
// 如果获取父级评查点分组失败,返回空数组(不阻塞页面加载)
if(parentGroupsResponse.error){
console.error("获取父级评查点分组失败:", parentGroupsResponse.error);
}
const parentGroups = parentGroupsResponse.error ? [] : (parentGroupsResponse.data || []);
const typesResponse = await getDocumentTypes(searchParams, frontendJWT);
// 如果获取文档类型失败(如403无权限),返回空数组和错误信息
if(typesResponse.error){
console.error("获取文档类型失败:", typesResponse.error);
throw new Error(typesResponse.error);
return Response.json({
types: [],
total: 0,
pageSize,
currentPage: page,
parentGroups: [],
frontendJWT,
error: typesResponse.error
});
}
const typesResult = typesResponse.data?.types || [];
// console.log('文档类型数据:', typesResult);
// console.log('父级评查点分组:', parentGroups);
const typesResult = typesResponse.data?.types || [];
return Response.json({
types: typesResult,
@@ -95,12 +107,16 @@ export async function loader({ request }: LoaderFunctionArgs) {
});
} catch (error) {
console.error("加载文档类型列表失败:", error);
return Response.json(
{
error: error || "加载文档类型列表失败",
status: 500
}
);
const errorMessage = error instanceof Error ? error.message : "加载文档类型列表失败";
return Response.json({
types: [],
total: 0,
pageSize: 10,
currentPage: 1,
parentGroups: [],
frontendJWT: null,
error: errorMessage
});
}
}
@@ -142,20 +158,32 @@ export default function DocumentTypesList() {
// 获取加载器数据
const { types, total, error, parentGroups, frontendJWT } = useLoaderData<LoaderData>();
// 获取用户角色并判断权限
const rootData = useRouteLoaderData("root") as { userRole: string };
const userRole = rootData?.userRole || 'common';
const hasEditPermission = userRole.toLowerCase().includes('provin');
// 权限控制
const { canCreate, canUpdate, canDelete, canView } = usePermission();
const canCreateType = canCreate('document_type');
const canUpdateType = canUpdate('document_type');
const canDeleteType = canDelete('document_type');
const canViewType = canView('document_type');
// 获取搜索参数
const name = searchParams.get('name') || '';
const currentPage = parseInt(searchParams.get('page') || String(1), 10);
const pageSize = parseInt(searchParams.get('pageSize') || String(10), 10);
// 处理测试loader返回的信息
useEffect(() => {
console.log('返回的父级评查点分组数据',parentGroups)
}, [parentGroups])
// 处理loader加载数据的时候的错误
useEffect(() => {
if(error){
toastService.error(error);
// 如果是无权限错误,显示友好提示
if(error.includes('Permission denied') || error.includes('无权限') || error.includes('权限不足')){
toastService.error('无权限访问文档类型管理,请联系系统管理员');
} else {
toastService.error(error);
}
}
}, [error]);
@@ -202,6 +230,12 @@ export default function DocumentTypesList() {
// 处理删除文档类型
const handleDelete = async (id: number) => {
// 权限检查
if (!canDeleteType) {
toastService.warning('您没有删除权限');
return;
}
messageService.show({
title: "确认删除",
message: "确定要删除该文档类型吗?此操作不会影响关联的评查点分组,但可能会影响使用该类型的文档评查。",
@@ -331,14 +365,19 @@ export default function DocumentTypesList() {
key: "operation",
width: "150px",
render: (_: unknown, record: DocumentTypeUI) => (
<>
<button
className="operation-btn text-primary"
onClick={() => handleEdit(record.id)}
>
<i className="ri-edit-line"></i> {hasEditPermission ? '编辑' : '查看'}
</button>
{hasEditPermission && (
<div className="operations-cell">
{canViewType && (
<>
<button
className="operation-btn text-primary"
onClick={() => handleEdit(record.id)}
>
<i className={canUpdateType ? "ri-edit-line" : "ri-eye-line"}></i>
{canUpdateType ? '编辑' : '查看'}
</button>
</>
)}
{canDeleteType && (
<button
className="operation-btn text-error !hidden"
onClick={() => handleDelete(record.id)}
@@ -347,7 +386,10 @@ export default function DocumentTypesList() {
<i className="ri-delete-bin-line"></i>
</button>
)}
</>
{!canViewType && !canDeleteType && (
<span className="text-gray-400">-</span>
)}
</div>
)
}
];
@@ -364,7 +406,7 @@ export default function DocumentTypesList() {
<div className="page-header">
<h2 className="page-title"></h2>
<div>
{hasEditPermission && (
{canCreateType && (
<Button
type="primary"
icon="ri-add-line"
@@ -394,12 +436,12 @@ export default function DocumentTypesList() {
noActionDivider={true}
>
<FilterSelect
label="父级评查分组"
name="ruleType"
value={searchParams.get('ruleType') || ''}
label="评查分组"
name="group_id"
value={searchParams.get('group_id') || ''}
options={[
...(parentGroups || []).map(group => ({
value: group.id,
value: group.id.toString(),
label: group.name
}))
]}
+215 -182
View File
@@ -1,13 +1,21 @@
import React, { useState, useEffect, useRef } from "react";
import { Form, useActionData, useLoaderData, useNavigate, useSearchParams, useRouteLoaderData } from "@remix-run/react";
import { Form, useActionData, useLoaderData, useNavigate, useSearchParams } from "@remix-run/react";
import { redirect, type MetaFunction, type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/node";
import { Card } from "~/components/ui/Card";
import { Button } from "~/components/ui/Button";
import documentTypesNewStyles from "~/styles/pages/document-types_new.css?url";
import { getAllEvaluationPointGroups, type RuleGroup } from "~/api/evaluation_points/rule-groups";
import { getDocumentType, createDocumentType, updateDocumentType, getEntryModules } from "~/api/document-types/document-types";
import { getPromptTemplates, type PromptTemplateUI } from "~/api/prompts/prompts";
import { type RuleGroup } from "~/api/evaluation_points/rule-groups";
import {
getDocumentType,
createDocumentType,
updateDocumentType,
getEntryModules,
getRootEvaluationPointGroups_ForDocTypes,
getPromptTemplateOptions
} from "~/api/document-types/document-types";
import { getEvaluationPointGroupChildren } from "~/api/evaluation_points/rule-groups";
import { toastService } from "~/components/ui/Toast";
import { usePermission } from "~/hooks/usePermission";
export function links() {
return [{ rel: "stylesheet", href: documentTypesNewStyles }];
@@ -38,9 +46,7 @@ export const meta: MetaFunction = ({ location }) => {
// 定义模板类型
const TEMPLATE_TYPES = {
LLM_EXTRACTION: "LLM_Extraction",
VLM_EXTRACTION: "VLM_Extraction",
EVALUATION: "Evaluation",
SUMMARY: "Summary"
VLM_EXTRACTION: "VLM_Extraction"
};
// 定义动作返回的数据类型
@@ -52,8 +58,6 @@ interface ActionData {
general?: string;
llmExtractionTemplate?: string;
vlmExtractionTemplate?: string;
evaluationTemplate?: string;
summaryTemplate?: string;
};
values?: Record<string, string | string[]>;
}
@@ -70,16 +74,12 @@ export async function loader({ request }: LoaderFunctionArgs) {
const id = url.searchParams.get("id");
const isEdit = id ? true : false;
// 1. 获取评查点分组 - 使用 FastAPI v3 的 getAllEvaluationPointGroups 获取所有分组
const ruleGroupsResponse = await getAllEvaluationPointGroups(false, true, frontendJWT);
// 1. 获取一级评查点分组(后续通过点击展开按钮动态加载子分组
const ruleGroupsResponse = await getRootEvaluationPointGroups_ForDocTypes(frontendJWT);
if (ruleGroupsResponse.error) {
console.error("获取评查点分组失败:", ruleGroupsResponse.error);
// throw new Error(ruleGroupsResponse.error);
console.error("获取一级评查点分组失败:", ruleGroupsResponse.error);
}
// ruleGroupsResponse.data已经是树形结构数据,getAllEvaluationPointGroups内部已处理好parent-children关系
const groupsTree = ruleGroupsResponse.error ? [] : ruleGroupsResponse.data || [];
const rootGroups = ruleGroupsResponse.error ? [] : ruleGroupsResponse.data || [];
// 2. 获取入口模块列表
const entryModulesResponse = await getEntryModules(frontendJWT);
@@ -88,16 +88,14 @@ export async function loader({ request }: LoaderFunctionArgs) {
}
const entryModules = entryModulesResponse.error ? [] : (entryModulesResponse.data || []);
// 3. 获取各类型的提示词模板
const [llmExtractionTemplatesResponse, vlmExtractionTemplatesResponse, evaluationTemplatesResponse, summaryTemplatesResponse] =
// 3. 获取提示词模板(只获取 LLM 和 VLM
const [llmExtractionTemplatesResponse, vlmExtractionTemplatesResponse] =
await Promise.all([
getPromptTemplates({ type: TEMPLATE_TYPES.LLM_EXTRACTION }, frontendJWT),
getPromptTemplates({ type: TEMPLATE_TYPES.VLM_EXTRACTION }, frontendJWT),
getPromptTemplates({ type: TEMPLATE_TYPES.EVALUATION }, frontendJWT),
getPromptTemplates({ type: TEMPLATE_TYPES.SUMMARY }, frontendJWT)
getPromptTemplateOptions(TEMPLATE_TYPES.LLM_EXTRACTION, frontendJWT),
getPromptTemplateOptions(TEMPLATE_TYPES.VLM_EXTRACTION, frontendJWT)
]);
// 3. 如果是编辑模式,获取文档类型详情
// 4. 如果是编辑模式,获取文档类型详情
let documentType = undefined;
if (id) {
const typeResponse = await getDocumentType(id, frontendJWT);
@@ -109,12 +107,10 @@ export async function loader({ request }: LoaderFunctionArgs) {
return Response.json({
isEdit,
documentType,
ruleGroups: groupsTree,
ruleGroups: rootGroups,
entryModules,
llmExtractionTemplates: llmExtractionTemplatesResponse.data?.templates || [],
vlmExtractionTemplates: vlmExtractionTemplatesResponse.data?.templates || [],
evaluationTemplates: evaluationTemplatesResponse.data?.templates || [],
summaryTemplates: summaryTemplatesResponse.data?.templates || []
llmExtractionTemplates: llmExtractionTemplatesResponse.data || [],
vlmExtractionTemplates: vlmExtractionTemplatesResponse.data || []
});
} catch (error) {
console.error("加载数据失败:", error);
@@ -125,8 +121,6 @@ export async function loader({ request }: LoaderFunctionArgs) {
entryModules: [],
llmExtractionTemplates: [],
vlmExtractionTemplates: [],
evaluationTemplates: [],
summaryTemplates: [],
error: error || "加载数据失败"
});
}
@@ -144,8 +138,6 @@ export async function action({ request }: ActionFunctionArgs) {
const entryModuleId = formData.get("entry_module_id") as string;
const llmExtractionTemplateId = formData.get("llm_extraction_template") as string;
const vlmExtractionTemplateId = formData.get("vlm_extraction_template") as string;
const evaluationTemplateId = formData.get("evaluation_template") as string;
const summaryTemplateId = formData.get("summary_template") as string;
// 获取选中的评查点分组ID列表
const selectedGroups = formData.getAll("checkpoint_group_ids") as string[];
@@ -170,31 +162,20 @@ export async function action({ request }: ActionFunctionArgs) {
errors.vlmExtractionTemplate = "请选择vlm抽取提示词模板";
}
if (!evaluationTemplateId) {
errors.evaluationTemplate = "请选择评查提示词模板";
}
if (!summaryTemplateId) {
errors.summaryTemplate = "请选择总结提示词模板";
}
// 如果有错误,返回错误信息
if (Object.keys(errors).length > 0) {
return Response.json({ errors, result: false });
}
try {
// 构建文档类型数据
// 构建文档类型数据 - group_ids 转换为 number[]
const documentTypeData = {
name,
description,
group_ids: selectedGroups,
group_ids: selectedGroups.map(id => parseInt(id, 10)),
entry_module_id: entryModuleId ? parseInt(entryModuleId) : null,
// 确保映射关系与prompt_config字段对应正确
llm_extraction_template_id: llmExtractionTemplateId ? parseInt(llmExtractionTemplateId) : null,
vlm_extraction_template_id: vlmExtractionTemplateId ? parseInt(vlmExtractionTemplateId) : null,
evaluation_template_id: evaluationTemplateId ? parseInt(evaluationTemplateId) : null,
summary_template_id: summaryTemplateId ? parseInt(summaryTemplateId) : null
vlm_extraction_template_id: vlmExtractionTemplateId ? parseInt(vlmExtractionTemplateId) : null
};
// 调用API创建或更新文档类型
@@ -238,18 +219,21 @@ export default function DocumentTypeNew() {
ruleGroups,
entryModules,
llmExtractionTemplates,
vlmExtractionTemplates,
evaluationTemplates,
summaryTemplates
vlmExtractionTemplates
} = useLoaderData<typeof loader>();
const actionData = useActionData<ActionData>();
// 获取用户角色并判断权限
const rootData = useRouteLoaderData("root") as { userRole: string };
const userRole = rootData?.userRole || 'common';
const hasEditPermission = userRole.toLowerCase().includes('provin');
const isReadOnly = !hasEditPermission;
// 权限控制
const { canCreate, canUpdate } = usePermission();
const canCreateType = canCreate('document_type');
const canUpdateType = canUpdate('document_type');
const urlMode = searchParams.get('mode');
const isViewMode = urlMode === 'view';
const hasEditPermission = isEditMode ? canUpdateType : canCreateType;
const isReadOnly = isViewMode || !hasEditPermission;
// 状态管理
const [formData, setFormData] = useState({
@@ -257,11 +241,9 @@ export default function DocumentTypeNew() {
name: documentType?.name || "",
description: documentType?.description || "",
entryModuleId: documentType?.entry_module?.id?.toString() || "",
llmExtractionTemplateId: documentType?.llm_extraction_template_id || "",
vlmExtractionTemplateId: documentType?.vlm_extraction_template_id || "",
evaluationTemplateId: documentType?.evaluation_template_id || "",
summaryTemplateId: documentType?.summary_template_id || "",
selectedGroups: documentType?.groups?.map((g: { id: string }) => g.id) || []
llmExtractionTemplateId: documentType?.llm_extraction_template_id?.toString() || "",
vlmExtractionTemplateId: documentType?.vlm_extraction_template_id?.toString() || "",
selectedGroups: documentType?.groups?.map((g: { id: string | number }) => g.id.toString()) || []
});
// 添加本地验证错误状态
@@ -275,11 +257,14 @@ export default function DocumentTypeNew() {
name: false,
llmExtractionTemplate: false,
vlmExtractionTemplate: false,
evaluationTemplate: false,
summaryTemplate: false,
groups: false
});
// 客户端调试信息ruleGroups
useEffect(() => {
console.log('返回的评查点分组数据',ruleGroups)
}, [ruleGroups])
// 从actionData初始化本地错误
useEffect(() => {
if (!actionData?.result) {
@@ -293,6 +278,12 @@ export default function DocumentTypeNew() {
// 分组展开状态
const [expandedGroups, setExpandedGroups] = useState<Record<string, boolean>>({});
// 动态加载的子分组数据(groupId -> children[]
const [groupChildrenMap, setGroupChildrenMap] = useState<Record<string, RuleGroup[]>>({});
// 子分组加载状态
const [loadingChildren, setLoadingChildren] = useState<Record<string, boolean>>({});
// 当文档类型数据加载完成时更新表单
useEffect(() => {
if (documentType) {
@@ -301,30 +292,62 @@ export default function DocumentTypeNew() {
name: documentType.name,
description: documentType.description,
entryModuleId: documentType.entry_module?.id?.toString() || "",
llmExtractionTemplateId: documentType.llm_extraction_template_id || "",
vlmExtractionTemplateId: documentType.vlm_extraction_template_id || "",
evaluationTemplateId: documentType.evaluation_template_id || "",
summaryTemplateId: documentType.summary_template_id || "",
selectedGroups: documentType.groups.map((g: { id: string }) => g.id)
llmExtractionTemplateId: documentType.llm_extraction_template_id?.toString() || "",
vlmExtractionTemplateId: documentType.vlm_extraction_template_id?.toString() || "",
selectedGroups: documentType.groups.map((g: { id: string | number }) => g.id.toString())
});
// 初始化展开状态 - 对于有选中子分组的父分组,默认展开
const newExpandedGroups: Record<string, boolean> = {};
// 初始化展开状态 - 如果选中的是一级分组,需要加载子分组并展开
const loadInitialChildren = async () => {
const newExpandedGroups: Record<string, boolean> = {};
const newChildrenMap: Record<string, RuleGroup[]> = {};
ruleGroups.forEach((parentGroup: RuleGroup) => {
// 如果父分组被选中或者有子分组被选中,则展开
const isParentSelected = documentType.groups.some((g: { id: string }) => g.id === parentGroup.id);
const hasSelectedChild = parentGroup.children &&
parentGroup.children.some(child =>
documentType.groups.some((g: { id: string }) => g.id === child.id)
// 获取 frontendJWT
let frontendJWT: string | undefined = undefined;
if (typeof window !== 'undefined') {
const userInfoStr = localStorage.getItem('user_info');
if (userInfoStr) {
try {
const userInfo = JSON.parse(userInfoStr);
frontendJWT = userInfo.frontendJWT;
} catch (e) {
console.error('解析用户信息失败:', e);
}
}
}
// 遍历所有一级分组,检查是否被选中
for (const parentGroup of ruleGroups) {
const isParentSelected = documentType.groups.some((g: { id: string | number }) =>
g.id.toString() === parentGroup.id.toString()
);
if (isParentSelected || hasSelectedChild) {
newExpandedGroups[parentGroup.id] = true;
}
});
if (isParentSelected) {
// 标记为展开
newExpandedGroups[parentGroup.id] = true;
setExpandedGroups(newExpandedGroups);
// 加载子分组
try {
const response = await getEvaluationPointGroupChildren(
parentGroup.id,
{ pageSize: 1000 },
frontendJWT
);
if (!response.error && response.data) {
newChildrenMap[parentGroup.id] = response.data;
}
} catch (error) {
console.error(`加载分组 ${parentGroup.id} 的子分组失败:`, error);
}
}
}
setExpandedGroups(newExpandedGroups);
setGroupChildrenMap(newChildrenMap);
};
loadInitialChildren();
}
}, [documentType, ruleGroups]);
@@ -337,10 +360,6 @@ export default function DocumentTypeNew() {
return !value || (typeof value === 'string' && value.trim() === "") ? "请选择llm抽取提示词模板" : "";
case 'vlmExtractionTemplate':
return !value || (typeof value === 'string' && value.trim() === "") ? "请选择vlm抽取提示词模板" : "";
case 'evaluationTemplate':
return !value || (typeof value === 'string' && value.trim() === "") ? "请选择评查提示词模板" : "";
case 'summaryTemplate':
return !value || (typeof value === 'string' && value.trim() === "") ? "请选择总结提示词模板" : "";
case 'groups':
return Array.isArray(value) && value.length === 0 ? "请至少选择一个关联的评查点分组" : "";
default:
@@ -353,12 +372,15 @@ export default function DocumentTypeNew() {
groupId: string,
isChecked: boolean
) => {
// 选模式:清空之前所有选择,只保留当前选中的
// 选模式:添加或移除选中的分组
let newSelectedGroups: string[] = [];
if (isChecked) {
// 添加当前选中的分组
newSelectedGroups = [groupId];
// 添加当前选中的分组(避免重复)
newSelectedGroups = [...formData.selectedGroups, groupId];
} else {
// 移除取消选中的分组
newSelectedGroups = formData.selectedGroups.filter(id => id !== groupId);
}
setFormData(prev => ({ ...prev, selectedGroups: newSelectedGroups }));
@@ -371,14 +393,69 @@ export default function DocumentTypeNew() {
setFormErrors(prev => ({...prev, groups: error}));
};
// 修复展开/折叠功能
const handleGroupExpand = (groupId: string, event: React.MouseEvent) => {
// 修复展开/折叠功能 - 动态加载子分组
const handleGroupExpand = async (groupId: string, event: React.MouseEvent) => {
// 阻止事件冒泡,避免触发checkbox选中
event.stopPropagation();
const isCurrentlyExpanded = expandedGroups[groupId];
// 如果当前是折叠状态,准备展开
if (!isCurrentlyExpanded) {
// 检查是否已经加载过子分组
if (!groupChildrenMap[groupId]) {
// 还未加载,开始加载
setLoadingChildren(prev => ({ ...prev, [groupId]: true }));
try {
// 获取用户 token
let frontendJWT: string | undefined = undefined;
if (typeof window !== 'undefined') {
const userInfoStr = localStorage.getItem('user_info');
if (userInfoStr) {
try {
const userInfo = JSON.parse(userInfoStr);
frontendJWT = userInfo.frontendJWT;
} catch (e) {
console.error('解析用户信息失败:', e);
}
}
}
// 调用 API 获取子分组
const response = await getEvaluationPointGroupChildren(
groupId,
{ pageSize: 1000 }, // 获取所有子分组
frontendJWT
);
if (response.error) {
console.error('获取子分组失败:', response.error);
toastService.error('获取子分组失败');
setLoadingChildren(prev => ({ ...prev, [groupId]: false }));
return;
}
// 保存子分组数据
setGroupChildrenMap(prev => ({
...prev,
[groupId]: response.data || []
}));
setLoadingChildren(prev => ({ ...prev, [groupId]: false }));
} catch (error) {
console.error('获取子分组异常:', error);
toastService.error('获取子分组失败');
setLoadingChildren(prev => ({ ...prev, [groupId]: false }));
return;
}
}
}
// 切换展开/折叠状态
setExpandedGroups(prev => ({
...prev,
[groupId]: !prev[groupId]
[groupId]: !isCurrentlyExpanded
}));
};
@@ -391,22 +468,11 @@ export default function DocumentTypeNew() {
if (name === 'llm_extraction_template') {
fieldName = 'llmExtractionTemplateId';
// 标记字段为已触摸
setTouchedFields(prev => ({...prev, llmExtractionTemplate: true}));
} else if (name === 'vlm_extraction_template') {
fieldName = 'vlmExtractionTemplateId';
// 标记字段为已触摸
setTouchedFields(prev => ({...prev, vlmExtractionTemplate: true}));
} else if (name === 'evaluation_template') {
fieldName = 'evaluationTemplateId';
// 标记字段为已触摸
setTouchedFields(prev => ({...prev, evaluationTemplate: true}));
} else if (name === 'summary_template') {
fieldName = 'summaryTemplateId';
// 标记字段为已触摸
setTouchedFields(prev => ({...prev, summaryTemplate: true}));
} else if (name === 'name') {
// 标记字段为已触摸
setTouchedFields(prev => ({...prev, name: true}));
}
@@ -422,24 +488,28 @@ export default function DocumentTypeNew() {
} else if (name === 'vlm_extraction_template') {
const error = validateField('vlmExtractionTemplate', value);
setFormErrors(prev => ({...prev, vlmExtractionTemplate: error}));
} else if (name === 'evaluation_template') {
const error = validateField('evaluationTemplate', value);
setFormErrors(prev => ({...prev, evaluationTemplate: error}));
} else if (name === 'summary_template') {
const error = validateField('summaryTemplate', value);
setFormErrors(prev => ({...prev, summaryTemplate: error}));
}
};
// 处理表单提交前验证
const handleBeforeSubmit = (e: React.FormEvent) => {
// 权限检查
if (isEditMode && !canUpdateType) {
toastService.warning('您没有修改权限,无法保存更改');
e.preventDefault();
return;
}
if (!isEditMode && !canCreateType) {
toastService.warning('您没有创建权限,无法新增文档类型');
e.preventDefault();
return;
}
// 标记所有字段为已触摸
setTouchedFields({
name: true,
llmExtractionTemplate: true,
vlmExtractionTemplate: true,
evaluationTemplate: true,
summaryTemplate: true,
groups: true
});
@@ -448,16 +518,13 @@ export default function DocumentTypeNew() {
name: validateField('name', formData.name),
llmExtractionTemplate: validateField('llmExtractionTemplate', formData.llmExtractionTemplateId),
vlmExtractionTemplate: validateField('vlmExtractionTemplate', formData.vlmExtractionTemplateId),
evaluationTemplate: validateField('evaluationTemplate', formData.evaluationTemplateId),
summaryTemplate: validateField('summaryTemplate', formData.summaryTemplateId),
groups: validateField('groups', formData.selectedGroups)
};
setFormErrors(errors);
// 如果有错误,阻止提交
if (errors.name || errors.llmExtractionTemplate || errors.vlmExtractionTemplate ||
errors.evaluationTemplate || errors.summaryTemplate || errors.groups) {
if (errors.name || errors.llmExtractionTemplate || errors.vlmExtractionTemplate || errors.groups) {
e.preventDefault();
}
};
@@ -576,8 +643,8 @@ export default function DocumentTypeNew() {
onChange={handleInputChange}
disabled={isReadOnly}
>
<option value="">llm抽取提示词模板</option>
{llmExtractionTemplates.map((template: PromptTemplateUI) => (
{/* <option value="">请选择llm抽取提示词模板</option> */}
{llmExtractionTemplates.map((template: { id: number; template_name: string }) => (
<option key={template.id} value={template.id}>
{template.template_name}
</option>
@@ -600,8 +667,8 @@ export default function DocumentTypeNew() {
onChange={handleInputChange}
disabled={isReadOnly}
>
<option value="">vlm抽取提示词模板</option>
{vlmExtractionTemplates.map((template: PromptTemplateUI) => (
{/* <option value="">请选择vlm抽取提示词模板</option> */}
{vlmExtractionTemplates.map((template: { id: number; template_name: string }) => (
<option key={template.id} value={template.id}>
{template.template_name}
</option>
@@ -612,54 +679,6 @@ export default function DocumentTypeNew() {
)}
<div className="form-tip">vlm提示词模板</div>
</div>
{/* 评查提示词模板 */}
<div className="w-full">
<label htmlFor="evaluation-template" className="form-label"> <span className="text-red-500">*</span></label>
<select
id="evaluation-template"
name="evaluation_template"
className={`form-select ${touchedFields.evaluationTemplate && formErrors?.evaluationTemplate ? 'input-error' : ''}`}
value={formData.evaluationTemplateId}
onChange={handleInputChange}
disabled={isReadOnly}
>
<option value=""></option>
{evaluationTemplates.map((template: PromptTemplateUI) => (
<option key={template.id} value={template.id}>
{template.template_name}
</option>
))}
</select>
{touchedFields.evaluationTemplate && formErrors?.evaluationTemplate && (
<div className="error-message">{formErrors.evaluationTemplate}</div>
)}
<div className="form-tip"></div>
</div>
{/* 总结提示词模板 */}
<div className="w-full">
<label htmlFor="summary-template" className="form-label"> <span className="text-red-500">*</span></label>
<select
id="summary-template"
name="summary_template"
className={`form-select ${touchedFields.summaryTemplate && formErrors?.summaryTemplate ? 'input-error' : ''}`}
value={formData.summaryTemplateId}
onChange={handleInputChange}
disabled={isReadOnly}
>
<option value=""></option>
{summaryTemplates.map((template: PromptTemplateUI) => (
<option key={template.id} value={template.id}>
{template.template_name}
</option>
))}
</select>
{touchedFields.summaryTemplate && formErrors?.summaryTemplate && (
<div className="error-message">{formErrors.summaryTemplate}</div>
)}
<div className="form-tip"></div>
</div>
</div>
{/* 关联评查点分组 */}
@@ -689,13 +708,13 @@ export default function DocumentTypeNew() {
<i className={`ri-arrow-${expandedGroups[group.id] ? 'down' : 'right'}-s-line text-primary`}></i>
</button>
<input
type="radio"
type="checkbox"
id={`group-${group.id}`}
name="checkpoint_group_ids"
value={group.id}
checked={formData.selectedGroups.includes(group.id)}
onChange={(e) => handleGroupCheckChange(group.id, e.target.checked)}
className="radio-input"
className="checkbox-input"
disabled={isReadOnly}
/>
<label
@@ -707,21 +726,35 @@ export default function DocumentTypeNew() {
</label>
</div>
{/* 子分组 - 仅展示,不可选 */}
{group.children && group.children.length > 0 && expandedGroups[group.id] && (
group.children.map((child: RuleGroup) => (
<div
key={child.id}
className="checkbox-item child-checkbox-item"
style={{ paddingLeft: '2.5rem', opacity: 0.9 }}
>
<i className="ri-subtract-line" style={{ marginRight: '8px', color: '#9ca3af' }}></i>
<span className="checkbox-label">
{child.name}
<span className="group-badge child-badge"></span>
</span>
</div>
))
{/* 子分组 - 动态加载并展示 */}
{expandedGroups[group.id] && (
<>
{loadingChildren[group.id] ? (
<div
className="checkbox-item child-checkbox-item"
style={{ paddingLeft: '2.5rem', opacity: 0.9 }}
>
<i className="ri-loader-4-line spin" style={{ marginRight: '8px', color: '#9ca3af' }}></i>
<span className="checkbox-label" style={{ color: '#9ca3af' }}>
...
</span>
</div>
) : (
groupChildrenMap[group.id]?.map((child: RuleGroup) => (
<div
key={child.id}
className="checkbox-item child-checkbox-item"
style={{ paddingLeft: '2.5rem', opacity: 0.9 }}
>
<i className="ri-subtract-line" style={{ marginRight: '8px', color: '#9ca3af' }}></i>
<span className="checkbox-label">
{child.name}
<span className="group-badge child-badge"></span>
</span>
</div>
))
)}
</>
)}
</React.Fragment>
))}
+12 -8
View File
@@ -1008,14 +1008,18 @@ export default function DocumentsIndex() {
})()}
</td>
<td className="px-4 py-3" style={{ width: '8%' }}>
<div className={`inline-flex items-center px-2 py-1 rounded-full text-xs ${
historyDoc.auditStatus === 1 ? 'bg-green-100 text-green-800' :
historyDoc.auditStatus === -1 ? 'bg-red-100 text-red-800' :
'bg-blue-100 text-blue-800'
}`}>
<i className={`${auditStatusMapping[historyDoc.auditStatus]?.icon || 'ri-time-line'} mr-1`}></i>
<span>{auditStatusMapping[historyDoc.auditStatus]?.label || '待审核'}</span>
</div>
{(() => {
// 处理auditStatus为null或undefined的情况,默认为0(待审核)
const auditStatus = historyDoc.auditStatus != null ? historyDoc.auditStatus : 0;
const statusKey = auditStatus.toString();
const statusInfo = auditStatusMapping[statusKey] || auditStatusMapping["0"];
return (
<div className={`inline-flex items-center px-2 py-1 rounded-full text-xs bg-${statusInfo.color}-100 text-${statusInfo.color}-800`}>
<i className={`${statusInfo.icon} mr-1`}></i>
<span>{statusInfo.label}</span>
</div>
);
})()}
</td>
<td className="px-4 py-3" style={{ width: '15%' }}>
<ResultStats
+26 -21
View File
@@ -1,5 +1,5 @@
import { useState, useEffect } from "react";
import { useSearchParams, useNavigate, useLoaderData, useRouteLoaderData, useRevalidator } from "@remix-run/react";
import { useSearchParams, useNavigate, useLoaderData, useRevalidator } from "@remix-run/react";
import { ClientLoaderFunctionArgs, MetaFunction } from "@remix-run/react";
import { Table } from "~/components/ui/Table";
import { Card } from "~/components/ui/Card";
@@ -8,6 +8,7 @@ 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 { usePermission } from "~/hooks/usePermission";
import {
getEntryModules,
deleteEntryModule,
@@ -113,16 +114,12 @@ export default function EntryModulesList() {
const loaderData = useLoaderData<LoaderData>();
const { modules, total, error } = loaderData;
// 获取用户角色并判断权限
const rootData = useRouteLoaderData("root") as { userRole: string };
const userRole = rootData?.userRole || 'common';
const hasEditPermission = userRole.toLowerCase().includes('admin') || userRole.toLowerCase().includes('developer');
// 调试信息
useEffect(() => {
console.log('📋 [EntryModules] 用户角色:', userRole);
console.log('📋 [EntryModules] 是否有编辑权限:', hasEditPermission);
}, [userRole, hasEditPermission]);
// ✅ 使用权限 Hook
const { canCreate, canUpdate, canDelete, canView } = usePermission();
const canCreateModule = canCreate('entry_module');
const canUpdateModule = canUpdate('entry_module');
const canDeleteModule = canDelete('entry_module');
const canViewModule = canView('entry_module');
// 获取搜索参数
const name = searchParams.get('name') || '';
@@ -179,6 +176,12 @@ export default function EntryModulesList() {
// 处理删除入口模块
const handleDelete = async (id: number) => {
// ✅ 检查删除权限
if (!canDeleteModule) {
toastService.warning('您没有删除权限');
return;
}
messageService.show({
title: "确认删除",
message: "确定要删除该入口模块吗?此操作不可撤销。",
@@ -317,15 +320,16 @@ export default function EntryModulesList() {
width: '180px',
render: (_: any, record: EntryModule) => (
<div className="operations-cell">
<button
onClick={() => handleEdit(record.id!)}
className="operation-btn"
disabled={!hasEditPermission}
title={hasEditPermission ? "编辑入口模块" : "无权限"}
>
<i className="ri-edit-line"></i> {hasEditPermission ? '编辑' : '查看'}
</button>
{hasEditPermission && (
{ canViewModule &&
<button
onClick={() => handleEdit(record.id!)}
className="operation-btn"
title="查看/编辑入口模块"
>
<i className="ri-edit-line"></i> {canUpdateModule ? '编辑' : '查看'}
</button>
}
{canDeleteModule && (
<button
type="button"
className="operation-btn !text-[--color-error]"
@@ -350,7 +354,8 @@ export default function EntryModulesList() {
<h1 className="text-2xl font-bold text-gray-900"></h1>
<p className="text-sm text-gray-600 mt-1">Logo图片和适用地区设置</p>
</div>
{hasEditPermission && (
{/* ✅ 仅在有创建权限时显示新建按钮 */}
{canCreateModule && (
<Button
type="primary"
icon="ri-add-line"
+51 -11
View File
@@ -5,6 +5,7 @@ import { Card } from "~/components/ui/Card";
import { Button } from "~/components/ui/Button";
import { toastService } from "~/components/ui/Toast";
import { Modal } from "~/components/ui/Modal";
import { usePermission } from "~/hooks/usePermission";
import {
getEntryModuleById,
createEntryModule,
@@ -88,6 +89,15 @@ export default function EntryModuleNew() {
const id = searchParams.get('id');
const isEditMode = !!id;
// ✅ 使用权限 Hook
const { canCreate, canUpdate } = usePermission();
const canCreateModule = canCreate('entry_module');
const canUpdateModule = canUpdate('entry_module');
// ✅ 根据当前操作类型判断权限
const hasEditPermission = isEditMode ? canUpdateModule : canCreateModule;
const isReadOnly = !hasEditPermission;
// 表单状态
const [name, setName] = useState(module?.name || '');
const [description, setDescription] = useState(module?.description || '');
@@ -104,6 +114,17 @@ export default function EntryModuleNew() {
const fileInputRef = useRef<HTMLInputElement>(null);
// ✅ 页面加载时检查权限并提示(仅在只读模式下提示)
useEffect(() => {
if (isReadOnly) {
if (isEditMode) {
toastService.info('当前为查看模式,您没有编辑权限');
} else {
toastService.warning('您没有创建入口模块的权限');
}
}
}, [isReadOnly, isEditMode]);
// 处理loader加载数据的时候的错误
useEffect(() => {
if (error) {
@@ -208,6 +229,17 @@ export default function EntryModuleNew() {
// 处理表单提交
const handleSubmit = async () => {
// ✅ Runtime permission check
if (isEditMode && !canUpdateModule) {
toastService.warning('您没有修改权限,无法保存更改');
return;
}
if (!isEditMode && !canCreateModule) {
toastService.warning('您没有创建权限,无法新增入口模块');
return;
}
if (!validateForm()) return;
setIsSubmitting(true);
@@ -270,10 +302,10 @@ export default function EntryModuleNew() {
<div className="page-header">
<div>
<h1 className="text-2xl font-bold text-gray-900">
{isEditMode ? '编辑入口模块' : '新建入口模块'}
{isEditMode ? (isReadOnly ? '查看入口模块' : '编辑入口模块') : '新建入口模块'}
</h1>
<p className="text-sm text-gray-600 mt-1">
{isEditMode ? '修改入口模块信息' : '创建新的入口模块'}
{isEditMode ? (isReadOnly ? '查看入口模块信息' : '修改入口模块信息') : '创建新的入口模块'}
</p>
</div>
</div>
@@ -293,6 +325,7 @@ export default function EntryModuleNew() {
placeholder="请输入模块名称,如:合同管理"
maxLength={255}
className="w-full px-3 py-2 border border-gray-300 rounded-md"
disabled={isReadOnly}
/>
</div>
@@ -305,6 +338,7 @@ export default function EntryModuleNew() {
placeholder="请输入模块描述"
className="w-full px-3 py-2 border border-gray-300 rounded-md"
rows={4}
disabled={isReadOnly}
/>
</div>
@@ -317,6 +351,7 @@ export default function EntryModuleNew() {
type="default"
icon="ri-upload-line"
onClick={() => fileInputRef.current?.click()}
disabled={isReadOnly}
>
{logoPreview ? '更换图片' : '上传图片'}
</Button>
@@ -330,6 +365,7 @@ export default function EntryModuleNew() {
accept="image/*"
onChange={handleLogoChange}
className="hidden"
disabled={isReadOnly}
/>
{logoPreview && (
<div className="mt-3">
@@ -355,13 +391,14 @@ export default function EntryModuleNew() {
{AREA_OPTIONS.map(option => (
<label
key={option.value}
className="flex items-center space-x-2 cursor-pointer"
className={`flex items-center space-x-2 ${isReadOnly ? 'cursor-not-allowed' : 'cursor-pointer'}`}
>
<input
type="checkbox"
checked={selectedAreas.includes(option.value)}
onChange={() => handleAreaToggle(option.value)}
className="w-4 h-4 border-gray-300 rounded cursor-pointer"
disabled={isReadOnly}
/>
<span className="text-sm text-gray-700">{option.label}</span>
</label>
@@ -379,14 +416,17 @@ export default function EntryModuleNew() {
>
</Button>
<Button
type="primary"
onClick={handleSubmit}
loading={isSubmitting}
disabled={isSubmitting}
>
{isSubmitting ? '提交中...' : (isEditMode ? '保存' : '创建')}
</Button>
{/* ✅ 仅在有对应权限时显示保存/创建按钮 */}
{hasEditPermission && (
<Button
type="primary"
onClick={handleSubmit}
loading={isSubmitting}
disabled={isSubmitting}
>
{isSubmitting ? '提交中...' : (isEditMode ? '保存' : '创建')}
</Button>
)}
</div>
</Card>
+66 -30
View File
@@ -9,6 +9,7 @@ import { Table } from "~/components/ui/Table";
import { Pagination } from "~/components/ui/Pagination";
import { getPromptTemplates, deletePromptTemplate, type PromptTemplateUI } from "~/api/prompts/prompts";
import { toastService, messageService } from "~/components/ui";
import { usePermission, PermissionGuard } from "~/hooks/usePermission";
// 样式链接
export function links() {
@@ -41,7 +42,7 @@ interface ActionData {
// 数据加载器
export async function loader({ request }: LoaderFunctionArgs) {
try {
// 获取用户会话信息
// 获取用户会话信息(服务端需要获取 JWT token
const { getUserSession } = await import("~/api/login/auth.server");
const { frontendJWT } = await getUserSession(request);
@@ -102,14 +103,14 @@ export async function loader({ request }: LoaderFunctionArgs) {
// Action函数 - 处理删除请求
export async function action({ request }: ActionFunctionArgs) {
// 获取用户会话信息(服务端需要获取 JWT token)
const { getUserSession } = await import("~/api/login/auth.server");
const { frontendJWT } = await getUserSession(request);
const formData = await request.formData();
const id = formData.get("id") as string;
const intent = formData.get("intent") as string;
// 获取用户会话信息
const { getUserSession } = await import("~/api/login/auth.server");
const { frontendJWT } = await getUserSession(request);
if (intent === "delete" && id) {
try {
const result = await deletePromptTemplate(id, frontendJWT);
@@ -138,16 +139,35 @@ export default function PromptsIndex() {
const [isLoading, setIsLoading] = useState(false);
const fetcher = useFetcher<ActionData>();
// 获取用户角色并判断权限
const rootData = useRouteLoaderData("root") as { userRole: string };
const userRole = rootData?.userRole || 'common';
const hasEditPermission = userRole.toLowerCase().includes('provin');
// 🔐 使用新的权限检查Hook
const {
canCreate,
canUpdate,
canDelete,
canView,
hasPermission,
permissions,
userRole
} = usePermission();
// 检查各项权限
const canCreateTemplate = canCreate('prompt_template');
const canEditTemplate = canUpdate('prompt_template');
const canDeleteTemplate = canDelete('prompt_template');
const canViewTemplate = canView('prompt_template');
// 调试信息
useEffect(() => {
console.log('📋 [Prompts] 用户角色:', userRole);
console.log('📋 [Prompts] 是否有编辑权限:', hasEditPermission);
}, [userRole, hasEditPermission]);
// useEffect(() => {
// console.log('📋 [Prompts] 模板数据:', templates);
// console.log('📋 [Prompts] 用户角色:', userRole);
// console.log('📋 [Prompts] 权限列表:', permissions);
// console.log('📋 [Prompts] 权限检查结果:', {
// canCreate: canCreateTemplate,
// canEdit: canEditTemplate,
// canDelete: canDeleteTemplate,
// canView: canViewTemplate
// });
// }, [userRole, permissions, templates, canCreateTemplate, canEditTemplate, canDeleteTemplate, canViewTemplate]);
// 处理搜索名称
const handleNameSearch = (value: string) => {
@@ -234,6 +254,13 @@ export default function PromptsIndex() {
});
};
// 监听 loader 错误
useEffect(() => {
if (error) {
toastService.error(error);
}
}, [error]);
// 监听 fetcher 状态变化
useEffect(() => {
if (fetcher.state === 'idle' && fetcher.data) {
@@ -366,6 +393,7 @@ export default function PromptsIndex() {
render: (_: unknown, record: PromptTemplateUI) => (
<div>
{record.status === 'system' ? (
// 系统预设模板:只能查看,有编辑权限的可以复制
<>
<button
className="operation-btn text-primary"
@@ -373,7 +401,8 @@ export default function PromptsIndex() {
>
<i className="ri-eye-line"></i>
</button>
{hasEditPermission && (
{/* 🔐 复制按钮需要创建权限 */}
{canCreateTemplate && (
<button
className="operation-btn text-primary"
onClick={() => handleCloneTemplate(record.id)}
@@ -383,14 +412,27 @@ export default function PromptsIndex() {
)}
</>
) : (
// 自定义模板:根据权限显示编辑/查看/删除
<>
<button
className="operation-btn text-primary"
onClick={() => hasEditPermission ? handleEditTemplate(record.id) : handleViewTemplate(record.id)}
>
<i className={hasEditPermission ? "ri-edit-line" : "ri-eye-line"}></i> {hasEditPermission ? '编辑' : '查看'}
</button>
{hasEditPermission && (
{/* 🔐 有编辑权限显示编辑按钮,否则显示查看按钮 */}
{canEditTemplate ? (
<button
className="operation-btn text-primary"
onClick={() => handleEditTemplate(record.id)}
>
<i className="ri-edit-line"></i>
</button>
) : canViewTemplate ? (
<button
className="operation-btn text-primary"
onClick={() => handleViewTemplate(record.id)}
>
<i className="ri-eye-line"></i>
</button>
) : null}
{/* 🔐 删除按钮需要删除权限 */}
{canDeleteTemplate && (
<button
className="operation-btn text-error"
onClick={() => handleDeleteTemplate(record.id)}
@@ -412,7 +454,8 @@ export default function PromptsIndex() {
<div className="page-header">
<h2 className="page-title"></h2>
<div>
{hasEditPermission && (
{/* 🔐 使用权限控制显示新增按钮 */}
<PermissionGuard permission="prompt_template:create:write">
<Button
type="primary"
icon="ri-add-line"
@@ -420,7 +463,7 @@ export default function PromptsIndex() {
>
</Button>
)}
</PermissionGuard>
</div>
</div>
@@ -486,13 +529,6 @@ export default function PromptsIndex() {
/>
</FilterPanel>
{/* 错误信息 */}
{error && (
<div className="error-alert mb-4 p-4 bg-red-50 text-red-700 rounded-md">
<i className="ri-error-warning-line mr-2"></i> {error}
</div>
)}
{/* 数据表格 */}
<Card bodyClassName="px-4 py-4">
<Table
+15 -16
View File
@@ -4,7 +4,7 @@ import { Link, useLoaderData, useNavigation, useActionData, Form } from "@remix-
import { Button } from "~/components/ui/Button";
import newStyles from "~/styles/pages/prompts_new.css?url";
import { getPromptTemplate, createPromptTemplate, updatePromptTemplate, type PromptTemplateUI } from "~/api/prompts/prompts";
// import { toastService } from "~/components/ui";
import { toastService } from "~/components/ui";
// 样式链接
export function links() {
@@ -353,6 +353,20 @@ export default function PromptsNew() {
}
}, [template, mode, actionData?.formData]);
// 监听 loader 错误
useEffect(() => {
if (error) {
toastService.error(error);
}
}, [error]);
// 监听 action 错误
useEffect(() => {
if (actionData?.errors?.general) {
toastService.error(actionData.errors.general);
}
}, [actionData?.errors?.general]);
// 处理输入变化
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
const { name, value } = e.target;
@@ -463,21 +477,6 @@ export default function PromptsNew() {
</div>
</div>
{/* 错误信息 */}
{error && (
<div className="alert alert-error mb-4">
<i className="ri-error-warning-line"></i>
<div>{error}</div>
</div>
)}
{actionData?.errors?.general && (
<div className="alert alert-error mb-4">
<i className="ri-error-warning-line"></i>
<div>{actionData.errors.general}</div>
</div>
)}
{/* 查看模式提示 */}
{isViewMode && (
<div className="alert alert-info">
+233 -11
View File
@@ -529,9 +529,11 @@ interface AssignUserModalProps {
onClose: () => void;
onSuccess: () => void;
role: RoleInfo | null;
isCityAdmin?: boolean;
currentUserArea?: string;
}
function AssignUserModal({ isOpen, onClose, onSuccess, role }: AssignUserModalProps) {
function AssignUserModal({ isOpen, onClose, onSuccess, role, isCityAdmin, currentUserArea }: AssignUserModalProps) {
const [allUsers, setAllUsers] = useState<UserInfo[]>([]);
const [selectedUserIds, setSelectedUserIds] = useState<number[]>([]);
const [searchTerm, setSearchTerm] = useState('');
@@ -552,12 +554,24 @@ function AssignUserModal({ isOpen, onClose, onSuccess, role }: AssignUserModalPr
setLoadingUsers(true);
try {
const users = await getAllUsers();
setAllUsers(users);
// v3.3: 市级管理员只能看到同地区的用户(使用 area 字段)
let filteredUsers = users;
if (isCityAdmin && currentUserArea) {
filteredUsers = users.filter(user => user.area === currentUserArea);
console.log('🔒 [AssignUserModal v3.3] 市级管理员用户过滤:', {
当前地区: currentUserArea,
原始用户数: users.length,
过滤后用户数: filteredUsers.length
});
}
setAllUsers(filteredUsers);
// 批量获取每个用户的角色
const rolesMap = new Map<number, RoleInfo[]>();
await Promise.all(
users.map(async (user) => {
filteredUsers.map(async (user) => {
const roles = await getUserRoles(user.id);
rolesMap.set(user.id, roles);
})
@@ -678,6 +692,14 @@ function AssignUserModal({ isOpen, onClose, onSuccess, role }: AssignUserModalPr
}
>
<div className="assign-user-modal">
{/* v3.3: 市级管理员地区过滤提示 */}
{isCityAdmin && currentUserArea && (
<div className="form-notice info" style={{ marginBottom: '12px' }}>
<i className="ri-information-line"></i>
<span> {currentUserArea} </span>
</div>
)}
{/* 搜索框 */}
<div className="search-box">
<i className="ri-search-line"></i>
@@ -784,6 +806,12 @@ export default function RolePermissions() {
const [activeTab, setActiveTab] = useState<'permissions' | 'users'>('permissions');
const [loading, setLoading] = useState(true);
// v3.3: 检查当前用户角色和地区
const [currentUserRole, setCurrentUserRole] = useState('');
const [currentUserArea, setCurrentUserArea] = useState('');
const [isProvincialAdmin, setIsProvincialAdmin] = useState(false);
const [isCityAdmin, setIsCityAdmin] = useState(false);
// 模态框状态
const [showCreateModal, setShowCreateModal] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
@@ -800,6 +828,13 @@ export default function RolePermissions() {
} | null>(null);
const [deleteCountdown, setDeleteCountdown] = useState(3);
// 权限警告Modal状态
const [showPermissionWarning, setShowPermissionWarning] = useState(false);
const [pendingRouteChange, setPendingRouteChange] = useState<{
routeId: number;
checked: boolean;
} | null>(null);
// 路由权限相关状态
const [selectedRouteIds, setSelectedRouteIds] = useState<number[]>([]);
const [roleUsers, setRoleUsers] = useState<UserInfo[]>([]);
@@ -829,19 +864,62 @@ export default function RolePermissions() {
const loadData = async () => {
setLoading(true);
try {
// v3.3: 检查当前用户角色和地区
if (typeof window !== 'undefined') {
const userInfoStr = localStorage.getItem('user_info');
if (userInfoStr) {
try {
const userInfo = JSON.parse(userInfoStr);
const userRole = userInfo.user_role || '';
const userArea = userInfo.area || ''; // v3.3: 使用 area 字段进行地区隔离
setCurrentUserRole(userRole);
setCurrentUserArea(userArea);
setIsProvincialAdmin(userRole === 'provincial_admin');
setIsCityAdmin(userRole === 'admin');
console.log('🔑 [RolePermissions v3.3] 当前用户信息:', {
role: userRole,
area: userArea,
isProvincialAdmin: userRole === 'provincial_admin',
isCityAdmin: userRole === 'admin'
});
} catch (e) {
console.error('❌ [RolePermissions] 解析用户信息失败:', e);
}
}
}
const [rolesData, routesData, usersData] = await Promise.all([
getRoles(),
getRoutes(),
getAllUsers()
]);
setRoles(rolesData);
setRoutes(routesData);
setUsers(usersData);
// v3.3: 角色列表对所有人可见(不过滤)
const filteredRoles = rolesData;
// 默认选中第一个角色
if (rolesData.length > 0) {
handleSelectRole(rolesData[0]);
// v3.3: 根据用户地区过滤可见的用户列表
let filteredUsers = usersData;
if (isCityAdmin && currentUserArea) {
// 市级管理员只能看到同地区的用户(使用 area 字段)
filteredUsers = usersData.filter(user =>
user.area === currentUserArea
);
console.log('🔒 [RolePermissions v3.3] 市级管理员用户过滤:', {
当前地区: currentUserArea,
原始用户数: usersData.length,
过滤后用户数: filteredUsers.length
});
}
setRoles(filteredRoles);
setRoutes(routesData);
setUsers(filteredUsers);
// 默认选中第一个角色(使用过滤后的列表)
if (filteredRoles.length > 0) {
handleSelectRole(filteredRoles[0]);
}
} catch (error) {
console.error("加载数据失败:", error);
@@ -888,6 +966,20 @@ export default function RolePermissions() {
setRoleUsers(users);
};
// 递归查找路由
const findRouteById = (routes: RouteInfo[], routeId: number): RouteInfo | null => {
for (const route of routes) {
if (route.id === routeId) {
return route;
}
if (route.children && route.children.length > 0) {
const found = findRouteById(route.children, routeId);
if (found) return found;
}
}
return null;
};
// 递归获取所有路由ID(包括子路由)
const getAllRouteIds = (routes: RouteInfo[]): number[] => {
let ids: number[] = [];
@@ -900,8 +992,34 @@ export default function RolePermissions() {
return ids;
};
// 递归检查路由树中是否包含指定路径的路由
const containsRoutePath = (routes: RouteInfo[], targetPath: string): boolean => {
for (const route of routes) {
if (route.route_path === targetPath) {
return true;
}
if (route.children && route.children.length > 0) {
if (containsRoutePath(route.children, targetPath)) {
return true;
}
}
}
return false;
};
// 切换路由权限
const handleToggleRoute = (routeId: number, checked: boolean) => {
// 检查是否正在取消勾选 /role-permissions 路由
if (!checked) {
const route = findRouteById(routes, routeId);
if (route && route.route_path === '/role-permissions') {
// 显示警告模态框
setPendingRouteChange({ routeId, checked });
setShowPermissionWarning(true);
return;
}
}
if (checked) {
setSelectedRouteIds([...selectedRouteIds, routeId]);
} else {
@@ -911,6 +1029,20 @@ export default function RolePermissions() {
// 切换父路由(包括所有子路由)
const handleToggleParentRoute = (route: RouteInfo, checked: boolean) => {
// 检查是否正在取消勾选包含 /role-permissions 的父路由
if (!checked) {
const allRoutes = route.children ? [route, ...route.children] : [route];
const hasRolePermissionsRoute = allRoutes.some(r => r.route_path === '/role-permissions') ||
(route.children && containsRoutePath(route.children, '/role-permissions'));
if (route.route_path === '/role-permissions' || hasRolePermissionsRoute) {
// 显示警告模态框,传递 route 对象表示是父路由操作
setPendingRouteChange({ routeId: route.id, checked });
setShowPermissionWarning(true);
return;
}
}
const childIds = route.children ? getAllRouteIds(route.children) : [];
const allIds = [route.id, ...childIds];
@@ -926,6 +1058,32 @@ export default function RolePermissions() {
}
};
// 确认取消角色权限管理路由
const confirmRemovePermissionRoute = () => {
if (!pendingRouteChange) return;
const { routeId, checked } = pendingRouteChange;
const route = findRouteById(routes, routeId);
if (route) {
// 如果是父路由,取消所有子路由
if (route.children && route.children.length > 0) {
const childIds = getAllRouteIds(route.children);
const allIds = [route.id, ...childIds];
setSelectedRouteIds(selectedRouteIds.filter(id => !allIds.includes(id)));
} else {
// 单个路由
setSelectedRouteIds(selectedRouteIds.filter(id => id !== routeId));
}
}
// 关闭模态框并重置状态
setShowPermissionWarning(false);
setPendingRouteChange(null);
toastService.warning('已取消角色权限管理路由,请谨慎保存权限配置');
};
// v3.0: 切换路由展开状态(显示/隐藏权限列表)
const handleToggleRouteExpand = (routeId: number) => {
setExpandedRouteIds(prev =>
@@ -1070,16 +1228,27 @@ export default function RolePermissions() {
}
};
// 保存权限 - v3.0: 同时保存路由权限和API权限
// 保存权限 - v3.3: 同时保存路由权限和API权限,仅省级管理员可操作
const handleSavePermissions = async () => {
if (!selectedRole) return;
// v3.3: 前置权限检查(仅省级管理员)
if (!isProvincialAdmin) {
toastService.error('权限不足:仅省级管理员可以修改角色路由权限');
return;
}
try {
// 1. 保存路由权限
const routeResult = await updateRoleRoutePermissions(selectedRole.id, selectedRouteIds);
// v3.3: 处理权限不足错误
if (!routeResult.success) {
toastService.error(routeResult.message);
if (routeResult.code === 4003) {
toastService.error('权限不足:仅省级管理员可以修改角色路由权限');
} else {
toastService.error(routeResult.message);
}
return;
}
@@ -1146,6 +1315,7 @@ export default function RolePermissions() {
}
}}
className="route-checkbox"
disabled={!isProvincialAdmin}
/>
<label htmlFor={`route-${route.id}`} className="route-label">
{route.icon && <i className={`${route.icon} route-icon`}></i>}
@@ -1212,6 +1382,7 @@ export default function RolePermissions() {
checked={selectedPermissionIds.includes(permission.id)}
onChange={(e) => handleTogglePermission(permission.id, e.target.checked)}
style={{ margin: 0 }}
disabled={!isProvincialAdmin}
/>
<span
style={{
@@ -1398,12 +1569,20 @@ export default function RolePermissions() {
{/* 路由权限Tab */}
{activeTab === 'permissions' && (
<div className="permissions-tab">
{/* v3.3: 权限提示(仅省级管理员可修改) */}
{!isProvincialAdmin && (
<div className="form-notice warning" style={{ marginBottom: '16px' }}>
<i className="ri-information-line"></i>
<span></span>
</div>
)}
<div className="permissions-header">
<h3> "{selectedRole.role_name}" </h3>
<Button
type="primary"
icon="ri-save-line"
onClick={handleSavePermissions}
disabled={!isProvincialAdmin}
>
</Button>
@@ -1536,6 +1715,8 @@ export default function RolePermissions() {
}
}}
role={selectedRole}
isCityAdmin={isCityAdmin}
currentUserArea={currentUserArea}
/>
{/* 确认删除模态框 */}
@@ -1614,6 +1795,47 @@ export default function RolePermissions() {
</div>
</div>
</Modal>
{/* 权限警告模态框 */}
<Modal
isOpen={showPermissionWarning}
onClose={() => {
setShowPermissionWarning(false);
setPendingRouteChange(null);
}}
title="⚠️ 警告:取消角色权限管理路由"
size="medium"
>
<div style={{ padding: '20px 0' }}>
<p style={{ marginBottom: '16px', fontSize: '15px', lineHeight: '1.6', color: '#ff6b00' }}>
<strong>"/role-permissions"</strong>
</p>
<p style={{ marginBottom: '16px', fontSize: '14px', lineHeight: '1.6' }}>
<strong></strong>访
</p>
<p style={{ marginBottom: '16px', fontSize: '14px', color: '#666' }}>
"保存权限"
</p>
<div style={{ display: 'flex', gap: '12px', justifyContent: 'flex-end', marginTop: '24px' }}>
<Button
variant="secondary"
onClick={() => {
setShowPermissionWarning(false);
setPendingRouteChange(null);
}}
>
</Button>
<Button
variant="danger"
onClick={confirmRemovePermissionRoute}
>
</Button>
</div>
</div>
</Modal>
</div>
);
}
+46 -19
View File
@@ -1,5 +1,5 @@
import { type MetaFunction } from "@remix-run/node";
import { useLoaderData, Link, useNavigate, useSearchParams, useRouteLoaderData } from "@remix-run/react";
import { useLoaderData, Link, useNavigate, useSearchParams } from "@remix-run/react";
import { useState, useEffect } from "react";
import indexStyles from "~/styles/pages/rule-groups_index.css?url";
import { Card } from "~/components/ui/Card";
@@ -17,6 +17,7 @@ import {
batchDeleteEvaluationPointGroups
} from "~/api/evaluation_points/rule-groups";
import { toastService, messageService } from "~/components/ui";
import { usePermission } from "~/hooks/usePermission";
export function links() {
return [{ rel: "stylesheet", href: indexStyles }];
@@ -78,8 +79,7 @@ export async function loader({ request }: { request: Request }) {
export default function RuleGroupsIndex() {
const loaderData = useLoaderData<typeof loader>();
const { groups: initialGroups, totalCount = 0, page = 1, pageSize = 50, frontendJWT } = loaderData;
const rootData = useRouteLoaderData("root") as { userRole: string };
const { groups: initialGroups, frontendJWT } = loaderData;
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const [expandedGroups, setExpandedGroups] = useState<string[]>([]);
@@ -88,8 +88,13 @@ export default function RuleGroupsIndex() {
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');
// ✅ 使用权限 Hook
const { canCreate, canUpdate, canDelete, canBatch } = usePermission();
const canCreateGroup = canCreate('evaluation_group');
const canUpdateGroup = canUpdate('evaluation_group');
const canDeleteGroup = canDelete('evaluation_group');
const canBatchOperation = canBatch('evaluation_group'); // ✅ 批量操作权限
// 初始加载时自动加载所有子分组
useEffect(() => {
@@ -230,6 +235,12 @@ export default function RuleGroupsIndex() {
// 处理删除分组
const handleDeleteGroup = async (groupId: string) => {
// ✅ 检查删除权限
if (!canDeleteGroup) {
toastService.warning('您没有删除权限');
return;
}
messageService.show({
title: "确认删除",
message: "确定要删除该分组吗?此操作将同时删除该分组下的所有评查点,且不可恢复。",
@@ -277,6 +288,12 @@ export default function RuleGroupsIndex() {
// 🆕 批量启用/禁用
const handleBatchEnable = async (enable: boolean) => {
// ✅ 检查更新权限
if (!canUpdateGroup) {
toastService.warning('您没有更新权限');
return;
}
if (selectedIds.length === 0) {
toastService.warning('请先选择要操作的分组');
return;
@@ -299,6 +316,12 @@ export default function RuleGroupsIndex() {
// 🆕 批量删除
const handleBatchDelete = async () => {
// ✅ 检查删除权限
if (!canDeleteGroup) {
toastService.warning('您没有删除权限');
return;
}
if (selectedIds.length === 0) {
toastService.warning('请先选择要删除的分组');
return;
@@ -569,8 +592,8 @@ export default function RuleGroupsIndex() {
// 定义表格列配置
const columns = [
// 🆕 复选框列
...(hasEditPermission ? [{
// 🆕 复选框列 - 仅在有批量操作权限时显示
...(canBatchOperation ? [{
title: (
<input
type="checkbox"
@@ -676,9 +699,9 @@ export default function RuleGroupsIndex() {
onClick={() => navigate(`/rule-groups/new?id=${record.id}`)}
className="operation-btn"
>
<i className="ri-edit-line"></i> {hasEditPermission ? '编辑' : '查看'}
<i className="ri-edit-line"></i> {canUpdateGroup ? '编辑' : '查看'}
</button>
{hasEditPermission && (
{canDeleteGroup && (
<button
type="button"
className="operation-btn !text-[--color-error]"
@@ -720,7 +743,8 @@ export default function RuleGroupsIndex() {
>
</Button>
{hasEditPermission && selectedIds.length > 0 && (
{/* ✅ 批量启用/禁用按钮:仅当有更新权限且有选中项时显示 */}
{canUpdateGroup && selectedIds.length > 0 && (
<>
<Button
type="default"
@@ -738,17 +762,20 @@ export default function RuleGroupsIndex() {
>
({selectedIds.length})
</Button>
<Button
type="danger"
icon="ri-delete-bin-line"
onClick={handleBatchDelete}
className="mr-2"
>
({selectedIds.length})
</Button>
</>
)}
{hasEditPermission && (
{/* ✅ 批量删除按钮:仅当有删除权限且有选中项时显示 */}
{canDeleteGroup && selectedIds.length > 0 && (
<Button
type="danger"
icon="ri-delete-bin-line"
onClick={handleBatchDelete}
className="mr-2"
>
({selectedIds.length})
</Button>
)}
{canCreateGroup && (
<Button
type="primary"
icon="ri-add-line"
+52 -9
View File
@@ -1,10 +1,11 @@
import { redirect, type ActionFunctionArgs, type LoaderFunctionArgs, type MetaFunction } from "@remix-run/node";
import { useLoaderData, useActionData, useNavigation, Form, useRouteLoaderData } from "@remix-run/react";
import { useLoaderData, useActionData, useNavigation, Form } from "@remix-run/react";
import { useEffect, useState, useRef } from "react";
import { Button } from "~/components/ui/Button";
import { Card } from "~/components/ui/Card";
import { toastService } from "~/components/ui/Toast";
import { usePermission } from "~/hooks/usePermission";
import ruleGroupsNewStyles from "~/styles/pages/rule-groups_new.css?url";
import {
getEvaluationPointGroups,
@@ -246,16 +247,19 @@ export default function RuleGroupNew() {
const actionData = useActionData<typeof action>();
const navigation = useNavigation();
const isSubmitting = navigation.state === "submitting";
const rootData = useRouteLoaderData("root") as { userRole: string };
const userRole = rootData?.userRole || 'common';
// 判断表单是否为只读模式(只有包含'provin'的用户才有编辑权限)
const hasEditPermission = userRole.toLowerCase().includes('provin');
const isReadOnly = !hasEditPermission;
// ✅ Use permission Hook
const { canCreate, canUpdate } = usePermission();
const canCreateGroup = canCreate('evaluation_group');
const canUpdateGroup = canUpdate('evaluation_group');
// 解构数据
const { group, parentGroups, isEdit, error } = data;
// ✅ 根据当前操作类型判断权限
const hasEditPermission = isEdit ? canUpdateGroup : canCreateGroup;
const isReadOnly = !hasEditPermission;
// 表单状态管理 - 使用受控组件
const [formValues, setFormValues] = useState<{
groupType: "primary" | "secondary";
@@ -299,10 +303,26 @@ export default function RuleGroupNew() {
parentId: false
});
// ✅ 页面加载时检查权限并提示(仅在只读模式下提示)
// useEffect(() => {
// console.log("权限",canCreateGroup,canUpdateGroup)
// if (isReadOnly) {
// if (isEdit) {
// toastService.info('当前为查看模式,您没有编辑权限');
// } else {
// toastService.warning('您没有创建分组的权限');
// }
// }
// }, [isReadOnly, isEdit]);
// 从 actionData 初始化表单错误
useEffect(() => {
if (actionData?.errors) {
setFormErrors(actionData.errors);
// ✅ 如果有通用错误,使用 toast 显示
if (actionData.errors.general) {
toastService.error(actionData.errors.general);
}
}
}, [actionData]);
@@ -452,9 +472,23 @@ export default function RuleGroupNew() {
// 处理表单提交前验证
const handleBeforeSubmit = (e: React.FormEvent) => {
// ✅ Runtime permission check
if (isEdit && !canUpdateGroup) {
e.preventDefault();
toastService.warning('您没有修改权限,无法保存更改');
return;
}
if (!isEdit && !canCreateGroup) {
e.preventDefault();
toastService.warning('您没有创建权限,无法新增分组');
return;
}
// 如果是只读模式,阻止提交
if (isReadOnly) {
e.preventDefault();
toastService.info('当前为只读模式,无法提交');
return;
}
@@ -474,9 +508,17 @@ export default function RuleGroupNew() {
setFormErrors(errors);
// 如果有错误,阻止提交
if (errors.name || errors.code || (formValues.groupType === "secondary" && errors.parentId)) {
// 如果有错误,阻止提交并提示
const hasErrors = errors.name || errors.code || (formValues.groupType === "secondary" && errors.parentId);
if (hasErrors) {
e.preventDefault();
// ✅ 收集所有错误信息并提示
const errorMessages = [];
if (errors.name) errorMessages.push(errors.name);
if (errors.code) errorMessages.push(errors.code);
if (errors.parentId) errorMessages.push(errors.parentId);
toastService.error(`表单验证失败:${errorMessages[0]}`);
}
};
@@ -509,7 +551,8 @@ export default function RuleGroupNew() {
>
<i className="ri-arrow-left-line"></i>
</Button>
{!isReadOnly && (
{/* ✅ 仅在有对应权限时显示保存按钮 */}
{hasEditPermission && (
<Button
type="primary"
form="group-form"
+65 -41
View File
@@ -1,6 +1,6 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { type MetaFunction, type LoaderFunctionArgs } from "@remix-run/node";
import { useLoaderData, useSearchParams, Link, useNavigate, useFetcher, useRouteLoaderData, useLocation } from "@remix-run/react";
import { useLoaderData, useSearchParams, Link, useNavigate, useFetcher, useLocation } from "@remix-run/react";
import { Button } from '~/components/ui/Button';
import { Card } from '~/components/ui/Card';
import { Tag } from '~/components/ui/Tag';
@@ -15,6 +15,7 @@ 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 { usePermission } from '~/hooks/usePermission';
import {
getRulesList,
deleteRule,
@@ -25,7 +26,6 @@ import {
type RuleType as ApiRuleType,
type RuleGroup
} from '~/api/evaluation_points/rules';
import type { UserRole } from '~/root';
export const links = () => [
{ rel: "stylesheet", href: rulesStyles }
@@ -193,13 +193,20 @@ const priorityLabels = {
export default function RulesIndex() {
const loaderData = useLoaderData<typeof loader>();
const rootData = useRouteLoaderData("root") as { userRole: UserRole };
const { rules: initialRules, totalCount: initialTotalCount, currentPage, pageSize, ruleTypes: initialRuleTypes, initialLoad } = loaderData;
const [searchParams, setSearchParams] = useSearchParams();
const navigate = useNavigate();
const fetcher = useFetcher<ActionResponse>();
const location = useLocation();
// ✅ 使用权限 Hook
const { canCreate, canUpdate, canDelete, canBatch, canView } = usePermission();
const canCreateRule = canCreate('evaluation_point');
const canUpdateRule = canUpdate('evaluation_point');
const canDeleteRule = canDelete('evaluation_point');
const canBatchRule = canBatch('evaluation_point');
const canViewRule = canView('evaluation_point');
// 状态管理
const [ruleGroups, setRuleGroups] = useState<RuleGroup[]>([]);
const [loadingGroups, setLoadingGroups] = useState(false);
@@ -243,15 +250,6 @@ export default function RulesIndex() {
// 判断是否禁用规则组选择
const isRuleGroupSelectDisabled = loadingGroups || !ruleTypeParam || ruleGroups.length === 0;
// 检查用户是否为开发者角色
const userRole = rootData?.userRole || 'common';
const isDeveloper = userRole.includes('admin');
// 调试日志
// console.log("🔑 [Rules List] rootData:", rootData);
// console.log("🔑 [Rules List] 用户角色:", userRole);
// console.log("🔑 [Rules List] 是否为管理员:", isDeveloper);
// 在组件渲染时初始化状态
// useEffect(() => {
// setFilteredRules(initialRules);
@@ -523,6 +521,12 @@ export default function RulesIndex() {
// 删除评查点
const handleDeleteClick = (rule: Rule) => {
// ✅ 检查删除权限
if (!canDeleteRule) {
toastService.warning('您没有删除权限');
return;
}
messageService.show({
title: "确认删除",
message: `确认删除评查点【${rule.name}】吗?`,
@@ -563,6 +567,12 @@ export default function RulesIndex() {
// 批量启用/禁用
const handleBatchEnable = async (isEnabled: boolean) => {
// ✅ 检查批量操作权限
if (!canBatchRule) {
toastService.warning('您没有批量操作权限');
return;
}
if (selectedIds.length === 0) {
toastService.warning('请先选择要操作的评查点');
return;
@@ -594,6 +604,12 @@ export default function RulesIndex() {
// 批量删除
const handleBatchDelete = async () => {
// ✅ 检查批量删除权限
if (!canBatchRule || !canDeleteRule) {
toastService.warning('您没有批量删除权限');
return;
}
if (selectedIds.length === 0) {
toastService.warning('请先选择要删除的评查点');
return;
@@ -664,8 +680,8 @@ export default function RulesIndex() {
// 定义表格列配置
const columns = [
// 添加复选框列(仅开发者可见)
...(isDeveloper ? [{
// 添加复选框列(有批量操作权限时可见)
...(canBatchRule ? [{
title: (
<input
type="checkbox"
@@ -762,24 +778,30 @@ export default function RulesIndex() {
width: "10%",
render: (_: unknown, record: Rule) => (
<div className="operations-cell">
{isDeveloper ? (
// 开发者可以看到编辑、复制、删除
{/* ✅ 查看/编辑和复制按钮 - 需要查看权限 */}
{canViewRule && (
<>
<Link to={`/rules/new?id=${record.id}`} className="operation-btn">
<i className="ri-edit-line"></i>
{/* ✅ 编辑/查看按钮 - 根据权限显示编辑或查看 */}
<Link to={`/rules/new?id=${record.id}${!canUpdateRule ? '&mode=view' : ''}`} className="operation-btn">
<i className={canUpdateRule ? "ri-edit-line" : "ri-eye-line"}></i> {canUpdateRule ? '编辑' : '查看'}
</Link>
<button className="operation-btn" onClick={() => handleCopy(record)}>
<i className="ri-file-copy-line"></i>
</button>
<button className="operation-btn operation-btn-danger" onClick={() => handleDeleteClick(record)}>
<i className="ri-delete-bin-line"></i>
</button>
{/* ✅ 复制按钮 - 有创建权限时显示 */}
{canCreateRule && (
<button className="operation-btn" onClick={() => handleCopy(record)}>
<i className="ri-file-copy-line"></i>
</button>
)}
</>
) : (
// 普通用户只能查看
<Link to={`/rules/new?id=${record.id}&mode=view`} className="operation-btn">
<i className="ri-eye-line"></i>
</Link>
)}
{/* ✅ 删除按钮 - 只需要删除权限 */}
{canDeleteRule && (
<button className="operation-btn operation-btn-danger" onClick={() => handleDeleteClick(record)}>
<i className="ri-delete-bin-line"></i>
</button>
)}
{/* 如果什么权限都没有,显示 - */}
{!canViewRule && !canDeleteRule && (
<span className="text-gray-400">-</span>
)}
</div>
)
@@ -805,8 +827,8 @@ export default function RulesIndex() {
)}
</div>
<div className="flex items-center gap-2">
{/* 批量操作按钮(仅在有选择时显示) */}
{isDeveloper && selectedIds.length > 0 && (
{/* 批量操作按钮(有批量权限且有选择时显示) */}
{canBatchRule && selectedIds.length > 0 && (
<>
<Button
type="default"
@@ -824,18 +846,20 @@ export default function RulesIndex() {
>
({selectedIds.length})
</Button>
<Button
type="danger"
icon="ri-delete-bin-line"
onClick={handleBatchDelete}
className="btn-batch-delete"
>
({selectedIds.length})
</Button>
{canDeleteRule && (
<Button
type="danger"
icon="ri-delete-bin-line"
onClick={handleBatchDelete}
className="btn-batch-delete"
>
({selectedIds.length})
</Button>
)}
</>
)}
{/* 新增按钮 */}
{isDeveloper && (
{/* 新增按钮 - 有创建权限时显示 */}
{canCreateRule && (
<Button type="primary" icon="ri-add-line" to="/rules/new" className="btn-add-rule">
</Button>
+42 -7
View File
@@ -49,7 +49,7 @@ import type { EvaluationPointGroup } from "~/models/evaluation_point_groups";
// 导入RuleContext上下文
import { RuleContext } from "~/contexts/RuleContext";
import { toastService } from '~/components/ui/Toast';
import type { UserRole } from '~/root';
import { usePermission } from '~/hooks/usePermission';
import { getPromptTemplateOptions } from '~/api/prompts/prompts';
import {
createEvaluationPoint,
@@ -148,26 +148,50 @@ export default function RuleNew() {
const [isCopyMode, setIsCopyMode] = useState(false); // 添加复制模式状态
const [isLoading, setIsLoading] = useState(false);
const [instanceKey, setInstanceKey] = useState<string>('new');
// 从root路由获取用户角色和JWT token
const rootData = useRouteLoaderData("root") as { userRole: UserRole; frontendJWT?: string };
const userRole = rootData?.userRole || 'common';
// 从root路由获取JWT token
const rootData = useRouteLoaderData("root") as { frontendJWT?: string };
const frontendJWT = rootData?.frontendJWT;
// ✅ 使用权限 Hook
const { canCreate, canUpdate } = usePermission();
const canCreateRule = canCreate('evaluation_point');
const canUpdateRule = canUpdate('evaluation_point');
// ✅ 判断表单是否为只读模式
// 从 URL 检查是否为查看模式
const searchParams = new URLSearchParams(location.search);
const urlMode = searchParams.get('mode');
const isViewMode = urlMode === 'view';
// 根据模式和权限决定是否只读
const hasEditPermission = isEditMode ? canUpdateRule : canCreateRule;
const isReadOnly = isViewMode || !hasEditPermission;
// 使用 ref 跟踪当前加载的 URL,避免重复加载
const loadedUrlRef = useRef<string>('');
const [formData, setFormData] = useState<EvaluationPoint>({});
const [evaluationPointGroups, setEvaluationPointGroups] = useState<EvaluationPointGroup[]>([]);
// 判断表单是否为只读模式
const isReadOnly = userRole === 'common';
// 添加用于共享的字段数据状态
const [extractionFields, setExtractionFields] = useState<string[]>([]);
// VLM字段类型选项
const [vlmFieldTypeOptions, setVlmFieldTypeOptions] = useState<Array<{ value: string; label: string }>>([]);
// ✅ 页面加载时检查权限并提示(仅在只读模式下提示)
useEffect(() => {
if (isReadOnly && !isLoading) {
if (isViewMode) {
// toastService.info('当前为查看模式');
} else if (isEditMode && !canUpdateRule) {
toastService.info('当前为查看模式,您没有编辑权限');
} else if (!isEditMode && !canCreateRule) {
toastService.warning('您没有创建评查点的权限');
}
}
}, [isReadOnly, isViewMode, isEditMode, canUpdateRule, canCreateRule, isLoading]);
/**
* 从表单数据中提取所有字段
* 用于编辑模式下初始化字段数据
@@ -417,6 +441,17 @@ export default function RuleNew() {
const handleSave = async () => {
// console.log("保存评查点", formData);
// ✅ Runtime permission check
if (isEditMode && !canUpdateRule) {
toastService.warning('您没有修改权限,无法保存更改');
return;
}
if (!isEditMode && !canCreateRule) {
toastService.warning('您没有创建权限,无法新增评查点');
return;
}
// ========== 验证必填字段 ==========
// 1. 验证评查点名称
+43
View File
@@ -0,0 +1,43 @@
/**
* 思源黑体Source Han Sans字体定义
* 本地托管版本支持 woff2/woff/otf 格式
*/
/* 思源黑体 - 常规(Regular/Normal - 400 */
@font-face {
font-family: 'Source Han Sans SC';
font-style: normal;
font-weight: 400;
font-display: swap; /* 快速显示文本,字体加载完成后替换 */
src: local('Source Han Sans SC Regular'),
local('SourceHanSansSC-Regular'),
url('/fonts/source-han-sans/SourceHanSansSC-Regular.otf') format('opentype');
}
/* 思源黑体 - 中等(Medium - 500 */
@font-face {
font-family: 'Source Han Sans SC';
font-style: normal;
font-weight: 500;
font-display: swap;
src: local('Source Han Sans SC Medium'),
local('SourceHanSansSC-Medium'),
url('/fonts/source-han-sans/SourceHanSansSC-Medium.otf') format('opentype');
}
/* 思源黑体 - 粗体(Bold - 700 */
@font-face {
font-family: 'Source Han Sans SC';
font-style: normal;
font-weight: 700;
font-display: swap;
src: local('Source Han Sans SC Bold'),
local('SourceHanSansSC-Bold'),
url('/fonts/source-han-sans/SourceHanSansSC-Bold.otf') format('opentype');
}
/* 如果需要更多字重可以继续添加
* - Light (300)
* - ExtraLight (250)
* - Heavy (900)
*/
+7
View File
@@ -107,6 +107,13 @@
.document-type-new-page .checkbox-input {
@apply mr-2 h-4 w-4 text-primary-600 border-gray-300 rounded;
@apply focus:ring-primary-500 cursor-pointer;
accent-color: #00684a;
}
/* 复选框选中状态 */
.document-type-new-page .checkbox-input:checked {
background-color: #00684a;
border-color: #00684a;
}
.document-type-new-page .checkbox-label {
+61 -23
View File
@@ -75,6 +75,7 @@
/* 主要内容区域 */
.index-main-content {
position: relative; /* 为绝对定位的管理入口提供定位上下文 */
height: 100%;
flex: 1;
display: flex;
@@ -114,16 +115,18 @@
flex-shrink: 0; /* 防止被压缩 */
}
/* 模块网格容器 - 每行4个 */
.modules-container {
display: flex;
flex-wrap: wrap; /* 自动换行 */
justify-content: center;
align-content: flex-start; /* 内容从顶部开始排列 */
gap: 2.5rem;
flex: 1; /* 占据剩余空间 */
overflow-y: auto; /* 超出高度时显示垂直滚动条 */
overflow-x: hidden; /* 隐藏水平滚动条 */
padding: 2rem 0 3rem 0; /* 上下留出一些空间 */
padding: 2rem 0 3rem 0;
display: grid;
grid-template-columns: repeat(4, 1fr); /* 每行固定4列 */
gap: 2rem 2.5rem; /* 行间距2rem,列间距2.5rem */
align-content: flex-start;
max-width: 1200px; /* 限制最大宽度 */
margin: 0 auto; /* 居中 */
}
/* 滚动条样式优化 */
@@ -153,8 +156,7 @@
gap: 1.5rem;
padding: 0 2rem;
height: 136px;
width: 290px;
flex-shrink: 0; /* 防止卡片被压缩 */
width: 100%; /* 适应grid列宽 */
background: linear-gradient(180deg, #ebf1f7 0%, #ffffff 100%);
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
@@ -200,6 +202,33 @@
background-size: cover;
}
.settings-button {
display: flex;
align-items: center;
justify-content: center;
padding: 0.5rem;
background: transparent;
border: none;
color: #666;
cursor: pointer;
transition: all 0.2s;
border-radius: 4px;
}
.settings-button:hover {
background-color: rgba(0, 104, 74, 0.05);
color: #00684a;
}
.settings-button i {
font-size: 1.5rem;
transition: transform 0.5s ease;
}
.settings-button:hover i {
transform: rotate(90deg);
}
.logout-button {
display: flex;
align-items: center;
@@ -278,14 +307,12 @@
padding: 0 1rem;
}
/* 模块容器改为纵向排列 */
/* 模块容器改为列 */
.modules-container {
flex-direction: column;
flex-wrap: nowrap; /* 移动端不需要换行 */
padding: 1.5rem 0 2rem 0;
grid-template-columns: 1fr; /* 移动端单列 */
gap: 1.25rem;
align-items: center;
overflow-y: auto; /* 移动端超出长度滚动显示 */
padding: 1rem 0 2rem 0;
max-width: 100%; /* 移除最大宽度限制 */
}
/* 移动端滚动条样式 */
@@ -296,11 +323,10 @@
/* 模块卡片调整 */
.module-card {
width: 100%;
max-width: 340px;
max-width: 100%;
height: 100px;
padding: 0 1.5rem;
gap: 1.25rem;
flex-shrink: 0; /* 移动端也防止卡片被压缩 */
}
.module-card img {
@@ -312,6 +338,14 @@
font-size: 1.1rem;
}
.settings-button {
padding: 0.4rem;
}
.settings-button i {
font-size: 1.35rem;
}
.logout-button {
padding: 0.4rem;
}
@@ -344,8 +378,13 @@
height: 18vh; /* 超小屏幕标题区域更小 */
}
/* 超小屏幕模块网格 */
.modules-container {
gap: 1rem;
}
.module-card {
max-width: 300px;
max-width: 100%;
height: 90px;
padding: 0 1.25rem;
gap: 1rem;
@@ -359,10 +398,6 @@
.module-name {
font-size: 1rem;
}
.modules-container {
gap: 1rem;
}
}
/* 平板横屏 */
@@ -376,13 +411,16 @@
height: 22vh; /* 平板电脑标题区域高度 */
}
/* 平板模块网格 - 每行3个 */
.modules-container {
gap: 2rem;
padding: 1.5rem 0 2.5rem 0;
grid-template-columns: repeat(3, 1fr); /* 平板每行3列 */
gap: 1.75rem 2rem;
max-width: 900px; /* 平板上稍窄一些 */
}
.module-card {
width: 260px;
width: 100%;
height: 120px;
}
}
+143
View File
@@ -0,0 +1,143 @@
# 思源黑体(Source Han Sans SC)字体文件
本目录存放思源黑体的字体文件,用于本地化部署。
## 📥 下载字体文件
### 方法 1:从 GitHub 直接下载(推荐)
访问官方 GitHub 仓库:
https://github.com/adobe-fonts/source-han-sans/tree/release
**步骤**
1. 进入 `SubsetOTF/SC/` 目录
2. 下载以下文件(点击文件名 → 点击 "Download" 按钮):
- `SourceHanSansSC-Regular.otf` - https://github.com/adobe-fonts/source-han-sans/raw/release/SubsetOTF/SC/SourceHanSansSC-Regular.otf
- `SourceHanSansSC-Medium.otf` - https://github.com/adobe-fonts/source-han-sans/raw/release/SubsetOTF/SC/SourceHanSansSC-Medium.otf
- `SourceHanSansSC-Bold.otf` - https://github.com/adobe-fonts/source-han-sans/raw/release/SubsetOTF/SC/SourceHanSansSC-Bold.otf
3. 下载后放置到本目录(`public/fonts/source-han-sans/`)。
**快捷命令**Windows PowerShell):
```powershell
# 创建目录
New-Item -ItemType Directory -Force -Path "public/fonts/source-han-sans"
# 下载字体文件
$baseUrl = "https://github.com/adobe-fonts/source-han-sans/raw/release/SubsetOTF/SC"
Invoke-WebRequest -Uri "$baseUrl/SourceHanSansSC-Regular.otf" -OutFile "public/fonts/source-han-sans/SourceHanSansSC-Regular.otf"
Invoke-WebRequest -Uri "$baseUrl/SourceHanSansSC-Medium.otf" -OutFile "public/fonts/source-han-sans/SourceHanSansSC-Medium.otf"
Invoke-WebRequest -Uri "$baseUrl/SourceHanSansSC-Bold.otf" -OutFile "public/fonts/source-han-sans/SourceHanSansSC-Bold.otf"
```
**快捷命令**macOS/Linux):
```bash
# 创建目录
mkdir -p public/fonts/source-han-sans
# 下载字体文件
cd public/fonts/source-han-sans
curl -LO "https://github.com/adobe-fonts/source-han-sans/raw/release/SubsetOTF/SC/SourceHanSansSC-Regular.otf"
curl -LO "https://github.com/adobe-fonts/source-han-sans/raw/release/SubsetOTF/SC/SourceHanSansSC-Medium.otf"
curl -LO "https://github.com/adobe-fonts/source-han-sans/raw/release/SubsetOTF/SC/SourceHanSansSC-Bold.otf"
cd ../../..
```
### 方法 2:从 Google Fonts 下载
访问 Google Fonts 下载 Noto Sans SC(与思源黑体相同):
https://fonts.google.com/noto/specimen/Noto+Sans+SC
选择需要的字重后点击 "Download family" 下载。
### 方法 3:使用自动下载脚本
在项目根目录执行:
```powershell
# Windows PowerShell
.\scripts\download-fonts.ps1
```
脚本会自动从 GitHub 下载字体文件到正确的位置。
## 🔄 字体格式转换(可选,优化性能)
为了更好的 Web 性能,建议将 OTF 转换为 WOFF2 格式(压缩率更高)。
### 使用在线工具转换
1. **CloudConvert**(推荐)
- 访问:https://cloudconvert.com/otf-to-woff2
- 上传 OTF 文件
- 选择输出格式为 WOFF2
- 下载转换后的文件
2. **Font Squirrel**
- 访问:https://www.fontsquirrel.com/tools/webfont-generator
- 上传 OTF 文件
- 选择 "Optimal" 模式
- 下载 webfont kit
### 使用命令行工具转换
安装 `fonttools`Python 工具):
```bash
# 安装 fonttools
pip install fonttools brotli
# 转换 OTF 到 WOFF2
pyftsubset SourceHanSansSC-Regular.otf \
--output-file=SourceHanSansSC-Regular.woff2 \
--flavor=woff2 \
--layout-features="*" \
--unicodes="*"
```
或使用 Node.js 工具 `ttf2woff2`
```bash
# 安装工具
npm install -g ttf2woff2
# 转换 OTF 到 WOFF2(需要先转为 TTF
# 1. OTF → TTF(使用 fontforge 或在线工具)
# 2. TTF → WOFF2
ttf2woff2 SourceHanSansSC-Regular.ttf SourceHanSansSC-Regular.woff2
```
## 📂 最终文件结构
完成后,本目录应包含以下文件:
```
public/fonts/source-han-sans/
├── README.md (本文件)
├── SourceHanSansSC-Regular.otf (或 .woff2)
├── SourceHanSansSC-Medium.otf (或 .woff2)
└── SourceHanSansSC-Bold.otf (或 .woff2)
```
## ⚡ 性能优化建议
1. **只包含需要的字重**:减少文件数量
2. **使用 WOFF2 格式**:比 OTF 小 30-50%
3. **字体子集化**(高级):只包含项目中用到的汉字
4. **启用 CDN 缓存**:设置长期缓存头
## 🔍 字体验证
完成配置后,在浏览器中:
1. 打开开发者工具(F12
2. 切换到 Network 标签
3. 刷新页面
4. 查看字体文件是否成功加载
5. 在 Elements 标签中检查 `font-family` 是否应用
## 📝 许可证
思源黑体使用 **SIL Open Font License 1.1** 许可证,可免费用于商业和个人项目。
详见:https://github.com/adobe-fonts/source-han-sans/blob/release/LICENSE.txt
+1
View File
@@ -25,6 +25,7 @@ export default {
},
fontFamily: {
sans: [
"Source Han Sans SC", // 思源黑体(优先使用)
"-apple-system",
"BlinkMacSystemFont",
"Segoe UI",