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
677 lines
28 KiB
TypeScript
677 lines
28 KiB
TypeScript
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>
|
||
);
|
||
}
|