Files
Qiu Yuzhou f6abd4343e feat(rules)!: add isolated routing presets
Add Mainland China proxy routing with independent rule workspaces.
Preserve existing routing choices through equivalent preset mappings.

BREAKING CHANGE: existing custom rules and enablement settings are
not migrated. Recreate them in the appropriate preset.
2026-10-25 14:32:12 +08:00

385 lines
14 KiB
Swift

import SwiftUI
/// Browsing and offline testing go through the workflow.
struct RulesView: View {
@ObservedObject var workflow: RulesWorkflow
@Environment(\.openWindow) private var openWindow
/// 规则编辑草稿与编辑 sheet 由本页自持:@State 生命周期绑定在 rules
/// destination 分支上,离开分区即丢弃(与壳级清稿等价)。
@State private var ruleDraft: CustomRuleDraft?
/// 删除确认由本页自持,工具栏按钮与右键菜单共用同一确认弹窗。
@State private var deletionConfirmation: CustomRuleDeletion?
var body: some View {
VStack(spacing: 0) {
RulesAddressTestView(workflow: workflow)
Divider()
HStack(spacing: 0) {
VStack(spacing: 0) {
sourceList
if let source = workflow.snapshot.sources.first(where: {
$0.id == workflow.snapshot.query.source && $0.metadata != nil
}) {
Divider()
RulesSourceSummaryView(source: source) {
if workflow.openSourceReport(source.id) {
openWindow(id: RulesReportView.sceneID)
}
}.padding(10)
}
}.frame(width: 180)
Divider()
VStack(spacing: 8) {
filters
ruleTable.overlay { emptyState }
if let row = workflow.selectedRelationshipRow {
Divider()
RulesRelationshipsView(row: row)
.frame(maxHeight: 180)
}
}.frame(minWidth: 400, maxWidth: .infinity)
}
}
.task {
if workflow.snapshot.version.isEmpty { await workflow.refresh() }
}
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button(RulesCopy.text("新增规则"), systemImage: "plus") {
ruleDraft = workflow.makeCustomRuleDraft()
}
.disabled(!workflow.snapshot.isComplete)
}
ToolbarItem(placement: .primaryAction) {
Button(RulesCopy.text("删除规则"), systemImage: "trash") {
deletionConfirmation = workflow.prepareCustomRuleDeletion()
}
.disabled(!canDeleteSelection)
}
}
.sheet(item: $ruleDraft) { draft in
CustomRuleEditorSheet(workflow: workflow, draft: draft)
}
.alert(
countText("删除 %lld 条自定义规则?", deletionConfirmation?.customIDs.count ?? 0),
isPresented: Binding(
get: { deletionConfirmation != nil }, set: { if !$0 { deletionConfirmation = nil } }),
presenting: deletionConfirmation
) { captured in
Button(RulesCopy.text("删除"), role: .destructive) {
deletionConfirmation = nil
Task { await workflow.deleteCustomRules(captured) }
}
Button(RulesCopy.text("取消"), role: .cancel) { deletionConfirmation = nil }
} message: { _ in
Text(RulesCopy.text("只删除所选自定义条目。其他来源的规则和已有禁用记录将保留。"))
}
}
@ViewBuilder
private var emptyState: some View {
if workflow.snapshot.operationStatus == .initialLoading {
ProgressView(RulesCopy.text("正在加载规则…"))
} else if workflow.snapshot.rows.isEmpty && !workflow.snapshot.isLoading {
if workflow.snapshot.issues.isEmpty {
ContentUnavailableView(
RulesCopy.text("没有符合条件的规则"), systemImage: "line.3.horizontal.decrease.circle")
} else {
VStack(spacing: 10) {
Label(RulesCopy.text("集合不完整"), systemImage: "exclamationmark.triangle")
RulesCollectionIssuesView(issues: workflow.snapshot.issues)
Button(RulesCopy.text("刷新规则")) {
Task { await workflow.refresh(retryFailedSources: true) }
}
.disabled(workflow.snapshot.isCommitting)
}.padding()
}
}
}
private var sourceList: some View {
List(selection: sourceBinding) {
ForEach([RulesSource.custom] + workflow.preset.sources + [.fixed]) { source in
HStack {
Text(source.label)
Spacer()
if let metadata = workflow.snapshot.sources.first(where: { $0.id == source }) {
Text(metadata.count, format: .number).foregroundStyle(.secondary)
} else if !workflow.snapshot.isLoading && !workflow.snapshot.version.isEmpty {
Image(systemName: "exclamationmark.triangle").foregroundStyle(.secondary)
}
}.tag(source)
}
}
}
private var filters: some View {
VStack(spacing: 8) {
HStack {
actionFilter
.fixedSize()
enabledFilter
.fixedSize()
RulesOperationStatusView(workflow: workflow)
}
.frame(maxWidth: .infinity, alignment: .leading)
TextField(
RulesCopy.text("搜索规则"),
text: Binding(
get: { workflow.snapshot.query.search },
set: { value in updateQuery { $0.search = value } })
)
.textFieldStyle(.roundedBorder)
}
.padding(8)
}
private var actionFilter: some View {
Picker(
RulesCopy.text("行动"),
selection: Binding(
get: { workflow.snapshot.query.action },
set: { value in updateQuery { $0.action = value } })
) {
Text(RulesCopy.text("全部行动")).tag(Optional<RuleAction>.none)
Text(RulesCopy.text("直连")).tag(Optional(RuleAction.direct))
Text(RulesCopy.text("代理")).tag(Optional(RuleAction.proxy))
}
.pickerStyle(.segmented)
.labelsHidden()
}
private var enabledFilter: some View {
Picker(
RulesCopy.text("启用"),
selection: Binding(
get: { workflow.snapshot.query.enabled },
set: { value in updateQuery { $0.enabled = value } })
) {
Text(RulesCopy.text("全部")).tag(Optional<Bool>.none)
Text(RulesCopy.text("已启用")).tag(Optional(true))
Text(RulesCopy.text("已禁用")).tag(Optional(false))
}
.pickerStyle(.segmented)
.labelsHidden()
}
private var ruleTable: some View {
Table(
workflow.snapshot.rows,
selection: Binding(
get: { workflow.snapshot.selection },
set: { ids in DispatchQueue.main.async { workflow.select(ids) } })
) {
TableColumn(RulesCopy.text("启用")) { row in
Toggle(
RulesCopy.text("启用"),
isOn: Binding(
get: { row.isEnabled },
set: { enabled in
guard let identity = row.identity else { return }
Task { await workflow.setEnabled(enabled, identities: [identity]) }
})
)
.labelsHidden()
.disabled(row.isFixed || !workflow.snapshot.isComplete || workflow.snapshot.isCommitting)
}.width(45)
TableColumn(RulesCopy.text("匹配内容")) { row in Text(verbatim: row.displayContent) }
.width(min: 120, ideal: 170)
TableColumn(RulesCopy.text("类型")) { row in Text(verbatim: row.matchType) }
.width(min: 70, ideal: 80)
TableColumn(RulesCopy.text("行动")) { row in Text(row.actionLabel) }
.width(50)
TableColumn(RulesCopy.text("来源")) { row in Text(row.sourceLabels) }
.width(min: 75, ideal: 90)
TableColumn(RulesCopy.text("状态")) { row in Text(row.statusLabel) }
.width(min: 80, ideal: 110)
TableColumn("") { row in
if let id = row.customIDs.first {
Button(RulesCopy.text("编辑规则"), systemImage: "pencil") {
ruleDraft = workflow.makeCustomRuleDraft(editing: id)
}
.labelStyle(.iconOnly)
.help(RulesCopy.text("编辑规则"))
.disabled(!workflow.snapshot.isComplete)
}
}.width(30)
}.frame(minWidth: 0, maxWidth: .infinity)
.contextMenu {
ForEach([true, false], id: \.self) { enabled in
Button {
let identities = workflow.actionableSelection
Task { await workflow.setEnabled(enabled, identities: identities) }
} label: {
Text(
RulesCopy.text(enabled ? "启用" : "禁用") + " ("
+ String(workflow.actionableSelection.count) + ")")
}
.disabled(
workflow.actionableSelection.isEmpty || !workflow.snapshot.isComplete
|| workflow.snapshot.isCommitting)
}
if workflow.snapshot.query.source == .custom {
Divider()
Button(role: .destructive) {
deletionConfirmation = workflow.prepareCustomRuleDeletion()
} label: {
Text(
RulesCopy.text("删除") + " ("
+ String(workflow.deletableSelection.count) + ")")
}
.disabled(!canDeleteSelection)
}
}
}
private var sourceBinding: Binding<RulesSource?> {
Binding(
get: {
workflow.snapshot.query.source ?? .custom
},
set: { choice in
guard let choice else { return }
updateQuery {
$0.source = choice
}
})
}
/// 删除只针对自定义条目:左侧须选“自定义规则”,且所选行含自定义规则、页完整。
private var canDeleteSelection: Bool {
workflow.snapshot.query.source == .custom && workflow.snapshot.isComplete
&& !workflow.deletableSelection.isEmpty
}
private func countText(_ key: String, _ count: Int) -> String {
String.localizedStringWithFormat(RulesCopy.text(key), Int64(count))
}
// Native controls can write their bindings during a SwiftUI update. Publish
// after that pass, and merge each edit into the latest query so queued edits
// to different filters do not overwrite one another.
private func updateQuery(_ update: @escaping @MainActor (inout RulesQuery) -> Void) {
DispatchQueue.main.async {
var query = workflow.snapshot.query
update(&query)
workflow.query(query)
}
}
}
struct RulesRelationshipsView: View {
let row: RulesRow
var body: some View {
ScrollView { RulesRelationshipsContent(row: row).padding() }
}
}
struct RulesRelationshipsContent: View {
let row: RulesRow
var body: some View {
VStack(alignment: .leading, spacing: 10) {
ForEach([RuleRelationship.Kind.absorption, .shadowing], id: \.self) { kind in
let relationships = row.relationships.filter { $0.kind == kind }
let fixed = row.fixedCoverage.flatMap { coverage in
row.action == (kind == .absorption ? .direct : .proxy) ? coverage : nil
}
if !relationships.isEmpty || fixed != nil {
VStack(alignment: .leading, spacing: 4) {
Text(RulesCopy.text(kind == .absorption ? "被同行动规则覆盖" : "被相反行动规则遮蔽"))
ForEach(relationships, id: \.kind) { relationship in
let covering = relationship.covering.filter { identity in
!(identity.action == .direct
&& (fixed?.matches.contains(identity.match) ?? false))
}
if !covering.isEmpty && relationship.extent == .partial {
Text(RulesCopy.text("部分重叠"))
}
ForEach(covering, id: \.self) { identity in
Text(
verbatim: identity.match.browsingContent + " · "
+ identity.match.browsingType + " · "
+ RulesCopy.text(identity.action == .direct ? "直连" : "代理"))
}
}
if let fixed {
Text(
RulesCopy.text("固定本地策略")
+ (fixed.extent == .partial ? " · " + RulesCopy.text("部分重叠") : ""))
ForEach(fixed.matches, id: \.self) { match in
Text(verbatim: match.browsingContent)
}
if fixed.includesSimpleHostname { Text(RulesCopy.text("简单主机名")) }
}
}
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.textSelection(.enabled)
}
}
enum RulesCopy {
static func text(_ key: String) -> String {
NSLocalizedString(key, tableName: "Rules", bundle: .main, value: key, comment: "")
}
}
extension RulesSource {
var label: String {
switch self {
case .custom: RulesCopy.text("自定义规则")
case .fixed: RulesCopy.text("固定本地策略")
case .geolocationCN: "geolocation-cn"
case .chinaIPv4: "china-ipv4"
case .gfwlist: "GFWList"
}
}
}
extension RulesRow {
var displayContent: String { identity?.match.editingContent ?? RulesCopy.text("简单主机名") }
var sourceLabels: String {
guard hasCurrentSource else { return RulesCopy.text("无当前来源") }
return RulesSource.allCases.filter { sources.contains($0) }.map(\.label).joined(separator: ", ")
}
var actionLabel: String { RulesCopy.text(action == .direct ? "直连" : "代理") }
var matchType: String { identity?.match.browsingType ?? RulesCopy.text("简单主机名") }
var statusLabel: String {
if isFixed { return RulesCopy.text("固定本地策略") }
if !isEnabled { return RulesCopy.text("已禁用") }
var labels = relationships.map(\.browsingLabel)
if let fixedCoverage {
let label =
RulesCopy.text(action == .direct ? "被同行动规则覆盖" : "被相反行动规则遮蔽")
+ " · " + RulesCopy.text("固定本地策略")
labels.append(
fixedCoverage.extent == .partial ? label + " · " + RulesCopy.text("部分重叠") : label)
}
return labels.isEmpty ? RulesCopy.text("独立候选") : labels.joined(separator: "; ")
}
}
extension RuleRelationship {
var browsingLabel: String {
let label = RulesCopy.text(kind == .absorption ? "被同行动规则覆盖" : "被相反行动规则遮蔽")
return extent == .partial ? label + " · " + RulesCopy.text("部分重叠") : label
}
}
extension RuleMatch {
var browsingType: String {
if editingKind == .ipAddress { return RulesCopy.text("IP 地址") }
return switch pattern {
case .domainExact: RulesCopy.text("精确域名")
case .domainSuffix: RulesCopy.text("域名后缀")
case .ipv4CIDR: "IPv4 CIDR"
case .ipv6CIDR: "IPv6 CIDR"
}
}
}