dev-manager-tauri/src/components/dashboard/Dashboard.tsx
lanrtop 289b558278 refactor(github): GitHub 全面退役——免登录 + 发布/导入/同步链路移除 + 凭据去 GitHub 化
D1 免登录改造:App 启动直接进主界面,删 LoginPage/github.rs/OAuth 全套命令、
   Sidebar 账户区与 GitHub OAuth 设置弹窗、健康检查 GitHub Token 项
D2 移除发布/导入/PR/CI 入口:删 PublishModal/ImportRepoModal、ProjectCard 的
   PR compare 链接与 Actions 状态灯、RepoRegistryModal 同步 tab、CicdModal runs tab、
   publish.rs 的 github_create_repo/git_push_to_github
D3 spacesSync 单机化:删 spacesSync/useSpacesSync/SpacesPage/DiscoveryPanel,
   拆除 App→Dashboard→ProjectCard 的 spacesJson 链
D4 git_ops 凭据链重写:ssh agent → git credential helper → gitea_instances token
   前缀匹配 → default,不再依赖 github_token

保留:server_software GitHub 镜像(Releases 下载加速)、gitea_migrate 迁移入口、
cicd.rs(待 Gitea Actions 改造卡);DB 表不删(R05 migration 兼容)

顺带:修复 servers.rs 两个存量测试的 schema 漂移(测试建表缺 deploy_type 列);
      包含会话前未提交的 agent-infra F3(get_agent_health 健康诊断,与 lib.rs/
      commands.ts 物理耦合无法拆分提交)

验证:cargo test 53 passed / typecheck 全绿 / vitest passWithNoTests
2026-07-02 13:15:56 +09:00

