🎨 新增正则管理 和 全局正则功能

Signed-off-by: Echo <1711788888@qq.com>
This commit is contained in:
2026-03-02 01:14:16 +08:00
parent 2b8be78fdc
commit 8888d9ea85
5 changed files with 519 additions and 9 deletions

View File

@@ -3,8 +3,12 @@ package app
import (
"encoding/json"
"errors"
"fmt"
"math/rand"
"regexp"
"strconv"
"strings"
"time"
"git.echol.cn/loser/st/server/global"
"git.echol.cn/loser/st/server/model/app"
@@ -236,14 +240,74 @@ func (s *RegexScriptService) ExecuteScript(script *app.RegexScript, text string,
// substituteMacros 替换宏变量
func (s *RegexScriptService) substituteMacros(text string, userName string, charName string) string {
result := text
// 保存原始文本
result = strings.ReplaceAll(result, "{{original}}", text)
// 用户名变量
if userName != "" {
result = strings.ReplaceAll(result, "{{user}}", userName)
result = strings.ReplaceAll(result, "{{User}}", userName)
}
// 角色名变量
if charName != "" {
result = strings.ReplaceAll(result, "{{char}}", charName)
result = strings.ReplaceAll(result, "{{Char}}", charName)
}
// 时间变量
now := time.Now()
result = strings.ReplaceAll(result, "{{time}}", now.Format("15:04:05"))
result = strings.ReplaceAll(result, "{{date}}", now.Format("2006-01-02"))
result = strings.ReplaceAll(result, "{{datetime}}", now.Format("2006-01-02 15:04:05"))
result = strings.ReplaceAll(result, "{{timestamp}}", fmt.Sprintf("%d", now.Unix()))
result = strings.ReplaceAll(result, "{{time_12h}}", now.Format("03:04:05 PM"))
result = strings.ReplaceAll(result, "{{date_short}}", now.Format("01/02/06"))
result = strings.ReplaceAll(result, "{{weekday}}", now.Weekday().String())
result = strings.ReplaceAll(result, "{{month}}", now.Month().String())
result = strings.ReplaceAll(result, "{{year}}", fmt.Sprintf("%d", now.Year()))
// 随机数变量
result = regexp.MustCompile(`\{\{random:(\d+)-(\d+)\}\}`).ReplaceAllStringFunc(result, func(match string) string {
re := regexp.MustCompile(`\{\{random:(\d+)-(\d+)\}\}`)
matches := re.FindStringSubmatch(match)
if len(matches) == 3 {
min, _ := strconv.Atoi(matches[1])
max, _ := strconv.Atoi(matches[2])
if max > min {
return fmt.Sprintf("%d", rand.Intn(max-min+1)+min)
}
}
return match
})
// 简单随机数 {{random}}
result = regexp.MustCompile(`\{\{random\}\}`).ReplaceAllStringFunc(result, func(match string) string {
return fmt.Sprintf("%d", rand.Intn(100))
})
// 随机选择 {{pick:option1|option2|option3}}
result = regexp.MustCompile(`\{\{pick:([^}]+)\}\}`).ReplaceAllStringFunc(result, func(match string) string {
re := regexp.MustCompile(`\{\{pick:([^}]+)\}\}`)
matches := re.FindStringSubmatch(match)
if len(matches) == 2 {
options := strings.Split(matches[1], "|")
if len(options) > 0 {
return options[rand.Intn(len(options))]
}
}
return match
})
// 换行符变量
result = strings.ReplaceAll(result, "{{newline}}", "\n")
result = strings.ReplaceAll(result, "{{tab}}", "\t")
result = strings.ReplaceAll(result, "{{space}}", " ")
// 空值变量
result = strings.ReplaceAll(result, "{{empty}}", "")
return result
}

View File

@@ -16,7 +16,8 @@
"react-router-dom": "^7.13.1",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
"remark-gfm": "^4.0.1"
"remark-gfm": "^4.0.1",
"zustand": "^5.0.11"
},
"devDependencies": {
"@types/react": "^18.3.3",
@@ -4638,6 +4639,35 @@
"dev": true,
"license": "ISC"
},
"node_modules/zustand": {
"version": "5.0.11",
"resolved": "https://registry.npmmirror.com/zustand/-/zustand-5.0.11.tgz",
"integrity": "sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg==",
"license": "MIT",
"engines": {
"node": ">=12.20.0"
},
"peerDependencies": {
"@types/react": ">=18.0.0",
"immer": ">=9.0.6",
"react": ">=18.0.0",
"use-sync-external-store": ">=1.2.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"immer": {
"optional": true
},
"react": {
"optional": true
},
"use-sync-external-store": {
"optional": true
}
}
},
"node_modules/zwitch": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",

