mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-11-13 17:43:45 +00:00
Ancestor groups of the active target now show a hollow bolt beside the existing filled bolt on the target itself, in the servers tree, the favorites table and the home target browser; the node detail subtitle shows "含活动目标". The derivation lives in CatalogTreeSnapshot.ancestors(of:) so every surface shares one rule while activeTargetID stays a runtime fact outside the catalog projection. Groups activated directly keep the filled bolt and leave their member rows unmarked.
222 lines
8.0 KiB
Swift
222 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")
|
|
) {
|
|
guard let selected else { return }
|
|
Task { @MainActor in
|
|
// 现有运行状态卡片呈现失败;这里不重复弹窗或增加内联错误。
|
|
_ = try? await activation.activate(selected.id, via: workflow)
|
|
}
|
|
}
|
|
.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
|
|
}
|
|
}
|