feat: 1. 本地化思源黑体的字体包并优先使用。
2. 添加权限映射表和全局查看权限的hook,便于路由控制不同权限按钮显示/隐藏。 3. 删除评查点分组的部分旧api方法。 4. 对接评查点分组接口,文档类型接口, 提示词管理接口, 入口模块管理的接口。 5. 优化角色权限管理的接口,完善不用地区的访问权限认证。 6. 优化主页交叉评查和设置的入口样式和布局。 7. 优化评查点分组,评查规则的功能权限校验。
This commit is contained in:
+105
-3
@@ -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
@@ -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
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: 收集已启用的路由ID(enabled=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
|
||||
|
||||
@@ -33,8 +33,8 @@ interface ToastProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// 默认自动关闭延迟
|
||||
const DEFAULT_AUTO_CLOSE_DELAY = 3000;
|
||||
// 默认自动关闭延迟(缩短为2秒)
|
||||
const DEFAULT_AUTO_CLOSE_DELAY = 2000;
|
||||
|
||||
// 导出样式
|
||||
export function links() {
|
||||
|
||||
@@ -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}</>;
|
||||
}
|
||||
@@ -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
@@ -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">
|
||||
|
||||
@@ -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
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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. 验证评查点名称
|
||||
|
||||
@@ -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)
|
||||
*/
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -25,6 +25,7 @@ export default {
|
||||
},
|
||||
fontFamily: {
|
||||
sans: [
|
||||
"Source Han Sans SC", // 思源黑体(优先使用)
|
||||
"-apple-system",
|
||||
"BlinkMacSystemFont",
|
||||
"Segoe UI",
|
||||
|
||||
Reference in New Issue
Block a user