feat: 增强项目 API,添加项目路径和文件过滤功能;更新 BotHelperModal 和 NodeInfo 组件,优化状态管理和交互体验;改进 ProjectDialog,支持多选和项目初始化功能

This commit is contained in:
xiongxiao
2026-03-15 23:19:38 +08:00
committed by cnb
parent fc533701f6
commit 1afd39b970
8 changed files with 448 additions and 48 deletions

View File

@@ -276,7 +276,6 @@ const api = {
*
* @param data - Request parameters
* @param data.q - {string} 搜索关键词,选填;留空或不传则返回全部文件
* @param data.projectPath - {string} 按项目根目录路径过滤,仅返回该项目下的文件,选填
* @param data.filepath - {string} 按文件绝对路径过滤,选填
* @param data.repo - {string} 按代码仓库标识过滤(如 owner/repo选填
* @param data.title - {string} 按人工标注的标题字段过滤,选填
@@ -287,6 +286,7 @@ const api = {
* @param data.sort - {array} 排序规则数组,格式为 ["字段:asc"] 或 ["字段:desc"],选填,当 q 为空时默认为 ["projectPath:asc"]
* @param data.limit - {number} 返回结果数量上限,选填,当 q 为空时默认为 1000
* @param data.getContent - {boolean} 是否返回文件内容,默认为 false如果为 true则在结果中包含 content 字段,内容以 base64 编码返回,适用于前端预览或下载场景
* @param data.projects - {array} 按项目名称列表过滤,选填,默认不穿,只过滤当前工作区的项目
*/
"files": {
"path": "project-search",
@@ -300,12 +300,120 @@ const api = {
"type": "string",
"optional": true
},
"projectPath": {
"filepath": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"description": "按项目根目录路径过滤,仅返回该项目下的文件,选填",
"description": "按文件绝对路径过滤,选填",
"type": "string",
"optional": true
},
"repo": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"description": "按代码仓库标识过滤(如 owner/repo选填",
"type": "string",
"optional": true
},
"title": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"description": "按人工标注的标题字段过滤,选填",
"type": "string",
"optional": true
},
"tags": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"description": "按人工标注的标签列表过滤,选填",
"type": "array",
"items": {
"type": "string"
},
"optional": true
},
"summary": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"description": "按人工标注的摘要字段过滤,选填",
"type": "string",
"optional": true
},
"description": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"description": "按人工标注的描述字段过滤,选填",
"type": "string",
"optional": true
},
"link": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"description": "按人工标注的外部链接字段过滤,选填",
"type": "string",
"optional": true
},
"sort": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"description": "排序规则数组,格式为 [\"字段:asc\"] 或 [\"字段:desc\"],选填,当 q 为空时默认为 [\"projectPath:asc\"]",
"type": "array",
"items": {
"type": "string"
},
"optional": true
},
"limit": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"description": "返回结果数量上限,选填,当 q 为空时默认为 1000",
"type": "number",
"optional": true
},
"getContent": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"description": "是否返回文件内容,默认为 false如果为 true则在结果中包含 content 字段,内容以 base64 编码返回,适用于前端预览或下载场景",
"type": "boolean",
"optional": true
},
"projects": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"description": "按项目名称列表过滤,选填,默认不穿,只过滤当前工作区的项目",
"type": "array",
"items": {
"type": "string"
},
"optional": true
}
},
"url": "/root/v1/cnb-dev",
"source": "query-proxy-api"
}
},
/**
* 在已索引的项目文件中执行全文搜索,支持按仓库、目录、标签等字段过滤,以及自定义排序和数量限制
*
* @param data - Request parameters
* @param data.q - {string} 搜索关键词,选填;留空或不传则返回全部文件
* @param data.projectPath - {string} 按项目根目录路径过滤,仅返回该项目下的文件,必填
* @param data.filepath - {string} 按文件绝对路径过滤,选填
* @param data.repo - {string} 按代码仓库标识过滤(如 owner/repo选填
* @param data.title - {string} 按人工标注的标题字段过滤,选填
* @param data.tags - {array} 按人工标注的标签列表过滤,选填
* @param data.summary - {string} 按人工标注的摘要字段过滤,选填
* @param data.description - {string} 按人工标注的描述字段过滤,选填
* @param data.link - {string} 按人工标注的外部链接字段过滤,选填
* @param data.sort - {array} 排序规则数组,格式为 ["字段:asc"] 或 ["字段:desc"],选填,当 q 为空时默认为 ["projectPath:asc"]
* @param data.limit - {number} 返回结果数量上限,选填,当 q 为空时默认为 1000
* @param data.getContent - {boolean} 是否返回文件内容,默认为 false如果为 true则在结果中包含 content 字段,内容以 base64 编码返回,适用于前端预览或下载场景
*/
"search": {
"path": "project-search",
"key": "search",
"description": "在已索引的项目文件中执行全文搜索,支持按仓库、目录、标签等字段过滤,以及自定义排序和数量限制",
"metadata": {
"args": {
"q": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"description": "搜索关键词,选填;留空或不传则返回全部文件",
"type": "string",
"optional": true
},
"projectPath": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "string",
"description": "按项目根目录路径过滤,仅返回该项目下的文件,必填"
},
"filepath": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"description": "按文件绝对路径过滤,选填",

View File

@@ -1,4 +1,5 @@
import { BotIcon, XIcon, FileIcon, FolderIcon, DatabaseIcon } from 'lucide-react';
import { BotIcon, XIcon, FileIcon, FolderIcon, DatabaseIcon, MoreHorizontalIcon } from 'lucide-react';
import { getDynamicBasename, wrapBasename } from '@/modules/basename';
import { useBotHelperStore, BOT_KEYS, BotKey } from '../store/bot-helper';
import { useShallow } from 'zustand/react/shallow';
import { useCodeGraphStore, NodeInfoData } from '../store';
@@ -18,7 +19,7 @@ function NodeIcon({ kind, color }: { kind: NodeInfoData['kind']; color: string }
}
export function BotHelperModal() {
const { open, input, setInput, closeModal, activeKey, setActiveKey } = useBotHelperStore(
const botHelperStore = useBotHelperStore(
useShallow((s) => ({
open: s.open,
input: s.input,
@@ -40,16 +41,16 @@ export function BotHelperModal() {
const handleConfirm = async () => {
if (nodeInfoData) {
const res = await createQuestion({
question: input,
question: botHelperStore.input,
projectPath: nodeInfoData.projectPath,
engine: activeKey,
engine: botHelperStore.activeKey,
});
console.log(res);
}
closeModal();
botHelperStore.closeModal();
};
if (!open) return null;
if (!botHelperStore.open) return null;
return (
<div className='fixed inset-0 z-[200] flex items-center justify-center'>
@@ -62,11 +63,22 @@ export function BotHelperModal() {
<BotIcon className='size-4 text-emerald-400' />
<span className='text-sm font-medium text-slate-100'>AI </span>
</div>
<button
onClick={closeModal}
className='text-slate-500 hover:text-slate-200 transition-colors'>
<XIcon className='size-4' />
</button>
<div className='flex items-center gap-1'>
<button
onClick={() => {
const timestamp = Date.now();
window.open(`/code-graph?timestamp=${timestamp}`, '_blank');
}}
className='text-slate-500 hover:text-slate-200 transition-colors p-1 rounded hover:bg-white/10'
title='新窗口打开'>
<MoreHorizontalIcon className='size-4' />
</button>
<button
onClick={botHelperStore.closeModal}
className='text-slate-500 hover:text-slate-200 transition-colors'>
<XIcon className='size-4' />
</button>
</div>
</div>
{/* 内容区 */}
<div className='px-4 py-4 flex flex-col gap-4'>
@@ -88,8 +100,8 @@ export function BotHelperModal() {
<button
key={key}
title={key}
onClick={() => setActiveKey(key)}
className={`p-1.5 rounded-lg border transition-colors ${activeKey === key
onClick={() => botHelperStore.setActiveKey(key)}
className={`p-1.5 rounded-lg border transition-colors ${botHelperStore.activeKey === key
? 'border-emerald-500/60 bg-emerald-500/10'
: 'border-white/5 bg-slate-800/60 opacity-40 hover:opacity-70'
}`}>
@@ -102,8 +114,8 @@ export function BotHelperModal() {
className='w-full rounded-lg border border-white/10 bg-slate-800 px-3 py-2 text-sm text-slate-200 placeholder:text-slate-500 focus:outline-none focus:ring-1 focus:ring-emerald-500 resize-none'
rows={5}
placeholder='请输入内容...'
value={input}
onChange={(e) => setInput(e.target.value)}
value={botHelperStore.input}
onChange={(e) => botHelperStore.setInput(e.target.value)}
autoFocus
/>
<button

View File

@@ -180,17 +180,18 @@ function buildGraph3DData(files: FileProjectData[]): Graph3DData {
interface Code3DGraphProps {
files: FileProjectData[];
className?: string;
type?: "map" | 'minimap';
}
// ─── 主组件 ───────────────────────────────────────────────────────────────────
export function Code3DGraph({ files, className }: Code3DGraphProps) {
export function Code3DGraph({ files, className, type }: Code3DGraphProps) {
const containerRef = useRef<HTMLDivElement>(null);
const graphRef = useRef<ForceGraph3DInstance | null>(null);
const searchBoxRef = useRef<NodeSearchBoxHandle>(null);
const [searchIndex, setSearchIndex] = useState<NodeSearchEntry[]>([]);
const { setNodeInfo, selectedNodeId } = useCodeGraphStore(
const codeGraphStore = useCodeGraphStore(
useShallow((s) => ({
setNodeInfo: s.setNodeInfo,
selectedNodeId: s.nodeInfoData?.fullPath ?? null,
@@ -200,8 +201,8 @@ export function Code3DGraph({ files, className }: Code3DGraphProps) {
// 用 ref 避免 nodeThreeObject 回调的陈旧闭包
const selectedNodeIdRef = useRef<string | null>(null);
useEffect(() => {
selectedNodeIdRef.current = selectedNodeId;
}, [selectedNodeId]);
selectedNodeIdRef.current = codeGraphStore.selectedNodeId;
}, [codeGraphStore.selectedNodeId]);
// 节点跳转
const focusNode = useCallback((nodeKey: string) => {
@@ -301,7 +302,7 @@ export function Code3DGraph({ files, className }: Code3DGraphProps) {
// 等相机飞行动画结束800ms用节点投影坐标打开 NodeInfo
setTimeout(() => {
const pos = nodeToScreenPos(n);
setNodeInfo(
codeGraphStore.setNodeInfo(
{
label: n.label,
fullPath: n.fullPath,
@@ -345,7 +346,7 @@ export function Code3DGraph({ files, className }: Code3DGraphProps) {
return () => {
ro.disconnect();
};
}, [files, focusNode, setNodeInfo, nodeToScreenPos]);
}, [files, focusNode, codeGraphStore.setNodeInfo, nodeToScreenPos]);
// 卸载时销毁
useEffect(() => {
@@ -361,7 +362,7 @@ export function Code3DGraph({ files, className }: Code3DGraphProps) {
if (graph) {
graph.refresh();
}
}, [selectedNodeId]);
}, [codeGraphStore.selectedNodeId]);
return (
<div className={`relative w-full h-full overflow-hidden ${className ?? ''}`}>
@@ -378,7 +379,7 @@ export function Code3DGraph({ files, className }: Code3DGraphProps) {
if (!n) return;
setTimeout(() => {
const pos = nodeToScreenPos(n);
setNodeInfo(
codeGraphStore.setNodeInfo(
{
label: n.label,
fullPath: n.fullPath,

View File

@@ -184,6 +184,12 @@ export function CodePod({ open, onClose, nodeAttrs }: CodePodProps) {
getFiles: s.getFiles,
saveFile: s.saveFile,
})));
const botHelperStore = useBotHelperStore(useShallow((s) => ({
openModal: s.openModal,
setProjectInfo: s.setProjectInfo,
})));
const [showFileName, setShowFileName] = useState(true); // 是否显示文件名(移动端默认隐藏)
const [showProjectPath, setShowProjectPath] = useState(false); // 是否显示项目路径(移动端默认隐藏)
const projectPath = nodeAttrs?.projectPath ?? '';
const displayPath = rootPath.startsWith(projectPath)
? rootPath.slice(projectPath.length) || '/'
@@ -233,7 +239,11 @@ export function CodePod({ open, onClose, nodeAttrs }: CodePodProps) {
};
const handleAIOpen = () => {
useBotHelperStore.getState().openModal();
botHelperStore.setProjectInfo({
filepath: nodeAttrs?.fullPath ?? '',
projectPath: nodeAttrs?.projectPath ?? '',
});
botHelperStore.openModal();
};
useEffect(() => {
@@ -275,8 +285,8 @@ export function CodePod({ open, onClose, nodeAttrs }: CodePodProps) {
{/* 标题:显示相对路径(去掉 projectPath */}
<div className='px-3 py-2.5 border-b border-white/10 shrink-0 min-w-[14rem]'>
<div className='text-[10px] text-slate-500 mb-0.5'></div>
<div className='text-xs font-semibold text-slate-300 truncate' title={rootPath}>
{displayPath}
<div className='text-xs font-semibold text-slate-300 truncate' title={rootPath} onClick={() => setShowProjectPath((v) => !v)} style={{ cursor: 'pointer' }}>
{showProjectPath ? rootPath : displayPath}
</div>
</div>
{/* 目录树 */}
@@ -299,7 +309,7 @@ export function CodePod({ open, onClose, nodeAttrs }: CodePodProps) {
{/* 编辑器区域 */}
<div className='flex-1 flex flex-col min-w-0'>
{/* 编辑器标题栏 */}
<div className='flex items-center gap-2 px-4 py-2.5 border-b border-white/10 text-xs text-slate-400 shrink-0'>
<div className='flex items-center gap-2 px-4 py-2.5 border-b border-white/10 text-xs text-slate-400 shrink-0 mr-6'>
{/* 侧边栏切换按钮 */}
<button
onClick={() => setSidebarOpen((v) => !v)}
@@ -307,8 +317,8 @@ export function CodePod({ open, onClose, nodeAttrs }: CodePodProps) {
title={sidebarOpen ? '收起侧边栏' : '展开侧边栏'}>
{sidebarOpen ? '◀' : '▶'}
</button>
<span className='truncate text-slate-200'>
{selectedFile?.filepath ?? nodeAttrs.fullPath}
<span className='truncate text-slate-200' title={selectedFile?.filepath ?? nodeAttrs.fullPath} onClick={() => setShowFileName((v) => !v)} style={{ cursor: 'pointer' }}>
{showFileName ? filename : selectedFile?.filepath ?? nodeAttrs.fullPath}
</span>
<div className='ml-1 flex items-center gap-1 shrink-0'>
{loading && <span className='text-slate-500 text-xs'></span>}

View File

@@ -3,6 +3,7 @@ import { FileIcon, FolderIcon, DatabaseIcon, XIcon, MoveIcon, SquarePenIcon, Bot
import { useCodeGraphStore, NodeInfoData } from '../store';
import { useBotHelperStore } from '../store/bot-helper';
import { useShallow } from 'zustand/react/shallow';
import clsx from 'clsx';
function KindIcon({ kind, color }: { kind: NodeInfoData['kind']; color: string }) {
const cls = 'size-4 shrink-0';
@@ -21,7 +22,6 @@ export function NodeInfo() {
const codeGraphStore = useCodeGraphStore(
useShallow((s) => ({
nodeInfoData: s.nodeInfoData,
})),
);
const projectPath = codeGraphStore.nodeInfoData?.projectPath || '';
@@ -51,14 +51,18 @@ export function NodeInfo() {
</div></>)
}
export const NodeInfoContainer = () => {
const { nodeInfoOpen, nodeInfoData, nodeInfoPos, closeNodeInfo, setCodePodOpen, setCodePodAttrs } = useCodeGraphStore(
const { nodeInfoOpen, nodeInfoData, nodeInfoPos, closeNodeInfo, codePodOpen, setCodePodOpen, setCodePodAttrs, isMobile, setIsMobile } = useCodeGraphStore(
useShallow((s) => ({
nodeInfoOpen: s.nodeInfoOpen,
codePodOpen: s.codePodOpen,
nodeInfoData: s.nodeInfoData,
nodeInfoPos: s.nodeInfoPos,
setNodeInfoOpen: s.setNodeInfo,
closeNodeInfo: s.closeNodeInfo,
setCodePodOpen: s.setCodePodOpen,
setCodePodAttrs: s.setCodePodAttrs,
isMobile: s.isMobile,
setIsMobile: s.setIsMobile,
})),
);
@@ -75,18 +79,21 @@ export const NodeInfoContainer = () => {
kind: nodeInfoData.kind,
fileId: nodeInfoData.fileId,
});
setCodePodOpen(true);
// 移到左上角,避免遮挡编辑器
setPinLeft(true);
setOffset({ x: 0, y: 0 });
};
const openBotModal = useBotHelperStore((s) => s.openModal);
const botHelperStore = useBotHelperStore(useShallow((s) => ({
openModal: s.openModal,
setProjectInfo: s.setProjectInfo,
})));
// 拖拽偏移
const [offset, setOffset] = useState({ x: 0, y: 0 });
const [pinLeft, setPinLeft] = useState(false); // 编辑后固定到右下角
const [isMobile, setIsMobile] = useState(false);
// 检测屏幕大小
useEffect(() => {
@@ -143,11 +150,11 @@ export const NodeInfoContainer = () => {
: { left: nodeInfoPos.x + offset.x + 40, top: nodeInfoPos.y + offset.y - 40 };
const name = nodeInfoData.fullPath.split('/').pop() || nodeInfoData.label;
const projectPath = nodeInfoData.projectPath || '';
const relativePath = nodeInfoData.fullPath.replace(projectPath + '/', '') || '/';
return (
<div
className={`fixed z-50 rounded-xl border border-white/10 bg-slate-900/95 backdrop-blur-sm shadow-2xl select-none ${isMobile ? 'w-full h-full' : 'w-72'}`}
className={clsx(`fixed z-50 rounded-xl border border-white/10 bg-slate-900/95 backdrop-blur-sm shadow-2xl select-none`, isMobile ? 'w-full h-full' : 'w-72',
isMobile && codePodOpen && 'hidden' // 移动端如果 CodePod 打开了,就隐藏 NodeInfo避免遮挡
)}
style={posStyle}
onMouseDown={isMobile ? undefined : onMouseDown}>
{/* 标题栏 */}
@@ -164,7 +171,13 @@ export const NodeInfoContainer = () => {
<SquarePenIcon className='size-3.5' />
</button>
<button
onClick={openBotModal}
onClick={() => {
botHelperStore.setProjectInfo({
filepath: nodeInfoData.fullPath,
projectPath: nodeInfoData.projectPath,
});
botHelperStore.openModal();
}}
title='AI 助手'
className='ml-1 text-slate-500 hover:text-emerald-400 transition-colors'>
<BotIcon className='size-3.5' />

View File

@@ -1,11 +1,12 @@
import { useState } from 'react';
import { useShallow } from 'zustand/react/shallow';
import { FolderOpenIcon, PlusIcon, Trash2Icon, RefreshCwIcon, PlayCircleIcon, StopCircleIcon, FolderIcon, AlertCircleIcon, CircleOffIcon } from 'lucide-react';
import { FolderOpenIcon, PlusIcon, Trash2Icon, RefreshCwIcon, PlayCircleIcon, StopCircleIcon, FolderIcon, AlertCircleIcon, CircleOffIcon, DownloadIcon, ListTodoIcon, CheckSquareIcon } from 'lucide-react';
import { toast } from 'sonner';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useCodeGraphStore } from '../store';
export function ProjectDialog() {
@@ -18,6 +19,7 @@ export function ProjectDialog() {
addProject,
removeProject,
toggleProjectStatus,
initProject,
} = useCodeGraphStore(
useShallow((s) => ({
projectDialogOpen: s.projectDialogOpen,
@@ -28,6 +30,7 @@ export function ProjectDialog() {
addProject: s.addProject,
removeProject: s.removeProject,
toggleProjectStatus: s.toggleProjectStatus,
initProject: s.initProject,
})),
);
@@ -50,6 +53,64 @@ export function ProjectDialog() {
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [pendingDeleteProject, setPendingDeleteProject] = useState<{ path: string; name?: string } | null>(null);
// 初始化确认弹窗
const [initConfirmOpen, setInitConfirmOpen] = useState(false);
const [initLoading, setInitLoading] = useState(false);
// 多选模式
const [multiSelectMode, setMultiSelectMode] = useState(false);
const [selectedProjects, setSelectedProjects] = useState<string[]>([]);
// 切换多选模式
const toggleMultiSelectMode = () => {
if (multiSelectMode) {
// 退出多选模式时清空选择
setSelectedProjects([]);
}
setMultiSelectMode(!multiSelectMode);
};
// 切换项目选中状态
const toggleProjectSelection = (path: string) => {
setSelectedProjects((prev) =>
prev.includes(path) ? prev.filter((p) => p !== path) : [...prev, path]
);
};
// 全选
const selectAll = () => {
setSelectedProjects(projects.map((p) => p.path));
};
// 取消全选
const deselectAll = () => {
setSelectedProjects([]);
};
// 全部启动
const handleStartAll = async () => {
for (const path of selectedProjects) {
const project = projects.find((p) => p.path === path);
if (project && project.status !== 'active') {
await toggleProjectStatus(path);
}
}
setSelectedProjects([]);
setMultiSelectMode(false);
};
// 全部关闭
const handleStopAll = async () => {
for (const path of selectedProjects) {
const project = projects.find((p) => p.path === path);
if (project && project.status === 'active') {
await toggleProjectStatus(path);
}
}
setSelectedProjects([]);
setMultiSelectMode(false);
};
const handleAdd = async () => {
if (!newPath.trim()) return;
setAddLoading(true);
@@ -114,27 +175,42 @@ export function ProjectDialog() {
setPendingDeleteProject(null);
};
// 确认初始化
const handleConfirmInit = async () => {
setInitLoading(true);
await initProject();
setInitLoading(false);
setInitConfirmOpen(false);
};
// 取消初始化
const handleCancelInit = () => {
setInitConfirmOpen(false);
};
return (
<Dialog open={projectDialogOpen} onOpenChange={handleOpenChange}>
<DialogContent className='sm:max-w-lg bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 text-slate-100 border border-white/10 shadow-2xl'>
<DialogContent className='w-[calc(100%-2rem)] sm:max-w-lg max-h-[85vh] flex flex-col bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 text-slate-100 border border-white/10 shadow-2xl overflow-hidden'>
{/* 装饰性背景 */}
<div className='absolute inset-0 overflow-hidden pointer-events-none'>
<div className='absolute -top-20 -right-20 w-40 h-40 bg-indigo-500/10 rounded-full blur-3xl' />
<div className='absolute -bottom-20 -left-20 w-40 h-40 bg-purple-500/10 rounded-full blur-3xl' />
</div>
<DialogHeader className='relative'>
<DialogHeader className='relative shrink-0'>
<DialogTitle className='flex items-center gap-3 text-slate-100 text-lg'>
<div className='p-2 rounded-xl bg-gradient-to-br from-indigo-500 to-purple-600 shadow-lg shadow-indigo-500/25'>
<div className='p-2 rounded-xl bg-gradient-to-br from-indigo-500 to-purple-600 shadow-lg shadow-indigo-500/25 shrink-0'>
<FolderOpenIcon className='w-5 h-5 text-white' />
</div>
</DialogTitle>
<DialogDescription className='text-slate-400 ml-14'>
<DialogDescription className='text-slate-400 ml-0'>
</DialogDescription>
</DialogHeader>
{/* 内容区域 - 可滚动 */}
<div className='flex-1 overflow-y-auto space-y-4 pr-1 scrollbar-thin scrollbar-thumb-slate-700 scrollbar-track-transparent -mx-2 px-2'>
{/* 新增项目 */}
{showAddProject && (
<div className='relative space-y-3 rounded-xl bg-white/5 p-4 border border-white/10 shadow-lg backdrop-blur-sm'>
@@ -241,6 +317,104 @@ export function ProjectDialog() {
title='刷新'>
<RefreshCwIcon className={`w-4 h-4 ${projectsLoading ? 'animate-spin' : ''}`} />
</button>
<Tooltip>
<TooltipTrigger>
<div
onClick={() => setInitConfirmOpen(true)}
className='text-slate-500 hover:text-indigo-400 transition-colors p-1.5 rounded-lg hover:bg-white/5 disabled:opacity-50'>
<DownloadIcon className={`w-4 h-4 ${initLoading ? 'animate-spin' : ''}`} />
</div>
</TooltipTrigger>
<TooltipContent>
<p> workspace/projects </p>
</TooltipContent>
</Tooltip>
{multiSelectMode ? (
<>
<div className='w-px h-5 bg-white/20 mx-1' />
<Tooltip>
<TooltipTrigger>
<div
onClick={selectAll}
className='text-slate-500 hover:text-indigo-400 transition-colors p-1.5 rounded-lg hover:bg-white/5'
title='全选'>
<CheckSquareIcon className='w-4 h-4' />
</div>
</TooltipTrigger>
<TooltipContent>
<p></p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger>
<div
onClick={deselectAll}
className='text-slate-500 hover:text-indigo-400 transition-colors p-1.5 rounded-lg hover:bg-white/5'
title='取消全选'>
<ListTodoIcon className='w-4 h-4' />
</div>
</TooltipTrigger>
<TooltipContent>
<p></p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger>
<button
onClick={handleStartAll}
disabled={selectedProjects.length === 0}
className='text-slate-500 hover:text-green-400 transition-colors p-1.5 rounded-lg hover:bg-white/5 disabled:opacity-50'
title='全部启动'>
<PlayCircleIcon className='w-4 h-4' />
</button>
</TooltipTrigger>
<TooltipContent>
<p> ({selectedProjects.length})</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger>
<button
onClick={handleStopAll}
disabled={selectedProjects.length === 0}
className='text-slate-500 hover:text-red-400 transition-colors p-1.5 rounded-lg hover:bg-white/5 disabled:opacity-50'
title='全部关闭'>
<StopCircleIcon className='w-4 h-4' />
</button>
</TooltipTrigger>
<TooltipContent>
<p> ({selectedProjects.length})</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger>
<div
onClick={toggleMultiSelectMode}
className='text-slate-500 hover:text-indigo-400 transition-colors p-1.5 rounded-lg hover:bg-white/5'
title='退出多选'>
<CheckSquareIcon className='w-4 h-4' />
</div>
</TooltipTrigger>
<TooltipContent>
<p>退</p>
</TooltipContent>
</Tooltip>
</>
) : (
<Tooltip>
<TooltipTrigger>
<div
onClick={toggleMultiSelectMode}
className='text-slate-500 hover:text-indigo-400 transition-colors p-1.5 rounded-lg hover:bg-white/5'
title='多选'>
<CheckSquareIcon className='w-4 h-4' />
</div>
</TooltipTrigger>
<TooltipContent>
<p></p>
</TooltipContent>
</Tooltip>
)}
</div>
</div>
@@ -261,7 +435,19 @@ export function ProjectDialog() {
{projects.map((p) => (
<li
key={p.path}
className='group flex items-center gap-3 rounded-xl bg-white/5 px-4 py-3 border border-white/5 hover:bg-white/10 hover:border-white/10 transition-all duration-200'>
className={`group flex items-center gap-3 rounded-xl bg-white/5 px-4 py-3 border border-white/5 hover:bg-white/10 hover:border-white/10 transition-all duration-200 ${multiSelectMode && selectedProjects.includes(p.path) ? 'border-indigo-500/50 bg-indigo-500/10' : ''}`}>
{/* 多选框 */}
{multiSelectMode && (
<button
onClick={() => toggleProjectSelection(p.path)}
className={`shrink-0 w-5 h-5 rounded border-2 flex items-center justify-center transition-colors ${
selectedProjects.includes(p.path)
? 'bg-indigo-500 border-indigo-500 text-white'
: 'border-slate-500 hover:border-indigo-400'
}`}>
{selectedProjects.includes(p.path) && <CheckSquareIcon className='w-3 h-3' />}
</button>
)}
{/* 项目图标 */}
<div className={`shrink-0 p-2 rounded-lg ${p.status === 'active' ? 'bg-green-500/20' : p.status === 'unlive' ? 'bg-orange-500/20' : 'bg-slate-700/50'}`}>
<FolderIcon className={`w-4 h-4 ${p.status === 'active' ? 'text-green-400' : p.status === 'unlive' ? 'text-orange-400' : 'text-slate-400'}`} />
@@ -312,6 +498,7 @@ export function ProjectDialog() {
</ul>
)}
</div>
</div>
</DialogContent>
{/* 状态切换确认弹窗 */}
@@ -382,6 +569,45 @@ export function ProjectDialog() {
</DialogFooter>
</DialogContent>
</Dialog>
{/* 初始化确认弹窗 */}
<Dialog open={initConfirmOpen} onOpenChange={(open) => !open && handleCancelInit()}>
<DialogContent className='sm:max-w-md bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 text-slate-100 border border-white/10 shadow-2xl'>
<DialogHeader>
<div className='w-12 h-12 rounded-full flex items-center justify-center mx-auto mb-3 bg-indigo-500/20'>
<DownloadIcon className='w-6 h-6 text-indigo-400' />
</div>
<DialogTitle className='text-center text-lg font-semibold'>
</DialogTitle>
<DialogDescription className='text-center text-slate-400'>
workspace/projects
</DialogDescription>
</DialogHeader>
<DialogFooter className='gap-2 sm:justify-center'>
<Button
variant='outline'
onClick={handleCancelInit}
disabled={initLoading}
className='bg-transparent border-white/20 text-slate-300 hover:bg-white/10 hover:border-white/30 flex-1'>
</Button>
<Button
onClick={handleConfirmInit}
disabled={initLoading}
className='bg-indigo-600 hover:bg-indigo-500 text-white flex-1 shadow-lg shadow-indigo-500/25 disabled:opacity-50'>
{initLoading ? (
<span className='flex items-center gap-2'>
<span className='w-3 h-3 border-2 border-white/30 border-t-white rounded-full animate-spin' />
</span>
) : (
'确认初始化'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Dialog>
);
}

View File

@@ -11,6 +11,11 @@ type BotHelperState = {
setOpen: (open: boolean) => void;
setInput: (input: string) => void;
setActiveKey: (key: BotKey) => void;
projectInfo: {
filepath: string;
projectPath: string;
} | null;
setProjectInfo: (info: { filepath: string; projectPath: string } | null) => void;
openModal: () => void;
closeModal: () => void;
};
@@ -21,7 +26,10 @@ export const useBotHelperStore = create<BotHelperState>()((set) => ({
activeKey: 'opencode',
setOpen: (open) => set({ open }),
setInput: (input) => set({ input }),
setActiveKey: (key) => set({ activeKey: key }),
projectInfo: null,
setProjectInfo: (info) => set({ projectInfo: info }),
openModal: () => set({ open: true }),
closeModal: () => set({ open: false, input: '' }),
closeModal: () => set({ open: false, input: '', projectInfo: null }),
}));

View File

@@ -43,8 +43,10 @@ type State = {
addProject: (filepath: string, name?: string, type?: 'filepath' | 'cnb-repo') => Promise<boolean>;
removeProject: (path: string) => Promise<void>;
toggleProjectStatus: (path: string) => Promise<void>;
initProject: () => Promise<void>;
// NodeInfo 弹窗
nodeInfoOpen: boolean;
setNodeInfoOpen: (open: boolean) => void;
nodeInfoData: NodeInfoData | null;
nodeInfoPos: { x: number; y: number };
setNodeInfo: (data: NodeInfoData | null, pos?: { x: number; y: number }) => void;
@@ -60,6 +62,8 @@ type State = {
}) => Promise<Result<{ list: FileProjectData[] }>>;
createQuestion: (opts: { question: string, projectPath: string, engine?: 'openclaw' | 'opencode' }) => any;
saveFile: (filepath: string, content: string) => Promise<void>;
isMobile: boolean;
setIsMobile: (isMobile: boolean) => void;
};
export const useCodeGraphStore = create<State>()((set, get) => ({
@@ -117,6 +121,20 @@ export const useCodeGraphStore = create<State>()((set, get) => ({
return false;
}
},
initProject: async () => {
try {
const url = get().url || API_URL;
const res = await projectApi.project.init(undefined, { url });
if (res.code === 200) {
toast.success('项目初始化成功');
await get().loadProjects();
} else {
toast.error(res.message ?? '项目初始化失败');
}
} catch {
toast.error('项目初始化失败');
}
},
removeProject: async (path) => {
try {
const url = get().url || API_URL;
@@ -165,6 +183,7 @@ export const useCodeGraphStore = create<State>()((set, get) => ({
}
},
nodeInfoOpen: false,
setNodeInfoOpen: (open) => set({ nodeInfoOpen: open }),
nodeInfoData: null,
nodeInfoPos: { x: 0, y: 0 },
setNodeInfo: (data, pos) =>
@@ -221,6 +240,8 @@ export const useCodeGraphStore = create<State>()((set, get) => ({
toast.error('保存失败');
}
},
isMobile: false,
setIsMobile: (isMobile) => set({ isMobile }),
createQuestion: async (opts) => {
const { question, projectPath, engine = 'opencode' } = opts;
const url = get().url
@@ -231,7 +252,8 @@ export const useCodeGraphStore = create<State>()((set, get) => ({
question: q,
directory: projectPath,
}, {
url
url,
timeout: 60 * 1000 * 15, // 15分钟
});
return res;
},