feat: 添加项目面板组件,支持项目点击事件;优化 BotHelperModal 和 Code3DGraph 组件,增强用户交互体验

This commit is contained in:
xiongxiao
2026-03-16 01:49:39 +08:00
committed by cnb
parent 66f70b144a
commit 8ad1254341
5 changed files with 139 additions and 8 deletions

View File

@@ -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

View File

@@ -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;

View 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;

View File

@@ -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' />
)}

View File

@@ -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,