View File

@@ -17,7 +17,8 @@
"react-router-dom": "^7.13.1",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
"remark-gfm": "^4.0.1"
"remark-gfm": "^4.0.1",
"zustand": "^5.0.11"
},
"devDependencies": {
"@types/react": "^18.3.3",

View File

@@ -80,34 +80,37 @@ export interface RegexScriptListResponse {
}
// API 方法
export const regexScriptApi = {
export const regexAPI = {
// 创建正则脚本
createRegexScript: (data: CreateRegexScriptRequest): Promise<{ data: RegexScript }> => {
create: (data: CreateRegexScriptRequest): Promise<{ data: RegexScript }> => {
return apiClient.post('/app/regex', data)
},
// 获取正则脚本列表
getRegexScriptList: (params?: GetRegexScriptListRequest): Promise<{ data: RegexScriptListResponse }> => {
getList: (params?: GetRegexScriptListRequest): Promise<{ data: RegexScriptListResponse }> => {
return apiClient.get('/app/regex', { params })
},
// 获取正则脚本详情
getRegexScriptById: (id: number): Promise<{ data: RegexScript }> => {
getById: (id: number): Promise<{ data: RegexScript }> => {
return apiClient.get(`/app/regex/${id}`)
},
// 更新正则脚本
updateRegexScript: (id: number, data: UpdateRegexScriptRequest) => {
update: (id: number, data: UpdateRegexScriptRequest) => {
return apiClient.put(`/app/regex/${id}`, data)
},
// 删除正则脚本
deleteRegexScript: (id: number) => {
delete: (id: number) => {
return apiClient.delete(`/app/regex/${id}`)
},
// 测试正则脚本
testRegexScript: (id: number, testString: string): Promise<{ data: { original: string; result: string; success: boolean; error?: string } }> => {
test: (id: number, testString: string): Promise<{ data: { original: string; result: string; success: boolean; error?: string } }> => {
return apiClient.post(`/app/regex/${id}/test`, { testString })
},
}
// 保持向后兼容
export const regexScriptApi = regexAPI

View File

@@ -0,0 +1,412 @@
import React, { useState, useEffect } from 'react'
import { Plus, Search, Edit2, Trash2, Upload, Download, Play, X } from 'lucide-react'
import { regexAPI } from '../api/regex'
interface RegexScript {
id: number
name: string
findRegex: string
replaceWith: string
placement: number
disabled: boolean
order: number
scope: number
createdAt: string
}
const placementNames = ['输入', '输出', '世界书', '显示']
export default function RegexScriptManagePage() {
const [scripts, setScripts] = useState<RegexScript[]>([])
const [loading, setLoading] = useState(false)
const [searchKeyword, setSearchKeyword] = useState('')
const [showModal, setShowModal] = useState(false)
const [editingScript, setEditingScript] = useState<RegexScript | null>(null)
const [formData, setFormData] = useState({
name: '',
findRegex: '',
replaceWith: '',
placement: 1,
disabled: false,
order: 100,
scope: 0,
})
useEffect(() => {
loadScripts()
}, [searchKeyword])
const loadScripts = async () => {
try {
setLoading(true)
const response = await regexAPI.getList({
page: 1,
pageSize: 100,
keyword: searchKeyword,
scope: 0, // 只加载全局脚本
})
setScripts(response.data.list || [])
} catch (error) {
console.error('加载正则脚本失败:', error)
} finally {
setLoading(false)
}
}
const handleCreate = () => {
setEditingScript(null)
setFormData({
name: '',
findRegex: '',
replaceWith: '',
placement: 1,
disabled: false,
order: 100,
scope: 0,
})
setShowModal(true)
}
const handleEdit = (script: RegexScript) => {
setEditingScript(script)
setFormData({
name: script.name,
findRegex: script.findRegex,
replaceWith: script.replaceWith,
placement: script.placement,
disabled: script.disabled,
order: script.order,
scope: 0,
})
setShowModal(true)
}
const handleSave = async () => {
try {
if (editingScript) {
await regexAPI.update(editingScript.id, formData)
} else {
await regexAPI.create(formData)
}
setShowModal(false)
loadScripts()
} catch (error) {
console.error('保存正则脚本失败:', error)
alert('保存失败')
}
}
const handleDelete = async (id: number) => {
if (!confirm('确定要删除这个正则脚本吗?')) return
try {
await regexAPI.delete(id)
loadScripts()
} catch (error) {
console.error('删除正则脚本失败:', error)
alert('删除失败')
}
}
const handleImport = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
try {
const text = await file.text()
const data = JSON.parse(text)
// 支持导入单个脚本或脚本数组
const scriptsToImport = Array.isArray(data) ? data : [data]
for (const script of scriptsToImport) {
await regexAPI.create({
name: script.scriptName || script.name || '未命名脚本',
findRegex: script.findRegex || '',
replaceWith: script.replaceString || script.replaceWith || '',
placement: script.placement ?? 1,
disabled: script.disabled ?? false,
order: script.order ?? 100,
scope: 0, // 全局作用域
})
}
loadScripts()
alert(`成功导入 ${scriptsToImport.length} 个脚本`)
} catch (error) {
console.error('导入失败:', error)
alert('导入失败,请检查文件格式')
}
e.target.value = ''
}
const handleExport = (script: RegexScript) => {
const data = {
scriptName: script.name,
findRegex: script.findRegex,
replaceString: script.replaceWith,
placement: script.placement,
disabled: script.disabled,
order: script.order,
}
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${script.name}.json`
a.click()
URL.revokeObjectURL(url)
}
const handleExportAll = () => {
const data = scripts.map(script => ({
scriptName: script.name,
findRegex: script.findRegex,
replaceString: script.replaceWith,
placement: script.placement,
disabled: script.disabled,
order: script.order,
}))
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'global-regex-scripts.json'
a.click()
URL.revokeObjectURL(url)
}
return (
<div className="min-h-screen bg-gradient-to-br from-gray-900 via-purple-900 to-gray-900 p-6">
<div className="max-w-7xl mx-auto">
{/* 头部 */}
<div className="mb-8">
<h1 className="text-3xl font-bold text-white mb-2"></h1>
<p className="text-gray-400"></p>
</div>
{/* 工具栏 */}
<div className="glass rounded-xl p-4 mb-6">
<div className="flex items-center gap-4">
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
type="text"
placeholder="搜索脚本名称..."
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
className="w-full pl-10 pr-4 py-2 bg-black/30 border border-white/10 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-primary/50"
/>
</div>
<button
onClick={handleCreate}
className="flex items-center gap-2 px-4 py-2 bg-primary hover:bg-primary/80 text-white rounded-lg transition-colors"
>
<Plus className="w-5 h-5" />
</button>
<label className="flex items-center gap-2 px-4 py-2 glass-hover rounded-lg cursor-pointer">
<Upload className="w-5 h-5" />
<input type="file" accept=".json" onChange={handleImport} className="hidden" />
</label>
{scripts.length > 0 && (
<button
onClick={handleExportAll}
className="flex items-center gap-2 px-4 py-2 glass-hover rounded-lg"
>
<Download className="w-5 h-5" />
</button>
)}
</div>
</div>
{/* 脚本列表 */}
{loading ? (
<div className="text-center py-12 text-gray-400">...</div>
) : scripts.length === 0 ? (
<div className="text-center py-12 glass rounded-xl">
<p className="text-gray-400 mb-4"></p>
<button
onClick={handleCreate}
className="px-6 py-2 bg-primary hover:bg-primary/80 text-white rounded-lg transition-colors"
>
</button>
</div>
) : (
<div className="grid gap-4">
{scripts.map((script) => (
<div key={script.id} className="glass rounded-xl p-6 hover:bg-white/5 transition-colors">
<div className="flex items-start justify-between mb-4">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h3 className="text-lg font-semibold text-white">{script.name}</h3>
<span className={`px-2 py-1 text-xs rounded ${
script.disabled ? 'bg-red-500/20 text-red-400' : 'bg-green-500/20 text-green-400'
}`}>
{script.disabled ? '已禁用' : '已启用'}
</span>
<span className="px-2 py-1 text-xs rounded bg-blue-500/20 text-blue-400">
{placementNames[script.placement]}
</span>
<span className="text-xs text-gray-500">: {script.order}</span>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => handleEdit(script)}
className="p-2 glass-hover rounded-lg"
title="编辑"
>
<Edit2 className="w-4 h-4" />
</button>
<button
onClick={() => handleExport(script)}
className="p-2 glass-hover rounded-lg"
title="导出"
>
<Download className="w-4 h-4" />
</button>
<button
onClick={() => handleDelete(script.id)}
className="p-2 glass-hover rounded-lg text-red-400"
title="删除"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
<div className="space-y-2 text-sm">
<div>
<span className="text-gray-400"></span>
<code className="ml-2 px-2 py-1 bg-black/30 rounded text-primary">{script.findRegex}</code>
</div>
<div>
<span className="text-gray-400"></span>
<code className="ml-2 px-2 py-1 bg-black/30 rounded text-green-400">{script.replaceWith}</code>
</div>
</div>
</div>
))}
</div>
)}
{/* 编辑/创建模态框 */}
{showModal && (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<div className="glass rounded-xl p-6 max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-bold text-white">
{editingScript ? '编辑正则脚本' : '新建正则脚本'}
</h2>
<button onClick={() => setShowModal(false)} className="p-2 glass-hover rounded-lg">
<X className="w-5 h-5" />
</button>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm text-gray-400 mb-2"></label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="w-full px-4 py-2 bg-black/30 border border-white/10 rounded-lg text-white focus:outline-none focus:border-primary/50"
placeholder="例如移除HTML标签"
/>
</div>
<div>
<label className="block text-sm text-gray-400 mb-2"></label>
<input
type="text"
value={formData.findRegex}
onChange={(e) => setFormData({ ...formData, findRegex: e.target.value })}
className="w-full px-4 py-2 bg-black/30 border border-white/10 rounded-lg text-white font-mono focus:outline-none focus:border-primary/50"
placeholder="例如:<[^>]+>"
/>
</div>
<div>
<label className="block text-sm text-gray-400 mb-2"></label>
<input
type="text"
value={formData.replaceWith}
onChange={(e) => setFormData({ ...formData, replaceWith: e.target.value })}
className="w-full px-4 py-2 bg-black/30 border border-white/10 rounded-lg text-white font-mono focus:outline-none focus:border-primary/50"
placeholder="例如:空字符串或替换文本"
/>
<p className="text-xs text-gray-500 mt-1">
{'{{user}}, {{char}}, {{random}}, {{time}}'}
</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm text-gray-400 mb-2"></label>
<select
value={formData.placement}
onChange={(e) => setFormData({ ...formData, placement: Number(e.target.value) })}
className="w-full px-4 py-2 bg-black/30 border border-white/10 rounded-lg text-white focus:outline-none focus:border-primary/50"
>
<option value={0}></option>
<option value={1}>AI回复</option>
<option value={2}></option>
<option value={3}></option>
</select>
</div>
<div>
<label className="block text-sm text-gray-400 mb-2"></label>
<input
type="number"
value={formData.order}
onChange={(e) => setFormData({ ...formData, order: Number(e.target.value) })}
className="w-full px-4 py-2 bg-black/30 border border-white/10 rounded-lg text-white focus:outline-none focus:border-primary/50"
placeholder="100"
/>
</div>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="disabled"
checked={formData.disabled}
onChange={(e) => setFormData({ ...formData, disabled: e.target.checked })}
className="w-4 h-4"
/>
<label htmlFor="disabled" className="text-sm text-gray-400"></label>
</div>
</div>
<div className="flex items-center gap-3 mt-6">
<button
onClick={handleSave}
className="flex-1 px-4 py-2 bg-primary hover:bg-primary/80 text-white rounded-lg transition-colors"
>
</button>
<button
onClick={() => setShowModal(false)}
className="flex-1 px-4 py-2 glass-hover rounded-lg"
>
</button>
</div>
</div>
</div>
)}
</div>
</div>
)
}