Redesign frontend
This commit is contained in:
@@ -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 拼接后可命中被切分的品牌
|
||||
|
||||
8
frontend-redesign/.gitignore
vendored
8
frontend-redesign/.gitignore
vendored
@@ -1,8 +0,0 @@
|
||||
node_modules/
|
||||
build/
|
||||
dist/
|
||||
coverage/
|
||||
.DS_Store
|
||||
*.log
|
||||
.env*
|
||||
!.env.example
|
||||
@@ -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、启用状态、关键词以及重复数据,并替换页面工作副本。
|
||||
- “导出”按照相同模板格式导出当前页面中的规则。
|
||||
- 导入不会自动发布,确认数据后仍需点击“保存并发布”。
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
);
|
||||
@@ -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'
|
||||
}
|
||||
];
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
30
frontend/.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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、启用状态、关键词以及重复数据,并替换页面工作副本。
|
||||
- “导出”按照相同模板格式导出当前页面中的规则。
|
||||
- 导入不会自动发布,确认数据后仍需点击“保存并发布”。
|
||||
|
||||
@@ -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": {}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
8017
frontend/package-lock.json
generated
8017
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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 |
@@ -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 |
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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 |
@@ -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 |
234
frontend/src/components/Header.tsx
Normal file
234
frontend/src/components/Header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@@ -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>,
|
||||
)
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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("。"));
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.example.demo.domain;
|
||||
|
||||
import jakarta.validation.constraints.PositiveOrZero;
|
||||
|
||||
public record RestoreRuleVersionRequest(
|
||||
@PositiveOrZero
|
||||
long baseVersion
|
||||
) {
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.example.demo.support;
|
||||
|
||||
public class RuleVersionNotFoundException extends RuntimeException {
|
||||
|
||||
public RuleVersionNotFoundException(long version) {
|
||||
super("规则版本不存在: V" + version);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user