dev-manager-tauri/src/components/settings/SettingsPage.tsx
lanrtop bf697b5e28 feat(ui): 功能区重构阶段二——洞察页 + 全局/项目层级归位(N3/N4/N5)
N3 洞察页三 tab(飞轮/健康中心/全局治理):
   - FlywheelPanel(版本队列/阻塞/停转归档/Git质量/梦核)整体迁出 BlueprintModal
     ——跨项目内容不再寄生单项目弹窗,BlueprintModal 减重 414 行
   - CONVENTIONS 规则来源 + 应用梦核建议迁至全局治理 tab(影响所有项目的操作归全局层)
   - BlueprintModal 侧板只留单项目内容(模块详情/余票/可派发/项目治理)
N4 Gitea 层级归位:实例管理(全局配置)拆为 GiteaInstancesSection 挂设置页;
   项目绑定+webhook 留治理面板,无实例时提示去设置页添加;共用 queryKey 缓存
N5 仓库注册表并入 项目→管理→仓库 tab(RegistryTab 复用),Sidebar 底部入口
   与弹窗死壳移除;总览页更名对齐"跨项目行动项"定位

至此 ui-restructure N1-N5 全部完成:三条公理(名实一致/层级对齐/频率分层)落地。
零后端变更,typecheck 全绿。
2026-07-02 14:51:43 +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/group/&lt;group_id&gt;
</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>
);
}