dev-manager-tauri/src/components/settings/SettingsPage.tsx
lanrtop a41161f7d1
All checks were successful
Push & PR Check / check (push) Successful in 1m4s
feat(mcp): 单一全局 lian-jing 服务器——组级挂载收敛(T1-T3+T5)
T1 服务器层:新增全局 /mcp + /mcp/messages 路由,process_rpc 组上下文
Option 化;旧 /mcp/group/:gid 路由保留过渡,T6 移除。驻军测试 2 条。
T2 工具层:resolve_project_root 按 project_id 直查 project_workspaces,
删除 3 处组归属校验(实测无隔离价值);list_group_projects 改显式传参。
T3 注入层:settings.json 收敛为单 "lian-jing" key(URL /mcp),注入时
清理旧 lian-jing:group-* key;liangjing.json 主动删除 group_id;
启动时一次性迁移(重注入全量 + 清理旧全局注册两种命名格式,标记位防重)。
T5 同步:git-workflow 文档/模板、retro-catchup 技能 Python 兜底、
设置页 URL 展示、mcp-config-inject 模块取代注记。

收益:MCP 客户端配置成本 O(N组)→O(1),授权一次永久生效;
group_id 缺失/过期/移组同步一整类问题从根上消失。
2026-07-06 19:30:01 +09:00

