Add frontend app and redesign prototype for rule management UI.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -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/
|
||||
|
||||
9
frontend-redesign/.env.example
Normal file
9
frontend-redesign/.env.example
Normal file
@@ -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"
|
||||
8
frontend-redesign/.gitignore
vendored
Normal file
8
frontend-redesign/.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
build/
|
||||
dist/
|
||||
coverage/
|
||||
.DS_Store
|
||||
*.log
|
||||
.env*
|
||||
!.env.example
|
||||
20
frontend-redesign/README.md
Normal file
20
frontend-redesign/README.md
Normal file
@@ -0,0 +1,20 @@
|
||||
<div align="center">
|
||||
<img width="1200" height="475" alt="GHBanner" src="https://ai.google.dev/static/site-assets/images/share-ais-513315318.png" />
|
||||
</div>
|
||||
|
||||
# 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`
|
||||
13
frontend-redesign/index.html
Normal file
13
frontend-redesign/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>ASR 事件监控中心</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
6
frontend-redesign/metadata.json
Normal file
6
frontend-redesign/metadata.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "ASR 事件监控中心",
|
||||
"description": "极简、高效、响应式的 ASR 实时事件监控与沙盒模拟服务前端系统",
|
||||
"requestFramePermissions": [],
|
||||
"majorCapabilities": ["MAJOR_CAPABILITY_SERVER_SIDE_GEMINI_API"]
|
||||
}
|
||||
35
frontend-redesign/package.json
Normal file
35
frontend-redesign/package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
25
frontend-redesign/patch.py
Normal file
25
frontend-redesign/patch.py
Normal file
@@ -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<string>\('all'\);\n", "", content)
|
||||
content = re.sub(r"const \[formCategory, setFormCategory\] = useState\(''\);\n const \[formSeverity, setFormSeverity\] = useState<SeverityType>\('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)
|
||||
11
frontend-redesign/patch2.py
Normal file
11
frontend-redesign/patch2.py
Normal file
@@ -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)
|
||||
10
frontend-redesign/patch3.py
Normal file
10
frontend-redesign/patch3.py
Normal file
@@ -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" <div className=\"flex items-center space-x-2\">\n <span className=\"text-\[10px\] font-bold text-slate-400 uppercase tracking-widest flex items-center\">\n <SlidersHorizontal className=\"h-3\.5 w-3\.5 mr-1\" />\n 类别过滤:\n </span>\n <select\n value=\{selectedCategory\}\n onChange=\{\(e\) => 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 <option key=\{cat\} value=\{cat\}>\n \{cat === 'all' \? '全部监控类别' : cat\}\n </option>\n \)\)\}\n </select>\n </div>", "", content)
|
||||
|
||||
with open('src/components/RuleManagement.tsx', 'w') as f:
|
||||
f.write(content)
|
||||
19
frontend-redesign/patch4.py
Normal file
19
frontend-redesign/patch4.py
Normal file
@@ -0,0 +1,19 @@
|
||||
import re
|
||||
|
||||
with open('src/components/RuleManagement.tsx', 'r') as f:
|
||||
content = f.read()
|
||||
|
||||
# Headers
|
||||
content = content.replace('<th scope="col" className="px-6 py-3 font-semibold">类别</th>\n <th scope="col" className="px-6 py-3 font-semibold">级别</th>', '')
|
||||
|
||||
# Body
|
||||
# From `<td className="px-6 py-4">` of name to end of category and severity
|
||||
body_pattern = r"\{/\* Category Tag \*/\}\n\s*<td className=\"px-6 py-4 whitespace-nowrap\">\n\s*<span className=\"rounded bg-slate-50 px-2\.5 py-0\.5 text-\[10px\] font-bold uppercase text-slate-600 border border-slate-200/60\">\n\s*\{rule\.category\}\n\s*</span>\n\s*</td>\n\s*\{/\* Severity level \*/\}\n\s*<td className=\"px-6 py-4 whitespace-nowrap\">\n\s*<span className=\{\`inline-flex items-center rounded px-1\.5 py-0\.5 text-\[9px\] font-bold uppercase tracking-wide border \$\{\n\s*rule\.severity === 'high' \n\s*\? 'bg-red-50 text-red-800 border-red-150' \n\s*: rule\.severity === 'medium' \n\s*\? 'bg-amber-50 text-amber-800 border-amber-150' \n\s*: 'bg-slate-50 text-slate-800 border-slate-200'\n\s*\}\`\}>\n\s*<span className=\{\`mr-1 h-1\.5 w-1\.5 rounded-full \$\{\n\s*rule\.severity === 'high' \? 'bg-red-500' : rule\.severity === 'medium' \? 'bg-amber-500' : 'bg-slate-500'\n\s*\}\`\} />\n\s*\{rule\.severity === 'high' \? '最高' : rule\.severity === 'medium' \? '中级' : '普通'\}\n\s*</span>\n\s*</td>"
|
||||
|
||||
content = re.sub(body_pattern, "", content)
|
||||
|
||||
# Remove rule.description
|
||||
content = re.sub(r"\n\s*\{rule\.description && \(\n\s*<span className=\"mt-0\.5 max-w-xs truncate text-\[10px\] text-slate-400 font-sans\" title=\{rule\.description\}>\n\s*\{rule\.description\}\n\s*</span>\n\s*\)\}", "", content)
|
||||
|
||||
with open('src/components/RuleManagement.tsx', 'w') as f:
|
||||
f.write(content)
|
||||
11
frontend-redesign/patch5.py
Normal file
11
frontend-redesign/patch5.py
Normal file
@@ -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)
|
||||
32
frontend-redesign/patch_buttons.py
Normal file
32
frontend-redesign/patch_buttons.py
Normal file
@@ -0,0 +1,32 @@
|
||||
import re
|
||||
|
||||
with open('src/components/RuleManagement.tsx', 'r') as f:
|
||||
content = f.read()
|
||||
|
||||
buttons_html = """ <button
|
||||
className="inline-flex items-center justify-center space-x-1 rounded-md border border-slate-200 bg-white px-3 py-2 text-xs font-bold text-slate-700 uppercase tracking-wider transition hover:bg-slate-50 focus:outline-none"
|
||||
>
|
||||
<FileDown className="h-3.5 w-3.5 text-slate-500" />
|
||||
<span>模版下载</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="inline-flex items-center justify-center space-x-1 rounded-md border border-slate-200 bg-white px-3 py-2 text-xs font-bold text-slate-700 uppercase tracking-wider transition hover:bg-slate-50 focus:outline-none"
|
||||
>
|
||||
<Upload className="h-3.5 w-3.5 text-slate-500" />
|
||||
<span>导入</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="inline-flex items-center justify-center space-x-1 rounded-md border border-slate-200 bg-white px-3 py-2 text-xs font-bold text-slate-700 uppercase tracking-wider transition hover:bg-slate-50 focus:outline-none"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5 text-slate-500" />
|
||||
<span>导出</span>
|
||||
</button>
|
||||
|
||||
<button"""
|
||||
|
||||
content = content.replace(" <button\n onClick={handleAddRuleClick}", buttons_html + "\n onClick={handleAddRuleClick}")
|
||||
|
||||
with open('src/components/RuleManagement.tsx', 'w') as f:
|
||||
f.write(content)
|
||||
26
frontend-redesign/patch_fetch.py
Normal file
26
frontend-redesign/patch_fetch.py
Normal file
@@ -0,0 +1,26 @@
|
||||
import re
|
||||
|
||||
with open('src/components/SandboxSimulation.tsx', 'r') as f:
|
||||
content = f.read()
|
||||
|
||||
replacement = """ // 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"""
|
||||
|
||||
content = content.replace(" // Perform rule matching", replacement)
|
||||
|
||||
with open('src/components/SandboxSimulation.tsx', 'w') as f:
|
||||
f.write(content)
|
||||
9
frontend-redesign/patch_header.py
Normal file
9
frontend-redesign/patch_header.py
Normal file
@@ -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)
|
||||
11
frontend-redesign/patch_header_english.py
Normal file
11
frontend-redesign/patch_header_english.py
Normal file
@@ -0,0 +1,11 @@
|
||||
import re
|
||||
|
||||
with open('src/components/Header.tsx', 'r') as f:
|
||||
content = f.read()
|
||||
|
||||
content = re.sub(r'\s*<span className="font-mono text-\[9px\] uppercase tracking-widest text-slate-400">\s*Event Monitoring Center\s*</span>', '', content)
|
||||
content = re.sub(r'\s*<span className="hidden font-mono text-\[9px\] font-normal opacity-50 ml-1 sm:inline">Rule Management</span>', '', content)
|
||||
content = re.sub(r'\s*<span className="hidden font-mono text-\[9px\] font-normal opacity-50 ml-1 sm:inline">Sandbox Simulation</span>', '', content)
|
||||
|
||||
with open('src/components/Header.tsx', 'w') as f:
|
||||
f.write(content)
|
||||
14
frontend-redesign/patch_header_online.py
Normal file
14
frontend-redesign/patch_header_online.py
Normal file
@@ -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*<div className=\"flex items-center space-x-2\">.*?</div>\s*</div>\s*</header>"
|
||||
replacement = """
|
||||
</div>
|
||||
</header>"""
|
||||
|
||||
content = re.sub(pattern, replacement, content, flags=re.DOTALL)
|
||||
|
||||
with open('src/components/Header.tsx', 'w') as f:
|
||||
f.write(content)
|
||||
38
frontend-redesign/patch_header_restore.py
Normal file
38
frontend-redesign/patch_header_restore.py
Normal file
@@ -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 */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className={`flex items-center space-x-1.5 px-2.5 py-1 border rounded-full transition-colors ${
|
||||
isDirty
|
||||
? 'bg-amber-50 border-amber-100 text-amber-800'
|
||||
: 'bg-emerald-50 border-emerald-100 text-emerald-800'
|
||||
}`}>
|
||||
<span className="relative flex h-1.5 w-1.5">
|
||||
<span className={`absolute inline-flex h-full w-full rounded-full opacity-75 ${
|
||||
isDirty ? 'animate-ping bg-amber-400' : 'animate-ping bg-emerald-400'
|
||||
}`}></span>
|
||||
<span className={`relative inline-flex h-1.5 w-1.5 rounded-full ${
|
||||
isDirty ? 'bg-amber-500' : 'bg-emerald-500'
|
||||
}`}></span>
|
||||
</span>
|
||||
<span className="font-sans text-[10px] font-bold tracking-tight">
|
||||
在线
|
||||
</span>
|
||||
</div>
|
||||
{isDirty && (
|
||||
<span className="hidden rounded bg-amber-100/70 border border-amber-200 px-2 py-0.5 text-[10px] font-bold text-amber-800 tracking-wider sm:inline">
|
||||
待发布
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>"""
|
||||
|
||||
content = content.replace(" </div>\n </header>", replacement)
|
||||
|
||||
with open('src/components/Header.tsx', 'w') as f:
|
||||
f.write(content)
|
||||
9
frontend-redesign/patch_icons.py
Normal file
9
frontend-redesign/patch_icons.py
Normal file
@@ -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)
|
||||
10
frontend-redesign/patch_imports.py
Normal file
10
frontend-redesign/patch_imports.py
Normal file
@@ -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)
|
||||
11
frontend-redesign/patch_labels.py
Normal file
11
frontend-redesign/patch_labels.py
Normal file
@@ -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)
|
||||
9
frontend-redesign/patch_monitoring.py
Normal file
9
frontend-redesign/patch_monitoring.py
Normal file
@@ -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)
|
||||
106
frontend-redesign/patch_sandbox.py
Normal file
106
frontend-redesign/patch_sandbox.py
Normal file
@@ -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 = """ <div className="flex items-center space-x-1 font-mono text-[10px] font-bold">
|
||||
<span className="text-slate-400">去重静默拦截:</span>
|
||||
<span className={`px-1.5 py-0.2 rounded ${
|
||||
session.interceptCount > 0
|
||||
? 'bg-amber-100 text-amber-800'
|
||||
: 'bg-slate-200 text-slate-600'
|
||||
}`}>
|
||||
{session.interceptCount} 次
|
||||
</span>
|
||||
</div>"""
|
||||
|
||||
content = content.replace(ui_to_remove, "")
|
||||
|
||||
with open('src/components/SandboxSimulation.tsx', 'w') as f:
|
||||
f.write(content)
|
||||
7
frontend-redesign/patch_sandbox2.py
Normal file
7
frontend-redesign/patch_sandbox2.py
Normal file
@@ -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)
|
||||
87
frontend-redesign/patch_sandbox3.py
Normal file
87
frontend-redesign/patch_sandbox3.py
Normal file
@@ -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)
|
||||
18
frontend-redesign/patch_sandbox4.py
Normal file
18
frontend-redesign/patch_sandbox4.py
Normal file
@@ -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 \*/\}(.*?)</div>\n </div>\n </div>\n \);\n\}"
|
||||
|
||||
replacement = r"""
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}"""
|
||||
|
||||
content = re.sub(pattern, replacement, content, flags=re.DOTALL)
|
||||
|
||||
with open('src/components/SandboxSimulation.tsx', 'w') as f:
|
||||
f.write(content)
|
||||
87
frontend-redesign/patch_sandbox5.py
Normal file
87
frontend-redesign/patch_sandbox5.py
Normal file
@@ -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)
|
||||
44
frontend-redesign/patch_sandbox6.py
Normal file
44
frontend-redesign/patch_sandbox6.py
Normal file
@@ -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)
|
||||
46
frontend-redesign/patch_sandbox7.py
Normal file
46
frontend-redesign/patch_sandbox7.py
Normal file
@@ -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)
|
||||
55
frontend-redesign/patch_sandbox8.py
Normal file
55
frontend-redesign/patch_sandbox8.py
Normal file
@@ -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)
|
||||
16
frontend-redesign/patch_severity.py
Normal file
16
frontend-redesign/patch_severity.py
Normal file
@@ -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)
|
||||
13
frontend-redesign/patch_severity2.py
Normal file
13
frontend-redesign/patch_severity2.py
Normal file
@@ -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)
|
||||
13
frontend-redesign/patch_severity3.py
Normal file
13
frontend-redesign/patch_severity3.py
Normal file
@@ -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)
|
||||
9
frontend-redesign/patch_toast.py
Normal file
9
frontend-redesign/patch_toast.py
Normal file
@@ -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)
|
||||
9
frontend-redesign/patch_types.py
Normal file
9
frontend-redesign/patch_types.py
Normal file
@@ -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)
|
||||
162
frontend-redesign/src/App.tsx
Normal file
162
frontend-redesign/src/App.tsx
Normal file
@@ -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<Rule[]>([]);
|
||||
|
||||
// Rules State: Published snapshot
|
||||
const [publishedRules, setPublishedRules] = useState<Rule[]>([]);
|
||||
|
||||
// Version counter state
|
||||
const [publishedVersion, setPublishedVersion] = useState<number>(6);
|
||||
|
||||
// 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));
|
||||
};
|
||||
|
||||
// 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 (
|
||||
<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}
|
||||
/>
|
||||
|
||||
{/* 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}
|
||||
addToast={addToast}
|
||||
/>
|
||||
</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={rules}
|
||||
addToast={addToast}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</main>
|
||||
|
||||
{/* Persistent Toast Notifications */}
|
||||
<ToastContainer toasts={toasts} removeToast={removeToast} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
91
frontend-redesign/src/components/Header.tsx
Normal file
91
frontend-redesign/src/components/Header.tsx
Normal file
@@ -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 (
|
||||
<header className="sticky top-0 z-40 w-full border-b border-slate-200 bg-white/95 backdrop-blur-md shrink-0">
|
||||
<div className="mx-auto flex h-14 max-w-7xl items-center justify-between px-4 sm:px-6">
|
||||
{/* Left Section: Brand Logo */}
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-8 h-8 bg-slate-900 rounded-md flex items-center justify-center shadow-xs">
|
||||
<span className="text-white text-xs font-bold tracking-wider">ASR</span>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs font-bold tracking-tight text-slate-900 uppercase sm:text-sm">
|
||||
ASR 事件监控中心
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Center Section: Navigation Menu */}
|
||||
<nav className="flex h-full space-x-1" aria-label="Global Navigation">
|
||||
<button
|
||||
onClick={() => setActiveTab('rules')}
|
||||
className={`relative h-full flex items-center px-4 text-xs font-semibold uppercase tracking-wider transition-colors duration-150 ${
|
||||
activeTab === 'rules'
|
||||
? 'text-slate-900'
|
||||
: 'text-slate-400 hover:text-slate-900'
|
||||
}`}
|
||||
>
|
||||
<span>规则配置</span>
|
||||
{activeTab === 'rules' && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-[2px] bg-slate-900" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveTab('sandbox')}
|
||||
className={`relative h-full flex items-center px-4 text-xs font-semibold uppercase tracking-wider transition-colors duration-150 ${
|
||||
activeTab === 'sandbox'
|
||||
? 'text-slate-900'
|
||||
: 'text-slate-400 hover:text-slate-900'
|
||||
}`}
|
||||
>
|
||||
<span>沙盒模拟</span>
|
||||
{activeTab === 'sandbox' && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-[2px] bg-slate-900" />
|
||||
)}
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
{/* Right Section: Version Badge & Env Perceive */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className={`flex items-center space-x-1.5 px-2.5 py-1 border rounded-full transition-colors ${
|
||||
isDirty
|
||||
? 'bg-amber-50 border-amber-100 text-amber-800'
|
||||
: 'bg-emerald-50 border-emerald-100 text-emerald-800'
|
||||
}`}>
|
||||
<span className="relative flex h-1.5 w-1.5">
|
||||
<span className={`absolute inline-flex h-full w-full rounded-full opacity-75 ${
|
||||
isDirty ? 'animate-ping bg-amber-400' : 'animate-ping bg-emerald-400'
|
||||
}`}></span>
|
||||
<span className={`relative inline-flex h-1.5 w-1.5 rounded-full ${
|
||||
isDirty ? 'bg-amber-500' : 'bg-emerald-500'
|
||||
}`}></span>
|
||||
</span>
|
||||
<span className="font-sans text-[10px] font-bold tracking-tight">
|
||||
在线
|
||||
</span>
|
||||
</div>
|
||||
{isDirty && (
|
||||
<span className="hidden rounded bg-amber-100/70 border border-amber-200 px-2 py-0.5 text-[10px] font-bold text-amber-800 tracking-wider sm:inline">
|
||||
待发布
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
595
frontend-redesign/src/components/RuleManagement.tsx
Normal file
595
frontend-redesign/src/components/RuleManagement.tsx
Normal file
@@ -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<Rule | null>(null);
|
||||
|
||||
// Form states inside drawer
|
||||
const [formId, setFormId] = useState('');
|
||||
const [formName, setFormName] = useState('');
|
||||
const [keywordInput, setKeywordInput] = useState('');
|
||||
const [formKeywords, setFormKeywords] = useState<string[]>([]);
|
||||
const [formErrors, setFormErrors] = useState<Record<string, string>>({});
|
||||
|
||||
// Tag editor input ref
|
||||
const tagInputRef = useRef<HTMLInputElement>(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<string, string> = {};
|
||||
|
||||
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<string | null>(null);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col space-y-6">
|
||||
{/* 1. Header Toolbar area */}
|
||||
<div className="flex flex-col justify-between space-y-4 md:flex-row md:items-center md:space-y-0">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold tracking-tight text-slate-950 sm:text-2xl font-sans">
|
||||
监控规则库
|
||||
</h1>
|
||||
<p className="mt-1 text-xs text-slate-500 font-sans">
|
||||
共 <span className="font-bold text-slate-950 font-mono">{rules.length}</span> 项特征规则,当前已启用{' '}
|
||||
<span className="font-bold text-slate-950 font-mono">{activeRulesCount}</span> 项,匹配特征词累计{' '}
|
||||
<span className="font-bold text-slate-950 font-mono">{totalKeywords}</span> 个。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
className="inline-flex items-center justify-center space-x-1 rounded-md border border-slate-200 bg-white px-3 py-2 text-xs font-bold text-slate-700 uppercase tracking-wider transition hover:bg-slate-50 focus:outline-none"
|
||||
>
|
||||
<FileDown className="h-3.5 w-3.5 text-slate-500" />
|
||||
<span>模版下载</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="inline-flex items-center justify-center space-x-1 rounded-md border border-slate-200 bg-white px-3 py-2 text-xs font-bold text-slate-700 uppercase tracking-wider transition hover:bg-slate-50 focus:outline-none"
|
||||
>
|
||||
<Upload className="h-3.5 w-3.5 text-slate-500" />
|
||||
<span>导入</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="inline-flex items-center justify-center space-x-1 rounded-md border border-slate-200 bg-white px-3 py-2 text-xs font-bold text-slate-700 uppercase tracking-wider transition hover:bg-slate-50 focus:outline-none"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5 text-slate-500" />
|
||||
<span>导出</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleAddRuleClick}
|
||||
className="inline-flex items-center justify-center space-x-1 rounded-md border border-slate-200 bg-white px-3.5 py-2 text-xs font-bold text-slate-900 uppercase tracking-wider transition hover:bg-slate-50 focus:outline-none"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5 text-slate-900" />
|
||||
<span>新增规则</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={onSavePublish}
|
||||
disabled={!isDirty}
|
||||
className={`relative inline-flex items-center justify-center space-x-1.5 rounded-md px-4 py-2 text-xs font-bold uppercase tracking-wider text-white transition-all ${
|
||||
isDirty
|
||||
? 'bg-slate-900 hover:bg-slate-800 cursor-pointer animate-breathe'
|
||||
: 'bg-slate-200 text-slate-400 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
<Save className="h-3.5 w-3.5" />
|
||||
<span>保存并发布</span>
|
||||
{isDirty && (
|
||||
<span className="absolute -top-1 -right-1 flex h-2 w-2">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-rose-400 opacity-75"></span>
|
||||
<span className="relative inline-flex h-2 w-2 rounded-full bg-rose-500"></span>
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 2. Searching & Filter Options Bar */}
|
||||
<div className="flex flex-col space-y-3 rounded-md border border-slate-200 bg-white p-4 shadow-3xs sm:flex-row sm:items-center sm:space-y-0 sm:space-x-4">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute top-2.5 left-3 h-4 w-4 text-slate-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索特征名称、规则唯一 ID 或触发词..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => 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 && (
|
||||
<button
|
||||
onClick={() => setSearchTerm('')}
|
||||
className="absolute right-3 top-2 text-slate-400 hover:text-slate-600"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
{/* 3. High Density Data Table */}
|
||||
<div className="overflow-hidden rounded-md border border-slate-200 bg-white shadow-3xs">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse text-left text-xs text-slate-505">
|
||||
<thead className="bg-slate-50/75 text-[10px] font-bold uppercase tracking-widest text-slate-400 border-b border-slate-200">
|
||||
<tr>
|
||||
<th scope="col" className="px-6 py-3 font-semibold">事件 ID</th>
|
||||
<th scope="col" className="px-6 py-3 font-semibold">特征规则名称</th>
|
||||
|
||||
<th scope="col" className="px-6 py-3 font-semibold">监控特征关键词</th>
|
||||
<th scope="col" className="px-6 py-3 font-semibold text-center">状态</th>
|
||||
<th scope="col" className="px-6 py-3 font-semibold text-right">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100 bg-white">
|
||||
{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 (
|
||||
<tr
|
||||
key={rule.id}
|
||||
className={`transition-colors duration-75 hover:bg-slate-50/50 ${
|
||||
!rule.enabled ? 'bg-slate-50/20' : ''
|
||||
}`}
|
||||
>
|
||||
{/* Event ID */}
|
||||
<td className="px-6 py-4 font-mono text-[10px] font-bold text-slate-400 whitespace-nowrap">
|
||||
{rule.id}
|
||||
</td>
|
||||
|
||||
{/* Event Name & Description */}
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex flex-col">
|
||||
<span className={`text-xs font-bold text-slate-900 ${
|
||||
!rule.enabled ? 'line-through text-slate-400 font-medium' : ''
|
||||
}`}>
|
||||
{rule.name}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
|
||||
|
||||
{/* Keywords Badges */}
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{visibleKeywords.map((keyword, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className={`rounded bg-slate-100/70 px-2 py-0.5 text-[10px] font-medium text-slate-800 border border-slate-200/50 transition-all ${
|
||||
!rule.enabled ? 'bg-slate-50 text-slate-400 border-slate-100' : ''
|
||||
}`}
|
||||
>
|
||||
{keyword}
|
||||
</span>
|
||||
))}
|
||||
|
||||
{hasManyKeywords && (
|
||||
<button
|
||||
onClick={() => setExpandedKeywordsRuleId(isKeywordsExpanded ? null : rule.id)}
|
||||
className="inline-flex items-center rounded bg-slate-100 hover:bg-slate-200 px-1.5 py-0.5 text-[10px] font-bold text-slate-600 transition-colors"
|
||||
>
|
||||
{isKeywordsExpanded ? '收起' : `+${rule.keywords.length - 5}`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Status switch toggle */}
|
||||
<td className="px-6 py-4 text-center whitespace-nowrap">
|
||||
<label className="relative inline-flex cursor-pointer items-center justify-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={rule.enabled}
|
||||
onChange={() => handleToggleStatus(rule.id, rule.enabled, rule.name)}
|
||||
className="peer sr-only"
|
||||
/>
|
||||
<div className="peer h-5 w-9 rounded-full bg-slate-150 after:absolute after:top-[2px] after:left-[2px] after:h-4 after:w-4 after:rounded-full after:bg-white after:transition-all after:content-[''] peer-checked:bg-slate-900 peer-checked:after:translate-x-full peer-focus:outline-none" />
|
||||
</label>
|
||||
</td>
|
||||
|
||||
{/* Action buttons */}
|
||||
<td className="px-6 py-4 text-right whitespace-nowrap text-sm font-medium">
|
||||
<div className="flex items-center justify-end space-x-1">
|
||||
<button
|
||||
onClick={() => handleEditRuleClick(rule)}
|
||||
className="p-1.5 text-slate-400 hover:text-slate-900 hover:bg-slate-50 rounded transition"
|
||||
title="编辑规则"
|
||||
>
|
||||
<Edit className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteRule(rule.id, rule.name)}
|
||||
className="p-1.5 text-slate-400 hover:text-rose-600 hover:bg-rose-50 rounded transition"
|
||||
title="删除规则"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-6 py-12 text-center">
|
||||
<div className="flex flex-col items-center justify-center space-y-2">
|
||||
<AlertCircle className="h-7 w-7 text-slate-300 animate-pulse" />
|
||||
<p className="text-xs font-bold text-slate-700 uppercase tracking-widest">未检索到任何匹配规则</p>
|
||||
<p className="text-xs text-slate-400">请清除筛选条件或重置搜索词。</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 4. Sliding Right Drawer for Creating/Editing Rules */}
|
||||
<AnimatePresence>
|
||||
{isDrawerOpen && (
|
||||
<>
|
||||
{/* Dark Overlay Backdrop */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 0.3 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={() => setIsDrawerOpen(false)}
|
||||
className="fixed inset-0 z-50 bg-slate-950 backdrop-blur-3xs"
|
||||
/>
|
||||
|
||||
{/* Sliding Panel */}
|
||||
<motion.div
|
||||
initial={{ x: '100%' }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: '100%' }}
|
||||
transition={{ type: 'spring', damping: 24, stiffness: 220 }}
|
||||
className="fixed top-0 right-0 z-50 h-full w-full max-w-lg bg-white shadow-2xl border-l border-slate-200 flex flex-col font-sans"
|
||||
>
|
||||
{/* Drawer Header */}
|
||||
<div className="flex items-center justify-between border-b border-slate-200 px-6 py-4.5 bg-slate-50/50">
|
||||
<div>
|
||||
<h2 className="text-sm font-bold text-slate-900 uppercase tracking-wide">
|
||||
{editingRule ? '编辑特征规则' : '新增特征规则'}
|
||||
</h2>
|
||||
<p className="text-[11px] text-slate-400 mt-0.5">
|
||||
{editingRule ? `ID: ${formId}` : '创建特征文本提取条件以过滤并识别 ASR 事件语音。'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsDrawerOpen(false)}
|
||||
className="rounded-md p-1 text-slate-400 hover:text-slate-900 hover:bg-slate-100 transition"
|
||||
>
|
||||
<X className="h-4.5 w-4.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Drawer Scroll Body */}
|
||||
<form onSubmit={handleSaveForm} className="flex-1 overflow-y-auto p-6 space-y-5">
|
||||
{/* Rule ID Field (disabled if editing) */}
|
||||
<div className="space-y-1">
|
||||
<label htmlFor="rule-id" className="block text-[10px] font-bold text-slate-400 uppercase tracking-widest">
|
||||
事件 ID <span className="text-rose-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="rule-id"
|
||||
type="text"
|
||||
disabled={!!editingRule}
|
||||
placeholder="例如: customer-complaint-high"
|
||||
value={formId}
|
||||
onChange={(e) => 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 ? (
|
||||
<p className="text-[10px] font-bold text-rose-500 flex items-center mt-1">
|
||||
<AlertCircle className="h-3 w-3 mr-1 inline" />
|
||||
{formErrors.id}
|
||||
</p>
|
||||
) : (
|
||||
!editingRule && (
|
||||
<p className="text-[10px] text-slate-400 mt-1">
|
||||
仅支持字母、数字、短横线与下划线,一经创建无法重置
|
||||
</p>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Event Name Field */}
|
||||
<div className="space-y-1">
|
||||
<label htmlFor="rule-name" className="block text-[10px] font-bold text-slate-400 uppercase tracking-widest">
|
||||
特征规则名称 <span className="text-rose-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="rule-name"
|
||||
type="text"
|
||||
placeholder="例如: 客户升级投诉意愿"
|
||||
value={formName}
|
||||
onChange={(e) => 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 && (
|
||||
<p className="text-[10px] font-bold text-rose-500 flex items-center mt-1">
|
||||
<AlertCircle className="h-3 w-3 mr-1 inline" />
|
||||
{formErrors.name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Key Tags Editor Input */}
|
||||
<div className="space-y-2 border-t border-slate-150 pt-4">
|
||||
<label className="block text-[10px] font-bold text-slate-400 uppercase tracking-widest">
|
||||
监控特征关键词 <span className="text-rose-500">*</span>
|
||||
</label>
|
||||
|
||||
{/* Tag Input Field Container */}
|
||||
<div className="flex flex-col space-y-2">
|
||||
<div className={`flex flex-wrap items-center gap-1 p-2 rounded border bg-white ${
|
||||
formErrors.keywords ? 'border-rose-300 ring-1 ring-rose-500' : 'border-slate-200 focus-within:border-slate-900'
|
||||
}`}>
|
||||
{/* Interactive pill tags */}
|
||||
{formKeywords.map((tag, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
className="inline-flex items-center space-x-1 rounded bg-slate-100 pl-2 pr-1 py-0.5 text-[10px] font-bold text-slate-800 border border-slate-200/50"
|
||||
>
|
||||
<span>{tag}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveKeyword(idx)}
|
||||
className="rounded-full p-0.5 hover:bg-slate-200 text-slate-500"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
|
||||
{/* Actual Input */}
|
||||
<input
|
||||
ref={tagInputRef}
|
||||
type="text"
|
||||
placeholder={formKeywords.length === 0 ? "敲空格或回车键快速添加" : "添加..."}
|
||||
value={keywordInput}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{formErrors.keywords && (
|
||||
<p className="text-[10px] font-bold text-rose-500 flex items-center mt-1">
|
||||
<AlertCircle className="h-3 w-3 mr-1 inline" />
|
||||
{formErrors.keywords}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="rounded border border-slate-200 bg-slate-50 p-3 text-xs text-slate-500 flex items-start space-x-2">
|
||||
<Info className="h-4 w-4 text-slate-600 shrink-0 mt-0.5" />
|
||||
<div className="space-y-1 font-sans">
|
||||
<p className="font-bold text-slate-700">过滤提取逻辑须知</p>
|
||||
<p>1. 只要 ASR 语句文本中「完整包含」此处设定的任一触发词,即瞬时抛出预警卡片。</p>
|
||||
<p>2. 特征词需精简准确,避免设置过短或通用的词(例如“有”、“去”),以免大面积噪点干扰。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Drawer Footer Actions */}
|
||||
<div className="border-t border-slate-200 px-6 py-4 bg-gray-50 flex items-center justify-end space-x-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsDrawerOpen(false)}
|
||||
className="rounded border border-slate-200 bg-white px-4 py-2 text-xs font-bold uppercase tracking-wider text-slate-700 hover:bg-slate-50 transition"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSaveForm}
|
||||
className="rounded bg-slate-900 hover:bg-slate-800 px-4 py-2 text-xs font-bold uppercase tracking-wider text-white transition"
|
||||
>
|
||||
{editingRule ? '保存修改' : '确认新增'}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
657
frontend-redesign/src/components/SandboxSimulation.tsx
Normal file
657
frontend-redesign/src/components/SandboxSimulation.tsx
Normal file
@@ -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<SandboxSession>({
|
||||
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<string[]>([]);
|
||||
const [playbackSpeedMs, setPlaybackSpeedMs] = useState(1500);
|
||||
|
||||
// Refs for scrolling
|
||||
const chatScrollRef = useRef<HTMLDivElement>(null);
|
||||
const alertScrollRef = useRef<HTMLDivElement>(null);
|
||||
const timerRef = useRef<NodeJS.Timeout | null>(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 ? (
|
||||
<mark key={index} className="bg-red-500/10 text-red-600 font-bold px-0.5 rounded border-b border-red-500/40">
|
||||
{part}
|
||||
</mark>
|
||||
) : (
|
||||
<span key={index}>{part}</span>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6 h-[calc(100vh-180px)] min-h-[500px]">
|
||||
|
||||
{/* ==================== LEFT COLUMN: CHAT & SCRIPT CONTROLLER ==================== */}
|
||||
<div className="lg:col-span-7 flex flex-col space-y-4 h-full min-h-0">
|
||||
|
||||
{/* Chat Conversation Scroll Pane */}
|
||||
<div className="flex-1 rounded-lg border border-slate-200 bg-white shadow-xs flex flex-col overflow-hidden min-h-0">
|
||||
{/* Conversational Window Header */}
|
||||
<div className="bg-slate-50/55 border-b border-slate-200 px-4 py-3 flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex h-2 w-2 items-center justify-center rounded-full bg-emerald-500 animate-pulse" />
|
||||
<span className="text-[11px] font-bold uppercase tracking-wider text-slate-700 font-sans">
|
||||
ASR 听写会话语音流
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="font-mono text-[9px] font-bold text-slate-500 bg-slate-100 border border-slate-200/60 px-2 py-0.5 rounded">
|
||||
CALL ID: {session.callId || 'UNINITIALIZED'}
|
||||
</span>
|
||||
<button
|
||||
onClick={handleStartNewCall}
|
||||
className="text-[10px] font-bold text-slate-900 hover:text-slate-600 flex items-center space-x-1 uppercase tracking-tight"
|
||||
title="关闭当前会话并清空,开启一条新通话"
|
||||
>
|
||||
<ListRestart className="h-3 w-3 text-slate-900" />
|
||||
<span>新通话</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dialog Flow list */}
|
||||
<div
|
||||
ref={chatScrollRef}
|
||||
className="flex-1 overflow-y-auto p-4 space-y-4 bg-slate-50/30"
|
||||
>
|
||||
{session.lines.length === 0 ? (
|
||||
<div className="h-full flex flex-col items-center justify-center text-center p-6">
|
||||
<div className="h-10 w-10 rounded-full bg-slate-100 text-slate-900 flex items-center justify-center mb-3">
|
||||
<Radio className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xs font-bold text-slate-700 uppercase tracking-widest">等待 ASR Final 数据写入</h3>
|
||||
<p className="text-xs text-slate-400 mt-1 max-w-xs leading-relaxed">
|
||||
在下方控制台模拟发送单步语音文本,或通过上方按钮进行多轮对话流式仿真测试。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
session.lines.map((line) => {
|
||||
const isAgent = line.role === 'agent';
|
||||
return (
|
||||
<div
|
||||
key={line.id}
|
||||
className={`flex items-start space-x-2.5 ${!isAgent ? 'flex-row' : 'flex-row-reverse space-x-reverse'}`}
|
||||
>
|
||||
{/* Avatar Icon */}
|
||||
<div className={`h-8 w-8 rounded-full shrink-0 flex items-center justify-center border shadow-3xs ${
|
||||
isAgent
|
||||
? 'bg-white text-slate-800 border-slate-200'
|
||||
: 'bg-slate-900 text-white border-slate-950'
|
||||
}`}>
|
||||
{isAgent ? <Headphones className="h-4 w-4" /> : <User className="h-4 w-4" />}
|
||||
</div>
|
||||
|
||||
{/* Chat Bubble Group */}
|
||||
<div className="flex flex-col max-w-[80%]">
|
||||
{/* Meta information row */}
|
||||
<div className={`flex items-center space-x-2 text-[9px] text-slate-400 mb-1 ${
|
||||
!isAgent ? 'justify-start' : 'justify-end'
|
||||
}`}>
|
||||
<span className="font-bold uppercase tracking-tight text-slate-500">
|
||||
{isAgent ? '坐席 (Agent)' : '市民 (Citizen)'}
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span className="font-mono bg-slate-100 border border-slate-200/50 px-1 rounded">seq: {line.seq}</span>
|
||||
<span>•</span>
|
||||
<span>{line.timestamp}</span>
|
||||
</div>
|
||||
|
||||
{/* Actual Bubble Box */}
|
||||
<div className={`p-3 rounded-2xl text-xs leading-relaxed border shadow-3xs ${
|
||||
!isAgent
|
||||
? 'bg-slate-900 text-white border-slate-950 rounded-tl-none'
|
||||
: 'bg-white text-slate-800 border-slate-200 rounded-tr-none'
|
||||
}`}>
|
||||
<p className="whitespace-pre-wrap">{renderHighlightedText(line.text)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
{/* Simulated typing/stream indicator */}
|
||||
{isPlayingScript && (
|
||||
<div className="flex items-center space-x-2 text-[10px] text-slate-400 font-mono ml-10">
|
||||
<span className="relative flex h-1.5 w-1.5 mr-1">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-slate-400 opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-slate-500"></span>
|
||||
</span>
|
||||
<span>ASR 通话音频流听写解析中...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Interactive Input Panel */}
|
||||
<div className="border-t border-slate-200 p-3 bg-white space-y-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
{/* Role Toggle Switch */}
|
||||
<div className="flex rounded-md border border-slate-200 p-0.5 bg-slate-50 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveSpeaker('citizen')}
|
||||
className={`flex items-center space-x-1 px-2.5 py-1 rounded text-[10px] font-bold uppercase tracking-wider transition ${
|
||||
activeSpeaker === 'citizen'
|
||||
? 'bg-slate-900 text-white'
|
||||
: 'text-slate-400 hover:text-slate-800'
|
||||
}`}
|
||||
>
|
||||
<User className="h-3 w-3" />
|
||||
<span>市民</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveSpeaker('agent')}
|
||||
className={`flex items-center space-x-1 px-2.5 py-1 rounded text-[10px] font-bold uppercase tracking-wider transition ${
|
||||
activeSpeaker === 'agent'
|
||||
? 'bg-slate-900 text-white'
|
||||
: 'text-slate-400 hover:text-slate-800'
|
||||
}`}
|
||||
>
|
||||
<Headphones className="h-3 w-3" />
|
||||
<span>坐席</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Input text field */}
|
||||
<input
|
||||
type="text"
|
||||
placeholder={`模拟${activeSpeaker === 'citizen' ? '市民' : '坐席'}发言,按回车键直接推送...`}
|
||||
value={singleInput}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSendSingle}
|
||||
className="bg-slate-900 hover:bg-slate-800 text-white px-3 py-1.5 rounded-md text-[11px] font-bold uppercase tracking-wider inline-flex items-center space-x-1 transition"
|
||||
>
|
||||
<Send className="h-3.5 w-3.5" />
|
||||
<span className="hidden sm:inline">发送</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Script Block Controller Panels */}
|
||||
<div className="shrink-0 rounded-lg border border-slate-200 bg-white p-4 shadow-xs space-y-3">
|
||||
<div className="flex items-center justify-between border-b border-slate-100 pb-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<FileText className="h-4 w-4 text-slate-900" />
|
||||
<h2 className="text-[11px] font-bold uppercase tracking-wider text-slate-900">
|
||||
剧本连播模拟器
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* Play controls */}
|
||||
<div className="flex items-center space-x-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePlayScript}
|
||||
className={`inline-flex items-center space-x-1 rounded px-2.5 py-1.5 text-[10px] font-bold uppercase tracking-wider border transition ${
|
||||
isPlayingScript
|
||||
? 'bg-amber-50 text-amber-700 border-amber-200 hover:bg-amber-100'
|
||||
: 'bg-slate-900 text-white border-slate-950 hover:bg-slate-800'
|
||||
}`}
|
||||
>
|
||||
<Play className="h-3 w-3" />
|
||||
<span>{isPlayingScript ? '暂停' : '播剧本'}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleStopScript}
|
||||
disabled={!isPlayingScript}
|
||||
className={`inline-flex items-center space-x-1 rounded px-2.5 py-1.5 text-[10px] font-bold uppercase tracking-wider border transition ${
|
||||
isPlayingScript
|
||||
? 'bg-white text-slate-700 border-slate-200 hover:bg-slate-50'
|
||||
: 'bg-slate-50 text-slate-400 border-slate-100 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
<Square className="h-3 w-3" />
|
||||
<span>停止</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Pre-loaded scripts choices */}
|
||||
<div className="flex items-start space-x-2 bg-slate-50 rounded-md p-2.5 border border-slate-200/60">
|
||||
<Sparkles className="h-4 w-4 text-slate-600 shrink-0 mt-0.5" />
|
||||
<div className="space-y-1">
|
||||
<span className="text-[9px] font-bold text-slate-500 uppercase tracking-wider">
|
||||
载入内置测试剧本预设:
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-1.5 mt-1">
|
||||
{PRESET_SCRIPTS.map((preset, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
type="button"
|
||||
onClick={() => handleLoadPreset(preset)}
|
||||
className="rounded bg-white hover:bg-slate-50 px-2 py-0.5 text-[10px] text-slate-900 font-semibold border border-slate-200 shadow-3xs transition-colors"
|
||||
>
|
||||
{preset.title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Raw Text Box Script Block */}
|
||||
<div className="space-y-1">
|
||||
<textarea
|
||||
rows={4}
|
||||
value={scriptText}
|
||||
onChange={(e) => setScriptText(e.target.value)}
|
||||
disabled={isPlayingScript}
|
||||
placeholder="请输入通话剧本。规则: 每行一句。格式支持'市民: 说话内容'或'坐席: 说话内容'。"
|
||||
className="w-full rounded-md border border-slate-200 py-1.5 px-3 text-xs font-mono text-slate-850 focus:border-slate-900 focus:outline-none disabled:bg-slate-50 disabled:text-slate-400"
|
||||
/>
|
||||
<p className="text-[10px] text-slate-400 flex items-center justify-between">
|
||||
<span>系统将逐行抓取话术流式推送至 ASR 通道中。</span>
|
||||
<span className="font-semibold text-slate-500">
|
||||
流式延时:{' '}
|
||||
<select
|
||||
value={playbackSpeedMs}
|
||||
onChange={(e) => setPlaybackSpeedMs(Number(e.target.value))}
|
||||
disabled={isPlayingScript}
|
||||
className="bg-transparent border-b border-slate-300 focus:outline-none cursor-pointer"
|
||||
>
|
||||
<option value={1000}>1.0 秒/句</option>
|
||||
<option value={1500}>1.5 秒/句</option>
|
||||
<option value={2000}>2.0 秒/句</option>
|
||||
<option value={3000}>3.0 秒/句</option>
|
||||
</select>
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ==================== RIGHT COLUMN: LIVE EVENT FEED ==================== */}
|
||||
<div className="lg:col-span-5 flex flex-col space-y-4 h-full min-h-0">
|
||||
|
||||
{/* Alerts Monitoring View Panel */}
|
||||
<div className="flex-1 rounded-lg border border-slate-200 bg-white shadow-xs flex flex-col overflow-hidden min-h-0">
|
||||
|
||||
{/* Dashboard Panel Header */}
|
||||
<div className="bg-slate-50/50 border-b border-slate-200 px-4 py-3 flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="relative flex h-1.5 w-1.5">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-slate-500 opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-slate-900"></span>
|
||||
</span>
|
||||
<span className="text-[11px] font-bold uppercase tracking-wider text-slate-700">
|
||||
实时预警流监控
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Count Tag indicator */}
|
||||
<span className="rounded bg-slate-900 border border-slate-950 px-2 py-0.5 text-[9px] font-bold text-white tracking-wider">
|
||||
警情: {session.alerts.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Time Line Scroll container */}
|
||||
<div
|
||||
ref={alertScrollRef}
|
||||
className="flex-1 overflow-y-auto p-4 space-y-4 bg-slate-50/20"
|
||||
>
|
||||
{session.alerts.length === 0 ? (
|
||||
<div className="h-full flex flex-col items-center justify-center text-center p-6 space-y-3">
|
||||
<div className="h-10 w-10 rounded-full bg-slate-100 text-slate-400 flex items-center justify-center">
|
||||
<Clock className="h-5 w-5 animate-pulse text-slate-500" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-xs font-bold text-slate-700 uppercase tracking-widest">监听中,暂无事件触发</h4>
|
||||
<p className="text-xs text-slate-400 mt-1 max-w-xs leading-relaxed">
|
||||
一旦命中监控规则词,此视窗将立刻流式抛出对应预警卡片。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative border-l border-slate-200 pl-4 ml-2 space-y-4">
|
||||
{session.alerts.map((alert) => (
|
||||
<div key={alert.id} className="relative group">
|
||||
|
||||
{/* Circle Pulse timeline icon anchor */}
|
||||
<div 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">
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-amber-500" />
|
||||
</div>
|
||||
|
||||
{/* Pre-warning card */}
|
||||
<div 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">
|
||||
|
||||
{/* Top level details: Incident Name & Severity level */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<span className="font-mono text-[9px] text-slate-400 flex items-center space-x-1">
|
||||
<span>SEQ: {alert.seq}</span>
|
||||
<span>•</span>
|
||||
<span>{alert.timestamp}</span>
|
||||
</span>
|
||||
<h4 className="text-xs font-bold text-slate-900">{alert.ruleName}</h4>
|
||||
</div>
|
||||
|
||||
<span 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">
|
||||
{'命中提示'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Matched keyword badges */}
|
||||
<div className="mt-2.5 flex items-center space-x-1.5 text-xs">
|
||||
<span className="text-[10px] text-slate-400 font-bold uppercase tracking-wider">命中特征词:</span>
|
||||
<span className="rounded bg-rose-50 border border-rose-100 px-1.5 py-0.5 font-mono font-bold text-rose-700 text-[10px]">
|
||||
{alert.keyword}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Evidence block */}
|
||||
<div className="mt-2 bg-slate-50 rounded border border-slate-100 p-2.5 text-xs text-slate-600 leading-relaxed font-sans">
|
||||
<span className="font-bold text-[8px] text-slate-400 block mb-1 uppercase tracking-widest">
|
||||
ASR 实时听写片段:
|
||||
</span>
|
||||
<p>
|
||||
“ {alert.text.split(alert.keyword).map((chunk, i, arr) => (
|
||||
<React.Fragment key={i}>
|
||||
{chunk}
|
||||
{i < arr.length - 1 && (
|
||||
<mark className="bg-rose-50 text-rose-700 font-bold border-b-2 border-rose-400 px-0.5 rounded">
|
||||
{alert.keyword}
|
||||
</mark>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))} ”
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
78
frontend-redesign/src/components/ToastContainer.tsx
Normal file
78
frontend-redesign/src/components/ToastContainer.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* @license
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import { CheckCircle, AlertTriangle, Info, X } from 'lucide-react';
|
||||
|
||||
export interface Toast {
|
||||
id: string;
|
||||
message: string;
|
||||
type: 'success' | 'warning' | 'info';
|
||||
}
|
||||
|
||||
interface ToastContainerProps {
|
||||
toasts: Toast[];
|
||||
removeToast: (id: string) => void;
|
||||
}
|
||||
|
||||
export default function ToastContainer({ toasts, removeToast }: ToastContainerProps) {
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 z-50 flex flex-col space-y-2.5 max-w-sm w-full pointer-events-none px-4 sm:px-0">
|
||||
<AnimatePresence>
|
||||
{toasts.map((toast) => {
|
||||
const isSuccess = toast.type === 'success';
|
||||
const isWarning = toast.type === 'warning';
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={toast.id}
|
||||
initial={{ opacity: 0, y: 20, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.9, y: -10 }}
|
||||
transition={{ type: 'spring', damping: 25, stiffness: 350 }}
|
||||
className={`pointer-events-auto flex items-start space-x-3 rounded-xl border p-4 shadow-lg backdrop-blur-md transition-all ${
|
||||
isSuccess
|
||||
? 'bg-emerald-50/95 border-emerald-200 text-emerald-900 shadow-emerald-500/10'
|
||||
: isWarning
|
||||
? 'bg-amber-50/95 border-amber-200 text-amber-900 shadow-amber-500/10'
|
||||
: 'bg-blue-50/95 border-blue-200 text-blue-900 shadow-blue-500/10'
|
||||
}`}
|
||||
>
|
||||
{/* Icon Indicator */}
|
||||
<div className="shrink-0 mt-0.5">
|
||||
{isSuccess ? (
|
||||
<CheckCircle className="h-5 w-5 text-emerald-600" />
|
||||
) : isWarning ? (
|
||||
<AlertTriangle className="h-5 w-5 text-amber-600 animate-bounce" />
|
||||
) : (
|
||||
<Info className="h-5 w-5 text-blue-600" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Message Block */}
|
||||
<div className="flex-1">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-gray-500">
|
||||
{isSuccess ? '成功 SUCCESS' : isWarning ? '报警 ALERT' : '系统提示 SYSTEM INFO'}
|
||||
</p>
|
||||
<p className="text-xs text-gray-700 mt-1 font-medium leading-relaxed">
|
||||
{toast.message}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Close Button */}
|
||||
<button
|
||||
onClick={() => removeToast(toast.id)}
|
||||
className="shrink-0 rounded-lg p-1 text-gray-400 hover:text-gray-700 hover:bg-black/5 transition"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
72
frontend-redesign/src/data.ts
Normal file
72
frontend-redesign/src/data.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* @license
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { Rule } from './types';
|
||||
|
||||
export const INITIAL_RULES: Rule[] = [
|
||||
{
|
||||
id: 'sf-express',
|
||||
name: '顺丰速递绿色渠道服务热线',
|
||||
keywords: ['顺丰', '顺丰快递', '顺丰速运'],
|
||||
enabled: true
|
||||
},
|
||||
{
|
||||
id: 'pdd',
|
||||
name: '拼多多平台热线',
|
||||
keywords: ['拼多多', '拼夕夕'],
|
||||
enabled: true
|
||||
},
|
||||
{
|
||||
id: 'ems',
|
||||
name: 'EMS绿色通道',
|
||||
keywords: ['EMS', '邮政速递'],
|
||||
enabled: true
|
||||
}
|
||||
];
|
||||
|
||||
export interface PresetScript {
|
||||
title: string;
|
||||
description: string;
|
||||
lines: string; // Text block of raw script lines (each line is "role: text")
|
||||
}
|
||||
|
||||
export const PRESET_SCRIPTS: PresetScript[] = [
|
||||
{
|
||||
title: '顺丰急件高考准考证调度',
|
||||
description: '模拟市民为准考证加急,坐席确认开启绿色通道的 ASR 识别流。触发:顺丰速递绿色渠道。',
|
||||
lines: `市民: 喂,你好!我的准考证好像在快递里,现在还没送到,我真的急死了!
|
||||
坐席: 别着急女士,请问您选择的是哪家物流?我们马上帮您联系。
|
||||
市民: 是顺丰快递。今天下午就要模拟考试了,没有准考证我进不去。
|
||||
坐席: 好的,顺丰速运是有我们的加急绿色渠道对接的。请您报一下单号。
|
||||
市民: 单号是 SF198273645,老师说应该已经到网点了,但还没有派件。
|
||||
坐席: 收到。我这边已经在系统里录入,立刻为您开启 顺丰速递绿色渠道 应急加急指令。
|
||||
市民: 太感谢了!那大概多久能拿到呢?
|
||||
坐席: 我们会通知离您最近的顺丰快递网点,安排快递专人进行派送,预计30分钟内送到您手上。`
|
||||
},
|
||||
{
|
||||
title: '小区居民楼燃气泄露紧急排查',
|
||||
description: '模拟楼道内强烈的天然气气味,呼叫抢修并触发警报。触发:火灾险情监控、市政燃气水电紧急报修。',
|
||||
lines: `市民: 喂!物业吗?二号楼三单元这边全是煤气味,特别刺鼻!
|
||||
坐席: 收到!请问您现在在具体哪个位置?闻到浓烟或者看到起火了吗?
|
||||
市民: 我在二楼楼梯间。没看到明火,但是气味大到眼睛都疼了,我怀疑是燃气泄漏!
|
||||
坐席: 明白。请您和周围邻居立刻疏散到楼外的开阔空地,千万不要使用手机或电梯,也不要开关任何电灯!
|
||||
市民: 好的,我已经通知邻居开始往下跑了,我们已经在楼下草坪。
|
||||
坐席: 极好。我已经为您记录并上报了燃气泄漏和严重隐患事件,抢修队立刻赶往现场。
|
||||
坐席: 同时,我这边已经通知119消防编组和燃气集团,消防车已经拉响警报出发了。
|
||||
市民: 听到消防车的声音了,你们速度真快。
|
||||
坐席: 保持安全距离,我们的专业人员会在10分钟内到位切断主阀门。`
|
||||
},
|
||||
{
|
||||
title: '邻里夜间噪音与垃圾乱堆投诉',
|
||||
description: '日常噪音扰民及不作为投诉。触发:恶性扰民与垃圾投诉。在后续发言中重现关键词,触发会话级拦截。',
|
||||
lines: `市民: 喂,我想反映一下,我们小区楼下有人大半夜在施工噪音扰民,根本睡不着!
|
||||
坐席: 您好,十分抱歉给您的生活造成困扰,请问具体的施工地址和目前的时间是?
|
||||
市民: 就是在幸福路12号,都已经深夜11点了还在打钻,垃圾堆积得到处都是,没人管。
|
||||
坐席: 了解。关于夜间施工噪音扰民,以及垃圾堆积不作为问题,我们城管中队已做记录。
|
||||
市民: 真是气人,这个问题我已经投诉过好几次了,城管都不怎么管。
|
||||
坐席: 抱歉女士,本次已标记为重点投诉。城管队员正在路口巡逻,15分钟内过去责令停工。
|
||||
市民: 好的,那我就等你们的反馈,别再让我继续听到那些噪音和投诉没动静了。`
|
||||
}
|
||||
];
|
||||
43
frontend-redesign/src/index.css
Normal file
43
frontend-redesign/src/index.css
Normal file
@@ -0,0 +1,43 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap');
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
|
||||
--font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, monospace;
|
||||
}
|
||||
|
||||
/* Custom Scrollbars for a high-density, professional UI */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(156, 163, 175, 0.25);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(156, 163, 175, 0.45);
|
||||
}
|
||||
|
||||
/* Custom styles for breathing effects and animations */
|
||||
@keyframes breathe {
|
||||
0%, 100% {
|
||||
transform: scale(1);
|
||||
box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.4);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.02);
|
||||
box-shadow: 0 0 12px 4px rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-breathe {
|
||||
animation: breathe 2s infinite ease-in-out;
|
||||
}
|
||||
|
||||
10
frontend-redesign/src/main.tsx
Normal file
10
frontend-redesign/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import {StrictMode} from 'react';
|
||||
import {createRoot} from 'react-dom/client';
|
||||
import App from './App.tsx';
|
||||
import './index.css';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
37
frontend-redesign/src/types.ts
Normal file
37
frontend-redesign/src/types.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* @license
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
export interface Rule {
|
||||
id: string;
|
||||
name: string;
|
||||
keywords: string[];
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface DialogLine {
|
||||
id: string;
|
||||
role: 'agent' | 'citizen';
|
||||
text: string;
|
||||
seq: number;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface Alert {
|
||||
id: string;
|
||||
ruleId: string;
|
||||
ruleName: string;
|
||||
keyword: string; // The matched keyword
|
||||
text: string; // Evidence (the matched statement)
|
||||
seq: number;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface SandboxSession {
|
||||
callId: string;
|
||||
status: 'idle' | 'listening' | 'completed';
|
||||
lines: DialogLine[];
|
||||
alerts: Alert[];
|
||||
|
||||
}
|
||||
71
frontend-redesign/temp.patch
Normal file
71
frontend-redesign/temp.patch
Normal file
@@ -0,0 +1,71 @@
|
||||
--- src/data.ts
|
||||
+++ src/data.ts
|
||||
@@ -10,48 +10,24 @@
|
||||
{
|
||||
id: 'sf-express',
|
||||
- name: '顺丰速递绿色渠道',
|
||||
- keywords: ['顺丰', '快递', '绿色通道', '速递', '绿色渠道', '寄件', '派件'],
|
||||
+ name: '顺丰速递绿色渠道服务热线',
|
||||
+ keywords: ['顺丰', '顺丰快递', '顺丰速运'],
|
||||
enabled: true,
|
||||
category: '快递物流',
|
||||
- severity: 'medium',
|
||||
- description: '针对救灾物资、准考证、紧急公文等顺丰速运渠道进行的高级绿色通关及加急配送事件监控。'
|
||||
+ severity: 'high'
|
||||
},
|
||||
{
|
||||
- id: 'emergency-help',
|
||||
- name: '市民紧急救助',
|
||||
- keywords: ['救命', '报警', '120', '救护车', '晕倒', '大出血', '呼吸困难', '心梗', '生命危险'],
|
||||
+ id: 'pdd',
|
||||
+ name: '拼多多平台热线',
|
||||
+ keywords: ['拼多多', '拼夕夕'],
|
||||
enabled: true,
|
||||
- category: '紧急生命安全',
|
||||
- severity: 'high',
|
||||
- description: '市民来电中涉及突发重症、严重人身伤害等需要立刻呼叫120或紧急联动的突发情况。'
|
||||
+ category: '电商平台',
|
||||
+ severity: 'medium'
|
||||
},
|
||||
{
|
||||
- id: 'fire-alarm',
|
||||
- name: '火灾险情监控',
|
||||
- keywords: ['起火', '着火', '浓烟', '火灾', '爆炸', '自燃', '煤气罐', '电线短路'],
|
||||
+ id: 'ems',
|
||||
+ name: 'EMS绿色通道',
|
||||
+ keywords: ['EMS', '邮政速递'],
|
||||
enabled: true,
|
||||
- category: '公共安全',
|
||||
- severity: 'high',
|
||||
- description: '检测市民来电、坐席记录中涉及建筑物起火、森林火灾、严重浓烟以及易燃易爆气体泄漏。'
|
||||
- },
|
||||
- {
|
||||
- id: 'utility-repair',
|
||||
- name: '市政燃气水电紧急报修',
|
||||
- keywords: ['漏水', '停电', '燃气泄漏', '断水', '停水', '水管爆裂', '高压线', '井盖缺失'],
|
||||
- enabled: true,
|
||||
- category: '市政基础设施',
|
||||
- severity: 'medium',
|
||||
- description: '公共基础设施大面积故障或直接危害公共安全的报修需求,如燃气管道泄露、高空坠物、大面积停水断电。'
|
||||
- },
|
||||
- {
|
||||
- id: 'complaint-feedback',
|
||||
- name: '恶性扰民与垃圾投诉',
|
||||
- keywords: ['投诉', '垃圾堆积', '噪音扰民', '乱停车', '不作为', '恶意占道', '施工噪音'],
|
||||
- enabled: true,
|
||||
- category: '城市治理',
|
||||
- severity: 'low',
|
||||
- description: '社会综合治理层面的民生噪音、垃圾分类及占道经营等常规投诉。'
|
||||
- },
|
||||
- {
|
||||
- id: 'gov-consult',
|
||||
- name: '政务社保公积金咨询',
|
||||
- keywords: ['公积金', '社保', '办证', '身份证', '户口', '营业执照', '养老金', '医保'],
|
||||
- enabled: false,
|
||||
- category: '政务咨询',
|
||||
- severity: 'low',
|
||||
- description: '市民关于居民医保、社保缴纳、住房公积金提取、身份证及落户等常规政策性问题咨询。'
|
||||
+ category: '快递物流',
|
||||
+ severity: 'medium'
|
||||
}
|
||||
];
|
||||
26
frontend-redesign/tsconfig.json
Normal file
26
frontend-redesign/tsconfig.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"experimentalDecorators": true,
|
||||
"useDefineForClassFields": false,
|
||||
"module": "ESNext",
|
||||
"lib": [
|
||||
"ES2022",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"allowJs": true,
|
||||
"jsx": "react-jsx",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
},
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true
|
||||
}
|
||||
}
|
||||
22
frontend-redesign/vite.config.ts
Normal file
22
frontend-redesign/vite.config.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
import {defineConfig} from 'vite';
|
||||
|
||||
export default defineConfig(() => {
|
||||
return {
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, '.'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
// HMR is disabled in AI Studio via DISABLE_HMR env var.
|
||||
// Do not modifyâfile watching is disabled to prevent flickering during agent edits.
|
||||
hmr: process.env.DISABLE_HMR !== 'true',
|
||||
// Disable file watching when DISABLE_HMR is true to save CPU during agent edits.
|
||||
watch: process.env.DISABLE_HMR === 'true' ? null : {},
|
||||
},
|
||||
};
|
||||
});
|
||||
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
75
frontend/README.md
Normal file
75
frontend/README.md
Normal file
@@ -0,0 +1,75 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
```
|
||||
25
frontend/components.json
Normal file
25
frontend/components.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "radix-nova",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/index.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"rtl": false,
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"menuColor": "default",
|
||||
"menuAccent": "subtle",
|
||||
"registries": {}
|
||||
}
|
||||
22
frontend/eslint.config.js
Normal file
22
frontend/eslint.config.js
Normal file
@@ -0,0 +1,22 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>frontend</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
32
frontend/npm-cn
Executable file
32
frontend/npm-cn
Executable file
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run npm/npx with Chinese DNS overrides (WSL nameserver often hangs).
|
||||
set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "$0")" && pwd)"
|
||||
REAL_HOME="${HOME}"
|
||||
DNS_DIR="${TMPDIR:-/tmp}/npm-dns-fix"
|
||||
mkdir -p "$DNS_DIR"
|
||||
printf 'nameserver 223.5.5.5\nnameserver 119.29.29.29\noptions timeout:2 attempts:2\n' > "$DNS_DIR/resolv.conf"
|
||||
cat > "$DNS_DIR/hosts" <<'EOF'
|
||||
127.0.0.1 localhost
|
||||
::1 localhost
|
||||
104.16.2.34 registry.npmjs.org
|
||||
76.76.21.61 ui.shadcn.com
|
||||
66.33.60.130 ui.shadcn.com
|
||||
151.101.65.229 cdn.jsdelivr.net
|
||||
47.96.233.62 npmmirror.com
|
||||
EOF
|
||||
|
||||
CMD="${1:?usage: npm-cn <npm|npx> ...}"
|
||||
shift
|
||||
|
||||
if unshare --user --map-root-user --mount true 2>/dev/null; then
|
||||
exec unshare --user --map-root-user --mount bash -c "
|
||||
mount --bind '$DNS_DIR/resolv.conf' /etc/resolv.conf
|
||||
mount --bind '$DNS_DIR/hosts' /etc/hosts
|
||||
export HOME='$REAL_HOME'
|
||||
cd '$ROOT'
|
||||
exec '$CMD' \"\$@\"
|
||||
" bash "$@"
|
||||
else
|
||||
exec "$CMD" "$@"
|
||||
fi
|
||||
8017
frontend/package-lock.json
generated
Normal file
8017
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
42
frontend/package.json
Normal file
42
frontend/package.json
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/geist": "^5.2.9",
|
||||
"@tailwindcss/vite": "^4.3.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^1.24.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"radix-ui": "^1.6.2",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"shadcn": "^4.13.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tailwindcss": "^4.3.2",
|
||||
"tw-animate-css": "^1.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@types/node": "^24.13.2",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.3",
|
||||
"eslint": "^10.6.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.3",
|
||||
"globals": "^17.7.0",
|
||||
"typescript": "~6.0.2",
|
||||
"typescript-eslint": "^8.62.0",
|
||||
"vite": "^8.1.1"
|
||||
}
|
||||
}
|
||||
1
frontend/public/favicon.svg
Normal file
1
frontend/public/favicon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
24
frontend/public/icons.svg
Normal file
24
frontend/public/icons.svg
Normal file
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
52
frontend/src/App.tsx
Normal file
52
frontend/src/App.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
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"
|
||||
|
||||
export default function App() {
|
||||
const [version, setVersion] = useState<number | null>(null)
|
||||
const [tab, setTab] = useState("rules")
|
||||
|
||||
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>
|
||||
|
||||
<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")}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="demo" className="mt-6">
|
||||
<MatchTester activeVersion={version} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</main>
|
||||
</div>
|
||||
<Toaster richColors position="top-center" />
|
||||
</ThemeProvider>
|
||||
)
|
||||
}
|
||||
106
frontend/src/api/rule-api.ts
Normal file
106
frontend/src/api/rule-api.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
export type EventRule = {
|
||||
id: string
|
||||
name: string
|
||||
enabled: boolean
|
||||
keywords: string[]
|
||||
}
|
||||
|
||||
export type RuleSet = {
|
||||
id: string
|
||||
name: string
|
||||
enabled: boolean
|
||||
matcher: string
|
||||
rules: EventRule[]
|
||||
}
|
||||
|
||||
export type CurrentRulesResponse = {
|
||||
version: number
|
||||
ruleCount: number
|
||||
keywordCount: number
|
||||
ruleSets: RuleSet[]
|
||||
}
|
||||
|
||||
export type SaveRuleResponse = {
|
||||
version: number
|
||||
ruleCount: number
|
||||
keywordCount: number
|
||||
warnings: string[]
|
||||
ruleSets: RuleSet[]
|
||||
}
|
||||
|
||||
export type MatchResult = {
|
||||
eventType: string
|
||||
eventId: string
|
||||
eventName: string
|
||||
matchedKeywords: string[]
|
||||
}
|
||||
|
||||
export type TestRuleResponse = {
|
||||
activeVersion: number
|
||||
normalizedText: string
|
||||
matches: MatchResult[]
|
||||
}
|
||||
|
||||
export type ApiErrorBody = {
|
||||
code?: string
|
||||
message?: string
|
||||
currentVersion?: number
|
||||
fields?: Record<string, string>
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
readonly status: number
|
||||
readonly body: ApiErrorBody
|
||||
|
||||
constructor(status: number, body: ApiErrorBody) {
|
||||
super(body.message ?? `Request failed (${status})`)
|
||||
this.name = "ApiError"
|
||||
this.status = status
|
||||
this.body = body
|
||||
}
|
||||
}
|
||||
|
||||
async function parseError(response: Response): Promise<ApiError> {
|
||||
let body: ApiErrorBody = {}
|
||||
try {
|
||||
body = (await response.json()) as ApiErrorBody
|
||||
} catch {
|
||||
body = { message: response.statusText }
|
||||
}
|
||||
return new ApiError(response.status, body)
|
||||
}
|
||||
|
||||
export async function fetchCurrentRules(): Promise<CurrentRulesResponse> {
|
||||
const response = await fetch("/api/v1/admin/rules")
|
||||
if (!response.ok) {
|
||||
throw await parseError(response)
|
||||
}
|
||||
return (await response.json()) as CurrentRulesResponse
|
||||
}
|
||||
|
||||
export async function saveAndActivateRules(
|
||||
baseVersion: number,
|
||||
ruleSets: RuleSet[],
|
||||
): Promise<SaveRuleResponse> {
|
||||
const response = await fetch("/api/v1/admin/rules", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ baseVersion, ruleSets }),
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw await parseError(response)
|
||||
}
|
||||
return (await response.json()) as SaveRuleResponse
|
||||
}
|
||||
|
||||
export async function testRules(text: string): Promise<TestRuleResponse> {
|
||||
const response = await fetch("/api/v1/admin/rules/test", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ text }),
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw await parseError(response)
|
||||
}
|
||||
return (await response.json()) as TestRuleResponse
|
||||
}
|
||||
BIN
frontend/src/assets/hero.png
Normal file
BIN
frontend/src/assets/hero.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
1
frontend/src/assets/react.svg
Normal file
1
frontend/src/assets/react.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
1
frontend/src/assets/vite.svg
Normal file
1
frontend/src/assets/vite.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
142
frontend/src/components/MatchTester.tsx
Normal file
142
frontend/src/components/MatchTester.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import { useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { ApiError, testRules, type MatchResult } from "@/api/rule-api"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
|
||||
type MatchTesterProps = {
|
||||
activeVersion: number | null
|
||||
}
|
||||
|
||||
export function MatchTester({ activeVersion }: MatchTesterProps) {
|
||||
const [text, setText] = useState("我这个顺丰快递一直没有收到")
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [normalizedText, setNormalizedText] = useState<string | null>(null)
|
||||
const [testedVersion, setTestedVersion] = useState<number | null>(null)
|
||||
const [matches, setMatches] = useState<MatchResult[] | null>(null)
|
||||
|
||||
async function handleTest() {
|
||||
const trimmed = text.trim()
|
||||
if (!trimmed) {
|
||||
toast.error("请输入测试文本")
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const result = await testRules(trimmed)
|
||||
setNormalizedText(result.normalizedText)
|
||||
setTestedVersion(result.activeVersion)
|
||||
setMatches(result.matches)
|
||||
if (result.matches.length === 0) {
|
||||
toast.message("未命中任何规则")
|
||||
} else {
|
||||
toast.success(`命中 ${result.matches.length} 个事件`)
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof ApiError ? error.message : "检测失败"
|
||||
toast.error(message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
function handleClear() {
|
||||
setText("")
|
||||
setNormalizedText(null)
|
||||
setTestedVersion(null)
|
||||
setMatches(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold tracking-tight">匹配验证</h2>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
验证后端当前真正生效的 AC 自动机(无状态,不做通话去重)
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{activeVersion == null
|
||||
? "版本未知"
|
||||
: `生效版本 V${activeVersion}`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>测试文本</CardTitle>
|
||||
<CardDescription>模拟一条市民侧 ASR Final</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Textarea
|
||||
value={text}
|
||||
rows={4}
|
||||
placeholder="例如:我这个顺丰快递一直没有收到"
|
||||
onChange={(event) => setText(event.target.value)}
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={handleClear}>
|
||||
清空
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={loading}
|
||||
onClick={() => void handleTest()}
|
||||
>
|
||||
{loading ? "检测中…" : "开始检测"}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{matches != null ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>检测结果</CardTitle>
|
||||
<CardDescription>
|
||||
{testedVersion != null ? `使用版本 V${testedVersion}` : null}
|
||||
{normalizedText ? ` · 标准化:${normalizedText}` : null}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{matches.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">未命中潜在转接方向</p>
|
||||
) : (
|
||||
matches.map((match) => (
|
||||
<div
|
||||
key={`${match.eventType}:${match.eventId}`}
|
||||
className="rounded-lg border p-4"
|
||||
>
|
||||
<p className="text-sm font-medium text-emerald-700">
|
||||
✓ 命中潜在转接方向
|
||||
</p>
|
||||
<p className="mt-2 text-base font-semibold">{match.eventName}</p>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
事件 ID:{match.eventId}
|
||||
</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{match.matchedKeywords.map((keyword) => (
|
||||
<Badge key={keyword} variant="secondary">
|
||||
{keyword}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
199
frontend/src/components/RuleDialog.tsx
Normal file
199
frontend/src/components/RuleDialog.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { XIcon } from "lucide-react"
|
||||
import type { EventRule } from "@/api/rule-api"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
|
||||
type RuleDialogProps = {
|
||||
open: boolean
|
||||
mode: "create" | "edit"
|
||||
initial?: EventRule | null
|
||||
existingIds: string[]
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSubmit: (rule: EventRule) => void
|
||||
}
|
||||
|
||||
function slugify(value: string): string {
|
||||
return value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\u4e00-\u9fff]+/gi, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 64)
|
||||
}
|
||||
|
||||
export function RuleDialog({
|
||||
open,
|
||||
mode,
|
||||
initial,
|
||||
existingIds,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
}: RuleDialogProps) {
|
||||
const [id, setId] = useState("")
|
||||
const [name, setName] = useState("")
|
||||
const [keywords, setKeywords] = useState<string[]>([])
|
||||
const [keywordDraft, setKeywordDraft] = useState("")
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [idTouched, setIdTouched] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return
|
||||
}
|
||||
setId(initial?.id ?? "")
|
||||
setName(initial?.name ?? "")
|
||||
setKeywords(initial?.keywords ?? [])
|
||||
setKeywordDraft("")
|
||||
setError(null)
|
||||
setIdTouched(mode === "edit")
|
||||
}, [open, initial, mode])
|
||||
|
||||
function addKeyword() {
|
||||
const value = keywordDraft.trim()
|
||||
if (!value) {
|
||||
return
|
||||
}
|
||||
if (keywords.includes(value)) {
|
||||
setError(`关键词已存在:${value}`)
|
||||
return
|
||||
}
|
||||
setKeywords((prev) => [...prev, value])
|
||||
setKeywordDraft("")
|
||||
setError(null)
|
||||
}
|
||||
|
||||
function removeKeyword(keyword: string) {
|
||||
setKeywords((prev) => prev.filter((item) => item !== keyword))
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
const trimmedName = name.trim()
|
||||
const trimmedId = (
|
||||
mode === "create" ? id.trim() || slugify(trimmedName) : id
|
||||
).trim()
|
||||
|
||||
if (!trimmedName) {
|
||||
setError("事件名称不能为空")
|
||||
return
|
||||
}
|
||||
if (!trimmedId) {
|
||||
setError("事件 ID 不能为空")
|
||||
return
|
||||
}
|
||||
if (mode === "create" && existingIds.includes(trimmedId)) {
|
||||
setError(`事件 ID 已存在:${trimmedId}`)
|
||||
return
|
||||
}
|
||||
if (keywords.length === 0) {
|
||||
setError("至少需要一个关键词")
|
||||
return
|
||||
}
|
||||
|
||||
onSubmit({
|
||||
id: trimmedId,
|
||||
name: trimmedName,
|
||||
enabled: initial?.enabled ?? true,
|
||||
keywords,
|
||||
})
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{mode === "create" ? "新增规则" : "编辑规则"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
配置潜在转接方向的事件名称与关键词。页面点“保存并生效”后才会真正生效。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 py-2">
|
||||
<label className="grid gap-2 text-sm">
|
||||
<span className="font-medium">事件名称</span>
|
||||
<Input
|
||||
value={name}
|
||||
placeholder="例如:顺丰速递绿色渠道服务热线"
|
||||
onChange={(event) => {
|
||||
const nextName = event.target.value
|
||||
setName(nextName)
|
||||
if (mode === "create" && !idTouched) {
|
||||
setId(slugify(nextName))
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="grid gap-2 text-sm">
|
||||
<span className="font-medium">事件 ID</span>
|
||||
<Input
|
||||
value={id}
|
||||
disabled={mode === "edit"}
|
||||
placeholder="例如:sf-express"
|
||||
onChange={(event) => {
|
||||
setIdTouched(true)
|
||||
setId(event.target.value)
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="grid gap-2 text-sm">
|
||||
<span className="font-medium">关键词</span>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={keywordDraft}
|
||||
placeholder="输入关键词后回车或点击添加"
|
||||
onChange={(event) => setKeywordDraft(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault()
|
||||
addKeyword()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button type="button" variant="secondary" onClick={addKeyword}>
|
||||
添加
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex min-h-8 flex-wrap gap-2">
|
||||
{keywords.map((keyword) => (
|
||||
<Badge key={keyword} variant="secondary" className="gap-1 pr-1">
|
||||
{keyword}
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full p-0.5 hover:bg-black/10"
|
||||
aria-label={`删除关键词 ${keyword}`}
|
||||
onClick={() => removeKeyword(keyword)}
|
||||
>
|
||||
<XIcon className="size-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? <p className="text-destructive text-sm">{error}</p> : null}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="button" onClick={handleSubmit}>
|
||||
确定
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
342
frontend/src/components/RuleEditor.tsx
Normal file
342
frontend/src/components/RuleEditor.tsx
Normal file
@@ -0,0 +1,342 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { PencilIcon, PlusIcon, Trash2Icon, XIcon } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import {
|
||||
ApiError,
|
||||
fetchCurrentRules,
|
||||
saveAndActivateRules,
|
||||
type EventRule,
|
||||
type RuleSet,
|
||||
} from "@/api/rule-api"
|
||||
import { RuleDialog } from "@/components/RuleDialog"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
|
||||
const TRANSFER_SET_ID = "transfer"
|
||||
|
||||
type RuleEditorProps = {
|
||||
onVersionChange?: (version: number) => void
|
||||
onSaved?: () => void
|
||||
}
|
||||
|
||||
function emptyTransferSet(): RuleSet {
|
||||
return {
|
||||
id: TRANSFER_SET_ID,
|
||||
name: "潜在转接",
|
||||
enabled: true,
|
||||
matcher: "ac-keyword",
|
||||
rules: [],
|
||||
}
|
||||
}
|
||||
|
||||
export function RuleEditor({ onVersionChange, onSaved }: RuleEditorProps) {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [version, setVersion] = useState(0)
|
||||
const [ruleCount, setRuleCount] = useState(0)
|
||||
const [keywordCount, setKeywordCount] = useState(0)
|
||||
const [ruleSets, setRuleSets] = useState<RuleSet[]>([emptyTransferSet()])
|
||||
const [dirty, setDirty] = useState(false)
|
||||
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [dialogMode, setDialogMode] = useState<"create" | "edit">("create")
|
||||
const [editingRule, setEditingRule] = useState<EventRule | null>(null)
|
||||
|
||||
const [deleteTarget, setDeleteTarget] = useState<EventRule | null>(null)
|
||||
|
||||
const transferSet =
|
||||
ruleSets.find((set) => set.id === TRANSFER_SET_ID) ?? emptyTransferSet()
|
||||
const rules = transferSet.rules
|
||||
|
||||
async function loadRules() {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await fetchCurrentRules()
|
||||
const nextSets =
|
||||
data.ruleSets.length > 0 ? data.ruleSets : [emptyTransferSet()]
|
||||
setRuleSets(nextSets)
|
||||
setVersion(data.version)
|
||||
setRuleCount(data.ruleCount)
|
||||
setKeywordCount(data.keywordCount)
|
||||
setDirty(false)
|
||||
onVersionChange?.(data.version)
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof ApiError ? error.message : "加载规则失败"
|
||||
toast.error(message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void loadRules()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
function updateTransferRules(nextRules: EventRule[]) {
|
||||
setRuleSets((prev) => {
|
||||
const hasTransfer = prev.some((set) => set.id === TRANSFER_SET_ID)
|
||||
if (!hasTransfer) {
|
||||
return [{ ...emptyTransferSet(), rules: nextRules }, ...prev]
|
||||
}
|
||||
return prev.map((set) =>
|
||||
set.id === TRANSFER_SET_ID ? { ...set, rules: nextRules } : set,
|
||||
)
|
||||
})
|
||||
setDirty(true)
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
setDialogMode("create")
|
||||
setEditingRule(null)
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
function openEdit(rule: EventRule) {
|
||||
setDialogMode("edit")
|
||||
setEditingRule(rule)
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
function handleDialogSubmit(rule: EventRule) {
|
||||
if (dialogMode === "create") {
|
||||
updateTransferRules([...rules, rule])
|
||||
toast.success("已添加规则(尚未保存生效)")
|
||||
return
|
||||
}
|
||||
updateTransferRules(
|
||||
rules.map((item) => (item.id === rule.id ? { ...rule, enabled: item.enabled } : item)),
|
||||
)
|
||||
toast.success("已更新规则(尚未保存生效)")
|
||||
}
|
||||
|
||||
function toggleEnabled(ruleId: string, enabled: boolean) {
|
||||
updateTransferRules(
|
||||
rules.map((rule) => (rule.id === ruleId ? { ...rule, enabled } : rule)),
|
||||
)
|
||||
}
|
||||
|
||||
function removeKeyword(ruleId: string, keyword: string) {
|
||||
updateTransferRules(
|
||||
rules.map((rule) =>
|
||||
rule.id === ruleId
|
||||
? { ...rule, keywords: rule.keywords.filter((item) => item !== keyword) }
|
||||
: rule,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function confirmDelete() {
|
||||
if (!deleteTarget) {
|
||||
return
|
||||
}
|
||||
updateTransferRules(rules.filter((rule) => rule.id !== deleteTarget.id))
|
||||
toast.success(`已删除 ${deleteTarget.name}(尚未保存生效)`)
|
||||
setDeleteTarget(null)
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
setSaving(true)
|
||||
try {
|
||||
const result = await saveAndActivateRules(version, ruleSets)
|
||||
setVersion(result.version)
|
||||
setRuleCount(result.ruleCount)
|
||||
setKeywordCount(result.keywordCount)
|
||||
setRuleSets(result.ruleSets)
|
||||
setDirty(false)
|
||||
onVersionChange?.(result.version)
|
||||
toast.success(`已保存并生效:V${result.version}`)
|
||||
if (result.warnings?.length) {
|
||||
for (const warning of result.warnings) {
|
||||
toast.warning(warning)
|
||||
}
|
||||
}
|
||||
onSaved?.()
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.body.code === "RULE_VERSION_CONFLICT") {
|
||||
toast.error(error.message, {
|
||||
description: `当前版本已是 V${error.body.currentVersion},请刷新后重试`,
|
||||
action: {
|
||||
label: "刷新",
|
||||
onClick: () => {
|
||||
void loadRules()
|
||||
},
|
||||
},
|
||||
})
|
||||
} else {
|
||||
const message =
|
||||
error instanceof ApiError ? error.message : "保存失败"
|
||||
toast.error(message)
|
||||
}
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="text-muted-foreground py-16 text-center text-sm">
|
||||
正在加载规则…
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold tracking-tight">潜在转接规则</h2>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
共 {rules.length} 条事件
|
||||
{dirty ? " · 有未保存修改" : ""}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
生效版本 V{version} · {ruleCount} 规则 · {keywordCount} 关键词
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4">
|
||||
{rules.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="text-muted-foreground py-10 text-center">
|
||||
暂无规则,点击下方“新增规则”开始配置。
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
rules.map((rule) => (
|
||||
<Card key={rule.id}>
|
||||
<CardHeader className="border-b">
|
||||
<CardTitle>{rule.name}</CardTitle>
|
||||
<CardDescription>ID:{rule.id}</CardDescription>
|
||||
<CardAction className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground text-xs">启用</span>
|
||||
<Switch
|
||||
checked={rule.enabled}
|
||||
onCheckedChange={(checked) => toggleEnabled(rule.id, checked)}
|
||||
/>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-(--card-spacing)">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{rule.keywords.length === 0 ? (
|
||||
<span className="text-muted-foreground text-sm">暂无关键词</span>
|
||||
) : (
|
||||
rule.keywords.map((keyword) => (
|
||||
<Badge key={keyword} variant="secondary" className="gap-1 pr-1">
|
||||
{keyword}
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full p-0.5 hover:bg-black/10"
|
||||
aria-label={`删除关键词 ${keyword}`}
|
||||
onClick={() => removeKeyword(rule.id, keyword)}
|
||||
>
|
||||
<XIcon className="size-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => openEdit(rule)}
|
||||
>
|
||||
<PencilIcon />
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => setDeleteTarget(rule)}
|
||||
>
|
||||
<Trash2Icon />
|
||||
删除
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-t pt-4">
|
||||
<Button type="button" variant="secondary" onClick={openCreate}>
|
||||
<PlusIcon />
|
||||
新增规则
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={saving}
|
||||
onClick={() => void loadRules()}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
<Button type="button" disabled={saving} onClick={() => void handleSave()}>
|
||||
{saving ? "保存中…" : "保存并生效"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RuleDialog
|
||||
key={`${dialogMode}-${editingRule?.id ?? "new"}-${dialogOpen}`}
|
||||
open={dialogOpen}
|
||||
mode={dialogMode}
|
||||
initial={editingRule}
|
||||
existingIds={rules.map((rule) => rule.id)}
|
||||
onOpenChange={setDialogOpen}
|
||||
onSubmit={handleDialogSubmit}
|
||||
/>
|
||||
|
||||
<AlertDialog
|
||||
open={deleteTarget != null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setDeleteTarget(null)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除规则?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
将删除事件「{deleteTarget?.name}」。点击“保存并生效”后才会从后端移除。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction variant="destructive" onClick={confirmDelete}>
|
||||
删除
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
197
frontend/src/components/ui/alert-dialog.tsx
Normal file
197
frontend/src/components/ui/alert-dialog.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
import * as React from "react"
|
||||
import { AlertDialog as AlertDialogPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
function AlertDialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
||||
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
|
||||
}
|
||||
|
||||
function AlertDialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
data-slot="alert-dialog-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogContent({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Content> & {
|
||||
size?: "default" | "sm"
|
||||
}) {
|
||||
return (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
data-slot="alert-dialog-content"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogHeader({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-header"
|
||||
className={cn(
|
||||
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-4 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogFooter({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-footer"
|
||||
className={cn(
|
||||
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogMedia({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-media"
|
||||
className={cn(
|
||||
"mb-2 inline-flex size-10 items-center justify-center rounded-md bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-6",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Title
|
||||
data-slot="alert-dialog-title"
|
||||
className={cn(
|
||||
"font-heading text-base font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Description
|
||||
data-slot="alert-dialog-description"
|
||||
className={cn(
|
||||
"text-sm text-balance text-muted-foreground md:text-pretty *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogAction({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Action> &
|
||||
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
|
||||
return (
|
||||
<Button variant={variant} size={size} asChild>
|
||||
<AlertDialogPrimitive.Action
|
||||
data-slot="alert-dialog-action"
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogCancel({
|
||||
className,
|
||||
variant = "outline",
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel> &
|
||||
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
|
||||
return (
|
||||
<Button variant={variant} size={size} asChild>
|
||||
<AlertDialogPrimitive.Cancel
|
||||
data-slot="alert-dialog-cancel"
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogMedia,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogPortal,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
}
|
||||
49
frontend/src/components/ui/badge.tsx
Normal file
49
frontend/src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
|
||||
outline:
|
||||
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"span"> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot.Root : "span"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
data-variant={variant}
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
67
frontend/src/components/ui/button.tsx
Normal file
67
frontend/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
outline:
|
||||
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default:
|
||||
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
icon: "size-8",
|
||||
"icon-xs":
|
||||
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm":
|
||||
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
||||
"icon-lg": "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot.Root : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
103
frontend/src/components/ui/card.tsx
Normal file
103
frontend/src/components/ui/card.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Card({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-xl bg-card py-(--card-spacing) text-sm text-card-foreground ring-1 ring-foreground/10 [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-(--card-spacing) has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-(--card-spacing)",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn(
|
||||
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-(--card-spacing)", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn(
|
||||
"flex items-center rounded-b-xl border-t bg-muted/50 p-(--card-spacing)",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
168
frontend/src/components/ui/dialog.tsx
Normal file
168
frontend/src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Dialog as DialogPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close data-slot="dialog-close" asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="absolute top-2 right-2"
|
||||
size="icon-sm"
|
||||
>
|
||||
<XIcon
|
||||
/>
|
||||
<span className="sr-only">Close</span>
|
||||
</Button>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogFooter({
|
||||
className,
|
||||
showCloseButton = false,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close asChild>
|
||||
<Button variant="outline">Close</Button>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn(
|
||||
"font-heading text-base leading-none font-medium",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn(
|
||||
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
19
frontend/src/components/ui/input.tsx
Normal file
19
frontend/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
47
frontend/src/components/ui/sonner.tsx
Normal file
47
frontend/src/components/ui/sonner.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { useTheme } from "next-themes"
|
||||
import { Toaster as Sonner, type ToasterProps } from "sonner"
|
||||
import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = "system" } = useTheme()
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
className="toaster group"
|
||||
icons={{
|
||||
success: (
|
||||
<CircleCheckIcon className="size-4" />
|
||||
),
|
||||
info: (
|
||||
<InfoIcon className="size-4" />
|
||||
),
|
||||
warning: (
|
||||
<TriangleAlertIcon className="size-4" />
|
||||
),
|
||||
error: (
|
||||
<OctagonXIcon className="size-4" />
|
||||
),
|
||||
loading: (
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
),
|
||||
}}
|
||||
style={
|
||||
{
|
||||
"--normal-bg": "var(--popover)",
|
||||
"--normal-text": "var(--popover-foreground)",
|
||||
"--normal-border": "var(--border)",
|
||||
"--border-radius": "var(--radius)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
toast: "cn-toast",
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Toaster }
|
||||
33
frontend/src/components/ui/switch.tsx
Normal file
33
frontend/src/components/ui/switch.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Switch as SwitchPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Switch({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SwitchPrimitive.Root> & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SwitchPrimitive.Root
|
||||
data-slot="switch"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:bg-primary data-unchecked:bg-input dark:data-unchecked:bg-input/80 data-disabled:cursor-not-allowed data-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
data-slot="switch-thumb"
|
||||
className="pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] dark:data-checked:bg-primary-foreground group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 dark:data-unchecked:bg-foreground"
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Switch }
|
||||
88
frontend/src/components/ui/tabs.tsx
Normal file
88
frontend/src/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Tabs as TabsPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
data-orientation={orientation}
|
||||
className={cn(
|
||||
"group/tabs flex gap-2 data-horizontal:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const tabsListVariants = cva(
|
||||
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-muted",
|
||||
line: "gap-1 bg-transparent",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.List> &
|
||||
VariantProps<typeof tabsListVariants>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
data-variant={variant}
|
||||
className={cn(tabsListVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
|
||||
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
|
||||
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
data-slot="tabs-content"
|
||||
className={cn("flex-1 text-sm outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
|
||||
18
frontend/src/components/ui/textarea.tsx
Normal file
18
frontend/src/components/ui/textarea.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Textarea }
|
||||
130
frontend/src/index.css
Normal file
130
frontend/src/index.css
Normal file
@@ -0,0 +1,130 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
@import "@fontsource-variable/geist";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--font-heading: var(--font-sans);
|
||||
--font-sans: 'Geist Variable', sans-serif;
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-background: var(--background);
|
||||
--radius-sm: calc(var(--radius) * 0.6);
|
||||
--radius-md: calc(var(--radius) * 0.8);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) * 1.4);
|
||||
--radius-2xl: calc(var(--radius) * 1.8);
|
||||
--radius-3xl: calc(var(--radius) * 2.2);
|
||||
--radius-4xl: calc(var(--radius) * 2.6);
|
||||
}
|
||||
|
||||
:root {
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.87 0 0);
|
||||
--chart-2: oklch(0.556 0 0);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--radius: 0.625rem;
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.87 0 0);
|
||||
--chart-2: oklch(0.556 0 0);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
html {
|
||||
@apply font-sans;
|
||||
}
|
||||
}
|
||||
6
frontend/src/lib/utils.ts
Normal file
6
frontend/src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
10
frontend/src/main.tsx
Normal file
10
frontend/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
31
frontend/tsconfig.app.json
Normal file
31
frontend/tsconfig.app.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023", "DOM"],
|
||||
"module": "esnext",
|
||||
"types": ["vite/client"],
|
||||
"allowArbitraryExtensions": true,
|
||||
"skipLibCheck": true,
|
||||
"ignoreDeprecations": "6.0",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
7
frontend/tsconfig.json
Normal file
7
frontend/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
23
frontend/tsconfig.node.json
Normal file
23
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023"],
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"module": "nodenext",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
24
frontend/vite.config.ts
Normal file
24
frontend/vite.config.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
import react from "@vitejs/plugin-react"
|
||||
import tailwindcss from "@tailwindcss/vite"
|
||||
import { defineConfig } from "vite"
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: "http://localhost:8080",
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user