diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3038e643..ddd32211 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,7 +4,6 @@ import LoginPage from "@/pages/login" import WelcomePage from "@/pages/welcome" import UserConsolePage from "@/pages/console/user/page" import ManagerConsolePage from "@/pages/console/manager/page" -import SettingsPage from "@/pages/console/user/settings" import TasksPage from "@/pages/console/user/tasks" import IDEIDE from "@/pages/console/user/ide-ide" import GitBotsPage from "@/pages/console/user/git-bots" @@ -22,15 +21,13 @@ import ResetPasswordPage from "./pages/resetpassword" import FindPasswordPage from "./pages/findpassword" import TeamManagerManager from "./pages/console/manager/manager" import TeamManagerOtherSettings from "./pages/console/manager/other-settings" -import ProjectPage from "./pages/console/user/project/project" import PlaygroundPage from "./pages/playground" import PlaygroundDetailPage from "./pages/playground-detail" import PublicTaskPage from "./pages/public-task" import PostCreatePage from "./pages/post-create" -import ProjectIssuesPage from "./pages/console/user/project/issues" -import ProjectTasksPage from "./pages/console/user/project/tasks" +import ProjectOverviewPage from "./pages/console/user/project/overview" import TaskDevelopPage from "./pages/console/user/task/task-dev" -import TaskViewPage from "./pages/console/user/task/task-view" +import TaskDetailPage from "./pages/console/user/task/task-detail" function App() { return ( @@ -49,16 +46,13 @@ function App() { }> } /> } /> - } /> - } /> - } /> + } /> + } /> } /> } /> - } /> } /> } /> - } /> } /> } /> }> diff --git a/frontend/src/api/Api.ts b/frontend/src/api/Api.ts index 9b795460..dc4025cf 100644 --- a/frontend/src/api/Api.ts +++ b/frontend/src/api/Api.ts @@ -866,12 +866,12 @@ export interface DomainProject { issues?: DomainProjectIssue[]; /** 项目名 */ name?: string; - /** 未解决问题数量 */ - open_issue_count?: number; /** 项目平台 */ platform?: ConstsGitPlatform; /** 项目仓库URL */ repo_url?: string; + /** 项目相关的任务 */ + tasks?: DomainProjectTask[]; /** 更新时间 */ updated_at?: number; /** 用户信息 */ @@ -5320,7 +5320,10 @@ export class Api extends HttpClient void +} + +const BreadcrumbTaskContext = createContext(null) + +export function BreadcrumbTaskProvider({ children }: { children: React.ReactNode }) { + const [taskName, setTaskName] = useState(null) + return ( + + {children} + + ) +} + +export function useBreadcrumbTask() { + const ctx = useContext(BreadcrumbTaskContext) + return ctx +} diff --git a/frontend/src/components/console/data-provider.tsx b/frontend/src/components/console/data-provider.tsx index fd9c34c8..d0a26b93 100644 --- a/frontend/src/components/console/data-provider.tsx +++ b/frontend/src/components/console/data-provider.tsx @@ -1,4 +1,4 @@ -import { ConstsGitPlatform, ConstsOwnerType, type DomainGitIdentity, type DomainHost, type DomainImage, type DomainModel, type DomainProject, type DomainUser, type DomainVirtualMachine } from '@/api/Api'; +import { ConstsGitPlatform, ConstsOwnerType, type DomainGitIdentity, type DomainHost, type DomainImage, type DomainModel, type DomainProject, type DomainProjectTask, type DomainUser, type DomainVirtualMachine } from '@/api/Api'; import { getImageShortName } from '@/utils/common'; import { apiRequest } from '@/utils/requestUtils'; import React, { createContext, useContext, useEffect, useMemo, useState } from 'react'; @@ -37,6 +37,11 @@ type CommonData = { projects: DomainProject[]; loadingProjects: boolean; reloadProjects: () => void; + + /** 未关联项目的任务(quick_start),用于侧边栏「默认」分组展示 */ + unlinkedTasks: DomainProjectTask[]; + loadingUnlinkedTasks: boolean; + reloadUnlinkedTasks: () => void; }; const DataContext = createContext(null); @@ -66,6 +71,8 @@ export const DataProvider: React.FC<{ children: React.ReactNode }> = ({ children const [projects, setProjects] = useState([]); const [loadingProjects, setLoadingProjects] = useState(true); + const [unlinkedTasks, setUnlinkedTasks] = useState([]); + const [loadingUnlinkedTasks, setLoadingUnlinkedTasks] = useState(true); const fetchUserInfo = () => { apiRequest('v1UsersStatusList', {}, [], (resp) => { @@ -255,6 +262,22 @@ export const DataProvider: React.FC<{ children: React.ReactNode }> = ({ children setLoadingProjects(false) } + const UNLINKED_TASKS_LIMIT = 5 + const UNLINKED_TASKS_FETCH_SIZE = 50 + + const fetchUnlinkedTasks = async () => { + setLoadingUnlinkedTasks(true) + await apiRequest('v1UsersTasksList', { page: 1, size: UNLINKED_TASKS_FETCH_SIZE, quick_start: true }, [], (resp) => { + if (resp.code === 0) { + const allTasks = resp.data?.tasks || [] + const unlinked = allTasks + .sort((a: DomainProjectTask, b: DomainProjectTask) => (b.created_at || 0) - (a.created_at || 0)) + .slice(0, UNLINKED_TASKS_LIMIT) + setUnlinkedTasks(unlinked) + } + setLoadingUnlinkedTasks(false) + }, () => setLoadingUnlinkedTasks(false)) + } useEffect(() => { fetchUserInfo(); @@ -265,6 +288,7 @@ export const DataProvider: React.FC<{ children: React.ReactNode }> = ({ children fetchWallet(); fetchMembers(); fetchProjects(); + fetchUnlinkedTasks(); }, []); return ( @@ -301,6 +325,10 @@ export const DataProvider: React.FC<{ children: React.ReactNode }> = ({ children projects: projects, loadingProjects: loadingProjects, reloadProjects: fetchProjects, + + unlinkedTasks: unlinkedTasks, + loadingUnlinkedTasks: loadingUnlinkedTasks, + reloadUnlinkedTasks: fetchUnlinkedTasks, }}> {children} diff --git a/frontend/src/components/console/nav/nav-balance.tsx b/frontend/src/components/console/nav/nav-balance.tsx index bb682761..f9b50afd 100644 --- a/frontend/src/components/console/nav/nav-balance.tsx +++ b/frontend/src/components/console/nav/nav-balance.tsx @@ -18,7 +18,11 @@ import { cn } from "@/lib/utils"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { useCommonData } from "../data-provider"; -export default function NavBalance() { +interface NavBalanceProps { + variant?: "sidebar" | "header"; +} + +export default function NavBalance({ variant = "sidebar" }: NavBalanceProps) { const [transcations, setTranscations] = useState([]); const [exchangeCode, setExchangeCode] = useState(""); const [isLoading, setIsLoading] = useState(false); @@ -110,17 +114,29 @@ export default function NavBalance() { toast.success("邀请链接已复制到剪贴板"); } + const triggerContent = ( + <> + + 余额: {Math.ceil(balance + bonus).toLocaleString()} 点 + + ); + return ( - - - - - 余额: {Math.ceil(balance + bonus).toLocaleString()} 点 - - - + {variant === "header" ? ( + + ) : ( + + + + {triggerContent} + + + + )} diff --git a/frontend/src/components/console/nav/nav-main.tsx b/frontend/src/components/console/nav/nav-main.tsx index ac43c78d..2064f9c3 100644 --- a/frontend/src/components/console/nav/nav-main.tsx +++ b/frontend/src/components/console/nav/nav-main.tsx @@ -1,7 +1,4 @@ -import { - Bot, - Github, -} from "lucide-react" +import { Github } from "lucide-react" import { Link, useLocation } from "react-router-dom" import { @@ -17,17 +14,6 @@ export default function NavMain() { return ( - - - - - 智能任务 - - - => { + try { + const cached = localStorage.getItem(STORAGE_KEY) + if (cached) return JSON.parse(cached) + } catch {} + return {} +} + +const saveExpandedToStorage = (state: Record) => { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(state)) + } catch {} +} export default function NavProject() { const location = useLocation() const [addDialogOpen, setAddDialogOpen] = useState(false) + const [startTaskProject, setStartTaskProject] = useState<{ id: string; name?: string } | null>(null) + const [expandedProjects, setExpandedProjects] = useState>(loadExpandedFromStorage) + + const { projects, loadingProjects, reloadProjects, unlinkedTasks, loadingUnlinkedTasks, reloadUnlinkedTasks } = useCommonData() + + useEffect(() => { + const stored = loadExpandedFromStorage() + const next: Record = {} + const toSave: Record = { ...stored } + let changed = false + + for (const project of projects) { + const projectId = project.id ?? "" + const hasActiveTasks = (project.tasks || []).some( + (t) => t.status === "pending" || t.status === "processing" + ) + if (hasActiveTasks) { + next[projectId] = true + } else if (projectId in stored) { + next[projectId] = stored[projectId] + } else { + next[projectId] = false + } + if (!(projectId in stored)) { + toSave[projectId] = next[projectId] + changed = true + } + } + + setExpandedProjects((prev) => { + const merged = { ...prev, ...next } + return merged + }) + if (changed) saveExpandedToStorage(toSave) + }, [projects]) + + useEffect(() => { + if (loadingUnlinkedTasks) return + const stored = loadExpandedFromStorage() + const hasActiveUnlinked = unlinkedTasks.some( + (t) => t.status === "pending" || t.status === "processing" + ) + const nextUnlinked = hasActiveUnlinked + ? true + : UNLINKED_KEY in stored + ? stored[UNLINKED_KEY] + : false + const changed = !(UNLINKED_KEY in stored) + setExpandedProjects((prev) => ({ ...prev, [UNLINKED_KEY]: nextUnlinked })) + if (changed && unlinkedTasks.length > 0) { + saveExpandedToStorage({ ...stored, [UNLINKED_KEY]: nextUnlinked }) + } + }, [unlinkedTasks, loadingUnlinkedTasks]) + + // 选中项目或默认时自动展开二级菜单并持久化 + useEffect(() => { + if (location.pathname === "/console/tasks") { + setExpandedProjects((prev) => { + if (prev[UNLINKED_KEY]) return prev + const next = { ...prev, [UNLINKED_KEY]: true } + saveExpandedToStorage(next) + return next + }) + return + } + const match = location.pathname.match(/^\/console\/project\/([^/]+)/) + if (match) { + const projectId = match[1] + if (projectId && projectId !== UNLINKED_KEY) { + setExpandedProjects((prev) => { + if (prev[projectId]) return prev + const next = { ...prev, [projectId]: true } + saveExpandedToStorage(next) + return next + }) + } + } + }, [location.pathname]) + + const handleProjectOpenChange = (projectId: string, open: boolean) => { + setExpandedProjects((prev) => { + const next = { ...prev, [projectId]: open } + saveExpandedToStorage(next) + return next + }) + } - const { projects, loadingProjects, reloadProjects } = useCommonData() + useEffect(() => { + const timer = setInterval(() => { + reloadProjects() + reloadUnlinkedTasks() + }, 30000) + return () => clearInterval(timer) + }, [reloadProjects, reloadUnlinkedTasks]) + + const isUnlinkedActive = location.pathname === "/console/tasks" return ( @@ -34,19 +150,27 @@ export default function NavProject() { variant="ghost" size="icon" className="size-5" - onClick={reloadProjects} - disabled={loadingProjects} - > - - - + + + + + 创建项目 + + {startTaskProject && ( + { + if (!open) { + setStartTaskProject(null) + reloadProjects() + reloadUnlinkedTasks() + } + }} + project={projects.find((p) => p.id === startTaskProject.id)} + /> + )} - {projects.length > 0 ? projects.map((project) => ( - - - - {location.pathname.startsWith(`/console/project/${project.id}/`) ? : } - {project.name} - - - {location.pathname.startsWith(`/console/project/${project.id}/`) && - - - - - 项目 - - - - - - 需求 - {project.open_issue_count || 0} - - - - - - 任务 + handleProjectOpenChange(UNLINKED_KEY, open)} + > + +
svg]:size-4 [&>svg]:shrink-0", + isUnlinkedActive && "bg-sidebar-accent font-medium text-sidebar-accent-foreground" + )} + > + + + + + 默认 + + + + + + 创建任务 + +
+ + {unlinkedTasks.length > 0 && ( + + + {unlinkedTasks.map((task: DomainProjectTask, index) => { + const TaskIcon = + task.status === "finished" || task.status === "error" + ? IconCircleMinus + : IconLoader + return ( + svg]:!text-muted-foreground" + )} + > + + + {task.summary || stripMarkdown(task.content || "")} + + + ) + })} + + + )} + +
+
+ {projects.length > 0 ? projects.map((project) => { + const projectId = project.id ?? "" + const isExpanded = expandedProjects[projectId] ?? false + const isProjectActive = location.pathname === `/console/project/${projectId}` || location.pathname.startsWith(`/console/project/${projectId}/`) + return ( + handleProjectOpenChange(projectId, open)} + > + +
svg]:size-4 [&>svg]:shrink-0", + isProjectActive && "bg-sidebar-accent font-medium text-sidebar-accent-foreground" + )} + > + + + + + {project.name} - - - } - - )) : ( + + + + + 启动任务 + +
+ + + + {(project.tasks || []).map((task: DomainProjectTask, index) => { + const TaskIcon = + task.status === "finished" || task.status === "error" + ? IconCircleMinus + : IconLoader + return ( + svg]:!text-muted-foreground" + )} + > + + + {task.summary || stripMarkdown(task.content || "")} + + + ) + })} + + + +
+
+ ) + }) : ( 暂无项目 diff --git a/frontend/src/components/console/nav/user-sidebar.tsx b/frontend/src/components/console/nav/user-sidebar.tsx index afb894fb..bd4b8830 100644 --- a/frontend/src/components/console/nav/user-sidebar.tsx +++ b/frontend/src/components/console/nav/user-sidebar.tsx @@ -10,14 +10,14 @@ import { SidebarMenuButton, SidebarMenuItem, } from "@/components/ui/sidebar" -import NavBalance from "./nav-balance" import NavMain from "./nav-main" -import { Link } from "react-router-dom" +import { useSettingsDialog } from "@/pages/console/user/page" import { Settings } from "lucide-react" export default function UserSidebar({ ...props }: React.ComponentProps) { + const { open: settingsOpen, setOpen: setSettingsOpen } = useSettingsDialog() return ( @@ -44,17 +44,14 @@ export default function UserSidebar({ setSettingsOpen(true)} > - - - 配置 - + + 配置
- diff --git a/frontend/src/components/console/project/add-project.tsx b/frontend/src/components/console/project/add-project.tsx index f13a41ed..44e0912d 100644 --- a/frontend/src/components/console/project/add-project.tsx +++ b/frontend/src/components/console/project/add-project.tsx @@ -35,6 +35,7 @@ import { apiRequest } from "@/utils/requestUtils" import { IconCheck, IconChevronDown, IconGitBranch, IconLoader } from "@tabler/icons-react" import { useCallback, useEffect, useMemo, useState } from "react" import { useNavigate } from "react-router-dom" +import { useSettingsDialog } from "@/pages/console/user/page" import { toast } from "sonner" import { Spinner } from "@/components/ui/spinner" import { useCommonData } from "@/components/console/data-provider" @@ -64,6 +65,7 @@ export default function AddProjectDialog({ const [repoPopoverOpen, setRepoPopoverOpen] = useState(false) const [loading, setLoading] = useState(false) const navigate = useNavigate() + const { setOpen: setSettingsOpen } = useSettingsDialog() const { identities } = useCommonData() @@ -183,7 +185,7 @@ export default function AddProjectDialog({ setIdentityRepoOptions([]) onSuccess?.() if (resp.data?.id) { - navigate(`/console/project/${resp.data.id}/info/`) + navigate(`/console/project/${resp.data.id}`) } } else { toast.error(resp.message || "创建项目失败") @@ -265,7 +267,7 @@ export default function AddProjectDialog({ size="sm" onClick={() => { onOpenChange(false) - navigate("/console/settings") + setSettingsOpen(true) }} > 去设置 diff --git a/frontend/src/components/console/project/issue-card.tsx b/frontend/src/components/console/project/issue-card.tsx index f97af954..f104a8b3 100644 --- a/frontend/src/components/console/project/issue-card.tsx +++ b/frontend/src/components/console/project/issue-card.tsx @@ -13,9 +13,10 @@ interface IssueCardProps { projectId: string project?: DomainProject onViewIssue: (issue: DomainProjectIssue) => void + onTaskCreated?: () => void } -export default function IssueCard({ issue, projectId, project, onViewIssue }: IssueCardProps) { +export default function IssueCard({ issue, projectId, project, onViewIssue, onTaskCreated }: IssueCardProps) { const priority = useMemo(() => { switch (issue.priority) { @@ -60,7 +61,7 @@ export default function IssueCard({ issue, projectId, project, onViewIssue }: Is > {issue.title} - +
{issue.summary}
diff --git a/frontend/src/components/console/project/issue-design-dialog.tsx b/frontend/src/components/console/project/issue-design-dialog.tsx index a038e265..b7f1f2df 100644 --- a/frontend/src/components/console/project/issue-design-dialog.tsx +++ b/frontend/src/components/console/project/issue-design-dialog.tsx @@ -10,6 +10,7 @@ import { selectHost, selectImage, selectModel } from "@/utils/common" import { apiRequest } from "@/utils/requestUtils" import { IconSparkles } from "@tabler/icons-react" import { useEffect, useMemo, useState } from "react" +import { useNavigate } from "react-router-dom" import { toast } from "sonner" interface IssueDesignDialogProps { @@ -29,11 +30,12 @@ export default function IssueDesignDialog({ project, onConfirm }: IssueDesignDialogProps) { + const navigate = useNavigate() const [submitting, setSubmitting] = useState(false) const [branches, setBranches] = useState([]) const [selectedBranch, setSelectedBranch] = useState('') const [loadingBranches, setLoadingBranches] = useState(false) - const { images, models, hosts } = useCommonData() + const { images, models, hosts, reloadProjects, reloadUnlinkedTasks } = useCommonData() const fetchBranches = async () => { if (!project?.git_identity_id || !project?.repo_url) { @@ -136,9 +138,11 @@ ${issue?.requirement_document?.replaceAll("`", "\\`")} }, [], (resp) => { if (resp.code === 0) { toast.success('方案设计任务已启动') + reloadProjects() + reloadUnlinkedTasks() onConfirm?.() handleOpenChange(false) - window.open(`/console/task/view?taskId=${resp.data?.id}`, "_blank") + navigate(`/console/task/${resp.data?.id}`) } else { toast.error(resp.message || '任务启动失败') } diff --git a/frontend/src/components/console/project/issue-detail.tsx b/frontend/src/components/console/project/issue-detail.tsx index a9d6b88e..344c6761 100644 --- a/frontend/src/components/console/project/issue-detail.tsx +++ b/frontend/src/components/console/project/issue-detail.tsx @@ -22,6 +22,7 @@ interface ViewIssueDialogProps { projectId: string project?: DomainProject onSuccess?: () => void + onTaskCreated?: () => void } export default function ViewIssueDialog({ @@ -31,6 +32,7 @@ export default function ViewIssueDialog({ projectId, project, onSuccess, + onTaskCreated, }: ViewIssueDialogProps) { const [loading, setLoading] = useState(false) const [issueData, setIssueData] = useState(issue) @@ -312,7 +314,7 @@ export default function ViewIssueDialog({
- +
diff --git a/frontend/src/components/console/project/issue-dev-dialog.tsx b/frontend/src/components/console/project/issue-dev-dialog.tsx index 8d2fa6b9..7282a35a 100644 --- a/frontend/src/components/console/project/issue-dev-dialog.tsx +++ b/frontend/src/components/console/project/issue-dev-dialog.tsx @@ -10,6 +10,7 @@ import { selectHost, selectImage, selectModel } from "@/utils/common" import { apiRequest } from "@/utils/requestUtils" import { IconSparkles } from "@tabler/icons-react" import { useEffect, useMemo, useState } from "react" +import { useNavigate } from "react-router-dom" import { toast } from "sonner" interface IssueDevelopDialogProps { @@ -29,11 +30,12 @@ export default function IssueDevelopDialog({ project, onConfirm }: IssueDevelopDialogProps) { + const navigate = useNavigate() const [submitting, setSubmitting] = useState(false) const [branches, setBranches] = useState([]) const [selectedBranch, setSelectedBranch] = useState('') const [loadingBranches, setLoadingBranches] = useState(false) - const { images, models, hosts } = useCommonData() + const { images, models, hosts, reloadProjects, reloadUnlinkedTasks } = useCommonData() const fetchBranches = async () => { if (!project?.git_identity_id || !project?.repo_url) { @@ -141,9 +143,11 @@ ${issue?.design_document?.replaceAll("`", "\\`")} }, [], (resp) => { if (resp.code === 0) { toast.success('开发任务已启动') + reloadProjects() + reloadUnlinkedTasks() onConfirm?.() handleOpenChange(false) - window.open(`/console/task/view?taskId=${resp.data?.id}`, "_blank") + navigate(`/console/task/${resp.data?.id}`) } else { toast.error(resp.message || '任务启动失败') } diff --git a/frontend/src/components/console/project/issue-list.tsx b/frontend/src/components/console/project/issue-list.tsx index 2e3ccd18..358076d7 100644 --- a/frontend/src/components/console/project/issue-list.tsx +++ b/frontend/src/components/console/project/issue-list.tsx @@ -3,20 +3,22 @@ import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/ import { CalendarDays } from "lucide-react"; import IssueCard from "./issue-card"; -export default function ProjectIssueList({ issues, projectId, project, onViewIssue }: { issues: DomainProjectIssue[], projectId: string, project?: DomainProject, onViewIssue: (issue: DomainProjectIssue) => void }) { +export default function ProjectIssueList({ issues, projectId, project, onViewIssue, onTaskCreated }: { issues: DomainProjectIssue[], projectId: string, project?: DomainProject, onViewIssue: (issue: DomainProjectIssue) => void, onTaskCreated?: () => void }) { if (issues.length === 0) { return ( - - - - - - 暂无内容 - - 可以点击右上角的 “创建需求” - - - +
+ + + + + + 暂无内容 + + 可以点击右上角的 “创建需求” + + + +
) } @@ -29,6 +31,7 @@ export default function ProjectIssueList({ issues, projectId, project, onViewIss projectId={projectId} project={project} onViewIssue={onViewIssue} + onTaskCreated={onTaskCreated} /> ))} diff --git a/frontend/src/components/console/project/issue-menu.tsx b/frontend/src/components/console/project/issue-menu.tsx index f03b5a01..77c35c85 100644 --- a/frontend/src/components/console/project/issue-menu.tsx +++ b/frontend/src/components/console/project/issue-menu.tsx @@ -11,9 +11,10 @@ interface IssueMenuProps { issue?: DomainProjectIssue projectId: string project?: DomainProject + onTaskCreated?: () => void } -export default function IssueMenu({ issue, projectId, project }: IssueMenuProps) { +export default function IssueMenu({ issue, projectId, project, onTaskCreated }: IssueMenuProps) { const [designDialogOpen, setDesignDialogOpen] = useState(false) const [developDialogOpen, setDevelopDialogOpen] = useState(false) @@ -50,6 +51,7 @@ export default function IssueMenu({ issue, projectId, project }: IssueMenuProps) issue={issue} projectId={projectId} project={project} + onConfirm={onTaskCreated} /> ) diff --git a/frontend/src/components/console/project/project-info.tsx b/frontend/src/components/console/project/project-info.tsx index 4b17a87f..9d3be094 100644 --- a/frontend/src/components/console/project/project-info.tsx +++ b/frontend/src/components/console/project/project-info.tsx @@ -35,7 +35,7 @@ const ProjectInfo = ({ const [envDialogOpen, setEnvDialogOpen] = useState(false) const [imageDialogOpen, setImageDialogOpen] = useState(false) const navigate = useNavigate() - const { projects, reloadProjects } = useCommonData() + const { projects, reloadProjects, reloadUnlinkedTasks } = useCommonData() const handleEditProjectName = () => { if (!project) return @@ -74,7 +74,7 @@ const ProjectInfo = ({ const remainingProjects = projects.filter(p => p.id !== deletingProject.id) reloadProjects() if (remainingProjects.length > 0) { - navigate(`/console/project/${remainingProjects[0].id}/info/`) + navigate(`/console/project/${remainingProjects[0].id}`) } else { navigate('/console/tasks') } @@ -206,7 +206,13 @@ const ProjectInfo = ({ { + setConversationDialogOpen(open) + if (!open) { + reloadProjects() + reloadUnlinkedTasks() + } + }} project={project} /> diff --git a/frontend/src/components/console/project/start-develop-task-dialog.tsx b/frontend/src/components/console/project/start-develop-task-dialog.tsx index 66f65c61..e6543d71 100644 --- a/frontend/src/components/console/project/start-develop-task-dialog.tsx +++ b/frontend/src/components/console/project/start-develop-task-dialog.tsx @@ -10,6 +10,7 @@ import { selectHost, selectImage, selectModel } from "@/utils/common" import { apiRequest } from "@/utils/requestUtils" import { IconSparkles } from "@tabler/icons-react" import { useState, useEffect } from "react" +import { useNavigate } from "react-router-dom" import { toast } from "sonner" interface StartDevelopTaskDialogProps { @@ -23,6 +24,7 @@ export default function StartDevelopTaskDialog({ onOpenChange, project }: StartDevelopTaskDialogProps) { + const navigate = useNavigate() const [submitting, setSubmitting] = useState(false) const [branches, setBranches] = useState([]) const [selectedBranch, setSelectedBranch] = useState('') @@ -118,10 +120,7 @@ export default function StartDevelopTaskDialog({ if (resp.code === 0) { toast.success('对话任务已启动') onOpenChange(false) - const viewUrl = project?.id - ? `/console/task/view?taskId=${resp.data?.id}&projectId=${project.id}` - : `/console/task/view?taskId=${resp.data?.id}` - window.open(viewUrl, "_blank") + navigate(`/console/task/${resp.data?.id}`) } else { toast.error(resp.message || '任务启动失败') } @@ -134,7 +133,7 @@ export default function StartDevelopTaskDialog({ - 发起对话 + 启动 AI 任务
diff --git a/frontend/src/components/console/settings/hosts.tsx b/frontend/src/components/console/settings/hosts.tsx index b8272700..d62a417d 100644 --- a/frontend/src/components/console/settings/hosts.tsx +++ b/frontend/src/components/console/settings/hosts.tsx @@ -1,13 +1,5 @@ import { useState, useEffect } from "react" import { Button } from "@/components/ui/button" -import { - Card, - CardAction, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card" import { Dialog, DialogContent, @@ -162,7 +154,7 @@ export default function Hosts() { const loadHosts = () => { return ( - + @@ -177,7 +169,7 @@ export default function Hosts() { const noHosts = () => { return ( - + @@ -261,23 +253,23 @@ export default function Hosts() { return ( <> - - - - - 开发环境宿主机 - - - 用于在宿主机上创建开发环境 - - - - - - - {loadingHosts ? loadHosts() : hosts.length === 0 ? noHosts() : listHosts()} - - +
+
+
+
+ + 开发环境宿主机 +
+

+ 用于在宿主机上创建开发环境 +

+
+ +
+
+ {loadingHosts ? loadHosts() : hosts.length === 0 ? noHosts() : listHosts()} +
+
diff --git a/frontend/src/components/console/settings/identities.tsx b/frontend/src/components/console/settings/identities.tsx index af8015a9..a9606bce 100644 --- a/frontend/src/components/console/settings/identities.tsx +++ b/frontend/src/components/console/settings/identities.tsx @@ -1,14 +1,6 @@ import { useState } from "react" import { Avatar, AvatarFallback } from "@/components/ui/avatar" import { Button } from "@/components/ui/button" -import { - Card, - CardAction, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card" import { Item, ItemActions, @@ -168,7 +160,7 @@ export default function Identities() { } const loadIdentities = () => { return ( - + @@ -374,16 +366,18 @@ export default function Identities() { return ( - - - - - Git 平台身份凭证 - - - 用于在 Git 仓库中提交代码和拉取代码的身份凭证 - - +
+
+
+
+ + Git 平台身份凭证 +
+

+ 用于在 Git 仓库中提交代码和拉取代码的身份凭证 +

+
+
+
+
{loadingIdentities ? ( loadIdentities() ) : ( @@ -445,7 +439,7 @@ export default function Identities() { {identityItems()} )} - +
{ @@ -456,6 +450,6 @@ export default function Identities() { identity={editingIdentity} onRefresh={reloadIdentities} /> - +
) } diff --git a/frontend/src/components/console/settings/images.tsx b/frontend/src/components/console/settings/images.tsx index 6bef9c60..83e7e2c7 100644 --- a/frontend/src/components/console/settings/images.tsx +++ b/frontend/src/components/console/settings/images.tsx @@ -2,14 +2,6 @@ import { useState } from "react" import Icon from "@/components/common/Icon" import { Avatar, AvatarFallback } from "@/components/ui/avatar" import { Button } from "@/components/ui/button" -import { - Card, - CardAction, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card" import { Item, ItemActions, @@ -104,7 +96,7 @@ export default function Images() { const loadImages = () => { return ( - + @@ -121,7 +113,7 @@ export default function Images() { const noImages = () => { return ( - + @@ -213,28 +205,26 @@ export default function Images() { } return ( - - - - - 系统镜像 - - - 使用 Docker 镜像,用于构建开发环境 - - - - - - - - {loadingImages ? loadImages() : images.length === 0 ? noImages() : listImages()} - - +
+
+
+
+ + 系统镜像 +
+

+ 使用 Docker 镜像,用于构建开发环境 +

+
+ +
+
+ {loadingImages ? loadImages() : images.length === 0 ? noImages() : listImages()} +
{ @@ -245,6 +235,6 @@ export default function Images() { image={editingImage ? { id: editingImage.id || '', image_name: editingImage.name || '', remark: editingImage.remark || '' } : null} onRefresh={reloadImages} /> - +
) } diff --git a/frontend/src/components/console/settings/models.tsx b/frontend/src/components/console/settings/models.tsx index fc8bbb86..b337e856 100644 --- a/frontend/src/components/console/settings/models.tsx +++ b/frontend/src/components/console/settings/models.tsx @@ -1,13 +1,5 @@ import { useState } from "react" import { Button } from "@/components/ui/button" -import { - Card, - CardAction, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card" import { Bot, MoreVertical, @@ -142,7 +134,7 @@ export default function Models() { const loadModels = () => { return ( - + @@ -159,7 +151,7 @@ export default function Models() { const noModels = () => { return ( - + @@ -257,26 +249,26 @@ export default function Models() { return ( - - - - - AI 大模型 - - - 配置 AI 大模型,用于代码生成和分析项目 - - - - - - +
+
+
+
+ + AI 大模型 +
+

+ 配置 AI 大模型,用于代码生成和分析项目 +

+
+ +
+
{loadingModels ? loadModels() : models.length === 0 ? noModels() : listModels()} - +
- +
) } diff --git a/frontend/src/components/console/settings/notifications.tsx b/frontend/src/components/console/settings/notifications.tsx index db5cadf9..7edb04a5 100644 --- a/frontend/src/components/console/settings/notifications.tsx +++ b/frontend/src/components/console/settings/notifications.tsx @@ -1,13 +1,5 @@ import { useState, useEffect } from "react" import { Bell, CirclePlus, Link2, MoreVertical } from "lucide-react" -import { - Card, - CardAction, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" @@ -348,7 +340,7 @@ export default function Notifications() { ) const loadingContent = ( - + @@ -361,30 +353,45 @@ export default function Notifications() { ) return ( - - - - - 消息通知 - - - 配置任务、系统等消息的接收方式,支持钉钉、飞书、企业微信机器人和 Webhook - - - - - - - {loadingChannels ? loadingContent : channels.length > 0 ? listChannels() : null} - +
+
+
+
+ + 消息通知 +
+

+ 配置任务、系统等消息的接收方式,支持钉钉、飞书、企业微信机器人和 Webhook +

+
+ +
+
+ {loadingChannels ? ( + loadingContent + ) : channels.length > 0 ? ( + listChannels() + ) : ( + + + + + + + 添加接收端以接收任务、系统等消息通知 + + + + )} +
@@ -516,6 +523,6 @@ export default function Notifications() { - +
) } diff --git a/frontend/src/components/console/settings/settings-dialog.tsx b/frontend/src/components/console/settings/settings-dialog.tsx new file mode 100644 index 00000000..0a703185 --- /dev/null +++ b/frontend/src/components/console/settings/settings-dialog.tsx @@ -0,0 +1,195 @@ +"use client" + +import * as React from "react" +import { + Bell, + Bot, + Box, + HardDrive, + MonitorCloud, + Settings, +} from "lucide-react" +import { IconPasswordFingerprint } from "@tabler/icons-react" + +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { + Sidebar, + SidebarContent, + SidebarGroup, + SidebarGroupContent, + SidebarHeader, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + SidebarProvider, +} from "@/components/ui/sidebar" +import { useGitHubSetupCallback } from "@/hooks/useGitHubSetupCallback" +import { useCommonData } from "@/components/console/data-provider" +import { + AlertDialog, + AlertDialogAction, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog" + +import Images from "./images" +import Models from "./models" +import Hosts from "./hosts" +import Identities from "./identities" +import VmsPage from "./vms" +import Notifications from "./notifications" + +const SETTINGS_NAV = [ + { id: "identities", name: "Git 身份", icon: IconPasswordFingerprint }, + { id: "models", name: "AI 大模型", icon: Bot }, + { id: "images", name: "系统镜像", icon: Box }, + { id: "hosts", name: "宿主机", icon: HardDrive }, + { id: "vms", name: "开发环境", icon: MonitorCloud }, + { id: "notifications", name: "通知", icon: Bell }, +] as const + +type SettingsSectionId = (typeof SETTINGS_NAV)[number]["id"] + +function SettingsContent({ section }: { section: SettingsSectionId }) { + switch (section) { + case "identities": + return + case "models": + return + case "images": + return + case "hosts": + return + case "vms": + return + case "notifications": + return + default: + return + } +} + +function SettingsNavContent({ + activeSection, + onSectionChange, +}: { + activeSection: SettingsSectionId + onSectionChange: (id: SettingsSectionId) => void +}) { + return ( + + +
+ + 设置 +
+
+ + + + + {SETTINGS_NAV.map((item) => ( + + onSectionChange(item.id)} + > + + {item.name} + + + ))} + + + + +
+ ) +} + +export interface SettingsDialogProps { + open?: boolean + onOpenChange?: (open: boolean) => void +} + +export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) { + const [activeSection, setActiveSection] = + React.useState("identities") + const { reloadIdentities } = useCommonData() + + const { result, dismiss } = useGitHubSetupCallback(() => { + reloadIdentities() + }) + + return ( + <> + + + + 配置 + 自定义您的配置选项 + + +
+ +
+
+ +
+
+
+
+
+
+ + { + if (!open) dismiss() + }} + > + + + + {result?.type === "success" + ? "GitHub App 安装成功" + : "GitHub App 安装失败"} + + + {result?.type === "success" + ? result.accountLogin + ? `已关联到账户 ${result.accountLogin}` + : "GitHub App 已成功安装" + : `安装失败 (${result?.reason}): ${result?.message}`} + + + + 确定 + + + + + ) +} diff --git a/frontend/src/components/console/settings/vms.tsx b/frontend/src/components/console/settings/vms.tsx index 7854c64e..7e34ce48 100644 --- a/frontend/src/components/console/settings/vms.tsx +++ b/frontend/src/components/console/settings/vms.tsx @@ -57,7 +57,6 @@ import { import { HoverCard, HoverCardTrigger } from "@/components/ui/hover-card"; import { useCommonData } from "@/components/console/data-provider"; import { VmRenewDialog } from "@/components/console/vm/vm-renew"; -import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Switch } from "@/components/ui/switch"; export default function VmsPage() { @@ -147,7 +146,7 @@ export default function VmsPage() { const loadVms = () => { return ( - + @@ -164,7 +163,7 @@ export default function VmsPage() { const NoVms = () => { return ( - + @@ -190,6 +189,21 @@ export default function VmsPage() { ) } + const AllOfflineVms = () => { + return ( + + + + + + + 您有 {vms.length} 个离线开发环境,开启「离线开发环境」可查看 + + + + ) + } + const VmsList = () => { return ( @@ -284,17 +298,18 @@ export default function VmsPage() { } return ( - - - - - 开发环境 - - - 用于在宿主机上创建开发环境 - - - +
+
+
+
+ + 开发环境 +
+

+ 用于在宿主机上创建开发环境 +

+
+
+
+ {loadingHosts && !hostsInited ? loadVms() : vms.length === 0 ? : showVms.length === 0 ? : } - - +
+
) } diff --git a/frontend/src/components/console/task/chat-panel.tsx b/frontend/src/components/console/task/chat-panel.tsx index 3a314b29..015fa472 100644 --- a/frontend/src/components/console/task/chat-panel.tsx +++ b/frontend/src/components/console/task/chat-panel.tsx @@ -1,13 +1,11 @@ import { MessageItem, type MessageType } from "./message" import React from "react" +import { createPortal } from "react-dom" import { useVirtualizer } from "@tanstack/react-virtual" import { Button } from "@/components/ui/button" import { ChevronsDownUp, ChevronsUpDown } from "lucide-react" import { Label } from "@/components/ui/label" -import { stripMarkdown } from "@/utils/common" -import ReactMarkdown from "react-markdown" -import remarkGfm from "remark-gfm" -import { IconCircle, IconCircleCheck, IconLoader, IconPlayerStopFilled, IconSparkles, IconSubtask } from "@tabler/icons-react" +import { IconCircle, IconCircleCheck, IconLoader, IconPlayerStopFilled, IconSubtask } from "@tabler/icons-react" import type { AvailableCommands, PlanEntry, RepoFileChange, TaskPlan, TaskStreamStatus, TaskWebSocketManager } from "./ws-manager" import { TaskChatInputBox } from "./chat-inputbox" import { cn } from "@/lib/utils" @@ -15,11 +13,12 @@ import { FileChangesDialog } from "./file-changes-dialog" import type { ConstsCliName } from "@/api/Api" interface TaskChatPanelProps { + scrollContainerRef?: React.RefObject + inputPortalTargetRef?: React.RefObject messages: MessageType[] cli?: ConstsCliName streamStatus: TaskStreamStatus disabled: boolean - thinkingMessage: string plan: TaskPlan | null availableCommands: AvailableCommands | null sending: boolean @@ -33,11 +32,11 @@ interface TaskChatPanelProps { taskManager: TaskWebSocketManager | null } -export const TaskChatPanel = ({ messages, cli, streamStatus, disabled, thinkingMessage, plan, availableCommands, sending, sendUserInput, sendCancelCommand, sendResetSession, sendReloadSession, queueSize, fileChanges, fileChangesMap, taskManager }: TaskChatPanelProps) => { - const [thinkingOpened, setThinkingOpened] = React.useState(false) +export const TaskChatPanel = ({ scrollContainerRef: externalScrollRef, inputPortalTargetRef, messages, cli, streamStatus, disabled, plan, availableCommands, sending, sendUserInput, sendCancelCommand, sendResetSession, sendReloadSession, queueSize, fileChanges, fileChangesMap, taskManager }: TaskChatPanelProps) => { const [planOpened, setPlanOpened] = React.useState(false) const [timeCost, setTimeCost] = React.useState(0) - const scrollContainerRef = React.useRef(null) + const internalScrollRef = React.useRef(null) + const scrollContainerRef = externalScrollRef ?? internalScrollRef const [showSubmitButton, setShowSubmitButton] = React.useState(false) const [fileChangesDialogOpen, setFileChangesDialogOpen] = React.useState(false) @@ -55,14 +54,6 @@ export const TaskChatPanel = ({ messages, cli, streamStatus, disabled, thinkingM }, [plan]) - const thinkingSummary = React.useMemo(() => { - const textThinkingMesssage = stripMarkdown(thinkingMessage) - if (textThinkingMesssage.length <= 200) { - return textThinkingMesssage - } - return textThinkingMesssage.slice(-200) - }, [thinkingMessage]) - React.useEffect(() => { if (streamStatus === 'executing') { setTimeCost(0) @@ -192,27 +183,7 @@ export const TaskChatPanel = ({ messages, cli, streamStatus, disabled, thinkingM } return ( -
- {thinkingSummary &&
-
- - -
- {thinkingOpened ? (
-
- {thinkingMessage} -
-
): (
-
- {thinkingSummary} -
-
)} -
} +
{plan && plan.entries.length > 0 &&
} -
+
- {disabled ? ( -
- 开发环境不可用 -
- ) : ( - - )} + {inputPortalTargetRef + ? inputPortalTargetRef.current && + createPortal( + disabled ? ( +
+ 开发环境不可用 +
+ ) : ( + + ), + inputPortalTargetRef.current + ) + : null} + {!inputPortalTargetRef && (disabled ? ( +
+ 开发环境不可用 +
+ ) : ( + + ))}
) diff --git a/frontend/src/components/console/task/file-actions-dropdown.tsx b/frontend/src/components/console/task/file-actions-dropdown.tsx index ee83e5af..d40c177e 100644 --- a/frontend/src/components/console/task/file-actions-dropdown.tsx +++ b/frontend/src/components/console/task/file-actions-dropdown.tsx @@ -81,7 +81,7 @@ export function FileActionsDropdown({ file, envid, onRefresh, onSuccess, alwaysV +
+
{renderDiffContent()}
+
+ ) + + return ( +
+ {selectedFile ? ( + + + {fileListPanel} + + + + {diffPanel} + + + ) : ( +
{fileListPanel}
+ )} +
+ ) +} diff --git a/frontend/src/components/console/task/task-chat-section.tsx b/frontend/src/components/console/task/task-chat-section.tsx new file mode 100644 index 00000000..fa13129e --- /dev/null +++ b/frontend/src/components/console/task/task-chat-section.tsx @@ -0,0 +1,230 @@ +import { MessageItem, type MessageType } from "@/components/console/task/message" +import { Button } from "@/components/ui/button" +import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupTextarea } from "@/components/ui/input-group" +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" +import { cn } from "@/lib/utils" +import { IconDeviceDesktop, IconFile, IconGitBranch, IconSend, IconTerminal2 } from "@tabler/icons-react" + +const MOCK_MESSAGES: MessageType[] = [ + { + id: "1", + time: Date.now() - 300000, + role: "user", + type: "user_input", + data: { content: "请帮我写个 Frida 脚本,来实现自动收取企业微信消息的逻辑" }, + }, + { + id: "2", + time: Date.now() - 270000, + role: "agent", + type: "agent_message_chunk", + data: { + content: + "这个请求我不能直接帮你写。企业微信涉及敏感的企业通信数据,建议通过以下合规方式实现:\n\n1. **官方 API**:使用企业微信提供的 Webhook、回调接口接收消息\n2. **会话存档**:开通企业微信的会话内容存档能力\n3. **群机器人**:在群内添加机器人,通过 Webhook 接收消息推送\n\n如需示例,我可以帮你写 Node.js 或 Python 调用上述接口的代码。", + }, + }, + { + id: "3", + time: Date.now() - 240000, + role: "user", + type: "user_input", + data: { content: "那就用官方 API 的方式,写一个 Python 示例吧" }, + }, + { + id: "4", + time: Date.now() - 240000, + role: "user", + type: "user_input", + data: { content: "那就用官方 API 的方式,写一个 Python 示例吧" }, + }, + { + id: "5", + time: Date.now() - 270000, + role: "agent", + type: "agent_message_chunk", + data: { + content: + "这个请求我不能直接帮你写。企业微信涉及敏感的企业通信数据,建议通过以下合规方式实现:\n\n1. **官方 API**:使用企业微信提供的 Webhook、回调接口接收消息\n2. **会话存档**:开通企业微信的会话内容存档能力\n3. **群机器人**:在群内添加机器人,通过 Webhook 接收消息推送\n\n如需示例,我可以帮你写 Node.js 或 Python 调用上述接口的代码。", + }, + }, + { + id: "6", + time: Date.now() - 270000, + role: "agent", + type: "agent_message_chunk", + data: { + content: + "这个请求我不能直接帮你写。企业微信涉及敏感的企业通信数据,建议通过以下合规方式实现:\n\n1. **官方 API**:使用企业微信提供的 Webhook、回调接口接收消息\n2. **会话存档**:开通企业微信的会话内容存档能力\n3. **群机器人**:在群内添加机器人,通过 Webhook 接收消息推送\n\n如需示例,我可以帮你写 Node.js 或 Python 调用上述接口的代码。", + }, + }, + { + id: "7", + time: Date.now() - 270000, + role: "agent", + type: "agent_message_chunk", + data: { + content: + "这个请求我不能直接帮你写。企业微信涉及敏感的企业通信数据,建议通过以下合规方式实现:\n\n1. **官方 API**:使用企业微信提供的 Webhook、回调接口接收消息\n2. **会话存档**:开通企业微信的会话内容存档能力\n3. **群机器人**:在群内添加机器人,通过 Webhook 接收消息推送\n\n如需示例,我可以帮你写 Node.js 或 Python 调用上述接口的代码。", + }, + }, + { + id: "8", + time: Date.now() - 270000, + role: "agent", + type: "agent_message_chunk", + data: { + content: + "这个请求我不能直接帮你写。企业微信涉及敏感的企业通信数据,建议通过以下合规方式实现:\n\n1. **官方 API**:使用企业微信提供的 Webhook、回调接口接收消息\n2. **会话存档**:开通企业微信的会话内容存档能力\n3. **群机器人**:在群内添加机器人,通过 Webhook 接收消息推送\n\n如需示例,我可以帮你写 Node.js 或 Python 调用上述接口的代码。", + }, + }, +] + +export type PanelType = "files" | "terminal" | "changes" | "preview" + +const formatTokens = (tokens?: number) => { + if (tokens === undefined || tokens === null) return "" + if (tokens >= 1000000) return `${(tokens / 1000000).toFixed(1)}M` + if (tokens >= 1000) return `${(tokens / 1000).toFixed(1)}K` + return tokens.toString() +} + +export interface TaskChatSectionProps { + inputValue: string + onInputChange: (value: string) => void + onSend: () => void + onKeyDown: (e: React.KeyboardEvent) => void + hasPanel: boolean + activePanel: PanelType | null + onTogglePanel: (panel: PanelType) => void + panelsDisabled?: boolean + taskStats?: { input_tokens?: number; output_tokens?: number; total_tokens?: number } +} + +function PanelButton({ + active, + disabled, + icon: Icon, + label, + onClick, +}: { + active: boolean + disabled: boolean + icon: React.ComponentType<{ className?: string }> + label: string + onClick: () => void +}) { + const button = ( + + ) + if (disabled) { + return ( + + + {button} + + 任务已结束,无法查看 + + ) + } + return button +} + +export function TaskChatSection({ + inputValue, + onInputChange, + onSend, + onKeyDown, + hasPanel, + activePanel, + onTogglePanel, + panelsDisabled = false, + taskStats, +}: TaskChatSectionProps) { + return ( +
+
+
+ {MOCK_MESSAGES.map((msg) => ( + + ))} +
+
+
+
+ + onInputChange(e.target.value)} + onKeyDown={onKeyDown} + /> + + + + 发送 + + + +
+
+ onTogglePanel("files")} + /> + onTogglePanel("terminal")} + /> + onTogglePanel("changes")} + /> + onTogglePanel("preview")} + /> +
+ {(taskStats?.input_tokens != null || taskStats?.output_tokens != null || taskStats?.total_tokens != null) ? ( + + + {formatTokens(taskStats?.total_tokens ?? ((taskStats?.input_tokens ?? 0) + (taskStats?.output_tokens ?? 0)))} tokens + + + 输入 {formatTokens(taskStats?.input_tokens) || "-"} / 输出 {formatTokens(taskStats?.output_tokens) || "-"} tokens + + + ) : null} +
+
+
+
+ ) +} diff --git a/frontend/src/components/console/task/task-file-changes-panel.tsx b/frontend/src/components/console/task/task-file-changes-panel.tsx new file mode 100644 index 00000000..9ba45936 --- /dev/null +++ b/frontend/src/components/console/task/task-file-changes-panel.tsx @@ -0,0 +1,150 @@ +import { useState } from "react" +import { Button } from "@/components/ui/button" +import { IconLoader, IconReport } from "@tabler/icons-react" +import { parseDiff, Diff, Hunk } from "react-diff-view" +import "react-diff-view/style/index.css" +import type { RepoFileChange, TaskWebSocketManager } from "./ws-manager" +import { cn } from "@/lib/utils" +import { Badge } from "@/components/ui/badge" +import { Checkbox } from "@/components/ui/checkbox" + +interface TaskFileChangesPanelProps { + fileChanges: string[] + fileChangesMap: Map + taskManager: TaskWebSocketManager | null + onSubmit: (selectedFiles: string[]) => void + disabled?: boolean +} + +export function TaskFileChangesPanel({ + fileChanges, + fileChangesMap, + taskManager, + onSubmit, + disabled, +}: TaskFileChangesPanelProps) { + const [selectedFile, setSelectedFile] = useState(null) + const [diffContent, setDiffContent] = useState("") + const [loading, setLoading] = useState(false) + const [checkedFiles, setCheckedFiles] = useState>(new Set()) + + const handleCheckboxChange = (path: string, checked: boolean) => { + setCheckedFiles((prev) => { + const newSet = new Set(prev) + if (checked) newSet.add(path) + else newSet.delete(path) + return newSet + }) + } + + const handleSubmitSelected = () => { + onSubmit(Array.from(checkedFiles)) + } + + const handleSubmitAll = () => { + onSubmit(fileChanges) + } + + const handleFileClick = async (path: string) => { + if (selectedFile === path) { + setSelectedFile(null) + setDiffContent("") + return + } + setSelectedFile(path) + setLoading(true) + setDiffContent("") + const diff = await taskManager?.getFileDiff(path) + setDiffContent(diff || "") + setLoading(false) + } + + const files = diffContent ? parseDiff(diffContent) : [] + + const renderStatusBadge = (change?: RepoFileChange) => { + if (!change) return null + switch (change.status) { + case "A": + return 新增 + case "D": + return 删除 + case "M": + return 修改 + case "R": + return 移动 + case "RM": + return 删除 + default: + return 新增 + } + } + + return ( +
+
+ 以下修改尚未提交 +
+ + +
+
+
+ {fileChanges.map((path) => { + const change = fileChangesMap.get(path) + const isSelected = selectedFile === path + return ( +
+
+ handleCheckboxChange(path, checked as boolean)} + onClick={(e) => e.stopPropagation()} + /> +
handleFileClick(path)}> + {path} +
+ {renderStatusBadge(change)} +
+ + {isSelected && ( +
+ {loading ? ( +
+ +
+ ) : files.length > 0 && files.some((file) => file.hunks?.length) ? ( +
+ {files.map((file, index) => ( + + {(hunks) => hunks.map((hunk) => )} + + ))} +
+ ) : ( +
+ + 无内容 +
+ )} +
+ )} +
+ ) + })} +
+
+ ) +} diff --git a/frontend/src/components/console/task/task-file-explorer.tsx b/frontend/src/components/console/task/task-file-explorer.tsx new file mode 100644 index 00000000..e4c79285 --- /dev/null +++ b/frontend/src/components/console/task/task-file-explorer.tsx @@ -0,0 +1,601 @@ +import { useState, useEffect, useCallback, useMemo, useRef, forwardRef, useImperativeHandle } from "react" +import { getFileExtension } from "@/utils/common" +import { cn } from "@/lib/utils" +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible" +import { IconCloudOff, IconFileCode, IconFileSymlink, IconFileText, IconFolder, IconFolderOpen, IconFolderRoot, IconLoader, IconPhoto, IconReload, IconX } from "@tabler/icons-react" +import { Button } from "@/components/ui/button" +import { RepoFileEntryMode, TaskWebSocketManager, type RepoFileChange, type RepoFileStatus, type TaskStreamStatus } from "./ws-manager" +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" +import { FileActionsDropdown } from "./file-actions-dropdown" +import AceEditor from "react-ace" +import "ace-builds/src-noconflict/mode-text" +import "ace-builds/src-noconflict/mode-javascript" +import "ace-builds/src-noconflict/mode-typescript" +import "ace-builds/src-noconflict/mode-python" +import "ace-builds/src-noconflict/mode-json" +import "ace-builds/src-noconflict/mode-yaml" +import "ace-builds/src-noconflict/mode-markdown" +import "ace-builds/src-noconflict/mode-html" +import "ace-builds/src-noconflict/mode-css" +import "ace-builds/src-noconflict/mode-sql" +import "ace-builds/src-noconflict/mode-sh" +import "ace-builds/src-noconflict/mode-dockerfile" +import "ace-builds/src-noconflict/mode-c_cpp" +import "ace-builds/src-noconflict/mode-csharp" +import "ace-builds/src-noconflict/mode-golang" +import "ace-builds/src-noconflict/mode-ruby" +import "ace-builds/src-noconflict/mode-rust" +import "ace-builds/src-noconflict/mode-perl" +import "ace-builds/src-noconflict/mode-swift" +import "ace-builds/src-noconflict/mode-lua" +import "ace-builds/src-noconflict/mode-php" +import "ace-builds/src-noconflict/mode-java" +import "@/utils/ace-theme" +import React from "react" +import { toast } from "sonner" +import { Empty, EmptyDescription, EmptyHeader, EmptyMedia } from "@/components/ui/empty" +import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable" + +interface TaskFileExplorerProps { + className?: string + disabled?: boolean + streamStatus?: TaskStreamStatus + fileChangesMap: Map + changedPaths?: string[] + taskManager: TaskWebSocketManager | null + onRefresh?: () => void + envid?: string +} + +// --- 文件树逻辑 --- +const sortFiles = (files: RepoFileStatus[]) => { + return files.sort((a, b) => { + const getTypePriority = (file: RepoFileStatus) => { + return (file.entry_mode === RepoFileEntryMode.RepoEntryModeTree || file.entry_mode === RepoFileEntryMode.RepoEntryModeSubmodule) ? 0 : 2 + } + const priorityA = getTypePriority(a) + const priorityB = getTypePriority(b) + if (priorityA !== priorityB) return priorityA - priorityB + return (a.name.toLowerCase() || '').localeCompare(b.name.toLowerCase() || '') + }) +} + +const isDirectory = (file: RepoFileStatus) => { + return file.entry_mode === RepoFileEntryMode.RepoEntryModeTree || file.entry_mode === RepoFileEntryMode.RepoEntryModeSubmodule +} + +const getFileIcon = (file: RepoFileStatus, isOpen?: boolean) => { + switch (file.entry_mode) { + case RepoFileEntryMode.RepoEntryModeTree: + return isOpen ? : + case RepoFileEntryMode.RepoEntryModeSymlink: + return + case RepoFileEntryMode.RepoEntryModeExecutable: + return + case RepoFileEntryMode.RepoEntryModeSubmodule: + return isOpen ? : + case RepoFileEntryMode.RepoEntryModeFile: + case RepoFileEntryMode.RepoEntryModeUnspecified: + default: + if (['jpg', 'png', 'gif', 'jpeg', 'webp', 'svg', 'ico'].includes(getFileExtension(file.name))) { + return + } + return + } +} + +const getLanguageMode = (fileName: string): string => { + const ext = fileName.split('.').pop()?.toLowerCase() + const modeMap: Record = { + 'bash': 'sh', 'c': 'c_cpp', 'cpp': 'c_cpp', 'cs': 'csharp', 'fish': 'sh', 'go': 'golang', + 'h': 'c_cpp', 'htm': 'html', 'html': 'html', 'hpp': 'c_cpp', 'java': 'java', 'js': 'javascript', + 'jsx': 'javascript', 'lua': 'lua', 'markdown': 'markdown', 'md': 'markdown', 'php': 'php', + 'pl': 'perl', 'py': 'python', 'rb': 'ruby', 'rs': 'rust', 'sh': 'sh', 'swift': 'swift', + 'sql': 'sql', 'ts': 'typescript', 'tsx': 'typescript', 'yml': 'yaml', 'yaml': 'yaml', 'zsh': 'sh', + } + return modeMap[ext || ''] || 'text' +} + +const MAX_FILE_SIZE = 100 * 1024 // 100KB + +const BINARY_EXTENSIONS = [ + '.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.bmp', '.ico', + '.mp4', '.webm', '.ogv', '.mov', '.avi', '.mkv', '.mp3', '.wav', '.ogg', '.aac', '.flac', '.m4a', '.wma', + '.pdf', '.zip', '.tar', '.gz', '.rar', '.7z', '.exe', '.dll', '.so', '.dylib', + '.woff', '.woff2', '.ttf', '.otf', '.eot', +] + +function isBinaryExtension(path: string): boolean { + const ext = path.substring(path.lastIndexOf('.')).toLowerCase() + return BINARY_EXTENSIONS.includes(ext) +} + +function tryDecodeAsText(bytes: Uint8Array): { text: string; isText: boolean } { + try { + const text = new TextDecoder('utf-8', { fatal: true }).decode(bytes) + return { text, isText: true } + } catch { + return { text: '', isText: false } + } +} + +interface FileItem { + name: string + path: string + bytes: Uint8Array | null + content: string | null + isBinary: boolean + isTooLarge: boolean +} + +// --- DirNode ref --- +interface DirNodeRef { + refresh: () => Promise + refreshPaths: (paths: string[]) => Promise +} + +// --- FileNode (新样式) --- +const FileNode = ({ file, depth, onFileSelect, fileChangesMap, envid, onRefresh, selectedPath }: { + file: RepoFileStatus + depth: number + onFileSelect?: (path: string, file: RepoFileStatus) => void + fileChangesMap: Map + envid?: string + onRefresh?: () => void + selectedPath?: string | null +}) => { + const paddingLeft = depth * 14 + const fileChange = fileChangesMap.get(file.path) + const hasChanges = !!fileChange?.status + const isSelected = selectedPath === file.path + + return ( +
+
onFileSelect?.(file.path, file)}> + {getFileIcon(file)} + {file.name} +
+
+ {hasChanges && ( + + )} +
+ +
+
+
+ ) +} + +// --- DirNode (新样式) --- +const DirNode = forwardRef void + defaultExpanded?: boolean + taskManager: TaskWebSocketManager | null + streamStatus?: TaskStreamStatus + fileChangesMap: Map + envid?: string + onRefresh?: () => void + selectedPath?: string | null +}>(({ file, depth, onFileSelect, defaultExpanded = false, streamStatus, taskManager, fileChangesMap, envid, onRefresh, selectedPath }, ref) => { + const [children, setChildren] = useState([]) + const [loading, setLoading] = useState(false) + const [expanded, setExpanded] = useState(defaultExpanded) + const [loaded, setLoaded] = useState(false) + const childRefs = useRef>(new Map()) + const fullPath = file?.path || '' + const paddingLeft = depth * 14 + + const fetchChildren = useCallback(async (showLoading = true) => { + if (showLoading) setLoading(true) + try { + if (taskManager) { + const result = await taskManager.getFileList(fullPath) + const filtered = (result || []).filter(f => f.name !== '.git') + setChildren(sortFiles(filtered)) + setLoaded(true) + } + } finally { + if (showLoading) setLoading(false) + } + }, [fullPath, taskManager]) + + const refresh = useCallback(async () => { + await fetchChildren(true) + const refreshPromises: Promise[] = [] + childRefs.current.forEach((childRef) => refreshPromises.push(childRef.refresh())) + await Promise.all(refreshPromises) + }, [fetchChildren]) + + const refreshPaths = useCallback(async (paths: string[]) => { + const needsRefresh = paths.some(p => { + const lastSlashIndex = p.lastIndexOf('/') + const parentPath = lastSlashIndex > 0 ? p.substring(0, lastSlashIndex) : '' + return parentPath === fullPath || (fullPath === '' && parentPath === '') + }) + if (needsRefresh) await fetchChildren(false) + const childPaths = paths.filter(p => fullPath === '' || fullPath === '/' || p.startsWith(fullPath + '/')) + if (childPaths.length > 0) { + const refreshPromises: Promise[] = [] + childRefs.current.forEach((childRef) => refreshPromises.push(childRef.refreshPaths(childPaths))) + await Promise.all(refreshPromises) + } + }, [fullPath, fetchChildren]) + + useImperativeHandle(ref, () => ({ refresh, refreshPaths }), [refresh, refreshPaths]) + + const handleToggle = useCallback((open: boolean) => { + setExpanded(open) + if (open && !loaded) fetchChildren(true) + }, [loaded, fetchChildren]) + + useEffect(() => { + if (defaultExpanded && !loaded && (streamStatus === 'waiting' || streamStatus === 'executing')) { + fetchChildren(true) + } + }, [defaultExpanded, loaded, fetchChildren, streamStatus]) + + const hasChangesInChildren = useMemo(() => { + if (fileChangesMap.has(fullPath)) return true + if (children.some((child) => fileChangesMap.has(fullPath + '/' + child.name))) return true + const prefix = fullPath === '' ? '' : fullPath + '/' + for (const changedPath of fileChangesMap.keys()) { + if (prefix === '' || changedPath.startsWith(prefix)) return true + } + return false + }, [children, fileChangesMap, fullPath]) + + if (!file) { + if (loading && children.length === 0) { + return ( + + + + + + 正在加载... + + + ) + } + if (children.length === 0) { + return ( + + + + + + 当前目录没有文件 + + + ) + } + return ( +
+ {children.map((child) => + isDirectory(child) ? ( + { if (r) childRefs.current.set(child.name!, r); else childRefs.current.delete(child.name!) }} + file={child} + depth={depth} + onFileSelect={onFileSelect} + taskManager={taskManager} + fileChangesMap={fileChangesMap} + envid={envid} + onRefresh={onRefresh} + selectedPath={selectedPath} + /> + ) : ( + + ) + )} +
+ ) + } + + return ( + +
+ +
+ {loading ? : getFileIcon(file, expanded)} + {file.name} +
+
+
+ {hasChangesInChildren && } +
+ { await refresh(); onRefresh?.() }} onSuccess={async () => { await refresh(); onRefresh?.() }} /> +
+
+
+ + {children.map((child) => + isDirectory(child) ? ( + { if (r) childRefs.current.set(child.name!, r); else childRefs.current.delete(child.name!) }} + file={child} + depth={depth + 1} + onFileSelect={onFileSelect} + taskManager={taskManager} + fileChangesMap={fileChangesMap} + envid={envid} + onRefresh={onRefresh} + selectedPath={selectedPath} + /> + ) : ( + + ) + )} + +
+ ) +}) + +DirNode.displayName = 'DirNode' + +export const TaskFileExplorer = ({ + className, + disabled, + streamStatus, + fileChangesMap, + changedPaths, + taskManager, + onRefresh, + envid, +}: TaskFileExplorerProps): React.JSX.Element => { + const rootRef = useRef(null) + const [refreshKey, setRefreshKey] = useState(0) + const [currentFile, setCurrentFile] = useState(null) + const [fileLoading, setFileLoading] = useState(false) + + const handleRefresh = useCallback(() => { + setRefreshKey((prev) => prev + 1) + onRefresh?.() + }, [onRefresh]) + + const refreshPathsTimeoutRef = useRef | null>(null) + useEffect(() => { + if (!changedPaths || changedPaths.length === 0) return + refreshPathsTimeoutRef.current && clearTimeout(refreshPathsTimeoutRef.current) + refreshPathsTimeoutRef.current = setTimeout(() => { + rootRef.current?.refreshPaths(changedPaths) + refreshPathsTimeoutRef.current = null + }, 300) + return () => { + refreshPathsTimeoutRef.current && clearTimeout(refreshPathsTimeoutRef.current) + } + }, [changedPaths]) + + const fetchFileContent = useCallback(async (path: string) => { + let bytes: Uint8Array | null = null + setFileLoading(true) + if (taskManager) { + bytes = await taskManager.getFileContent(path) + if (!bytes) toast.error(`文件读取失败`) + } + setFileLoading(false) + return bytes + }, [taskManager]) + + const openFile = useCallback(async (path: string) => { + if (!envid || !path) return null + if (currentFile?.path === path) return currentFile + const bytes = await fetchFileContent(path) + if (!bytes) return null + const isBinaryByExt = isBinaryExtension(path) + const isTooLarge = bytes.length > MAX_FILE_SIZE + const { text, isText } = isBinaryByExt ? { text: '', isText: false } : tryDecodeAsText(bytes) + const isBinary = isBinaryByExt || !isText + const file: FileItem = { + name: path.split('/').pop() || path, + path, + bytes, + content: isText ? text : null, + isBinary, + isTooLarge, + } + setCurrentFile(file) + return file + }, [envid, currentFile, fetchFileContent]) + + const handleFileSelect = useCallback((path: string, file: RepoFileStatus) => { + if (file.entry_mode === RepoFileEntryMode.RepoEntryModeTree || file.entry_mode === RepoFileEntryMode.RepoEntryModeSubmodule) return + openFile(path) + }, [openFile]) + + const reloadFile = useCallback(async () => { + if (!currentFile) return + const bytes = await fetchFileContent(currentFile.path) + if (!bytes) return + const isBinaryByExt = isBinaryExtension(currentFile.path) + const isTooLarge = bytes.length > MAX_FILE_SIZE + const { text, isText } = isBinaryByExt ? { text: '', isText: false } : tryDecodeAsText(bytes) + setCurrentFile({ + ...currentFile, + bytes, + content: isText ? text : null, + isBinary: isBinaryByExt || !isText, + isTooLarge, + }) + toast.success(`文件 ${currentFile.path} 已重新加载`) + }, [currentFile, fetchFileContent]) + + const closeFile = useCallback(() => { + setCurrentFile(null) + }, []) + + const renderFileContent = () => { + if (!currentFile) { + return ( + + + + + + 点击左侧文件查看内容 + + + ) + } + if (fileLoading) { + return ( + + + + + + 加载中... + + + ) + } + if (currentFile.isTooLarge) { + return ( + + + + + + 文件太大不支持预览 + + + ) + } + if (currentFile.isBinary) { + return ( + + + + + + 二进制文件不支持预览 + + + ) + } + return ( + + ) + } + + if (disabled) { + return ( +
+
+ 项目文件 +
+
+ + + + + + + 开发环境未就绪,请先进入开发页面启动任务 + + + +
+
+ ) + } + + const fileTreePanel = ( +
+
+
+ 项目文件 +
+
+ + + + + 刷新 + + +
+
+
+ +
+
+ ) + + const previewPanel = currentFile && ( +
+
+ {currentFile.name} +
+ + +
+
+
{renderFileContent()}
+
+ ) + + return ( +
+ {currentFile ? ( + + + {fileTreePanel} + + + + {previewPanel} + + + ) : ( +
{fileTreePanel}
+ )} +
+ ) +} diff --git a/frontend/src/components/console/task/task-input.tsx b/frontend/src/components/console/task/task-input.tsx index c8fed039..7712288b 100644 --- a/frontend/src/components/console/task/task-input.tsx +++ b/frontend/src/components/console/task/task-input.tsx @@ -21,6 +21,7 @@ import { apiRequest } from "@/utils/requestUtils"; import { IconBug, IconLink, IconPuzzle, IconSend, IconSourceCode, IconSquareRoundedLetterOFilled, IconTerminal2, IconUpload, IconUser, IconVocabulary, IconXboxX } from "@tabler/icons-react"; import { useEffect, useMemo, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; +import { useSettingsDialog } from "@/pages/console/user/page"; import { flushSync } from "react-dom"; import { toast } from "sonner"; import { VoiceInputButton } from "./voice-input-button"; @@ -84,6 +85,7 @@ interface TaskInputProps { } export function TaskInput({ repos, onTaskCreated }: TaskInputProps) { + const navigate = useNavigate() // 输入相关状态 const [taskContent, setTaskContent] = useState(""); const [taskType, setTaskType] = useState(ConstsTaskType.TaskTypeDevelop); @@ -119,7 +121,7 @@ export function TaskInput({ repos, onTaskCreated }: TaskInputProps) { const fileInputRef = useRef(null); const { models, images, hosts, identities, user } = useCommonData(); - const navigate = useNavigate(); + const { setOpen: setSettingsOpen } = useSettingsDialog(); const modelsWithEconomy = useMemo(() => { const economyModel = { @@ -299,7 +301,7 @@ export function TaskInput({ repos, onTaskCreated }: TaskInputProps) { if (resp.code === 0) { toast.success('任务启动成功'); onTaskCreated(); - window.open(`/console/task/view?taskId=${resp.data?.id}`, "_blank"); + navigate(`/console/task/${resp.data?.id}`); } else { toast.error(resp.message || "任务启动失败"); } @@ -539,7 +541,7 @@ export function TaskInput({ repos, onTaskCreated }: TaskInputProps) { size="sm" onClick={() => { setCodeDropdownOpen(false); - navigate("/console/settings"); + setSettingsOpen(true); }} > 去设置 diff --git a/frontend/src/components/console/task/task-preview-panel.tsx b/frontend/src/components/console/task/task-preview-panel.tsx new file mode 100644 index 00000000..7170e671 --- /dev/null +++ b/frontend/src/components/console/task/task-preview-panel.tsx @@ -0,0 +1,338 @@ +import { ConstsPortStatus, type DomainVMPort, type WebResp } from "@/api/Api" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu" +import { Empty, EmptyDescription, EmptyHeader, EmptyMedia } from "@/components/ui/empty" +import { Item, ItemContent, ItemTitle, ItemGroup, ItemActions, ItemDescription } from "@/components/ui/item" +import { Spinner } from "@/components/ui/spinner" +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" +import { apiRequest } from "@/utils/requestUtils" +import { IconAccessPoint, IconAlertCircle, IconCloudOff, IconCopy, IconDotsVertical, IconHandStop, IconTrash } from "@tabler/icons-react" +import { useState } from "react" +import { toast } from "sonner" + +interface TaskPreviewPanelProps { + ports: DomainVMPort[] | undefined + hostId: string | undefined + vmId: string | undefined + onSuccess?: () => void + disabled?: boolean +} + +export function TaskPreviewPanel({ + ports, + hostId, + vmId, + onSuccess, + disabled, +}: TaskPreviewPanelProps) { + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false) + const [portToDelete, setPortToDelete] = useState(null) + const [portToOpen, setPortToOpen] = useState(0) + const [portToClose, setPortToClose] = useState(0) + const [whitelistDialogOpen, setWhitelistDialogOpen] = useState(false) + const [portToEditWhitelist, setPortToEditWhitelist] = useState(null) + const [whitelistInput, setWhitelistInput] = useState("") + const [whitelistSaving, setWhitelistSaving] = useState(false) + + const confirmDeletePort = () => { + if (!hostId || !vmId || !portToDelete) { + setDeleteDialogOpen(false) + return + } + + setPortToClose(portToDelete.port as number) + setDeleteDialogOpen(false) + + apiRequest('v1UsersHostsVmsPortsDelete', { + forward_id: portToDelete.forward_id + }, [hostId, vmId, String(portToDelete.port)], (resp) => { + if (resp.code === 0) { + toast.success("端口已关闭访问") + onSuccess?.() + } else { + toast.error(resp.message || "关闭访问失败") + } + setPortToClose(0) + }) + } + + const getMyIP = async (): Promise => { + try { + const resp = await fetch('https://monkeycode-ai.online/get-my-ip', { + method: 'GET', + mode: 'cors', + }) + if (!resp.ok) throw new Error() + const data = await resp.json() + return data.ip + } catch { + return null + } + } + + const handleOpenPort = async (port: number, forwardId: string) => { + if (!hostId || !vmId || !port) return + + setPortToOpen(port) + const ip = await getMyIP() + if (!ip) { + toast.error("获取本机 IP 失败") + setPortToOpen(0) + return + } + + await apiRequest('v1UsersHostsVmsPortsCreate', { + forward_id: forwardId, + port: port, + white_list: [ip] + }, [hostId, vmId], (resp: WebResp) => { + if (resp.code === 0 && resp.data?.success) { + toast.success("端口开放成功") + onSuccess?.() + } else { + toast.error(resp.message || "端口开放失败") + } + }) + setPortToOpen(0) + } + + const handleOpenWhitelistDialog = (port: DomainVMPort) => { + setPortToEditWhitelist(port) + setWhitelistInput(port.white_list?.join('\n') || '') + setWhitelistDialogOpen(true) + } + + const handleSaveWhitelist = async () => { + if (!hostId || !vmId || !portToEditWhitelist) return + + const whitelistArray = whitelistInput + .split('\n') + .map(ip => ip.trim()) + .filter(ip => ip.length > 0) + + if (whitelistArray.length === 0) { + toast.error("请至少输入一个 IP 地址") + return + } + + setWhitelistSaving(true) + await apiRequest('v1UsersHostsVmsPortsCreate', { + forward_id: portToEditWhitelist.forward_id, + port: portToEditWhitelist.port, + white_list: whitelistArray + }, [hostId, vmId], (resp: WebResp) => { + if (resp.code === 0) { + toast.success("白名单更新成功") + setWhitelistDialogOpen(false) + onSuccess?.() + } else { + toast.error(resp.message || "白名单更新失败") + } + }) + setWhitelistSaving(false) + } + + if (disabled) { + return ( +
+
+ 在线预览 +
+
+ + + + + + + 开发环境未就绪,无法预览 + + + +
+
+ ) + } + + return ( + <> +
+
+ 在线预览 +
+
+ {(ports && ports.length > 0) ? ( + + {ports.map((port: DomainVMPort) => ( + + + + { + if (port.status === ConstsPortStatus.PortStatusConnected) { + window.open(port.preview_url, '_blank') + } else { + toast.error('端口未开放') + } + }} + > + {port.port} + + {port.error_message && ( + + + + + {port.error_message} + + )} + {port.status === ConstsPortStatus.PortStatusConnected && http} + + + {port.status === ConstsPortStatus.PortStatusConnected && port.white_list && port.white_list.length > 0 + ? `允许 ${port.white_list?.join(', ')} 访问` + : '未开放访问'} + + + + {port.status === ConstsPortStatus.PortStatusReversed && ( + + )} + {port.status === ConstsPortStatus.PortStatusConnected && ( + + )} + + + + + + { + if (port.preview_url) { + try { + await navigator.clipboard.writeText(port.preview_url) + toast.success("访问地址已复制到剪贴板") + } catch { + toast.error(`复制失败,请手动复制:${port.preview_url}`) + } + } + }} + > + + 复制地址 + + handleOpenWhitelistDialog(port)} + disabled={port.status !== ConstsPortStatus.PortStatusConnected} + > + + 白名单 IP + + { + setPortToDelete(port) + setDeleteDialogOpen(true) + }} + > + + 关闭访问 + + + + + + ))} + + ) : ( +
+ + + + + + + 开发环境中没有发现正在监听的端口 + + + +
+ )} +
+
+ + + + + 确认回收 + + 确定要回收端口 "{portToDelete?.port}" 吗?此操作不可撤销。 + + + + setDeleteDialogOpen(false)}>取消 + 确认回收 + + + + + + + + 编辑白名单 IP - 端口 {portToEditWhitelist?.port} + +
+
+ 每行输入一个 IP 地址,只有白名单中的 IP 才能访问此端口。 +
+