782 lines
28 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 { useEffect, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { open } from "@tauri-apps/plugin-dialog";
import { check as checkUpdate } from "@tauri-apps/plugin-updater";
import { relaunch } from "@tauri-apps/plugin-process";
import { getVersion } from "@tauri-apps/api/app";
import {
detectEditors, getSettings, getSpaces, searchEditorPath, updateSettings, getWslDistros,
getProjects, syncBlueprintRules, checkMcpHealth,
type EditorCandidate, type SyncResult, type McpHealthResult,
} from "../../lib/commands";
// GitHub OAuth 配置已移至侧边栏「应用设置」
import { useUIStore } from "../../store/ui";
import { Dialog } from "../ui/Dialog";
import { GiteaInstancesSection } from "../dashboard/GiteaPanel";
// 编辑器条目数据结构(存为 JSON 保存到 settings 表)
interface EditorEntry {
id: string; // 唯一 id
name: string; // 显示名
path: string; // 完整可执行路径或命令名
}
function loadEditors(raw: string | undefined): EditorEntry[] {
try { return raw ? JSON.parse(raw) : []; } catch { return []; }
}
const inputCls = "w-full border border-gray-200 rounded-lg px-3 py-2 text-sm font-mono outline-none focus:ring-2 focus:ring-blue-200 focus:border-blue-400";
/* ── 编辑器表单弹窗 ─────────────────────────────────────────────────────────── */
function EditorFormModal({ entry, onClose, onSave }: {
entry: EditorEntry | null; // null = 新增
onClose: () => void;
onSave: (e: EditorEntry) => void;
}) {
const showToast = useUIStore((s) => s.showToast);
const [name, setName] = useState(entry?.name ?? "");
const [path, setPath] = useState(entry?.path ?? "");
const [searching, setSearching] = useState(false);
const { data: detected, isLoading: detecting } = useQuery({
queryKey: ["editors"],
queryFn: detectEditors,
staleTime: 60000,
});
const pickExe = async () => {
const selected = await open({
multiple: false,
filters: [{ name: "可执行文件", extensions: ["exe", "cmd", "bat"] }],
});
if (selected) setPath(selected as string);
};
const autoSearch = async () => {
if (!name.trim()) { showToast("❌ 请先填写名称或命令关键词"); return; }
setSearching(true);
try {
// 把名称当命令名搜一下
const keyword = name.trim().toLowerCase().replace(/\s+/g, "");
const found = await searchEditorPath(keyword);
if (found) {
setPath(found);
showToast(`已找到:${found}`);
} else {
showToast("未找到,请手动填写路径");
}
} finally {
setSearching(false);
}
};
const fillFromDetected = (c: EditorCandidate) => {
setName(c.name);
setPath(c.path ?? c.cmd);
};
const handleSave = () => {
if (!name.trim()) { showToast("❌ 名称不能为空"); return; }
if (!path.trim()) { showToast("❌ 路径不能为空"); return; }
onSave({
id: entry?.id ?? `editor-${Date.now()}`,
name: name.trim(),
path: path.trim(),
});
};
return (
<Dialog title={entry ? "编辑编辑器" : "新增编辑器"} onClose={onClose} size="md">
<div className="px-6 py-4 space-y-4">
{/* 检测到的编辑器快速填充 */}
<div>
<p className="text-xs font-medium text-gray-500 mb-2"></p>
{detecting ? (
<p className="text-xs text-gray-400"></p>
) : (
<div className="flex flex-wrap gap-2">
{(detected ?? []).map((c) => (
<button
key={c.cmd}
onClick={() => fillFromDetected(c)}
className={`px-3 py-1.5 rounded-lg text-xs border transition-colors ${
c.found
? "border-green-200 bg-green-50 text-green-800 hover:bg-green-100"
: "border-gray-100 text-gray-300 cursor-not-allowed"
}`}
disabled={!c.found}
title={c.path ?? "未检测到"}
>
{c.found ? "✓ " : ""}{c.name}
</button>
))}
</div>
)}
</div>
<hr className="border-gray-100" />
{/* 名称 */}
<div>
<label className="block text-xs font-medium text-gray-600 mb-1"></label>
<input
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-blue-200"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Cursor / VS Code / …"
/>
</div>
{/* 路径 */}
<div>
<label className="block text-xs font-medium text-gray-600 mb-1"></label>
<div className="flex gap-2">
<input
className={`${inputCls} flex-1`}
value={path}
onChange={(e) => setPath(e.target.value)}
placeholder="cursor 或 C:\...\Cursor.exe"
/>
<button
onClick={pickExe}
className="px-3 py-2 rounded-lg border border-gray-200 text-xs text-gray-600 hover:bg-gray-50 shrink-0"
title="浏览文件"
>
📁
</button>
</div>
{/* 搜索按钮 */}
<button
onClick={autoSearch}
disabled={searching}
className="mt-2 flex items-center gap-1.5 text-xs text-blue-600 hover:text-blue-800 disabled:opacity-50"
>
<span>{searching ? "🔍 搜索中…" : "🔍 自动搜索路径"}</span>
<span className="text-gray-400"></span>
</button>
</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} className="px-4 py-2 rounded-lg text-sm bg-blue-600 text-white hover:bg-blue-700">
</button>
</div>
</div>
</Dialog>
);
}
/* ── 主设置页 ───────────────────────────────────────────────────────────────── */
export function SettingsPage() {
const showToast = useUIStore((s) => s.showToast);
const qc = useQueryClient();
const { data: spaces = [] } = useQuery({ queryKey: ["spaces"], queryFn: getSpaces });
const spaceId = spaces.find((s) => s.is_current)?.id;
const { data: settings } = useQuery({
queryKey: ["settings", spaceId],
queryFn: () => getSettings(spaceId),
enabled: spaces.length > 0,
});
const [editors, setEditors] = useState<EditorEntry[]>([]);
const [activeIds, setActiveIds] = useState<string[]>([]); // 多选
const [wslDistro, setWslDistro] = useState("Ubuntu");
const [wslOpenMode, setWslOpenMode] = useState("wsl_internal");
const [wslDistros, setWslDistros] = useState<string[]>([]);
const [detectingWsl, setDetectingWsl] = useState(false);
const [wslError, setWslError] = useState("");
const [formTarget, setFormTarget] = useState<EditorEntry | null | undefined>(undefined);
const [mcpPort, setMcpPort] = useState("27190");
const [mcpHealth, setMcpHealth] = useState<McpHealthResult | null>(null);
const [mcpChecking, setMcpChecking] = useState(false);
useEffect(() => {
if (settings) {
setEditors(loadEditors(settings.editors));
try {
setActiveIds(settings.active_editors ? JSON.parse(settings.active_editors) : []);
} catch {
// 兼容旧的单选字段
const old = settings.active_editor;
setActiveIds(old ? [old] : []);
}
setWslDistro(settings.wsl_distro ?? "Ubuntu");
setWslOpenMode(settings.wsl_open_mode ?? "wsl_internal");
setMcpPort(settings.mcp_port ?? "27190");
// 设置加载后自动检查一次 MCP 健康状态
pingMcp();
}
}, [settings]);
const saveAll = async (nextEditors: EditorEntry[], nextActiveIds: string[]) => {
await updateSettings({
editors: JSON.stringify(nextEditors),
active_editors: JSON.stringify(nextActiveIds),
wsl_distro: wslDistro,
}, spaceId);
qc.invalidateQueries({ queryKey: ["settings", spaceId] });
showToast("设置已保存 ✓");
};
const handleSaveEditor = (entry: EditorEntry) => {
const next = formTarget
? editors.map((e) => (e.id === entry.id ? entry : e))
: [...editors, entry];
// 新增时自动勾选
const nextActive = formTarget ? activeIds : [...new Set([...activeIds, entry.id])];
setEditors(next);
setActiveIds(nextActive);
setFormTarget(undefined);
saveAll(next, nextActive);
};
const handleDelete = (id: string) => {
const next = editors.filter((e) => e.id !== id);
const nextActive = activeIds.filter((a) => a !== id);
setEditors(next);
setActiveIds(nextActive);
saveAll(next, nextActive);
showToast("已删除");
};
const toggleActive = (id: string) => {
const next = activeIds.includes(id)
? activeIds.filter((a) => a !== id)
: [...activeIds, id];
setActiveIds(next);
saveAll(editors, next);
};
const saveWSL = async () => {
await updateSettings({ wsl_distro: wslDistro, wsl_open_mode: wslOpenMode }, spaceId);
qc.invalidateQueries({ queryKey: ["settings", spaceId] });
showToast("已保存 ✓");
};
const pingMcp = async () => {
setMcpChecking(true);
try {
const result = await checkMcpHealth();
setMcpHealth(result);
} catch {
setMcpHealth({ ok: false, port: parseInt(mcpPort, 10), error: "检测失败" });
} finally {
setMcpChecking(false);
}
};
const saveMcp = async () => {
const port = parseInt(mcpPort, 10);
if (isNaN(port) || port < 1024 || port > 65535) {
showToast("❌ 端口范围1024 ~ 65535");
return;
}
await updateSettings({ mcp_port: mcpPort });
qc.invalidateQueries({ queryKey: ["settings", spaceId] });
showToast("已保存,所有产品组成员配置已更新 ✓");
pingMcp();
};
const detectWsl = async () => {
setDetectingWsl(true);
setWslError("");
try {
const list = await getWslDistros();
setWslDistros(list);
if (list.length > 0 && !list.includes(wslDistro)) {
setWslDistro(list[0]);
}
} catch (e) {
setWslError(String(e));
setWslDistros([]);
} finally {
setDetectingWsl(false);
}
};
return (
<div className="max-w-xl mx-auto px-6 py-8 space-y-8 pb-12">
<h1 className="text-lg font-semibold text-gray-800"></h1>
{/* ── 编辑器管理 ── */}
<section className="bg-white rounded-xl border border-gray-100 shadow-sm p-5 space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold text-gray-700"></h2>
<button
onClick={() => setFormTarget(null)}
className="px-3 py-1.5 rounded-lg text-xs bg-blue-600 text-white hover:bg-blue-700"
>
+
</button>
</div>
{editors.length === 0 ? (
<div className="text-center py-8 text-gray-400 text-sm border border-dashed border-gray-200 rounded-lg">
<p className="text-2xl mb-2">🖥</p>
<p></p>
<p className="text-xs mt-1"></p>
</div>
) : (
<div className="space-y-2">
{editors.map((e) => {
const checked = activeIds.includes(e.id);
return (
<div
key={e.id}
className={`flex items-center gap-3 p-3 rounded-lg border transition-colors ${
checked ? "border-blue-300 bg-blue-50" : "border-gray-100 bg-gray-50 hover:bg-gray-100"
}`}
>
{/* 多选 checkbox */}
<input
type="checkbox"
checked={checked}
onChange={() => toggleActive(e.id)}
className="w-4 h-4 rounded accent-blue-600 shrink-0 cursor-pointer"
/>
{/* 信息 */}
<div className="flex-1 min-w-0 cursor-pointer" onClick={() => toggleActive(e.id)}>
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-800">{e.name}</span>
{checked && (
<span className="px-1.5 py-0.5 rounded text-xs bg-blue-100 text-blue-700"></span>
)}
</div>
<div className="text-xs font-mono text-gray-400 truncate" title={e.path}>
{e.path}
</div>
</div>
{/* 操作 */}
<div className="flex gap-1 shrink-0">
<button
onClick={() => setFormTarget(e)}
className="px-2 py-1 rounded text-xs border border-gray-200 text-gray-500 hover:bg-white"
>
</button>
<button
onClick={() => handleDelete(e.id)}
className="px-2 py-1 rounded text-xs border border-red-200 text-red-500 hover:bg-red-50"
>
</button>
</div>
</div>
);
})}
</div>
)}
<p className="text-xs text-gray-400">
</p>
</section>
{/* ── WSL ── */}
<section className="bg-white rounded-xl border border-gray-100 shadow-sm p-5 space-y-4">
<h2 className="text-sm font-semibold text-gray-700">WSL </h2>
{/* 检测按钮 */}
<div className="flex items-center gap-3">
<button
onClick={detectWsl}
disabled={detectingWsl}
className="px-3 py-1.5 rounded-lg text-xs border border-gray-200 text-gray-600 hover:bg-gray-50 disabled:opacity-50"
>
{detectingWsl ? "检测中…" : "检测已安装的 WSL 发行版"}
</button>
{wslDistros.length > 0 && (
<span className="text-xs text-green-600"> {wslDistros.length} </span>
)}
{wslError && (
<span className="text-xs text-red-500">{wslError}</span>
)}
</div>
{/* 发行版选择 */}
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">使</label>
{wslDistros.length > 0 ? (
<select
className={inputCls}
value={wslDistro}
onChange={(e) => setWslDistro(e.target.value)}
>
{wslDistros.map((d) => (
<option key={d} value={d}>{d}</option>
))}
</select>
) : (
<input
className={inputCls}
value={wslDistro}
onChange={(e) => setWslDistro(e.target.value)}
placeholder="Ubuntu"
/>
)}
<p className="text-xs text-gray-400 mt-1">
<code className="font-mono bg-gray-50 px-1 rounded">
\\wsl.localhost\{wslDistro}\
</code>
</p>
</div>
{/* 编辑器启动方式 */}
<div>
<label className="block text-xs font-medium text-gray-500 mb-2"></label>
<div className="space-y-2">
{([
{
value: "wsl_internal",
label: "从 WSL 内部启动(推荐)",
desc: `wsl -d ${wslDistro} -- <editor> <linux-path>`,
},
{
value: "remote_flag",
label: "Remote WSL 参数",
desc: `<editor> --remote wsl+${wslDistro} <linux-path>`,
},
{
value: "unc_path",
label: "Windows UNC 路径(不走 Remote",
desc: `<editor> \\\\wsl.localhost\\${wslDistro}\\<path>`,
},
] as const).map(({ value, label, desc }) => (
<label
key={value}
className={`flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${
wslOpenMode === value
? "border-blue-300 bg-blue-50"
: "border-gray-100 bg-gray-50 hover:bg-gray-100"
}`}
>
<input
type="radio"
name="wslOpenMode"
value={value}
checked={wslOpenMode === value}
onChange={() => setWslOpenMode(value)}
className="mt-0.5 accent-blue-600 shrink-0"
/>
<div className="min-w-0">
<div className="text-sm font-medium text-gray-800">{label}</div>
<code className="text-xs font-mono text-gray-400 break-all">{desc}</code>
</div>
</label>
))}
</div>
</div>
<div className="flex justify-end">
<button
onClick={saveWSL}
className="px-4 py-2 rounded-lg text-sm bg-blue-600 text-white hover:bg-blue-700"
>
</button>
</div>
</section>
{/* ── MCP 服务 ── */}
<section className="bg-white rounded-xl border border-gray-100 shadow-sm p-5 space-y-4">
<div className="flex items-center gap-2">
<h2 className="text-sm font-semibold text-gray-700">MCP </h2>
{mcpChecking ? (
<span className="text-xs text-gray-400"></span>
) : mcpHealth ? (
mcpHealth.ok ? (
<span className="flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs bg-green-50 text-green-600 border border-green-200">
<span className="w-1.5 h-1.5 rounded-full bg-green-500 inline-block" />
{mcpHealth.latency_ms}ms
</span>
) : (
<span className="flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs bg-red-50 text-red-600 border border-red-200" title={mcpHealth.error}>
<span className="w-1.5 h-1.5 rounded-full bg-red-500 inline-block" />
</span>
)
) : null}
<button onClick={pingMcp} className="text-xs text-gray-400 hover:text-gray-600"></button>
</div>
<p className="text-xs text-gray-400">
MCP Server Claude
{" "}<code className="font-mono bg-gray-50 px-1 rounded">.claude/settings.json</code>{" "}
MCP
</p>
<div>
<label className="block text-xs font-medium text-gray-500 mb-1"></label>
<div className="flex gap-2 items-center">
<input
type="number"
className="w-28 border border-gray-200 rounded-lg px-3 py-2 text-sm font-mono outline-none focus:ring-2 focus:ring-blue-200 focus:border-blue-400"
value={mcpPort}
onChange={(e) => setMcpPort(e.target.value)}
min={1024}
max={65535}
/>
<button
onClick={saveMcp}
className="px-4 py-2 rounded-lg text-sm bg-blue-600 text-white hover:bg-blue-700"
>
</button>
</div>
<p className="text-xs text-gray-400 mt-1.5">
MCP
<code className="font-mono bg-gray-50 px-1 rounded">
http://localhost:{mcpPort}/mcp
</code>
</p>
</div>
</section>
{/* 编辑器表单弹窗 */}
{formTarget !== undefined && (
<EditorFormModal
entry={formTarget}
onClose={() => setFormTarget(undefined)}
onSave={handleSaveEditor}
/>
)}
<BatchSyncSection />
<GiteaInstancesSection />
<UpdateSection />
</div>
);
}
/* ── 蓝图规则批量同步区块 ────────────────────────────────────────────────────── */
interface SyncRow {
name: string;
path: string;
status: "pending" | "ok" | "error";
result?: SyncResult;
error?: string;
}
function BatchSyncSection() {
const [rows, setRows] = useState<SyncRow[]>([]);
const [running, setRunning] = useState(false);
const [done, setDone] = useState(false);
const handleSync = async () => {
setRunning(true);
setDone(false);
let projects: Awaited<ReturnType<typeof getProjects>> = [];
try {
projects = await getProjects(undefined, undefined);
} catch {
setRunning(false);
return;
}
// 只处理有 win_path 的项目
const targets = projects
.filter((p) => p.win_path)
.map((p) => ({ name: p.name, path: p.win_path! }));
if (targets.length === 0) {
setRows([]);
setRunning(false);
setDone(true);
return;
}
const initial: SyncRow[] = targets.map((t) => ({ ...t, status: "pending" }));
setRows(initial);
const results = [...initial];
for (let i = 0; i < results.length; i++) {
try {
const result = await syncBlueprintRules(results[i].path);
results[i] = { ...results[i], status: "ok", result };
} catch (e) {
results[i] = { ...results[i], status: "error", error: String(e) };
}
setRows([...results]);
}
setRunning(false);
setDone(true);
};
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">
<div>
<h2 className="text-sm font-semibold text-gray-700"></h2>
<p className="text-xs text-gray-400 mt-0.5">
CONVENTIONS.md
</p>
</div>
<button
onClick={handleSync}
disabled={running}
className="px-4 py-1.5 rounded-lg border border-gray-200 text-sm text-gray-600 hover:bg-gray-50 disabled:opacity-50 transition-colors"
>
{running ? "同步中…" : "同步所有项目"}
</button>
</div>
{rows.length > 0 && (
<div className="space-y-1.5 max-h-60 overflow-y-auto">
{rows.map((row) => (
<div
key={row.path}
className={`flex items-start gap-2 px-3 py-2 rounded-lg text-xs ${
row.status === "ok"
? "bg-green-50 text-green-800"
: row.status === "error"
? "bg-red-50 text-red-700"
: "bg-gray-50 text-gray-400"
}`}
>
<span className="shrink-0 mt-0.5">
{row.status === "ok" ? "✓" : row.status === "error" ? "✗" : "…"}
</span>
<div className="min-w-0">
<span className="font-medium">{row.name}</span>
{row.status === "ok" && row.result && (
<span className="ml-2 opacity-70">{row.result.message}</span>
)}
{row.status === "error" && (
<span className="ml-2 font-mono break-all">{row.error}</span>
)}
</div>
</div>
))}
</div>
)}
{done && rows.length === 0 && (
<p className="text-xs text-gray-400"> win_path </p>
)}
</section>
);
}
/* ── 检查更新区块 ─────────────────────────────────────────────────────────────── */
type UpdateState =
| { status: "idle" }
| { status: "checking" }
| { status: "latest" }
| { status: "available"; version: string; notes: string; downloadAndInstall: () => Promise<void> }
| { status: "downloading"; progress: number }
| { status: "error"; message: string };
function UpdateSection() {
const [appVersion, setAppVersion] = useState<string>("");
const [state, setState] = useState<UpdateState>({ status: "idle" });
useEffect(() => {
getVersion().then(setAppVersion).catch(() => {});
}, []);
const handleCheck = async () => {
setState({ status: "checking" });
try {
const update = await checkUpdate();
if (!update?.available) {
setState({ status: "latest" });
return;
}
setState({
status: "available",
version: update.version,
notes: update.body ?? "",
downloadAndInstall: async () => {
let downloaded = 0;
let total = 0;
await update.downloadAndInstall((event) => {
if (event.event === "Started") {
total = event.data.contentLength ?? 0;
setState({ status: "downloading", progress: 0 });
} else if (event.event === "Progress") {
downloaded += event.data.chunkLength;
setState({ status: "downloading", progress: total > 0 ? Math.round((downloaded / total) * 100) : 0 });
} else if (event.event === "Finished") {
setState({ status: "idle" });
}
});
await relaunch();
},
});
} catch (e) {
setState({ status: "error", message: String(e) });
}
};
return (
<section className="bg-white rounded-xl border border-gray-200 shadow-sm px-6 py-5">
<h2 className="text-sm font-semibold text-gray-700 mb-4"> & </h2>
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-700"></p>
<p className="text-xs text-gray-400 mt-0.5"> v{appVersion}</p>
</div>
<div className="flex items-center gap-3">
{state.status === "available" && (
<span className="text-xs text-blue-600 bg-blue-50 border border-blue-200 px-2 py-0.5 rounded-full">
v{state.version}
</span>
)}
{state.status === "latest" && (
<span className="text-xs text-green-600"></span>
)}
{state.status === "downloading" && (
<span className="text-xs text-blue-600"> {state.progress}%</span>
)}
{state.status === "available" ? (
<button
onClick={state.downloadAndInstall}
className="px-4 py-1.5 rounded-lg bg-blue-600 text-white text-sm hover:bg-blue-700 transition-colors"
>
</button>
) : (
<button
onClick={handleCheck}
disabled={state.status === "checking" || state.status === "downloading"}
className="px-4 py-1.5 rounded-lg border border-gray-200 text-sm text-gray-600 hover:bg-gray-50 disabled:opacity-50 transition-colors"
>
{state.status === "checking" ? "检查中…" : "检查更新"}
</button>
)}
</div>
</div>
{state.status === "error" && (
<div className="mt-3 px-3 py-2.5 bg-red-50 border border-red-200 rounded-lg">
<div className="flex items-start justify-between gap-2">
<p className="text-xs text-red-600 break-all font-mono flex-1 select-text">
{state.message}
</p>
<button
onClick={() => {
navigator.clipboard.writeText(state.message);
}}
className="text-xs text-red-400 hover:text-red-600 shrink-0 transition-colors"
>
</button>
</div>
</div>
)}
</section>
);
}