feat: 添加项目面板组件,支持项目点击事件;优化 BotHelperModal 和 Code3DGraph 组件,增强用户交互体验
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { BotIcon, XIcon, FileIcon, FolderIcon, DatabaseIcon, MoreHorizontalIcon } from 'lucide-react';
|
||||
import { useNavigate, useLocation } from '@tanstack/react-router';
|
||||
import { toast } from 'sonner';
|
||||
import { useBotHelperStore, BOT_KEYS, BotKey } from '../store/bot-helper';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { useCodeGraphStore, NodeInfoData } from '../store';
|
||||
@@ -46,13 +47,22 @@ export function BotHelperModal() {
|
||||
const res = await createQuestion({
|
||||
question: botHelperStore.input,
|
||||
projectPath: nodeInfoData.projectPath,
|
||||
filePath: nodeInfoData.kind === 'file' ? nodeInfoData.fullPath : undefined,
|
||||
engine: botHelperStore.activeKey,
|
||||
});
|
||||
console.log(res);
|
||||
}
|
||||
toast.success('消息发送成功');
|
||||
botHelperStore.closeModal();
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.ctrlKey && e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleConfirm();
|
||||
}
|
||||
};
|
||||
|
||||
if (!botHelperStore.open) return null;
|
||||
|
||||
return (
|
||||
@@ -124,6 +134,7 @@ export function BotHelperModal() {
|
||||
placeholder='请输入内容...'
|
||||
value={botHelperStore.input}
|
||||
onChange={(e) => botHelperStore.setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
|
||||
@@ -185,11 +185,12 @@ interface Code3DGraphProps {
|
||||
files: FileProjectData[];
|
||||
className?: string;
|
||||
type?: "map" | 'minimap';
|
||||
onProjectFocus?: (projectPath: string) => void;
|
||||
}
|
||||
|
||||
// ─── 主组件 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export function Code3DGraph({ files, className, type }: Code3DGraphProps) {
|
||||
export function Code3DGraph({ files, className, type, onProjectFocus }: Code3DGraphProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const graphRef = useRef<ForceGraph3DInstance | null>(null);
|
||||
const searchBoxRef = useRef<NodeSearchBoxHandle>(null);
|
||||
@@ -230,6 +231,19 @@ export function Code3DGraph({ files, className, type }: Code3DGraphProps) {
|
||||
);
|
||||
}, []);
|
||||
|
||||
// 外部触发项目跳转
|
||||
useEffect(() => {
|
||||
if (!onProjectFocus) return;
|
||||
const rootKey = `root::${onProjectFocus}`;
|
||||
const graph = graphRef.current;
|
||||
if (graph) {
|
||||
const node = (graph.graphData().nodes as Graph3DNode[]).find((n) => n.id === rootKey);
|
||||
if (node) {
|
||||
focusNode(rootKey);
|
||||
}
|
||||
}
|
||||
}, [onProjectFocus, focusNode]);
|
||||
|
||||
// 将节点世界坐标投影为屏幕坐标
|
||||
const nodeToScreenPos = useCallback((node: Graph3DNode): { x: number; y: number } => {
|
||||
const graph = graphRef.current;
|
||||
@@ -451,5 +465,3 @@ export function Code3DGraph({ files, className, type }: Code3DGraphProps) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Code3DGraph;
|
||||
|
||||
88
src/pages/code-graph/components/ProjectPanel.tsx
Normal file
88
src/pages/code-graph/components/ProjectPanel.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { GripVertical, X } from 'lucide-react';
|
||||
import { useCodeGraphStore } from '../store';
|
||||
import { FileProjectData } from '../modules/tree';
|
||||
|
||||
interface ProjectPanelProps {
|
||||
onProjectClick?: (projectPath: string, files: FileProjectData[]) => void;
|
||||
}
|
||||
|
||||
export function ProjectPanel({ onProjectClick }: ProjectPanelProps) {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [position, setPosition] = useState({ x: 20, y: 80 });
|
||||
const dragOffset = useRef({ x: 0, y: 0 });
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { projects, files } = useCodeGraphStore(
|
||||
useShallow((s) => ({
|
||||
projects: s.projects,
|
||||
files: s.files,
|
||||
})),
|
||||
);
|
||||
|
||||
const activeProjects = projects.filter((p) => p.status === 'active');
|
||||
|
||||
const [isLargeScreen, setIsLargeScreen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const checkScreen = () => {
|
||||
setIsLargeScreen(window.innerWidth >= 1024);
|
||||
};
|
||||
checkScreen();
|
||||
window.addEventListener('resize', checkScreen);
|
||||
return () => window.removeEventListener('resize', checkScreen);
|
||||
}, []);
|
||||
|
||||
if (activeProjects.length === 0 || !isLargeScreen) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={panelRef}
|
||||
className='fixed z-50 select-none'
|
||||
style={{
|
||||
left: position.x,
|
||||
top: position.y,
|
||||
maxHeight: 'calc(100vh - 100px)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`
|
||||
flex flex-col w-56 bg-slate-900/95 backdrop-blur-sm rounded-lg border border-white/10 shadow-xl
|
||||
transition-opacity duration-200
|
||||
${isDragging ? 'cursor-grabbing' : 'cursor-grab'}
|
||||
`}
|
||||
onMouseDown={handleMouseDown}
|
||||
>
|
||||
{/* 拖动手柄 */}
|
||||
<div className='flex items-center justify-between px-3 py-2 border-b border-white/10 cursor-grab active:cursor-grabbing'>
|
||||
<div className='flex items-center gap-2 text-xs font-medium text-slate-300'>
|
||||
<GripVertical className='w-3 h-3' />
|
||||
<span>项目列表</span>
|
||||
</div>
|
||||
<span className='text-xs text-slate-500'>{activeProjects.length}</span>
|
||||
</div>
|
||||
|
||||
{/* 项目列表 */}
|
||||
<div className='flex flex-col py-1 max-h-80 overflow-y-auto'>
|
||||
{activeProjects.map((project) => {
|
||||
const projectName = project.name || project.path.split('/').pop() || project.path;
|
||||
return (
|
||||
<button
|
||||
key={project.path}
|
||||
onClick={() => handleProjectClick(project.path)}
|
||||
className='flex items-center gap-2 px-3 py-2 text-left text-sm text-slate-300 hover:bg-white/5 hover:text-white transition-colors border-l-2 border-transparent hover:border-indigo-500'
|
||||
>
|
||||
<span className='truncate flex-1' title={projectName}>
|
||||
{projectName}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ProjectPanel;
|
||||
@@ -8,15 +8,18 @@ import { Code3DGraph } from './components/Code3DGraph';
|
||||
import { NodeInfoContainer } from './components/NodeInfo';
|
||||
import { ProjectDialog } from './components/ProjectDialog';
|
||||
import { BotHelperModal } from './components/BotHelperModal';
|
||||
import { ProjectPanel } from './components/ProjectPanel';
|
||||
import { useLayoutStore } from '../auth/store';
|
||||
import type { FileProjectData } from './modules/tree';
|
||||
type ViewMode = '2d' | '3d';
|
||||
|
||||
export default function CodeGraphPage() {
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('3d');
|
||||
const [projectFocus, setProjectFocus] = useState<string | null>(null);
|
||||
const layoutStore = useLayoutStore(useShallow((s) => ({
|
||||
me: s.me,
|
||||
})));
|
||||
const { codePodOpen, setCodePodOpen, codePodAttrs, setProjectDialogOpen, init, files, fetchProjects } = useCodeGraphStore(
|
||||
const { codePodOpen, setCodePodOpen, codePodAttrs, setProjectDialogOpen, init, files, fetchProjects, setFiles } = useCodeGraphStore(
|
||||
useShallow((s) => ({
|
||||
files: s.files,
|
||||
setFiles: s.setFiles,
|
||||
@@ -34,6 +37,16 @@ export default function CodeGraphPage() {
|
||||
init(layoutStore.me);
|
||||
}, [layoutStore.me]);
|
||||
|
||||
const handleProjectClick = (projectPath: string, projectFiles: FileProjectData[]) => {
|
||||
if (viewMode === '3d') {
|
||||
setFiles(projectFiles);
|
||||
setTimeout(() => {
|
||||
setProjectFocus(projectPath);
|
||||
setTimeout(() => setProjectFocus(null), 100);
|
||||
}, 150);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='flex flex-col h-full bg-slate-950 text-slate-100'>
|
||||
{/* 顶部工具栏 */}
|
||||
@@ -77,7 +90,10 @@ export default function CodeGraphPage() {
|
||||
{/* 图视图 */}
|
||||
<div className='flex-1 min-h-0'>
|
||||
{viewMode === '3d' ? (
|
||||
<Code3DGraph files={files} className='h-full' />
|
||||
<>
|
||||
<Code3DGraph files={files} className='h-full' onProjectFocus={projectFocus ?? undefined} />
|
||||
<ProjectPanel onProjectClick={handleProjectClick} />
|
||||
</>
|
||||
) : (
|
||||
<CodeGraphView files={files} className='h-full' />
|
||||
)}
|
||||
|
||||
@@ -64,7 +64,7 @@ type State = {
|
||||
projectPath?: string; // 项目路径,必填
|
||||
getContent?: boolean; // 是否获取文件内容,默认为 false
|
||||
}) => Promise<Result<{ list: FileProjectData[] }>>;
|
||||
createQuestion: (opts: { question: string, projectPath: string, engine?: 'openclaw' | 'opencode' }) => any;
|
||||
createQuestion: (opts: { question: string, projectPath: string, filePath?: string, engine?: 'openclaw' | 'opencode', }) => any;
|
||||
saveFile: (filepath: string, content: string) => Promise<void>;
|
||||
isMobile: boolean;
|
||||
setIsMobile: (isMobile: boolean) => void;
|
||||
@@ -274,11 +274,15 @@ export const useCodeGraphStore = create<State>()((set, get) => ({
|
||||
isMobile: false,
|
||||
setIsMobile: (isMobile) => set({ isMobile }),
|
||||
createQuestion: async (opts) => {
|
||||
const { question, projectPath, engine = 'opencode' } = opts;
|
||||
const { question, projectPath, filePath, engine = 'opencode' } = opts;
|
||||
const url = get().url
|
||||
const q = `
|
||||
let q = `
|
||||
${question}
|
||||
项目路径: ${projectPath}`
|
||||
if (filePath && filePath !== projectPath) {
|
||||
q += `
|
||||
文件路径: ${filePath}`;
|
||||
}
|
||||
const res = await opencodeApi["opencode-cnb"].question({
|
||||
question: q,
|
||||
directory: projectPath,
|
||||
|
||||
Reference in New Issue
Block a user