dev-manager-tauri/src/components/dashboard/ProjectCard.tsx
lanrtop fea43b0b8a feat: 仪表盘全局快启栏
新增 global_tools 表及 CRUD 命令,Dashboard 顶部增加独立的「快启」工具栏,
可配置与项目无关的日常应用。ProjectToolsModal 重构为通用组件,支持注入
自定义 commands / queryKey;ProjectCard 快启行始终显示并内联添加按钮。
2026-04-07 00:46:13 +09:00

848 lines
36 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<BlueprintStatus["status"], { dot: string; label: string; title: string }> = {
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 (
<button
onClick={handleClick}
disabled={syncing}
title={cfg.title}
className="flex items-center gap-1 px-1.5 py-0.5 rounded-full border border-gray-100 bg-gray-50 hover:bg-gray-100 disabled:opacity-50 transition-colors"
>
<div className={`w-1.5 h-1.5 rounded-full ${cfg.dot} shrink-0`} />
<span className="text-[10px] text-gray-500">{syncing ? "同步中…" : cfg.label}</span>
</button>
);
}
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<HTMLDivElement>(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<Space[]>(["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<ActionsRun | null>({
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_pathWSL 项目仅当 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<BlueprintStatus>({
queryKey: ["blueprintStatus", p.id],
queryFn: () => getBlueprintStatus(blueprintPath!),
enabled: !!blueprintPath,
staleTime: 60000,
retry: false,
});
const { data: buffStatus } = useQuery<BuffStatus | null>({
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<string | null>(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 (
<div className="bg-white rounded-xl border border-gray-200 shadow-sm hover:shadow-md transition-shadow">
{/* ── 卡片头部 ── */}
<div className="px-5 pt-4 pb-3 flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-semibold text-[15px] text-gray-900 leading-tight">{p.name}</span>
{p.type && (
<span className="px-1.5 py-0.5 rounded text-xs bg-indigo-50 text-indigo-600 border border-indigo-100">
{p.type}
</span>
)}
{role && (
<span className="px-1.5 py-0.5 rounded text-xs bg-purple-50 text-purple-600 border border-purple-100">
{role}
</span>
)}
</div>
{p.description && (
<p className="text-xs text-gray-400 mt-1 leading-relaxed line-clamp-2">{p.description}</p>
)}
</div>
<div className="flex flex-col items-end gap-1.5 shrink-0">
<div className="flex gap-1.5">
<PriorityBadge priority={p.priority} />
<StatusBadge status={p.status} />
</div>
{/* 仓库状态三灯 */}
<div className="flex items-center gap-2">
{([
{ 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 }) => (
<div key={label} className="flex items-center gap-1" title={active ? title : `${title}`}>
<div className={`w-1.5 h-1.5 rounded-full transition-colors ${active ? "bg-green-400" : "bg-gray-200"}`} />
<span className={`text-[10px] ${active ? "text-green-600" : "text-gray-300"}`}>{label}</span>
</div>
))}
</div>
{/* 蓝图状态徽章:路径存在且查询已完成(成功或失败均显示) */}
{blueprintPath && !bpPending && (
<BlueprintStatusBadge
status={bpStatus?.status ?? "none"}
syncing={false}
onOpenPrompt={() => setShowBlueprintPrompt(true)}
onOpenViewer={() => setShowBlueprint(true)}
/>
)}
{/* Buff 激活徽章 */}
{buffStatus?.status === "active" && (
<button
onClick={() => setShowBlueprint(true)}
title={`蓝图 Buff 已激活\n上次同步${buffStatus.last_sync_at ?? "未同步"}`}
className="flex items-center gap-1 px-1.5 py-0.5 rounded-full border border-purple-200 bg-purple-50 hover:bg-purple-100 transition-colors"
>
<span className="text-[10px]"></span>
<span className="text-[10px] text-purple-600">Buff</span>
</button>
)}
</div>
</div>
{/* ── 元信息区 ── */}
<div className="px-5 flex flex-col gap-2.5 pb-3">
{/* Recent focus */}
{p.recent_focus && (
<p className="text-xs text-indigo-500 bg-indigo-50 rounded-md px-2.5 py-1.5 leading-relaxed">
📌 {p.recent_focus}
</p>
)}
{/* Tech stack tags */}
{tags.length > 0 && (
<div className="flex flex-wrap gap-1">
{tags.map((t) => (
<span key={t} className="px-2 py-0.5 rounded-full text-xs bg-gray-100 text-gray-500 font-medium">
{t}
</span>
))}
</div>
)}
{/* Paths */}
{(p.wsl_path || p.win_path) && (
<div className="flex flex-col gap-1">
{p.wsl_path && (
<div className="flex items-center gap-2 text-xs bg-green-50 rounded-md px-2.5 py-1.5">
<span className="font-semibold text-green-700 shrink-0">WSL</span>
<span className="font-mono text-green-800 truncate flex-1" title={p.wsl_path}>{p.wsl_path}</span>
<button onClick={() => copy(p.wsl_path!)} className="text-green-400 hover:text-green-600 shrink-0 text-[11px]"></button>
</div>
)}
{p.win_path && (
<div className="flex items-center gap-2 text-xs bg-blue-50 rounded-md px-2.5 py-1.5">
<span className="font-semibold text-blue-700 shrink-0">WIN</span>
<span className="font-mono text-blue-800 truncate flex-1" title={p.win_path}>{p.win_path}</span>
<button onClick={() => copy(p.win_path!)} className="text-blue-400 hover:text-blue-600 shrink-0 text-[11px]"></button>
</div>
)}
</div>
)}
{/* Startup commands */}
{p.startup_commands && (
<div className="flex items-center gap-2 bg-gray-50 border border-gray-100 rounded-md px-2.5 py-2 text-xs">
<code className="flex-1 font-mono text-gray-600 whitespace-pre-wrap break-all">{p.startup_commands}</code>
<button onClick={() => copy(p.startup_commands!)} className="text-gray-400 hover:text-gray-600 shrink-0 text-[11px]"></button>
</div>
)}
{/* 部署链接 */}
{(p.prod_url || p.staging_url || p.server_note) && (
<div className="flex items-center gap-2 flex-wrap">
{p.prod_url && (
<button
onClick={() => openUrl(p.prod_url!).catch(() => {})}
className="flex items-center gap-1 text-xs px-2.5 py-1 rounded-md bg-emerald-50 text-emerald-700 border border-emerald-200 hover:bg-emerald-100 transition-colors"
title={p.prod_url}
>
🌐
</button>
)}
{p.staging_url && (
<button
onClick={() => openUrl(p.staging_url!).catch(() => {})}
className="flex items-center gap-1 text-xs px-2.5 py-1 rounded-md bg-amber-50 text-amber-700 border border-amber-200 hover:bg-amber-100 transition-colors"
title={p.staging_url}
>
🧪
</button>
)}
{p.server_note && (
<span className="text-xs text-gray-400 truncate" title={p.server_note}>
🖥 {p.server_note}
</span>
)}
</div>
)}
</div>
{/* ── Git 状态栏 ── */}
{hasGit && (
<div className="mx-5 mb-3">
<div className="flex items-center gap-2 text-xs px-3 py-2 bg-gray-50 border border-gray-100 rounded-lg">
{gitStat ? (
<>
<div className="relative" ref={branchMenuRef}>
<button
onClick={() => { setShowBranchMenu((v) => !v); refetchBranches(); }}
className="font-mono text-indigo-600 font-semibold hover:text-indigo-800 flex items-center gap-0.5"
title="切换分支"
>
{gitStat.branch}
<svg className="w-3 h-3 text-indigo-400" viewBox="0 0 16 16" fill="currentColor">
<path d="M4.427 7.427l3.396 3.396a.25.25 0 00.354 0l3.396-3.396A.25.25 0 0011.396 7H4.604a.25.25 0 00-.177.427z"/>
</svg>
</button>
{showBranchMenu && (
<div className="absolute left-0 top-full mt-1 w-52 bg-white border border-gray-200 rounded-lg shadow-lg z-50 py-1 text-xs">
<div className="max-h-36 overflow-y-auto">
{branches.map((b) => (
<button
key={b}
onClick={() => handleCheckoutBranch(b)}
className={`w-full text-left px-3 py-1.5 hover:bg-gray-50 font-mono transition-colors ${
b === gitStat.branch ? "text-indigo-600 font-semibold bg-indigo-50" : "text-gray-700"
}`}
>
{b === gitStat.branch ? "✓ " : " "}{b}
</button>
))}
</div>
<div className="border-t border-gray-100 px-2 py-2 flex gap-1">
<input
value={newBranchName}
onChange={(e) => 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
/>
<button
onClick={handleCreateBranch}
disabled={!newBranchName.trim() || creatingBranch}
className="px-2 py-1 rounded bg-blue-600 text-white disabled:opacity-40 hover:bg-blue-700 transition-colors shrink-0"
title="创建新分支"
>
{creatingBranch ? "…" : "+"}
</button>
</div>
</div>
)}
</div>
<div className="flex items-center gap-1">
{gitStat.ahead > 0 && <span className="text-green-600 font-medium">{gitStat.ahead}</span>}
{gitStat.behind > 0 && <span className="text-orange-500 font-medium">{gitStat.behind}</span>}
{gitStat.has_changes && <span className="text-yellow-500 font-bold"></span>}
</div>
{gitStat.last_commit_msg && (
<span className="text-gray-400 truncate flex-1" title={gitStat.last_commit_msg}>
{gitStat.last_commit_msg}
</span>
)}
<span className="text-gray-300 shrink-0">{gitStat.last_commit_time?.slice(5)}</span>
<div className="flex items-center gap-1 shrink-0">
<button
onClick={() => fetchMutation.mutate()}
disabled={fetchMutation.isPending}
className="px-2 py-0.5 rounded border border-gray-200 bg-white hover:bg-gray-100 text-gray-500 disabled:opacity-50 transition-colors"
title="Fetch"
>
{fetchMutation.isPending ? "…" : "↻"}
</button>
{gitStat.behind > 0 && (
<button
onClick={() => pullMutation.mutate()}
disabled={pullMutation.isPending}
className="px-2 py-0.5 rounded border border-orange-200 bg-orange-50 text-orange-600 hover:bg-orange-100 disabled:opacity-50 transition-colors"
>
{pullMutation.isPending ? "…" : "Pull"}
</button>
)}
{gitStat.ahead > 0 && (
<button
onClick={() => pushMutation.mutate()}
disabled={pushMutation.isPending}
className="px-2 py-0.5 rounded border border-green-200 bg-green-50 text-green-600 hover:bg-green-100 disabled:opacity-50 transition-colors"
>
{pushMutation.isPending ? "…" : "Push"}
</button>
)}
{prUrl && (
<button
onClick={() => openUrl(prUrl).catch(() => {})}
className="px-2 py-0.5 rounded border border-purple-200 bg-purple-50 text-purple-600 hover:bg-purple-100 transition-colors"
title="在 GitHub 发起 PR"
>
PR
</button>
)}
{latestRun && (
<button
onClick={() => setShowCicd(true)}
title={`CI: ${latestRun.conclusion ?? latestRun.status}`}
className="px-1.5 py-0.5 rounded border border-gray-200 bg-white hover:bg-gray-50 transition-colors text-[11px]"
>
{latestRun.status === "in_progress" || latestRun.status === "queued"
? "🔄"
: latestRun.conclusion === "success"
? "✅"
: latestRun.conclusion === "failure"
? "❌"
: "⊘"}
</button>
)}
</div>
</>
) : gitError ? (
<span className="text-red-400 text-[11px]"> git </span>
) : (
<span className="text-gray-300"> git </span>
)}
</div>
</div>
)}
{/* ── 跨空间感知 ── */}
{otherSpacesInfo.length > 0 && (
<div className="mx-5 mb-3 flex flex-col gap-1.5">
{otherSpacesInfo.map((info) => (
<div key={info.spaceId} className="flex items-center gap-2 text-xs px-3 py-2 bg-indigo-50 rounded-lg border border-indigo-100">
<span className="text-indigo-400 shrink-0"></span>
<span className="text-indigo-700 font-medium shrink-0">{info.spaceName}</span>
<span className="font-mono text-indigo-500 shrink-0">{info.branch}</span>
{info.last_commit_msg && (
<span className="text-indigo-400 truncate flex-1">{info.last_commit_msg}</span>
)}
<span className="text-indigo-300 shrink-0">{formatRelativeTime(info.last_push_at)}</span>
{hasGit && (
<button
onClick={() => handleResume(info.branch, info.spaceId)}
disabled={resuming === info.spaceId}
className="px-2.5 py-1 rounded-md border border-indigo-300 bg-white text-indigo-700 hover:bg-indigo-100 disabled:opacity-50 shrink-0 transition-colors"
>
{resuming === info.spaceId ? "…" : "接续"}
</button>
)}
</div>
))}
</div>
)}
{/* ── Upstream 同步提示 ── */}
{upstreamBehind > 0 && (
<div className="mx-5 mb-3 flex items-center gap-2 text-xs px-3 py-2 bg-amber-50 rounded-lg border border-amber-200">
<span className="text-amber-500"></span>
<span className="text-amber-700 flex-1"> <strong>{upstreamBehind}</strong> </span>
<code className="text-amber-400 text-[11px]">git fetch upstream</code>
</div>
)}
{/* ── 标签行(纯展示,编辑请点「编辑」) */}
{customTags.length > 0 && (
<div className="border-t border-gray-100 px-5 py-2.5 flex items-center gap-1.5 flex-wrap">
<span className="text-[11px] font-semibold text-violet-500 shrink-0"></span>
{customTags.map((t) => (
<span key={t} className="px-2 py-0.5 rounded-full text-xs bg-violet-50 text-violet-700 border border-violet-200 font-medium">
{t}
</span>
))}
</div>
)}
{/* ── 快捷操作区 ── */}
{(p.wsl_path || p.win_path) && (
<div className="border-t border-gray-100 px-5 py-3 flex flex-col gap-2">
{p.wsl_path && (
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-[11px] font-semibold text-green-700 bg-green-50 border border-green-200 rounded px-1.5 py-0.5 shrink-0">WSL</span>
{activeEditors.map((ed) => (
<button key={ed.id} onClick={() => handleOpenEditor("wsl", ed.path)}
className="px-2.5 py-1 rounded-md text-xs border border-gray-200 text-gray-600 hover:bg-green-50 hover:border-green-300 hover:text-green-800 transition-colors">
💻 {ed.name}
</button>
))}
<button onClick={() => handleOpenTerminal("wsl")}
className="px-2.5 py-1 rounded-md text-xs border border-gray-200 text-gray-600 hover:bg-amber-50 hover:border-amber-300 hover:text-amber-800 transition-colors">
</button>
</div>
)}
{p.win_path && (
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-[11px] font-semibold text-blue-700 bg-blue-50 border border-blue-200 rounded px-1.5 py-0.5 shrink-0">WIN</span>
{activeEditors.map((ed) => (
<button key={ed.id} onClick={() => handleOpenEditor("win", ed.path)}
className="px-2.5 py-1 rounded-md text-xs border border-gray-200 text-gray-600 hover:bg-green-50 hover:border-green-300 hover:text-green-800 transition-colors">
💻 {ed.name}
</button>
))}
<button onClick={() => handleOpenTerminal("win")}
className="px-2.5 py-1 rounded-md text-xs border border-gray-200 text-gray-600 hover:bg-amber-50 hover:border-amber-300 hover:text-amber-800 transition-colors">
</button>
</div>
)}
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-[11px] font-semibold text-purple-700 bg-purple-50 border border-purple-200 rounded px-1.5 py-0.5 shrink-0"></span>
{projectTools.length > 0 ? (
<>
{projectTools.map((tool) => (
<button key={tool.id} onClick={() => handleLaunchTool(tool.exe_path, tool.args, tool.name)}
className="px-2.5 py-1 rounded-md text-xs border border-gray-200 text-gray-600 hover:bg-purple-50 hover:border-purple-300 hover:text-purple-800 transition-colors"
title={tool.exe_path}>
{tool.icon} {tool.name}
</button>
))}
<button
onClick={() => { setToolsMgrDefaultAdd(true); setShowToolsMgr(true); }}
className="px-2 py-1 rounded-md text-xs border border-dashed border-purple-200 text-purple-400 hover:border-purple-400 hover:text-purple-600 hover:bg-purple-50 transition-colors"
title="添加快启应用">
+
</button>
</>
) : (
<button
onClick={() => { setToolsMgrDefaultAdd(true); setShowToolsMgr(true); }}
className="px-2.5 py-1 rounded-md text-xs border border-dashed border-purple-200 text-purple-400 hover:border-purple-400 hover:text-purple-600 hover:bg-purple-50 transition-colors">
+
</button>
)}
</div>
</div>
)}
{/* ── 底部操作栏 ── */}
<div className="border-t border-gray-100 px-5 py-2.5 flex items-center justify-between">
<span className="text-[11px] text-gray-300">{(p.updated_at ?? "").slice(0, 10)}</span>
<div className="flex items-center gap-1.5">
<button onClick={() => setShowMentor(true)}
className="px-2.5 py-1.5 rounded-md text-xs border border-gray-200 text-gray-500 hover:bg-gray-50 transition-colors" title="项目导师">
🎓
</button>
<button onClick={() => setShowBlueprint(true)}
className="px-2.5 py-1.5 rounded-md text-xs border border-gray-200 text-gray-500 hover:bg-gray-50 transition-colors" title="项目蓝图">
🗺
</button>
<button onClick={() => setShowCicd(true)}
className="px-2.5 py-1.5 rounded-md text-xs border border-gray-200 text-gray-500 hover:bg-gray-50 transition-colors" title="CI/CD 控制台">
🚀
</button>
<button onClick={() => setShowEnvScan(true)}
className="px-2.5 py-1.5 rounded-md text-xs border border-gray-200 text-gray-500 hover:bg-gray-50 transition-colors" title="环境扫描">
🔬
</button>
<button onClick={() => setShowToolsMgr(true)}
className="px-2.5 py-1.5 rounded-md text-xs border border-gray-200 text-gray-500 hover:bg-gray-50 transition-colors" title="工具管理">
🧰
</button>
{/* 未完全绑定时显示「发布到 GitHub」 */}
{(!p.repo_id || !p.repo_url) && (p.win_path || p.wsl_path) && (
<button
onClick={() => setShowPublish(true)}
className="px-2.5 py-1.5 rounded-md text-xs border border-blue-200 text-blue-600 bg-blue-50 hover:bg-blue-100 transition-colors"
title="发布到 GitHub"
>
</button>
)}
<div className="w-px h-4 bg-gray-200 mx-0.5" />
<button onClick={() => openEdit(p)}
className="px-3 py-1.5 rounded-md text-xs border border-gray-200 text-gray-600 hover:bg-gray-50 transition-colors">
</button>
<button onClick={() => setExpanded((v) => !v)}
className="px-3 py-1.5 rounded-md text-xs border border-gray-200 text-gray-600 hover:bg-gray-50 transition-colors">
{expanded ? "收起 ▲" : "动态 ▼"}
</button>
</div>
</div>
{showToolsMgr && (
<ProjectToolsModal
projectId={p.id}
projectName={p.name}
defaultOpenAdd={toolsMgrDefaultAdd}
onClose={() => { setShowToolsMgr(false); setToolsMgrDefaultAdd(false); }}
/>
)}
{showEnvScan && (
<ProjectEnvModal
project={p}
onClose={() => setShowEnvScan(false)}
/>
)}
{showPublish && (
<PublishModal project={p} onClose={() => setShowPublish(false)} />
)}
{showCicd && (
<CicdModal
projectId={p.id}
projectName={p.name}
repoUrl={p.repo_url ?? undefined}
winPath={p.win_path ?? undefined}
wslPath={p.wsl_path ?? undefined}
platform={p.platform ?? undefined}
onClose={() => setShowCicd(false)}
/>
)}
{showBlueprint && (p.win_path || p.wsl_path) && (
<BlueprintModal
projectName={p.name}
projectPath={p.win_path || p.wsl_path || ""}
projectId={p.id}
onClose={() => setShowBlueprint(false)}
/>
)}
{showBlueprintPrompt && blueprintPath && (
<BlueprintPromptModal
projectName={p.name}
projectPath={blueprintPath}
bpStatus={bpStatus?.status ?? "none"}
onClose={() => setShowBlueprintPrompt(false)}
onSynced={() => refetchBpStatus()}
/>
)}
{showMentor && (
<MentorPanel
projectId={p.id}
projectName={p.name}
onClose={() => setShowMentor(false)}
/>
)}
{/* Expanded activity */}
{expanded && (
<div className="border-t border-gray-50 pt-3">
<p className="text-xs font-medium text-gray-500 mb-2"></p>
{!acts ? (
<p className="text-xs text-gray-400"></p>
) : acts.length === 0 ? (
<p className="text-xs text-gray-400"></p>
) : (
<ul className="flex flex-col gap-1">
{acts.map((a) => (
<li key={a.id} className="flex gap-2 text-xs text-gray-500">
<span className="text-gray-300 shrink-0">{(a.created_at ?? "").slice(5, 10)}</span>
<span>{a.summary}</span>
</li>
))}
</ul>
)}
</div>
)}
</div>
);
}