Redesign frontend
This commit is contained in:
@@ -1,52 +1,237 @@
|
||||
import { useState } from "react"
|
||||
import { ThemeProvider } from "next-themes"
|
||||
import { MatchTester } from "@/components/MatchTester"
|
||||
import { RuleEditor } from "@/components/RuleEditor"
|
||||
import { Toaster } from "@/components/ui/sonner"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
/**
|
||||
* @license
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import Header from './components/Header';
|
||||
import RuleManagement from './components/RuleManagement';
|
||||
import SandboxSimulation from './components/SandboxSimulation';
|
||||
import ToastContainer, { Toast } from './components/ToastContainer';
|
||||
import {
|
||||
ApiError,
|
||||
fetchCurrentRules,
|
||||
fetchRuleVersions,
|
||||
restoreRuleVersion,
|
||||
saveAndActivateRules,
|
||||
} from './api';
|
||||
import { Rule, RuleSet, RuleVersionSummary } from './types';
|
||||
|
||||
const TRANSFER_SET_ID = 'transfer';
|
||||
|
||||
function emptyTransferSet(): RuleSet {
|
||||
return {
|
||||
id: TRANSFER_SET_ID,
|
||||
name: '潜在转接',
|
||||
enabled: true,
|
||||
matcher: 'ac-keyword',
|
||||
rules: [],
|
||||
};
|
||||
}
|
||||
|
||||
function transferRules(ruleSets: RuleSet[]): Rule[] {
|
||||
return ruleSets.find(ruleSet => ruleSet.id === TRANSFER_SET_ID)?.rules ?? [];
|
||||
}
|
||||
|
||||
function replaceTransferRules(ruleSets: RuleSet[], rules: Rule[]): RuleSet[] {
|
||||
if (!ruleSets.some(ruleSet => ruleSet.id === TRANSFER_SET_ID)) {
|
||||
return [{ ...emptyTransferSet(), rules }, ...ruleSets];
|
||||
}
|
||||
return ruleSets.map(ruleSet =>
|
||||
ruleSet.id === TRANSFER_SET_ID ? { ...ruleSet, rules } : ruleSet
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [version, setVersion] = useState<number | null>(null)
|
||||
const [tab, setTab] = useState("rules")
|
||||
// Global Active Tab: 'rules' | 'sandbox'
|
||||
const [activeTab, setActiveTab] = useState<'rules' | 'sandbox'>('rules');
|
||||
|
||||
const [ruleSets, setRuleSets] = useState<RuleSet[]>([]);
|
||||
const [publishedRuleSets, setPublishedRuleSets] = useState<RuleSet[]>([]);
|
||||
const [publishedVersion, setPublishedVersion] = useState(0);
|
||||
const [loadingRules, setLoadingRules] = useState(true);
|
||||
const [savingRules, setSavingRules] = useState(false);
|
||||
const [versionHistory, setVersionHistory] = useState<RuleVersionSummary[]>([]);
|
||||
const [versionHistoryLoading, setVersionHistoryLoading] = useState(false);
|
||||
const [restoringVersion, setRestoringVersion] = useState<number | null>(null);
|
||||
|
||||
// Floating notifications
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
|
||||
// 1. Toast helper
|
||||
const addToast = (message: string, type: 'success' | 'warning' | 'info') => {
|
||||
const id = `toast-${Date.now()}-${Math.random().toString(36).substr(2, 5)}`;
|
||||
setToasts((prev) => [...prev, { id, message, type }]);
|
||||
|
||||
// Auto remove after 4.5 seconds
|
||||
setTimeout(() => {
|
||||
removeToast(id);
|
||||
}, 4500);
|
||||
};
|
||||
|
||||
const removeToast = (id: string) => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
};
|
||||
|
||||
// Load the active server-side snapshot. The backend is the source of truth.
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
void fetchCurrentRules()
|
||||
.then(data => {
|
||||
if (!active) return;
|
||||
setRuleSets(data.ruleSets);
|
||||
setPublishedRuleSets(data.ruleSets);
|
||||
setPublishedVersion(data.version);
|
||||
})
|
||||
.catch(error => {
|
||||
if (!active) return;
|
||||
const message = error instanceof ApiError ? error.message : '无法连接规则服务';
|
||||
addToast(`加载规则失败:${message}`, 'warning');
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) setLoadingRules(false);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleUpdateRules = (updatedRules: Rule[]) => {
|
||||
setRuleSets(current => replaceTransferRules(current, updatedRules));
|
||||
};
|
||||
|
||||
const isDirty = useMemo(() => {
|
||||
return JSON.stringify(ruleSets) !== JSON.stringify(publishedRuleSets);
|
||||
}, [ruleSets, publishedRuleSets]);
|
||||
|
||||
const handlePublish = async () => {
|
||||
if (!isDirty || savingRules || restoringVersion !== null) return;
|
||||
setSavingRules(true);
|
||||
try {
|
||||
const result = await saveAndActivateRules(publishedVersion, ruleSets);
|
||||
setRuleSets(result.ruleSets);
|
||||
setPublishedRuleSets(result.ruleSets);
|
||||
setPublishedVersion(result.version);
|
||||
addToast(`保存成功,监控规则 V${result.version} 已发布生效`, 'success');
|
||||
result.warnings.forEach(warning => addToast(warning, 'warning'));
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.body.code === 'RULE_VERSION_CONFLICT') {
|
||||
addToast(`规则版本冲突,服务端当前为 V${error.body.currentVersion ?? '?' },请刷新页面后重试`, 'warning');
|
||||
} else {
|
||||
const message = error instanceof ApiError ? error.message : '未知错误';
|
||||
addToast(`发布失败:${message}`, 'warning');
|
||||
}
|
||||
} finally {
|
||||
setSavingRules(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadVersionHistory = async () => {
|
||||
if (versionHistoryLoading) return;
|
||||
setVersionHistoryLoading(true);
|
||||
try {
|
||||
setVersionHistory(await fetchRuleVersions(20));
|
||||
} catch (error) {
|
||||
const message = error instanceof ApiError ? error.message : '未知错误';
|
||||
addToast(`加载历史版本失败:${message}`, 'warning');
|
||||
} finally {
|
||||
setVersionHistoryLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestoreVersion = async (sourceVersion: number) => {
|
||||
if (restoringVersion !== null || savingRules || sourceVersion === publishedVersion) return;
|
||||
const draftWarning = isDirty
|
||||
? '\n当前未发布的页面修改将被回溯结果覆盖。'
|
||||
: '';
|
||||
const confirmed = window.confirm(
|
||||
`确定回溯到 V${sourceVersion}?${draftWarning}\n系统不会删除历史版本,而是基于 V${sourceVersion} 创建并发布一个新版本。`
|
||||
);
|
||||
if (!confirmed) return;
|
||||
|
||||
setRestoringVersion(sourceVersion);
|
||||
try {
|
||||
const result = await restoreRuleVersion(sourceVersion, publishedVersion);
|
||||
setRuleSets(result.ruleSets);
|
||||
setPublishedRuleSets(result.ruleSets);
|
||||
setPublishedVersion(result.version);
|
||||
addToast(`已基于 V${sourceVersion} 创建并生效 V${result.version}`, 'success');
|
||||
result.warnings.forEach(warning => addToast(warning, 'warning'));
|
||||
void loadVersionHistory();
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.body.code === 'RULE_VERSION_CONFLICT') {
|
||||
addToast(`回溯失败:服务端当前已是 V${error.body.currentVersion ?? '?'},请重新打开版本菜单`, 'warning');
|
||||
void loadVersionHistory();
|
||||
} else {
|
||||
const message = error instanceof ApiError ? error.message : '未知错误';
|
||||
addToast(`回溯失败:${message}`, 'warning');
|
||||
}
|
||||
} finally {
|
||||
setRestoringVersion(null);
|
||||
}
|
||||
};
|
||||
|
||||
const rules = transferRules(ruleSets);
|
||||
const activeRules = transferRules(publishedRuleSets);
|
||||
|
||||
return (
|
||||
<ThemeProvider attribute="class" defaultTheme="light" enableSystem={false}>
|
||||
<div className="bg-background min-h-svh">
|
||||
<header className="border-b">
|
||||
<div className="mx-auto flex w-full max-w-5xl items-center justify-between gap-4 px-6 py-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold tracking-tight">
|
||||
ASR 事件监控配置
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
编辑潜在转接规则并立即生效
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{version == null ? "版本加载中…" : `当前生效版本 V${version}`}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div className="min-h-screen bg-slate-50 font-sans antialiased flex flex-col selection:bg-blue-100 selection:text-blue-900">
|
||||
{/* Dynamic Header */}
|
||||
<Header
|
||||
activeTab={activeTab}
|
||||
setActiveTab={setActiveTab}
|
||||
publishedVersion={publishedVersion}
|
||||
isDirty={isDirty}
|
||||
versionHistory={versionHistory}
|
||||
versionHistoryLoading={versionHistoryLoading}
|
||||
restoringVersion={restoringVersion}
|
||||
onLoadVersionHistory={() => void loadVersionHistory()}
|
||||
onRestoreVersion={(version) => void handleRestoreVersion(version)}
|
||||
/>
|
||||
|
||||
<main className="mx-auto w-full max-w-5xl px-6 py-6">
|
||||
<Tabs value={tab} onValueChange={setTab}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="rules">规则配置</TabsTrigger>
|
||||
<TabsTrigger value="demo">匹配验证</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="rules" className="mt-6">
|
||||
<RuleEditor
|
||||
onVersionChange={setVersion}
|
||||
onSaved={() => setTab("demo")}
|
||||
{/* Main Container Area */}
|
||||
<main className="flex-1 max-w-7xl w-full mx-auto px-4 py-6 sm:px-6 overflow-hidden">
|
||||
<AnimatePresence mode="wait">
|
||||
{activeTab === 'rules' ? (
|
||||
<motion.div
|
||||
key="rules-page"
|
||||
initial={{ opacity: 0, y: 15 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -15 }}
|
||||
transition={{ duration: 0.18, ease: 'easeOut' }}
|
||||
className="h-full"
|
||||
>
|
||||
<RuleManagement
|
||||
rules={rules}
|
||||
setRules={handleUpdateRules}
|
||||
onSavePublish={handlePublish}
|
||||
isDirty={isDirty}
|
||||
loading={loadingRules}
|
||||
saving={savingRules || restoringVersion !== null}
|
||||
addToast={addToast}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="demo" className="mt-6">
|
||||
<MatchTester activeVersion={version} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</main>
|
||||
</div>
|
||||
<Toaster richColors position="top-center" />
|
||||
</ThemeProvider>
|
||||
)
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="sandbox-page"
|
||||
initial={{ opacity: 0, y: 15 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -15 }}
|
||||
transition={{ duration: 0.18, ease: 'easeOut' }}
|
||||
className="h-full"
|
||||
>
|
||||
<SandboxSimulation
|
||||
rules={activeRules}
|
||||
addToast={addToast}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</main>
|
||||
|
||||
{/* Persistent Toast Notifications */}
|
||||
<ToastContainer toasts={toasts} removeToast={removeToast} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user