diff --git a/app/api/auth/user-routes.ts b/app/api/auth/user-routes.ts index f450bdc..7bff9ec 100644 --- a/app/api/auth/user-routes.ts +++ b/app/api/auth/user-routes.ts @@ -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 = { ] }; +/** + * 权限映射表类型 + * key: 路由路径 (如 '/prompts', '/documents') + * value: 该路由下的权限列表 + */ +export type PermissionMap = Map; + +/** + * 从路由树中提取权限映射表 + * @param routes 路由树 + * @param aggregateChildren 是否聚合子路由权限到父路由(默认true) + * @returns 权限映射表 (路径 -> 权限列表) + */ +export function buildPermissionMap(routes: BackendRouteInfo[], aggregateChildren: boolean = true): PermissionMap { + const permissionMap = new Map(); + + /** + * 递归收集路由及其所有子路由的权限 + */ + function collectAllPermissions(route: BackendRouteInfo): string[] { + const allPermissions = new Set(); + + // 添加当前路由的权限 + 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 { + const obj: Record = {}; + map.forEach((value, key) => { + obj[key] = value; + }); + return obj; +} + +/** + * 从对象恢复权限映射表 + */ +export function objectToPermissionMap(obj: Record): PermissionMap { + const map = new Map(); + 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 = { * @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; // ✅ 新增:返回权限映射表 + 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 参数 diff --git a/app/api/document-types/document-types.ts b/app/api/document-types/document-types.ts index 5f0ab50..e3d8c37 100644 --- a/app/api/document-types/document-types.ts +++ b/app/api/document-types/document-types.ts @@ -1,28 +1,31 @@ -import { postgrestGet, postgrestDelete, postgrestPost, postgrestPut, type PostgrestParams } from '../postgrest-client'; +import { apiRequest } from '../axios-client'; import { formatDate } from '../../utils'; +import { getAllEvaluationPointGroups, type RuleGroup } from '../evaluation_points/rule-groups'; // 定义文档类型接口 export interface DocumentType { id: number; name: string; + code?: string | null; description: string | null; - evaluation_point_groups_ids: number[]; // jsonb数组字段 - prompt_config?: { - summary_template?: number; - llm_extract_template?: number; - vlm_extract_template?: number; - evaluation_template?: number; - execution_template?: number; + evaluation_point_groups_ids: number[]; // 评查点分组ID数组 + entry_module_id?: number | null; + entry_module?: { + id: number; + name: string; } | null; + groups?: DocumentTypeGroup[]; + llm_extraction_template_id?: number | null; + vlm_extraction_template_id?: number | null; created_at: string; updated_at: string; - code?: string | null; } // 定义用于UI展示的文档类型接口 export interface DocumentTypeUI { id: number; name: string; + code?: string | null; description: string; groups: DocumentTypeGroup[]; entry_module?: { @@ -31,24 +34,18 @@ export interface DocumentTypeUI { } | null; llm_extraction_template_id?: number | null; vlm_extraction_template_id?: number | null; - evaluation_template_id?: number | null; - summary_template_id?: number | null; created_at: string; updated_at: string; - code?: string | null; } // 文档类型创建接口 export interface DocumentTypeCreateDTO { name: string; description?: string; - group_ids: string[]; + group_ids: number[]; // 改为 number[] 数组 entry_module_id?: number | null; llm_extraction_template_id?: number | null; vlm_extraction_template_id?: number | null; - evaluation_template_id?: number | null; - summary_template_id?: number | null; - code?: string | null; } // 文档类型更新接口 @@ -58,257 +55,50 @@ export interface DocumentTypeUpdateDTO extends DocumentTypeCreateDTO { // 文档类型分组关系 export interface DocumentTypeGroup { - id: string; + id: string | number; name: string; + code?: string; } // 搜索参数 export interface DocumentTypeSearchParams { name?: string; - ruleType?: string; - groupId?: string; + group_id?: number; // 按评查点分组ID筛选 + entry_module_id?: number; // 按入口模块ID筛选 page?: number; pageSize?: number; - documentTypeIds?: number[]; // 文档类型 ID 数组 } - -/** - * 从不同格式的 API 响应中提取数据 - * @param responseData API 响应数据 - * @returns 提取后的数据或 null - */ -function extractApiData(responseData: unknown): T | null { - if (!responseData) return null; - - // 格式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; - } - - // 格式2: 直接是数据对象 - return responseData as T; +// API 响应格式 +interface ApiResponse { + code: number; + msg: string; + data: T; } -/** - * 获取所有评查点分组 - * @param token JWT token (可选) - * @returns 评查点分组列表 - */ -export async function getAllEvaluationPointGroups(token?: string): Promise<{ - data?: DocumentTypeGroup[]; - error?: string; - status?: number; -}> { - try { - const params: PostgrestParams = { - select: 'id, name', - token - }; - - const response = await postgrestGet>('evaluation_point_groups', params); - - if (response.error) { - return { error: response.error, status: response.status }; - } - - // 使用extractApiData提取数据 - const extractedData = extractApiData>(response.data); - - if (!extractedData) { - return { data: [] }; - } - - // 转换为DocumentTypeGroup格式 - const groups: DocumentTypeGroup[] = extractedData.map(item => ({ - id: item.id.toString(), - name: item.name - })); - - return { data: groups }; - } catch (error) { - console.error('获取所有评查点分组失败:', error); - return { error: error instanceof Error ? error.message : '获取所有评查点分组失败' }; - } +// 列表响应数据 +interface ListResponseData { + total: number; + page: number; + page_size: number; + items: DocumentType[]; } -/** - * 获取父级评查分组(pid=0的分组) - * @param token JWT token (可选) - * @returns 父级评查点分组列表 - */ -export async function getParentEvaluationPointGroups(token?: string): Promise<{ - data?: DocumentTypeGroup[]; - error?: string; - status?: number; -}> { - try { - const params: PostgrestParams = { - select: 'id, name', - filter: { - 'pid': 'eq.0' - }, - order: 'id.asc', - token - }; - - const response = await postgrestGet>('evaluation_point_groups', params); - - if (response.error) { - return { error: response.error, status: response.status }; - } - - // 使用extractApiData提取数据 - const extractedData = extractApiData>(response.data); - - if (!extractedData) { - return { data: [] }; - } - - // 转换为DocumentTypeGroup格式 - const groups: DocumentTypeGroup[] = extractedData.map(item => ({ - id: item.id.toString(), - name: item.name - })); - - return { data: groups }; - } catch (error) { - console.error('获取父级评查点分组失败:', error); - return { error: error instanceof Error ? error.message : '获取父级评查点分组失败' }; - } -} - -/** - * 获取所有入口模块 - * @param token JWT token (可选) - * @returns 入口模块列表 - */ -export async function getEntryModules(token?: string): Promise<{ - data?: Array<{ id: number; name: string }>; - error?: string; - status?: number; -}> { - try { - const params: PostgrestParams = { - select: 'id, name', - order: 'id.asc', - token - }; - - const response = await postgrestGet>('entry_modules', params); - - if (response.error) { - return { error: response.error, status: response.status }; - } - - // 使用extractApiData提取数据 - const extractedData = extractApiData>(response.data); - - if (!extractedData) { - return { data: [] }; - } - - return { data: extractedData }; - } catch (error) { - console.error('获取入口模块失败:', error); - return { error: error instanceof Error ? error.message : '获取入口模块失败' }; - } -} - -/** - * 根据ID获取评查点分组信息 - * @param ids 评查点分组ID数组 - * @param token JWT token (可选) - * @returns 评查点分组信息 - */ -export async function getEvaluationPointGroupsByIds(ids: number[] | number, token?: string): Promise<{ - data?: DocumentTypeGroup[]; - error?: string; - status?: number; -}> { - try { - // 确保ids是数组 - if (!ids) { - return { data: [] }; - } - - // 将单个ID转换为数组 - const idsArray = Array.isArray(ids) ? ids : [ids]; - if (idsArray.length === 0) { - return { data: [] }; - } - - // console.log('获取评查点分组,ID类型:', typeof ids, '转换后的ID数组:', idsArray); - - const params: PostgrestParams = { - select: 'id, name', - filter: { - 'id': `in.(${idsArray.join(',')})` - }, - token - }; - - // console.log('获取评查点分组,查询参数:', params); - - const response = await postgrestGet>('evaluation_point_groups', params); - - if (response.error) { - return { error: response.error, status: response.status }; - } - - // 使用extractApiData提取数据 - const extractedData = extractApiData>(response.data); - - if (!extractedData) { - return { data: [] }; - } - - // 转换为DocumentTypeGroup格式 - const groups: DocumentTypeGroup[] = extractedData.map(item => ({ - id: item.id.toString(), - name: item.name - })); - - return { data: groups }; - } catch (error) { - console.error('根据ID获取评查点分组失败:', error); - return { error: error instanceof Error ? error.message : '根据ID获取评查点分组失败' }; - } +// 选项数据 +interface OptionsResponseData { + items: Array<{ id: number; name: string; code?: string }>; } /** * 获取文档类型列表 * @param searchParams 搜索参数 + * @param frontendJWT JWT token * @returns 文档类型列表和总数 */ -export async function getDocumentTypes(searchParams: DocumentTypeSearchParams = {}, frontendJWT?: string): Promise<{ +export async function getDocumentTypes( + searchParams: DocumentTypeSearchParams = {}, + frontendJWT?: string +): Promise<{ data?: { types: DocumentTypeUI[], total: number }; error?: string; status?: number; @@ -317,135 +107,49 @@ export async function getDocumentTypes(searchParams: DocumentTypeSearchParams = const page = searchParams.page || 1; const pageSize = searchParams.pageSize || 10; - // 构建查询参数,使用 PostgREST 的资源嵌入语法来关联查询 - // 使用外键约束名称进行关联:entry_modules!fk_document_types_entry_module - const params: PostgrestParams = { - select: ` - id, - name, - description, - evaluation_point_groups_ids, - entry_module_id, - entry_modules!fk_document_types_entry_module(id, name), - prompt_config, - created_at, - updated_at, - code - `.replace(/\s+/g,' ').trim(), - order: 'updated_at.desc', - headers: { - 'Prefer': 'count=exact' - }, - limit: pageSize, - offset: (page - 1) * pageSize, - filter: {} as Record, - token: frontendJWT + // 构建查询参数 + const params: Record = { + page, + page_size: pageSize, }; - // 添加筛选条件 - const filter: Record = {}; if (searchParams.name) { - filter['name'] = `ilike.%${searchParams.name}%`; + params.name = searchParams.name; } - // 如果有分组ID筛选条件 - if (searchParams.ruleType) { - filter['evaluation_point_groups_ids'] = `cs.[${searchParams.ruleType}]`; + if (searchParams.group_id) { + params.group_id = searchParams.group_id; } - if (searchParams.groupId) { - // 如果groupId存在,则将groupId作为子级评查点分组ID - filter['evaluation_point_groups_ids'] = `cs.[${searchParams.groupId}]`; + if (searchParams.entry_module_id) { + params.entry_module_id = searchParams.entry_module_id; } - // 🔑 根据 documentTypeIds 添加过滤条件 - if (searchParams.documentTypeIds && searchParams.documentTypeIds.length > 0) { - // 直接使用文档类型 ID 数组进行过滤 - const typeIdsStr = searchParams.documentTypeIds.join(','); - filter['id'] = `in.(${typeIdsStr})`; - // console.log('📋 [getDocumentTypes] 根据文档类型 IDs 过滤:', typeIdsStr); - } - - params.filter = filter; - - // console.log('获取文档类型列表,参数:', params); - const response = await postgrestGet<(DocumentType & { - entry_modules: { id: number; name: string } | null; - })[]>('document_types', params); + const response = await apiRequest>( + '/api/v3/document-types', + { + method: 'GET', + headers: frontendJWT ? { 'Authorization': `Bearer ${frontendJWT}` } : undefined, + }, + params + ); if (response.error) { return { error: response.error, status: response.status }; } - // console.log('提取的文档类型数据:', JSON.stringify(response)); - - // 使用extractApiData提取数据 - const extractedData = extractApiData<(DocumentType & { - entry_modules: { id: number; name: string } | null; - })[]>(response.data); - const documentTypes = extractedData || []; - - - // 并发查询所有需要的评查点分组信息 - const allGroupIds = new Set(); - documentTypes.forEach(type => { - if (type.evaluation_point_groups_ids) { - const ids = Array.isArray(type.evaluation_point_groups_ids) - ? type.evaluation_point_groups_ids - : [type.evaluation_point_groups_ids as unknown as number]; - ids.forEach(id => allGroupIds.add(id)); - } - }); - - // 如果有分组ID,查询所有分组信息 - let groupsMap: Map = new Map(); - if (allGroupIds.size > 0) { - const groupsResponse = await getEvaluationPointGroupsByIds(Array.from(allGroupIds), frontendJWT); - if (groupsResponse.data) { - groupsResponse.data.forEach(group => { - groupsMap.set(parseInt(group.id, 10), group); - }); - } + const apiData = response.data?.data; + if (!apiData) { + return { error: '获取文档类型列表失败', status: 500 }; } - // 转换为UI类型,包含entry_module和groups信息 - const uiTypes = documentTypes.map(type => { - // 获取该文档类型关联的分组 - let typeGroups: DocumentTypeGroup[] = []; - if (type.evaluation_point_groups_ids) { - const ids = Array.isArray(type.evaluation_point_groups_ids) - ? type.evaluation_point_groups_ids - : [type.evaluation_point_groups_ids as unknown as number]; - typeGroups = ids.map(id => groupsMap.get(id)).filter(Boolean) as DocumentTypeGroup[]; - } - - return { - ...convertToUIDocumentType({ ...type, groups: typeGroups }), - entry_module: type.entry_modules || null, - groups: typeGroups - }; - }); - - // 获取总数 - let totalCount = 0; - const responseWithHeaders = response as { - data: unknown; - headers: Record - }; - if (responseWithHeaders.headers) { - const rangeHeader = responseWithHeaders.headers['content-range']; - if (rangeHeader) { - const total = rangeHeader.split('/')[1]; - if (total !== '*') { - totalCount = parseInt(total, 10); - } - } - } + // 转换为UI类型 + const uiTypes = apiData.items.map(type => convertToUIDocumentType(type)); return { data: { types: uiTypes, - total: totalCount || uiTypes.length + total: apiData.total } }; } catch (error) { @@ -457,107 +161,16 @@ export async function getDocumentTypes(searchParams: DocumentTypeSearchParams = } } -/** - * 删除文档类型 - * @param id 文档类型ID - * @param frontendJWT JWT token (可选) - * @returns 删除结果 - */ -export async function deleteDocumentType(id: string, frontendJWT?: string): Promise<{ - success?: boolean; - error?: string; - status?: number; -}> { - try { - if (!id) { - return { error: '文档类型ID不能为空', status: 400 }; - } - - // 删除文档类型 - const response = await postgrestDelete( - 'document_types', - { - filter: { - 'id': `eq.${id}` - }, - token: frontendJWT - } - ); - - if (response.error) { - return { error: response.error, status: response.status }; - } - - return { success: true }; - } catch (error) { - console.error('删除文档类型失败:', error); - return { - error: error instanceof Error ? error.message : '删除文档类型失败', - status: 500 - }; - } -} - -/** - * 将API返回的文档类型转换为UI文档类型 - */ -function convertToUIDocumentType(type: DocumentType & { - groups: DocumentTypeGroup[]; - entry_modules?: { id: number; name: string } | null; -}): DocumentTypeUI { - // 提取提示词模板ID,确保安全处理以避免控制台警告 - let llmExtractionTemplateId: number | null = null; - let vlmExtractionTemplateId: number | null = null; - let evaluationTemplateId: number | null = null; - let summaryTemplateId: number | null = null; - - // 安全地获取prompt_config字段 - if (type.prompt_config) { - // 转换为字符串或保持为null - if (type.prompt_config.llm_extract_template !== undefined && type.prompt_config.llm_extract_template !== null) { - llmExtractionTemplateId = type.prompt_config.llm_extract_template; - } - - if (type.prompt_config.vlm_extract_template !== undefined && type.prompt_config.vlm_extract_template !== null) { - vlmExtractionTemplateId = type.prompt_config.vlm_extract_template; - } - - // 注意: 后端字段可能是 evaluation_template 或 execution_template - // 优先使用 evaluation_template,如果不存在则尝试使用 execution_template - if (type.prompt_config.evaluation_template !== undefined && type.prompt_config.evaluation_template !== null) { - evaluationTemplateId = type.prompt_config.evaluation_template; - } else if (type.prompt_config.execution_template !== undefined && type.prompt_config.execution_template !== null) { - evaluationTemplateId = type.prompt_config.execution_template; - } - - if (type.prompt_config.summary_template !== undefined && type.prompt_config.summary_template !== null) { - summaryTemplateId = type.prompt_config.summary_template; - } - } - - return { - id: type.id, - name: type.name, - description: type.description || '', - groups: type.groups || [], - entry_module: type.entry_modules || null, - llm_extraction_template_id: llmExtractionTemplateId, - vlm_extraction_template_id: vlmExtractionTemplateId, - evaluation_template_id: evaluationTemplateId, - summary_template_id: summaryTemplateId, - created_at: formatDate(type.created_at), - updated_at: formatDate(type.updated_at), - code: type.code - }; -} - /** * 获取文档类型详情 * @param id 文档类型ID - * @param frontendJWT JWT token (可选) + * @param frontendJWT JWT token * @returns 文档类型详情 */ -export async function getDocumentType(id: string, frontendJWT?: string): Promise<{ +export async function getDocumentType( + id: string, + frontendJWT?: string +): Promise<{ data?: DocumentTypeUI; error?: string; status?: number; @@ -567,77 +180,24 @@ export async function getDocumentType(id: string, frontendJWT?: string): Promise return { error: '文档类型ID不能为空', status: 400 }; } - const params: PostgrestParams = { - select: ` - id, - name, - description, - evaluation_point_groups_ids, - entry_module_id, - entry_modules!fk_document_types_entry_module(id, name), - prompt_config, - created_at, - updated_at, - code - `.replace(/\s+/g,' ').trim(), - filter: { - 'id': `eq.${id}` - }, - token: frontendJWT - }; + const response = await apiRequest>( + `/api/v3/document-types/${id}`, + { + method: 'GET', + headers: frontendJWT ? { 'Authorization': `Bearer ${frontendJWT}` } : undefined, + } + ); - const response = await postgrestGet<(DocumentType & { - entry_modules: { id: number; name: string } | null; - })[]>('document_types', params); - if (response.error) { return { error: response.error, status: response.status }; } - - // 使用extractApiData提取数据 - const extractedData = extractApiData(response.data); - - if (!extractedData || extractedData.length === 0) { + + const documentType = response.data?.data; + if (!documentType) { return { error: '未找到文档类型', status: 404 }; } - - const documentType = extractedData[0]; - - // 获取关联分组 - let groupIds: number[] = []; - - try { - // 尝试解析evaluation_point_groups_ids - if (typeof documentType.evaluation_point_groups_ids === 'string') { - // 如果是JSON字符串,解析它 - groupIds = JSON.parse(documentType.evaluation_point_groups_ids as unknown as string); - } else if (Array.isArray(documentType.evaluation_point_groups_ids)) { - // 如果已经是数组,直接使用 - groupIds = documentType.evaluation_point_groups_ids; - } else if (documentType.evaluation_point_groups_ids) { - // 其他情况,尝试将其转换为数组 - groupIds = [documentType.evaluation_point_groups_ids as unknown as number]; - } - } catch (error) { - console.error('解析分组ID失败:', error, '原始值:', documentType.evaluation_point_groups_ids); - groupIds = []; - } - - // console.log(`文档类型 ${id} 的分组IDs:`, groupIds); - const groupsResponse = await getEvaluationPointGroupsByIds(groupIds, frontendJWT); - - if (groupsResponse.error) { - return { error: groupsResponse.error, status: 500 }; - } - - // 添加分组信息 - const typeWithGroups = { - ...documentType, - groups: groupsResponse.data || [] - }; - - return { data: convertToUIDocumentType(typeWithGroups) }; + return { data: convertToUIDocumentType(documentType) }; } catch (error) { console.error('获取文档类型详情失败:', error); return { @@ -650,9 +210,13 @@ export async function getDocumentType(id: string, frontendJWT?: string): Promise /** * 创建文档类型 * @param documentType 文档类型数据 + * @param frontendJWT JWT token * @returns 创建结果 */ -export async function createDocumentType(documentType: DocumentTypeCreateDTO, frontendJWT?: string): Promise<{ +export async function createDocumentType( + documentType: DocumentTypeCreateDTO, + frontendJWT?: string +): Promise<{ data?: DocumentTypeUI; error?: string; status?: number; @@ -662,108 +226,41 @@ export async function createDocumentType(documentType: DocumentTypeCreateDTO, fr if (!documentType.name) { return { error: '文档类型名称不能为空', status: 400 }; } - + if (!documentType.group_ids || documentType.group_ids.length === 0) { return { error: '请至少选择一个关联的评查点分组', status: 400 }; } - - // 目前因为关联操作是做单选的,所以传过来的拿第一个值即可。 - const groupId = documentType.group_ids[0]; - if (!groupId || isNaN(parseInt(groupId, 10))) { - return { error: '无效的评查点分组ID', status: 400 }; - } - // const groupIds = parseInt(groupId, 10); // 修改为数组形式 - const groupIds = [parseInt(groupId, 10)]; // 修改为数组形式 - // 构建提示词配置 - 确保所有字段都有明确的设置 - const promptConfig: Record = { - llm_extract_template: null, - vlm_extract_template: null, - // evaluation_template: null, - execution_template: null, - summary_template: null - }; - - // 只有当ID存在且不为空字符串时才设置值 - if (documentType.llm_extraction_template_id) { - const llmId = documentType.llm_extraction_template_id; - if (isNaN(llmId)) { - return { error: '无效的llm抽取提示词模板ID', status: 400 }; - } - promptConfig.llm_extract_template = llmId; - } - - if (documentType.vlm_extraction_template_id) { - const vlmId = documentType.vlm_extraction_template_id; - if (isNaN(vlmId)) { - return { error: '无效的vlm抽取提示词模板ID', status: 400 }; - } - promptConfig.vlm_extract_template = vlmId; - } - - if (documentType.evaluation_template_id) { - const evaluationId = documentType.evaluation_template_id; - if (isNaN(evaluationId)) { - return { error: '无效的评查提示词模板ID', status: 400 }; - } - promptConfig.execution_template = evaluationId; - } - - if (documentType.summary_template_id) { - const summaryId = documentType.summary_template_id; - if (isNaN(summaryId)) { - return { error: '无效的总结提示词模板ID', status: 400 }; - } - promptConfig.summary_template = summaryId; - } - - // 构建API请求数据 - 始终包含prompt_config对象 - const apiDocumentType = { + // 构建请求数据 + const requestData = { name: documentType.name.trim(), description: documentType.description || '', - evaluation_point_groups_ids: groupIds, entry_module_id: documentType.entry_module_id || null, - prompt_config: promptConfig, - // code: documentType.code || null + group_ids: documentType.group_ids, + llm_extraction_template_id: documentType.llm_extraction_template_id || null, + vlm_extraction_template_id: documentType.vlm_extraction_template_id || null, }; - - // console.log('创建文档类型请求数据:', JSON.stringify(apiDocumentType, null, 2)); - // console.log('创建文档类型请求数据:', apiDocumentType); - // if(apiDocumentType){ - // throw new Error('测试错误'); - // } - - // 发送创建请求 - const response = await postgrestPost( - 'document_types', - apiDocumentType, - frontendJWT + + const response = await apiRequest>( + '/api/v3/document-types', + { + method: 'POST', + headers: frontendJWT ? { 'Authorization': `Bearer ${frontendJWT}` } : undefined, + data: requestData, + } ); - + if (response.error) { console.error('创建文档类型API返回错误:', response.error, '状态码:', response.status); return { error: response.error, status: response.status }; } - - // console.log('创建文档类型响应数据:', JSON.stringify(response.data, null, 2)); - - // 处理响应数据 - const newDocumentType = extractApiData(response.data); - + + const newDocumentType = response.data?.data; if (!newDocumentType) { return { error: '创建文档类型失败: 无法获取新创建的数据', status: 500 }; } - - // 获取关联分组信息 - const groupsResponse = await getEvaluationPointGroupsByIds(groupIds, frontendJWT); - // 添加分组信息并转换为UI类型 - const typeWithGroups = { - ...newDocumentType, - groups: groupsResponse.data || [] - }; - - return { data: convertToUIDocumentType(typeWithGroups) }; + return { data: convertToUIDocumentType(newDocumentType) }; } catch (error) { console.error('创建文档类型失败:', error); return { @@ -777,9 +274,14 @@ export async function createDocumentType(documentType: DocumentTypeCreateDTO, fr * 更新文档类型 * @param id 文档类型ID * @param documentType 文档类型数据 + * @param frontendJWT JWT token * @returns 更新结果 */ -export async function updateDocumentType(id: string, documentType: DocumentTypeUpdateDTO, frontendJWT?: string): Promise<{ +export async function updateDocumentType( + id: string, + documentType: DocumentTypeUpdateDTO, + frontendJWT?: string +): Promise<{ data?: DocumentTypeUI; error?: string; status?: number; @@ -789,103 +291,45 @@ export async function updateDocumentType(id: string, documentType: DocumentTypeU if (!id) { return { error: '文档类型ID不能为空', status: 400 }; } - + if (!documentType.name) { return { error: '文档类型名称不能为空', status: 400 }; } - + if (!documentType.group_ids || documentType.group_ids.length === 0) { return { error: '请至少选择一个关联的评查点分组', status: 400 }; } - - // 将分组ID转换为数字数组 - const groupIds = documentType.group_ids.map(id => parseInt(id, 10)); - - // 构建提示词配置 - 始终创建一个包含所有字段的对象,并明确设置值 - const promptConfig: Record = { - llm_extract_template: null, - vlm_extract_template: null, - // evaluation_template: null, - execution_template: null, - summary_template: null - }; - - // 只有当ID存在且不为空字符串时才设置值 - if (documentType.llm_extraction_template_id) { - const llmId = documentType.llm_extraction_template_id; - if (isNaN(llmId)) { - return { error: '无效的llm抽取提示词模板ID', status: 400 }; - } - promptConfig.llm_extract_template = llmId; - } - - if (documentType.vlm_extraction_template_id) { - const vlmId = documentType.vlm_extraction_template_id; - if (isNaN(vlmId)) { - return { error: '无效的vlm抽取提示词模板ID', status: 400 }; - } - promptConfig.vlm_extract_template = vlmId; - } - - if (documentType.evaluation_template_id) { - const evaluationId = documentType.evaluation_template_id; - if (isNaN(evaluationId)) { - return { error: '无效的评查提示词模板ID', status: 400 }; - } - promptConfig.execution_template = evaluationId; - } - - if (documentType.summary_template_id) { - const summaryId = documentType.summary_template_id; - if (isNaN(summaryId)) { - return { error: '无效的总结提示词模板ID', status: 400 }; - } - promptConfig.summary_template = summaryId; - } - - // 构建API请求数据 - 始终包含prompt_config对象 - const apiDocumentType = { + + // 构建请求数据 + const requestData = { name: documentType.name.trim(), description: documentType.description || '', - evaluation_point_groups_ids: groupIds, entry_module_id: documentType.entry_module_id || null, - prompt_config: promptConfig + group_ids: documentType.group_ids, + llm_extraction_template_id: documentType.llm_extraction_template_id || null, + vlm_extraction_template_id: documentType.vlm_extraction_template_id || null, }; - - // console.log('更新文档类型请求数据:', JSON.stringify(apiDocumentType, null, 2)); - // throw new Error('测试错误'); - // 发送更新请求 - const response = await postgrestPut( - 'document_types', - apiDocumentType, - {id}, - frontendJWT + + const response = await apiRequest>( + `/api/v3/document-types/${id}`, + { + method: 'PUT', + headers: frontendJWT ? { 'Authorization': `Bearer ${frontendJWT}` } : undefined, + data: requestData, + } ); - + if (response.error) { console.error('更新文档类型API返回错误:', response.error, '状态码:', response.status); return { error: response.error, status: response.status }; } - - // console.log('更新文档类型响应数据:', JSON.stringify(response.data, null, 2)); - - // 处理响应数据 - const updatedDocumentType = extractApiData(response.data); - + + const updatedDocumentType = response.data?.data; if (!updatedDocumentType) { return { error: '更新文档类型失败: 无法获取更新后的数据', status: 500 }; } - - // 获取关联分组信息 - const groupsResponse = await getEvaluationPointGroupsByIds(groupIds, frontendJWT); - // 添加分组信息并转换为UI类型 - const typeWithGroups = { - ...updatedDocumentType, - groups: groupsResponse.data || [] - }; - - return { data: convertToUIDocumentType(typeWithGroups) }; + return { data: convertToUIDocumentType(updatedDocumentType) }; } catch (error) { console.error('更新文档类型失败:', error); return { @@ -893,4 +337,315 @@ export async function updateDocumentType(id: string, documentType: DocumentTypeU status: 500 }; } -} \ No newline at end of file +} + +/** + * 删除文档类型 + * @param id 文档类型ID + * @param frontendJWT JWT token + * @returns 删除结果 + */ +export async function deleteDocumentType( + id: string, + frontendJWT?: string +): Promise<{ + success?: boolean; + error?: string; + status?: number; +}> { + try { + if (!id) { + return { error: '文档类型ID不能为空', status: 400 }; + } + + const response = await apiRequest>( + `/api/v3/document-types/${id}`, + { + method: 'DELETE', + headers: frontendJWT ? { 'Authorization': `Bearer ${frontendJWT}` } : undefined, + } + ); + + if (response.error) { + return { error: response.error, status: response.status }; + } + + return { success: true }; + } catch (error) { + console.error('删除文档类型失败:', error); + return { + error: error instanceof Error ? error.message : '删除文档类型失败', + status: 500 + }; + } +} + +/** + * 获取入口模块选项 + * @param token JWT token + * @returns 入口模块列表 + */ +export async function getEntryModules( + token?: string +): Promise<{ + data?: Array<{ id: number; name: string }>; + error?: string; + status?: number; +}> { + try { + const response = await apiRequest>( + '/api/v3/document-types/options/entry-modules', + { + method: 'GET', + headers: token ? { 'Authorization': `Bearer ${token}` } : undefined, + } + ); + + if (response.error) { + return { error: response.error, status: response.status }; + } + + const items = response.data?.data?.items; + if (!items) { + return { data: [] }; + } + + return { data: items }; + } catch (error) { + console.error('获取入口模块失败:', error); + return { error: error instanceof Error ? error.message : '获取入口模块失败' }; + } +} + +/** + * 将扁平的分组列表转换为树形结构 + * @param flatList 扁平列表,每个元素包含 id 和 pid + * @returns 树形结构数组 + */ +function buildGroupTree(flatList: any[]): RuleGroup[] { + const map = new Map(); + const roots: RuleGroup[] = []; + + // 第一遍:创建所有节点的映射 + flatList.forEach(item => { + const node: RuleGroup = { + id: item.id.toString(), + pid: item.pid !== null && item.pid !== undefined ? item.pid.toString() : '0', + name: item.name || '', + code: item.code, + is_enabled: item.is_enabled !== undefined ? item.is_enabled : true, + ruleCount: item.ruleCount || item.rule_count || 0, + children: [], + createdAt: item.created_at || item.createdAt, + description: item.description + }; + map.set(item.id, node); + }); + + // 第二遍:建立父子关系 + flatList.forEach(item => { + const node = map.get(item.id); + if (!node) return; + + const pid = item.pid !== null && item.pid !== undefined ? item.pid : 0; + + // pid 为 0 或 null 的是根节点 + if (pid === 0 || pid === '0' || pid === null) { + roots.push(node); + } else { + // 找到父节点并添加到其 children 中 + const parent = map.get(pid); + if (parent) { + if (!parent.children) { + parent.children = []; + } + parent.children.push(node); + } else { + // 如果找不到父节点,作为根节点处理 + roots.push(node); + } + } + }); + + return roots; +} + +/** + * 获取所有评查点分组(使用 FastAPI v3 接口) + * @param token JWT token + * @returns 评查点分组列表(树形结构) + */ +export async function getAllEvaluationPointGroups_ForDocTypes( + token?: string +): Promise<{ + data?: RuleGroup[]; + error?: string; + status?: number; +}> { + // 调用原始方法获取扁平数据 + const result = await getAllEvaluationPointGroups(false, false, token); // 第二个参数改为 false,不自动构建树 + + if (result.error || !result.data) { + return result; + } + + // 构建树形结构 + const treeData = buildGroupTree(result.data); + + return { + data: treeData, + error: result.error + }; +} + +/** + * 获取一级评查点分组(pid=0 的分组) + * @param token JWT token + * @returns 一级评查点分组列表 + */ +export async function getRootEvaluationPointGroups_ForDocTypes( + token?: string +): Promise<{ + data?: RuleGroup[]; + error?: string; + status?: number; +}> { + try { + // 导入 getEvaluationPointGroups 函数 + const { getEvaluationPointGroups } = await import('../evaluation_points/rule-groups'); + + // 获取一级分组(pid='0' 或 pid=null) + const result = await getEvaluationPointGroups( + { + pid: '0', + pageSize: 1000, // 设置较大的页面大小以获取所有一级分组 + }, + token + ); + + if (result.error) { + return { error: result.error, status: result.status }; + } + + return { + data: result.data || [], + error: result.error + }; + } catch (error) { + console.error('获取一级评查点分组失败:', error); + return { error: error instanceof Error ? error.message : '获取一级评查点分组失败' }; + } +} + +/** + * 获取父级评查点分组(pid=0的分组) + * @param token JWT token + * @returns 父级评查点分组列表 + */ +export async function getParentEvaluationPointGroups( + token?: string +): Promise<{ + data?: DocumentTypeGroup[]; + error?: string; + status?: number; +}> { + try { + const response = await apiRequest>( + '/api/v3/document-types/options/evaluation-point-groups', + { + method: 'GET', + headers: token ? { 'Authorization': `Bearer ${token}` } : undefined, + }, + { root_only: true } + ); + + if (response.error) { + return { error: response.error, status: response.status }; + } + + // console.log('文档类型返回的评查点分组父级数据', response.data) + const items = response.data?.data?.items; + if (!items) { + return { data: [] }; + } + + // 转换为DocumentTypeGroup格式 + const groups: DocumentTypeGroup[] = items.map(item => ({ + id: item.id, + name: item.name, + code: item.code + })); + + return { data: groups }; + } catch (error) { + console.error('获取父级评查点分组失败:', error); + return { error: error instanceof Error ? error.message : '获取父级评查点分组失败' }; + } +} + +/** + * 获取提示词模板选项 + * @param templateType 模板类型(LLM_Extraction, VLM_Extraction 等) + * @param token JWT token + * @returns 提示词模板列表 + */ +export async function getPromptTemplateOptions( + templateType?: string, + token?: string +): Promise<{ + data?: Array<{ id: number; template_name: string; template_code: string; template_type: string }>; + error?: string; + status?: number; +}> { + try { + const params: Record = {}; + if (templateType) { + params.template_type = templateType; + } + + const response = await apiRequest>( + '/api/v3/document-types/options/prompt-templates', + { + method: 'GET', + headers: token ? { 'Authorization': `Bearer ${token}` } : undefined, + }, + Object.keys(params).length > 0 ? params : undefined + ); + + if (response.error) { + return { error: response.error, status: response.status }; + } + + const items = response.data?.data?.items; + if (!items) { + return { data: [] }; + } + + return { data: items as any }; + } catch (error) { + console.error('获取提示词模板选项失败:', error); + return { error: error instanceof Error ? error.message : '获取提示词模板选项失败' }; + } +} + +/** + * 将API返回的文档类型转换为UI文档类型 + */ +function convertToUIDocumentType(type: DocumentType): DocumentTypeUI { + return { + id: type.id, + name: type.name, + code: type.code, + description: type.description || '', + groups: type.groups?.map(g => ({ + id: g.id, + name: g.name, + code: g.code + })) || [], + entry_module: type.entry_module || null, + llm_extraction_template_id: type.llm_extraction_template_id, + vlm_extraction_template_id: type.vlm_extraction_template_id, + created_at: formatDate(type.created_at), + updated_at: formatDate(type.updated_at), + }; +} diff --git a/app/api/evaluation_points/rule-groups.ts b/app/api/evaluation_points/rule-groups.ts index 610e4fc..95d87cd 100644 --- a/app/api/evaluation_points/rule-groups.ts +++ b/app/api/evaluation_points/rule-groups.ts @@ -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>>('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>>( - '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 | 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(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 = { - name: trimmedName, - code: trimmedCode, - description: data.description?.trim() || '', - is_enabled: data.is_enabled !== undefined ? data.is_enabled : true - }; - - // 注意:不包含 pid 字段,防止误修改 - - const response = await postgrestPut | RuleGroup, Partial>( - '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(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>>( - '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>('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>('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>('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); diff --git a/app/api/prompts/prompts.ts b/app/api/prompts/prompts.ts index 57b72e6..b03706f 100644 --- a/app/api/prompts/prompts.ts +++ b/app/api/prompts/prompts.ts @@ -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; // 变量定义 + template_abbreviation: string | null; + variables: Record | 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 { + 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(responseData: unknown): T | null { - if (!responseData) return null; - - // 格式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; +function buildRequestOptions(method: 'GET' | 'POST' | 'PUT' | 'DELETE', data?: unknown, jwt?: string) { + const options: { + method: string; + data?: unknown; + headers?: Record; + } = { + method, + }; + + 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, - token: frontendJWT + + // 构建查询参数 + const params: Record = { + page, + page_size: pageSize, }; - + // 添加筛选条件 - const filter: Record = {}; - 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('prompt_templates', params); - + const response = await apiRequest>( + '/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(response.data); - - if (!extractedData) { - console.error('提取数据失败,原始响应:', response.data); + + // 检查响应数据 + if (!response.data) { + console.error('响应数据为空'); return { error: '获取提示词模板数据失败', status: 500 }; } - - // console.log(`成功获取${extractedData.length}条提示词模板数据`); - - // 从响应头中获取总数 - let totalCount = 0; - const responseWithHeaders = response as { data: PromptTemplate[]; headers: Record }; - if(responseWithHeaders.headers){ - const rangeHeader = responseWithHeaders.headers['content-range']; - if(rangeHeader){ - const total = rangeHeader.split('/')[1]; - if(total !== '*'){ - totalCount = parseInt(total, 10); - } - } + + // 解析响应数据 + const apiResponse = response.data; + + // 支持 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<{ @@ -239,28 +261,32 @@ export async function getPromptTemplate(id: string, frontendJWT?: string): Promi if (!id) { 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('prompt_templates', params); - + + const response = await apiRequest>( + `/api/v3/prompt-templates/${id}`, + buildRequestOptions('GET', undefined, frontendJWT) + ); + if (response.error) { return { error: response.error, status: response.status }; } - - const extractedData = extractApiData(response.data); - - if (!extractedData || extractedData.length === 0) { + + 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: 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, userId: number, frontendJWT?: string): Promise<{ @@ -288,59 +314,47 @@ export async function createPromptTemplate(template: Partial, return { error: '模板名称、类型和内容不能为空', status: 400 }; } - // 验证用户ID if (!userId) { return { error: '用户ID不能为空', status: 400 }; } - // 准备变量数据 - let variablesData: Record = {}; - 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 = { + 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>( - 'prompt_templates', - apiTemplate, - frontendJWT + const response = await apiRequest>( + '/api/v3/prompt-templates', + buildRequestOptions('POST', apiTemplate, frontendJWT) ); - + if (response.error) { return { error: response.error, status: response.status }; } - - const extractedData = extractApiData(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, * 更新提示词模板 * @param id 模板ID * @param template 提示词模板数据 - * @param frontendJWT JWT token (可选) + * @param frontendJWT JWT token (可选,已由 axios-client 自动处理) * @returns 更新后的提示词模板 */ export async function updatePromptTemplate(id: string, template: Partial, frontendJWT?: string): Promise<{ @@ -366,81 +380,67 @@ export async function updatePromptTemplate(id: string, template: Partial = {}; - 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 = {}; - + + // 准备API数据(只传需要更新的字段) + const apiTemplate: Record = {}; + if (template.template_name !== undefined) { apiTemplate.template_name = template.template_name; } - - if (template.template_type !== undefined) { - apiTemplate.template_type = template.template_type; - } - - if (template.description !== undefined) { - apiTemplate.description = template.description; - } - - if (template.template_content !== undefined) { - apiTemplate.template_content = template.template_content; - } - - if (template.variables !== undefined) { - apiTemplate.variables = variablesData; - } - - 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_type !== undefined) { + apiTemplate.template_type = template.template_type; + } + + if (template.description !== undefined) { + apiTemplate.description = template.description; + } + + if (template.template_content !== undefined) { + apiTemplate.template_content = template.template_content; + } + if (template.template_abbreviation !== undefined) { apiTemplate.template_abbreviation = template.template_abbreviation; } - // if(apiTemplate){ - // console.log('apiTemplate', apiTemplate); - // throw new Error('测试错误'); - // } + if (template.variables !== undefined) { + apiTemplate.variables = template.variables; + } - const response = await postgrestPut>( - 'prompt_templates', - apiTemplate, - { id }, - frontendJWT + if (template.status !== undefined) { + apiTemplate.status = mapStatusToAPI(template.status); + } + + const response = await apiRequest>( + `/api/v3/prompt-templates/${id}`, + buildRequestOptions('PUT', apiTemplate, frontendJWT) ); - + if (response.error) { return { error: response.error, status: response.status }; } - - const extractedData = extractApiData(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( - 'prompt_templates', - { - filter: { - 'id': `eq.${id}` - }, - token: frontendJWT - } + const response = await apiRequest>( + `/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>('prompt_templates', params); + const response = await apiRequest>( + '/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>(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) { @@ -545,4 +564,105 @@ export async function getPromptTemplateOptions(templateType: string, frontendJWT status: 500 }; } -} \ No newline at end of file +} + +/** + * 获取提示词模板类型列表 + * @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>( + '/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>( + `/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 + }; + } +} diff --git a/app/api/role-permissions/role-permissions.ts b/app/api/role-permissions/role-permissions.ts index bfdf264..bf0c740 100644 --- a/app/api/role-permissions/role-permissions.ts +++ b/app/api/role-permissions/role-permissions.ts @@ -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 { 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 ({ + 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 diff --git a/app/components/ui/Toast.tsx b/app/components/ui/Toast.tsx index d951090..97178eb 100644 --- a/app/components/ui/Toast.tsx +++ b/app/components/ui/Toast.tsx @@ -33,8 +33,8 @@ interface ToastProps { className?: string; } -// 默认自动关闭延迟 -const DEFAULT_AUTO_CLOSE_DELAY = 3000; +// 默认自动关闭延迟(缩短为2秒) +const DEFAULT_AUTO_CLOSE_DELAY = 2000; // 导出样式 export function links() { diff --git a/app/hooks/usePermission.tsx b/app/hooks/usePermission.tsx new file mode 100644 index 0000000..93f8de2 --- /dev/null +++ b/app/hooks/usePermission.tsx @@ -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; // ✅ 新增:权限映射表 + 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 + * + * + * + * ``` + */ +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}; +} diff --git a/app/root.tsx b/app/root.tsx index 9837aa1..0081a25 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -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 = {}; // ✅ 权限映射表 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" } diff --git a/app/routes/_index.tsx b/app/routes/_index.tsx index 4e79559..8cfe0cb 100644 --- a/app/routes/_index.tsx +++ b/app/routes/_index.tsx @@ -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() {
+ {/* 系统设置按钮 - 只在有权限时显示 */} + {loaderData.hasSettingsAccess && ( + + )} {currentDateTime.date} {currentDateTime.time}
{(() => { @@ -340,67 +357,76 @@ export default function Index() {

- 欢迎来到智慧法务平台 -

+ {/* 模块网格区域 */}
{/* 动态渲染入口模块 */} {loaderData.entryModules && loaderData.entryModules.length > 0 ? ( <> - {loaderData.entryModules.map((module) => ( -
handleModuleClick(module)} - onKeyDown={(e) => handleKeyDown(module, e)} - role="button" - tabIndex={0} - aria-label={module.name} - > - {module.name} - {module.name} -
- ))} + {loaderData.entryModules.map((module) => { + // 判断是否为智慧法务大模型,如果是且有交叉评查权限,则在其之前插入交叉评查卡片 + const isLLMModule = module.name === '智慧法务大模型'; - {/* 🔑 交叉评查入口 - 只有有权限的用户才能看到 */} - {loaderData.hasCrossCheckingAccess && ( -
{ - if (e.key === 'Enter' || e.key === ' ') { - handleEnterCrossChecking(); - } - }} - role="button" - tabIndex={0} - aria-label="交叉评查" - > - - 交叉评查 -
- )} + // 🔑 如果是智慧法务大模型且用户没有访问权限,则不渲染该模块 + if (isLLMModule && !loaderData.hasChatLLMAccess) { + return null; + } - {/* 🔑 系统设置入口 - 只有有权限的用户才能看到 */} - {loaderData.hasSettingsAccess && ( -
{ - if (e.key === 'Enter' || e.key === ' ') { - handleEnterSettings(); - } - }} - role="button" - tabIndex={0} - aria-label="系统设置" - > - - 系统设置 -
- )} + return ( + + {/* 在智慧法务大模型之前插入交叉评查入口 */} + {isLLMModule && loaderData.hasCrossCheckingAccess && ( +
{ + if (e.key === 'Enter' || e.key === ' ') { + handleEnterCrossChecking(); + } + }} + role="button" + tabIndex={0} + aria-label="交叉评查" + > + 交叉评查 { + // 如果图片加载失败,使用 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); + } + }} + /> + 交叉评查 +
+ )} + {/* 渲染原有模块 */} +
handleModuleClick(module)} + onKeyDown={(e) => handleKeyDown(module, e)} + role="button" + tabIndex={0} + aria-label={module.name} + > + {module.name} + {module.name} +
+
+ ); + })} ) : (
diff --git a/app/routes/document-types._index.tsx b/app/routes/document-types._index.tsx index f1382fb..a1c75bc 100644 --- a/app/routes/document-types._index.tsx +++ b/app/routes/document-types._index.tsx @@ -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, @@ -51,39 +52,50 @@ export async function loader({ request }: LoaderFunctionArgs) { // 获取用户会话信息 const { getUserSession } = await import("~/api/login/auth.server"); const { frontendJWT } = await getUserSession(request); - + 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(); - // 获取用户角色并判断权限 - 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: "确定要删除该文档类型吗?此操作不会影响关联的评查点分组,但可能会影响使用该类型的文档评查。", @@ -259,7 +293,7 @@ export default function DocumentTypesList() { newParams.set('page', '1'); setSearchParams(newParams); }; - + // 定义表格列配置 const columns = [ { @@ -331,14 +365,19 @@ export default function DocumentTypesList() { key: "operation", width: "150px", render: (_: unknown, record: DocumentTypeUI) => ( - <> - - {hasEditPermission && ( +
+ {canViewType && ( + <> + + + )} + {canDeleteType && ( )} - + {!canViewType && !canDeleteType && ( + - + )} +
) } ]; @@ -364,7 +406,7 @@ export default function DocumentTypesList() {

文档类型管理

- {hasEditPermission && ( + {canCreateType && (
- - {/* 评查提示词模板 */} -
- - - {touchedFields.evaluationTemplate && formErrors?.evaluationTemplate && ( -
{formErrors.evaluationTemplate}
- )} -
选择用于评估此类文档内容的提示词模板
-
- - {/* 总结提示词模板 */} -
- - - {touchedFields.summaryTemplate && formErrors?.summaryTemplate && ( -
{formErrors.summaryTemplate}
- )} -
选择用于生成此类文档摘要的提示词模板
-
{/* 关联评查点分组 */} @@ -689,13 +708,13 @@ export default function DocumentTypeNew() { handleGroupCheckChange(group.id, e.target.checked)} - className="radio-input" + className="checkbox-input" disabled={isReadOnly} />
- {/* 子分组 - 仅展示,不可选 */} - {group.children && group.children.length > 0 && expandedGroups[group.id] && ( - group.children.map((child: RuleGroup) => ( -
- - - {child.name} - 二级分组 - -
- )) + {/* 子分组 - 动态加载并展示 */} + {expandedGroups[group.id] && ( + <> + {loadingChildren[group.id] ? ( +
+ + + 加载中... + +
+ ) : ( + groupChildrenMap[group.id]?.map((child: RuleGroup) => ( +
+ + + {child.name} + 二级分组 + +
+ )) + )} + )} ))} diff --git a/app/routes/documents.list.tsx b/app/routes/documents.list.tsx index 23c7df4..fa08830 100644 --- a/app/routes/documents.list.tsx +++ b/app/routes/documents.list.tsx @@ -1008,14 +1008,18 @@ export default function DocumentsIndex() { })()} -
- - {auditStatusMapping[historyDoc.auditStatus]?.label || '待审核'} -
+ {(() => { + // 处理auditStatus为null或undefined的情况,默认为0(待审核) + const auditStatus = historyDoc.auditStatus != null ? historyDoc.auditStatus : 0; + const statusKey = auditStatus.toString(); + const statusInfo = auditStatusMapping[statusKey] || auditStatusMapping["0"]; + return ( +
+ + {statusInfo.label} +
+ ); + })()} (); 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) => (
- - {hasEditPermission && ( + { canViewModule && + + } + {canDeleteModule && (
- {hasEditPermission && ( + {/* ✅ 仅在有创建权限时显示新建按钮 */} + {canCreateModule && (
@@ -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} />
@@ -317,6 +351,7 @@ export default function EntryModuleNew() { type="default" icon="ri-upload-line" onClick={() => fileInputRef.current?.click()} + disabled={isReadOnly} > {logoPreview ? '更换图片' : '上传图片'} @@ -330,6 +365,7 @@ export default function EntryModuleNew() { accept="image/*" onChange={handleLogoChange} className="hidden" + disabled={isReadOnly} /> {logoPreview && (
@@ -355,13 +391,14 @@ export default function EntryModuleNew() { {AREA_OPTIONS.map(option => ( @@ -379,14 +416,17 @@ export default function EntryModuleNew() { > 取消 - + {/* ✅ 仅在有对应权限时显示保存/创建按钮 */} + {hasEditPermission && ( + + )}
diff --git a/app/routes/prompts._index.tsx b/app/routes/prompts._index.tsx index a0602d0..053103b 100644 --- a/app/routes/prompts._index.tsx +++ b/app/routes/prompts._index.tsx @@ -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(); - // 获取用户角色并判断权限 - 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) => (
{record.status === 'system' ? ( + // 系统预设模板:只能查看,有编辑权限的可以复制 <> - {hasEditPermission && ( + {/* 🔐 复制按钮需要创建权限 */} + {canCreateTemplate && ( - {hasEditPermission && ( + {/* 🔐 有编辑权限显示编辑按钮,否则显示查看按钮 */} + {canEditTemplate ? ( + + ) : canViewTemplate ? ( + + ) : null} + + {/* 🔐 删除按钮需要删除权限 */} + {canDeleteTemplate && ( - )} +
@@ -485,14 +528,7 @@ export default function PromptsIndex() { className="flex-1 min-w-[200px]" /> - - {/* 错误信息 */} - {error && ( -
- {error} -
- )} - + {/* 数据表格 */} { + 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) => { const { name, value } = e.target; @@ -462,22 +476,7 @@ export default function PromptsNew() { )} - - {/* 错误信息 */} - {error && ( -
- -
{error}
-
- )} - - {actionData?.errors?.general && ( -
- -
{actionData.errors.general}
-
- )} - + {/* 查看模式提示 */} {isViewMode && (
diff --git a/app/routes/role-permissions._index.tsx b/app/routes/role-permissions._index.tsx index 5068a6c..635cad2 100644 --- a/app/routes/role-permissions._index.tsx +++ b/app/routes/role-permissions._index.tsx @@ -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([]); const [selectedUserIds, setSelectedUserIds] = useState([]); 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(); 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 } >
+ {/* v3.3: 市级管理员地区过滤提示 */} + {isCityAdmin && currentUserArea && ( +
+ + 市级管理员权限:仅显示 {currentUserArea} 地区的用户 +
+ )} + {/* 搜索框 */}
@@ -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([]); const [roleUsers, setRoleUsers] = useState([]); @@ -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} />
+ + {/* 权限警告模态框 */} + { + setShowPermissionWarning(false); + setPendingRouteChange(null); + }} + title="⚠️ 警告:取消角色权限管理路由" + size="medium" + > +
+

+ 您正在尝试取消勾选 "/role-permissions" 路由权限。 +

+

+ 请注意:如果取消此路由权限,该角色的用户将无法访问角色权限管理页面,这可能导致无法管理系统权限。 +

+

+ 请谨慎操作,确认后需要点击"保存权限"才会生效。 +

+ +
+ + +
+
+
); } diff --git a/app/routes/rule-groups._index.tsx b/app/routes/rule-groups._index.tsx index cf28810..0da8d38 100644 --- a/app/routes/rule-groups._index.tsx +++ b/app/routes/rule-groups._index.tsx @@ -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(); - 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([]); @@ -88,8 +88,13 @@ export default function RuleGroupsIndex() { const [filteredChildrenMap, setFilteredChildrenMap] = useState>({}); const [initialLoading, setInitialLoading] = useState(true); const [selectedIds, setSelectedIds] = useState([]); // 🆕 批量选择状态 - 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: ( navigate(`/rule-groups/new?id=${record.id}`)} className="operation-btn" > - {hasEditPermission ? '编辑' : '查看'} + {canUpdateGroup ? '编辑' : '查看'} - {hasEditPermission && ( + {canDeleteGroup && ( - {hasEditPermission && selectedIds.length > 0 && ( + {/* ✅ 批量启用/禁用按钮:仅当有更新权限且有选中项时显示 */} + {canUpdateGroup && selectedIds.length > 0 && ( <> - )} - {hasEditPermission && ( + {/* ✅ 批量删除按钮:仅当有删除权限且有选中项时显示 */} + {canDeleteGroup && selectedIds.length > 0 && ( + + )} + {canCreateGroup && (
- - {!isReadOnly && ( - - + {/* ✅ 复制按钮 - 有创建权限时显示 */} + {canCreateRule && ( + + )} - ) : ( - // 普通用户只能查看 - - 查看 - + )} + {/* ✅ 删除按钮 - 只需要删除权限 */} + {canDeleteRule && ( + + )} + {/* 如果什么权限都没有,显示 - */} + {!canViewRule && !canDeleteRule && ( + - )}
) @@ -805,8 +827,8 @@ export default function RulesIndex() { )}
- {/* 批量操作按钮(仅在有选择时显示) */} - {isDeveloper && selectedIds.length > 0 && ( + {/* ✅ 批量操作按钮(有批量权限且有选择时显示) */} + {canBatchRule && selectedIds.length > 0 && ( <> - + {canDeleteRule && ( + + )} )} - {/* 新增按钮 */} - {isDeveloper && ( + {/* ✅ 新增按钮 - 有创建权限时显示 */} + {canCreateRule && ( diff --git a/app/routes/rules.new.tsx b/app/routes/rules.new.tsx index 811dacd..460faf4 100644 --- a/app/routes/rules.new.tsx +++ b/app/routes/rules.new.tsx @@ -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('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(''); const [formData, setFormData] = useState({}); const [evaluationPointGroups, setEvaluationPointGroups] = useState([]); - // 判断表单是否为只读模式 - const isReadOnly = userRole === 'common'; - // 添加用于共享的字段数据状态 const [extractionFields, setExtractionFields] = useState([]); // VLM字段类型选项 const [vlmFieldTypeOptions, setVlmFieldTypeOptions] = useState>([]); + // ✅ 页面加载时检查权限并提示(仅在只读模式下提示) + 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. 验证评查点名称 diff --git a/app/styles/fonts/source-han-sans.css b/app/styles/fonts/source-han-sans.css new file mode 100644 index 0000000..9de70c4 --- /dev/null +++ b/app/styles/fonts/source-han-sans.css @@ -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) + */ diff --git a/app/styles/pages/document-types_new.css b/app/styles/pages/document-types_new.css index 6cc2a43..eacd8c4 100644 --- a/app/styles/pages/document-types_new.css +++ b/app/styles/pages/document-types_new.css @@ -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 { diff --git a/app/styles/pages/home.css b/app/styles/pages/home.css index e0feb56..a94ba1a 100644 --- a/app/styles/pages/home.css +++ b/app/styles/pages/home.css @@ -75,6 +75,7 @@ /* 主要内容区域 */ .index-main-content { + position: relative; /* 为绝对定位的管理入口提供定位上下文 */ height: 100%; flex: 1; display: flex; @@ -113,17 +114,19 @@ margin: 0; 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; } } \ No newline at end of file diff --git a/public/fonts/source-han-sans/README.md b/public/fonts/source-han-sans/README.md new file mode 100644 index 0000000..b9783e1 --- /dev/null +++ b/public/fonts/source-han-sans/README.md @@ -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 diff --git a/public/fonts/source-han-sans/SourceHanSansSC-Bold.otf b/public/fonts/source-han-sans/SourceHanSansSC-Bold.otf new file mode 100644 index 0000000..49a623f Binary files /dev/null and b/public/fonts/source-han-sans/SourceHanSansSC-Bold.otf differ diff --git a/public/fonts/source-han-sans/SourceHanSansSC-Medium.otf b/public/fonts/source-han-sans/SourceHanSansSC-Medium.otf new file mode 100644 index 0000000..d870b35 Binary files /dev/null and b/public/fonts/source-han-sans/SourceHanSansSC-Medium.otf differ diff --git a/public/fonts/source-han-sans/SourceHanSansSC-Regular.otf b/public/fonts/source-han-sans/SourceHanSansSC-Regular.otf new file mode 100644 index 0000000..5e66059 Binary files /dev/null and b/public/fonts/source-han-sans/SourceHanSansSC-Regular.otf differ diff --git a/tailwind.config.ts b/tailwind.config.ts index f654b4d..ba4d267 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -25,6 +25,7 @@ export default { }, fontFamily: { sans: [ + "Source Han Sans SC", // 思源黑体(优先使用) "-apple-system", "BlinkMacSystemFont", "Segoe UI",