feat: 精简配置管理与运行默认值
This commit is contained in:
+1
-1
@@ -63,7 +63,7 @@ Axum 根据配置中的 `http.web_dir` 提供静态资源和单页回退 不需
|
||||
- 仅在系统诊断页打开时每秒刷新服务状态
|
||||
- 独立展示进程 RocksDB Tantivy DHT 和队列诊断数据
|
||||
- 使用懒加载 ECharts 展示内存存储压力 Metadata 吞吐以及 HTTP 请求错误趋势
|
||||
- 使用 reka-ui 页签按职责分组的完整配置查看编辑校验保存和重启提示
|
||||
- 使用设置与过滤两个精简页签管理常用配置 Metadata 单位换算和按行通配符
|
||||
- 搜索诊断配置使用互不冲突的单页路由
|
||||
- 加载 空结果 接口错误 重试和移动端适配
|
||||
|
||||
|
||||
@@ -1,108 +1,55 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { AlertTriangle, Check, FileCog, LoaderCircle, Plus, RotateCcw, Save, Trash2 } from '@lucide/vue'
|
||||
import { TabsContent, TabsList, TabsRoot, TabsTrigger } from 'reka-ui'
|
||||
import { AlertTriangle, Check, LoaderCircle, RotateCcw, Save } from '@lucide/vue'
|
||||
import { SwitchRoot, SwitchThumb, TabsContent, TabsList, TabsRoot, TabsTrigger } from 'reka-ui'
|
||||
import { onBeforeRouteLeave } from 'vue-router'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { AppSelect } from '@/components/ui/select'
|
||||
import { getConfig, updateConfig } from '@/lib/api'
|
||||
import type { AppConfigDto, ConfigSnapshot, ContentFilterRule } from '@/types/api'
|
||||
import type { AppConfigDto, ConfigSnapshot } from '@/types/api'
|
||||
|
||||
type ConfigFieldType = 'text' | 'number' | 'boolean' | 'select'
|
||||
type ConfigFieldType = 'number' | 'boolean' | 'select' | 'bytes'
|
||||
|
||||
interface ConfigField {
|
||||
path: string
|
||||
label: string
|
||||
description: string
|
||||
type: ConfigFieldType
|
||||
nullable?: boolean
|
||||
options?: Array<{ value: string; label: string }>
|
||||
options?: ReadonlyArray<{ value: string; label: string }>
|
||||
}
|
||||
|
||||
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' },
|
||||
{ title: '设置', fields: [
|
||||
{ path: 'dht.netmode', label: '网络模式', description: '选择 DHT 使用的 IP 协议族', type: 'select', options: [{ value: 'ipv4-only', label: '仅 IPv4' }, { value: 'ipv6-only', label: '仅 IPv6' }, { value: 'dual-stack', label: '双栈' }] },
|
||||
{ path: 'dht.max_outbound_queries_per_second', label: 'UDP 总速率', description: '所有主动 DHT 查询的每秒硬上限', type: 'number' },
|
||||
{ path: 'dht.metadata_timeout_secs', label: 'Metadata 超时', description: '单次 Metadata 下载的总超时秒数', type: 'number' },
|
||||
{ path: 'dht.metadata_workers', label: 'Metadata Worker', description: '同时处理 Metadata 下载的最大任务数', type: 'number' },
|
||||
{ path: 'dht.metadata_connects_per_second', label: 'Metadata 建连速率', description: '每秒发起 TCP 连接的硬上限', type: 'number' },
|
||||
{ path: 'metadata_limits.max_metadata_bytes', label: '最大 Metadata', description: '单条 Metadata 允许占用的最大空间', type: 'bytes' },
|
||||
{ path: 'metadata_limits.max_files', label: '最大文件数', description: '单个种子允许包含的文件数量', type: 'number' },
|
||||
{ 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 },
|
||||
{ path: 'index_batch_size', label: '索引批量大小', description: '单次提交的内容组数量', type: 'number' },
|
||||
{ path: 'index_interval_millis', label: '索引提交间隔', description: 'Tantivy 批量提交毫秒数', type: 'number' },
|
||||
] },
|
||||
{ title: 'DHT 网络', description: 'UDP 预算、采样和 Metadata 下载并发', fields: [
|
||||
{ path: 'dht.port', label: 'DHT 端口', description: '本机 UDP 监听端口', type: 'number' },
|
||||
{ path: 'dht.netmode', label: '网络模式', description: 'DHT 使用的 IP 协议族', type: 'select', options: [{ value: 'ipv4-only', label: '仅 IPv4' }, { value: 'ipv6-only', label: '仅 IPv6' }, { value: 'dual-stack', label: '双栈' }] },
|
||||
{ path: 'dht.hash_queue_capacity', label: 'InfoHash 队列', description: '待处理 Hash 有界容量', type: 'number' },
|
||||
{ path: 'dht.max_outbound_queries_per_second', label: 'UDP 总速率', description: '所有主动 DHT 查询每秒硬上限', type: 'number' },
|
||||
{ path: 'dht.outbound_query_burst', label: 'UDP 突发', description: '空闲后可立即消费的查询数', type: 'number' },
|
||||
{ path: 'dht.find_node_queries_per_second', label: 'Find Node 速率', description: '每秒节点探测查询', type: 'number' },
|
||||
{ path: 'dht.find_node_max_in_flight', label: 'Find Node 在途', description: '同时等待响应的查询数', type: 'number' },
|
||||
{ path: 'dht.new_destinations_per_minute', label: '新目标速率', description: '每分钟首次探测的目标数', type: 'number' },
|
||||
{ path: 'dht.peer_lookups_per_second', label: 'Peer Lookup 速率', description: '每秒启动的 Peer 查找', type: 'number' },
|
||||
{ path: 'dht.peer_lookup_max_active', label: 'Peer Lookup 并发', description: '同时运行的 Peer 查找', type: 'number' },
|
||||
{ path: 'dht.sample_queries_per_second', label: 'BEP-51 采样速率', description: '每秒采样查询数', type: 'number' },
|
||||
{ path: 'dht.sample_max_in_flight', label: '采样在途', description: '同时等待的采样请求数', type: 'number' },
|
||||
{ path: 'dht.sample_new_node_percent', label: '新节点采样比例', description: '分流到直接采样的百分比', type: 'number' },
|
||||
{ path: 'dht.sample_candidate_queue_capacity', label: '采样候选队列', description: '新节点采样通道容量', type: 'number' },
|
||||
{ path: 'dht.sample_fallback_to_iterative', label: '采样递归回退', description: '直接采样失败后继续迭代查找', type: 'boolean' },
|
||||
{ path: 'dht.metadata_timeout_secs', label: 'Metadata 超时', description: '单次下载总超时秒数', type: 'number' },
|
||||
{ path: 'dht.metadata_queue_capacity', label: 'Metadata 队列', description: '待下载任务容量', type: 'number' },
|
||||
{ path: 'dht.metadata_workers', label: 'Metadata Worker', description: '同时处理的下载任务数', type: 'number' },
|
||||
{ path: 'dht.metadata_connects_per_second', label: 'Metadata 建连速率', description: '每秒 TCP 连接硬上限', type: 'number' },
|
||||
] },
|
||||
{ title: 'Metadata 安全限制', description: '拒绝异常或恶意元数据的资源边界', fields: [
|
||||
{ path: 'metadata_limits.max_metadata_bytes', label: '最大 Metadata', description: '单条 Metadata 最大字节数', type: 'number' },
|
||||
{ path: 'metadata_limits.max_files', label: '最大文件数', description: '单个种子允许的文件数量', type: 'number' },
|
||||
{ path: 'metadata_limits.max_name_bytes', label: '最大名称长度', description: '种子名称最大字节数', type: 'number' },
|
||||
{ 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' },
|
||||
{ path: 'disk_guard.minimum_free_bytes', label: '只读阈值', description: '低于该字节数停止写入', type: 'number' },
|
||||
{ path: 'disk_guard.resume_free_bytes', label: '恢复阈值', description: '高于该字节数恢复写入', type: 'number' },
|
||||
{ path: 'backup.enabled', label: '自动备份', description: '启用 RocksDB 在线检查点', type: 'boolean' },
|
||||
{ path: 'backup.directory', label: '备份目录', description: '检查点保存位置', type: 'text' },
|
||||
{ path: 'backup.interval_secs', label: '备份间隔', description: '自动检查点间隔秒数', type: 'number' },
|
||||
{ path: 'backup.retain_checkpoints', label: '备份保留数量', description: '保留最近检查点数量', type: 'number' },
|
||||
{ path: 'backup.create_on_start', label: '启动时备份', description: '服务启动后立即创建检查点', type: 'boolean' },
|
||||
] },
|
||||
{ title: '运行诊断', description: '独立 SQLite 历史采样与保留', fields: [
|
||||
{ path: 'diagnostics.enabled', label: '运行诊断', description: '采集并保存资源快照', type: 'boolean' },
|
||||
{ path: 'diagnostics.database', label: '诊断数据库', description: 'SQLite 数据库路径', type: 'text' },
|
||||
{ path: 'diagnostics.sample_interval_secs', label: '采样间隔', description: '实时采样秒数', type: 'number' },
|
||||
{ path: 'diagnostics.raw_retention_hours', label: '原始数据保留', description: '原始采样保留小时数', type: 'number' },
|
||||
{ path: 'diagnostics.minute_retention_days', label: '分钟数据保留', description: '分钟快照保留天数', type: 'number' },
|
||||
{ path: 'diagnostics.queue_capacity', label: '诊断写入队列', description: 'SQLite writer 有界容量', type: 'number' },
|
||||
] },
|
||||
{ title: '日志', description: '终端和滚动文件日志', fields: [
|
||||
{ path: 'logging.directory', label: '日志目录', description: '滚动日志文件位置', type: 'text' },
|
||||
{ path: 'logging.file_enabled', label: '文件日志', description: '写入滚动日志文件', type: 'boolean' },
|
||||
{ path: 'logging.console_enabled', label: '终端日志', description: '同时输出到当前终端', type: 'boolean' },
|
||||
{ path: 'logging.rotation', label: '轮转周期', description: '日志文件切换周期', type: 'select', options: [{ value: 'minutely', label: '每分钟' }, { value: 'hourly', label: '每小时' }, { value: 'daily', label: '每天' }, { value: 'never', label: '不轮转' }] },
|
||||
{ path: 'logging.retain_files', label: '日志保留数量', description: '最多保留的匹配日志文件', type: 'number' },
|
||||
{ path: 'logging.file_prefix', label: '日志前缀', description: '滚动日志文件名前缀', type: 'text' },
|
||||
] },
|
||||
{ title: 'HTTP 与验证', description: 'Web 服务和按需种子有效性验证', fields: [
|
||||
{ path: 'http.listen', label: 'HTTP 监听地址', description: 'Web 与 API 监听地址', type: 'text' },
|
||||
{ path: 'http.web_dir', label: 'Web 静态目录', description: '前端生产构建目录', type: 'text' },
|
||||
{ path: 'verification.enabled', label: '按需验证', description: '搜索和详情触发异步验证', type: 'boolean' },
|
||||
{ path: 'verification.queue_capacity', label: '验证队列', description: '持久化验证任务容量', type: 'number' },
|
||||
{ path: 'verification.max_active', label: '验证并发', description: '同时验证的种子数量', type: 'number' },
|
||||
{ path: 'verification.max_peer_attempts', label: 'Peer 尝试数', description: '每个种子最多握手数量', type: 'number' },
|
||||
{ path: 'verification.lease_secs', label: '验证租约', description: '异常退出后的任务恢复秒数', type: 'number' },
|
||||
{ path: 'verification.poll_interval_millis', label: '验证轮询间隔', description: '队列轮询毫秒数', type: 'number' },
|
||||
{ path: 'index_batch_size', label: '索引批量大小', description: 'Tantivy 单次提交的内容组数量', type: 'number' },
|
||||
{ path: 'index_interval_millis', label: '索引提交间隔', description: 'Tantivy 批量提交的间隔毫秒数', type: 'number' },
|
||||
{ path: 'diagnostics.enabled', label: '运行诊断', description: '采集并保存资源与运行趋势', type: 'boolean' },
|
||||
{ path: 'logging.file_enabled', label: '保存日志文件', description: '写入每天轮转并保留七天的日志文件', type: 'boolean' },
|
||||
{ path: 'verification.enabled', label: '按需验证', description: '搜索和详情访问后异步验证种子有效性', type: 'boolean' },
|
||||
] },
|
||||
{ title: '过滤', kind: 'content-filter', fields: [] },
|
||||
]
|
||||
|
||||
const byteUnitOptions = [
|
||||
{ value: 'KiB', label: 'KiB', multiplier: 1024 },
|
||||
{ value: 'MiB', label: 'MiB', multiplier: 1024 ** 2 },
|
||||
{ value: 'GiB', label: 'GiB', multiplier: 1024 ** 3 },
|
||||
] as const
|
||||
|
||||
const snapshot = ref<ConfigSnapshot | null>(null)
|
||||
const config = ref<AppConfigDto | null>(null)
|
||||
const activeSection = ref(sections[0]?.title ?? '')
|
||||
@@ -111,35 +58,19 @@ const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
const saved = ref(false)
|
||||
const metadataUnit = ref('MiB')
|
||||
const fileNamePatterns = ref('')
|
||||
const filePathPatterns = ref('')
|
||||
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)
|
||||
}
|
||||
|
||||
function updateField(field: ConfigField, event: Event) {
|
||||
function setFieldValue(path: string, value: unknown) {
|
||||
if (!config.value) return
|
||||
const input = event.target as HTMLInputElement | HTMLSelectElement
|
||||
let value: unknown
|
||||
if (field.type === 'boolean') value = (input as HTMLInputElement).checked
|
||||
else if (field.type === 'number') value = field.nullable && input.value === '' ? null : Number(input.value)
|
||||
else value = input.value
|
||||
const keys = field.path.split('.')
|
||||
const keys = path.split('.')
|
||||
const last = keys.pop()
|
||||
if (!last) return
|
||||
let target = config.value as unknown as Record<string, unknown>
|
||||
@@ -148,33 +79,61 @@ function updateField(field: ConfigField, event: Event) {
|
||||
saved.value = false
|
||||
}
|
||||
|
||||
function markChanged() {
|
||||
function updateNumberField(field: ConfigField, event: Event) {
|
||||
const value = Number((event.target as HTMLInputElement).value)
|
||||
if (Number.isFinite(value)) setFieldValue(field.path, value)
|
||||
}
|
||||
|
||||
function updateBooleanField(field: ConfigField, value: boolean) {
|
||||
setFieldValue(field.path, value)
|
||||
}
|
||||
|
||||
function selectByteUnit(bytes: number): string {
|
||||
for (const unit of [...byteUnitOptions].reverse()) {
|
||||
if (bytes >= unit.multiplier && bytes % unit.multiplier === 0) return unit.value
|
||||
}
|
||||
return 'KiB'
|
||||
}
|
||||
|
||||
function metadataUnitMultiplier(): number {
|
||||
return byteUnitOptions.find((unit) => unit.value === metadataUnit.value)?.multiplier ?? 1
|
||||
}
|
||||
|
||||
function metadataDisplayValue(): number {
|
||||
return Number(fieldValue('metadata_limits.max_metadata_bytes')) / metadataUnitMultiplier()
|
||||
}
|
||||
|
||||
function updateMetadataBytes(event: Event) {
|
||||
const value = Number((event.target as HTMLInputElement).value)
|
||||
if (!Number.isFinite(value)) return
|
||||
setFieldValue('metadata_limits.max_metadata_bytes', Math.round(value * metadataUnitMultiplier()))
|
||||
}
|
||||
|
||||
function splitPatterns(value: string): string[] {
|
||||
const seen = new Set<string>()
|
||||
return value
|
||||
.split(/\r?\n/)
|
||||
.map((pattern) => pattern.trim())
|
||||
.filter((pattern) => {
|
||||
if (!pattern) return false
|
||||
const key = pattern.toLocaleLowerCase()
|
||||
if (seen.has(key)) return false
|
||||
seen.add(key)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
function syncContentFilter() {
|
||||
if (!config.value) return
|
||||
config.value.content_filter.file_name_patterns = splitPatterns(fileNamePatterns.value)
|
||||
config.value.content_filter.file_path_patterns = splitPatterns(filePathPatterns.value)
|
||||
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()
|
||||
function syncEditorState(next: AppConfigDto) {
|
||||
metadataUnit.value = selectByteUnit(next.metadata_limits.max_metadata_bytes)
|
||||
fileNamePatterns.value = next.content_filter.file_name_patterns.join('\n')
|
||||
filePathPatterns.value = next.content_filter.file_path_patterns.join('\n')
|
||||
}
|
||||
|
||||
async function load() {
|
||||
@@ -184,6 +143,7 @@ async function load() {
|
||||
const next = await getConfig()
|
||||
snapshot.value = next
|
||||
config.value = structuredClone(next.config)
|
||||
syncEditorState(next.config)
|
||||
original.value = JSON.stringify(next.config)
|
||||
saved.value = false
|
||||
} catch (cause) {
|
||||
@@ -201,6 +161,7 @@ async function save() {
|
||||
const next = await updateConfig({ revision: snapshot.value.revision, config: config.value })
|
||||
snapshot.value = next
|
||||
config.value = structuredClone(next.config)
|
||||
syncEditorState(next.config)
|
||||
original.value = JSON.stringify(next.config)
|
||||
saved.value = true
|
||||
} catch (cause) {
|
||||
@@ -231,7 +192,6 @@ onBeforeRouteLeave(() => !dirty.value || window.confirm('配置尚未保存,
|
||||
</div>
|
||||
|
||||
<div v-if="snapshot?.restart_required" class="mb-4 flex items-start gap-3 rounded-xl border border-amber-500/30 bg-amber-500/5 p-4 text-sm"><AlertTriangle class="mt-0.5 size-4 shrink-0 text-amber-500" /><div><p class="font-medium">配置已保存,需要重启服务后生效</p><p class="mt-1 text-muted-foreground">当前进程仍使用启动时的配置</p></div></div>
|
||||
<div v-if="snapshot?.command_line_overrides.length" class="mb-4 flex items-start gap-3 rounded-xl border bg-muted/40 p-4 text-sm"><FileCog class="mt-0.5 size-4 shrink-0" /><div><p class="font-medium">存在命令行覆盖</p><p class="mt-1 text-muted-foreground">{{ snapshot.command_line_overrides.join('、') }} 会优先于配置文件</p></div></div>
|
||||
<div v-if="saved" class="mb-4 flex items-center gap-2 rounded-xl border border-emerald-500/30 bg-emerald-500/5 p-4 text-sm text-emerald-700 dark:text-emerald-400"><Check class="size-4" />配置已安全保存</div>
|
||||
<div v-if="error" class="mb-4 rounded-xl border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">{{ error }}</div>
|
||||
|
||||
@@ -246,35 +206,21 @@ 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="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)" />
|
||||
<select v-else-if="field.type === 'select'" class="config-input" :value="String(fieldValue(field.path))" @change="updateField(field, $event)"><option v-for="option in field.options" :key="option.value" :value="option.value">{{ option.label }}</option></select>
|
||||
<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)" />
|
||||
<SwitchRoot v-if="field.type === 'boolean'" :model-value="Boolean(fieldValue(field.path))" class="relative h-5 w-9 shrink-0 rounded-full bg-input outline-none transition-colors focus-visible:ring-3 focus-visible:ring-ring/30 data-[state=checked]:bg-foreground" @update:model-value="updateBooleanField(field, $event)"><SwitchThumb class="block size-4 translate-x-0.5 rounded-full bg-background shadow-sm transition-transform data-[state=checked]:translate-x-[1.125rem]" /></SwitchRoot>
|
||||
<AppSelect v-else-if="field.type === 'select'" :model-value="String(fieldValue(field.path))" :options="field.options ?? []" :label="field.label" @update:model-value="setFieldValue(field.path, $event)" />
|
||||
<div v-else-if="field.type === 'bytes'" class="grid w-full grid-cols-[minmax(0,1fr)_auto] gap-2">
|
||||
<input class="config-input mt-0" type="number" min="0" step="any" :value="metadataDisplayValue()" @input="updateMetadataBytes" />
|
||||
<AppSelect :model-value="metadataUnit" :options="byteUnitOptions" label="Metadata 大小单位" @update:model-value="metadataUnit = $event" />
|
||||
</div>
|
||||
<input v-else class="config-input" type="number" :value="fieldValue(field.path)" min="0" step="1" @input="updateNumberField(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 v-else class="grid gap-4 p-5 md:grid-cols-2">
|
||||
<label class="field-label">文件名过滤<textarea v-model="fileNamePatterns" class="filter-textarea" spellcheck="false" placeholder="每行一条通配符 *_____padding_file_*" @input="syncContentFilter" /></label>
|
||||
<label class="field-label">文件路径过滤<textarea v-model="filePathPatterns" class="filter-textarea" spellcheck="false" placeholder="每行一条通配符 *.pad/*" @input="syncContentFilter" /></label>
|
||||
</div>
|
||||
</section>
|
||||
</TabsContent>
|
||||
|
||||
@@ -179,6 +179,9 @@
|
||||
.config-input {
|
||||
@apply mt-2 h-9 w-full rounded-md border bg-background px-3 text-sm outline-none transition focus:border-foreground/25 focus:ring-3 focus:ring-ring/15;
|
||||
}
|
||||
.filter-textarea {
|
||||
@apply mt-1 min-h-64 w-full resize-y rounded-lg border bg-background p-3 font-mono text-sm leading-6 text-foreground outline-none transition placeholder:text-muted-foreground focus:border-foreground/25 focus:ring-3 focus:ring-ring/15;
|
||||
}
|
||||
dl dt {
|
||||
@apply text-xs text-muted-foreground;
|
||||
}
|
||||
|
||||
@@ -221,23 +221,9 @@ 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[]
|
||||
file_name_patterns: string[]
|
||||
file_path_patterns: string[]
|
||||
}
|
||||
|
||||
export interface AppConfigDto {
|
||||
@@ -245,7 +231,6 @@ export interface AppConfigDto {
|
||||
content_filter: ContentFilterConfig
|
||||
persistence_queue_capacity: number
|
||||
stats_interval_secs: number
|
||||
run_duration_secs: number | null
|
||||
index_batch_size: number
|
||||
index_interval_millis: number
|
||||
metadata_limits: {
|
||||
@@ -277,12 +262,6 @@ export interface AppConfigDto {
|
||||
find_node_max_in_flight: number
|
||||
new_destinations_per_minute: number
|
||||
}
|
||||
disk_guard: {
|
||||
enabled: boolean
|
||||
check_interval_secs: number
|
||||
minimum_free_bytes: number
|
||||
resume_free_bytes: number
|
||||
}
|
||||
backup: {
|
||||
enabled: boolean
|
||||
directory: string
|
||||
@@ -301,7 +280,6 @@ export interface AppConfigDto {
|
||||
logging: {
|
||||
directory: string
|
||||
file_enabled: boolean
|
||||
console_enabled: boolean
|
||||
rotation: LogRotation
|
||||
retain_files: number
|
||||
file_prefix: string
|
||||
|
||||
Reference in New Issue
Block a user