mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-11-08 11:14:51 +00:00
224 lines
8.0 KiB
Swift
224 lines
8.0 KiB
Swift
import SwiftUI
|
|
|
|
/// 浏览操作留在首页,测试与激活始终经过两页共享的 module。
|
|
struct HomeTargetCard: View {
|
|
@ObservedObject var workflow: CatalogWorkflow
|
|
@ObservedObject var browser: HomeTargetBrowser
|
|
@ObservedObject var activation: ActivationFeedbackState
|
|
let activeTargetID: NodeID?
|
|
let showServers: () -> Void
|
|
@State private var revealID: NodeID?
|
|
@State private var revealRevision = 0
|
|
|
|
private var rows: [CatalogTreeRow] {
|
|
browser.rows(in: workflow.tree, favorites: workflow.favorites)
|
|
}
|
|
|
|
/// 子树含活动目标的分组集合(空心闪电候选)。
|
|
private var activeAncestors: Set<NodeID> {
|
|
workflow.tree.ancestors(of: activeTargetID)
|
|
}
|
|
|
|
private var selected: CatalogTreeNode? {
|
|
browser.selection.flatMap { workflow.tree.node(withID: $0) }
|
|
}
|
|
|
|
var body: some View {
|
|
ScrollViewReader { proxy in
|
|
HomeCard(
|
|
title: activeName,
|
|
subtitle: activeTargetID.map { path(for: $0) },
|
|
trailing: {
|
|
Button(String(localized: "定位", table: "HomeTargets")) {
|
|
requestReveal(
|
|
browser.locate(activeTargetID, in: workflow.tree, favorites: workflow.favorites))
|
|
}
|
|
.disabled(activeTargetID == nil)
|
|
},
|
|
content: {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
toolbar
|
|
directory
|
|
Divider()
|
|
selectionSummary
|
|
}
|
|
.padding(.top, 14)
|
|
}
|
|
)
|
|
.disabled(activation.isPending)
|
|
.onAppear {
|
|
browser.reconcile(in: workflow.tree, favorites: workflow.favorites)
|
|
requestReveal(
|
|
browser.enterHome(
|
|
current: activeTargetID, in: workflow.tree, favorites: workflow.favorites))
|
|
}
|
|
.task(id: revealRevision) {
|
|
guard let revealID else { return }
|
|
await Task.yield()
|
|
guard !Task.isCancelled else { return }
|
|
proxy.scrollTo(revealID, anchor: .center)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var activeName: String {
|
|
activeTargetID.flatMap { workflow.tree.node(withID: $0)?.name }
|
|
?? String(localized: "未选择活动目标", table: "HomeTargets")
|
|
}
|
|
|
|
private var toolbar: some View {
|
|
HStack {
|
|
Picker(
|
|
String(localized: "目录视图", table: "HomeTargets"),
|
|
selection: Binding(
|
|
get: { browser.mode },
|
|
set: { browser.setMode($0, in: workflow.tree, favorites: workflow.favorites) })
|
|
) {
|
|
Text(String(localized: "全部", table: "HomeTargets")).tag(HomeTargetBrowser.Mode.all)
|
|
Text(String(localized: "收藏", table: "HomeTargets")).tag(HomeTargetBrowser.Mode.favorites)
|
|
}
|
|
.pickerStyle(.segmented)
|
|
.labelsHidden()
|
|
Spacer(minLength: 0)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
|
|
private var directory: some View {
|
|
VStack(spacing: 4) {
|
|
HStack {
|
|
Text(String(localized: "名称", table: "HomeTargets"))
|
|
Spacer()
|
|
Text(ConnectivityCopy.text("响应延迟"))
|
|
}
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.padding(.horizontal, 8)
|
|
if rows.isEmpty {
|
|
emptyDirectory
|
|
} else {
|
|
ScrollView {
|
|
LazyVStack(spacing: 0) {
|
|
ForEach(rows) { row in
|
|
HomeTargetRow(
|
|
row: row, workflow: workflow,
|
|
selected: browser.selection == row.id, active: activeTargetID == row.id,
|
|
containsActiveTarget: activeAncestors.contains(row.id),
|
|
expandable: browser.mode == .all && row.node.isGroup,
|
|
expanded: browser.expandedGroupIDs.contains(row.id),
|
|
path: path(for: row.id),
|
|
select: { browser.select(row.id) },
|
|
toggle: { browser.toggleGroup(row.id, in: workflow.tree) }
|
|
)
|
|
.id(row.id)
|
|
}
|
|
}
|
|
}
|
|
.frame(height: min(CGFloat(rows.count) * HomeTargetRow.height, 360))
|
|
}
|
|
}
|
|
}
|
|
|
|
private var emptyDirectory: some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text(
|
|
browser.mode == .all
|
|
? String(localized: "目录为空,请到服务器页添加配置。", table: "HomeTargets")
|
|
: String(localized: "暂无收藏,请到服务器页收藏配置或分组。", table: "HomeTargets")
|
|
)
|
|
.foregroundStyle(.secondary)
|
|
Button(String(localized: "前往服务器页", table: "HomeTargets"), action: showServers)
|
|
}
|
|
.font(.callout)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding(.vertical, 12)
|
|
}
|
|
|
|
private var selectionSummary: some View {
|
|
HStack(alignment: .top, spacing: 4) {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
if let selected {
|
|
Text(selected.name).font(.callout.weight(.medium))
|
|
.lineLimit(1).help(path(for: selected.id))
|
|
Text(path(for: selected.id)).font(.caption).foregroundStyle(.secondary)
|
|
.lineLimit(2).help(path(for: selected.id))
|
|
if selected.isGroup, let eligibility = workflow.activationEligibility(for: selected.id) {
|
|
Text(
|
|
String(
|
|
localized:
|
|
"可激活 \(eligibility.candidateCount) 个配置,将跳过 \(eligibility.skippedInvalidCount) 个已知无效配置。",
|
|
table: "HomeTargets")
|
|
)
|
|
.font(.caption).foregroundStyle(.secondary)
|
|
}
|
|
} else {
|
|
Text(String(localized: "请选择服务器或分组", table: "HomeTargets"))
|
|
.font(.callout).foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
Spacer()
|
|
actions
|
|
}
|
|
}
|
|
|
|
private var actions: some View {
|
|
VStack(alignment: .trailing, spacing: 8) {
|
|
HStack(alignment: .center, spacing: 8) {
|
|
if let module = workflow.connectivity, let selected {
|
|
ConnectivityTestButton(module: module, node: selected)
|
|
} else {
|
|
Button(ConnectivityCopy.text("延迟测试")) {}.disabled(true)
|
|
}
|
|
Button(
|
|
activation.isPending
|
|
? String(localized: "激活中…", table: "HomeTargets")
|
|
: String(localized: "激活", table: "HomeTargets"),
|
|
systemImage: "bolt.fill"
|
|
) {
|
|
guard let selected else { return }
|
|
Task { @MainActor in
|
|
// 现有运行状态卡片呈现失败;这里不重复弹窗或增加内联错误。
|
|
_ = try? await activation.activate(selected.id, via: workflow)
|
|
}
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
.disabled(activationDisabledReason != nil)
|
|
.help(activationDisabledReason ?? String(localized: "激活所选目标", table: "HomeTargets"))
|
|
}
|
|
Text(activationDisabledReason ?? "")
|
|
.font(.caption).foregroundStyle(.secondary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
}
|
|
|
|
private var activationDisabledReason: String? {
|
|
if activation.isPending { return String(localized: "激活中…", table: "HomeTargets") }
|
|
guard let selected else { return String(localized: "尚未选择目标", table: "HomeTargets") }
|
|
if selected.id == activeTargetID { return String(localized: "当前项已激活", table: "HomeTargets") }
|
|
guard let eligibility = workflow.activationEligibility(for: selected.id),
|
|
eligibility.canActivate
|
|
else {
|
|
if selected.isGroup {
|
|
return String(localized: "分组中没有可激活的有效服务器。", table: "ServerDetails")
|
|
}
|
|
return selected.invalidReasons.first.map {
|
|
AppPresentation.message(for: ActivationFailure.invalidLeaf(node: selected.id, reason: $0))
|
|
} ?? String(localized: "配置不满足本地激活条件", table: "HomeTargets")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private func path(for id: NodeID) -> String {
|
|
guard let node = workflow.tree.node(withID: id) else { return "" }
|
|
let source = node.isManual ? String(localized: "本地") : String(localized: "订阅")
|
|
return ([source] + (workflow.tree.pathComponents(for: id) ?? [])).joined(separator: " / ")
|
|
}
|
|
|
|
private func requestReveal(_ id: NodeID?) {
|
|
guard let id else { return }
|
|
revealID = id
|
|
revealRevision += 1
|
|
}
|
|
}
|