dev-manager-tauri/src/components/dashboard/RepoRegistryModal.tsx
lanrtop 0baa304fbe feat: WSL路径支持、项目发布到GitHub、三灯状态指示
- 新增发布到GitHub功能(scan/init/commit/push/register全流程)
- 新增WSL发行版检测与UNC路径自动转换,路径存储改为Windows可访问格式
- 修复libgit2访问WSL UNC路径的owner验证问题(启动时全局禁用)
- ProjectCard新增本地项目/本地仓库/远程仓库三灯状态指示
- ProjectModal重构为基本信息+部署信息双标签页,固定高度稳定切换
- 添加项目支持顶部快速选择文件夹并自动填充ID和名称
- 设置页WSL区块改为下拉检测选择发行版
- Dialog组件支持fixedHeight固定高度模式
2026-04-01 01:36:21 +09:00

402 lines
16 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, useQueryClient } from "@tanstack/react-query";
import {
getGitRepos,
importGitRepos,
updateGitRepoDeploy,
deleteGitRepo,
githubGetToken,
githubListRepos,
type GitRepo,
type UpdateGitRepoDeployInput,
} from "../../lib/commands";
import { useUIStore } from "../../store/ui";
interface Props {
onClose: () => void;
}
type Tab = "registry" | "sync";
// ── 部署信息编辑表单 ──────────────────────────────────────────────────────────
function DeployForm({
repo,
onSaved,
onCancel,
}: {
repo: GitRepo;
onSaved: () => void;
onCancel: () => void;
}) {
const showToast = useUIStore((s) => s.showToast);
const qc = useQueryClient();
const [form, setForm] = useState<UpdateGitRepoDeployInput>({
staging_url: repo.staging_url ?? "",
prod_url: repo.prod_url ?? "",
server_note: repo.server_note ?? "",
upstream_url: repo.upstream_url ?? "",
tech_stack: repo.tech_stack ?? "",
status: repo.status ?? "active",
priority: repo.priority ?? "mid",
});
const [saving, setSaving] = useState(false);
const set = (k: keyof UpdateGitRepoDeployInput) =>
(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) =>
setForm((f) => ({ ...f, [k]: e.target.value }));
const handleSave = async () => {
setSaving(true);
try {
await updateGitRepoDeploy(repo.id, form);
qc.invalidateQueries({ queryKey: ["git-repos"] });
showToast("已保存 ✓");
onSaved();
} catch (e) {
showToast(`❌ ${e}`);
} finally {
setSaving(false);
}
};
const inputCls = "w-full border border-gray-200 rounded-lg px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-blue-200";
return (
<div className="border-t border-blue-100 px-4 py-4 space-y-3 bg-blue-50/30">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">生产环境 URL</label>
<input className={inputCls} value={form.prod_url ?? ""} onChange={set("prod_url")} placeholder="https://example.com" />
</div>
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">预发 / 测试 URL</label>
<input className={inputCls} value={form.staging_url ?? ""} onChange={set("staging_url")} placeholder="https://staging.example.com" />
</div>
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">公司上游仓库</label>
<input className={inputCls} value={form.upstream_url ?? ""} onChange={set("upstream_url")} placeholder="https://github.com/company/repo.git" />
</div>
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">技术栈</label>
<input className={inputCls} value={form.tech_stack ?? ""} onChange={set("tech_stack")} placeholder="React / Rust / Node" />
</div>
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">状态</label>
<select className={inputCls} value={form.status ?? "active"} onChange={set("status")}>
<option value="active">进行中</option>
<option value="paused">暂停</option>
<option value="archived">归档</option>
</select>
</div>
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">优先级</label>
<select className={inputCls} value={form.priority ?? "mid"} onChange={set("priority")}>
<option value="high">高</option>
<option value="mid">中</option>
<option value="low">低</option>
</select>
</div>
</div>
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">服务器备注</label>
<textarea
className={`${inputCls} resize-none`}
rows={2}
value={form.server_note ?? ""}
onChange={set("server_note")}
placeholder="服务器地址、SSH Key 路径…"
/>
</div>
<div className="flex justify-end gap-2">
<button onClick={onCancel} className="px-3 py-1.5 text-xs border border-gray-200 rounded-lg text-gray-500 hover:bg-gray-50">取消</button>
<button
onClick={handleSave}
disabled={saving}
className="px-3 py-1.5 text-xs bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
>
{saving ? "保存中…" : "保存"}
</button>
</div>
</div>
);
}
// ── 已注册仓库列表 ────────────────────────────────────────────────────────────
function RegistryTab() {
const showToast = useUIStore((s) => s.showToast);
const qc = useQueryClient();
const [expandedId, setExpandedId] = useState<string | null>(null);
const { data: repos = [], isLoading } = useQuery({
queryKey: ["git-repos"],
queryFn: getGitRepos,
});
const handleDelete = async (id: string, name: string) => {
if (!confirm(`确定删除仓库「${name}」?关联的工作区也会被删除。`)) return;
try {
await deleteGitRepo(id);
qc.invalidateQueries({ queryKey: ["git-repos"] });
qc.invalidateQueries({ queryKey: ["projects"] });
showToast("已删除");
} catch (e) {
showToast(`❌ ${e}`);
}
};
if (isLoading) {
return <div className="flex-1 flex items-center justify-center text-gray-400 text-sm">加载中…</div>;
}
if (repos.length === 0) {
return (
<div className="flex-1 flex flex-col items-center justify-center text-gray-400 py-16 gap-2">
<p className="text-2xl">📦</p>
<p className="text-sm">还没有注册仓库,切换到「从 GitHub 同步」标签页导入</p>
</div>
);
}
return (
<div className="flex-1 overflow-y-auto min-h-0">
{repos.map((repo) => {
const isExpanded = expandedId === repo.id;
return (
<div key={repo.id} className="border-b border-gray-100 last:border-0">
<div className="flex items-center gap-3 px-4 py-3 hover:bg-gray-50 transition-colors">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium text-gray-800">{repo.name}</span>
{repo.tech_stack && <span className="text-xs text-gray-400">{repo.tech_stack}</span>}
{repo.default_branch && (
<span className="px-1.5 py-0.5 rounded text-xs bg-gray-100 text-gray-500 font-mono">{repo.default_branch}</span>
)}
{repo.status && repo.status !== "active" && (
<span className={`text-xs px-1.5 py-0.5 rounded ${repo.status === "archived" ? "bg-gray-100 text-gray-400" : "bg-yellow-50 text-yellow-600"}`}>
{repo.status === "archived" ? "已归档" : "暂停"}
</span>
)}
</div>
<p className="text-xs text-gray-400 truncate mt-0.5 font-mono">{repo.repo_url}</p>
{(repo.prod_url || repo.staging_url) && (
<div className="flex gap-3 mt-1">
{repo.prod_url && <span className="text-xs text-green-600 truncate">生产: {repo.prod_url}</span>}
{repo.staging_url && <span className="text-xs text-blue-500 truncate">预发: {repo.staging_url}</span>}
</div>
)}
</div>
<div className="flex gap-1.5 shrink-0">
<button
onClick={() => setExpandedId(isExpanded ? null : repo.id)}
className={`px-2.5 py-1.5 rounded-lg text-xs border transition-colors ${
isExpanded
? "border-blue-300 bg-blue-50 text-blue-700"
: "border-gray-200 text-gray-500 hover:bg-gray-50"
}`}
>
{isExpanded ? "收起" : "部署信息"}
</button>
<button
onClick={() => handleDelete(repo.id, repo.name)}
className="px-2.5 py-1.5 rounded-lg text-xs border border-gray-200 text-red-400 hover:bg-red-50 hover:border-red-200 transition-colors"
>
删除
</button>
</div>
</div>
{isExpanded && (
<DeployForm
repo={repo}
onSaved={() => setExpandedId(null)}
onCancel={() => setExpandedId(null)}
/>
)}
</div>
);
})}
</div>
);
}
// ── 从 GitHub 同步标签 ───────────────────────────────────────────────────────
function SyncTab() {
const showToast = useUIStore((s) => s.showToast);
const qc = useQueryClient();
const [search, setSearch] = useState("");
const [selected, setSelected] = useState<Set<number>>(new Set());
const [importing, setImporting] = useState(false);
const { data: repos = [], isLoading, error } = useQuery({
queryKey: ["github-repos"],
queryFn: async () => {
const token = await githubGetToken();
if (!token) throw new Error("未登录 GitHub");
return githubListRepos(token);
},
});
const { data: existingRepos = [] } = useQuery({
queryKey: ["git-repos"],
queryFn: getGitRepos,
});
const existingUrls = new Set(existingRepos.map((r) => r.repo_url));
const filtered = repos.filter(
(r) =>
r.name.toLowerCase().includes(search.toLowerCase()) ||
r.full_name.toLowerCase().includes(search.toLowerCase())
);
const toggleSelect = (id: number) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const selectAll = () => {
const newRepos = filtered.filter((r) => !existingUrls.has(r.clone_url));
setSelected(new Set(newRepos.map((r) => r.id)));
};
const handleImport = async () => {
const toImport = repos.filter((r) => selected.has(r.id));
if (toImport.length === 0) return;
setImporting(true);
try {
const inputs = toImport.map((r) => ({
id: crypto.randomUUID(),
repo_url: r.clone_url,
name: r.name,
description: r.description,
default_branch: r.default_branch,
}));
const imported = await importGitRepos(inputs);
qc.invalidateQueries({ queryKey: ["git-repos"] });
setSelected(new Set());
showToast(`已导入 ${imported.length} 个仓库 ✓`);
} catch (e) {
showToast(`❌ ${e}`);
} finally {
setImporting(false);
}
};
if (isLoading) return <div className="flex-1 flex items-center justify-center text-gray-400 text-sm">加载 GitHub 仓库…</div>;
if (error) return <div className="flex-1 flex items-center justify-center text-red-400 text-sm p-6">{String(error)}</div>;
return (
<>
<div className="px-4 py-3 border-b border-gray-100 flex gap-2">
<input
type="text"
placeholder="搜索仓库…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="flex-1 border border-gray-200 text-sm rounded-lg px-3 py-2 outline-none focus:ring-2 focus:ring-blue-200"
/>
<button onClick={selectAll} className="px-3 py-2 text-xs border border-gray-200 rounded-lg text-gray-500 hover:bg-gray-50 shrink-0">
全选未导入
</button>
<button
onClick={handleImport}
disabled={selected.size === 0 || importing}
className="px-3 py-2 text-xs bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 shrink-0"
>
{importing ? "导入中…" : `导入 ${selected.size} 个`}
</button>
</div>
<div className="flex-1 overflow-y-auto min-h-0">
{filtered.map((repo) => {
const alreadyImported = existingUrls.has(repo.clone_url);
const isSelected = selected.has(repo.id);
return (
<button
key={repo.id}
onClick={() => !alreadyImported && toggleSelect(repo.id)}
disabled={alreadyImported}
className={`w-full text-left px-4 py-3 border-b border-gray-100 transition-colors flex items-center gap-3 ${
alreadyImported
? "opacity-40 cursor-not-allowed"
: isSelected
? "bg-blue-50"
: "hover:bg-gray-50"
}`}
>
<div className={`w-4 h-4 rounded border flex items-center justify-center shrink-0 ${
alreadyImported ? "border-gray-200 bg-gray-100" :
isSelected ? "border-blue-500 bg-blue-500" : "border-gray-300"
}`}>
{(isSelected || alreadyImported) && (
<svg className="w-2.5 h-2.5 text-white" viewBox="0 0 16 16" fill="currentColor">
<path d="M13.78 4.22a.75.75 0 010 1.06l-7.25 7.25a.75.75 0 01-1.06 0L2.22 9.28a.75.75 0 011.06-1.06L6 10.94l6.72-6.72a.75.75 0 011.06 0z"/>
</svg>
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-800">{repo.name}</span>
{repo.private && <span className="text-xs px-1.5 py-0.5 bg-gray-100 text-gray-500 rounded">私有</span>}
{alreadyImported && <span className="text-xs px-1.5 py-0.5 bg-green-100 text-green-600 rounded">已注册</span>}
<span className="text-xs text-gray-400 ml-auto font-mono">{repo.default_branch}</span>
</div>
{repo.description && <p className="text-xs text-gray-400 mt-0.5 truncate">{repo.description}</p>}
</div>
</button>
);
})}
{filtered.length === 0 && (
<div className="text-center text-gray-400 text-sm py-8">没有找到匹配的仓库</div>
)}
</div>
</>
);
}
// ── 主模态框 ──────────────────────────────────────────────────────────────────
export function RepoRegistryModal({ onClose }: Props) {
const [tab, setTab] = useState<Tab>("registry");
return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
<div className="bg-white rounded-xl shadow-2xl w-[700px] max-h-[85vh] flex flex-col border border-gray-200">
<div className="px-5 py-4 border-b border-gray-100 flex items-center justify-between">
<h2 className="text-base font-semibold text-gray-800">仓库管理</h2>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-xl leading-none">×</button>
</div>
<div className="border-b border-gray-100 px-5 flex">
{([
{ id: "registry" as Tab, label: "已注册仓库" },
{ id: "sync" as Tab, label: "从 GitHub 同步" },
]).map(({ id, label }) => (
<button
key={id}
onClick={() => setTab(id)}
className={`px-4 py-2.5 text-sm font-medium border-b-2 -mb-px transition-colors ${
tab === id
? "border-blue-500 text-blue-600"
: "border-transparent text-gray-500 hover:text-gray-700"
}`}
>
{label}
</button>
))}
</div>
<div className="flex flex-col flex-1 min-h-0">
{tab === "registry" ? <RegistryTab /> : <SyncTab />}
</div>
</div>
</div>
);
}