Redesign frontend

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

View File

@@ -512,7 +512,8 @@ E M S → ems
## 11. 最近 Final 上下文
匹配文本使用最近五条市民 ASR Final。
市民和坐席文本都参与匹配。同一次通话维护一个共享上下文窗口,
匹配文本使用该通话最近五条 ASR Final。
目的:降低 ASR 切段造成的漏判。
@@ -540,20 +541,20 @@ Final 28多多上买的
会话中按 seq 保存:
```java
private final NavigableMap<Long, String> recentCitizenFinals =
private final NavigableMap<Long, String> recentFinals =
new TreeMap<>();
```
```java
public void appendCitizenFinal(
public void appendFinal(
long seq,
String text,
int windowSize
) {
recentCitizenFinals.put(seq, text);
recentFinals.put(seq, text);
while (recentCitizenFinals.size() > windowSize) {
recentCitizenFinals.pollFirstEntry();
while (recentFinals.size() > windowSize) {
recentFinals.pollFirstEntry();
}
}
```
@@ -969,7 +970,33 @@ PUT /api/v1/admin/rules
}
```
### 17.3 测试当前生效规则
### 17.3 查询历史版本
```http
GET /api/v1/admin/rules/versions?limit=20
```
按版本号倒序返回版本摘要,包括版本号、是否生效、规则数、关键词数和创建时间。
### 17.4 回溯历史版本
```http
POST /api/v1/admin/rules/versions/{version}/restore
```
请求:
```json
{
"baseVersion": 8
}
```
回溯不会重新激活或修改旧记录。系统复制目标历史版本的规则内容,
创建并发布一个新的递增版本。例如当前 V8 回溯 V3最终创建并生效 V9。
`baseVersion` 继续用于乐观并发控制。
### 17.5 测试当前生效规则
```http
POST /api/v1/admin/rules/test
@@ -1376,7 +1403,7 @@ java \
3. 同一通话后续出现“拼多多”产生新增提醒
4. 同一个 `callId + seq` 重复提交返回 `duplicate=true`
5. 少量乱序请求仍可参与匹配
6. 坐席文本不参与匹配
6. 市民和坐席文本均可触发匹配,并共享同一通话上下文窗口
7. Partial 文本不参与匹配
8. `EMS``ems``E M S` 均可命中
9. 相邻 Final 拼接后可命中被切分的品牌

View File

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

View File

@@ -1,49 +0,0 @@
# ASR 事件监控前端
这是与仓库 Spring Boot 后端配套的新版 React + Vite 前端。
## 本地联调
先在仓库根目录启动后端(默认端口 `8080`),再启动前端:
```bash
cd frontend-redesign
npm install
npm run dev
```
访问 `http://localhost:3000`。Vite 会把 `/api` 请求代理到
`http://localhost:8080`,无需额外配置 CORS。
## 生产打包
```bash
cd frontend-redesign
npm run build
```
构建结果位于 `frontend-redesign/dist`。如需与 Spring Boot 打成同一个 JAR
在执行 Maven 打包前,将 `dist` 内的文件复制到:
```text
src/main/resources/static/
```
之后访问 Spring Boot 的 `http://localhost:8080/` 即可使用前端。
## 后端接口
- `GET /api/v1/admin/rules`:读取当前生效规则
- `PUT /api/v1/admin/rules`:保存并发布完整规则文档
- `POST /api/v1/asr-events`:提交 ASR Final 并获取会话告警
- `POST /api/v1/calls/{callId}/close`:关闭并释放通话会话
规则数据和生效版本以后端为准;浏览器不再通过 localStorage 模拟发布。
## Excel 导入导出
- “模板下载”生成 `.xlsx` 规则模板。
- 每行填写一条规则,多个关键词在同一个单元格内换行填写。
- “导入”会校验事件 ID、启用状态、关键词以及重复数据并替换页面工作副本。
- “导出”按照相同模板格式导出当前页面中的规则。
- 导入不会自动发布,确认数据后仍需点击“保存并发布”。

View File

@@ -1,13 +0,0 @@
<!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

