import SwiftUI import UIKit struct ReplaceRuleEditorView: View { @Environment(\.dismiss) private var dismiss @EnvironmentObject private var library: ReplaceRuleLibraryModel @State private var rule: ReplaceRule @State private var mode: Int @State private var testTitle = "第一章 开始" @State private var testContent = "在这里粘贴需要净化的正文。" @State private var testBook = "" @State private var testSource = "" @State private var testResult: ReplaceRuleTestResult? @State private var errorMessage: String? @State private var isTesting = false @State private var isSaving = false @State private var showsDiscard = false private let original: ReplaceRule init(rule: ReplaceRule, startsWithTest: Bool = false) { original = rule _rule = State(initialValue: rule) _mode = State(initialValue: startsWithTest ? 1 : 0) } var body: some View { ReadingNavigationStack { VStack(spacing: 0) { Picker("模式", selection: $mode) { Text("编辑规则").tag(0); Text("测试规则").tag(1) } .pickerStyle(.segmented).padding() Form { if mode == 0 { configuration } else { testForm } } } .navigationTitle(rule.name.isEmpty ? "净化规则" : rule.name) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("取消") { if rule != original { showsDiscard = true } else { dismiss() } } } ToolbarItem(placement: .topBarTrailing) { Menu("更多", systemImage: "ellipsis.circle") { Button("复制规则 JSON", systemImage: "doc.on.doc") { UIPasteboard.general.string = json } Button("粘贴规则 JSON", systemImage: "doc.on.clipboard") { do { guard let text = UIPasteboard.general.string else { return } var pasted = try JSONDecoder().decode(ReplaceRule.self, from: Data(text.utf8)) pasted.id = rule.id pasted.sortOrder = rule.sortOrder rule = pasted } catch { errorMessage = error.localizedDescription } } ReadingShareLink(item: json) { Label("分享规则", systemImage: "square.and.arrow.up") } } } ToolbarItem(placement: .confirmationAction) { Button(isSaving ? "保存中" : "保存") { isSaving = true Task { let saved = await library.save(rule) isSaving = false if saved { dismiss() } else { let message = library.notice?.detail ?? "保存失败" library.notice = nil errorMessage = message } } }.disabled(isSaving || isTesting) } } .alert("规则提示", isPresented: Binding(get: { errorMessage != nil }, set: { if !$0 { errorMessage = nil } })) { Button("好", role: .cancel) {} } message: { Text(errorMessage ?? "") } .confirmationDialog("放弃未保存的修改?", isPresented: $showsDiscard, titleVisibility: .visible) { Button("放弃修改", role: .destructive) { dismiss() } } .onChange(of: rule) { _ in testResult = nil } .onChange(of: testTitle + testContent + testBook + testSource) { _ in testResult = nil } } .interactiveDismissDisabled(rule != original || isSaving) } private var configuration: some View { Group { Section("基本信息") { TextField("名称", text: $rule.name) TextField("分组", text: optional(\.group)) Toggle("启用", isOn: $rule.isEnabled) } Section("匹配与替换") { Toggle("使用正则表达式", isOn: $rule.isRegex) NavigationLink { RuleCodeFieldView(title: "匹配内容", path: "pattern", text: $rule.pattern, language: .regex) } label: { fieldPreview("匹配内容", value: rule.pattern) } NavigationLink { RuleCodeFieldView(title: "替换为", path: "replacement", text: $rule.replacement, language: .regex) } label: { fieldPreview("替换为(留空即删除)", value: rule.replacement) } Text("正则替换使用 $1、$2 引用捕获组;普通文本替换按字面匹配。") .font(.footnote).foregroundStyle(.secondary) } Section("作用范围") { Toggle("章节标题", isOn: $rule.scopeTitle) Toggle("正文", isOn: $rule.scopeContent) ReadingTextField("限定书名或书源(留空为全部)", text: optional(\.scope), axis: .vertical) ReadingTextField("排除书名或书源", text: optional(\.excludeScope), axis: .vertical) } Section("执行设置") { HStack { Text("超时 (ms)") TextField("3000", value: $rule.timeoutMillisecond, format: .number.grouping(.never)) .keyboardType(.numbersAndPunctuation).multilineTextAlignment(.trailing) } HStack { Text("排序编号") TextField("0", value: $rule.sortOrder, format: .number.grouping(.never)) .keyboardType(.numbersAndPunctuation).multilineTextAlignment(.trailing) } Text("超时小于等于 0 时使用 3000 ms。单次正则最多运行 60 秒。规则按排序编号依次执行;资源超限或超时会保留原文并报告错误。") .font(.footnote).foregroundStyle(.secondary) } } } private var testForm: some View { Group { Section("测试上下文") { TextField("书名(用于作用范围判断)", text: $testBook) TextField("书源地址(用于作用范围判断)", text: $testSource).textInputAutocapitalization(.never) TextField("章节标题", text: $testTitle) NavigationLink { RuleCodeFieldView(title: "测试正文", path: "测试输入", text: $testContent) } label: { fieldPreview("正文", value: testContent) } } Section { Button { runTest() } label: { HStack { Label(isTesting ? "测试中…" : "运行当前草稿", systemImage: "play.fill"); Spacer(); if isTesting { ProgressView() } } }.disabled(isTesting) if !rule.isEnabled { Text("规则处于停用状态,结果将保持原文。可在编辑页启用后测试。").font(.footnote).foregroundStyle(.secondary) } } if let result = testResult { Section("测试结果 · \(result.elapsedMilliseconds.formatted(.number.precision(.fractionLength(1)))) ms") { Label(result.report.effectiveRuleIDs.isEmpty ? "未改变文本(请检查匹配、启用状态及作用范围)" : "规则已生效", systemImage: result.report.effectiveRuleIDs.isEmpty ? "info.circle" : "checkmark.circle") ReadingLabeledContent("标题", value: result.report.title) Text(result.report.content.isEmpty ? "(正文为空)" : result.report.content) .font(.body.monospaced()).textSelection(.enabled) } if !result.report.diagnostics.isEmpty { Section("执行错误") { ForEach(result.report.diagnostics) { diagnostic in Text(diagnostic.message).foregroundStyle(.red) } } } } } } private var json: String { let encoder = JSONEncoder() encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] return (try? encoder.encode(rule)).map { String(decoding: $0, as: UTF8.self) } ?? "{}" } private func optional(_ path: WritableKeyPath) -> Binding { Binding(get: { rule[keyPath: path] ?? "" }, set: { rule[keyPath: path] = $0.isEmpty ? nil : $0 }) } private func fieldPreview(_ title: String, value: String) -> some View { VStack(alignment: .leading, spacing: 5) { Text(title) Text(value.isEmpty ? "未填写" : value).font(.caption.monospaced()).foregroundStyle(.secondary).lineLimit(3) } } private func runTest() { let snapshot = rule let title = testTitle, content = testContent, book = testBook, source = testSource isTesting = true testResult = nil Task { do { let result = try await Task.detached(priority: .userInitiated) { try ReplaceRuleTester().test(rule: snapshot, title: title, content: content, bookName: book, sourceURL: source) }.value if rule == snapshot, title == testTitle, content == testContent, book == testBook, source == testSource { testResult = result } } catch { errorMessage = error.localizedDescription } isTesting = false } } }