From 547633bf38201f3bf1860a5757a1b4ba0bc8f4f7 Mon Sep 17 00:00:00 2001 From: Wenyan Date: Mon, 8 Dec 2025 15:29:31 +0800 Subject: [PATCH 1/7] =?UTF-8?q?feat:=20=E4=BF=AE=E5=A4=8D=E6=9D=83?= =?UTF-8?q?=E9=99=90=E9=94=AE=E4=B8=8D=E5=8C=B9=E9=85=8D=E9=97=AE=E9=A2=98?= =?UTF-8?q?=20-=20=E5=89=8D=E7=AB=AF=E6=9D=83=E9=99=90=E6=98=A0=E5=B0=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题:前端权限列表显示dify:bind:*,但路由实际检查dify:dataset:*和dify:file:* 导致取消勾选后权限控制失效 解决方案: 1. 创建权限映射工具(permission-mapper.ts) - dify:bind:list/create/update/delete → dify:dataset:manage - 自动将数据库权限键映射为实际生效的权限键 2. 修改角色权限管理页面 - 加载角色权限时应用权限键映射 - 渲染权限列表时显示实际生效的权限键 - 保存权限时使用映射后的权限ID 影响范围: - 知识库管理权限(/chat-with-llm/dataset-manager) - 角色权限分配页面(/role-permissions) 验证方式: 取消勾选dify:dataset:manage后,知识库管理接口应返回403 --- app/routes/role-permissions._index.tsx | 25 ++++- app/utils/permission-mapper.ts | 142 +++++++++++++++++++++++++ 2 files changed, 164 insertions(+), 3 deletions(-) create mode 100644 app/utils/permission-mapper.ts diff --git a/app/routes/role-permissions._index.tsx b/app/routes/role-permissions._index.tsx index 9588c6d..96d40a9 100644 --- a/app/routes/role-permissions._index.tsx +++ b/app/routes/role-permissions._index.tsx @@ -949,6 +949,9 @@ export default function RolePermissions() { const handleSelectRole = async (role: RoleInfo) => { setSelectedRole(role); + // 动态导入权限映射工具 + const { mapPermissions, mapPermissionKey, findDbPermissionKeys } = await import('~/utils/permission-mapper'); + // v3.0: 并行加载数据 const [routesResult, rolePermissions, users] = await Promise.all([ getRoleRoutesWithPermissions(role.id), @@ -959,11 +962,14 @@ export default function RolePermissions() { const { routes: routesWithPerms, selectedRouteIds: routeIds } = routesResult; // 构建 routePermissionsMap:从返回的路由中提取每个路由的可用 permissions + // 并应用权限键映射(dify:bind:* -> dify:dataset:*, dify:file:*) const permMap = new Map(); const extractPermissions = (routes: RouteInfo[]) => { routes.forEach(route => { if (route.permissions && route.permissions.length > 0) { - permMap.set(route.id, route.permissions); + // 应用权限键映射 + const mappedPermissions = mapPermissions(route.permissions); + permMap.set(route.id, mappedPermissions); } if (route.children) { extractPermissions(route.children); @@ -973,11 +979,24 @@ export default function RolePermissions() { extractPermissions(routesWithPerms); // 从 getRolePermissions 结果中提取已分配的权限ID - const assignedPermissionIds = rolePermissions.map(p => p.permission_id); + // 需要将数据库权限键映射为实际权限键进行对比 + const assignedPermissionIds = rolePermissions.map(p => { + // 查找该权限对应的所有数据库权限键 + const dbKeys = findDbPermissionKeys(p.permission_key); + // 如果使用映射,找到对应的权限ID + const mappedKey = mapPermissionKey(p.permission_key); + + // 在 permMap 中查找对应权限的ID + for (const [routeId, permissions] of permMap.entries()) { + const foundPerm = permissions.find(perm => perm.permission_key === mappedKey); + if (foundPerm) return foundPerm.id; + } + return p.permission_id; + }); setRoutePermissionsMap(permMap); setSelectedRouteIds(routeIds); - setSelectedPermissionIds(assignedPermissionIds); // 使用实际已分配的权限ID + setSelectedPermissionIds(assignedPermissionIds); // 使用映射后的权限ID setExpandedRouteIds([]); // 重置展开状态 setRoleUsers(users); }; diff --git a/app/utils/permission-mapper.ts b/app/utils/permission-mapper.ts new file mode 100644 index 0000000..ea4ad8b --- /dev/null +++ b/app/utils/permission-mapper.ts @@ -0,0 +1,142 @@ +/** + * 权限键映射工具 + * 用于将数据库中的权限键映射为前端显示的权限键 + * + * 问题背景: + * 数据库中的权限键(dify:bind:*)与路由实际检查的权限键(dify:dataset:*, dify:file:*)不一致 + * 导致前端取消勾选后,后端仍然检查实际权限,权限控制失效 + */ + +/** + * 权限键映射表 + * key: 数据库中的权限键 + * value: 前端显示和实际生效的权限键 + */ +const PERMISSION_KEY_MAP: Record = { + // 知识库绑定相关 - 映射到数据集管理权限 + 'dify:bind:list': 'dify:dataset:manage', + 'dify:bind:create': 'dify:dataset:manage', + 'dify:bind:update': 'dify:dataset:manage', + 'dify:bind:delete': 'dify:dataset:manage', +}; + +/** + * 反向映射表:实际权限键 -> 数据库权限键列表 + * 用于检查某个权限是否被正确配置 + */ +const REVERSE_PERMISSION_MAP: Record = { + 'dify:dataset:manage': [ + 'dify:bind:list', + 'dify:bind:create', + 'dify:bind:update', + 'dify:bind:delete', + ], +}; + +/** + * 将数据库权限键转换为显示权限键 + * @param permissionKey 数据库中的权限键 + * @returns 前端显示的权限键 + * + * @example + * mapPermissionKey('dify:bind:list') // 返回 'dify:dataset:manage' + * mapPermissionKey('dify:file:read') // 返回 'dify:file:read'(无映射) + */ +export function mapPermissionKey(permissionKey: string): string { + return PERMISSION_KEY_MAP[permissionKey] || permissionKey; +} + +/** + * 批量转换权限键列表 + * @param permissionKeys 权限键数组 + * @returns 转换后的权限键数组(去重) + */ +export function mapPermissionKeys(permissionKeys: string[]): string[] { + const mappedKeys = permissionKeys.map(key => mapPermissionKey(key)); + return [...new Set(mappedKeys)]; // 去重 +} + +/** + * 反向查找:根据实际权限键找到对应的数据库权限键列表 + * @param effectivePermissionKey 实际生效的权限键(如 'dify:dataset:manage') + * @returns 对应的数据库权限键列表 + * + * @example + * findDbPermissionKeys('dify:dataset:manage') + * // 返回 ['dify:bind:list', 'dify:bind:create', 'dify:bind:update', 'dify:bind:delete'] + */ +export function findDbPermissionKeys(effectivePermissionKey: string): string[] { + return REVERSE_PERMISSION_MAP[effectivePermissionKey] || [effectivePermissionKey]; +} + +/** + * 转换权限对象:将数据库权限对象转换为显示权限对象 + * @param permission 权限对象(来自数据库) + * @returns 转换后的权限对象 + */ +export interface Permission { + id: number; + permission_key: string; + display_name: string; + api_method?: string; + api_path?: string; + [key: string]: any; +} + +export function mapPermission(permission: Permission): Permission { + const mappedKey = mapPermissionKey(permission.permission_key); + + // 如果权限键被映射,更新显示名称(可选) + let displayName = permission.display_name; + if (mappedKey !== permission.permission_key) { + // 根据映射后的权限键更新显示名称 + if (mappedKey === 'dify:dataset:manage') { + displayName = '知识库管理(查看、创建、编辑、删除)'; + } + } + + return { + ...permission, + permission_key: mappedKey, + display_name: displayName, + }; +} + +/** + * 批量转换权限列表 + * @param permissions 权限对象数组 + * @returns 转换后的权限对象数组(去重) + */ +export function mapPermissions(permissions: Permission[]): Permission[] { + const mappedMap = new Map(); + + permissions.forEach(permission => { + const mapped = mapPermission(permission); + + // 如果映射后的权限键已存在,合并(保留第一个或根据业务逻辑) + if (!mappedMap.has(mapped.permission_key)) { + mappedMap.set(mapped.permission_key, mapped); + } + }); + + return Array.from(mappedMap.values()); +} + +/** + * 检查权限是否受映射影响 + * @param permissionKey 权限键 + * @returns 是否有映射关系 + */ +export function hasPermissionMapping(permissionKey: string): boolean { + return PERMISSION_KEY_MAP[permissionKey] !== undefined; +} + +/** + * 获取权限映射的说明信息 + * @returns 映射关系的说明文本 + */ +export function getPermissionMappingInfo(): string { + return `权限映射说明:\n` + + `- dify:bind:list/create/update/delete → dify:dataset:manage(知识库管理)\n` + + `取消勾选后,对应接口将返回403权限不足错误`; +} From 209f57a5b75c0e73c596f60117a40b2c39bd9a6f Mon Sep 17 00:00:00 2001 From: Wenyan Date: Mon, 8 Dec 2025 15:32:09 +0800 Subject: [PATCH 2/7] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96403=E6=9D=83?= =?UTF-8?q?=E9=99=90=E9=94=99=E8=AF=AF=E6=8F=90=E7=A4=BA\n\n=E5=BD=93?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E6=B2=A1=E6=9C=89dify:dataset:manage?= =?UTF-8?q?=E6=9D=83=E9=99=90=E6=97=B6=EF=BC=8C\n=E6=89=A7=E8=A1=8C?= =?UTF-8?q?=E6=9B=B4=E6=96=B0/=E5=88=A0=E9=99=A4=E6=93=8D=E4=BD=9C?= =?UTF-8?q?=E8=BF=94=E5=9B=9E403=EF=BC=8C\n=E5=89=8D=E7=AB=AF=E6=98=BE?= =?UTF-8?q?=E7=A4=BA=E5=8F=8B=E5=A5=BD=E7=9A=84"=E6=9D=83=E9=99=90?= =?UTF-8?q?=E4=B8=8D=E8=B6=B3"=E6=8F=90=E7=A4=BA=EF=BC=8C\n=E8=80=8C?= =?UTF-8?q?=E4=B8=8D=E6=98=AF=E9=80=9A=E7=94=A8=E7=9A=84"=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E5=A4=B1=E8=B4=A5=EF=BC=8C=E8=AF=B7=E7=A8=8D=E5=90=8E?= =?UTF-8?q?=E9=87=8D=E8=AF=95"\n\n=F0=9F=A4=96=20Generated=20with=20[Claud?= =?UTF-8?q?e=20Code](https://claude.com/claude-code)\n\nCo-Authored-By:=20?= =?UTF-8?q?Claude=20?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/hooks/use-area-dataset-config.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/app/hooks/use-area-dataset-config.ts b/app/hooks/use-area-dataset-config.ts index f964ca1..bb155cf 100644 --- a/app/hooks/use-area-dataset-config.ts +++ b/app/hooks/use-area-dataset-config.ts @@ -255,7 +255,12 @@ export function useAreaDatasetConfig(): UseAreaDatasetConfigReturn { } } catch (error: any) { console.error('更新知识库绑定失败:', error); - message.error('更新失败,请稍后重试'); + // 检查是否为403权限不足错误 + if (error?.response?.status === 403 || error?.status === 403) { + message.error('权限不足:您没有编辑知识库绑定的权限'); + } else { + message.error('更新失败,请稍后重试'); + } return false; } finally { setSubmitLoading(false); From 34029db395b12c81d3e2e539f7f61b91716e28a0 Mon Sep 17 00:00:00 2001 From: Wenyan Date: Mon, 8 Dec 2025 15:35:34 +0800 Subject: [PATCH 3/7] =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=93=8D=E4=BD=9C?= =?UTF-8?q?=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/routes/api.v3.dify.area-datasets.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/routes/api.v3.dify.area-datasets.tsx b/app/routes/api.v3.dify.area-datasets.tsx index 74342b8..b3f7315 100644 --- a/app/routes/api.v3.dify.area-datasets.tsx +++ b/app/routes/api.v3.dify.area-datasets.tsx @@ -1,6 +1,10 @@ /** * GET /api/v3/dify/area-datasets - 获取所有知识库绑定列表(管理员) * POST /api/v3/dify/area-datasets - 创建知识库绑定 + * + * 权限说明: + * GET: @require_permission_v2("dify:dataset:manage") + * POST: @require_permission_v2("dify:dataset:manage") */ import { type LoaderFunctionArgs, json } from '@remix-run/node'; From d3418ef31bcbe0416748950b988344bd63c490a3 Mon Sep 17 00:00:00 2001 From: Wenyan Date: Mon, 8 Dec 2025 15:39:39 +0800 Subject: [PATCH 4/7] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E6=9D=83=E9=99=90?= =?UTF-8?q?=E6=98=A0=E5=B0=84=E7=9A=84=E5=8F=8D=E5=90=91=E4=BF=9D=E5=AD=98?= =?UTF-8?q?=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题:权限映射只在显示层面进行,但保存时还需要反向映射回数据库权限键 解决方案: 1. 分离显示权限和原始权限: - originalRoutePermissionsMap:存储未映射的原始权限(用于保存) - routePermissionsMap:存储映射后的权限(用于显示) - originalAllPermissions:存储所有原始权限的列表 2. 加载角色权限时: - 从API获取角色已分配的权限ID(原始ID) - 直接存储到 selectedPermissionIds - 不做任何映射转换 3. 显示权限列表时: - 从原始权限构建映射后的权限(合并相同的) - 用户看到的就是映射后的权限(如dify:dataset:manage) - 但勾选状态基于原始权限ID 4. 保存权限时: - 直接使用 selectedPermissionIds(原始ID) - 无需反向映射 验证方式: 1. 取消勾选 dify:dataset:manage → 数据库中4个bind权限被DENY → 接口返回403 2. 重新勾选 dify:dataset:manage → 数据库中4个bind权限被GRANT → 接口可访问 --- app/routes/role-permissions._index.tsx | 64 +++++++++++++++----------- app/utils/permission-mapper.ts | 56 ++++++++++++++++++++++ 2 files changed, 94 insertions(+), 26 deletions(-) diff --git a/app/routes/role-permissions._index.tsx b/app/routes/role-permissions._index.tsx index 96d40a9..add48cc 100644 --- a/app/routes/role-permissions._index.tsx +++ b/app/routes/role-permissions._index.tsx @@ -945,12 +945,17 @@ export default function RolePermissions() { } }; + // ==================== 权限状态管理 ==================== + // 存储原始的、未映射的权限(用于保存时) + const [originalRoutePermissionsMap, setOriginalRoutePermissionsMap] = useState>(new Map()); + const [originalAllPermissions, setOriginalAllPermissions] = useState([]); + // 选择角色 const handleSelectRole = async (role: RoleInfo) => { setSelectedRole(role); // 动态导入权限映射工具 - const { mapPermissions, mapPermissionKey, findDbPermissionKeys } = await import('~/utils/permission-mapper'); + const { mapPermissions } = await import('~/utils/permission-mapper'); // v3.0: 并行加载数据 const [routesResult, rolePermissions, users] = await Promise.all([ @@ -961,42 +966,49 @@ export default function RolePermissions() { const { routes: routesWithPerms, selectedRouteIds: routeIds } = routesResult; - // 构建 routePermissionsMap:从返回的路由中提取每个路由的可用 permissions - // 并应用权限键映射(dify:bind:* -> dify:dataset:*, dify:file:*) - const permMap = new Map(); - const extractPermissions = (routes: RouteInfo[]) => { + // 构建原始权限映射(未映射的,用于保存) + const originalPermMap = new Map(); + // 存储所有原始权限的列表 + const allOriginalPerms: ApiPermission[] = []; + const extractOriginalPermissions = (routes: RouteInfo[]) => { routes.forEach(route => { if (route.permissions && route.permissions.length > 0) { - // 应用权限键映射 - const mappedPermissions = mapPermissions(route.permissions); - permMap.set(route.id, mappedPermissions); + originalPermMap.set(route.id, route.permissions); + allOriginalPerms.push(...route.permissions); } if (route.children) { - extractPermissions(route.children); + extractOriginalPermissions(route.children); } }); }; - extractPermissions(routesWithPerms); + extractOriginalPermissions(routesWithPerms); - // 从 getRolePermissions 结果中提取已分配的权限ID - // 需要将数据库权限键映射为实际权限键进行对比 - const assignedPermissionIds = rolePermissions.map(p => { - // 查找该权限对应的所有数据库权限键 - const dbKeys = findDbPermissionKeys(p.permission_key); - // 如果使用映射,找到对应的权限ID - const mappedKey = mapPermissionKey(p.permission_key); + // 存储原始权限 + setOriginalRoutePermissionsMap(originalPermMap); + setOriginalAllPermissions(allOriginalPerms); - // 在 permMap 中查找对应权限的ID - for (const [routeId, permissions] of permMap.entries()) { - const foundPerm = permissions.find(perm => perm.permission_key === mappedKey); - if (foundPerm) return foundPerm.id; - } - return p.permission_id; - }); + // 构建映射后的权限映射(用于显示) + const displayPermMap = new Map(); + const extractDisplayPermissions = (routes: RouteInfo[]) => { + routes.forEach(route => { + if (route.permissions && route.permissions.length > 0) { + const mappedPermissions = mapPermissions(route.permissions); + displayPermMap.set(route.id, mappedPermissions); + } + if (route.children) { + extractDisplayPermissions(route.children); + } + }); + }; + extractDisplayPermissions(routesWithPerms); - setRoutePermissionsMap(permMap); + // 从 getRolePermissions 结果中提取已分配的权限ID(原始ID) + const assignedPermissionIds = rolePermissions.map(p => p.permission_id); + + // 存储状态 + setRoutePermissionsMap(displayPermMap); // 用于显示 setSelectedRouteIds(routeIds); - setSelectedPermissionIds(assignedPermissionIds); // 使用映射后的权限ID + setSelectedPermissionIds(assignedPermissionIds); // 使用原始权限ID setExpandedRouteIds([]); // 重置展开状态 setRoleUsers(users); }; diff --git a/app/utils/permission-mapper.ts b/app/utils/permission-mapper.ts index ea4ad8b..fd49873 100644 --- a/app/utils/permission-mapper.ts +++ b/app/utils/permission-mapper.ts @@ -122,6 +122,62 @@ export function mapPermissions(permissions: Permission[]): Permission[] { return Array.from(mappedMap.values()); } +/** + * 反向转换:将显示权限键转换为数据库权限键列表(用于保存权限) + * @param permissionKey 显示权限键(如 'dify:dataset:manage') + * @returns 对应的数据库权限键列表 + * + * @example + * reverseMapPermissionKey('dify:dataset:manage') + * // 返回 ['dify:bind:list', 'dify:bind:create', 'dify:bind:update', 'dify:bind:delete'] + * + * reverseMapPermissionKey('dify:file:read') + * // 返回 ['dify:file:read'](无映射) + */ +export function reverseMapPermissionKey(permissionKey: string): string[] { + // 查找所有映射到该权限键的原始权限键 + const originalKeys: string[] = []; + for (const [key, mappedKey] of Object.entries(PERMISSION_KEY_MAP)) { + if (mappedKey === permissionKey) { + originalKeys.push(key); + } + } + return originalKeys.length > 0 ? originalKeys : [permissionKey]; +} + +/** + * 根据显示的权限ID查找对应的数据库权限ID(用于保存) + * 这是一个复杂操作,因为前端显示的权限ID是经过映射的 + * + * @param displayPermissionId 前端显示的权限ID(映射后的) + * @param allMappedPermissions 所有映射后的权限列表(从路由获取的) + * @param originalPermissions 原始的权限列表(未映射的) + * @returns 对应的数据库权限ID列表 + */ +export function findDbPermissionIds( + displayPermissionId: number, + allMappedPermissions: Permission[], + originalPermissions: Permission[] +): number[] { + // 查找显示权限对应的显示权限对象 + const displayPerm = allMappedPermissions.find(p => p.id === displayPermissionId); + if (!displayPerm) return []; + + // 将显示权限键反向映射为数据库权限键列表 + const dbKeys = reverseMapPermissionKey(displayPerm.permission_key); + + // 在原始权限列表中查找这些数据库权限键对应的ID + const dbIds: number[] = []; + for (const key of dbKeys) { + const originalPerm = originalPermissions.find(p => p.permission_key === key); + if (originalPerm) { + dbIds.push(originalPerm.id); + } + } + + return dbIds; +} + /** * 检查权限是否受映射影响 * @param permissionKey 权限键 From 32fe0da48a4ddbb7b616c93a10541dfb198859ab Mon Sep 17 00:00:00 2001 From: Wenyan Date: Mon, 8 Dec 2025 15:47:26 +0800 Subject: [PATCH 5/7] =?UTF-8?q?refactor:=20=E7=A7=BB=E9=99=A4dify:dataset:?= =?UTF-8?q?manage=E6=9D=83=E9=99=90=E6=98=A0=E5=B0=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 数据库已经删除了统一的dify:dataset:manage权限(id:103), 保留了细分的CRUD权限: - dify:bind:list (查看) - dify:bind:create (创建) - dify:bind:update (更新) - dify:bind:delete (删除) 修改内容: 1. 从permission-mapper.ts中移除所有权限键映射 2. 现在前端直接显示数据库权限键,不再进行任何转换 3. 权限检查直接使用数据库中的细分权限 优势: - 更灵活的角色权限配置 - 不同角色可以拥有不同的操作组合 - 符合最小权限原则 --- app/utils/permission-mapper.ts | 104 +++++++++------------------------ 1 file changed, 28 insertions(+), 76 deletions(-) diff --git a/app/utils/permission-mapper.ts b/app/utils/permission-mapper.ts index fd49873..7f747b7 100644 --- a/app/utils/permission-mapper.ts +++ b/app/utils/permission-mapper.ts @@ -1,46 +1,36 @@ /** * 权限键映射工具 - * 用于将数据库中的权限键映射为前端显示的权限键 - * - * 问题背景: - * 数据库中的权限键(dify:bind:*)与路由实际检查的权限键(dify:dataset:*, dify:file:*)不一致 - * 导致前端取消勾选后,后端仍然检查实际权限,权限控制失效 + * 用于处理数据库权限键与路由装饰器检查权限键不一致的情况 */ /** * 权限键映射表 * key: 数据库中的权限键 * value: 前端显示和实际生效的权限键 + * + * 当前配置:不做映射,直接显示数据库权限键 + * 因为权限已经细分为CRUD操作,不需要合并显示 */ const PERMISSION_KEY_MAP: Record = { - // 知识库绑定相关 - 映射到数据集管理权限 - 'dify:bind:list': 'dify:dataset:manage', - 'dify:bind:create': 'dify:dataset:manage', - 'dify:bind:update': 'dify:dataset:manage', - 'dify:bind:delete': 'dify:dataset:manage', + // 知识库绑定相关 - 直接显示数据库权限键,不映射 + // 'dify:bind:list': 'dify:bind:list', + // 'dify:bind:create': 'dify:bind:create', + // 'dify:bind:update': 'dify:bind:update', + // 'dify:bind:delete': 'dify:bind:delete', }; /** * 反向映射表:实际权限键 -> 数据库权限键列表 - * 用于检查某个权限是否被正确配置 + * 当前配置:不进行反向映射 */ const REVERSE_PERMISSION_MAP: Record = { - 'dify:dataset:manage': [ - 'dify:bind:list', - 'dify:bind:create', - 'dify:bind:update', - 'dify:bind:delete', - ], + // 'dify:bind:list': ['dify:bind:list'], }; /** * 将数据库权限键转换为显示权限键 * @param permissionKey 数据库中的权限键 - * @returns 前端显示的权限键 - * - * @example - * mapPermissionKey('dify:bind:list') // 返回 'dify:dataset:manage' - * mapPermissionKey('dify:file:read') // 返回 'dify:file:read'(无映射) + * @returns 前端显示的权限键(无映射时返回原值) */ export function mapPermissionKey(permissionKey: string): string { return PERMISSION_KEY_MAP[permissionKey] || permissionKey; @@ -57,22 +47,23 @@ export function mapPermissionKeys(permissionKeys: string[]): string[] { } /** - * 反向查找:根据实际权限键找到对应的数据库权限键列表 - * @param effectivePermissionKey 实际生效的权限键(如 'dify:dataset:manage') - * @returns 对应的数据库权限键列表 - * - * @example - * findDbPermissionKeys('dify:dataset:manage') - * // 返回 ['dify:bind:list', 'dify:bind:create', 'dify:bind:update', 'dify:bind:delete'] + * 反向查找:根据显示权限键找到对应的数据库权限键 + * @param effectivePermissionKey 显示权限键 + * @returns 对应的数据库权限键列表(无映射时返回原值) */ -export function findDbPermissionKeys(effectivePermissionKey: string): string[] { - return REVERSE_PERMISSION_MAP[effectivePermissionKey] || [effectivePermissionKey]; +export function reverseMapPermissionKey(permissionKey: string): string[] { + // 查找所有映射到该权限键的原始权限键 + const originalKeys: string[] = []; + for (const [key, mappedKey] of Object.entries(PERMISSION_KEY_MAP)) { + if (mappedKey === permissionKey) { + originalKeys.push(key); + } + } + return originalKeys.length > 0 ? originalKeys : [permissionKey]; } /** * 转换权限对象:将数据库权限对象转换为显示权限对象 - * @param permission 权限对象(来自数据库) - * @returns 转换后的权限对象 */ export interface Permission { id: number; @@ -86,13 +77,10 @@ export interface Permission { export function mapPermission(permission: Permission): Permission { const mappedKey = mapPermissionKey(permission.permission_key); - // 如果权限键被映射,更新显示名称(可选) + // 如果权限键被映射,更新显示名称(可根据业务逻辑调整) let displayName = permission.display_name; if (mappedKey !== permission.permission_key) { - // 根据映射后的权限键更新显示名称 - if (mappedKey === 'dify:dataset:manage') { - displayName = '知识库管理(查看、创建、编辑、删除)'; - } + // 这里可以添加自定义的显示名称映射逻辑 } return { @@ -113,7 +101,7 @@ export function mapPermissions(permissions: Permission[]): Permission[] { permissions.forEach(permission => { const mapped = mapPermission(permission); - // 如果映射后的权限键已存在,合并(保留第一个或根据业务逻辑) + // 如果映射后的权限键已存在,合并(保留第一个) if (!mappedMap.has(mapped.permission_key)) { mappedMap.set(mapped.permission_key, mapped); } @@ -122,51 +110,19 @@ export function mapPermissions(permissions: Permission[]): Permission[] { return Array.from(mappedMap.values()); } -/** - * 反向转换:将显示权限键转换为数据库权限键列表(用于保存权限) - * @param permissionKey 显示权限键(如 'dify:dataset:manage') - * @returns 对应的数据库权限键列表 - * - * @example - * reverseMapPermissionKey('dify:dataset:manage') - * // 返回 ['dify:bind:list', 'dify:bind:create', 'dify:bind:update', 'dify:bind:delete'] - * - * reverseMapPermissionKey('dify:file:read') - * // 返回 ['dify:file:read'](无映射) - */ -export function reverseMapPermissionKey(permissionKey: string): string[] { - // 查找所有映射到该权限键的原始权限键 - const originalKeys: string[] = []; - for (const [key, mappedKey] of Object.entries(PERMISSION_KEY_MAP)) { - if (mappedKey === permissionKey) { - originalKeys.push(key); - } - } - return originalKeys.length > 0 ? originalKeys : [permissionKey]; -} - /** * 根据显示的权限ID查找对应的数据库权限ID(用于保存) - * 这是一个复杂操作,因为前端显示的权限ID是经过映射的 - * - * @param displayPermissionId 前端显示的权限ID(映射后的) - * @param allMappedPermissions 所有映射后的权限列表(从路由获取的) - * @param originalPermissions 原始的权限列表(未映射的) - * @returns 对应的数据库权限ID列表 */ export function findDbPermissionIds( displayPermissionId: number, allMappedPermissions: Permission[], originalPermissions: Permission[] ): number[] { - // 查找显示权限对应的显示权限对象 const displayPerm = allMappedPermissions.find(p => p.id === displayPermissionId); if (!displayPerm) return []; - // 将显示权限键反向映射为数据库权限键列表 const dbKeys = reverseMapPermissionKey(displayPerm.permission_key); - // 在原始权限列表中查找这些数据库权限键对应的ID const dbIds: number[] = []; for (const key of dbKeys) { const originalPerm = originalPermissions.find(p => p.permission_key === key); @@ -180,8 +136,6 @@ export function findDbPermissionIds( /** * 检查权限是否受映射影响 - * @param permissionKey 权限键 - * @returns 是否有映射关系 */ export function hasPermissionMapping(permissionKey: string): boolean { return PERMISSION_KEY_MAP[permissionKey] !== undefined; @@ -189,10 +143,8 @@ export function hasPermissionMapping(permissionKey: string): boolean { /** * 获取权限映射的说明信息 - * @returns 映射关系的说明文本 */ export function getPermissionMappingInfo(): string { return `权限映射说明:\n` + - `- dify:bind:list/create/update/delete → dify:dataset:manage(知识库管理)\n` + - `取消勾选后,对应接口将返回403权限不足错误`; + `当前未启用权限键映射,直接使用数据库权限键`; } From a4479971a845d31912e34a367ef22a28759e4940 Mon Sep 17 00:00:00 2001 From: Wenyan Date: Mon, 8 Dec 2025 16:12:01 +0800 Subject: [PATCH 6/7] =?UTF-8?q?feat:=20=E7=BB=9F=E4=B8=80403=E9=94=99?= =?UTF-8?q?=E8=AF=AF=E6=8F=90=E7=A4=BA=E4=B8=BA'=E6=97=A0=E6=9D=83?= =?UTF-8?q?=E9=99=90=E6=93=8D=E4=BD=9C'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修改内容: 1. handleCreate: 捕获403错误,显示'无权限操作:您没有创建知识库绑定的权限' 2. handleUpdate: 捕获403错误,显示'无权限操作:您没有编辑知识库绑定的权限' 3. handleDelete: 捕获403错误,显示'无权限操作:您没有删除知识库绑定的权限' 检查逻辑: - error?.response?.status === 403 - error?.status === 403 - error?.code === 403 优势:用户能清楚知道是权限问题,而不是系统错误 --- app/hooks/use-area-dataset-config.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/app/hooks/use-area-dataset-config.ts b/app/hooks/use-area-dataset-config.ts index bb155cf..23b8bfe 100644 --- a/app/hooks/use-area-dataset-config.ts +++ b/app/hooks/use-area-dataset-config.ts @@ -222,7 +222,12 @@ export function useAreaDatasetConfig(): UseAreaDatasetConfigReturn { } } catch (error: any) { console.error('创建知识库绑定失败:', error); - message.error('创建失败,请稍后重试'); + // 检查是否为403权限不足错误 + if (error?.response?.status === 403 || error?.status === 403 || error?.code === 403) { + message.error('无权限操作:您没有创建知识库绑定的权限'); + } else { + message.error('创建失败,请稍后重试'); + } return false; } finally { setSubmitLoading(false); @@ -256,8 +261,8 @@ export function useAreaDatasetConfig(): UseAreaDatasetConfigReturn { } catch (error: any) { console.error('更新知识库绑定失败:', error); // 检查是否为403权限不足错误 - if (error?.response?.status === 403 || error?.status === 403) { - message.error('权限不足:您没有编辑知识库绑定的权限'); + if (error?.response?.status === 403 || error?.status === 403 || error?.code === 403) { + message.error('无权限操作:您没有编辑知识库绑定的权限'); } else { message.error('更新失败,请稍后重试'); } @@ -292,7 +297,12 @@ export function useAreaDatasetConfig(): UseAreaDatasetConfigReturn { } } catch (error: any) { console.error('删除知识库绑定失败:', error); - message.error('删除失败,请稍后重试'); + // 检查是否为403权限不足错误 + if (error?.response?.status === 403 || error?.status === 403 || error?.code === 403) { + message.error('无权限操作:您没有删除知识库绑定的权限'); + } else { + message.error('删除失败,请稍后重试'); + } return false; } }, From e9c89d6d00d09867b92488af7f572fe0a5838d74 Mon Sep 17 00:00:00 2001 From: PingChuan <1259732256@qq.com> Date: Mon, 8 Dec 2025 17:08:56 +0800 Subject: [PATCH 7/7] =?UTF-8?q?feat=EF=BC=9A=E5=AE=8C=E5=96=84=E5=90=88?= =?UTF-8?q?=E5=90=8C=E8=B5=B7=E8=8D=89=E9=A1=B5=E9=9D=A2=E7=82=B9=E5=87=BB?= =?UTF-8?q?=E9=AB=98=E4=BA=AE=E4=BB=A5=E5=8F=8A=E9=A1=B5=E9=9D=A2=E8=B7=B3?= =?UTF-8?q?=E8=BD=AC=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/components/collabora/CollaboraViewer.tsx | 62 +++++++++--------- .../collabora/lib/SearchandReplace.ts | 63 ++++++++++++++++++ app/components/collabora/lib/index.ts | 2 + app/components/contracts/PlaceholderForm.tsx | 65 ++++++++++++++----- app/routes/contract-draft.$draftId.tsx | 26 ++++---- 5 files changed, 155 insertions(+), 63 deletions(-) diff --git a/app/components/collabora/CollaboraViewer.tsx b/app/components/collabora/CollaboraViewer.tsx index 45cb7ba..d9d8c30 100644 --- a/app/components/collabora/CollaboraViewer.tsx +++ b/app/components/collabora/CollaboraViewer.tsx @@ -14,7 +14,6 @@ import { useRef, forwardRef, useImperativeHandle, useState, useEffect } from 're import type { CollaboraViewerProps, CollaboraViewerHandle } from './types'; import { useCollaboraConfig, useDocumentReady, useCollaboraUnoCommands } from './hooks'; import { sendUnoCommand } from './Uno'; -import { highlightText as performTextHighlight } from './lib/Highlightselecttext'; import { clearHighlights } from './lib/ClearHighlight'; import { unoScrollToTop, @@ -25,6 +24,8 @@ import { unoReplaceAll, unoCancelSearch, replaceTextInPage, + unoHighlightText, + unoClearHighlight, type PageInfo, type GotoPageResponse } from './lib'; @@ -184,6 +185,7 @@ export const CollaboraViewer = forwardRef { // 如果文档未加载完成,不执行跳转和高亮 if (!isDocumentLoaded || !iframeRef.current?.contentWindow) { @@ -193,27 +195,32 @@ export const CollaboraViewer = forwardRef { - try { - const iframeWindow = iframeRef.current!.contentWindow!; - const textToHighlight = highlightText.trim(); + const iframeWindow = iframeRef.current!.contentWindow!; + const textToHighlight = highlightText.trim(); - // 🔥 在高亮新内容之前,先清除之前的所有高亮 - // console.log('[CollaboraViewer] 清除旧高亮...'); + try { + // 步骤1:清除之前的所有高亮(调用 Python 脚本) + console.log('[CollaboraViewer] 步骤1:清除旧高亮...'); await clearHighlights(iframeWindow, { color: 16776960, // 黄色 timeout: 5000, }); - // 短暂延迟后执行新高亮,确保清除操作完成 + // 短暂延迟,确保清除操作完成 await new Promise(resolve => setTimeout(resolve, 100)); - // 执行新高亮 - await performTextHighlight( - iframeWindow, - textToHighlight, - { page: targetPage } - ); - // console.log(`[CollaboraViewer] 已高亮文本: "${textToHighlight}"${targetPage ? ` (第${targetPage}页)` : ''}`); + // 步骤2:使用 UNO 命令高亮新文本(搜索 + 设置背景色) + console.log(`[CollaboraViewer] 步骤2:高亮文本 "${textToHighlight}"`); + unoHighlightText(iframeWindow, textToHighlight, 16776960); // 黄色 + + // 短暂延迟,确保高亮操作完成 + await new Promise(resolve => setTimeout(resolve, 100)); + + // 步骤3:取消选中状态(避免高亮后文本仍被选中) + console.log('[CollaboraViewer] 步骤3:取消选中状态...'); + sendUnoCommand(iframeWindow, '.uno:Escape', {}); + + console.log(`[CollaboraViewer] ✓ 高亮完成: "${textToHighlight}"`); } catch (error) { console.error('[CollaboraViewer] 高亮失败:', error); } @@ -319,9 +326,10 @@ export const CollaboraViewer = forwardRef { - if (shouldAutoReplaceRef.current && searchText && replaceText && searchReplacePageNumber && isDocumentLoaded) { - console.log('[CollaboraViewer] 静默替换模式:自动执行替换:', { searchText, replaceText, searchReplacePageNumber }); + if (shouldAutoReplaceRef.current && searchText && replaceText && isDocumentLoaded) { + console.log('[CollaboraViewer] 静默替换模式:自动执行替换:', { searchText, replaceText }); // 重置标志 shouldAutoReplaceRef.current = false; @@ -334,30 +342,18 @@ export const CollaboraViewer = forwardRef setTimeout(resolve, 300)); - - // 步骤2:搜索文本(确保文本被选中) - console.log(`[CollaboraViewer] 步骤2:搜索文本 "${searchText}"`); + // 步骤1:搜索文本(确保文本被选中,会自动跳转到匹配位置) + console.log(`[CollaboraViewer] 步骤1:搜索文本 "${searchText}"`); unoSearchNext(iframeRef.current.contentWindow, searchText); // 等待搜索完成 await new Promise(resolve => setTimeout(resolve, 300)); - // 步骤3:执行替换 - console.log(`[CollaboraViewer] 步骤3:替换为 "${replaceText}"`); + // 步骤2:执行替换(替换后光标保留在当前位置) + console.log(`[CollaboraViewer] 步骤2:替换为 "${replaceText}"`); unoReplaceCurrent(iframeRef.current.contentWindow, searchText, replaceText); console.log('[CollaboraViewer] ✓ 静默替换完成'); - - // 显示成功提示(可选) - // toastService.success(`已替换: "${searchText}" → "${replaceText}"`); } catch (error) { console.error('[CollaboraViewer] 静默替换失败:', error); } @@ -365,7 +361,7 @@ export const CollaboraViewer = forwardRef clearTimeout(timer); } - }, [searchText, replaceText, searchReplacePageNumber, isDocumentLoaded]); // eslint-disable-line react-hooks/exhaustive-deps + }, [searchText, replaceText, isDocumentLoaded]); // eslint-disable-line react-hooks/exhaustive-deps // 加载中状态 if (loading) { diff --git a/app/components/collabora/lib/SearchandReplace.ts b/app/components/collabora/lib/SearchandReplace.ts index c2af74c..17f0843 100644 --- a/app/components/collabora/lib/SearchandReplace.ts +++ b/app/components/collabora/lib/SearchandReplace.ts @@ -304,3 +304,66 @@ export async function replaceTextInPage( function delay(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } + +/** + * 高亮文本(使用 UNO 命令) + * 流程:先搜索选中所有匹配项,再设置背景色 + * + * @param iframeWindow - iframe 的 contentWindow + * @param text - 要高亮的文本 + * @param color - 高亮颜色,默认 16776960 = 黄色 + */ +export function unoHighlightText( + iframeWindow: Window, + text: string, + color: number = 16776960 +): void { + // 1. 查找所有匹配项(FindAll) + sendUnoCommand(iframeWindow, '.uno:ExecuteSearch', { + 'SearchItem.SearchString': { type: 'string', value: text }, + 'SearchItem.Command': { type: 'long', value: 1 }, // 1 = FindAll / Search Next + 'SearchItem.SearchFlags': { type: 'long', value: 0 }, + 'SearchItem.AlgorithmType': { type: 'short', value: 0 }, + 'SearchItem.Backward': { type: 'boolean', value: false }, + 'SearchItem.SearchCaseSensitive': { type: 'boolean', value: false }, + 'Quiet': { type: 'boolean', value: true }, + }); + + // 2. 设置背景色高亮 + sendUnoCommand(iframeWindow, '.uno:BackColor', { + BackColor: { type: 'long', value: color }, + }); + + console.log('[SearchReplace] 高亮文本:', text, '颜色:', color); +} + +/** + * 清除高亮(使用 UNO 命令) + * 通过设置背景色为透明来清除高亮 + * + * @param iframeWindow - iframe 的 contentWindow + * @param text - 要清除高亮的文本(可选,不传则清除当前选中) + */ +export function unoClearHighlight( + iframeWindow: Window, + text?: string +): void { + if (text) { + // 先搜索选中文本 + sendUnoCommand(iframeWindow, '.uno:ExecuteSearch', { + 'SearchItem.SearchString': { type: 'string', value: text }, + 'SearchItem.Command': { type: 'long', value: 1 }, + 'SearchItem.SearchFlags': { type: 'long', value: 0 }, + 'SearchItem.AlgorithmType': { type: 'short', value: 0 }, + 'SearchItem.Backward': { type: 'boolean', value: false }, + 'Quiet': { type: 'boolean', value: true }, + }); + } + + // 设置背景色为透明(-1 表示无背景色) + sendUnoCommand(iframeWindow, '.uno:BackColor', { + BackColor: { type: 'long', value: -1 }, + }); + + console.log('[SearchReplace] 清除高亮:', text || '当前选中'); +} diff --git a/app/components/collabora/lib/index.ts b/app/components/collabora/lib/index.ts index b958682..8a5a60b 100644 --- a/app/components/collabora/lib/index.ts +++ b/app/components/collabora/lib/index.ts @@ -41,6 +41,8 @@ export { unoSearchAndReplace, unoCancelSearch, replaceTextInPage, + unoHighlightText, + unoClearHighlight, SearchCommand, type SearchOptions, type ReplaceOptions, diff --git a/app/components/contracts/PlaceholderForm.tsx b/app/components/contracts/PlaceholderForm.tsx index 07eba70..ed3ba98 100644 --- a/app/components/contracts/PlaceholderForm.tsx +++ b/app/components/contracts/PlaceholderForm.tsx @@ -3,9 +3,9 @@ * 用于合同起草时填写占位符值 */ -import { useState, useEffect } from 'react'; -import type { PlaceholderSchema } from '~/types/contract-draft'; +import { useEffect, useState } from 'react'; import { messageService } from '~/components/ui/MessageModal'; +import type { PlaceholderSchema } from '~/types/contract-draft'; interface PlaceholderFormProps { schema: PlaceholderSchema | null; @@ -28,6 +28,8 @@ export function PlaceholderForm({ }: PlaceholderFormProps) { const [localValues, setLocalValues] = useState>(values); const [replacingFields, setReplacingFields] = useState>(new Set()); + // 【新增】记录当前高亮的字段(只存一个值),避免重复高亮导致焦点被抢 + const [currentHighlightedField, setCurrentHighlightedField] = useState(null); // 同步外部 values 到本地状态 useEffect(() => { @@ -41,11 +43,44 @@ export function PlaceholderForm({ onChange(newValues); }; - // 处理字段聚焦(高亮文档中的占位符) - const handleFieldFocus = (key: string) => { + // 处理字段点击(高亮文档中的占位符) + const handleFieldClick = async (e: React.MouseEvent, key: string) => { + // 1. 检查是否已经高亮当前字段 + if (currentHighlightedField === key) { + console.log(`[PlaceholderForm] 字段 "${key}" 已高亮,跳过高亮操作`); + return; + } + + // 2. 捕获当前输入框 DOM 元素 + const inputElement = e.currentTarget; + + // 3. 更新当前高亮字段(只保存一个) + setCurrentHighlightedField(key); + console.log(`[PlaceholderForm] 切换高亮字段到 "${key}"`); + + // 4. 调用父组件的高亮回调 if (onFieldFocus) { onFieldFocus(key); } + + // 5. 【核心】延迟后强制夺回焦点(UNO 命令会让 iframe 抢焦点) + // 分多次确保焦点回到输入框,防止被 iframe 再次抢走 + const refocusWithRetry = () => { + console.log(`[PlaceholderForm] 夺回焦点到输入框 "${key}"`); + inputElement.focus(); + + // 保持光标在文字最后 + const len = inputElement.value.length; + if ('setSelectionRange' in inputElement) { + inputElement.setSelectionRange(len, len); + } + }; + + // 第一次夺回焦点:150ms(高亮操作完成时) + setTimeout(refocusWithRetry, 150); + + // 第二次确认焦点:300ms(确保没有被再次抢走) + setTimeout(refocusWithRetry, 300); }; // 处理单个字段替换 @@ -125,11 +160,10 @@ export function PlaceholderForm({