Merge branch 'PingChuan' into shiy-login
This commit is contained in:
@@ -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<CollaboraViewerHandle, CollaboraViewer
|
||||
}), [unoCommands, isDocumentLoaded, mode]);
|
||||
|
||||
// 5. 监听 targetPage 和 highlightText 变化,自动跳转并高亮
|
||||
// 使用 UNO 命令实现高亮,不再使用 Python 脚本
|
||||
useEffect(() => {
|
||||
// 如果文档未加载完成,不执行跳转和高亮
|
||||
if (!isDocumentLoaded || !iframeRef.current?.contentWindow) {
|
||||
@@ -193,27 +195,32 @@ export const CollaboraViewer = forwardRef<CollaboraViewerHandle, CollaboraViewer
|
||||
// 如果有高亮文本,执行高亮操作
|
||||
if (highlightText && highlightText.trim() !== '') {
|
||||
const performHighlight = async () => {
|
||||
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<CollaboraViewerHandle, CollaboraViewer
|
||||
}, [searchText, searchReplacePageNumber, isDocumentLoaded]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// 9. 当搜索参数更新完成后,自动执行替换(静默模式)
|
||||
// 不跳转页面,直接在当前位置搜索并替换,替换后保留在替换位置
|
||||
useEffect(() => {
|
||||
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<CollaboraViewerHandle, CollaboraViewer
|
||||
}
|
||||
|
||||
try {
|
||||
const pageNumber = parseInt(searchReplacePageNumber, 10);
|
||||
|
||||
// 步骤1:跳转到指定页
|
||||
console.log(`[CollaboraViewer] 步骤1:跳转到第 ${pageNumber} 页`);
|
||||
await customGotoPage(iframeRef.current.contentWindow, pageNumber);
|
||||
|
||||
// 等待页面渲染
|
||||
await new Promise(resolve => 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<CollaboraViewerHandle, CollaboraViewer
|
||||
|
||||
return () => 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) {
|
||||
|
||||
@@ -304,3 +304,66 @@ export async function replaceTextInPage(
|
||||
function delay(ms: number): Promise<void> {
|
||||
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 || '当前选中');
|
||||
}
|
||||
|
||||
@@ -41,6 +41,8 @@ export {
|
||||
unoSearchAndReplace,
|
||||
unoCancelSearch,
|
||||
replaceTextInPage,
|
||||
unoHighlightText,
|
||||
unoClearHighlight,
|
||||
SearchCommand,
|
||||
type SearchOptions,
|
||||
type ReplaceOptions,
|
||||
|
||||
@@ -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<Record<string, string>>(values);
|
||||
const [replacingFields, setReplacingFields] = useState<Set<string>>(new Set());
|
||||
// 【新增】记录当前高亮的字段(只存一个值),避免重复高亮导致焦点被抢
|
||||
const [currentHighlightedField, setCurrentHighlightedField] = useState<string | null>(null);
|
||||
|
||||
// 同步外部 values 到本地状态
|
||||
useEffect(() => {
|
||||
@@ -41,11 +43,44 @@ export function PlaceholderForm({
|
||||
onChange(newValues);
|
||||
};
|
||||
|
||||
// 处理字段聚焦(高亮文档中的占位符)
|
||||
const handleFieldFocus = (key: string) => {
|
||||
// 处理字段点击(高亮文档中的占位符)
|
||||
const handleFieldClick = async (e: React.MouseEvent<HTMLInputElement | HTMLTextAreaElement>, 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({
|
||||
<button
|
||||
onClick={handleCompleteClick}
|
||||
disabled={isDeleting}
|
||||
className={`flex items-center justify-center gap-1.5 px-6 py-2 text-sm font-medium rounded-lg transition-all duration-150 ${
|
||||
isDeleting
|
||||
? 'bg-gray-200 text-gray-400 cursor-not-allowed'
|
||||
: 'bg-green-600 text-white hover:bg-green-700 hover:shadow-md active:scale-[0.98]'
|
||||
}`}
|
||||
className={`flex items-center justify-center gap-1.5 px-6 py-2 text-sm font-medium rounded-lg transition-all duration-150 ${isDeleting
|
||||
? 'bg-gray-200 text-gray-400 cursor-not-allowed'
|
||||
: 'bg-green-600 text-white hover:bg-green-700 hover:shadow-md active:scale-[0.98]'
|
||||
}`}
|
||||
>
|
||||
{isDeleting ? (
|
||||
<>
|
||||
@@ -163,7 +197,7 @@ export function PlaceholderForm({
|
||||
<textarea
|
||||
value={localValues[field.key] || ''}
|
||||
onChange={(e) => handleFieldChange(field.key, e.target.value)}
|
||||
onFocus={() => handleFieldFocus(field.key)}
|
||||
onClick={(e) => handleFieldClick(e, field.key)}
|
||||
placeholder={field.placeholder || `请输入${field.label}`}
|
||||
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-all duration-150 resize-none bg-white text-gray-900 placeholder-gray-400 text-sm"
|
||||
rows={3}
|
||||
@@ -173,7 +207,7 @@ export function PlaceholderForm({
|
||||
type={field.type}
|
||||
value={localValues[field.key] || ''}
|
||||
onChange={(e) => handleFieldChange(field.key, e.target.value)}
|
||||
onFocus={() => handleFieldFocus(field.key)}
|
||||
onClick={(e) => handleFieldClick(e, field.key)}
|
||||
placeholder={field.placeholder || `请输入${field.label}`}
|
||||
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-all duration-150 bg-white text-gray-900 placeholder-gray-400 text-sm"
|
||||
/>
|
||||
@@ -183,11 +217,10 @@ export function PlaceholderForm({
|
||||
<button
|
||||
onClick={() => handleSingleReplace(field.key)}
|
||||
disabled={!localValues[field.key] || replacingFields.has(field.key)}
|
||||
className={`px-3 py-2 rounded-lg transition-all duration-150 flex items-center gap-1.5 text-sm font-medium whitespace-nowrap ${
|
||||
!localValues[field.key] || replacingFields.has(field.key)
|
||||
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
||||
: 'bg-primary text-white hover:bg-primary-hover shadow-sm hover:shadow'
|
||||
}`}
|
||||
className={`px-3 py-2 rounded-lg transition-all duration-150 flex items-center gap-1.5 text-sm font-medium whitespace-nowrap ${!localValues[field.key] || replacingFields.has(field.key)
|
||||
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
||||
: 'bg-primary text-white hover:bg-primary-hover shadow-sm hover:shadow'
|
||||
}`}
|
||||
title="替换此占位符"
|
||||
>
|
||||
{replacingFields.has(field.key) ? (
|
||||
|
||||
@@ -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);
|
||||
@@ -255,7 +260,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);
|
||||
@@ -287,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;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -3,22 +3,19 @@
|
||||
* 路由: /contract-draft/:draftId
|
||||
*/
|
||||
|
||||
import type { LoaderFunctionArgs, MetaFunction, ActionFunctionArgs } from '@remix-run/node';
|
||||
import { redirect } from '@remix-run/node';
|
||||
import { useLoaderData, useNavigate, useFetcher } from '@remix-run/react';
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { FilePreview, type FilePreviewHandle } from '~/components/reviews/FilePreview';
|
||||
import { PlaceholderForm } from '~/components/contracts/PlaceholderForm';
|
||||
import { getDraftById, deleteDraft } from '~/api/contracts/draft-service.server';
|
||||
import type { ActionFunctionArgs, LoaderFunctionArgs, MetaFunction } from '@remix-run/node';
|
||||
import { useFetcher, useLoaderData, useNavigate } from '@remix-run/react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { downloadFile } from '~/api/axios-client';
|
||||
import type { ContractTemplate } from '~/api/contract-template/templates';
|
||||
import { extractPlaceholdersFromDocx, generateDefaultSchema } from '~/api/contracts/docx-parser.server';
|
||||
import { getUserSession } from '~/api/login/auth.server';
|
||||
import { deleteFile } from '~/api/storage/minio-client';
|
||||
import { toastService } from '~/components/ui/Toast';
|
||||
import { PlaceholderForm } from '~/components/contracts/PlaceholderForm';
|
||||
import { FilePreview, type FilePreviewHandle } from '~/components/reviews/FilePreview';
|
||||
import { messageService } from '~/components/ui/MessageModal';
|
||||
import { downloadFile } from '~/api/axios-client';
|
||||
import { extractPlaceholdersFromDocx, generateDefaultSchema } from '~/api/contracts/docx-parser.server';
|
||||
import path from 'path';
|
||||
import { toastService } from '~/components/ui/Toast';
|
||||
import type { DraftedContract, PlaceholderSchema } from '~/types/contract-draft';
|
||||
import type { ContractTemplate } from '~/api/contract-template/templates';
|
||||
|
||||
export const meta: MetaFunction<typeof loader> = ({ data }) => {
|
||||
return [
|
||||
@@ -301,10 +298,11 @@ export default function ContractDraftPage() {
|
||||
// 设置高亮值,触发 FilePreview 中的高亮
|
||||
setHighlightValue(placeholder);
|
||||
|
||||
// 短暂延迟后清除高亮,以便下次点击可以重新触发
|
||||
// 延迟清除高亮,给焦点夺回机制充足的时间
|
||||
// PlaceholderForm 会在 150ms 后夺回焦点,所以这里延迟 250ms 清除
|
||||
setTimeout(() => {
|
||||
setHighlightValue(undefined);
|
||||
}, 100);
|
||||
}, 250);
|
||||
};
|
||||
|
||||
// 导出文档(下载当前编辑的文件)- 不再需要手动保存
|
||||
|
||||
@@ -945,10 +945,18 @@ export default function RolePermissions() {
|
||||
}
|
||||
};
|
||||
|
||||
// ==================== 权限状态管理 ====================
|
||||
// 存储原始的、未映射的权限(用于保存时)
|
||||
const [originalRoutePermissionsMap, setOriginalRoutePermissionsMap] = useState<Map<number, ApiPermission[]>>(new Map());
|
||||
const [originalAllPermissions, setOriginalAllPermissions] = useState<ApiPermission[]>([]);
|
||||
|
||||
// 选择角色
|
||||
const handleSelectRole = async (role: RoleInfo) => {
|
||||
setSelectedRole(role);
|
||||
|
||||
// 动态导入权限映射工具
|
||||
const { mapPermissions } = await import('~/utils/permission-mapper');
|
||||
|
||||
// v3.0: 并行加载数据
|
||||
const [routesResult, rolePermissions, users] = await Promise.all([
|
||||
getRoleRoutesWithPermissions(role.id),
|
||||
@@ -958,26 +966,49 @@ export default function RolePermissions() {
|
||||
|
||||
const { routes: routesWithPerms, selectedRouteIds: routeIds } = routesResult;
|
||||
|
||||
// 构建 routePermissionsMap:从返回的路由中提取每个路由的可用 permissions
|
||||
const permMap = new Map<number, ApiPermission[]>();
|
||||
const extractPermissions = (routes: RouteInfo[]) => {
|
||||
// 构建原始权限映射(未映射的,用于保存)
|
||||
const originalPermMap = new Map<number, ApiPermission[]>();
|
||||
// 存储所有原始权限的列表
|
||||
const allOriginalPerms: ApiPermission[] = [];
|
||||
const extractOriginalPermissions = (routes: RouteInfo[]) => {
|
||||
routes.forEach(route => {
|
||||
if (route.permissions && route.permissions.length > 0) {
|
||||
permMap.set(route.id, route.permissions);
|
||||
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
|
||||
// 存储原始权限
|
||||
setOriginalRoutePermissionsMap(originalPermMap);
|
||||
setOriginalAllPermissions(allOriginalPerms);
|
||||
|
||||
// 构建映射后的权限映射(用于显示)
|
||||
const displayPermMap = new Map<number, ApiPermission[]>();
|
||||
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);
|
||||
|
||||
// 从 getRolePermissions 结果中提取已分配的权限ID(原始ID)
|
||||
const assignedPermissionIds = rolePermissions.map(p => p.permission_id);
|
||||
|
||||
setRoutePermissionsMap(permMap);
|
||||
// 存储状态
|
||||
setRoutePermissionsMap(displayPermMap); // 用于显示
|
||||
setSelectedRouteIds(routeIds);
|
||||
setSelectedPermissionIds(assignedPermissionIds); // 使用实际已分配的权限ID
|
||||
setSelectedPermissionIds(assignedPermissionIds); // 使用原始权限ID
|
||||
setExpandedRouteIds([]); // 重置展开状态
|
||||
setRoleUsers(users);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* 权限键映射工具
|
||||
* 用于处理数据库权限键与路由装饰器检查权限键不一致的情况
|
||||
*/
|
||||
|
||||
/**
|
||||
* 权限键映射表
|
||||
* key: 数据库中的权限键
|
||||
* value: 前端显示和实际生效的权限键
|
||||
*
|
||||
* 当前配置:不做映射,直接显示数据库权限键
|
||||
* 因为权限已经细分为CRUD操作,不需要合并显示
|
||||
*/
|
||||
const PERMISSION_KEY_MAP: Record<string, string> = {
|
||||
// 知识库绑定相关 - 直接显示数据库权限键,不映射
|
||||
// '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<string, string[]> = {
|
||||
// 'dify:bind:list': ['dify:bind:list'],
|
||||
};
|
||||
|
||||
/**
|
||||
* 将数据库权限键转换为显示权限键
|
||||
* @param permissionKey 数据库中的权限键
|
||||
* @returns 前端显示的权限键(无映射时返回原值)
|
||||
*/
|
||||
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 显示权限键
|
||||
* @returns 对应的数据库权限键列表(无映射时返回原值)
|
||||
*/
|
||||
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];
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换权限对象:将数据库权限对象转换为显示权限对象
|
||||
*/
|
||||
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) {
|
||||
// 这里可以添加自定义的显示名称映射逻辑
|
||||
}
|
||||
|
||||
return {
|
||||
...permission,
|
||||
permission_key: mappedKey,
|
||||
display_name: displayName,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量转换权限列表
|
||||
* @param permissions 权限对象数组
|
||||
* @returns 转换后的权限对象数组(去重)
|
||||
*/
|
||||
export function mapPermissions(permissions: Permission[]): Permission[] {
|
||||
const mappedMap = new Map<string, Permission>();
|
||||
|
||||
permissions.forEach(permission => {
|
||||
const mapped = mapPermission(permission);
|
||||
|
||||
// 如果映射后的权限键已存在,合并(保留第一个)
|
||||
if (!mappedMap.has(mapped.permission_key)) {
|
||||
mappedMap.set(mapped.permission_key, mapped);
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(mappedMap.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据显示的权限ID查找对应的数据库权限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);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查权限是否受映射影响
|
||||
*/
|
||||
export function hasPermissionMapping(permissionKey: string): boolean {
|
||||
return PERMISSION_KEY_MAP[permissionKey] !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取权限映射的说明信息
|
||||
*/
|
||||
export function getPermissionMappingInfo(): string {
|
||||
return `权限映射说明:\n` +
|
||||
`当前未启用权限键映射,直接使用数据库权限键`;
|
||||
}
|
||||
Reference in New Issue
Block a user