From d093f95197758948f82196ea46b163bf1824233d Mon Sep 17 00:00:00 2001 From: Xin Wang Date: Thu, 16 Jul 2026 11:24:02 +0800 Subject: [PATCH] Add frontend app and redesign prototype for rule management UI. Co-authored-by: Cursor --- .gitignore | 5 + frontend-redesign/.env.example | 9 + frontend-redesign/.gitignore | 8 + frontend-redesign/README.md | 20 + frontend-redesign/index.html | 13 + frontend-redesign/metadata.json | 6 + frontend-redesign/package.json | 35 + frontend-redesign/patch.py | 25 + frontend-redesign/patch2.py | 11 + frontend-redesign/patch3.py | 10 + frontend-redesign/patch4.py | 19 + frontend-redesign/patch5.py | 11 + frontend-redesign/patch_buttons.py | 32 + frontend-redesign/patch_fetch.py | 26 + frontend-redesign/patch_header.py | 9 + frontend-redesign/patch_header_english.py | 11 + frontend-redesign/patch_header_online.py | 14 + frontend-redesign/patch_header_restore.py | 38 + frontend-redesign/patch_icons.py | 9 + frontend-redesign/patch_imports.py | 10 + frontend-redesign/patch_labels.py | 11 + frontend-redesign/patch_monitoring.py | 9 + frontend-redesign/patch_sandbox.py | 106 + frontend-redesign/patch_sandbox2.py | 7 + frontend-redesign/patch_sandbox3.py | 87 + frontend-redesign/patch_sandbox4.py | 18 + frontend-redesign/patch_sandbox5.py | 87 + frontend-redesign/patch_sandbox6.py | 44 + frontend-redesign/patch_sandbox7.py | 46 + frontend-redesign/patch_sandbox8.py | 55 + frontend-redesign/patch_severity.py | 16 + frontend-redesign/patch_severity2.py | 13 + frontend-redesign/patch_severity3.py | 13 + frontend-redesign/patch_toast.py | 9 + frontend-redesign/patch_types.py | 9 + frontend-redesign/src/App.tsx | 162 + frontend-redesign/src/components/Header.tsx | 91 + .../src/components/RuleManagement.tsx | 595 ++ .../src/components/SandboxSimulation.tsx | 657 ++ .../src/components/ToastContainer.tsx | 78 + frontend-redesign/src/data.ts | 72 + frontend-redesign/src/index.css | 43 + frontend-redesign/src/main.tsx | 10 + frontend-redesign/src/types.ts | 37 + frontend-redesign/temp.patch | 71 + frontend-redesign/tsconfig.json | 26 + frontend-redesign/vite.config.ts | 22 + frontend/.gitignore | 24 + frontend/README.md | 75 + frontend/components.json | 25 + frontend/eslint.config.js | 22 + frontend/index.html | 13 + frontend/npm-cn | 32 + frontend/package-lock.json | 8017 +++++++++++++++++ frontend/package.json | 42 + frontend/public/favicon.svg | 1 + frontend/public/icons.svg | 24 + frontend/src/App.tsx | 52 + frontend/src/api/rule-api.ts | 106 + frontend/src/assets/hero.png | Bin 0 -> 13057 bytes frontend/src/assets/react.svg | 1 + frontend/src/assets/vite.svg | 1 + frontend/src/components/MatchTester.tsx | 142 + frontend/src/components/RuleDialog.tsx | 199 + frontend/src/components/RuleEditor.tsx | 342 + frontend/src/components/ui/alert-dialog.tsx | 197 + frontend/src/components/ui/badge.tsx | 49 + frontend/src/components/ui/button.tsx | 67 + frontend/src/components/ui/card.tsx | 103 + frontend/src/components/ui/dialog.tsx | 168 + frontend/src/components/ui/input.tsx | 19 + frontend/src/components/ui/sonner.tsx | 47 + frontend/src/components/ui/switch.tsx | 33 + frontend/src/components/ui/tabs.tsx | 88 + frontend/src/components/ui/textarea.tsx | 18 + frontend/src/index.css | 130 + frontend/src/lib/utils.ts | 6 + frontend/src/main.tsx | 10 + frontend/tsconfig.app.json | 31 + frontend/tsconfig.json | 7 + frontend/tsconfig.node.json | 23 + frontend/vite.config.ts | 24 + 82 files changed, 12853 insertions(+) create mode 100644 frontend-redesign/.env.example create mode 100644 frontend-redesign/.gitignore create mode 100644 frontend-redesign/README.md create mode 100644 frontend-redesign/index.html create mode 100644 frontend-redesign/metadata.json create mode 100644 frontend-redesign/package.json create mode 100644 frontend-redesign/patch.py create mode 100644 frontend-redesign/patch2.py create mode 100644 frontend-redesign/patch3.py create mode 100644 frontend-redesign/patch4.py create mode 100644 frontend-redesign/patch5.py create mode 100644 frontend-redesign/patch_buttons.py create mode 100644 frontend-redesign/patch_fetch.py create mode 100644 frontend-redesign/patch_header.py create mode 100644 frontend-redesign/patch_header_english.py create mode 100644 frontend-redesign/patch_header_online.py create mode 100644 frontend-redesign/patch_header_restore.py create mode 100644 frontend-redesign/patch_icons.py create mode 100644 frontend-redesign/patch_imports.py create mode 100644 frontend-redesign/patch_labels.py create mode 100644 frontend-redesign/patch_monitoring.py create mode 100644 frontend-redesign/patch_sandbox.py create mode 100644 frontend-redesign/patch_sandbox2.py create mode 100644 frontend-redesign/patch_sandbox3.py create mode 100644 frontend-redesign/patch_sandbox4.py create mode 100644 frontend-redesign/patch_sandbox5.py create mode 100644 frontend-redesign/patch_sandbox6.py create mode 100644 frontend-redesign/patch_sandbox7.py create mode 100644 frontend-redesign/patch_sandbox8.py create mode 100644 frontend-redesign/patch_severity.py create mode 100644 frontend-redesign/patch_severity2.py create mode 100644 frontend-redesign/patch_severity3.py create mode 100644 frontend-redesign/patch_toast.py create mode 100644 frontend-redesign/patch_types.py create mode 100644 frontend-redesign/src/App.tsx create mode 100644 frontend-redesign/src/components/Header.tsx create mode 100644 frontend-redesign/src/components/RuleManagement.tsx create mode 100644 frontend-redesign/src/components/SandboxSimulation.tsx create mode 100644 frontend-redesign/src/components/ToastContainer.tsx create mode 100644 frontend-redesign/src/data.ts create mode 100644 frontend-redesign/src/index.css create mode 100644 frontend-redesign/src/main.tsx create mode 100644 frontend-redesign/src/types.ts create mode 100644 frontend-redesign/temp.patch create mode 100644 frontend-redesign/tsconfig.json create mode 100644 frontend-redesign/vite.config.ts create mode 100644 frontend/.gitignore create mode 100644 frontend/README.md create mode 100644 frontend/components.json create mode 100644 frontend/eslint.config.js create mode 100644 frontend/index.html create mode 100755 frontend/npm-cn create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/public/favicon.svg create mode 100644 frontend/public/icons.svg create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/api/rule-api.ts create mode 100644 frontend/src/assets/hero.png create mode 100644 frontend/src/assets/react.svg create mode 100644 frontend/src/assets/vite.svg create mode 100644 frontend/src/components/MatchTester.tsx create mode 100644 frontend/src/components/RuleDialog.tsx create mode 100644 frontend/src/components/RuleEditor.tsx create mode 100644 frontend/src/components/ui/alert-dialog.tsx create mode 100644 frontend/src/components/ui/badge.tsx create mode 100644 frontend/src/components/ui/button.tsx create mode 100644 frontend/src/components/ui/card.tsx create mode 100644 frontend/src/components/ui/dialog.tsx create mode 100644 frontend/src/components/ui/input.tsx create mode 100644 frontend/src/components/ui/sonner.tsx create mode 100644 frontend/src/components/ui/switch.tsx create mode 100644 frontend/src/components/ui/tabs.tsx create mode 100644 frontend/src/components/ui/textarea.tsx create mode 100644 frontend/src/index.css create mode 100644 frontend/src/lib/utils.ts create mode 100644 frontend/src/main.tsx create mode 100644 frontend/tsconfig.app.json create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.node.json create mode 100644 frontend/vite.config.ts diff --git a/.gitignore b/.gitignore index 1c4cf5c..0f1a1b8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,11 @@ HELP.md target/ data/ +/node_modules/ +/package.json +/package-lock.json +frontend/node_modules/ +frontend/dist/ .mvn/wrapper/maven-wrapper.jar !**/src/main/**/target/ !**/src/test/**/target/ diff --git a/frontend-redesign/.env.example b/frontend-redesign/.env.example new file mode 100644 index 0000000..7a550fe --- /dev/null +++ b/frontend-redesign/.env.example @@ -0,0 +1,9 @@ +# GEMINI_API_KEY: Required for Gemini AI API calls. +# AI Studio automatically injects this at runtime from user secrets. +# Users configure this via the Secrets panel in the AI Studio UI. +GEMINI_API_KEY="MY_GEMINI_API_KEY" + +# APP_URL: The URL where this applet is hosted. +# AI Studio automatically injects this at runtime with the Cloud Run service URL. +# Used for self-referential links, OAuth callbacks, and API endpoints. +APP_URL="MY_APP_URL" diff --git a/frontend-redesign/.gitignore b/frontend-redesign/.gitignore new file mode 100644 index 0000000..5a86d2a --- /dev/null +++ b/frontend-redesign/.gitignore @@ -0,0 +1,8 @@ +node_modules/ +build/ +dist/ +coverage/ +.DS_Store +*.log +.env* +!.env.example diff --git a/frontend-redesign/README.md b/frontend-redesign/README.md new file mode 100644 index 0000000..cc2c959 --- /dev/null +++ b/frontend-redesign/README.md @@ -0,0 +1,20 @@ +
+GHBanner +
+ +# Run and deploy your AI Studio app + +This contains everything you need to run your app locally. + +View your app in AI Studio: https://ai.studio/apps/da74db99-cca8-4fb2-ad63-46c938405741 + +## Run Locally + +**Prerequisites:** Node.js + + +1. Install dependencies: + `npm install` +2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key +3. Run the app: + `npm run dev` diff --git a/frontend-redesign/index.html b/frontend-redesign/index.html new file mode 100644 index 0000000..7aa6a97 --- /dev/null +++ b/frontend-redesign/index.html @@ -0,0 +1,13 @@ + + + + + + ASR 事件监控中心 + + +
+ + + + diff --git a/frontend-redesign/metadata.json b/frontend-redesign/metadata.json new file mode 100644 index 0000000..552f311 --- /dev/null +++ b/frontend-redesign/metadata.json @@ -0,0 +1,6 @@ +{ + "name": "ASR 事件监控中心", + "description": "极简、高效、响应式的 ASR 实时事件监控与沙盒模拟服务前端系统", + "requestFramePermissions": [], + "majorCapabilities": ["MAJOR_CAPABILITY_SERVER_SIDE_GEMINI_API"] +} diff --git a/frontend-redesign/package.json b/frontend-redesign/package.json new file mode 100644 index 0000000..44745ed --- /dev/null +++ b/frontend-redesign/package.json @@ -0,0 +1,35 @@ +{ + "name": "react-example", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite --port=3000 --host=0.0.0.0", + "build": "vite build", + "preview": "vite preview", + "clean": "rm -rf dist server.js", + "lint": "tsc --noEmit" + }, + "dependencies": { + "@google/genai": "^2.4.0", + "@tailwindcss/vite": "^4.1.14", + "@vitejs/plugin-react": "^5.0.4", + "lucide-react": "^0.546.0", + "react": "^19.0.1", + "react-dom": "^19.0.1", + "vite": "^6.2.3", + "express": "^4.21.2", + "dotenv": "^17.2.3", + "motion": "^12.23.24" + }, + "devDependencies": { + "@types/node": "^22.14.0", + "autoprefixer": "^10.4.21", + "esbuild": "^0.25.0", + "tailwindcss": "^4.1.14", + "tsx": "^4.21.0", + "typescript": "~5.8.2", + "vite": "^6.2.3", + "@types/express": "^4.17.21" + } +} diff --git a/frontend-redesign/patch.py b/frontend-redesign/patch.py new file mode 100644 index 0000000..0ddc546 --- /dev/null +++ b/frontend-redesign/patch.py @@ -0,0 +1,25 @@ +import re + +with open('src/components/RuleManagement.tsx', 'r') as f: + content = f.read() + +# 1. Imports +content = content.replace("import { Rule, SeverityType } from '../types';", "import { Rule } from '../types';") + +# 2. States and useMemo +content = re.sub(r"const \[selectedCategory, setSelectedCategory\] = useState\('all'\);\n", "", content) +content = re.sub(r"const \[formCategory, setFormCategory\] = useState\(''\);\n const \[formSeverity, setFormSeverity\] = useState\('medium'\);\n const \[formDescription, setFormDescription\] = useState\(''\);\n", "", content) +content = re.sub(r"// Available categories based on rules \+ standard ones\n const categories = useMemo\(\(\) => \{\n const list = new Set\(rules\.map\(r => r\.category\)\);\n return \['all', \.\.\.Array\.from\(list\)\];\n \}, \[rules\]\);\n", "", content) + +# 3. Filtered rules +content = re.sub(r"\|\|\n\s*\(rule\.description && rule\.description\.toLowerCase\(\)\.includes\(searchTerm\.toLowerCase\(\)\)\);", ";", content) +content = re.sub(r"const matchesCategory = selectedCategory === 'all' \|\| rule\.category === selectedCategory;\n\n return matchesSearch && matchesCategory;\n \}\);\n \}, \[rules, searchTerm, selectedCategory\]\);", "return matchesSearch;\n });\n }, [rules, searchTerm]);", content) + +# 4. Form state resets in handleAddRuleClick +content = re.sub(r"setFormCategory\('城市治理'\);\n setFormSeverity\('medium'\);\n setFormDescription\(''\);\n", "", content) + +# 5. Form state setting in handleEditRule +content = re.sub(r"setFormCategory\(rule\.category\);\n setFormSeverity\(rule\.severity\);\n setFormDescription\(rule\.description \|\| ''\);\n", "", content) + +with open('src/components/RuleManagement.tsx', 'w') as f: + f.write(content) diff --git a/frontend-redesign/patch2.py b/frontend-redesign/patch2.py new file mode 100644 index 0000000..2edd7e5 --- /dev/null +++ b/frontend-redesign/patch2.py @@ -0,0 +1,11 @@ +import re + +with open('src/components/RuleManagement.tsx', 'r') as f: + content = f.read() + +content = re.sub(r" if \(!formCategory\.trim\(\)\) \{\n errors\.category = '类别不能为空';\n \}\n", "", content) + +content = re.sub(r" enabled: editingRule \? editingRule\.enabled : true, // default enabled for new\n category: formCategory\.trim\(\),\n severity: formSeverity,\n description: formDescription\.trim\(\) \|\| undefined\n", " enabled: editingRule ? editingRule.enabled : true\n", content) + +with open('src/components/RuleManagement.tsx', 'w') as f: + f.write(content) diff --git a/frontend-redesign/patch3.py b/frontend-redesign/patch3.py new file mode 100644 index 0000000..d733367 --- /dev/null +++ b/frontend-redesign/patch3.py @@ -0,0 +1,10 @@ +import re + +with open('src/components/RuleManagement.tsx', 'r') as f: + content = f.read() + +# remove select category +content = re.sub(r"
\n \n \n 类别过滤:\n \n setSelectedCategory\(e\.target\.value\)\}\n className=\"rounded-md border border-slate-200 bg-white py-1\.5 px-3 text-xs text-slate-800 focus:border-slate-900 focus:outline-none cursor-pointer\"\n >\n \{categories\.map\(\(cat\) => \(\n \n \)\)\}\n \n
", "", content) + +with open('src/components/RuleManagement.tsx', 'w') as f: + f.write(content) diff --git a/frontend-redesign/patch4.py b/frontend-redesign/patch4.py new file mode 100644 index 0000000..92da48a --- /dev/null +++ b/frontend-redesign/patch4.py @@ -0,0 +1,19 @@ +import re + +with open('src/components/RuleManagement.tsx', 'r') as f: + content = f.read() + +# Headers +content = content.replace('类别\n 级别', '') + +# Body +# From `` of name to end of category and severity +body_pattern = r"\{/\* Category Tag \*/\}\n\s*\n\s*\n\s*\{rule\.category\}\n\s*\n\s*\n\s*\{/\* Severity level \*/\}\n\s*\n\s*\n\s*\n\s*\{rule\.severity === 'high' \? '最高' : rule\.severity === 'medium' \? '中级' : '普通'\}\n\s*\n\s*" + +content = re.sub(body_pattern, "", content) + +# Remove rule.description +content = re.sub(r"\n\s*\{rule\.description && \(\n\s*\n\s*\{rule\.description\}\n\s*\n\s*\)\}", "", content) + +with open('src/components/RuleManagement.tsx', 'w') as f: + f.write(content) diff --git a/frontend-redesign/patch5.py b/frontend-redesign/patch5.py new file mode 100644 index 0000000..e47aa2f --- /dev/null +++ b/frontend-redesign/patch5.py @@ -0,0 +1,11 @@ +import re + +with open('src/components/RuleManagement.tsx', 'r') as f: + content = f.read() + +# Category & Severity Row + Description Textarea +pattern = r"\{/\* Category & Severity Row \*/\}(.*?)\{/\* Key Tags Editor Input \*/\}" +content = re.sub(pattern, "{/* Key Tags Editor Input */}", content, flags=re.DOTALL) + +with open('src/components/RuleManagement.tsx', 'w') as f: + f.write(content) diff --git a/frontend-redesign/patch_buttons.py b/frontend-redesign/patch_buttons.py new file mode 100644 index 0000000..c1295ee --- /dev/null +++ b/frontend-redesign/patch_buttons.py @@ -0,0 +1,32 @@ +import re + +with open('src/components/RuleManagement.tsx', 'r') as f: + content = f.read() + +buttons_html = """ + + + + + + console.error('Failed to report ASR event:', err)); + + // Perform rule matching""" + +content = content.replace(" // Perform rule matching", replacement) + +with open('src/components/SandboxSimulation.tsx', 'w') as f: + f.write(content) diff --git a/frontend-redesign/patch_header.py b/frontend-redesign/patch_header.py new file mode 100644 index 0000000..eefe359 --- /dev/null +++ b/frontend-redesign/patch_header.py @@ -0,0 +1,9 @@ +import re + +with open('src/components/Header.tsx', 'r') as f: + content = f.read() + +content = content.replace(" ONLINE V{publishedVersion}", " ONLINE") + +with open('src/components/Header.tsx', 'w') as f: + f.write(content) diff --git a/frontend-redesign/patch_header_english.py b/frontend-redesign/patch_header_english.py new file mode 100644 index 0000000..ee49f9d --- /dev/null +++ b/frontend-redesign/patch_header_english.py @@ -0,0 +1,11 @@ +import re + +with open('src/components/Header.tsx', 'r') as f: + content = f.read() + +content = re.sub(r'\s*\s*Event Monitoring Center\s*', '', content) +content = re.sub(r'\s*Rule Management', '', content) +content = re.sub(r'\s*Sandbox Simulation', '', content) + +with open('src/components/Header.tsx', 'w') as f: + f.write(content) diff --git a/frontend-redesign/patch_header_online.py b/frontend-redesign/patch_header_online.py new file mode 100644 index 0000000..39530d4 --- /dev/null +++ b/frontend-redesign/patch_header_online.py @@ -0,0 +1,14 @@ +import re + +with open('src/components/Header.tsx', 'r') as f: + content = f.read() + +pattern = r"\s*\{/\* Right Section: Version Badge & Env Perceive \*/\}\s*
.*?
\s*\s*" +replacement = """ + + """ + +content = re.sub(pattern, replacement, content, flags=re.DOTALL) + +with open('src/components/Header.tsx', 'w') as f: + f.write(content) diff --git a/frontend-redesign/patch_header_restore.py b/frontend-redesign/patch_header_restore.py new file mode 100644 index 0000000..20dae00 --- /dev/null +++ b/frontend-redesign/patch_header_restore.py @@ -0,0 +1,38 @@ +import re + +with open('src/components/Header.tsx', 'r') as f: + content = f.read() + +replacement = """ + {/* Right Section: Version Badge & Env Perceive */} +
+
+ + + + + + 在线 + +
+ {isDirty && ( + + 待发布 + + )} +
+ + """ + +content = content.replace(" \n ", replacement) + +with open('src/components/Header.tsx', 'w') as f: + f.write(content) diff --git a/frontend-redesign/patch_icons.py b/frontend-redesign/patch_icons.py new file mode 100644 index 0000000..6f85488 --- /dev/null +++ b/frontend-redesign/patch_icons.py @@ -0,0 +1,9 @@ +import re + +with open('src/components/RuleManagement.tsx', 'r') as f: + content = f.read() + +content = content.replace(" Check\n} from 'lucide-react';", " Check,\n Upload,\n Download,\n FileDown\n} from 'lucide-react';") + +with open('src/components/RuleManagement.tsx', 'w') as f: + f.write(content) diff --git a/frontend-redesign/patch_imports.py b/frontend-redesign/patch_imports.py new file mode 100644 index 0000000..e08504f --- /dev/null +++ b/frontend-redesign/patch_imports.py @@ -0,0 +1,10 @@ +import re + +with open('src/components/SandboxSimulation.tsx', 'r') as f: + content = f.read() + +content = content.replace(" ShieldAlert, \n", "") +content = content.replace(" HelpCircle,\n", "") + +with open('src/components/SandboxSimulation.tsx', 'w') as f: + f.write(content) diff --git a/frontend-redesign/patch_labels.py b/frontend-redesign/patch_labels.py new file mode 100644 index 0000000..287b1f5 --- /dev/null +++ b/frontend-redesign/patch_labels.py @@ -0,0 +1,11 @@ +import re + +with open('src/components/SandboxSimulation.tsx', 'r') as f: + content = f.read() + +content = content.replace("ASR 听写会话语音流 (ASR Stream)", "ASR 听写会话语音流") +content = content.replace("新通话 (New)", "新通话") +content = content.replace("剧本连播模拟器 (Scenario Player)", "剧本连播模拟器") + +with open('src/components/SandboxSimulation.tsx', 'w') as f: + f.write(content) diff --git a/frontend-redesign/patch_monitoring.py b/frontend-redesign/patch_monitoring.py new file mode 100644 index 0000000..2ba7ee3 --- /dev/null +++ b/frontend-redesign/patch_monitoring.py @@ -0,0 +1,9 @@ +import re + +with open('src/components/SandboxSimulation.tsx', 'r') as f: + content = f.read() + +content = content.replace("实时预警流监控 (Event Monitoring)", "实时预警流监控") + +with open('src/components/SandboxSimulation.tsx', 'w') as f: + f.write(content) diff --git a/frontend-redesign/patch_sandbox.py b/frontend-redesign/patch_sandbox.py new file mode 100644 index 0000000..1924f11 --- /dev/null +++ b/frontend-redesign/patch_sandbox.py @@ -0,0 +1,106 @@ +import re + +with open('src/components/SandboxSimulation.tsx', 'r') as f: + content = f.read() + +# 1. remove interceptCount from initial states +content = re.sub(r" alerts: \[\],\n interceptCount: 0\n \}\);", " alerts: []\n });", content) +content = re.sub(r" alerts: \[\],\n interceptCount: 0\n \}\);", " alerts: []\n });", content) + +# 2. handleSendLine +old_handle = """ // Perform rule matching + let currentAlerts = [...prev.alerts]; + let silentCount = prev.interceptCount; + const activeRules = rules.filter(r => r.enabled); + + activeRules.forEach(rule => { + // Check if any keyword matches + const matchedKeyword = rule.keywords.find(kw => + text.toLowerCase().includes(kw.toLowerCase()) + ); + + if (matchedKeyword) { + // Check if this rule was already triggered in the current session + const alreadyTriggered = currentAlerts.some(a => a.ruleId === rule.id); + + if (alreadyTriggered) { + // Duplicate hit: silent interception + silentCount += 1; + // Silent notify + addToast(`规则「${rule.name}」在 seq: ${nextSeq} 中再次触发,已被系统去重静默拦截!`, 'info'); + } else { + // Fresh alert! + const alertId = `alert-${rule.id}-${Date.now()}`; + const newAlert: Alert = { + id: alertId, + ruleId: rule.id, + ruleName: rule.name, + keyword: matchedKeyword, + text: text.trim(), + seq: nextSeq, + timestamp, + }; + currentAlerts = [...currentAlerts, newAlert]; + addToast(`⚠️ 触发预警: 「${rule.name}」(命中词: ${matchedKeyword})`, 'warning'); + } + } + }); + + return { + ...prev, + lines: updatedLines, + alerts: currentAlerts, + interceptCount: silentCount + };""" + +new_handle = """ // Perform rule matching + let currentAlerts = [...prev.alerts]; + const activeRules = rules.filter(r => r.enabled); + + activeRules.forEach(rule => { + // Check if any keyword matches + const matchedKeyword = rule.keywords.find(kw => + text.toLowerCase().includes(kw.toLowerCase()) + ); + + if (matchedKeyword) { + // Fresh alert! + const alertId = `alert-${rule.id}-${Date.now()}`; + const newAlert: Alert = { + id: alertId, + ruleId: rule.id, + ruleName: rule.name, + keyword: matchedKeyword, + text: text.trim(), + seq: nextSeq, + timestamp, + }; + currentAlerts = [...currentAlerts, newAlert]; + addToast(`⚠️ 触发预警: 「${rule.name}」(命中词: ${matchedKeyword})`, 'warning'); + } + }); + + return { + ...prev, + lines: updatedLines, + alerts: currentAlerts + };""" + +content = content.replace(old_handle, new_handle) + +# 3. UI +ui_to_remove = """
+ 去重静默拦截: + 0 + ? 'bg-amber-100 text-amber-800' + : 'bg-slate-200 text-slate-600' + }`}> + {session.interceptCount} 次 + +
""" + +content = content.replace(ui_to_remove, "") + +with open('src/components/SandboxSimulation.tsx', 'w') as f: + f.write(content) diff --git a/frontend-redesign/patch_sandbox2.py b/frontend-redesign/patch_sandbox2.py new file mode 100644 index 0000000..7207986 --- /dev/null +++ b/frontend-redesign/patch_sandbox2.py @@ -0,0 +1,7 @@ +import re + +with open('src/components/SandboxSimulation.tsx', 'r') as f: + content = f.read() + +# Make sure interceptCount is completely gone +print("interceptCount" in content) diff --git a/frontend-redesign/patch_sandbox3.py b/frontend-redesign/patch_sandbox3.py new file mode 100644 index 0000000..a7c42cb --- /dev/null +++ b/frontend-redesign/patch_sandbox3.py @@ -0,0 +1,87 @@ +import re + +with open('src/components/SandboxSimulation.tsx', 'r') as f: + content = f.read() + +old_handle = """ // Perform rule matching + let currentAlerts = [...prev.alerts]; + let silentCount = prev.interceptCount; + const activeRules = rules.filter(r => r.enabled); + + activeRules.forEach(rule => { + // Check if any keyword matches + const matchedKeyword = rule.keywords.find(kw => + text.toLowerCase().includes(kw.toLowerCase()) + ); + + if (matchedKeyword) { + // Check if this rule was already triggered in the current session + const alreadyTriggered = currentAlerts.some(a => a.ruleId === rule.id); + + if (alreadyTriggered) { + // Duplicate hit: silent interception + silentCount += 1; + // Silent notify + addToast(`规则「${rule.name}」在 seq: ${nextSeq} 中再次触发,已被系统去重静默拦截!`, 'info'); + } else { + // Fresh alert! + const alertId = `alert-${rule.id}-${Date.now()}`; + const newAlert: Alert = { + id: alertId, + ruleId: rule.id, + ruleName: rule.name, + keyword: matchedKeyword, + text: text.trim(), + seq: nextSeq, + timestamp, + }; + currentAlerts = [...currentAlerts, newAlert]; + addToast(`⚠️ 触发预警: 「${rule.name}」(命中词: ${matchedKeyword})`, 'warning'); + } + } + }); + + return { + ...prev, + lines: updatedLines, + alerts: currentAlerts, + interceptCount: silentCount + };""" + +new_handle = """ // Perform rule matching + let currentAlerts = [...prev.alerts]; + const activeRules = rules.filter(r => r.enabled); + + activeRules.forEach(rule => { + // Check if any keyword matches + const matchedKeyword = rule.keywords.find(kw => + text.toLowerCase().includes(kw.toLowerCase()) + ); + + if (matchedKeyword) { + // Fresh alert! + const alertId = `alert-${rule.id}-${Date.now()}`; + const newAlert: Alert = { + id: alertId, + ruleId: rule.id, + ruleName: rule.name, + keyword: matchedKeyword, + text: text.trim(), + seq: nextSeq, + timestamp, + }; + currentAlerts = [...currentAlerts, newAlert]; + addToast(`⚠️ 触发预警: 「${rule.name}」(命中词: ${matchedKeyword})`, 'warning'); + } + }); + + return { + ...prev, + lines: updatedLines, + alerts: currentAlerts + };""" + +content = content.replace(old_handle, new_handle) + +with open('src/components/SandboxSimulation.tsx', 'w') as f: + f.write(content) diff --git a/frontend-redesign/patch_sandbox4.py b/frontend-redesign/patch_sandbox4.py new file mode 100644 index 0000000..5579a86 --- /dev/null +++ b/frontend-redesign/patch_sandbox4.py @@ -0,0 +1,18 @@ +import re + +with open('src/components/SandboxSimulation.tsx', 'r') as f: + content = f.read() + +pattern = r"\s*\{/\* Session level Silent Duplicate Deduplication Counter Status Bar \*/\}(.*?)\{/\* Sandbox system help documentation \*/\}(.*?)\n \n \n \);\n\}" + +replacement = r""" + + + + ); +}""" + +content = re.sub(pattern, replacement, content, flags=re.DOTALL) + +with open('src/components/SandboxSimulation.tsx', 'w') as f: + f.write(content) diff --git a/frontend-redesign/patch_sandbox5.py b/frontend-redesign/patch_sandbox5.py new file mode 100644 index 0000000..a7c42cb --- /dev/null +++ b/frontend-redesign/patch_sandbox5.py @@ -0,0 +1,87 @@ +import re + +with open('src/components/SandboxSimulation.tsx', 'r') as f: + content = f.read() + +old_handle = """ // Perform rule matching + let currentAlerts = [...prev.alerts]; + let silentCount = prev.interceptCount; + const activeRules = rules.filter(r => r.enabled); + + activeRules.forEach(rule => { + // Check if any keyword matches + const matchedKeyword = rule.keywords.find(kw => + text.toLowerCase().includes(kw.toLowerCase()) + ); + + if (matchedKeyword) { + // Check if this rule was already triggered in the current session + const alreadyTriggered = currentAlerts.some(a => a.ruleId === rule.id); + + if (alreadyTriggered) { + // Duplicate hit: silent interception + silentCount += 1; + // Silent notify + addToast(`规则「${rule.name}」在 seq: ${nextSeq} 中再次触发,已被系统去重静默拦截!`, 'info'); + } else { + // Fresh alert! + const alertId = `alert-${rule.id}-${Date.now()}`; + const newAlert: Alert = { + id: alertId, + ruleId: rule.id, + ruleName: rule.name, + keyword: matchedKeyword, + text: text.trim(), + seq: nextSeq, + timestamp, + }; + currentAlerts = [...currentAlerts, newAlert]; + addToast(`⚠️ 触发预警: 「${rule.name}」(命中词: ${matchedKeyword})`, 'warning'); + } + } + }); + + return { + ...prev, + lines: updatedLines, + alerts: currentAlerts, + interceptCount: silentCount + };""" + +new_handle = """ // Perform rule matching + let currentAlerts = [...prev.alerts]; + const activeRules = rules.filter(r => r.enabled); + + activeRules.forEach(rule => { + // Check if any keyword matches + const matchedKeyword = rule.keywords.find(kw => + text.toLowerCase().includes(kw.toLowerCase()) + ); + + if (matchedKeyword) { + // Fresh alert! + const alertId = `alert-${rule.id}-${Date.now()}`; + const newAlert: Alert = { + id: alertId, + ruleId: rule.id, + ruleName: rule.name, + keyword: matchedKeyword, + text: text.trim(), + seq: nextSeq, + timestamp, + }; + currentAlerts = [...currentAlerts, newAlert]; + addToast(`⚠️ 触发预警: 「${rule.name}」(命中词: ${matchedKeyword})`, 'warning'); + } + }); + + return { + ...prev, + lines: updatedLines, + alerts: currentAlerts + };""" + +content = content.replace(old_handle, new_handle) + +with open('src/components/SandboxSimulation.tsx', 'w') as f: + f.write(content) diff --git a/frontend-redesign/patch_sandbox6.py b/frontend-redesign/patch_sandbox6.py new file mode 100644 index 0000000..5a862db --- /dev/null +++ b/frontend-redesign/patch_sandbox6.py @@ -0,0 +1,44 @@ +import re + +with open('src/components/SandboxSimulation.tsx', 'r') as f: + content = f.read() + +pattern = r" // Perform rule matching\n let currentAlerts = \[\.\.\.prev\.alerts\];\n let silentCount = prev\.interceptCount;\n const activeRules = rules\.filter\(r => r\.enabled\);\n\n activeRules\.forEach\(rule => \{\n // Check if any keyword matches\n const matchedKeyword = rule\.keywords\.find\(kw => \n text\.toLowerCase\(\)\.includes\(kw\.toLowerCase\(\)\)\n \);\n\n if \(matchedKeyword\) \{\n // Check if this rule was already triggered in the current session\n const alreadyTriggered = currentAlerts\.some\(a => a\.ruleId === rule\.id\);\n\n if \(alreadyTriggered\) \{\n // Duplicate hit: silent interception\n silentCount \+= 1;\n // Silent notify\n addToast\(`规则「\$\{rule\.name\}」在 seq: \$\{nextSeq\} 中再次触发,已被系统去重静默拦截!`, 'info'\);\n \} else \{\n // Fresh alert!\n const alertId = `alert-\$\{rule\.id\}-\$\{Date\.now\(\)\}`;\n const newAlert: Alert = \{\n id: alertId,\n ruleId: rule\.id,\n ruleName: rule\.name,\n keyword: matchedKeyword,\n text: text\.trim\(\),\n seq: nextSeq,\n timestamp,\n \};\n currentAlerts = \[\.\.\.currentAlerts, newAlert\];\n addToast\(`⚠️ 触发预警: 「\$\{rule\.name\}」(命中词: \$\{matchedKeyword\})`, 'warning'\);\n \}\n \}\n \}\);\n\n return \{\n \.\.\.prev,\n lines: updatedLines,\n alerts: currentAlerts,\n interceptCount: silentCount\n \};" + +new_handle = """ // Perform rule matching + let currentAlerts = [...prev.alerts]; + const activeRules = rules.filter(r => r.enabled); + + activeRules.forEach(rule => { + // Check if any keyword matches + const matchedKeyword = rule.keywords.find(kw => + text.toLowerCase().includes(kw.toLowerCase()) + ); + + if (matchedKeyword) { + // Fresh alert! + const alertId = `alert-${rule.id}-${Date.now()}`; + const newAlert: Alert = { + id: alertId, + ruleId: rule.id, + ruleName: rule.name, + keyword: matchedKeyword, + text: text.trim(), + seq: nextSeq, + timestamp, + }; + currentAlerts = [...currentAlerts, newAlert]; + addToast(`⚠️ 触发预警: 「${rule.name}」(命中词: ${matchedKeyword})`, 'warning'); + } + }); + + return { + ...prev, + lines: updatedLines, + alerts: currentAlerts + };""" + +content = re.sub(pattern, new_handle, content, flags=re.DOTALL) + +with open('src/components/SandboxSimulation.tsx', 'w') as f: + f.write(content) diff --git a/frontend-redesign/patch_sandbox7.py b/frontend-redesign/patch_sandbox7.py new file mode 100644 index 0000000..d38aca9 --- /dev/null +++ b/frontend-redesign/patch_sandbox7.py @@ -0,0 +1,46 @@ +lines = [] +with open('src/components/SandboxSimulation.tsx', 'r') as f: + lines = f.readlines() + +out = [] +in_block = False +for line in lines: + if "// Perform rule matching" in line: + in_block = True + out.append(line) + out.append(" let currentAlerts = [...prev.alerts];\n") + out.append(" const activeRules = rules.filter(r => r.enabled);\n\n") + out.append(" activeRules.forEach(rule => {\n") + out.append(" // Check if any keyword matches\n") + out.append(" const matchedKeyword = rule.keywords.find(kw => \n") + out.append(" text.toLowerCase().includes(kw.toLowerCase())\n") + out.append(" );\n\n") + out.append(" if (matchedKeyword) {\n") + out.append(" // Fresh alert!\n") + out.append(" const alertId = `alert-${rule.id}-${Date.now()}`;\n") + out.append(" const newAlert: Alert = {\n") + out.append(" id: alertId,\n") + out.append(" ruleId: rule.id,\n") + out.append(" ruleName: rule.name,\n") + out.append(" keyword: matchedKeyword,\n") + out.append(" text: text.trim(),\n") + out.append(" seq: nextSeq,\n") + out.append(" timestamp,\n") + out.append(" };\n") + out.append(" currentAlerts = [...currentAlerts, newAlert];\n") + out.append(" addToast(`⚠️ 触发预警: 「${rule.name}」(命中词: ${matchedKeyword})`, 'warning');\n") + out.append(" }\n") + out.append(" });\n\n") + out.append(" return {\n") + out.append(" ...prev,\n") + out.append(" lines: updatedLines,\n") + out.append(" alerts: currentAlerts\n") + out.append(" };\n") + elif in_block and " });" in line: + in_block = False + out.append(line) + elif not in_block: + out.append(line) + +with open('src/components/SandboxSimulation.tsx', 'w') as f: + f.writelines(out) diff --git a/frontend-redesign/patch_sandbox8.py b/frontend-redesign/patch_sandbox8.py new file mode 100644 index 0000000..07f2450 --- /dev/null +++ b/frontend-redesign/patch_sandbox8.py @@ -0,0 +1,55 @@ +lines = [] +with open('src/components/SandboxSimulation.tsx', 'r') as f: + lines = f.readlines() + +for i, line in enumerate(lines): + if "// Perform rule matching" in line: + start_idx = i + break + +for i in range(start_idx, len(lines)): + if " // Send single manual input" in lines[i]: + end_idx = i + break + +replacement = """ // Perform rule matching + let currentAlerts = [...prev.alerts]; + const activeRules = rules.filter(r => r.enabled); + + activeRules.forEach(rule => { + // Check if any keyword matches + const matchedKeyword = rule.keywords.find(kw => + text.toLowerCase().includes(kw.toLowerCase()) + ); + + if (matchedKeyword) { + // Fresh alert! + const alertId = `alert-${rule.id}-${Date.now()}`; + const newAlert: Alert = { + id: alertId, + ruleId: rule.id, + ruleName: rule.name, + keyword: matchedKeyword, + text: text.trim(), + seq: nextSeq, + timestamp, + }; + currentAlerts = [...currentAlerts, newAlert]; + addToast(`⚠️ 触发预警: 「${rule.name}」(命中词: ${matchedKeyword})`, 'warning'); + } + }); + + return { + ...prev, + lines: updatedLines, + alerts: currentAlerts + }; + }); + }; + +""" + +out = "".join(lines[:start_idx]) + replacement + "".join(lines[end_idx:]) + +with open('src/components/SandboxSimulation.tsx', 'w') as f: + f.write(out) diff --git a/frontend-redesign/patch_severity.py b/frontend-redesign/patch_severity.py new file mode 100644 index 0000000..57c394f --- /dev/null +++ b/frontend-redesign/patch_severity.py @@ -0,0 +1,16 @@ +import re + +with open('src/components/SandboxSimulation.tsx', 'r') as f: + content = f.read() + +# Remove UI relying on severity +content = re.sub(r"className=\{\`flex h-3 w-3 shrink-0 items-center justify-center rounded-full \$\{\n\s*alert\.severity === 'high' \n\s*\? 'bg-red-100' \n\s*: alert\.severity === 'medium' \n\s*\? 'bg-amber-100' \n\s*: 'bg-slate-200'\n\s*\}\`\}", 'className="flex h-3 w-3 shrink-0 items-center justify-center rounded-full bg-amber-100"', content) + +content = re.sub(r"className=\{\`absolute left-3 top-3 h-full w-0\.5 \$\{\n\s*alert\.severity === 'high' \n\s*\? 'bg-red-200' \n\s*: alert\.severity === 'medium' \n\s*\? 'bg-amber-200' \n\s*: 'bg-slate-200'\n\s*\}\`\}", 'className="absolute left-3 top-3 h-full w-0.5 bg-amber-200"', content) + +content = re.sub(r"className=\{\`px-1\.5 py-0\.5 rounded text-\[9px\] font-bold uppercase tracking-widest \$\{\n\s*alert\.severity === 'high' \n\s*\? 'bg-red-50 text-red-700' \n\s*: alert\.severity === 'medium' \n\s*\? 'bg-amber-50 text-amber-700' \n\s*: 'bg-slate-100 text-slate-600'\n\s*\}\`\}", 'className="px-1.5 py-0.5 rounded text-[9px] font-bold uppercase tracking-widest bg-amber-50 text-amber-700"', content) + +content = re.sub(r"\{alert\.severity === 'high' \? '最高警情' : alert\.severity === 'medium' \? '中级警情' : '普通提醒'\}", "{'命中提示'}", content) + +with open('src/components/SandboxSimulation.tsx', 'w') as f: + f.write(content) diff --git a/frontend-redesign/patch_severity2.py b/frontend-redesign/patch_severity2.py new file mode 100644 index 0000000..ebd10e3 --- /dev/null +++ b/frontend-redesign/patch_severity2.py @@ -0,0 +1,13 @@ +import re + +with open('src/components/SandboxSimulation.tsx', 'r') as f: + content = f.read() + +content = re.sub(r"className=\{\`flex h-3 w-3 shrink-0 items-center justify-center rounded-full \$\{\s*alert\.severity === 'high'\s*\? 'bg-red-100'\s*: alert\.severity === 'medium'\s*\? 'bg-amber-100'\s*: 'bg-slate-200'\s*\}\`\}", 'className="flex h-3 w-3 shrink-0 items-center justify-center rounded-full bg-amber-100"', content) + +content = re.sub(r"className=\{\`absolute left-3 top-3 h-full w-0\.5 \$\{\s*alert\.severity === 'high'\s*\? 'bg-red-200'\s*: alert\.severity === 'medium'\s*\? 'bg-amber-200'\s*: 'bg-slate-200'\s*\}\`\}", 'className="absolute left-3 top-3 h-full w-0.5 bg-amber-200"', content) + +content = re.sub(r"className=\{\`px-1\.5 py-0\.5 rounded text-\[9px\] font-bold uppercase tracking-widest \$\{\s*alert\.severity === 'high'\s*\? 'bg-red-50 text-red-700'\s*: alert\.severity === 'medium'\s*\? 'bg-amber-50 text-amber-700'\s*: 'bg-slate-100 text-slate-600'\s*\}\`\}", 'className="px-1.5 py-0.5 rounded text-[9px] font-bold uppercase tracking-widest bg-amber-50 text-amber-700"', content) + +with open('src/components/SandboxSimulation.tsx', 'w') as f: + f.write(content) diff --git a/frontend-redesign/patch_severity3.py b/frontend-redesign/patch_severity3.py new file mode 100644 index 0000000..44e8e71 --- /dev/null +++ b/frontend-redesign/patch_severity3.py @@ -0,0 +1,13 @@ +import re + +with open('src/components/SandboxSimulation.tsx', 'r') as f: + content = f.read() + +content = re.sub(r"className=\{\`absolute -left-\[25px\] top-1 h-4 w-4 rounded-full border bg-white flex items-center justify-center \$\{\s*alert\.severity === 'high'\s*\? 'border-red-500 text-red-500'\s*: alert\.severity === 'medium'\s*\? 'border-amber-500 text-amber-500'\s*: 'border-slate-500 text-slate-500'\s*\}\`\}", 'className="absolute -left-[25px] top-1 h-4 w-4 rounded-full border bg-white flex items-center justify-center border-amber-500 text-amber-500"', content) + +content = re.sub(r"className=\{\`rounded-md border-l-4 bg-white p-3\.5 shadow-2xs border-slate-200 border transition hover:shadow-xs \$\{\s*alert\.severity === 'high'\s*\? 'border-l-red-500'\s*: alert\.severity === 'medium'\s*\? 'border-l-amber-500'\s*: 'border-l-slate-600'\s*\}\`\}", 'className="rounded-md border-l-4 bg-white p-3.5 shadow-2xs border-slate-200 border transition hover:shadow-xs border-l-amber-500"', content) + +content = re.sub(r"className=\{\`rounded-sm px-1\.5 py-0\.5 text-\[9px\] font-bold uppercase tracking-wider border \$\{\s*alert\.severity === 'high'\s*\? 'bg-red-50 text-red-800 border-red-100'\s*: alert\.severity === 'medium'\s*\? 'bg-amber-50 text-amber-800 border-amber-100'\s*: 'bg-slate-50 text-slate-800 border-slate-200'\s*\}\`\}", 'className="rounded-sm px-1.5 py-0.5 text-[9px] font-bold uppercase tracking-wider border bg-amber-50 text-amber-800 border-amber-100"', content) + +with open('src/components/SandboxSimulation.tsx', 'w') as f: + f.write(content) diff --git a/frontend-redesign/patch_toast.py b/frontend-redesign/patch_toast.py new file mode 100644 index 0000000..65c13a6 --- /dev/null +++ b/frontend-redesign/patch_toast.py @@ -0,0 +1,9 @@ +import re + +with open('src/App.tsx', 'r') as f: + content = f.read() + +content = content.replace(":V${nextVersion}", "") + +with open('src/App.tsx', 'w') as f: + f.write(content) diff --git a/frontend-redesign/patch_types.py b/frontend-redesign/patch_types.py new file mode 100644 index 0000000..1148703 --- /dev/null +++ b/frontend-redesign/patch_types.py @@ -0,0 +1,9 @@ +import re + +with open('src/types.ts', 'r') as f: + content = f.read() + +content = content.replace(" interceptCount: number; // Silent intercept count for duplicates", "") + +with open('src/types.ts', 'w') as f: + f.write(content) diff --git a/frontend-redesign/src/App.tsx b/frontend-redesign/src/App.tsx new file mode 100644 index 0000000..8a89eb0 --- /dev/null +++ b/frontend-redesign/src/App.tsx @@ -0,0 +1,162 @@ +/** + * @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 { Rule } from './types'; +import { INITIAL_RULES } from './data'; + +export default function App() { + // Global Active Tab: 'rules' | 'sandbox' + const [activeTab, setActiveTab] = useState<'rules' | 'sandbox'>('rules'); + + // Rules State: Working copy + const [rules, setRules] = useState([]); + + // Rules State: Published snapshot + const [publishedRules, setPublishedRules] = useState([]); + + // Version counter state + const [publishedVersion, setPublishedVersion] = useState(6); + + // Floating notifications + const [toasts, setToasts] = useState([]); + + // 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)); + }; + + // 2. Load initially from localStorage + useEffect(() => { + try { + const storedRules = localStorage.getItem('asr_rules_working'); + const storedPublished = localStorage.getItem('asr_rules_published'); + const storedVersion = localStorage.getItem('asr_rules_version'); + + if (storedRules && storedPublished) { + setRules(JSON.parse(storedRules)); + setPublishedRules(JSON.parse(storedPublished)); + } else { + // First-time load: Seed initial rules + setRules(INITIAL_RULES); + setPublishedRules(INITIAL_RULES); + localStorage.setItem('asr_rules_working', JSON.stringify(INITIAL_RULES)); + localStorage.setItem('asr_rules_published', JSON.stringify(INITIAL_RULES)); + } + + if (storedVersion) { + setPublishedVersion(Number(storedVersion)); + } else { + localStorage.setItem('asr_rules_version', '6'); + } + } catch (e) { + console.error('Failed to parse storage rules', e); + setRules(INITIAL_RULES); + setPublishedRules(INITIAL_RULES); + } + }, []); + + // 3. Save working rules to localStorage whenever they change + const handleUpdateRules = (updatedRules: Rule[]) => { + setRules(updatedRules); + try { + localStorage.setItem('asr_rules_working', JSON.stringify(updatedRules)); + } catch (e) { + console.error('Failed to write rules to local storage', e); + } + }; + + // 4. Dirty tracking: compare working rules vs published rules + const isDirty = useMemo(() => { + if (rules.length === 0 && publishedRules.length === 0) return false; + return JSON.stringify(rules) !== JSON.stringify(publishedRules); + }, [rules, publishedRules]); + + // 5. Publish action: increment version, update snapshot + const handlePublish = () => { + if (!isDirty) return; + + const nextVersion = publishedVersion + 1; + setPublishedVersion(nextVersion); + setPublishedRules(rules); + + try { + localStorage.setItem('asr_rules_published', JSON.stringify(rules)); + localStorage.setItem('asr_rules_version', String(nextVersion)); + addToast(`🎉 保存成功!当前监控规则版本已发布并上线生效`, 'success'); + } catch (e) { + console.error('Failed to publish rules', e); + addToast('发布规则时写入本地存储失败!', 'warning'); + } + }; + + return ( +
+ {/* Dynamic Header */} +
+ + {/* Main Container Area */} +
+ + {activeTab === 'rules' ? ( + + + + ) : ( + + + + )} + +
+ + {/* Persistent Toast Notifications */} + +
+ ); +} diff --git a/frontend-redesign/src/components/Header.tsx b/frontend-redesign/src/components/Header.tsx new file mode 100644 index 0000000..dbe8c75 --- /dev/null +++ b/frontend-redesign/src/components/Header.tsx @@ -0,0 +1,91 @@ +/** + * @license + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { Volume2 } from 'lucide-react'; + +interface HeaderProps { + activeTab: 'rules' | 'sandbox'; + setActiveTab: (tab: 'rules' | 'sandbox') => void; + publishedVersion: number; + isDirty: boolean; +} + +export default function Header({ activeTab, setActiveTab, publishedVersion, isDirty }: HeaderProps) { + return ( +
+
+ {/* Left Section: Brand Logo */} +
+
+ ASR +
+
+ + ASR 事件监控中心 + +
+
+ + {/* Center Section: Navigation Menu */} + + + {/* Right Section: Version Badge & Env Perceive */} +
+
+ + + + + + 在线 + +
+ {isDirty && ( + + 待发布 + + )} +
+
+
+ ); +} diff --git a/frontend-redesign/src/components/RuleManagement.tsx b/frontend-redesign/src/components/RuleManagement.tsx new file mode 100644 index 0000000..2465300 --- /dev/null +++ b/frontend-redesign/src/components/RuleManagement.tsx @@ -0,0 +1,595 @@ +/** + * @license + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useState, useMemo, useRef, useEffect } from 'react'; +import { AnimatePresence, motion } from 'motion/react'; +import { + Plus, + Save, + Search, + X, + Edit, + Trash2, + AlertCircle, + SlidersHorizontal, + ChevronDown, + Info, + Check, + Upload, + Download, + FileDown +} from 'lucide-react'; +import { Rule } from '../types'; + +interface RuleManagementProps { + rules: Rule[]; + setRules: (rules: Rule[]) => void; + onSavePublish: () => void; + isDirty: boolean; + addToast: (message: string, type: 'success' | 'warning' | 'info') => void; +} + +export default function RuleManagement({ + rules, + setRules, + onSavePublish, + isDirty, + addToast +}: RuleManagementProps) { + const [searchTerm, setSearchTerm] = useState(''); + + // Drawer state + const [isDrawerOpen, setIsDrawerOpen] = useState(false); + const [editingRule, setEditingRule] = useState(null); + + // Form states inside drawer + const [formId, setFormId] = useState(''); + const [formName, setFormName] = useState(''); + const [keywordInput, setKeywordInput] = useState(''); + const [formKeywords, setFormKeywords] = useState([]); + const [formErrors, setFormErrors] = useState>({}); + + // Tag editor input ref + const tagInputRef = useRef(null); + + + // Statistics + const totalKeywords = useMemo(() => { + return rules.reduce((sum, r) => sum + r.keywords.length, 0); + }, [rules]); + + const activeRulesCount = useMemo(() => { + return rules.filter(r => r.enabled).length; + }, [rules]); + + // Filtered Rules + const filteredRules = useMemo(() => { + return rules.filter(rule => { + const matchesSearch = + rule.name.toLowerCase().includes(searchTerm.toLowerCase()) || + rule.id.toLowerCase().includes(searchTerm.toLowerCase()) || + rule.keywords.some(k => k.toLowerCase().includes(searchTerm.toLowerCase())) ; + + return matchesSearch; + }); + }, [rules, searchTerm]); + + // Open Drawer for Add + const handleAddRuleClick = () => { + setEditingRule(null); + setFormId(''); + setFormName(''); + setFormKeywords([]); + setKeywordInput(''); + setFormErrors({}); + setIsDrawerOpen(true); + }; + + // Open Drawer for Edit + const handleEditRuleClick = (rule: Rule) => { + setEditingRule(rule); + setFormId(rule.id); + setFormName(rule.name); + setFormKeywords([...rule.keywords]); + setKeywordInput(''); + setFormErrors({}); + setIsDrawerOpen(true); + }; + + // Delete Rule + const handleDeleteRule = (id: string, name: string) => { + if (window.confirm(`确定要删除规则「${name}」吗?`)) { + setRules(rules.filter(r => r.id !== id)); + addToast(`规则 「${name}」已本地移除,别忘了点击「保存并发布」使其生效。`, 'warning'); + } + }; + + // Switch status trigger + const handleToggleStatus = (id: string, currentStatus: boolean, name: string) => { + setRules( + rules.map(r => r.id === id ? { ...r, enabled: !currentStatus } : r) + ); + addToast( + `规则「${name}」已${!currentStatus ? '启用' : '停用'},发布后生效。`, + !currentStatus ? 'success' : 'info' + ); + }; + + // Tag editor utilities + const handleAddKeyword = (e?: React.KeyboardEvent) => { + if (e) { + if (e.key !== 'Enter' && e.key !== ',' && e.key !== ' ') return; + e.preventDefault(); + } + + const val = keywordInput.trim().replace(/,/g, ''); + if (!val) return; + + if (formKeywords.includes(val)) { + setFormErrors(prev => ({ ...prev, keywords: '关键词已存在' })); + return; + } + + setFormKeywords([...formKeywords, val]); + setKeywordInput(''); + setFormErrors(prev => { + const copy = { ...prev }; + delete copy.keywords; + return copy; + }); + }; + + const handleRemoveKeyword = (indexToRemove: number) => { + setFormKeywords(formKeywords.filter((_, idx) => idx !== indexToRemove)); + }; + + // Form submit in Drawer + const handleSaveForm = (e: React.FormEvent) => { + e.preventDefault(); + const errors: Record = {}; + + if (!formId.trim()) { + errors.id = '事件 ID 不能为空'; + } else if (!/^[a-zA-Z0-9_-]+$/.test(formId)) { + errors.id = '事件 ID 仅支持英文、数字、中划线和下划线'; + } else if (!editingRule && rules.some(r => r.id === formId.trim())) { + errors.id = '此事件 ID 已存在'; + } + + if (!formName.trim()) { + errors.name = '事件名称不能为空'; + } + + + if (formKeywords.length === 0) { + errors.keywords = '请至少添加一个监控关键词'; + } + + if (Object.keys(errors).length > 0) { + setFormErrors(errors); + return; + } + + const newRule: Rule = { + id: formId.trim(), + name: formName.trim(), + keywords: formKeywords, + enabled: editingRule ? editingRule.enabled : true + }; + + if (editingRule) { + // update + setRules(rules.map(r => r.id === editingRule.id ? newRule : r)); + addToast(`规则「${newRule.name}」已更新,需点击「保存并发布」发布至线上。`, 'success'); + } else { + // create + setRules([newRule, ...rules]); + addToast(`新规则「${newRule.name}」已添加,需点击「保存并发布」发布至线上。`, 'success'); + } + + setIsDrawerOpen(false); + }; + + // Keyword show tooltips state + const [expandedKeywordsRuleId, setExpandedKeywordsRuleId] = useState(null); + + return ( +
+ {/* 1. Header Toolbar area */} +
+
+

+ 监控规则库 +

+

+ 共 {rules.length} 项特征规则,当前已启用{' '} + {activeRulesCount} 项,匹配特征词累计{' '} + {totalKeywords} 个。 +

+
+ +
+ + + + + + + + + +
+
+ + {/* 2. Searching & Filter Options Bar */} +
+
+ + setSearchTerm(e.target.value)} + className="w-full rounded-md border border-slate-200 bg-white py-1.5 pr-4 pl-9 text-xs text-slate-900 placeholder-slate-400 focus:border-slate-900 focus:outline-none" + /> + {searchTerm && ( + + )} +
+ + +
+ + {/* 3. High Density Data Table */} +
+
+ + + + + + + + + + + + + {filteredRules.length > 0 ? ( + filteredRules.map((rule) => { + const hasManyKeywords = rule.keywords.length > 5; + const isKeywordsExpanded = expandedKeywordsRuleId === rule.id; + const visibleKeywords = isKeywordsExpanded + ? rule.keywords + : rule.keywords.slice(0, 5); + + return ( + + {/* Event ID */} + + + {/* Event Name & Description */} + + + + + {/* Keywords Badges */} + + + {/* Status switch toggle */} + + + {/* Action buttons */} + + + ); + }) + ) : ( + + + + )} + +
事件 ID特征规则名称监控特征关键词状态操作
+ {rule.id} + +
+ + {rule.name} + +
+
+
+ {visibleKeywords.map((keyword, index) => ( + + {keyword} + + ))} + + {hasManyKeywords && ( + + )} +
+
+ +
+ + +
+
+
+ +

未检索到任何匹配规则

+

请清除筛选条件或重置搜索词。

+
+
+
+
+ + {/* 4. Sliding Right Drawer for Creating/Editing Rules */} + + {isDrawerOpen && ( + <> + {/* Dark Overlay Backdrop */} + setIsDrawerOpen(false)} + className="fixed inset-0 z-50 bg-slate-950 backdrop-blur-3xs" + /> + + {/* Sliding Panel */} + + {/* Drawer Header */} +
+
+

+ {editingRule ? '编辑特征规则' : '新增特征规则'} +

+

+ {editingRule ? `ID: ${formId}` : '创建特征文本提取条件以过滤并识别 ASR 事件语音。'} +

+
+ +
+ + {/* Drawer Scroll Body */} +
+ {/* Rule ID Field (disabled if editing) */} +
+ + setFormId(e.target.value)} + className={`w-full rounded border py-1.5 px-3 text-xs font-mono placeholder-slate-400 focus:outline-none transition ${ + editingRule + ? 'bg-slate-100 text-slate-400 border-slate-200 cursor-not-allowed' + : formErrors.id + ? 'border-rose-300 focus:border-rose-500' + : 'border-slate-200 focus:border-slate-900' + }`} + /> + {formErrors.id ? ( +

+ + {formErrors.id} +

+ ) : ( + !editingRule && ( +

+ 仅支持字母、数字、短横线与下划线,一经创建无法重置 +

+ ) + )} +
+ + {/* Event Name Field */} +
+ + setFormName(e.target.value)} + className={`w-full rounded border py-1.5 px-3 text-xs placeholder-slate-400 focus:outline-none transition ${ + formErrors.name + ? 'border-rose-300 focus:border-rose-500' + : 'border-slate-200 focus:border-slate-900' + }`} + /> + {formErrors.name && ( +

+ + {formErrors.name} +

+ )} +
+ + {/* Key Tags Editor Input */} +
+ + + {/* Tag Input Field Container */} +
+
+ {/* Interactive pill tags */} + {formKeywords.map((tag, idx) => ( + + {tag} + + + ))} + + {/* Actual Input */} + setKeywordInput(e.target.value)} + onKeyDown={handleAddKeyword} + onBlur={() => handleAddKeyword()} + className="flex-1 min-w-[120px] bg-transparent text-xs text-slate-900 focus:outline-none py-0.5" + /> +
+ + {formErrors.keywords && ( +

+ + {formErrors.keywords} +

+ )} + +
+ +
+

过滤提取逻辑须知

+

1. 只要 ASR 语句文本中「完整包含」此处设定的任一触发词,即瞬时抛出预警卡片。

+

2. 特征词需精简准确,避免设置过短或通用的词(例如“有”、“去”),以免大面积噪点干扰。

+
+
+
+
+
+ + {/* Drawer Footer Actions */} +
+ + +
+
+ + )} +
+
+ ); +} diff --git a/frontend-redesign/src/components/SandboxSimulation.tsx b/frontend-redesign/src/components/SandboxSimulation.tsx new file mode 100644 index 0000000..0882f8c --- /dev/null +++ b/frontend-redesign/src/components/SandboxSimulation.tsx @@ -0,0 +1,657 @@ +/** + * @license + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useState, useEffect, useRef, useMemo } from 'react'; +import { + Play, + Square, + RotateCcw, + Send, + Sparkles, + User, + Headphones, + Bell, + FileText, + VolumeX, + Radio, + Clock, + ArrowDownCircle, + ListRestart +} from 'lucide-react'; +import { Rule, DialogLine, Alert, SandboxSession } from '../types'; +import { PRESET_SCRIPTS } from '../data'; + +interface SandboxSimulationProps { + rules: Rule[]; + addToast: (message: string, type: 'success' | 'warning' | 'info') => void; +} + +export default function SandboxSimulation({ rules, addToast }: SandboxSimulationProps) { + // Session State + const [session, setSession] = useState({ + callId: '', + status: 'idle', + lines: [], + alerts: [] + }); + + // Current controls + const [activeSpeaker, setActiveSpeaker] = useState<'citizen' | 'agent'>('citizen'); + const [singleInput, setSingleInput] = useState(''); + + // Custom Script playback states + const [scriptText, setScriptText] = useState(PRESET_SCRIPTS[0].lines); + const [isPlayingScript, setIsPlayingScript] = useState(false); + const [scriptLinesQueue, setScriptLinesQueue] = useState([]); + const [playbackSpeedMs, setPlaybackSpeedMs] = useState(1500); + + // Refs for scrolling + const chatScrollRef = useRef(null); + const alertScrollRef = useRef(null); + const timerRef = useRef(null); + + // Generate an elegant Call ID + const generateCallId = () => { + const prefix = 'ASR-CALL'; + const rand = Math.floor(10000000 + Math.random() * 90000000); + return `${prefix}-${rand}`; + }; + + // Start fresh call + const handleStartNewCall = () => { + // Clear playback timer if active + if (timerRef.current) { + clearInterval(timerRef.current); + timerRef.current = null; + } + + const newId = generateCallId(); + setSession({ + callId: newId, + status: 'listening', + lines: [], + alerts: [] + }); + setIsPlayingScript(false); + setScriptLinesQueue([]); + addToast(`通话重置。已创建新会话: ${newId}`, 'info'); + }; + + // On mount, auto-generate call if none + useEffect(() => { + if (!session.callId) { + handleStartNewCall(); + } + return () => { + if (timerRef.current) clearInterval(timerRef.current); + }; + }, []); + + // Scroll to bottom on updates + useEffect(() => { + if (chatScrollRef.current) { + chatScrollRef.current.scrollTop = chatScrollRef.current.scrollHeight; + } + }, [session.lines]); + + useEffect(() => { + if (alertScrollRef.current) { + alertScrollRef.current.scrollTop = alertScrollRef.current.scrollHeight; + } + }, [session.alerts]); + + // Core sentence processing logic + const processSentence = (role: 'citizen' | 'agent', text: string) => { + if (!text.trim()) return; + + setSession(prev => { + const nextSeq = prev.lines.length + 1; + const now = new Date(); + const timestamp = now.toTimeString().split(' ')[0]; + + const newLine: DialogLine = { + id: `line-${nextSeq}-${Date.now()}`, + role, + text: text.trim(), + seq: nextSeq, + timestamp + }; + + const updatedLines = [...prev.lines, newLine]; + + // Send to backend (as requested for future monitoring) + fetch('/api/v1/asr-events', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + callId: prev.callId, + seq: nextSeq, + speaker: role, + text: text.trim(), + final: true + }) + }).catch(err => console.error('Failed to report ASR event:', err)); + + // Perform rule matching + let currentAlerts = [...prev.alerts]; + const activeRules = rules.filter(r => r.enabled); + + activeRules.forEach(rule => { + // Check if any keyword matches + const matchedKeyword = rule.keywords.find(kw => + text.toLowerCase().includes(kw.toLowerCase()) + ); + + if (matchedKeyword) { + // Fresh alert! + const alertId = `alert-${rule.id}-${Date.now()}`; + const newAlert: Alert = { + id: alertId, + ruleId: rule.id, + ruleName: rule.name, + keyword: matchedKeyword, + text: text.trim(), + seq: nextSeq, + timestamp, + }; + currentAlerts = [...currentAlerts, newAlert]; + addToast(`⚠️ 触发预警: 「${rule.name}」(命中词: ${matchedKeyword})`, 'warning'); + } + }); + + return { + ...prev, + lines: updatedLines, + alerts: currentAlerts + }; + }); + }; + + // Send single manual input + const handleSendSingle = () => { + if (!singleInput.trim()) return; + processSentence(activeSpeaker, singleInput); + setSingleInput(''); + }; + + // Handle enter key in single input + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + handleSendSingle(); + } + }; + + // Play script line by line + const handlePlayScript = () => { + if (isPlayingScript) { + // Pause + if (timerRef.current) { + clearInterval(timerRef.current); + timerRef.current = null; + } + setIsPlayingScript(false); + return; + } + + // Split text into valid script sentences + const lines = scriptText + .split('\n') + .map(l => l.trim()) + .filter(l => l.length > 0); + + if (lines.length === 0) { + addToast('剧本内容为空,请输入后再试。', 'warning'); + return; + } + + // Prepare queue + setIsPlayingScript(true); + let currentLinesQueue = [...lines]; + + addToast('正在播放剧本,系统模拟每隔 1.5 秒追加一条 ASR Final 话术。', 'success'); + + // Run interval + timerRef.current = setInterval(() => { + if (currentLinesQueue.length === 0) { + if (timerRef.current) { + clearInterval(timerRef.current); + timerRef.current = null; + } + setIsPlayingScript(false); + addToast('剧本播放完毕!', 'success'); + return; + } + + const nextRawLine = currentLinesQueue.shift() || ''; + setScriptLinesQueue([...currentLinesQueue]); + + // Parse line roles + // Format 1: "市民: 文本" or "坐席: 文本" + // Format 2: "市民:文本" or "坐席:文本" + let role: 'citizen' | 'agent' = 'citizen'; + let content = nextRawLine; + + if (nextRawLine.startsWith('市民:') || nextRawLine.startsWith('市民:')) { + role = 'citizen'; + content = nextRawLine.replace(/^市民[::]\s*/, ''); + } else if (nextRawLine.startsWith('坐席:') || nextRawLine.startsWith('坐席:')) { + role = 'agent'; + content = nextRawLine.replace(/^坐席[::]\s*/, ''); + } else { + // Fallback: cycle or use current activeSpeaker + role = activeSpeaker; + } + + processSentence(role, content); + + }, playbackSpeedMs); + }; + + // Stop playback + const handleStopScript = () => { + if (timerRef.current) { + clearInterval(timerRef.current); + timerRef.current = null; + } + setIsPlayingScript(false); + setScriptLinesQueue([]); + addToast('剧本播放已停止。', 'info'); + }; + + // Quick load built-in preset scripts + const handleLoadPreset = (preset: typeof PRESET_SCRIPTS[0]) => { + setScriptText(preset.lines); + addToast(`已载入预设剧本: 「${preset.title}」`, 'info'); + }; + + // Helper: renders text while highlighting any enabled rule's keywords + const renderHighlightedText = (text: string) => { + const activeKeywords = rules + .filter(r => r.enabled) + .flatMap(r => r.keywords) + .filter(k => k.trim().length > 0); + + if (activeKeywords.length === 0) return text; + + // Sort by length descending to match longer keywords first + const sortedKeywords = [...activeKeywords].sort((a, b) => b.length - a.length); + const escapedKeywords = sortedKeywords.map(k => k.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&')); + const regex = new RegExp(`(${escapedKeywords.join('|')})`, 'gi'); + + const parts = text.split(regex); + return parts.map((part, index) => { + const isMatch = sortedKeywords.some(kw => kw.toLowerCase() === part.toLowerCase()); + return isMatch ? ( + + {part} + + ) : ( + {part} + ); + }); + }; + + return ( +
+ + {/* ==================== LEFT COLUMN: CHAT & SCRIPT CONTROLLER ==================== */} +
+ + {/* Chat Conversation Scroll Pane */} +
+ {/* Conversational Window Header */} +
+
+
+ + ASR 听写会话语音流 + +
+
+ + CALL ID: {session.callId || 'UNINITIALIZED'} + + +
+
+ + {/* Dialog Flow list */} +
+ {session.lines.length === 0 ? ( +
+
+ +
+
+

等待 ASR Final 数据写入

+

+ 在下方控制台模拟发送单步语音文本,或通过上方按钮进行多轮对话流式仿真测试。 +

+
+
+ ) : ( + session.lines.map((line) => { + const isAgent = line.role === 'agent'; + return ( +
+ {/* Avatar Icon */} +
+ {isAgent ? : } +
+ + {/* Chat Bubble Group */} +
+ {/* Meta information row */} +
+ + {isAgent ? '坐席 (Agent)' : '市民 (Citizen)'} + + + seq: {line.seq} + + {line.timestamp} +
+ + {/* Actual Bubble Box */} +
+

{renderHighlightedText(line.text)}

+
+
+
+ ); + }) + )} + + {/* Simulated typing/stream indicator */} + {isPlayingScript && ( +
+ + + + + ASR 通话音频流听写解析中... +
+ )} +
+ + {/* Interactive Input Panel */} +
+
+ {/* Role Toggle Switch */} +
+ + +
+ + {/* Input text field */} + setSingleInput(e.target.value)} + onKeyDown={handleKeyDown} + className="flex-1 rounded-md border border-slate-200 py-1.5 px-3 text-xs text-slate-950 focus:border-slate-900 focus:outline-none placeholder-slate-400 font-sans" + /> + + +
+
+
+ + {/* Script Block Controller Panels */} +
+
+
+ +

+ 剧本连播模拟器 +

+
+ + {/* Play controls */} +
+ + + +
+
+ + {/* Quick Pre-loaded scripts choices */} +
+ +
+ + 载入内置测试剧本预设: + +
+ {PRESET_SCRIPTS.map((preset, idx) => ( + + ))} +
+
+
+ + {/* Raw Text Box Script Block */} +
+