- 新增发布到GitHub功能(scan/init/commit/push/register全流程) - 新增WSL发行版检测与UNC路径自动转换,路径存储改为Windows可访问格式 - 修复libgit2访问WSL UNC路径的owner验证问题(启动时全局禁用) - ProjectCard新增本地项目/本地仓库/远程仓库三灯状态指示 - ProjectModal重构为基本信息+部署信息双标签页,固定高度稳定切换 - 添加项目支持顶部快速选择文件夹并自动填充ID和名称 - 设置页WSL区块改为下拉检测选择发行版 - Dialog组件支持fixedHeight固定高度模式
402 lines
16 KiB
TypeScript
402 lines
16 KiB
TypeScript
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>
|
||
);
|
||
}
|