Redesign frontend

This commit is contained in:
Xin Wang
2026-07-16 13:58:44 +08:00
parent 69896460cb
commit af70358686
98 changed files with 880 additions and 11435 deletions

View File

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