@@ -1,36 +0,0 @@
{
"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",
"exceljs": "^4.4.0",
"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

@@ -1,25 +0,0 @@
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

@@ -1,11 +0,0 @@
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

@@ -1,10 +0,0 @@
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

@@ -1,19 +0,0 @@
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

@@ -1,11 +0,0 @@
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

@@ -1,32 +0,0 @@
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

@@ -1,26 +0,0 @@
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

@@ -1,9 +0,0 @@
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

@@ -1,11 +0,0 @@
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

@@ -1,14 +0,0 @@
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

@@ -1,38 +0,0 @@
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

@@ -1,9 +0,0 @@
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

@@ -1,10 +0,0 @@
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

@@ -1,11 +0,0 @@
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

@@ -1,9 +0,0 @@
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

@@ -1,106 +0,0 @@
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

@@ -1,7 +0,0 @@
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

@@ -1,87 +0,0 @@
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

@@ -1,18 +0,0 @@
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

@@ -1,87 +0,0 @@
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

@@ -1,44 +0,0 @@
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

@@ -1,46 +0,0 @@
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

@@ -1,55 +0,0 @@
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

@@ -1,16 +0,0 @@
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

@@ -1,13 +0,0 @@
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

@@ -1,13 +0,0 @@
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

@@ -1,9 +0,0 @@
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

@@ -1,9 +0,0 @@
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

@@ -1,178 +0,0 @@
/**
* @license
* SPDX-License-Identifier: Apache-2.0
*/
import React, { useState, useEffect, useMemo } from 'react';
import { motion, AnimatePresence } from 'motion/react';
import Header from './components/Header';
import RuleManagement from './components/RuleManagement';
import SandboxSimulation from './components/SandboxSimulation';
import ToastContainer, { Toast } from './components/ToastContainer';
import { ApiError, fetchCurrentRules, saveAndActivateRules } from './api';
import { Rule, RuleSet } from './types';
const TRANSFER_SET_ID = 'transfer';
function emptyTransferSet(): RuleSet {
return {
id: TRANSFER_SET_ID,
name: '潜在转接',
enabled: true,
matcher: 'ac-keyword',
rules: [],
};
}
function transferRules(ruleSets: RuleSet[]): Rule[] {
return ruleSets.find(ruleSet => ruleSet.id === TRANSFER_SET_ID)?.rules ?? [];
}
function replaceTransferRules(ruleSets: RuleSet[], rules: Rule[]): RuleSet[] {
if (!ruleSets.some(ruleSet => ruleSet.id === TRANSFER_SET_ID)) {
return [{ ...emptyTransferSet(), rules }, ...ruleSets];
}
return ruleSets.map(ruleSet =>
ruleSet.id === TRANSFER_SET_ID ? { ...ruleSet, rules } : ruleSet
);
}
export default function App() {
// Global Active Tab: 'rules' | 'sandbox'
const [activeTab, setActiveTab] = useState<'rules' | 'sandbox'>('rules');
const [ruleSets, setRuleSets] = useState<RuleSet[]>([]);
const [publishedRuleSets, setPublishedRuleSets] = useState<RuleSet[]>([]);
const [publishedVersion, setPublishedVersion] = useState(0);
const [loadingRules, setLoadingRules] = useState(true);
const [savingRules, setSavingRules] = useState(false);
// Floating notifications
const [toasts, setToasts] = useState<Toast[]>([]);
// 1. Toast helper
const addToast = (message: string, type: 'success' | 'warning' | 'info') => {
const id = `toast-${Date.now()}-${Math.random().toString(36).substr(2, 5)}`;
setToasts((prev) => [...prev, { id, message, type }]);
// Auto remove after 4.5 seconds
setTimeout(() => {
removeToast(id);
}, 4500);
};
const removeToast = (id: string) => {
setToasts((prev) => prev.filter((t) => t.id !== id));
};
// Load the active server-side snapshot. The backend is the source of truth.
useEffect(() => {
let active = true;
void fetchCurrentRules()
.then(data => {
if (!active) return;
setRuleSets(data.ruleSets);
setPublishedRuleSets(data.ruleSets);
setPublishedVersion(data.version);
})
.catch(error => {
if (!active) return;
const message = error instanceof ApiError ? error.message : '无法连接规则服务';
addToast(`加载规则失败:${message}`, 'warning');
})
.finally(() => {
if (active) setLoadingRules(false);
});
return () => {
active = false;
};
}, []);
const handleUpdateRules = (updatedRules: Rule[]) => {
setRuleSets(current => replaceTransferRules(current, updatedRules));
};
const isDirty = useMemo(() => {
return JSON.stringify(ruleSets) !== JSON.stringify(publishedRuleSets);
}, [ruleSets, publishedRuleSets]);
const handlePublish = async () => {
if (!isDirty || savingRules) return;
setSavingRules(true);
try {
const result = await saveAndActivateRules(publishedVersion, ruleSets);
setRuleSets(result.ruleSets);
setPublishedRuleSets(result.ruleSets);
setPublishedVersion(result.version);
addToast(`保存成功,监控规则 V${result.version} 已发布生效`, 'success');
result.warnings.forEach(warning => addToast(warning, 'warning'));
} catch (error) {
if (error instanceof ApiError && error.body.code === 'RULE_VERSION_CONFLICT') {
addToast(`规则版本冲突,服务端当前为 V${error.body.currentVersion ?? '?' },请刷新页面后重试`, 'warning');
} else {
const message = error instanceof ApiError ? error.message : '未知错误';
addToast(`发布失败:${message}`, 'warning');
}
} finally {
setSavingRules(false);
}
};
const rules = transferRules(ruleSets);
const activeRules = transferRules(publishedRuleSets);
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}
loading={loadingRules}
saving={savingRules}
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={activeRules}
addToast={addToast}
/>
</motion.div>
)}
</AnimatePresence>
</main>
{/* Persistent Toast Notifications */}
<ToastContainer toasts={toasts} removeToast={removeToast} />
</div>
);
}

