mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-11-12 20:15:07 +00:00
Replace the inline RulesDeletionButton with a toolbar delete button (trash icon next to the add button). It is enabled only when the custom-rules source is selected on the left and the selection contains deletable custom rules; the collection must also be complete and not committing. The table context menu gains a destructive "删除 (N)" item after a divider when the custom-rules source is selected, with the same enablement conditions. Both entries share one confirmation alert now held by RulesView. Also inline the operation status view into the filter row and tighten filter-area padding.
391 lines
14 KiB
Swift
391 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) {
|
|
Text(RulesCopy.text("全部规则")).tag(RulesSourceChoice.all)
|
|
ForEach(RulesSource.allCases) { 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(RulesSourceChoice.source(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<RulesSourceChoice?> {
|
|
Binding(
|
|
get: {
|
|
workflow.snapshot.query.source.map(RulesSourceChoice.source) ?? .all
|
|
},
|
|
set: { choice in
|
|
guard let choice else { return }
|
|
updateQuery {
|
|
if case .source(let source) = choice { $0.source = source } else { $0.source = nil }
|
|
}
|
|
})
|
|
}
|
|
|
|
/// 删除只针对自定义条目:左侧须选“自定义规则”,且所选行含自定义规则、页完整。
|
|
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: "; ")
|
|
}
|
|
}
|
|
|
|
private enum RulesSourceChoice: Hashable {
|
|
case all
|
|
case source(RulesSource)
|
|
}
|
|
|
|
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 self {
|
|
case .domainExact: RulesCopy.text("精确域名")
|
|
case .domainSuffix: RulesCopy.text("域名后缀")
|
|
case .ipv4CIDR: "IPv4 CIDR"
|
|
case .ipv6CIDR: "IPv6 CIDR"
|
|
}
|
|
}
|
|
}
|