Merge branch 'PingChuan' into shiy-temp
This commit is contained in:
@@ -0,0 +1,256 @@
|
||||
import React, { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { Input, Button, Upload, Tooltip, message as antdMessage, Space } from 'antd';
|
||||
import { SendOutlined, StopOutlined, PaperClipOutlined, PictureOutlined } from '@ant-design/icons';
|
||||
import type { VisionFile } from '../../types/dify_chat';
|
||||
import '../../styles/components/chat-with-llm/chat-input.css';
|
||||
|
||||
const { TextArea } = Input;
|
||||
|
||||
interface ChatInputProps {
|
||||
onSendMessage: (message: string, files?: VisionFile[]) => void;
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
onStop?: () => void;
|
||||
isResponding?: boolean;
|
||||
visionConfig?: {
|
||||
enabled: boolean;
|
||||
number_limits?: number;
|
||||
image_file_size_limit?: number;
|
||||
transfer_methods?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 聊天输入组件
|
||||
*/
|
||||
export default function ChatInput({
|
||||
onSendMessage,
|
||||
disabled = false,
|
||||
placeholder = '输入消息...',
|
||||
onStop,
|
||||
isResponding = false,
|
||||
visionConfig,
|
||||
}: ChatInputProps) {
|
||||
const [message, setMessage] = useState('');
|
||||
const [files, setFiles] = useState<VisionFile[]>([]);
|
||||
const textareaRef = useRef<any>(null);
|
||||
const isComposing = useRef(false);
|
||||
|
||||
/**
|
||||
* 提交消息
|
||||
*/
|
||||
const handleSubmit = () => {
|
||||
if (!message.trim() || disabled) return;
|
||||
|
||||
onSendMessage(message, files.length > 0 ? files : undefined);
|
||||
setMessage('');
|
||||
setFiles([]);
|
||||
|
||||
// 聚焦回输入框
|
||||
setTimeout(() => {
|
||||
textareaRef.current?.focus();
|
||||
}, 10);
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理键盘事件
|
||||
*/
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
// 处理输入法状态
|
||||
if (e.nativeEvent.isComposing) {
|
||||
isComposing.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Enter发送,Shift+Enter换行
|
||||
if (e.key === 'Enter' && !e.shiftKey && !isComposing.current) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理输入法结束
|
||||
*/
|
||||
const handleCompositionEnd = () => {
|
||||
isComposing.current = false;
|
||||
};
|
||||
|
||||
/**
|
||||
* 停止响应
|
||||
*/
|
||||
const handleStop = () => {
|
||||
onStop?.();
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理文件上传
|
||||
*/
|
||||
const handleFileUpload = (file: File) => {
|
||||
// 检查文件数量限制
|
||||
if (visionConfig?.number_limits && files.length >= visionConfig.number_limits) {
|
||||
antdMessage.error(`最多只能上传 ${visionConfig.number_limits} 个文件`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查文件大小限制
|
||||
if (visionConfig?.image_file_size_limit) {
|
||||
const limitMB = visionConfig.image_file_size_limit;
|
||||
const fileSizeMB = file.size / (1024 * 1024);
|
||||
if (fileSizeMB > limitMB) {
|
||||
antdMessage.error(`文件大小不能超过 ${limitMB}MB`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 检查文件类型
|
||||
if (!file.type.startsWith('image/')) {
|
||||
antdMessage.error('只支持图片文件');
|
||||
return false;
|
||||
}
|
||||
|
||||
// 创建文件对象
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const newFile: VisionFile = {
|
||||
id: `file-${Date.now()}-${Math.random()}`,
|
||||
type: 'image',
|
||||
transfer_method: 'local_file' as any,
|
||||
url: e.target?.result as string,
|
||||
upload_file_id: '',
|
||||
};
|
||||
|
||||
setFiles(prev => [...prev, newFile]);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
|
||||
return false; // 阻止默认上传行为
|
||||
};
|
||||
|
||||
/**
|
||||
* 移除文件
|
||||
*/
|
||||
const handleRemoveFile = (fileId: string) => {
|
||||
setFiles(prev => prev.filter(file => file.id !== fileId));
|
||||
};
|
||||
|
||||
/**
|
||||
* 渲染文件预览
|
||||
*/
|
||||
const renderFilePreview = () => {
|
||||
if (files.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2 mb-2 p-2 bg-gray-50 rounded">
|
||||
{files.map((file) => (
|
||||
<div key={file.id} className="relative group">
|
||||
<img
|
||||
src={file.url}
|
||||
alt="预览"
|
||||
className="w-16 h-16 object-cover rounded border"
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
className="absolute -top-1 -right-1 bg-red-500 text-white rounded-full w-5 h-5 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={() => handleRemoveFile(file.id!)}
|
||||
>
|
||||
×
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 渲染上传按钮
|
||||
*/
|
||||
const renderUploadButton = () => {
|
||||
if (!visionConfig?.enabled) return null;
|
||||
|
||||
const isDisabled = disabled || (visionConfig.number_limits ? files.length >= visionConfig.number_limits : false);
|
||||
|
||||
return (
|
||||
<Upload
|
||||
beforeUpload={handleFileUpload}
|
||||
showUploadList={false}
|
||||
accept="image/*"
|
||||
multiple={false}
|
||||
>
|
||||
<Tooltip title="上传图片">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<PictureOutlined />}
|
||||
size="small"
|
||||
disabled={isDisabled}
|
||||
className="text-gray-500 hover:text-blue-500"
|
||||
/>
|
||||
</Tooltip>
|
||||
</Upload>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border-t border-gray-200 bg-white p-4">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* 文件预览 */}
|
||||
{renderFilePreview()}
|
||||
|
||||
{/* 输入区域 */}
|
||||
<div className="relative">
|
||||
<div className="flex items-end gap-2">
|
||||
{/* 上传按钮 */}
|
||||
<div className="flex items-end pb-1">
|
||||
{renderUploadButton()}
|
||||
</div>
|
||||
|
||||
{/* 文本输入 */}
|
||||
<div className="flex-1">
|
||||
<TextArea
|
||||
ref={textareaRef}
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onCompositionEnd={handleCompositionEnd}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
autoSize={{ minRows: 1, maxRows: 6 }}
|
||||
className="resize-none"
|
||||
style={{ paddingRight: '50px' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 发送/停止按钮 */}
|
||||
<div className="flex items-end pb-1">
|
||||
{isResponding ? (
|
||||
<Tooltip title="停止生成">
|
||||
<Button
|
||||
type="primary"
|
||||
danger
|
||||
icon={<StopOutlined />}
|
||||
onClick={handleStop}
|
||||
disabled={disabled}
|
||||
size="large"
|
||||
/>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip title="发送消息 (Enter)">
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SendOutlined />}
|
||||
onClick={handleSubmit}
|
||||
disabled={disabled || !message.trim()}
|
||||
size="large"
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 字符计数和提示 */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Button, Tooltip, Avatar, Card, Image, Spin } from 'antd';
|
||||
import { AntDesignOutlined, SmileTwoTone, RobotOutlined } from '@ant-design/icons';
|
||||
import type { ChatItem, Feedbacktype, ThoughtItem, VisionFile } from '../../types/dify_chat';
|
||||
import { CHAT_CONFIG } from '../../config/chat';
|
||||
import Markdown from './markdown';
|
||||
import ThoughtProcess from './thought-process';
|
||||
import { Dayjs } from 'dayjs';
|
||||
import '../../styles/components/chat-with-llm/chat-message.css';
|
||||
|
||||
interface ChatMessageProps {
|
||||
message: ChatItem;
|
||||
onFeedback?: (messageId: string, feedback: Feedbacktype) => void;
|
||||
isResponding?: boolean;
|
||||
onRegenerate?: (messageId: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 聊天消息组件
|
||||
*/
|
||||
export default function ChatMessage({
|
||||
message,
|
||||
onFeedback,
|
||||
isResponding = false,
|
||||
onRegenerate
|
||||
}: ChatMessageProps) {
|
||||
const [feedback, setFeedback] = useState<'like' | 'dislike' | null>(
|
||||
message.feedback?.rating || null
|
||||
);
|
||||
|
||||
const { id, content, isAnswer, agent_thoughts, message_files, isOpeningStatement, suggestedQuestions, more } = message;
|
||||
const isAgentMode = !!agent_thoughts && agent_thoughts.length > 0;
|
||||
|
||||
/**
|
||||
* 处理反馈
|
||||
*/
|
||||
const handleFeedback = async (type: 'like' | 'dislike') => {
|
||||
if (!id || id.startsWith('placeholder-') || id.startsWith('question-') || id.startsWith('opening-'))
|
||||
return;
|
||||
|
||||
// 如果已经选择了相同的反馈,则取消选择
|
||||
const newFeedback = feedback === type ? null : type;
|
||||
setFeedback(newFeedback);
|
||||
|
||||
await onFeedback?.(id, {
|
||||
rating: newFeedback,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 渲染AI回答内容
|
||||
*/
|
||||
const renderAnswerContent = () => {
|
||||
// console.log('🎨 渲染AI回答内容:', { content, isResponding, isAgentMode });
|
||||
|
||||
// 如果正在响应且没有内容
|
||||
if (isResponding && (isAgentMode ? (!content && (agent_thoughts || []).filter(item => !!item.thought || !!item.tool).length === 0) : !content)) {
|
||||
return (
|
||||
<div className="responding-indicator">
|
||||
<Spin size="small" />
|
||||
<span>AI 正在思考...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Agent模式(有思考过程)
|
||||
if (isAgentMode) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{agent_thoughts?.map((thought, index) => (
|
||||
<div key={index}>
|
||||
{thought.thought && (
|
||||
<div className={isResponding && index === agent_thoughts.length - 1 ? 'streaming-text' : ''}>
|
||||
<Markdown content={thought.thought} />
|
||||
</div>
|
||||
)}
|
||||
{thought.tool && (
|
||||
<ThoughtProcess
|
||||
thought={thought}
|
||||
isFinished={!!thought.observation || !isResponding}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 普通模式 - 恢复Markdown渲染
|
||||
return (
|
||||
<div>
|
||||
<div className={isResponding ? 'streaming-text' : ''}>
|
||||
<Markdown content={content} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 渲染建议问题
|
||||
*/
|
||||
const renderSuggestedQuestions = () => {
|
||||
if (!suggestedQuestions || suggestedQuestions.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="suggested-questions">
|
||||
<div className="text-sm text-gray-500 mb-2">建议问题:</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{suggestedQuestions.map((question, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
size="small"
|
||||
type="dashed"
|
||||
className="question-button text-left"
|
||||
onClick={() => {
|
||||
// 这里可以添加点击建议问题的处理逻辑
|
||||
console.log('Suggested question clicked:', question);
|
||||
}}
|
||||
>
|
||||
{question}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 渲染消息元信息
|
||||
*/
|
||||
const renderMessageMeta = () => {
|
||||
if (!more) return null;
|
||||
|
||||
return (
|
||||
<div className="message-timestamp">
|
||||
{more.time && <span>时间: {more.time}</span>}
|
||||
{more.tokens && <span className="ml-2">Token: {more.tokens}</span>}
|
||||
{more.latency && <span className="ml-2">延迟: {more.latency}</span>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 如果是开场白,特殊处理
|
||||
if (isOpeningStatement) {
|
||||
return (
|
||||
<div className="chat-message">
|
||||
<Card className="message-card assistant" size="small">
|
||||
<div className="flex items-start gap-3">
|
||||
{/* <Avatar icon={<RobotOutlined />} className="flex-shrink-0" />
|
||||
*/}
|
||||
<Avatar
|
||||
size={{ xs: 24, sm: 32, md: 40, lg: 64, xl: 80, xxl: 100 }}
|
||||
icon={<RobotOutlined />}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<Markdown content={content} />
|
||||
{renderSuggestedQuestions()}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="chat-message">
|
||||
<div className={`flex ${isAnswer ? 'justify-start' : 'justify-end'} mb-2`}>
|
||||
<div className={`message-card ${isAnswer ? 'assistant' : 'user'}`}>
|
||||
<Card size="small" className="shadow-sm">
|
||||
<div className="flex items-start gap-2">
|
||||
{/* 头像 */}
|
||||
<Avatar
|
||||
icon={isAnswer ? <RobotOutlined /> : <SmileTwoTone />}
|
||||
className="flex-shrink-0"
|
||||
style={{
|
||||
backgroundColor: isAnswer ? '#1890ff' : '#52c41a'
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 消息内容 */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{isAnswer ? renderAnswerContent() : (
|
||||
<div>
|
||||
<Markdown content={content} />
|
||||
{/* {renderImages(message_files)} */}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 建议问题 */}
|
||||
{/* {isAnswer && renderSuggestedQuestions()} */}
|
||||
|
||||
{/* 消息元信息 */}
|
||||
{/* {renderMessageMeta()} */}
|
||||
|
||||
{/* 反馈按钮 */}
|
||||
{/* {isAnswer && (
|
||||
<div className="feedback-buttons">
|
||||
{renderFeedbackButtons()}
|
||||
</div>
|
||||
)} */}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,571 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { produce } from 'immer';
|
||||
import { useBoolean, useGetState } from 'ahooks';
|
||||
import { Layout, theme } from 'antd';
|
||||
import ChatMessage from './chat-message';
|
||||
import ChatInput from './chat-input';
|
||||
import ChatSidebar, { type ChatSidebarRef } from './sidebar';
|
||||
// import Header from '../layout/Header';
|
||||
import useConversation from '../../hooks/use-conversation';
|
||||
import useChatMessage from '../../hooks/use-chat-message';
|
||||
import type { ChatItem, ConversationItem } from '../../types/dify_chat';
|
||||
import { CHAT_CONFIG } from '../../config/chat';
|
||||
import { fetchConversations, fetchAppParams, fetchChatList } from '../../services/api.client';
|
||||
import '../../styles/components/chat-with-llm/index.css';
|
||||
|
||||
const { Content } = Layout;
|
||||
|
||||
/**
|
||||
* 主聊天组件
|
||||
* 实现单页面应用模式,参考webapp-conversation的初始化逻辑
|
||||
*/
|
||||
export default function Chat() {
|
||||
// 侧边栏状态
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
const {
|
||||
token: { colorBgContainer, borderRadiusLG },
|
||||
} = theme.useToken();
|
||||
|
||||
// 会话管理
|
||||
const {
|
||||
conversationList,
|
||||
setConversationList,
|
||||
currConversationId,
|
||||
getCurrConversationId,
|
||||
setCurrConversationId,
|
||||
getConversationIdFromStorage,
|
||||
isNewConversation,
|
||||
currInputs,
|
||||
newConversationInputs,
|
||||
resetNewConversationInputs,
|
||||
setCurrInputs,
|
||||
currConversationInfo,
|
||||
setNewConversationInfo,
|
||||
setExistConversationInfo,
|
||||
addConversationToList,
|
||||
updateConversationInList,
|
||||
removeConversationFromList,
|
||||
} = useConversation();
|
||||
|
||||
// 消息管理
|
||||
const {
|
||||
chatList,
|
||||
setChatList,
|
||||
getChatList,
|
||||
isResponding,
|
||||
handleSend,
|
||||
stopResponding,
|
||||
handleFeedback,
|
||||
} = useChatMessage({
|
||||
onUpdateConversationList: updateConversationInList,
|
||||
onConversationIdChange: async (conversationId: string) => {
|
||||
console.log('🔄 收到会话ID变更通知:', conversationId);
|
||||
|
||||
// 设置当前会话ID(这会触发localStorage更新)
|
||||
setCurrConversationId(conversationId, CHAT_CONFIG.APP_ID);
|
||||
|
||||
// 如果是新会话,添加到会话列表
|
||||
const existingConversation = conversationList.find(item => item.id === conversationId);
|
||||
if (!existingConversation) {
|
||||
console.log('🆕 添加新会话到列表:', conversationId);
|
||||
const newConversation = {
|
||||
id: conversationId,
|
||||
name: '新对话',
|
||||
inputs: currInputs || {},
|
||||
introduction: '',
|
||||
};
|
||||
addConversationToList(newConversation);
|
||||
|
||||
// 检查是否需要自动重命名(新对话的第一条消息)
|
||||
if (!newConversationFirstMessageSent.has(conversationId)) {
|
||||
console.log('🏷️ 新对话第一条消息,准备自动重命名:', conversationId);
|
||||
|
||||
// 标记该对话已发送第一条消息
|
||||
setNewConversationFirstMessageSent(prev => new Set(prev).add(conversationId));
|
||||
|
||||
// 延迟一下确保组件状态已更新
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
if (sidebarRef.current) {
|
||||
await sidebarRef.current.autoRename(conversationId);
|
||||
console.log('✅ 新对话自动重命名完成:', conversationId);
|
||||
} else {
|
||||
console.warn('⚠️ 侧边栏引用不可用,无法自动重命名');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ 新对话自动重命名失败:', error);
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// 应用状态
|
||||
const [appUnavailable, setAppUnavailable] = useState<boolean>(false);
|
||||
const [isUnknownReason, setIsUnknownReason] = useState<boolean>(false);
|
||||
const [inited, setInited] = useState<boolean>(false);
|
||||
const [promptConfig, setPromptConfig] = useState<any>(null);
|
||||
|
||||
// 会话状态管理
|
||||
const [conversationIdChangeBecauseOfNew, setConversationIdChangeBecauseOfNew, getConversationIdChangeBecauseOfNew] = useGetState(false);
|
||||
const [isChatStarted, { setTrue: setChatStarted, setFalse: setChatNotStarted }] = useBoolean(false);
|
||||
|
||||
// 聊天列表容器引用
|
||||
const chatListDomRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 侧边栏组件引用
|
||||
const sidebarRef = useRef<ChatSidebarRef>(null);
|
||||
|
||||
// 跟踪新对话是否已发送第一条消息
|
||||
const [newConversationFirstMessageSent, setNewConversationFirstMessageSent] = useState<Set<string>>(new Set());
|
||||
|
||||
// 检查应用配置
|
||||
const hasSetAppConfig = CHAT_CONFIG.APP_ID && CHAT_CONFIG.API_KEY;
|
||||
|
||||
/**
|
||||
* 处理开始聊天
|
||||
*/
|
||||
const handleStartChat = (inputs: Record<string, any>) => {
|
||||
createNewChat();
|
||||
setConversationIdChangeBecauseOfNew(true);
|
||||
setCurrInputs(inputs);
|
||||
setChatStarted();
|
||||
// 解析变量并生成开场白
|
||||
setChatList(generateNewChatListWithOpenStatement('', inputs));
|
||||
};
|
||||
|
||||
/**
|
||||
* 创建新聊天
|
||||
*/
|
||||
const createNewChat = () => {
|
||||
setChatList([]);
|
||||
setChatNotStarted();
|
||||
resetNewConversationInputs();
|
||||
};
|
||||
|
||||
/**
|
||||
* 生成带开场白的新聊天列表
|
||||
*/
|
||||
const generateNewChatListWithOpenStatement = (introduction?: string, inputs?: Record<string, any> | null): ChatItem[] => {
|
||||
const newChatList: ChatItem[] = [];
|
||||
|
||||
if (introduction) {
|
||||
newChatList.push({
|
||||
id: `opening-statement-${Date.now()}`,
|
||||
content: introduction,
|
||||
isAnswer: true,
|
||||
isOpeningStatement: true,
|
||||
});
|
||||
}
|
||||
|
||||
return newChatList;
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理会话切换
|
||||
*/
|
||||
const handleConversationSwitch = async () => {
|
||||
if (!inited) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('🔄 处理会话切换:', { currConversationId, isNewConversation });
|
||||
|
||||
// 更新当前会话的输入
|
||||
let notSyncToStateIntroduction = '';
|
||||
let notSyncToStateInputs: Record<string, any> | undefined | null = {};
|
||||
|
||||
if (!isNewConversation) {
|
||||
const item = conversationList.find(item => item.id === currConversationId);
|
||||
notSyncToStateInputs = item?.inputs || {};
|
||||
setCurrInputs(notSyncToStateInputs as any);
|
||||
notSyncToStateIntroduction = item?.introduction || '';
|
||||
setExistConversationInfo({
|
||||
name: item?.name || '',
|
||||
introduction: notSyncToStateIntroduction,
|
||||
});
|
||||
} else {
|
||||
notSyncToStateInputs = newConversationInputs;
|
||||
setCurrInputs(notSyncToStateInputs);
|
||||
}
|
||||
|
||||
// 更新当前会话的聊天列表
|
||||
if (!isNewConversation && !conversationIdChangeBecauseOfNew && !isResponding) {
|
||||
try {
|
||||
console.log('📨 获取会话历史消息:', currConversationId);
|
||||
|
||||
// 调用API获取历史消息
|
||||
const response = await fetchChatList(currConversationId);
|
||||
console.log('📋 历史消息响应:', response);
|
||||
|
||||
if (response && (response as any).data) {
|
||||
const { data: historyMessages } = response as any;
|
||||
|
||||
// 生成新的聊天列表,包含开场白
|
||||
const newChatList: ChatItem[] = generateNewChatListWithOpenStatement(notSyncToStateIntroduction, notSyncToStateInputs);
|
||||
|
||||
// 添加历史消息
|
||||
historyMessages.forEach((item: any) => {
|
||||
// 添加用户问题
|
||||
newChatList.push({
|
||||
id: `question-${item.id}`,
|
||||
content: item.query,
|
||||
isAnswer: false,
|
||||
message_files: item.message_files?.filter((file: any) => file.belongs_to === 'user') || [],
|
||||
});
|
||||
|
||||
// 添加AI回答
|
||||
newChatList.push({
|
||||
id: item.id,
|
||||
content: item.answer,
|
||||
agent_thoughts: item.agent_thoughts || [],
|
||||
feedback: item.feedback,
|
||||
isAnswer: true,
|
||||
message_files: item.message_files?.filter((file: any) => file.belongs_to === 'assistant') || [],
|
||||
});
|
||||
});
|
||||
|
||||
console.log('✅ 设置历史聊天列表:', newChatList.length, '条消息');
|
||||
setChatList(newChatList);
|
||||
} else {
|
||||
console.warn('⚠️ 获取历史消息失败或无数据');
|
||||
// 如果获取失败,至少显示开场白
|
||||
const newChatList: ChatItem[] = generateNewChatListWithOpenStatement(notSyncToStateIntroduction, notSyncToStateInputs);
|
||||
setChatList(newChatList);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ 获取历史消息失败:', error);
|
||||
// 如果获取失败,至少显示开场白
|
||||
const newChatList: ChatItem[] = generateNewChatListWithOpenStatement(notSyncToStateIntroduction, notSyncToStateInputs);
|
||||
setChatList(newChatList);
|
||||
}
|
||||
}
|
||||
|
||||
if (isNewConversation && isChatStarted) {
|
||||
setChatList(generateNewChatListWithOpenStatement());
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理会话ID变化
|
||||
*/
|
||||
const handleConversationIdChange = (id: string) => {
|
||||
console.log('🔄 会话ID变化:', { id, currentId: currConversationId });
|
||||
|
||||
if (id === '-1') {
|
||||
createNewChat();
|
||||
setConversationIdChangeBecauseOfNew(true);
|
||||
} else {
|
||||
setConversationIdChangeBecauseOfNew(false);
|
||||
}
|
||||
|
||||
// 触发会话切换
|
||||
setCurrConversationId(id, CHAT_CONFIG.APP_ID);
|
||||
};
|
||||
|
||||
/**
|
||||
* 检查是否可以发送消息
|
||||
*/
|
||||
const checkCanSend = () => {
|
||||
if (currConversationId !== '-1') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!currInputs || !promptConfig?.prompt_variables) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const inputLens = Object.values(currInputs).length;
|
||||
const promptVariablesLens = promptConfig.prompt_variables.length;
|
||||
|
||||
const emptyInput = inputLens < promptVariablesLens || Object.values(currInputs).find(v => !v);
|
||||
if (emptyInput) {
|
||||
console.error('必填变量值不能为空');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理发送消息
|
||||
*/
|
||||
const handleSendMessage = async (message: string, files?: any[]) => {
|
||||
if (isResponding) {
|
||||
console.log('正在响应中,请等待...');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!checkCanSend()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// console.log('📤 发送消息:', { message, conversationId: currConversationId });
|
||||
|
||||
try {
|
||||
// 准备输入数据
|
||||
const toServerInputs: Record<string, any> = {};
|
||||
if (currInputs) {
|
||||
Object.keys(currInputs).forEach((key) => {
|
||||
toServerInputs[key] = currInputs[key];
|
||||
});
|
||||
}
|
||||
|
||||
// 使用 useChatMessage 钩子的 handleSend 方法
|
||||
await handleSend(
|
||||
message,
|
||||
isNewConversation ? null : currConversationId,
|
||||
files,
|
||||
toServerInputs
|
||||
);
|
||||
|
||||
} catch (error) {
|
||||
console.error('发送消息失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理侧边栏切换
|
||||
*/
|
||||
const handleSidebarToggle = () => {
|
||||
setSidebarCollapsed(!sidebarCollapsed);
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理会话选择
|
||||
*/
|
||||
const handleConversationSelect = (conversationId: string) => {
|
||||
if (conversationId !== currConversationId) {
|
||||
setCurrConversationId(conversationId, CHAT_CONFIG.APP_ID);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理新建会话
|
||||
*/
|
||||
const handleNewConversation = () => {
|
||||
setCurrConversationId('-1', CHAT_CONFIG.APP_ID, false);
|
||||
createNewChat();
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理会话删除后的状态更新
|
||||
*/
|
||||
const handleConversationDeleted = (conversationId: string) => {
|
||||
console.log('🗑️ 处理会话删除后的状态更新:', conversationId);
|
||||
|
||||
// 如果删除的是当前会话,切换到新会话
|
||||
if (conversationId === currConversationId) {
|
||||
handleNewConversation();
|
||||
}
|
||||
|
||||
// 从列表中移除会话
|
||||
removeConversationFromList(conversationId);
|
||||
|
||||
console.log('✅ 会话删除状态更新完成:', conversationId);
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理会话重命名后的状态更新
|
||||
*/
|
||||
const handleConversationRenamed = (conversationId: string, newName: string) => {
|
||||
console.log('✏️ 处理会话重命名后的状态更新:', { conversationId, newName });
|
||||
|
||||
// 更新本地会话列表中的名称
|
||||
updateConversationInList(conversationId, { name: newName });
|
||||
|
||||
console.log('✅ 会话重命名状态更新完成:', conversationId, '->', newName);
|
||||
};
|
||||
|
||||
/**
|
||||
* 组件初始化 - 参考webapp-conversation的逻辑
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!hasSetAppConfig) {
|
||||
console.error('应用配置不完整');
|
||||
setAppUnavailable(true);
|
||||
return;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
console.log('🚀 开始初始化聊天应用...');
|
||||
|
||||
// 并行获取会话列表和应用参数
|
||||
const [conversationData, appParams] = await Promise.all([
|
||||
fetchConversations(),
|
||||
fetchAppParams()
|
||||
]);
|
||||
|
||||
console.log('📋 获取到的数据:', { conversationData, appParams });
|
||||
|
||||
// 处理会话数据
|
||||
const conversations = (conversationData as any).data || [];
|
||||
if ((conversationData as any).error) {
|
||||
console.error('获取会话列表失败:', (conversationData as any).error);
|
||||
throw new Error((conversationData as any).error);
|
||||
}
|
||||
|
||||
// 处理当前会话ID
|
||||
const _conversationId = getConversationIdFromStorage(CHAT_CONFIG.APP_ID);
|
||||
const isNotNewConversation = conversations.some((item: ConversationItem) => item.id === _conversationId);
|
||||
|
||||
console.log('💾 本地存储的会话ID:', _conversationId);
|
||||
console.log('🔍 是否为已存在的会话:', isNotNewConversation);
|
||||
|
||||
// 获取新会话信息
|
||||
const { user_input_form, opening_statement: introduction } = (appParams as any).data || {};
|
||||
|
||||
setNewConversationInfo({
|
||||
name: '新对话',
|
||||
introduction: introduction || '',
|
||||
});
|
||||
|
||||
// 设置提示配置
|
||||
setPromptConfig({
|
||||
prompt_template: '',
|
||||
prompt_variables: user_input_form || [],
|
||||
});
|
||||
|
||||
// 设置会话列表
|
||||
setConversationList(conversations);
|
||||
|
||||
// 如果存在有效的会话ID,则设置为当前会话
|
||||
if (isNotNewConversation) {
|
||||
console.log('🎯 设置当前会话ID:', _conversationId);
|
||||
setCurrConversationId(_conversationId, CHAT_CONFIG.APP_ID, false);
|
||||
} else {
|
||||
// 如果localStorage为空或会话不存在,自动创建新会话
|
||||
console.log('🆕 localStorage为空或会话不存在,创建新会话');
|
||||
setCurrConversationId('-1', CHAT_CONFIG.APP_ID, false);
|
||||
}
|
||||
|
||||
setInited(true);
|
||||
console.log('✅ 聊天应用初始化完成');
|
||||
} catch (e: any) {
|
||||
console.error('❌ 初始化失败:', e);
|
||||
if (e.status === 404) {
|
||||
setAppUnavailable(true);
|
||||
} else {
|
||||
setIsUnknownReason(true);
|
||||
setAppUnavailable(true);
|
||||
}
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
// 监听会话切换
|
||||
useEffect(() => {
|
||||
handleConversationSwitch();
|
||||
}, [currConversationId, inited]);
|
||||
|
||||
// 自动滚动到底部
|
||||
useEffect(() => {
|
||||
if (chatListDomRef.current) {
|
||||
chatListDomRef.current.scrollTop = chatListDomRef.current.scrollHeight;
|
||||
}
|
||||
}, [chatList]);
|
||||
|
||||
// 如果应用不可用,显示错误页面
|
||||
if (appUnavailable) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-2">应用暂时不可用</h2>
|
||||
<p className="text-gray-600">
|
||||
{isUnknownReason ? '发生了未知错误,请稍后重试' : '应用配置不正确,请检查配置'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 如果未初始化完成,显示加载状态
|
||||
if (!inited) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-2"></div>
|
||||
<p className="text-gray-600">正在加载...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 判断是否已设置输入
|
||||
const hasSetInputs = (() => {
|
||||
if (!isNewConversation) {
|
||||
return true;
|
||||
}
|
||||
return isChatStarted;
|
||||
})();
|
||||
|
||||
const conversationName = currConversationInfo?.name || '新对话';
|
||||
const conversationIntroduction = currConversationInfo?.introduction || '';
|
||||
|
||||
return (
|
||||
<Layout style={{ height: '90vh' }}>
|
||||
{/* 侧边栏 */}
|
||||
<ChatSidebar
|
||||
ref={sidebarRef}
|
||||
collapsed={sidebarCollapsed}
|
||||
onToggle={handleSidebarToggle}
|
||||
conversations={conversationList}
|
||||
currentConversationId={currConversationId}
|
||||
onConversationSelect={handleConversationSelect}
|
||||
onNewConversation={handleNewConversation}
|
||||
onConversationDeleted={handleConversationDeleted}
|
||||
onConversationRenamed={handleConversationRenamed}
|
||||
/>
|
||||
|
||||
{/* 主内容区域 */}
|
||||
<Layout>
|
||||
<Content
|
||||
style={{
|
||||
background: colorBgContainer,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
{/* 聊天区域 */}
|
||||
<div className="flex-[0.85] overflow-hidden">
|
||||
<div
|
||||
ref={chatListDomRef}
|
||||
className="h-full overflow-y-auto px-4 py-4 space-y-2"
|
||||
>
|
||||
{/* 如果是新会话且未开始聊天,显示欢迎信息 */}
|
||||
{isNewConversation && !isChatStarted && (
|
||||
<div className="text-center py-8">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">开始新的对话</h3>
|
||||
<p className="text-gray-600">请在下方输入您的问题</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 聊天消息列表 */}
|
||||
{chatList.map((item) => (
|
||||
<ChatMessage
|
||||
key={item.id}
|
||||
message={item}
|
||||
isResponding={isResponding && item.id === chatList[chatList.length - 1]?.id}
|
||||
onFeedback={handleFeedback}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 输入区域 */}
|
||||
<div className="flex-[0.16] border-t border-gray-200 bg-white">
|
||||
<ChatInput
|
||||
onSendMessage={handleSendMessage}
|
||||
disabled={isResponding}
|
||||
placeholder="请输入您的问题..."
|
||||
onStop={stopResponding}
|
||||
isResponding={isResponding}
|
||||
/>
|
||||
</div>
|
||||
</Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import React from 'react';
|
||||
import { Typography } from 'antd';
|
||||
import '../../styles/components/chat-with-llm/markdown.css';
|
||||
|
||||
const { Paragraph, Text, Title } = Typography;
|
||||
|
||||
interface MarkdownProps {
|
||||
content: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Markdown 渲染组件
|
||||
* 简化版本,支持基本的 Markdown 语法
|
||||
*/
|
||||
export default function Markdown({ content, className = '' }: MarkdownProps) {
|
||||
if (!content) return null;
|
||||
|
||||
/**
|
||||
* 简单的 Markdown 解析器
|
||||
*/
|
||||
const parseMarkdown = (text: string): React.ReactNode => {
|
||||
// 分割成行
|
||||
const lines = text.split('\n');
|
||||
const elements: React.ReactNode[] = [];
|
||||
let currentParagraph: string[] = [];
|
||||
let inCodeBlock = false;
|
||||
let codeBlockContent: string[] = [];
|
||||
let codeBlockLanguage = '';
|
||||
|
||||
const flushParagraph = () => {
|
||||
if (currentParagraph.length > 0) {
|
||||
const paragraphText = currentParagraph.join('\n');
|
||||
elements.push(
|
||||
<Paragraph key={elements.length} className={className}>
|
||||
{parseInlineElements(paragraphText)}
|
||||
</Paragraph>
|
||||
);
|
||||
currentParagraph = [];
|
||||
}
|
||||
};
|
||||
|
||||
const flushCodeBlock = () => {
|
||||
if (codeBlockContent.length > 0) {
|
||||
elements.push(
|
||||
<pre
|
||||
key={elements.length}
|
||||
className="bg-gray-800 text-white p-3 rounded-md overflow-x-auto my-2"
|
||||
>
|
||||
<code className={codeBlockLanguage ? `language-${codeBlockLanguage}` : ''}>
|
||||
{codeBlockContent.join('\n')}
|
||||
</code>
|
||||
</pre>
|
||||
);
|
||||
codeBlockContent = [];
|
||||
codeBlockLanguage = '';
|
||||
}
|
||||
};
|
||||
|
||||
lines.forEach((line, index) => {
|
||||
// 处理代码块
|
||||
if (line.startsWith('```')) {
|
||||
if (inCodeBlock) {
|
||||
// 结束代码块
|
||||
flushCodeBlock();
|
||||
inCodeBlock = false;
|
||||
} else {
|
||||
// 开始代码块
|
||||
flushParagraph();
|
||||
inCodeBlock = true;
|
||||
codeBlockLanguage = line.slice(3).trim();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (inCodeBlock) {
|
||||
codeBlockContent.push(line);
|
||||
return;
|
||||
}
|
||||
|
||||
// 处理标题
|
||||
if (line.startsWith('# ')) {
|
||||
flushParagraph();
|
||||
elements.push(
|
||||
<Title key={elements.length} level={1} className={className}>
|
||||
{parseInlineElements(line.slice(2))}
|
||||
</Title>
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (line.startsWith('## ')) {
|
||||
flushParagraph();
|
||||
elements.push(
|
||||
<Title key={elements.length} level={2} className={className}>
|
||||
{parseInlineElements(line.slice(3))}
|
||||
</Title>
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (line.startsWith('### ')) {
|
||||
flushParagraph();
|
||||
elements.push(
|
||||
<Title key={elements.length} level={3} className={className}>
|
||||
{parseInlineElements(line.slice(4))}
|
||||
</Title>
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 处理列表
|
||||
if (line.startsWith('- ') || line.startsWith('* ')) {
|
||||
flushParagraph();
|
||||
elements.push(
|
||||
<ul key={elements.length} className="list-disc list-inside my-2">
|
||||
<li>{parseInlineElements(line.slice(2))}</li>
|
||||
</ul>
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (/^\d+\.\s/.test(line)) {
|
||||
flushParagraph();
|
||||
const match = line.match(/^\d+\.\s(.*)$/);
|
||||
if (match) {
|
||||
elements.push(
|
||||
<ol key={elements.length} className="list-decimal list-inside my-2">
|
||||
<li>{parseInlineElements(match[1])}</li>
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 处理空行
|
||||
if (line.trim() === '') {
|
||||
flushParagraph();
|
||||
return;
|
||||
}
|
||||
|
||||
// 普通段落
|
||||
currentParagraph.push(line);
|
||||
});
|
||||
|
||||
// 处理剩余内容
|
||||
flushParagraph();
|
||||
flushCodeBlock();
|
||||
|
||||
return elements;
|
||||
};
|
||||
|
||||
/**
|
||||
* 解析行内元素(粗体、斜体、代码、链接等)
|
||||
*/
|
||||
const parseInlineElements = (text: string): React.ReactNode => {
|
||||
const parts: React.ReactNode[] = [];
|
||||
let currentText = text;
|
||||
let key = 0;
|
||||
|
||||
// 处理行内代码
|
||||
currentText = currentText.replace(/`([^`]+)`/g, (match, code) => {
|
||||
const placeholder = `__CODE_${key}__`;
|
||||
parts.push(
|
||||
<Text key={`code-${key}`} code className="bg-gray-100 px-1 rounded">
|
||||
{code}
|
||||
</Text>
|
||||
);
|
||||
key++;
|
||||
return placeholder;
|
||||
});
|
||||
|
||||
// 处理粗体
|
||||
currentText = currentText.replace(/\*\*([^*]+)\*\*/g, (match, bold) => {
|
||||
const placeholder = `__BOLD_${key}__`;
|
||||
parts.push(
|
||||
<Text key={`bold-${key}`} strong>
|
||||
{bold}
|
||||
</Text>
|
||||
);
|
||||
key++;
|
||||
return placeholder;
|
||||
});
|
||||
|
||||
// 处理斜体
|
||||
currentText = currentText.replace(/\*([^*]+)\*/g, (match, italic) => {
|
||||
const placeholder = `__ITALIC_${key}__`;
|
||||
parts.push(
|
||||
<Text key={`italic-${key}`} italic>
|
||||
{italic}
|
||||
</Text>
|
||||
);
|
||||
key++;
|
||||
return placeholder;
|
||||
});
|
||||
|
||||
// 处理链接
|
||||
currentText = currentText.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, linkText, url) => {
|
||||
const placeholder = `__LINK_${key}__`;
|
||||
parts.push(
|
||||
<a
|
||||
key={`link-${key}`}
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-500 hover:text-blue-700 underline"
|
||||
>
|
||||
{linkText}
|
||||
</a>
|
||||
);
|
||||
key++;
|
||||
return placeholder;
|
||||
});
|
||||
|
||||
// 重新组装文本
|
||||
const finalParts: React.ReactNode[] = [];
|
||||
const textParts = currentText.split(/(__(?:CODE|BOLD|ITALIC|LINK)_\d+__)/);
|
||||
|
||||
textParts.forEach((part, index) => {
|
||||
const match = part.match(/^__(\w+)_(\d+)__$/);
|
||||
if (match) {
|
||||
const [, type, partKey] = match;
|
||||
const component = parts.find((p: any) =>
|
||||
p.key === `${type.toLowerCase()}-${partKey}`
|
||||
);
|
||||
if (component) {
|
||||
finalParts.push(component);
|
||||
}
|
||||
} else if (part) {
|
||||
finalParts.push(part);
|
||||
}
|
||||
});
|
||||
|
||||
return finalParts.length === 1 ? finalParts[0] : finalParts;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`markdown-content ${className}`}>
|
||||
{parseMarkdown(content)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
import React, { useState, forwardRef, useImperativeHandle } from 'react';
|
||||
import { Button, Layout, Menu, theme, Input, Tooltip, Dropdown, Modal, message } from 'antd';
|
||||
import {
|
||||
MenuFoldOutlined,
|
||||
MenuUnfoldOutlined,
|
||||
MessageOutlined,
|
||||
PlusOutlined,
|
||||
SearchOutlined,
|
||||
MoreOutlined,
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { ConversationItem } from '../../types/dify_chat';
|
||||
import { deleteConversation, renameConversation } from '../../services/api.client';
|
||||
import '../../styles/components/chat-with-llm/sidebar.css';
|
||||
|
||||
const { Sider } = Layout;
|
||||
|
||||
interface ChatSidebarProps {
|
||||
collapsed: boolean;
|
||||
onToggle: () => void;
|
||||
conversations: ConversationItem[];
|
||||
currentConversationId: string;
|
||||
onConversationSelect: (conversationId: string) => void;
|
||||
onNewConversation: () => void;
|
||||
onConversationDeleted?: (conversationId: string) => void;
|
||||
onConversationRenamed?: (conversationId: string, newName: string) => void;
|
||||
}
|
||||
|
||||
// 暴露给父组件的方法接口
|
||||
export interface ChatSidebarRef {
|
||||
autoRename: (conversationId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 聊天侧边栏组件
|
||||
*/
|
||||
const ChatSidebar = forwardRef<ChatSidebarRef, ChatSidebarProps>(({
|
||||
collapsed,
|
||||
onToggle,
|
||||
conversations,
|
||||
currentConversationId,
|
||||
onConversationSelect,
|
||||
onNewConversation,
|
||||
onConversationDeleted,
|
||||
onConversationRenamed,
|
||||
}, ref) => {
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
const [renameModalVisible, setRenameModalVisible] = useState(false);
|
||||
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
|
||||
const [renamingConversation, setRenamingConversation] = useState<ConversationItem | null>(null);
|
||||
const [deletingConversation, setDeletingConversation] = useState<ConversationItem | null>(null);
|
||||
const [newName, setNewName] = useState('');
|
||||
const [renameLoading, setRenameLoading] = useState(false);
|
||||
const [deleteLoading, setDeleteLoading] = useState(false);
|
||||
const {
|
||||
token: { colorBgContainer, borderRadiusLG },
|
||||
} = theme.useToken();
|
||||
|
||||
// 过滤会话列表
|
||||
const filteredConversations = conversations.filter(conv =>
|
||||
conv.name.toLowerCase().includes(searchValue.toLowerCase())
|
||||
);
|
||||
|
||||
// 处理重命名
|
||||
const handleRename = (conv: ConversationItem) => {
|
||||
setRenamingConversation(conv);
|
||||
setNewName(conv.name);
|
||||
setRenameModalVisible(true);
|
||||
};
|
||||
|
||||
// 处理删除会话 - 显示确认Modal
|
||||
const handleDeleteClick = (conv: ConversationItem) => {
|
||||
setDeletingConversation(conv);
|
||||
setDeleteModalVisible(true);
|
||||
};
|
||||
|
||||
// 确认删除会话
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (!deletingConversation) return;
|
||||
|
||||
setDeleteLoading(true);
|
||||
try {
|
||||
console.log('🗑️ 开始删除会话:', deletingConversation.id);
|
||||
|
||||
// 调用API删除服务器端的会话
|
||||
const response = await deleteConversation(deletingConversation.id);
|
||||
console.log('✅ 服务器端会话删除响应:', response);
|
||||
|
||||
// 检查响应是否成功
|
||||
if (response && (response as any).result === 'success') {
|
||||
console.log('✅ 服务器端会话删除成功');
|
||||
message.success('会话删除成功');
|
||||
setDeleteModalVisible(false);
|
||||
|
||||
// 通知父组件会话已删除
|
||||
onConversationDeleted?.(deletingConversation.id);
|
||||
|
||||
console.log('✅ 会话删除完成:', deletingConversation.id);
|
||||
} else {
|
||||
throw new Error((response as any)?.error || '删除会话失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ 删除会话失败:', error);
|
||||
message.error(`删除会话失败: ${error instanceof Error ? error.message : '未知错误'}`);
|
||||
} finally {
|
||||
setDeleteLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 取消删除
|
||||
const handleDeleteCancel = () => {
|
||||
setDeleteModalVisible(false);
|
||||
setDeletingConversation(null);
|
||||
setDeleteLoading(false);
|
||||
};
|
||||
|
||||
// 确认重命名
|
||||
const handleRenameConfirm = async () => {
|
||||
if (!renamingConversation || !newName.trim()) {
|
||||
message.error('请输入有效的会话名称');
|
||||
return;
|
||||
}
|
||||
|
||||
if (newName.trim() === renamingConversation.name) {
|
||||
setRenameModalVisible(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setRenameLoading(true);
|
||||
try {
|
||||
console.log('✏️ 开始重命名会话:', { conversationId: renamingConversation.id, newName: newName.trim() });
|
||||
|
||||
// 调用API重命名服务器端的会话
|
||||
const response = await renameConversation(renamingConversation.id, newName.trim(), false);
|
||||
console.log('✅ 服务器端会话重命名响应:', response);
|
||||
|
||||
// 检查响应是否成功
|
||||
if (response && (response as any).name) {
|
||||
console.log('✅ 服务器端会话重命名成功');
|
||||
message.success('重命名成功');
|
||||
setRenameModalVisible(false);
|
||||
|
||||
// 通知父组件会话已重命名
|
||||
onConversationRenamed?.(renamingConversation.id, (response as any).name);
|
||||
|
||||
console.log('✅ 会话重命名完成:', renamingConversation.id, '->', (response as any).name);
|
||||
} else {
|
||||
throw new Error((response as any)?.error || '重命名会话失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ 重命名会话失败:', error);
|
||||
message.error(`重命名失败: ${error instanceof Error ? error.message : '未知错误'}`);
|
||||
} finally {
|
||||
setRenameLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 取消重命名
|
||||
const handleRenameCancel = () => {
|
||||
setRenameModalVisible(false);
|
||||
setRenamingConversation(null);
|
||||
setNewName('');
|
||||
setRenameLoading(false);
|
||||
};
|
||||
|
||||
// 生成菜单项
|
||||
const menuItems = filteredConversations.map(conv => ({
|
||||
key: conv.id,
|
||||
icon: <MessageOutlined />,
|
||||
label: (
|
||||
<div className="flex items-center justify-between group">
|
||||
<span className="truncate flex-1" title={conv.name}>
|
||||
{conv.name}
|
||||
</span>
|
||||
{!collapsed && (
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
{
|
||||
key: 'rename',
|
||||
icon: <EditOutlined />,
|
||||
label: '重命名',
|
||||
onClick: () => handleRename(conv),
|
||||
},
|
||||
{
|
||||
key: 'delete',
|
||||
icon: <DeleteOutlined />,
|
||||
label: '删除',
|
||||
danger: true,
|
||||
onClick: () => handleDeleteClick(conv),
|
||||
},
|
||||
],
|
||||
}}
|
||||
trigger={['click']}
|
||||
placement="bottomRight"
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<MoreOutlined />}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</Dropdown>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
autoRename: async (conversationId: string) => {
|
||||
try {
|
||||
console.log('🏷️ 开始自动重命名会话为"新对话":', conversationId);
|
||||
|
||||
// 调用API将会话重命名为固定的"新对话"
|
||||
const response = await renameConversation(conversationId, '新对话', false);
|
||||
console.log('✅ 服务器端会话重命名响应:', response);
|
||||
|
||||
// 检查响应是否成功
|
||||
if (response && (response as any).name) {
|
||||
console.log('✅ 服务器端会话重命名成功');
|
||||
|
||||
// 通知父组件会话已重命名
|
||||
onConversationRenamed?.(conversationId, (response as any).name);
|
||||
|
||||
console.log('✅ 会话重命名完成:', conversationId, '->', (response as any).name);
|
||||
} else {
|
||||
throw new Error((response as any)?.error || '重命名会话失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ 重命名会话失败:', error);
|
||||
// 重命名失败时不显示错误消息,避免打扰用户
|
||||
console.warn('⚠️ 重命名失败,会话将保持默认名称');
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
return (
|
||||
<Sider
|
||||
trigger={null}
|
||||
collapsible
|
||||
collapsed={collapsed}
|
||||
width={280}
|
||||
style={{
|
||||
background: colorBgContainer,
|
||||
borderRight: '1px solid #f0f0f0',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
{/* 侧边栏头部 - 固定在顶部 */}
|
||||
<div className="p-4 border-b border-gray-100 flex-shrink-0">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<Button
|
||||
type="text"
|
||||
icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
|
||||
onClick={onToggle}
|
||||
style={{
|
||||
fontSize: '16px',
|
||||
width: 32,
|
||||
height: 32,
|
||||
color: 'rgb(0, 104, 74)',
|
||||
}}
|
||||
/>
|
||||
{!collapsed && (
|
||||
<Tooltip title="新建对话">
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={onNewConversation}
|
||||
size="small"
|
||||
>
|
||||
新对话
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 搜索框 */}
|
||||
{!collapsed && (
|
||||
<Input
|
||||
placeholder="搜索对话..."
|
||||
prefix={<SearchOutlined />}
|
||||
value={searchValue}
|
||||
onChange={(e) => setSearchValue(e.target.value)}
|
||||
allowClear
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 会话列表 - 可滚动区域 */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<div
|
||||
className="h-full overflow-y-auto"
|
||||
style={{
|
||||
scrollbarWidth: 'thin',
|
||||
scrollbarColor: '#c1c1c1 #f1f1f1',
|
||||
}}
|
||||
>
|
||||
{!collapsed && filteredConversations.length === 0 && searchValue && (
|
||||
<div className="p-4 text-center text-gray-500">
|
||||
<MessageOutlined className="text-2xl mb-2" />
|
||||
<p>未找到相关对话</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!collapsed && conversations.length === 0 && !searchValue && (
|
||||
<div className="p-4 text-center text-gray-500">
|
||||
<MessageOutlined className="text-2xl mb-2" />
|
||||
<p>暂无对话记录</p>
|
||||
<Button
|
||||
type="link"
|
||||
onClick={onNewConversation}
|
||||
className="mt-2"
|
||||
>
|
||||
开始新对话
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Menu
|
||||
mode="inline"
|
||||
selectedKeys={[currentConversationId]}
|
||||
items={menuItems}
|
||||
onClick={({ key }) => onConversationSelect(key)}
|
||||
style={{
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
}}
|
||||
className="chat-sidebar-menu"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 侧边栏底部 - 固定在底部 */}
|
||||
{!collapsed && (
|
||||
<div className="p-4 border-t border-gray-100 flex-shrink-0">
|
||||
<div className="text-xs text-gray-500 text-center">
|
||||
共 {conversations.length} 个对话
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 重命名Modal */}
|
||||
<Modal
|
||||
title="重命名会话"
|
||||
open={renameModalVisible}
|
||||
onOk={handleRenameConfirm}
|
||||
onCancel={handleRenameCancel}
|
||||
confirmLoading={renameLoading}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
destroyOnClose
|
||||
>
|
||||
<div className="py-4">
|
||||
<Input
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
placeholder="请输入新的会话名称"
|
||||
maxLength={10}
|
||||
showCount
|
||||
onPressEnter={handleRenameConfirm}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* 删除确认Modal */}
|
||||
<Modal
|
||||
title={
|
||||
<div className="flex items-center">
|
||||
<ExclamationCircleOutlined className="text-red-500 mr-2" />
|
||||
删除会话
|
||||
</div>
|
||||
}
|
||||
open={deleteModalVisible}
|
||||
onOk={handleDeleteConfirm}
|
||||
onCancel={handleDeleteCancel}
|
||||
confirmLoading={deleteLoading}
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
okType="danger"
|
||||
destroyOnClose
|
||||
>
|
||||
<div className="py-4">
|
||||
<p>确定要删除会话 <strong>"{deletingConversation?.name}"</strong> 吗?</p>
|
||||
<p className="text-gray-500 text-sm mt-2">此操作不可撤销,会话中的所有消息都将被永久删除。</p>
|
||||
</div>
|
||||
</Modal>
|
||||
</Sider>
|
||||
);
|
||||
});
|
||||
|
||||
export default ChatSidebar;
|
||||
@@ -0,0 +1,220 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Card, Collapse, Tag, Spin, Typography, Button } from 'antd';
|
||||
import { ToolOutlined, ThunderboltOutlined, CheckCircleOutlined, LoadingOutlined } from '@ant-design/icons';
|
||||
import type { ThoughtItem } from '../../types/dify_chat';
|
||||
import Markdown from './markdown';
|
||||
import '../../styles/components/chat-with-llm/thought-process.css';
|
||||
|
||||
const { Panel } = Collapse;
|
||||
const { Text, Paragraph } = Typography;
|
||||
|
||||
interface ThoughtProcessProps {
|
||||
thought: ThoughtItem;
|
||||
isFinished: boolean;
|
||||
allToolIcons?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 思考过程组件
|
||||
* 展示AI的思考过程和工具调用
|
||||
*/
|
||||
export default function ThoughtProcess({
|
||||
thought,
|
||||
isFinished,
|
||||
allToolIcons = {}
|
||||
}: ThoughtProcessProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const { tool_name, tool_input, tool_output, thought: thoughtText, observation } = thought;
|
||||
|
||||
/**
|
||||
* 获取工具图标
|
||||
*/
|
||||
const getToolIcon = (toolName?: string) => {
|
||||
if (!toolName) return <ToolOutlined />;
|
||||
|
||||
// 如果有自定义图标映射
|
||||
if (allToolIcons[toolName]) {
|
||||
return <span>{allToolIcons[toolName]}</span>;
|
||||
}
|
||||
|
||||
// 根据工具名称返回默认图标
|
||||
switch (toolName.toLowerCase()) {
|
||||
case 'search':
|
||||
case 'web_search':
|
||||
return '🔍';
|
||||
case 'calculator':
|
||||
case 'math':
|
||||
return '🧮';
|
||||
case 'code':
|
||||
case 'python':
|
||||
return '💻';
|
||||
case 'image':
|
||||
case 'vision':
|
||||
return '👁️';
|
||||
case 'file':
|
||||
case 'document':
|
||||
return '📄';
|
||||
default:
|
||||
return <ToolOutlined />;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取状态图标和颜色
|
||||
*/
|
||||
const getStatusInfo = () => {
|
||||
if (isFinished && observation) {
|
||||
return {
|
||||
icon: <CheckCircleOutlined />,
|
||||
color: 'success',
|
||||
text: '已完成'
|
||||
};
|
||||
} else if (!isFinished) {
|
||||
return {
|
||||
icon: <LoadingOutlined spin />,
|
||||
color: 'processing',
|
||||
text: '执行中'
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
icon: <ThunderboltOutlined />,
|
||||
color: 'default',
|
||||
text: '等待中'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const statusInfo = getStatusInfo();
|
||||
|
||||
/**
|
||||
* 格式化工具输入
|
||||
*/
|
||||
const formatToolInput = (input?: string) => {
|
||||
if (!input) return '无输入参数';
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(input);
|
||||
return (
|
||||
<pre className="bg-gray-50 p-2 rounded text-sm overflow-x-auto">
|
||||
{JSON.stringify(parsed, null, 2)}
|
||||
</pre>
|
||||
);
|
||||
} catch {
|
||||
return <Text code>{input}</Text>;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 格式化工具输出
|
||||
*/
|
||||
const formatToolOutput = (output?: string) => {
|
||||
if (!output) return '暂无输出';
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(output);
|
||||
return (
|
||||
<pre className="bg-gray-50 p-2 rounded text-sm overflow-x-auto">
|
||||
{JSON.stringify(parsed, null, 2)}
|
||||
</pre>
|
||||
);
|
||||
} catch {
|
||||
return <Markdown content={output} />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
size="small"
|
||||
className="my-2 border-l-4 border-l-blue-400 bg-blue-50"
|
||||
bodyStyle={{ padding: '12px' }}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg">{getToolIcon(tool_name)}</span>
|
||||
<Text strong className="text-blue-700">
|
||||
{tool_name || '工具调用'}
|
||||
</Text>
|
||||
<Tag
|
||||
color={statusInfo.color as any}
|
||||
icon={statusInfo.icon}
|
||||
className="ml-2"
|
||||
>
|
||||
{statusInfo.text}
|
||||
</Tag>
|
||||
</div>
|
||||
|
||||
{(tool_input || tool_output) && (
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="text-blue-600"
|
||||
>
|
||||
{expanded ? '收起详情' : '查看详情'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 思考内容 */}
|
||||
{thoughtText && (
|
||||
<div className="mb-2">
|
||||
<Markdown content={thoughtText} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 工具详情 */}
|
||||
{expanded && (tool_input || tool_output) && (
|
||||
<Collapse
|
||||
ghost
|
||||
size="small"
|
||||
className="bg-white rounded"
|
||||
>
|
||||
{tool_input && (
|
||||
<Panel
|
||||
header={
|
||||
<span className="text-sm font-medium text-gray-600">
|
||||
📥 输入参数
|
||||
</span>
|
||||
}
|
||||
key="input"
|
||||
>
|
||||
{formatToolInput(tool_input)}
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
{tool_output && (
|
||||
<Panel
|
||||
header={
|
||||
<span className="text-sm font-medium text-gray-600">
|
||||
📤 执行结果
|
||||
</span>
|
||||
}
|
||||
key="output"
|
||||
>
|
||||
{formatToolOutput(tool_output)}
|
||||
</Panel>
|
||||
)}
|
||||
</Collapse>
|
||||
)}
|
||||
|
||||
{/* 观察结果 */}
|
||||
{observation && observation !== tool_output && (
|
||||
<div className="mt-2 p-2 bg-green-50 rounded border border-green-200">
|
||||
<Text className="text-green-700 text-sm font-medium">💡 观察结果:</Text>
|
||||
<div className="mt-1">
|
||||
<Markdown content={observation} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 加载状态 */}
|
||||
{!isFinished && !observation && (
|
||||
<div className="flex items-center justify-center py-2 text-blue-600">
|
||||
<Spin size="small" className="mr-2" />
|
||||
<Text className="text-sm">正在执行工具调用...</Text>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
+398
-392
@@ -1,393 +1,399 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Link, useLocation, useNavigate } from '@remix-run/react';
|
||||
import type { UserRole } from '~/root';
|
||||
|
||||
interface MenuItem {
|
||||
id: string;
|
||||
title: string;
|
||||
path: string;
|
||||
icon: string;
|
||||
hideBreadcrumb?: boolean;
|
||||
requiredRole?: UserRole;
|
||||
children?: MenuItem[];
|
||||
}
|
||||
|
||||
interface SidebarProps {
|
||||
onToggle: () => void;
|
||||
collapsed: boolean;
|
||||
userRole: UserRole;
|
||||
selectedApp?: string; // 添加所选应用模块参数
|
||||
}
|
||||
|
||||
// 定义不同应用模块下显示的菜单项ID
|
||||
const APP_MENU_MAP = {
|
||||
'contract': ['home', 'contract-template', 'file-management', 'rule-management', 'system-settings'],
|
||||
'record': ['home', 'file-management', 'rule-management', 'system-settings'],
|
||||
'model': ['home']
|
||||
};
|
||||
|
||||
// 应用模块名称映射
|
||||
const APP_NAME_MAP: Record<string, string> = {
|
||||
'contract': '合同管理',
|
||||
'record': '案卷智能评查',
|
||||
'model': '智慧法务大模型'
|
||||
};
|
||||
|
||||
// 应用模块图标映射
|
||||
const APP_ICON_MAP: Record<string, string> = {
|
||||
'contract': 'ri-file-list-2-fill',
|
||||
'record': 'ri-folder-shared-fill',
|
||||
'model': 'ri-robot-2-fill'
|
||||
};
|
||||
|
||||
export function Sidebar({ onToggle, collapsed, userRole, selectedApp = 'contract' }: SidebarProps) {
|
||||
const location = useLocation();
|
||||
const [expandedMenus, setExpandedMenus] = useState<Record<string, boolean>>({});
|
||||
const [currentApp, setCurrentApp] = useState<string>(selectedApp);
|
||||
const navigate = useNavigate();
|
||||
|
||||
// 组件挂载后从 sessionStorage 读取初始 reviewType
|
||||
useEffect(() => {
|
||||
try {
|
||||
const reviewType = sessionStorage.getItem('reviewType');
|
||||
// console.log('初始 reviewType:', reviewType);
|
||||
if (reviewType) {
|
||||
setCurrentApp(reviewType);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('读取 reviewType 失败:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 从 sessionStorage 获取 reviewType 并设置当前应用模块
|
||||
useEffect(() => {
|
||||
// 监听 sessionStorage 变化(主要用于多标签页情况)
|
||||
const handleStorageChange = (e: StorageEvent) => {
|
||||
if (e.key === 'reviewType' && e.newValue) {
|
||||
setCurrentApp(e.newValue);
|
||||
}
|
||||
};
|
||||
|
||||
// 添加事件监听器
|
||||
window.addEventListener('storage', handleStorageChange);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('storage', handleStorageChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 监听路由变化,重新检查 reviewType
|
||||
useEffect(() => {
|
||||
try {
|
||||
const reviewType = sessionStorage.getItem('reviewType');
|
||||
// console.log('路由变化, 检查 reviewType:', reviewType, '路径:', location.pathname);
|
||||
if (reviewType) {
|
||||
setCurrentApp(reviewType);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('路由变化时读取 reviewType 失败:', error);
|
||||
}
|
||||
}, [location.pathname]);
|
||||
|
||||
// 监听 selectedApp 属性变化
|
||||
useEffect(() => {
|
||||
if (selectedApp) {
|
||||
setCurrentApp(selectedApp);
|
||||
}
|
||||
}, [selectedApp]);
|
||||
|
||||
const menuItems: MenuItem[] = [
|
||||
{
|
||||
id: 'home',
|
||||
title: '系统概览',
|
||||
path: '/home',
|
||||
icon: 'ri-home-line'
|
||||
},
|
||||
{
|
||||
id: 'file-management',
|
||||
title: '文件管理',
|
||||
path: '/files',
|
||||
icon: 'ri-folder-line',
|
||||
children: [
|
||||
{
|
||||
id: 'file-upload',
|
||||
title: '文件上传',
|
||||
path: '/files/upload',
|
||||
icon: 'ri-upload-cloud-line'
|
||||
},
|
||||
{
|
||||
id:'documents',
|
||||
title:'文档列表',
|
||||
path:'/documents',
|
||||
icon:'ri-file-list-3-line'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'rule-management',
|
||||
title: '评查规则库',
|
||||
path: '/rules',
|
||||
icon: 'ri-book-3-line',
|
||||
children: [
|
||||
{
|
||||
id: 'rule-groups',
|
||||
title: '评查点分组',
|
||||
path: '/rule-groups',
|
||||
icon: 'ri-folder-open-line'
|
||||
},
|
||||
{
|
||||
id: 'rules-list',
|
||||
title: '评查点列表',
|
||||
path: '/rules',
|
||||
icon: 'ri-list-check-3'
|
||||
},
|
||||
{
|
||||
id: 'rules-file',
|
||||
title: '评查文件列表',
|
||||
path: '/rules-files',
|
||||
icon: 'ri-list-check-2'
|
||||
},
|
||||
// {
|
||||
// id: 'rule-new',
|
||||
// title: '新增评查点',
|
||||
// path: '/rules-new',
|
||||
// requiredRole: 'developer',
|
||||
// icon: 'ri-add-circle-line'
|
||||
// },
|
||||
// {
|
||||
// id: 'review-detail',
|
||||
// title: '评查详情',
|
||||
// path: '/reviews',
|
||||
// icon: 'ri-file-chart-line'
|
||||
// }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'contract-template',
|
||||
title: '合同模板',
|
||||
path: '/contract-template',
|
||||
icon: 'ri-file-search-line',
|
||||
children: [
|
||||
{
|
||||
id: 'contract-search-ai',
|
||||
title: '智能搜索',
|
||||
path: '/contract-template/search',
|
||||
icon: 'ri-search-line'
|
||||
},
|
||||
{
|
||||
id: 'contract-list',
|
||||
title: '合同列表',
|
||||
path: '/contract-template/list',
|
||||
icon: 'ri-folder-line'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'system-settings',
|
||||
title: '系统设置',
|
||||
path: '/settings',
|
||||
icon: 'ri-settings-4-line',
|
||||
requiredRole: 'developer',
|
||||
children: [
|
||||
{
|
||||
id: 'config-lists',
|
||||
title: '配置列表',
|
||||
path: '/config-lists',
|
||||
icon: 'ri-list-check-3',
|
||||
requiredRole: 'developer'
|
||||
},
|
||||
// {
|
||||
// id: 'basic-settings',
|
||||
// title: '基础设置',
|
||||
// path: '/settings',
|
||||
// icon: 'ri-equalizer-line'
|
||||
// },
|
||||
{
|
||||
id: 'document-types',
|
||||
title: '文档类型',
|
||||
path: '/document-types',
|
||||
icon: 'ri-file-list-line',
|
||||
requiredRole: 'developer'
|
||||
},
|
||||
{
|
||||
id: 'prompt-management',
|
||||
title: '提示词管理',
|
||||
path: '/prompts',
|
||||
icon: 'ri-chat-1-line',
|
||||
requiredRole: 'developer'
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
// 初始化展开状态,默认全部展开
|
||||
useEffect(() => {
|
||||
const initialExpandedState: Record<string, boolean> = {};
|
||||
menuItems.forEach(item => {
|
||||
if (item.children) {
|
||||
initialExpandedState[item.id] = true;
|
||||
}
|
||||
});
|
||||
setExpandedMenus(initialExpandedState);
|
||||
}, []);
|
||||
|
||||
const toggleMenu = (id: string, e: React.MouseEvent) => {
|
||||
// 我们只防止事件冒泡,不阻止默认行为
|
||||
e.stopPropagation();
|
||||
|
||||
// console.log('父菜单展开/折叠:', id);
|
||||
|
||||
setExpandedMenus(prev => ({
|
||||
...prev,
|
||||
[id]: !prev[id]
|
||||
}));
|
||||
};
|
||||
|
||||
const isActive = (path: string) => {
|
||||
return location.pathname === path || location.pathname.startsWith(`${path}/`);
|
||||
};
|
||||
|
||||
// 处理侧边栏切换事件
|
||||
const handleToggleSidebar = (e: React.MouseEvent) => {
|
||||
// console.log('侧边栏折叠/展开');
|
||||
// 只防止事件冒泡,不阻止默认行为
|
||||
e.stopPropagation();
|
||||
onToggle();
|
||||
};
|
||||
|
||||
// 处理子菜单项点击事件
|
||||
const handleSubMenuClick = (child: MenuItem, e: React.MouseEvent) => {
|
||||
// 只需要阻止冒泡,不阻止默认行为
|
||||
e.stopPropagation();
|
||||
// console.log('子菜单点击:', child.title, '路径:', child.path);
|
||||
};
|
||||
|
||||
// 获取当前应用模式下应显示的菜单ID列表
|
||||
const visibleMenuIds = APP_MENU_MAP[currentApp as keyof typeof APP_MENU_MAP] || APP_MENU_MAP['contract'];
|
||||
// console.log('当前应用模式:', currentApp, '可见菜单ID:', visibleMenuIds);
|
||||
|
||||
// 根据用户角色和当前应用模式过滤菜单项
|
||||
const filteredMenuItems = menuItems.filter(item => {
|
||||
// 如果菜单项需要特定角色但用户没有
|
||||
if (item.requiredRole && item.requiredRole !== userRole) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查当前菜单是否在所选应用模式中显示
|
||||
if (!visibleMenuIds.includes(item.id)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={`sidebar ${collapsed ? 'collapsed' : ''}`}>
|
||||
<div className="py-6 px-4 border-b border-gray-100 flex justify-between items-center">
|
||||
<div className="flex items-center"
|
||||
onClick={() => {
|
||||
navigate('/');
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
navigate('/');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<img src="/logo.svg" alt="智慧法务" className="w-12 h-12 mr-2" />
|
||||
{!collapsed && <h2 className="text-lg font-medium">智慧法务</h2>}
|
||||
</div>
|
||||
<button
|
||||
className="sidebar-toggle"
|
||||
onClick={handleToggleSidebar}
|
||||
aria-label={collapsed ? "展开侧边栏" : "折叠侧边栏"}
|
||||
type="button"
|
||||
>
|
||||
<i className={`${collapsed ? 'ri-menu-unfold-line' : 'ri-menu-fold-line'}`}></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!collapsed && (
|
||||
<div className="px-4 py-3 border-b border-gray-100">
|
||||
<div className="flex items-center text-green-700">
|
||||
<i className={`${APP_ICON_MAP[currentApp] || 'ri-file-list-2-fill'} mr-2 text-xl`}></i>
|
||||
<span className="font-medium">{APP_NAME_MAP[currentApp] || '合同管理'}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="py-4 px-[10px]">
|
||||
{filteredMenuItems.map((item) => (
|
||||
<div key={item.id} className={`${collapsed ? 'px-0' : ''}`}>
|
||||
{!item.children ? (
|
||||
<Link
|
||||
to={item.path}
|
||||
className={`sidebar-menu-item ${isActive(item.path) ? 'active' : ''} flex items-center ${collapsed ? 'justify-center' : ''}`}
|
||||
onClick={(e) => {
|
||||
// 只阻止冒泡,不阻止默认行为
|
||||
e.stopPropagation();
|
||||
// console.log('单级菜单点击:', item.title, '路径:', item.path);
|
||||
}}
|
||||
>
|
||||
<i className={`${item.icon} ${collapsed ? 'text-xl' : 'mr-3'}`}></i>
|
||||
{!collapsed && <span>{item.title}</span>}
|
||||
</Link>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
className={`sidebar-menu-item flex items-center ${collapsed ? 'justify-center' : 'justify-between'} cursor-pointer z-10`}
|
||||
onClick={(e) => {
|
||||
// console.log('%c父菜单点击 ===> ', 'background: #722ed1; color: white; padding: 2px 4px; border-radius: 2px;', item.title);
|
||||
toggleMenu(item.id, e);
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-expanded={expandedMenus[item.id] || false}
|
||||
aria-controls={`submenu-${item.id}`}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
toggleMenu(item.id, e as unknown as React.MouseEvent);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<i className={`${item.icon} ${collapsed ? 'text-xl' : 'mr-3'}`}></i>
|
||||
{!collapsed && <span>{item.title}</span>}
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<i className={`ri-arrow-${expandedMenus[item.id] ? 'down' : 'right'}-s-line`}></i>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(expandedMenus[item.id] || collapsed) && (
|
||||
<div
|
||||
className={`submenu-container ${collapsed ? 'border-l-0 pl-0' : 'border-l border-gray-100 ml-4 pl-3'} z-20`}
|
||||
id={`submenu-${item.id}`}
|
||||
>
|
||||
{item.children
|
||||
.filter(child => !child.requiredRole || child.requiredRole === userRole)
|
||||
.map((child) => (
|
||||
<Link
|
||||
key={child.id}
|
||||
to={child.path}
|
||||
className={`sidebar-menu-item ${isActive(child.path) ? 'active' : ''} flex items-center ${collapsed ? 'justify-center' : ''}`}
|
||||
onClick={(e) => handleSubMenuClick(child, e)}
|
||||
>
|
||||
<i className={`${child.icon} ${collapsed ? 'text-xl' : 'mr-3'}`}></i>
|
||||
{!collapsed && <span>{child.title}</span>}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Link, useLocation, useNavigate } from '@remix-run/react';
|
||||
import type { UserRole } from '~/root';
|
||||
|
||||
interface MenuItem {
|
||||
id: string;
|
||||
title: string;
|
||||
path: string;
|
||||
icon: string;
|
||||
hideBreadcrumb?: boolean;
|
||||
requiredRole?: UserRole;
|
||||
children?: MenuItem[];
|
||||
}
|
||||
|
||||
interface SidebarProps {
|
||||
onToggle: () => void;
|
||||
collapsed: boolean;
|
||||
userRole: UserRole;
|
||||
selectedApp?: string; // 添加所选应用模块参数
|
||||
}
|
||||
|
||||
// 定义不同应用模块下显示的菜单项ID
|
||||
const APP_MENU_MAP = {
|
||||
'contract': ['home', 'contract-template', 'file-management', 'rule-management', 'system-settings'],
|
||||
'record': ['home', 'file-management', 'rule-management', 'system-settings'],
|
||||
'model': ['chat-with-llm']
|
||||
};
|
||||
|
||||
// 应用模块名称映射
|
||||
const APP_NAME_MAP: Record<string, string> = {
|
||||
'contract': '合同管理',
|
||||
'record': '案卷智能评查',
|
||||
'model': '智慧法务大模型'
|
||||
};
|
||||
|
||||
// 应用模块图标映射
|
||||
const APP_ICON_MAP: Record<string, string> = {
|
||||
'contract': 'ri-file-list-2-fill',
|
||||
'record': 'ri-folder-shared-fill',
|
||||
'model': 'ri-robot-2-fill'
|
||||
};
|
||||
|
||||
export function Sidebar({ onToggle, collapsed, userRole, selectedApp = 'contract' }: SidebarProps) {
|
||||
const location = useLocation();
|
||||
const [expandedMenus, setExpandedMenus] = useState<Record<string, boolean>>({});
|
||||
const [currentApp, setCurrentApp] = useState<string>(selectedApp);
|
||||
const navigate = useNavigate();
|
||||
|
||||
// 组件挂载后从 sessionStorage 读取初始 reviewType
|
||||
useEffect(() => {
|
||||
try {
|
||||
const reviewType = sessionStorage.getItem('reviewType');
|
||||
// console.log('初始 reviewType:', reviewType);
|
||||
if (reviewType) {
|
||||
setCurrentApp(reviewType);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('读取 reviewType 失败:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 从 sessionStorage 获取 reviewType 并设置当前应用模块
|
||||
useEffect(() => {
|
||||
// 监听 sessionStorage 变化(主要用于多标签页情况)
|
||||
const handleStorageChange = (e: StorageEvent) => {
|
||||
if (e.key === 'reviewType' && e.newValue) {
|
||||
setCurrentApp(e.newValue);
|
||||
}
|
||||
};
|
||||
|
||||
// 添加事件监听器
|
||||
window.addEventListener('storage', handleStorageChange);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('storage', handleStorageChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 监听路由变化,重新检查 reviewType
|
||||
useEffect(() => {
|
||||
try {
|
||||
const reviewType = sessionStorage.getItem('reviewType');
|
||||
// console.log('路由变化, 检查 reviewType:', reviewType, '路径:', location.pathname);
|
||||
if (reviewType) {
|
||||
setCurrentApp(reviewType);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('路由变化时读取 reviewType 失败:', error);
|
||||
}
|
||||
}, [location.pathname]);
|
||||
|
||||
// 监听 selectedApp 属性变化
|
||||
useEffect(() => {
|
||||
if (selectedApp) {
|
||||
setCurrentApp(selectedApp);
|
||||
}
|
||||
}, [selectedApp]);
|
||||
|
||||
const menuItems: MenuItem[] = [
|
||||
{
|
||||
id: 'home',
|
||||
title: '系统概览',
|
||||
path: '/home',
|
||||
icon: 'ri-home-line'
|
||||
},
|
||||
{
|
||||
id: 'chat-with-llm',
|
||||
title: 'AI对话',
|
||||
path: '/chat-with-llm',
|
||||
icon: 'ri-chat-smile-2-line'
|
||||
},
|
||||
{
|
||||
id: 'file-management',
|
||||
title: '文件管理',
|
||||
path: '/files',
|
||||
icon: 'ri-folder-line',
|
||||
children: [
|
||||
{
|
||||
id: 'file-upload',
|
||||
title: '文件上传',
|
||||
path: '/files/upload',
|
||||
icon: 'ri-upload-cloud-line'
|
||||
},
|
||||
{
|
||||
id: 'documents',
|
||||
title: '文档列表',
|
||||
path: '/documents',
|
||||
icon: 'ri-file-list-3-line'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'rule-management',
|
||||
title: '评查规则库',
|
||||
path: '/rules',
|
||||
icon: 'ri-book-3-line',
|
||||
children: [
|
||||
{
|
||||
id: 'rule-groups',
|
||||
title: '评查点分组',
|
||||
path: '/rule-groups',
|
||||
icon: 'ri-folder-open-line'
|
||||
},
|
||||
{
|
||||
id: 'rules-list',
|
||||
title: '评查点列表',
|
||||
path: '/rules',
|
||||
icon: 'ri-list-check-3'
|
||||
},
|
||||
{
|
||||
id: 'rules-file',
|
||||
title: '评查文件列表',
|
||||
path: '/rules-files',
|
||||
icon: 'ri-list-check-2'
|
||||
},
|
||||
// {
|
||||
// id: 'rule-new',
|
||||
// title: '新增评查点',
|
||||
// path: '/rules-new',
|
||||
// requiredRole: 'developer',
|
||||
// icon: 'ri-add-circle-line'
|
||||
// },
|
||||
// {
|
||||
// id: 'review-detail',
|
||||
// title: '评查详情',
|
||||
// path: '/reviews',
|
||||
// icon: 'ri-file-chart-line'
|
||||
// }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'contract-template',
|
||||
title: '合同模板',
|
||||
path: '/contract-template',
|
||||
icon: 'ri-file-search-line',
|
||||
children: [
|
||||
{
|
||||
id: 'contract-search-ai',
|
||||
title: '智能搜索',
|
||||
path: '/contract-template/search',
|
||||
icon: 'ri-search-line'
|
||||
},
|
||||
{
|
||||
id: 'contract-list',
|
||||
title: '合同列表',
|
||||
path: '/contract-template/list',
|
||||
icon: 'ri-folder-line'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'system-settings',
|
||||
title: '系统设置',
|
||||
path: '/settings',
|
||||
icon: 'ri-settings-4-line',
|
||||
requiredRole: 'developer',
|
||||
children: [
|
||||
{
|
||||
id: 'config-lists',
|
||||
title: '配置列表',
|
||||
path: '/config-lists',
|
||||
icon: 'ri-list-check-3',
|
||||
requiredRole: 'developer'
|
||||
},
|
||||
// {
|
||||
// id: 'basic-settings',
|
||||
// title: '基础设置',
|
||||
// path: '/settings',
|
||||
// icon: 'ri-equalizer-line'
|
||||
// },
|
||||
{
|
||||
id: 'document-types',
|
||||
title: '文档类型',
|
||||
path: '/document-types',
|
||||
icon: 'ri-file-list-line',
|
||||
requiredRole: 'developer'
|
||||
},
|
||||
{
|
||||
id: 'prompt-management',
|
||||
title: '提示词管理',
|
||||
path: '/prompts',
|
||||
icon: 'ri-chat-1-line',
|
||||
requiredRole: 'developer'
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
// 初始化展开状态,默认全部展开
|
||||
useEffect(() => {
|
||||
const initialExpandedState: Record<string, boolean> = {};
|
||||
menuItems.forEach(item => {
|
||||
if (item.children) {
|
||||
initialExpandedState[item.id] = true;
|
||||
}
|
||||
});
|
||||
setExpandedMenus(initialExpandedState);
|
||||
}, []);
|
||||
|
||||
const toggleMenu = (id: string, e: React.MouseEvent) => {
|
||||
// 我们只防止事件冒泡,不阻止默认行为
|
||||
e.stopPropagation();
|
||||
|
||||
// console.log('父菜单展开/折叠:', id);
|
||||
|
||||
setExpandedMenus(prev => ({
|
||||
...prev,
|
||||
[id]: !prev[id]
|
||||
}));
|
||||
};
|
||||
|
||||
const isActive = (path: string) => {
|
||||
return location.pathname === path || location.pathname.startsWith(`${path}/`);
|
||||
};
|
||||
|
||||
// 处理侧边栏切换事件
|
||||
const handleToggleSidebar = (e: React.MouseEvent) => {
|
||||
// console.log('侧边栏折叠/展开');
|
||||
// 只防止事件冒泡,不阻止默认行为
|
||||
e.stopPropagation();
|
||||
onToggle();
|
||||
};
|
||||
|
||||
// 处理子菜单项点击事件
|
||||
const handleSubMenuClick = (child: MenuItem, e: React.MouseEvent) => {
|
||||
// 只需要阻止冒泡,不阻止默认行为
|
||||
e.stopPropagation();
|
||||
// console.log('子菜单点击:', child.title, '路径:', child.path);
|
||||
};
|
||||
|
||||
// 获取当前应用模式下应显示的菜单ID列表
|
||||
const visibleMenuIds = APP_MENU_MAP[currentApp as keyof typeof APP_MENU_MAP] || APP_MENU_MAP['contract'];
|
||||
// console.log('当前应用模式:', currentApp, '可见菜单ID:', visibleMenuIds);
|
||||
|
||||
// 根据用户角色和当前应用模式过滤菜单项
|
||||
const filteredMenuItems = menuItems.filter(item => {
|
||||
// 如果菜单项需要特定角色但用户没有
|
||||
if (item.requiredRole && item.requiredRole !== userRole) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查当前菜单是否在所选应用模式中显示
|
||||
if (!visibleMenuIds.includes(item.id)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={`sidebar ${collapsed ? 'collapsed' : ''}`}>
|
||||
<div className="py-6 px-4 border-b border-gray-100 flex justify-between items-center">
|
||||
<div className="flex items-center"
|
||||
onClick={() => {
|
||||
navigate('/');
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
navigate('/');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<img src="/logo.svg" alt="智慧法务" className="w-12 h-12 mr-2" />
|
||||
{!collapsed && <h2 className="text-lg font-medium">智慧法务</h2>}
|
||||
</div>
|
||||
<button
|
||||
className="sidebar-toggle"
|
||||
onClick={handleToggleSidebar}
|
||||
aria-label={collapsed ? "展开侧边栏" : "折叠侧边栏"}
|
||||
type="button"
|
||||
>
|
||||
<i className={`${collapsed ? 'ri-menu-unfold-line' : 'ri-menu-fold-line'}`}></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!collapsed && (
|
||||
<div className="px-4 py-3 border-b border-gray-100">
|
||||
<div className="flex items-center text-green-700">
|
||||
<i className={`${APP_ICON_MAP[currentApp] || 'ri-file-list-2-fill'} mr-2 text-xl`}></i>
|
||||
<span className="font-medium">{APP_NAME_MAP[currentApp] || '合同管理'}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="py-4 px-[10px]">
|
||||
{filteredMenuItems.map((item) => (
|
||||
<div key={item.id} className={`${collapsed ? 'px-0' : ''}`}>
|
||||
{!item.children ? (
|
||||
<Link
|
||||
to={item.path}
|
||||
className={`sidebar-menu-item ${isActive(item.path) ? 'active' : ''} flex items-center ${collapsed ? 'justify-center' : ''}`}
|
||||
onClick={(e) => {
|
||||
// 只阻止冒泡,不阻止默认行为
|
||||
e.stopPropagation();
|
||||
// console.log('单级菜单点击:', item.title, '路径:', item.path);
|
||||
}}
|
||||
>
|
||||
<i className={`${item.icon} ${collapsed ? 'text-xl' : 'mr-3'}`}></i>
|
||||
{!collapsed && <span>{item.title}</span>}
|
||||
</Link>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
className={`sidebar-menu-item flex items-center ${collapsed ? 'justify-center' : 'justify-between'} cursor-pointer z-10`}
|
||||
onClick={(e) => {
|
||||
// console.log('%c父菜单点击 ===> ', 'background: #722ed1; color: white; padding: 2px 4px; border-radius: 2px;', item.title);
|
||||
toggleMenu(item.id, e);
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-expanded={expandedMenus[item.id] || false}
|
||||
aria-controls={`submenu-${item.id}`}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
toggleMenu(item.id, e as unknown as React.MouseEvent);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<i className={`${item.icon} ${collapsed ? 'text-xl' : 'mr-3'}`}></i>
|
||||
{!collapsed && <span>{item.title}</span>}
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<i className={`ri-arrow-${expandedMenus[item.id] ? 'down' : 'right'}-s-line`}></i>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(expandedMenus[item.id] || collapsed) && (
|
||||
<div
|
||||
className={`submenu-container ${collapsed ? 'border-l-0 pl-0' : 'border-l border-gray-100 ml-4 pl-3'} z-20`}
|
||||
id={`submenu-${item.id}`}
|
||||
>
|
||||
{item.children
|
||||
.filter(child => !child.requiredRole || child.requiredRole === userRole)
|
||||
.map((child) => (
|
||||
<Link
|
||||
key={child.id}
|
||||
to={child.path}
|
||||
className={`sidebar-menu-item ${isActive(child.path) ? 'active' : ''} flex items-center ${collapsed ? 'justify-center' : ''}`}
|
||||
onClick={(e) => handleSubMenuClick(child, e)}
|
||||
>
|
||||
<i className={`${child.icon} ${collapsed ? 'text-xl' : 'mr-3'}`}></i>
|
||||
{!collapsed && <span>{child.title}</span>}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user