View File

@@ -1,94 +0,0 @@
/**
* @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>
)}
<span className="hidden font-mono text-[10px] font-bold text-slate-500 sm:inline">
V{publishedVersion}
</span>
</div>
</div>
</header>
);
}

View File

@@ -1,43 +0,0 @@
@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

@@ -1,10 +0,0 @@
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

@@ -1,71 +0,0 @@
--- 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

@@ -1,26 +0,0 @@
{
"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

@@ -1,28 +0,0 @@
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 : {},
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
},
},
},
};
});

30
frontend/.gitignore vendored
View File

@@ -1,24 +1,8 @@
# 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
node_modules/
build/
dist/
coverage/
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.log
.env*
!.env.example

View File

@@ -1,75 +1,49 @@
# React + TypeScript + Vite
# ASR 事件监控前端
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
这是与仓库 Spring Boot 后端配套的新版 React + Vite 前端。
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...
},
},
])
先在仓库根目录启动后端(默认端口 `8080`),再启动前端:
```bash
cd frontend
npm install
npm run dev
```
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:
访问 `http://localhost:3000`。Vite 会把 `/api` 请求代理到
`http://localhost:8080`,无需额外配置 CORS。
```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...
},
},
])
## 生产打包
```bash
cd frontend
npm run build
```
构建结果位于 `frontend/dist`。如需与 Spring Boot 打成同一个 JAR
在执行 Maven 打包前,将 `dist` 内的文件复制到:
```text
src/main/resources/static/
```
之后访问 Spring Boot 的 `http://localhost:8080/` 即可使用前端。
## 后端接口
- `GET /api/v1/admin/rules`:读取当前生效规则
- `PUT /api/v1/admin/rules`:保存并发布完整规则文档
- `POST /api/v1/asr-events`:提交 ASR Final 并获取会话告警
- `POST /api/v1/calls/{callId}/close`:关闭并释放通话会话
规则数据和生效版本以后端为准;浏览器不再通过 localStorage 模拟发布。
## Excel 导入导出
- “模板下载”生成 `.xlsx` 规则模板。
- 每行填写一条规则,多个关键词在同一个单元格内换行填写。
- “导入”会校验事件 ID、启用状态、关键词以及重复数据并替换页面工作副本。
- “导出”按照相同模板格式导出当前页面中的规则。
- 导入不会自动发布,确认数据后仍需点击“保存并发布”。

View File

@@ -1,25 +0,0 @@
{
"$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": {}
}

View File

@@ -1,22 +0,0 @@
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,
},
},
])

View File

@@ -2,9 +2,8 @@
<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>
<title>ASR 事件监控中心</title>
</head>
<body>
<div id="root"></div>

View File

@@ -1,32 +0,0 @@
#!/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

File diff suppressed because it is too large Load Diff

View File

@@ -1,42 +1,36 @@
{
"name": "frontend",
"name": "react-example",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
"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": {
"@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"
"@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",
"exceljs": "^4.4.0",
"motion": "^12.23.24"
},
"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"
"@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"
}
}

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.3 KiB

View File

@@ -1,24 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 4.9 KiB

View File

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

View File

@@ -2,6 +2,7 @@ import type {
CurrentRulesResponse,
MonitorResponse,
RuleSet,
RuleVersionSummary,
SaveRuleResponse,
} from './types';
@@ -56,6 +57,27 @@ export async function saveAndActivateRules(
}));
}
export async function fetchRuleVersions(limit = 20): Promise<RuleVersionSummary[]> {
const params = new URLSearchParams({ limit: String(limit) });
return parseResponse<RuleVersionSummary[]>(
await fetch(`/api/v1/admin/rules/versions?${params.toString()}`)
);
}
export async function restoreRuleVersion(
sourceVersion: number,
baseVersion: number,
): Promise<SaveRuleResponse> {
return parseResponse<SaveRuleResponse>(await fetch(
`/api/v1/admin/rules/versions/${sourceVersion}/restore`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ baseVersion }),
}
));
}
export async function submitAsrEvent(request: {
callId: string;
seq: number;

View File

@@ -1,106 +0,0 @@
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.

Before

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.5 KiB

View File

@@ -0,0 +1,234 @@
/**
* @license
* SPDX-License-Identifier: Apache-2.0
*/
import React, { useEffect, useRef, useState } from 'react';
import { ChevronDown, History, LoaderCircle, RotateCcw } from 'lucide-react';
import type { RuleVersionSummary } from '../types';
interface HeaderProps {
activeTab: 'rules' | 'sandbox';
setActiveTab: (tab: 'rules' | 'sandbox') => void;
publishedVersion: number;
isDirty: boolean;
versionHistory: RuleVersionSummary[];
versionHistoryLoading: boolean;
restoringVersion: number | null;
onLoadVersionHistory: () => void;
onRestoreVersion: (version: number) => void;
}
function formatCreatedAt(value: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
export default function Header({
activeTab,
setActiveTab,
publishedVersion,
isDirty,
versionHistory,
versionHistoryLoading,
restoringVersion,
onLoadVersionHistory,
onRestoreVersion,
}: HeaderProps) {
const [versionMenuOpen, setVersionMenuOpen] = useState(false);
const versionMenuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!versionMenuOpen) return;
const handlePointerDown = (event: MouseEvent) => {
if (!versionMenuRef.current?.contains(event.target as Node)) {
setVersionMenuOpen(false);
}
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') setVersionMenuOpen(false);
};
document.addEventListener('mousedown', handlePointerDown);
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('mousedown', handlePointerDown);
document.removeEventListener('keydown', handleKeyDown);
};
}, [versionMenuOpen]);
const toggleVersionMenu = () => {
const nextOpen = !versionMenuOpen;
setVersionMenuOpen(nextOpen);
if (nextOpen) onLoadVersionHistory();
};
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 ref={versionMenuRef} className="relative">
<button
type="button"
onClick={toggleVersionMenu}
aria-haspopup="menu"
aria-expanded={versionMenuOpen}
className="inline-flex items-center gap-1 rounded-md border border-slate-200 bg-white px-2.5 py-1 font-mono text-[10px] font-bold text-slate-600 transition hover:border-slate-300 hover:bg-slate-50"
>
<span>V{publishedVersion}</span>
<ChevronDown className={`h-3 w-3 transition-transform ${versionMenuOpen ? 'rotate-180' : ''}`} />
</button>
{versionMenuOpen && (
<div
role="menu"
className="absolute right-0 top-full z-50 mt-2 w-80 overflow-hidden rounded-lg border border-slate-200 bg-white shadow-xl"
>
<div className="flex items-center justify-between border-b border-slate-100 px-4 py-3">
<div className="flex items-center gap-2">
<History className="h-4 w-4 text-slate-500" />
<span className="text-xs font-bold text-slate-900"></span>
</div>
{isDirty && (
<span className="rounded bg-amber-50 px-2 py-0.5 text-[9px] font-bold text-amber-700">
</span>
)}
</div>
<div className="max-h-80 overflow-y-auto p-2">
{versionHistoryLoading && versionHistory.length === 0 ? (
<div className="flex items-center justify-center gap-2 py-8 text-xs text-slate-400">
<LoaderCircle className="h-4 w-4 animate-spin" />
</div>
) : versionHistory.length === 0 ? (
<div className="py-8 text-center text-xs text-slate-400"></div>
) : (
versionHistory.map(version => {
const isCurrent = version.version === publishedVersion;
const isRestoring = restoringVersion === version.version;
return (
<div
key={version.version}
className={`flex items-center justify-between gap-3 rounded-md px-3 py-2.5 ${
isCurrent ? 'bg-emerald-50/70' : 'hover:bg-slate-50'
}`}
>
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="font-mono text-xs font-bold text-slate-900">V{version.version}</span>
{isCurrent && (
<span className="rounded bg-emerald-100 px-1.5 py-0.5 text-[9px] font-bold text-emerald-700">
</span>
)}
</div>
<div className="mt-1 text-[10px] text-slate-400">
{version.ruleCount} · {version.keywordCount} · {formatCreatedAt(version.createdAt)}
</div>
</div>
{!isCurrent && (
<button
type="button"
role="menuitem"
disabled={restoringVersion !== null}
onClick={() => {
setVersionMenuOpen(false);
onRestoreVersion(version.version);
}}
className="inline-flex shrink-0 items-center gap-1 rounded border border-slate-200 bg-white px-2 py-1 text-[10px] font-bold text-slate-600 transition hover:border-slate-300 hover:text-slate-900 disabled:cursor-not-allowed disabled:opacity-50"
>
{isRestoring ? (
<LoaderCircle className="h-3 w-3 animate-spin" />
) : (
<RotateCcw className="h-3 w-3" />
)}
</button>
)}
</div>
);
})
)}
</div>
</div>
)}
</div>
</div>
</div>
</header>
);
}

