feat: 统一应用配置和内容过滤规则

This commit is contained in:
chuan
2026-08-10 15:33:12 +08:00
parent f6e5c1ceca
commit 9f0911c596
16 changed files with 226 additions and 98 deletions
-1
View File
@@ -6,7 +6,6 @@
/data/
/data-filter-test/
/benchmark-data/
/dht-search.toml
**/*.rs.bk
# IDE
+2 -4
View File
@@ -21,9 +21,7 @@ opencodes/ 不参与构建的参考项目
Rust 测试分层和默认验证命令见 [`TESTING.md`](TESTING.md)
应用配置模板见 [`dht-search.example.toml`](dht-search.example.toml)
无效文件隐藏规则见 [`content-filters.toml`](content-filters.toml)
应用全部配置和无效文件隐藏规则统一位于 [`config.toml`](config.toml)
应用构建运行和 API 文档见 [`src/search/README.md`](src/search/README.md)
@@ -31,6 +29,6 @@ Web 开发和构建方式见 [`src/web/README.md`](src/web/README.md)
开发环境可以直接运行 `scripts\run.bat` 在当前窗口同时启动 Rust 后端和 Web 前端 按一次 `Ctrl+C` 即可统一停止
启动脚本使用本地 `dht-search.toml` 文件 文件不存在时会从 `dht-search.example.toml` 创建 因此 Web 配置页不会修改版本库中的模板
启动脚本和 Web 配置页统一读取并保存根目录 `config.toml`
任一服务异常退出时启动脚本会清理 Cargo Bun 及其子进程树 避免遗留 Vite 或后端进程
+2 -1
View File
@@ -216,7 +216,7 @@
- [x] `/stats` 返回验证队列发现握手成功失败和拒绝指标
- [ ] 统计真实数据的 infohash 重复率和内容重复率
- [x] 使用独立配置文件定义文件名和文件路径隐藏规则
- [x] 在统一 `config.toml`定义文件名和文件路径隐藏规则
- [x] 支持精确前缀后缀包含通配符和正则匹配并限制规则复杂度
- [x] 保留 RocksDB 原始文件列表并为详情统计搜索和内容聚合生成有效内容视图
- [x] 使用规则指纹在配置变化时重算内容组并从 RocksDB 重建 Tantivy
@@ -346,6 +346,7 @@
- [x] 增加配置查询完整校验原子保存并发修订和统一重启提示
- [x] 增加 Web 诊断页和配置管理页
- [x] 使用页签拆分配置分类并隐藏底层配置存储位置
- [x] 将主配置和内容过滤规则合并为唯一 `config.toml`
- [ ] 将用户可编辑配置的持久化适配器从 TOML 迁移到 SQLite
完成二十四小时持续运行并继续观察私有内存 Metadata 成功率候选队列深度和每条成功 Metadata 的网络成本
+35 -2
View File
@@ -1,9 +1,9 @@
# 定义 dht-search 的推荐起始配置并作为用户配置模板
# 定义 dht-search 的全部运行配置
data_dir = "data"
content_filter_file = "content-filters.toml"
persistence_queue_capacity = 8192
stats_interval_secs = 10
# run_duration_secs = 3600
index_batch_size = 1024
index_interval_millis = 5000
@@ -43,6 +43,39 @@ max_name_bytes = 1024
max_path_bytes = 4096
max_path_depth = 64
[content_filter]
version = 1
[[content_filter.file_rules]]
id = "bitcomet-padding-file"
enabled = true
field = "file-name"
match = "prefix"
value = "_____padding_file_"
case_sensitive = false
action = "hide"
reason = "BitComet 分片边界填充文件"
[[content_filter.file_rules]]
id = "generic-pad-directory"
enabled = true
field = "file-path"
match = "regex"
value = '(^|/)\.pad/'
case_sensitive = false
action = "hide"
reason = "客户端分片边界填充目录"
[[content_filter.file_rules]]
id = "libtorrent-padding-directory"
enabled = true
field = "file-path"
match = "regex"
value = '(^|/)\.____padding_file/'
case_sensitive = false
action = "hide"
reason = "libtorrent 分片边界填充目录"
[dht]
port = 12313
netmode = "ipv4-only"
-33
View File
@@ -1,33 +0,0 @@
# 定义不参与搜索展示统计和内容聚合的无效文件规则
version = 1
[[file_rules]]
id = "bitcomet-padding-file"
enabled = true
field = "file-name"
match = "prefix"
value = "_____padding_file_"
case_sensitive = false
action = "hide"
reason = "BitComet 分片边界填充文件"
[[file_rules]]
id = "generic-pad-directory"
enabled = true
field = "file-path"
match = "regex"
value = '(^|/)\.pad/'
case_sensitive = false
action = "hide"
reason = "客户端分片边界填充目录"
[[file_rules]]
id = "libtorrent-padding-directory"
enabled = true
field = "file-path"
match = "regex"
value = '(^|/)\.____padding_file/'
case_sensitive = false
action = "hide"
reason = "libtorrent 分片边界填充目录"
-13
View File
@@ -1,13 +0,0 @@
data_dir = "data-filter-test"
run_duration_secs = 300
[metadata_limits]
max_metadata_bytes = 65536
max_files = 10
max_name_bytes = 64
max_path_bytes = 128
max_path_depth = 4
[http]
listen = "127.0.0.1:8080"
web_dir = "src/web/dist"
+5 -9
View File
@@ -29,14 +29,10 @@ if not exist "%LIBCLANG_PATH%\libclang.dll" (
exit /b 1
)
if not exist "%PROJECT_ROOT%\dht-search.toml" (
echo [INFO] Creating local configuration from template
copy /Y "%PROJECT_ROOT%\dht-search.example.toml" "%PROJECT_ROOT%\dht-search.toml" >nul
if errorlevel 1 (
echo [ERROR] Failed to create dht-search.toml
pause
exit /b 1
)
if not exist "%PROJECT_ROOT%\config.toml" (
echo [ERROR] config.toml was not found
pause
exit /b 1
)
if not exist "%WEB_DIR%\node_modules" (
@@ -70,7 +66,7 @@ powershell -NoProfile -ExecutionPolicy Bypass -Command ^
" }" ^
"}" ^
"try {" ^
" $backend = Start-Process -FilePath 'cargo' -ArgumentList @('run','-p','dht-search','--bin','dht-search','--','--config','dht-search.toml') -WorkingDirectory $env:PROJECT_ROOT -NoNewWindow -PassThru;" ^
" $backend = Start-Process -FilePath 'cargo' -ArgumentList @('run','-p','dht-search','--bin','dht-search','--','--config','config.toml') -WorkingDirectory $env:PROJECT_ROOT -NoNewWindow -PassThru;" ^
" $web = Start-Process -FilePath 'bun' -ArgumentList @('--bun','run','dev','--','--host','127.0.0.1') -WorkingDirectory $env:WEB_DIR -NoNewWindow -PassThru;" ^
" while (-not $backend.HasExited -and -not $web.HasExited) { Start-Sleep -Milliseconds 250 }" ^
"} finally {" ^
+9 -13
View File
@@ -18,15 +18,13 @@ cargo build -p dht-search --release
## 配置
复制根目录的配置模板
根目录 `config.toml` 包含全部运行配置和内容过滤规则
```powershell
Copy-Item dht-search.example.toml dht-search.toml
cargo run -p dht-search -- --config config.toml
```
相对 `data_dir` 以配置文件所在目录为基准解析
`content_filter_file` 指向独立的无效文件过滤配置 相对路径同样以主配置文件所在目录为基准解析 推荐直接使用根目录的 `content-filters.toml`
相对目录以 `config.toml` 所在目录为基准解析
也可以通过命令行覆盖数据目录和本次运行时长
@@ -40,7 +38,7 @@ cargo run -p dht-search -- --data-dir D:\data\dht-search --run-duration-secs 360
### 当前运行模板网络配置
`dht-search.example.toml` 默认使用经过本机一分钟资源测试的 Bitmagnet 等效激进配置 主动 DHT 查询仍共享 `max_outbound_queries_per_second` 总预算 所有队列保持有界 可以按设备和网络条件主动下调
`config.toml` 默认使用经过本机一分钟资源测试的 Bitmagnet 等效激进配置 主动 DHT 查询仍共享 `max_outbound_queries_per_second` 总预算 所有队列保持有界 可以按设备和网络条件主动下调
| 配置项 | 运行模板值 | 作用 |
|---|---:|---|
@@ -138,7 +136,7 @@ RocksDB 是唯一权威数据源 应用使用 RocksDB 原生 Checkpoint API 在
```powershell
target\release\dht-search.exe `
--config dht-search.example.toml `
--config config.toml `
--restore-checkpoint data\backups\checkpoint-00000001775400000000
```
@@ -191,18 +189,16 @@ SQLite 使用 WAL 和单独 writer 线程 原始采样超过 24 小时自动删
`/stats` 返回 `metadata_filtered` 总数以及 `metadata_filtered_*` 分类计数 Web 运行状态展示本次运行的过滤总数
可以使用独立数据目录和严格限制运行五分钟测试 不会污染正式数据目录
可以通过命令行覆盖独立数据目录运行五分钟测试 不会污染正式数据目录
```powershell
cargo run -p dht-search -- --config dht-search.filter-test.toml
cargo run -p dht-search -- --config config.toml --data-dir data-filter-test --run-duration-secs 300
Invoke-RestMethod http://127.0.0.1:8080/stats | ConvertTo-Json -Depth 5
```
严格测试配置仅用于观察过滤效果 不应作为正式采集配置
### 无效文件隐藏规则
`content-filters.toml` 控制哪些文件不参与详情展示 搜索 文件数量 有效大小和内容聚合 默认规则会隐藏 BitComet `_____padding_file_` 文件以及 `.pad``.____padding_file` 填充目录
`config.toml``content_filter` 区域控制哪些文件不参与详情展示 搜索 文件数量 有效大小和内容聚合 默认规则会隐藏 BitComet `_____padding_file_` 文件以及 `.pad``.____padding_file` 填充目录
RocksDB 始终保存完整原始 Metadata 隐藏规则不会删除文件或种子 修改或回滚规则后应用会根据规则指纹重新计算内容组并从 RocksDB 重建 Tantivy
@@ -232,7 +228,7 @@ Linux 生产运行必须把文件描述符上限提高到至少 65536
```bash
prlimit --nofile=65536:65536 -- \
/opt/dht-search/dht-search --config /opt/dht-search/dht-search.toml
/opt/dht-search/dht-search --config /opt/dht-search/config.toml
```
systemd 服务需要设置
+23
View File
@@ -259,6 +259,29 @@ mod tests {
serde_json::from_slice(&to_bytes(response.into_body(), usize::MAX).await.unwrap())
.unwrap();
let original_revision = config_snapshot["revision"].as_str().unwrap().to_owned();
let mut invalid_filter = config_snapshot["config"].clone();
invalid_filter["content_filter"]["file_rules"][0]["match"] = serde_json::json!("regex");
invalid_filter["content_filter"]["file_rules"][0]["value"] = serde_json::json!("(");
let response = app
.clone()
.oneshot(
Request::builder()
.method("PUT")
.uri("/config")
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_vec(&serde_json::json!({
"revision": original_revision.clone(),
"config": invalid_filter,
}))
.unwrap(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
config_snapshot["config"]["dht"]["port"] = serde_json::json!(22_313);
let update = serde_json::json!({
"revision": original_revision,
+10 -5
View File
@@ -25,7 +25,7 @@ pub(crate) use store::{ConfigStore, TomlConfigStore};
#[derive(Debug, Parser)]
#[command(name = "dht-search", version, about = "DHT 元数据采集和搜索服务")]
pub(crate) struct Cli {
#[arg(long, default_value = "dht-search.toml")]
#[arg(long, default_value = "config.toml")]
config: PathBuf,
#[arg(long)]
data_dir: Option<PathBuf>,
@@ -128,6 +128,7 @@ mod tests {
assert_eq!(decoded.http.listen, dto.http.listen);
assert_eq!(decoded.logging.file_prefix, dto.logging.file_prefix);
assert_eq!(decoded.diagnostics.database, dto.diagnostics.database);
assert_eq!(decoded.content_filter, dto.content_filter);
}
#[test]
@@ -145,10 +146,6 @@ mod tests {
.unwrap();
let config = startup.app;
assert_eq!(config.data_dir, directory.path().join("state"));
assert_eq!(
config.content_filter_file,
directory.path().join("content-filters.toml")
);
assert_eq!(config.logging.directory, directory.path().join("data/logs"));
assert_eq!(
config.backup.directory,
@@ -191,6 +188,14 @@ mod tests {
assert!(matches!(resolve(dto), Err(AppError::Config(_))));
}
#[test]
fn invalid_embedded_content_filter_is_rejected() {
let mut dto = AppConfigDto::default();
dto.content_filter.file_rules[0].match_kind = crate::domain::FileMatchKind::Regex;
dto.content_filter.file_rules[0].value = "(".to_owned();
assert!(matches!(resolve(dto), Err(AppError::ContentFilter(_))));
}
#[test]
fn disk_resume_threshold_must_exceed_minimum() {
let mut dto = AppConfigDto::default();
+44 -2
View File
@@ -4,11 +4,15 @@ use std::{net::SocketAddr, path::PathBuf};
use serde::{Deserialize, Serialize};
use crate::domain::{
ContentFilterConfig, FileFilterRule, FileMatchField, FileMatchKind, FileRuleAction,
};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub(crate) struct AppConfigDto {
pub(crate) data_dir: PathBuf,
pub(crate) content_filter_file: PathBuf,
pub(crate) content_filter: ContentFilterConfig,
pub(crate) persistence_queue_capacity: usize,
pub(crate) stats_interval_secs: u64,
pub(crate) run_duration_secs: Option<u64>,
@@ -140,7 +144,7 @@ impl Default for AppConfigDto {
fn default() -> Self {
Self {
data_dir: PathBuf::from("data"),
content_filter_file: PathBuf::from("content-filters.toml"),
content_filter: default_content_filter(),
persistence_queue_capacity: 8_192,
stats_interval_secs: 10,
run_duration_secs: None,
@@ -158,6 +162,44 @@ impl Default for AppConfigDto {
}
}
fn default_content_filter() -> ContentFilterConfig {
ContentFilterConfig {
version: 1,
file_rules: vec![
FileFilterRule {
id: "bitcomet-padding-file".to_owned(),
enabled: true,
field: FileMatchField::FileName,
match_kind: FileMatchKind::Prefix,
value: "_____padding_file_".to_owned(),
case_sensitive: false,
action: FileRuleAction::Hide,
reason: "BitComet 分片边界填充文件".to_owned(),
},
FileFilterRule {
id: "generic-pad-directory".to_owned(),
enabled: true,
field: FileMatchField::FilePath,
match_kind: FileMatchKind::Regex,
value: r"(^|/)\.pad/".to_owned(),
case_sensitive: false,
action: FileRuleAction::Hide,
reason: "客户端分片边界填充目录".to_owned(),
},
FileFilterRule {
id: "libtorrent-padding-directory".to_owned(),
enabled: true,
field: FileMatchField::FilePath,
match_kind: FileMatchKind::Regex,
value: r"(^|/)\.____padding_file/".to_owned(),
case_sensitive: false,
action: FileRuleAction::Hide,
reason: "libtorrent 分片边界填充目录".to_owned(),
},
],
}
}
impl Default for MetadataLimitsConfig {
fn default() -> Self {
Self {
+4 -6
View File
@@ -1,8 +1,8 @@
// 负责解析校验用户配置并生成应用可直接使用的运行时配置
use std::{fs, ops::Deref, path::Path};
use std::{ops::Deref, path::Path};
use crate::domain::{ContentFilter, ContentFilterConfig, MetadataLimits};
use crate::domain::{ContentFilter, MetadataLimits};
use dht_crawler::{
BootstrapOptions, CrawlOptions, DHTOptions, MetadataOptions, NetMode, PeerLookupOptions,
PoolOptions, RateLimitOptions, SampleInfohashesOptions, SchedulerOptions, TargetOptions,
@@ -18,7 +18,6 @@ pub(crate) struct AppConfig(AppConfigDto);
impl AppConfig {
pub(crate) fn resolve(mut dto: AppConfigDto, base: &Path) -> Result<Self, AppError> {
dto.data_dir = resolve_path(base, dto.data_dir);
dto.content_filter_file = resolve_path(base, dto.content_filter_file);
dto.logging.directory = resolve_path(base, dto.logging.directory);
dto.backup.directory = resolve_path(base, dto.backup.directory);
dto.diagnostics.database = resolve_path(base, dto.diagnostics.database);
@@ -29,9 +28,7 @@ impl AppConfig {
}
pub(crate) fn content_filter(&self) -> Result<ContentFilter, AppError> {
let contents = fs::read_to_string(&self.content_filter_file)?;
let config = toml::from_str::<ContentFilterConfig>(&contents)?;
ContentFilter::compile(config).map_err(AppError::from)
ContentFilter::compile(self.content_filter.clone()).map_err(AppError::from)
}
pub(crate) fn dht_options(&self) -> DHTOptions {
@@ -98,6 +95,7 @@ impl AppConfig {
}
fn validate(&self) -> Result<(), AppError> {
ContentFilter::compile(self.content_filter.clone())?;
if self.persistence_queue_capacity == 0 {
return Err(AppError::Config(
"persistence_queue_capacity 必须大于零".to_owned(),
+1 -1
View File
@@ -75,7 +75,7 @@ fn temporary_path(destination: &Path) -> PathBuf {
let file_name = destination
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("dht-search.toml");
.unwrap_or("config.toml");
destination.with_file_name(format!(
".{file_name}.tmp-{}-{sequence}",
std::process::id()
+2 -2
View File
@@ -8,7 +8,7 @@
```powershell
$env:LIBCLANG_PATH = "D:\tools\dht\.tools\libclang\clang\native"
cargo run -p dht-search --bin dht-search -- --config dht-search.toml
cargo run -p dht-search --bin dht-search -- --config config.toml
```
然后打开另一个终端启动前端开发服务
@@ -40,7 +40,7 @@ cd src/web
bun install --frozen-lockfile
bun run build
cd ../..
cargo run --release -p dht-search --bin dht-search -- --config dht-search.toml
cargo run --release -p dht-search --bin dht-search -- --config config.toml
```
打开 `http://127.0.0.1:8080`
+70 -5
View File
@@ -1,12 +1,12 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { AlertTriangle, Check, FileCog, LoaderCircle, RotateCcw, Save } from '@lucide/vue'
import { AlertTriangle, Check, FileCog, LoaderCircle, Plus, RotateCcw, Save, Trash2 } from '@lucide/vue'
import { TabsContent, TabsList, TabsRoot, TabsTrigger } from 'reka-ui'
import { onBeforeRouteLeave } from 'vue-router'
import { Button } from '@/components/ui/button'
import { getConfig, updateConfig } from '@/lib/api'
import type { AppConfigDto, ConfigSnapshot } from '@/types/api'
import type { AppConfigDto, ConfigSnapshot, ContentFilterRule } from '@/types/api'
type ConfigFieldType = 'text' | 'number' | 'boolean' | 'select'
@@ -23,12 +23,12 @@ interface ConfigSection {
title: string
description: string
fields: ConfigField[]
kind?: 'fields' | 'content-filter'
}
const sections: ConfigSection[] = [
{ title: '基础', description: '数据目录、任务队列和索引提交', fields: [
{ path: 'data_dir', label: '数据目录', description: 'RocksDB 和 Tantivy 的根目录', type: 'text' },
{ path: 'content_filter_file', label: '内容过滤规则', description: '无效文件隐藏规则 TOML', type: 'text' },
{ path: 'persistence_queue_capacity', label: '持久化队列容量', description: 'Metadata 到 RocksDB 的有界队列', type: 'number' },
{ path: 'stats_interval_secs', label: '状态日志间隔', description: '运行状态日志输出秒数', type: 'number' },
{ path: 'run_duration_secs', label: '运行时长', description: '留空表示持续运行', type: 'number', nullable: true },
@@ -63,6 +63,7 @@ const sections: ConfigSection[] = [
{ path: 'metadata_limits.max_path_bytes', label: '最大路径长度', description: '文件路径最大字节数', type: 'number' },
{ path: 'metadata_limits.max_path_depth', label: '最大目录层级', description: '文件路径允许的目录深度', type: 'number' },
] },
{ title: '内容过滤', description: '从展示、统计、搜索和内容聚合中隐藏无效文件', kind: 'content-filter', fields: [] },
{ title: '磁盘与备份', description: '空间保护和 RocksDB 在线检查点', fields: [
{ path: 'disk_guard.enabled', label: '磁盘保护', description: '空间不足时自动进入只读状态', type: 'boolean' },
{ path: 'disk_guard.check_interval_secs', label: '磁盘检查间隔', description: '剩余空间检查秒数', type: 'number' },
@@ -112,6 +113,20 @@ const error = ref('')
const saved = ref(false)
const dirty = computed(() => config.value !== null && JSON.stringify(config.value) !== original.value)
const filterFieldOptions = [
{ value: 'file-name', label: '文件名' },
{ value: 'file-path', label: '文件路径' },
]
const filterMatchOptions = [
{ value: 'exact', label: '精确匹配' },
{ value: 'prefix', label: '前缀' },
{ value: 'suffix', label: '后缀' },
{ value: 'contains', label: '包含' },
{ value: 'wildcard', label: '通配符' },
{ value: 'regex', label: '正则表达式' },
]
function fieldValue(path: string): unknown {
if (!config.value) return ''
return path.split('.').reduce<unknown>((value, key) => (value as Record<string, unknown>)[key], config.value)
@@ -133,6 +148,35 @@ function updateField(field: ConfigField, event: Event) {
saved.value = false
}
function markChanged() {
saved.value = false
}
function addFilterRule() {
if (!config.value) return
const used = new Set(config.value.content_filter.file_rules.map((rule) => rule.id))
let index = config.value.content_filter.file_rules.length + 1
while (used.has(`rule-${index}`)) index += 1
const rule: ContentFilterRule = {
id: `rule-${index}`,
enabled: true,
field: 'file-name',
match: 'contains',
value: '',
case_sensitive: false,
action: 'hide',
reason: '',
}
config.value.content_filter.file_rules.push(rule)
markChanged()
}
function removeFilterRule(index: number) {
if (!config.value) return
config.value.content_filter.file_rules.splice(index, 1)
markChanged()
}
async function load() {
loading.value = true
error.value = ''
@@ -202,8 +246,11 @@ onBeforeRouteLeave(() => !dirty.value || window.confirm('配置尚未保存,
<TabsContent v-for="section in sections" :key="section.title" :value="section.title" class="mt-4 outline-none">
<section class="rounded-xl border bg-card shadow-sm">
<div class="border-b px-5 py-4"><h2 class="font-semibold">{{ section.title }}</h2><p class="mt-1 text-sm text-muted-foreground">{{ section.description }}</p></div>
<div class="grid gap-x-6 px-5 sm:grid-cols-2">
<div class="flex items-center gap-3 border-b px-5 py-4">
<div><h2 class="font-semibold">{{ section.title }}</h2><p class="mt-1 text-sm text-muted-foreground">{{ section.description }}</p></div>
<Button v-if="section.kind === 'content-filter'" class="ml-auto" size="sm" type="button" variant="outline" @click="addFilterRule"><Plus />添加规则</Button>
</div>
<div v-if="section.kind !== 'content-filter'" class="grid gap-x-6 px-5 sm:grid-cols-2">
<label v-for="field in section.fields" :key="field.path" class="config-field" :class="field.type === 'boolean' ? 'flex-row items-center' : 'flex-col'">
<span class="min-w-0 flex-1"><b>{{ field.label }}</b><small>{{ field.description }}</small></span>
<input v-if="field.type === 'boolean'" class="size-4 accent-foreground" type="checkbox" :checked="Boolean(fieldValue(field.path))" @change="updateField(field, $event)" />
@@ -211,6 +258,24 @@ onBeforeRouteLeave(() => !dirty.value || window.confirm('配置尚未保存,
<input v-else class="config-input" :type="field.type" :value="fieldValue(field.path) ?? ''" :min="field.type === 'number' ? 0 : undefined" step="1" @input="updateField(field, $event)" />
</label>
</div>
<div v-else class="space-y-3 p-5">
<p v-if="config.content_filter.file_rules.length === 0" class="rounded-lg border border-dashed p-8 text-center text-sm text-muted-foreground">当前没有内容过滤规则</p>
<article v-for="(rule, index) in config.content_filter.file_rules" :key="index" class="rounded-xl border bg-background p-4">
<div class="mb-4 flex items-center gap-3">
<label class="flex items-center gap-2 text-sm font-medium"><input v-model="rule.enabled" class="size-4 accent-foreground" type="checkbox" @change="markChanged" />启用</label>
<span class="text-xs text-muted-foreground">规则 {{ index + 1 }}</span>
<Button class="ml-auto" size="icon-sm" type="button" variant="ghost" aria-label="删除过滤规则" title="删除规则" @click="removeFilterRule(index)"><Trash2 /></Button>
</div>
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<label class="field-label">规则 ID<input v-model="rule.id" class="config-input mt-0" type="text" @input="markChanged" /></label>
<label class="field-label">匹配字段<select v-model="rule.field" class="config-input mt-0" @change="markChanged"><option v-for="option in filterFieldOptions" :key="option.value" :value="option.value">{{ option.label }}</option></select></label>
<label class="field-label">匹配方式<select v-model="rule.match" class="config-input mt-0" @change="markChanged"><option v-for="option in filterMatchOptions" :key="option.value" :value="option.value">{{ option.label }}</option></select></label>
<label class="field-label sm:col-span-2">匹配值<input v-model="rule.value" class="config-input mt-0 font-mono" type="text" @input="markChanged" /></label>
<label class="field-label">区分大小写<span class="flex h-9 items-center"><input v-model="rule.case_sensitive" class="size-4 accent-foreground" type="checkbox" @change="markChanged" /></span></label>
<label class="field-label sm:col-span-2 lg:col-span-3">规则说明<input v-model="rule.reason" class="config-input mt-0" type="text" @input="markChanged" /></label>
</div>
</article>
</div>
</section>
</TabsContent>
</TabsRoot>
+19 -1
View File
@@ -209,10 +209,28 @@ export interface DiagnosticHistory {
export type NetworkMode = 'ipv4-only' | 'ipv6-only' | 'dual-stack'
export type LogRotation = 'minutely' | 'hourly' | 'daily' | 'never'
export type ContentFilterField = 'file-name' | 'file-path'
export type ContentFilterMatch = 'exact' | 'prefix' | 'suffix' | 'contains' | 'wildcard' | 'regex'
export interface ContentFilterRule {
id: string
enabled: boolean
field: ContentFilterField
match: ContentFilterMatch
value: string
case_sensitive: boolean
action: 'hide'
reason: string
}
export interface ContentFilterConfig {
version: number
file_rules: ContentFilterRule[]
}
export interface AppConfigDto {
data_dir: string
content_filter_file: string
content_filter: ContentFilterConfig
persistence_queue_capacity: number
stats_interval_secs: number
run_duration_secs: number | null