tauri-plugin-dialog 把 window.confirm 替换为 async 函数,同步判断拿到恒为真的 Promise,撤销测试账户、删除 Gitea 实例/解绑仓库、删除仓库、准备工具链 5 处 破坏性或改机器状态的操作均未弹框即执行(UI 终验发现)。统一 await @tauri-apps/plugin-dialog 的 confirm,并加源码扫描回归测试防复发。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
386 lines
15 KiB
TypeScript
386 lines
15 KiB
TypeScript
import { useState, useEffect } from "react";
|
||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||
import {
|
||
scanProjectEnv,
|
||
getProjectEnvTools,
|
||
saveProjectEnvTools,
|
||
getToolchainStatus,
|
||
prepareToolchain,
|
||
type Project,
|
||
type EnvToolItem,
|
||
type SaveEnvToolInput,
|
||
type RuntimeToolStatus,
|
||
type ToolchainStatus,
|
||
} from "../../lib/commands";
|
||
import { confirm } from "@tauri-apps/plugin-dialog";
|
||
import { useUIStore } from "../../store/ui";
|
||
|
||
// ── 层元信息 ───────────────────────────────────────────────────────────────────
|
||
|
||
const LAYER_META: Record<number, { label: string; desc: string; color: string }> = {
|
||
0: { label: "第 0 层", desc: "版本管理器", color: "bg-purple-50 border-purple-200 text-purple-800" },
|
||
1: { label: "第 1 层", desc: "语言运行时", color: "bg-blue-50 border-blue-200 text-blue-800" },
|
||
2: { label: "第 2 层", desc: "直接使用工具", color: "bg-green-50 border-green-200 text-green-800" },
|
||
3: { label: "第 3 层", desc: "局部内置", color: "bg-gray-50 border-gray-200 text-gray-700" },
|
||
};
|
||
|
||
const TOOLCHAIN_STATUS_META: Record<string, { label: string; color: string }> = {
|
||
ready_managed: { label: "已由 mise 管理", color: "text-green-700 bg-green-50 border-green-200" },
|
||
compliant_unmanaged: { label: "版本匹配但未托管", color: "text-amber-700 bg-amber-50 border-amber-200" },
|
||
mismatch: { label: "版本不匹配", color: "text-red-700 bg-red-50 border-red-200" },
|
||
conflict: { label: "声明冲突", color: "text-red-700 bg-red-50 border-red-200" },
|
||
missing_declaration: { label: "缺少声明", color: "text-amber-700 bg-amber-50 border-amber-200" },
|
||
unavailable: { label: "当前不可用", color: "text-gray-600 bg-gray-50 border-gray-200" },
|
||
};
|
||
|
||
// ── 主组件 ────────────────────────────────────────────────────────────────────
|
||
|
||
interface Props {
|
||
project: Project;
|
||
onClose: () => void;
|
||
}
|
||
|
||
export function ProjectEnvModal({ project, onClose }: Props) {
|
||
const showToast = useUIStore((s) => s.showToast);
|
||
const qc = useQueryClient();
|
||
|
||
const [items, setItems] = useState<EnvToolItem[]>([]);
|
||
const [scanning, setScanning] = useState(false);
|
||
const [saving, setSaving] = useState(false);
|
||
const [preparing, setPreparing] = useState(false);
|
||
const [scanned, setScanned] = useState(false);
|
||
|
||
// 加载已保存的数据
|
||
const { data: savedItems = [], isLoading } = useQuery({
|
||
queryKey: ["projectEnvTools", project.id],
|
||
queryFn: () => getProjectEnvTools(project.id),
|
||
});
|
||
|
||
const {
|
||
data: toolchain,
|
||
isLoading: toolchainLoading,
|
||
error: toolchainError,
|
||
refetch: refetchToolchain,
|
||
} = useQuery({
|
||
queryKey: ["toolchainStatus", project.id],
|
||
queryFn: () => getToolchainStatus(project.id),
|
||
});
|
||
|
||
// 已保存数据加载后,若尚未扫描则显示已保存内容
|
||
useEffect(() => {
|
||
if (savedItems.length > 0 && !scanned) {
|
||
setItems(savedItems);
|
||
}
|
||
}, [savedItems, scanned]);
|
||
|
||
const handleScan = async (env: "win" | "wsl") => {
|
||
setScanning(true);
|
||
try {
|
||
const result = await scanProjectEnv(project.id, env);
|
||
setItems(result);
|
||
setScanned(true);
|
||
showToast(`扫描完成,发现 ${result.length} 项工具 ✓`);
|
||
} catch (e) {
|
||
showToast(`❌ ${e}`);
|
||
} finally {
|
||
setScanning(false);
|
||
}
|
||
};
|
||
|
||
const handleRemove = (id: string) => {
|
||
setItems((prev) => prev.filter((item) => item.id !== id));
|
||
};
|
||
|
||
const handleSave = async () => {
|
||
setSaving(true);
|
||
try {
|
||
const inputs: SaveEnvToolInput[] = items.map((item) => ({
|
||
id: item.id,
|
||
layer: item.layer,
|
||
name: item.name,
|
||
source: item.source,
|
||
version: item.version,
|
||
note: item.note,
|
||
}));
|
||
await saveProjectEnvTools(project.id, inputs);
|
||
qc.invalidateQueries({ queryKey: ["projectEnvTools", project.id] });
|
||
showToast("已保存 ✓");
|
||
onClose();
|
||
} catch (e) {
|
||
showToast(`❌ ${e}`);
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
};
|
||
|
||
const handlePrepareToolchain = async () => {
|
||
const confirmed = await confirm(
|
||
"炼境将通过 mise install 下载并准备项目已声明的 Node/pnpm 版本。不会修改 package.json 或 lockfile,是否继续?",
|
||
{ kind: "warning" },
|
||
);
|
||
if (!confirmed) return;
|
||
setPreparing(true);
|
||
try {
|
||
const result = await prepareToolchain(project.id, true);
|
||
await refetchToolchain();
|
||
showToast(result.verified
|
||
? `工具链准备并验证完成:${result.command.join(" ")} ✓`
|
||
: `⚠️ ${result.command.join(" ")} 已执行,但二次诊断尚未达到 ready_managed,请查看状态证据`);
|
||
} catch (e) {
|
||
showToast(`❌ ${e}`);
|
||
} finally {
|
||
setPreparing(false);
|
||
}
|
||
};
|
||
|
||
// 按层分组
|
||
const grouped = [0, 1, 2, 3].reduce<Record<number, EnvToolItem[]>>((acc, layer) => {
|
||
const layerItems = items.filter((item) => item.layer === layer);
|
||
if (layerItems.length > 0) acc[layer] = layerItems;
|
||
return acc;
|
||
}, {});
|
||
|
||
return (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4 sm:p-6">
|
||
<div className="flex h-[88vh] max-h-[900px] w-[92vw] max-w-5xl flex-col overflow-hidden rounded-xl bg-white shadow-xl">
|
||
{/* Header */}
|
||
<div className="flex items-center justify-between border-b border-gray-100 px-8 py-5">
|
||
<div>
|
||
<h2 className="font-semibold text-gray-800">环境工具</h2>
|
||
<p className="text-xs text-gray-400 mt-0.5">{project.name}</p>
|
||
</div>
|
||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-xl leading-none">×</button>
|
||
</div>
|
||
|
||
{/* Scan buttons */}
|
||
<div className="flex gap-2 border-b border-gray-50 bg-gray-50 px-8 py-4">
|
||
<span className="text-xs text-gray-500 self-center mr-1">扫描目录:</span>
|
||
{project.win_path && (
|
||
<button
|
||
onClick={() => handleScan("win")}
|
||
disabled={scanning}
|
||
className="px-3 py-1.5 rounded-lg text-xs border border-blue-200 text-blue-700 hover:bg-blue-50 disabled:opacity-50 transition-colors"
|
||
>
|
||
{scanning ? "扫描中…" : "💻 WIN 环境"}
|
||
</button>
|
||
)}
|
||
{project.wsl_path && (
|
||
<button
|
||
onClick={() => handleScan("wsl")}
|
||
disabled={scanning}
|
||
className="px-3 py-1.5 rounded-lg text-xs border border-green-200 text-green-700 hover:bg-green-50 disabled:opacity-50 transition-colors"
|
||
>
|
||
{scanning ? "扫描中…" : "🐧 WSL 环境"}
|
||
</button>
|
||
)}
|
||
<span className="text-xs text-gray-400 self-center ml-auto">
|
||
扫描后结果自动替换,确认后点保存
|
||
</span>
|
||
</div>
|
||
|
||
{/* Content */}
|
||
<div className="flex-1 overflow-y-auto px-8 py-6">
|
||
<ToolchainCard
|
||
status={toolchain}
|
||
loading={toolchainLoading}
|
||
error={toolchainError}
|
||
preparing={preparing}
|
||
onPrepare={handlePrepareToolchain}
|
||
/>
|
||
{isLoading && !scanned ? (
|
||
<p className="text-sm text-gray-400 text-center py-8">加载中…</p>
|
||
) : items.length === 0 ? (
|
||
<div className="text-center py-12 text-gray-400">
|
||
<p className="text-3xl mb-3">🔍</p>
|
||
<p className="text-sm">点击上方「扫描」按钮分析项目目录</p>
|
||
<p className="text-xs mt-1 text-gray-300">会根据配置文件自动推断技术栈和工具链</p>
|
||
</div>
|
||
) : (
|
||
<div className="space-y-5">
|
||
{/* 三层模型说明 */}
|
||
<div className="flex flex-wrap gap-1.5 text-xs">
|
||
{[0, 1, 2, 3].map((l) => (
|
||
<span key={l} className={`px-2 py-0.5 rounded border ${LAYER_META[l].color}`}>
|
||
{LAYER_META[l].label} {LAYER_META[l].desc}
|
||
</span>
|
||
))}
|
||
</div>
|
||
|
||
{/* 分层展示 */}
|
||
{Object.entries(grouped).map(([layerStr, layerItems]) => {
|
||
const layer = Number(layerStr);
|
||
const meta = LAYER_META[layer];
|
||
return (
|
||
<div key={layer}>
|
||
<div className="flex items-center gap-2 mb-2">
|
||
<span className={`text-xs font-semibold px-2 py-0.5 rounded border ${meta.color}`}>
|
||
{meta.label} {meta.desc}
|
||
</span>
|
||
</div>
|
||
<div className="space-y-1.5">
|
||
{layerItems.map((item) => (
|
||
<EnvToolRow
|
||
key={item.id}
|
||
item={item}
|
||
onRemove={() => handleRemove(item.id)}
|
||
/>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Footer */}
|
||
<div className="flex items-center justify-between px-6 py-4 border-t border-gray-100">
|
||
<span className="text-xs text-gray-400">{items.length} 项工具</span>
|
||
<div className="flex gap-3">
|
||
<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}
|
||
disabled={saving || items.length === 0}
|
||
className="px-4 py-2 rounded-lg text-sm bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50"
|
||
>
|
||
{saving ? "保存中…" : "保存"}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ToolchainCard({
|
||
status,
|
||
loading,
|
||
error,
|
||
preparing,
|
||
onPrepare,
|
||
}: {
|
||
status?: ToolchainStatus;
|
||
loading: boolean;
|
||
error: unknown;
|
||
preparing: boolean;
|
||
onPrepare: () => void;
|
||
}) {
|
||
if (loading) {
|
||
return <div className="mb-4 rounded-lg border border-gray-100 p-3 text-xs text-gray-400">正在检查项目工具链…</div>;
|
||
}
|
||
if (error || !status) {
|
||
return (
|
||
<div className="mb-4 rounded-lg border border-red-100 bg-red-50 p-3 text-xs text-red-600">
|
||
工具链状态不可用:{String(error ?? "未返回状态")}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const needsPrepare = [status.node.status, status.pnpm.status].some((value) =>
|
||
["mismatch", "unavailable", "compliant_unmanaged"].includes(value),
|
||
);
|
||
const canPrepare = needsPrepare
|
||
&& status.conflicts.length === 0
|
||
&& status.resolver.configured
|
||
&& Boolean(status.resolver.executable);
|
||
|
||
return (
|
||
<section className="mb-5 rounded-xl border border-slate-200 bg-slate-50/70 p-4">
|
||
<div className="mb-3 flex items-start justify-between gap-3">
|
||
<div>
|
||
<h3 className="text-sm font-semibold text-slate-800">项目工具链契约</h3>
|
||
<p className="mt-0.5 break-all text-[11px] text-slate-400">{status.project_root}</p>
|
||
</div>
|
||
{canPrepare && (
|
||
<button
|
||
type="button"
|
||
onClick={onPrepare}
|
||
disabled={preparing}
|
||
className="shrink-0 rounded-lg bg-slate-800 px-3 py-1.5 text-xs text-white hover:bg-slate-700 disabled:opacity-50"
|
||
>
|
||
{preparing ? "准备中…" : "准备项目工具链"}
|
||
</button>
|
||
)}
|
||
</div>
|
||
<div className="space-y-2">
|
||
<ToolchainRow label="Node" item={status.node} />
|
||
<ToolchainRow label="pnpm" item={status.pnpm} />
|
||
<div className="flex items-center justify-between gap-3 rounded-lg bg-white px-3 py-2 text-xs">
|
||
<span className="font-medium text-slate-700">mise resolver</span>
|
||
<div className="min-w-0 text-right text-slate-500">
|
||
<span>{status.resolver.status}</span>
|
||
{status.resolver.actual_version && <span className="ml-2 font-mono">{status.resolver.actual_version}</span>}
|
||
{status.resolver.executable && <p className="truncate text-[10px] text-slate-400">{status.resolver.executable}</p>}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{status.conflicts.length > 0 && (
|
||
<p className="mt-3 text-xs text-red-600">冲突声明:{status.conflicts.join("、")}。炼境不会替项目选择版本。</p>
|
||
)}
|
||
{needsPrepare && !canPrepare && status.conflicts.length === 0 && (
|
||
<p className="mt-3 text-xs text-amber-700">
|
||
{!status.resolver.configured
|
||
? "项目尚未配置 mise.toml,请先应用工具链能力包。"
|
||
: "本机尚未安装 mise,请先完成一次用户级安装。"}
|
||
</p>
|
||
)}
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function ToolchainRow({ label, item }: { label: string; item: RuntimeToolStatus }) {
|
||
const meta = TOOLCHAIN_STATUS_META[item.status] ?? TOOLCHAIN_STATUS_META.unavailable;
|
||
return (
|
||
<div className="rounded-lg bg-white px-3 py-2 text-xs">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<span className="font-medium text-slate-700">{label}</span>
|
||
<span className={`rounded border px-2 py-0.5 ${meta.color}`}>{meta.label}</span>
|
||
</div>
|
||
<div className="mt-1 grid grid-cols-2 gap-2 text-[11px] text-slate-500">
|
||
<span>期望:<span className="font-mono">{item.declared_version ?? "未声明"}</span></span>
|
||
<span>实际:<span className="font-mono">{item.actual_version ?? "不可用"}</span></span>
|
||
</div>
|
||
<p className="mt-1 truncate text-[10px] text-slate-400">
|
||
{item.declared_source ?? "无声明来源"}{item.executable ? ` · ${item.executable}` : ""}
|
||
</p>
|
||
{item.recommendation && <p className="mt-1 text-[11px] text-slate-500">{item.recommendation}</p>}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── 工具行 ────────────────────────────────────────────────────────────────────
|
||
|
||
function EnvToolRow({ item, onRemove }: { item: EnvToolItem; onRemove: () => void }) {
|
||
return (
|
||
<div className="flex items-center gap-3 px-3 py-2 rounded-lg bg-white border border-gray-100 hover:border-gray-200 group 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">{item.name}</span>
|
||
{item.source && (
|
||
<span className="text-xs px-1.5 py-0.5 rounded bg-slate-100 text-slate-500">
|
||
via {item.source}
|
||
</span>
|
||
)}
|
||
{item.version && (
|
||
<span className="text-xs text-gray-400 font-mono">{item.version}</span>
|
||
)}
|
||
</div>
|
||
{item.note && (
|
||
<p className="text-xs text-gray-400 mt-0.5">{item.note}</p>
|
||
)}
|
||
</div>
|
||
<button
|
||
onClick={onRemove}
|
||
className="opacity-0 group-hover:opacity-100 text-xs text-red-400 hover:text-red-600 transition-all shrink-0"
|
||
>
|
||
移除
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|