Files
2026-05-16 19:24:30 -05:00

333 lines
12 KiB
TypeScript

"use client";
import { AlertTriangle, History, Loader2, RefreshCw, RotateCcw, Sparkles } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
type Lang = "zh" | "en";
type ConfigIssue = {
level: string;
code: string;
message: string;
line?: number;
};
type ConfigStat = {
key: string;
label: string;
value: string;
};
type ConfigAction = {
id: string;
label: string;
description: string;
};
type ConfigBackup = {
id: string;
source: string;
reason: string;
label: string;
created_at: string;
size: number;
};
type ConfigInsights = {
kind?: string;
source?: string;
summary?: string;
issues?: ConfigIssue[];
stats?: ConfigStat[];
actions?: ConfigAction[];
backups?: ConfigBackup[];
};
type Props = {
lang: Lang;
rootId: string;
filePath: string;
fileContent: string;
refreshKey: number;
busy: string | null;
fetchJson: <T,>(path: string, init?: RequestInit) => Promise<T>;
notify: (message: string) => void;
onChangeContent: (content: string) => void;
onRefreshListing?: () => Promise<void> | void;
};
function tr(lang: Lang, zh: string, en: string) {
return lang === "zh" ? zh : en;
}
function formatBytes(size: number) {
if (!size) return "0 B";
const units = ["B", "KB", "MB", "GB"];
let value = size;
let unit = 0;
while (value >= 1024 && unit < units.length - 1) {
value /= 1024;
unit += 1;
}
return `${value.toFixed(value >= 10 || unit === 0 ? 0 : 1)} ${units[unit]}`;
}
function issueTone(level: string) {
if (level === "error") return "border-rose-500/30 bg-rose-500/10 text-rose-100";
if (level === "warning") return "border-amber-500/30 bg-amber-500/10 text-amber-100";
return "border-cyan-500/30 bg-cyan-500/10 text-cyan-100";
}
export function ConfigAdvisorPanel({
lang,
rootId,
filePath,
fileContent,
refreshKey,
busy,
fetchJson,
notify,
onChangeContent,
onRefreshListing,
}: Props) {
const [insights, setInsights] = useState<ConfigInsights | null>(null);
const [loading, setLoading] = useState(false);
const [draftLoading, setDraftLoading] = useState(false);
const [actionBusy, setActionBusy] = useState<string | null>(null);
const [restoreBusy, setRestoreBusy] = useState<string | null>(null);
const t = useCallback((zh: string, en: string) => tr(lang, zh, en), [lang]);
const loadSavedInsights = useCallback(async () => {
if (!rootId || !filePath) {
setInsights(null);
return;
}
setLoading(true);
try {
const params = new URLSearchParams({ root: rootId, path: filePath });
const data = await fetchJson<ConfigInsights>(`/config_insights?${params.toString()}`);
setInsights(data);
} catch (reason) {
setInsights(null);
notify((reason as Error).message);
} finally {
setLoading(false);
}
}, [fetchJson, filePath, notify, rootId]);
useEffect(() => {
void loadSavedInsights();
}, [loadSavedInsights, refreshKey]);
const analyzeDraft = async () => {
if (!rootId || !filePath) return;
setDraftLoading(true);
try {
const data = await fetchJson<ConfigInsights>("/config_insights", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ root: rootId, path: filePath, content: fileContent }),
});
setInsights(data);
notify(t("已分析当前编辑内容", "Analyzed current editor draft"));
} catch (reason) {
notify((reason as Error).message);
} finally {
setDraftLoading(false);
}
};
const applyAction = async (actionId: string) => {
if (!rootId || !filePath) return;
setActionBusy(actionId);
try {
const data = await fetchJson<{ message?: string; content?: string; insights?: ConfigInsights }>(
"/config_optimize",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ root: rootId, path: filePath, content: fileContent, action: actionId }),
},
);
if (typeof data.content === "string") onChangeContent(data.content);
if (data.insights) {
setInsights((prev) => ({ ...prev, ...data.insights, backups: prev?.backups || [] }));
}
notify(data.message || t("已应用优化", "Optimization applied"));
} catch (reason) {
notify((reason as Error).message);
} finally {
setActionBusy(null);
}
};
const restoreFromBackup = async (backupId: string) => {
if (!rootId || !filePath) return;
setRestoreBusy(backupId);
try {
const data = await fetchJson<{ message?: string; content?: string }>("/config_restore", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ root: rootId, path: filePath, backup_id: backupId }),
});
if (typeof data.content === "string") onChangeContent(data.content);
await Promise.resolve(onRefreshListing?.());
await loadSavedInsights();
notify(data.message || t("已恢复历史版本", "Backup restored"));
} catch (reason) {
notify((reason as Error).message);
} finally {
setRestoreBusy(null);
}
};
if (!filePath) {
return (
<div className="rounded-2xl border border-white/8 bg-black/20 p-4 text-sm text-slate-400">
{t("选择文件后显示诊断、回滚和一键优化。", "Pick a file to see diagnostics, rollback, and one-click optimizations.")}
</div>
);
}
const issues = insights?.issues || [];
const actions = insights?.actions || [];
const backups = insights?.backups || [];
const stats = insights?.stats || [];
return (
<div className="space-y-4">
<div className="rounded-2xl border border-cyan-500/15 bg-gradient-to-br from-cyan-500/[0.08] via-black/25 to-transparent p-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="flex items-center gap-2 text-white">
<Sparkles className="h-4 w-4 text-cyan-300" />
<h3 className="text-sm font-semibold">{t("配置治理", "Config governance")}</h3>
{insights?.source ? (
<span className="rounded-full border border-white/10 bg-black/25 px-2 py-0.5 text-[10px] uppercase tracking-wider text-slate-400">
{insights.source}
</span>
) : null}
</div>
<p className="mt-2 text-xs text-slate-400">
{insights?.summary ||
t("校验配置风险、查看历史快照,并将常见优化应用到编辑器。", "Validate risks, inspect snapshots, and apply optimizations into the editor.")}
</p>
</div>
<div className="flex flex-wrap gap-2">
<button
type="button"
disabled={!!busy || loading}
onClick={() => void loadSavedInsights()}
className="btn-focus inline-flex items-center gap-2 rounded-xl border border-white/10 bg-black/25 px-3 py-2 text-xs text-slate-100 disabled:opacity-40"
>
{loading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <RefreshCw className="h-3.5 w-3.5" />}
{t("刷新已保存状态", "Refresh saved state")}
</button>
<button
type="button"
disabled={!!busy || draftLoading}
onClick={() => void analyzeDraft()}
className="btn-focus inline-flex items-center gap-2 rounded-xl border border-cyan-500/30 bg-cyan-500/15 px-3 py-2 text-xs text-cyan-100 disabled:opacity-40"
>
{draftLoading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <AlertTriangle className="h-3.5 w-3.5" />}
{t("分析当前编辑器", "Analyze editor draft")}
</button>
</div>
</div>
<div className="mt-4 grid gap-2 sm:grid-cols-2 xl:grid-cols-4">
{stats.slice(0, 8).map((item) => (
<div key={item.key} className="rounded-xl border border-white/8 bg-black/20 px-3 py-2.5">
<div className="text-[10px] uppercase tracking-wider text-slate-500">{item.label}</div>
<div className="mt-1 truncate font-mono text-sm text-white">{item.value}</div>
</div>
))}
</div>
</div>
<div className="grid gap-4 xl:grid-cols-2">
<div className="rounded-2xl border border-white/8 bg-black/20 p-4">
<div className="flex items-center gap-2 text-white">
<AlertTriangle className="h-4 w-4 text-amber-300" />
<h4 className="text-sm font-semibold">{t("风险与建议", "Risks & guidance")}</h4>
</div>
{issues.length ? (
<div className="mt-3 space-y-2">
{issues.map((item, index) => (
<div key={`${item.code}-${index}`} className={`rounded-xl border px-3 py-2 text-xs ${issueTone(item.level)}`}>
<div className="font-medium">
{item.message}
{item.line ? ` (L${item.line})` : ""}
</div>
<div className="mt-1 font-mono opacity-70">{item.code}</div>
</div>
))}
</div>
) : (
<p className="mt-3 text-xs text-emerald-300/90">{t("当前没有发现明显配置风险。", "No obvious config risks were detected.")}</p>
)}
<div className="mt-4 flex flex-wrap gap-2">
{actions.length ? (
actions.map((item) => (
<button
key={item.id}
type="button"
disabled={!!busy || !!actionBusy}
onClick={() => void applyAction(item.id)}
className="btn-focus rounded-xl border border-violet-500/30 bg-violet-500/15 px-3 py-2 text-xs text-violet-100 disabled:opacity-40"
title={item.description}
>
{actionBusy === item.id ? t("处理中...", "Working...") : item.label}
</button>
))
) : (
<span className="text-xs text-slate-500">{t("当前文件没有可自动应用的预设。", "No auto-applicable presets for this file.")}</span>
)}
</div>
</div>
<div className="rounded-2xl border border-white/8 bg-black/20 p-4">
<div className="flex items-center gap-2 text-white">
<History className="h-4 w-4 text-cyan-300" />
<h4 className="text-sm font-semibold">{t("历史快照", "Backup snapshots")}</h4>
</div>
{backups.length ? (
<div className="mt-3 space-y-2">
{backups.slice(0, 8).map((item) => (
<div key={item.id} className="rounded-xl border border-white/8 bg-black/25 px-3 py-2.5">
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<div className="text-xs text-white">{new Date(item.created_at).toLocaleString(lang === "zh" ? "zh-CN" : "en-US", { hour12: false })}</div>
<div className="mt-1 text-[11px] text-slate-500">
{item.reason} · {formatBytes(item.size)}
{item.source === "legacy" ? ` · ${t("旧备份", "legacy")}` : ""}
</div>
</div>
<button
type="button"
disabled={!!busy || !!restoreBusy}
onClick={() => void restoreFromBackup(item.id)}
className="btn-focus inline-flex items-center gap-1 rounded-lg border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-slate-100 disabled:opacity-40"
>
{restoreBusy === item.id ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<RotateCcw className="h-3.5 w-3.5" />
)}
{t("恢复", "Restore")}
</button>
</div>
</div>
))}
</div>
) : (
<p className="mt-3 text-xs text-slate-500">{t("当前文件还没有 Web 侧快照。保存、删除或恢复后会自动累积历史。", "No Web snapshots yet. Saving, deleting, or restoring will accumulate history automatically.")}</p>
)}
</div>
</div>
</div>
);
}