import { useState, useRef, useEffect } from "react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { getActivity, getSettings, getProjectTools, openEditor, openTerminal, launchTool, gitStatus, gitFetch, gitPull, gitPush, gitUpstreamBehind, githubGetToken, githubGetAccount, gitCreateBranch, gitListBranches, gitCheckoutBranch, fetchActionsRuns, parseRepoPath, getBlueprintStatus, getBuffStatus, type Project, type Space, type ActionsRun, type BlueprintStatus, type BuffStatus } from "../../lib/commands"; import { useUIStore } from "../../store/ui"; import { PriorityBadge, StatusBadge } from "../ui/Badge"; import { ProjectToolsModal } from "./ProjectToolsModal"; import { ProjectEnvModal } from "./ProjectEnvModal"; import { CicdModal } from "./CicdModal"; import { PublishModal } from "./PublishModal"; import { BlueprintModal } from "./BlueprintModal"; import { BlueprintPromptModal } from "./BlueprintPromptModal"; import { MentorPanel } from "../mentor/MentorPanel"; import { getOtherSpacesInfo, recordPushToRemote, formatRelativeTime, type SpacesJson } from "../../lib/spacesSync"; import { openUrl } from "@tauri-apps/plugin-opener"; // ── 蓝图状态徽章 ────────────────────────────────────────────────────────────── const BP_BADGE: Record = { none: { dot: "bg-gray-300", label: "无蓝图", title: "项目尚未接入蓝图,点击获取初始化提示词" }, rules_outdated: { dot: "bg-yellow-400", label: "规则待更新", title: "CONVENTIONS.md 版本落后,点击查看详情" }, content_stale: { dot: "bg-amber-400", label: "内容待同步", title: "蓝图内容可能落后于代码,点击获取同步提示词" }, synced: { dot: "bg-green-400", label: "已同步", title: "蓝图规则和内容均为最新" }, }; function BlueprintStatusBadge({ status, syncing, onOpenPrompt, onOpenViewer, }: { status: BlueprintStatus["status"]; syncing: boolean; onOpenPrompt: () => void; onOpenViewer: () => void; }) { const cfg = BP_BADGE[status]; const handleClick = () => { if (status === "synced") { onOpenViewer(); return; } onOpenPrompt(); // none / rules_outdated / content_stale }; return ( ); } interface Props { project: Project; role?: string; spacesJson?: SpacesJson | null; } export function ProjectCard({ project: p, role, spacesJson }: Props) { const [expanded, setExpanded] = useState(false); const [showToolsMgr, setShowToolsMgr] = useState(false); const [toolsMgrDefaultAdd, setToolsMgrDefaultAdd] = useState(false); const [showEnvScan, setShowEnvScan] = useState(false); const [showCicd, setShowCicd] = useState(false); const [showPublish, setShowPublish] = useState(false); const [showBlueprint, setShowBlueprint] = useState(false); const [showBlueprintPrompt, setShowBlueprintPrompt] = useState(false); const [showMentor, setShowMentor] = useState(false); const [showBranchMenu, setShowBranchMenu] = useState(false); const [newBranchName, setNewBranchName] = useState(""); const [creatingBranch, setCreatingBranch] = useState(false); const branchMenuRef = useRef(null); const showToast = useUIStore((s) => s.showToast); const openEdit = useUIStore((s) => s.openEdit); const queryClient = useQueryClient(); // 根据 platform 选取本地路径;git 操作目前仅支持 Windows 侧路径 const localPath = p.platform === "wsl" ? p.wsl_path : p.win_path; const hasGit = !!(localPath && p.repo_url && p.platform !== "wsl"); const { data: gitStat, isError: gitError, refetch: refetchGit } = useQuery({ queryKey: ["gitStatus", p.id], queryFn: () => gitStatus(localPath!), enabled: hasGit, staleTime: 30000, retry: false, }); const { data: branches = [], refetch: refetchBranches } = useQuery({ queryKey: ["branches", p.id], queryFn: () => gitListBranches(localPath!), enabled: hasGit, staleTime: 30000, }); useEffect(() => { if (!showBranchMenu) return; const handler = (e: MouseEvent) => { if (branchMenuRef.current && !branchMenuRef.current.contains(e.target as Node)) { setShowBranchMenu(false); setNewBranchName(""); } }; document.addEventListener("mousedown", handler); return () => document.removeEventListener("mousedown", handler); }, [showBranchMenu]); const pullMutation = useMutation({ mutationFn: () => gitPull(localPath!), onSuccess: (msg) => { showToast(msg); queryClient.invalidateQueries({ queryKey: ["gitStatus", p.id] }); }, onError: (e) => showToast(`Pull 失败: ${e}`), }); const fetchMutation = useMutation({ mutationFn: () => gitFetch(localPath!), onSuccess: () => { showToast("已同步远端信息"); refetchGit(); }, onError: (e) => showToast(`Fetch 失败: ${e}`), }); const pushMutation = useMutation({ mutationFn: async () => { const msg = await gitPush(localPath!); // push 成功后,把最新信息同步到 .dev-spaces try { const token = await githubGetToken(); const account = await githubGetAccount(); // 从 React Query 缓存取 spaces,避免重复 DB 查询 const spaces = queryClient.getQueryData(["spaces"]) ?? []; const currentSpace = spaces.find((s) => s.is_current); if (token && account && currentSpace && p.repo_url && gitStat) { await recordPushToRemote( token, account.username, currentSpace.id, currentSpace.name, p.repo_url, gitStat.branch, gitStat.last_commit_msg ?? undefined ); queryClient.invalidateQueries({ queryKey: ["spacesSync"] }); } } catch { // 同步失败不阻断主流程 } return msg; }, onSuccess: (msg) => { showToast(msg); queryClient.invalidateQueries({ queryKey: ["gitStatus", p.id] }); }, onError: (e) => showToast(`Push 失败: ${e}`), }); // 跨空间感知:其他空间对同一仓库的最新 push 信息 const otherSpacesInfo = spacesJson && p.repo_url && p.space_id ? getOtherSpacesInfo(spacesJson, p.space_id, p.repo_url) : []; // upstream 落后检查(有 upstream_url 且有本地路径时) const { data: upstreamBehind = 0 } = useQuery({ queryKey: ["upstreamBehind", p.id], queryFn: () => gitUpstreamBehind(localPath!, p.default_branch ?? "main"), enabled: !!(localPath && p.upstream_url), staleTime: 10 * 60 * 1000, retry: false, }); // CI 最新 run 状态(有 repo_url 时才查) const ciRepoPath = p.repo_url ? parseRepoPath(p.repo_url) : null; const { data: latestRun } = useQuery({ queryKey: ["ciLatest", p.id], queryFn: async () => { if (!ciRepoPath) return null; const token = await githubGetToken(); if (!token) return null; const runs = await fetchActionsRuns(token, ciRepoPath, 1); return runs[0] ?? null; }, enabled: !!ciRepoPath, staleTime: 60000, retry: false, }); // 蓝图路径:优先 win_path;WSL 项目仅当 wsl_path 为 UNC 格式时可从 Windows 侧访问 const blueprintPath = p.win_path || (p.wsl_path?.startsWith("\\\\wsl.") ? p.wsl_path : undefined); const { data: bpStatus, refetch: refetchBpStatus, isPending: bpPending } = useQuery({ queryKey: ["blueprintStatus", p.id], queryFn: () => getBlueprintStatus(blueprintPath!), enabled: !!blueprintPath, staleTime: 60000, retry: false, }); const { data: buffStatus } = useQuery({ queryKey: ["buffStatus", blueprintPath], queryFn: () => getBuffStatus(blueprintPath!), enabled: !!blueprintPath, staleTime: 10000, retry: false, }); // PR 链接:从 repo_url 解析出 GitHub 路径 const prUrl = (() => { if (!p.repo_url || !gitStat) return null; const match = p.repo_url.match(/github\.com[/:](.+?)(?:\.git)?$/); if (!match) return null; const repoPath = match[1]; const base = p.default_branch ?? "main"; const head = gitStat.branch; if (head === base) return `https://github.com/${repoPath}/compare`; return `https://github.com/${repoPath}/compare/${base}...${head}`; })(); const { data: settings } = useQuery({ queryKey: ["settings", p.space_id], queryFn: () => getSettings(p.space_id ?? undefined), staleTime: 30000, }); const { data: projectTools = [] } = useQuery({ queryKey: ["projectTools", p.id], queryFn: () => getProjectTools(p.id), }); const { data: acts } = useQuery({ queryKey: ["activity", p.id], queryFn: () => getActivity(p.id, 5), enabled: expanded, }); // 当前已启用的编辑器列表 const activeEditors: { id: string; name: string; path: string }[] = (() => { try { const list: { id: string; name: string; path: string }[] = settings?.editors ? JSON.parse(settings.editors) : []; const activeIds: string[] = settings?.active_editors ? JSON.parse(settings.active_editors) : settings?.active_editor ? [settings.active_editor] : []; const active = list.filter((e) => activeIds.includes(e.id)); return active.length > 0 ? active : list.slice(0, 1); } catch { return []; } })(); const tags = (p.tech_stack ?? "").split(",").map((t) => t.trim()).filter(Boolean); const handleOpenEditor = async (env: "wsl" | "win", editorPath: string) => { try { await openEditor(p.id, env, editorPath); showToast("已发送指令 ✓"); } catch (e) { showToast(`❌ ${e}`); } }; const handleOpenTerminal = async (env: "wsl" | "win") => { try { await openTerminal(p.id, env); showToast("已发送指令 ✓"); } catch (e) { showToast(`❌ ${e}`); } }; const handleLaunchTool = async (exePath: string, args?: string | null, name?: string) => { try { await launchTool(exePath, args ?? undefined); showToast(`已启动 ${name ?? ""} ✓`); } catch (e) { showToast(`❌ ${e}`); } }; const copy = (text: string) => { navigator.clipboard.writeText(text).catch(() => {}); showToast("已复制 ✓"); }; const handleCheckoutBranch = async (branchName: string) => { try { await gitCheckoutBranch(localPath!, branchName); setShowBranchMenu(false); queryClient.invalidateQueries({ queryKey: ["gitStatus", p.id] }); queryClient.invalidateQueries({ queryKey: ["branches", p.id] }); showToast(`已切换到 ${branchName}`); } catch (e) { showToast(`切换失败: ${e}`); } }; const handleCreateBranch = async () => { if (!newBranchName.trim()) return; setCreatingBranch(true); try { await gitCreateBranch(localPath!, newBranchName.trim()); showToast(`已创建并切换到 ${newBranchName.trim()}`); setNewBranchName(""); setShowBranchMenu(false); queryClient.invalidateQueries({ queryKey: ["gitStatus", p.id] }); queryClient.invalidateQueries({ queryKey: ["branches", p.id] }); } catch (e) { showToast(`创建失败: ${e}`); } finally { setCreatingBranch(false); } }; const [resuming, setResuming] = useState(null); // spaceId 正在接续 const customTags = (p.tags ?? "").split(",").map((t) => t.trim()).filter(Boolean); const handleResume = async (branch: string, spaceId: string) => { if (!hasGit) { showToast("❌ 当前项目没有配置 git 路径"); return; } if (gitStat?.has_changes) { showToast("❌ 本地有未提交改动,请先 commit 或 stash 后再接续"); return; } setResuming(spaceId); try { await gitCheckoutBranch(localPath!, branch); await gitPull(localPath!); showToast(`已切换到 ${branch} 并拉取最新代码 ✓`); queryClient.invalidateQueries({ queryKey: ["gitStatus", p.id] }); queryClient.invalidateQueries({ queryKey: ["branches", p.id] }); } catch (e) { showToast(`接续失败: ${e}`); } finally { setResuming(null); } }; return (
{/* ── 卡片头部 ── */}
{p.name} {p.type && ( {p.type} )} {role && ( {role} )}
{p.description && (

{p.description}

)}
{/* 仓库状态三灯 */}
{([ { label: "本地项目", active: !!(p.win_path || p.wsl_path), title: "本地路径已配置" }, { label: "本地仓库", active: !!p.repo_id, title: "已关联仓库注册表" }, { label: "远程仓库", active: !!p.repo_url, title: "远程仓库 URL 已填写" }, ] as const).map(({ label, active, title }) => (
{label}
))}
{/* 蓝图状态徽章:路径存在且查询已完成(成功或失败均显示) */} {blueprintPath && !bpPending && ( setShowBlueprintPrompt(true)} onOpenViewer={() => setShowBlueprint(true)} /> )} {/* Buff 激活徽章 */} {buffStatus?.status === "active" && ( )}
{/* ── 元信息区 ── */}
{/* Recent focus */} {p.recent_focus && (

📌 {p.recent_focus}

)} {/* Tech stack tags */} {tags.length > 0 && (
{tags.map((t) => ( {t} ))}
)} {/* Paths */} {(p.wsl_path || p.win_path) && (
{p.wsl_path && (
WSL {p.wsl_path}
)} {p.win_path && (
WIN {p.win_path}
)}
)} {/* Startup commands */} {p.startup_commands && (
{p.startup_commands}
)} {/* 部署链接 */} {(p.prod_url || p.staging_url || p.server_note) && (
{p.prod_url && ( )} {p.staging_url && ( )} {p.server_note && ( 🖥 {p.server_note} )}
)}
{/* ── Git 状态栏 ── */} {hasGit && (
{gitStat ? ( <>
{showBranchMenu && (
{branches.map((b) => ( ))}
setNewBranchName(e.target.value)} onKeyDown={(e) => e.key === "Enter" && handleCreateBranch()} placeholder="new-branch-name" className="flex-1 bg-gray-50 border border-gray-200 rounded px-2 py-1 text-xs font-mono outline-none focus:ring-1 focus:ring-blue-300 placeholder:text-gray-400" autoFocus />
)}
{gitStat.ahead > 0 && ↑{gitStat.ahead}} {gitStat.behind > 0 && ↓{gitStat.behind}} {gitStat.has_changes && }
{gitStat.last_commit_msg && ( {gitStat.last_commit_msg} )} {gitStat.last_commit_time?.slice(5)}
{gitStat.behind > 0 && ( )} {gitStat.ahead > 0 && ( )} {prUrl && ( )} {latestRun && ( )}
) : gitError ? ( ⚠ 无法读取 git 状态 ) : ( 加载 git 状态… )}
)} {/* ── 跨空间感知 ── */} {otherSpacesInfo.length > 0 && (
{otherSpacesInfo.map((info) => (
{info.spaceName} {info.branch} {info.last_commit_msg && ( {info.last_commit_msg} )} {formatRelativeTime(info.last_push_at)} {hasGit && ( )}
))}
)} {/* ── Upstream 同步提示 ── */} {upstreamBehind > 0 && (
上游仓库有 {upstreamBehind} 个新提交未同步 git fetch upstream
)} {/* ── 标签行(纯展示,编辑请点「编辑」) */} {customTags.length > 0 && (
标签 {customTags.map((t) => ( {t} ))}
)} {/* ── 快捷操作区 ── */} {(p.wsl_path || p.win_path) && (
{p.wsl_path && (
WSL {activeEditors.map((ed) => ( ))}
)} {p.win_path && (
WIN {activeEditors.map((ed) => ( ))}
)}
快启 {projectTools.length > 0 ? ( <> {projectTools.map((tool) => ( ))} ) : ( )}
)} {/* ── 底部操作栏 ── */}
{(p.updated_at ?? "").slice(0, 10)}
{/* 未完全绑定时显示「发布到 GitHub」 */} {(!p.repo_id || !p.repo_url) && (p.win_path || p.wsl_path) && ( )}
{showToolsMgr && ( { setShowToolsMgr(false); setToolsMgrDefaultAdd(false); }} /> )} {showEnvScan && ( setShowEnvScan(false)} /> )} {showPublish && ( setShowPublish(false)} /> )} {showCicd && ( setShowCicd(false)} /> )} {showBlueprint && (p.win_path || p.wsl_path) && ( setShowBlueprint(false)} /> )} {showBlueprintPrompt && blueprintPath && ( setShowBlueprintPrompt(false)} onSynced={() => refetchBpStatus()} /> )} {showMentor && ( setShowMentor(false)} /> )} {/* Expanded activity */} {expanded && (

最近动态

{!acts ? (

加载中…

) : acts.length === 0 ? (

暂无记录

) : (
    {acts.map((a) => (
  • {(a.created_at ?? "").slice(5, 10)} {a.summary}
  • ))}
)}
)}
); }