Files
ShadowsocksX-NG/ShadowsocksX-NG2/App/SubscriptionsView.swift
T
Qiu Yuzhou 2400c7c81d feat(ng2): window title follows active destination
- window.title tracks the destination via composition-root wiring
  into WorkspaceWindowOpeningAdapter.bindTitle; drop the static
  "ShadowsocksX-NG 2.0" title
- delete the 28pt shell destinationHeader; subscription and
  diagnostics actions move into the unified toolbar
- settings save/reset stay with the form as a top action row in
  SettingsView; form-level commit actions read wrong in a toolbar
2026-09-25 03:18:59 +08:00

364 lines
11 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
/// 订阅分区(spec #21 D11,issue #35/#41,地图 #52 票 #56):每订阅一张卡片
/// (图标 + 名称 + 「HTTPS 订阅地址已隐藏」+ 状态徽标 + 更新/重试 + 「…」菜单 +
/// 三列元数据 + 脱敏说明行),失败卡警告色描边。添加订阅与更新全部在窗口
/// 工具栏(由主窗口壳提供)。卡片数据来自订阅 projection(非敏感);结构化
/// 刷新状态在呈现层本地化(story 42)。
struct SubscriptionsView: View {
@ObservedObject var workflow: CatalogWorkflow
let errors: ErrorAlertPresenter
/// 删除完成后回调(被删身份集合;主窗口据此清除失效选择)。
let onNodesRemoved: (Set<NodeID>) -> Void
@State private var editTarget: SubscriptionSummary?
@State private var deleteTarget: SubscriptionSummary?
var body: some View {
ScrollView {
VStack(spacing: 14) {
ForEach(workflow.subscriptions) { summary in
SubscriptionCard(
summary: summary,
groupName: workflow.displayName(for: summary.groupID),
isRefreshing: workflow.refreshingSubscriptionIDs.contains(summary.id),
onRefresh: { Task { await workflow.refreshSubscription(summary.id) } },
onEdit: { editTarget = summary },
onDelete: { deleteTarget = summary })
}
}
.frame(maxWidth: 960, alignment: .leading)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.leading, 28)
.padding(.trailing, 32)
.padding(.top, 24)
.padding(.bottom, 36)
}
.overlay {
if workflow.subscriptions.isEmpty {
ContentUnavailableView(
"暂无订阅", systemImage: "arrow.triangle.2.circlepath",
description: Text("用工具栏「添加订阅」粘贴 HTTPS 订阅地址(SIP-008 JSON)")
)
.allowsHitTesting(false)
}
}
.sheet(item: $editTarget) { summary in
EditSubscriptionURLSheet(workflow: workflow, errors: errors, summary: summary)
}
.confirmationDialog(
"删除订阅「\(deleteTarget?.name ?? "")」及其整棵子树?",
isPresented: Binding(
get: { deleteTarget != nil },
set: { if !$0 { deleteTarget = nil } }),
titleVisibility: .visible
) {
Button("删除订阅", role: .destructive) { commitDelete() }
Button("取消", role: .cancel) { deleteTarget = nil }
} message: {
Text(
"将移除订阅源、固定分组与全部远端成员;若代理正走此订阅,代理会停止。此操作不可撤销。"
)
}
}
private func commitDelete() {
guard let target = deleteTarget else { return }
deleteTarget = nil
Task {
do {
let outcome = try await workflow.removeSubscription(target.id)
onNodesRemoved(outcome.removedNodeIDs)
} catch {
errors.present(error)
}
}
}
}
/// 刷新状态 → 呈现文案(story 42:本地化在 UI 呈现层,不在 module 内)。
extension SubscriptionRefreshStatus {
var badgeText: String {
switch self {
case .never: "尚未刷新"
case .succeeded: "已同步"
case .failed: "刷新失败"
}
}
var lastRefreshText: String {
switch self {
case .never: "尚未刷新"
case .succeeded(let date), .failed(let date, _):
date.formatted(date: .abbreviated, time: .shortened)
}
}
var failureDetail: String? {
if case .failed(_, let failure) = self {
return AppPresentation.message(for: failure)
}
return nil
}
var isFailed: Bool {
if case .failed = self { return true }
return false
}
/// 脱敏说明行(票 #56):失败卡说明保留快照语义。
var footerNote: String {
isFailed
? "保留最后一次成功快照 · 不会清空现有节点"
: "远端结构由订阅管理,本地仅保留启用状态"
}
}
/// 单张订阅卡片(票 #56):头部、三列元数据与脱敏说明;失败态警告色描边。
private struct SubscriptionCard: View {
let summary: SubscriptionSummary
let groupName: String
let isRefreshing: Bool
let onRefresh: () -> Void
let onEdit: () -> Void
let onDelete: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 0) {
header
Divider()
.padding(.top, 16)
meta
.padding(.top, 14)
if let detail = summary.status.failureDetail {
Text(detail)
.font(.footnote)
.foregroundStyle(.red)
.lineLimit(2)
.padding(.top, 10)
}
footer
.padding(.top, 14)
}
.padding(20)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
.background.secondary,
in: RoundedRectangle(cornerRadius: 12, style: .continuous)
)
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.strokeBorder(summary.status.isFailed ? Color.orange.opacity(0.55) : Color.clear)
)
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.strokeBorder(.quaternary)
)
}
private var header: some View {
HStack(alignment: .top, spacing: 12) {
ZStack {
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(
summary.status.isFailed ? Color.orange.opacity(0.14) : Color.accentColor.opacity(0.12))
Image(systemName: "arrow.triangle.2.circlepath")
.font(.system(size: 15, weight: .medium))
.foregroundStyle(summary.status.isFailed ? Color.orange : Color.accentColor)
}
.frame(width: 38, height: 38)
VStack(alignment: .leading, spacing: 2) {
Text(summary.name)
.font(.title3.weight(.semibold))
.lineLimit(1)
Text("HTTPS 订阅地址已隐藏")
.font(.subheadline)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
.help("订阅主机:\(summary.host)(完整地址不入诊断与日志)")
}
Spacer(minLength: 12)
badge
if isRefreshing {
ProgressView()
.controlSize(.small)
.padding(.horizontal, 6)
} else {
Button(summary.status.isFailed ? "重试" : "更新", action: onRefresh)
.buttonStyle(.plain)
.foregroundStyle(.tint)
.font(.callout.weight(.medium))
}
Menu {
Button("编辑 URL…", action: onEdit)
Button("删除…", role: .destructive, action: onDelete)
} label: {
Image(systemName: "ellipsis")
.font(.callout.weight(.medium))
.foregroundStyle(.secondary)
.frame(width: 24, height: 24)
.contentShape(Rectangle())
}
.menuStyle(.borderlessButton)
.fixedSize()
.accessibilityLabel("订阅操作")
}
}
private var badge: some View {
Text(summary.status.badgeText)
.font(.caption.weight(.semibold))
.foregroundStyle(badgeForeground)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(badgeBackground, in: Capsule())
}
private var badgeForeground: Color {
switch summary.status {
case .succeeded: .green
case .failed: .orange
case .never: .secondary
}
}
private var badgeBackground: Color {
switch summary.status {
case .succeeded: Color.green.opacity(0.14)
case .failed: Color.orange.opacity(0.14)
case .never: Color(nsColor: .quaternaryLabelColor).opacity(0.3)
}
}
/// 三列元数据(票 #56):固定分组 | 服务器 | 上次成功刷新。
private var meta: some View {
HStack(alignment: .top, spacing: 24) {
metaColumn("固定分组") {
Text(groupName)
.lineLimit(1)
.help(groupName)
}
metaColumn("服务器") {
Text("\(summary.serverCount) 台")
}
metaColumn("上次成功刷新") {
Text(summary.status.lastRefreshText)
}
Spacer(minLength: 0)
}
}
private func metaColumn<Content: View>(
_ label: String, @ViewBuilder content: () -> Content
) -> some View {
VStack(alignment: .leading, spacing: 3) {
Text(label)
.font(.caption)
.foregroundStyle(.secondary)
content()
.font(.callout.weight(.medium))
.foregroundStyle(.primary)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
private var footer: some View {
HStack(spacing: 6) {
Image(systemName: "checkmark.shield")
.font(.footnote)
.foregroundStyle(.secondary)
Text(summary.status.footerNote)
.font(.footnote)
.foregroundStyle(.secondary)
Spacer(minLength: 0)
}
}
}
/// 「添加订阅」表单(工具栏入口):粘贴 HTTPS 订阅地址;创建后立即首次刷新。
struct AddSubscriptionSheet: View {
let workflow: CatalogWorkflow
let errors: ErrorAlertPresenter
@Environment(\.dismiss) private var dismiss
@State private var urlString = ""
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("粘贴订阅地址(必须 HTTPS,返回 SIP-008 JSON;Content-Type 需为 application/json; charset=utf-8)。")
.font(.callout)
.foregroundStyle(.secondary)
TextField("https://example.com/subscription.json", text: $urlString)
.textFieldStyle(.roundedBorder)
HStack {
Spacer()
Button("取消", role: .cancel) { dismiss() }
Button("添加并刷新") { create() }
.keyboardShortcut(.defaultAction)
.disabled(urlString.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
}
.padding(20)
.frame(minWidth: 480)
}
private func create() {
Task {
do {
_ = try await workflow.createSubscription(urlString: urlString)
dismiss()
} catch {
errors.present(error)
}
}
}
}
/// 「编辑订阅 URL」表单(保留订阅与固定分组身份);保存后立即刷新。
/// URL 明文经显式命令读取(story 11)。
private struct EditSubscriptionURLSheet: View {
let workflow: CatalogWorkflow
let errors: ErrorAlertPresenter
let summary: SubscriptionSummary
@Environment(\.dismiss) private var dismiss
@State private var urlString = ""
@State private var loaded = false
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("编辑订阅地址。订阅身份与固定分组全部保留;新地址刷新成功前保留最后一次成功内容。")
.font(.callout)
.foregroundStyle(.secondary)
TextField("https://example.com/subscription.json", text: $urlString)
.textFieldStyle(.roundedBorder)
HStack {
Spacer()
Button("取消", role: .cancel) { dismiss() }
Button("保存并刷新") { save() }
.keyboardShortcut(.defaultAction)
.disabled(urlString.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
}
.padding(20)
.frame(minWidth: 480)
.onAppear {
guard !loaded else { return }
loaded = true
urlString = (try? workflow.subscriptionURL(for: summary.id)) ?? ""
}
}
private func save() {
Task {
do {
try await workflow.editSubscriptionURL(summary.id, urlString: urlString)
dismiss()
} catch {
errors.present(error)
}
}
}
}