333 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|