677 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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 } from "react";
import { useQuery } from "@tanstack/react-query";
import { useQueryClient } from "@tanstack/react-query";
import { getGroups, getGroupMaps, getProjects, getSpaces, getTags, getAllProjectTagMaps, getGlobalTools, addGlobalTool, updateGlobalTool, removeGlobalTool, launchTool, type ProductGroup, type Project, type ProjectToolInput } from "../../lib/commands";
import { ProjectCard } from "./ProjectCard";
import { DeployModal } from "./DeployModal";
import { GroupResourceCanvas } from "./GroupResourceCanvas";
import { ManagePage } from "../manage/ManagePage";
import { ServerManagerPanel } from "./ServerManagerPanel";
import { ServerSoftwarePanel } from "./ServerSoftwareBlock";
import { ApiKeysPanel } from "./ApiKeysPanel";
import { CloudProductsPanel } from "./CloudProductsPanel";
import { BatchAddModal } from "./BatchAddModal";
import { MountRepoModal } from "./MountRepoModal";
import { ProjectToolsModal } from "./ProjectToolsModal";
import { StatusBadge } from "../ui/Badge";
import { openUrl } from "@tauri-apps/plugin-opener";
import { useUIStore } from "../../store/ui";
type DashView = "board" | "manage";
type BoardTab = "groups" | "standalone" | "servers" | "software" | "apikeys" | "cloud";
type GroupView = "default" | "resource";
export function Dashboard() {
const [view, setView] = useState<DashView>("board");
const [boardTab, setBoardTab] = useState<BoardTab>("groups");
const [showBatch, setShowBatch] = useState(false);
const [showMount, setShowMount] = useState(false);
const qc = useQueryClient();
const { data: spaces = [] } = useQuery({
queryKey: ["spaces"],
queryFn: getSpaces,
});
const currentSpace = spaces.find((s) => s.is_current);
const { data: projects = [] } = useQuery({
queryKey: ["projects", currentSpace?.id],
queryFn: () => getProjects(undefined, currentSpace?.id),
});
const { data: groups = [] } = useQuery({
queryKey: ["groups", currentSpace?.id],
queryFn: () => getGroups(currentSpace?.id),
});
const { data: maps = [] } = useQuery({ queryKey: ["maps"], queryFn: getGroupMaps });
const mappedIds = new Set(maps.map((m) => m.project_id));
const standalone = projects.filter((p) => !mappedIds.has(p.id));
return (
<div className="flex flex-col h-full">
{/* 子导航栏 */}
<div className="border-b border-gray-100 bg-white px-4 flex items-center gap-1">
{(["board", "manage"] as DashView[]).map((v) => (
<button
key={v}
onClick={() => setView(v)}
className={`px-4 py-2.5 text-sm font-medium border-b-2 -mb-px transition-colors ${
view === v
? "border-blue-500 text-blue-600"
: "border-transparent text-gray-500 hover:text-gray-700"
}`}
>
{v === "board" ? "看板" : "管理"}
</button>
))}
<div className="flex-1" />
{view === "board" && (
<div className="flex gap-2 py-1.5">
<button
onClick={() => setShowBatch(true)}
className="px-3 py-1.5 rounded-md text-xs font-medium border border-gray-200 text-gray-600 hover:bg-gray-50 transition-colors"
>
</button>
<button
onClick={() => setShowMount(true)}
className="px-3 py-1.5 rounded-md text-xs font-medium border border-gray-200 text-gray-600 hover:bg-gray-50 transition-colors"
>
</button>
</div>
)}
</div>
{showBatch && <BatchAddModal onClose={() => { setShowBatch(false); qc.invalidateQueries({ queryKey: ["projects"] }); }} />}
{showMount && currentSpace && (
<MountRepoModal
spaceId={currentSpace.id}
onClose={() => setShowMount(false)}
/>
)}
{/* 内容区 */}
{view === "manage" ? (
<div className="flex-1 min-h-0">
<ManagePage />
</div>
) : (
<div className="flex-1 flex flex-col min-h-0">
{/* 看板子标签 */}
<div className="shrink-0 border-b border-gray-100 bg-white px-8 flex items-center gap-1">
<button
onClick={() => setBoardTab("groups")}
className={`px-4 py-2.5 text-sm font-medium border-b-2 -mb-px transition-colors ${
boardTab === "groups"
? "border-blue-500 text-blue-600"
: "border-transparent text-gray-400 hover:text-gray-600"
}`}
>
{groups.filter((g) => maps.some((m) => m.group_id === g.id)).length > 0 && (
<span className="ml-1.5 text-xs bg-gray-100 text-gray-500 rounded-full px-1.5 py-0.5">
{groups.filter((g) => maps.some((m) => m.group_id === g.id)).length}
</span>
)}
</button>
<button
onClick={() => setBoardTab("standalone")}
className={`px-4 py-2.5 text-sm font-medium border-b-2 -mb-px transition-colors ${
boardTab === "standalone"
? "border-blue-500 text-blue-600"
: "border-transparent text-gray-400 hover:text-gray-600"
}`}
>
{standalone.length > 0 && (
<span className="ml-1.5 text-xs bg-gray-100 text-gray-500 rounded-full px-1.5 py-0.5">
{standalone.length}
</span>
)}
</button>
<button
onClick={() => setBoardTab("servers")}
className={`px-4 py-2.5 text-sm font-medium border-b-2 -mb-px transition-colors ${
boardTab === "servers"
? "border-blue-500 text-blue-600"
: "border-transparent text-gray-400 hover:text-gray-600"
}`}
>
</button>
<button
onClick={() => setBoardTab("software")}
className={`px-4 py-2.5 text-sm font-medium border-b-2 -mb-px transition-colors ${
boardTab === "software"
? "border-blue-500 text-blue-600"
: "border-transparent text-gray-400 hover:text-gray-600"
}`}
>
</button>
<button
onClick={() => setBoardTab("apikeys")}
className={`px-4 py-2.5 text-sm font-medium border-b-2 -mb-px transition-colors ${
boardTab === "apikeys"
? "border-blue-500 text-blue-600"
: "border-transparent text-gray-400 hover:text-gray-600"
}`}
>
API
</button>
<button
onClick={() => setBoardTab("cloud")}
className={`px-4 py-2.5 text-sm font-medium border-b-2 -mb-px transition-colors ${
boardTab === "cloud"
? "border-blue-500 text-blue-600"
: "border-transparent text-gray-400 hover:text-gray-600"
}`}
>
</button>
</div>
<GlobalQuickLaunchBar />
<div className="flex-1 overflow-y-auto min-h-0">
<div className="w-full px-8 py-8 space-y-8 pb-16">
{/* ── 产品组标签 ── */}
{boardTab === "groups" && (
<>
{groups.map((g) => {
const members = maps
.filter((m) => m.group_id === g.id)
.map((m) => ({ project: projects.find((p) => p.id === m.project_id), role: m.role }))
.filter((x): x is { project: NonNullable<typeof x.project>; role: typeof x.role } => !!x.project);
if (members.length === 0) return null;
return (
<GroupBoardSection
key={g.id}
group={g}
members={members}
/>
);
})}
{groups.every((g) => !maps.some((m) => m.group_id === g.id)) && (
<div className="text-center py-24 text-gray-400">
<p className="text-4xl mb-4">🗂</p>
<p className="text-sm"></p>
</div>
)}
</>
)}
{/* ── 独立项目标签 ── */}
{boardTab === "standalone" && (
<>
{standalone.length > 0 ? (
<StandaloneBoardSection standalone={standalone} />
) : (
<div className="text-center py-24 text-gray-400">
<p className="text-4xl mb-4">📂</p>
<p className="text-sm"></p>
</div>
)}
</>
)}
{/* ── 服务器管理标签 ── */}
{boardTab === "servers" && <ServerManagerPanel />}
{/* ── 软件仓库标签 ── */}
{boardTab === "software" && (
<div className="w-full px-8 py-8 pb-16">
<ServerSoftwarePanel />
</div>
)}
{/* ── API 管理标签 ── */}
{boardTab === "apikeys" && <ApiKeysPanel />}
{/* ── 云产品标签 ── */}
{boardTab === "cloud" && <CloudProductsPanel />}
</div>
</div>
</div>
)}
</div>
);
}
/* ── GroupBoardSection ─────────────────────────────────────────────────────── */
interface GroupMember {
project: Project;
role: string | null | undefined;
}
interface GroupBoardSectionProps {
group: ProductGroup;
members: GroupMember[];
}
function GroupBoardSection({ group: g, members }: GroupBoardSectionProps) {
const [deployTarget, setDeployTarget] = useState<Project | null>(null);
const [groupView, setGroupView] = useState<GroupView>("default");
const winMembers = members.filter(({ project: p }) => p.platform !== "wsl");
const wslMembers = members.filter(({ project: p }) => p.platform === "wsl");
return (
<section className="bg-white rounded-2xl border border-gray-200 shadow-sm overflow-hidden">
{/* 产品组标题栏 */}
<div className="flex items-center gap-3 px-6 py-3.5 border-b border-gray-100 bg-gray-50/80">
<h2 className="text-sm font-bold text-gray-800 tracking-tight">{g.name}</h2>
{g.description && (
<span className="text-xs text-gray-400 border-l border-gray-200 pl-3">{g.description}</span>
)}
<span className="ml-auto text-xs text-gray-400 bg-white border border-gray-200 rounded-full px-2.5 py-0.5">
{members.length}
</span>
{/* 视图切换 */}
<div className="flex items-center rounded-lg border border-gray-200 overflow-hidden text-[11px] font-medium">
<button
onClick={() => setGroupView("default")}
className={`px-2.5 py-1 transition-colors ${
groupView === "default"
? "bg-gray-700 text-white"
: "bg-white text-gray-500 hover:bg-gray-50"
}`}
>
</button>
<button
onClick={() => setGroupView("resource")}
className={`px-2.5 py-1 transition-colors border-l border-gray-200 ${
groupView === "resource"
? "bg-gray-700 text-white"
: "bg-white text-gray-500 hover:bg-gray-50"
}`}
>
</button>
</div>
</div>
{/* ── 资源视角画布 ── */}
{groupView === "resource" && (
<GroupResourceCanvas members={members} groupId={g.id} />
)}
{/* ── 默认视图 ── */}
{groupView === "default" && (
<>
{/* 列头 */}
<div className="flex border-b border-gray-100 bg-gray-50/60">
<div className="flex-1 px-5 py-2 text-xs font-semibold text-gray-400"></div>
<div className="w-72 shrink-0 px-4 py-2 text-xs font-semibold text-gray-400 border-l border-gray-100"></div>
</div>
{/* 主体:每个项目一行,左=卡片 右=部署信息,平台分组作行标题 */}
<div className="flex flex-col">
{/* Windows 分组 */}
<div className="flex items-center gap-2 px-5 py-2 bg-blue-50/40 border-b border-gray-100">
<p className="text-xs font-semibold text-blue-500 uppercase tracking-widest">Windows</p>
<span className="text-[10px] text-blue-400">{winMembers.length} </span>
</div>
{winMembers.length > 0
? winMembers.map(({ project, role }) => (
<ProjectRow
key={project.id}
project={project}
role={role ?? undefined}
onDeploy={setDeployTarget}
/>
))
: <p className="text-xs text-gray-300 py-5 text-center border-b border-gray-50"></p>
}
{/* WSL 分组 */}
<div className="flex items-center gap-2 px-5 py-2 bg-green-50/40 border-b border-gray-100">
<p className="text-xs font-semibold text-green-600 uppercase tracking-widest">WSL / Linux</p>
<span className="text-[10px] text-green-500">{wslMembers.length} </span>
</div>
{wslMembers.length > 0
? wslMembers.map(({ project, role }) => (
<ProjectRow
key={project.id}
project={project}
role={role ?? undefined}
onDeploy={setDeployTarget}
/>
))
: <p className="text-xs text-gray-300 py-5 text-center"></p>
}
</div>
</>
)}
{deployTarget && (
<DeployModal
projectId={deployTarget.id}
projectName={deployTarget.name}
projectPath={deployTarget.win_path ?? undefined}
projectWslPath={deployTarget.wsl_path ?? undefined}
onClose={() => setDeployTarget(null)}
/>
)}
</section>
);
}
/* ── ProjectRow ────────────────────────────────────────────────────────────── */
function ProjectRow({
project: p, role, onDeploy,
}: {
project: Project;
role?: string;
onDeploy: (p: Project) => void;
}) {
return (
<div className="flex divide-x divide-gray-100 border-b border-gray-50 last:border-b-0">
{/* 左:项目卡片 */}
<div className="flex-1 min-w-0 p-4">
<ProjectCard project={p} role={role} />
</div>
{/* 右:部署信息 */}
<div className="w-72 shrink-0 p-4 flex flex-col gap-2.5">
{/* 状态 + 角色 + 时间 */}
<div className="flex items-center gap-1.5 flex-wrap">
{p.status && <StatusBadge status={p.status} />}
{role && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-purple-50 text-purple-600 border border-purple-100 font-medium">
{role}
</span>
)}
{p.updated_at && (
<span className="text-[10px] text-gray-300 ml-auto">{p.updated_at.slice(0, 10)}</span>
)}
</div>
{/* 当前专注 */}
{p.recent_focus && (
<div className="bg-indigo-50 border border-indigo-100 rounded-md px-2.5 py-2">
<p className="text-[11px] text-indigo-600 leading-relaxed line-clamp-3">
📌 {p.recent_focus}
</p>
</div>
)}
{/* 部署环境入口 */}
{(p.prod_url || p.staging_url) && (
<div className="flex flex-col gap-1.5">
{p.prod_url && (
<button
onClick={() => openUrl(p.prod_url!).catch(() => {})}
className="flex items-center gap-2 text-[11px] px-2.5 py-2 rounded-md bg-emerald-50 text-emerald-700 border border-emerald-200 hover:bg-emerald-100 transition-colors w-full text-left"
title={p.prod_url}
>
<span>🌐</span>
<span className="font-medium"></span>
<svg className="w-3 h-3 ml-auto text-emerald-400 shrink-0" viewBox="0 0 16 16" fill="currentColor">
<path d="M6.22 8.72a.75.75 0 001.06 1.06l5.22-5.22v1.69a.75.75 0 001.5 0V3a.75.75 0 00-.75-.75H9.5a.75.75 0 000 1.5h1.69L6.22 8.72z"/><path d="M3.5 6.75a.75.75 0 00-.75.75v5.75c0 .414.336.75.75.75h5.75a.75.75 0 000-1.5H4.25V7.5a.75.75 0 00-.75-.75z"/>
</svg>
</button>
)}
{p.staging_url && (
<button
onClick={() => openUrl(p.staging_url!).catch(() => {})}
className="flex items-center gap-2 text-[11px] px-2.5 py-2 rounded-md bg-amber-50 text-amber-700 border border-amber-200 hover:bg-amber-100 transition-colors w-full text-left"
title={p.staging_url}
>
<span>🧪</span>
<span className="font-medium"></span>
<svg className="w-3 h-3 ml-auto text-amber-400 shrink-0" viewBox="0 0 16 16" fill="currentColor">
<path d="M6.22 8.72a.75.75 0 001.06 1.06l5.22-5.22v1.69a.75.75 0 001.5 0V3a.75.75 0 00-.75-.75H9.5a.75.75 0 000 1.5h1.69L6.22 8.72z"/><path d="M3.5 6.75a.75.75 0 00-.75.75v5.75c0 .414.336.75.75.75h5.75a.75.75 0 000-1.5H4.25V7.5a.75.75 0 00-.75-.75z"/>
</svg>
</button>
)}
</div>
)}
{/* 服务器备注 */}
{p.server_note && (
<div className="bg-gray-50 border border-gray-100 rounded-md px-2.5 py-2">
<p className="text-[11px] text-gray-500 leading-relaxed">🖥 {p.server_note}</p>
</div>
)}
{/* 无信息提示 */}
{!p.prod_url && !p.staging_url && !p.server_note && !p.recent_focus && (
<p className="text-[11px] text-gray-300 italic"></p>
)}
{/* 部署配置按钮 */}
<div className="mt-auto pt-1">
<button
onClick={() => onDeploy(p)}
className="w-full px-2.5 py-1.5 rounded-md text-[11px] border border-gray-200 text-gray-500 hover:bg-gray-50 transition-colors"
>
🚀
</button>
</div>
</div>
</div>
);
}
/* ── GlobalQuickLaunchBar ──────────────────────────────────────────────────── */
const GLOBAL_QUERY_KEY = ["globalTools"];
const globalToolCmds = {
getTools: () => getGlobalTools(),
addTool: (input: ProjectToolInput) => addGlobalTool(input),
updateTool: (id: string, input: ProjectToolInput) => updateGlobalTool(id, input),
removeTool: (id: string) => removeGlobalTool(id),
};
function GlobalQuickLaunchBar() {
const showToast = useUIStore((s) => s.showToast);
const [showMgr, setShowMgr] = useState(false);
const [mgrDefaultAdd, setMgrDefaultAdd] = useState(false);
const { data: tools = [] } = useQuery({
queryKey: GLOBAL_QUERY_KEY,
queryFn: () => getGlobalTools(),
staleTime: 30000,
});
const handleLaunch = async (exePath: string, args: string | null | undefined, name: string) => {
try {
await launchTool(exePath, args ?? undefined);
showToast(`已启动 ${name}`);
} catch (e) {
showToast(`${e}`);
}
};
return (
<div className="shrink-0 border-b border-gray-100 bg-white px-8 py-2 flex items-center gap-2 flex-wrap">
<span className="text-[11px] font-semibold text-orange-600 bg-orange-50 border border-orange-200 rounded px-1.5 py-0.5 shrink-0">
</span>
{tools.length > 0 ? (
<>
{tools.map((tool) => (
<button
key={tool.id}
onClick={() => handleLaunch(tool.exe_path, tool.args, tool.name)}
title={tool.exe_path}
className="px-2.5 py-1 rounded-md text-xs border border-gray-200 text-gray-600 hover:bg-orange-50 hover:border-orange-300 hover:text-orange-800 transition-colors"
>
{tool.icon} {tool.name}
</button>
))}
<button
onClick={() => { setMgrDefaultAdd(true); setShowMgr(true); }}
className="px-2 py-1 rounded-md text-xs border border-dashed border-orange-200 text-orange-400 hover:border-orange-400 hover:text-orange-600 hover:bg-orange-50 transition-colors"
title="添加快启应用"
>
+
</button>
</>
) : (
<button
onClick={() => { setMgrDefaultAdd(true); setShowMgr(true); }}
className="px-2.5 py-1 rounded-md text-xs border border-dashed border-orange-200 text-orange-400 hover:border-orange-400 hover:text-orange-600 hover:bg-orange-50 transition-colors"
>
+
</button>
)}
<button
onClick={() => { setMgrDefaultAdd(false); setShowMgr(true); }}
className="ml-auto text-gray-300 hover:text-gray-500 transition-colors"
title="管理快启应用"
>
<svg className="w-3.5 h-3.5" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z" clipRule="evenodd" />
</svg>
</button>
{showMgr && (
<ProjectToolsModal
projectId="__global__"
projectName="日常应用"
defaultOpenAdd={mgrDefaultAdd}
commands={globalToolCmds}
queryKey={GLOBAL_QUERY_KEY}
onClose={() => { setShowMgr(false); setMgrDefaultAdd(false); }}
/>
)}
</div>
);
}
/* ── StandaloneBoardSection ────────────────────────────────────────────────── */
function StandaloneBoardSection({
standalone,
}: {
standalone: Project[];
}) {
const [deployTarget, setDeployTarget] = useState<Project | null>(null);
const [selectedTagIds, setSelectedTagIds] = useState<Set<string>>(new Set());
const [search, setSearch] = useState("");
const { data: allTags = [] } = useQuery({ queryKey: ["tags"], queryFn: getTags });
const { data: tagMaps = [] } = useQuery({ queryKey: ["tagMaps"], queryFn: getAllProjectTagMaps });
const toggleTag = (tagId: string) => {
setSelectedTagIds((prev) => {
const next = new Set(prev);
next.has(tagId) ? next.delete(tagId) : next.add(tagId);
return next;
});
};
const filtered = standalone.filter((p) => {
if (search.trim() && !p.name.toLowerCase().includes(search.trim().toLowerCase())) return false;
if (selectedTagIds.size === 0) return true;
const projectTagIds = tagMaps
.filter((m) => m.profile_id === p.profile_id)
.map((m) => m.tag_id);
return [...selectedTagIds].some((id) => projectTagIds.includes(id));
});
const winProjects = filtered.filter((p) => p.platform !== "wsl");
const wslProjects = filtered.filter((p) => p.platform === "wsl");
return (
<section className="bg-white rounded-2xl border border-gray-200 shadow-sm overflow-hidden">
{/* 搜索 + 标签筛选栏(始终显示) */}
<div className="px-5 py-3 border-b border-gray-100 flex flex-col gap-2 bg-gray-50/40">
{/* 搜索框 */}
<div className="flex items-center gap-2">
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="搜索项目名称…"
className="flex-1 border border-gray-200 rounded-lg px-3 py-1.5 text-xs outline-none focus:ring-2 focus:ring-violet-200 bg-white"
/>
<span className="text-[11px] text-gray-300 shrink-0">
{filtered.length} / {standalone.length}
</span>
{(search || selectedTagIds.size > 0) && (
<button
onClick={() => { setSearch(""); setSelectedTagIds(new Set()); }}
className="text-[11px] text-gray-400 hover:text-gray-600 shrink-0"
>
</button>
)}
</div>
{/* 标签筛选 chips */}
{allTags.length > 0 && (
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-[11px] text-gray-300 shrink-0"></span>
{allTags.map((tag) => (
<button
key={tag.id}
onClick={() => toggleTag(tag.id)}
className={`px-2.5 py-0.5 rounded-full text-xs font-medium border-2 transition-colors ${
selectedTagIds.has(tag.id) ? "text-white border-transparent" : "bg-white border-gray-200 text-gray-500"
}`}
style={selectedTagIds.has(tag.id) ? { backgroundColor: tag.color, borderColor: tag.color } : {}}
>
{tag.name}
</button>
))}
</div>
)}
{allTags.length === 0 && (
<p className="text-[11px] text-gray-300">-</p>
)}
</div>
{/* 列头 */}
<div className="flex border-b border-gray-100 bg-gray-50/60">
<div className="flex-1 px-5 py-2 text-xs font-semibold text-gray-400"></div>
<div className="w-72 shrink-0 px-4 py-2 text-xs font-semibold text-gray-400 border-l border-gray-100"></div>
</div>
<div className="flex flex-col">
{/* Windows */}
<div className="flex items-center gap-2 px-5 py-2 bg-blue-50/40 border-b border-gray-100">
<p className="text-xs font-semibold text-blue-500 uppercase tracking-widest">Windows</p>
<span className="text-[10px] text-blue-400">{winProjects.length} </span>
</div>
{winProjects.length > 0
? winProjects.map((p) => (
<ProjectRow key={p.id} project={p} onDeploy={setDeployTarget} />
))
: <p className="text-xs text-gray-300 py-5 text-center border-b border-gray-50"></p>
}
{/* WSL */}
<div className="flex items-center gap-2 px-5 py-2 bg-green-50/40 border-b border-gray-100">
<p className="text-xs font-semibold text-green-600 uppercase tracking-widest">WSL / Linux</p>
<span className="text-[10px] text-green-500">{wslProjects.length} </span>
</div>
{wslProjects.length > 0
? wslProjects.map((p) => (
<ProjectRow key={p.id} project={p} onDeploy={setDeployTarget} />
))
: <p className="text-xs text-gray-300 py-5 text-center"></p>
}
</div>
{deployTarget && (
<DeployModal
projectId={deployTarget.id}
projectName={deployTarget.name}
projectPath={deployTarget.win_path ?? undefined}
projectWslPath={deployTarget.wsl_path ?? undefined}
onClose={() => setDeployTarget(null)}
/>
)}
</section>
);
}