View File

@@ -1,142 +0,0 @@
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

@@ -1,199 +0,0 @@
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

@@ -1,342 +0,0 @@
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

@@ -1,197 +0,0 @@
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

@@ -1,49 +0,0 @@
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

@@ -1,67 +0,0 @@
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

@@ -1,103 +0,0 @@
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

@@ -1,168 +0,0 @@
"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

@@ -1,19 +0,0 @@
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

@@ -1,47 +0,0 @@
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

@@ -1,33 +0,0 @@
"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

@@ -1,88 +0,0 @@
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

@@ -1,18 +0,0 @@
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 }

View File

@@ -1,130 +1,42 @@
@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";
@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);
@theme {
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
--font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, monospace;
}
: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);
/* Custom Scrollbars for a high-density, professional UI */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.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);
::-webkit-scrollbar-track {
background: transparent;
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
html {
@apply font-sans;
}
}
::-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

@@ -1,6 +0,0 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View File

@@ -1,10 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
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

@@ -29,6 +29,14 @@ export interface SaveRuleResponse extends CurrentRulesResponse {
warnings: string[];
}
export interface RuleVersionSummary {
version: number;
active: boolean;
ruleCount: number;
keywordCount: number;
createdAt: string;
}
export interface AlertResult {
eventType: string;
eventId: string;

View File

@@ -1,31 +0,0 @@
{
"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"]
}

