Add frontend app and redesign prototype for rule management UI.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Xin Wang
2026-07-16 11:24:02 +08:00
parent 64d5b82560
commit d093f95197
82 changed files with 12853 additions and 0 deletions

5
.gitignore vendored
View File

@@ -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/

View 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
View File

@@ -0,0 +1,8 @@
node_modules/
build/
dist/
coverage/
.DS_Store
*.log
.env*
!.env.example

View 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`

View 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>

View File

@@ -0,0 +1,6 @@
{
"name": "ASR 事件监控中心",
"description": "极简、高效、响应式的 ASR 实时事件监控与沙盒模拟服务前端系统",
"requestFramePermissions": [],
"majorCapabilities": ["MAJOR_CAPABILITY_SERVER_SIDE_GEMINI_API"]
}

View 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"
}
}

View 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)

View 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)

View 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)

View 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)

View 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)

View 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)

View 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)

View 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)

View 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)

View 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)

View 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)

View 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)

View 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)

View 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)

View 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)

View 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)

View 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)

View 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)

View 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)

View 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)

View 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)

View 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)

View 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)

View 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)

View 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)

View 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)

View 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)

View 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)

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

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

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

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

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

View 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分钟内过去责令停工。
市民: 好的,那我就等你们的反馈,别再让我继续听到那些噪音和投诉没动静了。`
}
];

View 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;
}

View 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>,
);

View 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[];
}

View 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'
}
];

View 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
}
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

42
frontend/package.json Normal file
View 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"
}
}

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
View 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
View 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>
)
}

View 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
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View 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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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,
}

View 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 }

View 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 }

View 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,
}

View 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,
}

View 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 }

View 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 }

View 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 }

View 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 }

View 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
View 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;
}
}

View 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
View 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>,
)

View 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
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View 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
View 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,
},
},
},
})