Files
ShadowsocksX-NG/ShadowsocksX-NG2/App/UI/Subscriptions/SubscriptionsView.swift
T
Qiu Yuzhou fb428f12bc refactor(app): 按分层重组 App/ 源码目录
App/ 根目录原有 73 个散落源文件,已无法从目录读出任何分组信息。

按「目录即分层」重组为 Composition / Application / Presentation / UI /
PlatformEffects / Resources 六个目录,UI 层再按子视图分目录(Workspace、
Home、Servers(含 Plugin)、Subscriptions、Rules、Settings、Diagnostics、
StatusMenu)。纯移动:类型名、模块名与 import 均未改动,App/ 根层不再有
散落文件。

配套改动:

- project.yml:Core 测试镜像的 excludes 由裸文件名改为 `**/` 递归形式。
  XcodeGen 的 exclude 不跨 `/`,裸名只匹配 App 根层;MainApp.swift 下移后
  旧写法会静默失效,把 @main 组合根编进测试镜像。info.path 与
  entitlements.path 指向 App/Resources/;并排除 `**/*.md`——App/ 下任何
  非源码文件都会被 XcodeGen 收进 Resources 阶段并封进签名后的 bundle。
- SkeletonTests 的架构守卫由「默认扫描全部 App/ + 7 个豁免文件 + 1 个豁免
  前缀 + 4 个豁免目录」改为正面圈定 App/UI/,豁免登记全部删除;新增运行时
  适配器放进 Application/ 即自动落在扫描范围外,无需再登记。
- README 工程结构说明同步;根 .gitignore 的生成物路径随 Resources/ 归位。

验证:clean build 0 error;task test 1022 用例全通过(含两个架构守卫);
task format 无改动(证明纯移动);task lint 与移动前同为 21 条内容型
warning;packaging-gate.sh ALL CHECKS PASSED,确认 plist/entitlements 归位
后签名与 entitlements 语义不变。

注意:迁移时须先删旧的生成物再 xcodegen generate,否则遗留的 App/Info.plist
会被当作普通资源扫进 Resources 阶段。
2026-10-05 19:56:18 +08:00

437 lines
14 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
/// 共享错误弹窗呈现(UI 持有;typed error → 本地化文案的呈现边缘)。
@StateObject private var errors = ErrorAlertPresenter()
/// 删除完成后回调(被删身份集合;主窗口据此清除失效选择)。
let onNodesRemoved: (Set<NodeID>) -> Void
@State private var editTarget: SubscriptionSummary?
@State private var deleteTarget: SubscriptionSummary?
/// 添加订阅表单:本页自持(2026-10-04 自窗口壳「添加」菜单移回本页工具栏)。
@State private var isPresentingAddSubscription = false
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)
}
.sheet(isPresented: $isPresentingAddSubscription) {
AddSubscriptionSheet(workflow: workflow, errors: errors)
}
.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(
"将移除订阅源、固定分组与全部远端成员;若代理正走此订阅,代理会停止。此操作不可撤销。"
)
}
.presentingErrors(errors)
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button("添加订阅", systemImage: "plus") {
isPresentingAddSubscription = true
}
}
ToolbarItem(placement: .primaryAction) {
HStack(spacing: 8) {
if !workflow.refreshingSubscriptionIDs.isEmpty {
ProgressView()
.controlSize(.small)
}
Button("更新全部", systemImage: "arrow.triangle.2.circlepath") {
Task { await workflow.refreshAllSubscriptions() }
}
.disabled(workflow.subscriptions.isEmpty)
}
}
}
}
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 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 {
@Environment(\.locale) private var locale
@Environment(\.timeZone) private var timeZone
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 information = summary.information {
subscriptionInformation(information)
.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("上次成功刷新") {
if let date = summary.lastSucceededAt {
dateText(date)
} else {
Text("—")
}
}
Spacer(minLength: 0)
}
}
@ViewBuilder
private func subscriptionInformation(_ information: SubscriptionInformation) -> some View {
VStack(alignment: .leading, spacing: 12) {
if information.bytesUsed != nil || information.bytesRemaining != nil {
HStack(alignment: .top, spacing: 24) {
if let used = information.bytesUsed {
metaColumn("已用流量") {
Text(SubscriptionInformation.byteCountText(used, locale: locale))
}
}
if let remaining = information.bytesRemaining {
metaColumn("剩余流量") {
Text(SubscriptionInformation.byteCountText(remaining, locale: locale))
}
}
}
if let fraction = information.usedFraction {
ProgressView(value: fraction)
.accessibilityLabel("流量消耗")
}
}
if information.expiresAt != nil || information.trafficResetAt != nil {
HStack(alignment: .top, spacing: 24) {
if let expiresAt = information.expiresAt {
metaColumn("到期时间") { dateText(expiresAt) }
}
if let resetAt = information.trafficResetAt {
metaColumn("重置时间") { dateText(resetAt) }
}
}
}
}
}
private func dateText(_ date: Date) -> Text {
Text(
date,
format: Date.FormatStyle(
date: .abbreviated, time: .shortened, locale: locale, timeZone: timeZone))
}
private func metaColumn<Content: View>(
_ label: LocalizedStringKey, @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
do {
urlString = try workflow.subscriptionURL(for: summary.id)
} catch {
errors.present(error)
}
}
}
private func save() {
Task {
do {
try await workflow.editSubscriptionURL(summary.id, urlString: urlString)
dismiss()
} catch {
errors.present(error)
}
}
}
}