View File

@@ -1,7 +1,26 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
"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

@@ -1,23 +0,0 @@
{
"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"]
}

View File

@@ -1,24 +1,28 @@
import path from "path"
import { fileURLToPath } from "url"
import react from "@vitejs/plugin-react"
import tailwindcss from "@tailwindcss/vite"
import { defineConfig } from "vite"
import tailwindcss from '@tailwindcss/vite';
import react from '@vitejs/plugin-react';
import path from 'path';
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,
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 : {},
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
},
},
},
};
});

View File

@@ -4,6 +4,8 @@ import com.example.demo.application.RuleManagementService;
import com.example.demo.domain.CurrentRulesResponse;
import com.example.demo.domain.SaveRuleRequest;
import com.example.demo.domain.SaveRuleResponse;
import com.example.demo.domain.RestoreRuleVersionRequest;
import com.example.demo.domain.RuleVersionSummaryResponse;
import com.example.demo.domain.TestRuleRequest;
import com.example.demo.domain.TestRuleResponse;
import jakarta.validation.Valid;
@@ -13,7 +15,10 @@ import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/api/v1/admin/rules")
@@ -30,11 +35,26 @@ public class RuleAdminController {
return ResponseEntity.ok(ruleManagementService.getCurrentRules());
}
@GetMapping("/versions")
public ResponseEntity<List<RuleVersionSummaryResponse>> getVersions(
@RequestParam(defaultValue = "20") int limit
) {
return ResponseEntity.ok(ruleManagementService.getRuleVersions(limit));
}
@PutMapping
public ResponseEntity<SaveRuleResponse> saveAndActivate(@Valid @RequestBody SaveRuleRequest request) {
return ResponseEntity.ok(ruleManagementService.saveAndActivate(request));
}
@PostMapping("/versions/{version}/restore")
public ResponseEntity<SaveRuleResponse> restore(
@PathVariable long version,
@Valid @RequestBody RestoreRuleVersionRequest request
) {
return ResponseEntity.ok(ruleManagementService.restoreVersion(version, request));
}
@PostMapping("/test")
public ResponseEntity<TestRuleResponse> test(@Valid @RequestBody TestRuleRequest request) {
return ResponseEntity.ok(ruleManagementService.test(request));

View File

@@ -22,8 +22,8 @@ import org.springframework.stereotype.Service;
* <ol>
* <li>拒绝 Partial{@code final=false}),不改写会话匹配状态</li>
* <li>按 {@code callId + seq} 做幂等</li>
* <li>忽略坐席agent文本:请求可接受,但不参与关键词匹配</li>
* <li>维护最近若干条市民 Final 窗口,送入 AC 自动机匹配</li>
* <li>市民citizen坐席agent文本参与关键词匹配</li>
* <li>每通电话维护一个最近若干条 Final 窗口,送入 AC 自动机匹配</li>
* <li>与本通话已提醒事件做差,得到 {@code newAlerts}</li>
* <li>同时返回 {@code newAlerts} 与累计 {@code currentResults}</li>
* </ol>
@@ -31,8 +31,6 @@ import org.springframework.stereotype.Service;
@Service
public class AsrEventMonitorService {
private static final String SPEAKER_CITIZEN = "citizen";
private final SessionStore sessionStore;
private final AcKeywordMatcher keywordMatcher;
private final MonitorProperties properties;
@@ -91,20 +89,8 @@ public class AsrEventMonitorService {
);
}
// 坐席 Final只计入幂等关键词仅匹配市民侧文本
if (!SPEAKER_CITIZEN.equals(request.speaker())) {
return MonitorResponse.accepted(
request.callId(),
request.seq(),
session.revision(),
activeRuleVersion,
List.of(),
session.currentResults()
);
}
// 保留最近 N 条市民 Final 再拼接,降低 ASR 切段导致的漏判
session.appendCitizenFinal(
// 同一通话的市民和坐席共享最近 N 条 Final 上下文
session.appendFinal(
request.seq(),
request.text(),
properties.recentFinalWindowSize()

View File

@@ -3,6 +3,8 @@ package com.example.demo.application;
import com.example.demo.domain.CurrentRulesResponse;
import com.example.demo.domain.MatchResult;
import com.example.demo.domain.RuleDocument;
import com.example.demo.domain.RestoreRuleVersionRequest;
import com.example.demo.domain.RuleVersionSummaryResponse;
import com.example.demo.domain.SaveRuleRequest;
import com.example.demo.domain.SaveRuleResponse;
import com.example.demo.domain.TestRuleRequest;
@@ -14,6 +16,7 @@ import com.example.demo.repository.RuleVersionEntity;
import com.example.demo.repository.RuleVersionRepository;
import com.example.demo.support.RuleValidationException;
import com.example.demo.support.RuleVersionConflictException;
import com.example.demo.support.RuleVersionNotFoundException;
import com.example.demo.support.TextNormalizer;
import java.io.IOException;
import java.io.InputStream;
@@ -26,6 +29,7 @@ import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.core.io.ClassPathResource;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import tools.jackson.core.JacksonException;
@@ -81,6 +85,19 @@ public class RuleManagementService implements ApplicationRunner {
.orElseGet(() -> new CurrentRulesResponse(0L, 0, 0, List.of()));
}
public List<RuleVersionSummaryResponse> getRuleVersions(int requestedLimit) {
int limit = Math.max(1, Math.min(requestedLimit, 100));
return repository.findAllByOrderByVersionNoDesc(PageRequest.of(0, limit)).stream()
.map(entity -> new RuleVersionSummaryResponse(
entity.getVersionNo(),
entity.isActive(),
entity.getRuleCount(),
entity.getKeywordCount(),
entity.getCreatedAt()
))
.toList();
}
@Transactional
public SaveRuleResponse saveAndActivate(SaveRuleRequest request) {
long currentVersion = currentVersionNo();
@@ -94,7 +111,7 @@ public class RuleManagementService implements ApplicationRunner {
repository.deactivateCurrent();
long nextVersion = currentVersion + 1;
long nextVersion = nextVersionNo();
RuleVersionEntity entity = repository.save(
RuleVersionEntity.active(
nextVersion,
@@ -116,6 +133,47 @@ public class RuleManagementService implements ApplicationRunner {
);
}
@Transactional
public SaveRuleResponse restoreVersion(long sourceVersion, RestoreRuleVersionRequest request) {
long currentVersion = currentVersionNo();
if (request.baseVersion() != currentVersion) {
throw new RuleVersionConflictException(currentVersion);
}
if (sourceVersion == currentVersion) {
throw new RuleValidationException("V" + sourceVersion + " 已是当前生效版本");
}
RuleVersionEntity source = repository.findFirstByVersionNoOrderByIdDesc(sourceVersion)
.orElseThrow(() -> new RuleVersionNotFoundException(sourceVersion));
RuleDocument sourceDocument = readDocument(source.getContentJson());
RuleValidator.ValidationResult validation = ruleValidator.validateAndNormalize(
new SaveRuleRequest(currentVersion, sourceDocument.ruleSets())
);
RuleDocument document = validation.document();
AcAutomatonSnapshot snapshot = automatonFactory.build(document);
repository.deactivateCurrent();
long nextVersion = nextVersionNo();
RuleVersionEntity entity = repository.save(
RuleVersionEntity.active(
nextVersion,
writeDocument(document),
snapshot.ruleCount(),
snapshot.keywordCount(),
Instant.now(clock)
)
);
eventPublisher.publishEvent(new RuleActivatedEvent(entity.getVersionNo(), snapshot));
return new SaveRuleResponse(
entity.getVersionNo(),
entity.getRuleCount(),
entity.getKeywordCount(),
validation.warnings(),
document.ruleSets()
);
}
public TestRuleResponse test(TestRuleRequest request) {
List<MatchResult> matches = keywordMatcher.match(request.text());
return new TestRuleResponse(
@@ -166,6 +224,12 @@ public class RuleManagementService implements ApplicationRunner {
.orElse(0L);
}
private long nextVersionNo() {
return repository.findTopByOrderByVersionNoDesc()
.map(entity -> entity.getVersionNo() + 1)
.orElse(1L);
}
private RuleDocument readDocument(String json) {
try {
return objectMapper.readValue(json, RuleDocument.class);

View File

@@ -22,7 +22,7 @@ public class CallSession {
private final String callId;
private final Set<Long> processedSeqs = new LinkedHashSet<>();
private final NavigableMap<Long, String> recentCitizenFinals = new TreeMap<>();
private final NavigableMap<Long, String> recentFinals = new TreeMap<>();
private final Set<String> alertedEventKeys = new HashSet<>();
private final Map<String, EventState> eventStates = new LinkedHashMap<>();
@@ -70,20 +70,20 @@ public class CallSession {
return true;
}
public void appendCitizenFinal(long seq, String text, int windowSize) {
recentCitizenFinals.put(seq, text);
public void appendFinal(long seq, String text, int windowSize) {
recentFinals.put(seq, text);
while (recentCitizenFinals.size() > windowSize) {
recentCitizenFinals.pollFirstEntry();
while (recentFinals.size() > windowSize) {
recentFinals.pollFirstEntry();
}
}
/**
* Join recent citizen finals with "。" so Chinese phrase boundaries
* can be glued after normalization (e.g. 拼 + 多多 → 拼多多).
* Join recent finals from the whole call with "。" so Chinese phrase boundaries
* can be glued after normalization across citizen and agent turns.
*/
public String buildMatchText() {
return recentCitizenFinals.values().stream()
return recentFinals.values().stream()
.collect(Collectors.joining(""));
}

View File

@@ -0,0 +1,9 @@
package com.example.demo.domain;
import jakarta.validation.constraints.PositiveOrZero;
public record RestoreRuleVersionRequest(
@PositiveOrZero
long baseVersion
) {
}

View File

@@ -0,0 +1,12 @@
package com.example.demo.domain;
import java.time.Instant;
public record RuleVersionSummaryResponse(
long version,
boolean active,
int ruleCount,
int keywordCount,
Instant createdAt
) {
}

View File

@@ -1,6 +1,8 @@
package com.example.demo.repository;
import java.util.List;
import java.util.Optional;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
@@ -11,6 +13,10 @@ public interface RuleVersionRepository extends JpaRepository<RuleVersionEntity,
Optional<RuleVersionEntity> findTopByOrderByVersionNoDesc();
Optional<RuleVersionEntity> findFirstByVersionNoOrderByIdDesc(long versionNo);
List<RuleVersionEntity> findAllByOrderByVersionNoDesc(Pageable pageable);
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query("update RuleVersionEntity r set r.active = false where r.active = true")
int deactivateCurrent();

View File

@@ -42,4 +42,12 @@ public class ApiExceptionHandler {
body.put("currentVersion", ex.currentVersion());
return ResponseEntity.status(HttpStatus.CONFLICT).body(body);
}
@ExceptionHandler(RuleVersionNotFoundException.class)
public ResponseEntity<Map<String, Object>> handleRuleVersionNotFound(RuleVersionNotFoundException ex) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("code", "RULE_VERSION_NOT_FOUND");
body.put("message", ex.getMessage());
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(body);
}
}

View File

@@ -0,0 +1,8 @@
package com.example.demo.support;
public class RuleVersionNotFoundException extends RuntimeException {
public RuleVersionNotFoundException(long version) {
super("规则版本不存在: V" + version);
}
}

View File

@@ -113,4 +113,37 @@ class RuleAdminControllerTest {
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("RULE_VERSION_CONFLICT"));
}
@Test
@Order(3)
void listsHistoryAndRestoresAsANewVersion() throws Exception {
MvcResult currentResult = mockMvc.perform(get("/api/v1/admin/rules"))
.andExpect(status().isOk())
.andReturn();
long currentVersion = objectMapper.readTree(
currentResult.getResponse().getContentAsString()
).get("version").asLong();
MvcResult historyResult = mockMvc.perform(get("/api/v1/admin/rules/versions?limit=20"))
.andExpect(status().isOk())
.andExpect(jsonPath("$", hasSize(greaterThanOrEqualTo(2))))
.andExpect(jsonPath("$[0].version").value(currentVersion))
.andExpect(jsonPath("$[0].active").value(true))
.andReturn();
JsonNode history = objectMapper.readTree(historyResult.getResponse().getContentAsString());
long sourceVersion = history.get(history.size() - 1).get("version").asLong();
mockMvc.perform(post("/api/v1/admin/rules/versions/{version}/restore", sourceVersion)
.contentType(MediaType.APPLICATION_JSON)
.content("""
{ "baseVersion": %d }
""".formatted(currentVersion)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.version").value(currentVersion + 1));
mockMvc.perform(get("/api/v1/admin/rules/versions?limit=1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].version").value(currentVersion + 1))
.andExpect(jsonPath("$[0].active").value(true));
}
}

View File

@@ -75,11 +75,20 @@ class AsrEventMonitorServiceTest {
}
@Test
void agentTextDoesNotMatch() {
void agentTextMatches() {
MonitorResponse response = service.handle(agent(1, "您说的是顺丰快递对吗"));
assertTrue(response.accepted());
assertTrue(response.newAlerts().isEmpty());
assertTrue(response.currentResults().isEmpty());
assertEquals(1, response.newAlerts().size());
assertEquals("sf-express", response.newAlerts().getFirst().eventId());
assertEquals(1, response.currentResults().size());
}
@Test
void citizenAndAgentShareOneContextWindow() {
service.handle(citizen(1, "我是在拼"));
MonitorResponse response = service.handle(agent(2, "多多上买的"));
assertEquals(1, response.newAlerts().size());
assertEquals("pdd", response.newAlerts().getFirst().eventId());
}
@Test

View File

@@ -27,10 +27,10 @@ class CallSessionTest {
}
@Test
void buildsMatchTextFromRecentCitizenFinals() {
void buildsMatchTextFromRecentFinalsAcrossSpeakers() {
CallSession session = new CallSession("call-1", clock);
session.appendCitizenFinal(27, "我是在拼", 2);
session.appendCitizenFinal(28, "多多上买的", 2);
session.appendFinal(27, "我是在拼", 2);
session.appendFinal(28, "多多上买的", 2);
assertEquals("我是在拼。多多上买的", session.buildMatchText());
}

View File

@@ -104,8 +104,8 @@ class InMemorySessionStoreTest {
@Test
void differentCallsAreIsolated() {
store.getOrCreate("call-a").appendCitizenFinal(1, "顺丰", 2);
store.getOrCreate("call-b").appendCitizenFinal(1, "拼多多", 2);
store.getOrCreate("call-a").appendFinal(1, "顺丰", 2);
store.getOrCreate("call-b").appendFinal(1, "拼多多", 2);
assertEquals("顺丰", store.find("call-a").orElseThrow().buildMatchText());
assertEquals("拼多多", store.find("call-b").orElseThrow().buildMatchText());