绑定完成但本地未连通时(无 .git 或无 origin),Gitea 绑定区显示接线引导: - 无 git → init + 首次提交 + 关联 origin + 推送 - 有 git 无 origin → 关联 origin + 推送当前分支 复用 publish.rs 存量命令(git_init_and_commit/git_remote_set/git_push), 补齐 GitHub 退役时遗落的"本地接线"半程(接线/放风筝边界裁决的落地)。 Rules-Applied: R01
512 lines
21 KiB
TypeScript
512 lines
21 KiB
TypeScript
// Gitea 集成,拆两半(ui-restructure N4):
|
||
// - GiteaInstancesSection:实例管理(全局配置)→ 挂设置页
|
||
// - GiteaPanel:当前项目仓库绑定 + webhook(项目级)→ 留项目蓝图治理面板
|
||
// 控制面在 gitea.rs,观测面(webhook 接收)在 mcp_server.rs
|
||
import { useState } from "react";
|
||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||
import {
|
||
giteaSaveInstance,
|
||
giteaListInstances,
|
||
giteaDeleteInstance,
|
||
giteaUpdateInstance,
|
||
giteaTestConnection,
|
||
giteaGetRepoLink,
|
||
giteaListLinkedRepos,
|
||
giteaCreateRepo,
|
||
giteaMigrateRepo,
|
||
giteaLinkRepo,
|
||
giteaSetupWebhook,
|
||
giteaUnlinkRepo,
|
||
giteaPollNow,
|
||
scanLocalGit,
|
||
gitInitAndCommit,
|
||
gitRemoteSet,
|
||
gitPush,
|
||
type GiteaInstance,
|
||
} from "../../lib/commands";
|
||
import { useUIStore } from "../../store/ui";
|
||
|
||
type BindMode = "create" | "migrate" | "link";
|
||
|
||
const CALLBACK_BASE_KEY = "gitea_callback_base";
|
||
|
||
const inputCls =
|
||
"w-full text-[11px] border border-gray-200 rounded px-2 py-1.5 outline-none focus:border-blue-400";
|
||
const btnCls =
|
||
"px-2 py-1 rounded text-[11px] border border-gray-200 text-gray-500 hover:bg-gray-50 transition-colors";
|
||
|
||
// ── 实例管理(全局,挂设置页)────────────────────────────────────────────────
|
||
|
||
export function GiteaInstancesSection() {
|
||
const qc = useQueryClient();
|
||
const showToast = useUIStore((s) => s.showToast);
|
||
|
||
const { data: instances = [] } = useQuery({
|
||
queryKey: ["gitea-instances"],
|
||
queryFn: giteaListInstances,
|
||
});
|
||
|
||
const [showAdd, setShowAdd] = useState(false);
|
||
const [instName, setInstName] = useState("");
|
||
const [instUrl, setInstUrl] = useState("");
|
||
const [instToken, setInstToken] = useState("");
|
||
const [testing, setTesting] = useState<string | null>(null);
|
||
const [editingTokenId, setEditingTokenId] = useState<string | null>(null);
|
||
const [newToken, setNewToken] = useState("");
|
||
|
||
const handleUpdateToken = async (id: string) => {
|
||
if (!newToken.trim()) return;
|
||
try {
|
||
await giteaUpdateInstance(id, { access_token: newToken.trim() });
|
||
qc.invalidateQueries({ queryKey: ["gitea-instances"] });
|
||
setEditingTokenId(null);
|
||
setNewToken("");
|
||
showToast("Token 已更新 ✓ 可点「测试连接」验证");
|
||
} catch (e) {
|
||
showToast(`❌ ${e}`);
|
||
}
|
||
};
|
||
|
||
const handleAdd = async () => {
|
||
if (!instName.trim() || !instUrl.trim() || !instToken.trim()) return;
|
||
try {
|
||
await giteaSaveInstance({
|
||
name: instName.trim(),
|
||
url: instUrl.trim(),
|
||
access_token: instToken.trim(),
|
||
is_default: instances.length === 0,
|
||
});
|
||
qc.invalidateQueries({ queryKey: ["gitea-instances"] });
|
||
setInstName(""); setInstUrl(""); setInstToken("");
|
||
setShowAdd(false);
|
||
showToast("Gitea 实例已添加 ✓");
|
||
} catch (e) {
|
||
showToast(`❌ ${e}`);
|
||
}
|
||
};
|
||
|
||
const handleTest = async (inst: GiteaInstance) => {
|
||
setTesting(inst.id);
|
||
try {
|
||
const user = await giteaTestConnection(inst.id);
|
||
showToast(`✓ 连接成功,账户: ${user.login}`);
|
||
} catch (e) {
|
||
showToast(`❌ ${e}`);
|
||
} finally {
|
||
setTesting(null);
|
||
}
|
||
};
|
||
|
||
const handleDelete = async (inst: GiteaInstance) => {
|
||
if (!confirm(`删除实例「${inst.name}」?其下所有项目绑定会一并删除。`)) return;
|
||
try {
|
||
await giteaDeleteInstance(inst.id);
|
||
qc.invalidateQueries({ queryKey: ["gitea-instances"] });
|
||
qc.invalidateQueries({ queryKey: ["gitea-linked-repos"] });
|
||
showToast("已删除");
|
||
} catch (e) {
|
||
showToast(`❌ ${e}`);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<section className="bg-white rounded-xl border border-gray-200 shadow-sm px-6 py-5">
|
||
<div className="flex items-center justify-between mb-4">
|
||
<h2 className="text-sm font-semibold text-gray-700">🪁 Gitea 实例</h2>
|
||
{!showAdd && (
|
||
<button onClick={() => setShowAdd(true)} className="text-xs text-blue-500 hover:text-blue-600">
|
||
+ 添加实例
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
{instances.length === 0 && !showAdd && (
|
||
<p className="text-xs text-gray-400">尚未添加 Gitea 实例——添加后可在各项目蓝图的治理面板中绑定仓库</p>
|
||
)}
|
||
{instances.map((inst) => (
|
||
<div key={inst.id} className="space-y-1.5">
|
||
<div className="flex items-center gap-2 text-xs">
|
||
<span className="font-medium text-gray-700 shrink-0">{inst.name}</span>
|
||
{inst.is_default && (
|
||
<span className="text-[10px] px-1 rounded bg-blue-50 text-blue-500 shrink-0">默认</span>
|
||
)}
|
||
<span className="text-gray-400 font-mono truncate flex-1">{inst.url}</span>
|
||
<button onClick={() => handleTest(inst)} disabled={testing === inst.id} className={btnCls}>
|
||
{testing === inst.id ? "…" : "测试连接"}
|
||
</button>
|
||
<button
|
||
onClick={() => { setEditingTokenId(editingTokenId === inst.id ? null : inst.id); setNewToken(""); }}
|
||
className={btnCls}
|
||
>
|
||
改 Token
|
||
</button>
|
||
<button
|
||
onClick={() => handleDelete(inst)}
|
||
className="px-2 py-1 rounded text-[11px] border border-gray-200 text-red-400 hover:bg-red-50"
|
||
>
|
||
删除
|
||
</button>
|
||
</div>
|
||
{editingTokenId === inst.id && (
|
||
<div className="flex gap-1.5 pl-2">
|
||
<input
|
||
className={inputCls}
|
||
type="password"
|
||
placeholder="新 Access Token(需 repository 读写 + user 读写权限)"
|
||
value={newToken}
|
||
onChange={(e) => setNewToken(e.target.value)}
|
||
autoFocus
|
||
/>
|
||
<button
|
||
onClick={() => handleUpdateToken(inst.id)}
|
||
disabled={!newToken.trim()}
|
||
className="px-3 py-1.5 rounded text-xs bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50 shrink-0"
|
||
>
|
||
更新
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
{showAdd && (
|
||
<div className="space-y-2 border-t border-gray-100 pt-3">
|
||
<input className={inputCls} placeholder="实例名称(如:家里 NAS)" value={instName} onChange={(e) => setInstName(e.target.value)} />
|
||
<input className={inputCls} placeholder="https://gitea.example.com" value={instUrl} onChange={(e) => setInstUrl(e.target.value)} />
|
||
<input className={inputCls} type="password" placeholder="Access Token(用户设置 → 应用 → 生成令牌;权限:repository 读写 + user 读写)" value={instToken} onChange={(e) => setInstToken(e.target.value)} />
|
||
<div className="flex gap-1.5">
|
||
<button onClick={handleAdd} className="px-3 py-1.5 rounded text-xs bg-blue-600 text-white hover:bg-blue-700">保存</button>
|
||
<button onClick={() => setShowAdd(false)} className="px-3 py-1.5 rounded text-xs border border-gray-200 text-gray-500 hover:bg-gray-50">取消</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
// ── 项目级绑定 + webhook(留项目蓝图治理面板)────────────────────────────────
|
||
|
||
interface Props {
|
||
projectId: string;
|
||
projectName: string;
|
||
projectPath?: string;
|
||
}
|
||
|
||
export function GiteaPanel({ projectId, projectName, projectPath }: Props) {
|
||
const qc = useQueryClient();
|
||
const showToast = useUIStore((s) => s.showToast);
|
||
|
||
const { data: instances = [] } = useQuery({
|
||
queryKey: ["gitea-instances"],
|
||
queryFn: giteaListInstances,
|
||
});
|
||
|
||
const { data: link } = useQuery({
|
||
queryKey: ["gitea-link", projectId],
|
||
queryFn: () => giteaGetRepoLink(projectId),
|
||
});
|
||
|
||
const { data: allLinks = [] } = useQuery({
|
||
queryKey: ["gitea-linked-repos"],
|
||
queryFn: giteaListLinkedRepos,
|
||
});
|
||
|
||
// G7 一键接线:绑定后检测本地 git 状态(无 git / 无 origin / 已接线)
|
||
const { data: localGit, refetch: refetchLocalGit } = useQuery({
|
||
queryKey: ["gitea-local-git", projectId],
|
||
queryFn: () => scanLocalGit(projectPath!),
|
||
enabled: !!link && !!projectPath,
|
||
staleTime: 30000,
|
||
retry: false,
|
||
});
|
||
const hasOrigin = localGit?.remotes.some((r) => r.name === "origin") ?? false;
|
||
const needsWiring = !!link && !!projectPath && localGit != null && (!localGit.has_git || !hasOrigin);
|
||
const [wiring, setWiring] = useState(false);
|
||
|
||
const handleWireUp = async () => {
|
||
if (!projectPath || !link?.clone_url) return;
|
||
setWiring(true);
|
||
try {
|
||
if (!localGit?.has_git || !localGit?.has_commits) {
|
||
await gitInitAndCommit(projectPath, "chore: 项目初始化(炼境接线)");
|
||
}
|
||
await gitRemoteSet(projectPath, link.clone_url);
|
||
const msg = await gitPush(projectPath);
|
||
showToast(`✓ 接线完成:${msg}`);
|
||
refetchLocalGit();
|
||
} catch (e) {
|
||
showToast(`❌ 接线失败: ${e}`);
|
||
} finally {
|
||
setWiring(false);
|
||
}
|
||
};
|
||
|
||
const refresh = () => {
|
||
qc.invalidateQueries({ queryKey: ["gitea-link", projectId] });
|
||
qc.invalidateQueries({ queryKey: ["gitea-linked-repos"] });
|
||
};
|
||
|
||
// ── 绑定操作 ──
|
||
const [bindMode, setBindMode] = useState<BindMode | null>(null);
|
||
const [bindInstanceId, setBindInstanceId] = useState("");
|
||
const [repoName, setRepoName] = useState("");
|
||
const [repoOwner, setRepoOwner] = useState("");
|
||
const [cloneAddr, setCloneAddr] = useState("");
|
||
const [migrateToken, setMigrateToken] = useState("");
|
||
const [binding, setBinding] = useState(false);
|
||
|
||
const effectiveInstanceId =
|
||
bindInstanceId || instances.find((i) => i.is_default)?.id || instances[0]?.id || "";
|
||
|
||
const handleBind = async () => {
|
||
if (!effectiveInstanceId || !repoName.trim()) return;
|
||
setBinding(true);
|
||
try {
|
||
if (bindMode === "create") {
|
||
await giteaCreateRepo(effectiveInstanceId, projectId, repoName.trim(), projectName, true);
|
||
} else if (bindMode === "migrate") {
|
||
if (!cloneAddr.trim()) { showToast("❌ 请填写源仓库地址"); return; }
|
||
await giteaMigrateRepo(
|
||
effectiveInstanceId, projectId, cloneAddr.trim(), repoName.trim(),
|
||
migrateToken.trim() || undefined, true
|
||
);
|
||
} else if (bindMode === "link") {
|
||
if (!repoOwner.trim()) { showToast("❌ 请填写仓库 owner"); return; }
|
||
await giteaLinkRepo(effectiveInstanceId, projectId, repoOwner.trim(), repoName.trim());
|
||
}
|
||
refresh();
|
||
setBindMode(null);
|
||
setRepoName(""); setRepoOwner(""); setCloneAddr(""); setMigrateToken("");
|
||
showToast("仓库绑定成功 ✓");
|
||
} catch (e) {
|
||
showToast(`❌ ${e}`);
|
||
} finally {
|
||
setBinding(false);
|
||
}
|
||
};
|
||
|
||
// ── Webhook ──
|
||
const [callbackBase, setCallbackBase] = useState(
|
||
() => localStorage.getItem(CALLBACK_BASE_KEY) ?? "http://127.0.0.1:27190"
|
||
);
|
||
const [settingWebhook, setSettingWebhook] = useState(false);
|
||
const callbackUrl = `${callbackBase.replace(/\/+$/, "")}/webhook/gitea/${projectId}`;
|
||
|
||
const handleSetupWebhook = async () => {
|
||
setSettingWebhook(true);
|
||
try {
|
||
localStorage.setItem(CALLBACK_BASE_KEY, callbackBase);
|
||
await giteaSetupWebhook(projectId, callbackBase);
|
||
refresh();
|
||
showToast("Webhook 已注册 ✓ push 事件将实时进入飞轮");
|
||
} catch (e) {
|
||
showToast(`❌ ${e}`);
|
||
} finally {
|
||
setSettingWebhook(false);
|
||
}
|
||
};
|
||
|
||
const handleUnlink = async () => {
|
||
if (!confirm("解绑当前项目与 Gitea 仓库?(Gitea 侧仓库不会删除)")) return;
|
||
try {
|
||
await giteaUnlinkRepo(projectId);
|
||
refresh();
|
||
showToast("已解绑");
|
||
} catch (e) {
|
||
showToast(`❌ ${e}`);
|
||
}
|
||
};
|
||
|
||
const [polling, setPolling] = useState(false);
|
||
const handlePollNow = async () => {
|
||
setPolling(true);
|
||
try {
|
||
const r = await giteaPollNow();
|
||
showToast(`✓ 轮询 ${r.polled} 个仓库,新采集 ${r.ingested} 条 commit`);
|
||
} catch (e) {
|
||
showToast(`❌ ${e}`);
|
||
} finally {
|
||
setPolling(false);
|
||
}
|
||
};
|
||
|
||
const copy = (text: string) => {
|
||
navigator.clipboard.writeText(text).catch(() => {});
|
||
showToast("已复制 ✓");
|
||
};
|
||
|
||
return (
|
||
<div className="bg-white rounded-lg border border-gray-100 p-3 space-y-3">
|
||
<p className="text-[10px] font-semibold text-gray-400 uppercase tracking-wider">
|
||
🪁 Gitea 绑定
|
||
</p>
|
||
|
||
{/* ── 当前项目绑定 ── */}
|
||
<div className="space-y-2">
|
||
{link ? (
|
||
<>
|
||
<div className="flex items-center gap-2 text-[11px]">
|
||
<span className="text-gray-500 shrink-0">已绑定</span>
|
||
<span className="font-mono text-gray-700 truncate">
|
||
{link.gitea_owner}/{link.gitea_repo_name}
|
||
</span>
|
||
{link.clone_url && (
|
||
<button onClick={() => copy(link.clone_url!)} className="text-blue-400 hover:text-blue-600 shrink-0 text-[10px]">
|
||
复制 clone URL
|
||
</button>
|
||
)}
|
||
<button onClick={handleUnlink} className="ml-auto text-red-400 hover:text-red-500 shrink-0 text-[10px]">
|
||
解绑
|
||
</button>
|
||
</div>
|
||
|
||
{/* G7 本地接线:仓库已建但本地未连通时的一次性引导 */}
|
||
{needsWiring && (
|
||
<div className="bg-indigo-50 border border-indigo-100 rounded p-2 space-y-1.5">
|
||
<p className="text-[11px] text-indigo-700 font-medium">
|
||
{!localGit?.has_git ? "本地目录还不是 git 仓库" : "本地仓库尚未关联远端 origin"}
|
||
</p>
|
||
<div className="flex items-center gap-1.5">
|
||
<p className="flex-1 text-[10px] text-indigo-400 leading-relaxed">
|
||
{!localGit?.has_git
|
||
? "将执行:git init + 首次提交 + 关联 origin + 推送"
|
||
: "将执行:关联 origin + 推送当前分支"}
|
||
</p>
|
||
<button
|
||
onClick={handleWireUp}
|
||
disabled={wiring}
|
||
className="px-2.5 py-1 rounded text-[11px] bg-indigo-600 text-white hover:bg-indigo-700 disabled:opacity-50 shrink-0"
|
||
>
|
||
{wiring ? "接线中…" : "🔌 一键接线"}
|
||
</button>
|
||
</div>
|
||
<p className="text-[10px] text-indigo-300 leading-relaxed">
|
||
接线是一次性动作;日常 commit / push 在项目自己的 AI 会话中进行。
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{/* webhook 状态与注册 */}
|
||
<div className="bg-gray-50 rounded p-2 space-y-1.5">
|
||
<div className="flex items-center gap-1.5 text-[11px]">
|
||
<span className={`w-1.5 h-1.5 rounded-full ${link.webhook_id ? "bg-green-400" : "bg-gray-300"}`} />
|
||
<span className="text-gray-500">
|
||
{link.webhook_id ? `Webhook 已注册(#${link.webhook_id})` : "Webhook 未注册"}
|
||
</span>
|
||
</div>
|
||
<div className="flex gap-1.5">
|
||
<input
|
||
className={inputCls}
|
||
value={callbackBase}
|
||
onChange={(e) => setCallbackBase(e.target.value)}
|
||
placeholder="Gitea 服务器可达的宿主机地址,如 http://192.168.1.5:27190"
|
||
/>
|
||
</div>
|
||
<div className="flex items-center gap-1.5">
|
||
<code className="flex-1 text-[10px] text-gray-400 font-mono truncate" title={callbackUrl}>
|
||
{callbackUrl}
|
||
</code>
|
||
<button onClick={() => copy(callbackUrl)} className={btnCls}>复制</button>
|
||
<button
|
||
onClick={handleSetupWebhook}
|
||
disabled={settingWebhook}
|
||
className="px-2.5 py-1 rounded text-[11px] bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50"
|
||
>
|
||
{settingWebhook ? "注册中…" : link.webhook_id ? "重设" : "注册 Webhook"}
|
||
</button>
|
||
</div>
|
||
<p className="text-[10px] text-gray-400 leading-relaxed">
|
||
需 Gitea 服务器能访问上述地址(MCP 端口默认 27190)。push 事件将实时写入飞轮 commit 数据。
|
||
</p>
|
||
<div className="flex items-center gap-1.5 border-t border-gray-100 pt-1.5">
|
||
<p className="flex-1 text-[10px] text-gray-400 leading-relaxed">
|
||
公网 Gitea + 内网炼境时 webhook 不可达——已由后台轮询兜底(每 10 分钟出站拉取)。
|
||
</p>
|
||
<button
|
||
onClick={handlePollNow}
|
||
disabled={polling}
|
||
className={btnCls}
|
||
>
|
||
{polling ? "拉取中…" : "立即拉取"}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</>
|
||
) : instances.length === 0 ? (
|
||
<p className="text-[11px] text-gray-400">
|
||
尚未配置 Gitea 实例——请先在「设置 → Gitea 实例」中添加
|
||
</p>
|
||
) : (
|
||
<>
|
||
<div className="flex items-center gap-1.5">
|
||
<span className="text-[11px] text-gray-500">本项目未绑定:</span>
|
||
{(["create", "migrate", "link"] as BindMode[]).map((m) => (
|
||
<button
|
||
key={m}
|
||
onClick={() => setBindMode(bindMode === m ? null : m)}
|
||
className={`px-2 py-1 rounded text-[11px] border transition-colors ${
|
||
bindMode === m
|
||
? "border-blue-300 bg-blue-50 text-blue-600"
|
||
: "border-gray-200 text-gray-500 hover:bg-gray-50"
|
||
}`}
|
||
>
|
||
{m === "create" ? "新建仓库" : m === "migrate" ? "迁移" : "绑定已有"}
|
||
</button>
|
||
))}
|
||
</div>
|
||
{bindMode && (
|
||
<div className="space-y-1.5 bg-gray-50 rounded p-2">
|
||
{instances.length > 1 && (
|
||
<select
|
||
className={inputCls}
|
||
value={effectiveInstanceId}
|
||
onChange={(e) => setBindInstanceId(e.target.value)}
|
||
>
|
||
{instances.map((i) => (
|
||
<option key={i.id} value={i.id}>{i.name}({i.url})</option>
|
||
))}
|
||
</select>
|
||
)}
|
||
{bindMode === "migrate" && (
|
||
<>
|
||
<input className={inputCls} placeholder="源仓库地址(https://github.com/user/repo.git)" value={cloneAddr} onChange={(e) => setCloneAddr(e.target.value)} />
|
||
<input className={inputCls} type="password" placeholder="源仓库 token(私有仓库需要,可留空)" value={migrateToken} onChange={(e) => setMigrateToken(e.target.value)} />
|
||
</>
|
||
)}
|
||
{bindMode === "link" && (
|
||
<input className={inputCls} placeholder="owner(Gitea 用户名或组织)" value={repoOwner} onChange={(e) => setRepoOwner(e.target.value)} />
|
||
)}
|
||
<input className={inputCls} placeholder="仓库名" value={repoName} onChange={(e) => setRepoName(e.target.value)} />
|
||
<button
|
||
onClick={handleBind}
|
||
disabled={binding || !effectiveInstanceId || !repoName.trim()}
|
||
className="px-2.5 py-1 rounded text-[11px] bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50"
|
||
>
|
||
{binding ? "执行中…" : "确认"}
|
||
</button>
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
{/* ── 全部绑定概览 ── */}
|
||
{allLinks.length > 0 && (
|
||
<div className="border-t border-gray-50 pt-2">
|
||
<p className="text-[10px] text-gray-400 mb-1">全部绑定({allLinks.length})</p>
|
||
<div className="space-y-0.5 max-h-24 overflow-y-auto">
|
||
{allLinks.map((r) => (
|
||
<div key={r.id} className="flex items-center gap-1.5 text-[10px] text-gray-500">
|
||
<span className={`w-1 h-1 rounded-full shrink-0 ${r.has_webhook ? "bg-green-400" : "bg-gray-300"}`} title={r.has_webhook ? "webhook 已注册" : "webhook 未注册"} />
|
||
<span className="font-mono truncate">{r.gitea_owner}/{r.gitea_repo_name}</span>
|
||
<span className="text-gray-300 truncate ml-auto shrink-0">{r.instance_name}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|