dev-manager-tauri/src/components/dashboard/ProjectEnvModal.tsx
lanrtop f74fe11145 fix(ui): 确认框在 Tauri 下被绕过——改用 await plugin-dialog confirm
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>
2026-09-30 13:48:54 +09:00

386 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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 { 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>
);
}