All checks were successful
Push & PR Check / check (push) Successful in 1m4s
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 缺失/过期/移组同步一整类问题从根上消失。
782 lines
28 KiB
TypeScript
782 lines
28 KiB
TypeScript
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>
|
||
);
|
||
}
|