mirror of
https://github.com/Aminark/legado-ios.git
synced 2026-10-23 20:31:22 +08:00
feat: open-source 小浣熊阅读 for iOS
This commit is contained in:
@@ -0,0 +1,213 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
struct SourceEditorView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@EnvironmentObject private var library: SourceLibraryModel
|
||||
let original: BookSource?
|
||||
let onSaved: ((BookSource) -> Void)?
|
||||
@State private var document: BookSourceEditorDocument
|
||||
@State private var jsonText: String
|
||||
@State private var mode = 0
|
||||
@State private var sectionID = "basic"
|
||||
@State private var errorMessage: String?
|
||||
@State private var validation: [BookSourceIssue] = []
|
||||
@State private var debugSource: BookSource?
|
||||
@State private var isSaving = false
|
||||
@State private var showsDiscard = false
|
||||
private let initialJSON: String
|
||||
|
||||
init(source: BookSource? = nil, onSaved: ((BookSource) -> Void)? = nil) {
|
||||
self.onSaved = onSaved
|
||||
original = source
|
||||
let doc = try! BookSourceEditorDocument(source: source ?? BookSource(bookSourceUrl: "", bookSourceName: ""))
|
||||
let json = (try? doc.json()) ?? "{}"
|
||||
initialJSON = json
|
||||
_document = State(initialValue: doc)
|
||||
_jsonText = State(initialValue: json)
|
||||
}
|
||||
|
||||
private var isDirty: Bool { mode == 1 ? jsonText != initialJSON : (try? document.json()) != initialJSON }
|
||||
private var currentSection: BookSourceEditorSection { BookSourceEditorSchema.sections.first { $0.id == sectionID }! }
|
||||
|
||||
var body: some View {
|
||||
ReadingNavigationStack {
|
||||
VStack(spacing: 0) {
|
||||
Picker("编辑模式", selection: Binding(get: { mode }, set: { value in switchMode(value) })) {
|
||||
Text("字段编辑").tag(0)
|
||||
Text("JSON").tag(1)
|
||||
}.pickerStyle(.segmented).padding(.horizontal).padding(.vertical, 8)
|
||||
if mode == 1 {
|
||||
SyntaxCodeEditor(text: $jsonText, language: .json)
|
||||
} else {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack {
|
||||
ForEach(BookSourceEditorSchema.sections) { section in
|
||||
Button(section.title) { sectionID = section.id }
|
||||
.buttonStyle(.bordered)
|
||||
.tint(sectionID == section.id ? ReadingTheme.accent : .secondary)
|
||||
}
|
||||
Button("选项") { sectionID = "options" }.buttonStyle(.bordered)
|
||||
.tint(sectionID == "options" ? ReadingTheme.accent : .secondary)
|
||||
}.padding(.horizontal)
|
||||
}.padding(.vertical, 6)
|
||||
Form {
|
||||
if sectionID == "options" { options }
|
||||
else {
|
||||
Section(currentSection.title) {
|
||||
ForEach(currentSection.fields) { field in
|
||||
NavigationLink {
|
||||
RuleCodeFieldView(title: field.title, path: field.path, text: string(field.path),
|
||||
language: field.isJavaScript ? .javascript : .rule, help: field.help)
|
||||
} label: {
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
Text(field.title)
|
||||
Text(document.value(at: field.path)?.stringValue.isEmpty == false ? document.value(at: field.path)!.stringValue : field.path)
|
||||
.font(.caption.monospaced()).foregroundStyle(.secondary).lineLimit(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Section {
|
||||
Text("点击字段可全屏编辑规则。支持 @css:、XPath、$. JSONPath、@js:、{{key}}、{{page}} 和 ## 替换语法。未识别的扩展字段保留在 JSON 中。")
|
||||
.font(.footnote).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
if !validation.isEmpty {
|
||||
Section("静态检查") {
|
||||
ForEach(validation) { issue in
|
||||
Label("\(issue.field):\(issue.message)", systemImage: issue.severity == .error ? "xmark.circle" : "exclamationmark.triangle")
|
||||
.font(.footnote).foregroundStyle(issue.severity == .error ? .red : .orange)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(original == nil ? "新建书源" : "编辑书源")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("取消") { if isDirty { showsDiscard = true } else { dismiss() } }
|
||||
}
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Menu("更多", systemImage: "ellipsis.circle") {
|
||||
Button("格式化 JSON", systemImage: "text.alignleft") { perform { try synchronize(); jsonText = try document.json() } }
|
||||
Button("校验配置", systemImage: "checkmark.shield") { check() }
|
||||
Button("调试当前草稿", systemImage: "stethoscope") { perform { try synchronize(); debugSource = try document.source() } }
|
||||
Button("复制 JSON", systemImage: "doc.on.doc") { perform { try synchronize(); UIPasteboard.general.string = try document.json() } }
|
||||
Button("粘贴 JSON", systemImage: "doc.on.clipboard") {
|
||||
perform {
|
||||
guard let value = UIPasteboard.general.string else { return }
|
||||
let pasted = try BookSourceEditorDocument(json: value)
|
||||
_ = try JSONDecoder().decode(BookSource.self, from: Data(pasted.json().utf8))
|
||||
document = pasted
|
||||
jsonText = try pasted.json()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button(isSaving ? "保存中" : "保存", action: save).disabled(isSaving)
|
||||
}
|
||||
}
|
||||
.readingNavigationDestination(isPresented: Binding(
|
||||
get: { debugSource != nil },
|
||||
set: { if !$0 { debugSource = nil } }
|
||||
)) {
|
||||
if let debugSource { SourceDebugView(source: debugSource) }
|
||||
}
|
||||
.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() }
|
||||
}
|
||||
}
|
||||
.interactiveDismissDisabled(isDirty || isSaving)
|
||||
}
|
||||
|
||||
private var options: some View {
|
||||
Group {
|
||||
Section("书源类型") {
|
||||
Picker("类型", selection: Binding(get: { Int(document.value(at: "bookSourceType")?.stringValue ?? "0") ?? 0 },
|
||||
set: { document.set(.number(Double($0)), at: "bookSourceType") })) {
|
||||
Text("文本").tag(0); Text("音频").tag(1); Text("图片").tag(2); Text("文件").tag(3); Text("视频").tag(4)
|
||||
}
|
||||
}
|
||||
Section("开关") {
|
||||
Toggle("启用书源", isOn: boolean("enabled", fallback: true))
|
||||
Toggle("启用发现", isOn: boolean("enabledExplore", fallback: true))
|
||||
Toggle("Cookie Jar", isOn: boolean("enabledCookieJar", fallback: true))
|
||||
Toggle("事件监听", isOn: boolean("eventListener"))
|
||||
Toggle("定制按钮", isOn: boolean("customButton"))
|
||||
}
|
||||
Section("排序与状态") {
|
||||
ForEach(["customOrder", "weight", "lastUpdateTime", "respondTime"], id: \.self) { key in
|
||||
HStack {
|
||||
Text(["customOrder": "排序编号", "weight": "搜索权重", "lastUpdateTime": "更新时间 (ms)", "respondTime": "响应时间 (ms)"][key]!)
|
||||
TextField(key, text: number(key)).keyboardType(.numbersAndPunctuation).multilineTextAlignment(.trailing)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func string(_ path: String) -> Binding<String> {
|
||||
Binding(get: { document.value(at: path)?.stringValue ?? "" }, set: { document.set($0.isEmpty ? nil : .string($0), at: path) })
|
||||
}
|
||||
private func boolean(_ path: String, fallback: Bool = false) -> Binding<Bool> {
|
||||
Binding(get: { if case .bool(let value) = document.value(at: path) { return value }; return fallback },
|
||||
set: { document.set(.bool($0), at: path) })
|
||||
}
|
||||
private func number(_ path: String) -> Binding<String> {
|
||||
Binding(get: { document.value(at: path)?.stringValue ?? "0" }, set: { document.set(.string($0), at: path) })
|
||||
}
|
||||
private func synchronize() throws { if mode == 1 { document = try BookSourceEditorDocument(json: jsonText) } }
|
||||
private func switchMode(_ next: Int) {
|
||||
guard next != mode else { return }
|
||||
perform {
|
||||
if next == 0 { try synchronize() }
|
||||
else { jsonText = try document.json() }
|
||||
mode = next
|
||||
}
|
||||
}
|
||||
private func perform(_ action: () throws -> Void) {
|
||||
do { try action() } catch { errorMessage = readable(error) }
|
||||
}
|
||||
private func readable(_ error: Error) -> String {
|
||||
if case DecodingError.typeMismatch(_, let context) = error {
|
||||
return "\(context.codingPath.map(\.stringValue).joined(separator: ".")):字段类型不正确。\(context.debugDescription)"
|
||||
}
|
||||
return error.localizedDescription
|
||||
}
|
||||
private func check() {
|
||||
perform {
|
||||
try synchronize()
|
||||
let source = try JSONDecoder().decode(BookSource.self, from: Data(document.json().utf8))
|
||||
validation = BookSourceValidator.validate(source)
|
||||
if validation.isEmpty { errorMessage = "静态检查通过。可通过“调试当前草稿”验证实际请求及解析结果。" }
|
||||
}
|
||||
}
|
||||
private func save() {
|
||||
perform {
|
||||
try synchronize()
|
||||
var source = try document.source()
|
||||
if library.sources.contains(where: { $0.id == source.id && $0.id != original?.id }) {
|
||||
throw BookSourceImportError.duplicate(source.id)
|
||||
}
|
||||
source.lastUpdateTime = Int64(Date().timeIntervalSince1970 * 1_000)
|
||||
isSaving = true
|
||||
Task {
|
||||
let saved = await library.saveSource(source, replacing: original?.id)
|
||||
isSaving = false
|
||||
if saved { onSaved?(source); dismiss() }
|
||||
else {
|
||||
let message = library.notice?.detail ?? "保存失败"
|
||||
library.notice = nil
|
||||
errorMessage = message
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user