Files

163 lines
6.6 KiB
Swift

import SwiftUI
/// The draft belongs to this presentation; only an explicit save enters the Workflow.
struct CustomRuleEditorSheet: View {
@ObservedObject var workflow: RulesWorkflow
@Environment(\.dismiss) private var dismiss
@State private var draft: CustomRuleDraft
@State private var preview: CustomRulePreview?
@State private var isSaving = false
@State private var saveFailure: CustomRuleUpdateOutcome?
init(workflow: RulesWorkflow, draft: CustomRuleDraft) {
self.workflow = workflow
_draft = State(initialValue: draft)
}
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text(RulesCopy.text(draft.editingID == nil ? "新增规则" : "编辑规则"))
.font(.title2)
Form {
VStack(alignment: .leading, spacing: 10) {
Picker(RulesCopy.text("类型"), selection: $draft.kind) {
ForEach(CustomRuleDraft.Kind.allCases, id: \.self) { kind in
Text(verbatim: kind.label).tag(kind)
}
}
.pickerStyle(.segmented)
TextField(RulesCopy.text("匹配内容"), text: $draft.content)
.autocorrectionDisabled()
LabeledContent(RulesCopy.text("行动")) {
Text(RulesCopy.text(draft.action == .proxy ? "代理" : "直连"))
}
}
}.disabled(isSaving || workflow.snapshot.isCommitting)
ScrollView {
VStack(alignment: .leading, spacing: 10) {
if let preview {
if let content = preview.displayContent {
LabeledContent(RulesCopy.text("规范化预览")) {
Text(verbatim: content).textSelection(.enabled)
}
}
if let failure = preview.failure {
Text(verbatim: failure.message).foregroundStyle(.orange)
} else if let row = preview.row {
if !row.sources.subtracting([.custom]).isEmpty {
Text(RulesCopy.text("将与已有来源合并显示。"))
Text(verbatim: row.sourceLabels).foregroundStyle(.secondary)
}
if preview.inheritsDisablement {
Text(RulesCopy.text("新身份将继承禁用状态;旧禁用记录保留。"))
} else if !row.isEnabled {
Text(RulesCopy.text("此身份已禁用;保存不会重新启用。"))
}
RulesRelationshipsContent(row: row)
Text(RulesCopy.text("覆盖关系仅解释已启用规则之间的关系,不代表当前流量行为。"))
.font(.caption).foregroundStyle(.secondary)
}
} else {
ProgressView().controlSize(.small)
}
if let saveFailure {
Label(saveFailure.rulesMessage, systemImage: "exclamationmark.triangle")
.foregroundStyle(.orange)
if let detail = saveFailure.failureDetail {
Text(verbatim: detail).textSelection(.enabled)
}
if let nextStep = saveFailure.nextStep { Text(RulesCopy.text(nextStep)) }
}
}.frame(maxWidth: .infinity, alignment: .leading)
}
// 预览区随内容伸缩,上限封顶,避免初始 ProgressView 阶段撑出大片空白、
// 长预览把 sheet 无限拉高。
.frame(maxHeight: 210)
HStack {
if isSaving || workflow.snapshot.isCommitting {
ProgressView().controlSize(.small)
Text(RulesCopy.text("正在更新规则…")).font(.caption)
}
Spacer()
Button("取消", role: .cancel) { dismiss() }
.keyboardShortcut(.cancelAction)
.disabled(isSaving || workflow.snapshot.isCommitting)
Button("保存") { save() }
.keyboardShortcut(.defaultAction)
.disabled(preview?.document == nil || isSaving || !workflow.snapshot.isComplete)
}
}
.onChange(of: draft) { _, _ in saveFailure = nil }
// 预览内容高度天然波动(失败提示一两行,合法预览可达上限),尺寸变化
// 只发生在合法↔失败、关系行增减这类语义边界;平缓过渡避免 sheet 跳动。
.animation(.snappy(duration: 0.2), value: preview)
.animation(.snappy(duration: 0.2), value: saveFailure)
.padding(20)
.frame(width: 530)
.interactiveDismissDisabled(isSaving || workflow.snapshot.isCommitting)
.task(
id: PreviewRequest(
draft: draft, version: workflow.snapshot.version, isComplete: workflow.snapshot.isComplete)
) {
// 重算期间保留上一次预览:清空会让预览区塌缩成进度条,sheet 随
// 每个按键先缩后胀;新结果就绪后原位替换(旧任务由 task(id:) 取消)。
let result = await workflow.previewCustomRule(draft)
guard !Task.isCancelled else { return }
preview = result
}
}
private func save() {
guard !isSaving, workflow.snapshot.isComplete else { return }
isSaving = true
saveFailure = nil
Task {
let result = await workflow.saveCustomRule(draft)
isSaving = false
switch result {
case .unavailable(let failure): preview = CustomRulePreview(failure: failure)
case .committed(let outcome):
if outcome.isSuccess { dismiss() } else { saveFailure = outcome }
}
}
}
private struct PreviewRequest: Equatable {
let draft: CustomRuleDraft
let version: String
let isComplete: Bool
}
}
extension CustomRuleDraft.Kind {
var label: String {
switch self {
case .ipAddress: RulesCopy.text("IP 地址")
case .cidr: RulesCopy.text("CIDR 范围")
case .domainSuffix: RulesCopy.text("域名后缀")
case .domainExact: RulesCopy.text("精确域名")
}
}
}
extension CustomRulePreview.Failure {
var message: String {
switch self {
case .incompleteCollection: RulesCopy.text("集合不完整")
case .busy: RulesCopy.text("正在更新规则…")
case .staleDraft: RulesCopy.text("规则集合已变化,请重新打开编辑器。")
case .duplicate: RulesCopy.text("相同匹配条件和行动的自定义规则已存在。")
case .fixedPolicyDisablement:
RulesCopy.text("无法更新规则") + " · " + RulesCopy.text("固定策略优先,以下范围始终直连。")
case .fixedLocalConflict: RulesCopy.text("代理规则与固定本地策略冲突,无法保存。")
case .invalidInput(let kind):
switch kind {
case .ipAddress: RulesCopy.text("请输入有效的 IPv4 或 IPv6 地址。")
case .cidr: RulesCopy.text("请输入有效的 IPv4 或 IPv6 CIDR 范围。")
case .domainExact, .domainSuffix:
RulesCopy.text("请输入有效的 ASCII 域名;不支持通配符、URL 或路径。")
}
}
}
}