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
263 lines
11 KiB
TypeScript
263 lines
11 KiB
TypeScript
import { useState } from "react";
|
||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||
import {
|
||
getSpaces, setCurrentSpace, createSpace, updateSpace, deleteSpace,
|
||
type Space,
|
||
} from "../../lib/commands";
|
||
import { useUIStore } from "../../store/ui";
|
||
import { RepoRegistryModal } from "../dashboard/RepoRegistryModal";
|
||
|
||
export type Page = "overview" | "dashboard" | "source-library" | "devtools" | "proxy" | "settings" | "health";
|
||
|
||
const NAV_ITEMS: { id: Page; label: string }[] = [
|
||
{ id: "overview", label: "仪表盘" },
|
||
{ id: "dashboard", label: "项目管理" },
|
||
{ id: "source-library", label: "源码库" },
|
||
{ id: "devtools", label: "开发环境" },
|
||
{ id: "proxy", label: "WSL 代理" },
|
||
{ id: "health", label: "健康中心" },
|
||
{ id: "settings", label: "设置" },
|
||
];
|
||
|
||
interface Props {
|
||
page: Page;
|
||
onPageChange: (page: Page) => void;
|
||
}
|
||
|
||
export function Sidebar({ page, onPageChange }: Props) {
|
||
const [spaceOpen, setSpaceOpen] = useState(true);
|
||
const [showForm, setShowForm] = useState<Space | null | undefined>(undefined);
|
||
const [deleteTarget, setDeleteTarget] = useState<Space | null>(null);
|
||
const [showRepoRegistry, setShowRepoRegistry] = useState(false);
|
||
const qc = useQueryClient();
|
||
const showToast = useUIStore((s) => s.showToast);
|
||
|
||
const { data: spaces = [], refetch } = useQuery({
|
||
queryKey: ["spaces"],
|
||
queryFn: getSpaces,
|
||
});
|
||
const currentSpace = spaces.find((s) => s.is_current);
|
||
|
||
const handleSwitch = async (id: string) => {
|
||
await setCurrentSpace(id);
|
||
refetch();
|
||
qc.invalidateQueries({ queryKey: ["projects"] });
|
||
};
|
||
|
||
const handleDelete = async (space: Space) => {
|
||
await deleteSpace(space.id);
|
||
refetch();
|
||
showToast("已删除");
|
||
setDeleteTarget(null);
|
||
};
|
||
|
||
return (
|
||
<>
|
||
<aside className="w-48 shrink-0 bg-slate-900 border-r border-slate-700 flex flex-col">
|
||
|
||
{/* Logo */}
|
||
<div className="h-14 flex items-center px-4 border-b border-slate-700 shrink-0">
|
||
<span className="font-bold text-white text-sm tracking-tight">炼境</span>
|
||
</div>
|
||
|
||
{/* 导航区(随当前空间) */}
|
||
<nav className="px-2 pt-3 pb-2 space-y-0.5 shrink-0">
|
||
{NAV_ITEMS.map(({ id, label }) => (
|
||
<button
|
||
key={id}
|
||
onClick={() => onPageChange(id)}
|
||
className={`w-full text-left px-3 py-2 rounded-lg text-sm transition-colors ${
|
||
page === id
|
||
? "bg-slate-700 text-white font-medium"
|
||
: "text-slate-400 hover:bg-slate-800 hover:text-slate-200"
|
||
}`}
|
||
>
|
||
{label}
|
||
</button>
|
||
))}
|
||
</nav>
|
||
|
||
{/* 弹性留白 */}
|
||
<div className="flex-1" />
|
||
|
||
{/* 空间下拉列表 */}
|
||
<div className="border-t border-slate-700">
|
||
{/* 标题行 */}
|
||
<button
|
||
onClick={() => setSpaceOpen((v) => !v)}
|
||
className="w-full flex items-center justify-between px-4 py-2.5 text-xs font-semibold text-slate-400 uppercase tracking-wider hover:text-slate-200 transition-colors"
|
||
>
|
||
<span>空间</span>
|
||
<div className="flex items-center gap-1.5">
|
||
<span
|
||
onClick={(e) => { e.stopPropagation(); setShowForm(null); }}
|
||
className="w-4 h-4 flex items-center justify-center rounded text-slate-500 hover:text-white hover:bg-slate-700 transition-colors leading-none cursor-pointer"
|
||
title="新建空间"
|
||
>
|
||
+
|
||
</span>
|
||
<svg
|
||
className={`w-3 h-3 transition-transform ${spaceOpen ? "rotate-180" : ""}`}
|
||
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>
|
||
</div>
|
||
</button>
|
||
|
||
{/* 空间列表 */}
|
||
{spaceOpen && (
|
||
<div className="px-2 pb-2 space-y-0.5">
|
||
{spaces.length === 0 && (
|
||
<p className="text-xs text-slate-500 px-2 py-2 text-center">点击 + 新建空间</p>
|
||
)}
|
||
{spaces.map((s) => {
|
||
const isCurrent = s.id === currentSpace?.id;
|
||
return (
|
||
<div
|
||
key={s.id}
|
||
onClick={() => !isCurrent && handleSwitch(s.id)}
|
||
className={`group flex items-center gap-1.5 px-2 py-1.5 rounded-lg transition-colors ${
|
||
isCurrent
|
||
? "bg-slate-700 text-white"
|
||
: "text-slate-400 hover:bg-slate-800 hover:text-slate-200 cursor-pointer"
|
||
}`}
|
||
>
|
||
<span className={`w-1.5 h-1.5 rounded-full shrink-0 ${isCurrent ? "bg-blue-400" : "bg-transparent"}`} />
|
||
<span className="flex-1 text-xs truncate">{s.name}</span>
|
||
{/* UD 操作 */}
|
||
<div className="flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
|
||
<button
|
||
onClick={(e) => { e.stopPropagation(); setShowForm(s); }}
|
||
className="p-0.5 rounded hover:bg-slate-600 text-slate-400 hover:text-white"
|
||
title="编辑"
|
||
>
|
||
<svg className="w-3 h-3" viewBox="0 0 16 16" fill="currentColor">
|
||
<path d="M11.013 1.427a1.75 1.75 0 012.474 0l1.086 1.086a1.75 1.75 0 010 2.474l-8.61 8.61c-.21.21-.47.364-.756.445l-3.251.93a.75.75 0 01-.927-.928l.929-3.25c.081-.286.235-.547.445-.758l8.61-8.61zm1.414 1.06a.25.25 0 00-.354 0L10.811 3.75l1.439 1.44 1.263-1.263a.25.25 0 000-.354l-1.086-1.086zM11.189 6.25L9.75 4.81 3.22 11.34a.25.25 0 00-.064.108l-.65 2.278 2.278-.65a.25.25 0 00.108-.065L11.19 6.25z"/>
|
||
</svg>
|
||
</button>
|
||
{!isCurrent && (
|
||
<button
|
||
onClick={(e) => { e.stopPropagation(); setDeleteTarget(s); }}
|
||
className="p-0.5 rounded hover:bg-red-900 text-slate-400 hover:text-red-400"
|
||
title="删除"
|
||
>
|
||
<svg className="w-3 h-3" viewBox="0 0 16 16" fill="currentColor">
|
||
<path d="M11 1.75V3h2.25a.75.75 0 010 1.5H2.75a.75.75 0 010-1.5H5V1.75C5 .784 5.784 0 6.75 0h2.5C10.216 0 11 .784 11 1.75zM4.496 6.675a.75.75 0 10-1.492.15l.66 6.6A1.75 1.75 0 005.405 15h5.19c.9 0 1.652-.681 1.741-1.576l.66-6.6a.75.75 0 10-1.492-.149l-.66 6.6a.25.25 0 01-.249.225h-5.19a.25.25 0 01-.249-.225l-.66-6.6z"/>
|
||
</svg>
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* 仓库管理 */}
|
||
<div className="border-t border-slate-700 px-3 py-2 shrink-0">
|
||
<button
|
||
onClick={() => setShowRepoRegistry(true)}
|
||
className="w-full flex items-center gap-2 px-2 py-1.5 rounded-md text-xs text-slate-400 hover:bg-slate-800 hover:text-slate-200 transition-colors"
|
||
>
|
||
<svg className="w-3.5 h-3.5 shrink-0" viewBox="0 0 16 16" fill="currentColor">
|
||
<path d="M2 2.5A2.5 2.5 0 014.5 0h8.75a.75.75 0 01.75.75v12.5a.75.75 0 01-.75.75h-2.5a.75.75 0 110-1.5h1.75v-2h-8a1 1 0 00-.714 1.7.75.75 0 01-1.072 1.05A2.495 2.495 0 012 11.5v-9zm10.5-1V9h-8c-.356 0-.694.074-1 .208V2.5a1 1 0 011-1h8z"/>
|
||
</svg>
|
||
<span>仓库管理</span>
|
||
</button>
|
||
</div>
|
||
|
||
</aside>
|
||
|
||
{/* 新建/编辑模态框 */}
|
||
{showForm !== undefined && (
|
||
<SpaceFormModal
|
||
space={showForm}
|
||
onClose={() => setShowForm(undefined)}
|
||
onSaved={() => {
|
||
setShowForm(undefined);
|
||
refetch();
|
||
showToast(showForm ? "已更新" : "已创建");
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{/* 仓库管理弹窗 */}
|
||
{showRepoRegistry && (
|
||
<RepoRegistryModal onClose={() => setShowRepoRegistry(false)} />
|
||
)}
|
||
|
||
{/* 删除确认 */}
|
||
{deleteTarget && (
|
||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||
<div className="bg-white rounded-xl shadow-2xl w-80 p-6 space-y-4">
|
||
<h2 className="text-base font-semibold text-gray-800">删除空间</h2>
|
||
<p className="text-sm text-gray-600">
|
||
确定删除空间「<span className="font-medium">{deleteTarget.name}</span>」?该空间下的项目记录不会删除。
|
||
</p>
|
||
<div className="flex justify-end gap-3">
|
||
<button onClick={() => setDeleteTarget(null)} className="px-4 py-2 rounded-lg text-sm border border-gray-200 text-gray-600 hover:bg-gray-50">取消</button>
|
||
<button onClick={() => handleDelete(deleteTarget)} className="px-4 py-2 rounded-lg text-sm bg-red-600 text-white hover:bg-red-700">删除</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</>
|
||
);
|
||
}
|
||
|
||
function SpaceFormModal({ space, onClose, onSaved }: {
|
||
space: Space | null;
|
||
onClose: () => void;
|
||
onSaved: () => void;
|
||
}) {
|
||
const [name, setName] = useState(space?.name ?? "");
|
||
const [saving, setSaving] = useState(false);
|
||
|
||
const handleSave = async () => {
|
||
if (!name.trim()) return;
|
||
setSaving(true);
|
||
try {
|
||
if (space) {
|
||
await updateSpace(space.id, name.trim());
|
||
} else {
|
||
await createSpace(name.trim());
|
||
}
|
||
onSaved();
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50">
|
||
<div className="bg-white rounded-xl shadow-2xl w-80 p-6 space-y-4">
|
||
<h2 className="text-base font-semibold text-gray-800">{space ? "编辑空间" : "新建空间"}</h2>
|
||
<div>
|
||
<label className="block text-xs font-medium text-gray-600 mb-1">空间名称</label>
|
||
<input
|
||
value={name}
|
||
onChange={(e) => setName(e.target.value)}
|
||
placeholder="笔记本 / 日本服务器 / 公司台式机"
|
||
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-blue-200"
|
||
autoFocus
|
||
onKeyDown={(e) => e.key === "Enter" && handleSave()}
|
||
/>
|
||
</div>
|
||
<div className="flex justify-end gap-3 pt-2">
|
||
<button onClick={onClose} className="px-4 py-2 rounded-lg text-sm border border-gray-200 text-gray-600 hover:bg-gray-50">取消</button>
|
||
<button
|
||
onClick={handleSave}
|
||
disabled={!name.trim() || saving}
|
||
className="px-4 py-2 rounded-lg text-sm bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50"
|
||
>
|
||
{saving ? "保存中…" : "保存"}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|